Skip to content

Commit 6524d5c

Browse files
msonnbcodex
andauthored
fix(v10/bundler-plugins): Stamp debug IDs onto emitted source maps when disable-upload is set (#24332)
Backport of: #23754 ## Differences to the original PR - `packages/bundler-plugins/src/core/debug-id-upload.ts`: included the existing `bundleHasInlineSourceMap` helper from `develop`, because `v10` does not have it and the new stamping logic requires it. - `packages/bundler-plugins/test/core/debug-id-upload.test.ts`: added the shared debug-ID snippet and logger helpers directly, rather than moving them out of the upload tests, because those earlier tests are absent on `v10`. All tests introduced by #23754 are preserved. - `packages/bundler-plugins/test/rollup/disable-upload.test.ts`: skip only the Rolldown/Vite case on Node 18 and import Rolldown inside that test. Rolldown requires newer Node APIs, so a top-level import prevented all five Rollup tests from loading on Node 18. The other four cases still run there, and newer CI runtimes retain the Rolldown coverage. --------- Co-authored-by: OpenAI GPT-6 <codex@openai.com>
1 parent b63a73c commit 6524d5c

10 files changed

Lines changed: 725 additions & 49 deletions

File tree

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

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,131 @@ function addDebugIdToBundleSource(bundleSource: string, debugId: string): string
107107
}
108108
}
109109

