Skip to content

Commit 63f337d

Browse files
nicohrubeccodex
andauthored
fix(core): allow project arrays in framework build options (#25534)
Framework build options reject project arrays that the bundler plugins already support. Widen the shared option and handle React Router's single-project CLI clients by associating releases with all configured projects and uploading through one client per project. Fixes #21069 --------- Co-authored-by: GPT-6 <codex@openai.com>
1 parent 71f4be9 commit 63f337d

6 files changed

Lines changed: 85 additions & 60 deletions

File tree

‎packages/astro/test/buildOptions.test-d.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ describe('Sentry Astro build-time options type', () => {
66
const completeOptions: SentryOptions = {
77
// --- BuildTimeOptionsBase options ---
88
org: 'test-org',
9-
project: 'test-project',
9+
project: ['test-project', 'other-project'],
1010
authToken: 'test-auth-token',
1111
sentryUrl: 'https://sentry.io',
1212
headers: { Authorization: ' Bearer test-auth-token' },

‎packages/core/src/build-time-plugins/buildTimeOptionsBase.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ export interface BuildTimeOptionsBase {
3030
/**
3131
* The slug of the Sentry project associated with the app.
3232
*
33+
* Multiple projects can be passed to upload the build's source maps to each of them.
34+
*
3335
* This value can also be specified via the `SENTRY_PROJECT` environment variable.
3436
*/
35-
project?: string;
37+
project?: string | string[];
3638

3739
/**
3840
* The authentication token to use for all communication with Sentry.

‎packages/nuxt/test/vite/buildOptions.test-d.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ describe('Sentry Nuxt build-time options type', () => {
66
const completeOptions: SentryNuxtModuleOptions = {
77
// --- BuildTimeOptionsBase options ---
88
org: 'test-org',
9-
project: 'test-project',
9+
project: ['test-project', 'other-project'],
1010
authToken: 'test-auth-token',
1111
sentryUrl: 'https://sentry.io',
1212
headers: { Authorization: ' Bearer test-auth-token' },

‎packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo
5858
token: authToken,
5959
org,
6060
url: sentryUrl,
61-
project,
61+
project: Array.isArray(project) ? undefined : project,
6262
headers,
6363
};
6464

@@ -68,7 +68,10 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo
6868
if (release?.name) {
6969
try {
7070
// The release API requires a project, and the one given to the SDK does not reach this call.
71-
await sentry.release.create({ orgVersion: release.name, project });
71+
await sentry.release.create({
72+
orgVersion: release.name,
73+
project: Array.isArray(project) ? project.join(',') : project,
74+
});
7275
} catch (error) {
7376
// eslint-disable-next-line no-console
7477
console.error('[Sentry] Could not create release', error);
@@ -92,10 +95,16 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo
9295
if (!uploadDisabled) {
9396
// upload sourcemaps
9497
try {
95-
await sentry.sourcemap.upload({
96-
directory: reactRouterConfig.buildDirectory,
97-
release: release?.name || 'undefined',
98-
});
98+
const uploadClients = Array.isArray(project)
99+
? project.map(project => createSentrySDK({ ...sentryOptions, project }))
100+
: [sentry];
101+
102+
for (const client of uploadClients) {
103+
await client.sourcemap.upload({
104+
directory: reactRouterConfig.buildDirectory,
105+
release: release?.name || 'undefined',
106+
});
107+
}
99108
} catch (error) {
100109
// eslint-disable-next-line no-console
101110
console.error('[Sentry] Could not upload sourcemaps', error);

‎packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts‎

Lines changed: 64 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
import { createSentrySDK } from 'sentry';
2-
import * as fs from 'fs';
2+
import { rm } from 'node:fs/promises';
33
import { glob } from 'glob';
44
import type { ResolvedConfig } from 'vite';
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
66
import { sentryOnBuildEnd } from '../../../src/vite/buildEnd/handleOnBuildEnd';
77
import type { SentryReactRouterBuildOptions } from '../../../src/vite/types';
88

99
vi.mock('sentry');
10-
vi.mock('fs', () => ({
11-
promises: {
12-
rm: vi.fn().mockResolvedValue(undefined),
13-
},
14-
}));
10+
vi.mock('node:fs/promises', () => {
11+
const rm = vi.fn().mockResolvedValue(undefined);
12+
return { rm, default: { rm } };
13+
});
1514
vi.mock('glob');
1615

1716
type TestConfig = ResolvedConfig & {
@@ -59,12 +58,16 @@ describe('sentryOnBuildEnd', () => {
5958

6059
beforeEach(() => {
6160
vi.clearAllMocks();
62-
vi.mocked(createSentrySDK).mockReturnValue(mockSentrySdkInstance as unknown as ReturnType<typeof createSentrySDK>);
61+
mockSentrySdkInstance.sourcemap.upload.mockReset();
62+
vi.mocked(createSentrySDK)
63+
.mockReset()
64+
.mockReturnValue(mockSentrySdkInstance as unknown as ReturnType<typeof createSentrySDK>);
6365
vi.mocked(glob).mockResolvedValue(['/build/file1.map', '/build/file2.map']);
64-
vi.mocked(fs.promises.rm).mockResolvedValue(undefined);
66+
vi.mocked(rm).mockResolvedValue(undefined);
6567
});
6668

6769
afterEach(() => {
70+
vi.restoreAllMocks();
6871
vi.resetModules();
6972
});
7073

@@ -140,75 +143,83 @@ describe('sentryOnBuildEnd', () => {
140143
});
141144
});
142145

143-
it('should not upload or inject source maps when disabled via top-level sourcemaps.disable', async () => {
146+
it('creates a release for both projects and uploads to each with one debug ID injection', async () => {
147+
const upload = vi.fn();
148+
vi.mocked(createSentrySDK).mockImplementation(options => {
149+
const clientUpload: ReturnType<typeof createSentrySDK>['sourcemap']['upload'] = params =>
150+
upload(options?.project, params);
151+
return {
152+
...mockSentrySdkInstance,
153+
sourcemap: {
154+
...mockSentrySdkInstance.sourcemap,
155+
upload: clientUpload,
156+
},
157+
} as unknown as ReturnType<typeof createSentrySDK>;
158+
});
159+
const sentryConfig: SentryReactRouterBuildOptions = {
160+
...defaultConfig.viteConfig.sentryConfig,
161+
project: ['frontend', 'backend'],
162+
release: { name: 'v1.0.0' },
163+
};
144164
const config = {
145165
...defaultConfig,
146-
viteConfig: {
147-
...defaultConfig.viteConfig,
148-
sentryConfig: {
149-
...defaultConfig.viteConfig.sentryConfig,
150-
sourcemaps: { disable: true },
151-
},
152-
} as unknown as TestConfig,
166+
viteConfig: { ...defaultConfig.viteConfig, sentryConfig },
153167
};
154168

155169
// @ts-expect-error - mocking the React config
156170
await sentryOnBuildEnd(config);
157171

158-
expect(mockSentrySdkInstance.sourcemap.inject).not.toHaveBeenCalled();
159-
expect(mockSentrySdkInstance.sourcemap.upload).not.toHaveBeenCalled();
172+
expect(mockSentrySdkInstance.release.create).toHaveBeenCalledExactlyOnceWith({
173+
orgVersion: 'v1.0.0',
174+
project: 'frontend,backend',
175+
});
176+
expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledExactlyOnceWith({ directory: '/build' });
177+
expect(upload).toHaveBeenCalledTimes(2);
178+
expect(upload).toHaveBeenCalledWith('frontend', { directory: '/build', release: 'v1.0.0' });
179+
expect(upload).toHaveBeenCalledWith('backend', { directory: '/build', release: 'v1.0.0' });
160180
});
161181

162-
// `'disable-upload'` means "inject debug IDs, but let me upload the maps myself", so
163-
// injection must still run and the maps must survive.
164-
it('should inject debug IDs but skip upload and deletion when disable is "disable-upload"', async () => {
182+
it('should not upload or inject source maps when disabled via top-level sourcemaps.disable', async () => {
165183
const config = {
166184
...defaultConfig,
167185
viteConfig: {
168186
...defaultConfig.viteConfig,
169187
sentryConfig: {
170188
...defaultConfig.viteConfig.sentryConfig,
171-
sourcemaps: { disable: 'disable-upload' },
189+
sourcemaps: { disable: true },
172190
},
173191
} as unknown as TestConfig,
174192
};
175193

176194
// @ts-expect-error - mocking the React config
177195
await sentryOnBuildEnd(config);
178196

179-
expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledWith({ directory: '/build' });
197+
expect(mockSentrySdkInstance.sourcemap.inject).not.toHaveBeenCalled();
180198
expect(mockSentrySdkInstance.sourcemap.upload).not.toHaveBeenCalled();
181199
expect(glob).not.toHaveBeenCalled();
200+
expect(rm).not.toHaveBeenCalled();
182201
});
183202

184-
// Deleting maps that were never uploaded would leave the user with neither.
185-
it('should not delete source maps when upload is disabled', async () => {
203+
// `'disable-upload'` means "inject debug IDs, but let me upload the maps myself", so
204+
// injection must still run and the maps must survive.
205+
it('should inject debug IDs but skip upload and deletion when disable is "disable-upload"', async () => {
186206
const config = {
187207
...defaultConfig,
188208
viteConfig: {
189209
...defaultConfig.viteConfig,
190210
sentryConfig: {
191211
...defaultConfig.viteConfig.sentryConfig,
192-
sourcemaps: { disable: true },
212+
sourcemaps: { disable: 'disable-upload' },
193213
},
194214
} as unknown as TestConfig,
195215
};
196216

197217
// @ts-expect-error - mocking the React config
198218
await sentryOnBuildEnd(config);
199219

220+
expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledWith({ directory: '/build' });
221+
expect(mockSentrySdkInstance.sourcemap.upload).not.toHaveBeenCalled();
200222
expect(glob).not.toHaveBeenCalled();
201-
expect(fs.promises.rm).not.toHaveBeenCalled();
202-
});
203-
204-
it('should delete source maps after upload with default pattern', async () => {
205-
// @ts-expect-error - mocking the React config
206-
await sentryOnBuildEnd(defaultConfig);
207-
208-
expect(glob).toHaveBeenCalledWith(['/build/**/*.map'], {
209-
absolute: true,
210-
nodir: true,
211-
});
212223
});
213224

214225
it('should delete custom files after upload when specified', async () => {
@@ -258,13 +269,6 @@ describe('sentryOnBuildEnd', () => {
258269
consoleSpy.mockRestore();
259270
});
260271

261-
it('should inject debug IDs before uploading source maps', async () => {
262-
// @ts-expect-error - mocking the React config
263-
await sentryOnBuildEnd(defaultConfig);
264-
265-
expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledWith({ directory: '/build' });
266-
});
267-
268272
it('should handle errors during debug ID injection gracefully', async () => {
269273
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
270274
mockSentrySdkInstance.sourcemap.inject.mockRejectedValueOnce(new Error('Injection failed'));
@@ -278,15 +282,25 @@ describe('sentryOnBuildEnd', () => {
278282
consoleSpy.mockRestore();
279283
});
280284

281-
it('should handle errors during source map upload gracefully', async () => {
282-
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
283-
mockSentrySdkInstance.sourcemap.upload.mockRejectedValueOnce(new Error('Upload failed'));
285+
it('logs a failed project upload and still runs cleanup', async () => {
286+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
287+
const uploadError = new Error('Backend upload failed');
288+
mockSentrySdkInstance.sourcemap.upload.mockResolvedValueOnce(undefined).mockRejectedValueOnce(uploadError);
289+
const sentryConfig: SentryReactRouterBuildOptions = {
290+
...defaultConfig.viteConfig.sentryConfig,
291+
project: ['frontend', 'backend'],
292+
};
293+
const config = {
294+
...defaultConfig,
295+
viteConfig: { ...defaultConfig.viteConfig, sentryConfig },
296+
};
284297

285298
// @ts-expect-error - mocking the React config
286-
await sentryOnBuildEnd(defaultConfig);
299+
await sentryOnBuildEnd(config);
287300

288-
expect(consoleSpy).toHaveBeenCalledWith('[Sentry] Could not upload sourcemaps', expect.any(Error));
289-
consoleSpy.mockRestore();
301+
expect(mockSentrySdkInstance.sourcemap.upload).toHaveBeenCalledTimes(2);
302+
expect(consoleSpy).toHaveBeenCalledWith('[Sentry] Could not upload sourcemaps', uploadError);
303+
expect(rm).toHaveBeenCalledTimes(2);
290304
});
291305

292306
it('should log debug information when debug is enabled', async () => {

‎packages/react-router/test/vite/types.test-d.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ describe('Sentry React-Router build-time options type', () => {
66
const completeOptions: SentryReactRouterBuildOptions = {
77
// --- BuildTimeOptionsBase options ---
88
org: 'test-org',
9-
project: 'test-project',
9+
project: ['test-project', 'other-project'],
1010
authToken: 'test-auth-token',
1111
sentryUrl: 'https://sentry.io',
1212
headers: { Authorization: ' Bearer test-auth-token' },

0 commit comments

Comments
 (0)