diff --git a/packages/nitro/package.json b/packages/nitro/package.json index 69a0019402f3..4ddd3714c736 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -38,6 +38,7 @@ "nitro": ">=3.0.1-alpha.1" }, "dependencies": { + "@sentry/bundler-plugin-core": "^4.9.0", "@sentry/core": "10.38.0", "@sentry/node": "10.38.0", "otel-tracing-channel": "^0.2.0" diff --git a/packages/nitro/rollup.npm.config.mjs b/packages/nitro/rollup.npm.config.mjs index 35b018b2d99c..1c7c4259db7e 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/, 'otel-tracing-channel', /^h3/, /^srvx/], + external: [/^nitro/, 'otel-tracing-channel', /^h3/, /^srvx/, '@sentry/bundler-plugin-core'], }, }), ), diff --git a/packages/nitro/src/config.ts b/packages/nitro/src/config.ts index 9b22023735e3..b90e314c82b6 100644 --- a/packages/nitro/src/config.ts +++ b/packages/nitro/src/config.ts @@ -1,18 +1,31 @@ +import type { Options as SentryBundlerPluginOptions } from '@sentry/bundler-plugin-core'; import type { NitroConfig } from 'nitro/types'; import { createNitroModule } from './module'; +import { configureSourcemapSettings } from './sourceMaps'; -type SentryNitroOptions = { - // TODO: Add options -}; +export type SentryNitroOptions = Pick< + SentryBundlerPluginOptions, + | 'org' + | 'project' + | 'authToken' + | 'url' + | 'headers' + | 'debug' + | 'silent' + | 'errorHandler' + | 'telemetry' + | 'disable' + | 'sourcemaps' + | 'release' + | 'bundleSizeOptimizations' + | '_metaOptions' +>; /** * 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,7 +33,7 @@ export function withSentryConfig(config: NitroConfig, moduleOptions?: SentryNitr */ export function setupSentryNitroModule( config: NitroConfig, - _moduleOptions?: SentryNitroOptions, + moduleOptions?: SentryNitroOptions, _serverConfigFile?: string, ): NitroConfig { // @ts-expect-error Nitro tracing config is not out yet @@ -29,8 +42,10 @@ export function setupSentryNitroModule( config.tracing = true; } + configureSourcemapSettings(config, moduleOptions); + config.modules = config.modules || []; - config.modules.push(createNitroModule()); + config.modules.push(createNitroModule(moduleOptions)); return config; } diff --git a/packages/nitro/src/module.ts b/packages/nitro/src/module.ts index 1f0955301813..6cda58554a25 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): NitroModule { return { name: 'sentry', setup: nitro => { instrumentServer(nitro); + setupSourceMaps(nitro, sentryOptions); }, }; } diff --git a/packages/nitro/src/sourceMaps.ts b/packages/nitro/src/sourceMaps.ts new file mode 100644 index 000000000000..a24b17926a0c --- /dev/null +++ b/packages/nitro/src/sourceMaps.ts @@ -0,0 +1,121 @@ +import type { Options } from '@sentry/bundler-plugin-core'; +import { createSentryBuildPluginManager } from '@sentry/bundler-plugin-core'; +import { debug } from '@sentry/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): 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; + } + + // Respect user's explicit disable + if (options?.sourcemaps?.disable === true || options?.disable === true) { + return; + } + + nitro.hooks.hook('compiled', async (_nitro: Nitro) => { + await handleSourceMapUpload(_nitro, options); + }); +} + +/** + * Handles the actual source map upload after the build completes. + */ +async function handleSourceMapUpload(nitro: Nitro, options?: SentryNitroOptions): Promise { + const outputDir = nitro.options.output.serverDir; + const pluginOptions = getPluginOptions(options); + + const sentryBuildPluginManager = createSentryBuildPluginManager(pluginOptions, { + buildTool: 'nitro', + loggerPrefix: '[@sentry/nitro]', + }); + + await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal(); + await sentryBuildPluginManager.createRelease(); + + if (options?.sourcemaps?.disable !== 'disable-upload') { + await sentryBuildPluginManager.injectDebugIds([outputDir]); + 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): Options { + 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?.url ?? 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 ?? ['**/*.map'], + rewriteSources: (source: string) => normalizePath(source), + }, + release: options?.release, + bundleSizeOptimizations: options?.bundleSizeOptimizations, + _metaOptions: { + telemetry: { + metaFramework: 'nitro', + }, + ...options?._metaOptions, + }, + }; +} + +/** + * Configures the Nitro config to enable source map generation. + */ +export function configureSourcemapSettings(config: NitroConfig, moduleOptions?: SentryNitroOptions): void { + const sourcemapUploadDisabled = moduleOptions?.sourcemaps?.disable === true || moduleOptions?.disable === true; + if (sourcemapUploadDisabled) { + return; + } + + if (config.sourcemap === false) { + debug.warn( + '[Sentry] You have explicitly disabled source maps (`sourcemap: false`). Sentry is overriding this to `true` so that errors can be un-minified in Sentry. To disable Sentry source map uploads entirely, use `sourcemaps: { disable: true }` in your Sentry options instead.', + ); + } + + config.sourcemap = true; + + // 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. + // FIXME: Not sure about this one, it works either way? + config.experimental = config.experimental || {}; + config.experimental.sourcemapMinify = false; + + if (moduleOptions?.debug) { + debug.log('[Sentry] Enabled source map generation and configured build settings for Sentry source map uploads.'); + } +} diff --git a/packages/nitro/test/sourceMaps.test.ts b/packages/nitro/test/sourceMaps.test.ts new file mode 100644 index 000000000000..1117dd2b2208 --- /dev/null +++ b/packages/nitro/test/sourceMaps.test.ts @@ -0,0 +1,279 @@ +import { debug } from '@sentry/core'; +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(); + + 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('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'); + }); + + it('prefers direct options over environment variables', () => { + process.env.SENTRY_ORG = 'env-org'; + process.env.SENTRY_AUTH_TOKEN = 'env-token'; + + const options = getPluginOptions({ + org: 'direct-org', + authToken: 'direct-token', + }); + + expect(options.org).toBe('direct-org'); + expect(options.authToken).toBe('direct-token'); + }); + + it('passes through all user options', () => { + const sentryOptions: SentryNitroOptions = { + org: 'my-org', + project: 'my-project', + authToken: 'my-token', + url: '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('always sets metaFramework to nitro', () => { + const options = getPluginOptions({ _metaOptions: { loggerPrefixOverride: '[custom]' } }); + + expect(options._metaOptions?.telemetry?.metaFramework).toBe('nitro'); + expect(options._metaOptions?.loggerPrefixOverride).toBe('[custom]'); + }); + + 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 = {}; + configureSourcemapSettings(config); + + expect(config.sourcemap).toBe(true); + }); + + it('forces sourcemap to true even when user set it to false', () => { + const debugSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + const config: NitroConfig = { sourcemap: false }; + configureSourcemapSettings(config); + + expect(config.sourcemap).toBe(true); + expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('overriding this to `true`')); + debugSpy.mockRestore(); + }); + + it('keeps sourcemap true when user already set it', () => { + const config: NitroConfig = { sourcemap: true }; + configureSourcemapSettings(config); + + expect(config.sourcemap).toBe(true); + }); + + 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('skips sourcemap config when disable is true', () => { + const config: NitroConfig = { sourcemap: false }; + configureSourcemapSettings(config, { disable: true }); + + expect(config.sourcemap).toBe(false); + }); +}); + +describe('setupSentryNitroModule', () => { + it('enables tracing', () => { + const config: NitroConfig = {}; + setupSentryNitroModule(config); + + // @ts-expect-error -- Nitro tracing config is not out yet + expect(config.tracing).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 when disable is true', () => { + const hookFn = vi.fn(); + const nitro = { + options: { dev: false, output: { serverDir: '/output/server' } }, + hooks: { hook: hookFn }, + } as any; + + setupSourceMaps(nitro, { disable: true }); + + 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)); + }); +});