From 6774c34cb95944b0125d0ebc5c9a3b856772b405 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 | 15 + ...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 | 35 ++ .../rolldown/release-disabled.test.ts | 1 + .../rolldown/sourcemaps-assets-auto.config.ts | 12 + .../rolldown/sourcemaps-assets-auto.test.ts | 31 ++ .../rolldown/sourcemaps-ignore-auto.config.ts | 12 + .../rolldown/sourcemaps-ignore-auto.test.ts | 34 ++ .../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 | 35 ++ .../fixtures/rollup4/release-disabled.test.ts | 1 + .../rollup4/sourcemaps-assets-auto.config.js | 12 + .../rollup4/sourcemaps-assets-auto.test.ts | 31 ++ .../rollup4/sourcemaps-ignore-auto.config.js | 12 + .../rollup4/sourcemaps-ignore-auto.test.ts | 34 ++ .../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 | 4 + .../bundler-plugins/src/core/upload-filter.ts | 43 ++ packages/bundler-plugins/src/rollup/index.ts | 69 ++- .../src/rollup/source-map-handling.ts | 288 ++++++++++ .../test/core/upload-filter.test.ts | 68 +++ .../test/core/upload-staging.test.ts | 129 ++++- .../delete-sourcemaps-after-upload.test.ts | 513 ++++++++++++++++++ .../test/rollup/public-api.test.ts | 10 +- .../test/rollup/source-map-handling.test.ts | 99 ++++ .../buildTimeOptionsBase.ts | 4 + 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 + 45 files changed, 1613 insertions(+), 44 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 1cd97aa38ba7..73e2c4571a42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,21 @@ Work in this release was contributed by @lazerg, @chiliec, @61465, @jpdefo, and When Next.js serves a page from its cache, for example an ISR page or a `cacheComponents` shell, the pageload now has the `page.server_rendering.type` attribute. It is `static` if the whole page came from the cache, and `partially_static` if parts of it rendered for this request. Then the pageload stays connected to this request, as before. If the request that rendered the cached page was sampled, the pageload also links to it. So in Sentry, a cached page no longer looks like missing server instrumentation. +- **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..3e987dc46dc3 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/multiple-outputs-mixed-sourcemaps.test.ts @@ -0,0 +1,35 @@ +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", + ] + `); + // The two outputs write at the same time, so the order of the CLI calls varies. + expect(files["sentry-cli-mock.json"]!.trim().split("\n").sort()).toMatchInlineSnapshot(` + [ + "["release","create","CURRENT_SHA","--project","fake-project"],", + "["release","create","CURRENT_SHA","--project","fake-project"],", + "["release","finalize","CURRENT_SHA"],", + "["release","finalize","CURRENT_SHA"],", + "["release","set-commits","CURRENT_SHA","--auto"],", + "["release","set-commits","CURRENT_SHA","--auto"],", + "["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/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..4a8f0fc7754f --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-assets-auto.test.ts @@ -0,0 +1,31 @@ +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", + "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..3d78ed2d1c45 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/sourcemaps-ignore-auto.test.ts @@ -0,0 +1,34 @@ +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", + "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..3e987dc46dc3 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/multiple-outputs-mixed-sourcemaps.test.ts @@ -0,0 +1,35 @@ +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", + ] + `); + // The two outputs write at the same time, so the order of the CLI calls varies. + expect(files["sentry-cli-mock.json"]!.trim().split("\n").sort()).toMatchInlineSnapshot(` + [ + "["release","create","CURRENT_SHA","--project","fake-project"],", + "["release","create","CURRENT_SHA","--project","fake-project"],", + "["release","finalize","CURRENT_SHA"],", + "["release","finalize","CURRENT_SHA"],", + "["release","set-commits","CURRENT_SHA","--auto"],", + "["release","set-commits","CURRENT_SHA","--auto"],", + "["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..4a8f0fc7754f --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-assets-auto.test.ts @@ -0,0 +1,31 @@ +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", + "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..3d78ed2d1c45 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/sourcemaps-ignore-auto.test.ts @@ -0,0 +1,34 @@ +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", + "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 a529629e4413..2e43f8adca09 100644 --- a/packages/bundler-plugins/src/core/build-plugin-manager.ts +++ b/packages/bundler-plugins/src/core/build-plugin-manager.ts @@ -22,6 +22,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 @@ -53,14 +55,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; /** @@ -143,9 +146,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; }; /** @@ -237,6 +248,8 @@ export function createSentryBuildPluginManager( sourceMaps: { uploadPossible: false, decide: () => ({ enableHidden: false, deleteAfterUpload: false }), + logDecision: () => undefined, + shouldUpload: () => false, }, deleteArtifacts: async () => { /* noop */ @@ -365,6 +378,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. @@ -872,6 +893,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, @@ -880,7 +910,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'; } @@ -888,7 +918,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'); @@ -920,14 +950,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..28de6c6ee95e 100644 --- a/packages/bundler-plugins/src/core/types.ts +++ b/packages/bundler-plugins/src/core/types.ts @@ -200,6 +200,10 @@ 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. * + * When the plugin turned source maps on, none of them reach the build output, not even those of chunks that + * `sourcemaps.assets` or `sourcemaps.ignore` keep out of the upload. Source maps you enabled yourself are only + * removed for the JavaScript chunks that were uploaded; excluded chunks keep theirs. + * * 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..6ab92920ec2b --- /dev/null +++ b/packages/bundler-plugins/src/core/upload-filter.ts @@ -0,0 +1,43 @@ +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; + // Like `glob`, `assets` skips dot files and folders unless a pattern names them, and `ignore` matches them. + const matchesAssets = assets === undefined ? () => true : createMatcher(toArray(assets), { dot: false }); + const ignore = toArray(options.sourcemaps?.ignore ?? []); + const matchesIgnore = ignore.length === 0 ? () => false : createMatcher(ignore, { dot: true }); + + 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[], { dot }: { dot: boolean }): UploadFilter { + const matcher = new Ignore([], { nocase: process.platform === 'darwin' || process.platform === 'win32' }); + // `Ignore` always parses in `dot: true` mode, so the option is set before the patterns are added. + matcher.mmopts.dot = dot; + for (const pattern of patterns) { + matcher.add(pattern); + } + 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..b907b217afed --- /dev/null +++ b/packages/bundler-plugins/src/rollup/source-map-handling.ts @@ -0,0 +1,288 @@ +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. + * `sourcemap` holds the setting as the plugin left it, so an output it switched to hidden maps + * differs from one the user set. + */ +export type OutputMatch = { + dir?: unknown; + file?: unknown; + format?: unknown; + entryFileNames?: unknown; + chunkFileNames?: unknown; + preserveModules?: unknown; + sourcemap?: 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 = [ + 'dir', + 'file', + 'format', + 'entryFileNames', + 'chunkFileNames', + 'preserveModules', + 'sourcemap', +] 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 === 'dir' || 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 }); + const outputOptions = decision.enableHidden + ? { ...rawOutputOptions, sourcemap: 'hidden' as const } + : rawOutputOptions; + setDecision(getOutputDestination(rawOutputOptions), { match: getOutputMatch(outputOptions), kind, decision }); + + return decision.enableHidden ? outputOptions : 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), + }); + + // A map the plugin turned on never reaches the output, whether or + // not the chunk is uploaded. A map the user enabled stays unless + // it was staged. + if (result === 'staged' || record.kind === 'unset') { + // oxlint-disable-next-line typescript/no-dynamic-delete -- Rollup drops an asset when its key is deleted from the bundle + delete bundle[mapFileName]; + } + if (result === 'skipped' || result === 'failed') { + sentryBuildPluginManager.logger.warn( + record.kind === 'unset' + ? `Could not prepare ${output.fileName} for upload, so it is not uploaded and its source map ${mapFileName} was removed from the build output.` + : `Could not prepare ${output.fileName} for upload, so its source map ${mapFileName} stays in the build output.`, + ); + } + } + } 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/upload-filter.test.ts b/packages/bundler-plugins/test/core/upload-filter.test.ts new file mode 100644 index 000000000000..17052f6de4b5 --- /dev/null +++ b/packages/bundler-plugins/test/core/upload-filter.test.ts @@ -0,0 +1,68 @@ +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('skips dot files and folders for `assets` unless a pattern names them, like `glob`', () => { + const hidden = path.resolve('.next/static/chunks/main.js'); + expect(filter({ assets: '**/*.js' })(hidden)).toBe(false); + expect(filter({ assets: '.next/**/*.js' })(hidden)).toBe(true); + expect(filter({ assets: '**/*.js' })(inDist)).toBe(true); + }); + + it('matches dot files and folders for `ignore`, like `glob`', () => { + const hidden = path.resolve('.next/static/chunks/main.js'); + expect(filter({ ignore: '**/*.js' })(hidden)).toBe(false); + expect(filter({ assets: '.next/**/*.js', ignore: '**/chunks/*.js' })(hidden)).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..0c28e0b13076 --- /dev/null +++ b/packages/bundler-plugins/test/rollup/delete-sourcemaps-after-upload.test.ts @@ -0,0 +1,513 @@ +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 drops the maps it turned on for them', 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']); + expect(uploads).toHaveLength(1); + expectUploadedPair(uploads[0]!, fs.readFileSync(path.join(outDir, 'entry.js'), 'utf8')); + expect(Object.keys(uploads[0]!.files)).toHaveLength(2); + }); + + it('keeps the maps the user enabled for chunks matched by `sourcemaps.ignore`', 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'], deleteSourcemapsAfterUpload: true }, + }), + ], + }); + await build.write({ dir: outDir, sourcemap: 'hidden' }); + + 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')); + }); + + 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']); + 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('tells a file output apart from a dir output written into the same directory at once', async () => { + const build = await rollup({ input: entry, plugins: [sentryRollupPlugin(credentials)] }); + await Promise.all([ + build.write({ dir: outDir, format: 'es' }), + build.write({ file: path.join(outDir, 'bundle.js'), sourcemap: true }), + ]); + + expect(fs.readdirSync(outDir).sort()).toEqual(['bundle.js', 'bundle.js.map', 'entry.js']); + expect(uploads).toHaveLength(2); + }); + + it('tells a preserveModules output apart from a bundled one written into the same directory', async () => { + const build = await rollup({ input: entry, plugins: [sentryRollupPlugin(credentials)] }); + await Promise.all([ + build.write({ dir: outDir, preserveModules: true, sourcemap: true }), + build.write({ dir: outDir, entryFileNames: 'bundle.js' }), + ]); + + expect(fs.readdirSync(outDir).sort()).toEqual(['bundle.js', 'entry.js', 'entry.js.map']); + expect(uploads).toHaveLength(2); + }); + + 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); + }); + + const foreignDebugIdPlugin = { + name: 'foreign-debug-id', + renderChunk: (code: string) => `${code}\n//# debugId=11111111-2222-3333-4444-555555555555`, + }; + + it('drops the map it turned on for a chunk it could not prepare and warns', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const build = await rollup({ + input: entry, + plugins: [foreignDebugIdPlugin, sentryRollupPlugin({ ...credentials, silent: false })], + }); + await build.write({ dir: outDir }); + + expect(fs.readdirSync(outDir).sort()).toEqual(['entry.js']); + expect(uploads).toHaveLength(0); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('was removed from the build output')); + }); + + it('keeps the map the user enabled for a chunk it could not prepare and warns', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const build = await rollup({ + input: entry, + plugins: [ + foreignDebugIdPlugin, + sentryRollupPlugin({ ...credentials, silent: false, sourcemaps: { deleteSourcemapsAfterUpload: true } }), + ], + }); + await build.write({ dir: outDir, sourcemap: 'hidden' }); + + 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..13e9346243fb 100644 --- a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts +++ b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts @@ -256,6 +256,10 @@ 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. + * + * When the plugin turned source maps on, none of them reach the build output, not even those of chunks that + * `sourcemaps.assets` or `sourcemaps.ignore` keep out of the upload. Source maps you enabled yourself are only + * removed for the JavaScript chunks that were uploaded; excluded chunks keep theirs. */ 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, }, });