diff --git a/packages/astro/test/buildOptions.test-d.ts b/packages/astro/test/buildOptions.test-d.ts index 275e47ae68ee..da853204f179 100644 --- a/packages/astro/test/buildOptions.test-d.ts +++ b/packages/astro/test/buildOptions.test-d.ts @@ -6,7 +6,7 @@ describe('Sentry Astro build-time options type', () => { const completeOptions: SentryOptions = { // --- BuildTimeOptionsBase options --- org: 'test-org', - project: 'test-project', + project: ['test-project', 'other-project'], authToken: 'test-auth-token', sentryUrl: 'https://sentry.io', headers: { Authorization: ' Bearer test-auth-token' }, diff --git a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts index 36a8967c8105..9cc883e8e257 100644 --- a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts +++ b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts @@ -30,9 +30,11 @@ export interface BuildTimeOptionsBase { /** * The slug of the Sentry project associated with the app. * + * Multiple projects can be passed to upload the build's source maps to each of them. + * * 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. diff --git a/packages/nuxt/test/vite/buildOptions.test-d.ts b/packages/nuxt/test/vite/buildOptions.test-d.ts index 95fa09f09778..a1f6df85ec52 100644 --- a/packages/nuxt/test/vite/buildOptions.test-d.ts +++ b/packages/nuxt/test/vite/buildOptions.test-d.ts @@ -6,7 +6,7 @@ describe('Sentry Nuxt build-time options type', () => { const completeOptions: SentryNuxtModuleOptions = { // --- BuildTimeOptionsBase options --- org: 'test-org', - project: 'test-project', + project: ['test-project', 'other-project'], authToken: 'test-auth-token', sentryUrl: 'https://sentry.io', headers: { Authorization: ' Bearer test-auth-token' }, diff --git a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts index 3af80b186e14..a4cf0403e248 100644 --- a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts +++ b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts @@ -58,7 +58,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo token: authToken, org, url: sentryUrl, - project, + project: Array.isArray(project) ? undefined : project, headers, }; @@ -68,7 +68,10 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo if (release?.name) { try { // The release API requires a project, and the one given to the SDK does not reach this call. - await sentry.release.create({ orgVersion: release.name, project }); + await sentry.release.create({ + orgVersion: release.name, + project: Array.isArray(project) ? project.join(',') : project, + }); } catch (error) { // eslint-disable-next-line no-console console.error('[Sentry] Could not create release', error); @@ -92,10 +95,16 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo if (!uploadDisabled) { // upload sourcemaps try { - await sentry.sourcemap.upload({ - directory: reactRouterConfig.buildDirectory, - release: release?.name || 'undefined', - }); + const uploadClients = Array.isArray(project) + ? project.map(project => createSentrySDK({ ...sentryOptions, project })) + : [sentry]; + + for (const client of uploadClients) { + await client.sourcemap.upload({ + directory: reactRouterConfig.buildDirectory, + release: release?.name || 'undefined', + }); + } } catch (error) { // eslint-disable-next-line no-console console.error('[Sentry] Could not upload sourcemaps', error); diff --git a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts index 9bb18672ba3c..560552ed278d 100644 --- a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts +++ b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts @@ -1,5 +1,5 @@ import { createSentrySDK } from 'sentry'; -import * as fs from 'fs'; +import { rm } from 'node:fs/promises'; import { glob } from 'glob'; import type { ResolvedConfig } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -7,11 +7,10 @@ import { sentryOnBuildEnd } from '../../../src/vite/buildEnd/handleOnBuildEnd'; import type { SentryReactRouterBuildOptions } from '../../../src/vite/types'; vi.mock('sentry'); -vi.mock('fs', () => ({ - promises: { - rm: vi.fn().mockResolvedValue(undefined), - }, -})); +vi.mock('node:fs/promises', () => { + const rm = vi.fn().mockResolvedValue(undefined); + return { rm, default: { rm } }; +}); vi.mock('glob'); type TestConfig = ResolvedConfig & { @@ -59,12 +58,16 @@ describe('sentryOnBuildEnd', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(createSentrySDK).mockReturnValue(mockSentrySdkInstance as unknown as ReturnType); + mockSentrySdkInstance.sourcemap.upload.mockReset(); + vi.mocked(createSentrySDK) + .mockReset() + .mockReturnValue(mockSentrySdkInstance as unknown as ReturnType); vi.mocked(glob).mockResolvedValue(['/build/file1.map', '/build/file2.map']); - vi.mocked(fs.promises.rm).mockResolvedValue(undefined); + vi.mocked(rm).mockResolvedValue(undefined); }); afterEach(() => { + vi.restoreAllMocks(); vi.resetModules(); }); @@ -140,35 +143,50 @@ describe('sentryOnBuildEnd', () => { }); }); - it('should not upload or inject source maps when disabled via top-level sourcemaps.disable', async () => { + it('creates a release for both projects and uploads to each with one debug ID injection', async () => { + const upload = vi.fn(); + vi.mocked(createSentrySDK).mockImplementation(options => { + const clientUpload: ReturnType['sourcemap']['upload'] = params => + upload(options?.project, params); + return { + ...mockSentrySdkInstance, + sourcemap: { + ...mockSentrySdkInstance.sourcemap, + upload: clientUpload, + }, + } as unknown as ReturnType; + }); + const sentryConfig: SentryReactRouterBuildOptions = { + ...defaultConfig.viteConfig.sentryConfig, + project: ['frontend', 'backend'], + release: { name: 'v1.0.0' }, + }; const config = { ...defaultConfig, - viteConfig: { - ...defaultConfig.viteConfig, - sentryConfig: { - ...defaultConfig.viteConfig.sentryConfig, - sourcemaps: { disable: true }, - }, - } as unknown as TestConfig, + viteConfig: { ...defaultConfig.viteConfig, sentryConfig }, }; // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); - expect(mockSentrySdkInstance.sourcemap.inject).not.toHaveBeenCalled(); - expect(mockSentrySdkInstance.sourcemap.upload).not.toHaveBeenCalled(); + expect(mockSentrySdkInstance.release.create).toHaveBeenCalledExactlyOnceWith({ + orgVersion: 'v1.0.0', + project: 'frontend,backend', + }); + expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledExactlyOnceWith({ directory: '/build' }); + expect(upload).toHaveBeenCalledTimes(2); + expect(upload).toHaveBeenCalledWith('frontend', { directory: '/build', release: 'v1.0.0' }); + expect(upload).toHaveBeenCalledWith('backend', { directory: '/build', release: 'v1.0.0' }); }); - // `'disable-upload'` means "inject debug IDs, but let me upload the maps myself", so - // injection must still run and the maps must survive. - it('should inject debug IDs but skip upload and deletion when disable is "disable-upload"', async () => { + it('should not upload or inject source maps when disabled via top-level sourcemaps.disable', async () => { const config = { ...defaultConfig, viteConfig: { ...defaultConfig.viteConfig, sentryConfig: { ...defaultConfig.viteConfig.sentryConfig, - sourcemaps: { disable: 'disable-upload' }, + sourcemaps: { disable: true }, }, } as unknown as TestConfig, }; @@ -176,20 +194,22 @@ describe('sentryOnBuildEnd', () => { // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); - expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledWith({ directory: '/build' }); + expect(mockSentrySdkInstance.sourcemap.inject).not.toHaveBeenCalled(); expect(mockSentrySdkInstance.sourcemap.upload).not.toHaveBeenCalled(); expect(glob).not.toHaveBeenCalled(); + expect(rm).not.toHaveBeenCalled(); }); - // Deleting maps that were never uploaded would leave the user with neither. - it('should not delete source maps when upload is disabled', async () => { + // `'disable-upload'` means "inject debug IDs, but let me upload the maps myself", so + // injection must still run and the maps must survive. + it('should inject debug IDs but skip upload and deletion when disable is "disable-upload"', async () => { const config = { ...defaultConfig, viteConfig: { ...defaultConfig.viteConfig, sentryConfig: { ...defaultConfig.viteConfig.sentryConfig, - sourcemaps: { disable: true }, + sourcemaps: { disable: 'disable-upload' }, }, } as unknown as TestConfig, }; @@ -197,18 +217,9 @@ describe('sentryOnBuildEnd', () => { // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); + expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledWith({ directory: '/build' }); + expect(mockSentrySdkInstance.sourcemap.upload).not.toHaveBeenCalled(); expect(glob).not.toHaveBeenCalled(); - expect(fs.promises.rm).not.toHaveBeenCalled(); - }); - - it('should delete source maps after upload with default pattern', async () => { - // @ts-expect-error - mocking the React config - await sentryOnBuildEnd(defaultConfig); - - expect(glob).toHaveBeenCalledWith(['/build/**/*.map'], { - absolute: true, - nodir: true, - }); }); it('should delete custom files after upload when specified', async () => { @@ -258,13 +269,6 @@ describe('sentryOnBuildEnd', () => { consoleSpy.mockRestore(); }); - it('should inject debug IDs before uploading source maps', async () => { - // @ts-expect-error - mocking the React config - await sentryOnBuildEnd(defaultConfig); - - expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledWith({ directory: '/build' }); - }); - it('should handle errors during debug ID injection gracefully', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); mockSentrySdkInstance.sourcemap.inject.mockRejectedValueOnce(new Error('Injection failed')); @@ -278,15 +282,25 @@ describe('sentryOnBuildEnd', () => { consoleSpy.mockRestore(); }); - it('should handle errors during source map upload gracefully', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - mockSentrySdkInstance.sourcemap.upload.mockRejectedValueOnce(new Error('Upload failed')); + it('logs a failed project upload and still runs cleanup', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const uploadError = new Error('Backend upload failed'); + mockSentrySdkInstance.sourcemap.upload.mockResolvedValueOnce(undefined).mockRejectedValueOnce(uploadError); + const sentryConfig: SentryReactRouterBuildOptions = { + ...defaultConfig.viteConfig.sentryConfig, + project: ['frontend', 'backend'], + }; + const config = { + ...defaultConfig, + viteConfig: { ...defaultConfig.viteConfig, sentryConfig }, + }; // @ts-expect-error - mocking the React config - await sentryOnBuildEnd(defaultConfig); + await sentryOnBuildEnd(config); - expect(consoleSpy).toHaveBeenCalledWith('[Sentry] Could not upload sourcemaps', expect.any(Error)); - consoleSpy.mockRestore(); + expect(mockSentrySdkInstance.sourcemap.upload).toHaveBeenCalledTimes(2); + expect(consoleSpy).toHaveBeenCalledWith('[Sentry] Could not upload sourcemaps', uploadError); + expect(rm).toHaveBeenCalledTimes(2); }); it('should log debug information when debug is enabled', async () => { diff --git a/packages/react-router/test/vite/types.test-d.ts b/packages/react-router/test/vite/types.test-d.ts index f7ba9b49b8b9..699aa1323521 100644 --- a/packages/react-router/test/vite/types.test-d.ts +++ b/packages/react-router/test/vite/types.test-d.ts @@ -6,7 +6,7 @@ describe('Sentry React-Router build-time options type', () => { const completeOptions: SentryReactRouterBuildOptions = { // --- BuildTimeOptionsBase options --- org: 'test-org', - project: 'test-project', + project: ['test-project', 'other-project'], authToken: 'test-auth-token', sentryUrl: 'https://sentry.io', headers: { Authorization: ' Bearer test-auth-token' },