Skip to content

Commit d79f44c

Browse files
authored
fix(nuxt): Do not inject debug IDs into the SSR build (#25080)
Nuxt server source maps can still resolve to wrong lines (more info in #18519). Nitro bundles Nuxt's SSR build again. The Sentry Vite plugin also ran on the SSR build, so its debug IDs got into Nitro's output. Many server files used such an ID instead of Nitro's own, and both builds uploaded different maps under that ID. The Vite plugin now runs only on the client build. Nitro's plugin uploads the server maps, which already point to the original sources. The e2e test now checks that each shipped file has one debug ID, resolving to exactly one map generated for that file. Fixes #18974
1 parent c8805d8 commit d79f44c

6 files changed

Lines changed: 160 additions & 41 deletions

File tree

‎dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@ import * as assert from 'assert/strict';
22
import * as fs from 'fs';
33
import * as path from 'path';
44
import {
5+
findDebugIdsWithConflictingSourcemaps,
6+
findFilesWithForeignSourcemaps,
7+
findFilesWithMultipleDebugIds,
58
findInjectedDebugIds,
69
findSourceMapFiles,
710
findSourceMappingUrlComments,
811
getArtifactBundles,
912
getAssembleRequests,
1013
getChunkUploadPosts,
1114
getDebugIdPairs,
15+
getSourcemapSources,
1216
getSourcemaps,
1317
loadMockServerResults,
1418
} from '@sentry-internal/test-utils';
@@ -173,6 +177,36 @@ for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER
173177
assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`);
174178

175179
console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s), all uploaded`);
180+
181+
// Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own.
182+
// Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519
183+
const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir });
184+
assert.deepEqual(filesWithMultipleDebugIds, [], `Expected at most one debug ID per file in ${outputDir}`);
185+
186+
// A server file can borrow the debug ID of its SSR build chunk. Then the upload check above passes on
187+
// Vite's map even if Nitro uploaded nothing.
188+
const filesWithForeignSourcemaps = findFilesWithForeignSourcemaps({ outputDir }, sourcemaps);
189+
assert.deepEqual(
190+
filesWithForeignSourcemaps,
191+
[],
192+
`Expected the source map uploaded for each file in ${outputDir} to be generated for that file`,
193+
);
194+
}
195+
196+
// If both builds upload a map under a borrowed debug ID, Sentry can resolve with the wrong one.
197+
const conflictingDebugIds = findDebugIdsWithConflictingSourcemaps(sourcemaps);
198+
assert.deepEqual(conflictingDebugIds, [], 'Expected every uploaded debug ID to resolve to exactly one source map');
199+
200+
if (!isStaticBuild) {
201+
// Nitro bundles Vite's SSR output, so server frames reach `.vue` files only through the maps of that build.
202+
const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT }));
203+
const serverSources = sourcemaps
204+
.filter(map => map.debugId && serverDebugIds.has(map.debugId))
205+
.flatMap(map => getSourcemapSources(map.sourcemap));
206+
assert.ok(
207+
serverSources.some(source => source.endsWith('app/pages/index.vue')),
208+
`Expected the server source maps to resolve to app/pages/index.vue, got: ${serverSources.join(', ')}`,
209+
);
176210
}
177211
console.log('');
178212

‎dev-packages/test-utils/src/build-output.ts‎

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as fs from 'fs';
22
import * as path from 'path';
3+
import type { SourcemapEntry } from './sourcemap-upload-utils';
34

