Skip to content

Commit 0637f26

Browse files
committed
fix(bundler-plugins): Stamp debug IDs onto emitted source maps when disable-upload is set
1 parent c698cdc commit 0637f26

10 files changed

Lines changed: 524 additions & 53 deletions

File tree

packages/bundler-plugins/src/core/debug-id-upload.ts

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export async function prepareBundleForDebugIdUpload(
9393
*
9494
* The string pattern is injected via the debug ID injection snipped.
9595
*/
96-
function determineDebugIdFromBundleSource(code: string): string | undefined {
96+
export function determineDebugIdFromBundleSource(code: string): string | undefined {
9797
const match = code.match(
9898
/sentry-dbid-([0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})/,
9999
);
@@ -119,6 +119,95 @@ function addDebugIdToBundleSource(bundleSource: string, debugId: string): string
119119
}
120120
}
121121

122+
function setDebugIdOnSourceMap(map: Record<string, unknown>, debugId: string): void {
123+
// For now we write both fields until we know what will become the standard - if ever.
124+
map['debug_id'] = debugId;
125+
map['debugId'] = debugId;
126+
}
127+
128+
/**
129+
* Stamps the debug ID injected into `bundleSource` into the given source map.
130+
*
131+
* This exists for `sourcemaps.disable: "disable-upload"`: the regular upload path only stamps
132+
* temporary copies of the artifacts (see `prepareBundleForDebugIdUpload`), so without this the
133+
* emitted map would carry no debug ID and a later manual upload could not match it to the bundle.
134+
* Only the map is touched. The bundle's bytes stay as emitted so subresource integrity hashes
135+
* computed on them remain valid.
136+
*
137+
* @returns the stamped source map, or `undefined` if the bundle carries no debug ID or the map
138+
* isn't valid JSON.
139+
*/
140+
export function addDebugIdToSourceMap(bundleSource: string, sourceMapSource: string): string | undefined {
141+
const debugId = determineDebugIdFromBundleSource(bundleSource);
142+
if (debugId === undefined) {
143+
return undefined;
144+
}
145+
146+
let map: unknown;
147+
try {
148+
map = JSON.parse(sourceMapSource);
149+
} catch {
150+
return undefined;
151+
}
152+
153+
if (!map || typeof map !== 'object') {
154+
return undefined;
155+
}
156+
157+
setDebugIdOnSourceMap(map as Record<string, unknown>, debugId);
158+
return JSON.stringify(map);
159+
}
160+
161+
/**
162+
* Stamps the debug ID of an emitted bundle into its source map file on disk.
163+
*
164+
* Used by bundlers that offer no hook to modify assets before they are written (esbuild).
165+
* Rewrites only the map; the bundle itself is never modified.
166+
*/
167+
export async function addDebugIdToEmittedSourceMap(
168+
bundleFilePath: string,
169+
logger: Logger,
170+
resolveSourceMapHook: ResolveSourceMapHook | undefined,
171+
): Promise<void> {
172+
let bundleSource: string;
173+
try {
174+
bundleSource = await fs.promises.readFile(bundleFilePath, 'utf8');
175+
} catch (e) {
176+
logger.error(`Could not read bundle to stamp debug ID into its source map: ${bundleFilePath}`, e);
177+
return;
178+
}
179+
180+
const sourceMapPath = await determineSourceMapPathFromBundle(
181+
bundleFilePath,
182+
bundleSource,
183+
logger,
184+
resolveSourceMapHook,
185+
);
186+
if (!sourceMapPath) {
187+
return;
188+
}
189+
190+
let sourceMapSource: string;
191+
try {
192+
sourceMapSource = await fs.promises.readFile(sourceMapPath, 'utf8');
193+
} catch (e) {
194+
logger.error(`Could not read source map to stamp debug ID: ${sourceMapPath}`, e);
195+
return;
196+
}
197+
198+
const stampedSourceMap = addDebugIdToSourceMap(bundleSource, sourceMapSource);
199+
if (stampedSourceMap === undefined) {
200+
logger.debug(`Could not stamp debug ID into source map (no debug ID in bundle or invalid map): ${sourceMapPath}`);
201+
return;
202+
}
203+
204+
try {
205+
await fs.promises.writeFile(sourceMapPath, stampedSourceMap, 'utf8');
206+
} catch (e) {
207+
logger.error(`Could not write debug ID into source map: ${sourceMapPath}`, e);
208+
}
209+
}
210+
122211
/**
123212
* Whether the bundle carries its source map inlined as `sourceMappingURL=data:`
124213
* URI, rather than referencing a separate `.map` file. Such bundles must still
@@ -223,9 +312,7 @@ async function prepareSourceMapForDebugIdUpload(
223312
let map: Record<string, unknown>;
224313
try {
225314
map = JSON.parse(sourceMapFileContent) as { sources: unknown; [key: string]: unknown };
226-
// For now we write both fields until we know what will become the standard - if ever.
227-
map['debug_id'] = debugId;
228-
map['debugId'] = debugId;
315+
setDebugIdOnSourceMap(map, debugId);
229316
} catch {
230317
logger.error(`Failed to parse source map for debug ID upload: ${sourceMapPath}`);
231318
return;

packages/bundler-plugins/src/core/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,4 @@ export {
147147
generateModuleMetadataInjectorCode,
148148
} from './utils';
149149
export { createSentryBuildPluginManager } from './build-plugin-manager';
150-
export { createDebugIdUploadFunction } from './debug-id-upload';
150+
export { createDebugIdUploadFunction, addDebugIdToSourceMap, addDebugIdToEmittedSourceMap } from './debug-id-upload';

packages/bundler-plugins/src/core/types.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,13 @@ export interface Options {
114114
/**
115115
* Disables all functionality related to sourcemaps if set to `true`.
116116
*
117-
* If set to `"disable-upload"`, the plugin will not upload sourcemaps to Sentry, but will inject debug IDs into the build artifacts.
118-
* This is useful if you want to manually upload sourcemaps to Sentry at a later point in time.
117+
* If set to `"disable-upload"`, the plugin will not upload sourcemaps to Sentry, but will still inject debug IDs
118+
* into the build artifacts: the bundles receive the runtime debug ID snippet and the emitted source maps receive
119+
* the matching `debug_id` field. Bundles are not modified beyond the snippet (no `//# debugId=` comment is
120+
* appended), so their bytes stay stable for subresource integrity hashes.
121+
*
122+
* This is useful if you want to manually upload sourcemaps to Sentry at a later point in time, e.g. with
123+
* `sentry sourcemap upload`.
119124
*
120125
* @default false
121126
*/

packages/bundler-plugins/src/esbuild/index.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
getDebugIdSnippet,
77
createDebugIdUploadFunction,
88
CodeInjection,
9+
addDebugIdToEmittedSourceMap,
10+
isJsFile,
911
} from '../core';
1012
import * as path from 'node:path';
1113
import { createRequire } from 'node:module';
@@ -51,6 +53,8 @@ interface EsbuildInitialOptions {
5153
inject?: string[];
5254
metafile?: boolean;
5355
define?: Record<string, string>;
56+
write?: boolean;
57+
absWorkingDir?: string;
5458
}
5559

