Skip to content

Commit 2db2cf8

Browse files
committed
feat(core): Allow multi-project sourcemaps upload
The `project` option now allows specifiying multiple projects via a string array. Source maps will be uploaded to all specified projects.
1 parent 3ed5631 commit 2db2cf8

11 files changed

Lines changed: 434 additions & 11 deletions

File tree

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ function createCliInstance(options: NormalizedOptions): SentryCli {
9494
return new SentryCli(null, {
9595
authToken: options.authToken,
9696
org: options.org,
97-
project: options.project,
97+
// Default to the first project if multiple projects are specified
98+
project: Array.isArray(options.project) ? options.project[0] : options.project,
9899
silent: options.silent,
99100
url: options.url,
100101
vcsRemote: options.release.vcsRemote,
@@ -360,7 +361,12 @@ export function createSentryBuildPluginManager(
360361
if (typeof options.moduleMetadata === "function") {
361362
const args = {
362363
org: options.org,
363-
project: options.project,
364+
project: Array.isArray(options.project) ? options.project[0] : options.project,
365+
projects: Array.isArray(options.project)
366+
? options.project
367+
: options.project
368+
? [options.project]
369+
: undefined,
364370
release: options.release.name,
365371
};
366372
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@@ -444,7 +450,10 @@ export function createSentryBuildPluginManager(
444450
getTurborepoEnvPassthroughWarning("SENTRY_ORG")
445451
);
446452
return;
447-
} else if (!options.project) {
453+
} else if (
454+
!options.project ||
455+
(Array.isArray(options.project) && options.project.length === 0)
456+
) {
448457
logger.warn(
449458
"No project provided. Will not create release. Please set the `project` option to your Sentry project slug." +
450459
getTurborepoEnvPassthroughWarning("SENTRY_PROJECT")
@@ -481,6 +490,9 @@ export function createSentryBuildPluginManager(
481490
await cliInstance.releases.uploadSourceMaps(options.release.name, {
482491
include: normalizedInclude,
483492
dist: options.release.dist,
493+
// @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI
494+
// Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released
495+
projects: Array.isArray(options.project) ? options.project : [options.project],
484496
// We want this promise to throw if the sourcemaps fail to upload so that we know about it.
485497
// see: https://github.com/getsentry/sentry-cli/pull/2605
486498
live: "rejectOnError",
@@ -625,6 +637,9 @@ export function createSentryBuildPluginManager(
625637
},
626638
],
627639
ignore: ignorePaths,
640+
// @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI
641+
// Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released
642+
projects: Array.isArray(options.project) ? options.project : [options.project],
628643
live: "rejectOnError",
629644
});
630645
});
@@ -735,6 +750,11 @@ export function createSentryBuildPluginManager(
735750
dist: options.release.dist,
736751
},
737752
],
753+
// @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI
754+
// Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released
755+
projects: Array.isArray(options.project)
756+
? options.project
757+
: [options.project],
738758
live: "rejectOnError",
739759
}
740760
);
@@ -843,7 +863,7 @@ function canUploadSourceMaps(
843863
);
844864
return false;
845865
}
846-
if (!options.project) {
866+
if (!options.project || (Array.isArray(options.project) && options.project.length === 0)) {
847867
logger.warn(
848868
"No project provided. Will not upload source maps. Please set the `project` option to your Sentry project slug." +
849869
getTurborepoEnvPassthroughWarning("SENTRY_PROJECT")

packages/bundler-plugin-core/src/options-mapping.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { determineReleaseName } from "./utils";
1212

1313
export type NormalizedOptions = {
1414
org: string | undefined;
15-
project: string | undefined;
15+
project: string | string[] | undefined;
1616
authToken: string | undefined;
1717
url: string;
1818
headers: Record<string, string> | undefined;
@@ -89,7 +89,9 @@ export const SENTRY_SAAS_URL = "https://sentry.io";
8989
export function normalizeUserOptions(userOptions: UserOptions): NormalizedOptions {
9090
const options = {
9191
org: userOptions.org ?? process.env["SENTRY_ORG"],
92-
project: userOptions.project ?? process.env["SENTRY_PROJECT"],
92+
project: userOptions.project ?? (process.env["SENTRY_PROJECT"]?.includes(',')
93+
? process.env["SENTRY_PROJECT"].split(',').map(p => p.trim())
94+
: process.env["SENTRY_PROJECT"]),
9395
authToken: userOptions.authToken ?? process.env["SENTRY_AUTH_TOKEN"],
9496
url: userOptions.url ?? process.env["SENTRY_URL"] ?? SENTRY_SAAS_URL,
9597
headers: userOptions.headers,
@@ -209,5 +211,26 @@ export function validateOptions(options: NormalizedOptions, logger: Logger): boo
209211
return false;
210212
}
211213

214+
if (options.project) {
215+
if (Array.isArray(options.project)) {
216+
if (options.project.length === 0) {
217+
logger.error(
218+
"The `project` option was specified as an array but is empty.",
219+
"Please provide at least one project slug."
220+
);
221+
return false;
222+
}
223+
// Check each project is a non-empty string
224+
const invalidProjects = options.project.filter(p => typeof p !== 'string' || p.trim() === '');
225+
if (invalidProjects.length > 0) {
226+
logger.error(
227+
"The `project` option contains invalid project slugs.",
228+
"All projects must be non-empty strings."
229+
);
230+
return false;
231+
}
232+
}
233+
}
234+
212235
return true;
213236
}

packages/bundler-plugin-core/src/sentry/telemetry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export function setTelemetryDataOnScope(
106106

107107
scope.setTags({
108108
organization: org,
109-
project,
109+
project: Array.isArray(project) ? project.join(", ") : project ?? "undefined",
110110
bundler: buildTool,
111111
});
112112

@@ -129,7 +129,7 @@ export async function allowedToSendTelemetry(options: NormalizedOptions): Promis
129129
url,
130130
authToken,
131131
org,
132-
project,
132+
project: Array.isArray(project) ? project[0] : project,
133133
vcsRemote: release.vcsRemote,
134134
silent,
135135
headers,

packages/bundler-plugin-core/src/types.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ export interface Options {
99
/**
1010
* The slug of the Sentry project associated with the app.
1111
*
12+
* When uploading source maps, you can specify multiple projects (as an array) to upload
13+
* the same source maps to multiple projects. This is useful in monorepo environments
14+
* where multiple projects share the same release.
15+
*
1216
* This value can also be specified via the `SENTRY_PROJECT` environment variable.
1317
*/
14-
project?: string;
18+
project?: string | string[];
1519

1620
/**
1721
* The authentication token to use for all communication with Sentry.
@@ -361,7 +365,8 @@ export interface Options {
361365
* Metadata can either be passed directly or alternatively a callback can be provided that will be
362366
* called with the following parameters:
363367
* - `org`: The organization slug.
364-
* - `project`: The project slug.
368+
* - `project`: The project slug (when multiple projects are configured, this is the first project).
369+
* - `projects`: An array of all project slugs (available when multiple projects are configured).
365370
* - `release`: The release name.
366371
*/
367372
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -428,6 +433,7 @@ export interface ModuleMetadata {
428433
export interface ModuleMetadataCallbackArgs {
429434
org?: string;
430435
project?: string;
436+
projects?: string[];
431437
release?: string;
432438
}
433439

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

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,7 @@ describe("createSentryBuildPluginManager", () => {
408408
// Should upload from temp folder
409409
expect(mockCliUploadSourceMaps).toHaveBeenCalledWith("some-release-name", {
410410
include: [{ paths: ["/tmp/sentry-upload-xyz"], rewrite: false, dist: "1" }],
411+
projects: ["p"],
411412
live: "rejectOnError",
412413
});
413414
});
@@ -463,4 +464,171 @@ describe("createSentryBuildPluginManager", () => {
463464
);
464465
});
465466
});
467+
468+
describe("uploadSourcemaps with multiple projects", () => {
469+
beforeEach(() => {
470+
jest.clearAllMocks();
471+
mockGlob.mockResolvedValue(["/path/to/bundle.js"]);
472+
mockPrepareBundleForDebugIdUpload.mockResolvedValue(undefined);
473+
mockCliUploadSourceMaps.mockResolvedValue(undefined);
474+
475+
// Mock fs operations needed for temp folder upload path
476+
jest.spyOn(fs.promises, "mkdtemp").mockResolvedValue("/tmp/sentry-test");
477+
jest.spyOn(fs.promises, "readdir").mockResolvedValue([]);
478+
jest.spyOn(fs.promises, "stat").mockResolvedValue({ size: 1000 } as fs.Stats);
479+
jest.spyOn(fs.promises, "rm").mockResolvedValue(undefined);
480+
});
481+
482+
afterEach(() => {
483+
jest.restoreAllMocks();
484+
});
485+
486+
it("should pass projects array to uploadSourceMaps when multiple projects configured", async () => {
487+
const buildPluginManager = createSentryBuildPluginManager(
488+
{
489+
authToken: "test-token",
490+
org: "test-org",
491+
project: ["proj-a", "proj-b", "proj-c"],
492+
release: { name: "test-release" },
493+
},
494+
{
495+
buildTool: "webpack",
496+
loggerPrefix: "[sentry-webpack-plugin]",
497+
}
498+
);
499+
500+
await buildPluginManager.uploadSourcemaps(["/path/to/bundle.js"]);
501+
502+
expect(mockCliUploadSourceMaps).toHaveBeenCalledWith(
503+
"test-release",
504+
expect.objectContaining({
505+
projects: ["proj-a", "proj-b", "proj-c"],
506+
})
507+
);
508+
});
509+
510+
it("should pass single project as array to uploadSourceMaps", async () => {
511+
const buildPluginManager = createSentryBuildPluginManager(
512+
{
513+
authToken: "test-token",
514+
org: "test-org",
515+
project: "single-project",
516+
release: { name: "test-release" },
517+
},
518+
{
519+
buildTool: "webpack",
520+
loggerPrefix: "[sentry-webpack-plugin]",
521+
}
522+
);
523+
524+
await buildPluginManager.uploadSourcemaps(["/path/to/bundle.js"]);
525+
526+
expect(mockCliUploadSourceMaps).toHaveBeenCalledWith(
527+
"test-release",
528+
expect.objectContaining({
529+
projects: ["single-project"],
530+
})
531+
);
532+
});
533+
534+
it("should pass projects array in direct upload mode", async () => {
535+
const buildPluginManager = createSentryBuildPluginManager(
536+
{
537+
authToken: "test-token",
538+
org: "test-org",
539+
project: ["proj-a", "proj-b"],
540+
release: { name: "test-release" },
541+
},
542+
{
543+
buildTool: "webpack",
544+
loggerPrefix: "[sentry-webpack-plugin]",
545+
}
546+
);
547+
548+
await buildPluginManager.uploadSourcemaps(["/path/to/bundle.js"], { prepareArtifacts: false });
549+
550+
expect(mockCliUploadSourceMaps).toHaveBeenCalledWith(
551+
"test-release",
552+
expect.objectContaining({
553+
projects: ["proj-a", "proj-b"],
554+
})
555+
);
556+
});
557+
});
558+
559+
describe("moduleMetadata callback with multiple projects", () => {
560+
it("should pass project as string and projects as array when multiple projects configured", () => {
561+
const moduleMetadataCallback = jest.fn().mockReturnValue({ custom: "metadata" });
562+
563+
createSentryBuildPluginManager(
564+
{
565+
authToken: "test-token",
566+
org: "test-org",
567+
project: ["proj-a", "proj-b", "proj-c"],
568+
release: { name: "test-release" },
569+
moduleMetadata: moduleMetadataCallback,
570+
},
571+
{
572+
buildTool: "webpack",
573+
loggerPrefix: "[sentry-webpack-plugin]",
574+
}
575+
);
576+
577+
expect(moduleMetadataCallback).toHaveBeenCalledWith({
578+
org: "test-org",
579+
project: "proj-a",
580+
projects: ["proj-a", "proj-b", "proj-c"],
581+
release: "test-release",
582+
});
583+
});
584+
585+
it("should pass project as string and projects as array with single project", () => {
586+
const moduleMetadataCallback = jest.fn().mockReturnValue({ custom: "metadata" });
587+
588+
createSentryBuildPluginManager(
589+
{
590+
authToken: "test-token",
591+
org: "test-org",
592+
project: "single-project",
593+
release: { name: "test-release" },
594+
moduleMetadata: moduleMetadataCallback,
595+
},
596+
{
597+
buildTool: "webpack",
598+
loggerPrefix: "[sentry-webpack-plugin]",
599+
}
600+
);
601+
602+
expect(moduleMetadataCallback).toHaveBeenCalledWith({
603+
org: "test-org",
604+
project: "single-project",
605+
projects: ["single-project"],
606+
release: "test-release",
607+
});
608+
});
609+
610+
it("should pass undefined for projects when no project configured", () => {
611+
const moduleMetadataCallback = jest.fn().mockReturnValue({ custom: "metadata" });
612+
613+
createSentryBuildPluginManager(
614+
{
615+
authToken: "test-token",
616+
org: "test-org",
617+
release: { name: "test-release" },
618+
moduleMetadata: moduleMetadataCallback,
619+
},
620+
{
621+
buildTool: "webpack",
622+
loggerPrefix: "[sentry-webpack-plugin]",
623+
}
624+
);
625+
626+
expect(moduleMetadataCallback).toHaveBeenCalledWith({
627+
org: "test-org",
628+
project: undefined,
629+
projects: undefined,
630+
release: "test-release",
631+
});
632+
});
633+
});
466634
});

0 commit comments

Comments
 (0)