Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => {
"sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"],
["release","set-commits","CURRENT_SHA","--auto"],
["release","finalize","CURRENT_SHA"],
["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"],
",
}
`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => {
"sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"],
["release","set-commits","CURRENT_SHA","--auto"],
["release","finalize","CURRENT_SHA"],
["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"],
",
}
`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles }) => {
;",
"sentry-cli-mock.json": "["release","set-commits","CURRENT_SHA","--auto"],
["release","finalize","CURRENT_SHA"],
["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"],
",
}
`);
Expand Down
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"],
"
`);
});
3 changes: 3 additions & 0 deletions packages/bundler-plugins/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ export interface Options {
* `sourcemaps.assets` or `sourcemaps.ignore` keep out of the upload. Source maps you enabled yourself are only
* removed for the JavaScript chunks that were uploaded; excluded chunks keep theirs.
*
* With webpack's array form of `devtool`, `true` replaces the array with one hidden source map setting for
* all asset types, because webpack hides the array from plugins.
*
* With Rollup, Vite and webpack the removed source maps are never written to the output directory. With esbuild
* they are deleted after the upload.
*/
Expand Down
173 changes: 171 additions & 2 deletions packages/bundler-plugins/src/webpack/index.ts
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,
Expand Down Expand Up @@ -71,6 +71,7 @@ type WebpackCompiler = {
options: {
plugins?: unknown[];
mode?: string;
devtool?: string | false;
module?: {
rules?: unknown[];
};
Expand All @@ -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;
Expand All @@ -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';

Copy link
Copy Markdown
Member

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, but includes('eval') maps it to inline. The user then gets "Source maps are inlined into your bundles. They are uploaded to Sentry, but…", which is false: nothing is uploaded. Plain eval in a production build is rare.

Suggestion: map devtool === 'eval' (no source-map in the value) to 'off'.

}
if (devtool.includes('hidden')) {
return 'hidden';
}
return 'visible';
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

type WebpackModule = {
version?: string;
default?: { version?: string };
Expand Down Expand Up @@ -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`;
Comment thread
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),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 devtool is unset, the plugin sets the string 'hidden-source-map', which webpack applies to every asset type. css-loader defaults its sourceMap option from devtool, so with mini-css-extract-plugin (or webpack's native CSS) webpack now emits main.css.map. stageSourceMaps only looks at JS assets, so the CSS map is written to disk.

Before this PR, an app with no devtool had no maps. After upgrading it ships a hidden CSS map that holds the original CSS (or Sass/Less) source, which is what this feature is meant to prevent. Bugbot raised this already, and your answer is right for Vite, but not for webpack.

I could see two options for a fix, both are pretty small.

  • On webpack 5.105+, set devtool to [{ type: 'javascript', use: 'hidden-source-map' }] instead of the string, so CSS gets no maps at all. I checked that a JS-only array emits no main.css.map ([{"type":"javascript","use":"source-map"}] gave main.css, main.js, main.js.map). Older webpack versions need a fallback.
  • Or, when the decision came from kind === 'unset', also delete map assets of non-JS assets (related.sourceMap of any asset, or *.css.map) without uploading them, because the user never asked for them.

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);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
}

function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) {
const sentryBuildPluginManager = createSentryBuildPluginManager(userOptions, {
loggerPrefix: userOptions._metaOptions?.loggerPrefixOverride ?? '[sentry-webpack-plugin]',
Expand Down Expand Up @@ -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';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Webpack 5.105 added an array form, devtool: [{ type: 'javascript' | 'css' | 'all', use }]. Bugbot's "Array devtool becomes invalid value" (hidden-[object Object]) does not happen. Webpack wraps the options in a compatibility proxy that returns the all or javascript entry's use as a string when a plugin reads options.devtool. But writing compiler.options.devtool = 'hidden-...' replaces the whole array with a string, which webpack then applies to type: 'all'. Reproduced:

  • [{ type: 'javascript', use: 'source-map' }] + true: the user asked for no CSS maps, and the output now has main.css.map (left on disk, see comment above).
  • [{ type: 'javascript', use: 'source-map' }, { type: 'css', use: 'inline-source-map' }] + true: the user's inline CSS maps become a separate main.css.map file left on disk.

This only affects users who opt in with true and use the new array form, so it is rare.

Suggestion: when Array.isArray holds for the raw value (read it through Reflect.get on the target, or check typeof before the proxy turns it into a string), add the hidden- prefix only to the javascript/all entries and leave the rest alone. If that is too much for this PR, skip the switch for array values and log at debug level.

Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

return { kind, decision };
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

function addCodeInjection(compiler: WebpackCompiler): void {
if (releaseInjectionCode.isEmpty() && chunkInjectionCode.isEmpty() && !sourcemapsEnabled) {
return;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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();
Comment thread
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();
Expand Down
Loading
Loading