5660
interface EsbuildPluginBuild {
@@ -283,9 +287,32 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any {
283287
try {
284288
await sentryBuildPluginManager.createRelease();
285289

286-
if (sourcemapsEnabled && options.sourcemaps?.disable !== 'disable-upload') {
290+
if (sourcemapsEnabled) {
287291
const buildArtifacts = result.metafile ? Object.keys(result.metafile.outputs) : [];
288-
await upload(buildArtifacts);
292+
293+
if (options.sourcemaps?.disable !== 'disable-upload') {
294+
await upload(buildArtifacts);
295+
} else if (initialOptions.write === false) {
296+
logger.debug(
297+
'Build output is not written to disk. Skipping debug ID injection into emitted source maps.',
298+
);
299+
} else {
300+
// The upload routine (which stamps debug IDs into temp copies of the source maps) is
301+
// skipped with `disable-upload`. esbuild has no hook to modify outputs before they are
302+
// written, so the emitted maps get stamped on disk instead.
303+
const outputDir = initialOptions.absWorkingDir ?? process.cwd();
304+
await Promise.all(
305+
buildArtifacts
306+
.filter(isJsFile)
307+
.map(artifact =>
308+
addDebugIdToEmittedSourceMap(
309+
path.resolve(outputDir, artifact),
310+
logger,
311+
options.sourcemaps?.resolveSourceMap,
312+
),
313+
),
314+
);
315+
}
289316
}
290317
} finally {
291318
freeGlobalDependencyOnBuildArtifacts();

packages/bundler-plugins/src/rollup/index.ts

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createComponentNameAnnotateHooks,
1414
replaceBooleanFlagsInCode,
1515
CodeInjection,
16+
addDebugIdToSourceMap,
1617
} from '../core';
1718
import type {
1819
ComponentAnnotationTransformMeta,
@@ -28,6 +29,13 @@ import { createRequire } from 'node:module';
2829
// because `rollup` is an optional dependency.
2930
type TransformResult = { code: string; map?: SourceMap | string | { mappings: string } | null } | null | undefined;
3031

32+
// The subset of Rollup's `OutputBundle` this plugin's `generateBundle` hook reads.
33+
type OutputBundle = Record<
34+
string,
35+
| { type: 'chunk'; fileName: string; code: string; sourcemapFileName?: string | null }
36+
| { type: 'asset'; fileName: string; source: string | Uint8Array }
37+
>;
38+
3139
type ViteModule = {
3240
parseAstAsync?: (code: string, options: { lang: 'jsx' | 'tsx' }) => Promise<unknown>;
3341
};
@@ -284,6 +292,27 @@ export function _rollupPluginInternal(
284292
};
285293
}
286294

295+
// The upload routine (which stamps debug IDs into temp copies of the source maps) is skipped
296+
// with `disable-upload`, so the emitted maps get stamped before being written instead.
297+
// Only the map is modified; the chunk's bytes stay untouched so subresource integrity hashes stay valid.
298+
function generateBundle(_outputOptions: unknown, bundle: OutputBundle): void {
299+
for (const output of Object.values(bundle)) {
300+
if (output.type !== 'chunk' || !isJsFile(output.fileName)) {
301+
continue;
302+
}
303+
304+
const sourceMap = bundle[output.sourcemapFileName ?? `${output.fileName}.map`];
305+
if (sourceMap?.type !== 'asset' || typeof sourceMap.source !== 'string') {
306+
continue;
307+
}
308+
309+
const stampedSourceMap = addDebugIdToSourceMap(output.code, sourceMap.source);
310+
if (stampedSourceMap !== undefined) {
311+
sourceMap.source = stampedSourceMap;
312+
}
313+
}
314+
}
315+
287316
async function writeBundle(
288317
outputOptions: { dir?: string; file?: string },
289318
bundle: { [fileName: string]: unknown },
@@ -318,29 +347,21 @@ export function _rollupPluginInternal(
318347
}
319348

320349
const name = `sentry-${buildTool}-plugin`;
321-
322-
if (shouldTransform) {
323-
const transformHook =
324-
buildTool === 'vite'
325-
? {
326-
filter: { id: JS_MODULE_ID_FILTER },
327-
handler: transform,
328-
}
329-
: transform;
330-
331-
return {
332-
name,
333-
buildStart,
334-
transform: transformHook,
335-
renderChunk,
336-
writeBundle,
337-
};
338-
}
350+
const stampEmittedSourceMaps = sourcemapsEnabled && options.sourcemaps?.disable === 'disable-upload';
351+
const transformHook =
352+
buildTool === 'vite'
353+
? {
354+
filter: { id: JS_MODULE_ID_FILTER },
355+
handler: transform,
356+
}
357+
: transform;
339358

340359
return {
341360
name,
342361
buildStart,
362+
...(shouldTransform ? { transform: transformHook } : {}),
343363
renderChunk,
364+
...(stampEmittedSourceMaps ? { generateBundle } : {}),
344365
writeBundle,
345366
};
346367
}

0 commit comments

Comments
 (0)