Skip to content

Commit 35e806c

Browse files
committed
feat(bundler-plugins): Enable external source maps and delete them after upload in the esbuild plugin
esbuild writes its output before `onEnd` runs and has no hook to drop an output, so the maps are deleted from disk after a successful upload instead. When the upload fails they stay, with an error log.
1 parent 7ac38b2 commit 35e806c

11 files changed

Lines changed: 376 additions & 12 deletions

File tree

‎dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/basic-cjs.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => {
3535
"sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"],
3636
["release","set-commits","CURRENT_SHA","--auto"],
3737
["release","finalize","CURRENT_SHA"],
38+
["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"],
3839
",
3940
}
4041
`);

‎dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/basic.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => {
3535
"sentry-cli-mock.json": "["release","create","CURRENT_SHA","--project","fake-project"],
3636
["release","set-commits","CURRENT_SHA","--auto"],
3737
["release","finalize","CURRENT_SHA"],
38+
["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"],
3839
",
3940
}
4041
`);

‎dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/release-disabled.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => {
3434
",
3535
"sentry-cli-mock.json": "["release","set-commits","CURRENT_SHA","--auto"],
3636
["release","finalize","CURRENT_SHA"],
37+
["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"],
3738
",
3839
}
3940
`);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as esbuild from "esbuild";
2+
import { sentryEsbuildPlugin } from "@sentry/bundler-plugins/esbuild";
3+
import { sentryConfig } from "../configs/sourcemaps-assets-auto.config.js";
4+
5+
await esbuild.build({
6+
entryPoints: ["./src/entry1.js", "./src/entry2.js"],
7+
bundle: true,
8+
outdir: "./out/sourcemaps-assets-auto",
9+
minify: false,
10+
format: "esm",
11+
splitting: true,
12+
chunkNames: "[name]",
13+
plugins: [sentryEsbuildPlugin(sentryConfig)],
14+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { expect } from "vitest";
2+
import { test } from "./utils";
3+
4+
test(import.meta.url, ({ runBundler, readOutputFiles }) => {
5+
runBundler({ SENTRY_TEST_RECORD_UPLOADED_SOURCES: "1" });
6+
const files = readOutputFiles();
7+
8+
expect(Object.keys(files).sort()).toMatchInlineSnapshot(`
9+
[
10+
"chunk.js",
11+
"chunk.js.map",
12+
"entry1.js",
13+
"entry2.js",
14+
"entry2.js.map",
15+
"sentry-cli-mock.json",
16+
"sentry-uploaded-sources.json",
17+
]
18+
`);
19+
expect(JSON.parse(files["sentry-uploaded-sources.json"]!)).toMatchInlineSnapshot(`
20+
[
21+
[
22+
"sentry-debug-id-stub:_sentry-debug-id-injection-stub?sentry-module-id=00000000-0000-0000-0000-000000000000",
23+
"../../src/entry1.js",
24+
"../../src/entry1.js",
25+
],
26+
]
27+
`);
28+
expect(files["sentry-cli-mock.json"]).toMatchInlineSnapshot(`
29+
"["release","create","CURRENT_SHA","--project","fake-project"],
30+
["release","set-commits","CURRENT_SHA","--auto"],
31+
["release","finalize","CURRENT_SHA"],
32+
["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"],
33+
"
34+
`);
35+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as esbuild from "esbuild";
2+
import { sentryEsbuildPlugin } from "@sentry/bundler-plugins/esbuild";
3+
import { sentryConfig } from "../configs/sourcemaps-ignore-auto.config.js";
4+
5+
await esbuild.build({
6+
entryPoints: ["./src/entry1.js", "./src/entry2.js"],
7+
bundle: true,
8+
outdir: "./out/sourcemaps-ignore-auto",
9+
minify: false,
10+
format: "esm",
11+
splitting: true,
12+
chunkNames: "[name]",
13+
plugins: [sentryEsbuildPlugin(sentryConfig)],
14+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { expect } from "vitest";
2+
import { test } from "./utils";
3+
4+
test(import.meta.url, ({ runBundler, readOutputFiles }) => {
5+
runBundler({ SENTRY_TEST_RECORD_UPLOADED_SOURCES: "1" });
6+
const files = readOutputFiles();
7+
8+
expect(Object.keys(files).sort()).toMatchInlineSnapshot(`
9+
[
10+
"chunk.js",
11+
"chunk.js.map",
12+
"entry1.js",
13+
"entry2.js",
14+
"entry2.js.map",
15+
"sentry-cli-mock.json",
16+
"sentry-uploaded-sources.json",
17+
]
18+
`);
19+
expect(JSON.parse(files["sentry-uploaded-sources.json"]!)).toMatchInlineSnapshot(`
20+
[
21+
[
22+
"sentry-debug-id-stub:_sentry-debug-id-injection-stub?sentry-module-id=00000000-0000-0000-0000-000000000000",
23+
"../../src/entry1.js",
24+
"../../src/entry1.js",
25+
],
26+
]
27+
`);
28+
expect(files["sentry-cli-mock.json"]).toMatchInlineSnapshot(`
29+
"["release","create","CURRENT_SHA","--project","fake-project"],
30+
["release","set-commits","CURRENT_SHA","--auto"],
31+
["release","finalize","CURRENT_SHA"],
32+
["sourcemap","upload","-p","fake-project","--release","CURRENT_SHA","sentry-bundler-plugin-upload-path"],
33+
"
34+
`);
35+
});

‎packages/bundler-plugins/src/core/debug-id-upload.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,11 @@ function hasSpecCompliantDebugId(bundleSource: string): boolean {
184184
return SPEC_LAST_DEBUG_ID_REGEX.test(bundleSource);
185185
}
186186

187+
/** Whether the bundle carries a debug ID, injected by the plugin or as a `//# debugId=` comment. */
188+
export function hasDebugId(bundleSource: string): boolean {
189+
return determineDebugIdFromBundleSource(bundleSource) !== undefined || hasSpecCompliantDebugId(bundleSource);
190+
}
191+
187192
function addDebugIdToBundleSource(bundleSource: string, debugId: string): string {
188193
if (hasSpecCompliantDebugId(bundleSource)) {
189194
return bundleSource.replace(SPEC_LAST_DEBUG_ID_REGEX, `//# debugId=${debugId}`);

‎packages/bundler-plugins/src/core/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export {
111111
} from './utils';
112112
export { createSentryBuildPluginManager } from './build-plugin-manager';
113113
export type { SentryBuildPluginManager, UploadStaging } from './build-plugin-manager';
114-
export { createDebugIdUploadFunction, addDebugIdToEmittedArtifacts, stampDebugId } from './debug-id-upload';
114+
export { createDebugIdUploadFunction, addDebugIdToEmittedArtifacts, hasDebugId, stampDebugId } from './debug-id-upload';
115115
export { decideSourceMapHandling } from './sourcemap-settings';
116116
export type { SourceMapDecision, SourceMapKind } from './sourcemap-settings';
117117
export type { UploadFilter } from './upload-filter';

‎packages/bundler-plugins/src/esbuild/index.ts‎

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Options } from '../core';
1+
import type { Options, SourceMapKind, UploadFilter } from '../core';
22
import {
33
createSentryBuildPluginManager,
44
generateReleaseInjectorCode,
@@ -7,8 +7,10 @@ import {
77
createDebugIdUploadFunction,
88
CodeInjection,
99
addDebugIdToEmittedArtifacts,
10+
hasDebugId,
1011
isJsFile,
1112
} from '../core';
13+
import * as fs from 'node:fs';
1214
import * as path from 'node:path';
1315
import { createRequire } from 'node:module';
1416
import { randomUUID } from 'node:crypto';
@@ -48,13 +50,33 @@ interface EsbuildOnEndArgs {
4850
};
4951
}
5052

53+
type EsbuildSourceMapSetting = boolean | 'inline' | 'external' | 'linked' | 'both' | undefined;
54+
5155
interface EsbuildInitialOptions {
5256
bundle?: boolean;
5357
inject?: string[];
5458
metafile?: boolean;
5559
define?: Record<string, string>;
5660
write?: boolean;
5761
absWorkingDir?: string;
62+
sourcemap?: EsbuildSourceMapSetting;
63+
}
64+
65+
/** Maps esbuild's `sourcemap` setting onto the plugin's source map kinds. */
66+
function sourceMapKindFromEsbuildSetting(setting: EsbuildSourceMapSetting): SourceMapKind {
67+
switch (setting) {
68+
case undefined:
69+
return 'unset';
70+
case false:
71+
return 'off';
72+
case 'external':
73+
return 'hidden';
74+
case 'inline':
75+
case 'both':
76+
return 'inline';
77+
default:
78+
return 'visible';
79+
}
5880
}
5981

6082
interface EsbuildPluginBuild {
@@ -280,6 +302,13 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any {
280302
});
281303
}
282304

