diff --git a/packages/bundler-plugins/src/core/build-plugin-manager.ts b/packages/bundler-plugins/src/core/build-plugin-manager.ts index b70b32664d01..a05321740aa5 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, @@ -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; /** @@ -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` @@ -970,8 +970,11 @@ export function createSentryBuildPluginManager( sourceMaps: { uploadPossible: sourceMapUploadPossible, - decide(kind, { log = true } = {}) { - const decision = decideSourceMapHandling(kind, options, sourceMapUploadPossible); + decide(kind, { log = true, 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/core/index.ts b/packages/bundler-plugins/src/core/index.ts index b721b2ec47e7..a784e7ded36a 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, getSdkEnabledSourceMaps } 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..a811edf748db 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,8 @@ export type NormalizedOptions = { rewriteSources?: RewriteSourcesHook; resolveSourceMap?: ResolveSourceMapHook; filesToDeleteAfterUpload?: string | string[] | Promise; - deleteSourcemapsAfterUpload?: boolean; + deleteSourcemapsAfterUpload?: boolean | ((environment?: string) => boolean | undefined); + sdkEnabledSourceMaps?: boolean | ((environment?: string) => boolean | undefined); } | undefined; release: { @@ -217,13 +218,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..2998c8759386 100644 --- a/packages/bundler-plugins/src/core/sourcemap-settings.ts +++ b/packages/bundler-plugins/src/core/sourcemap-settings.ts @@ -16,11 +16,28 @@ 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 }; }; const NO_CHANGE: SourceMapDecision = { enableHidden: false, deleteAfterUpload: false }; +/** Reads `sourcemaps.deleteSourcemapsAfterUpload`, calling the internal function form if given. */ +export function getDeleteSourcemapsAfterUpload(options: NormalizedOptions, environment?: string): boolean | undefined { + const value = options.sourcemaps?.deleteSourcemapsAfterUpload; + return typeof value === 'function' ? value(environment) : value; +} + +/** Reads the internal `sourcemaps.sdkEnabledSourceMaps` option, calling its function form if given. */ +export function getSdkEnabledSourceMaps(options: NormalizedOptions, environment?: string): boolean { + const value = options.sourcemaps?.sdkEnabledSourceMaps; + return (typeof value === 'function' ? value(environment) : 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 @@ -30,11 +47,37 @@ export function decideSourceMapHandling( kind: SourceMapKind, options: NormalizedOptions, uploadPossible: boolean, + environment?: string, ): SourceMapDecision { - const deleteOption = options.sourcemaps?.deleteSourcemapsAfterUpload; + const deleteOption = getDeleteSourcemapsAfterUpload(options, environment); + const sdkEnabledSourceMaps = getSdkEnabledSourceMaps(options, environment); if (!uploadPossible || options.sourcemaps?.disable) { - return NO_CHANGE; + 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, + removeAllSourceMaps: sdkEnabledSourceMaps, + log: { + level: 'warn', + message: + 'Source maps are not uploaded, but they are removed from the build output because `sourcemaps.deleteSourcemapsAfterUpload` is set.', + }, + }; } switch (kind) { @@ -74,6 +117,7 @@ export function decideSourceMapHandling( return { enableHidden: true, deleteAfterUpload: true, + removeAllSourceMaps: true, log: { level: 'info', message: @@ -83,7 +127,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) { @@ -92,6 +139,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 7cf821a63937..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`. * @@ -204,6 +207,10 @@ 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. + * 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. * @@ -668,3 +675,24 @@ 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, 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. 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 | ((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 | ((environment?: string) => boolean | undefined); + }; +}; diff --git a/packages/bundler-plugins/src/esbuild/index.ts b/packages/bundler-plugins/src/esbuild/index.ts index 51e5f126db8f..75e9f01213df 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', @@ -326,36 +326,17 @@ 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)) - : []; - - 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) { + // 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; + 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) { 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 @@ -367,6 +348,35 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any { .map(bundle => addDebugIdToEmittedArtifacts(bundle, logger, options.sourcemaps?.resolveSourceMap)), ); } + } 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('/')), + ); + + // 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 && !deleteWithoutUpload) { + const mapsToDelete = sourceMapDecision.removeAllSourceMaps ? allMaps : 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 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/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..552b01950ee9 100644 --- a/packages/bundler-plugins/src/rollup/source-map-handling.ts +++ b/packages/bundler-plugins/src/rollup/source-map-handling.ts @@ -165,15 +165,32 @@ 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)]; } } } + // 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('.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]; + } + } + } + return { outputOptions: rawOutputOptions => { const kind = sourceMapKindFromRollupSetting(rawOutputOptions.sourcemap); @@ -210,6 +227,23 @@ export function createSourceMapHandling(sentryBuildPluginManager: SentryBuildPlu return; } + const removeAll = record.decision.removeAllSourceMaps === true; + // 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) { + dropSourceMaps( + bundle, + removeAll + ? undefined + : fileName => sentryBuildPluginManager.sourceMaps.shouldUpload(path.join(destination, fileName)), + ); + return; + } + const staging = await sentryBuildPluginManager.createUploadStaging(); try { @@ -231,16 +265,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/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..0295dae0079b 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, getSdkEnabledSourceMaps } from '../core'; import type { OutputMatch, RollupSourceMapSetting, SourceMapHandling } from '../rollup/source-map-handling'; import { getOutputMatch, sourceMapKindFromRollupSetting } from '../rollup/source-map-handling'; @@ -96,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) { @@ -110,22 +114,27 @@ export function createViteSourceMapHooks( configResolved: config => { const environments = config.environments ?? { client: { build: config.build } }; - const { uploadPossible } = sentryBuildPluginManager.sourceMaps; - const deleteOption = sentryBuildPluginManager.normalizedOptions.sourcemaps?.deleteSourcemapsAfterUpload; - for (const [name, environment] of Object.entries(environments)) { const { kind, decision } = decisions.get(name) ?? decisions.get('*') ?? { kind: 'unset', decision: NO_CHANGE }; + 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: uploadPossible && deleteOption === true && output.sourcemap === 'hidden', + deleteAfterUpload: deleteOption === true && (outputKind === 'hidden' || removeAllSourceMaps), + removeAllSourceMaps, }, }); continue; diff --git a/packages/bundler-plugins/src/webpack/index.ts b/packages/bundler-plugins/src/webpack/index.ts index 85795477405d..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, UploadStaging } from '../core/index'; +import type { + InternalOptions, + Options, + SourceMapDecision, + SourceMapKind, + UploadFilter, + UploadStaging, +} from '../core/index'; import { createSentryBuildPluginManager, generateReleaseInjectorCode, @@ -220,13 +227,32 @@ function deleteSourceMapAssets(compilation: WebpackCompilation, sourceMapName: s } } +/** + * 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 (!removeAll && (!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. * - * 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, @@ -271,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', @@ -416,21 +442,31 @@ 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(); - 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, removeAllSourceMaps); }); }); + } 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, removeAllSourceMaps); + }, + ); + }); // A staging that `afterEmit` never took belongs to a compilation that // failed before emitting, or to one whose assets were emitted without @@ -557,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/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..29fa32a86f3f 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 }); } @@ -52,6 +52,7 @@ describe('decideSourceMapHandling', () => { expect(decideSourceMapHandling('hidden', options({ deleteSourcemapsAfterUpload: true }), true)).toEqual({ enableHidden: false, deleteAfterUpload: true, + removeAllSourceMaps: false, }); }); @@ -91,16 +92,142 @@ 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)( + '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, sdkEnabledSourceMaps: true }), + true, + ), + ).toMatchObject({ enableHidden: false, deleteAfterUpload: true, removeAllSourceMaps: 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)( + '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 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; + 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, + removeAllSourceMaps: false, + }); + }); + + 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/esbuild/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/esbuild/delete-sourcemaps-after-upload.test.ts index 75578262a922..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 @@ -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 }); @@ -197,6 +214,27 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (esbuild)', () => { expect(uploads).toHaveLength(0); }); + it('deletes external source maps when the option is true and no upload runs', async () => { + 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 () => { const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined); const result = await build({ silent: false }, { write: false }); diff --git a/packages/bundler-plugins/test/esbuild/disable-upload.test.ts b/packages/bundler-plugins/test/esbuild/disable-upload.test.ts index ca077832169e..fb8801c69d65 100644 --- a/packages/bundler-plugins/test/esbuild/disable-upload.test.ts +++ b/packages/bundler-plugins/test/esbuild/disable-upload.test.ts @@ -43,6 +43,53 @@ describe('sourcemaps.disable: "disable-upload"', () => { expect(bundle).toContain(`//# debugId=${debugId}`); }); + it('keeps the stamped 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 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]; + + 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'); 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..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 @@ -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(), @@ -250,6 +251,148 @@ describe('sourcemaps.deleteSourcemapsAfterUpload (rollup / rolldown)', () => { 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'); + const build = await rollup({ + input: entry, + plugins: [ + sentryRollupPlugin({ ...credentials, authToken: undefined, 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.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, + 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('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[] { + const { main, post } = _createRollupPluginInternals({ ...credentials, ...options }, 'rollup'); + return (post ? [main, post] : [main]) as Plugin[]; + } + + // 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":""}' }); + }, + }; + + 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), assetMapsPlugin] }); + await build.write({ dir: outDir, sourcemap: 'hidden' }); + } + + 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', 'worker.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', 'worker.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, assetMapsPlugin] }); + await build.write({ dir: outDir, sourcemap: 'hidden' }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'style.css', 'vendor.js', 'worker.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', 'worker.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..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 @@ -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, @@ -203,6 +203,91 @@ 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' } }); + + 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('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' } }); @@ -328,6 +413,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 } }); 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..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, @@ -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 })); @@ -444,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' })); diff --git a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts index 130e38c086bc..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. * @@ -261,6 +264,10 @@ 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. + * 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. */