Skip to content

Commit 19ecfaa

Browse files
committed
feat(nuxt): Inject debug IDs only once at build end
1 parent cc93c68 commit 19ecfaa

File tree

5 files changed

+151
-3
lines changed

5 files changed

+151
-3
lines changed

packages/nuxt/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"dependencies": {
5151
"@nuxt/kit": "^3.13.2",
5252
"@sentry/browser": "10.32.1",
53+
"@sentry/bundler-plugin-core": "^4.6.1",
5354
"@sentry/cloudflare": "10.32.1",
5455
"@sentry/core": "10.32.1",
5556
"@sentry/node": "10.32.1",

packages/nuxt/src/module.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { consoleSandbox } from '@sentry/core';
1010
import * as path from 'path';
1111
import type { SentryNuxtModuleOptions } from './common/types';
1212
import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig';
13+
import { handleBuildDoneHook } from './vite/buildEndUpload';
1314
import { addDatabaseInstrumentation } from './vite/databaseConfig';
1415
import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig';
1516
import { setupSourceMaps } from './vite/sourceMaps';
@@ -209,5 +210,12 @@ export default defineNuxtModule<ModuleOptions>({
209210
}
210211
}
211212
});
213+
214+
// This ensures debug IDs are injected and source maps uploaded only once
215+
nuxt.hook('close', async () => {
216+
if (!nuxt.options.dev && (clientConfigFile || serverConfigFile)) {
217+
await handleBuildDoneHook(moduleOptions, nuxt);
218+
}
219+
});
212220
},
213221
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { existsSync } from 'node:fs';
2+
import type { Nuxt } from '@nuxt/schema';
3+
import type { createSentryBuildPluginManager as createSentryBuildPluginManagerType } from '@sentry/bundler-plugin-core';
4+
import * as path from 'path';
5+
import type { SentryNuxtModuleOptions } from '../common/types';
6+
import { getPluginOptions } from './sourceMaps';
7+
8+
/**
9+
* A build-end hook that handles Sentry release creation and source map uploads.
10+
* It creates a new Sentry release if configured, uploads source maps to Sentry,
11+
* and optionally deletes the source map files after upload.
12+
*
13+
* This runs after both Vite (Nuxt) and Rollup (Nitro) builds complete, ensuring
14+
* debug IDs are injected and source maps uploaded only once.
15+
*/
16+
// eslint-disable-next-line complexity
17+
export async function handleBuildDoneHook(sentryModuleOptions: SentryNuxtModuleOptions, nuxt: Nuxt): Promise<void> {
18+
const debug = sentryModuleOptions.debug ?? false;
19+
if (debug) {
20+
// eslint-disable-next-line no-console
21+
console.log('[Sentry] Running build:done hook to upload source maps.');
22+
}
23+
24+
// eslint-disable-next-line deprecation/deprecation
25+
const sourceMapsUploadOptions = sentryModuleOptions.sourceMapsUploadOptions || {};
26+
27+
const sourceMapsEnabled =
28+
sentryModuleOptions.sourcemaps?.disable === true
29+
? false
30+
: sentryModuleOptions.sourcemaps?.disable === false
31+
? true
32+
: // eslint-disable-next-line deprecation/deprecation
33+
(sourceMapsUploadOptions.enabled ?? true);
34+
35+
if (!sourceMapsEnabled) {
36+
return;
37+
}
38+
39+
let createSentryBuildPluginManager: typeof createSentryBuildPluginManagerType | undefined;
40+
try {
41+
const bundlerPluginCore = await import('@sentry/bundler-plugin-core');
42+
createSentryBuildPluginManager = bundlerPluginCore.createSentryBuildPluginManager;
43+
} catch (error) {
44+
// eslint-disable-next-line no-console
45+
debug && console.warn('[Sentry] Could not load build manager package. Will not upload source maps.', error);
46+
return;
47+
}
48+
49+
if (!createSentryBuildPluginManager) {
50+
// eslint-disable-next-line no-console
51+
debug && console.warn('[Sentry] Could not find createSentryBuildPluginManager in bundler plugin core.');
52+
return;
53+
}
54+
55+
const outputDir = nuxt.options.nitro?.output?.dir || path.join(nuxt.options.rootDir, '.output');
56+
57+
if (!existsSync(outputDir)) {
58+
// eslint-disable-next-line no-console
59+
debug && console.warn(`[Sentry] Output directory does not exist yet: ${outputDir}. Skipping source map upload.`);
60+
return;
61+
}
62+
63+
const options = getPluginOptions(sentryModuleOptions, undefined);
64+
65+
const existingIgnore = options.sourcemaps?.ignore || [];
66+
const ignorePatterns = Array.isArray(existingIgnore) ? existingIgnore : [existingIgnore];
67+
68+
// node_modules source maps are ignored
69+
const nodeModulesPatterns = ['**/node_modules/**', '**/node_modules/**/*.map'];
70+
const hasNodeModulesIgnore = ignorePatterns.some(
71+
pattern => typeof pattern === 'string' && pattern.includes('node_modules'),
72+
);
73+
74+
if (!hasNodeModulesIgnore) {
75+
ignorePatterns.push(...nodeModulesPatterns);
76+
}
77+
78+
options.sourcemaps = {
79+
...options.sourcemaps,
80+
ignore: ignorePatterns.length > 0 ? ignorePatterns : undefined,
81+
};
82+
83+
if (debug && ignorePatterns.length > 0) {
84+
// eslint-disable-next-line no-console
85+
console.log(`[Sentry] Excluding patterns from source map upload: ${ignorePatterns.join(', ')}`);
86+
}
87+
88+
try {
89+
const sentryBuildPluginManager = createSentryBuildPluginManager(options, {
90+
buildTool: 'nuxt',
91+
loggerPrefix: '[Sentry Nuxt Module]',
92+
});
93+
94+
await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal();
95+
await sentryBuildPluginManager.createRelease();
96+
await sentryBuildPluginManager.injectDebugIds([outputDir]);
97+
await sentryBuildPluginManager.uploadSourcemaps([outputDir], {
98+
prepareArtifacts: false,
99+
});
100+
101+
await sentryBuildPluginManager.deleteArtifacts();
102+
103+
// eslint-disable-next-line no-console
104+
debug && console.log('[Sentry] Successfully uploaded source maps.');
105+
} catch (error) {
106+
// eslint-disable-next-line no-console
107+
console.error('[Sentry] Error during source map upload:', error);
108+
}
109+
}