110+
function bundleHasInlineSourceMap(bundleSource: string): boolean {
111+
return /^\s*\/\/# sourceMappingURL=data:/m.test(bundleSource);
112+
}
113+
114+
function setDebugIdOnSourceMap(map: Record<string, unknown>, debugId: string): void {
115+
// For now we write both fields until we know what will become the standard - if ever.
116+
map['debug_id'] = debugId;
117+
map['debugId'] = debugId;
118+
}
119+
120+
export type StampedArtifacts = {
121+
bundleSource: string;
122+
/** `undefined` when the bundle has no separate source map (i.e. the map is inlined). */
123+
sourceMapSource: string | undefined;
124+
};
125+
126+
function parseSourceMap(sourceMapSource: string): Record<string, unknown> | undefined {
127+
let map: unknown;
128+
try {
129+
map = JSON.parse(sourceMapSource);
130+
} catch {
131+
return undefined;
132+
}
133+
134+
return map && typeof map === 'object' ? (map as Record<string, unknown>) : undefined;
135+
}
136+
137+
/**
138+
* Stamps the debug ID injected into `bundleSource` into the bundle (as `//# debugId=` comment) and its
139+
* source map (as `debug_id`/`debugId` fields).
140+
*
141+
* This exists for `sourcemaps.disable: "disable-upload"`: the regular upload path only stamps
142+
* temporary copies of the artifacts (see `prepareBundleForDebugIdUpload`), so without this the
143+
* emitted artifacts would carry no debug ID and a later manual upload could not match them.
144+
* Callers must apply the result inside the bundler's asset pipeline (or, for bundlers without one,
145+
* before the build resolves) so that integrity hashes computed by later build steps include it.
146+
*
147+
* Pass `sourceMapSource: undefined` when the bundle has no separate source map. A bundle with an
148+
* inlined map still gets the comment, which is all the CLI and Symbolicator read the debug ID from.
149+
*
150+
* @returns the stamped artifacts, or `undefined` for bundles without a debug ID, without any source
151+
* map, or with an unparseable map.
152+
*/
153+
export function stampDebugId(bundleSource: string, sourceMapSource: string | undefined): StampedArtifacts | undefined {
154+
const debugId = determineDebugIdFromBundleSource(bundleSource);
155+
if (debugId === undefined) {
156+
return undefined;
157+
}
158+
159+
if (sourceMapSource === undefined) {
160+
if (!bundleHasInlineSourceMap(bundleSource)) {
161+
return undefined;
162+
}
163+
164+
return { bundleSource: addDebugIdToBundleSource(bundleSource, debugId), sourceMapSource: undefined };
165+
}
166+
167+
const map = parseSourceMap(sourceMapSource);
168+
if (!map) {
169+
return undefined;
170+
}
171+
172+
setDebugIdOnSourceMap(map, debugId);
173+
174+
return {
175+
bundleSource: addDebugIdToBundleSource(bundleSource, debugId),
176+
sourceMapSource: JSON.stringify(map),
177+
};
178+
}
179+
180+
/**
181+
* Stamps the debug ID of an emitted bundle into the bundle and its source map on disk.
182+
*
183+
* Used by bundlers that offer no hook to modify assets before they are written (esbuild).
184+
*/
185+
export async function addDebugIdToEmittedArtifacts(
186+
bundleFilePath: string,
187+
logger: Logger,
188+
resolveSourceMapHook: ResolveSourceMapHook | undefined,
189+
): Promise<void> {
190+
let bundleSource: string;
191+
try {
192+
bundleSource = await fs.promises.readFile(bundleFilePath, 'utf8');
193+
} catch (e) {
194+
logger.error(`Could not read bundle to stamp debug ID: ${bundleFilePath}`, e);
195+
return;
196+
}
197+
198+
const sourceMapPath = await determineSourceMapPathFromBundle(
199+
bundleFilePath,
200+
bundleSource,
201+
logger,
202+
resolveSourceMapHook,
203+
);
204+
205+
let sourceMapSource: string | undefined;
206+
if (sourceMapPath) {
207+
try {
208+
sourceMapSource = await fs.promises.readFile(sourceMapPath, 'utf8');
209+
} catch (e) {
210+
logger.error(`Could not read source map to stamp debug ID: ${sourceMapPath}`, e);
211+
return;
212+
}
213+
}
214+
215+
const stamped = stampDebugId(bundleSource, sourceMapSource);
216+
if (!stamped) {
217+
logger.debug(
218+
`Could not stamp debug ID (no debug ID in bundle, no source map, or invalid source map): ${bundleFilePath}`,
219+
);
220+
return;
221+
}
222+
223+
const writes = [fs.promises.writeFile(bundleFilePath, stamped.bundleSource, 'utf8')];
224+
if (sourceMapPath && stamped.sourceMapSource !== undefined) {
225+
writes.push(fs.promises.writeFile(sourceMapPath, stamped.sourceMapSource, 'utf8'));
226+
}
227+
228+
try {
229+
await Promise.all(writes);
230+
} catch (e) {
231+
logger.error(`Could not write debug ID into build artifacts: ${bundleFilePath}`, e);
232+
}
233+
}
234+
110235
/**
111236
* Applies a set of heuristics to find the source map for a particular bundle.
112237
*
@@ -201,9 +326,7 @@ async function prepareSourceMapForDebugIdUpload(
201326
let map: Record<string, unknown>;
202327
try {
203328
map = JSON.parse(sourceMapFileContent) as { sources: unknown; [key: string]: unknown };
204-
// For now we write both fields until we know what will become the standard - if ever.
205-
map['debug_id'] = debugId;
206-
map['debugId'] = debugId;
329+
setDebugIdOnSourceMap(map, debugId);
207330
} catch {
208331
logger.error(`Failed to parse source map for debug ID upload: ${sourceMapPath}`);
209332
return;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,4 @@ export {
158158
generateModuleMetadataInjectorCode,
159159
} from './utils';
160160
export { createSentryBuildPluginManager } from './build-plugin-manager';
161-
export { createDebugIdUploadFunction } from './debug-id-upload';
161+
export { createDebugIdUploadFunction, addDebugIdToEmittedArtifacts, stampDebugId } 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
@@ -105,8 +105,13 @@ export interface Options {
105105
/**
106106
* Disables all functionality related to sourcemaps if set to `true`.
107107
*
108-
* If set to `"disable-upload"`, the plugin will not upload sourcemaps to Sentry, but will inject debug IDs into the build artifacts.
109-
* This is useful if you want to manually upload sourcemaps to Sentry at a later point in time.
108+
* If set to `"disable-upload"`, the plugin will not upload sourcemaps to Sentry, but will still inject debug IDs
109+
* into the build artifacts: the bundles receive the runtime debug ID snippet and a `//# debugId=` comment, and the
110+
* emitted source maps receive the matching `debug_id` field. This happens inside the bundler's build pipeline, so
111+
* hashes computed by later build steps (e.g. for subresource integrity) include the debug IDs.
112+
*
113+
* This is useful if you want to manually upload sourcemaps to Sentry at a later point in time, e.g. with
114+
* `sentry sourcemap upload`. The artifacts already carry their debug IDs, so no `inject` step is needed.
110115
*
111116
* @default false
112117
*/

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
getDebugIdSnippet,
77
createDebugIdUploadFunction,
88
CodeInjection,
9+
addDebugIdToEmittedArtifacts,
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,30 @@ 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('Build output is not written to disk. Skipping debug ID injection into build artifacts.');
297+
} else {
298+
// The upload routine (which stamps debug IDs into temp copies of the artifacts) is
299+
// skipped with `disable-upload`. esbuild has no hook to modify outputs before they are
300+
// written, so the emitted artifacts get stamped on disk instead.
301+
const outputDir = initialOptions.absWorkingDir ?? process.cwd();
302+
await Promise.all(
303+
buildArtifacts
304+
.filter(isJsFile)
305+
.map(bundle =>
306+
addDebugIdToEmittedArtifacts(
307+
path.resolve(outputDir, bundle),
308+
logger,
309+
options.sourcemaps?.resolveSourceMap,
310+
),
311+
),
312+
);
313+
}
289314
}
290315
} finally {
291316
freeGlobalDependencyOnBuildArtifacts();

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

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createComponentNameAnnotateHooks,
1414
replaceBooleanFlagsInCode,
1515
CodeInjection,
16+
stampDebugId,
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` the stamping 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,38 @@ export function _rollupPluginInternal(
284292
};
285293
}
286294

295+
/**
296+
* Stamps debug IDs into the emitted chunks and source maps.
297+
*
298+
* `disable-upload` skips the upload routine (which stamps debug IDs into temp copies), so the emitted
299+
* artifacts get stamped here instead. Not in `renderChunk`: minifiers running after it would strip the
300+
* comment. Rollup computes `[hash]` file names before this hook, so only plugins that hash the final
301+
* assets afterwards (e.g. subresource integrity) see the stamped content.
302+
*/
303+
function generateBundle(_outputOptions: unknown, bundle: OutputBundle): void {
304+
for (const output of Object.values(bundle)) {
305+
if (output.type !== 'chunk' || !isJsFile(output.fileName)) {
306+
continue;
307+
}
308+
309+
const sourceMapAsset = bundle[output.sourcemapFileName ?? `${output.fileName}.map`];
310+
const sourceMapSource =
311+
sourceMapAsset?.type === 'asset' && typeof sourceMapAsset.source === 'string'
312+
? sourceMapAsset.source
313+
: undefined;
314+
315+
const stamped = stampDebugId(output.code, sourceMapSource);
316+
if (!stamped) {
317+
continue;
318+
}
319+
320+
output.code = stamped.bundleSource;
321+
if (stamped.sourceMapSource !== undefined && sourceMapAsset?.type === 'asset') {
322+
sourceMapAsset.source = stamped.sourceMapSource;
323+
}
324+
}
325+
}
326+
287327
async function writeBundle(
288328
outputOptions: { dir?: string; file?: string },
289329
bundle: { [fileName: string]: unknown },
@@ -318,29 +358,22 @@ export function _rollupPluginInternal(
318358
}
319359

320360
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-
}
361+
const transformHook =
362+
buildTool === 'vite'
363+
? {
364+
filter: { id: JS_MODULE_ID_FILTER },
365+
handler: transform,
366+
}
367+
: transform;
339368

340369
return {
341370
name,
342371
buildStart,
372+
...(shouldTransform ? { transform: transformHook } : {}),
343373
renderChunk,
374+
...(options.sourcemaps?.disable === 'disable-upload'
375+
? { generateBundle: { order: 'pre' as const, handler: generateBundle } }
376+
: {}),
344377
writeBundle,
345378
};
346379
}

0 commit comments

Comments
 (0)