45
export interface OutputScanOptions {
56
/** Directory holding the emitted bundles, e.g. `<app>/.output/public`. */
@@ -18,6 +19,12 @@ const SOURCE_MAPPING_URL_PATTERN = /[#@]\s*sourceMappingURL\s*=\s*(?!data:)(\S+)
1819
/** The `sentry-dbid-<uuid>` identifier the bundler plugin injects alongside `_sentryDebugIds`. */
1920
const INJECTED_DEBUG_ID_PATTERN = /sentry-dbid-([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})/gi;
2021

22+
/** Distinct lowercased debug IDs in `file`, in order of appearance. */
23+
function readDebugIds(file: string): string[] {
24+
const matches = fs.readFileSync(file, 'utf8').matchAll(INJECTED_DEBUG_ID_PATTERN);
25+
return [...new Set([...matches].map(match => (match[1] as string).toLowerCase()))];
26+
}
27+
2128
function* walkFiles(outputDir: string, extensions?: string[]): Generator<string> {
2229
if (!fs.existsSync(outputDir)) {
2330
throw new Error(`[build-output] Output directory does not exist: ${outputDir}`);
@@ -147,12 +154,59 @@ export function findInjectedDebugIds({ outputDir, extensions = JS_EXTENSIONS }:
147154
const debugIds = new Set<string>();
148155

149156
for (const file of walkFiles(outputDir, extensions)) {
150-
const contents = fs.readFileSync(file, 'utf8');
157+
readDebugIds(file).forEach(debugId => debugIds.add(debugId));
158+
}
159+
160+
return [...debugIds];
161+
}
162+
163+
/**
164+
* Returns `<file> → <debug IDs>` for each file with more than one debug ID.
165+
* The uploader reads the first ID, but the SDK can report any of them.
166+
*/
167+
export function findFilesWithMultipleDebugIds({
168+
outputDir,
169+
buildDir = process.cwd(),
170+
extensions = JS_EXTENSIONS,
171+
}: OutputScanOptions): string[] {
172+
const offenders: string[] = [];
173+
174+
for (const file of walkFiles(outputDir, extensions)) {
175+
const debugIds = readDebugIds(file);
151176

152-
for (const match of contents.matchAll(INJECTED_DEBUG_ID_PATTERN)) {
153-
debugIds.add((match[1] as string).toLowerCase());
177+
if (debugIds.length > 1) {
178+
offenders.push(`${path.relative(buildDir, file)} → ${debugIds.join(', ')}`);
154179
}
155180
}
156181

157-
return [...debugIds];
182+
return offenders;
183+
}
184+
185+
/**
186+
* Returns `<file> → <map files>` for each file where no map uploaded under its first debug ID names it in `file`.
187+
* Skips files without a debug ID or without an uploaded map that has `file`.
188+
*/
189+
export function findFilesWithForeignSourcemaps(
190+
{ outputDir, buildDir = process.cwd(), extensions = JS_EXTENSIONS }: OutputScanOptions,
191+
uploadedSourcemaps: SourcemapEntry[],
192+
): string[] {
193+
const offenders: string[] = [];
194+
195+
for (const file of walkFiles(outputDir, extensions)) {
196+
const [debugId] = readDebugIds(file);
197+
if (!debugId) {
198+
continue;
199+
}
200+
201+
const mapFiles = uploadedSourcemaps
202+
.filter(entry => entry.debugId === debugId)
203+
.map(entry => entry.sourcemap.file)
204+
.filter(mapFile => mapFile !== undefined);
205+
206+
if (mapFiles.length > 0 && !mapFiles.includes(path.basename(file))) {
207+
offenders.push(`${path.relative(buildDir, file)} → ${mapFiles.join(', ')}`);
208+
}
209+
}
210+
211+
return offenders;
158212
}

‎dev-packages/test-utils/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export {
2323
findSourceMapFiles,
2424
findSourceMappingUrlComments,
2525
findInjectedDebugIds,
26+
findFilesWithMultipleDebugIds,
27+
findFilesWithForeignSourcemaps,
2628
bundleReferencesModule,
2729
} from './build-output';
2830
export type { OutputScanOptions } from './build-output';

‎dev-packages/test-utils/src/sourcemap-upload-utils.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export interface ArtifactBundleData {
5858
export interface ParsedSourcemap {
5959
[key: string]: unknown;
6060
version?: number;
61+
file?: string;
6162
sources?: string[];
6263
/** Absent when the generator drops all sources (Rollup's `sourcemapExcludeSources`), null per dropped entry. */
6364
sourcesContent?: (string | null)[];
@@ -68,6 +69,8 @@ export interface ParsedSourcemap {
6869
export interface SourcemapEntry {
6970
url: string;
7071
bundleDir: string;
72+
/** Lowercased `debug-id` manifest header. */
73+
debugId?: string;
7174
sourcemap: ParsedSourcemap;
7275
}
7376

@@ -168,6 +171,28 @@ export function getDebugIdPairs(bundles: ArtifactBundleData[]): DebugIdPair[] {
168171
return pairs;
169172
}
170173

174+
/**
175+
* Returns `<debug ID> → <map files>` for each debug ID uploaded with maps that differ in `sources` or `mappings`.
176+
* Byte-identical chunks share a debug ID, so the `file` field is not compared.
177+
*/
178+
export function findDebugIdsWithConflictingSourcemaps(sourcemaps: SourcemapEntry[]): string[] {
179+
const mapsByDebugId = new Map<string, Map<string, string>>();
180+
181+
for (const { url, debugId, sourcemap } of sourcemaps) {
182+
if (!debugId) continue;
183+
184+
const mappingKey = JSON.stringify([getSourcemapSources(sourcemap), sourcemap.mappings]);
185+
186+
const maps = mapsByDebugId.get(debugId) ?? new Map<string, string>();
187+
maps.set(mappingKey, sourcemap.file ?? url);
188+
mapsByDebugId.set(debugId, maps);
189+
}
190+
191+
return [...mapsByDebugId]
192+
.filter(([, maps]) => maps.size > 1)
193+
.map(([debugId, maps]) => `${debugId} → ${[...maps.values()].join(', ')}`);
194+
}
195+
171196
/**
172197
* Read and parse all sourcemap files from artifact bundles.
173198
*/
@@ -195,7 +220,8 @@ export function getSourcemaps(bundles: ArtifactBundleData[]): SourcemapEntry[] {
195220
continue;
196221
}
197222

198-
sourcemaps.push({ url: entry.url, bundleDir, sourcemap });
223+
const debugId = getManifestHeader(entry.headers, 'debug-id')?.toLowerCase();
224+
sourcemaps.push({ url: entry.url, bundleDir, debugId, sourcemap });
199225
}
200226
}
201227

‎packages/nuxt/src/vite/sourceMaps.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export type SourceMapSetting = boolean | 'hidden' | 'inline';
2222
export function setupSourceMaps(
2323
moduleOptions: SentryNuxtModuleOptions,
2424
nuxt: Nuxt,
25-
addVitePlugin: (plugin: Plugin[], options?: { dev?: boolean; build?: boolean }) => void,
25+
addVitePlugin: (plugin: Plugin[], options?: { dev?: boolean; build?: boolean; server?: boolean }) => void,
2626
): void {
2727
// Warn here rather than in `getPluginOptions`, which runs once per bundler (Vite and Nitro's
2828
// Rollup) and would emit the same warning twice.
@@ -73,13 +73,16 @@ export function setupSourceMaps(
7373
});
7474

7575
if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) {
76+
// Also on the SSR build: Nitro's source maps reach the original files only through that build's maps.
77+
addVitePlugin([validateSourceMapsOptionsPlugin({ nuxt, moduleOptions, sourceMapsEnabled })], {
78+
dev: false,
79+
build: true,
80+
});
81+
7682
addVitePlugin(
77-
[
78-
validateSourceMapsOptionsPlugin({ nuxt, moduleOptions, sourceMapsEnabled }),
79-
// Vite plugin is added on the client and server side (plugin runs for both builds)
80-
...sentryVitePlugin(withoutSourceMapDeletion(getPluginOptions(moduleOptions, shouldDeleteFilesFallback))),
81-
],
82-
{ dev: false, build: true }, // Only add source map plugin during build
83+
sentryVitePlugin(withoutSourceMapDeletion(getPluginOptions(moduleOptions, shouldDeleteFilesFallback))),
84+
// Nitro bundles the SSR output again, so debug IDs from the SSR build would point to the wrong maps.
85+
{ dev: false, build: true, server: false },
8386
);
8487
}
8588

@@ -162,7 +165,7 @@ export function getPluginOptions(
162165
sourcemaps: {
163166
disable: moduleOptions.sourcemaps?.disable,
164167
// The server/client files are in different places depending on the nitro preset (e.g. '.output/server' or '.netlify/functions-internal/server')
165-
// 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).
168+
// We cannot determine automatically how the build folder looks like (depends on the preset), so each plugin uploads its own build output (the vitePlugin for the Nuxt client and the rollupPlugin for Nitro).
166169
// 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/**/*'],
167170
assets: sourcemapsOptions.assets ?? undefined,
168171
ignore: sourcemapsOptions.ignore ?? undefined,

‎packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts‎

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,16 @@ vi.mock('@sentry/bundler-plugins/vite', () => ({ sentryVitePlugin: mockSentryVit
2222
vi.mock('@sentry/bundler-plugins/rollup', () => ({ sentryRollupPlugin: mockSentryRollupPlugin }));
2323

2424
function createMockAddVitePlugin() {
25-
let capturedPlugins: Plugin[] | null = null;
25+
const capturedPlugins: Plugin[] = [];
2626

2727
const mockAddVitePlugin = vi.fn((plugins: Plugin[]) => {
28-
capturedPlugins = plugins;
28+
capturedPlugins.push(...plugins);
2929
});
3030

3131
return {
3232
mockAddVitePlugin,
33-
getCapturedPlugin: () => capturedPlugins?.[0] ?? null,
34-
getCapturedPlugins: () => capturedPlugins,
33+
getValidationPlugin: () =>
34+
capturedPlugins.find(plugin => plugin.name === 'sentry-nuxt-source-map-validation') ?? null,
3535
};
3636
}
3737

@@ -121,15 +121,30 @@ describe('setupSourceMaps hooks', () => {
121121
});
122122

123123
describe('vite plugin registration', () => {
124-
it('calls `addVitePlugin` when setupSourceMaps is called', async () => {
124+
it('adds the source map validation plugin to the client and server builds', async () => {
125125
const mockNuxt = createMockNuxt({ _prepare: false, dev: false });
126-
const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin();
126+
const { mockAddVitePlugin } = createMockAddVitePlugin();
127127

128128
setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin);
129129

130-
const plugin = getCapturedPlugin();
131-
expect(plugin).not.toBeNull();
132-
expect(plugin?.name).toBe('sentry-nuxt-source-map-validation');
130+
expect(mockAddVitePlugin).toHaveBeenCalledWith(
131+
[expect.objectContaining({ name: 'sentry-nuxt-source-map-validation' })],
132+
{ dev: false, build: true },
133+
);
134+
});
135+
136+
it('adds the Sentry Vite plugin to the client build only', async () => {
137+
const mockNuxt = createMockNuxt({ _prepare: false, dev: false });
138+
const { mockAddVitePlugin } = createMockAddVitePlugin();
139+
140+
setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin);
141+
142+
expect(mockAddVitePlugin).toHaveBeenCalledTimes(2);
143+
expect(mockAddVitePlugin).toHaveBeenCalledWith([{ name: 'sentry-vite-plugin' }], {
144+
dev: false,
145+
build: true,
146+
server: false,
147+
});
133148
});
134149

135150
it.each([
@@ -160,21 +175,6 @@ describe('setupSourceMaps hooks', () => {
160175

161176
expect(mockAddVitePlugin).not.toHaveBeenCalled();
162177
});
163-
164-
it.each([
165-
{ label: 'server (SSR) build', buildConfig: { build: { ssr: true }, plugins: [] } },
166-
{ label: 'client build', buildConfig: { build: { ssr: false }, plugins: [] } },
167-
])('adds sentry vite plugin to vite config for $label in production', async () => {
168-
const mockNuxt = createMockNuxt({ _prepare: false, dev: false });
169-
const { mockAddVitePlugin, getCapturedPlugins } = createMockAddVitePlugin();
170-
171-
setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin);
172-
173-
const plugins = getCapturedPlugins();
174-
expect(plugins).not.toBeNull();
175-
expect(plugins?.length).toBeGreaterThan(0);
176-
expect(mockSentryVitePlugin).toHaveBeenCalled();
177-
});
178178
});
179179

180180
describe('sentry vite plugin calls', () => {
@@ -372,12 +372,12 @@ describe('setupSourceMaps hooks', () => {
372372
describe('debug logging', () => {
373373
it('logs a [Sentry] message in production mode', async () => {
374374
const mockNuxt = createMockNuxt({ _prepare: false, dev: false });
375-
const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin();
375+
const { mockAddVitePlugin, getValidationPlugin } = createMockAddVitePlugin();
376376

377377
setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin);
378378
await mockNuxt.triggerHook('modules:done');
379379

380-
const plugin = getCapturedPlugin();
380+
const plugin = getValidationPlugin();
381381
if (plugin && typeof plugin.config === 'function') {
382382
(plugin.config as (config: UserConfig, env: ConfigEnv) => void)(
383383
{ build: { ssr: false }, plugins: [] } as UserConfig,
@@ -398,12 +398,12 @@ describe('setupSourceMaps hooks', () => {
398398

399399
it('does not log a [Sentry] messages in prepare mode', async () => {
400400
const mockNuxt = createMockNuxt({ _prepare: true });
401-
const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin();
401+
const { mockAddVitePlugin, getValidationPlugin } = createMockAddVitePlugin();
402402

403403
setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin);
404404
await mockNuxt.triggerHook('modules:done');
405405

406-
const plugin = getCapturedPlugin();
406+
const plugin = getValidationPlugin();
407407
if (plugin && typeof plugin.config === 'function') {
408408
(plugin.config as (config: UserConfig, env: ConfigEnv) => void)({ build: {}, plugins: [] } as UserConfig, {
409409
mode: 'production',

0 commit comments

Comments
 (0)