packages/nuxt/src/vite/sourceMaps.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,16 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu
9393
// Add Sentry plugin
9494
// Vite plugin is added on the client and server side (hook runs twice)
9595
// 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.
96+
// Note: We disable uploads in the plugin - uploads are handled in the build:done hook to prevent duplicate processing
9697
viteConfig.plugins = viteConfig.plugins || [];
97-
viteConfig.plugins.push(sentryVitePlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback)));
98+
viteConfig.plugins.push(
99+
sentryVitePlugin(
100+
getPluginOptions(moduleOptions, shouldDeleteFilesFallback, {
101+
sourceMapsUpload: false,
102+
releaseInjection: false,
103+
}),
104+
),
105+
);
98106
}
99107
});
100108

@@ -120,8 +128,14 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu
120128

121129
// Add Sentry plugin
122130
// Runs only on server-side (Nitro)
131+
// Note: We disable uploads in the plugin - uploads are handled in the build:done hook to prevent duplicate processing
123132
nitroConfig.rollupConfig.plugins.push(
124-
sentryRollupPlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback)),
133+
sentryRollupPlugin(
134+
getPluginOptions(moduleOptions, shouldDeleteFilesFallback, {
135+
sourceMapsUpload: false,
136+
releaseInjection: false,
137+
}),
138+
),
125139
);
126140
}
127141
});
@@ -144,6 +158,9 @@ function normalizePath(path: string): string {
144158
export function getPluginOptions(
145159
moduleOptions: SentryNuxtModuleOptions,
146160
shouldDeleteFilesFallback?: { client: boolean; server: boolean },
161+
// TODO: test that those are always true by default
162+
// TODO: test that it does what we expect when this is false (|| vs ??)
163+
enable = { sourceMapsUpload: true, releaseInjection: true },
147164
): SentryVitePluginOptions | SentryRollupPluginOptions {
148165
// eslint-disable-next-line deprecation/deprecation
149166
const sourceMapsUploadOptions = moduleOptions.sourceMapsUploadOptions || {};
@@ -197,6 +214,9 @@ export function getPluginOptions(
197214
release: {
198215
// eslint-disable-next-line deprecation/deprecation
199216
name: moduleOptions.release?.name ?? sourceMapsUploadOptions.release?.name,
217+
// could handled by buildEndUpload hook
218+
// TODO: problem is, that releases are sometimes injected twice (vite & rollup) but the CLI currently doesn't support release injection
219+
inject: enable?.releaseInjection ?? moduleOptions.release?.inject,
200220
// Support all release options from BuildTimeOptionsBase
201221
...moduleOptions.release,
202222
...moduleOptions?.unstable_sentryBundlerPluginOptions?.release,
@@ -209,7 +229,8 @@ export function getPluginOptions(
209229
...moduleOptions?.unstable_sentryBundlerPluginOptions,
210230

211231
sourcemaps: {
212-
disable: moduleOptions.sourcemaps?.disable,
232+
// When false, the plugin won't upload (handled by buildEndUpload hook instead)
233+
disable: enable?.sourceMapsUpload !== undefined ? !enable.sourceMapsUpload : moduleOptions.sourcemaps?.disable,
213234
// The server/client files are in different places depending on the nitro preset (e.g. '.output/server' or '.netlify/functions-internal/server')
214235
// 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).
215236
// 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/**/*'],

packages/nuxt/test/vite/sourceMaps.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,15 @@ describe('getPluginOptions', () => {
328328
expect(options?.sourcemaps?.filesToDeleteAfterUpload).toEqual(expectedFilesToDelete);
329329
},
330330
);
331+
332+
it('enables source map upload when sourceMapsUpload and releaseInjection is true', () => {
333+
const customOptions: SentryNuxtModuleOptions = { sourcemaps: { disable: false } };
334+
335+
const options = getPluginOptions(customOptions, undefined, { sourceMapsUpload: true, releaseInjection: true });
336+
337+
expect(options.sourcemaps?.disable).toBe(false);
338+
expect(options.release?.inject).toBe(true);
339+
});
331340
});
332341

333342
describe('validate sourcemap settings', () => {

0 commit comments

Comments
 (0)