Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/astro/test/buildOptions.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/build-time-plugins/buildTimeOptionsBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt/test/vite/buildOptions.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
26 changes: 19 additions & 7 deletions packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo
token: authToken,
org,
url: sentryUrl,
project,
project: Array.isArray(project) ? undefined : project,
headers,
};

Expand All @@ -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);
Expand All @@ -79,6 +82,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo
// uploaded manually at a later point - only `true` turns source maps off entirely.
const sourceMapsFullyDisabled = sourcemaps?.disable === true;
const uploadDisabled = sourceMapsFullyDisabled || sourcemaps?.disable === 'disable-upload';
let sourceMapsUploaded = false;

if (!sourceMapsFullyDisabled && viteConfig.build.sourcemap !== false) {
// inject debugIds
Expand All @@ -92,10 +96,18 @@ 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',
});
}

sourceMapsUploaded = uploadClients.length > 0;
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Sentry] Could not upload sourcemaps', error);
Expand All @@ -105,7 +117,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo

// Only clean up source maps that were actually uploaded. Deleting them after skipping
// the upload would leave the user with neither, breaking a manual upload.
if (uploadDisabled) {
if (!sourceMapsUploaded) {
return;
}

Expand Down
154 changes: 145 additions & 9 deletions packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
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';
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 & {
Expand Down Expand Up @@ -59,12 +58,16 @@ describe('sentryOnBuildEnd', () => {

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(createSentrySDK).mockReturnValue(mockSentrySdkInstance as unknown as ReturnType<typeof createSentrySDK>);
mockSentrySdkInstance.sourcemap.upload.mockReset();
vi.mocked(createSentrySDK)
.mockReset()
.mockReturnValue(mockSentrySdkInstance as unknown as ReturnType<typeof createSentrySDK>);
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();
});

Expand Down Expand Up @@ -140,6 +143,139 @@ describe('sentryOnBuildEnd', () => {
});
});

it('uploads to each project with one debug ID injection and cleanup after both uploads', async () => {
const frontendUpload = vi.fn();
const backendUpload = vi.fn();
vi.mocked(createSentrySDK)
.mockReturnValueOnce(mockSentrySdkInstance as unknown as ReturnType<typeof createSentrySDK>)
.mockReturnValueOnce({
...mockSentrySdkInstance,
sourcemap: { ...mockSentrySdkInstance.sourcemap, upload: frontendUpload },
} as unknown as ReturnType<typeof createSentrySDK>)
.mockReturnValueOnce({
...mockSentrySdkInstance,
sourcemap: { ...mockSentrySdkInstance.sourcemap, upload: backendUpload },
} as unknown as ReturnType<typeof createSentrySDK>);
const sentryConfig: SentryReactRouterBuildOptions = {
...defaultConfig.viteConfig.sentryConfig,
project: ['frontend', 'backend'],
release: { name: 'v1.0.0' },
sentryUrl: 'https://sentry.example.com',
headers: { 'X-Custom-Header': 'test-value' },
};
const config = {
...defaultConfig,
viteConfig: { ...defaultConfig.viteConfig, sentryConfig },
};

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

expect(createSentrySDK).toHaveBeenCalledTimes(3);
expect(createSentrySDK).toHaveBeenNthCalledWith(1, {
token: 'test-token',
org: 'test-org',
url: 'https://sentry.example.com',
headers: { 'X-Custom-Header': 'test-value' },
project: undefined,
});
expect(createSentrySDK).toHaveBeenNthCalledWith(2, {
token: 'test-token',
org: 'test-org',
url: 'https://sentry.example.com',
headers: { 'X-Custom-Header': 'test-value' },
project: 'frontend',
});
expect(createSentrySDK).toHaveBeenNthCalledWith(3, {
token: 'test-token',
org: 'test-org',
url: 'https://sentry.example.com',
headers: { 'X-Custom-Header': 'test-value' },
project: 'backend',
});
expect(mockSentrySdkInstance.release.create).toHaveBeenCalledExactlyOnceWith({
orgVersion: 'v1.0.0',
project: 'frontend,backend',
});
expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledExactlyOnceWith({ directory: '/build' });
expect(frontendUpload).toHaveBeenCalledExactlyOnceWith({ directory: '/build', release: 'v1.0.0' });
expect(backendUpload).toHaveBeenCalledExactlyOnceWith({ directory: '/build', release: 'v1.0.0' });
expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledBefore(frontendUpload);
expect(mockSentrySdkInstance.sourcemap.inject).toHaveBeenCalledBefore(backendUpload);
expect(glob).toHaveBeenCalledExactlyOnceWith(['/build/**/*.map'], { absolute: true, nodir: true });
expect(rm).toHaveBeenCalledTimes(2);
expect(rm).toHaveBeenCalledWith('/build/file1.map', { force: true });
expect(rm).toHaveBeenCalledWith('/build/file2.map', { force: true });
expect(frontendUpload).toHaveBeenCalledBefore(vi.mocked(rm));
expect(backendUpload).toHaveBeenCalledBefore(vi.mocked(rm));
});

it('preserves source maps when one project upload fails', 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(config);

expect(mockSentrySdkInstance.sourcemap.upload).toHaveBeenCalledTimes(2);
expect(consoleSpy).toHaveBeenCalledWith('[Sentry] Could not upload sourcemaps', uploadError);
expect(glob).not.toHaveBeenCalled();
expect(rm).not.toHaveBeenCalled();
});

it('preserves source maps when no projects are configured in the array', async () => {
const sentryConfig: SentryReactRouterBuildOptions = {
...defaultConfig.viteConfig.sentryConfig,
project: [],
};
const config = {
...defaultConfig,
viteConfig: { ...defaultConfig.viteConfig, sentryConfig },
};

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

expect(mockSentrySdkInstance.sourcemap.upload).not.toHaveBeenCalled();
expect(glob).not.toHaveBeenCalled();
expect(rm).not.toHaveBeenCalled();
});

it('leaves project resolution to the CLI when the project option is omitted', async () => {
const sentryConfig: SentryReactRouterBuildOptions = {
...defaultConfig.viteConfig.sentryConfig,
project: undefined,
};
const config = {
...defaultConfig,
viteConfig: { ...defaultConfig.viteConfig, sentryConfig },
};

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

expect(createSentrySDK).toHaveBeenCalledExactlyOnceWith({
token: 'test-token',
org: 'test-org',
url: undefined,
headers: undefined,
project: undefined,
});
expect(mockSentrySdkInstance.sourcemap.upload).toHaveBeenCalledExactlyOnceWith({
directory: '/build',
release: 'undefined',
});
});

it('should not upload or inject source maps when disabled via top-level sourcemaps.disable', async () => {
const config = {
...defaultConfig,
Expand Down Expand Up @@ -198,7 +334,7 @@ describe('sentryOnBuildEnd', () => {
await sentryOnBuildEnd(config);

expect(glob).not.toHaveBeenCalled();
expect(fs.promises.rm).not.toHaveBeenCalled();
expect(rm).not.toHaveBeenCalled();
});

it('should delete source maps after upload with default pattern', async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/test/vite/types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Loading