diff --git a/packages/bundler-plugin-core/src/build-plugin-manager.ts b/packages/bundler-plugin-core/src/build-plugin-manager.ts index e41b65d4..77c517f0 100644 --- a/packages/bundler-plugin-core/src/build-plugin-manager.ts +++ b/packages/bundler-plugin-core/src/build-plugin-manager.ts @@ -19,7 +19,12 @@ import { safeFlushTelemetry, } from "./sentry/telemetry"; import { Options, SentrySDKBuildFlags } from "./types"; -import { arrayify, getTurborepoEnvPassthroughWarning, stripQueryAndHashFromPath } from "./utils"; +import { + arrayify, + getProjects, + getTurborepoEnvPassthroughWarning, + stripQueryAndHashFromPath, +} from "./utils"; import { glob } from "glob"; import { defaultRewriteSourcesHook, prepareBundleForDebugIdUpload } from "./debug-id-upload"; @@ -94,7 +99,8 @@ function createCliInstance(options: NormalizedOptions): SentryCli { return new SentryCli(null, { authToken: options.authToken, org: options.org, - project: options.project, + // Default to the first project if multiple projects are specified + project: getProjects(options.project)?.[0], silent: options.silent, url: options.url, vcsRemote: options.release.vcsRemote, @@ -360,7 +366,8 @@ export function createSentryBuildPluginManager( if (typeof options.moduleMetadata === "function") { const args = { org: options.org, - project: options.project, + project: getProjects(options.project)?.[0], + projects: getProjects(options.project), release: options.release.name, }; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -444,7 +451,10 @@ export function createSentryBuildPluginManager( getTurborepoEnvPassthroughWarning("SENTRY_ORG") ); return; - } else if (!options.project) { + } else if ( + !options.project || + (Array.isArray(options.project) && options.project.length === 0) + ) { logger.warn( "No project provided. Will not create release. Please set the `project` option to your Sentry project slug." + getTurborepoEnvPassthroughWarning("SENTRY_PROJECT") @@ -481,6 +491,9 @@ export function createSentryBuildPluginManager( await cliInstance.releases.uploadSourceMaps(options.release.name, { include: normalizedInclude, dist: options.release.dist, + // @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI + // Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released + projects: getProjects(options.project), // We want this promise to throw if the sourcemaps fail to upload so that we know about it. // see: https://github.com/getsentry/sentry-cli/pull/2605 live: "rejectOnError", @@ -625,6 +638,9 @@ export function createSentryBuildPluginManager( }, ], ignore: ignorePaths, + // @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI + // Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released + projects: getProjects(options.project), live: "rejectOnError", }); }); @@ -735,6 +751,9 @@ export function createSentryBuildPluginManager( dist: options.release.dist, }, ], + // @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI + // Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released + projects: getProjects(options.project), live: "rejectOnError", } ); @@ -843,7 +862,7 @@ function canUploadSourceMaps( ); return false; } - if (!options.project) { + if (!getProjects(options.project)?.[0]) { logger.warn( "No project provided. Will not upload source maps. Please set the `project` option to your Sentry project slug." + getTurborepoEnvPassthroughWarning("SENTRY_PROJECT") diff --git a/packages/bundler-plugin-core/src/options-mapping.ts b/packages/bundler-plugin-core/src/options-mapping.ts index 0c79a5ae..b017a9d8 100644 --- a/packages/bundler-plugin-core/src/options-mapping.ts +++ b/packages/bundler-plugin-core/src/options-mapping.ts @@ -12,7 +12,7 @@ import { determineReleaseName } from "./utils"; export type NormalizedOptions = { org: string | undefined; - project: string | undefined; + project: string | string[] | undefined; authToken: string | undefined; url: string; headers: Record | undefined; @@ -89,7 +89,11 @@ export const SENTRY_SAAS_URL = "https://sentry.io"; export function normalizeUserOptions(userOptions: UserOptions): NormalizedOptions { const options = { org: userOptions.org ?? process.env["SENTRY_ORG"], - project: userOptions.project ?? process.env["SENTRY_PROJECT"], + project: + userOptions.project ?? + (process.env["SENTRY_PROJECT"]?.includes(",") + ? process.env["SENTRY_PROJECT"].split(",").map((p) => p.trim()) + : process.env["SENTRY_PROJECT"]), authToken: userOptions.authToken ?? process.env["SENTRY_AUTH_TOKEN"], url: userOptions.url ?? process.env["SENTRY_URL"] ?? SENTRY_SAAS_URL, headers: userOptions.headers, @@ -209,5 +213,24 @@ export function validateOptions(options: NormalizedOptions, logger: Logger): boo return false; } + if (options.project && Array.isArray(options.project)) { + if (options.project.length === 0) { + logger.error( + "The `project` option was specified as an array but is empty.", + "Please provide at least one project slug." + ); + return false; + } + // Check each project is a non-empty string + const invalidProjects = options.project.filter((p) => typeof p !== "string" || p.trim() === ""); + if (invalidProjects.length > 0) { + logger.error( + "The `project` option contains invalid project slugs.", + "All projects must be non-empty strings." + ); + return false; + } + } + return true; } diff --git a/packages/bundler-plugin-core/src/sentry/telemetry.ts b/packages/bundler-plugin-core/src/sentry/telemetry.ts index 48c93fb7..70c17b3e 100644 --- a/packages/bundler-plugin-core/src/sentry/telemetry.ts +++ b/packages/bundler-plugin-core/src/sentry/telemetry.ts @@ -5,6 +5,7 @@ import { NormalizedOptions, SENTRY_SAAS_URL } from "../options-mapping"; import { Scope } from "@sentry/core"; import { createStackParser, nodeStackLineParser } from "@sentry/utils"; import { makeOptionallyEnabledNodeTransport } from "./transports"; +import { getProjects } from "../utils"; const SENTRY_SAAS_HOSTNAME = "sentry.io"; @@ -106,7 +107,7 @@ export function setTelemetryDataOnScope( scope.setTags({ organization: org, - project, + project: Array.isArray(project) ? project.join(", ") : project ?? "undefined", bundler: buildTool, }); @@ -129,7 +130,7 @@ export async function allowedToSendTelemetry(options: NormalizedOptions): Promis url, authToken, org, - project, + project: getProjects(project)?.[0], vcsRemote: release.vcsRemote, silent, headers, diff --git a/packages/bundler-plugin-core/src/types.ts b/packages/bundler-plugin-core/src/types.ts index 1f8539ef..f86aca5e 100644 --- a/packages/bundler-plugin-core/src/types.ts +++ b/packages/bundler-plugin-core/src/types.ts @@ -9,9 +9,13 @@ export interface Options { /** * The slug of the Sentry project associated with the app. * + * When uploading source maps, you can specify multiple projects (as an array) to upload + * the same source maps to multiple projects. This is useful in monorepo environments + * where multiple projects share the same release. + * * This value can also be specified via the `SENTRY_PROJECT` environment variable. */ - project?: string; + project?: string | string[]; /** * The authentication token to use for all communication with Sentry. @@ -361,7 +365,8 @@ export interface Options { * Metadata can either be passed directly or alternatively a callback can be provided that will be * called with the following parameters: * - `org`: The organization slug. - * - `project`: The project slug. + * - `project`: The project slug (when multiple projects are configured, this is the first project). + * - `projects`: An array of all project slugs (available when multiple projects are configured). * - `release`: The release name. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -428,6 +433,7 @@ export interface ModuleMetadata { export interface ModuleMetadataCallbackArgs { org?: string; project?: string; + projects?: string[]; release?: string; } diff --git a/packages/bundler-plugin-core/src/utils.ts b/packages/bundler-plugin-core/src/utils.ts index 1162e9f6..a1aebf11 100644 --- a/packages/bundler-plugin-core/src/utils.ts +++ b/packages/bundler-plugin-core/src/utils.ts @@ -393,3 +393,18 @@ export function getTurborepoEnvPassthroughWarning(envVarName: string): string { ? `\nYou seem to be using Turborepo, did you forget to put ${envVarName} in \`passThroughEnv\`? https://turbo.build/repo/docs/reference/configuration#passthroughenv` : ""; } + +/** + * Gets the projects from the project option. This might be a single project or an array of projects. + */ +export function getProjects(project: string | string[] | undefined): string[] | undefined { + if (Array.isArray(project)) { + return project; + } + + if (project) { + return [project]; + } + + return undefined; +} diff --git a/packages/bundler-plugin-core/test/build-plugin-manager.test.ts b/packages/bundler-plugin-core/test/build-plugin-manager.test.ts index be6a2f20..1003d57c 100644 --- a/packages/bundler-plugin-core/test/build-plugin-manager.test.ts +++ b/packages/bundler-plugin-core/test/build-plugin-manager.test.ts @@ -408,6 +408,7 @@ describe("createSentryBuildPluginManager", () => { // Should upload from temp folder expect(mockCliUploadSourceMaps).toHaveBeenCalledWith("some-release-name", { include: [{ paths: ["/tmp/sentry-upload-xyz"], rewrite: false, dist: "1" }], + projects: ["p"], live: "rejectOnError", }); }); @@ -463,4 +464,173 @@ describe("createSentryBuildPluginManager", () => { ); }); }); + + describe("uploadSourcemaps with multiple projects", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGlob.mockResolvedValue(["/path/to/bundle.js"]); + mockPrepareBundleForDebugIdUpload.mockResolvedValue(undefined); + mockCliUploadSourceMaps.mockResolvedValue(undefined); + + // Mock fs operations needed for temp folder upload path + jest.spyOn(fs.promises, "mkdtemp").mockResolvedValue("/tmp/sentry-test"); + jest.spyOn(fs.promises, "readdir").mockResolvedValue([]); + jest.spyOn(fs.promises, "stat").mockResolvedValue({ size: 1000 } as fs.Stats); + jest.spyOn(fs.promises, "rm").mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should pass projects array to uploadSourceMaps when multiple projects configured", async () => { + const buildPluginManager = createSentryBuildPluginManager( + { + authToken: "test-token", + org: "test-org", + project: ["proj-a", "proj-b", "proj-c"], + release: { name: "test-release" }, + }, + { + buildTool: "webpack", + loggerPrefix: "[sentry-webpack-plugin]", + } + ); + + await buildPluginManager.uploadSourcemaps(["/path/to/bundle.js"]); + + expect(mockCliUploadSourceMaps).toHaveBeenCalledWith( + "test-release", + expect.objectContaining({ + projects: ["proj-a", "proj-b", "proj-c"], + }) + ); + }); + + it("should pass single project as array to uploadSourceMaps", async () => { + const buildPluginManager = createSentryBuildPluginManager( + { + authToken: "test-token", + org: "test-org", + project: "single-project", + release: { name: "test-release" }, + }, + { + buildTool: "webpack", + loggerPrefix: "[sentry-webpack-plugin]", + } + ); + + await buildPluginManager.uploadSourcemaps(["/path/to/bundle.js"]); + + expect(mockCliUploadSourceMaps).toHaveBeenCalledWith( + "test-release", + expect.objectContaining({ + projects: ["single-project"], + }) + ); + }); + + it("should pass projects array in direct upload mode", async () => { + const buildPluginManager = createSentryBuildPluginManager( + { + authToken: "test-token", + org: "test-org", + project: ["proj-a", "proj-b"], + release: { name: "test-release" }, + }, + { + buildTool: "webpack", + loggerPrefix: "[sentry-webpack-plugin]", + } + ); + + await buildPluginManager.uploadSourcemaps(["/path/to/bundle.js"], { + prepareArtifacts: false, + }); + + expect(mockCliUploadSourceMaps).toHaveBeenCalledWith( + "test-release", + expect.objectContaining({ + projects: ["proj-a", "proj-b"], + }) + ); + }); + }); + + describe("moduleMetadata callback with multiple projects", () => { + it("should pass project as string and projects as array when multiple projects configured", () => { + const moduleMetadataCallback = jest.fn().mockReturnValue({ custom: "metadata" }); + + createSentryBuildPluginManager( + { + authToken: "test-token", + org: "test-org", + project: ["proj-a", "proj-b", "proj-c"], + release: { name: "test-release" }, + moduleMetadata: moduleMetadataCallback, + }, + { + buildTool: "webpack", + loggerPrefix: "[sentry-webpack-plugin]", + } + ); + + expect(moduleMetadataCallback).toHaveBeenCalledWith({ + org: "test-org", + project: "proj-a", + projects: ["proj-a", "proj-b", "proj-c"], + release: "test-release", + }); + }); + + it("should pass project as string and projects as array with single project", () => { + const moduleMetadataCallback = jest.fn().mockReturnValue({ custom: "metadata" }); + + createSentryBuildPluginManager( + { + authToken: "test-token", + org: "test-org", + project: "single-project", + release: { name: "test-release" }, + moduleMetadata: moduleMetadataCallback, + }, + { + buildTool: "webpack", + loggerPrefix: "[sentry-webpack-plugin]", + } + ); + + expect(moduleMetadataCallback).toHaveBeenCalledWith({ + org: "test-org", + project: "single-project", + projects: ["single-project"], + release: "test-release", + }); + }); + + it("should pass undefined for projects when no project configured", () => { + const moduleMetadataCallback = jest.fn().mockReturnValue({ custom: "metadata" }); + + createSentryBuildPluginManager( + { + authToken: "test-token", + org: "test-org", + release: { name: "test-release" }, + moduleMetadata: moduleMetadataCallback, + }, + { + buildTool: "webpack", + loggerPrefix: "[sentry-webpack-plugin]", + } + ); + + expect(moduleMetadataCallback).toHaveBeenCalledWith({ + org: "test-org", + project: undefined, + projects: undefined, + release: "test-release", + }); + }); + }); }); diff --git a/packages/bundler-plugin-core/test/option-mappings.test.ts b/packages/bundler-plugin-core/test/option-mappings.test.ts index 76bceb3d..921bcd1e 100644 --- a/packages/bundler-plugin-core/test/option-mappings.test.ts +++ b/packages/bundler-plugin-core/test/option-mappings.test.ts @@ -190,6 +190,68 @@ describe("normalizeUserOptions()", () => { expect(normalizedOptions.release.deploy).toBeUndefined(); }); }); + + describe("multi-project support", () => { + test("should accept project as a string array", () => { + const userOptions: Options = { + org: "my-org", + project: ["project-a", "project-b", "project-c"], + authToken: "my-auth-token", + release: { name: "my-release" }, + }; + + const normalized = normalizeUserOptions(userOptions); + expect(normalized.project).toEqual(["project-a", "project-b", "project-c"]); + }); + + test("should parse comma-separated SENTRY_PROJECT env var", () => { + const originalEnv = process.env; + process.env = { ...originalEnv }; + process.env["SENTRY_PROJECT"] = "proj1,proj2,proj3"; + + const userOptions: Options = { + org: "my-org", + authToken: "my-auth-token", + }; + + const normalized = normalizeUserOptions(userOptions); + expect(normalized.project).toEqual(["proj1", "proj2", "proj3"]); + + process.env = originalEnv; + }); + + test("should trim whitespace from comma-separated projects", () => { + const originalEnv = process.env; + process.env = { ...originalEnv }; + process.env["SENTRY_PROJECT"] = "proj1 , proj2 , proj3"; + + const userOptions: Options = { + org: "my-org", + authToken: "my-auth-token", + }; + + const normalized = normalizeUserOptions(userOptions); + expect(normalized.project).toEqual(["proj1", "proj2", "proj3"]); + + process.env = originalEnv; + }); + + test("should keep single project as string (no comma)", () => { + const originalEnv = process.env; + process.env = { ...originalEnv }; + process.env["SENTRY_PROJECT"] = "single-project"; + + const userOptions: Options = { + org: "my-org", + authToken: "my-auth-token", + }; + + const normalized = normalizeUserOptions(userOptions); + expect(normalized.project).toBe("single-project"); + + process.env = originalEnv; + }); + }); }); describe("validateOptions", () => { @@ -269,4 +331,40 @@ describe("validateOptions", () => { expect(validateOptions(options as unknown as NormalizedOptions, mockedLogger)).toBe(true); expect(mockedLogger.error).not.toHaveBeenCalled(); }); + + describe("multi-project validation", () => { + it("should return `false` if project array is empty", () => { + const options = { project: [] } as Partial; + + expect(validateOptions(options as unknown as NormalizedOptions, mockedLogger)).toBe(false); + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringMatching(/project.*array.*empty/i), + expect.stringMatching(/at least one/i) + ); + }); + + it("should return `false` if project array contains invalid strings", () => { + const options = { project: ["valid", "", " ", "also-valid"] } as Partial; + + expect(validateOptions(options as unknown as NormalizedOptions, mockedLogger)).toBe(false); + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringMatching(/invalid.*project/i), + expect.stringMatching(/non-empty strings/i) + ); + }); + + it("should return `true` for valid project array", () => { + const options = { project: ["proj-a", "proj-b"] } as Partial; + + expect(validateOptions(options as unknown as NormalizedOptions, mockedLogger)).toBe(true); + expect(mockedLogger.error).not.toHaveBeenCalled(); + }); + + it("should return `true` for valid single project string", () => { + const options = { project: "single-project" } as Partial; + + expect(validateOptions(options as unknown as NormalizedOptions, mockedLogger)).toBe(true); + expect(mockedLogger.error).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/dev-utils/src/generate-documentation-table.ts b/packages/dev-utils/src/generate-documentation-table.ts index 073ba305..6e97771b 100644 --- a/packages/dev-utils/src/generate-documentation-table.ts +++ b/packages/dev-utils/src/generate-documentation-table.ts @@ -17,8 +17,9 @@ const options: OptionDocumentation[] = [ }, { name: "project", + type: "string | string[]", fullDescription: - "The slug of the Sentry project associated with the app.\n\nThis value can also be specified via the `SENTRY_PROJECT` environment variable.", + "The slug of the Sentry project associated with the app. You can also provide an array of project slugs to upload source maps to multiple projects with the same release.\n\nThis value can also be specified via the `SENTRY_PROJECT` environment variable. To specify multiple projects via the environment variable, separate them with commas: `SENTRY_PROJECT=project1,project2,project3`.", }, { name: "authToken", diff --git a/packages/esbuild-plugin/README_TEMPLATE.md b/packages/esbuild-plugin/README_TEMPLATE.md index ca35df29..5e1e3e0c 100644 --- a/packages/esbuild-plugin/README_TEMPLATE.md +++ b/packages/esbuild-plugin/README_TEMPLATE.md @@ -53,6 +53,32 @@ require("esbuild").build({ }); ``` +### Multi-Project Configuration + +If you want to upload the same source maps to multiple Sentry projects: + +```js +// esbuild.config.js +const { sentryEsbuildPlugin } = require("@sentry/esbuild-plugin"); + +require("esbuild").build({ + sourcemap: true, + plugins: [ + sentryEsbuildPlugin({ + org: process.env.SENTRY_ORG, + project: ["frontend-team-a", "frontend-team-b", "frontend-team-c"], + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], +}); +``` + +Or via environment variable: + +```bash +SENTRY_PROJECT=frontend-team-a,frontend-team-b,frontend-team-c +``` + #OPTIONS_SECTION_INSERT# ### Configuration File diff --git a/packages/rollup-plugin/README_TEMPLATE.md b/packages/rollup-plugin/README_TEMPLATE.md index 53ebf7c8..05e88416 100644 --- a/packages/rollup-plugin/README_TEMPLATE.md +++ b/packages/rollup-plugin/README_TEMPLATE.md @@ -55,6 +55,34 @@ export default { }; ``` +### Multi-Project Configuration + +If you want to upload the same source maps to multiple Sentry projects: + +```js +// rollup.config.js +import { sentryRollupPlugin } from "@sentry/rollup-plugin"; + +export default { + plugins: [ + sentryRollupPlugin({ + org: process.env.SENTRY_ORG, + project: ["frontend-team-a", "frontend-team-b", "frontend-team-c"], + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], + output: { + sourcemap: true, + }, +}; +``` + +Or via environment variable: + +```bash +SENTRY_PROJECT=frontend-team-a,frontend-team-b,frontend-team-c +``` + #OPTIONS_SECTION_INSERT# ### Configuration File diff --git a/packages/vite-plugin/README_TEMPLATE.md b/packages/vite-plugin/README_TEMPLATE.md index ad8d3cbc..0f34d017 100644 --- a/packages/vite-plugin/README_TEMPLATE.md +++ b/packages/vite-plugin/README_TEMPLATE.md @@ -60,6 +60,35 @@ export default defineConfig({ }); ``` +### Multi-Project Configuration + +If you want to upload the same source maps to multiple Sentry projects: + +```ts +// vite.config.ts +import { defineConfig } from "vite"; +import { sentryVitePlugin } from "@sentry/vite-plugin"; + +export default defineConfig({ + build: { + sourcemap: true, + }, + plugins: [ + sentryVitePlugin({ + org: process.env.SENTRY_ORG, + project: ["frontend-team-a", "frontend-team-b", "frontend-team-c"], + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], +}); +``` + +Or via environment variable: + +```bash +SENTRY_PROJECT=frontend-team-a,frontend-team-b,frontend-team-c +``` + #OPTIONS_SECTION_INSERT# ### Configuration File diff --git a/packages/webpack-plugin/README_TEMPLATE.md b/packages/webpack-plugin/README_TEMPLATE.md index 2b25c4a2..c02dc897 100644 --- a/packages/webpack-plugin/README_TEMPLATE.md +++ b/packages/webpack-plugin/README_TEMPLATE.md @@ -56,6 +56,34 @@ module.exports = { }; ``` +### Multi-Project Configuration + +If you want to upload the same source maps to multiple Sentry projects: + +```js +// webpack.config.js +const { sentryWebpackPlugin } = require("@sentry/webpack-plugin"); + +module.exports = { + // ... other config above ... + + devtool: "source-map", + plugins: [ + sentryWebpackPlugin({ + org: process.env.SENTRY_ORG, + project: ["frontend-team-a", "frontend-team-b", "frontend-team-c"], + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], +}; +``` + +Or via environment variable: + +```bash +SENTRY_PROJECT=frontend-team-a,frontend-team-b,frontend-team-c +``` + #OPTIONS_SECTION_INSERT# ### Configuration File