From 14864465e77a18ee22f3e83347acb2a76416ab26 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Wed, 26 Aug 2026 10:17:45 +0200 Subject: [PATCH] fix(bundler-plugins): Stamp debug IDs onto emitted source maps with `disable-upload` `sourcemaps.disable: 'disable-upload'` is documented for manually uploading source maps at a later point in time, but the debug ID only ended up in the bundle, never in the emitted source map - stamping happens on temporary copies inside the upload routine that the flag switches off. A later `sentry-cli sourcemaps upload` therefore produced an artifact bundle where every entry has `debugId: null` and nothing symbolicates. The emitted source maps now get the bundle's debug ID written into them when the upload is disabled. Bundles stay byte-identical so hashes computed during the build (e.g. for subresource integrity) remain valid. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/core/build-plugin-manager.ts | 180 +++++++++++++---- .../src/core/debug-id-upload.ts | 147 ++++++++++++-- packages/bundler-plugins/src/core/index.ts | 2 +- packages/bundler-plugins/src/core/types.ts | 16 +- packages/bundler-plugins/src/core/utils.ts | 22 +++ packages/bundler-plugins/src/esbuild/index.ts | 11 +- packages/bundler-plugins/src/rollup/index.ts | 46 +++-- .../src/webpack/webpack4and5.ts | 11 +- .../test/core/build-plugin-manager.test.ts | 100 +++++++++- .../test/core/debug-id-upload.test.ts | 183 +++++++++++++++++- .../bundler-plugins/test/core/utils.test.ts | 39 ++++ .../test/esbuild/disable-upload.test.ts | 51 +++++ .../test/rollup/disable-upload.test.ts | 54 ++++++ .../test/webpack/disable-upload.test.ts | 79 ++++++++ 14 files changed, 860 insertions(+), 81 deletions(-) create mode 100644 packages/bundler-plugins/test/esbuild/disable-upload.test.ts create mode 100644 packages/bundler-plugins/test/rollup/disable-upload.test.ts create mode 100644 packages/bundler-plugins/test/webpack/disable-upload.test.ts diff --git a/packages/bundler-plugins/src/core/build-plugin-manager.ts b/packages/bundler-plugins/src/core/build-plugin-manager.ts index 0df5d5ddd4ce..4da77dc650d6 100644 --- a/packages/bundler-plugins/src/core/build-plugin-manager.ts +++ b/packages/bundler-plugins/src/core/build-plugin-manager.ts @@ -15,10 +15,16 @@ import { arrayify, getProjects, getTurborepoEnvPassthroughWarning, + runWithConcurrency, serializeIgnoreOptions, stripQueryAndHashFromPath, } from './utils'; -import { defaultRewriteSourcesHook, prepareBundleForDebugIdUpload } from './debug-id-upload'; +import { + defaultRewriteSourcesHook, + prepareBundleForDebugIdUpload, + type SourceMapStampResult, + stampDebugIdOnEmittedSourceMap, +} from './debug-id-upload'; import { globFiles } from './glob'; import { LIB_VERSION } from './version'; @@ -27,6 +33,10 @@ import { LIB_VERSION } from './version'; // for client, server, and edge). Keyed by release name. const _deployedReleases = new Set(); +// Debug ID work reads whole bundles into memory, so it is spread over a fixed number of workers +// rather than being done all at once. +const DEBUG_ID_WORKER_COUNT = 16; + /** @internal Exported for testing only. */ export function _resetDeployedReleasesForTesting(): void { _deployedReleases.clear(); @@ -88,6 +98,15 @@ export type SentryBuildPluginManager = { */ uploadSourcemaps(buildArtifactPaths: string[], opts?: { prepareArtifacts?: boolean }): Promise; + /** + * Writes the debug ID that was injected into each build artifact into the artifact's emitted source map. + * + * `uploadSourcemaps` does this on temporary copies it then uploads and deletes, so the emitted source maps + * never carry a debug ID. Call this instead of `uploadSourcemaps` when the upload is disabled via + * `sourcemaps.disable: 'disable-upload'` and the artifacts are meant to be uploaded manually later on. + */ + stampDebugIdsOnSourceMaps(buildArtifactPaths: string[]): Promise; + /** * Will delete artifacts based on the passed `sourcemaps.filesToDeleteAfterUpload` option. */ @@ -180,6 +199,9 @@ export function createSentryBuildPluginManager( uploadSourcemaps: async () => { /* noop */ }, + stampDebugIdsOnSourceMaps: async () => { + /* noop */ + }, deleteArtifacts: async () => { /* noop */ }, @@ -305,6 +327,65 @@ export function createSentryBuildPluginManager( }; } + /** + * Resolves the JavaScript build artifacts that carry an injected debug ID, honoring the + * `sourcemaps.assets` and `sourcemaps.ignore` options. + */ + async function resolveDebugIdChunkFilePaths(buildArtifactPaths: string[]): Promise { + const assets = options.sourcemaps?.assets; + + let globAssets: string | string[]; + if (assets) { + globAssets = assets; + } else { + logger.debug('No `sourcemaps.assets` option provided, falling back to detected build artifacts.'); + globAssets = buildArtifactPaths; + } + + const globResult = await startSpan({ name: 'glob', scope: sentryScope }, async () => + globFiles(globAssets, { ignore: options.sourcemaps?.ignore }), + ); + + const debugIdChunkFilePaths = globResult.filter(debugIdChunkFilePath => { + return !!stripQueryAndHashFromPath(debugIdChunkFilePath).match(/\.(js|mjs|cjs)$/); + }); + + // The order of the files output by glob() is not deterministic + // Ensure order within the files so that {debug-id}-{chunkIndex} coupling is consistent + debugIdChunkFilePaths.sort(); + + return debugIdChunkFilePaths; + } + + /** + * Reports what the source map stamping actually accomplished. + * + * Stamping is best-effort per chunk, so "we found some chunks" is not the same as "the maps now carry a + * debug ID". Claiming the latter when nothing was written would point users at a manual upload that + * cannot possibly work. + */ + function logStampingOutcome(results: SourceMapStampResult[]): void { + const inlineSourceMapCount = results.filter(result => result === 'inlineSourceMap').length; + if (inlineSourceMapCount > 0) { + logger.warn( + `${inlineSourceMapCount} bundle(s) inline their source map. Stamping those would mean rewriting the bundle, so they were skipped and will not symbolicate. Emit source maps as separate files to get debug IDs.`, + ); + } + + const stampedCount = results.filter(result => result === 'stamped' || result === 'alreadyStamped').length; + + if (stampedCount === 0) { + logger.warn( + "Didn't stamp a debug ID onto any source map. Set the `debug` option to see why the individual build artifacts were skipped.", + ); + return; + } + + logger.info( + `Stamped debug IDs onto ${stampedCount} source map(s). Upload them with \`sentry-cli sourcemaps upload --debug-id-reference\` to symbolicate stack traces.`, + ); + } + /** * Returns a Promise that resolves when all the currently active dependencies are freed again. * @@ -628,28 +709,7 @@ export function createSentryBuildPluginManager( logger.info('Successfully uploaded source maps to Sentry'); } else { // Prepare artifacts in temp folder before uploading - let globAssets: string | string[]; - if (assets) { - globAssets = assets; - } else { - logger.debug( - 'No `sourcemaps.assets` option provided, falling back to uploading detected build artifacts.', - ); - globAssets = buildArtifactPaths; - } - - const globResult = await startSpan( - { name: 'glob', scope: sentryScope }, - async () => await globFiles(globAssets, { ignore: options.sourcemaps?.ignore }), - ); - - const debugIdChunkFilePaths = globResult.filter(debugIdChunkFilePath => { - return !!stripQueryAndHashFromPath(debugIdChunkFilePath).match(/\.(js|mjs|cjs)$/); - }); - - // The order of the files output by glob() is not deterministic - // Ensure order within the files so that {debug-id}-{chunkIndex} coupling is consistent - debugIdChunkFilePaths.sort(); + const debugIdChunkFilePaths = await resolveDebugIdChunkFilePaths(buildArtifactPaths); if (debugIdChunkFilePaths.length === 0) { logger.warn( @@ -666,8 +726,6 @@ export function createSentryBuildPluginManager( // Prepare into temp folder, then upload await startSpan({ name: 'prepare-bundles', scope: sentryScope }, async prepBundlesSpan => { - // Preparing the bundles can be a lot of work and doing it all at once has the potential of nuking the heap so - // instead we do it with a maximum of 16 concurrent workers const preparationTasks = debugIdChunkFilePaths.map((chunkFilePath, chunkIndex) => async () => { await prepareBundleForDebugIdUpload( chunkFilePath, @@ -678,20 +736,8 @@ export function createSentryBuildPluginManager( options.sourcemaps?.resolveSourceMap, ); }); - const workers: Promise[] = []; - const worker = async (): Promise => { - while (preparationTasks.length > 0) { - const task = preparationTasks.shift(); - if (task) { - await task(); - } - } - }; - for (let workerIndex = 0; workerIndex < 16; workerIndex++) { - workers.push(worker()); - } - await Promise.all(workers); + await runWithConcurrency(preparationTasks, DEBUG_ID_WORKER_COUNT); const files = await fs.promises.readdir(tmpUploadFolder); const stats = files.map(file => fs.promises.stat(path.join(tmpUploadFolder, file))); @@ -751,6 +797,64 @@ export function createSentryBuildPluginManager( ); }, + /** + * Writes the debug ID that was injected into each build artifact into the artifact's emitted source map. + * + * `uploadSourcemaps` stamps temporary copies it uploads and then deletes, which leaves the emitted source maps + * without a debug ID. That is fine as long as the plugin does the upload, but not when the user disabled it via + * `sourcemaps.disable: 'disable-upload'` to upload the artifacts manually later on - without a debug ID in the + * map, that upload cannot be matched to the bundles at runtime. + * + * Only the source maps are written to. The bundles stay byte-identical so that hashes computed during the build + * (e.g. for subresource integrity) remain valid. + * + * @param buildArtifactPaths - The paths of the build artifacts whose source maps should be stamped + */ + async stampDebugIdsOnSourceMaps(buildArtifactPaths: string[]) { + if (isDevMode) { + logger.debug('Running in development mode. Will not stamp debug IDs onto source maps.'); + return; + } + + const assets = options.sourcemaps?.assets; + if (Array.isArray(assets) && assets.length === 0) { + logger.debug('Empty `sourcemaps.assets` option provided. Will not stamp debug IDs onto source maps.'); + return; + } + + await startSpan({ name: 'debug-id-sourcemap-stamping', scope: sentryScope, forceTransaction: true }, async () => { + const freeStampingDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); + + try { + const debugIdChunkFilePaths = await resolveDebugIdChunkFilePaths(buildArtifactPaths); + + if (debugIdChunkFilePaths.length === 0) { + logger.warn( + "Didn't find any matching sources to stamp with debug IDs. Please check the `sourcemaps.assets` option.", + ); + return; + } + + const results = await startSpan({ name: 'stamp-source-maps', scope: sentryScope }, async () => { + const stampingTasks = debugIdChunkFilePaths.map( + chunkFilePath => () => + stampDebugIdOnEmittedSourceMap(chunkFilePath, logger, options.sourcemaps?.resolveSourceMap), + ); + + return runWithConcurrency(stampingTasks, DEBUG_ID_WORKER_COUNT); + }); + + logStampingOutcome(results); + } catch (e) { + sentryScope.captureException('Error in "debugIdStampingPlugin" writeBundle hook'); + handleRecoverableError(e, false); + } finally { + freeStampingDependencyOnBuildArtifacts(); + await safeFlushTelemetry(sentryClient); + } + }); + }, + /** * Will delete artifacts based on the passed `sourcemaps.filesToDeleteAfterUpload` option. */ diff --git a/packages/bundler-plugins/src/core/debug-id-upload.ts b/packages/bundler-plugins/src/core/debug-id-upload.ts index 932f326e1e98..94101193d9e9 100644 --- a/packages/bundler-plugins/src/core/debug-id-upload.ts +++ b/packages/bundler-plugins/src/core/debug-id-upload.ts @@ -21,6 +21,13 @@ export function createDebugIdUploadFunction({ sentryBuildPluginManager }: DebugI }; } +export function createDebugIdStampingFunction({ sentryBuildPluginManager }: DebugIdUploadPluginOptions) { + return async (buildArtifactPaths: string[]) => { + const cleanedPaths = buildArtifactPaths.map(stripQueryAndHashFromPath); + await sentryBuildPluginManager.stampDebugIdsOnSourceMaps(cleanedPaths); + }; +} + export async function prepareBundleForDebugIdUpload( bundleFilePath: string, uploadFolder: string, @@ -75,6 +82,77 @@ export async function prepareBundleForDebugIdUpload( await writeSourceMapFilePromise; } +/** + * The outcome of stamping a single bundle's source map. `inlineSourceMap` is called out separately + * because it is the one case the caller has to surface to the user - the debug ID silently never + * lands anywhere. + */ +export type SourceMapStampResult = 'stamped' | 'alreadyStamped' | 'inlineSourceMap' | 'skipped'; + +/** + * Writes the debug ID that was injected into a bundle into the bundle's emitted source map. + * + * This is the counterpart to `prepareBundleForDebugIdUpload` for the `sourcemaps.disable: + * 'disable-upload'` case: there is no upload to piggyback the temp-folder preparation on, so + * the emitted source map has to carry the debug ID itself for a later manual upload to be able + * to associate it with the bundle. + * + * The bundle itself is deliberately left byte-identical - hashes computed during the build + * (e.g. for subresource integrity) must stay valid. The map's `sources` are left alone as well, + * because unlike the throwaway upload copies this is a file the user keeps. + */ +export async function stampDebugIdOnEmittedSourceMap( + bundleFilePath: string, + logger: Logger, + resolveSourceMapHook: ResolveSourceMapHook | undefined, +): Promise { + let bundleContent: string; + try { + bundleContent = await fs.promises.readFile(bundleFilePath, 'utf8'); + } catch (e) { + logger.error(`Could not read bundle to determine debug ID and source map: ${bundleFilePath}`, e); + return 'skipped'; + } + + const debugId = determineDebugIdFromBundleSource(bundleContent); + if (debugId === undefined) { + logger.debug(`Could not determine debug ID from bundle. Source map will not be stamped: ${bundleFilePath}`); + return 'skipped'; + } + + const sourceMapPath = await determineSourceMapPathFromBundle( + bundleFilePath, + bundleContent, + logger, + resolveSourceMapHook, + ); + if (!sourceMapPath) { + // An inlined map lives inside the bundle, so stamping it would mean rewriting the bundle - which + // is exactly what this function must not do. Report it so the caller can tell the user. + return bundleHasInlineSourceMap(bundleContent) ? 'inlineSourceMap' : 'skipped'; + } + + const map = await readSourceMap(sourceMapPath, logger); + if (!map) { + return 'skipped'; + } + + if (map['debug_id'] === debugId && map['debugId'] === debugId) { + return 'alreadyStamped'; + } + + addDebugIdToSourceMap(map, debugId); + + try { + await fs.promises.writeFile(sourceMapPath, JSON.stringify(map), 'utf8'); + logger.debug(`Stamped debug ID ${debugId} onto source map: ${sourceMapPath}`); + return 'stamped'; + } catch (e) { + logger.error(`Failed to write source map with stamped debug ID: ${sourceMapPath}`, e); + return 'skipped'; + } +} + /** * Looks for a particular string pattern (`sdbid-[debug ID]`) in the bundle * source and extracts the bundle's debug ID from it. @@ -107,6 +185,14 @@ function addDebugIdToBundleSource(bundleSource: string, debugId: string): string } } +/** + * Whether the bundle carries its source map inlined as a `sourceMappingURL=data:` URI, rather than + * referencing a separate `.map` file. + */ +function bundleHasInlineSourceMap(bundleSource: string): boolean { + return /^\s*\/\/# sourceMappingURL=data:/m.test(bundleSource); +} + /** * Applies a set of heuristics to find the source map for a particular bundle. * @@ -188,26 +274,12 @@ async function prepareSourceMapForDebugIdUpload( rewriteSourcesHook: RewriteSourcesHook, logger: Logger, ): Promise { - let sourceMapFileContent: string; - try { - sourceMapFileContent = await util.promisify(fs.readFile)(sourceMapPath, { - encoding: 'utf8', - }); - } catch (e) { - logger.error(`Failed to read source map for debug ID upload: ${sourceMapPath}`, e); + const map = await readSourceMap(sourceMapPath, logger); + if (!map) { return; } - let map: Record; - try { - map = JSON.parse(sourceMapFileContent) as { sources: unknown; [key: string]: unknown }; - // For now we write both fields until we know what will become the standard - if ever. - map['debug_id'] = debugId; - map['debugId'] = debugId; - } catch { - logger.error(`Failed to parse source map for debug ID upload: ${sourceMapPath}`); - return; - } + addDebugIdToSourceMap(map, debugId); if (map['sources'] && Array.isArray(map['sources'])) { const mapDir = path.dirname(sourceMapPath); @@ -224,6 +296,47 @@ async function prepareSourceMapForDebugIdUpload( } } +/** + * Reads and parses a source map file. + * + * `JSON.parse` accepts plenty of JSON that is not a source map - `null`, numbers, arrays. Writing debug + * IDs onto those either throws or, for arrays, silently succeeds and then serializes back to `[]`, which + * looks like a valid upload until symbolication fails. So anything that is not a plain object is rejected + * here rather than at each call site. + * + * @returns the parsed source map, or `undefined` if it could not be read, parsed or is not an object. + */ +async function readSourceMap(sourceMapPath: string, logger: Logger): Promise | undefined> { + let sourceMapFileContent: string; + try { + sourceMapFileContent = await fs.promises.readFile(sourceMapPath, 'utf8'); + } catch (e) { + logger.error(`Failed to read source map: ${sourceMapPath}`, e); + return undefined; + } + + let parsedSourceMap: unknown; + try { + parsedSourceMap = JSON.parse(sourceMapFileContent); + } catch (e) { + logger.error(`Failed to parse source map: ${sourceMapPath}`, e); + return undefined; + } + + if (typeof parsedSourceMap !== 'object' || parsedSourceMap === null || Array.isArray(parsedSourceMap)) { + logger.error(`Source map is not a JSON object: ${sourceMapPath}`); + return undefined; + } + + return parsedSourceMap as Record; +} + +function addDebugIdToSourceMap(map: Record, debugId: string): void { + // For now we write both fields until we know what will become the standard - if ever. + map['debug_id'] = debugId; + map['debugId'] = debugId; +} + const PROTOCOL_REGEX = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//; export function defaultRewriteSourcesHook(source: string): string { if (source.match(PROTOCOL_REGEX)) { diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts index 961b6be4d14c..602c7fe25f72 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -158,4 +158,4 @@ export { generateModuleMetadataInjectorCode, } from './utils'; export { createSentryBuildPluginManager } from './build-plugin-manager'; -export { createDebugIdUploadFunction } from './debug-id-upload'; +export { createDebugIdStampingFunction, createDebugIdUploadFunction } from './debug-id-upload'; diff --git a/packages/bundler-plugins/src/core/types.ts b/packages/bundler-plugins/src/core/types.ts index 5f641d3a21a4..efe130b2e8ee 100644 --- a/packages/bundler-plugins/src/core/types.ts +++ b/packages/bundler-plugins/src/core/types.ts @@ -105,8 +105,20 @@ export interface Options { /** * Disables all functionality related to sourcemaps if set to `true`. * - * If set to `"disable-upload"`, the plugin will not upload sourcemaps to Sentry, but will inject debug IDs into the build artifacts. - * This is useful if you want to manually upload sourcemaps to Sentry at a later point in time. + * If set to `"disable-upload"`, the plugin will not upload sourcemaps to Sentry, but will inject debug IDs into the + * build artifacts and write them into the emitted source maps. This is useful if you want to manually upload + * sourcemaps to Sentry at a later point in time, for example with + * `sentry-cli sourcemaps upload --debug-id-reference`. + * + * The `--debug-id-reference` flag is required because the plugin only writes the debug ID into the source map, not + * into the bundle - rewriting the bundle would invalidate hashes computed during the build (e.g. for subresource + * integrity). The flag tells the CLI to take the debug ID from the linked source map instead of expecting it in + * both files. + * + * If you would rather have the debug ID in the bundles too, run `sentry-cli sourcemaps inject` before uploading. + * It reuses the debug IDs the plugin already wrote into the source maps, so they keep matching what the SDK + * reports at runtime - but be aware that it rewrites every bundle, so do not use it when you depend on build-time + * hashes such as subresource integrity. * * @default false */ diff --git a/packages/bundler-plugins/src/core/utils.ts b/packages/bundler-plugins/src/core/utils.ts index 2436147cc610..fd9beeb10ec1 100644 --- a/packages/bundler-plugins/src/core/utils.ts +++ b/packages/bundler-plugins/src/core/utils.ts @@ -412,6 +412,28 @@ export function serializeIgnoreOptions(ignoreValue: string | string[] | undefine return ignoreOptions.reduce((acc, value) => acc.concat(['--ignore', String(value)]), [] as string[]); } +/** + * Runs the given tasks with at most `concurrency` of them in flight at a time and returns their + * results in the order the tasks were passed in. + */ +export async function runWithConcurrency(tasks: Array<() => Promise>, concurrency: number): Promise { + const results = new Array(tasks.length); + + // All workers pull from one shared iterator, so each task is handed out exactly once and a worker + // that finishes early picks up the next one instead of waiting on a fixed slice. + const remainingTasks = tasks.entries(); + + const worker = async (): Promise => { + for (const [index, task] of remainingTasks) { + results[index] = await task(); + } + }; + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + + return results; +} + /** * Checks if a chunk contains only import/export statements and no substantial code. * diff --git a/packages/bundler-plugins/src/esbuild/index.ts b/packages/bundler-plugins/src/esbuild/index.ts index a1b1c12150ff..af7d3893ab8f 100644 --- a/packages/bundler-plugins/src/esbuild/index.ts +++ b/packages/bundler-plugins/src/esbuild/index.ts @@ -4,6 +4,7 @@ import { generateReleaseInjectorCode, generateModuleMetadataInjectorCode, getDebugIdSnippet, + createDebugIdStampingFunction, createDebugIdUploadFunction, CodeInjection, } from '../core'; @@ -277,15 +278,21 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any { // Create release and optionally upload const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); + const stampDebugIds = createDebugIdStampingFunction({ sentryBuildPluginManager }); initialOptions.metafile = true; onEnd(async result => { try { await sentryBuildPluginManager.createRelease(); - if (sourcemapsEnabled && options.sourcemaps?.disable !== 'disable-upload') { + if (sourcemapsEnabled) { const buildArtifacts = result.metafile ? Object.keys(result.metafile.outputs) : []; - await upload(buildArtifacts); + + if (options.sourcemaps?.disable === 'disable-upload') { + await stampDebugIds(buildArtifacts); + } else { + await upload(buildArtifacts); + } } } finally { freeGlobalDependencyOnBuildArtifacts(); diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index c53ce21245bd..726e7b1d5668 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -8,6 +8,7 @@ import { getDebugIdSnippet, stringToUUID, COMMENT_USE_STRICT_REGEX, + createDebugIdStampingFunction, createDebugIdUploadFunction, globFiles, createComponentNameAnnotateHooks, @@ -132,6 +133,7 @@ export function _rollupPluginInternal( const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); + const stampDebugIds = createDebugIdStampingFunction({ sentryBuildPluginManager }); const sourcemapsEnabled = options.sourcemaps?.disable !== true; const staticInjectionCode = new CodeInjection(); @@ -284,6 +286,29 @@ export function _rollupPluginInternal( }; } + async function resolveBuildArtifacts( + outputOptions: { dir?: string; file?: string }, + bundle: { [fileName: string]: unknown }, + ): Promise { + if (outputOptions.dir) { + const JS_AND_MAP_PATTERNS = [ + '/**/*.js', + '/**/*.mjs', + '/**/*.cjs', + '/**/*.js.map', + '/**/*.mjs.map', + '/**/*.cjs.map', + ].map(q => `${q}?(\\?*)?(#*)`); // We want to allow query and hash strings at the end of files + return globFiles(JS_AND_MAP_PATTERNS, { root: outputOptions.dir }); + } + + if (outputOptions.file) { + return [outputOptions.file]; + } + + return Object.keys(bundle).map(asset => path.join(path.resolve(), asset)); + } + async function writeBundle( outputOptions: { dir?: string; file?: string }, bundle: { [fileName: string]: unknown }, @@ -291,23 +316,12 @@ export function _rollupPluginInternal( try { await sentryBuildPluginManager.createRelease(); - if (sourcemapsEnabled && options.sourcemaps?.disable !== 'disable-upload') { - if (outputOptions.dir) { - const outputDir = outputOptions.dir; - const JS_AND_MAP_PATTERNS = [ - '/**/*.js', - '/**/*.mjs', - '/**/*.cjs', - '/**/*.js.map', - '/**/*.mjs.map', - '/**/*.cjs.map', - ].map(q => `${q}?(\\?*)?(#*)`); // We want to allow query and hash strings at the end of files - const buildArtifacts = await globFiles(JS_AND_MAP_PATTERNS, { root: outputDir }); - await upload(buildArtifacts); - } else if (outputOptions.file) { - await upload([outputOptions.file]); + if (sourcemapsEnabled) { + const buildArtifacts = await resolveBuildArtifacts(outputOptions, bundle); + + if (options.sourcemaps?.disable === 'disable-upload') { + await stampDebugIds(buildArtifacts); } else { - const buildArtifacts = Object.keys(bundle).map(asset => path.join(path.resolve(), asset)); await upload(buildArtifacts); } } diff --git a/packages/bundler-plugins/src/webpack/webpack4and5.ts b/packages/bundler-plugins/src/webpack/webpack4and5.ts index d58f37aefab8..67b9e7a1355c 100644 --- a/packages/bundler-plugins/src/webpack/webpack4and5.ts +++ b/packages/bundler-plugins/src/webpack/webpack4and5.ts @@ -7,6 +7,7 @@ import { createComponentNameAnnotateHooks, CodeInjection, getDebugIdSnippet, + createDebugIdStampingFunction, createDebugIdUploadFunction, } from '../core/index'; import * as path from 'node:path'; @@ -275,14 +276,20 @@ export function sentryWebpackPluginFactory({ (compilation: WebpackCompilation, callback: (err?: Error) => void) => { const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); + const stampDebugIds = createDebugIdStampingFunction({ sentryBuildPluginManager }); const run = async (): Promise => { try { await sentryBuildPluginManager.createRelease(); - if (sourcemapsEnabled && options.sourcemaps?.disable !== 'disable-upload') { + if (sourcemapsEnabled) { const outputPath = compilation.outputOptions.path ?? path.resolve(); const buildArtifacts = Object.keys(compilation.assets).map(asset => path.join(outputPath, asset)); - await upload(buildArtifacts); + + if (options.sourcemaps?.disable === 'disable-upload') { + await stampDebugIds(buildArtifacts); + } else { + await upload(buildArtifacts); + } } } finally { freeGlobalDependencyOnBuildArtifacts(); diff --git a/packages/bundler-plugins/test/core/build-plugin-manager.test.ts b/packages/bundler-plugins/test/core/build-plugin-manager.test.ts index 283030370d68..ae48e3dd1e0a 100644 --- a/packages/bundler-plugins/test/core/build-plugin-manager.test.ts +++ b/packages/bundler-plugins/test/core/build-plugin-manager.test.ts @@ -1,8 +1,8 @@ import { createSentryBuildPluginManager, _resetDeployedReleasesForTesting } from '../../src/core/build-plugin-manager'; import fs from 'fs'; import { globFiles } from '../../src/core/glob'; -import { prepareBundleForDebugIdUpload } from '../../src/core/debug-id-upload'; -import type { MockedFunction } from 'vitest'; +import { prepareBundleForDebugIdUpload, stampDebugIdOnEmittedSourceMap } from '../../src/core/debug-id-upload'; +import type { MockedFunction, MockInstance } from 'vitest'; import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; const { mockCliExecute, mockCliUploadSourceMaps, mockCliNewDeploy, mockCliConstructor } = vi.hoisted(() => ({ @@ -45,6 +45,9 @@ const mockGlobFiles = globFiles as MockedFunction; const mockPrepareBundleForDebugIdUpload = prepareBundleForDebugIdUpload as unknown as MockedFunction< typeof prepareBundleForDebugIdUpload >; +const mockStampDebugIdOnEmittedSourceMap = stampDebugIdOnEmittedSourceMap as unknown as MockedFunction< + typeof stampDebugIdOnEmittedSourceMap +>; describe('createSentryBuildPluginManager', () => { beforeEach(() => { @@ -419,6 +422,99 @@ describe('createSentryBuildPluginManager', () => { }); }); + describe('stampDebugIdsOnSourceMaps', () => { + function createManager(): ReturnType { + return createSentryBuildPluginManager( + { authToken: 't', org: 'o', project: 'p', sourcemaps: { disable: 'disable-upload' } }, + { buildTool: 'webpack', loggerPrefix: '[sentry-webpack-plugin]' }, + ); + } + + let consoleInfoSpy: MockInstance; + let consoleWarnSpy: MockInstance; + let originalNodeEnv: string | undefined; + + beforeEach(() => { + originalNodeEnv = process.env['NODE_ENV']; + consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalNodeEnv === undefined) { + delete process.env['NODE_ENV']; + } else { + process.env['NODE_ENV'] = originalNodeEnv; + } + }); + + it('stamps every globbed JS chunk and reports how many maps now carry a debug ID', async () => { + mockGlobFiles.mockResolvedValue(['/app/dist/b.js', '/app/dist/a.js', '/app/dist/a.js.map', '/app/dist/x.txt']); + mockStampDebugIdOnEmittedSourceMap.mockResolvedValue('stamped'); + + await createManager().stampDebugIdsOnSourceMaps(['/app/dist']); + + expect(mockStampDebugIdOnEmittedSourceMap).toHaveBeenCalledTimes(2); + expect(mockStampDebugIdOnEmittedSourceMap).toHaveBeenCalledWith('/app/dist/a.js', expect.anything(), undefined); + expect(mockStampDebugIdOnEmittedSourceMap).toHaveBeenCalledWith('/app/dist/b.js', expect.anything(), undefined); + expect(consoleInfoSpy).toHaveBeenCalledWith(expect.stringContaining('onto 2 source map(s)')); + }); + + it('counts maps that were already stamped by an earlier writeBundle run', async () => { + mockGlobFiles.mockResolvedValue(['/app/dist/a.js']); + mockStampDebugIdOnEmittedSourceMap.mockResolvedValue('alreadyStamped'); + + await createManager().stampDebugIdsOnSourceMaps(['/app/dist']); + + expect(consoleInfoSpy).toHaveBeenCalledWith(expect.stringContaining('onto 1 source map(s)')); + }); + + // Pointing users at a manual upload that cannot work is worse than saying nothing happened. + it('does not claim success when no map was stamped', async () => { + mockGlobFiles.mockResolvedValue(['/app/dist/a.js']); + mockStampDebugIdOnEmittedSourceMap.mockResolvedValue('skipped'); + + await createManager().stampDebugIdsOnSourceMaps(['/app/dist']); + + expect(consoleInfoSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Didn't stamp a debug ID onto any source map"), + ); + }); + + it('warns about bundles that inline their source map', async () => { + mockGlobFiles.mockResolvedValue(['/app/dist/a.js', '/app/dist/b.js']); + mockStampDebugIdOnEmittedSourceMap.mockResolvedValue('inlineSourceMap'); + + await createManager().stampDebugIdsOnSourceMaps(['/app/dist']); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('2 bundle(s) inline their source map')); + }); + + // Dev servers keep their assets in memory, so touching the filesystem would only produce read errors. + it('does nothing in development mode', async () => { + process.env['NODE_ENV'] = 'development'; + + await createManager().stampDebugIdsOnSourceMaps(['/app/dist']); + + expect(mockGlobFiles).not.toHaveBeenCalled(); + expect(mockStampDebugIdOnEmittedSourceMap).not.toHaveBeenCalled(); + }); + + it('exits early when assets is an empty array', async () => { + const manager = createSentryBuildPluginManager( + { authToken: 't', org: 'o', project: 'p', sourcemaps: { disable: 'disable-upload', assets: [] } }, + { buildTool: 'webpack', loggerPrefix: '[sentry-webpack-plugin]' }, + ); + + await manager.stampDebugIdsOnSourceMaps(['/app/dist']); + + expect(mockGlobFiles).not.toHaveBeenCalled(); + expect(mockStampDebugIdOnEmittedSourceMap).not.toHaveBeenCalled(); + }); + }); + describe('injectDebugIds', () => { it('should call CLI with correct sourcemaps inject command', async () => { mockCliExecute.mockResolvedValue(undefined); diff --git a/packages/bundler-plugins/test/core/debug-id-upload.test.ts b/packages/bundler-plugins/test/core/debug-id-upload.test.ts index c14dfe7e3651..a7d22aca1e32 100644 --- a/packages/bundler-plugins/test/core/debug-id-upload.test.ts +++ b/packages/bundler-plugins/test/core/debug-id-upload.test.ts @@ -2,10 +2,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { prepareBundleForDebugIdUpload } from '../../src/core/debug-id-upload'; +import { prepareBundleForDebugIdUpload, stampDebugIdOnEmittedSourceMap } from '../../src/core/debug-id-upload'; import type { RewriteSourcesHook } from '../../src/core/types'; import type { Logger } from '../../src/core'; +const debugIdSnippet = (debugId: string): string => + `;!function(){try{var e="undefined"!=typeof window?window:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}",e._sentryDebugIdIdentifier="sentry-dbid-${debugId}")}catch(e){}}();`; + +const makeLogger = (): Logger => + ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) as unknown as Logger; + describe('prepareBundleForDebugIdUpload', () => { let tmpDir: string; @@ -61,4 +67,179 @@ describe('prepareBundleForDebugIdUpload', () => { expect(capturedContexts).toHaveLength(1); expect(capturedContexts[0]!.mapDir).toBe(bundleDir); }); + + const noopRewriteHook: RewriteSourcesHook = source => source; + + // An array passed the old parse-and-mutate guard (arrays take string keys), then serialized back to + // `[]` - uploading a map that looks fine to the CLI and symbolicates nothing. + it.each(['null', '42', '[]'])('does not upload a source map that is %s', async mapContent => { + const bundleDir = path.join(tmpDir, 'src'); + const uploadDir = path.join(tmpDir, 'upload'); + fs.mkdirSync(bundleDir, { recursive: true }); + fs.mkdirSync(uploadDir, { recursive: true }); + + const debugId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + const bundlePath = path.join(bundleDir, 'bundle.js'); + fs.writeFileSync(bundlePath, `"use strict";\n// code\n${debugIdSnippet(debugId)}`); + fs.writeFileSync(path.join(bundleDir, 'bundle.js.map'), mapContent); + const logger = makeLogger(); + + await prepareBundleForDebugIdUpload(bundlePath, uploadDir, 0, logger, noopRewriteHook, undefined); + + expect(fs.readdirSync(uploadDir)).toEqual([`${debugId}-0.js`]); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Source map is not a JSON object')); + }); +}); + +describe('stampDebugIdOnEmittedSourceMap', () => { + const debugId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeBundle(fileName: string, { sourceMappingUrl }: { sourceMappingUrl?: string } = {}): string { + const bundlePath = path.join(tmpDir, fileName); + const sourceMappingUrlComment = sourceMappingUrl ? `\n//# sourceMappingURL=${sourceMappingUrl}` : ''; + fs.writeFileSync(bundlePath, `"use strict";\n// code\n${debugIdSnippet(debugId)}${sourceMappingUrlComment}`); + return bundlePath; + } + + function writeSourceMap(fileName: string, map: Record = {}): string { + const mapPath = path.join(tmpDir, fileName); + fs.writeFileSync(mapPath, JSON.stringify({ version: 3, sources: ['../src/index.ts'], mappings: 'AAAA', ...map })); + return mapPath; + } + + function readSourceMap(mapPath: string): Record { + return JSON.parse(fs.readFileSync(mapPath, 'utf8')) as Record; + } + + it('writes the bundle’s debug ID into the adjacent source map', async () => { + // `hidden-source-map` emits no sourceMappingURL comment, so the `.map` sibling is the only way to find the map + const bundlePath = writeBundle('bundle.js'); + const mapPath = writeSourceMap('bundle.js.map'); + + await stampDebugIdOnEmittedSourceMap(bundlePath, makeLogger(), undefined); + + expect(readSourceMap(mapPath)).toMatchObject({ debug_id: debugId, debugId }); + }); + + it('follows the sourceMappingURL comment', async () => { + const bundlePath = writeBundle('bundle.js', { sourceMappingUrl: 'maps/bundle.map' }); + fs.mkdirSync(path.join(tmpDir, 'maps')); + const mapPath = writeSourceMap(path.join('maps', 'bundle.map')); + + await stampDebugIdOnEmittedSourceMap(bundlePath, makeLogger(), undefined); + + expect(readSourceMap(mapPath)).toMatchObject({ debug_id: debugId, debugId }); + }); + + it('leaves the bundle untouched so build-time hashes stay valid', async () => { + const bundlePath = writeBundle('bundle.js'); + writeSourceMap('bundle.js.map'); + const bundleContentBefore = fs.readFileSync(bundlePath, 'utf8'); + + await stampDebugIdOnEmittedSourceMap(bundlePath, makeLogger(), undefined); + + expect(fs.readFileSync(bundlePath, 'utf8')).toBe(bundleContentBefore); + }); + + it('does not rewrite the source map’s sources', async () => { + const bundlePath = writeBundle('bundle.js'); + const mapPath = writeSourceMap('bundle.js.map', { sources: ['webpack://app/./src/index.ts'] }); + + await stampDebugIdOnEmittedSourceMap(bundlePath, makeLogger(), undefined); + + expect(readSourceMap(mapPath)).toMatchObject({ sources: ['webpack://app/./src/index.ts'] }); + }); + + it('uses the resolveSourceMap hook when provided', async () => { + const bundlePath = writeBundle('bundle.js'); + const mapPath = writeSourceMap('somewhere-else.map'); + + await stampDebugIdOnEmittedSourceMap(bundlePath, makeLogger(), () => mapPath); + + expect(readSourceMap(mapPath)).toMatchObject({ debug_id: debugId, debugId }); + }); + + it('reports a stamped source map', async () => { + const bundlePath = writeBundle('bundle.js'); + writeSourceMap('bundle.js.map'); + + await expect(stampDebugIdOnEmittedSourceMap(bundlePath, makeLogger(), undefined)).resolves.toBe('stamped'); + }); + + it('does not rewrite a source map that is already stamped', async () => { + const bundlePath = writeBundle('bundle.js'); + const mapPath = writeSourceMap('bundle.js.map', { debug_id: debugId, debugId }); + const mtimeBefore = fs.statSync(mapPath).mtimeMs; + + await expect(stampDebugIdOnEmittedSourceMap(bundlePath, makeLogger(), undefined)).resolves.toBe('alreadyStamped'); + + expect(fs.statSync(mapPath).mtimeMs).toBe(mtimeBefore); + }); + + it('logs and skips bundles without an injected debug ID', async () => { + const bundlePath = path.join(tmpDir, 'bundle.js'); + fs.writeFileSync(bundlePath, '"use strict";\n// code'); + const mapPath = writeSourceMap('bundle.js.map'); + const logger = makeLogger(); + + await expect(stampDebugIdOnEmittedSourceMap(bundlePath, logger, undefined)).resolves.toBe('skipped'); + + expect(readSourceMap(mapPath)).not.toHaveProperty('debug_id'); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('Could not determine debug ID')); + }); + + it('does not throw when no source map can be found', async () => { + const bundlePath = writeBundle('bundle.js'); + + await expect(stampDebugIdOnEmittedSourceMap(bundlePath, makeLogger(), undefined)).resolves.toBe('skipped'); + }); + + // An inlined map cannot be stamped without rewriting the bundle, so the caller has to be able to + // tell this apart from "nothing to do" and warn the user that these bundles will not symbolicate. + it('reports bundles whose source map is inlined as a data URI', async () => { + const bundlePath = path.join(tmpDir, 'bundle.js'); + const inlineMap = Buffer.from(JSON.stringify({ version: 3, sources: [], mappings: '' })).toString('base64'); + fs.writeFileSync( + bundlePath, + `"use strict";\n${debugIdSnippet(debugId)}\n//# sourceMappingURL=data:application/json;base64,${inlineMap}`, + ); + + await expect(stampDebugIdOnEmittedSourceMap(bundlePath, makeLogger(), undefined)).resolves.toBe('inlineSourceMap'); + }); + + it('logs and skips source maps that cannot be parsed', async () => { + const bundlePath = writeBundle('bundle.js'); + const mapPath = path.join(tmpDir, 'bundle.js.map'); + fs.writeFileSync(mapPath, 'not json'); + const logger = makeLogger(); + + await expect(stampDebugIdOnEmittedSourceMap(bundlePath, logger, undefined)).resolves.toBe('skipped'); + + expect(fs.readFileSync(mapPath, 'utf8')).toBe('not json'); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to parse source map'), expect.anything()); + }); + + // `JSON.parse` happily returns non-objects, so indexing the result without a guard would throw a + // TypeError out of the stamping run and take the whole build's error handler with it. + it.each(['null', '42', '"a string"', '[]'])('logs and skips a source map that is %s', async mapContent => { + const bundlePath = writeBundle('bundle.js'); + const mapPath = path.join(tmpDir, 'bundle.js.map'); + fs.writeFileSync(mapPath, mapContent); + const logger = makeLogger(); + + await expect(stampDebugIdOnEmittedSourceMap(bundlePath, logger, undefined)).resolves.toBe('skipped'); + + expect(fs.readFileSync(mapPath, 'utf8')).toBe(mapContent); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Source map is not a JSON object')); + }); }); diff --git a/packages/bundler-plugins/test/core/utils.test.ts b/packages/bundler-plugins/test/core/utils.test.ts index 6aeaec85d689..e8faee6f54d1 100644 --- a/packages/bundler-plugins/test/core/utils.test.ts +++ b/packages/bundler-plugins/test/core/utils.test.ts @@ -6,6 +6,7 @@ import { getPackageJson, parseMajorVersion, replaceBooleanFlagsInCode, + runWithConcurrency, serializeIgnoreOptions, stringToUUID, } from '../../src/core/utils'; @@ -309,3 +310,41 @@ describe('determineReleaseName', () => { } }); }); + +describe('runWithConcurrency', () => { + it('returns results in task order regardless of completion order', async () => { + const delays = [30, 0, 20, 10]; + const tasks = delays.map( + (delay, index) => () => new Promise(resolve => setTimeout(() => resolve(index), delay)), + ); + + await expect(runWithConcurrency(tasks, 4)).resolves.toEqual([0, 1, 2, 3]); + }); + + it('never runs more than `concurrency` tasks at a time', async () => { + let inFlight = 0; + let peakInFlight = 0; + + const tasks = Array.from({ length: 20 }, () => async () => { + inFlight++; + peakInFlight = Math.max(peakInFlight, inFlight); + await new Promise(resolve => setTimeout(resolve, 1)); + inFlight--; + }); + + await runWithConcurrency(tasks, 3); + + expect(peakInFlight).toBe(3); + }); + + it('runs every task even when there are fewer tasks than workers', async () => { + const ran: number[] = []; + const tasks = [0, 1].map(index => async () => { + ran.push(index); + }); + + await runWithConcurrency(tasks, 16); + + expect(ran).toEqual([0, 1]); + }); +}); diff --git a/packages/bundler-plugins/test/esbuild/disable-upload.test.ts b/packages/bundler-plugins/test/esbuild/disable-upload.test.ts new file mode 100644 index 000000000000..7c98a50944f1 --- /dev/null +++ b/packages/bundler-plugins/test/esbuild/disable-upload.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as esbuild from 'esbuild'; +import { sentryEsbuildPlugin } from '../../src/esbuild'; + +// Regression test for https://github.com/getsentry/sentry-javascript-bundler-plugins/issues/949: +// `disable-upload` used to inject the debug ID into the bundle only, leaving the emitted source map +// without one - so a manual `sentry-cli` upload afterwards produced artifacts that never symbolicate. +describe('sentryEsbuildPlugin with `sourcemaps.disable: "disable-upload"`', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-esbuild-test-')); + fs.mkdirSync(path.join(tmpDir, 'src')); + fs.writeFileSync(path.join(tmpDir, 'src', 'index.js'), 'console.log("hello", Math.random());\n'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + async function build(sourcemap: boolean | 'external'): Promise { + await esbuild.build({ + entryPoints: [path.join(tmpDir, 'src', 'index.js')], + outfile: path.join(tmpDir, 'dist', 'bundle.js'), + bundle: true, + sourcemap, + plugins: [sentryEsbuildPlugin({ telemetry: false, silent: true, sourcemaps: { disable: 'disable-upload' } })], + }); + } + + function readDist(fileName: string): string { + return fs.readFileSync(path.join(tmpDir, 'dist', fileName), 'utf8'); + } + + // `external` is esbuild's equivalent of `hidden-source-map`: it emits the map but no + // sourceMappingURL comment, so the `.map` sibling is the only way to find it. + it.each([true, 'external'] as const)( + 'stamps the injected debug ID onto the emitted source map (sourcemap: %s)', + async sourcemap => { + await build(sourcemap); + + const debugId = readDist('bundle.js').match(/sentry-dbid-([0-9a-f-]{36})/)?.[1]; + expect(debugId).toBeDefined(); + + expect(JSON.parse(readDist('bundle.js.map'))).toMatchObject({ debug_id: debugId, debugId }); + }, + ); +}); diff --git a/packages/bundler-plugins/test/rollup/disable-upload.test.ts b/packages/bundler-plugins/test/rollup/disable-upload.test.ts new file mode 100644 index 000000000000..8ba8312e5c1b --- /dev/null +++ b/packages/bundler-plugins/test/rollup/disable-upload.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { rollup } from 'rollup'; +import type { Plugin } from 'rollup'; +import { sentryRollupPlugin } from '../../src/rollup'; + +// Regression test for https://github.com/getsentry/sentry-javascript-bundler-plugins/issues/949: +// `disable-upload` used to inject the debug ID into the bundle only, leaving the emitted source map +// without one - so a manual `sentry-cli` upload afterwards produced artifacts that never symbolicate. +describe('sentryRollupPlugin with `sourcemaps.disable: "disable-upload"`', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-rollup-test-')); + fs.mkdirSync(path.join(tmpDir, 'src')); + fs.writeFileSync(path.join(tmpDir, 'src', 'index.js'), 'console.log("hello", Math.random());\n'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + async function build(sourcemap: boolean | 'hidden'): Promise { + const bundle = await rollup({ + input: path.join(tmpDir, 'src', 'index.js'), + plugins: sentryRollupPlugin({ + telemetry: false, + silent: true, + sourcemaps: { disable: 'disable-upload' }, + }) as Plugin[], + }); + + await bundle.write({ dir: path.join(tmpDir, 'dist'), entryFileNames: 'bundle.js', sourcemap }); + await bundle.close(); + } + + function readDist(fileName: string): string { + return fs.readFileSync(path.join(tmpDir, 'dist', fileName), 'utf8'); + } + + it.each([true, 'hidden'] as const)( + 'stamps the injected debug ID onto the emitted source map (sourcemap: %s)', + async sourcemap => { + await build(sourcemap); + + const debugId = readDist('bundle.js').match(/sentry-dbid-([0-9a-f-]{36})/)?.[1]; + expect(debugId).toBeDefined(); + + expect(JSON.parse(readDist('bundle.js.map'))).toMatchObject({ debug_id: debugId, debugId }); + }, + ); +}); diff --git a/packages/bundler-plugins/test/webpack/disable-upload.test.ts b/packages/bundler-plugins/test/webpack/disable-upload.test.ts new file mode 100644 index 000000000000..036a5cf6a98c --- /dev/null +++ b/packages/bundler-plugins/test/webpack/disable-upload.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import webpack from 'webpack'; +import type { Configuration } from 'webpack'; +import { sentryWebpackPlugin } from '../../src/webpack/index'; + +// Regression test for https://github.com/getsentry/sentry-javascript-bundler-plugins/issues/949: +// `disable-upload` used to inject the debug ID into the bundle only, leaving the emitted source map +// without one - so a manual `sentry-cli` upload afterwards produced artifacts that never symbolicate. +describe('sentryWebpackPlugin with `sourcemaps.disable: "disable-upload"`', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-webpack-test-')); + fs.mkdirSync(path.join(tmpDir, 'src')); + // Needs a side effect, otherwise production mode tree-shakes the bundle down to nothing + // and webpack emits no source map at all. + fs.writeFileSync(path.join(tmpDir, 'src', 'index.js'), 'console.log("hello", Math.random());\n'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function build(config: Partial): Promise { + return new Promise((resolve, reject) => { + webpack( + { + mode: 'production', + entry: path.join(tmpDir, 'src', 'index.js'), + output: { path: path.join(tmpDir, 'dist'), filename: 'bundle.js' }, + ...config, + }, + (err, stats) => { + if (err ?? stats?.hasErrors()) { + reject(err ?? new Error(stats?.toString({ errorDetails: true }))); + return; + } + resolve(); + }, + ); + }); + } + + function readDist(fileName: string): string { + return fs.readFileSync(path.join(tmpDir, 'dist', fileName), 'utf8'); + } + + function getInjectedDebugId(bundleSource: string): string { + const match = bundleSource.match(/sentry-dbid-([0-9a-f-]{36})/); + expect(match).not.toBeNull(); + return match![1]!; + } + + it('stamps the injected debug ID onto the emitted source map', async () => { + await build({ + devtool: 'hidden-source-map', + plugins: [sentryWebpackPlugin({ telemetry: false, silent: true, sourcemaps: { disable: 'disable-upload' } })], + }); + + const debugId = getInjectedDebugId(readDist('bundle.js')); + + expect(JSON.parse(readDist('bundle.js.map'))).toMatchObject({ debug_id: debugId, debugId }); + }); + + it('does not rewrite the emitted source map’s sources', async () => { + await build({ + devtool: 'hidden-source-map', + plugins: [sentryWebpackPlugin({ telemetry: false, silent: true, sourcemaps: { disable: 'disable-upload' } })], + }); + + // Unlike the throwaway copies the upload path prepares, the emitted map is a file the user keeps, + // so its `sources` must stay exactly as the bundler wrote them. + const { sources } = JSON.parse(readDist('bundle.js.map')) as { sources: string[] }; + expect(sources.every(source => source.startsWith('webpack://'))).toBe(true); + }); +});