From 71832bc4f9f569ecd09dad5b19aecebaa0c3ff32 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 1/5] 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 e715264ba0e4..f9a1411c481b 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -50,6 +50,7 @@ "dependencies": { "@nuxt/kit": "^3.13.2", "@sentry/browser": "10.34.0", + "@sentry/bundler-plugin-core": "^4.6.1", "@sentry/cloudflare": "10.34.0", "@sentry/core": "10.34.0", "@sentry/node": "10.34.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', () => { From 8e54a0ef0aeb63d1eea52d9de68cf75ee9312996 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:43:28 +0100 Subject: [PATCH 2/5] change condition for uploading source maps --- packages/nuxt/src/vite/buildEndUpload.ts | 39 ++++++++++++------------ 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/nuxt/src/vite/buildEndUpload.ts b/packages/nuxt/src/vite/buildEndUpload.ts index 11b007f637c7..22441ef14ea0 100644 --- a/packages/nuxt/src/vite/buildEndUpload.ts +++ b/packages/nuxt/src/vite/buildEndUpload.ts @@ -21,21 +21,6 @@ export async function handleBuildDoneHook(sentryModuleOptions: SentryNuxtModuleO 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'); @@ -93,12 +78,26 @@ export async function handleBuildDoneHook(sentryModuleOptions: SentryNuxtModuleO await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal(); await sentryBuildPluginManager.createRelease(); - await sentryBuildPluginManager.injectDebugIds([outputDir]); - await sentryBuildPluginManager.uploadSourcemaps([outputDir], { - prepareArtifacts: false, - }); - await sentryBuildPluginManager.deleteArtifacts(); + // 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) { + await sentryBuildPluginManager.injectDebugIds([outputDir]); + await sentryBuildPluginManager.uploadSourcemaps([outputDir], { + // We don't want to prepare the artifacts because we injected debug ids manually before + prepareArtifacts: false, + }); + await sentryBuildPluginManager.deleteArtifacts(); + } // eslint-disable-next-line no-console debug && console.log('[Sentry] Successfully uploaded source maps.'); From b2b92a2018e4567f9ec73163d5b4b5488f26cc2f Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 20 Jan 2026 13:15:35 +0100 Subject: [PATCH 3/5] upgrade plugin version --- packages/nuxt/package.json | 6 +++--- yarn.lock | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index f9a1411c481b..9778ad6da483 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -50,13 +50,13 @@ "dependencies": { "@nuxt/kit": "^3.13.2", "@sentry/browser": "10.34.0", - "@sentry/bundler-plugin-core": "^4.6.1", + "@sentry/bundler-plugin-core": "^4.7.0", "@sentry/cloudflare": "10.34.0", "@sentry/core": "10.34.0", "@sentry/node": "10.34.0", "@sentry/node-core": "10.34.0", - "@sentry/rollup-plugin": "^4.6.2", - "@sentry/vite-plugin": "^4.6.2", + "@sentry/rollup-plugin": "^4.7.0", + "@sentry/vite-plugin": "^4.7.0", "@sentry/vue": "10.34.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 9ad1fe308e75..b059f3020863 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7074,6 +7074,11 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.6.2.tgz#b052ded0fc12088d4a5032a4022b65551717a631" integrity sha512-6VTjLJXtIHKwxMmThtZKwi1+hdklLNzlbYH98NhbH22/Vzb/c6BlSD2b5A0NGN9vFB807rD4x4tuP+Su7BxQXQ== +"@sentry/babel-plugin-component-annotate@4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.7.0.tgz#46841deb27275b7d235f2fbce42c5156ad6c7ae6" + integrity sha512-MkyajDiO17/GaHHFgOmh05ZtOwF5hmm9KRjVgn9PXHIdpz+TFM5mkp1dABmR6Y75TyNU98Z1aOwPOgyaR5etJw== + "@sentry/bundler-plugin-core@4.6.2", "@sentry/bundler-plugin-core@^4.6.2": version "4.6.2" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.6.2.tgz#65239308aba07de9dad48bf51d6589be5d492860" @@ -7088,6 +7093,20 @@ magic-string "0.30.8" unplugin "1.0.1" +"@sentry/bundler-plugin-core@4.7.0", "@sentry/bundler-plugin-core@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.7.0.tgz#00ab83727df34bbbe170f032fa948e6f21f43185" + integrity sha512-gFdEtiup/7qYhN3vp1v2f0WL9AG9OorWLtIpfSBYbWjtzklVNg1sizvNyZ8nEiwtnb25LzvvCUbOP1SyP6IodQ== + dependencies: + "@babel/core" "^7.18.5" + "@sentry/babel-plugin-component-annotate" "4.7.0" + "@sentry/cli" "^2.57.0" + dotenv "^16.3.1" + find-up "^5.0.0" + glob "^10.5.0" + magic-string "0.30.8" + unplugin "1.0.1" + "@sentry/cli-darwin@2.58.4": version "2.58.4" resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.58.4.tgz#5e3005c1f845acac243e8dcb23bef17337924768" @@ -7148,12 +7167,12 @@ "@sentry/cli-win32-i686" "2.58.4" "@sentry/cli-win32-x64" "2.58.4" -"@sentry/rollup-plugin@^4.6.2": - version "4.6.2" - resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.6.2.tgz#e03a835e52c4613b2c856ff3cb411f5683176c78" - integrity sha512-sTgh24KfV8iJhv1zESZi6atgJEgOPpwy1W/UqOdmKPyDW5FkX9Zp9lyMF+bbJDWBqhACUJBGsIbE3MAonLX3wQ== +"@sentry/rollup-plugin@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.7.0.tgz#92f9a5ed6b27de382ece4e973d9854099f62c1af" + integrity sha512-G928V05BLAIAIky42AN6zTDIKwfTYzWQ/OivSBTY3ZFJ2Db3lkB5UFHhtRsTjT9Hy/uZnQQjs397rixn51X3Vg== dependencies: - "@sentry/bundler-plugin-core" "4.6.2" + "@sentry/bundler-plugin-core" "4.7.0" unplugin "1.0.1" "@sentry/vite-plugin@^4.6.2": @@ -7164,6 +7183,14 @@ "@sentry/bundler-plugin-core" "4.6.2" unplugin "1.0.1" +"@sentry/vite-plugin@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-4.7.0.tgz#2d819ff0cc40d6a85503e86f834e358bad2cdde5" + integrity sha512-eQXDghOQLsYwnHutJo8TCzhG4gp0KLNq3h96iqFMhsbjnNnfYeCX1lIw1pJEh/az3cDwSyPI/KGkvf8hr0dZmQ== + dependencies: + "@sentry/bundler-plugin-core" "4.7.0" + unplugin "1.0.1" + "@sentry/webpack-plugin@^4.6.2": version "4.6.2" resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-4.6.2.tgz#371c00cc5ce7654e34c123accd471f55b6ce4ed4" From 52c164e8777fc42a2129325b4f267253f9e05429 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:47:46 +0100 Subject: [PATCH 4/5] feat(nuxt): Don't run source maps related code on Nuxt "prepare" --- packages/nuxt/src/module.ts | 4 +++ packages/nuxt/src/vite/sourceMaps.ts | 38 ++++++++++++++++++---------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 8db101f301cb..adaae85ea55c 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -136,6 +136,10 @@ export default defineNuxtModule({ } nuxt.hooks.hook('nitro:init', nitro => { + if (nuxt.options?._prepare) { + return; + } + if (serverConfigFile) { addMiddlewareInstrumentation(nitro); } diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index cd4e847d820d..e70ab37a4cbd 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -35,7 +35,7 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu let shouldDeleteFilesFallback = { client: true, server: true }; nuxt.hook('modules:done', () => { - if (sourceMapsEnabled && !nuxt.options.dev) { + if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) { // Changing this setting will propagate: // - for client to viteConfig.build.sourceMap // - for server to viteConfig.build.sourceMap and nitro.sourceMap @@ -49,17 +49,29 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu server: previousSourceMapSettings.server === 'unset', }; - if ( - isDebug && - !moduleOptions.sourcemaps?.filesToDeleteAfterUpload && - // eslint-disable-next-line deprecation/deprecation - !sourceMapsUploadOptions.sourcemaps?.filesToDeleteAfterUpload && - (shouldDeleteFilesFallback.client || shouldDeleteFilesFallback.server) - ) { - // eslint-disable-next-line no-console - console.log( - "[Sentry] As Sentry enabled `'hidden'` source maps, source maps will be automatically deleted after uploading them to Sentry.", - ); + if (isDebug && (shouldDeleteFilesFallback.client || shouldDeleteFilesFallback.server)) { + const enabledDeleteFallbacks = + shouldDeleteFilesFallback.client && shouldDeleteFilesFallback.server + ? 'client-side and server-side' + : shouldDeleteFilesFallback.server + ? 'server-side' + : 'client-side'; + + if ( + !moduleOptions.sourcemaps?.filesToDeleteAfterUpload && + // eslint-disable-next-line deprecation/deprecation + !sourceMapsUploadOptions.sourcemaps?.filesToDeleteAfterUpload + ) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] We enabled \`'hidden'\` source maps for your ${enabledDeleteFallbacks} build. Source map files will be automatically deleted after uploading them to Sentry.`, + ); + } else { + // eslint-disable-next-line no-console + console.log( + `[Sentry] We enabled \`'hidden'\` source maps for your ${enabledDeleteFallbacks} build. Source map files will be deleted according to your \`sourcemaps.filesToDeleteAfterUpload\` configuration. To use automatic deletion instead, leave \`filesToDeleteAfterUpload\` empty.`, + ); + } } } }); @@ -107,7 +119,7 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu }); nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => { - if (sourceMapsEnabled && !nitroConfig.dev) { + if (sourceMapsEnabled && !nitroConfig.dev && !nuxt.options?._prepare) { if (!nitroConfig.rollupConfig) { nitroConfig.rollupConfig = {}; } From 770cbe1e738b2fa212073eec1ef0274cbd15e4b6 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:55:50 +0100 Subject: [PATCH 5/5] use plugins only for release --- packages/nuxt/src/module.ts | 8 - packages/nuxt/src/vite/buildEndUpload.ts | 108 ----------- packages/nuxt/src/vite/buildEndUploadHook.ts | 130 +++++++++++++ packages/nuxt/src/vite/sourceMaps.ts | 77 ++++---- packages/nuxt/src/vite/utils.ts | 43 +++++ .../nuxt/test/vite/buildEndUploadHook.test.ts | 172 +++++++++++++++++ packages/nuxt/test/vite/sourceMaps.test.ts | 178 ++++++++++++++++-- packages/nuxt/test/vite/utils.test.ts | 74 ++++++++ 8 files changed, 626 insertions(+), 164 deletions(-) delete mode 100644 packages/nuxt/src/vite/buildEndUpload.ts create mode 100644 packages/nuxt/src/vite/buildEndUploadHook.ts create mode 100644 packages/nuxt/test/vite/buildEndUploadHook.test.ts diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index adaae85ea55c..11b9e5ce2ff4 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -10,7 +10,6 @@ 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'; @@ -214,12 +213,5 @@ 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 deleted file mode 100644 index 22441ef14ea0..000000000000 --- a/packages/nuxt/src/vite/buildEndUpload.ts +++ /dev/null @@ -1,108 +0,0 @@ -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.'); - } - - 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(); - - // 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) { - await sentryBuildPluginManager.injectDebugIds([outputDir]); - await sentryBuildPluginManager.uploadSourcemaps([outputDir], { - // We don't want to prepare the artifacts because we injected debug ids manually before - 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/buildEndUploadHook.ts b/packages/nuxt/src/vite/buildEndUploadHook.ts new file mode 100644 index 000000000000..21c19dfdc4cd --- /dev/null +++ b/packages/nuxt/src/vite/buildEndUploadHook.ts @@ -0,0 +1,130 @@ +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, + shouldDeleteFilesFallback?: { client: boolean; server: boolean }, +): Promise { + const debug = sentryModuleOptions.debug ?? false; + if (debug) { + // eslint-disable-next-line no-console + console.log('[Sentry] Nuxt build ended. Starting to upload build-time info to Sentry (release, source maps)...'); + } + + let createSentryBuildPluginManager: typeof createSentryBuildPluginManagerType | undefined; + try { + const bundlerPluginCore = await import('@sentry/bundler-plugin-core'); + createSentryBuildPluginManager = bundlerPluginCore.createSentryBuildPluginManager; + } catch (error) { + debug && + // eslint-disable-next-line no-console + console.warn('[Sentry] Could not load build manager package. Will not upload build-time info to Sentry.', 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, shouldDeleteFilesFallback, 'full'); + + // 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) { + 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(); + + // eslint-disable-next-line no-console + debug && console.log('[Sentry] Successfully uploaded release information.'); + + if (!sourceMapsEnabled) { + debug && + // eslint-disable-next-line no-console + console.log('[Sentry] Source map upload is disabled. Skipping debugID injection and source map upload steps.'); + } else { + await sentryBuildPluginManager.injectDebugIds([outputDir]); + // eslint-disable-next-line no-console + debug && console.log('[Sentry] Successfully injected Debug IDs.'); + + // todo: rewriteSources seems to not be applied + await sentryBuildPluginManager.uploadSourcemaps([outputDir], { + // We don't want to prepare the artifacts because we injected Debug IDs manually before + prepareArtifacts: false, + }); + // eslint-disable-next-line no-console + debug && console.log('[Sentry] Successfully uploaded source maps.'); + + await sentryBuildPluginManager.deleteArtifacts(); + debug && + // eslint-disable-next-line no-console + console.log( + `[Sentry] Successfully deleted specified source map artifacts (${sentryModuleOptions.sourcemaps?.filesToDeleteAfterUpload ? '' : "based on Sentry's default "}\`filesToDeleteAfterUpload: [${options.sourcemaps?.filesToDeleteAfterUpload}\`]).`, + ); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error("[Sentry] Error during Sentry's build-end hook: ", error); + } +} diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index e70ab37a4cbd..156f3d107919 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -3,6 +3,8 @@ import { sentryRollupPlugin, type SentryRollupPluginOptions } from '@sentry/roll import { sentryVitePlugin, type SentryVitePluginOptions } from '@sentry/vite-plugin'; import type { NitroConfig } from 'nitropack'; import type { SentryNuxtModuleOptions } from '../common/types'; +import { handleBuildDoneHook } from './buildEndUploadHook'; +import { shouldDisableSourceMapsUpload } from './utils'; /** * Whether the user enabled (true, 'hidden', 'inline') or disabled (false) source maps @@ -12,6 +14,15 @@ export type UserSourceMapSetting = 'enabled' | 'disabled' | 'unset' | undefined; /** A valid source map setting */ export type SourceMapSetting = boolean | 'hidden' | 'inline'; +/** + * Controls what functionality the bundler plugin provides. + * + * - `'release-injection-only'`: Plugin only injects release information. Source maps upload, + * debug ID injection, and file deletion are handled by the build-end hook. + * - `'full'`: Plugin handles everything including source maps upload and file deletion. + */ +export type PluginMode = 'release-injection-only' | 'full'; + /** * Setup source maps for Sentry inside the Nuxt module during build time (in Vite for Nuxt and Rollup for Nitro). */ @@ -98,22 +109,15 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu console.log("[Sentry] Cannot detect runtime (client/server) inside hook 'vite:extendConfig'."); } else { // eslint-disable-next-line no-console - console.log(`[Sentry] Adding Sentry Vite plugin to the ${runtime} runtime.`); + console.log(`[Sentry] Adding Sentry Vite plugin to the ${runtime} runtime for release injection.`); } } - // 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 + // Add Sentry Vite plugin for release injection only + // Source maps upload, debug ID injection, and artifact deletion are handled in the build:done hook viteConfig.plugins = viteConfig.plugins || []; viteConfig.plugins.push( - sentryVitePlugin( - getPluginOptions(moduleOptions, shouldDeleteFilesFallback, { - sourceMapsUpload: false, - releaseInjection: false, - }), - ), + sentryVitePlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback, 'release-injection-only')), ); } }); @@ -135,22 +139,24 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu if (isDebug) { // eslint-disable-next-line no-console - console.log('[Sentry] Adding Sentry Rollup plugin to the server runtime.'); + console.log('[Sentry] Adding Sentry Rollup plugin to the server runtime for release injection.'); } - // 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 + // Add Sentry Rollup plugin for release injection only + // Source maps upload, debug ID injection, and artifact deletion are handled in the build:done hook nitroConfig.rollupConfig.plugins.push( - sentryRollupPlugin( - getPluginOptions(moduleOptions, shouldDeleteFilesFallback, { - sourceMapsUpload: false, - releaseInjection: false, - }), - ), + sentryRollupPlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback, 'release-injection-only')), ); } }); + + // This ensures debug IDs are injected and source maps uploaded only once at the end of the build + nuxt.hook('close', async () => { + // `nuxt prepare` runs during package installation -> we don't need to upload anything here + if (!nuxt.options.dev && !nuxt.options._prepare) { + await handleBuildDoneHook(moduleOptions, nuxt, shouldDeleteFilesFallback); + } + }); } /** @@ -170,14 +176,14 @@ 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 }, + pluginMode: PluginMode = 'release-injection-only', ): SentryVitePluginOptions | SentryRollupPluginOptions { // eslint-disable-next-line deprecation/deprecation const sourceMapsUploadOptions = moduleOptions.sourceMapsUploadOptions || {}; - const shouldDeleteFilesAfterUpload = shouldDeleteFilesFallback?.client || shouldDeleteFilesFallback?.server; + const shouldDeleteFilesAfterUpload = + pluginMode === 'full' && (shouldDeleteFilesFallback?.client || shouldDeleteFilesFallback?.server); + const fallbackFilesToDelete = [ ...(shouldDeleteFilesFallback?.client ? ['.*/**/public/**/*.map'] : []), ...(shouldDeleteFilesFallback?.server @@ -226,9 +232,8 @@ 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, + // todo: release is injected twice sometimes (fix in bundler plugins) + inject: moduleOptions.release?.inject, // Support all release options from BuildTimeOptionsBase ...moduleOptions.release, ...moduleOptions?.unstable_sentryBundlerPluginOptions?.release, @@ -241,8 +246,7 @@ export function getPluginOptions( ...moduleOptions?.unstable_sentryBundlerPluginOptions, sourcemaps: { - // When false, the plugin won't upload (handled by buildEndUpload hook instead) - disable: enable?.sourceMapsUpload !== undefined ? !enable.sourceMapsUpload : moduleOptions.sourcemaps?.disable, + disable: shouldDisableSourceMapsUpload(moduleOptions, pluginMode), // 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/**/*'], @@ -250,11 +254,14 @@ export function getPluginOptions( assets: sourcemapsOptions.assets ?? deprecatedSourcemapsOptions.assets ?? undefined, // eslint-disable-next-line deprecation/deprecation ignore: sourcemapsOptions.ignore ?? deprecatedSourcemapsOptions.ignore ?? undefined, - filesToDeleteAfterUpload: filesToDeleteAfterUpload - ? filesToDeleteAfterUpload - : shouldDeleteFilesFallback?.server || shouldDeleteFilesFallback?.client - ? fallbackFilesToDelete - : undefined, + filesToDeleteAfterUpload: + pluginMode === 'release-injection-only' + ? undefined // Setting this to `undefined` to only delete files during buildEndUploadHook (not before, when vite/rollup plugins run) + : filesToDeleteAfterUpload + ? filesToDeleteAfterUpload + : shouldDeleteFilesFallback?.server || shouldDeleteFilesFallback?.client + ? fallbackFilesToDelete + : undefined, rewriteSources: (source: string) => normalizePath(source), ...moduleOptions?.unstable_sentryBundlerPluginOptions?.sourcemaps, }, diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index fc55ebf412c2..8920d9559136 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -2,6 +2,49 @@ import { consoleSandbox } from '@sentry/core'; import * as fs from 'fs'; import type { Nuxt } from 'nuxt/schema'; import * as path from 'path'; +import type { SentryNuxtModuleOptions } from '../common/types'; +import type { PluginMode } from './sourceMaps'; + +/** + * Determines whether source maps upload should be disabled in the bundler plugin. + * + * The logic follows a clear precedence: + * 1. Plugin mode check: If mode is `'release-injection-only'`, always disable upload + * (upload is handled by the build-end hook instead) + * 2. When mode is `'full'`, check user options with precedence: + * a. New option: `moduleOptions.sourcemaps.disable` (takes precedence) + * b. Deprecated option: `sourceMapsUploadOptions.enabled` (inverted, used as fallback) + * c. Default: `false` (upload enabled when mode is 'full' and no user option set) + * + * Only exported for testing. + */ +export function shouldDisableSourceMapsUpload( + moduleOptions: SentryNuxtModuleOptions, + pluginMode: PluginMode = 'release-injection-only', +): boolean { + // Step 1: If plugin mode is 'release-injection-only', always disable upload + if (pluginMode !== 'full') { + return true; + } + + // Step 2: Plugin mode is 'full' - check user options + // Note: disable can be boolean or 'disable-upload' - both truthy values mean disable upload + const disableOption = moduleOptions.sourcemaps?.disable; + if (disableOption !== undefined) { + // true or 'disable-upload' -> disable upload; false -> enable upload + return disableOption !== false; + } + + // Priority 2: Deprecated option + // eslint-disable-next-line deprecation/deprecation + const deprecatedEnabled = moduleOptions.sourceMapsUploadOptions?.enabled; + if (deprecatedEnabled !== undefined) { + return !deprecatedEnabled; + } + + // Default: upload enabled when plugin mode is 'full' + return false; +} /** * Find the default SDK init file for the given type (client or server). diff --git a/packages/nuxt/test/vite/buildEndUploadHook.test.ts b/packages/nuxt/test/vite/buildEndUploadHook.test.ts new file mode 100644 index 000000000000..3cfbfaf36a51 --- /dev/null +++ b/packages/nuxt/test/vite/buildEndUploadHook.test.ts @@ -0,0 +1,172 @@ +import type { Nuxt } from '@nuxt/schema'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SentryNuxtModuleOptions } from '../../src/common/types'; +import { handleBuildDoneHook } from '../../src/vite/buildEndUploadHook'; + +vi.mock('node:fs'); +vi.mock('../../src/vite/sourceMaps'); +vi.mock('@sentry/bundler-plugin-core'); + +describe('handleBuildDoneHook', () => { + let mockNuxt: Nuxt; + let mockSentryBuildPluginManager: any; + let mockCreateRelease: ReturnType; + let mockUploadSourcemaps: ReturnType; + let mockInjectDebugIds: ReturnType; + let mockDeleteArtifacts: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + + mockCreateRelease = vi.fn().mockResolvedValue(undefined); + mockUploadSourcemaps = vi.fn().mockResolvedValue(undefined); + mockInjectDebugIds = vi.fn().mockResolvedValue(undefined); + mockDeleteArtifacts = vi.fn().mockResolvedValue(undefined); + + mockSentryBuildPluginManager = { + createRelease: mockCreateRelease, + uploadSourcemaps: mockUploadSourcemaps, + injectDebugIds: mockInjectDebugIds, + deleteArtifacts: mockDeleteArtifacts, + telemetry: { + emitBundlerPluginExecutionSignal: vi.fn().mockResolvedValue(undefined), + }, + }; + + const { createSentryBuildPluginManager } = await import('@sentry/bundler-plugin-core'); + vi.mocked(createSentryBuildPluginManager).mockReturnValue(mockSentryBuildPluginManager); + + const { existsSync } = await import('node:fs'); + vi.mocked(existsSync).mockReturnValue(true); + + const { getPluginOptions } = await import('../../src/vite/sourceMaps'); + vi.mocked(getPluginOptions).mockReturnValue({}); + + mockNuxt = { + options: { + rootDir: '/test', + nitro: { output: { dir: '/test/.output' } }, + }, + } as any; + }); + + it('should create release even when source maps are disabled', async () => { + const options: SentryNuxtModuleOptions = { + sourcemaps: { disable: true }, + }; + + await handleBuildDoneHook(options, mockNuxt, undefined); + + expect(mockCreateRelease).toHaveBeenCalledTimes(1); + expect(mockInjectDebugIds).not.toHaveBeenCalled(); + expect(mockUploadSourcemaps).not.toHaveBeenCalled(); + }); + + it('should upload source maps when enabled', async () => { + const options: SentryNuxtModuleOptions = { + sourcemaps: { disable: false }, + }; + + await handleBuildDoneHook(options, mockNuxt, undefined); + + expect(mockCreateRelease).toHaveBeenCalledTimes(1); + expect(mockInjectDebugIds).toHaveBeenCalledWith(['/test/.output']); + expect(mockUploadSourcemaps).toHaveBeenCalledWith(['/test/.output'], { prepareArtifacts: false }); + expect(mockDeleteArtifacts).toHaveBeenCalledTimes(1); + }); + + it('should add node_modules to ignore patterns when source maps are enabled', async () => { + const { createSentryBuildPluginManager } = await import('@sentry/bundler-plugin-core'); + const { getPluginOptions } = await import('../../src/vite/sourceMaps'); + + vi.mocked(getPluginOptions).mockReturnValue({}); + + const options: SentryNuxtModuleOptions = { + sourcemaps: { disable: false }, + }; + + await handleBuildDoneHook(options, mockNuxt, undefined); + + const pluginOptions = vi.mocked(createSentryBuildPluginManager).mock.calls[0]?.[0]; + + expect(pluginOptions?.sourcemaps?.ignore).toEqual(['**/node_modules/**', '**/node_modules/**/*.map']); + }); + + it('should not add node_modules patterns when source maps are disabled', async () => { + const { createSentryBuildPluginManager } = await import('@sentry/bundler-plugin-core'); + const { getPluginOptions } = await import('../../src/vite/sourceMaps'); + + vi.mocked(getPluginOptions).mockReturnValue({}); + + const options: SentryNuxtModuleOptions = { + sourcemaps: { disable: true }, + }; + + await handleBuildDoneHook(options, mockNuxt, undefined); + + const pluginOptions = vi.mocked(createSentryBuildPluginManager).mock.calls[0]?.[0]; + + expect(pluginOptions?.sourcemaps?.ignore).toBeUndefined(); + }); + + it('should not log source map related messages when source maps are disabled', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const options: SentryNuxtModuleOptions = { + sourcemaps: { disable: true }, + debug: true, + }; + + await handleBuildDoneHook(options, mockNuxt, undefined); + + const allLogs = consoleLogSpy.mock.calls.map(call => call.join(' ')); + + const alwaysShownLogsStrings = [ + '[Sentry] Nuxt build ended. Starting to upload build-time info to Sentry (release, source maps)...', + '[Sentry] Source map upload is disabled. Skipping debugID injection and source map upload steps.', + ]; + + const loggedGeneralLogs = allLogs.filter(log => alwaysShownLogsStrings.includes(log)); + + const loggedSourceMapLogs = allLogs.filter(log => { + const lowerCaseLog = log.toLowerCase(); + + if (alwaysShownLogsStrings.map(log => log.toLowerCase()).includes(lowerCaseLog)) { + return false; + } + + return lowerCaseLog.includes('source map') || lowerCaseLog.includes('sourcemap'); + }); + + expect(loggedGeneralLogs).toHaveLength(2); + expect(loggedSourceMapLogs).toHaveLength(0); + + consoleLogSpy.mockRestore(); + }); + + it('should pass shouldDeleteFilesFallback to getPluginOptions', async () => { + const { getPluginOptions } = await import('../../src/vite/sourceMaps'); + + const options: SentryNuxtModuleOptions = { + sourcemaps: { disable: false }, + }; + + const shouldDeleteFilesFallback = { client: true, server: false }; + + await handleBuildDoneHook(options, mockNuxt, shouldDeleteFilesFallback); + + expect(getPluginOptions).toHaveBeenCalledWith(options, shouldDeleteFilesFallback); + }); + + it('should pass undefined shouldDeleteFilesFallback when not provided', async () => { + const { getPluginOptions } = await import('../../src/vite/sourceMaps'); + + const options: SentryNuxtModuleOptions = { + sourcemaps: { disable: false }, + }; + + await handleBuildDoneHook(options, mockNuxt, undefined); + + expect(getPluginOptions).toHaveBeenCalledWith(options, undefined); + }); +}); diff --git a/packages/nuxt/test/vite/sourceMaps.test.ts b/packages/nuxt/test/vite/sourceMaps.test.ts index b36ec058c29f..e1b5c0e5dbf1 100644 --- a/packages/nuxt/test/vite/sourceMaps.test.ts +++ b/packages/nuxt/test/vite/sourceMaps.test.ts @@ -1,7 +1,7 @@ import type { Nuxt } from '@nuxt/schema'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SentryNuxtModuleOptions } from '../../src/common/types'; -import type { SourceMapSetting } from '../../src/vite/sourceMaps'; +import type { PluginMode, SourceMapSetting } from '../../src/vite/sourceMaps'; import { changeNuxtSourceMapSettings, getPluginOptions, @@ -85,7 +85,8 @@ describe('getPluginOptions', () => { }, debug: true, }; - const options = getPluginOptions(customOptions, { client: true, server: false }); + // Pass 'full' mode to test filesToDeleteAfterUpload handling + const options = getPluginOptions(customOptions, { client: true, server: false }, 'full'); expect(options).toEqual( expect.objectContaining({ org: 'custom-org', @@ -151,7 +152,8 @@ describe('getPluginOptions', () => { }, }; - const result = getPluginOptions(options); + // Pass 'full' mode to test filesToDeleteAfterUpload handling (only in build-end hook) + const result = getPluginOptions(options, undefined, 'full'); expect(result).toMatchObject({ org: 'new-org', @@ -318,24 +320,174 @@ describe('getPluginOptions', () => { expectedFilesToDelete: undefined, }, ])( - 'sets filesToDeleteAfterUpload correctly when $name', + 'sets filesToDeleteAfterUpload correctly when $name (with pluginMode=full)', ({ clientFallback, serverFallback, customOptions, expectedFilesToDelete }) => { - const options = getPluginOptions(customOptions as SentryNuxtModuleOptions, { - client: clientFallback, - server: serverFallback, - }); + // These tests verify filesToDeleteAfterUpload behavior when pluginMode is 'full' + const options = getPluginOptions( + customOptions as SentryNuxtModuleOptions, + { client: clientFallback, server: serverFallback }, + 'full', + ); expect(options?.sourcemaps?.filesToDeleteAfterUpload).toEqual(expectedFilesToDelete); }, ); - it('enables source map upload when sourceMapsUpload and releaseInjection is true', () => { - const customOptions: SentryNuxtModuleOptions = { sourcemaps: { disable: false } }; + describe('filesToDeleteAfterUpload with pluginMode', () => { + it.each([ + // pluginMode='release-injection-only' - always undefined (files deleted only in buildEndUploadHook) + { + pluginMode: 'release-injection-only' as PluginMode, + clientFallback: true, + serverFallback: true, + customOptions: {}, + expected: undefined, + desc: 'release-injection-only + fallbacks set -> undefined (deferred to buildEndUploadHook)', + }, + { + pluginMode: 'release-injection-only' as PluginMode, + clientFallback: false, + serverFallback: false, + customOptions: { sourcemaps: { filesToDeleteAfterUpload: ['custom/**/*.map'] } }, + expected: undefined, + desc: 'release-injection-only + custom filesToDeleteAfterUpload -> undefined (deferred to buildEndUploadHook)', + }, + { + pluginMode: undefined as PluginMode | undefined, + clientFallback: true, + serverFallback: true, + customOptions: {}, + expected: undefined, + desc: 'default pluginMode + fallbacks set -> undefined (deferred to buildEndUploadHook)', + }, + + // pluginMode='full' - respects options + { + pluginMode: 'full' as PluginMode, + clientFallback: true, + serverFallback: true, + customOptions: {}, + expected: [ + '.*/**/public/**/*.map', + '.*/**/server/**/*.map', + '.*/**/output/**/*.map', + '.*/**/function/**/*.map', + ], + desc: 'full + both fallbacks -> uses fallback paths', + }, + { + pluginMode: 'full' as PluginMode, + clientFallback: true, + serverFallback: false, + customOptions: {}, + expected: ['.*/**/public/**/*.map'], + desc: 'full + client fallback only -> uses client fallback path', + }, + { + pluginMode: 'full' as PluginMode, + clientFallback: false, + serverFallback: true, + customOptions: {}, + expected: ['.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'], + desc: 'full + server fallback only -> uses server fallback paths', + }, + { + pluginMode: 'full' as PluginMode, + clientFallback: false, + serverFallback: false, + customOptions: { sourcemaps: { filesToDeleteAfterUpload: ['custom/**/*.map'] } }, + expected: ['custom/**/*.map'], + desc: 'full + custom filesToDeleteAfterUpload -> uses custom paths', + }, + { + pluginMode: 'full' as PluginMode, + clientFallback: false, + serverFallback: false, + customOptions: {}, + expected: undefined, + desc: 'full + no fallbacks + no custom -> undefined', + }, + ])('$desc', ({ pluginMode, clientFallback, serverFallback, customOptions, expected }) => { + const options = getPluginOptions( + customOptions as SentryNuxtModuleOptions, + { client: clientFallback, server: serverFallback }, + pluginMode, + ); + + expect(options?.sourcemaps?.filesToDeleteAfterUpload).toEqual(expected); + }); + }); + + describe('getPluginOptions sourcemaps.disable integration', () => { + it.each([ + // pluginMode='release-injection-only' - always disabled + { + pluginMode: 'release-injection-only' as PluginMode, + moduleDisable: true, + expected: true, + desc: 'release-injection-only + user disable=true -> disabled', + }, + { + pluginMode: 'release-injection-only' as PluginMode, + moduleDisable: false, + expected: true, + desc: 'release-injection-only + user disable=false -> disabled (pluginMode wins)', + }, + { + pluginMode: 'release-injection-only' as PluginMode, + moduleDisable: undefined, + expected: true, + desc: 'release-injection-only + no user option -> disabled', + }, - const options = getPluginOptions(customOptions, undefined, { sourceMapsUpload: true, releaseInjection: true }); + // pluginMode='full' - respects user options + { + pluginMode: 'full' as PluginMode, + moduleDisable: true, + expected: true, + desc: 'full + user disable=true -> disabled', + }, + { + pluginMode: 'full' as PluginMode, + moduleDisable: false, + expected: false, + desc: 'full + user disable=false -> enabled', + }, + { + pluginMode: 'full' as PluginMode, + moduleDisable: undefined, + expected: false, + desc: 'full + no user option -> enabled (default)', + }, - expect(options.sourcemaps?.disable).toBe(false); - expect(options.release?.inject).toBe(true); + // Default pluginMode (undefined -> defaults to 'release-injection-only') + { + pluginMode: undefined as PluginMode | undefined, + moduleDisable: true, + expected: true, + desc: 'default pluginMode + user disable=true -> disabled', + }, + { + pluginMode: undefined as PluginMode | undefined, + moduleDisable: false, + expected: true, + desc: 'default pluginMode + user disable=false -> disabled (pluginMode wins)', + }, + { + pluginMode: undefined as PluginMode | undefined, + moduleDisable: undefined, + expected: true, + desc: 'default pluginMode + no user option -> disabled', + }, + ])('$desc', ({ moduleDisable, pluginMode, expected }) => { + const options = getPluginOptions( + { sourcemaps: moduleDisable !== undefined ? { disable: moduleDisable } : {} }, + undefined, + pluginMode, + ); + + expect(options.sourcemaps?.disable).toBe(expected); + }); }); }); diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index 1b256987828b..43fc709140cf 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -13,6 +13,7 @@ import { SENTRY_REEXPORTED_FUNCTIONS, SENTRY_WRAPPED_ENTRY, SENTRY_WRAPPED_FUNCTIONS, + shouldDisableSourceMapsUpload, } from '../../src/vite/utils'; vi.mock('fs'); @@ -428,3 +429,76 @@ describe('addOTelCommonJSImportAlias', () => { expect(nuxtMock.options.alias).toBeUndefined(); }); }); + +describe('shouldDisableSourceMapsUpload', () => { + describe("pluginMode='release-injection-only'", () => { + it.each([ + { moduleDisable: true, desc: 'with user option disable=true' }, + { moduleDisable: false, desc: 'with user option disable=false' }, + { moduleDisable: undefined, desc: 'with no user option' }, + ])('always returns true (disabled) $desc', ({ moduleDisable }) => { + const result = shouldDisableSourceMapsUpload( + { sourcemaps: moduleDisable !== undefined ? { disable: moduleDisable } : {} }, + 'release-injection-only', + ); + expect(result).toBe(true); + }); + + it('ignores deprecated enabled option when pluginMode is release-injection-only', () => { + const result = shouldDisableSourceMapsUpload( + { sourceMapsUploadOptions: { enabled: true } }, + 'release-injection-only', + ); + expect(result).toBe(true); + }); + + it('defaults to disabled when pluginMode param is undefined', () => { + const result = shouldDisableSourceMapsUpload({}, undefined); + expect(result).toBe(true); + }); + }); + + describe("pluginMode='full'", () => { + describe('with new option (sourcemaps.disable)', () => { + it('returns true when disable=true', () => { + const result = shouldDisableSourceMapsUpload({ sourcemaps: { disable: true } }, 'full'); + expect(result).toBe(true); + }); + + it('returns false when disable=false', () => { + const result = shouldDisableSourceMapsUpload({ sourcemaps: { disable: false } }, 'full'); + expect(result).toBe(false); + }); + + it('new option takes precedence over deprecated option', () => { + // New option says enable (disable=false), deprecated says disable (enabled=false) + // New option should win + const result = shouldDisableSourceMapsUpload( + { sourcemaps: { disable: false }, sourceMapsUploadOptions: { enabled: false } }, + 'full', + ); + expect(result).toBe(false); + }); + }); + + // todo(v11): these tests can be removed when deprecated option is removed + describe('with deprecated option (sourceMapsUploadOptions.enabled)', () => { + it('returns true when enabled=false (inverted)', () => { + const result = shouldDisableSourceMapsUpload({ sourceMapsUploadOptions: { enabled: false } }, 'full'); + expect(result).toBe(true); + }); + + it('returns false when enabled=true (inverted)', () => { + const result = shouldDisableSourceMapsUpload({ sourceMapsUploadOptions: { enabled: true } }, 'full'); + expect(result).toBe(false); + }); + }); + + describe('with no user options', () => { + it('returns false (upload enabled by default)', () => { + const result = shouldDisableSourceMapsUpload({}, 'full'); + expect(result).toBe(false); + }); + }); + }); +});