diff --git a/CHANGELOG.md b/CHANGELOG.md index 29fecaa4a5ad..7281f1496c31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ Work in this release was contributed by @lazerg and @chiliec. Thank you for your contributions! +### Important Changes + +- **feat(bundler-plugins): Enable hidden source maps and remove them after upload ([#25637](https://github.com/getsentry/sentry-javascript/pull/25637), [#25645](https://github.com/getsentry/sentry-javascript/pull/25645), [#25646](https://github.com/getsentry/sentry-javascript/pull/25646), [#25647](https://github.com/getsentry/sentry-javascript/pull/25647), [#25648](https://github.com/getsentry/sentry-javascript/pull/25648))** + + The Sentry Rollup, Vite, webpack and esbuild plugins now turn on hidden source maps when your bundler has no source maps enabled and the plugin can upload them. They upload the maps to Sentry and remove them from the build output. You no longer need to set `sourcemap: 'hidden'` or `filesToDeleteAfterUpload` globs: + + ```js + sentryVitePlugin({ org, project, authToken }); + ``` + + The new `sourcemaps.deleteSourcemapsAfterUpload` option controls the removal: + - Unset: the plugin removes only the source maps it turned on itself. Source maps you enabled yourself stay in the build output, as before. + - `true`: the plugin also removes source maps you enabled yourself, and switches visible source maps to hidden. Set this if you already use `sourcemap: 'hidden'` and want to drop your `filesToDeleteAfterUpload` globs. + - `false`: the plugin never removes source maps. + + Builds in development mode or without an auth token, org and project do not change. + ## 11.10.0 ### Important Changes diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/basic-cjs.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/basic-cjs.test.ts index b648cee7c415..9b23588858ac 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/basic-cjs.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/basic-cjs.test.ts @@ -20,6 +20,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { "sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"], ["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/basic.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/basic.test.ts index b648cee7c415..9b23588858ac 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/basic.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/basic.test.ts @@ -20,6 +20,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { "sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"], ["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/release-disabled.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/release-disabled.test.ts index cd5480b81387..44729ef41b57 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/release-disabled.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/release-disabled.test.ts @@ -19,6 +19,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles }) => { ", "sentry-cli-mock.json": "["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/basic-cjs.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/basic-cjs.test.ts index 1a1d23690e7d..6295b31445f3 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/basic-cjs.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/basic-cjs.test.ts @@ -11,6 +11,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { "sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"], ["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/basic.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/basic.test.ts index 1a1d23690e7d..6295b31445f3 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/basic.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/basic.test.ts @@ -11,6 +11,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { "sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"], ["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/release-disabled.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/release-disabled.test.ts index 85b1040db223..377c85d97b9a 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/release-disabled.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/release-disabled.test.ts @@ -10,6 +10,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles }) => { ", "sentry-cli-mock.json": "["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/basic-cjs.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/basic-cjs.test.ts index 1a1d23690e7d..6295b31445f3 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/basic-cjs.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/basic-cjs.test.ts @@ -11,6 +11,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { "sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"], ["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/basic.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/basic.test.ts index 1a1d23690e7d..6295b31445f3 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/basic.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/basic.test.ts @@ -11,6 +11,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { "sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"], ["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/release-disabled.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/release-disabled.test.ts index 85b1040db223..377c85d97b9a 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/release-disabled.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/release-disabled.test.ts @@ -10,6 +10,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles }) => { ", "sentry-cli-mock.json": "["release","set-commits","CURRENT_SHA","--auto"], ["release","finalize","CURRENT_SHA"], + ["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"], ", } `); diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index 227f162eae8f..7306d9b7dfab 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -21,6 +21,7 @@ import * as path from 'node:path'; import { createRequire } from 'node:module'; import { finalizeRolldownDebugIds, getDebugIdForChunk, hasExistingDebugID } from './debug-id-injection'; import { stampDebugIds, type OutputBundle } from './debug-id-stamping'; +import { createSourceMapHandling, type OutputDestinationOptions } from './source-map-handling'; // The subset of Rollup's `TransformResult` that this plugin's `transform` // hook actually returns. Defined locally instead of imported from `rollup` @@ -81,10 +82,14 @@ function getViteParseAstAsync(): Promise { } /** - * @ignore - this is the internal plugin factory function only used for the Vite plugin! + * @ignore - internal plugin factory shared by the Rollup and Vite plugins. + * + * `main` carries injection, release creation and the upload. `post` runs last in `generateBundle`, + * copies chunks and their source maps into the upload staging folder and drops the map assets + * from the bundle, so the bundler never writes them. */ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function _rollupPluginInternal( +export function _createRollupPluginInternals( userOptions: Options = {}, buildTool: 'rollup' | 'vite', buildToolMajorVersion?: string, @@ -105,7 +110,10 @@ export function _rollupPluginInternal( if (options.disable) { return { - name: 'sentry-noop-plugin', + main: { + name: 'sentry-noop-plugin', + }, + post: undefined, }; } @@ -116,6 +124,7 @@ export function _rollupPluginInternal( const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); const sourcemapsEnabled = options.sourcemaps?.disable !== true; + const sourceMapHandling = createSourceMapHandling(sentryBuildPluginManager); const releaseInjectionCode = new CodeInjection(); const chunkInjectionCode = new CodeInjection(); @@ -255,14 +264,15 @@ export function _rollupPluginInternal( stampDebugIds(bundle, true); } - async function writeBundle( - outputOptions: { dir?: string; file?: string }, - bundle: { [fileName: string]: unknown }, - ): Promise { + async function writeBundle(outputOptions: OutputDestinationOptions, bundle: OutputBundle | undefined): Promise { + const staging = await sourceMapHandling.takeStaging(outputOptions, bundle); + let stagingHandedOver = false; + try { await sentryBuildPluginManager.createRelease(); if (sourcemapsEnabled && options.sourcemaps?.disable !== 'disable-upload') { + let buildArtifacts: string[]; if (outputOptions.dir) { const outputDir = outputOptions.dir; const JS_AND_MAP_PATTERNS = [ @@ -273,15 +283,23 @@ export function _rollupPluginInternal( '/**/*.mjs.map', '/**/*.cjs.map', ].map(q => `${q}?(\\?*)?(#*)`); // We want to allow query and hash strings at the end of files - const buildArtifacts = await globFiles(JS_AND_MAP_PATTERNS, { root: outputDir }); - await upload(buildArtifacts); + buildArtifacts = await globFiles(JS_AND_MAP_PATTERNS, { root: outputDir }); } else if (outputOptions.file) { - await upload([outputOptions.file]); + buildArtifacts = [outputOptions.file]; } else { - const buildArtifacts = Object.keys(bundle).map(asset => path.join(path.resolve(), asset)); - await upload(buildArtifacts); + buildArtifacts = Object.keys(bundle ?? {}).map(asset => path.join(path.resolve(), asset)); } + stagingHandedOver = true; + await upload(buildArtifacts, { staging }); + } else { + stagingHandedOver = true; + await staging?.discard(); } + } catch (e) { + if (!stagingHandedOver) { + await staging?.keep('The source map upload did not run.'); + } + throw e; } finally { freeGlobalDependencyOnBuildArtifacts(); await sentryBuildPluginManager.deleteArtifacts(); @@ -297,21 +315,44 @@ export function _rollupPluginInternal( } : transform; - return { + const main = { name, buildStart, ...(shouldTransform ? { transform: transformHook } : {}), + // Vite warns about `rollupOptions.output.sourcemap`, so the Vite plugin + // decides in its `config` hook instead. + ...(buildTool === 'rollup' ? { outputOptions: sourceMapHandling.outputOptions } : {}), renderChunk, generateBundle: { order: 'pre' as const, handler: generateBundle }, writeBundle, }; + + const post = { + name: `sentry-${buildTool}-upload-staging`, + generateBundle: { order: 'post' as const, handler: sourceMapHandling.stageSourceMaps }, + }; + + return { main, post }; +} + +/** + * @ignore - this is the internal plugin factory function only used for the Vite plugin! + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function _rollupPluginInternal( + userOptions: Options = {}, + buildTool: 'rollup' | 'vite', + buildToolMajorVersion?: string, +) { + return _createRollupPluginInternals(userOptions, buildTool, buildToolMajorVersion).main; } // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any export function sentryRollupPlugin(userOptions: Options = {}): any { + const { main, post } = _createRollupPluginInternals(userOptions, 'rollup'); // We return an array here so we don't break backwards compatibility with what // unplugin used to return - return [_rollupPluginInternal(userOptions, 'rollup')]; + return post ? [main, post] : [main]; } export type { Options as SentryRollupPluginOptions } from '../core'; diff --git a/packages/bundler-plugins/src/rollup/source-map-handling.ts b/packages/bundler-plugins/src/rollup/source-map-handling.ts new file mode 100644 index 000000000000..22a9d3e8df0d --- /dev/null +++ b/packages/bundler-plugins/src/rollup/source-map-handling.ts @@ -0,0 +1,168 @@ +import * as path from 'node:path'; +import type { SentryBuildPluginManager, SourceMapDecision, SourceMapKind, UploadStaging } from '../core'; +import { isJsFile } from '../core'; +import type { OutputBundle } from './debug-id-stamping'; + +export type RollupSourceMapSetting = boolean | 'inline' | 'hidden' | undefined; + +export type OutputDestinationOptions = { dir?: string; file?: string }; + +/** Maps Rollup's (and Vite's) `sourcemap` setting onto the plugin's source map kinds. */ +export function sourceMapKindFromRollupSetting(setting: RollupSourceMapSetting): SourceMapKind { + switch (setting) { + case undefined: + return 'unset'; + case false: + return 'off'; + case true: + return 'visible'; + default: + return setting; + } +} + +/** The directory an output writes to. Decisions and stagings are keyed by it. */ +export function getOutputDestination(outputOptions: OutputDestinationOptions): string { + return path.resolve(outputOptions.dir ?? path.dirname(outputOptions.file ?? '.')); +} + +const DEBUG_ID_PATTERN = /sentry-dbid-([0-9a-f-]{36})|\/\/# debugId=([0-9a-f-]{36})/i; + +/** Identifies the chunks of a bundle by name and debug ID, so a staging can be matched to its write. */ +function getBundleSignature(bundle: OutputBundle): string { + const entries: string[] = []; + for (const output of Object.values(bundle)) { + if (output.type === 'chunk' && isJsFile(output.fileName)) { + const match = output.code.match(DEBUG_ID_PATTERN); + entries.push(`${output.fileName}:${match?.[1] ?? match?.[2] ?? ''}`); + } + } + return entries.sort().join('\n'); +} + +type PendingStaging = { staging: UploadStaging; signature: string }; + +export type SourceMapHandling = { + /** Rollup's `outputOptions` hook: decides for the output and switches it to hidden source maps when needed. */ + outputOptions: ( + rawOutputOptions: OutputDestinationOptions & { sourcemap?: RollupSourceMapSetting }, + ) => (OutputDestinationOptions & { sourcemap?: RollupSourceMapSetting }) | null; + /** Records the decision for an output destination, for bundlers that decide outside `outputOptions`. */ + setDecision: (destination: string, decision: SourceMapDecision) => void; + /** + * `generateBundle` with `order: 'post'`: copies each chunk and its source map into a staging folder + * and drops the map asset from the bundle, so the bundler never writes it. + */ + stageSourceMaps: (outputOptions: OutputDestinationOptions, bundle: OutputBundle, isWrite?: boolean) => Promise; + /** + * Hands the staging of this bundle to `writeBundle`. A staging whose write never reached + * `writeBundle` is discarded instead of being uploaded for a later build. Without a bundle + * (SvelteKit calls the hook with output options only) the oldest staging is taken. + */ + takeStaging: ( + outputOptions: OutputDestinationOptions, + bundle: OutputBundle | undefined, + ) => Promise; +}; + +/** + * Source map decisions and upload stagings of one plugin instance. Keyed by output destination + * because one instance can serve several outputs, and watch mode or several `write()` calls + * rebuild the same one. + */ +export function createSourceMapHandling(sentryBuildPluginManager: SentryBuildPluginManager): SourceMapHandling { + const decisions = new Map(); + const pendingStagings = new Map(); + + return { + outputOptions: rawOutputOptions => { + const decision = sentryBuildPluginManager.sourceMaps.decide( + sourceMapKindFromRollupSetting(rawOutputOptions.sourcemap), + ); + decisions.set(getOutputDestination(rawOutputOptions), decision); + + return decision.enableHidden ? { ...rawOutputOptions, sourcemap: 'hidden' } : null; + }, + + setDecision: (destination, decision) => { + decisions.set(destination, decision); + }, + + stageSourceMaps: async (outputOptions, bundle, isWrite) => { + // `generate()` never reaches `writeBundle`, so nothing would consume the staging. + if (isWrite === false) { + return; + } + + const destination = getOutputDestination(outputOptions); + if (!decisions.get(destination)?.deleteAfterUpload) { + return; + } + + const staging = await sentryBuildPluginManager.createUploadStaging(); + + try { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !isJsFile(output.fileName)) { + continue; + } + + const mapFileName = output.sourcemapFileName ?? `${output.fileName}.map`; + const mapAsset = bundle[mapFileName]; + if (mapAsset?.type !== 'asset' || typeof mapAsset.source !== 'string') { + continue; + } + + const result = await staging.stage({ + code: output.code, + mapSource: mapAsset.source, + mapDir: path.join(destination, path.dirname(output.fileName)), + fileName: output.fileName, + }); + + if (result === 'staged') { + // oxlint-disable-next-line typescript/no-dynamic-delete -- Rollup drops an asset when its key is deleted from the bundle + delete bundle[mapFileName]; + } else { + sentryBuildPluginManager.logger.warn( + `Could not prepare ${output.fileName} for upload, so its source map ${mapFileName} stays in the build output.`, + ); + } + } + } catch (e) { + // The error fails the build before anything is written. + await staging.discard(); + throw e; + } + + const stagings = pendingStagings.get(destination) ?? []; + stagings.push({ staging, signature: getBundleSignature(bundle) }); + pendingStagings.set(destination, stagings); + }, + + takeStaging: async (outputOptions, bundle) => { + const destination = getOutputDestination(outputOptions); + const stagings = pendingStagings.get(destination) ?? []; + + if (!bundle) { + return stagings.shift()?.staging; + } + + const signature = getBundleSignature(bundle); + + while (stagings.length > 0) { + const pending = stagings.shift() as PendingStaging; + if (pending.signature === signature) { + return pending.staging; + } + + sentryBuildPluginManager.logger.debug( + `Discarding staged source maps of an earlier build of ${destination} that was never written.`, + ); + await pending.staging.discard(); + } + + return undefined; + }, + }; +} 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 new file mode 100644 index 000000000000..524a0bdbd602 --- /dev/null +++ b/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts @@ -0,0 +1,380 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { rollup } 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'; + +const { mockSourcemapUpload, mockReleaseCreate } = vi.hoisted(() => ({ + mockSourcemapUpload: vi.fn(), + mockReleaseCreate: vi.fn(), +})); + +vi.mock('sentry', () => ({ + createSentrySDK: () => ({ + release: { create: mockReleaseCreate, finalize: vi.fn(), 'set-commits': vi.fn(), deploy: vi.fn() }, + sourcemap: { upload: mockSourcemapUpload, inject: vi.fn() }, + run: vi.fn(), + }), +})); + +const DEBUG_ID_MARKER = /sentry-dbid-([0-9a-f-]{36})/; + +const credentials: Options = { + authToken: 't', + org: 'o', + project: 'p', + release: { name: 'test-release', create: false, finalize: false }, + telemetry: false, + silent: true, +}; + +type Upload = { directory: string; files: Record }; + +describe('sourcemaps.deleteSourcemapsAfterUpload (rollup / rolldown)', () => { + let tmpDir: string; + let entry: string; + let outDir: string; + let uploads: Upload[]; + + beforeEach(() => { + vi.clearAllMocks(); + uploads = []; + mockSourcemapUpload.mockImplementation(async ({ directory }: { directory: string }) => { + const files: Record = {}; + for (const file of fs.readdirSync(directory)) { + files[file] = fs.readFileSync(path.join(directory, file), 'utf8'); + } + uploads.push({ directory, files }); + }); + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-rollup-delete-maps-')); + entry = path.join(tmpDir, 'entry.js'); + outDir = path.join(tmpDir, 'dist'); + fs.writeFileSync(entry, 'export const answer = 42;\nconsole.log(answer);\n'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function trackStagingFolders(): string[] { + const createdFolders: string[] = []; + const mkdtemp = fs.promises.mkdtemp; + vi.spyOn(fs.promises, 'mkdtemp').mockImplementation(async (...args: Parameters) => { + const folder = await mkdtemp(...args); + createdFolders.push(folder); + return folder; + }); + return createdFolders; + } + + function expectUploadedPair(upload: Upload, chunk: string): void { + const debugId = chunk.match(DEBUG_ID_MARKER)?.[1]; + expect(debugId).toBeDefined(); + expect(Object.keys(upload.files).sort()).toEqual([`${debugId}-0.js`, `${debugId}-0.js.map`]); + expect(upload.files[`${debugId}-0.js`]).toContain(`//# debugId=${debugId}`); + expect(JSON.parse(upload.files[`${debugId}-0.js.map`]!)).toMatchObject({ debug_id: debugId }); + expect(fs.existsSync(upload.directory)).toBe(false); + } + + describe.each([ + ['rollup', rollup], + ['rolldown', rolldown], + ])('%s', (_, bundle) => { + it('enables hidden source maps, uploads them and leaves none in the output when the setting is unset', async () => { + const build = await bundle({ input: entry, plugins: [sentryRollupPlugin(credentials)] }); + await build.write({ dir: outDir }); + + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + const chunk = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); + expect(chunk).not.toContain('sourceMappingURL'); + + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, chunk); + }); + + it('stages chunks whose debug ID the bundler injects itself', async () => { + const build = await bundle({ input: entry, plugins: [sentryRollupPlugin(credentials)] }); + await build.write({ dir: outDir, sourcemapDebugIds: true }); + + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + const chunk = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); + expect(chunk).toContain('//# debugId='); + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, chunk); + }); + + it('switches visible source maps to hidden and removes them when the option is true', async () => { + const build = await bundle({ + input: entry, + plugins: [sentryRollupPlugin({ ...credentials, sourcemaps: { deleteSourcemapsAfterUpload: true } })], + }); + await build.write({ dir: outDir, sourcemap: true }); + + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + const chunk = fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8'); + expect(chunk).not.toContain('sourceMappingURL'); + + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, chunk); + }); + }); + + it('keeps hidden source maps the user enabled and uploads them from disk', async () => { + const build = await rollup({ input: entry, plugins: [sentryRollupPlugin(credentials)] }); + await build.write({ dir: outDir, sourcemap: 'hidden' }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')); + }); + + it('removes hidden source maps the user enabled when the option is true', async () => { + const build = await rollup({ + input: entry, + plugins: [sentryRollupPlugin({ ...credentials, sourcemaps: { deleteSourcemapsAfterUpload: true } })], + }); + await build.write({ dir: outDir, sourcemap: 'hidden' }); + + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + expect(uploads).toHaveLength(1); + }); + + it('keeps visible source maps when the option is unset', async () => { + const build = await rollup({ input: entry, plugins: [sentryRollupPlugin(credentials)] }); + await build.write({ dir: outDir, sourcemap: true }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + expect(fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')).toContain('//# sourceMappingURL=entry.js.map'); + }); + + it('enables hidden source maps but keeps them when the option is false', async () => { + const build = await rollup({ + input: entry, + plugins: [sentryRollupPlugin({ ...credentials, sourcemaps: { deleteSourcemapsAfterUpload: false } })], + }); + await build.write({ dir: outDir }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + expect(fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')).not.toContain('sourceMappingURL'); + expect(uploads).toHaveLength(1); + expect(Object.keys(uploads[0]!.files)).toHaveLength(2); + }); + + it('warns when source maps are turned off', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const build = await rollup({ input: entry, plugins: [sentryRollupPlugin({ ...credentials, silent: false })] }); + await build.write({ dir: outDir, sourcemap: false }); + + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Source map generation is turned off')); + }); + + it('warns when source maps are inlined and keeps them', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const build = await rollup({ + input: entry, + plugins: [ + sentryRollupPlugin({ ...credentials, silent: false, sourcemaps: { deleteSourcemapsAfterUpload: true } }), + ], + }); + await build.write({ dir: outDir, sourcemap: 'inline' }); + + expect(fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')).toContain('sourceMappingURL=data:'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Source maps are inlined')); + }); + + it('does not enable source maps when no upload can run', async () => { + const mkdtempSpy = vi.spyOn(fs.promises, 'mkdtemp'); + const build = await rollup({ + input: entry, + plugins: [sentryRollupPlugin({ ...credentials, authToken: undefined })], + }); + await build.write({ dir: outDir }); + + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + expect(uploads).toHaveLength(0); + expect(mkdtempSpy).not.toHaveBeenCalled(); + }); + + it('does not create a staging folder for generate()', async () => { + const mkdtempSpy = vi.spyOn(fs.promises, 'mkdtemp'); + const build = await rollup({ input: entry, plugins: [sentryRollupPlugin(credentials)] }); + await build.generate({ dir: outDir }); + + expect(mkdtempSpy).not.toHaveBeenCalled(); + expect(uploads).toHaveLength(0); + }); + + it('uploads each output on its own', async () => { + const otherOutDir = path.join(tmpDir, 'dist-other'); + const build = await rollup({ input: entry, plugins: [sentryRollupPlugin(credentials)] }); + await build.write({ dir: outDir }); + await build.write({ dir: otherOutDir, entryFileNames: 'other.js' }); + + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + expect(fs.readdirSync(otherOutDir)).toEqual(['other.js']); + expect(uploads).toHaveLength(2); + expectUploadedPair(uploads[0]!, fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')); + expectUploadedPair(uploads[1]!, fs.readFileSync(path.join(otherOutDir, 'other.js'), 'utf8')); + }); + + it('removes the map asset before other plugins see the written bundle', async () => { + let writtenFiles: string[] = []; + const build = await rollup({ + input: entry, + plugins: [ + sentryRollupPlugin(credentials), + { + name: 'observer', + writeBundle(_options: unknown, bundle: Record) { + writtenFiles = Object.keys(bundle); + }, + }, + ], + }); + await build.write({ dir: outDir }); + + expect(writtenFiles).toEqual(['entry.js']); + }); + + it('keeps the staging folder and logs its path when the release step throws', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const createdFolders = trackStagingFolders(); + mockReleaseCreate.mockRejectedValue(new Error('release failed')); + const build = await rollup({ + input: entry, + plugins: [ + sentryRollupPlugin({ + ...credentials, + silent: false, + release: { name: 'test-release', create: true, finalize: false }, + errorHandler: error => { + throw error; + }, + }), + ], + }); + + await expect(build.write({ dir: outDir })).rejects.toThrow('release failed'); + + try { + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + expect(uploads).toHaveLength(0); + expect(createdFolders).toHaveLength(1); + expect(fs.existsSync(createdFolders[0]!)).toBe(true); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(`sentry sourcemap upload ${createdFolders[0]}`)); + } finally { + fs.rmSync(createdFolders[0]!, { recursive: true, force: true }); + } + }); + + it('does not upload a staging left behind by a write that never finished', async () => { + const createdFolders = trackStagingFolders(); + const plugins = sentryRollupPlugin(credentials); + let fail = true; + const failingPlugin = { + name: 'failing-post-plugin', + generateBundle: { + order: 'post' as const, + handler: () => { + if (fail) { + throw new Error('write failed'); + } + }, + }, + }; + + const firstBuild = await rollup({ input: entry, plugins: [...plugins, failingPlugin] }); + await expect(firstBuild.write({ dir: outDir })).rejects.toThrow('write failed'); + + fail = false; + fs.writeFileSync(entry, 'export const answer = 43;\nconsole.log(answer);\n'); + const secondBuild = await rollup({ input: entry, plugins: [...plugins, failingPlugin] }); + await secondBuild.write({ dir: outDir }); + + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')); + expect(createdFolders).toHaveLength(2); + expect(createdFolders.filter(folder => fs.existsSync(folder))).toEqual([]); + }); + + it('does not pair a new chunk with a stale map left in the output directory', async () => { + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync( + path.join(outDir, 'entry.js.map'), + JSON.stringify({ version: 3, sources: ['stale.ts'], mappings: '' }), + ); + const build = await rollup({ input: entry, plugins: [sentryRollupPlugin(credentials)] }); + await build.write({ dir: outDir }); + + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')); + expect(JSON.stringify(uploads[0]!.files)).not.toContain('stale.ts'); + }); + + it('uploads from disk when writeBundle is called with output options only', async () => { + // SvelteKit defers the main plugin's writeBundle to closeBundle and calls it without a bundle. + const [main, post] = sentryRollupPlugin({ ...credentials, sourcemaps: { deleteSourcemapsAfterUpload: false } }); + const build = await rollup({ input: entry, plugins: [{ ...main, writeBundle: undefined }, post] }); + await build.write({ dir: outDir, sourcemap: 'hidden' }); + expect(uploads).toHaveLength(0); + + await main.writeBundle({ dir: outDir }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + expect(uploads).toHaveLength(1); + expect(Object.keys(uploads[0]!.files)).toHaveLength(2); + }); + + it('removes the staging folder when staging a chunk fails', async () => { + const createdFolders = trackStagingFolders(); + const writeFile = fs.promises.writeFile; + vi.spyOn(fs.promises, 'writeFile').mockImplementation(async (...args: Parameters) => { + if (String(args[0]).includes('sentry-bundler-plugin-upload-')) { + throw new Error('disk full'); + } + return writeFile(...args); + }); + const build = await rollup({ input: entry, plugins: [sentryRollupPlugin(credentials)] }); + + await expect(build.write({ dir: outDir })).rejects.toThrow('disk full'); + + expect(createdFolders).toHaveLength(1); + expect(fs.existsSync(createdFolders[0]!)).toBe(false); + expect(uploads).toHaveLength(0); + }); + + it('keeps the map of a chunk that already carries a debug ID instead of dropping it unuploaded', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const build = await rollup({ + input: entry, + plugins: [ + { + name: 'foreign-debug-id', + renderChunk: (code: string) => `${code}\n//# debugId=11111111-2222-3333-4444-555555555555`, + }, + sentryRollupPlugin({ ...credentials, silent: false }), + ], + }); + await build.write({ dir: outDir }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('stays in the build output')); + }); + + it('leaves "disable-upload" builds untouched', async () => { + const build = await rollup({ + input: entry, + plugins: [sentryRollupPlugin({ ...credentials, sourcemaps: { disable: 'disable-upload' } })], + }); + await build.write({ dir: outDir, sourcemap: true }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'entry.js.map']); + expect(uploads).toHaveLength(0); + }); +}); diff --git a/packages/bundler-plugins/test/rollup/public-api.test.ts b/packages/bundler-plugins/test/rollup/public-api.test.ts index aee8cdb5034b..85fa16562da1 100644 --- a/packages/bundler-plugins/test/rollup/public-api.test.ts +++ b/packages/bundler-plugins/test/rollup/public-api.test.ts @@ -111,7 +111,7 @@ describe('sentryRollupPlugin', () => { vi.clearAllMocks(); }); - it('returns an array of rollup plugins (although only one)', () => { + it('returns the main plugin and the upload staging plugin', () => { const plugins = sentryRollupPlugin({ authToken: 'test-token', org: 'test-org', @@ -119,9 +119,13 @@ describe('sentryRollupPlugin', () => { }) as Plugin[]; expect(Array.isArray(plugins)).toBe(true); - expect(plugins).toHaveLength(1); + expect(plugins.map(plugin => plugin.name)).toEqual(['sentry-rollup-plugin', 'sentry-rollup-upload-staging']); + }); + + it('returns only the noop plugin when disabled', () => { + const plugins = sentryRollupPlugin({ disable: true }) as Plugin[]; - expect(plugins[0]?.name).toBe('sentry-rollup-plugin'); + expect(plugins.map(plugin => plugin.name)).toEqual(['sentry-noop-plugin']); }); }); diff --git a/packages/bundler-plugins/test/rollup/source-map-handling.test.ts b/packages/bundler-plugins/test/rollup/source-map-handling.test.ts new file mode 100644 index 000000000000..a801841624d0 --- /dev/null +++ b/packages/bundler-plugins/test/rollup/source-map-handling.test.ts @@ -0,0 +1,74 @@ +import * as path from 'path'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createSentryBuildPluginManager } from '../../src/core/build-plugin-manager'; +import { createSourceMapHandling } from '../../src/rollup/source-map-handling'; +import type { OutputBundle } from '../../src/rollup/debug-id-stamping'; + +vi.mock('sentry', () => ({ + createSentrySDK: () => ({ + release: { create: vi.fn(), finalize: vi.fn(), 'set-commits': vi.fn(), deploy: vi.fn() }, + sourcemap: { upload: vi.fn(), inject: vi.fn() }, + run: vi.fn(), + }), +})); + +const DEBUG_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; +const OTHER_DEBUG_ID = '11111111-2222-3333-4444-555555555555'; +const MAP = JSON.stringify({ version: 3, sources: ['index.ts'], mappings: 'AAAA' }); + +function chunk(debugId: string): string { + return `console.log(1);\n;!function(){try{var e={},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}",e._sentryDebugIdIdentifier="sentry-dbid-${debugId}")}catch(e){}}();`; +} + +function bundleWith(debugId: string): OutputBundle { + return { + 'index.js': { type: 'chunk', fileName: 'index.js', code: chunk(debugId) }, + 'index.js.map': { type: 'asset', fileName: 'index.js.map', source: MAP }, + }; +} + +describe('createSourceMapHandling', () => { + const outputOptions = { dir: 'out' }; + let handling: ReturnType; + + beforeEach(() => { + const manager = createSentryBuildPluginManager( + { + authToken: 't', + org: 'o', + project: 'p', + release: { name: 'r', create: false, finalize: false }, + telemetry: false, + silent: true, + }, + { buildTool: 'rollup', loggerPrefix: '[test]' }, + ); + handling = createSourceMapHandling(manager); + handling.setDecision(path.resolve('out'), { enableHidden: true, deleteAfterUpload: true }); + }); + + it('hands the oldest staging to a writeBundle call without a bundle', async () => { + const bundle = bundleWith(DEBUG_ID); + await handling.stageSourceMaps(outputOptions, bundle, true); + expect(Object.keys(bundle)).toEqual(['index.js']); + + const staging = await handling.takeStaging(outputOptions, undefined); + + expect(staging?.count).toBe(1); + await staging?.discard(); + expect(await handling.takeStaging(outputOptions, undefined)).toBeUndefined(); + }); + + it('discards a staging whose chunks differ from the written bundle', async () => { + const stale = bundleWith(OTHER_DEBUG_ID); + await handling.stageSourceMaps(outputOptions, stale, true); + const current = bundleWith(DEBUG_ID); + await handling.stageSourceMaps(outputOptions, current, true); + + const staging = await handling.takeStaging(outputOptions, current); + + expect(staging?.count).toBe(1); + await staging?.discard(); + expect(await handling.takeStaging(outputOptions, current)).toBeUndefined(); + }); +}); diff --git a/packages/nuxt/src/vite/sourceMapDeletion.ts b/packages/nuxt/src/vite/sourceMapDeletion.ts index 7f9784e3bd37..874381d66b36 100644 --- a/packages/nuxt/src/vite/sourceMapDeletion.ts +++ b/packages/nuxt/src/vite/sourceMapDeletion.ts @@ -48,6 +48,7 @@ export function withoutSourceMapDeletion(options: Options): Options { sourcemaps: { ...options.sourcemaps, filesToDeleteAfterUpload: undefined, + deleteSourcemapsAfterUpload: false, }, }; } diff --git a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts index b6bc0b5109ba..ac10b8a871df 100644 --- a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts +++ b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts @@ -247,6 +247,7 @@ describe('setupSourceMaps hooks', () => { assets: undefined, ignore: undefined, filesToDeleteAfterUpload: undefined, + deleteSourcemapsAfterUpload: false, rewriteSources: expect.any(Function), }, }); diff --git a/packages/sveltekit/src/vite/sourceMaps.ts b/packages/sveltekit/src/vite/sourceMaps.ts index 7cfa82ed6f6d..7afd0ff088d1 100644 --- a/packages/sveltekit/src/vite/sourceMaps.ts +++ b/packages/sveltekit/src/vite/sourceMaps.ts @@ -77,6 +77,7 @@ export async function makeCustomSentryVitePlugins( sourcemaps: { ...options?.sourcemaps, filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise, + deleteSourcemapsAfterUpload: false, }, }; diff --git a/packages/sveltekit/test/vite/sourceMaps.test.ts b/packages/sveltekit/test/vite/sourceMaps.test.ts index d4f2d3c06db8..5c6134f71ca5 100644 --- a/packages/sveltekit/test/vite/sourceMaps.test.ts +++ b/packages/sveltekit/test/vite/sourceMaps.test.ts @@ -385,6 +385,7 @@ describe('deleteFilesAfterUpload', () => { }, sourcemaps: { filesToDeleteAfterUpload: expect.any(Promise), + deleteSourcemapsAfterUpload: false, }, }); @@ -463,6 +464,7 @@ describe('deleteFilesAfterUpload', () => { }, sourcemaps: { filesToDeleteAfterUpload: expect.any(Promise), + deleteSourcemapsAfterUpload: false, }, });