Repository navigation
feat(bundler-plugins): Enable hidden source maps and remove them after upload in the webpack plugin #25647
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(bundler-plugins): Enable hidden source maps and remove them after upload in the webpack plugin #25647
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { sentryWebpackPlugin } from "@sentry/bundler-plugins/webpack"; | ||
| import { sentryConfig } from "../configs/sourcemaps-assets-auto.config.js"; | ||
| import { resolve } from "node:path"; | ||
|
|
||
| export default { | ||
| cache: false, | ||
| entry: { | ||
| entry1: "./src/entry1.js", | ||
| entry2: "./src/entry2.js", | ||
| }, | ||
| output: { | ||
| path: resolve("./out/sourcemaps-assets-auto"), | ||
| filename: "[name].js", | ||
| }, | ||
| optimization: { | ||
| minimize: false, | ||
| }, | ||
| mode: "production", | ||
| plugins: [sentryWebpackPlugin(sentryConfig)], | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(` | ||
| [ | ||
| "entry1.js", | ||
| "entry2.js", | ||
| "sentry-cli-mock.json", | ||
| "sentry-uploaded-sources.json", | ||
| ] | ||
| `); | ||
| expect(JSON.parse(files["sentry-uploaded-sources.json"]!)).toMatchInlineSnapshot(` | ||
| [ | ||
| [ | ||
| "webpack5-integration-tests/./src/common.js", | ||
| "webpack5-integration-tests/./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"], | ||
| " | ||
| `); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { sentryWebpackPlugin } from "@sentry/bundler-plugins/webpack"; | ||
| import { sentryConfig } from "../configs/sourcemaps-ignore-auto.config.js"; | ||
| import { resolve } from "node:path"; | ||
|
|
||
| export default { | ||
| cache: false, | ||
| entry: { | ||
| entry1: "./src/entry1.js", | ||
| entry2: "./src/entry2.js", | ||
| }, | ||
| output: { | ||
| path: resolve("./out/sourcemaps-ignore-auto"), | ||
| filename: "[name].js", | ||
| }, | ||
| optimization: { | ||
| minimize: false, | ||
| }, | ||
| mode: "production", | ||
| plugins: [sentryWebpackPlugin(sentryConfig)], | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(` | ||
| [ | ||
| "entry1.js", | ||
| "entry2.js", | ||
| "sentry-cli-mock.json", | ||
| "sentry-uploaded-sources.json", | ||
| ] | ||
| `); | ||
| expect(JSON.parse(files["sentry-uploaded-sources.json"]!)).toMatchInlineSnapshot(` | ||
| [ | ||
| [ | ||
| "webpack5-integration-tests/./src/common.js", | ||
| "webpack5-integration-tests/./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"], | ||
| " | ||
| `); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| /* oxlint-disable max-lines */ | ||
|
|
||
| import type { Options } from '../core/index'; | ||
| import type { Options, SourceMapDecision, SourceMapKind, UploadStaging } from '../core/index'; | ||
| import { | ||
| createSentryBuildPluginManager, | ||
| generateReleaseInjectorCode, | ||
|
|
@@ -71,6 +71,7 @@ type WebpackCompiler = { | |
| options: { | ||
| plugins?: unknown[]; | ||
| mode?: string; | ||
| devtool?: string | false; | ||
| module?: { | ||
| rules?: unknown[]; | ||
| }; | ||
|
|
@@ -82,18 +83,25 @@ type WebpackCompiler = { | |
| thisCompilation: { | ||
| tap: (name: string, callback: (compilation: WebpackCompilation) => void) => void; | ||
| }; | ||
| emit: { | ||
| tap: (name: string, callback: (compilation: WebpackCompilation) => void) => void; | ||
| }; | ||
| afterEmit: { | ||
| tapAsync: (name: string, callback: (compilation: WebpackCompilation, cb: () => void) => void) => void; | ||
| }; | ||
| done: { | ||
| tap: (name: string, callback: () => void) => void; | ||
| }; | ||
| failed?: { | ||
| tap: (name: string, callback: () => void) => void; | ||
| }; | ||
| }; | ||
| webpack?: { | ||
| DefinePlugin?: PluginClass; | ||
| Compilation?: { | ||
| PROCESS_ASSETS_STAGE_ADDITIONS?: number; | ||
| PROCESS_ASSETS_STAGE_DEV_TOOLING?: number; | ||
| PROCESS_ASSETS_STAGE_REPORT?: number; | ||
| }; | ||
| sources?: { | ||
| RawSource?: WebpackRawSource; | ||
|
|
@@ -116,16 +124,48 @@ type WebpackCompilation = { | |
| getAssets: () => WebpackAsset[]; | ||
| getAsset: (name: string) => WebpackAsset | undefined; | ||
| updateAsset: (name: string, source: WebpackSource) => void; | ||
| deleteAsset: (name: string) => void; | ||
| hooks: { | ||
| processAssets: { | ||
| tap: ( | ||
| options: { name: string; stage: number }, | ||
| callback: (assets: Record<string, WebpackSource>) => void, | ||
| ) => void; | ||
| tapPromise: (options: { name: string; stage: number }, callback: () => Promise<void>) => void; | ||
| }; | ||
| }; | ||
| }; | ||
|
|
||
| /** | ||
| * Maps webpack's `devtool` onto the plugin's source map kinds. A user who sets `devtool: false` | ||
| * and adds a `SourceMapDevToolPlugin` manages source maps themselves. | ||
| */ | ||
| function sourceMapKindFromDevtool(compiler: WebpackCompiler): SourceMapKind | undefined { | ||
| const devtool = compiler.options.devtool; | ||
|
|
||
| if (devtool === undefined) { | ||
| return 'unset'; | ||
| } | ||
| if (devtool === false) { | ||
| const hasSourceMapPlugin = compiler.options.plugins?.some( | ||
| plugin => | ||
| (plugin as { constructor?: { name?: string } } | undefined)?.constructor?.name === 'SourceMapDevToolPlugin', | ||
| ); | ||
| return hasSourceMapPlugin ? undefined : 'off'; | ||
| } | ||
| // Plain `eval` has no source maps at all. | ||
| if (!devtool.includes('source-map')) { | ||
| return 'off'; | ||
| } | ||
| if (devtool.includes('inline') || devtool.includes('eval')) { | ||
| return 'inline'; | ||
| } | ||
| if (devtool.includes('hidden')) { | ||
| return 'hidden'; | ||
| } | ||
| return 'visible'; | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| type WebpackModule = { | ||
| version?: string; | ||
| default?: { version?: string }; | ||
|
|
@@ -171,6 +211,66 @@ function addDebugIdsToAssets(compilation: WebpackCompilation, RawSource: Webpack | |
| } | ||
| } | ||
|
|
||
| /** Drops a source map asset and its compressed variants from the compilation. */ | ||
| function deleteSourceMapAssets(compilation: WebpackCompilation, sourceMapName: string): void { | ||
| for (const candidate of compilation.getAssets()) { | ||
| if (candidate.name === sourceMapName || candidate.name.startsWith(`${sourceMapName}.`)) { | ||
| compilation.deleteAsset(candidate.name); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Copies each JS asset and its source map into the upload staging folder and drops the source map | ||
| * assets (and their compressed variants) from the compilation, so webpack never emits them. | ||
| * | ||
| * A map the plugin turned on (`pluginEnabledMaps`) never reaches the output, whether or not its | ||
| * asset is uploaded. That covers CSS maps, which `devtool` turns on along with the JS ones. A map | ||
| * the user enabled stays unless it was staged. | ||
| */ | ||
| async function stageSourceMaps( | ||
| compilation: WebpackCompilation, | ||
| staging: UploadStaging, | ||
| logger: { warn: (message: string) => void }, | ||
| pluginEnabledMaps: boolean, | ||
| ): Promise<void> { | ||
| const outputPath = compilation.outputOptions.path ?? path.resolve(); | ||
|
|
||
| for (const asset of compilation.getAssets()) { | ||
| const relatedSourceMap = asset.info.related?.sourceMap; | ||
| const sourceMapName = typeof relatedSourceMap === 'string' ? relatedSourceMap : `${asset.name}.map`; | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
| const sourceMapAsset = compilation.getAsset(sourceMapName); | ||
| if (!sourceMapAsset) { | ||
| continue; | ||
| } | ||
|
|
||
| if (!isJsFile(asset.name)) { | ||
| if (pluginEnabledMaps) { | ||
| deleteSourceMapAssets(compilation, sourceMapName); | ||
| } | ||
| continue; | ||
| } | ||
|
|
||
| const result = await staging.stage({ | ||
| code: asset.source.source().toString(), | ||
| mapSource: sourceMapAsset.source.source().toString(), | ||
| mapDir: path.join(outputPath, path.dirname(sourceMapName)), | ||
| chunkPath: path.join(outputPath, asset.name), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new default writes CSS source maps that stay in the output. When Before this PR, an app with no I could see two options for a fix, both are pretty small.
We should add a test with a CSS import either way. |
||
| }); | ||
|
|
||
| if (result === 'skipped' || result === 'failed') { | ||
| logger.warn( | ||
| pluginEnabledMaps | ||
| ? `Could not prepare ${asset.name} for upload, so it is not uploaded and its source map ${sourceMapName} was removed from the build output.` | ||
| : `Could not prepare ${asset.name} for upload, so its source map ${sourceMapName} stays in the build output.`, | ||
| ); | ||
| } | ||
| if (result === 'staged' || pluginEnabledMaps) { | ||
| deleteSourceMapAssets(compilation, sourceMapName); | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
|
|
||
| function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) { | ||
| const sentryBuildPluginManager = createSentryBuildPluginManager(userOptions, { | ||
| loggerPrefix: userOptions._metaOptions?.loggerPrefixOverride ?? '[sentry-webpack-plugin]', | ||
|
|
@@ -231,6 +331,21 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) | |
|
|
||
| const transformReplace = Object.keys(replacementValues).length > 0; | ||
|
|
||
| function decideSourceMaps(compiler: WebpackCompiler): { kind?: SourceMapKind; decision: SourceMapDecision } { | ||
| const kind = compiler.options.mode === 'development' ? undefined : sourceMapKindFromDevtool(compiler); | ||
| if (kind === undefined) { | ||
| return { decision: { enableHidden: false, deleteAfterUpload: false } }; | ||
| } | ||
|
|
||
| const decision = sentryBuildPluginManager.sourceMaps.decide(kind); | ||
|
|
||
| if (decision.enableHidden) { | ||
| compiler.options.devtool = kind === 'visible' ? `hidden-${compiler.options.devtool}` : 'hidden-source-map'; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Webpack 5.105 added an array form,
This only affects users who opt in with Suggestion: when
cursor[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| return { kind, decision }; | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
cursor[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| function addCodeInjection(compiler: WebpackCompiler): void { | ||
| if (releaseInjectionCode.isEmpty() && chunkInjectionCode.isEmpty() && !sourcemapsEnabled) { | ||
| return; | ||
|
|
@@ -299,6 +414,47 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) | |
|
|
||
| const { DefinePlugin } = compiler.webpack ?? {}; | ||
|
|
||
| // One plugin instance can serve several compilers (`MultiCompiler`) and | ||
| // a compiler several compilations (watch mode), so nothing is shared. | ||
| const { kind: sourceMapKind, decision: sourceMapDecision } = decideSourceMaps(compiler); | ||
| const pendingStagings = new Map<WebpackCompilation, UploadStaging>(); | ||
|
|
||
| if (sourceMapDecision.deleteAfterUpload) { | ||
| // After `RealContentHashPlugin` rewrote the hash placeholders, so the staged | ||
| // JS equals the emitted file and `[contenthash]` names are final. | ||
| const stage = (compiler.webpack?.Compilation?.PROCESS_ASSETS_STAGE_REPORT ?? 5000) + 1; | ||
|
|
||
| compiler.hooks.thisCompilation.tap('sentry-webpack-plugin', compilation => { | ||
| compilation.hooks.processAssets.tapPromise({ name: 'sentry-webpack-plugin', stage }, async () => { | ||
| const staging = await sentryBuildPluginManager.createUploadStaging(); | ||
| pendingStagings.set(compilation, staging); | ||
| await stageSourceMaps(compilation, staging, logger, sourceMapKind === 'unset'); | ||
| }); | ||
| }); | ||
|
|
||
| // A staging that `afterEmit` never took belongs to a compilation that | ||
| // failed before emitting, or to one whose assets were emitted without | ||
| // their maps because an earlier `afterEmit` tap failed. Only the | ||
| // second kind has no other copy of the maps. | ||
| const emittedCompilations = new Set<WebpackCompilation>(); | ||
| compiler.hooks.emit.tap('sentry-webpack-plugin', compilation => { | ||
| emittedCompilations.add(compilation); | ||
| }); | ||
| const settlePendingStagings = (): void => { | ||
| for (const [compilation, staging] of pendingStagings) { | ||
| if (emittedCompilations.has(compilation)) { | ||
| void staging.keep('The source map upload did not run.'); | ||
| } else { | ||
| void staging.discard(); | ||
| } | ||
| } | ||
| pendingStagings.clear(); | ||
| emittedCompilations.clear(); | ||
| }; | ||
| compiler.hooks.done.tap('sentry-webpack-plugin', settlePendingStagings); | ||
| compiler.hooks.failed?.tap('sentry-webpack-plugin', settlePendingStagings); | ||
| } | ||
|
|
||
| // BannerPlugin prepends executable code before directive prologues. | ||
| addCodeInjection(compiler); | ||
|
|
||
|
|
@@ -354,14 +510,27 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) | |
| const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); | ||
| const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); | ||
|
|
||
| const staging = pendingStagings.get(compilation); | ||
| pendingStagings.delete(compilation); | ||
| let stagingHandedOver = false; | ||
|
|
||
| const run = async (): Promise<void> => { | ||
| try { | ||
| await sentryBuildPluginManager.createRelease(); | ||
| if (sourcemapsEnabled && options.sourcemaps?.disable !== 'disable-upload') { | ||
| const outputPath = compilation.outputOptions.path ?? path.resolve(); | ||
| const buildArtifacts = Object.keys(compilation.assets).map(asset => path.join(outputPath, asset)); | ||
| await upload(buildArtifacts); | ||
| stagingHandedOver = true; | ||
| await upload(buildArtifacts, { staging }); | ||
| } else { | ||
| stagingHandedOver = true; | ||
| await staging?.discard(); | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
| } catch (e) { | ||
| if (!stagingHandedOver) { | ||
| await staging?.keep('The source map upload did not run.'); | ||
| } | ||
| throw e; | ||
| } finally { | ||
| freeGlobalDependencyOnBuildArtifacts(); | ||
| await sentryBuildPluginManager.deleteArtifacts(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
'eval'contains no source map at all, butincludes('eval')maps it toinline. The user then gets "Source maps are inlined into your bundles. They are uploaded to Sentry, but…", which is false: nothing is uploaded. Plainevalin a production build is rare.Suggestion: map
devtool === 'eval'(nosource-mapin the value) to'off'.