From 1c26accb3f0aac762feff99c6e3dc6a12a4b0624 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 22 Oct 2026 01:19:43 +0100 Subject: [PATCH] feat(bundler-plugins): Add `sourcemaps.deleteSourcemapsAfterUpload` and staged uploads Adds the option, a decision helper that maps the user's source map setting to enable and delete choices, and an upload staging folder on the build plugin manager that bundler plugins can fill from their pre-write hooks. No bundler plugin uses it yet, so build output does not change. --- .../fixtures/esbuild/telemetry.test.ts | 2 +- .../fixtures/rolldown/telemetry.test.ts | 2 +- .../fixtures/rollup3/telemetry.test.ts | 2 +- .../fixtures/rollup4/telemetry.test.ts | 2 +- .../fixtures/vite4/telemetry.test.ts | 2 +- .../fixtures/vite7/telemetry.test.ts | 2 +- .../fixtures/vite8/telemetry.test.ts | 2 +- .../fixtures/webpack5/telemetry.test.ts | 2 +- .../src/core/build-plugin-manager.ts | 262 ++++++++++++--- .../src/core/debug-id-upload.ts | 195 ++++++----- packages/bundler-plugins/src/core/index.ts | 3 + .../src/core/options-mapping.ts | 8 + .../src/core/sentry/telemetry.ts | 4 + .../src/core/sourcemap-settings.ts | 94 ++++++ packages/bundler-plugins/src/core/types.ts | 12 + .../test/core/debug-id-upload.test.ts | 113 +++++++ .../test/core/option-mappings.test.ts | 19 ++ .../test/core/sentry/telemetry.test.ts | 13 + .../test/core/sourcemap-settings.test.ts | 94 ++++++ .../test/core/upload-staging.test.ts | 314 ++++++++++++++++++ .../buildTimeOptionsBase.ts | 9 + 21 files changed, 1028 insertions(+), 128 deletions(-) create mode 100644 packages/bundler-plugins/src/core/sourcemap-settings.ts create mode 100644 packages/bundler-plugins/test/core/sourcemap-settings.test.ts create mode 100644 packages/bundler-plugins/test/core/upload-staging.test.ts diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts index fe5649ca57dc..2585278adc54 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts @@ -6,7 +6,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { expect(readOutputFiles()).toMatchInlineSnapshot(` { "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"esbuild","bundler-major-version":"28"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"esbuild","bundler-major-version":"28"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", "telemetry.js": "(() => { diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts index 77ac826d8d70..f995682ca797 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts @@ -18,7 +18,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { //#endregion ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts index 329f3b6969ed..72ae140b7b42 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts @@ -9,7 +9,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { !function(){try{var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};e.SENTRY_RELEASE={id:"CURRENT_SHA"};var n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="00000000-0000-0000-0000-000000000000",e._sentryDebugIdIdentifier="sentry-dbid-00000000-0000-0000-0000-000000000000");}catch(e){}}();console.log("hello world"); ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"3"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"3"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts index 4e0c4d969660..d4bde5c071ae 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts @@ -9,7 +9,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { !function(){try{var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};e.SENTRY_RELEASE={id:"CURRENT_SHA"};var n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="00000000-0000-0000-0000-000000000000",e._sentryDebugIdIdentifier="sentry-dbid-00000000-0000-0000-0000-000000000000");}catch(e){}}();console.log("hello world"); ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts index d6ea3f3a91d5..f61dde5478e3 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts @@ -17,7 +17,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { console.log("hello world"); ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts index 3b5c50c6a113..9ad711e030fc 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts @@ -17,7 +17,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { console.log("hello world"); ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"7"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"7"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts index d09ee8bc094a..6d832d2ae117 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts @@ -18,7 +18,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { //#endregion ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"8"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"8"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts index 0854328dd4a8..22d582168680 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts @@ -13,7 +13,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { /******/ })() ;", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"webpack","bundler-major-version":"5"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"delete-sourcemaps-after-upload":"auto","sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"webpack","bundler-major-version":"5"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/packages/bundler-plugins/src/core/build-plugin-manager.ts b/packages/bundler-plugins/src/core/build-plugin-manager.ts index 242a77d73f0d..d0cf2f0d706e 100644 --- a/packages/bundler-plugins/src/core/build-plugin-manager.ts +++ b/packages/bundler-plugins/src/core/build-plugin-manager.ts @@ -12,8 +12,15 @@ import { createLogger } from './logger'; import { allowedToSendTelemetry, createSentryInstance, safeFlushTelemetry } from './sentry/telemetry'; import type { Options, SentrySDKBuildFlags } from './types'; import { arrayify, getProjects, getTurborepoEnvPassthroughWarning, stripQueryAndHashFromPath } from './utils'; -import { defaultRewriteSourcesHook, prepareBundleForDebugIdUpload } from './debug-id-upload'; +import { + defaultRewriteSourcesHook, + prepareArtifactForUpload, + prepareBundleForDebugIdUpload, + writePreparedArtifact, +} from './debug-id-upload'; import { globFiles } from './glob'; +import type { SourceMapDecision, SourceMapKind } from './sourcemap-settings'; +import { decideSourceMapHandling } from './sourcemap-settings'; import { LIB_VERSION } from './version'; // Module-level guard to prevent duplicate deploy records when multiple bundler plugin @@ -26,6 +33,31 @@ export function _resetDeployedReleasesForTesting(): void { _deployedReleases.clear(); } +/** + * A temporary folder that build artifacts are written into straight from the bundler's output, + * before the bundler writes them to disk. The upload reads from this folder, so the bundler can + * drop the source map assets from its own output. + */ +export type UploadStaging = { + readonly dir: string; + /** Number of artifacts written so far. */ + readonly count: number; + /** + * 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. + */ + stage(input: { + code: string; + mapSource: string | undefined; + mapDir: string; + fileName: string; + }): Promise<'staged' | 'skipped' | 'failed'>; + /** Removes the folder. Call this when no upload will consume it. */ + discard(): Promise; +}; + export type SentryBuildPluginManager = { /** * A logger instance that takes the options passed to the build plugin manager into account. (for silencing and log level etc.) @@ -80,7 +112,28 @@ export type SentryBuildPluginManager = { /** * Uploads sourcemaps using the "Debug ID" method. This function takes a list of build artifact paths that will be uploaded */ - uploadSourcemaps(buildArtifactPaths: string[], opts?: { prepareArtifacts?: boolean }): Promise; + uploadSourcemaps( + buildArtifactPaths: string[], + opts?: { prepareArtifacts?: boolean; staging?: UploadStaging }, + ): Promise<{ uploaded: boolean }>; + + /** + * Creates a staging folder that `uploadSourcemaps` uploads together with the build artifacts + * found on disk, in both the prepared and the direct (`prepareArtifacts: false`) mode. + */ + createUploadStaging(): Promise; + + sourceMaps: { + /** + * Whether a source map upload can run with the current options and environment. + */ + uploadPossible: boolean; + /** + * Decides how the plugin treats the user's source map setting (see `decideSourceMapHandling`). + * Logs the decision's message once per message. + */ + decide(kind: SourceMapKind): SourceMapDecision; + }; /** * Will delete artifacts based on the passed `sourcemaps.filesToDeleteAfterUpload` option. @@ -155,8 +208,18 @@ export function createSentryBuildPluginManager( createRelease: async () => { /* noop */ }, - uploadSourcemaps: async () => { - /* noop */ + uploadSourcemaps: async () => ({ uploaded: false }), + createUploadStaging: async () => ({ + dir: '', + count: 0, + stage: async () => 'skipped' as const, + discard: async () => { + /* noop */ + }, + }), + sourceMaps: { + uploadPossible: false, + decide: () => ({ enableHidden: false, deleteAfterUpload: false }), }, deleteArtifacts: async () => { /* noop */ @@ -283,6 +346,9 @@ export function createSentryBuildPluginManager( }; } + const sourceMapUploadPossible = getSourceMapUploadBlocker(options, isDevMode) === undefined; + const loggedSourceMapMessages = new Set(); + /** * Returns a Promise that resolves when all the currently active dependencies are freed again. * @@ -548,17 +614,19 @@ export function createSentryBuildPluginManager( * @param buildArtifactPaths - The paths of the build artifacts to upload * @param opts - Optional flags to control temp folder usage and preparation */ - async uploadSourcemaps(buildArtifactPaths: string[], opts?: { prepareArtifacts?: boolean }) { + async uploadSourcemaps( + buildArtifactPaths: string[], + opts?: { prepareArtifacts?: boolean; staging?: UploadStaging }, + ): Promise<{ uploaded: boolean }> { + const staging = opts?.staging; + if (!canUploadSourceMaps(options, logger, isDevMode)) { - return; + await staging?.discard(); + return { uploaded: false }; } - // Early exit if assets is explicitly set to an empty array const assets = options.sourcemaps?.assets; - if (Array.isArray(assets) && assets.length === 0) { - logger.debug('Empty `sourcemaps.assets` option provided. Will not upload sourcemaps with debug ID.'); - return; - } + let uploaded = false; await startSpan( // This is `forceTransaction`ed because this span is used in dashboards in the form of indexed transactions. @@ -568,7 +636,7 @@ export function createSentryBuildPluginManager( // If we're not using a temp folder, we must not prepare artifacts in-place (to avoid mutating user files) const shouldPrepare = opts?.prepareArtifacts ?? true; - let folderToCleanUp: string | undefined; + let folderToCleanUp: string | undefined = staging?.dir; // It is possible that this writeBundle hook (which calls this function) is called multiple times in one build (for example when reusing the plugin, or when using build tooling like `@vitejs/plugin-legacy`) // Therefore we need to actually register the execution of this hook as dependency on the sourcemap files. @@ -589,6 +657,11 @@ export function createSentryBuildPluginManager( pathsToUpload = buildArtifactPaths; } + // Staged artifacts are already prepared, so the CLI can take the folder as it is. + if (staging && staging.count > 0) { + pathsToUpload = [...pathsToUpload, staging.dir]; + } + await startSpan({ name: 'upload', scope: sentryScope }, async () => { const cliInstance = new SentryCliAdapter(options); await cliInstance.uploadSourcemaps( @@ -601,6 +674,7 @@ export function createSentryBuildPluginManager( ); }); + uploaded = true; logger.info('Successfully uploaded source maps to Sentry'); } else { // Prepare artifacts in temp folder before uploading @@ -626,17 +700,16 @@ export function createSentryBuildPluginManager( // Ensure order within the files so that {debug-id}-{chunkIndex} coupling is consistent debugIdChunkFilePaths.sort(); - if (debugIdChunkFilePaths.length === 0) { + const stagedCount = staging?.count ?? 0; + + if (debugIdChunkFilePaths.length === 0 && stagedCount === 0) { logger.warn( "Didn't find any matching sources for debug ID upload. Please check the `sourcemaps.assets` option.", ); } else { - const tmpUploadFolder = await startSpan({ name: 'mkdtemp', scope: sentryScope }, async () => { - return ( - process.env?.['SENTRY_TEST_OVERRIDE_TEMP_DIR'] || - (await fs.promises.mkdtemp(path.join(os.tmpdir(), 'sentry-bundler-plugin-upload-'))) - ); - }); + const tmpUploadFolder = + staging?.dir ?? + (await startSpan({ name: 'mkdtemp', scope: sentryScope }, () => createTempUploadFolder())); folderToCleanUp = tmpUploadFolder; // Prepare into temp folder, then upload @@ -647,7 +720,7 @@ export function createSentryBuildPluginManager( await prepareBundleForDebugIdUpload( chunkFilePath, tmpUploadFolder, - chunkIndex, + stagedCount + chunkIndex, logger, options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook, options.sourcemaps?.resolveSourceMap, @@ -684,7 +757,7 @@ export function createSentryBuildPluginManager( // bundler, so warn instead of silently reporting success. if (files.length === 0) { logger.warn( - `No source maps found for any of the ${debugIdChunkFilePaths.length} matched build ` + + `No source maps found for any of the ${debugIdChunkFilePaths.length + stagedCount} matched build ` + 'artifacts, so no source maps were uploaded to Sentry. This usually means source map ' + 'generation is not enabled in your bundler. Enable it so Sentry can un-minify your stack traces.', ); @@ -703,11 +776,21 @@ export function createSentryBuildPluginManager( // this must be in the method so that the "no sourcemaps" // early return doesn't also log success. + uploaded = true; logger.info('Successfully uploaded source maps to Sentry'); }); } } } catch (e) { + if (staging && folderToCleanUp) { + // The bundler drops the staged source maps from its output, so + // this folder is the only copy left. + logger.error( + `Source map upload failed. The source maps were kept in ${folderToCleanUp}. ` + + `Upload them with \`sentry sourcemap upload ${folderToCleanUp}\`.`, + ); + folderToCleanUp = undefined; + } sentryScope.captureException('Error in "debugIdUploadPlugin" writeBundle hook'); handleRecoverableError(e, false); } finally { @@ -734,6 +817,76 @@ export function createSentryBuildPluginManager( } }, ); + + return { uploaded }; + }, + + async createUploadStaging(): Promise { + const dir = await createTempUploadFolder(); + const rewriteSourcesHook = options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook; + let count = 0; + let discarded = false; + + return { + dir, + get count() { + return count; + }, + async stage(input) { + const result = prepareArtifactForUpload({ + code: input.code, + mapSource: input.mapSource, + mapDir: input.mapDir, + rewriteSourcesHook, + }); + + if (!result.ok) { + logger.debug(`Not staging ${input.fileName} for upload: ${result.reason}`); + return 'skipped'; + } + + const index = count++; + + try { + await writePreparedArtifact(dir, `${result.artifact.debugId}-${index}`, result.artifact); + return 'staged'; + } catch (e) { + sentryScope.captureException('Error staging build artifact for upload'); + // Throwing by default: the bundler drops the source map from its + // output once staged, so a lost copy would leave no map at all. + handleRecoverableError(e, true); + return 'failed'; + } + }, + async discard() { + if (discarded) { + return; + } + discarded = true; + + if (process.env['SENTRY_TEST_OVERRIDE_TEMP_DIR']) { + return; + } + + try { + await fs.promises.rm(dir, { recursive: true, force: true }); + } catch (e) { + logger.debug(`Failed to remove staging folder ${dir}:`, e); + } + }, + }; + }, + + sourceMaps: { + uploadPossible: sourceMapUploadPossible, + decide(kind) { + 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); + } + return decision; + }, }, /** @@ -774,33 +927,64 @@ export function createSentryBuildPluginManager( }; } -function canUploadSourceMaps(options: NormalizedOptions, logger: Logger, isDevMode: boolean): boolean { +async function createTempUploadFolder(): Promise { + return ( + process.env['SENTRY_TEST_OVERRIDE_TEMP_DIR'] || + (await fs.promises.mkdtemp(path.join(os.tmpdir(), 'sentry-bundler-plugin-upload-'))) + ); +} + +type SourceMapUploadBlocker = 'disabled' | 'no-assets' | 'dev-mode' | 'no-auth-token' | 'no-org' | 'no-project'; + +function getSourceMapUploadBlocker(options: NormalizedOptions, isDevMode: boolean): SourceMapUploadBlocker | undefined { if (options.sourcemaps?.disable) { - logger.debug('Source map upload was disabled. Will not upload sourcemaps using debug ID process.'); - return false; + return 'disabled'; + } + if (Array.isArray(options.sourcemaps?.assets) && options.sourcemaps.assets.length === 0) { + return 'no-assets'; } if (isDevMode) { - logger.debug('Running in development mode. Will not upload sourcemaps.'); - return false; + return 'dev-mode'; } if (!options.authToken) { - logger.warn( - `No auth token provided. Will not upload source maps. Please set the \`authToken\` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/${getTurborepoEnvPassthroughWarning('SENTRY_AUTH_TOKEN')}`, - ); - return false; + return 'no-auth-token'; } if (!options.org && !options.authToken.startsWith('sntrys_')) { - logger.warn( - `No org provided. Will not upload source maps. Please set the \`org\` option to your Sentry organization slug.${getTurborepoEnvPassthroughWarning('SENTRY_ORG')}`, - ); - return false; + return 'no-org'; } if (!getProjects(options.project)?.[0]) { - logger.warn( - `No project provided. Will not upload source maps. Please set the \`project\` option to your Sentry project slug.${getTurborepoEnvPassthroughWarning('SENTRY_PROJECT')}`, - ); - return false; + return 'no-project'; } + return undefined; +} - return true; +function canUploadSourceMaps(options: NormalizedOptions, logger: Logger, isDevMode: boolean): boolean { + switch (getSourceMapUploadBlocker(options, isDevMode)) { + case 'disabled': + logger.debug('Source map upload was disabled. Will not upload sourcemaps using debug ID process.'); + return false; + case 'no-assets': + logger.debug('Empty `sourcemaps.assets` option provided. Will not upload sourcemaps with debug ID.'); + return false; + case 'dev-mode': + logger.debug('Running in development mode. Will not upload sourcemaps.'); + return false; + case 'no-auth-token': + logger.warn( + `No auth token provided. Will not upload source maps. Please set the \`authToken\` option. You can find information on how to generate a Sentry auth token here: https://docs.sentry.io/api/auth/${getTurborepoEnvPassthroughWarning('SENTRY_AUTH_TOKEN')}`, + ); + return false; + case 'no-org': + logger.warn( + `No org provided. Will not upload source maps. Please set the \`org\` option to your Sentry organization slug.${getTurborepoEnvPassthroughWarning('SENTRY_ORG')}`, + ); + return false; + case 'no-project': + logger.warn( + `No project provided. Will not upload source maps. Please set the \`project\` option to your Sentry project slug.${getTurborepoEnvPassthroughWarning('SENTRY_PROJECT')}`, + ); + return false; + default: + return true; + } } diff --git a/packages/bundler-plugins/src/core/debug-id-upload.ts b/packages/bundler-plugins/src/core/debug-id-upload.ts index 8041e8c99d0f..2d9e16d49dd1 100644 --- a/packages/bundler-plugins/src/core/debug-id-upload.ts +++ b/packages/bundler-plugins/src/core/debug-id-upload.ts @@ -2,8 +2,7 @@ import fs from 'fs'; import path from 'path'; import * as url from 'url'; import * as util from 'util'; -import { promisify } from 'util'; -import type { SentryBuildPluginManager } from './build-plugin-manager'; +import type { SentryBuildPluginManager, UploadStaging } from './build-plugin-manager'; import type { Logger } from './logger'; import type { ResolveSourceMapHook, RewriteSourcesHook } from './types'; import { stripQueryAndHashFromPath } from './utils'; @@ -13,11 +12,11 @@ interface DebugIdUploadPluginOptions { } export function createDebugIdUploadFunction({ sentryBuildPluginManager }: DebugIdUploadPluginOptions) { - return async (buildArtifactPaths: string[]) => { + return async (buildArtifactPaths: string[], opts?: { staging?: UploadStaging }) => { // Webpack and perhaps other bundlers allow you to append query strings to // filenames for cache busting purposes. We should strip these before upload. const cleanedPaths = buildArtifactPaths.map(stripQueryAndHashFromPath); - await sentryBuildPluginManager.uploadSourcemaps(cleanedPaths); + return sentryBuildPluginManager.uploadSourcemaps(cleanedPaths, opts); }; } @@ -29,26 +28,21 @@ export async function prepareBundleForDebugIdUpload( rewriteSourcesHook: RewriteSourcesHook, resolveSourceMapHook: ResolveSourceMapHook | undefined, ): Promise { - let bundleContent; + let bundleContent: string; try { - bundleContent = await promisify(fs.readFile)(bundleFilePath, 'utf8'); + bundleContent = await fs.promises.readFile(bundleFilePath, 'utf8'); } catch (e) { logger.error(`Could not read bundle to determine debug ID and source map: ${bundleFilePath}`, e); return; } - const debugId = determineDebugIdFromBundleSource(bundleContent); - if (debugId === undefined) { + if (determineDebugIdFromBundleSource(bundleContent) === undefined) { logger.debug( `Could not determine debug ID from bundle. This can happen if you did not clean your output folder before installing the Sentry plugin. File will not be source mapped: ${bundleFilePath}`, ); return; } - const uniqueUploadName = `${debugId}-${chunkIndex}`; - - bundleContent = addDebugIdToBundleSource(bundleContent, debugId); - const sourceMapPath = await determineSourceMapPathFromBundle( bundleFilePath, bundleContent, @@ -56,35 +50,118 @@ export async function prepareBundleForDebugIdUpload( resolveSourceMapHook, ); - // A chunk with a debug ID but no resolvable source map - // (e.g. framework-generated stub chunks that never emit one) can't be - // symbolicated, so uploading its minified source alone accomplishes - // nothing and only makes the uploader warn about a missing source map ref. - // Skip it, unless the source map is inlined into the bundle, in which case - // the source file carries the map itself and must still be uploaded. - if (!sourceMapPath && !bundleHasInlineSourceMap(bundleContent)) { - logger.debug(`Not uploading bundle without a source map: ${bundleFilePath}`); + let sourceMapSource: string | undefined; + if (sourceMapPath) { + try { + sourceMapSource = await fs.promises.readFile(sourceMapPath, 'utf8'); + } catch (e) { + logger.error(`Failed to read source map for debug ID upload: ${sourceMapPath}`, e); + return; + } + } + + const result = prepareArtifactForUpload({ + code: bundleContent, + mapSource: sourceMapSource, + mapDir: path.dirname(sourceMapPath ?? bundleFilePath), + rewriteSourcesHook, + }); + + if (!result.ok) { + if (result.reason === 'invalid-source-map') { + logger.error(`Failed to parse source map for debug ID upload: ${sourceMapPath}`); + } else { + // A chunk with a debug ID but no resolvable source map + // (e.g. framework-generated stub chunks that never emit one) can't be + // symbolicated, so uploading its minified source alone accomplishes + // nothing and only makes the uploader warn about a missing source map ref. + logger.debug(`Not uploading bundle without a source map: ${bundleFilePath}`); + } return; } - const writeSourceFilePromise = fs.promises.writeFile( - path.join(uploadFolder, `${uniqueUploadName}.js`), - bundleContent, - 'utf-8', - ); + try { + await writePreparedArtifact(uploadFolder, `${result.artifact.debugId}-${chunkIndex}`, result.artifact); + } catch (e) { + logger.error(`Failed to prepare bundle for debug ID upload: ${bundleFilePath}`, e); + } +} + +export type PreparedUploadArtifact = { + debugId: string; + bundleSource: string; + /** `undefined` when the bundle has no separate source map (i.e. the map is inlined). */ + sourceMapSource: string | undefined; +}; + +export type PrepareArtifactResult = + | { ok: true; artifact: PreparedUploadArtifact } + | { ok: false; reason: 'no-debug-id' | 'no-source-map' | 'invalid-source-map' }; + +/** + * Prepares a bundle and its source map for the debug ID upload: stamps the debug ID into both + * and runs the `rewriteSources` hook over the map's `sources`. + * + * Pass `mapSource: undefined` when the bundle has no separate source map. A bundle with an + * inlined map is still prepared; a bundle without any map is skipped because it cannot be + * symbolicated. + */ +export function prepareArtifactForUpload(input: { + code: string; + mapSource: string | undefined; + /** Directory the source map lives in, passed to the `rewriteSources` hook. */ + mapDir: string; + rewriteSourcesHook: RewriteSourcesHook; +}): PrepareArtifactResult { + const debugId = determineDebugIdFromBundleSource(input.code); + if (debugId === undefined) { + return { ok: false, reason: 'no-debug-id' }; + } + + const bundleSource = addDebugIdToBundleSource(input.code, debugId); + + if (input.mapSource === undefined) { + if (!bundleHasInlineSourceMap(input.code)) { + return { ok: false, reason: 'no-source-map' }; + } + return { ok: true, artifact: { debugId, bundleSource, sourceMapSource: undefined } }; + } + + const map = parseSourceMap(input.mapSource); + if (!map) { + return { ok: false, reason: 'invalid-source-map' }; + } + + setDebugIdOnSourceMap(map, debugId); + + if (Array.isArray(map['sources'])) { + map['sources'] = map['sources'].map((source: string) => + input.rewriteSourcesHook(source, map, { mapDir: input.mapDir }), + ); + } + + return { ok: true, artifact: { debugId, bundleSource, sourceMapSource: JSON.stringify(map) } }; +} + +/** + * Writes a prepared artifact as `.js` (and `.js.map`) into the upload folder. + */ +export async function writePreparedArtifact( + uploadFolder: string, + uniqueUploadName: string, + artifact: PreparedUploadArtifact, +): Promise { + const writes = [ + fs.promises.writeFile(path.join(uploadFolder, `${uniqueUploadName}.js`), artifact.bundleSource, 'utf-8'), + ]; + + if (artifact.sourceMapSource !== undefined) { + writes.push( + fs.promises.writeFile(path.join(uploadFolder, `${uniqueUploadName}.js.map`), artifact.sourceMapSource, 'utf-8'), + ); + } - const writeSourceMapFilePromise = sourceMapPath - ? prepareSourceMapForDebugIdUpload( - sourceMapPath, - path.join(uploadFolder, `${uniqueUploadName}.js.map`), - debugId, - rewriteSourcesHook, - logger, - ) - : Promise.resolve(); - - await writeSourceFilePromise; - await writeSourceMapFilePromise; + await Promise.all(writes); } /** @@ -321,50 +398,6 @@ export async function determineSourceMapPathFromBundle( return undefined; } -/** - * Reads a source map, injects debug ID fields, and writes the source map to the target path. - */ -async function prepareSourceMapForDebugIdUpload( - sourceMapPath: string, - targetPath: string, - debugId: string, - rewriteSourcesHook: RewriteSourcesHook, - logger: Logger, -): Promise { - let sourceMapFileContent: string; - try { - sourceMapFileContent = await util.promisify(fs.readFile)(sourceMapPath, { - encoding: 'utf8', - }); - } catch (e) { - logger.error(`Failed to read source map for debug ID upload: ${sourceMapPath}`, e); - return; - } - - let map: Record; - try { - map = JSON.parse(sourceMapFileContent) as { sources: unknown; [key: string]: unknown }; - setDebugIdOnSourceMap(map, debugId); - } catch { - logger.error(`Failed to parse source map for debug ID upload: ${sourceMapPath}`); - return; - } - - if (map['sources'] && Array.isArray(map['sources'])) { - const mapDir = path.dirname(sourceMapPath); - map['sources'] = map['sources'].map((source: string) => rewriteSourcesHook(source, map, { mapDir })); - } - - try { - await util.promisify(fs.writeFile)(targetPath, JSON.stringify(map), { - encoding: 'utf8', - }); - } catch (e) { - logger.error(`Failed to prepare source map for debug ID upload: ${sourceMapPath}`, e); - return; - } -} - const PROTOCOL_REGEX = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//; export function defaultRewriteSourcesHook(source: string): string { if (source.match(PROTOCOL_REGEX)) { diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts index 4edc5ebbed91..e46caaaec203 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -110,4 +110,7 @@ export { generateModuleMetadataInjectorCode, } from './utils'; export { createSentryBuildPluginManager } from './build-plugin-manager'; +export type { SentryBuildPluginManager, UploadStaging } from './build-plugin-manager'; export { createDebugIdUploadFunction, addDebugIdToEmittedArtifacts, stampDebugId } from './debug-id-upload'; +export { decideSourceMapHandling } from './sourcemap-settings'; +export type { SourceMapDecision, SourceMapKind } from './sourcemap-settings'; diff --git a/packages/bundler-plugins/src/core/options-mapping.ts b/packages/bundler-plugins/src/core/options-mapping.ts index 4847b11b0415..c190d3efc5cc 100644 --- a/packages/bundler-plugins/src/core/options-mapping.ts +++ b/packages/bundler-plugins/src/core/options-mapping.ts @@ -30,6 +30,7 @@ export type NormalizedOptions = { rewriteSources?: RewriteSourcesHook; resolveSourceMap?: ResolveSourceMapHook; filesToDeleteAfterUpload?: string | string[] | Promise; + deleteSourcemapsAfterUpload?: boolean; } | undefined; release: { @@ -214,6 +215,13 @@ export function validateOptions(options: NormalizedOptions, logger: Logger): boo return false; } + if (options.sourcemaps?.deleteSourcemapsAfterUpload && options.sourcemaps.disable) { + logger.warn( + 'The `sourcemaps.deleteSourcemapsAfterUpload` option is set but source map upload is disabled via `sourcemaps.disable`.', + 'Source maps will not be deleted.', + ); + } + if (options.project && Array.isArray(options.project)) { if (options.project.length === 0) { logger.error( diff --git a/packages/bundler-plugins/src/core/sentry/telemetry.ts b/packages/bundler-plugins/src/core/sentry/telemetry.ts index 72ea587bfef8..c9da77637d90 100644 --- a/packages/bundler-plugins/src/core/sentry/telemetry.ts +++ b/packages/bundler-plugins/src/core/sentry/telemetry.ts @@ -106,6 +106,10 @@ export function setTelemetryDataOnScope( scope.setTag('custom-error-handler', !!errorHandler); scope.setTag('sourcemaps-assets', !!sourcemaps?.assets); scope.setTag('delete-after-upload', !!sourcemaps?.filesToDeleteAfterUpload); + scope.setTag( + 'delete-sourcemaps-after-upload', + sourcemaps?.deleteSourcemapsAfterUpload === undefined ? 'auto' : String(sourcemaps.deleteSourcemapsAfterUpload), + ); scope.setTag('sourcemaps-disabled', !!sourcemaps?.disable); scope.setTag('react-annotate', !!reactComponentAnnotation?.enabled); diff --git a/packages/bundler-plugins/src/core/sourcemap-settings.ts b/packages/bundler-plugins/src/core/sourcemap-settings.ts new file mode 100644 index 000000000000..9c8b720ca85e --- /dev/null +++ b/packages/bundler-plugins/src/core/sourcemap-settings.ts @@ -0,0 +1,94 @@ +import type { NormalizedOptions } from './options-mapping'; + +/** + * The user's source map setting in the bundler, reduced to what matters for upload and deletion. + * + * - `unset`: the user did not configure source maps + * - `off`: source map generation is turned off + * - `visible`: separate map files with a `sourceMappingURL` comment + * - `hidden`: separate map files without the comment + * - `inline`: the map is embedded into the bundle + */ +export type SourceMapKind = 'unset' | 'off' | 'visible' | 'hidden' | 'inline'; + +export type SourceMapDecision = { + /** Switch the bundler to hidden source maps. */ + enableHidden: boolean; + /** Remove the source maps from the build output once they are uploaded. */ + deleteAfterUpload: boolean; + log?: { level: 'info' | 'warn'; message: string }; +}; + +const NO_CHANGE: SourceMapDecision = { enableHidden: false, deleteAfterUpload: false }; + +/** + * Decides whether the plugin turns hidden source maps on and whether it removes the maps from the + * build output after the upload. The plugin only turns maps on when it also removes them, and only + * when an upload can run. + */ +export function decideSourceMapHandling( + kind: SourceMapKind, + options: NormalizedOptions, + uploadPossible: boolean, +): SourceMapDecision { + const deleteOption = options.sourcemaps?.deleteSourcemapsAfterUpload; + + // `false` keeps the plugin away from source maps, warnings included. + if (!uploadPossible || options.sourcemaps?.disable || deleteOption === false) { + return NO_CHANGE; + } + + switch (kind) { + case 'off': + return { + ...NO_CHANGE, + log: { + level: 'warn', + message: + 'Source map generation is turned off in your bundler, so Sentry cannot un-minify your stack traces. ' + + 'Remove the setting to let the plugin generate hidden source maps, upload them to Sentry and remove them from the build output.', + }, + }; + + case 'inline': + return { + ...NO_CHANGE, + log: { + level: 'warn', + message: + 'Source maps are inlined into your bundles. They are uploaded to Sentry, but inline source maps ship your source code to users and cannot be removed after the upload. ' + + 'Remove the setting to let the plugin generate hidden source maps instead.', + }, + }; + + case 'unset': + return { + enableHidden: true, + deleteAfterUpload: true, + log: { + level: 'info', + message: + 'Enabling hidden source maps. They are uploaded to Sentry and removed from the build output. ' + + 'Set `sourcemaps.deleteSourcemapsAfterUpload: false` to keep this plugin from touching source maps.', + }, + }; + + case 'hidden': + return { enableHidden: false, deleteAfterUpload: deleteOption === true }; + + case 'visible': + if (deleteOption !== true) { + return NO_CHANGE; + } + return { + enableHidden: true, + deleteAfterUpload: true, + log: { + level: 'info', + message: + 'Switching to hidden source maps because `sourcemaps.deleteSourcemapsAfterUpload` is set. ' + + 'Without the `sourceMappingURL` comment browsers do not request the removed map files.', + }, + }; + } +} diff --git a/packages/bundler-plugins/src/core/types.ts b/packages/bundler-plugins/src/core/types.ts index bd3761c12dca..6562b20b5e19 100644 --- a/packages/bundler-plugins/src/core/types.ts +++ b/packages/bundler-plugins/src/core/types.ts @@ -192,6 +192,18 @@ export interface Options { * Use the `debug` option to print information about which files end up being deleted. */ filesToDeleteAfterUpload?: string | string[] | Promise; + + /** + * Whether source maps should be removed from the build output after they are uploaded to Sentry. + * + * When unset, source maps are removed only if the plugin turned source map generation on itself, that is, when + * the bundler's source map setting was not set. Set to `true` to also remove source maps you enabled yourself, or + * to `false` to keep the plugin from changing or warning about your source map setting. + * + * With Rollup, Vite and webpack the source map files are never written to the output directory. With esbuild they + * are deleted after the upload. + */ + deleteSourcemapsAfterUpload?: boolean; }; /** diff --git a/packages/bundler-plugins/test/core/debug-id-upload.test.ts b/packages/bundler-plugins/test/core/debug-id-upload.test.ts index ddeeff073fc3..6765e10bea9a 100644 --- a/packages/bundler-plugins/test/core/debug-id-upload.test.ts +++ b/packages/bundler-plugins/test/core/debug-id-upload.test.ts @@ -4,8 +4,10 @@ import * as path from 'path'; import * as os from 'os'; import { addDebugIdToEmittedArtifacts, + prepareArtifactForUpload, prepareBundleForDebugIdUpload, stampDebugId, + writePreparedArtifact, } from '../../src/core/debug-id-upload'; import type { RewriteSourcesHook } from '../../src/core/types'; import type { Logger } from '../../src/core'; @@ -112,6 +114,117 @@ describe('prepareBundleForDebugIdUpload', () => { }); }); +describe('prepareArtifactForUpload', () => { + const debugId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + const noopRewriteHook: RewriteSourcesHook = source => source; + const map = JSON.stringify({ version: 3, sources: ['../src/a.ts'], mappings: 'AAAA' }); + + it('stamps the debug ID into the bundle and the map and rewrites the sources', () => { + const rewriteHook: RewriteSourcesHook = (source, _map, context) => `${context?.mapDir}|${source}`; + + const result = prepareArtifactForUpload({ + code: `code();\n${debugIdSnippet(debugId)}`, + mapSource: map, + mapDir: '/out/assets', + rewriteSourcesHook: rewriteHook, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.artifact.debugId).toBe(debugId); + expect(result.artifact.bundleSource).toBe(`code();\n${debugIdSnippet(debugId)}\n//# debugId=${debugId}`); + expect(JSON.parse(result.artifact.sourceMapSource!)).toEqual({ + version: 3, + sources: ['/out/assets|../src/a.ts'], + mappings: 'AAAA', + debug_id: debugId, + debugId, + }); + }); + + it('replaces an existing spec comment', () => { + const result = prepareArtifactForUpload({ + code: `${debugIdSnippet(debugId)}\n//# debugId=00000000-0000-0000-0000-000000000000`, + mapSource: map, + mapDir: '/out', + rewriteSourcesHook: noopRewriteHook, + }); + + expect(result.ok && result.artifact.bundleSource).toBe(`${debugIdSnippet(debugId)}\n//# debugId=${debugId}`); + }); + + it('prepares a bundle with an inlined map without a separate map', () => { + const result = prepareArtifactForUpload({ + code: `${debugIdSnippet(debugId)}\n//# sourceMappingURL=data:application/json;base64,e30=`, + mapSource: undefined, + mapDir: '/out', + rewriteSourcesHook: noopRewriteHook, + }); + + expect(result.ok && result.artifact.sourceMapSource).toBeUndefined(); + }); + + it('reports a bundle without a debug ID', () => { + expect( + prepareArtifactForUpload({ + code: 'code();', + mapSource: map, + mapDir: '/out', + rewriteSourcesHook: noopRewriteHook, + }), + ).toEqual({ ok: false, reason: 'no-debug-id' }); + }); + + it('reports a bundle without any source map', () => { + expect( + prepareArtifactForUpload({ + code: debugIdSnippet(debugId), + mapSource: undefined, + mapDir: '/out', + rewriteSourcesHook: noopRewriteHook, + }), + ).toEqual({ ok: false, reason: 'no-source-map' }); + }); + + it('reports an invalid source map', () => { + expect( + prepareArtifactForUpload({ + code: debugIdSnippet(debugId), + mapSource: '{not json', + mapDir: '/out', + rewriteSourcesHook: noopRewriteHook, + }), + ).toEqual({ ok: false, reason: 'invalid-source-map' }); + }); +}); + +describe('writePreparedArtifact', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('writes the bundle and the map', async () => { + await writePreparedArtifact(tmpDir, 'abc-0', { debugId: 'abc', bundleSource: 'js', sourceMapSource: '{}' }); + + expect(fs.readFileSync(path.join(tmpDir, 'abc-0.js'), 'utf8')).toBe('js'); + expect(fs.readFileSync(path.join(tmpDir, 'abc-0.js.map'), 'utf8')).toBe('{}'); + }); + + it('writes only the bundle when there is no separate map', async () => { + await writePreparedArtifact(tmpDir, 'abc-0', { debugId: 'abc', bundleSource: 'js', sourceMapSource: undefined }); + + expect(fs.readdirSync(tmpDir)).toEqual(['abc-0.js']); + }); +}); + describe('stampDebugId', () => { const debugId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; const bundleSource = `"use strict";\n${debugIdSnippet(debugId)}\n//# sourceMappingURL=bundle.js.map`; diff --git a/packages/bundler-plugins/test/core/option-mappings.test.ts b/packages/bundler-plugins/test/core/option-mappings.test.ts index 341aad2f21e6..1c1bbbe42b82 100644 --- a/packages/bundler-plugins/test/core/option-mappings.test.ts +++ b/packages/bundler-plugins/test/core/option-mappings.test.ts @@ -351,6 +351,25 @@ describe('validateOptions', () => { expect(mockedLogger.error).not.toHaveBeenCalled(); }); + it('warns when `deleteSourcemapsAfterUpload` is set together with `sourcemaps.disable`', () => { + const options = normalizeUserOptions({ + sourcemaps: { disable: 'disable-upload', deleteSourcemapsAfterUpload: true }, + }); + + expect(validateOptions(options, mockedLogger)).toBe(true); + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('deleteSourcemapsAfterUpload'), + expect.any(String), + ); + }); + + it('does not warn when `deleteSourcemapsAfterUpload` is set and uploads are enabled', () => { + const options = normalizeUserOptions({ sourcemaps: { deleteSourcemapsAfterUpload: true } }); + + expect(validateOptions(options, mockedLogger)).toBe(true); + expect(mockedLogger.warn).not.toHaveBeenCalled(); + }); + it('should return `true` for options without special cases', () => { const options = { org: 'my-org', diff --git a/packages/bundler-plugins/test/core/sentry/telemetry.test.ts b/packages/bundler-plugins/test/core/sentry/telemetry.test.ts index 49cf78b7dffa..7de02ab79bc0 100644 --- a/packages/bundler-plugins/test/core/sentry/telemetry.test.ts +++ b/packages/bundler-plugins/test/core/sentry/telemetry.test.ts @@ -68,6 +68,19 @@ describe('addPluginOptionTagsToScope', () => { expect(mockedScope.setTag).toHaveBeenCalledWith('deploy-options', true); }); + it.each([ + [undefined, 'auto'], + [true, 'true'], + [false, 'false'], + ])('should set the delete-sourcemaps-after-upload tag for `%s` to `%s`', (option, tag) => { + setTelemetryDataOnScope( + normalizeUserOptions({ ...defaultOptions, sourcemaps: { deleteSourcemapsAfterUpload: option } }), + mockedScope as unknown as Scope, + 'rollup', + ); + expect(mockedScope.setTag).toHaveBeenCalledWith('delete-sourcemaps-after-upload', tag); + }); + it('should set errorHandler tag to `custom` if the errorHandler option is specified', () => { setTelemetryDataOnScope( // eslint-disable-next-line @typescript-eslint/no-empty-function diff --git a/packages/bundler-plugins/test/core/sourcemap-settings.test.ts b/packages/bundler-plugins/test/core/sourcemap-settings.test.ts new file mode 100644 index 000000000000..033f9e26ab36 --- /dev/null +++ b/packages/bundler-plugins/test/core/sourcemap-settings.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeUserOptions } from '../../src/core/options-mapping'; +import type { Options } from '../../src/core'; +import { decideSourceMapHandling } from '../../src/core/sourcemap-settings'; +import type { SourceMapKind } from '../../src/core/sourcemap-settings'; + +function options(sourcemaps?: Options['sourcemaps']) { + return normalizeUserOptions({ org: 'o', project: 'p', authToken: 't', release: { name: 'r' }, sourcemaps }); +} + +const NO_CHANGE = { enableHidden: false, deleteAfterUpload: false }; + +describe('decideSourceMapHandling', () => { + describe('with the option unset (auto)', () => { + it('enables hidden source maps and deletion when the bundler setting is unset', () => { + const decision = decideSourceMapHandling('unset', options(), true); + + expect(decision).toMatchObject({ enableHidden: true, deleteAfterUpload: true }); + expect(decision.log?.level).toBe('info'); + }); + + it.each(['hidden', 'visible'])('leaves a `%s` setting alone', kind => { + expect(decideSourceMapHandling(kind, options(), true)).toEqual(NO_CHANGE); + }); + + it('warns when source maps are turned off', () => { + const decision = decideSourceMapHandling('off', options(), true); + + expect(decision).toMatchObject(NO_CHANGE); + expect(decision.log?.level).toBe('warn'); + expect(decision.log?.message).toMatch(/turned off/); + }); + + it('warns when source maps are inlined', () => { + const decision = decideSourceMapHandling('inline', options(), true); + + expect(decision).toMatchObject(NO_CHANGE); + expect(decision.log?.level).toBe('warn'); + expect(decision.log?.message).toMatch(/inlined/); + }); + }); + + describe('with the option set to true', () => { + it('enables hidden source maps and deletion when the bundler setting is unset', () => { + expect(decideSourceMapHandling('unset', options({ deleteSourcemapsAfterUpload: true }), true)).toMatchObject({ + enableHidden: true, + deleteAfterUpload: true, + }); + }); + + it('deletes hidden source maps without changing the setting', () => { + expect(decideSourceMapHandling('hidden', options({ deleteSourcemapsAfterUpload: true }), true)).toEqual({ + enableHidden: false, + deleteAfterUpload: true, + }); + }); + + it('switches visible source maps to hidden and deletes them', () => { + const decision = decideSourceMapHandling('visible', options({ deleteSourcemapsAfterUpload: true }), true); + + expect(decision).toMatchObject({ enableHidden: true, deleteAfterUpload: true }); + expect(decision.log?.level).toBe('info'); + }); + + it.each(['off', 'inline'])('warns and does not delete for a `%s` setting', kind => { + const decision = decideSourceMapHandling(kind, options({ deleteSourcemapsAfterUpload: true }), true); + + expect(decision).toMatchObject(NO_CHANGE); + expect(decision.log?.level).toBe('warn'); + }); + }); + + describe('with the option set to false', () => { + it.each(['unset', 'off', 'visible', 'hidden', 'inline'])( + 'never changes or warns about a `%s` setting', + kind => { + expect(decideSourceMapHandling(kind, options({ deleteSourcemapsAfterUpload: false }), true)).toEqual(NO_CHANGE); + }, + ); + }); + + it.each(['unset', 'off', 'visible', 'hidden', 'inline'])( + 'does nothing for a `%s` setting when no upload can run', + kind => { + expect(decideSourceMapHandling(kind, options({ deleteSourcemapsAfterUpload: true }), false)).toEqual(NO_CHANGE); + }, + ); + + it.each([true, 'disable-upload'] as const)('does nothing when `sourcemaps.disable` is `%s`', disable => { + expect(decideSourceMapHandling('unset', options({ disable, deleteSourcemapsAfterUpload: true }), true)).toEqual( + NO_CHANGE, + ); + }); +}); diff --git a/packages/bundler-plugins/test/core/upload-staging.test.ts b/packages/bundler-plugins/test/core/upload-staging.test.ts new file mode 100644 index 000000000000..3a4577d90bc2 --- /dev/null +++ b/packages/bundler-plugins/test/core/upload-staging.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createSentryBuildPluginManager } from '../../src/core/build-plugin-manager'; + +const { mockSourcemapUpload } = vi.hoisted(() => ({ mockSourcemapUpload: vi.fn() })); + +vi.mock('sentry', () => ({ + createSentrySDK: () => ({ + release: { create: vi.fn(), finalize: vi.fn(), 'set-commits': vi.fn(), deploy: vi.fn() }, + sourcemap: { upload: mockSourcemapUpload, inject: vi.fn() }, + run: vi.fn(), + }), +})); + +vi.mock('../../src/core/sentry/telemetry', async () => ({ + ...(await vi.importActual('../../src/core/sentry/telemetry')), + safeFlushTelemetry: vi.fn(), +})); + +const DEBUG_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; +const OTHER_DEBUG_ID = '11111111-2222-3333-4444-555555555555'; + +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){}}();`; +} + +const MAP = JSON.stringify({ version: 3, sources: ['/abs/src/index.ts'], mappings: 'AAAA' }); + +function createManager(rewriteSources?: (source: string) => string) { + return createSentryBuildPluginManager( + { + authToken: 't', + org: 'o', + project: 'p', + release: { name: 'r', create: false, finalize: false }, + telemetry: false, + sourcemaps: { rewriteSources }, + }, + { buildTool: 'rollup', loggerPrefix: '[test]' }, + ); +} + +describe('createUploadStaging', () => { + let tmpDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + mockSourcemapUpload.mockResolvedValue(undefined); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-staging-test-')); + }); + + afterEach(() => { + delete process.env['SENTRY_TEST_OVERRIDE_TEMP_DIR']; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('writes the stamped chunk and map into the staging folder', async () => { + const manager = createManager(source => path.basename(source)); + const staging = await manager.createUploadStaging(); + + try { + const result = await staging.stage({ + code: chunk(DEBUG_ID), + mapSource: MAP, + mapDir: '/out', + fileName: 'index.js', + }); + + expect(result).toBe('staged'); + expect(staging.count).toBe(1); + expect(fs.readdirSync(staging.dir).sort()).toEqual([`${DEBUG_ID}-0.js`, `${DEBUG_ID}-0.js.map`]); + expect(fs.readFileSync(path.join(staging.dir, `${DEBUG_ID}-0.js`), 'utf8')).toContain(`//# debugId=${DEBUG_ID}`); + + const map = JSON.parse(fs.readFileSync(path.join(staging.dir, `${DEBUG_ID}-0.js.map`), 'utf8')); + expect(map).toMatchObject({ debug_id: DEBUG_ID, debugId: DEBUG_ID, sources: ['index.ts'] }); + } finally { + await staging.discard(); + } + }); + + it('passes the map directory to the rewriteSources hook', async () => { + const rewriteSources = vi.fn((source: string) => source); + const manager = createManager(rewriteSources); + const staging = await manager.createUploadStaging(); + + try { + await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out/assets', fileName: 'a.js' }); + } finally { + await staging.discard(); + } + + expect(rewriteSources).toHaveBeenCalledWith('/abs/src/index.ts', expect.any(Object), { mapDir: '/out/assets' }); + }); + + it('skips chunks without a debug ID or without a source map and does not count them', async () => { + const manager = createManager(); + 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' }), + ).toBe('skipped'); + expect(staging.count).toBe(0); + expect(fs.readdirSync(staging.dir)).toEqual([]); + } finally { + 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.discard(); + await staging.discard(); + + expect(fs.existsSync(staging.dir)).toBe(false); + }); + + it('keeps the folder on discard when SENTRY_TEST_OVERRIDE_TEMP_DIR is set', async () => { + process.env['SENTRY_TEST_OVERRIDE_TEMP_DIR'] = tmpDir; + const manager = createManager(); + const staging = await manager.createUploadStaging(); + + expect(staging.dir).toBe(tmpDir); + await staging.discard(); + + expect(fs.existsSync(tmpDir)).toBe(true); + }); + + describe('uploadSourcemaps with a staging folder', () => { + 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' }); + + const result = await manager.uploadSourcemaps([], { staging }); + + expect(result).toEqual({ uploaded: true }); + expect(mockSourcemapUpload).toHaveBeenCalledWith(expect.objectContaining({ directory: staging.dir })); + expect(fs.existsSync(staging.dir)).toBe(false); + }); + + it('numbers artifacts prepared from disk after the staged ones', async () => { + const outDir = path.join(tmpDir, 'out'); + fs.mkdirSync(outDir); + fs.writeFileSync(path.join(outDir, 'disk.js'), `${chunk(OTHER_DEBUG_ID)}\n//# sourceMappingURL=disk.js.map`); + fs.writeFileSync(path.join(outDir, 'disk.js.map'), MAP); + + const manager = createManager(); + const staging = await manager.createUploadStaging(); + await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: outDir, fileName: 'staged.js' }); + + let uploadedFiles: string[] = []; + mockSourcemapUpload.mockImplementation(async ({ directory }: { directory: string }) => { + uploadedFiles = fs.readdirSync(directory).sort(); + }); + + await manager.uploadSourcemaps([path.join(outDir, 'disk.js')], { staging }); + + expect(uploadedFiles).toEqual([ + `${OTHER_DEBUG_ID}-1.js`, + `${OTHER_DEBUG_ID}-1.js.map`, + `${DEBUG_ID}-0.js`, + `${DEBUG_ID}-0.js.map`, + ]); + }); + + it('does not warn about missing sources when artifacts were staged', async () => { + 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 manager.uploadSourcemaps([], { staging }); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(mockSourcemapUpload).toHaveBeenCalledTimes(1); + }); + + it('discards an empty staging folder and warns when nothing matched on disk', async () => { + const manager = createManager(); + const warnSpy = vi.spyOn(manager.logger, 'warn'); + const staging = await manager.createUploadStaging(); + + const result = await manager.uploadSourcemaps([], { staging }); + + expect(result).toEqual({ uploaded: false }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringMatching(/matching sources/)); + expect(mockSourcemapUpload).not.toHaveBeenCalled(); + expect(fs.existsSync(staging.dir)).toBe(false); + }); + + it('discards the staging folder when the upload cannot run', async () => { + const manager = createSentryBuildPluginManager( + { org: 'o', project: 'p', release: { name: 'r' }, telemetry: false }, + { buildTool: 'rollup', loggerPrefix: '[test]' }, + ); + const staging = await manager.createUploadStaging(); + await staging.stage({ code: chunk(DEBUG_ID), mapSource: MAP, mapDir: '/out', fileName: 'a.js' }); + + const result = await manager.uploadSourcemaps([], { staging }); + + expect(result).toEqual({ uploaded: false }); + expect(mockSourcemapUpload).not.toHaveBeenCalled(); + expect(fs.existsSync(staging.dir)).toBe(false); + }); + + it('keeps the staging folder and logs its path when the upload fails', async () => { + mockSourcemapUpload.mockRejectedValue(new Error('upload failed')); + 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' }); + + try { + const result = await manager.uploadSourcemaps([], { staging }); + + expect(result).toEqual({ uploaded: false }); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(`sentry sourcemap upload ${staging.dir}`)); + expect(fs.existsSync(staging.dir)).toBe(true); + } finally { + await staging.discard(); + } + }); + + 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' }); + + const result = await manager.uploadSourcemaps(['.next/server'], { staging, prepareArtifacts: false }); + + expect(result).toEqual({ uploaded: true }); + expect(mockSourcemapUpload).toHaveBeenCalledTimes(2); + expect(mockSourcemapUpload).toHaveBeenCalledWith(expect.objectContaining({ directory: '.next/server' })); + expect(mockSourcemapUpload).toHaveBeenCalledWith(expect.objectContaining({ directory: staging.dir })); + expect(fs.existsSync(staging.dir)).toBe(false); + }); + + it('skips an empty staging folder when prepareArtifacts is false', async () => { + const manager = createManager(); + const staging = await manager.createUploadStaging(); + + await manager.uploadSourcemaps(['.next/server'], { staging, prepareArtifacts: false }); + + expect(mockSourcemapUpload).toHaveBeenCalledTimes(1); + expect(mockSourcemapUpload).toHaveBeenCalledWith(expect.objectContaining({ directory: '.next/server' })); + expect(fs.existsSync(staging.dir)).toBe(false); + }); + }); +}); + +describe('sourceMaps', () => { + afterEach(() => { + delete process.env['NODE_ENV']; + }); + + it('reports that an upload is possible with credentials', () => { + expect(createManager().sourceMaps.uploadPossible).toBe(true); + }); + + it.each([ + ['no auth token', { org: 'o', project: 'p' }], + ['no project', { org: 'o', authToken: 't' }], + ['upload disabled', { org: 'o', project: 'p', authToken: 't', sourcemaps: { disable: true as const } }], + ['an empty assets list', { org: 'o', project: 'p', authToken: 't', sourcemaps: { assets: [] } }], + ])('reports that no upload is possible with %s', (_, options) => { + const manager = createSentryBuildPluginManager( + { ...options, release: { name: 'r' }, telemetry: false }, + { buildTool: 'rollup', loggerPrefix: '[test]' }, + ); + + expect(manager.sourceMaps.uploadPossible).toBe(false); + expect(manager.sourceMaps.decide('unset')).toEqual({ enableHidden: false, deleteAfterUpload: false }); + }); + + it('reports that no upload is possible in development mode', () => { + process.env['NODE_ENV'] = 'development'; + + expect(createManager().sourceMaps.uploadPossible).toBe(false); + }); + + it('logs the decision message once', () => { + const manager = createManager(); + const infoSpy = vi.spyOn(manager.logger, 'info'); + const { decide } = manager.sourceMaps; + + expect(decide('unset')).toMatchObject({ enableHidden: true, deleteAfterUpload: true }); + expect(decide('unset')).toMatchObject({ enableHidden: true, deleteAfterUpload: true }); + + expect(infoSpy).toHaveBeenCalledTimes(1); + }); + + it('is inert on a disabled plugin', async () => { + const manager = createSentryBuildPluginManager( + { disable: true, org: 'o', project: 'p', authToken: 't' }, + { buildTool: 'rollup', loggerPrefix: '[test]' }, + ); + + expect(manager.sourceMaps.uploadPossible).toBe(false); + 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 manager.uploadSourcemaps([], { staging })).toEqual({ uploaded: false }); + }); +}); diff --git a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts index 36a8967c8105..1d345c633c9c 100644 --- a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts +++ b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts @@ -248,6 +248,15 @@ interface SourceMapsOptions { */ filesToDeleteAfterUpload?: string | Array; + /** + * Whether source maps should be removed from the build output after they are uploaded to Sentry. + * + * When unset, source maps are removed only if the plugin turned source map generation on itself, that is, when + * the bundler's source map setting was not set. Set to `true` to also remove source maps you enabled yourself, or + * to `false` to keep the plugin from changing or warning about your source map setting. + */ + deleteSourcemapsAfterUpload?: boolean; + /** * Hook to rewrite the `sources` field inside the source map before being uploaded to Sentry. Does not modify the actual source map. *