Skip to content

Commit 84cbb90

Browse files
authored
feat(core): Add prepareArtifacts option for uploading sourcemaps (#794)
1 parent cc4d21d commit 84cbb90

File tree

2 files changed

+214
-84
lines changed

2 files changed

+214
-84
lines changed

packages/bundler-plugin-core/src/build-plugin-manager.ts

Lines changed: 111 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ export type SentryBuildPluginManager = {
7777
/**
7878
* Uploads sourcemaps using the "Debug ID" method. This function takes a list of build artifact paths that will be uploaded
7979
*/
80-
uploadSourcemaps(buildArtifactPaths: string[]): Promise<void>;
80+
uploadSourcemaps(
81+
buildArtifactPaths: string[],
82+
opts?: { prepareArtifacts?: boolean }
83+
): Promise<void>;
8184

8285
/**
8386
* Will delete artifacts based on the passed `sourcemaps.filesToDeleteAfterUpload` option.
@@ -546,9 +549,16 @@ export function createSentryBuildPluginManager(
546549
},
547550

548551
/**
549-
* Uploads sourcemaps using the "Debug ID" method. This function takes a list of build artifact paths that will be uploaded
552+
* Uploads sourcemaps using the "Debug ID" method.
553+
*
554+
* By default, this prepares bundles in a temporary folder before uploading. You can opt into an
555+
* in-place, direct upload path by setting `prepareArtifacts` to `false`. If `prepareArtifacts` is set to
556+
* `false`, no preparation (e.g. adding `//# debugId=...` and writing adjusted source maps) is performed and no temp folder is used.
557+
*
558+
* @param buildArtifactPaths - The paths of the build artifacts to upload
559+
* @param opts - Optional flags to control temp folder usage and preparation
550560
*/
551-
async uploadSourcemaps(buildArtifactPaths: string[]) {
561+
async uploadSourcemaps(buildArtifactPaths: string[], opts?: { prepareArtifacts?: boolean }) {
552562
if (!canUploadSourceMaps(options, logger, isDevMode)) {
553563
return;
554564
}
@@ -557,26 +567,16 @@ export function createSentryBuildPluginManager(
557567
// This is `forceTransaction`ed because this span is used in dashboards in the form of indexed transactions.
558568
{ name: "debug-id-sourcemap-upload", scope: sentryScope, forceTransaction: true },
559569
async () => {
570+
// If we're not using a temp folder, we must not prepare artifacts in-place (to avoid mutating user files)
571+
const shouldPrepare = opts?.prepareArtifacts ?? true;
572+
560573
let folderToCleanUp: string | undefined;
561574

562575
// 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`)
563576
// Therefore we need to actually register the execution of this hook as dependency on the sourcemap files.
564577
const freeUploadDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts();
565578

566579
try {
567-
const tmpUploadFolder = await startSpan(
568-
{ name: "mkdtemp", scope: sentryScope },
569-
async () => {
570-
return (
571-
process.env?.["SENTRY_TEST_OVERRIDE_TEMP_DIR"] ||
572-
(await fs.promises.mkdtemp(
573-
path.join(os.tmpdir(), "sentry-bundler-plugin-upload-")
574-
))
575-
);
576-
}
577-
);
578-
579-
folderToCleanUp = tmpUploadFolder;
580580
const assets = options.sourcemaps?.assets;
581581

582582
let globAssets: string | string[];
@@ -594,14 +594,17 @@ export function createSentryBuildPluginManager(
594594
async () =>
595595
await glob(globAssets, {
596596
absolute: true,
597-
nodir: true,
597+
// If we do not use a temp folder, we allow directories and files; CLI will traverse as needed when given paths.
598+
nodir: shouldPrepare,
598599
ignore: options.sourcemaps?.ignore,
599600
})
600601
);
601602

602-
const debugIdChunkFilePaths = globResult.filter((debugIdChunkFilePath) => {
603-
return !!stripQueryAndHashFromPath(debugIdChunkFilePath).match(/\.(js|mjs|cjs)$/);
604-
});
603+
const debugIdChunkFilePaths = shouldPrepare
604+
? globResult.filter((debugIdChunkFilePath) => {
605+
return !!stripQueryAndHashFromPath(debugIdChunkFilePath).match(/\.(js|mjs|cjs)$/);
606+
})
607+
: globResult;
605608

606609
// The order of the files output by glob() is not deterministic
607610
// Ensure order within the files so that {debug-id}-{chunkIndex} coupling is consistent
@@ -616,75 +619,101 @@ export function createSentryBuildPluginManager(
616619
"Didn't find any matching sources for debug ID upload. Please check the `sourcemaps.assets` option."
617620
);
618621
} else {
619-
const numUploadedFiles = await startSpan(
620-
{ name: "prepare-bundles", scope: sentryScope },
621-
async (prepBundlesSpan) => {
622-
// Preparing the bundles can be a lot of work and doing it all at once has the potential of nuking the heap so
623-
// instead we do it with a maximum of 16 concurrent workers
624-
const preparationTasks = debugIdChunkFilePaths.map(
625-
(chunkFilePath, chunkIndex) => async () => {
626-
await prepareBundleForDebugIdUpload(
627-
chunkFilePath,
628-
tmpUploadFolder,
629-
chunkIndex,
630-
logger,
631-
options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook,
632-
options.sourcemaps?.resolveSourceMap
633-
);
634-
}
635-
);
636-
const workers: Promise<void>[] = [];
637-
const worker = async (): Promise<void> => {
638-
while (preparationTasks.length > 0) {
639-
const task = preparationTasks.shift();
640-
if (task) {
641-
await task();
622+
if (!shouldPrepare) {
623+
// Direct CLI upload from existing artifact paths (no preparation or temp copies)
624+
await startSpan({ name: "upload", scope: sentryScope }, async () => {
625+
const cliInstance = createCliInstance(options);
626+
await cliInstance.releases.uploadSourceMaps(options.release.name ?? "undefined", {
627+
include: [
628+
{
629+
paths: debugIdChunkFilePaths,
630+
rewrite: false,
631+
dist: options.release.dist,
632+
},
633+
],
634+
live: "rejectOnError",
635+
});
636+
});
637+
638+
logger.info("Successfully uploaded source maps to Sentry");
639+
} else {
640+
const tmpUploadFolder = await startSpan(
641+
{ name: "mkdtemp", scope: sentryScope },
642+
async () => {
643+
return (
644+
process.env?.["SENTRY_TEST_OVERRIDE_TEMP_DIR"] ||
645+
(await fs.promises.mkdtemp(
646+
path.join(os.tmpdir(), "sentry-bundler-plugin-upload-")
647+
))
648+
);
649+
}
650+
);
651+
folderToCleanUp = tmpUploadFolder;
652+
653+
// Prepare into temp folder, then upload
654+
await startSpan(
655+
{ name: "prepare-bundles", scope: sentryScope },
656+
async (prepBundlesSpan) => {
657+
// Preparing the bundles can be a lot of work and doing it all at once has the potential of nuking the heap so
658+
// instead we do it with a maximum of 16 concurrent workers
659+
const preparationTasks = debugIdChunkFilePaths.map(
660+
(chunkFilePath, chunkIndex) => async () => {
661+
await prepareBundleForDebugIdUpload(
662+
chunkFilePath,
663+
tmpUploadFolder,
664+
chunkIndex,
665+
logger,
666+
options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook,
667+
options.sourcemaps?.resolveSourceMap
668+
);
669+
}
670+
);
671+
const workers: Promise<void>[] = [];
672+
const worker = async (): Promise<void> => {
673+
while (preparationTasks.length > 0) {
674+
const task = preparationTasks.shift();
675+
if (task) {
676+
await task();
677+
}
642678
}
679+
};
680+
for (let workerIndex = 0; workerIndex < 16; workerIndex++) {
681+
workers.push(worker());
643682
}
644-
};
645-
for (let workerIndex = 0; workerIndex < 16; workerIndex++) {
646-
workers.push(worker());
647-
}
648683

649-
await Promise.all(workers);
684+
await Promise.all(workers);
650685

651-
const files = await fs.promises.readdir(tmpUploadFolder);
652-
const stats = files.map((file) =>
653-
fs.promises.stat(path.join(tmpUploadFolder, file))
654-
);
655-
const uploadSize = (await Promise.all(stats)).reduce(
656-
(accumulator, { size }) => accumulator + size,
657-
0
658-
);
659-
660-
setMeasurement("files", files.length, "none", prepBundlesSpan);
661-
setMeasurement("upload_size", uploadSize, "byte", prepBundlesSpan);
662-
663-
await startSpan({ name: "upload", scope: sentryScope }, async () => {
664-
const cliInstance = createCliInstance(options);
665-
666-
await cliInstance.releases.uploadSourceMaps(
667-
options.release.name ?? "undefined", // unfortunately this needs a value for now but it will not matter since debug IDs overpower releases anyhow
668-
{
669-
include: [
670-
{
671-
paths: [tmpUploadFolder],
672-
rewrite: false,
673-
dist: options.release.dist,
674-
},
675-
],
676-
// We want this promise to throw if the sourcemaps fail to upload so that we know about it.
677-
// see: https://github.com/getsentry/sentry-cli/pull/2605
678-
live: "rejectOnError",
679-
}
686+
const files = await fs.promises.readdir(tmpUploadFolder);
687+
const stats = files.map((file) =>
688+
fs.promises.stat(path.join(tmpUploadFolder, file))
689+
);
690+
const uploadSize = (await Promise.all(stats)).reduce(
691+
(accumulator, { size }) => accumulator + size,
692+
0
680693
);
681-
});
682694

683-
return files.length;
684-
}
685-
);
695+
setMeasurement("files", files.length, "none", prepBundlesSpan);
696+
setMeasurement("upload_size", uploadSize, "byte", prepBundlesSpan);
697+
698+
await startSpan({ name: "upload", scope: sentryScope }, async () => {
699+
const cliInstance = createCliInstance(options);
700+
await cliInstance.releases.uploadSourceMaps(
701+
options.release.name ?? "undefined",
702+
{
703+
include: [
704+
{
705+
paths: [tmpUploadFolder],
706+
rewrite: false,
707+
dist: options.release.dist,
708+
},
709+
],
710+
live: "rejectOnError",
711+
}
712+
);
713+
});
714+
}
715+
);
686716

687-
if (numUploadedFiles > 0) {
688717
logger.info("Successfully uploaded source maps to Sentry");
689718
}
690719
}

packages/bundler-plugin-core/test/build-plugin-manager.test.ts

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,46 @@
11
import { createSentryBuildPluginManager } from "../src/build-plugin-manager";
2+
import fs from "fs";
3+
import { glob } from "glob";
4+
import { prepareBundleForDebugIdUpload } from "../src/debug-id-upload";
25

36
const mockCliExecute = jest.fn();
7+
const mockCliUploadSourceMaps = jest.fn();
8+
49
jest.mock("@sentry/cli", () => {
510
return jest.fn().mockImplementation(() => ({
611
execute: mockCliExecute,
12+
releases: {
13+
uploadSourceMaps: mockCliUploadSourceMaps,
14+
new: jest.fn(),
15+
finalize: jest.fn(),
16+
setCommits: jest.fn(),
17+
newDeploy: jest.fn(),
18+
},
719
}));
820
});
921

1022
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
1123
jest.mock("../src/sentry/telemetry", () => ({
24+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
1225
...jest.requireActual("../src/sentry/telemetry"),
1326
safeFlushTelemetry: jest.fn(),
1427
}));
1528

1629
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
1730
jest.mock("@sentry/core", () => ({
31+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
1832
...jest.requireActual("@sentry/core"),
19-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return
20-
startSpan: jest.fn((options, callback) => callback()),
33+
startSpan: jest.fn((options: unknown, callback: () => unknown) => callback()),
2134
}));
2235

36+
jest.mock("glob");
37+
jest.mock("../src/debug-id-upload");
38+
39+
const mockGlob = glob as jest.MockedFunction<typeof glob>;
40+
const mockPrepareBundleForDebugIdUpload = prepareBundleForDebugIdUpload as jest.MockedFunction<
41+
typeof prepareBundleForDebugIdUpload
42+
>;
43+
2344
describe("createSentryBuildPluginManager", () => {
2445
beforeEach(() => {
2546
jest.clearAllMocks();
@@ -73,6 +94,86 @@ describe("createSentryBuildPluginManager", () => {
7394
});
7495
});
7596

97+
describe("uploadSourcemaps", () => {
98+
it("uploads in-place when prepareArtifacts is false", async () => {
99+
mockCliUploadSourceMaps.mockResolvedValue(undefined);
100+
101+
// Return a mixture of files/dirs; in-place path should pass through as-is
102+
mockGlob.mockResolvedValue(["/app/dist/a.js", "/app/dist/dir", "/app/dist/a.js.map"]);
103+
104+
const manager = createSentryBuildPluginManager(
105+
{
106+
authToken: "t",
107+
org: "o",
108+
project: "p",
109+
release: { name: "some-release-name", dist: "1" },
110+
sourcemaps: { assets: ["/app/dist/**/*"] },
111+
},
112+
{ buildTool: "webpack", loggerPrefix: "[sentry-webpack-plugin]" }
113+
);
114+
115+
await manager.uploadSourcemaps(["/unused"], { prepareArtifacts: false });
116+
117+
expect(mockCliUploadSourceMaps).toHaveBeenCalledTimes(1);
118+
expect(mockCliUploadSourceMaps).toHaveBeenCalledWith(
119+
"some-release-name",
120+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
121+
expect.objectContaining({
122+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
123+
include: expect.arrayContaining([
124+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
125+
expect.objectContaining({
126+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
127+
paths: expect.arrayContaining([
128+
"/app/dist/a.js",
129+
"/app/dist/dir",
130+
"/app/dist/a.js.map",
131+
]),
132+
rewrite: false,
133+
dist: "1",
134+
}),
135+
]),
136+
live: "rejectOnError",
137+
})
138+
);
139+
expect(mockPrepareBundleForDebugIdUpload).not.toHaveBeenCalled();
140+
});
141+
142+
it("prepares into temp folder and uploads when prepareArtifacts is true (default)", async () => {
143+
mockCliUploadSourceMaps.mockResolvedValue(undefined);
144+
145+
mockGlob.mockResolvedValue(["/app/dist/a.js", "/app/dist/a.js.map", "/app/dist/other.txt"]);
146+
147+
jest.spyOn(fs.promises, "mkdtemp").mockResolvedValue("/tmp/sentry-upload-xyz");
148+
jest.spyOn(fs.promises, "readdir").mockResolvedValue(["a.js", "a.js.map"] as never);
149+
jest.spyOn(fs.promises, "stat").mockResolvedValue({ size: 10 } as fs.Stats);
150+
jest.spyOn(fs.promises, "rm").mockResolvedValue(undefined as never);
151+
152+
mockPrepareBundleForDebugIdUpload.mockResolvedValue(undefined);
153+
154+
const manager = createSentryBuildPluginManager(
155+
{
156+
authToken: "t",
157+
org: "o",
158+
project: "p",
159+
release: { name: "some-release-name", dist: "1" },
160+
sourcemaps: { assets: ["/app/dist/**/*"] },
161+
},
162+
{ buildTool: "webpack", loggerPrefix: "[sentry-webpack-plugin]" }
163+
);
164+
165+
await manager.uploadSourcemaps(["/unused"]);
166+
167+
// Should call prepare for each JS chunk discovered by glob
168+
expect(mockPrepareBundleForDebugIdUpload).toHaveBeenCalled();
169+
// Should upload from temp folder
170+
expect(mockCliUploadSourceMaps).toHaveBeenCalledWith("some-release-name", {
171+
include: [{ paths: ["/tmp/sentry-upload-xyz"], rewrite: false, dist: "1" }],
172+
live: "rejectOnError",
173+
});
174+
});
175+
});
176+
76177
describe("injectDebugIds", () => {
77178
it("should call CLI with correct sourcemaps inject command", async () => {
78179
mockCliExecute.mockResolvedValue(undefined);

0 commit comments

Comments
 (0)