Skip to content
Open
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
21 changes: 15 additions & 6 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 @@ -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);
Expand Down
114 changes: 64 additions & 50 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,75 +143,83 @@ 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<typeof createSentrySDK>['sourcemap']['upload'] = params =>
upload(options?.project, params);
return {
...mockSentrySdkInstance,
sourcemap: {
...mockSentrySdkInstance.sourcemap,
upload: clientUpload,
},
} as unknown as ReturnType<typeof createSentrySDK>;
});
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,
};

// @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,
};

// @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 () => {
Expand Down Expand Up @@ -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'));
Expand All @@ -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 () => {
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