From 11d820237e21b93353201e4f1f1797dbd9ac6b2e Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 22 Oct 2026 01:28:29 +0100 Subject: [PATCH] feat(bundler-plugins): Enable hidden source maps and remove them after upload in the Rollup plugin When the Rollup or Rolldown output has no `sourcemap` setting and an upload can run, the plugin switches the output to hidden source maps, copies each chunk and its map into the upload staging folder from `generateBundle`, and drops the map asset so it is never written. `deleteSourcemapsAfterUpload: true` does the same for maps the user enabled. SvelteKit and Nuxt pass `deleteSourcemapsAfterUpload: false` because they need the map files on disk after the Vite build. The other framework SDKs set the bundler's source map option themselves, which the plugin never deletes, so their builds do not change. --- CHANGELOG.md | 17 + ...tiple-outputs-mixed-sourcemaps.config.d.ts | 2 + ...ultiple-outputs-mixed-sourcemaps.config.js | 7 + .../sourcemaps-assets-auto.config.d.ts | 2 + .../configs/sourcemaps-assets-auto.config.js | 10 + .../sourcemaps-ignore-auto.config.d.ts | 2 + .../configs/sourcemaps-ignore-auto.config.js | 10 + .../fixtures/rolldown/basic-cjs.test.ts | 1 + .../fixtures/rolldown/basic.test.ts | 1 + ...ultiple-outputs-mixed-sourcemaps.config.ts | 23 + .../multiple-outputs-mixed-sourcemaps.test.ts | 33 ++ .../rolldown/release-disabled.test.ts | 1 + .../rolldown/sourcemaps-assets-auto.config.ts | 12 + .../rolldown/sourcemaps-assets-auto.test.ts | 33 ++ .../rolldown/sourcemaps-ignore-auto.config.ts | 12 + .../rolldown/sourcemaps-ignore-auto.test.ts | 35 ++ .../fixtures/rollup3/basic-cjs.test.ts | 1 + .../fixtures/rollup3/basic.test.ts | 1 + .../fixtures/rollup3/release-disabled.test.ts | 1 + .../fixtures/rollup4/basic-cjs.test.ts | 1 + .../fixtures/rollup4/basic.test.ts | 1 + ...ultiple-outputs-mixed-sourcemaps.config.js | 23 + .../multiple-outputs-mixed-sourcemaps.test.ts | 33 ++ .../fixtures/rollup4/release-disabled.test.ts | 1 + .../rollup4/sourcemaps-assets-auto.config.js | 12 + .../rollup4/sourcemaps-assets-auto.test.ts | 33 ++ .../rollup4/sourcemaps-ignore-auto.config.js | 12 + .../rollup4/sourcemaps-ignore-auto.test.ts | 35 ++ .../fixtures/sentry-stub/index.cjs | 20 + .../src/core/build-plugin-manager.ts | 53 +- packages/bundler-plugins/src/core/index.ts | 1 + packages/bundler-plugins/src/core/types.ts | 3 + .../bundler-plugins/src/core/upload-filter.ts | 37 ++ packages/bundler-plugins/src/rollup/index.ts | 69 ++- .../src/rollup/source-map-handling.ts | 260 ++++++++++ ...nent-annotation-parser-unavailable.test.ts | 8 +- .../test/core/upload-filter.test.ts | 55 +++ .../test/core/upload-staging.test.ts | 129 ++++- .../delete-sourcemaps-after-upload.test.ts | 457 ++++++++++++++++++ .../test/rollup/public-api.test.ts | 10 +- .../test/rollup/source-map-handling.test.ts | 99 ++++ .../buildTimeOptionsBase.ts | 3 + packages/nuxt/src/vite/sourceMapDeletion.ts | 1 + .../test/vite/sourceMaps-nuxtHooks.test.ts | 1 + packages/sveltekit/src/vite/sourceMaps.ts | 1 + .../sveltekit/test/vite/sourceMaps.test.ts | 2 + 46 files changed, 1518 insertions(+), 46 deletions(-) create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/configs/multiple-outputs-mixed-sourcemaps.config.d.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/configs/multiple-outputs-mixed-sourcemaps.config.js create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-assets-auto.config.d.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-assets-auto.config.js create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-ignore-auto.config.d.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-ignore-auto.config.js create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/multiple-outputs-mixed-sourcemaps.config.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/multiple-outputs-mixed-sourcemaps.test.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-assets-auto.config.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-assets-auto.test.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-ignore-auto.config.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-ignore-auto.test.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/multiple-outputs-mixed-sourcemaps.config.js create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/multiple-outputs-mixed-sourcemaps.test.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-assets-auto.config.js create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-assets-auto.test.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-ignore-auto.config.js create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-ignore-auto.test.ts create mode 100644 packages/bundler-plugins/src/core/upload-filter.ts create mode 100644 packages/bundler-plugins/src/rollup/source-map-handling.ts create mode 100644 packages/bundler-plugins/test/core/upload-filter.test.ts create mode 100644 packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts create mode 100644 packages/bundler-plugins/test/rollup/source-map-handling.test.ts 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/configs/multiple-outputs-mixed-sourcemaps.config.d.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/multiple-outputs-mixed-sourcemaps.config.d.ts new file mode 100644 index 000000000000..192b614bc29b --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/multiple-outputs-mixed-sourcemaps.config.d.ts @@ -0,0 +1,2 @@ +import type { SentryRollupPluginOptions } from "@sentry/bundler-plugins/rollup"; +export declare const sentryConfig: SentryRollupPluginOptions; diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/configs/multiple-outputs-mixed-sourcemaps.config.js b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/multiple-outputs-mixed-sourcemaps.config.js new file mode 100644 index 000000000000..4013b86e95cf --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/multiple-outputs-mixed-sourcemaps.config.js @@ -0,0 +1,7 @@ +export const sentryConfig = { + telemetry: false, + authToken: "fake-auth", + org: "fake-org", + project: "fake-project", + release: { inject: false }, +}; diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-assets-auto.config.d.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-assets-auto.config.d.ts new file mode 100644 index 000000000000..192b614bc29b --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-assets-auto.config.d.ts @@ -0,0 +1,2 @@ +import type { SentryRollupPluginOptions } from "@sentry/bundler-plugins/rollup"; +export declare const sentryConfig: SentryRollupPluginOptions; diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-assets-auto.config.js b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-assets-auto.config.js new file mode 100644 index 000000000000..fd2a5648a631 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-assets-auto.config.js @@ -0,0 +1,10 @@ +export const sentryConfig = { + telemetry: false, + authToken: "fake-auth", + org: "fake-org", + project: "fake-project", + release: { inject: false }, + sourcemaps: { + assets: ["out/sourcemaps-assets-auto/entry1.js"], + }, +}; diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-ignore-auto.config.d.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-ignore-auto.config.d.ts new file mode 100644 index 000000000000..192b614bc29b --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-ignore-auto.config.d.ts @@ -0,0 +1,2 @@ +import type { SentryRollupPluginOptions } from "@sentry/bundler-plugins/rollup"; +export declare const sentryConfig: SentryRollupPluginOptions; diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-ignore-auto.config.js b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-ignore-auto.config.js new file mode 100644 index 000000000000..2c6890272dfd --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-ignore-auto.config.js @@ -0,0 +1,10 @@ +export const sentryConfig = { + telemetry: false, + authToken: "fake-auth", + org: "fake-org", + project: "fake-project", + release: { inject: false }, + sourcemaps: { + ignore: ["**/entry2.js"], + }, +}; 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/multiple-outputs-mixed-sourcemaps.config.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/multiple-outputs-mixed-sourcemaps.config.ts new file mode 100644 index 000000000000..f8e7026e7182 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/multiple-outputs-mixed-sourcemaps.config.ts @@ -0,0 +1,23 @@ +import { sentryRollupPlugin } from "@sentry/bundler-plugins/rollup"; +import { defineConfig } from "rolldown"; +import { sentryConfig } from "../configs/multiple-outputs-mixed-sourcemaps.config.js"; + +export default defineConfig({ + input: ["src/entry1.js", "src/entry2.js"], + output: [ + { + dir: "out/multiple-outputs-mixed-sourcemaps", + format: "es", + entryFileNames: "[name].mjs", + chunkFileNames: "[name].mjs", + sourcemap: true, + }, + { + dir: "out/multiple-outputs-mixed-sourcemaps", + format: "cjs", + entryFileNames: "[name].cjs", + chunkFileNames: "[name].cjs", + }, + ], + plugins: [sentryRollupPlugin(sentryConfig)], +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/multiple-outputs-mixed-sourcemaps.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/multiple-outputs-mixed-sourcemaps.test.ts new file mode 100644 index 000000000000..0f124b15f94d --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/multiple-outputs-mixed-sourcemaps.test.ts @@ -0,0 +1,33 @@ +import { expect } from "vitest"; +import { test } from "./utils"; + +test(import.meta.url, ({ runBundler, readOutputFiles }) => { + runBundler(); + const files = readOutputFiles(); + + expect(Object.keys(files).sort()).toMatchInlineSnapshot(` + [ + "common.cjs", + "common.mjs", + "common.mjs.map", + "entry1.cjs", + "entry1.mjs", + "entry1.mjs.map", + "entry2.cjs", + "entry2.mjs", + "entry2.mjs.map", + "sentry-cli-mock.json", + ] + `); + expect(files["sentry-cli-mock.json"]).toMatchInlineSnapshot(` + "["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"], + ["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/rolldown/sourcemaps-assets-auto.config.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-assets-auto.config.ts new file mode 100644 index 000000000000..ee6f81fe2225 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-assets-auto.config.ts @@ -0,0 +1,12 @@ +import { sentryRollupPlugin } from "@sentry/bundler-plugins/rollup"; +import { defineConfig } from "rolldown"; +import { sentryConfig } from "../configs/sourcemaps-assets-auto.config.js"; + +export default defineConfig({ + input: ["src/entry1.js", "src/entry2.js"], + output: { + dir: "out/sourcemaps-assets-auto", + chunkFileNames: "[name].js", + }, + plugins: [sentryRollupPlugin(sentryConfig)], +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-assets-auto.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-assets-auto.test.ts new file mode 100644 index 000000000000..0b087b24d6c1 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-assets-auto.test.ts @@ -0,0 +1,33 @@ +import { expect } from "vitest"; +import { test } from "./utils"; + +test(import.meta.url, ({ runBundler, readOutputFiles }) => { + runBundler({ SENTRY_TEST_RECORD_UPLOADED_SOURCES: "1" }); + const files = readOutputFiles(); + + expect(Object.keys(files).sort()).toMatchInlineSnapshot(` + [ + "common.js", + "common.js.map", + "entry1.js", + "entry2.js", + "entry2.js.map", + "sentry-cli-mock.json", + "sentry-uploaded-sources.json", + ] + `); + expect(JSON.parse(files["sentry-uploaded-sources.json"]!)).toMatchInlineSnapshot(` + [ + [ + "../../src/entry1.js", + ], + ] + `); + expect(files["sentry-cli-mock.json"]).toMatchInlineSnapshot(` + "["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/sourcemaps-ignore-auto.config.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-ignore-auto.config.ts new file mode 100644 index 000000000000..f5b82becb477 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-ignore-auto.config.ts @@ -0,0 +1,12 @@ +import { sentryRollupPlugin } from "@sentry/bundler-plugins/rollup"; +import { defineConfig } from "rolldown"; +import { sentryConfig } from "../configs/sourcemaps-ignore-auto.config.js"; + +export default defineConfig({ + input: ["src/entry1.js", "src/entry2.js"], + output: { + dir: "out/sourcemaps-ignore-auto", + chunkFileNames: "[name].js", + }, + plugins: [sentryRollupPlugin(sentryConfig)], +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-ignore-auto.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-ignore-auto.test.ts new file mode 100644 index 000000000000..c1bfd1eb7df0 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-ignore-auto.test.ts @@ -0,0 +1,35 @@ +import { expect } from "vitest"; +import { test } from "./utils"; + +test(import.meta.url, ({ runBundler, readOutputFiles }) => { + runBundler({ SENTRY_TEST_RECORD_UPLOADED_SOURCES: "1" }); + const files = readOutputFiles(); + + expect(Object.keys(files).sort()).toMatchInlineSnapshot(` + [ + "common.js", + "entry1.js", + "entry2.js", + "entry2.js.map", + "sentry-cli-mock.json", + "sentry-uploaded-sources.json", + ] + `); + expect(JSON.parse(files["sentry-uploaded-sources.json"]!)).toMatchInlineSnapshot(` + [ + [ + "../../src/common.js", + ], + [ + "../../src/entry1.js", + ], + ] + `); + expect(files["sentry-cli-mock.json"]).toMatchInlineSnapshot(` + "["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-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/multiple-outputs-mixed-sourcemaps.config.js b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/multiple-outputs-mixed-sourcemaps.config.js new file mode 100644 index 000000000000..db32173f1382 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/multiple-outputs-mixed-sourcemaps.config.js @@ -0,0 +1,23 @@ +import { sentryRollupPlugin } from "@sentry/bundler-plugins/rollup"; +import { defineConfig } from "rollup"; +import { sentryConfig } from "../configs/multiple-outputs-mixed-sourcemaps.config.js"; + +export default defineConfig({ + input: ["src/entry1.js", "src/entry2.js"], + output: [ + { + dir: "out/multiple-outputs-mixed-sourcemaps", + format: "es", + entryFileNames: "[name].mjs", + chunkFileNames: "[name].mjs", + sourcemap: true, + }, + { + dir: "out/multiple-outputs-mixed-sourcemaps", + format: "cjs", + entryFileNames: "[name].cjs", + chunkFileNames: "[name].cjs", + }, + ], + plugins: [sentryRollupPlugin(sentryConfig)], +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/multiple-outputs-mixed-sourcemaps.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/multiple-outputs-mixed-sourcemaps.test.ts new file mode 100644 index 000000000000..ffdb7b2e4146 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/multiple-outputs-mixed-sourcemaps.test.ts @@ -0,0 +1,33 @@ +import { expect } from "vitest"; +import { test } from "./utils"; + +test(import.meta.url, ({ runBundler, readOutputFiles }) => { + runBundler(); + const files = readOutputFiles(); + + expect(Object.keys(files).sort()).toMatchInlineSnapshot(` + [ + "common.cjs", + "common.mjs", + "common.mjs.map", + "entry1.cjs", + "entry1.mjs", + "entry1.mjs.map", + "entry2.cjs", + "entry2.mjs", + "entry2.mjs.map", + "sentry-cli-mock.json", + ] + `); + expect(files["sentry-cli-mock.json"]).toMatchInlineSnapshot(` + "["release","create","CURRENT_SHA","--project","fake-project"], + ["release","set-commits","CURRENT_SHA","--auto"], + ["release","finalize","CURRENT_SHA"], + ["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"], + ["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/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-assets-auto.config.js b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-assets-auto.config.js new file mode 100644 index 000000000000..5ea2b86e6cea --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-assets-auto.config.js @@ -0,0 +1,12 @@ +import { sentryRollupPlugin } from "@sentry/bundler-plugins/rollup"; +import { defineConfig } from "rollup"; +import { sentryConfig } from "../configs/sourcemaps-assets-auto.config.js"; + +export default defineConfig({ + input: ["src/entry1.js", "src/entry2.js"], + output: { + dir: "out/sourcemaps-assets-auto", + chunkFileNames: "[name].js", + }, + plugins: [sentryRollupPlugin(sentryConfig)], +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-assets-auto.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-assets-auto.test.ts new file mode 100644 index 000000000000..0b087b24d6c1 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-assets-auto.test.ts @@ -0,0 +1,33 @@ +import { expect } from "vitest"; +import { test } from "./utils"; + +test(import.meta.url, ({ runBundler, readOutputFiles }) => { + runBundler({ SENTRY_TEST_RECORD_UPLOADED_SOURCES: "1" }); + const files = readOutputFiles(); + + expect(Object.keys(files).sort()).toMatchInlineSnapshot(` + [ + "common.js", + "common.js.map", + "entry1.js", + "entry2.js", + "entry2.js.map", + "sentry-cli-mock.json", + "sentry-uploaded-sources.json", + ] + `); + expect(JSON.parse(files["sentry-uploaded-sources.json"]!)).toMatchInlineSnapshot(` + [ + [ + "../../src/entry1.js", + ], + ] + `); + expect(files["sentry-cli-mock.json"]).toMatchInlineSnapshot(` + "["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/sourcemaps-ignore-auto.config.js b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-ignore-auto.config.js new file mode 100644 index 000000000000..a71f1098f713 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-ignore-auto.config.js @@ -0,0 +1,12 @@ +import { sentryRollupPlugin } from "@sentry/bundler-plugins/rollup"; +import { defineConfig } from "rollup"; +import { sentryConfig } from "../configs/sourcemaps-ignore-auto.config.js"; + +export default defineConfig({ + input: ["src/entry1.js", "src/entry2.js"], + output: { + dir: "out/sourcemaps-ignore-auto", + chunkFileNames: "[name].js", + }, + plugins: [sentryRollupPlugin(sentryConfig)], +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-ignore-auto.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-ignore-auto.test.ts new file mode 100644 index 000000000000..c1bfd1eb7df0 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-ignore-auto.test.ts @@ -0,0 +1,35 @@ +import { expect } from "vitest"; +import { test } from "./utils"; + +test(import.meta.url, ({ runBundler, readOutputFiles }) => { + runBundler({ SENTRY_TEST_RECORD_UPLOADED_SOURCES: "1" }); + const files = readOutputFiles(); + + expect(Object.keys(files).sort()).toMatchInlineSnapshot(` + [ + "common.js", + "entry1.js", + "entry2.js", + "entry2.js.map", + "sentry-cli-mock.json", + "sentry-uploaded-sources.json", + ] + `); + expect(JSON.parse(files["sentry-uploaded-sources.json"]!)).toMatchInlineSnapshot(` + [ + [ + "../../src/common.js", + ], + [ + "../../src/entry1.js", + ], + ] + `); + expect(files["sentry-cli-mock.json"]).toMatchInlineSnapshot(` + "["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/sentry-stub/index.cjs b/dev-packages/bundler-plugin-integration-tests/fixtures/sentry-stub/index.cjs index cd449620c86d..fd2c7a2bbb3f 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/sentry-stub/index.cjs +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/sentry-stub/index.cjs @@ -27,6 +27,25 @@ function record(args) { fs.appendFileSync(out, `${JSON.stringify(args)},\n`); } +/** + * Write the `sources` of every source map in the upload directory to `sentry-uploaded-sources.json`. + * The plugin renames uploaded files to `-.js`, so only the `sources` tell which + * chunks were uploaded. Opt-in through `SENTRY_TEST_RECORD_UPLOADED_SOURCES` so other snapshots + * stay unchanged. + */ +function recordUploadedSources(directory) { + const outDir = process.env['SENTRY_TEST_OUT_DIR']; + if (!outDir || !process.env['SENTRY_TEST_RECORD_UPLOADED_SOURCES'] || !fs.existsSync(directory)) { + return; + } + const sources = fs + .readdirSync(directory) + .filter(file => file.endsWith('.map')) + .map(file => JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8')).sources) + .sort((a, b) => String(a).localeCompare(String(b))); + fs.writeFileSync(path.join(outDir, 'sentry-uploaded-sources.json'), `${JSON.stringify(sources, null, 2)}\n`); +} + /** Append boolean flags (only when true) and value flags (only when set) to an args array. */ function pushFlags(args, flags, spec) { for (const [key, flag] of Object.entries(spec)) { @@ -83,6 +102,7 @@ function createSentrySDK(options = {}) { pushFlags(args, params, { release: '--release', dist: '--dist' }); if (params.directory) { args.push(params.directory); + recordUploadedSources(params.directory); } pushFlags(args, params, { ext: '--ext', diff --git a/packages/bundler-plugins/src/core/build-plugin-manager.ts b/packages/bundler-plugins/src/core/build-plugin-manager.ts index f4ccb991df5b..92c24154f2a1 100644 --- a/packages/bundler-plugins/src/core/build-plugin-manager.ts +++ b/packages/bundler-plugins/src/core/build-plugin-manager.ts @@ -21,6 +21,8 @@ import { import { globFiles } from './glob'; import type { SourceMapDecision, SourceMapKind } from './sourcemap-settings'; import { decideSourceMapHandling } from './sourcemap-settings'; +import type { UploadFilter } from './upload-filter'; +import { createUploadFilter } from './upload-filter'; import { LIB_VERSION } from './version'; // Module-level guard to prevent duplicate deploy records when multiple bundler plugin @@ -52,14 +54,15 @@ export type UploadStaging = { * Stamps the debug ID into the chunk and its source map and writes both into the folder. * * `mapSource` is `undefined` when the chunk has no separate source map. `mapDir` is the directory - * the map would be written to and is passed to the `rewriteSources` hook. + * the map would be written to and is passed to the `rewriteSources` hook. `chunkPath` is where + * the chunk is written; `sourcemaps.assets` and `sourcemaps.ignore` are matched against it. */ stage(input: { code: string; mapSource: string | undefined; mapDir: string; - fileName: string; - }): Promise<'staged' | 'skipped' | 'failed'>; + chunkPath: string; + }): Promise<'staged' | 'skipped' | 'excluded' | 'failed'>; /** Removes the folder. Call this when no upload will consume it. */ discard(): Promise; /** @@ -142,9 +145,17 @@ export type SentryBuildPluginManager = { uploadPossible: boolean; /** * Decides how the plugin treats the user's source map setting (see `decideSourceMapHandling`). - * Logs the decision's message once per message. + * Logs the decision's message once per message, unless `log` is `false`. */ - decide(kind: SourceMapKind): SourceMapDecision; + decide(kind: SourceMapKind, options?: { log?: boolean }): SourceMapDecision; + /** Logs the decision's message, once per message. */ + logDecision(decision: SourceMapDecision): void; + /** + * Whether a file of the build output is uploaded, for files that are not on disk yet. Mirrors + * the glob of the upload from disk: it must match `sourcemaps.assets` (all files when unset) + * and none of `sourcemaps.ignore`. + */ + shouldUpload: UploadFilter; }; /** @@ -236,6 +247,8 @@ export function createSentryBuildPluginManager( sourceMaps: { uploadPossible: false, decide: () => ({ enableHidden: false, deleteAfterUpload: false }), + logDecision: () => undefined, + shouldUpload: () => false, }, deleteArtifacts: async () => { /* noop */ @@ -364,6 +377,14 @@ export function createSentryBuildPluginManager( const sourceMapUploadPossible = getSourceMapUploadBlocker(options, isDevMode) === undefined; const loggedSourceMapMessages = new Set(); + const shouldUpload = createUploadFilter(options); + + function logDecision(decision: SourceMapDecision): void { + if (decision.log && !loggedSourceMapMessages.has(decision.log.message)) { + loggedSourceMapMessages.add(decision.log.message); + logger[decision.log.level](decision.log.message); + } + } /** * Returns a Promise that resolves when all the currently active dependencies are freed again. @@ -871,6 +892,15 @@ export function createSentryBuildPluginManager( }, stagedChunkPaths, async stage(input) { + const chunkPath = path.resolve(input.chunkPath); + const chunkName = path.basename(chunkPath); + if (!shouldUpload(chunkPath)) { + logger.debug( + `Not staging ${chunkName} for upload: excluded by \`sourcemaps.assets\` or \`sourcemaps.ignore\`.`, + ); + return 'excluded'; + } + const result = prepareArtifactForUpload({ code: input.code, mapSource: input.mapSource, @@ -879,7 +909,7 @@ export function createSentryBuildPluginManager( }); if (!result.ok) { - logger.debug(`Not staging ${input.fileName} for upload: ${result.reason}`); + logger.debug(`Not staging ${chunkName} for upload: ${result.reason}`); return 'skipped'; } @@ -887,7 +917,7 @@ export function createSentryBuildPluginManager( try { await writePreparedArtifact(dir, `${result.artifact.debugId}-${index}`, result.artifact); - stagedChunkPaths.add(path.resolve(input.mapDir, path.basename(input.fileName))); + stagedChunkPaths.add(chunkPath); return 'staged'; } catch (e) { sentryScope.captureException('Error staging build artifact for upload'); @@ -919,14 +949,15 @@ export function createSentryBuildPluginManager( sourceMaps: { uploadPossible: sourceMapUploadPossible, - decide(kind) { + decide(kind, { log = true } = {}) { const decision = decideSourceMapHandling(kind, options, sourceMapUploadPossible); - if (decision.log && !loggedSourceMapMessages.has(decision.log.message)) { - loggedSourceMapMessages.add(decision.log.message); - logger[decision.log.level](decision.log.message); + if (log) { + logDecision(decision); } return decision; }, + logDecision, + shouldUpload, }, /** diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts index e46caaaec203..f19fe4c5642a 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -114,3 +114,4 @@ export type { SentryBuildPluginManager, UploadStaging } from './build-plugin-man export { createDebugIdUploadFunction, addDebugIdToEmittedArtifacts, stampDebugId } from './debug-id-upload'; export { decideSourceMapHandling } from './sourcemap-settings'; export type { SourceMapDecision, SourceMapKind } from './sourcemap-settings'; +export type { UploadFilter } from './upload-filter'; diff --git a/packages/bundler-plugins/src/core/types.ts b/packages/bundler-plugins/src/core/types.ts index 5712cf3cfa5b..b112fb888236 100644 --- a/packages/bundler-plugins/src/core/types.ts +++ b/packages/bundler-plugins/src/core/types.ts @@ -200,6 +200,9 @@ export interface Options { * setting was not set. `true` also removes source maps you enabled yourself. `false` never removes them; the plugin * still turns on hidden source maps when the setting is not set, so they can be uploaded. * + * Only the source maps of JavaScript chunks are removed. Chunks that `sourcemaps.assets` or `sourcemaps.ignore` + * exclude are not uploaded and keep their source maps. + * * With Rollup, Vite and webpack the removed source maps are never written to the output directory. With esbuild * they are deleted after the upload. */ diff --git a/packages/bundler-plugins/src/core/upload-filter.ts b/packages/bundler-plugins/src/core/upload-filter.ts new file mode 100644 index 000000000000..68e16d34d111 --- /dev/null +++ b/packages/bundler-plugins/src/core/upload-filter.ts @@ -0,0 +1,37 @@ +import * as path from 'path'; +import { Ignore } from 'glob'; +import type { NormalizedOptions } from './options-mapping'; + +export type UploadFilter = (filePath: string) => boolean; + +/** + * Mirrors the glob of the upload from disk for files that are not on disk yet: a file is + * uploaded when it matches one of `sourcemaps.assets` (all files when unset) and none of + * `sourcemaps.ignore`. + */ +export function createUploadFilter(options: NormalizedOptions): UploadFilter { + const assets = options.sourcemaps?.assets; + const matchesAssets = assets === undefined ? () => true : createMatcher(toArray(assets)); + const ignore = toArray(options.sourcemaps?.ignore ?? []); + const matchesIgnore = ignore.length === 0 ? () => false : createMatcher(ignore); + + return filePath => matchesAssets(filePath) && !matchesIgnore(filePath); +} + +// `glob` exports the matcher behind its `ignore` option. It resolves relative patterns against +// the working directory and accepts absolute ones, so both options follow the rules of the +// upload from disk. +function createMatcher(patterns: string[]): UploadFilter { + const matcher = new Ignore(patterns, { nocase: process.platform === 'darwin' || process.platform === 'win32' }); + const cwd = process.cwd(); + return filePath => { + const fullpath = path.resolve(filePath); + // Like path-scurry, a file outside the working directory keeps its full path. + const relative = fullpath.startsWith(cwd + path.sep) ? fullpath.slice(cwd.length + 1) : fullpath; + return matcher.ignored({ fullpath: () => fullpath, relative: () => relative } as Parameters[0]); + }; +} + +function toArray(patterns: string | string[]): string[] { + return Array.isArray(patterns) ? patterns : [patterns]; +} 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..1f47aef12c34 --- /dev/null +++ b/packages/bundler-plugins/src/rollup/source-map-handling.ts @@ -0,0 +1,260 @@ +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 }; + +/** + * The output options that tell outputs of one directory apart. Several outputs can write into the + * same directory at once, for example an ES and a CJS build, each with its own `sourcemap` setting. + */ +export type OutputMatch = { format?: unknown; entryFileNames?: unknown; chunkFileNames?: unknown; file?: unknown }; + +export type OutputOptions = OutputDestinationOptions & OutputMatch & { sourcemap?: RollupSourceMapSetting }; + +/** 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 MATCH_KEYS = ['format', 'entryFileNames', 'chunkFileNames', 'file'] as const; + +/** The raw options accept aliases that the normalized options do not carry. */ +function normalizeFormat(format: unknown): unknown { + switch (format) { + case 'esm': + case 'module': + return 'es'; + case 'commonjs': + return 'cjs'; + case 'systemjs': + return 'system'; + default: + return format; + } +} + +/** The fields of `OutputMatch` an output sets, in the form the normalized options carry them. */ +export function getOutputMatch(outputOptions: OutputMatch): OutputMatch { + const match: OutputMatch = {}; + for (const key of MATCH_KEYS) { + const value = outputOptions[key]; + if (value === undefined) { + continue; + } + match[key] = key === 'format' ? normalizeFormat(value) : key === 'file' ? path.resolve(String(value)) : value; + } + return match; +} + +function matchesOutput(match: OutputMatch, outputOptions: OutputMatch): boolean { + const actual = getOutputMatch(outputOptions); + return MATCH_KEYS.every(key => match[key] === undefined || match[key] === actual[key]); +} + +function isSameMatch(a: OutputMatch, b: OutputMatch): boolean { + return MATCH_KEYS.every(key => a[key] === b[key]); +} + +function countMatchFields(match: OutputMatch): number { + return MATCH_KEYS.filter(key => match[key] !== undefined).length; +} + +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'); +} + +function getSourceMapFileName(output: { fileName: string; sourcemapFileName?: string | null }): string { + return output.sourcemapFileName ?? `${output.fileName}.map`; +} + +type PendingStaging = { staging: UploadStaging; signature: string; match: OutputMatch }; + +export type DecisionRecord = { match: OutputMatch; kind: SourceMapKind; decision: SourceMapDecision }; + +export type SourceMapHandling = { + /** Rollup's `outputOptions` hook: decides for the output and switches it to hidden source maps when needed. */ + outputOptions: (rawOutputOptions: OutputOptions) => OutputOptions | null; + /** Records the decision for an output, for bundlers that decide outside `outputOptions`. */ + setDecision: (destination: string, record: DecisionRecord) => 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: OutputOptions, bundle: OutputBundle, isWrite?: boolean) => Promise; + /** + * Hands the staging of this bundle to `writeBundle`. 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. Outputs that share a directory are told apart by `OutputMatch`. + */ +export function createSourceMapHandling(sentryBuildPluginManager: SentryBuildPluginManager): SourceMapHandling { + const decisions = new Map(); + const pendingStagings = new Map(); + + function setDecision(destination: string, record: DecisionRecord): void { + const records = decisions.get(destination) ?? []; + const index = records.findIndex(existing => isSameMatch(existing.match, record.match)); + if (index === -1) { + records.push(record); + } else { + records[index] = record; + } + decisions.set(destination, records); + } + + function findDecision(destination: string, outputOptions: OutputOptions): DecisionRecord | undefined { + const candidates = (decisions.get(destination) ?? []).filter(record => matchesOutput(record.match, outputOptions)); + // The record that sets the most fields describes this output best. Equal + // candidates keep their order, so the first one recorded wins. + return candidates.sort((a, b) => countMatchFields(b.match) - countMatchFields(a.match))[0]; + } + + function dropSourceMaps(bundle: OutputBundle): void { + for (const output of Object.values(bundle)) { + if (output.type === 'chunk' && isJsFile(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)]; + } + } + } + + return { + outputOptions: rawOutputOptions => { + const kind = sourceMapKindFromRollupSetting(rawOutputOptions.sourcemap); + // Logged in `generateBundle`, which knows whether the output is written. + const decision = sentryBuildPluginManager.sourceMaps.decide(kind, { log: false }); + setDecision(getOutputDestination(rawOutputOptions), { match: getOutputMatch(rawOutputOptions), kind, decision }); + + return decision.enableHidden ? { ...rawOutputOptions, sourcemap: 'hidden' } : null; + }, + + setDecision, + + stageSourceMaps: async (outputOptions, bundle, isWrite) => { + const destination = getOutputDestination(outputOptions); + const record = findDecision(destination, outputOptions); + if (!record) { + return; + } + + // `generate()` never reaches `writeBundle`, so nothing would upload a staging. Maps the + // plugin turned on are dropped, so the result holds what the user configured. + if (isWrite === false) { + if (record.kind === 'unset' && record.decision.enableHidden) { + dropSourceMaps(bundle); + } + return; + } + + sentryBuildPluginManager.sourceMaps.logDecision(record.decision); + if (!record.decision.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 = getSourceMapFileName(output); + 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(mapFileName)), + chunkPath: path.join(destination, 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 if (result !== 'excluded') { + 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; + } + + // A staging of the same output that no `writeBundle` took belongs to a + // write that never finished. Stagings of other outputs to this directory + // may still be in flight, so the shared list changes in place and without + // an await in between. + const stagings = pendingStagings.get(destination) ?? []; + pendingStagings.set(destination, stagings); + const superseded = stagings.filter(pending => isSameMatch(pending.match, record.match)); + for (const pending of superseded) { + stagings.splice(stagings.indexOf(pending), 1); + } + stagings.push({ staging, signature: getBundleSignature(bundle), match: record.match }); + + for (const pending of superseded) { + sentryBuildPluginManager.logger.debug( + `Discarding staged source maps of an earlier build of ${destination} that was never written.`, + ); + await pending.staging.discard(); + } + }, + + takeStaging: async (outputOptions, bundle) => { + const destination = getOutputDestination(outputOptions); + const stagings = pendingStagings.get(destination) ?? []; + + if (!bundle) { + return stagings.shift()?.staging; + } + + const signature = getBundleSignature(bundle); + const index = stagings.findIndex(pending => pending.signature === signature); + return index === -1 ? undefined : stagings.splice(index, 1)[0]?.staging; + }, + }; +} diff --git a/packages/bundler-plugins/test/core/component-annotation-parser-unavailable.test.ts b/packages/bundler-plugins/test/core/component-annotation-parser-unavailable.test.ts index fc60b926ddf9..0a1b1212b3d3 100644 --- a/packages/bundler-plugins/test/core/component-annotation-parser-unavailable.test.ts +++ b/packages/bundler-plugins/test/core/component-annotation-parser-unavailable.test.ts @@ -11,6 +11,10 @@ vi.mock('../../src/core/component-annotation-oxc', async importOriginal => { const MESSAGE = 'Could not load `oxc-parser` for this platform. React components will not be annotated.'; +// Each test imports the whole core module graph afresh after `resetModules`, +// which takes several seconds on a slow CI runner. +const IMPORT_TIMEOUT = 30_000; + describe('createComponentNameAnnotateHooks without a parser', () => { beforeEach(() => { vi.resetModules(); @@ -20,7 +24,7 @@ describe('createComponentNameAnnotateHooks without a parser', () => { vi.restoreAllMocks(); }); - it('leaves files unchanged and warns once through the logger', async () => { + it('leaves files unchanged and warns once through the logger', { timeout: IMPORT_TIMEOUT }, async () => { const { createComponentNameAnnotateHooks } = await import('../../src/core'); const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; const { transform } = createComponentNameAnnotateHooks([], false, { logger }); @@ -32,7 +36,7 @@ describe('createComponentNameAnnotateHooks without a parser', () => { expect(logger.warn).toHaveBeenCalledWith(MESSAGE); }); - it('warns once through the console across hooks created without a logger', async () => { + it('warns once through the console across hooks created without a logger', { timeout: IMPORT_TIMEOUT }, async () => { const { createComponentNameAnnotateHooks } = await import('../../src/core'); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/packages/bundler-plugins/test/core/upload-filter.test.ts b/packages/bundler-plugins/test/core/upload-filter.test.ts new file mode 100644 index 000000000000..623c78f4c03f --- /dev/null +++ b/packages/bundler-plugins/test/core/upload-filter.test.ts @@ -0,0 +1,55 @@ +import * as path from 'path'; +import { describe, it, expect } from 'vitest'; +import { createUploadFilter } from '../../src/core/upload-filter'; +import { normalizeUserOptions } from '../../src/core/options-mapping'; + +function filter(sourcemaps: { assets?: string | string[]; ignore?: string | string[] }) { + return createUploadFilter(normalizeUserOptions({ sourcemaps })); +} + +const inDist = path.resolve('dist/app/main.js'); +const vendor = path.resolve('dist/app/vendor-abc.js'); +const elsewhere = path.resolve('other/main.js'); + +describe('createUploadFilter', () => { + it('accepts every file when neither option is set', () => { + expect(filter({})(inDist)).toBe(true); + expect(filter({})(elsewhere)).toBe(true); + }); + + it('accepts only files matching `assets`, resolved against the working directory', () => { + const shouldUpload = filter({ assets: 'dist/**' }); + expect(shouldUpload(inDist)).toBe(true); + expect(shouldUpload('dist/app/main.js')).toBe(true); + expect(shouldUpload(elsewhere)).toBe(false); + }); + + it('accepts files matching any of several `assets` patterns', () => { + const shouldUpload = filter({ assets: ['other/*.js', path.resolve('dist/**/*.js')] }); + expect(shouldUpload(inDist)).toBe(true); + expect(shouldUpload(elsewhere)).toBe(true); + expect(shouldUpload(path.resolve('dist/app/styles.css'))).toBe(false); + }); + + it('rejects everything for an empty `assets` list', () => { + expect(filter({ assets: [] })(inDist)).toBe(false); + }); + + it('matches `**` patterns against files outside the working directory, like `glob`', () => { + const outside = path.join(path.parse(process.cwd()).root, 'tmp', 'build', 'vendor-abc.js'); + expect(filter({ ignore: '**/vendor-*.js' })(outside)).toBe(false); + expect(filter({ ignore: 'vendor-*.js' })(outside)).toBe(true); + }); + + it('rejects files matching `ignore`', () => { + const shouldUpload = filter({ ignore: ['**/vendor-*.js'] }); + expect(shouldUpload(inDist)).toBe(true); + expect(shouldUpload(vendor)).toBe(false); + }); + + it('applies `ignore` after `assets`', () => { + const shouldUpload = filter({ assets: 'dist/**', ignore: 'dist/app/vendor-*.js' }); + expect(shouldUpload(inDist)).toBe(true); + expect(shouldUpload(vendor)).toBe(false); + }); +}); diff --git a/packages/bundler-plugins/test/core/upload-staging.test.ts b/packages/bundler-plugins/test/core/upload-staging.test.ts index ab87bf4f133f..29437eff7c0e 100644 --- a/packages/bundler-plugins/test/core/upload-staging.test.ts +++ b/packages/bundler-plugins/test/core/upload-staging.test.ts @@ -65,7 +65,7 @@ describe('createUploadStaging', () => { code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', - fileName: 'index.js', + chunkPath: path.join('/out', 'index.js'), }); expect(result).toBe('staged'); @@ -86,7 +86,12 @@ describe('createUploadStaging', () => { const staging = await manager.createUploadStaging(); try { - await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out/assets', fileName: 'a.js' }); + await staging.stage({ + code: chunk(DEBUG_ID), + mapSource: MAP, + mapDir: '/out/assets', + chunkPath: path.join('/out/assets', 'a.js'), + }); } finally { await staging.discard(); } @@ -99,11 +104,21 @@ describe('createUploadStaging', () => { const staging = await manager.createUploadStaging(); try { - expect(await staging.stage({ code: 'console.log(1)', mapSource: MAP, mapDir: '/out', fileName: 'a.js' })).toBe( - 'skipped', - ); expect( - await staging.stage({ code: chunk(DEBUG_ID), mapSource: undefined, mapDir: '/out', fileName: 'b.js' }), + await staging.stage({ + code: 'console.log(1)', + mapSource: MAP, + mapDir: '/out', + chunkPath: path.join('/out', 'a.js'), + }), + ).toBe('skipped'); + expect( + await staging.stage({ + code: chunk(DEBUG_ID), + mapSource: undefined, + mapDir: '/out', + chunkPath: path.join('/out', 'b.js'), + }), ).toBe('skipped'); expect(staging.count).toBe(0); expect(fs.readdirSync(staging.dir)).toEqual([]); @@ -112,11 +127,53 @@ describe('createUploadStaging', () => { } }); + it('does not stage chunks excluded by `sourcemaps.ignore` and leaves them uncounted', async () => { + const manager = createSentryBuildPluginManager( + { + authToken: 't', + org: 'o', + project: 'p', + release: { name: 'r', create: false, finalize: false }, + telemetry: false, + sourcemaps: { ignore: ['**/vendor.js'] }, + }, + { buildTool: 'rollup', loggerPrefix: '[test]' }, + ); + const staging = await manager.createUploadStaging(); + + await expect( + staging.stage({ + code: chunk(DEBUG_ID), + mapSource: MAP, + mapDir: tmpDir, + chunkPath: path.join(tmpDir, 'vendor.js'), + }), + ).resolves.toBe('excluded'); + await expect( + staging.stage({ + code: chunk(OTHER_DEBUG_ID), + mapSource: MAP, + mapDir: tmpDir, + chunkPath: path.join(tmpDir, 'main.js'), + }), + ).resolves.toBe('staged'); + + expect(staging.count).toBe(1); + expect(staging.stagedChunkPaths.has(path.join(tmpDir, 'vendor.js'))).toBe(false); + expect(fs.readdirSync(staging.dir).sort()).toEqual([`${OTHER_DEBUG_ID}-0.js`, `${OTHER_DEBUG_ID}-0.js.map`]); + await staging.discard(); + }); + it('removes the folder on discard', async () => { const manager = createManager(); const staging = await manager.createUploadStaging(); - await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' }); + await staging.stage({ + code: chunk(DEBUG_ID), + mapSource: MAP, + mapDir: '/out', + chunkPath: path.join('/out', 'a.js'), + }); await staging.discard(); await staging.discard(); @@ -138,7 +195,12 @@ describe('createUploadStaging', () => { const manager = createManager(); const errorSpy = vi.spyOn(manager.logger, 'error'); const staging = await manager.createUploadStaging(); - await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' }); + await staging.stage({ + code: chunk(DEBUG_ID), + mapSource: MAP, + mapDir: '/out', + chunkPath: path.join('/out', 'a.js'), + }); try { await staging.keep('The upload did not run.'); @@ -170,7 +232,12 @@ describe('createUploadStaging', () => { it('uploads the staging folder and removes it afterwards', async () => { const manager = createManager(); const staging = await manager.createUploadStaging(); - await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' }); + await staging.stage({ + code: chunk(DEBUG_ID), + mapSource: MAP, + mapDir: '/out', + chunkPath: path.join('/out', 'a.js'), + }); const result = await manager.uploadSourcemaps([], { staging }); @@ -187,7 +254,12 @@ describe('createUploadStaging', () => { ); const manager = createManager(); const staging = await manager.createUploadStaging(); - await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: tmpDir, fileName: 'index.js' }); + await staging.stage({ + code: chunk(DEBUG_ID), + mapSource: MAP, + mapDir: tmpDir, + chunkPath: path.join(tmpDir, 'index.js'), + }); let uploadedFiles: string[] = []; let uploadedMaps = ''; @@ -214,7 +286,12 @@ describe('createUploadStaging', () => { const manager = createManager(); const staging = await manager.createUploadStaging(); - await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: outDir, fileName: 'staged.js' }); + await staging.stage({ + code: chunk(DEBUG_ID), + mapSource: MAP, + mapDir: outDir, + chunkPath: path.join(outDir, 'staged.js'), + }); let uploadedFiles: string[] = []; mockSourcemapUpload.mockImplementation(async ({ directory }: { directory: string }) => { @@ -235,7 +312,12 @@ describe('createUploadStaging', () => { const manager = createManager(); const warnSpy = vi.spyOn(manager.logger, 'warn'); const staging = await manager.createUploadStaging(); - await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' }); + await staging.stage({ + code: chunk(DEBUG_ID), + mapSource: MAP, + mapDir: '/out', + chunkPath: path.join('/out', 'a.js'), + }); await manager.uploadSourcemaps([], { staging }); @@ -262,7 +344,12 @@ describe('createUploadStaging', () => { { buildTool: 'rollup', loggerPrefix: '[test]' }, ); const staging = await manager.createUploadStaging(); - await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' }); + await staging.stage({ + code: chunk(DEBUG_ID), + mapSource: MAP, + mapDir: '/out', + chunkPath: path.join('/out', 'a.js'), + }); const result = await manager.uploadSourcemaps([], { staging }); @@ -276,7 +363,12 @@ describe('createUploadStaging', () => { const manager = createManager(); const errorSpy = vi.spyOn(manager.logger, 'error'); const staging = await manager.createUploadStaging(); - await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' }); + await staging.stage({ + code: chunk(DEBUG_ID), + mapSource: MAP, + mapDir: '/out', + chunkPath: path.join('/out', 'a.js'), + }); try { const result = await manager.uploadSourcemaps([], { staging }); @@ -292,7 +384,12 @@ describe('createUploadStaging', () => { it('uploads the staging folder next to the direct paths when prepareArtifacts is false', async () => { const manager = createManager(); const staging = await manager.createUploadStaging(); - await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' }); + await staging.stage({ + code: chunk(DEBUG_ID), + mapSource: MAP, + mapDir: '/out', + chunkPath: path.join('/out', 'a.js'), + }); const result = await manager.uploadSourcemaps(['.next/server'], { staging, prepareArtifacts: false }); @@ -378,7 +475,7 @@ describe('sourceMaps', () => { expect(manager.sourceMaps.decide('unset')).toEqual({ enableHidden: false, deleteAfterUpload: false }); const staging = await manager.createUploadStaging(); - expect(await staging.stage({ code: '', mapSource: undefined, mapDir: '', fileName: 'a.js' })).toBe('skipped'); + expect(await staging.stage({ code: '', mapSource: undefined, mapDir: '', chunkPath: 'a.js' })).toBe('skipped'); expect(await manager.uploadSourcemaps([], { staging })).toEqual({ uploaded: false }); }); }); diff --git a/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts b/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts new file mode 100644 index 000000000000..ec1018da4e09 --- /dev/null +++ b/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts @@ -0,0 +1,457 @@ +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('does not upload chunks matched by `sourcemaps.ignore` and leaves their maps in the output', async () => { + const vendor = path.join(tmpDir, 'vendor.js'); + fs.writeFileSync(vendor, 'console.log("vendor");\n'); + const build = await rollup({ + input: [entry, vendor], + plugins: [sentryRollupPlugin({ ...credentials, sourcemaps: { ignore: ['**/vendor.js'] } })], + }); + await build.write({ dir: outDir }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'vendor.js', 'vendor.js.map']); + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')); + expect(Object.keys(uploads[0]!.files)).toHaveLength(2); + }); + + it('uploads only chunks matched by `sourcemaps.assets`', async () => { + const vendor = path.join(tmpDir, 'vendor.js'); + fs.writeFileSync(vendor, 'console.log("vendor");\n'); + const build = await rollup({ + input: [entry, vendor], + plugins: [sentryRollupPlugin({ ...credentials, sourcemaps: { assets: [path.join(outDir, 'entry.js')] } })], + }); + await build.write({ dir: outDir }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js', 'vendor.js', 'vendor.js.map']); + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')); + 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('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'); + const build = await rollup({ input: entry, plugins: [sentryRollupPlugin({ ...credentials, silent: false })] }); + const { output } = await build.generate({ dir: outDir }); + + expect(output.map(file => file.fileName)).toEqual(['entry.js']); + expect(infoSpy).not.toHaveBeenCalledWith(expect.stringContaining('Enabling hidden source maps')); + expect(mkdtempSpy).not.toHaveBeenCalled(); + expect(uploads).toHaveLength(0); + }); + + it.each([ + ['visible output first', false], + ['unset output first', true], + ])('decides for each output when two write into one directory at once (%s)', async (_label, reversed) => { + const build = await rollup({ input: entry, plugins: [sentryRollupPlugin(credentials)] }); + const visible = { dir: outDir, format: 'es' as const, entryFileNames: '[name].mjs', sourcemap: true }; + const unset = { dir: outDir, format: 'cjs' as const, entryFileNames: '[name].cjs' }; + const outputs = reversed ? [unset, visible] : [visible, unset]; + await Promise.all(outputs.map(output => build.write(output))); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.cjs', 'entry.mjs', 'entry.mjs.map']); + expect(fs.readFileSync(path.join(outDir, 'entry.mjs'), 'utf8')).toContain('//# sourceMappingURL=entry.mjs.map'); + expect(fs.readFileSync(path.join(outDir, 'entry.cjs'), 'utf8')).not.toContain('sourceMappingURL'); + expect(uploads).toHaveLength(2); + // Each upload also globs the directory, so it may carry the other output's files. + const debugId = fs.readFileSync(path.join(outDir, 'entry.cjs'), 'utf8').match(DEBUG_ID_MARKER)?.[1]; + const staged = uploads.find(upload => `${debugId}-0.js` in upload.files); + expect(staged?.files[`${debugId}-0.js`]).toContain(`//# debugId=${debugId}`); + expect(JSON.parse(staged?.files[`${debugId}-0.js.map`] ?? '{}')).toMatchObject({ debug_id: debugId }); + }); + + it('passes the directory of the map file to the rewriteSources hook', async () => { + const mapDirs: Array = []; + const build = await rollup({ + input: entry, + plugins: [ + sentryRollupPlugin({ + ...credentials, + sourcemaps: { + rewriteSources: (source: string, _map: unknown, context?: { mapDir: string }) => { + mapDirs.push(context?.mapDir); + return source; + }, + }, + }), + ], + }); + await build.write({ dir: outDir, sourcemapFileNames: 'maps/[name].js.map' }); + + expect(fs.readdirSync(outDir)).toEqual(['entry.js']); + expect(mapDirs).toEqual([path.join(outDir, 'maps')]); + expect(uploads).toHaveLength(1); + }); + + 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..8ab640fc3737 --- /dev/null +++ b/packages/bundler-plugins/test/rollup/source-map-handling.test.ts @@ -0,0 +1,99 @@ +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 THIRD_DEBUG_ID = '99999999-8888-7777-6666-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'), { + match: {}, + kind: 'unset', + decision: { 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(); + }); + + it('keeps the staging of a parallel output while it discards a superseded one', async () => { + const es = { dir: 'out', format: 'es' }; + const cjs = { dir: 'out', format: 'cjs' }; + const decision = { enableHidden: true, deleteAfterUpload: true }; + handling.setDecision(path.resolve('out'), { match: { format: 'es' }, kind: 'unset', decision }); + handling.setDecision(path.resolve('out'), { match: { format: 'cjs' }, kind: 'unset', decision }); + await handling.stageSourceMaps(es, bundleWith(OTHER_DEBUG_ID), true); + + const esBundle = bundleWith(DEBUG_ID); + const cjsBundle = bundleWith(THIRD_DEBUG_ID); + await Promise.all([handling.stageSourceMaps(es, esBundle, true), handling.stageSourceMaps(cjs, cjsBundle, true)]); + + const esStaging = await handling.takeStaging(es, esBundle); + const cjsStaging = await handling.takeStaging(cjs, cjsBundle); + expect(esStaging?.count).toBe(1); + expect(cjsStaging?.count).toBe(1); + await esStaging?.discard(); + await cjsStaging?.discard(); + }); +}); diff --git a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts index 49eaae2b7916..006728c00647 100644 --- a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts +++ b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts @@ -256,6 +256,9 @@ interface SourceMapsOptions { * When unset, the plugin removes only the source maps it turned on itself, that is, when the bundler's source map * setting was not set. `true` also removes source maps you enabled yourself. `false` never removes them; the plugin * still turns on hidden source maps when the setting is not set, so they can be uploaded. + * + * Only the source maps of JavaScript chunks are removed. Chunks that `sourcemaps.assets` or `sourcemaps.ignore` + * exclude are not uploaded and keep their source maps. */ deleteSourcemapsAfterUpload?: boolean; 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, }, });