diff --git a/packages/nitro/package.json b/packages/nitro/package.json index 2f5ee0d52fa8..54dab47a7157 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -38,13 +38,14 @@ "nitro": ">=3.0.0-0 <4.0.0 || 3.0.260311-beta || 3.0.260415-beta" }, "dependencies": { + "@sentry/bundler-plugin-core": "^5.2.0", "@sentry/core": "10.49.0", "@sentry/node": "10.49.0", "@sentry/opentelemetry": "10.49.0" }, "devDependencies": { - "h3": "^2.0.1-rc.13", - "nitro": "^3.0.260415-beta" + "nitro": "^3.0.260415-beta", + "h3": "^2.0.1-rc.13" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/nitro/rollup.npm.config.mjs b/packages/nitro/rollup.npm.config.mjs index 1e41829a3a3a..3be46cb5f15b 100644 --- a/packages/nitro/rollup.npm.config.mjs +++ b/packages/nitro/rollup.npm.config.mjs @@ -5,7 +5,7 @@ export default [ makeBaseNPMConfig({ entrypoints: ['src/index.ts', 'src/runtime/plugins/server.ts'], packageSpecificConfig: { - external: [/^nitro/, /^h3/, /^srvx/, /^@sentry\/opentelemetry/], + external: [/^nitro/, /^h3/, /^srvx/, /^@sentry\/opentelemetry/, '@sentry/bundler-plugin-core'], }, }), ), diff --git a/packages/nitro/src/config.ts b/packages/nitro/src/config.ts index 219eb453fb18..cdc0f2b00dfb 100644 --- a/packages/nitro/src/config.ts +++ b/packages/nitro/src/config.ts @@ -1,18 +1,15 @@ +import type { BuildTimeOptionsBase } from '@sentry/core'; import type { NitroConfig } from 'nitro/types'; import { createNitroModule } from './module'; +import { configureSourcemapSettings } from './sourceMaps'; -type SentryNitroOptions = { - // TODO: Add options -}; +export type SentryNitroOptions = BuildTimeOptionsBase; /** * Modifies the passed in Nitro configuration with automatic build-time instrumentation. - * - * @param config A Nitro configuration object, as usually exported in `nitro.config.ts` or `nitro.config.mjs`. - * @returns The modified config to be exported */ -export function withSentryConfig(config: NitroConfig, moduleOptions?: SentryNitroOptions): NitroConfig { - return setupSentryNitroModule(config, moduleOptions); +export function withSentryConfig(config: NitroConfig, sentryOptions?: SentryNitroOptions): NitroConfig { + return setupSentryNitroModule(config, sentryOptions); } /** @@ -20,15 +17,17 @@ export function withSentryConfig(config: NitroConfig, moduleOptions?: SentryNitr */ export function setupSentryNitroModule( config: NitroConfig, - _moduleOptions?: SentryNitroOptions, + moduleOptions?: SentryNitroOptions, _serverConfigFile?: string, ): NitroConfig { if (!config.tracingChannel) { config.tracingChannel = true; } + const { sentryEnabledSourcemaps } = configureSourcemapSettings(config, moduleOptions); + config.modules = config.modules || []; - config.modules.push(createNitroModule()); + config.modules.push(createNitroModule(moduleOptions, sentryEnabledSourcemaps)); return config; } diff --git a/packages/nitro/src/module.ts b/packages/nitro/src/module.ts index 1f0955301813..1a4e5b0478d1 100644 --- a/packages/nitro/src/module.ts +++ b/packages/nitro/src/module.ts @@ -1,14 +1,17 @@ import type { NitroModule } from 'nitro/types'; +import type { SentryNitroOptions } from './config'; import { instrumentServer } from './instruments/instrumentServer'; +import { setupSourceMaps } from './sourceMaps'; /** * Creates a Nitro module to setup the Sentry SDK. */ -export function createNitroModule(): NitroModule { +export function createNitroModule(sentryOptions?: SentryNitroOptions, sentryEnabledSourcemaps?: boolean): NitroModule { return { name: 'sentry', setup: nitro => { instrumentServer(nitro); + setupSourceMaps(nitro, sentryOptions, sentryEnabledSourcemaps); }, }; } diff --git a/packages/nitro/src/sourceMaps.ts b/packages/nitro/src/sourceMaps.ts new file mode 100644 index 000000000000..baaad94b658f --- /dev/null +++ b/packages/nitro/src/sourceMaps.ts @@ -0,0 +1,158 @@ +import type { Options as BundlerPluginOptions } from '@sentry/bundler-plugin-core'; +import { createSentryBuildPluginManager } from '@sentry/bundler-plugin-core'; +import type { Nitro, NitroConfig } from 'nitro/types'; +import type { SentryNitroOptions } from './config'; + +/** + * Registers a `compiled` hook to upload source maps after the build completes. + */ +export function setupSourceMaps(nitro: Nitro, options?: SentryNitroOptions, sentryEnabledSourcemaps?: boolean): void { + // The `compiled` hook fires on EVERY rebuild during `nitro dev` watch mode. + // nitro.options.dev is reliably set by the time module setup runs. + if (nitro.options.dev) { + return; + } + + // Nitro spawns a nested Nitro instance for prerendering with the user's `modules` re-installed. + // Uploading here would double-upload source maps and create a duplicate release. + if (nitro.options.preset === 'nitro-prerender') { + return; + } + + // Respect user's explicit disable + if (options?.sourcemaps?.disable === true) { + return; + } + + nitro.hooks.hook('compiled', async (_nitro: Nitro) => { + await handleSourceMapUpload(_nitro, options, sentryEnabledSourcemaps); + }); +} + +/** + * Handles the actual source map upload after the build completes. + */ +async function handleSourceMapUpload( + nitro: Nitro, + options?: SentryNitroOptions, + sentryEnabledSourcemaps?: boolean, +): Promise { + const outputDir = nitro.options.output.serverDir; + const pluginOptions = getPluginOptions(options, sentryEnabledSourcemaps); + + const sentryBuildPluginManager = createSentryBuildPluginManager(pluginOptions, { + buildTool: 'nitro', + loggerPrefix: '[@sentry/nitro]', + }); + + await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal(); + await sentryBuildPluginManager.createRelease(); + + await sentryBuildPluginManager.injectDebugIds([outputDir]); + + if (options?.sourcemaps?.disable !== 'disable-upload') { + await sentryBuildPluginManager.uploadSourcemaps([outputDir], { + // We don't prepare the artifacts because we injected debug IDs manually before + prepareArtifacts: false, + }); + await sentryBuildPluginManager.deleteArtifacts(); + } +} + +/** + * Normalizes the beginning of a path from e.g. ../../../ to ./ + */ +function normalizePath(path: string): string { + return path.replace(/^(\.\.\/)+/, './'); +} + +/** + * Builds the plugin options for `createSentryBuildPluginManager` from the Sentry Nitro options. + * + * Only exported for testing purposes. + */ +export function getPluginOptions( + options?: SentryNitroOptions, + sentryEnabledSourcemaps?: boolean, +): BundlerPluginOptions { + return { + org: options?.org ?? process.env.SENTRY_ORG, + project: options?.project ?? process.env.SENTRY_PROJECT, + authToken: options?.authToken ?? process.env.SENTRY_AUTH_TOKEN, + url: options?.sentryUrl ?? process.env.SENTRY_URL, + headers: options?.headers, + telemetry: options?.telemetry ?? true, + debug: options?.debug ?? false, + silent: options?.silent ?? false, + errorHandler: options?.errorHandler, + sourcemaps: { + disable: options?.sourcemaps?.disable, + assets: options?.sourcemaps?.assets, + ignore: options?.sourcemaps?.ignore, + filesToDeleteAfterUpload: + options?.sourcemaps?.filesToDeleteAfterUpload ?? (sentryEnabledSourcemaps ? ['**/*.map'] : undefined), + rewriteSources: options?.sourcemaps?.rewriteSources ?? ((source: string) => normalizePath(source)), + }, + release: options?.release, + bundleSizeOptimizations: options?.bundleSizeOptimizations, + _metaOptions: { + telemetry: { + metaFramework: 'nitro', + }, + }, + }; +} + +/* Source map configuration rules: + 1. User explicitly disabled source maps (sourcemap: false) + - Keep their setting, emit a warning that errors won't be unminified in Sentry + - We will not upload anything + 2. User enabled source map generation (true) + - Keep their setting (don't modify besides uploading) + 3. User did not set source maps (undefined) + - We enable source maps for Sentry + - Configure `filesToDeleteAfterUpload` to clean up .map files after upload +*/ +export function configureSourcemapSettings( + config: NitroConfig, + moduleOptions?: SentryNitroOptions, +): { sentryEnabledSourcemaps: boolean } { + const sourcemapUploadDisabled = moduleOptions?.sourcemaps?.disable === true; + if (sourcemapUploadDisabled) { + return { sentryEnabledSourcemaps: false }; + } + + if (config.sourcemap === false) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nitro] You have explicitly disabled source maps (`sourcemap: false`). Sentry will not upload source maps, and errors will not be unminified. To let Sentry handle source maps, remove the `sourcemap` option from your Nitro config, or use `sourcemaps: { disable: true }` in your Sentry options to silence this warning.', + ); + return { sentryEnabledSourcemaps: false }; + } + + let sentryEnabledSourcemaps = false; + if (config.sourcemap === true) { + if (moduleOptions?.debug) { + // eslint-disable-next-line no-console + console.log('[@sentry/nitro] Source maps are already enabled. Sentry will upload them for error unminification.'); + } + } else { + // User did not explicitly set sourcemap — enable it for Sentry + config.sourcemap = true; + sentryEnabledSourcemaps = true; + if (moduleOptions?.debug) { + // eslint-disable-next-line no-console + console.log( + '[@sentry/nitro] Enabled source map generation for Sentry. Source map files will be deleted after upload.', + ); + } + } + + // Nitro v3 has a `sourcemapMinify` plugin that destructively deletes `sourcesContent`, + // `x_google_ignoreList`, and clears `mappings` for any chunk containing `node_modules`. + // This makes sourcemaps unusable for Sentry. + config.experimental = config.experimental || {}; + config.experimental.sourcemapMinify = false; + + return { sentryEnabledSourcemaps }; +} diff --git a/packages/nitro/test/sourceMaps.test.ts b/packages/nitro/test/sourceMaps.test.ts new file mode 100644 index 000000000000..ffc0c0295979 --- /dev/null +++ b/packages/nitro/test/sourceMaps.test.ts @@ -0,0 +1,312 @@ +import type { NitroConfig } from 'nitro/types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SentryNitroOptions } from '../src/config'; +import { setupSentryNitroModule } from '../src/config'; +import { configureSourcemapSettings, getPluginOptions, setupSourceMaps } from '../src/sourceMaps'; + +vi.mock('../src/instruments/instrumentServer', () => ({ + instrumentServer: vi.fn(), +})); + +describe('getPluginOptions', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('returns default options when no options are provided', () => { + const options = getPluginOptions(undefined, true); + + expect(options).toEqual( + expect.objectContaining({ + telemetry: true, + debug: false, + silent: false, + sourcemaps: expect.objectContaining({ + filesToDeleteAfterUpload: ['**/*.map'], + rewriteSources: expect.any(Function), + }), + _metaOptions: expect.objectContaining({ + telemetry: expect.objectContaining({ + metaFramework: 'nitro', + }), + }), + }), + ); + expect(options.org).toBeUndefined(); + expect(options.project).toBeUndefined(); + expect(options.authToken).toBeUndefined(); + expect(options.url).toBeUndefined(); + }); + + it('does not default filesToDeleteAfterUpload when user enabled sourcemaps themselves', () => { + const options = getPluginOptions(undefined, false); + + expect(options.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined(); + }); + + it('respects user-provided filesToDeleteAfterUpload even when Sentry enabled sourcemaps', () => { + const options = getPluginOptions({ sourcemaps: { filesToDeleteAfterUpload: ['dist/**/*.map'] } }, true); + + expect(options.sourcemaps?.filesToDeleteAfterUpload).toEqual(['dist/**/*.map']); + }); + + it('uses environment variables as fallback', () => { + process.env.SENTRY_ORG = 'env-org'; + process.env.SENTRY_PROJECT = 'env-project'; + process.env.SENTRY_AUTH_TOKEN = 'env-token'; + process.env.SENTRY_URL = 'https://custom.sentry.io'; + + const options = getPluginOptions(); + + expect(options.org).toBe('env-org'); + expect(options.project).toBe('env-project'); + expect(options.authToken).toBe('env-token'); + expect(options.url).toBe('https://custom.sentry.io'); // sentryUrl maps to url + }); + + it('prefers direct options over environment variables', () => { + process.env.SENTRY_ORG = 'env-org'; + process.env.SENTRY_AUTH_TOKEN = 'env-token'; + process.env.SENTRY_URL = 'https://env.sentry.io'; + + const options = getPluginOptions({ + org: 'direct-org', + authToken: 'direct-token', + sentryUrl: 'https://direct.sentry.io', + }); + + expect(options.org).toBe('direct-org'); + expect(options.authToken).toBe('direct-token'); + expect(options.url).toBe('https://direct.sentry.io'); + }); + + it('passes through all user options', () => { + const sentryOptions: SentryNitroOptions = { + org: 'my-org', + project: 'my-project', + authToken: 'my-token', + sentryUrl: 'https://my-sentry.io', + headers: { 'X-Custom': 'header' }, + debug: true, + silent: true, + telemetry: false, + errorHandler: () => {}, + release: { name: 'v1.0.0' }, + sourcemaps: { + assets: ['dist/**'], + ignore: ['dist/test/**'], + filesToDeleteAfterUpload: ['dist/**/*.map'], + }, + }; + + const options = getPluginOptions(sentryOptions); + + expect(options.org).toBe('my-org'); + expect(options.project).toBe('my-project'); + expect(options.authToken).toBe('my-token'); + expect(options.url).toBe('https://my-sentry.io'); + expect(options.headers).toEqual({ 'X-Custom': 'header' }); + expect(options.debug).toBe(true); + expect(options.silent).toBe(true); + expect(options.telemetry).toBe(false); + expect(options.errorHandler).toBeDefined(); + expect(options.release).toEqual({ name: 'v1.0.0' }); + expect(options.sourcemaps?.assets).toEqual(['dist/**']); + expect(options.sourcemaps?.ignore).toEqual(['dist/test/**']); + expect(options.sourcemaps?.filesToDeleteAfterUpload).toEqual(['dist/**/*.map']); + }); + + it('normalizes source paths via rewriteSources', () => { + const options = getPluginOptions(); + const rewriteSources = options.sourcemaps?.rewriteSources; + + expect(rewriteSources?.('../../../src/index.ts', undefined)).toBe('./src/index.ts'); + expect(rewriteSources?.('../../lib/utils.ts', undefined)).toBe('./lib/utils.ts'); + expect(rewriteSources?.('./src/index.ts', undefined)).toBe('./src/index.ts'); + expect(rewriteSources?.('src/index.ts', undefined)).toBe('src/index.ts'); + }); + + it('uses user-provided rewriteSources when given', () => { + const customRewrite = (source: string) => `/custom/${source}`; + const options = getPluginOptions({ sourcemaps: { rewriteSources: customRewrite } }); + + expect(options.sourcemaps?.rewriteSources?.('../../../src/index.ts', undefined)).toBe( + '/custom/../../../src/index.ts', + ); + }); + + it('always sets metaFramework to nitro', () => { + const options = getPluginOptions(); + + expect(options._metaOptions?.telemetry?.metaFramework).toBe('nitro'); + }); + + it('passes through sourcemaps.disable', () => { + const options = getPluginOptions({ sourcemaps: { disable: 'disable-upload' } }); + + expect(options.sourcemaps?.disable).toBe('disable-upload'); + }); +}); + +describe('configureSourcemapSettings', () => { + it('enables sourcemap generation on the config', () => { + const config: NitroConfig = {}; + const result = configureSourcemapSettings(config); + + expect(config.sourcemap).toBe(true); + expect(result.sentryEnabledSourcemaps).toBe(true); + }); + + it('respects user explicitly disabling sourcemaps and warns', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config: NitroConfig = { sourcemap: false }; + const result = configureSourcemapSettings(config); + + expect(config.sourcemap).toBe(false); + expect(result.sentryEnabledSourcemaps).toBe(false); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('explicitly disabled source maps')); + warnSpy.mockRestore(); + }); + + it('does not modify experimental config when user disabled sourcemaps', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config: NitroConfig = { sourcemap: false }; + configureSourcemapSettings(config); + + expect(config.experimental).toBeUndefined(); + vi.restoreAllMocks(); + }); + + it('keeps sourcemap true when user already set it', () => { + const config: NitroConfig = { sourcemap: true }; + const result = configureSourcemapSettings(config); + + expect(config.sourcemap).toBe(true); + expect(result.sentryEnabledSourcemaps).toBe(false); + }); + + it('disables experimental sourcemapMinify', () => { + const config: NitroConfig = {}; + configureSourcemapSettings(config); + + expect(config.experimental?.sourcemapMinify).toBe(false); + }); + + it('preserves existing experimental config', () => { + const config: NitroConfig = { + experimental: { + sourcemapMinify: undefined, + }, + }; + configureSourcemapSettings(config); + + expect(config.experimental?.sourcemapMinify).toBe(false); + }); + + it('skips sourcemap config when sourcemaps.disable is true', () => { + const config: NitroConfig = { sourcemap: false }; + configureSourcemapSettings(config, { sourcemaps: { disable: true } }); + + expect(config.sourcemap).toBe(false); + }); + + it('still configures sourcemaps when sourcemaps.disable is disable-upload', () => { + const config: NitroConfig = {}; + configureSourcemapSettings(config, { sourcemaps: { disable: 'disable-upload' } }); + + expect(config.sourcemap).toBe(true); + }); +}); + +describe('setupSentryNitroModule', () => { + it('enables tracing', () => { + const config: NitroConfig = {}; + setupSentryNitroModule(config); + + expect(config.tracingChannel).toBe(true); + }); + + it('adds the sentry module', () => { + const config: NitroConfig = {}; + setupSentryNitroModule(config); + + expect(config.modules).toBeDefined(); + expect(config.modules?.length).toBe(1); + }); + + it('still adds module when sourcemaps are disabled', () => { + const config: NitroConfig = {}; + setupSentryNitroModule(config, { sourcemaps: { disable: true } }); + + expect(config.modules).toBeDefined(); + expect(config.modules?.length).toBe(1); + }); +}); + +describe('setupSourceMaps', () => { + it('does not register hook in dev mode', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: true, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro); + + expect(hookFn).not.toHaveBeenCalled(); + }); + + it('does not register hook when sourcemaps.disable is true', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro, { sourcemaps: { disable: true } }); + + expect(hookFn).not.toHaveBeenCalled(); + }); + + it('does not register hook in nitro-prerender preset', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, preset: 'nitro-prerender', output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro); + + expect(hookFn).not.toHaveBeenCalled(); + }); + + it('registers compiled hook in production mode', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro); + + expect(hookFn).toHaveBeenCalledWith('compiled', expect.any(Function)); + }); + + it('registers compiled hook with custom options', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro, { org: 'my-org', project: 'my-project' }); + + expect(hookFn).toHaveBeenCalledWith('compiled', expect.any(Function)); + }); +});