Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/nuxt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions packages/nuxt/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -209,5 +210,12 @@ export default defineNuxtModule<ModuleOptions>({
}
}
});

// 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);
}
});
},
});
109 changes: 109 additions & 0 deletions packages/nuxt/src/vite/buildEndUpload.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
27 changes: 24 additions & 3 deletions packages/nuxt/src/vite/sourceMaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
),
);
}
});

Expand All @@ -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,
}),
),
);
}
});
Expand All @@ -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 || {};
Expand Down Expand Up @@ -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,
Expand All @@ -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/**/*'],
Expand Down
9 changes: 9 additions & 0 deletions packages/nuxt/test/vite/sourceMaps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading