From 9988bc97065acfc82b764a496c3791fe5ebe9d21 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 18 Dec 2025 12:07:39 +0100 Subject: [PATCH] feat(nuxt): Inject debug IDs only once at build end --- packages/nuxt/package.json | 1 + packages/nuxt/src/module.ts | 8 ++ packages/nuxt/src/vite/buildEndUpload.ts | 109 +++++++++++++++++++++ packages/nuxt/src/vite/sourceMaps.ts | 27 ++++- packages/nuxt/test/vite/sourceMaps.test.ts | 9 ++ 5 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 packages/nuxt/src/vite/buildEndUpload.ts diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index e9dda6a1afcc..ad7d7c9035cb 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -50,6 +50,7 @@ "dependencies": { "@nuxt/kit": "^3.13.2", "@sentry/browser": "10.31.0", + "@sentry/bundler-plugin-core": "^4.6.1", "@sentry/cloudflare": "10.31.0", "@sentry/core": "10.31.0", "@sentry/node": "10.31.0", diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 3656eac56e63..8db101f301cb 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -10,6 +10,7 @@ import { consoleSandbox } from '@sentry/core'; import * as path from 'path'; import type { SentryNuxtModuleOptions } from './common/types'; import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig'; +import { handleBuildDoneHook } from './vite/buildEndUpload'; import { addDatabaseInstrumentation } from './vite/databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; import { setupSourceMaps } from './vite/sourceMaps'; @@ -209,5 +210,12 @@ export default defineNuxtModule({ } } }); + + // This ensures debug IDs are injected and source maps uploaded only once + nuxt.hook('close', async () => { + if (!nuxt.options.dev && (clientConfigFile || serverConfigFile)) { + await handleBuildDoneHook(moduleOptions, nuxt); + } + }); }, }); diff --git a/packages/nuxt/src/vite/buildEndUpload.ts b/packages/nuxt/src/vite/buildEndUpload.ts new file mode 100644 index 000000000000..11b007f637c7 --- /dev/null +++ b/packages/nuxt/src/vite/buildEndUpload.ts @@ -0,0 +1,109 @@ +import { existsSync } from 'node:fs'; +import type { Nuxt } from '@nuxt/schema'; +import type { createSentryBuildPluginManager as createSentryBuildPluginManagerType } from '@sentry/bundler-plugin-core'; +import * as path from 'path'; +import type { SentryNuxtModuleOptions } from '../common/types'; +import { getPluginOptions } from './sourceMaps'; + +/** + * A build-end hook that handles Sentry release creation and source map uploads. + * It creates a new Sentry release if configured, uploads source maps to Sentry, + * and optionally deletes the source map files after upload. + * + * This runs after both Vite (Nuxt) and Rollup (Nitro) builds complete, ensuring + * debug IDs are injected and source maps uploaded only once. + */ +// eslint-disable-next-line complexity +export async function handleBuildDoneHook(sentryModuleOptions: SentryNuxtModuleOptions, nuxt: Nuxt): Promise { + const debug = sentryModuleOptions.debug ?? false; + if (debug) { + // eslint-disable-next-line no-console + console.log('[Sentry] Running build:done hook to upload source maps.'); + } + + // eslint-disable-next-line deprecation/deprecation + const sourceMapsUploadOptions = sentryModuleOptions.sourceMapsUploadOptions || {}; + + const sourceMapsEnabled = + sentryModuleOptions.sourcemaps?.disable === true + ? false + : sentryModuleOptions.sourcemaps?.disable === false + ? true + : // eslint-disable-next-line deprecation/deprecation + (sourceMapsUploadOptions.enabled ?? true); + + if (!sourceMapsEnabled) { + return; + } + + let createSentryBuildPluginManager: typeof createSentryBuildPluginManagerType | undefined; + try { + const bundlerPluginCore = await import('@sentry/bundler-plugin-core'); + createSentryBuildPluginManager = bundlerPluginCore.createSentryBuildPluginManager; + } catch (error) { + // eslint-disable-next-line no-console + debug && console.warn('[Sentry] Could not load build manager package. Will not upload source maps.', error); + return; + } + + if (!createSentryBuildPluginManager) { + // eslint-disable-next-line no-console + debug && console.warn('[Sentry] Could not find createSentryBuildPluginManager in bundler plugin core.'); + return; + } + + const outputDir = nuxt.options.nitro?.output?.dir || path.join(nuxt.options.rootDir, '.output'); + + if (!existsSync(outputDir)) { + // eslint-disable-next-line no-console + debug && console.warn(`[Sentry] Output directory does not exist yet: ${outputDir}. Skipping source map upload.`); + return; + } + + const options = getPluginOptions(sentryModuleOptions, undefined); + + const existingIgnore = options.sourcemaps?.ignore || []; + const ignorePatterns = Array.isArray(existingIgnore) ? existingIgnore : [existingIgnore]; + + // node_modules source maps are ignored + const nodeModulesPatterns = ['**/node_modules/**', '**/node_modules/**/*.map']; + const hasNodeModulesIgnore = ignorePatterns.some( + pattern => typeof pattern === 'string' && pattern.includes('node_modules'), + ); + + if (!hasNodeModulesIgnore) { + ignorePatterns.push(...nodeModulesPatterns); + } + + options.sourcemaps = { + ...options.sourcemaps, + ignore: ignorePatterns.length > 0 ? ignorePatterns : undefined, + }; + + if (debug && ignorePatterns.length > 0) { + // eslint-disable-next-line no-console + console.log(`[Sentry] Excluding patterns from source map upload: ${ignorePatterns.join(', ')}`); + } + + try { + const sentryBuildPluginManager = createSentryBuildPluginManager(options, { + buildTool: 'nuxt', + loggerPrefix: '[Sentry Nuxt Module]', + }); + + await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal(); + await sentryBuildPluginManager.createRelease(); + await sentryBuildPluginManager.injectDebugIds([outputDir]); + await sentryBuildPluginManager.uploadSourcemaps([outputDir], { + prepareArtifacts: false, + }); + + await sentryBuildPluginManager.deleteArtifacts(); + + // eslint-disable-next-line no-console + debug && console.log('[Sentry] Successfully uploaded source maps.'); + } catch (error) { + // eslint-disable-next-line no-console + console.error('[Sentry] Error during source map upload:', error); + } +} diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index dff4f74df2f7..cd4e847d820d 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -93,8 +93,16 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu // Add Sentry plugin // Vite plugin is added on the client and server side (hook runs twice) // Nuxt client source map is 'false' by default. Warning about this will be shown already in an earlier step, and it's also documented that `nuxt.sourcemap.client` needs to be enabled. + // Note: We disable uploads in the plugin - uploads are handled in the build:done hook to prevent duplicate processing viteConfig.plugins = viteConfig.plugins || []; - viteConfig.plugins.push(sentryVitePlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback))); + viteConfig.plugins.push( + sentryVitePlugin( + getPluginOptions(moduleOptions, shouldDeleteFilesFallback, { + sourceMapsUpload: false, + releaseInjection: false, + }), + ), + ); } }); @@ -120,8 +128,14 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu // Add Sentry plugin // Runs only on server-side (Nitro) + // Note: We disable uploads in the plugin - uploads are handled in the build:done hook to prevent duplicate processing nitroConfig.rollupConfig.plugins.push( - sentryRollupPlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback)), + sentryRollupPlugin( + getPluginOptions(moduleOptions, shouldDeleteFilesFallback, { + sourceMapsUpload: false, + releaseInjection: false, + }), + ), ); } }); @@ -144,6 +158,9 @@ function normalizePath(path: string): string { export function getPluginOptions( moduleOptions: SentryNuxtModuleOptions, shouldDeleteFilesFallback?: { client: boolean; server: boolean }, + // TODO: test that those are always true by default + // TODO: test that it does what we expect when this is false (|| vs ??) + enable = { sourceMapsUpload: true, releaseInjection: true }, ): SentryVitePluginOptions | SentryRollupPluginOptions { // eslint-disable-next-line deprecation/deprecation const sourceMapsUploadOptions = moduleOptions.sourceMapsUploadOptions || {}; @@ -197,6 +214,9 @@ export function getPluginOptions( release: { // eslint-disable-next-line deprecation/deprecation name: moduleOptions.release?.name ?? sourceMapsUploadOptions.release?.name, + // could handled by buildEndUpload hook + // TODO: problem is, that releases are sometimes injected twice (vite & rollup) but the CLI currently doesn't support release injection + inject: enable?.releaseInjection ?? moduleOptions.release?.inject, // Support all release options from BuildTimeOptionsBase ...moduleOptions.release, ...moduleOptions?.unstable_sentryBundlerPluginOptions?.release, @@ -209,7 +229,8 @@ export function getPluginOptions( ...moduleOptions?.unstable_sentryBundlerPluginOptions, sourcemaps: { - disable: moduleOptions.sourcemaps?.disable, + // When false, the plugin won't upload (handled by buildEndUpload hook instead) + disable: enable?.sourceMapsUpload !== undefined ? !enable.sourceMapsUpload : moduleOptions.sourcemaps?.disable, // The server/client files are in different places depending on the nitro preset (e.g. '.output/server' or '.netlify/functions-internal/server') // We cannot determine automatically how the build folder looks like (depends on the preset), so we have to accept that source maps are uploaded multiple times (with the vitePlugin for Nuxt and the rollupPlugin for Nitro). // If we could know where the server/client assets are located, we could do something like this (based on the Nitro preset): isNitro ? ['./.output/server/**/*'] : ['./.output/public/**/*'], diff --git a/packages/nuxt/test/vite/sourceMaps.test.ts b/packages/nuxt/test/vite/sourceMaps.test.ts index e4ae498639b0..b36ec058c29f 100644 --- a/packages/nuxt/test/vite/sourceMaps.test.ts +++ b/packages/nuxt/test/vite/sourceMaps.test.ts @@ -328,6 +328,15 @@ describe('getPluginOptions', () => { expect(options?.sourcemaps?.filesToDeleteAfterUpload).toEqual(expectedFilesToDelete); }, ); + + it('enables source map upload when sourceMapsUpload and releaseInjection is true', () => { + const customOptions: SentryNuxtModuleOptions = { sourcemaps: { disable: false } }; + + const options = getPluginOptions(customOptions, undefined, { sourceMapsUpload: true, releaseInjection: true }); + + expect(options.sourcemaps?.disable).toBe(false); + expect(options.release?.inject).toBe(true); + }); }); describe('validate sourcemap settings', () => {