305+
const sourceMapDecision = sentryBuildPluginManager.sourceMaps.decide(
306+
sourceMapKindFromEsbuildSetting(initialOptions.sourcemap),
307+
);
308+
if (sourceMapDecision.enableHidden) {
309+
initialOptions.sourcemap = 'external';
310+
}
311+
283312
// Create release and optionally upload
284313
const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts();
285314
const upload = createDebugIdUploadFunction({ sentryBuildPluginManager });
@@ -290,27 +319,38 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any {
290319
await sentryBuildPluginManager.createRelease();
291320

292321
if (sourcemapsEnabled) {
293-
const buildArtifacts = result.metafile ? Object.keys(result.metafile.outputs) : [];
322+
// The metafile lists outputs relative to `absWorkingDir`, not to the process.
323+
const outputDir = initialOptions.absWorkingDir ?? process.cwd();
324+
const buildArtifacts = result.metafile
325+
? Object.keys(result.metafile.outputs).map(output => path.resolve(outputDir, output))
326+
: [];
294327

295328
if (options.sourcemaps?.disable !== 'disable-upload') {
296-
await upload(buildArtifacts);
329+
// The upload globs the paths, and glob reads backslashes as escapes.
330+
const { uploaded } = await upload(buildArtifacts.map(artifact => artifact.split(path.sep).join('/')));
331+
332+
// esbuild writes before `onEnd` runs, so the maps are deleted from disk
333+
// afterwards instead of being dropped from the output like in other bundlers.
334+
if (sourceMapDecision.deleteAfterUpload && initialOptions.write !== false) {
335+
const { shouldUpload } = sentryBuildPluginManager.sourceMaps;
336+
const sourceMaps = await getUploadedSourceMaps(buildArtifacts, shouldUpload);
337+
338+
if (uploaded) {
339+
await Promise.all(sourceMaps.map(sourceMap => fs.promises.rm(sourceMap, { force: true })));
340+
} else if (sourceMaps.length > 0) {
341+
logger.error('The source maps were not uploaded, so they stay in the build output.');
342+
}
343+
}
297344
} else if (initialOptions.write === false) {
298345
logger.debug('Build output is not written to disk. Skipping debug ID injection into build artifacts.');
299346
} else {
300347
// The upload routine (which stamps debug IDs into temp copies of the artifacts) is
301348
// skipped with `disable-upload`. esbuild has no hook to modify outputs before they are
302349
// written, so the emitted artifacts get stamped on disk instead.
303-
const outputDir = initialOptions.absWorkingDir ?? process.cwd();
304350
await Promise.all(
305351
buildArtifacts
306352
.filter(isJsFile)
307-
.map(bundle =>
308-
addDebugIdToEmittedArtifacts(
309-
path.resolve(outputDir, bundle),
310-
logger,
311-
options.sourcemaps?.resolveSourceMap,
312-
),
313-
),
353+
.map(bundle => addDebugIdToEmittedArtifacts(bundle, logger, options.sourcemaps?.resolveSourceMap)),
314354
);
315355
}
316356
}
@@ -326,3 +366,24 @@ export function sentryEsbuildPlugin(userOptions: Options = {}): any {
326366
// eslint-disable-next-line @typescript-eslint/no-explicit-any
327367
export default sentryEsbuildPlugin;
328368
export type { Options as SentryEsbuildPluginOptions } from '../core';
369+
370+
/**
371+
* The maps of the chunks the upload took: those that pass `sourcemaps.assets` and `ignore` and
372+
* carry a debug ID. Shared chunks and CSS get none, so their maps stay in the build output.
373+
*/
374+
async function getUploadedSourceMaps(buildArtifacts: string[], shouldUpload: UploadFilter): Promise<string[]> {
375+
const sourceMaps = new Set(buildArtifacts.filter(artifact => artifact.endsWith('.map')));
376+
const uploaded: string[] = [];
377+
378+
for (const artifact of buildArtifacts) {
379+
if (!isJsFile(artifact) || !sourceMaps.has(`${artifact}.map`) || !shouldUpload(artifact)) {
380+
continue;
381+
}
382+
const code = await fs.promises.readFile(artifact, 'utf8').catch(() => '');
383+
if (hasDebugId(code)) {
384+
uploaded.push(`${artifact}.map`);
385+
}
386+
}
387+
388+
return uploaded;
389+
}

0 commit comments

Comments
 (0)