Skip to content

Commit 76a9745

Browse files
authored
feat(core): Allow multi-project sourcemaps upload (#813)
* 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 76a9745

File tree

12 files changed

+456
-12
lines changed

12 files changed

+456
-12
lines changed

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

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ import {
1919
safeFlushTelemetry,
2020
} from "./sentry/telemetry";
2121
import { Options, SentrySDKBuildFlags } from "./types";
22-
import { arrayify, getTurborepoEnvPassthroughWarning, stripQueryAndHashFromPath } from "./utils";
22+
import {
23+
arrayify,
24+
getProjects,
25+
getTurborepoEnvPassthroughWarning,
26+
stripQueryAndHashFromPath,
27+
} from "./utils";
2328
import { glob } from "glob";
2429
import { defaultRewriteSourcesHook, prepareBundleForDebugIdUpload } from "./debug-id-upload";
2530

@@ -94,7 +99,8 @@ function createCliInstance(options: NormalizedOptions): SentryCli {
9499
return new SentryCli(null, {
95100
authToken: options.authToken,
96101
org: options.org,
97-
project: options.project,
102+
// Default to the first project if multiple projects are specified
103+
project: getProjects(options.project)?.[0],
98104
silent: options.silent,
99105
url: options.url,
100106
vcsRemote: options.release.vcsRemote,
@@ -360,7 +366,8 @@ export function createSentryBuildPluginManager(
360366
if (typeof options.moduleMetadata === "function") {
361367
const args = {
362368
org: options.org,
363-
project: options.project,
369+
project: getProjects(options.project)?.[0],
370+
projects: getProjects(options.project),
364371
release: options.release.name,
365372
};
366373
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@@ -444,7 +451,10 @@ export function createSentryBuildPluginManager(
444451
getTurborepoEnvPassthroughWarning("SENTRY_ORG")
445452
);
446453
return;
447-
} else if (!options.project) {
454+
} else if (
455+
!options.project ||
456+
(Array.isArray(options.project) && options.project.length === 0)
457+
) {
448458
logger.warn(
449459
"No project provided. Will not create release. Please set the `project` option to your Sentry project slug." +
450460
getTurborepoEnvPassthroughWarning("SENTRY_PROJECT")
@@ -481,6 +491,9 @@ export function createSentryBuildPluginManager(
481491
await cliInstance.releases.uploadSourceMaps(options.release.name, {
482492
include: normalizedInclude,
483493
dist: options.release.dist,
494+
// @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI
495+
// Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released
496+
projects: getProjects(options.project),
484497
// We want this promise to throw if the sourcemaps fail to upload so that we know about it.
485498
// see: https://github.com/getsentry/sentry-cli/pull/2605
486499
live: "rejectOnError",
@@ -625,6 +638,9 @@ export function createSentryBuildPluginManager(
625638
},
626639
],
627640
ignore: ignorePaths,
641+
// @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI
642+
// Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released
643+
projects: getProjects(options.project),
628644
live: "rejectOnError",
629645
});
630646
});
@@ -735,6 +751,9 @@ export function createSentryBuildPluginManager(
735751
dist: options.release.dist,
736752
},
737753
],
754+
// @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI
755+
// Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released
756+
projects: getProjects(options.project),
738757
live: "rejectOnError",
739758
}
740759
);
@@ -843,7 +862,7 @@ function canUploadSourceMaps(
843862
);
844863
return false;
845864
}
846-
if (!options.project) {
865+
if (!getProjects(options.project)?.[0]) {
847866
logger.warn(
848867
"No project provided. Will not upload source maps. Please set the `project` option to your Sentry project slug." +
849868
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,11 @@ 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:
93+
userOptions.project ??
94+
(process.env["SENTRY_PROJECT"]?.includes(",")
95+
? process.env["SENTRY_PROJECT"].split(",").map((p) => p.trim())
96+
: process.env["SENTRY_PROJECT"]),
9397
authToken: userOptions.authToken ?? process.env["SENTRY_AUTH_TOKEN"],
9498
url: userOptions.url ?? process.env["SENTRY_URL"] ?? SENTRY_SAAS_URL,
9599
headers: userOptions.headers,
@@ -209,5 +213,24 @@ export function validateOptions(options: NormalizedOptions, logger: Logger): boo
209213
return false;
210214
}
211215

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

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { NormalizedOptions, SENTRY_SAAS_URL } from "../options-mapping";
55
import { Scope } from "@sentry/core";
66
import { createStackParser, nodeStackLineParser } from "@sentry/utils";
77
import { makeOptionallyEnabledNodeTransport } from "./transports";
8+
import { getProjects } from "../utils";
89

910
const SENTRY_SAAS_HOSTNAME = "sentry.io";
1011

@@ -106,7 +107,7 @@ export function setTelemetryDataOnScope(
106107

107108
scope.setTags({
108109
organization: org,
109-
project,
110+
project: Array.isArray(project) ? project.join(", ") : project ?? "undefined",
110111
bundler: buildTool,
111112
});
112113

@@ -129,7 +130,7 @@ export async function allowedToSendTelemetry(options: NormalizedOptions): Promis
129130
url,
130131
authToken,
131132
org,
132-
project,
133+
project: getProjects(project)?.[0],
133134
vcsRemote: release.vcsRemote,
134135
silent,
135136
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/src/utils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,3 +393,18 @@ export function getTurborepoEnvPassthroughWarning(envVarName: string): string {
393393
? `\nYou seem to be using Turborepo, did you forget to put ${envVarName} in \`passThroughEnv\`? https://turbo.build/repo/docs/reference/configuration#passthroughenv`
394394
: "";
395395
}
396+
397+
/**
398+
* Gets the projects from the project option. This might be a single project or an array of projects.
399+
*/
400+
export function getProjects(project: string | string[] | undefined): string[] | undefined {
401+
if (Array.isArray(project)) {
402+
return project;
403+
}
404+
405+
if (project) {
406+
return [project];
407+
}
408+
409+
return undefined;
410+
}

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

Lines changed: 170 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,173 @@ 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"], {
549+
prepareArtifacts: false,
550+
});
551+
552+
expect(mockCliUploadSourceMaps).toHaveBeenCalledWith(
553+
"test-release",
554+
expect.objectContaining({
555+
projects: ["proj-a", "proj-b"],
556+
})
557+
);
558+
});
559+
});
560+
561+
describe("moduleMetadata callback with multiple projects", () => {
562+
it("should pass project as string and projects as array when multiple projects configured", () => {
563+
const moduleMetadataCallback = jest.fn().mockReturnValue({ custom: "metadata" });
564+
565+
createSentryBuildPluginManager(
566+
{
567+
authToken: "test-token",
568+
org: "test-org",
569+
project: ["proj-a", "proj-b", "proj-c"],
570+
release: { name: "test-release" },
571+
moduleMetadata: moduleMetadataCallback,
572+
},
573+
{
574+
buildTool: "webpack",
575+
loggerPrefix: "[sentry-webpack-plugin]",
576+
}
577+
);
578+
579+
expect(moduleMetadataCallback).toHaveBeenCalledWith({
580+
org: "test-org",
581+
project: "proj-a",
582+
projects: ["proj-a", "proj-b", "proj-c"],
583+
release: "test-release",
584+
});
585+
});
586+
587+
it("should pass project as string and projects as array with single project", () => {
588+
const moduleMetadataCallback = jest.fn().mockReturnValue({ custom: "metadata" });
589+
590+
createSentryBuildPluginManager(
591+
{
592+
authToken: "test-token",
593+
org: "test-org",
594+
project: "single-project",
595+
release: { name: "test-release" },
596+
moduleMetadata: moduleMetadataCallback,
597+
},
598+
{
599+
buildTool: "webpack",
600+
loggerPrefix: "[sentry-webpack-plugin]",
601+
}
602+
);
603+
604+
expect(moduleMetadataCallback).toHaveBeenCalledWith({
605+
org: "test-org",
606+
project: "single-project",
607+
projects: ["single-project"],
608+
release: "test-release",
609+
});
610+
});
611+
612+
it("should pass undefined for projects when no project configured", () => {
613+
const moduleMetadataCallback = jest.fn().mockReturnValue({ custom: "metadata" });
614+
615+
createSentryBuildPluginManager(
616+
{
617+
authToken: "test-token",
618+
org: "test-org",
619+
release: { name: "test-release" },
620+
moduleMetadata: moduleMetadataCallback,
621+
},
622+
{
623+
buildTool: "webpack",
624+
loggerPrefix: "[sentry-webpack-plugin]",
625+
}
626+
);
627+
628+
expect(moduleMetadataCallback).toHaveBeenCalledWith({
629+
org: "test-org",
630+
project: undefined,
631+
projects: undefined,
632+
release: "test-release",
633+
});
634+
});
635+
});
466636
});

0 commit comments

Comments
 (0)