Skip to content

Commit 532bb7f

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

10 files changed

Lines changed: 687 additions & 55 deletions

File tree

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

Lines changed: 125 additions & 5 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,13 +119,135 @@ 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+
export type StampedArtifacts = {
129+
bundleSource: string;
130+
sourceMapSource: string;
131+
};
132+
133+
/**
134+
* Stamps the debug ID injected into `bundleSource` into both the bundle (as `//# debugId=` comment)
135+
* and its source map (as `debug_id`/`debugId` fields).
136+
*
137+
* This exists for `sourcemaps.disable: "disable-upload"`: the regular upload path only stamps
138+
* temporary copies of the artifacts (see `prepareBundleForDebugIdUpload`), so without this the
139+
* emitted artifacts would carry no debug ID and a later manual upload could not match them.
140+
* Callers must apply the result inside the bundler's asset pipeline (or, for bundlers without one,
141+
* before the build resolves) so that integrity hashes computed by later build steps include it.
142+
*
143+
* @returns the stamped artifacts, or `undefined` if the bundle carries no debug ID or the map
144+
* isn't valid JSON.
145+
*/
146+
export function addDebugIdToBundleAndSourceMap(
147+
bundleSource: string,
148+
sourceMapSource: string,
149+
): StampedArtifacts | undefined {
150+
const debugId = determineDebugIdFromBundleSource(bundleSource);
151+
if (debugId === undefined) {
152+
return undefined;
153+
}
154+
155+
let map: unknown;
156+
try {
157+
map = JSON.parse(sourceMapSource);
158+
} catch {
159+
return undefined;
160+
}
161+
162+
if (!map || typeof map !== 'object') {
163+
return undefined;
164+
}
165+
166+
setDebugIdOnSourceMap(map as Record<string, unknown>, debugId);
167+
168+
return {
169+
bundleSource: addDebugIdToBundleSource(bundleSource, debugId),
170+
sourceMapSource: JSON.stringify(map),
171+
};
172+
}
173+
174+
/**
175+
* Warns about bundles whose source map is inlined into the bundle. Those have no separate map
176+
* to stamp, so they can't be symbolicated after a manual upload.
177+
*/
178+
export function warnAboutInlineSourceMaps(bundleFileNames: string[], logger: Logger): void {
179+
if (bundleFileNames.length === 0) {
180+
return;
181+
}
182+
183+
logger.warn(
184+
`${bundleFileNames.length} bundle(s) inline their source map, which can't be stamped with a debug ID. Emit source maps as separate files so they can be symbolicated after a manual upload: ${bundleFileNames.join(', ')}`,
185+
);
186+
}
187+
188+
export type EmittedArtifactsStampResult = 'stamped' | 'inline-source-map' | 'skipped';
189+
190+
/**
191+
* Stamps the debug ID of an emitted bundle into the bundle and its source map on disk.
192+
*
193+
* Used by bundlers that offer no hook to modify assets before they are written (esbuild).
194+
*/
195+
export async function addDebugIdToEmittedArtifacts(
196+
bundleFilePath: string,
197+
logger: Logger,
198+
resolveSourceMapHook: ResolveSourceMapHook | undefined,
199+
): Promise<EmittedArtifactsStampResult> {
200+
let bundleSource: string;
201+
try {
202+
bundleSource = await fs.promises.readFile(bundleFilePath, 'utf8');
203+
} catch (e) {
204+
logger.error(`Could not read bundle to stamp debug ID: ${bundleFilePath}`, e);
205+
return 'skipped';
206+
}
207+
208+
const sourceMapPath = await determineSourceMapPathFromBundle(
209+
bundleFilePath,
210+
bundleSource,
211+
logger,
212+
resolveSourceMapHook,
213+
);
214+
if (!sourceMapPath) {
215+
return bundleHasInlineSourceMap(bundleSource) ? 'inline-source-map' : 'skipped';
216+
}
217+
218+
let sourceMapSource: string;
219+
try {
220+
sourceMapSource = await fs.promises.readFile(sourceMapPath, 'utf8');
221+
} catch (e) {
222+
logger.error(`Could not read source map to stamp debug ID: ${sourceMapPath}`, e);
223+
return 'skipped';
224+
}
225+
226+
const stamped = addDebugIdToBundleAndSourceMap(bundleSource, sourceMapSource);
227+
if (stamped === undefined) {
228+
logger.debug(`Could not stamp debug ID (no debug ID in bundle or invalid source map): ${bundleFilePath}`);
229+
return 'skipped';
230+
}
231+
232+
try {
233+
await Promise.all([
234+
fs.promises.writeFile(sourceMapPath, stamped.sourceMapSource, 'utf8'),
235+
fs.promises.writeFile(bundleFilePath, stamped.bundleSource, 'utf8'),
236+
]);
237+
return 'stamped';
238+
} catch (e) {
239+
logger.error(`Could not write debug ID into build artifacts: ${bundleFilePath}`, e);
240+
return 'skipped';
241+
}
242+
}
243+
122244
/**
123245
* Whether the bundle carries its source map inlined as `sourceMappingURL=data:`
124246
* URI, rather than referencing a separate `.map` file. Such bundles must still
125247
* be uploaded even when no `.map` file is found, because the source file itself
126248
* contains the map.
127249
*/
128-
function bundleHasInlineSourceMap(bundleSource: string): boolean {
250+
export function bundleHasInlineSourceMap(bundleSource: string): boolean {
129251
return /^\s*\/\/# sourceMappingURL=data:/m.test(bundleSource);
130252
}
131253

@@ -223,9 +345,7 @@ async function prepareSourceMapForDebugIdUpload(
223345
let map: Record<string, unknown>;
224346
try {
225347
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;
348+
setDebugIdOnSourceMap(map, debugId);
229349
} catch {
230350
logger.error(`Failed to parse source map for debug ID upload: ${sourceMapPath}`);
231351
return;

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,10 @@ export {
147147
generateModuleMetadataInjectorCode,
148148
} from './utils';
149149
export { createSentryBuildPluginManager } from './build-plugin-manager';
150-
export { createDebugIdUploadFunction } from './debug-id-upload';
150+
export {
151+
createDebugIdUploadFunction,
152+
addDebugIdToBundleAndSourceMap,
153+
addDebugIdToEmittedArtifacts,
154+
bundleHasInlineSourceMap,
155+
warnAboutInlineSourceMaps,
156+
} 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 a `//# debugId=` comment, and the
119+
* emitted source maps receive the matching `debug_id` field. This happens inside the bundler's build pipeline, so
120+
* hashes computed by later build steps (e.g. for subresource integrity) include the debug IDs.
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`. The artifacts already carry their debug IDs, so no `inject` step is needed.
119124
*
120125
* @default false
121126
*/

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {
66
getDebugIdSnippet,
77
createDebugIdUploadFunction,
88
CodeInjection,
9+
addDebugIdToEmittedArtifacts,
10+
isJsFile,
11+
warnAboutInlineSourceMaps,
912
} from '../core';
1013
import * as path from 'node:path';
1114
import { createRequire } from 'node:module';
@@ -51,6 +54,8 @@ interface EsbuildInitialOptions {
5154
inject?: string[];
5255
metafile?: boolean;
5356
define?: Record<string, string>;
57+
write?: boolean;
58+
absWorkingDir?: string;
5459
}
5560

5661
interface EsbuildPluginBuild {
@@ -283,9 +288,33 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any {
283288
try {
284289
await sentryBuildPluginManager.createRelease();
285290

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

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

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import {
1313
createComponentNameAnnotateHooks,
1414
replaceBooleanFlagsInCode,
1515
CodeInjection,
16+
addDebugIdToBundleAndSourceMap,
17+
bundleHasInlineSourceMap,
18+
warnAboutInlineSourceMaps,
1619
} from '../core';
1720
import type {
1821
ComponentAnnotationTransformMeta,
@@ -28,6 +31,13 @@ import { createRequire } from 'node:module';
2831
// because `rollup` is an optional dependency.
2932
type TransformResult = { code: string; map?: SourceMap | string | { mappings: string } | null } | null | undefined;
3033

34+
// The subset of Rollup's `OutputBundle` this plugin's `generateBundle` hook reads.
35+
type OutputBundle = Record<
36+
string,
37+
| { type: 'chunk'; fileName: string; code: string; sourcemapFileName?: string | null }
38+
| { type: 'asset'; fileName: string; source: string | Uint8Array }
39+
>;
40+
3141
type ViteModule = {
3242
parseAstAsync?: (code: string, options: { lang: 'jsx' | 'tsx' }) => Promise<unknown>;
3343
};
@@ -284,6 +294,34 @@ export function _rollupPluginInternal(
284294
};
285295
}
286296

297+
// `disable-upload` skips the upload routine (which stamps debug IDs into temp copies), so the emitted artifacts
298+
// get stamped here instead. Not in `renderChunk`: minifiers running after it would strip the comment.
299+
function generateBundle(_outputOptions: unknown, bundle: OutputBundle): void {
300+
const inlineSourceMapBundles: string[] = [];
301+
302+
for (const output of Object.values(bundle)) {
303+
if (output.type !== 'chunk' || !isJsFile(output.fileName)) {
304+
continue;
305+
}
306+
307+
const sourceMap = bundle[output.sourcemapFileName ?? `${output.fileName}.map`];
308+
if (sourceMap?.type !== 'asset' || typeof sourceMap.source !== 'string') {
309+
if (bundleHasInlineSourceMap(output.code)) {
310+
inlineSourceMapBundles.push(output.fileName);
311+
}
312+
continue;
313+
}
314+
315+
const stamped = addDebugIdToBundleAndSourceMap(output.code, sourceMap.source);
316+
if (stamped !== undefined) {
317+
output.code = stamped.bundleSource;
318+
sourceMap.source = stamped.sourceMapSource;
319+
}
320+
}
321+
322+
warnAboutInlineSourceMaps(inlineSourceMapBundles, logger);
323+
}
324+
287325
async function writeBundle(
288326
outputOptions: { dir?: string; file?: string },
289327
bundle: { [fileName: string]: unknown },
@@ -318,29 +356,20 @@ export function _rollupPluginInternal(
318356
}
319357

320358
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-
}
359+
const transformHook =
360+
buildTool === 'vite'
361+
? {
362+
filter: { id: JS_MODULE_ID_FILTER },
363+
handler: transform,
364+
}
365+
: transform;
339366

340367
return {
341368
name,
342369
buildStart,
370+
...(shouldTransform ? { transform: transformHook } : {}),
343371
renderChunk,
372+
...(options.sourcemaps?.disable === 'disable-upload' ? { generateBundle } : {}),
344373
writeBundle,
345374
};
346375
}

0 commit comments

Comments
 (0)