From 1269431f985e5e0221df297ecff22301a868fb5b Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 11 Nov 2026 11:01:00 +0000 Subject: [PATCH 1/9] feat(bundler-plugins): Delete source maps with `deleteSourcemapsAfterUpload: true` when no upload runs --- .../src/core/build-plugin-manager.ts | 4 +- packages/bundler-plugins/src/core/index.ts | 4 +- .../src/core/options-mapping.ts | 11 +-- .../src/core/sentry/telemetry.ts | 6 +- .../src/core/sourcemap-settings.ts | 21 ++++- packages/bundler-plugins/src/core/types.ts | 16 ++++ packages/bundler-plugins/src/esbuild/index.ts | 22 ++++-- packages/bundler-plugins/src/rollup/index.ts | 4 +- .../src/rollup/source-map-handling.ts | 11 ++- packages/bundler-plugins/src/vite/index.ts | 6 +- .../src/vite/source-map-hooks.ts | 6 +- packages/bundler-plugins/src/webpack/index.ts | 43 ++++++++--- .../test/core/option-mappings.test.ts | 25 ++---- .../test/core/sourcemap-settings.test.ts | 77 ++++++++++++++++--- .../delete-sourcemaps-after-upload.test.ts | 7 ++ .../delete-sourcemaps-after-upload.test.ts | 49 ++++++++++++ .../delete-sourcemaps-after-upload.test.ts | 31 ++++++++ .../delete-sourcemaps-after-upload.test.ts | 14 ++++ .../buildTimeOptionsBase.ts | 3 + 19 files changed, 290 insertions(+), 70 deletions(-) diff --git a/packages/bundler-plugins/src/core/build-plugin-manager.ts b/packages/bundler-plugins/src/core/build-plugin-manager.ts index b70b32664d01..17ec9018f7d4 100644 --- a/packages/bundler-plugins/src/core/build-plugin-manager.ts +++ b/packages/bundler-plugins/src/core/build-plugin-manager.ts @@ -11,7 +11,7 @@ import { normalizeUserOptions, validateOptions } from './options-mapping'; import type { Logger } from './logger'; import { createLogger } from './logger'; import { allowedToSendTelemetry, createSentryInstance, safeFlushTelemetry } from './sentry/telemetry'; -import type { Options, SentrySDKBuildFlags } from './types'; +import type { InternalOptions, SentrySDKBuildFlags } from './types'; import { arrayify, getProjects, getTurborepoEnvPassthroughWarning, stripQueryAndHashFromPath } from './utils'; import { defaultRewriteSourcesHook, @@ -183,7 +183,7 @@ export type SentryBuildPluginManager = { * The build plugin manager's behavior strongly depends on the options that are passed in. */ export function createSentryBuildPluginManager( - userOptions: Options, + userOptions: InternalOptions, bundlerPluginMetaContext: { /** * E.g. `webpack` or `nextjs` or `turbopack` diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts index b721b2ec47e7..017def78b302 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -101,7 +101,7 @@ export function getDebugIdSnippet(debugId: string): CodeInjection { } export type { Logger } from './logger'; -export type { Options, SentrySDKBuildFlags } from './types'; +export type { InternalOptions, Options, SentrySDKBuildFlags } from './types'; export { CodeInjection, replaceBooleanFlagsInCode, @@ -112,6 +112,6 @@ export { export { createSentryBuildPluginManager } from './build-plugin-manager'; export type { SentryBuildPluginManager, UploadResult, UploadStaging } from './build-plugin-manager'; export { createDebugIdUploadFunction, addDebugIdToEmittedArtifacts, stampDebugId } from './debug-id-upload'; -export { decideSourceMapHandling } from './sourcemap-settings'; +export { decideSourceMapHandling, getDeleteSourcemapsAfterUpload } from './sourcemap-settings'; export type { SourceMapDecision, SourceMapKind } from './sourcemap-settings'; export type { UploadFilter } from './upload-filter'; diff --git a/packages/bundler-plugins/src/core/options-mapping.ts b/packages/bundler-plugins/src/core/options-mapping.ts index f775ce0c5ad3..4dcd17c5c554 100644 --- a/packages/bundler-plugins/src/core/options-mapping.ts +++ b/packages/bundler-plugins/src/core/options-mapping.ts @@ -1,7 +1,7 @@ import { getVercelEnv } from '@sentry/core'; import type { Logger } from './logger'; import type { - Options as UserOptions, + InternalOptions as UserOptions, SetCommitsOptions, RewriteSourcesHook, ResolveSourceMapHook, @@ -30,7 +30,7 @@ export type NormalizedOptions = { rewriteSources?: RewriteSourcesHook; resolveSourceMap?: ResolveSourceMapHook; filesToDeleteAfterUpload?: string | string[] | Promise; - deleteSourcemapsAfterUpload?: boolean; + deleteSourcemapsAfterUpload?: boolean | (() => boolean | undefined); } | undefined; release: { @@ -217,13 +217,6 @@ export function validateOptions(options: NormalizedOptions, logger: Logger): boo return false; } - if (options.sourcemaps?.deleteSourcemapsAfterUpload && options.sourcemaps.disable) { - logger.warn( - 'The `sourcemaps.deleteSourcemapsAfterUpload` option is set but source map upload is disabled via `sourcemaps.disable`.', - 'Source maps will not be deleted.', - ); - } - if (options.project && Array.isArray(options.project)) { if (options.project.length === 0) { logger.error( diff --git a/packages/bundler-plugins/src/core/sentry/telemetry.ts b/packages/bundler-plugins/src/core/sentry/telemetry.ts index 66953eb3cb1f..31b4394698c5 100644 --- a/packages/bundler-plugins/src/core/sentry/telemetry.ts +++ b/packages/bundler-plugins/src/core/sentry/telemetry.ts @@ -108,7 +108,11 @@ export function setTelemetryDataOnScope( scope.setTag('delete-after-upload', !!sourcemaps?.filesToDeleteAfterUpload); scope.setTag( 'delete-sourcemaps-after-upload', - sourcemaps?.deleteSourcemapsAfterUpload === undefined ? 'auto' : String(sourcemaps.deleteSourcemapsAfterUpload), + typeof sourcemaps?.deleteSourcemapsAfterUpload === 'function' + ? 'function' + : sourcemaps?.deleteSourcemapsAfterUpload === undefined + ? 'auto' + : String(sourcemaps.deleteSourcemapsAfterUpload), ); scope.setTag('sourcemaps-disabled', !!sourcemaps?.disable); diff --git a/packages/bundler-plugins/src/core/sourcemap-settings.ts b/packages/bundler-plugins/src/core/sourcemap-settings.ts index 0f5a3b3d12fc..e5bb143188d7 100644 --- a/packages/bundler-plugins/src/core/sourcemap-settings.ts +++ b/packages/bundler-plugins/src/core/sourcemap-settings.ts @@ -21,6 +21,12 @@ export type SourceMapDecision = { const NO_CHANGE: SourceMapDecision = { enableHidden: false, deleteAfterUpload: false }; +/** Reads `sourcemaps.deleteSourcemapsAfterUpload`, calling the internal function form if given. */ +export function getDeleteSourcemapsAfterUpload(options: NormalizedOptions): boolean | undefined { + const value = options.sourcemaps?.deleteSourcemapsAfterUpload; + return typeof value === 'function' ? value() : value; +} + /** * Decides whether the plugin turns hidden source maps on and whether it removes the maps from the * build output after the upload. The plugin only turns maps on when an upload can run, and only @@ -31,10 +37,21 @@ export function decideSourceMapHandling( options: NormalizedOptions, uploadPossible: boolean, ): SourceMapDecision { - const deleteOption = options.sourcemaps?.deleteSourcemapsAfterUpload; + const deleteOption = getDeleteSourcemapsAfterUpload(options); if (!uploadPossible || options.sourcemaps?.disable) { - return NO_CHANGE; + if (deleteOption !== true || (kind !== 'hidden' && kind !== 'visible')) { + return NO_CHANGE; + } + return { + enableHidden: kind === 'visible', + deleteAfterUpload: true, + log: { + level: 'info', + message: + 'Source maps are not uploaded, but they are removed from the build output because `sourcemaps.deleteSourcemapsAfterUpload` is set.', + }, + }; } switch (kind) { diff --git a/packages/bundler-plugins/src/core/types.ts b/packages/bundler-plugins/src/core/types.ts index 7cf821a63937..f8dd000ac961 100644 --- a/packages/bundler-plugins/src/core/types.ts +++ b/packages/bundler-plugins/src/core/types.ts @@ -204,6 +204,9 @@ export interface Options { * `sourcemaps.assets` or `sourcemaps.ignore` keep out of the upload. Source maps you enabled yourself are only * removed for the JavaScript chunks that were uploaded; excluded chunks keep theirs. * + * `true` removes source maps you enabled yourself even when no upload runs, for example without an auth token or + * with `sourcemaps.disable`. The plugin does not turn source maps on when it cannot upload them. + * * With webpack's array form of `devtool`, `true` replaces the array with one hidden source map setting for * all asset types, because webpack hides the array from plugins. * @@ -668,3 +671,16 @@ type DeployOptions = { }; export type HandleRecoverableErrorFn = (error: unknown, throwByDefault: boolean) => void; + +/** + * @internal For Sentry SDK packages only. It can change in any release. + * + * Like `Options`, but `sourcemaps.deleteSourcemapsAfterUpload` can also be a function. The plugin + * calls it when it decides how to treat the bundler's source map setting, so an SDK can give a + * value that it only knows after it created the plugin. + */ +export type InternalOptions = Omit & { + sourcemaps?: Omit, 'deleteSourcemapsAfterUpload'> & { + deleteSourcemapsAfterUpload?: boolean | (() => boolean | undefined); + }; +}; diff --git a/packages/bundler-plugins/src/esbuild/index.ts b/packages/bundler-plugins/src/esbuild/index.ts index 51e5f126db8f..72b3e9699ebf 100644 --- a/packages/bundler-plugins/src/esbuild/index.ts +++ b/packages/bundler-plugins/src/esbuild/index.ts @@ -326,13 +326,21 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any { try { await sentryBuildPluginManager.createRelease(); - if (sourcemapsEnabled) { - // The metafile lists outputs relative to `absWorkingDir`, not to the process. - const outputDir = initialOptions.absWorkingDir ?? process.cwd(); - const buildArtifacts = result.metafile - ? Object.keys(result.metafile.outputs).map(output => path.resolve(outputDir, output)) - : []; - + // The metafile lists outputs relative to `absWorkingDir`, not to the process. + const outputDir = initialOptions.absWorkingDir ?? process.cwd(); + const buildArtifacts = result.metafile + ? Object.keys(result.metafile.outputs).map(output => path.resolve(outputDir, output)) + : []; + + const { sourceMaps } = sentryBuildPluginManager; + if (sourceMapDecision.deleteAfterUpload && !sourceMaps.uploadPossible) { + const bundleOf = (map: string): string => map.slice(0, -'.map'.length); + await sourceMaps.deleteFromOutput( + buildArtifacts.filter( + file => file.endsWith('.map') && isJsFile(bundleOf(file)) && sourceMaps.shouldUpload(bundleOf(file)), + ), + ); + } else if (sourcemapsEnabled) { if (options.sourcemaps?.disable !== 'disable-upload') { // The upload globs the paths, and glob reads backslashes as escapes. const { uploaded, uploadedSourceMapPaths } = await upload( diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index 7017577c6919..c36e3b73f249 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -1,4 +1,4 @@ -import type { ChunkGraphNode, Options } from '../core'; +import type { ChunkGraphNode, InternalOptions, Options } from '../core'; import { createSentryBuildPluginManager, generateReleaseInjectorCode, @@ -91,7 +91,7 @@ function getViteParseAstAsync(): Promise { */ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function _createRollupPluginInternals( - userOptions: Options = {}, + userOptions: InternalOptions = {}, buildTool: 'rollup' | 'vite', buildToolMajorVersion?: string, ) { diff --git a/packages/bundler-plugins/src/rollup/source-map-handling.ts b/packages/bundler-plugins/src/rollup/source-map-handling.ts index b907b217afed..8fe46b1fa26b 100644 --- a/packages/bundler-plugins/src/rollup/source-map-handling.ts +++ b/packages/bundler-plugins/src/rollup/source-map-handling.ts @@ -165,9 +165,9 @@ export function createSourceMapHandling(sentryBuildPluginManager: SentryBuildPlu return candidates.sort((a, b) => countMatchFields(b.match) - countMatchFields(a.match))[0]; } - function dropSourceMaps(bundle: OutputBundle): void { + function dropSourceMaps(bundle: OutputBundle, shouldDrop: (fileName: string) => boolean = () => true): void { for (const output of Object.values(bundle)) { - if (output.type === 'chunk' && isJsFile(output.fileName)) { + if (output.type === 'chunk' && isJsFile(output.fileName) && shouldDrop(output.fileName)) { // oxlint-disable-next-line typescript/no-dynamic-delete -- Rollup drops an asset when its key is deleted from the bundle delete bundle[getSourceMapFileName(output)]; } @@ -210,6 +210,13 @@ export function createSourceMapHandling(sentryBuildPluginManager: SentryBuildPlu return; } + if (!sentryBuildPluginManager.sourceMaps.uploadPossible) { + dropSourceMaps(bundle, fileName => + sentryBuildPluginManager.sourceMaps.shouldUpload(path.join(destination, fileName)), + ); + return; + } + const staging = await sentryBuildPluginManager.createUploadStaging(); try { diff --git a/packages/bundler-plugins/src/vite/index.ts b/packages/bundler-plugins/src/vite/index.ts index 9c8d5437fe03..414b0b0d538e 100644 --- a/packages/bundler-plugins/src/vite/index.ts +++ b/packages/bundler-plugins/src/vite/index.ts @@ -1,4 +1,4 @@ -import type { SentryRollupPluginOptions } from '../rollup'; +import type { InternalOptions } from '../core'; import { _createRollupPluginInternals } from '../rollup'; import { createRequire } from 'node:module'; @@ -22,7 +22,7 @@ function getViteMajorVersion(): string | undefined { return undefined; } -export const sentryVitePlugin = (options?: SentryRollupPluginOptions): SentryVitePlugin[] => { +export const sentryVitePlugin = (options?: InternalOptions): SentryVitePlugin[] => { const { main, post } = _createRollupPluginInternals(options, 'vite', getViteMajorVersion()); const plugins: SentryVitePlugin[] = [{ enforce: 'pre', ...main }]; @@ -34,4 +34,4 @@ export const sentryVitePlugin = (options?: SentryRollupPluginOptions): SentryVit return plugins; }; -export type { Options as SentryVitePluginOptions } from '../core'; +export type { Options as SentryVitePluginOptions, InternalOptions as SentryVitePluginInternalOptions } from '../core'; diff --git a/packages/bundler-plugins/src/vite/source-map-hooks.ts b/packages/bundler-plugins/src/vite/source-map-hooks.ts index 04e3eedc179a..c71705338c33 100644 --- a/packages/bundler-plugins/src/vite/source-map-hooks.ts +++ b/packages/bundler-plugins/src/vite/source-map-hooks.ts @@ -1,5 +1,6 @@ import * as path from 'node:path'; import type { SentryBuildPluginManager, SourceMapDecision, SourceMapKind } from '../core'; +import { getDeleteSourcemapsAfterUpload } from '../core'; import type { OutputMatch, RollupSourceMapSetting, SourceMapHandling } from '../rollup/source-map-handling'; import { getOutputMatch, sourceMapKindFromRollupSetting } from '../rollup/source-map-handling'; @@ -110,8 +111,7 @@ export function createViteSourceMapHooks( configResolved: config => { const environments = config.environments ?? { client: { build: config.build } }; - const { uploadPossible } = sentryBuildPluginManager.sourceMaps; - const deleteOption = sentryBuildPluginManager.normalizedOptions.sourcemaps?.deleteSourcemapsAfterUpload; + const deleteOption = getDeleteSourcemapsAfterUpload(sentryBuildPluginManager.normalizedOptions); for (const [name, environment] of Object.entries(environments)) { const { kind, decision } = decisions.get(name) ?? decisions.get('*') ?? { kind: 'unset', decision: NO_CHANGE }; @@ -125,7 +125,7 @@ export function createViteSourceMapHooks( kind: sourceMapKindFromRollupSetting(output.sourcemap), decision: { enableHidden: false, - deleteAfterUpload: uploadPossible && deleteOption === true && output.sourcemap === 'hidden', + deleteAfterUpload: deleteOption === true && output.sourcemap === 'hidden', }, }); continue; diff --git a/packages/bundler-plugins/src/webpack/index.ts b/packages/bundler-plugins/src/webpack/index.ts index 85795477405d..feffc9207753 100644 --- a/packages/bundler-plugins/src/webpack/index.ts +++ b/packages/bundler-plugins/src/webpack/index.ts @@ -1,6 +1,6 @@ /* oxlint-disable max-lines */ -import type { Options, SourceMapDecision, SourceMapKind, UploadStaging } from '../core/index'; +import type { Options, SourceMapDecision, SourceMapKind, UploadFilter, UploadStaging } from '../core/index'; import { createSentryBuildPluginManager, generateReleaseInjectorCode, @@ -220,6 +220,22 @@ function deleteSourceMapAssets(compilation: WebpackCompilation, sourceMapName: s } } +/** Drops the source maps of the JS assets that an upload would include. */ +function dropSourceMaps(compilation: WebpackCompilation, shouldUpload: UploadFilter): void { + const outputPath = compilation.outputOptions.path ?? path.resolve(); + + for (const asset of compilation.getAssets()) { + if (!isJsFile(asset.name) || !shouldUpload(path.join(outputPath, asset.name))) { + continue; + } + const relatedSourceMap = asset.info.related?.sourceMap; + const sourceMapName = typeof relatedSourceMap === 'string' ? relatedSourceMap : `${asset.name}.map`; + if (compilation.getAsset(sourceMapName)) { + deleteSourceMapAssets(compilation, sourceMapName); + } + } +} + /** * Copies each JS asset and its source map into the upload staging folder and drops the source map * assets (and their compressed variants) from the compilation, so webpack never emits them. @@ -419,18 +435,27 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) const { kind: sourceMapKind, decision: sourceMapDecision } = decideSourceMaps(compiler); const pendingStagings = new Map(); - if (sourceMapDecision.deleteAfterUpload) { - // After `RealContentHashPlugin` rewrote the hash placeholders, so the staged - // JS equals the emitted file and `[contenthash]` names are final. - const stage = (compiler.webpack?.Compilation?.PROCESS_ASSETS_STAGE_REPORT ?? 5000) + 1; + // After `RealContentHashPlugin` rewrote the hash placeholders, so the staged + // JS equals the emitted file and `[contenthash]` names are final. + const deleteStage = (compiler.webpack?.Compilation?.PROCESS_ASSETS_STAGE_REPORT ?? 5000) + 1; + if (sourceMapDecision.deleteAfterUpload && !sentryBuildPluginManager.sourceMaps.uploadPossible) { compiler.hooks.thisCompilation.tap('sentry-webpack-plugin', compilation => { - compilation.hooks.processAssets.tapPromise({ name: 'sentry-webpack-plugin', stage }, async () => { - const staging = await sentryBuildPluginManager.createUploadStaging(); - pendingStagings.set(compilation, staging); - await stageSourceMaps(compilation, staging, logger, sourceMapKind === 'unset'); + compilation.hooks.processAssets.tap({ name: 'sentry-webpack-plugin', stage: deleteStage }, () => { + dropSourceMaps(compilation, sentryBuildPluginManager.sourceMaps.shouldUpload); }); }); + } else if (sourceMapDecision.deleteAfterUpload) { + compiler.hooks.thisCompilation.tap('sentry-webpack-plugin', compilation => { + compilation.hooks.processAssets.tapPromise( + { name: 'sentry-webpack-plugin', stage: deleteStage }, + async () => { + const staging = await sentryBuildPluginManager.createUploadStaging(); + pendingStagings.set(compilation, staging); + await stageSourceMaps(compilation, staging, logger, sourceMapKind === 'unset'); + }, + ); + }); // A staging that `afterEmit` never took belongs to a compilation that // failed before emitting, or to one whose assets were emitted without diff --git a/packages/bundler-plugins/test/core/option-mappings.test.ts b/packages/bundler-plugins/test/core/option-mappings.test.ts index 754facc27e1e..1b853cf89b5e 100644 --- a/packages/bundler-plugins/test/core/option-mappings.test.ts +++ b/packages/bundler-plugins/test/core/option-mappings.test.ts @@ -351,24 +351,15 @@ describe('validateOptions', () => { expect(mockedLogger.error).not.toHaveBeenCalled(); }); - it('warns when `deleteSourcemapsAfterUpload` is set together with `sourcemaps.disable`', () => { - const options = normalizeUserOptions({ - sourcemaps: { disable: 'disable-upload', deleteSourcemapsAfterUpload: true }, - }); - - expect(validateOptions(options, mockedLogger)).toBe(true); - expect(mockedLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('deleteSourcemapsAfterUpload'), - expect.any(String), - ); - }); + it.each([undefined, 'disable-upload'] as const)( + 'does not warn when `deleteSourcemapsAfterUpload` is set and `sourcemaps.disable` is `%s`', + disable => { + const options = normalizeUserOptions({ sourcemaps: { disable, deleteSourcemapsAfterUpload: true } }); - it('does not warn when `deleteSourcemapsAfterUpload` is set and uploads are enabled', () => { - const options = normalizeUserOptions({ sourcemaps: { deleteSourcemapsAfterUpload: true } }); - - expect(validateOptions(options, mockedLogger)).toBe(true); - expect(mockedLogger.warn).not.toHaveBeenCalled(); - }); + expect(validateOptions(options, mockedLogger)).toBe(true); + expect(mockedLogger.warn).not.toHaveBeenCalled(); + }, + ); it('should return `true` for options without special cases', () => { const options = { diff --git a/packages/bundler-plugins/test/core/sourcemap-settings.test.ts b/packages/bundler-plugins/test/core/sourcemap-settings.test.ts index 4435fb98662e..795f0138fded 100644 --- a/packages/bundler-plugins/test/core/sourcemap-settings.test.ts +++ b/packages/bundler-plugins/test/core/sourcemap-settings.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest'; import { normalizeUserOptions } from '../../src/core/options-mapping'; -import type { Options } from '../../src/core'; +import type { InternalOptions } from '../../src/core'; import { decideSourceMapHandling } from '../../src/core/sourcemap-settings'; import type { SourceMapKind } from '../../src/core/sourcemap-settings'; -function options(sourcemaps?: Options['sourcemaps']) { +function options(sourcemaps?: InternalOptions['sourcemaps']) { return normalizeUserOptions({ org: 'o', project: 'p', authToken: 't', release: { name: 'r' }, sourcemaps }); } @@ -91,16 +91,71 @@ describe('decideSourceMapHandling', () => { }); }); - it.each(['unset', 'off', 'visible', 'hidden', 'inline'])( - 'does nothing for a `%s` setting when no upload can run', - kind => { - expect(decideSourceMapHandling(kind, options({ deleteSourcemapsAfterUpload: true }), false)).toEqual(NO_CHANGE); - }, - ); + describe('when no upload can run', () => { + it.each(['unset', 'off', 'visible', 'hidden', 'inline'])( + 'does nothing for a `%s` setting with the option unset', + kind => { + expect(decideSourceMapHandling(kind, options(), false)).toEqual(NO_CHANGE); + }, + ); + + it.each(['unset', 'off', 'inline'])( + 'does not turn source maps on for a `%s` setting with the option set to true', + kind => { + expect(decideSourceMapHandling(kind, options({ deleteSourcemapsAfterUpload: true }), false)).toEqual(NO_CHANGE); + }, + ); + + it('deletes hidden source maps with the option set to true', () => { + const decision = decideSourceMapHandling('hidden', options({ deleteSourcemapsAfterUpload: true }), false); + + expect(decision).toMatchObject({ enableHidden: false, deleteAfterUpload: true }); + expect(decision.log?.message).toMatch(/not uploaded/); + }); + + it('switches visible source maps to hidden and deletes them with the option set to true', () => { + expect(decideSourceMapHandling('visible', options({ deleteSourcemapsAfterUpload: true }), false)).toMatchObject({ + enableHidden: true, + deleteAfterUpload: true, + }); + }); - it.each([true, 'disable-upload'] as const)('does nothing when `sourcemaps.disable` is `%s`', disable => { - expect(decideSourceMapHandling('unset', options({ disable, deleteSourcemapsAfterUpload: true }), true)).toEqual( - NO_CHANGE, + it.each([true, 'disable-upload'] as const)( + 'deletes hidden source maps when `sourcemaps.disable` is `%s` and the option is true', + disable => { + expect( + decideSourceMapHandling('hidden', options({ disable, deleteSourcemapsAfterUpload: true }), true), + ).toMatchObject({ enableHidden: false, deleteAfterUpload: true }); + }, ); + + it.each([true, 'disable-upload'] as const)( + 'does not turn source maps on when `sourcemaps.disable` is `%s`', + disable => { + expect(decideSourceMapHandling('unset', options({ disable, deleteSourcemapsAfterUpload: true }), true)).toEqual( + NO_CHANGE, + ); + }, + ); + }); + + describe('with the internal function form', () => { + it('calls the function when it decides', () => { + let value: boolean | undefined; + const normalized = options({ deleteSourcemapsAfterUpload: () => value }); + + expect(decideSourceMapHandling('hidden', normalized, true)).toEqual(NO_CHANGE); + value = true; + expect(decideSourceMapHandling('hidden', normalized, true)).toEqual({ + enableHidden: false, + deleteAfterUpload: true, + }); + }); + + it('treats `undefined` from the function like an unset option', () => { + expect(decideSourceMapHandling('unset', options({ deleteSourcemapsAfterUpload: () => undefined }), true)).toEqual( + decideSourceMapHandling('unset', options(), true), + ); + }); }); }); diff --git a/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts index 75578262a922..7f962be5e3f4 100644 --- a/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts +++ b/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts @@ -197,6 +197,13 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (esbuild)', () => { expect(uploads).toHaveLength(0); }); + it('deletes external source maps when the option is true and no upload runs', async () => { + await build({ authToken: undefined, sourcemaps: { deleteSourcemapsAfterUpload: true } }, { sourcemap: 'external' }); + + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + expect(uploads).toHaveLength(0); + }); + it('leaves the source map setting alone and does not log when the build does not write', async () => { const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined); const result = await build({ silent: false }, { write: false }); diff --git a/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts index 0c28e0b13076..05dc68a79763 100644 --- a/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts +++ b/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts @@ -250,6 +250,55 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (rollup / rolldown)', () => { expect(mkdtempSpy).not.toHaveBeenCalled(); }); + it.each<[string, Options]>([ + ['without an auth token', { authToken: undefined }], + ['with "disable-upload"', { sourcemaps: { disable: 'disable-upload' } }], + ['with `disable: true`', { sourcemaps: { disable: true } }], + ])('removes hidden source maps when the option is true and no upload runs (%s)', async (_label, overrides) => { + const mkdtempSpy = vi.spyOn(fs.promises, 'mkdtemp'); + const build = await rollup({ + input: entry, + plugins: [ + sentryRollupPlugin({ + ...credentials, + ...overrides, + sourcemaps: { ...overrides.sourcemaps, deleteSourcemapsAfterUpload: true }, + }), + ], + }); + await build.write({ dir: outDir, sourcemap: 'hidden' }); + + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + expect(uploads).toHaveLength(0); + expect(mkdtempSpy).not.toHaveBeenCalled(); + }); + + it('keeps hidden source maps without an upload when the option is unset', async () => { + const build = await rollup({ + input: entry, + plugins: [sentryRollupPlugin({ ...credentials, authToken: undefined })], + }); + await build.write({ dir: outDir, sourcemap: 'hidden' }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + }); + + it('keeps the maps of chunks matched by `sourcemaps.ignore` when no upload runs', async () => { + const build = await rollup({ + input: entry, + plugins: [ + sentryRollupPlugin({ + ...credentials, + authToken: undefined, + sourcemaps: { ignore: ['**/entry.js'], deleteSourcemapsAfterUpload: true }, + }), + ], + }); + await build.write({ dir: outDir, sourcemap: 'hidden' }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + }); + it('returns no source maps from generate() and does not log or stage', async () => { const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined); const mkdtempSpy = vi.spyOn(fs.promises, 'mkdtemp'); diff --git a/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts index 8ab267e0d785..51496e105bb3 100644 --- a/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts +++ b/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts @@ -203,6 +203,37 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (vite)', () => { expectUploadedPairs(uploads[0]!, readChunks(outDir)); }); + it('removes hidden source maps when the option is true and no upload runs', async () => { + const config = viteConfig({ authToken: undefined, sourcemaps: { deleteSourcemapsAfterUpload: true } }); + await build({ ...config, build: { ...config.build, sourcemap: 'hidden' } }); + + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toEqual([]); + expect(uploads).toHaveLength(0); + }); + + it('reads the option from the internal function form when it decides', async () => { + let deleteMaps: boolean | undefined; + const sdkPlugin: Plugin = { + name: 'sdk', + config() { + deleteMaps = true; + return { build: { sourcemap: 'hidden' } }; + }, + }; + const config = viteConfig({}); + await build({ + ...config, + plugins: [ + sdkPlugin, + ...sentryVitePlugin({ ...credentials, sourcemaps: { deleteSourcemapsAfterUpload: () => deleteMaps } }), + ], + }); + + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toEqual([]); + expect(uploads).toHaveLength(1); + expectUploadedPairs(uploads[0]!, readChunks(outDir)); + }); + it('keeps hidden source maps the user enabled and uploads them from disk', async () => { const config = viteConfig({}); await build({ ...config, build: { ...config.build, sourcemap: 'hidden' } }); diff --git a/packages/bundler-plugins/test/webpack/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/webpack/delete-sourcemaps-after-upload.test.ts index 789f6d97d9d4..a01010890b81 100644 --- a/packages/bundler-plugins/test/webpack/delete-sourcemaps-after-upload.test.ts +++ b/packages/bundler-plugins/test/webpack/delete-sourcemaps-after-upload.test.ts @@ -248,6 +248,20 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (webpack)', () => { expect(mkdtempSpy).not.toHaveBeenCalled(); }); + it('removes hidden source maps when the option is true and no upload runs', async () => { + const mkdtempSpy = vi.spyOn(fs.promises, 'mkdtemp'); + await build( + config( + { authToken: undefined, sourcemaps: { deleteSourcemapsAfterUpload: true } }, + { devtool: 'hidden-source-map' }, + ), + ); + + expect(fs.readdirSync(outDir)).toEqual(['bundle.js']); + expect(uploads).toHaveLength(0); + expect(mkdtempSpy).not.toHaveBeenCalled(); + }); + it('stages the final content of content-hashed bundles', async () => { const output = { path: outDir, filename: '[name].[contenthash].js' }; await build(config({}, { output })); diff --git a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts index 130e38c086bc..0ecf62a86ef7 100644 --- a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts +++ b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts @@ -261,6 +261,9 @@ interface SourceMapsOptions { * `sourcemaps.assets` or `sourcemaps.ignore` keep out of the upload. Source maps you enabled yourself are only * removed for the JavaScript chunks that were uploaded; excluded chunks keep theirs. * + * `true` removes source maps you enabled yourself even when no upload runs, for example without an auth token or + * with `sourcemaps.disable`. The plugin does not turn source maps on when it cannot upload them. + * * With webpack's array form of `devtool`, `true` replaces the array with one hidden source map setting for * all asset types, because webpack hides the array from plugins. */ From 0a9fede83c115cf04126d1af9df822a17f5a8c67 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 12 Nov 2026 10:13:39 +0000 Subject: [PATCH 2/9] fix(bundler-plugins): Stamp esbuild debug IDs before deleting maps with `disable-upload` --- packages/bundler-plugins/src/esbuild/index.ts | 62 +++++++++---------- .../test/esbuild/disable-upload.test.ts | 25 ++++++++ 2 files changed, 56 insertions(+), 31 deletions(-) diff --git a/packages/bundler-plugins/src/esbuild/index.ts b/packages/bundler-plugins/src/esbuild/index.ts index 72b3e9699ebf..7853117d051b 100644 --- a/packages/bundler-plugins/src/esbuild/index.ts +++ b/packages/bundler-plugins/src/esbuild/index.ts @@ -333,37 +333,9 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any { : []; const { sourceMaps } = sentryBuildPluginManager; - if (sourceMapDecision.deleteAfterUpload && !sourceMaps.uploadPossible) { - const bundleOf = (map: string): string => map.slice(0, -'.map'.length); - await sourceMaps.deleteFromOutput( - buildArtifacts.filter( - file => file.endsWith('.map') && isJsFile(bundleOf(file)) && sourceMaps.shouldUpload(bundleOf(file)), - ), - ); - } else if (sourcemapsEnabled) { - if (options.sourcemaps?.disable !== 'disable-upload') { - // The upload globs the paths, and glob reads backslashes as escapes. - const { uploaded, uploadedSourceMapPaths } = await upload( - buildArtifacts.map(artifact => artifact.split(path.sep).join('/')), - ); - - // esbuild writes before `onEnd` runs, so the maps are deleted from disk - // afterwards instead of being dropped from the output like in other bundlers. - // A map the plugin turned on never stays in the output. A map the user - // enabled is only deleted once it was uploaded. - if (sourceMapDecision.deleteAfterUpload) { - const sourceMaps = - sourceMapKind === 'unset' - ? buildArtifacts.filter(artifact => artifact.endsWith('.map')) - : uploadedSourceMapPaths; - - if (uploaded) { - await sentryBuildPluginManager.sourceMaps.deleteFromOutput(sourceMaps); - } else if (sourceMaps.length > 0) { - logger.error('The source maps were not uploaded, so they stay in the build output.'); - } - } - } else if (initialOptions.write === false) { + const deleteWithoutUpload = sourceMapDecision.deleteAfterUpload && !sourceMaps.uploadPossible; + if (sourcemapsEnabled && options.sourcemaps?.disable === 'disable-upload') { + if (initialOptions.write === false) { logger.debug('Build output is not written to disk. Skipping debug ID injection into build artifacts.'); } else { // The upload routine (which stamps debug IDs into temp copies of the artifacts) is @@ -375,6 +347,34 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any { .map(bundle => addDebugIdToEmittedArtifacts(bundle, logger, options.sourcemaps?.resolveSourceMap)), ); } + } else if (sourcemapsEnabled && !deleteWithoutUpload) { + // The upload globs the paths, and glob reads backslashes as escapes. + const { uploaded, uploadedSourceMapPaths } = await upload( + buildArtifacts.map(artifact => artifact.split(path.sep).join('/')), + ); + + // esbuild writes before `onEnd` runs, so the maps are deleted from disk + // afterwards instead of being dropped from the output like in other bundlers. + // A map the plugin turned on never stays in the output. A map the user + // enabled is only deleted once it was uploaded. + if (sourceMapDecision.deleteAfterUpload) { + const mapsToDelete = + sourceMapKind === 'unset' + ? buildArtifacts.filter(artifact => artifact.endsWith('.map')) + : uploadedSourceMapPaths; + + if (uploaded) { + await sourceMaps.deleteFromOutput(mapsToDelete); + } else if (mapsToDelete.length > 0) { + logger.error('The source maps were not uploaded, so they stay in the build output.'); + } + } + } + + if (deleteWithoutUpload) { + const bundleOf = (map: string): string => map.slice(0, -'.map'.length); + const maps = buildArtifacts.filter(file => file.endsWith('.map') && isJsFile(bundleOf(file))); + await sourceMaps.deleteFromOutput(maps.filter(map => sourceMaps.shouldUpload(bundleOf(map)))); } } finally { freeGlobalDependencyOnBuildArtifacts(); diff --git a/packages/bundler-plugins/test/esbuild/disable-upload.test.ts b/packages/bundler-plugins/test/esbuild/disable-upload.test.ts index ca077832169e..6dc8253dc7e3 100644 --- a/packages/bundler-plugins/test/esbuild/disable-upload.test.ts +++ b/packages/bundler-plugins/test/esbuild/disable-upload.test.ts @@ -43,6 +43,31 @@ describe('sourcemaps.disable: "disable-upload"', () => { expect(bundle).toContain(`//# debugId=${debugId}`); }); + it('stamps the bundle before it deletes the source map with `deleteSourcemapsAfterUpload: true`', async () => { + const outDir = path.join(tmpDir, 'dist'); + + await esbuild.build({ + entryPoints: [entry], + bundle: true, + sourcemap: 'external', + outdir: outDir, + absWorkingDir: tmpDir, + plugins: [ + sentryEsbuildPlugin({ + telemetry: false, + sourcemaps: { disable: 'disable-upload', deleteSourcemapsAfterUpload: true }, + }), + ], + }); + + const bundle = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); + const debugId = bundle.match(DEBUG_ID_MARKER)?.[1]; + + expect(debugId).toBeDefined(); + expect(bundle).toContain(`//# debugId=${debugId}`); + expect(fs.existsSync(path.join(outDir, 'entry.js.map'))).toBe(false); + }); + it('does not stamp emitted source maps when uploading is enabled', async () => { const outDir = path.join(tmpDir, 'dist'); From 241a8f3284c0e8e5facd4285fb6648344f044c02 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 12 Nov 2026 11:40:25 +0000 Subject: [PATCH 3/9] feat(bundler-plugins): Remove all source maps the SDK turned on with `deleteSourcemapsAfterUpload` SDK packages turn on hidden source maps themselves, so the plugin read them as maps the user enabled and removed only the maps of uploaded chunks. Maps of chunks that `sourcemaps.assets` or `sourcemaps.ignore` leave out, and CSS maps, stayed in the build output. The internal option `sourcemaps.sdkEnabledSourceMaps` (a boolean or a function the plugin calls when it decides) tells the plugin that the SDK turned the maps on. With `deleteSourcemapsAfterUpload: true` the plugin then removes them like maps it turned on itself: all JS and CSS maps of the build, with or without an upload. Webpack, Rollup, Rolldown, Vite and esbuild read it. --- .../src/core/options-mapping.ts | 1 + .../src/core/sourcemap-settings.ts | 20 +++++- packages/bundler-plugins/src/core/types.ts | 13 +++- packages/bundler-plugins/src/esbuild/index.ts | 17 ++--- .../src/rollup/source-map-handling.ts | 32 +++++++-- packages/bundler-plugins/src/webpack/index.ts | 42 ++++++++---- .../test/core/sourcemap-settings.test.ts | 41 +++++++++++ .../delete-sourcemaps-after-upload.test.ts | 21 +++++- .../delete-sourcemaps-after-upload.test.ts | 68 ++++++++++++++++++- .../delete-sourcemaps-after-upload.test.ts | 33 ++++++++- .../delete-sourcemaps-after-upload.test.ts | 53 ++++++++++++++- 11 files changed, 301 insertions(+), 40 deletions(-) diff --git a/packages/bundler-plugins/src/core/options-mapping.ts b/packages/bundler-plugins/src/core/options-mapping.ts index 4dcd17c5c554..24dec2910628 100644 --- a/packages/bundler-plugins/src/core/options-mapping.ts +++ b/packages/bundler-plugins/src/core/options-mapping.ts @@ -31,6 +31,7 @@ export type NormalizedOptions = { resolveSourceMap?: ResolveSourceMapHook; filesToDeleteAfterUpload?: string | string[] | Promise; deleteSourcemapsAfterUpload?: boolean | (() => boolean | undefined); + sdkEnabledSourceMaps?: boolean | (() => boolean | undefined); } | undefined; release: { diff --git a/packages/bundler-plugins/src/core/sourcemap-settings.ts b/packages/bundler-plugins/src/core/sourcemap-settings.ts index e5bb143188d7..a0b6990d078f 100644 --- a/packages/bundler-plugins/src/core/sourcemap-settings.ts +++ b/packages/bundler-plugins/src/core/sourcemap-settings.ts @@ -16,6 +16,11 @@ export type SourceMapDecision = { enableHidden: boolean; /** Remove the source maps from the build output once they are uploaded. */ deleteAfterUpload: boolean; + /** + * With `deleteAfterUpload`, remove every source map of the build, also those of CSS and of chunks + * the upload leaves out. Set when the plugin or the SDK turned the source maps on. + */ + removeAllSourceMaps?: boolean; log?: { level: 'info' | 'warn'; message: string }; }; @@ -27,6 +32,12 @@ export function getDeleteSourcemapsAfterUpload(options: NormalizedOptions): bool return typeof value === 'function' ? value() : value; } +/** Reads the internal `sourcemaps.sdkEnabledSourceMaps` option, calling its function form if given. */ +export function getSdkEnabledSourceMaps(options: NormalizedOptions): boolean { + const value = options.sourcemaps?.sdkEnabledSourceMaps; + return (typeof value === 'function' ? value() : value) === true; +} + /** * Decides whether the plugin turns hidden source maps on and whether it removes the maps from the * build output after the upload. The plugin only turns maps on when an upload can run, and only @@ -38,6 +49,7 @@ export function decideSourceMapHandling( uploadPossible: boolean, ): SourceMapDecision { const deleteOption = getDeleteSourcemapsAfterUpload(options); + const sdkEnabledSourceMaps = getSdkEnabledSourceMaps(options); if (!uploadPossible || options.sourcemaps?.disable) { if (deleteOption !== true || (kind !== 'hidden' && kind !== 'visible')) { @@ -46,6 +58,7 @@ export function decideSourceMapHandling( return { enableHidden: kind === 'visible', deleteAfterUpload: true, + removeAllSourceMaps: sdkEnabledSourceMaps, log: { level: 'info', message: @@ -91,6 +104,7 @@ export function decideSourceMapHandling( return { enableHidden: true, deleteAfterUpload: true, + removeAllSourceMaps: true, log: { level: 'info', message: @@ -100,7 +114,10 @@ export function decideSourceMapHandling( }; case 'hidden': - return { enableHidden: false, deleteAfterUpload: deleteOption === true }; + if (deleteOption !== true) { + return NO_CHANGE; + } + return { enableHidden: false, deleteAfterUpload: true, removeAllSourceMaps: sdkEnabledSourceMaps }; case 'visible': if (deleteOption !== true) { @@ -109,6 +126,7 @@ export function decideSourceMapHandling( return { enableHidden: true, deleteAfterUpload: true, + removeAllSourceMaps: sdkEnabledSourceMaps, log: { level: 'info', message: diff --git a/packages/bundler-plugins/src/core/types.ts b/packages/bundler-plugins/src/core/types.ts index f8dd000ac961..c99851568fe1 100644 --- a/packages/bundler-plugins/src/core/types.ts +++ b/packages/bundler-plugins/src/core/types.ts @@ -675,12 +675,19 @@ export type HandleRecoverableErrorFn = (error: unknown, throwByDefault: boolean) /** * @internal For Sentry SDK packages only. It can change in any release. * - * Like `Options`, but `sourcemaps.deleteSourcemapsAfterUpload` can also be a function. The plugin - * calls it when it decides how to treat the bundler's source map setting, so an SDK can give a - * value that it only knows after it created the plugin. + * Like `Options`, but `sourcemaps.deleteSourcemapsAfterUpload` can also be a function, and + * `sourcemaps.sdkEnabledSourceMaps` is added. The plugin calls the functions when it decides how + * to treat the bundler's source map setting, so an SDK can give a value that it only knows after it + * created the plugin. */ export type InternalOptions = Omit & { sourcemaps?: Omit, 'deleteSourcemapsAfterUpload'> & { deleteSourcemapsAfterUpload?: boolean | (() => boolean | undefined); + /** + * The SDK turned on the bundler's source maps. With `deleteSourcemapsAfterUpload: true` the + * plugin then removes them like source maps it turned on itself: all of them, also those of CSS + * and of chunks that the upload leaves out. + */ + sdkEnabledSourceMaps?: boolean | (() => boolean | undefined); }; }; diff --git a/packages/bundler-plugins/src/esbuild/index.ts b/packages/bundler-plugins/src/esbuild/index.ts index 7853117d051b..5d6fa15d3f79 100644 --- a/packages/bundler-plugins/src/esbuild/index.ts +++ b/packages/bundler-plugins/src/esbuild/index.ts @@ -1,4 +1,4 @@ -import type { Options, SourceMapDecision, SourceMapKind } from '../core'; +import type { InternalOptions, SourceMapDecision, SourceMapKind } from '../core'; import { createSentryBuildPluginManager, generateReleaseInjectorCode, @@ -110,7 +110,7 @@ function getEsbuildMajorVersion(): string | undefined { const pluginName = 'sentry-esbuild-plugin'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function sentryEsbuildPlugin(userOptions: Options = {}): any { +export function sentryEsbuildPlugin(userOptions: InternalOptions = {}): any { const sentryBuildPluginManager = createSentryBuildPluginManager(userOptions, { loggerPrefix: userOptions._metaOptions?.loggerPrefixOverride ?? `[${pluginName}]`, buildTool: 'esbuild', @@ -333,6 +333,7 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any { : []; const { sourceMaps } = sentryBuildPluginManager; + const allMaps = buildArtifacts.filter(artifact => artifact.endsWith('.map')); const deleteWithoutUpload = sourceMapDecision.deleteAfterUpload && !sourceMaps.uploadPossible; if (sourcemapsEnabled && options.sourcemaps?.disable === 'disable-upload') { if (initialOptions.write === false) { @@ -358,10 +359,7 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any { // A map the plugin turned on never stays in the output. A map the user // enabled is only deleted once it was uploaded. if (sourceMapDecision.deleteAfterUpload) { - const mapsToDelete = - sourceMapKind === 'unset' - ? buildArtifacts.filter(artifact => artifact.endsWith('.map')) - : uploadedSourceMapPaths; + const mapsToDelete = sourceMapDecision.removeAllSourceMaps ? allMaps : uploadedSourceMapPaths; if (uploaded) { await sourceMaps.deleteFromOutput(mapsToDelete); @@ -373,8 +371,11 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any { if (deleteWithoutUpload) { const bundleOf = (map: string): string => map.slice(0, -'.map'.length); - const maps = buildArtifacts.filter(file => file.endsWith('.map') && isJsFile(bundleOf(file))); - await sourceMaps.deleteFromOutput(maps.filter(map => sourceMaps.shouldUpload(bundleOf(map)))); + const isUploaded = (map: string): boolean => + isJsFile(bundleOf(map)) && sourceMaps.shouldUpload(bundleOf(map)); + await sourceMaps.deleteFromOutput( + sourceMapDecision.removeAllSourceMaps ? allMaps : allMaps.filter(isUploaded), + ); } } finally { freeGlobalDependencyOnBuildArtifacts(); diff --git a/packages/bundler-plugins/src/rollup/source-map-handling.ts b/packages/bundler-plugins/src/rollup/source-map-handling.ts index 8fe46b1fa26b..20abb58ee439 100644 --- a/packages/bundler-plugins/src/rollup/source-map-handling.ts +++ b/packages/bundler-plugins/src/rollup/source-map-handling.ts @@ -174,6 +174,16 @@ export function createSourceMapHandling(sentryBuildPluginManager: SentryBuildPlu } } + // CSS maps are assets that other plugins emit next to their CSS asset. + function dropCssSourceMaps(bundle: OutputBundle): void { + for (const [fileName, output] of Object.entries(bundle)) { + if (output.type === 'asset' && fileName.endsWith('.css.map')) { + // oxlint-disable-next-line typescript/no-dynamic-delete -- Rollup drops an asset when its key is deleted from the bundle + delete bundle[fileName]; + } + } + } + return { outputOptions: rawOutputOptions => { const kind = sourceMapKindFromRollupSetting(rawOutputOptions.sourcemap); @@ -210,9 +220,17 @@ export function createSourceMapHandling(sentryBuildPluginManager: SentryBuildPlu return; } + const removeAll = record.decision.removeAllSourceMaps === true; + if (removeAll) { + dropCssSourceMaps(bundle); + } + if (!sentryBuildPluginManager.sourceMaps.uploadPossible) { - dropSourceMaps(bundle, fileName => - sentryBuildPluginManager.sourceMaps.shouldUpload(path.join(destination, fileName)), + dropSourceMaps( + bundle, + removeAll + ? undefined + : fileName => sentryBuildPluginManager.sourceMaps.shouldUpload(path.join(destination, fileName)), ); return; } @@ -238,16 +256,16 @@ export function createSourceMapHandling(sentryBuildPluginManager: SentryBuildPlu chunkPath: path.join(destination, output.fileName), }); - // A map the plugin turned on never reaches the output, whether or - // not the chunk is uploaded. A map the user enabled stays unless - // it was staged. - if (result === 'staged' || record.kind === 'unset') { + // A map the plugin or the SDK turned on never reaches the output, + // whether or not the chunk is uploaded. A map the user enabled + // stays unless it was staged. + if (result === 'staged' || removeAll) { // oxlint-disable-next-line typescript/no-dynamic-delete -- Rollup drops an asset when its key is deleted from the bundle delete bundle[mapFileName]; } if (result === 'skipped' || result === 'failed') { sentryBuildPluginManager.logger.warn( - record.kind === 'unset' + removeAll ? `Could not prepare ${output.fileName} for upload, so it is not uploaded and its source map ${mapFileName} was removed from the build output.` : `Could not prepare ${output.fileName} for upload, so its source map ${mapFileName} stays in the build output.`, ); diff --git a/packages/bundler-plugins/src/webpack/index.ts b/packages/bundler-plugins/src/webpack/index.ts index feffc9207753..4b60bfcfe64f 100644 --- a/packages/bundler-plugins/src/webpack/index.ts +++ b/packages/bundler-plugins/src/webpack/index.ts @@ -1,6 +1,13 @@ /* oxlint-disable max-lines */ -import type { Options, SourceMapDecision, SourceMapKind, UploadFilter, UploadStaging } from '../core/index'; +import type { + InternalOptions, + Options, + SourceMapDecision, + SourceMapKind, + UploadFilter, + UploadStaging, +} from '../core/index'; import { createSentryBuildPluginManager, generateReleaseInjectorCode, @@ -220,12 +227,15 @@ function deleteSourceMapAssets(compilation: WebpackCompilation, sourceMapName: s } } -/** Drops the source maps of the JS assets that an upload would include. */ -function dropSourceMaps(compilation: WebpackCompilation, shouldUpload: UploadFilter): void { +/** + * Drops the source maps of the JS assets that an upload would include. With `removeAll` it drops + * the source maps of all assets, CSS included. + */ +function dropSourceMaps(compilation: WebpackCompilation, shouldUpload: UploadFilter, removeAll: boolean): void { const outputPath = compilation.outputOptions.path ?? path.resolve(); for (const asset of compilation.getAssets()) { - if (!isJsFile(asset.name) || !shouldUpload(path.join(outputPath, asset.name))) { + if (!removeAll && (!isJsFile(asset.name) || !shouldUpload(path.join(outputPath, asset.name)))) { continue; } const relatedSourceMap = asset.info.related?.sourceMap; @@ -240,9 +250,9 @@ function dropSourceMaps(compilation: WebpackCompilation, shouldUpload: UploadFil * Copies each JS asset and its source map into the upload staging folder and drops the source map * assets (and their compressed variants) from the compilation, so webpack never emits them. * - * A map the plugin turned on (`pluginEnabledMaps`) never reaches the output, whether or not its - * asset is uploaded. That covers CSS maps, which `devtool` turns on along with the JS ones. A map - * the user enabled stays unless it was staged. + * A map the plugin or the SDK turned on (`pluginEnabledMaps`) never reaches the output, whether or + * not its asset is uploaded. That covers CSS maps, which `devtool` turns on along with the JS ones. + * A map the user enabled stays unless it was staged. */ async function stageSourceMaps( compilation: WebpackCompilation, @@ -287,7 +297,7 @@ async function stageSourceMaps( } } -function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) { +function createSentryWebpackPlugin(userOptions: SentryWebpackPluginInternalOptions = {}) { const sentryBuildPluginManager = createSentryBuildPluginManager(userOptions, { loggerPrefix: userOptions._metaOptions?.loggerPrefixOverride ?? '[sentry-webpack-plugin]', buildTool: 'webpack', @@ -432,7 +442,8 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) // One plugin instance can serve several compilers (`MultiCompiler`) and // a compiler several compilations (watch mode), so nothing is shared. - const { kind: sourceMapKind, decision: sourceMapDecision } = decideSourceMaps(compiler); + const { decision: sourceMapDecision } = decideSourceMaps(compiler); + const removeAllSourceMaps = sourceMapDecision.removeAllSourceMaps === true; const pendingStagings = new Map(); // After `RealContentHashPlugin` rewrote the hash placeholders, so the staged @@ -442,7 +453,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) if (sourceMapDecision.deleteAfterUpload && !sentryBuildPluginManager.sourceMaps.uploadPossible) { compiler.hooks.thisCompilation.tap('sentry-webpack-plugin', compilation => { compilation.hooks.processAssets.tap({ name: 'sentry-webpack-plugin', stage: deleteStage }, () => { - dropSourceMaps(compilation, sentryBuildPluginManager.sourceMaps.shouldUpload); + dropSourceMaps(compilation, sentryBuildPluginManager.sourceMaps.shouldUpload, removeAllSourceMaps); }); }); } else if (sourceMapDecision.deleteAfterUpload) { @@ -452,7 +463,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) async () => { const staging = await sentryBuildPluginManager.createUploadStaging(); pendingStagings.set(compilation, staging); - await stageSourceMaps(compilation, staging, logger, sourceMapKind === 'unset'); + await stageSourceMaps(compilation, staging, logger, removeAllSourceMaps); }, ); }); @@ -582,9 +593,14 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const sentryWebpackPlugin: (options?: SentryWebpackPluginOptions) => any = createSentryWebpackPlugin; +export const sentryWebpackPlugin: (options?: SentryWebpackPluginInternalOptions) => any = createSentryWebpackPlugin; + +/** @internal For Sentry SDK packages only. See `InternalOptions`. */ +export type SentryWebpackPluginInternalOptions = InternalOptions & WebpackExperiments; + +export type SentryWebpackPluginOptions = Options & WebpackExperiments; -export type SentryWebpackPluginOptions = Options & { +type WebpackExperiments = { _experiments?: Options['_experiments'] & { /** * If enabled, the webpack plugin will exit the build process after the build completes. diff --git a/packages/bundler-plugins/test/core/sourcemap-settings.test.ts b/packages/bundler-plugins/test/core/sourcemap-settings.test.ts index 795f0138fded..e3fc71f98cc4 100644 --- a/packages/bundler-plugins/test/core/sourcemap-settings.test.ts +++ b/packages/bundler-plugins/test/core/sourcemap-settings.test.ts @@ -52,6 +52,7 @@ describe('decideSourceMapHandling', () => { expect(decideSourceMapHandling('hidden', options({ deleteSourcemapsAfterUpload: true }), true)).toEqual({ enableHidden: false, deleteAfterUpload: true, + removeAllSourceMaps: false, }); }); @@ -139,6 +140,45 @@ describe('decideSourceMapHandling', () => { ); }); + describe('with source maps the SDK turned on', () => { + const sdkOptions = { deleteSourcemapsAfterUpload: true, sdkEnabledSourceMaps: true }; + + it.each(['hidden', 'visible'])('removes all `%s` source maps', kind => { + expect(decideSourceMapHandling(kind, options(sdkOptions), true)).toMatchObject({ + enableHidden: kind === 'visible', + deleteAfterUpload: true, + removeAllSourceMaps: true, + }); + }); + + it.each(['hidden', 'visible'])('removes all `%s` source maps when no upload can run', kind => { + expect(decideSourceMapHandling(kind, options(sdkOptions), false)).toMatchObject({ + enableHidden: kind === 'visible', + deleteAfterUpload: true, + removeAllSourceMaps: true, + }); + }); + + it.each(['unset', 'off', 'inline'])('does not turn `%s` source maps on without an upload', kind => { + expect(decideSourceMapHandling(kind, options(sdkOptions), false)).toEqual(NO_CHANGE); + }); + + it.each([undefined, false])('keeps them when `deleteSourcemapsAfterUpload` is %s', deleteSourcemapsAfterUpload => { + expect( + decideSourceMapHandling('hidden', options({ deleteSourcemapsAfterUpload, sdkEnabledSourceMaps: true }), true), + ).toEqual(NO_CHANGE); + }); + + it('calls the function form when it decides', () => { + let value: boolean | undefined; + const normalized = options({ deleteSourcemapsAfterUpload: true, sdkEnabledSourceMaps: () => value }); + + expect(decideSourceMapHandling('hidden', normalized, true)).toMatchObject({ removeAllSourceMaps: false }); + value = true; + expect(decideSourceMapHandling('hidden', normalized, true)).toMatchObject({ removeAllSourceMaps: true }); + }); + }); + describe('with the internal function form', () => { it('calls the function when it decides', () => { let value: boolean | undefined; @@ -149,6 +189,7 @@ describe('decideSourceMapHandling', () => { expect(decideSourceMapHandling('hidden', normalized, true)).toEqual({ enableHidden: false, deleteAfterUpload: true, + removeAllSourceMaps: false, }); }); diff --git a/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts index 7f962be5e3f4..789b47535406 100644 --- a/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts +++ b/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts @@ -5,7 +5,7 @@ import * as esbuild from 'esbuild'; import type { BuildOptions } from 'esbuild'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { sentryEsbuildPlugin } from '../../src/esbuild'; -import type { Options } from '../../src/core'; +import type { InternalOptions, Options } from '../../src/core'; const { mockSourcemapUpload } = vi.hoisted(() => ({ mockSourcemapUpload: vi.fn() })); @@ -58,7 +58,7 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (esbuild)', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - function build(pluginOptions: Options = {}, overrides: BuildOptions = {}) { + function build(pluginOptions: InternalOptions = {}, overrides: BuildOptions = {}) { return esbuild.build({ entryPoints: [entry], bundle: true, @@ -174,6 +174,23 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (esbuild)', () => { expect(uploads).toHaveLength(1); }); + it.each<[string, InternalOptions]>([ + ['after the upload', {}], + ['when no upload runs', { authToken: undefined }], + ])( + 'deletes every map the SDK turned on, also those of chunks and CSS that were not uploaded (%s)', + async (_, options) => { + await build( + { ...options, sourcemaps: { deleteSourcemapsAfterUpload: true, sdkEnabledSourceMaps: true } }, + { ...writeSplitBuild(), sourcemap: 'external' }, + ); + + const files = fs.readdirSync(outDir); + expect(files.filter(file => file.endsWith('.map'))).toEqual([]); + expect(files).toContain('entry.css'); + }, + ); + it('warns when source maps are turned off', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); await build({ silent: false }, { sourcemap: false }); diff --git a/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts index 05dc68a79763..371e4f0a2796 100644 --- a/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts +++ b/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts @@ -2,10 +2,11 @@ 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 { rolldown } from 'rolldown'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { sentryRollupPlugin } from '../../src/rollup'; -import type { Options } from '../../src/core'; +import { _createRollupPluginInternals, sentryRollupPlugin } from '../../src/rollup'; +import type { InternalOptions, Options } from '../../src/core'; const { mockSourcemapUpload, mockReleaseCreate } = vi.hoisted(() => ({ mockSourcemapUpload: vi.fn(), @@ -299,6 +300,69 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (rollup / rolldown)', () => { expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); }); + describe('with source maps the SDK turned on', () => { + // `sentryRollupPlugin` only takes the public options. + function sdkRollupPlugin(options: InternalOptions): Plugin[] { + const { main, post } = _createRollupPluginInternals({ ...credentials, ...options }, 'rollup'); + return (post ? [main, post] : [main]) as Plugin[]; + } + + const cssPlugin: Plugin = { + name: 'css', + generateBundle() { + this.emitFile({ type: 'asset', fileName: 'style.css', source: 'body { color: red; }\n' }); + this.emitFile({ type: 'asset', fileName: 'style.css.map', source: '{"version":3,"mappings":""}' }); + }, + }; + + const sdkSourcemaps = { deleteSourcemapsAfterUpload: true, sdkEnabledSourceMaps: true, ignore: ['**/vendor.js'] }; + + async function writeSdkBuild(options: InternalOptions): Promise { + const vendor = path.join(tmpDir, 'vendor.js'); + fs.writeFileSync(vendor, 'console.log("vendor");\n'); + const build = await rollup({ input: [entry, vendor], plugins: [...sdkRollupPlugin(options), cssPlugin] }); + await build.write({ dir: outDir, sourcemap: 'hidden' }); + } + + it('removes the maps of ignored chunks and of CSS after the upload', async () => { + await writeSdkBuild({ sourcemaps: sdkSourcemaps }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'style.css', 'vendor.js']); + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')); + }); + + it('removes all maps when no upload runs', async () => { + await writeSdkBuild({ authToken: undefined, sourcemaps: sdkSourcemaps }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'style.css', 'vendor.js']); + expect(uploads).toHaveLength(0); + }); + + it('reads the function form when it decides', async () => { + let sdkEnabled: boolean | undefined; + const plugins = sdkRollupPlugin({ sourcemaps: { ...sdkSourcemaps, sdkEnabledSourceMaps: () => sdkEnabled } }); + sdkEnabled = true; + const vendor = path.join(tmpDir, 'vendor.js'); + fs.writeFileSync(vendor, 'console.log("vendor");\n'); + const build = await rollup({ input: [entry, vendor], plugins: [...plugins, cssPlugin] }); + await build.write({ dir: outDir, sourcemap: 'hidden' }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'style.css', 'vendor.js']); + }); + + it('keeps the maps when `deleteSourcemapsAfterUpload` is not true', async () => { + await writeSdkBuild({ sourcemaps: { sdkEnabledSourceMaps: true } }); + + expect( + fs + .readdirSync(outDir) + .filter(file => file.endsWith('.map')) + .sort(), + ).toEqual(['entry.js.map', 'style.css.map', 'vendor.js.map']); + }); + }); + it('returns no source maps from generate() and does not log or stage', async () => { const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined); const mkdtempSpy = vi.spyOn(fs.promises, 'mkdtemp'); diff --git a/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts index 51496e105bb3..79a47cd268fe 100644 --- a/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts +++ b/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts @@ -5,7 +5,7 @@ import { build, createBuilder } from 'vite'; import type { Plugin, ResolvedConfig } from 'vite'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { sentryVitePlugin } from '../../src/vite'; -import type { Options } from '../../src/core'; +import type { InternalOptions, Options } from '../../src/core'; const { mockSourcemapUpload } = vi.hoisted(() => ({ mockSourcemapUpload: vi.fn() })); @@ -70,7 +70,7 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (vite)', () => { fs.rmSync(root, { recursive: true, force: true }); }); - function viteConfig(pluginOptions: Options, plugins: Plugin[] = []) { + function viteConfig(pluginOptions: InternalOptions, plugins: Plugin[] = []) { return { configFile: false as const, logLevel: 'silent' as const, @@ -234,6 +234,35 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (vite)', () => { expectUploadedPairs(uploads[0]!, readChunks(outDir)); }); + it('removes the maps of ignored chunks and of CSS when the SDK turned the maps on', async () => { + let sdkEnabled: boolean | undefined; + const sdkPlugin: Plugin = { + name: 'sdk', + config() { + sdkEnabled = true; + return { build: { sourcemap: 'hidden' } }; + }, + generateBundle() { + this.emitFile({ type: 'asset', fileName: 'assets/style.css.map', source: '{"version":3,"mappings":""}' }); + }, + }; + const config = viteConfig( + { + sourcemaps: { + deleteSourcemapsAfterUpload: true, + sdkEnabledSourceMaps: () => sdkEnabled, + ignore: ['**/lazy-*'], + }, + }, + [sdkPlugin], + ); + await build(config); + + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toEqual([]); + expect(uploads).toHaveLength(1); + expect(Object.keys(uploads[0]!.files).filter(file => file.endsWith('.js'))).toHaveLength(1); + }); + it('keeps hidden source maps the user enabled and uploads them from disk', async () => { const config = viteConfig({}); await build({ ...config, build: { ...config.build, sourcemap: 'hidden' } }); diff --git a/packages/bundler-plugins/test/webpack/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/webpack/delete-sourcemaps-after-upload.test.ts index a01010890b81..1982b1a34d9f 100644 --- a/packages/bundler-plugins/test/webpack/delete-sourcemaps-after-upload.test.ts +++ b/packages/bundler-plugins/test/webpack/delete-sourcemaps-after-upload.test.ts @@ -5,7 +5,7 @@ import { EntryPlugin, SourceMapDevToolPlugin, webpack } from 'webpack'; import type { Compiler, Configuration, Stats } from 'webpack'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { sentryWebpackPlugin } from '../../src/webpack/index'; -import type { Options } from '../../src/core'; +import type { InternalOptions, Options } from '../../src/core'; const { mockSourcemapUpload, mockReleaseCreate } = vi.hoisted(() => ({ mockSourcemapUpload: vi.fn(), @@ -98,7 +98,7 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (webpack)', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - function config(pluginOptions: Options = {}, overrides: Configuration = {}): Configuration { + function config(pluginOptions: InternalOptions = {}, overrides: Configuration = {}): Configuration { return { mode: 'production', context: tmpDir, @@ -458,6 +458,55 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (webpack)', () => { expect(uploads).toHaveLength(1); }); + describe('with source maps the SDK turned on', () => { + const sdkSourcemaps = { deleteSourcemapsAfterUpload: true, sdkEnabledSourceMaps: true }; + + function sdkConfig(pluginOptions: InternalOptions): Configuration { + fs.writeFileSync(path.join(tmpDir, 'style.css'), 'body { color: red; }\n'); + fs.writeFileSync(path.join(tmpDir, 'entry.js'), "import './style.css';\nconsole.log(42);\n"); + fs.writeFileSync(path.join(tmpDir, 'vendor.js'), 'console.log("vendor");\n'); + return config(pluginOptions, { + devtool: 'hidden-source-map', + experiments: { css: true }, + entry: { bundle: './entry.js', vendor: './vendor.js' }, + output: { path: outDir, filename: '[name].js' }, + }); + } + + it('removes the maps of ignored chunks and of CSS after the upload', async () => { + await build(sdkConfig({ sourcemaps: { ...sdkSourcemaps, ignore: ['**/vendor.js'] } })); + + const files = fs.readdirSync(outDir); + expect(files.filter(file => file.endsWith('.map'))).toEqual([]); + expect(files.filter(file => file.endsWith('.css'))).toHaveLength(1); + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, fs.readFileSync(path.join(outDir, 'bundle.js'), 'utf8')); + expect(Object.keys(uploads[0]!.files)).toHaveLength(2); + }); + + it('removes all maps when no upload runs', async () => { + const mkdtempSpy = vi.spyOn(fs.promises, 'mkdtemp'); + await build(sdkConfig({ authToken: undefined, sourcemaps: { ...sdkSourcemaps, ignore: ['**/vendor.js'] } })); + + const files = fs.readdirSync(outDir); + expect(files.filter(file => file.endsWith('.map'))).toEqual([]); + expect(files.filter(file => file.endsWith('.css'))).toHaveLength(1); + expect(uploads).toHaveLength(0); + expect(mkdtempSpy).not.toHaveBeenCalled(); + }); + + it('keeps the maps when `deleteSourcemapsAfterUpload` is not true', async () => { + await build(sdkConfig({ sourcemaps: { sdkEnabledSourceMaps: true } })); + + expect( + fs + .readdirSync(outDir) + .filter(file => file.endsWith('.map')) + .sort(), + ).toEqual(['bundle.css.map', 'bundle.js.map', 'vendor.js.map']); + }); + }); + it('treats eval without source maps as turned off', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); await build(config({ silent: false }, { devtool: 'eval' })); From 562347ee2a025b522c3dc0afa996d04f6d7be6ed Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 12 Nov 2026 11:53:08 +0000 Subject: [PATCH 4/9] feat(bundler-plugins): Pass the Vite environment to the internal function options --- .../src/core/build-plugin-manager.ts | 6 ++-- .../src/core/options-mapping.ts | 4 +-- .../src/core/sourcemap-settings.ts | 13 +++++---- packages/bundler-plugins/src/core/types.ts | 7 +++-- .../src/vite/source-map-hooks.ts | 11 ++++++-- .../test/core/sourcemap-settings.test.ts | 14 ++++++++++ .../delete-sourcemaps-after-upload.test.ts | 28 +++++++++++++++++++ 7 files changed, 66 insertions(+), 17 deletions(-) diff --git a/packages/bundler-plugins/src/core/build-plugin-manager.ts b/packages/bundler-plugins/src/core/build-plugin-manager.ts index 17ec9018f7d4..d5b7317e876b 100644 --- a/packages/bundler-plugins/src/core/build-plugin-manager.ts +++ b/packages/bundler-plugins/src/core/build-plugin-manager.ts @@ -153,7 +153,7 @@ export type SentryBuildPluginManager = { * Decides how the plugin treats the user's source map setting (see `decideSourceMapHandling`). * Logs the decision's message once per message, unless `log` is `false`. */ - decide(kind: SourceMapKind, options?: { log?: boolean }): SourceMapDecision; + decide(kind: SourceMapKind, options?: { log?: boolean; environment?: string }): SourceMapDecision; /** Logs the decision's message, once per message. */ logDecision(decision: SourceMapDecision): void; /** @@ -970,8 +970,8 @@ export function createSentryBuildPluginManager( sourceMaps: { uploadPossible: sourceMapUploadPossible, - decide(kind, { log = true } = {}) { - const decision = decideSourceMapHandling(kind, options, sourceMapUploadPossible); + decide(kind, { log = true, environment } = {}) { + const decision = decideSourceMapHandling(kind, options, sourceMapUploadPossible, environment); if (log) { logDecision(decision); } diff --git a/packages/bundler-plugins/src/core/options-mapping.ts b/packages/bundler-plugins/src/core/options-mapping.ts index 24dec2910628..a811edf748db 100644 --- a/packages/bundler-plugins/src/core/options-mapping.ts +++ b/packages/bundler-plugins/src/core/options-mapping.ts @@ -30,8 +30,8 @@ export type NormalizedOptions = { rewriteSources?: RewriteSourcesHook; resolveSourceMap?: ResolveSourceMapHook; filesToDeleteAfterUpload?: string | string[] | Promise; - deleteSourcemapsAfterUpload?: boolean | (() => boolean | undefined); - sdkEnabledSourceMaps?: boolean | (() => boolean | undefined); + deleteSourcemapsAfterUpload?: boolean | ((environment?: string) => boolean | undefined); + sdkEnabledSourceMaps?: boolean | ((environment?: string) => boolean | undefined); } | undefined; release: { diff --git a/packages/bundler-plugins/src/core/sourcemap-settings.ts b/packages/bundler-plugins/src/core/sourcemap-settings.ts index a0b6990d078f..65df33cbc96c 100644 --- a/packages/bundler-plugins/src/core/sourcemap-settings.ts +++ b/packages/bundler-plugins/src/core/sourcemap-settings.ts @@ -27,15 +27,15 @@ export type SourceMapDecision = { const NO_CHANGE: SourceMapDecision = { enableHidden: false, deleteAfterUpload: false }; /** Reads `sourcemaps.deleteSourcemapsAfterUpload`, calling the internal function form if given. */ -export function getDeleteSourcemapsAfterUpload(options: NormalizedOptions): boolean | undefined { +export function getDeleteSourcemapsAfterUpload(options: NormalizedOptions, environment?: string): boolean | undefined { const value = options.sourcemaps?.deleteSourcemapsAfterUpload; - return typeof value === 'function' ? value() : value; + return typeof value === 'function' ? value(environment) : value; } /** Reads the internal `sourcemaps.sdkEnabledSourceMaps` option, calling its function form if given. */ -export function getSdkEnabledSourceMaps(options: NormalizedOptions): boolean { +export function getSdkEnabledSourceMaps(options: NormalizedOptions, environment?: string): boolean { const value = options.sourcemaps?.sdkEnabledSourceMaps; - return (typeof value === 'function' ? value() : value) === true; + return (typeof value === 'function' ? value(environment) : value) === true; } /** @@ -47,9 +47,10 @@ export function decideSourceMapHandling( kind: SourceMapKind, options: NormalizedOptions, uploadPossible: boolean, + environment?: string, ): SourceMapDecision { - const deleteOption = getDeleteSourcemapsAfterUpload(options); - const sdkEnabledSourceMaps = getSdkEnabledSourceMaps(options); + const deleteOption = getDeleteSourcemapsAfterUpload(options, environment); + const sdkEnabledSourceMaps = getSdkEnabledSourceMaps(options, environment); if (!uploadPossible || options.sourcemaps?.disable) { if (deleteOption !== true || (kind !== 'hidden' && kind !== 'visible')) { diff --git a/packages/bundler-plugins/src/core/types.ts b/packages/bundler-plugins/src/core/types.ts index c99851568fe1..daf2f9068c2c 100644 --- a/packages/bundler-plugins/src/core/types.ts +++ b/packages/bundler-plugins/src/core/types.ts @@ -678,16 +678,17 @@ export type HandleRecoverableErrorFn = (error: unknown, throwByDefault: boolean) * Like `Options`, but `sourcemaps.deleteSourcemapsAfterUpload` can also be a function, and * `sourcemaps.sdkEnabledSourceMaps` is added. The plugin calls the functions when it decides how * to treat the bundler's source map setting, so an SDK can give a value that it only knows after it - * created the plugin. + * created the plugin. Vite passes the name of an environment with its own `build.sourcemap`, and + * `undefined` for the top-level `build.sourcemap`. */ export type InternalOptions = Omit & { sourcemaps?: Omit, 'deleteSourcemapsAfterUpload'> & { - deleteSourcemapsAfterUpload?: boolean | (() => boolean | undefined); + deleteSourcemapsAfterUpload?: boolean | ((environment?: string) => boolean | undefined); /** * The SDK turned on the bundler's source maps. With `deleteSourcemapsAfterUpload: true` the * plugin then removes them like source maps it turned on itself: all of them, also those of CSS * and of chunks that the upload leaves out. */ - sdkEnabledSourceMaps?: boolean | (() => boolean | undefined); + sdkEnabledSourceMaps?: boolean | ((environment?: string) => boolean | undefined); }; }; diff --git a/packages/bundler-plugins/src/vite/source-map-hooks.ts b/packages/bundler-plugins/src/vite/source-map-hooks.ts index c71705338c33..5b431bf26377 100644 --- a/packages/bundler-plugins/src/vite/source-map-hooks.ts +++ b/packages/bundler-plugins/src/vite/source-map-hooks.ts @@ -97,7 +97,10 @@ export function createViteSourceMapHooks( } const environmentKind = sourceMapKindFromRollupSetting(setting); - const environmentDecision = sentryBuildPluginManager.sourceMaps.decide(environmentKind, { log: false }); + const environmentDecision = sentryBuildPluginManager.sourceMaps.decide(environmentKind, { + log: false, + environment: name, + }); decisions.set(name, { kind: environmentKind, decision: environmentDecision }); if (environmentDecision.enableHidden) { @@ -111,10 +114,12 @@ export function createViteSourceMapHooks( configResolved: config => { const environments = config.environments ?? { client: { build: config.build } }; - const deleteOption = getDeleteSourcemapsAfterUpload(sentryBuildPluginManager.normalizedOptions); - for (const [name, environment] of Object.entries(environments)) { const { kind, decision } = decisions.get(name) ?? decisions.get('*') ?? { kind: 'unset', decision: NO_CHANGE }; + const deleteOption = getDeleteSourcemapsAfterUpload( + sentryBuildPluginManager.normalizedOptions, + decisions.has(name) ? name : undefined, + ); for (const output of getOutputs(config.root, environment.build)) { // The plugin never changes an output's own setting, so the user enabled diff --git a/packages/bundler-plugins/test/core/sourcemap-settings.test.ts b/packages/bundler-plugins/test/core/sourcemap-settings.test.ts index e3fc71f98cc4..68ec2f792bd1 100644 --- a/packages/bundler-plugins/test/core/sourcemap-settings.test.ts +++ b/packages/bundler-plugins/test/core/sourcemap-settings.test.ts @@ -193,6 +193,20 @@ describe('decideSourceMapHandling', () => { }); }); + it('passes the environment to both functions', () => { + const normalized = options({ + deleteSourcemapsAfterUpload: environment => environment === 'client', + sdkEnabledSourceMaps: environment => environment === 'client', + }); + + expect(decideSourceMapHandling('hidden', normalized, true, 'client')).toMatchObject({ + deleteAfterUpload: true, + removeAllSourceMaps: true, + }); + expect(decideSourceMapHandling('hidden', normalized, true, 'nitro')).toEqual(NO_CHANGE); + expect(decideSourceMapHandling('hidden', normalized, true)).toEqual(NO_CHANGE); + }); + it('treats `undefined` from the function like an unset option', () => { expect(decideSourceMapHandling('unset', options({ deleteSourcemapsAfterUpload: () => undefined }), true)).toEqual( decideSourceMapHandling('unset', options(), true), diff --git a/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts index 79a47cd268fe..33327200402b 100644 --- a/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts +++ b/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts @@ -388,6 +388,34 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (vite)', () => { expect(uploads).toHaveLength(2); }); + it('asks the function form per environment with its own setting', async () => { + const serverOutDir = path.join(root, 'dist-server'); + const askedFor: Array = []; + const builder = await createBuilder({ + ...viteConfig({ + sourcemaps: { + deleteSourcemapsAfterUpload: environment => { + askedFor.push(environment); + return environment === undefined ? true : undefined; + }, + }, + }), + build: { outDir, minify: false, sourcemap: 'hidden', rollupOptions: { input: entry } }, + environments: { + ssr: { + build: { outDir: serverOutDir, minify: false, sourcemap: 'hidden', rollupOptions: { input: entry } }, + }, + }, + builder: {}, + }); + await builder.buildApp(); + + expect(askedFor).toContain(undefined); + expect(askedFor).toContain('ssr'); + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toEqual([]); + expect(listFiles(serverOutDir).filter(file => file.endsWith('.map'))).toHaveLength(2); + }); + it('still restores debug IDs for "disable-upload" builds', async () => { const config = viteConfig({ sourcemaps: { disable: 'disable-upload' } }); await build({ ...config, build: { ...config.build, sourcemap: true } }); From dc41ecdc737e4ed564bb29c6d8145d3ea848493f Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 12 Nov 2026 21:17:42 +0000 Subject: [PATCH 5/9] feat(bundler-plugins): Remove maps of an output's own setting when the SDK turned source maps on With `deleteSourcemapsAfterUpload: true` and `sdkEnabledSourceMaps`, the Vite plugin now also removes the maps of an output that sets `sourcemap` itself, visible ones included. The SDKs' old globs removed these maps when `build.sourcemap` was unset, so the SDKs keep that behaviour. --- packages/bundler-plugins/src/core/index.ts | 2 +- .../src/vite/source-map-hooks.ts | 22 +++++++++------- .../delete-sourcemaps-after-upload.test.ts | 25 +++++++++++++++++++ 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts index 017def78b302..a784e7ded36a 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -112,6 +112,6 @@ export { export { createSentryBuildPluginManager } from './build-plugin-manager'; export type { SentryBuildPluginManager, UploadResult, UploadStaging } from './build-plugin-manager'; export { createDebugIdUploadFunction, addDebugIdToEmittedArtifacts, stampDebugId } from './debug-id-upload'; -export { decideSourceMapHandling, getDeleteSourcemapsAfterUpload } from './sourcemap-settings'; +export { decideSourceMapHandling, getDeleteSourcemapsAfterUpload, getSdkEnabledSourceMaps } from './sourcemap-settings'; export type { SourceMapDecision, SourceMapKind } from './sourcemap-settings'; export type { UploadFilter } from './upload-filter'; diff --git a/packages/bundler-plugins/src/vite/source-map-hooks.ts b/packages/bundler-plugins/src/vite/source-map-hooks.ts index 5b431bf26377..0295dae0079b 100644 --- a/packages/bundler-plugins/src/vite/source-map-hooks.ts +++ b/packages/bundler-plugins/src/vite/source-map-hooks.ts @@ -1,6 +1,6 @@ import * as path from 'node:path'; import type { SentryBuildPluginManager, SourceMapDecision, SourceMapKind } from '../core'; -import { getDeleteSourcemapsAfterUpload } from '../core'; +import { getDeleteSourcemapsAfterUpload, getSdkEnabledSourceMaps } from '../core'; import type { OutputMatch, RollupSourceMapSetting, SourceMapHandling } from '../rollup/source-map-handling'; import { getOutputMatch, sourceMapKindFromRollupSetting } from '../rollup/source-map-handling'; @@ -116,21 +116,25 @@ export function createViteSourceMapHooks( for (const [name, environment] of Object.entries(environments)) { const { kind, decision } = decisions.get(name) ?? decisions.get('*') ?? { kind: 'unset', decision: NO_CHANGE }; - const deleteOption = getDeleteSourcemapsAfterUpload( - sentryBuildPluginManager.normalizedOptions, - decisions.has(name) ? name : undefined, - ); + const environmentName = decisions.has(name) ? name : undefined; + const { normalizedOptions } = sentryBuildPluginManager; + const deleteOption = getDeleteSourcemapsAfterUpload(normalizedOptions, environmentName); + const sdkEnabledSourceMaps = getSdkEnabledSourceMaps(normalizedOptions, environmentName); for (const output of getOutputs(config.root, environment.build)) { - // The plugin never changes an output's own setting, so the user enabled - // these maps: only hidden ones the user asked to delete are removed. + // The plugin never changes an output's own setting. Hidden maps are + // removed when the user asks for it, visible ones only when the SDK + // turned source maps on. if (output.ownSetting) { + const outputKind = sourceMapKindFromRollupSetting(output.sourcemap); + const removeAllSourceMaps = sdkEnabledSourceMaps && (outputKind === 'hidden' || outputKind === 'visible'); sourceMapHandling.setDecision(output.destination, { match: output.match, - kind: sourceMapKindFromRollupSetting(output.sourcemap), + kind: outputKind, decision: { enableHidden: false, - deleteAfterUpload: deleteOption === true && output.sourcemap === 'hidden', + deleteAfterUpload: deleteOption === true && (outputKind === 'hidden' || removeAllSourceMaps), + removeAllSourceMaps, }, }); continue; diff --git a/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts index 33327200402b..5a154ca8231a 100644 --- a/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts +++ b/packages/bundler-plugins/test/vite/delete-sourcemaps-after-upload.test.ts @@ -203,6 +203,31 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (vite)', () => { expectUploadedPairs(uploads[0]!, readChunks(outDir)); }); + it('keeps visible source maps an output enables when the option is true', async () => { + const config = viteConfig({ sourcemaps: { deleteSourcemapsAfterUpload: true } }); + await build({ + ...config, + build: { ...config.build, rollupOptions: { input: entry, output: { sourcemap: true } } }, + }); + + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toHaveLength(2); + }); + + it.each([true, 'hidden'] as const)( + 'removes `%s` source maps an output enables when the SDK turned source maps on', + async sourcemap => { + const config = viteConfig({ sourcemaps: { deleteSourcemapsAfterUpload: true, sdkEnabledSourceMaps: true } }); + await build({ + ...config, + build: { ...config.build, rollupOptions: { input: entry, output: { sourcemap } } }, + }); + + expect(listFiles(outDir).filter(file => file.endsWith('.map'))).toEqual([]); + expect(uploads).toHaveLength(1); + expectUploadedPairs(uploads[0]!, readChunks(outDir)); + }, + ); + it('removes hidden source maps when the option is true and no upload runs', async () => { const config = viteConfig({ authToken: undefined, sourcemaps: { deleteSourcemapsAfterUpload: true } }); await build({ ...config, build: { ...config.build, sourcemap: 'hidden' } }); From 48ab2b31a9ce3d399ba4d8f74dc7dae7df843588 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 13 Nov 2026 02:34:08 +0000 Subject: [PATCH 6/9] fix(bundler-plugins): Remove asset source maps the SDK turned on and keep maps in development With removeAllSourceMaps, Rollup and Vite now also drop maps that other plugins emit as assets, such as Vite worker bundle maps, but only when the SDK turned the maps on. With an unset setting, the plugin keeps asset maps, as it did before. In development mode the plugin no longer changes or deletes source maps, and esbuild logs again why no upload ran. --- .../src/core/build-plugin-manager.ts | 5 ++- packages/bundler-plugins/src/esbuild/index.ts | 5 ++- .../src/rollup/source-map-handling.ts | 19 ++++++--- .../delete-sourcemaps-after-upload.test.ts | 16 +++++++- .../delete-sourcemaps-after-upload.test.ts | 40 ++++++++++++++----- 5 files changed, 67 insertions(+), 18 deletions(-) diff --git a/packages/bundler-plugins/src/core/build-plugin-manager.ts b/packages/bundler-plugins/src/core/build-plugin-manager.ts index d5b7317e876b..a05321740aa5 100644 --- a/packages/bundler-plugins/src/core/build-plugin-manager.ts +++ b/packages/bundler-plugins/src/core/build-plugin-manager.ts @@ -971,7 +971,10 @@ export function createSentryBuildPluginManager( sourceMaps: { uploadPossible: sourceMapUploadPossible, decide(kind, { log = true, environment } = {}) { - const decision = decideSourceMapHandling(kind, options, sourceMapUploadPossible, environment); + // Watch builds in development keep their source maps. + const decision = isDevMode + ? { enableHidden: false, deleteAfterUpload: false } + : decideSourceMapHandling(kind, options, sourceMapUploadPossible, environment); if (log) { logDecision(decision); } diff --git a/packages/bundler-plugins/src/esbuild/index.ts b/packages/bundler-plugins/src/esbuild/index.ts index 5d6fa15d3f79..75e9f01213df 100644 --- a/packages/bundler-plugins/src/esbuild/index.ts +++ b/packages/bundler-plugins/src/esbuild/index.ts @@ -348,8 +348,9 @@ export function sentryEsbuildPlugin(userOptions: InternalOptions = {}): any { .map(bundle => addDebugIdToEmittedArtifacts(bundle, logger, options.sourcemaps?.resolveSourceMap)), ); } - } else if (sourcemapsEnabled && !deleteWithoutUpload) { + } else if (sourcemapsEnabled) { // The upload globs the paths, and glob reads backslashes as escapes. + // When no upload can run, this logs why. const { uploaded, uploadedSourceMapPaths } = await upload( buildArtifacts.map(artifact => artifact.split(path.sep).join('/')), ); @@ -358,7 +359,7 @@ export function sentryEsbuildPlugin(userOptions: InternalOptions = {}): any { // afterwards instead of being dropped from the output like in other bundlers. // A map the plugin turned on never stays in the output. A map the user // enabled is only deleted once it was uploaded. - if (sourceMapDecision.deleteAfterUpload) { + if (sourceMapDecision.deleteAfterUpload && !deleteWithoutUpload) { const mapsToDelete = sourceMapDecision.removeAllSourceMaps ? allMaps : uploadedSourceMapPaths; if (uploaded) { diff --git a/packages/bundler-plugins/src/rollup/source-map-handling.ts b/packages/bundler-plugins/src/rollup/source-map-handling.ts index 20abb58ee439..552b01950ee9 100644 --- a/packages/bundler-plugins/src/rollup/source-map-handling.ts +++ b/packages/bundler-plugins/src/rollup/source-map-handling.ts @@ -174,10 +174,17 @@ export function createSourceMapHandling(sentryBuildPluginManager: SentryBuildPlu } } - // CSS maps are assets that other plugins emit next to their CSS asset. - function dropCssSourceMaps(bundle: OutputBundle): void { + // Other plugins emit maps as assets, e.g. for CSS or for Vite worker + // bundles. Chunk maps stay here, because staging still needs them. + function dropAssetSourceMaps(bundle: OutputBundle): void { + const chunkMaps = new Set(); + for (const output of Object.values(bundle)) { + if (output.type === 'chunk') { + chunkMaps.add(getSourceMapFileName(output)); + } + } for (const [fileName, output] of Object.entries(bundle)) { - if (output.type === 'asset' && fileName.endsWith('.css.map')) { + if (output.type === 'asset' && fileName.endsWith('.map') && !chunkMaps.has(fileName)) { // oxlint-disable-next-line typescript/no-dynamic-delete -- Rollup drops an asset when its key is deleted from the bundle delete bundle[fileName]; } @@ -221,8 +228,10 @@ export function createSourceMapHandling(sentryBuildPluginManager: SentryBuildPlu } const removeAll = record.decision.removeAllSourceMaps === true; - if (removeAll) { - dropCssSourceMaps(bundle); + // With `unset` the plugin only turned on the chunk maps. Asset maps + // come from other plugins' own settings. + if (removeAll && record.kind !== 'unset') { + dropAssetSourceMaps(bundle); } if (!sentryBuildPluginManager.sourceMaps.uploadPossible) { diff --git a/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts index 789b47535406..c82bd9a91bd0 100644 --- a/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts +++ b/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts @@ -215,10 +215,24 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (esbuild)', () => { }); it('deletes external source maps when the option is true and no upload runs', async () => { - await build({ authToken: undefined, sourcemaps: { deleteSourcemapsAfterUpload: true } }, { sourcemap: 'external' }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await build( + { silent: false, authToken: undefined, sourcemaps: { deleteSourcemapsAfterUpload: true } }, + { sourcemap: 'external' }, + ); expect(fs.readdirSync(outDir)).toEqual(['entry.js']); expect(uploads).toHaveLength(0); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No auth token provided')); + }); + + it('keeps the source maps in development mode when the option is true', async () => { + vi.stubEnv('NODE_ENV', 'development'); + await build({ sourcemaps: { deleteSourcemapsAfterUpload: true } }, { sourcemap: 'external' }); + vi.unstubAllEnvs(); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + expect(uploads).toHaveLength(0); }); it('leaves the source map setting alone and does not log when the build does not write', async () => { diff --git a/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts index 371e4f0a2796..404c27379bef 100644 --- a/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts +++ b/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts @@ -300,6 +300,25 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (rollup / rolldown)', () => { expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); }); + it('keeps the maps other plugins emit as assets when the setting is unset', async () => { + const build = await rollup({ + input: entry, + plugins: [ + sentryRollupPlugin(credentials), + { + name: 'css', + generateBundle() { + this.emitFile({ type: 'asset', fileName: 'style.css', source: 'body { color: red; }\n' }); + this.emitFile({ type: 'asset', fileName: 'style.css.map', source: '{"version":3,"mappings":""}' }); + }, + }, + ], + }); + await build.write({ dir: outDir }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'style.css', 'style.css.map']); + }); + describe('with source maps the SDK turned on', () => { // `sentryRollupPlugin` only takes the public options. function sdkRollupPlugin(options: InternalOptions): Plugin[] { @@ -307,11 +326,14 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (rollup / rolldown)', () => { return (post ? [main, post] : [main]) as Plugin[]; } - const cssPlugin: Plugin = { - name: 'css', + // Vite emits worker bundles and their maps as assets. + const assetMapsPlugin: Plugin = { + name: 'asset-maps', generateBundle() { this.emitFile({ type: 'asset', fileName: 'style.css', source: 'body { color: red; }\n' }); this.emitFile({ type: 'asset', fileName: 'style.css.map', source: '{"version":3,"mappings":""}' }); + this.emitFile({ type: 'asset', fileName: 'worker.js', source: 'self.onmessage = () => {};\n' }); + this.emitFile({ type: 'asset', fileName: 'worker.js.map', source: '{"version":3,"mappings":""}' }); }, }; @@ -320,14 +342,14 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (rollup / rolldown)', () => { async function writeSdkBuild(options: InternalOptions): Promise { const vendor = path.join(tmpDir, 'vendor.js'); fs.writeFileSync(vendor, 'console.log("vendor");\n'); - const build = await rollup({ input: [entry, vendor], plugins: [...sdkRollupPlugin(options), cssPlugin] }); + const build = await rollup({ input: [entry, vendor], plugins: [...sdkRollupPlugin(options), assetMapsPlugin] }); await build.write({ dir: outDir, sourcemap: 'hidden' }); } - it('removes the maps of ignored chunks and of CSS after the upload', async () => { + it('removes the maps of ignored chunks and of assets after the upload', async () => { await writeSdkBuild({ sourcemaps: sdkSourcemaps }); - expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'style.css', 'vendor.js']); + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'style.css', 'vendor.js', 'worker.js']); expect(uploads).toHaveLength(1); expectUploadedPair(uploads[0]!, fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')); }); @@ -335,7 +357,7 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (rollup / rolldown)', () => { it('removes all maps when no upload runs', async () => { await writeSdkBuild({ authToken: undefined, sourcemaps: sdkSourcemaps }); - expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'style.css', 'vendor.js']); + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'style.css', 'vendor.js', 'worker.js']); expect(uploads).toHaveLength(0); }); @@ -345,10 +367,10 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (rollup / rolldown)', () => { sdkEnabled = true; const vendor = path.join(tmpDir, 'vendor.js'); fs.writeFileSync(vendor, 'console.log("vendor");\n'); - const build = await rollup({ input: [entry, vendor], plugins: [...plugins, cssPlugin] }); + const build = await rollup({ input: [entry, vendor], plugins: [...plugins, assetMapsPlugin] }); await build.write({ dir: outDir, sourcemap: 'hidden' }); - expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'style.css', 'vendor.js']); + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'style.css', 'vendor.js', 'worker.js']); }); it('keeps the maps when `deleteSourcemapsAfterUpload` is not true', async () => { @@ -359,7 +381,7 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (rollup / rolldown)', () => { .readdirSync(outDir) .filter(file => file.endsWith('.map')) .sort(), - ).toEqual(['entry.js.map', 'style.css.map', 'vendor.js.map']); + ).toEqual(['entry.js.map', 'style.css.map', 'vendor.js.map', 'worker.js.map']); }); }); From ff39dee15f43f9b9073942756862794a6db2234f Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 13 Nov 2026 11:32:46 +0000 Subject: [PATCH 7/9] fix(bundler-plugins): Keep source maps the user enabled when `sourcemaps.disable` is set Users set `sourcemaps.disable` to upload the maps themselves, so `deleteSourcemapsAfterUpload: true` now keeps those maps and warns, as before. Maps an SDK turned on (`sdkEnabledSourceMaps`) are still removed, as the SDK globs did. --- .../src/core/sourcemap-settings.ts | 12 ++++++++ packages/bundler-plugins/src/core/types.ts | 5 ++-- .../test/core/sourcemap-settings.test.ts | 23 +++++++++++++-- .../test/esbuild/disable-upload.test.ts | 24 +++++++++++++++- .../delete-sourcemaps-after-upload.test.ts | 28 ++++++++++++------- .../buildTimeOptionsBase.ts | 5 ++-- 6 files changed, 79 insertions(+), 18 deletions(-) diff --git a/packages/bundler-plugins/src/core/sourcemap-settings.ts b/packages/bundler-plugins/src/core/sourcemap-settings.ts index 65df33cbc96c..626178ecdb48 100644 --- a/packages/bundler-plugins/src/core/sourcemap-settings.ts +++ b/packages/bundler-plugins/src/core/sourcemap-settings.ts @@ -56,6 +56,18 @@ export function decideSourceMapHandling( if (deleteOption !== true || (kind !== 'hidden' && kind !== 'visible')) { return NO_CHANGE; } + // Users set `sourcemaps.disable` to upload the maps themselves. Maps the + // SDK turned on are still removed, as the SDKs' own globs did. + if (options.sourcemaps?.disable && !sdkEnabledSourceMaps) { + return { + ...NO_CHANGE, + log: { + level: 'warn', + message: + '`sourcemaps.deleteSourcemapsAfterUpload` is set, but source map upload is disabled with `sourcemaps.disable`, so the source maps are not removed.', + }, + }; + } return { enableHidden: kind === 'visible', deleteAfterUpload: true, diff --git a/packages/bundler-plugins/src/core/types.ts b/packages/bundler-plugins/src/core/types.ts index daf2f9068c2c..40aedab8f265 100644 --- a/packages/bundler-plugins/src/core/types.ts +++ b/packages/bundler-plugins/src/core/types.ts @@ -204,8 +204,9 @@ export interface Options { * `sourcemaps.assets` or `sourcemaps.ignore` keep out of the upload. Source maps you enabled yourself are only * removed for the JavaScript chunks that were uploaded; excluded chunks keep theirs. * - * `true` removes source maps you enabled yourself even when no upload runs, for example without an auth token or - * with `sourcemaps.disable`. The plugin does not turn source maps on when it cannot upload them. + * `true` removes source maps you enabled yourself even when no upload runs, for example without an auth token. + * With `sourcemaps.disable` set, the plugin keeps them for a manual upload and warns. The plugin does not turn + * source maps on when it cannot upload them. * * With webpack's array form of `devtool`, `true` replaces the array with one hidden source map setting for * all asset types, because webpack hides the array from plugins. diff --git a/packages/bundler-plugins/test/core/sourcemap-settings.test.ts b/packages/bundler-plugins/test/core/sourcemap-settings.test.ts index 68ec2f792bd1..29fa32a86f3f 100644 --- a/packages/bundler-plugins/test/core/sourcemap-settings.test.ts +++ b/packages/bundler-plugins/test/core/sourcemap-settings.test.ts @@ -122,11 +122,28 @@ describe('decideSourceMapHandling', () => { }); it.each([true, 'disable-upload'] as const)( - 'deletes hidden source maps when `sourcemaps.disable` is `%s` and the option is true', + 'keeps source maps and warns when `sourcemaps.disable` is `%s` and the option is true', + disable => { + for (const kind of ['hidden', 'visible'] as const) { + const decision = decideSourceMapHandling(kind, options({ disable, deleteSourcemapsAfterUpload: true }), true); + + expect(decision).toMatchObject(NO_CHANGE); + expect(decision.log?.level).toBe('warn'); + expect(decision.log?.message).toMatch(/sourcemaps\.disable/); + } + }, + ); + + it.each([true, 'disable-upload'] as const)( + 'removes source maps the SDK turned on when `sourcemaps.disable` is `%s`', disable => { expect( - decideSourceMapHandling('hidden', options({ disable, deleteSourcemapsAfterUpload: true }), true), - ).toMatchObject({ enableHidden: false, deleteAfterUpload: true }); + decideSourceMapHandling( + 'hidden', + options({ disable, deleteSourcemapsAfterUpload: true, sdkEnabledSourceMaps: true }), + true, + ), + ).toMatchObject({ enableHidden: false, deleteAfterUpload: true, removeAllSourceMaps: true }); }, ); diff --git a/packages/bundler-plugins/test/esbuild/disable-upload.test.ts b/packages/bundler-plugins/test/esbuild/disable-upload.test.ts index 6dc8253dc7e3..fb8801c69d65 100644 --- a/packages/bundler-plugins/test/esbuild/disable-upload.test.ts +++ b/packages/bundler-plugins/test/esbuild/disable-upload.test.ts @@ -43,7 +43,7 @@ describe('sourcemaps.disable: "disable-upload"', () => { expect(bundle).toContain(`//# debugId=${debugId}`); }); - it('stamps the bundle before it deletes the source map with `deleteSourcemapsAfterUpload: true`', async () => { + it('keeps the stamped source map with `deleteSourcemapsAfterUpload: true`', async () => { const outDir = path.join(tmpDir, 'dist'); await esbuild.build({ @@ -60,6 +60,28 @@ describe('sourcemaps.disable: "disable-upload"', () => { ], }); + const bundle = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); + const map = JSON.parse(fs.readFileSync(path.join(outDir, 'entry.js.map'), 'utf8')); + expect(map.debug_id).toBe(bundle.match(DEBUG_ID_MARKER)?.[1]); + }); + + it('stamps the bundle before it deletes a source map the SDK turned on', async () => { + const outDir = path.join(tmpDir, 'dist'); + + await esbuild.build({ + entryPoints: [entry], + bundle: true, + sourcemap: 'external', + outdir: outDir, + absWorkingDir: tmpDir, + plugins: [ + sentryEsbuildPlugin({ + telemetry: false, + sourcemaps: { disable: 'disable-upload', deleteSourcemapsAfterUpload: true, sdkEnabledSourceMaps: true }, + }), + ], + }); + const bundle = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); const debugId = bundle.match(DEBUG_ID_MARKER)?.[1]; diff --git a/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts index 404c27379bef..29a7531ff95f 100644 --- a/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts +++ b/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts @@ -251,20 +251,12 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (rollup / rolldown)', () => { expect(mkdtempSpy).not.toHaveBeenCalled(); }); - it.each<[string, Options]>([ - ['without an auth token', { authToken: undefined }], - ['with "disable-upload"', { sourcemaps: { disable: 'disable-upload' } }], - ['with `disable: true`', { sourcemaps: { disable: true } }], - ])('removes hidden source maps when the option is true and no upload runs (%s)', async (_label, overrides) => { + it('removes hidden source maps when the option is true and no upload runs', async () => { const mkdtempSpy = vi.spyOn(fs.promises, 'mkdtemp'); const build = await rollup({ input: entry, plugins: [ - sentryRollupPlugin({ - ...credentials, - ...overrides, - sourcemaps: { ...overrides.sourcemaps, deleteSourcemapsAfterUpload: true }, - }), + sentryRollupPlugin({ ...credentials, authToken: undefined, sourcemaps: { deleteSourcemapsAfterUpload: true } }), ], }); await build.write({ dir: outDir, sourcemap: 'hidden' }); @@ -274,6 +266,22 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (rollup / rolldown)', () => { expect(mkdtempSpy).not.toHaveBeenCalled(); }); + it.each<[string, Options['sourcemaps']]>([ + ['"disable-upload"', { disable: 'disable-upload' }], + ['`true`', { disable: true }], + ])('keeps hidden source maps when the option is true and `sourcemaps.disable` is %s', async (_label, sourcemaps) => { + const build = await rollup({ + input: entry, + plugins: [ + sentryRollupPlugin({ ...credentials, sourcemaps: { ...sourcemaps, deleteSourcemapsAfterUpload: true } }), + ], + }); + await build.write({ dir: outDir, sourcemap: 'hidden' }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + expect(uploads).toHaveLength(0); + }); + it('keeps hidden source maps without an upload when the option is unset', async () => { const build = await rollup({ input: entry, diff --git a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts index 0ecf62a86ef7..e545611e37cb 100644 --- a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts +++ b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts @@ -261,8 +261,9 @@ interface SourceMapsOptions { * `sourcemaps.assets` or `sourcemaps.ignore` keep out of the upload. Source maps you enabled yourself are only * removed for the JavaScript chunks that were uploaded; excluded chunks keep theirs. * - * `true` removes source maps you enabled yourself even when no upload runs, for example without an auth token or - * with `sourcemaps.disable`. The plugin does not turn source maps on when it cannot upload them. + * `true` removes source maps you enabled yourself even when no upload runs, for example without an auth token. + * With `sourcemaps.disable` set, the plugin keeps them for a manual upload and warns. The plugin does not turn + * source maps on when it cannot upload them. * * With webpack's array form of `devtool`, `true` replaces the array with one hidden source map setting for * all asset types, because webpack hides the array from plugins. From 8c88f45e52e0ff3470a4949a9a3e8f15b91452fa Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 13 Nov 2026 11:36:21 +0000 Subject: [PATCH 8/9] Update packages/bundler-plugins/src/core/sourcemap-settings.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jan Peer Stöcklmair --- packages/bundler-plugins/src/core/sourcemap-settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bundler-plugins/src/core/sourcemap-settings.ts b/packages/bundler-plugins/src/core/sourcemap-settings.ts index 626178ecdb48..2998c8759386 100644 --- a/packages/bundler-plugins/src/core/sourcemap-settings.ts +++ b/packages/bundler-plugins/src/core/sourcemap-settings.ts @@ -73,7 +73,7 @@ export function decideSourceMapHandling( deleteAfterUpload: true, removeAllSourceMaps: sdkEnabledSourceMaps, log: { - level: 'info', + level: 'warn', message: 'Source maps are not uploaded, but they are removed from the build output because `sourcemaps.deleteSourcemapsAfterUpload` is set.', }, From ed29a65d038d3afae8763731b65f6e4485406a9c Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 13 Nov 2026 13:42:45 +0000 Subject: [PATCH 9/9] chore(bundler-plugins): Add a TODO(v12) to split `sourcemaps.disable` into separate options --- packages/bundler-plugins/src/core/types.ts | 3 +++ packages/core/src/build-time-plugins/buildTimeOptionsBase.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/bundler-plugins/src/core/types.ts b/packages/bundler-plugins/src/core/types.ts index 40aedab8f265..033ff005d36b 100644 --- a/packages/bundler-plugins/src/core/types.ts +++ b/packages/bundler-plugins/src/core/types.ts @@ -111,6 +111,9 @@ export interface Options { * Options related to source maps upload and processing. */ sourcemaps?: { + // TODO(v12): Replace `disable` with separate options, for example + // `upload`, `debugIds` and `deleteSourcemapsAfterUpload`, so that one + // option no longer controls several features. /** * Disables all functionality related to sourcemaps if set to `true`. * diff --git a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts index e545611e37cb..ae924dec5edd 100644 --- a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts +++ b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts @@ -208,6 +208,9 @@ export interface ReactComponentAnnotationOptions { } interface SourceMapsOptions { + // TODO(v12): Replace `disable` with separate options, for example + // `upload`, `debugIds` and `deleteSourcemapsAfterUpload`, so that one + // option no longer controls several features. /** * If this flag is `true`, any functionality related to source maps will be disabled. This includes the automatic upload of source maps. *