Skip to content

Commit cbc9da9

Browse files
authored
fix(nextjs): Use batched devserver symbolication endpoint (#15335)
1 parent 86f76e4 commit cbc9da9

File tree

2 files changed

+161
-63
lines changed

2 files changed

+161
-63
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ A comprehensive migration guide outlining all changes for all the frameworks can
148148
- fix(core): Fork scope if custom scope is passed to `startSpan` (#14900)
149149
- fix(core): Only fall back to `sendDefaultPii` for IP collection in `requestDataIntegration` (#15125)
150150
- fix(nextjs): Flush with `waitUntil` in `captureRequestError` (#15146)
151+
- fix(nextjs): Use batched devserver symbolication endpoint (#15335)
151152
- fix(node): Don't leak `__span` property into breadcrumbs (#14798)
152153
- fix(node): Ensure `httpIntegration` propagates traces (#15233)
153154
- fix(node): Fix sample rand propagation for negative sampling decisions (#15045)

packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts

Lines changed: 160 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import type { Event, EventHint } from '@sentry/core';
2+
import { parseSemver } from '@sentry/core';
23
import { GLOBAL_OBJ, suppressTracing } from '@sentry/core';
4+
import { logger } from '@sentry/core';
35
import type { StackFrame } from 'stacktrace-parser';
46
import * as stackTraceParser from 'stacktrace-parser';
7+
import { DEBUG_BUILD } from './debug-build';
58

69
type OriginalStackFrameResponse = {
710
originalStackFrame: StackFrame;
@@ -11,8 +14,92 @@ type OriginalStackFrameResponse = {
1114

1215
const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
1316
_sentryBasePath?: string;
17+
next?: {
18+
version?: string;
19+
};
1420
};
1521

22+
/**
23+
* Event processor that will symbolicate errors by using the webpack/nextjs dev server that is used to show stack traces
24+
* in the dev overlay.
25+
*/
26+
export async function devErrorSymbolicationEventProcessor(event: Event, hint: EventHint): Promise<Event | null> {
27+
// Filter out spans for requests resolving source maps for stack frames in dev mode
28+
if (event.type === 'transaction') {
29+
event.spans = event.spans?.filter(span => {
30+
const httpUrlAttribute: unknown = span.data?.['http.url'];
31+
if (typeof httpUrlAttribute === 'string') {
32+
return !httpUrlAttribute.includes('__nextjs_original-stack-frame'); // could also be __nextjs_original-stack-frames (plural)
33+
}
34+
35+
return true;
36+
});
37+
}
38+
39+
// Due to changes across Next.js versions, there are a million things that can go wrong here so we just try-catch the
40+
// entire event processor. Symbolicated stack traces are just a nice to have.
41+
try {
42+
if (hint.originalException && hint.originalException instanceof Error && hint.originalException.stack) {
43+
const frames = stackTraceParser.parse(hint.originalException.stack);
44+
45+
const nextjsVersion = globalWithInjectedValues.next?.version || '0.0.0';
46+
const parsedNextjsVersion = nextjsVersion ? parseSemver(nextjsVersion) : {};
47+
48+
let resolvedFrames: ({
49+
originalCodeFrame: string | null;
50+
originalStackFrame: StackFrame | null;
51+
} | null)[];
52+
53+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
54+
if (parsedNextjsVersion.major! > 15 || (parsedNextjsVersion.major === 15 && parsedNextjsVersion.minor! >= 2)) {
55+
const r = await resolveStackFrames(frames);
56+
if (r === null) {
57+
return event;
58+
}
59+
resolvedFrames = r;
60+
} else {
61+
resolvedFrames = await Promise.all(
62+
frames.map(frame => resolveStackFrame(frame, hint.originalException as Error)),
63+
);
64+
}
65+
66+
if (event.exception?.values?.[0]?.stacktrace?.frames) {
67+
event.exception.values[0].stacktrace.frames = event.exception.values[0].stacktrace.frames.map(
68+
(frame, i, frames) => {
69+
const resolvedFrame = resolvedFrames[frames.length - 1 - i];
70+
if (!resolvedFrame?.originalStackFrame || !resolvedFrame.originalCodeFrame) {
71+
return {
72+
...frame,
73+
platform: frame.filename?.startsWith('node:internal') ? 'nodejs' : undefined, // simple hack that will prevent a source mapping error from showing up
74+
in_app: false,
75+
};
76+
}
77+
78+
const { contextLine, preContextLines, postContextLines } = parseOriginalCodeFrame(
79+
resolvedFrame.originalCodeFrame,
80+
);
81+
82+
return {
83+
...frame,
84+
pre_context: preContextLines,
85+
context_line: contextLine,
86+
post_context: postContextLines,
87+
function: resolvedFrame.originalStackFrame.methodName,
88+
filename: resolvedFrame.originalStackFrame.file || undefined,
89+
lineno: resolvedFrame.originalStackFrame.lineNumber || undefined,
90+
colno: resolvedFrame.originalStackFrame.column || undefined,
91+
};
92+
},
93+
);
94+
}
95+
}
96+
} catch (e) {
97+
return event;
98+
}
99+
100+
return event;
101+
}
102+
16103
async function resolveStackFrame(
17104
frame: StackFrame,
18105
error: Error,
@@ -65,6 +152,79 @@ async function resolveStackFrame(
65152
originalStackFrame: body.originalStackFrame,
66153
};
67154
} catch (e) {
155+
DEBUG_BUILD && logger.error('Failed to symbolicate event with Next.js dev server', e);
156+
return null;
157+
}
158+
}
159+
160+
async function resolveStackFrames(
161+
frames: StackFrame[],
162+
): Promise<{ originalCodeFrame: string | null; originalStackFrame: StackFrame | null }[] | null> {
163+
try {
164+
const postBody = {
165+
frames: frames
166+
.filter(frame => {
167+
return !!frame.file;
168+
})
169+
.map(frame => {
170+
// https://github.com/vercel/next.js/blob/df0573a478baa8b55478a7963c473dddd59a5e40/packages/next/src/client/components/react-dev-overlay/server/middleware-turbopack.ts#L129
171+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
172+
frame.file = frame.file!.replace(/^rsc:\/\/React\/[^/]+\//, '').replace(/\?\d+$/, '');
173+
174+
return {
175+
file: frame.file,
176+
methodName: frame.methodName ?? '<unknown>',
177+
arguments: [],
178+
lineNumber: frame.lineNumber ?? 0,
179+
column: frame.column ?? 0,
180+
};
181+
}),
182+
isServer: false,
183+
isEdgeServer: false,
184+
isAppDirectory: true,
185+
};
186+
187+
let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? '';
188+
189+
// Prefix the basepath with a slash if it doesn't have one
190+
if (basePath !== '' && !basePath.match(/^\//)) {
191+
basePath = `/${basePath}`;
192+
}
193+
194+
const controller = new AbortController();
195+
const timer = setTimeout(() => controller.abort(), 3000);
196+
197+
const res = await fetch(
198+
`${
199+
// eslint-disable-next-line no-restricted-globals
200+
typeof window === 'undefined' ? 'http://localhost:3000' : '' // TODO: handle the case where users define a different port
201+
}${basePath}/__nextjs_original-stack-frames`,
202+
{
203+
method: 'POST',
204+
headers: {
205+
'Content-Type': 'application/json',
206+
},
207+
signal: controller.signal,
208+
body: JSON.stringify(postBody),
209+
},
210+
).finally(() => {
211+
clearTimeout(timer);
212+
});
213+
214+
if (!res.ok || res.status === 204) {
215+
return null;
216+
}
217+
218+
const body: { value: OriginalStackFrameResponse }[] = await res.json();
219+
220+
return body.map(frame => {
221+
return {
222+
originalCodeFrame: frame.value.originalCodeFrame,
223+
originalStackFrame: frame.value.originalStackFrame,
224+
};
225+
});
226+
} catch (e) {
227+
DEBUG_BUILD && logger.error('Failed to symbolicate event with Next.js dev server', e);
68228
return null;
69229
}
70230
}
@@ -118,66 +278,3 @@ function parseOriginalCodeFrame(codeFrame: string): {
118278
postContextLines,
119279
};
120280
}
121-
122-
/**
123-
* Event processor that will symbolicate errors by using the webpack/nextjs dev server that is used to show stack traces
124-
* in the dev overlay.
125-
*/
126-
export async function devErrorSymbolicationEventProcessor(event: Event, hint: EventHint): Promise<Event | null> {
127-
// Filter out spans for requests resolving source maps for stack frames in dev mode
128-
if (event.type === 'transaction') {
129-
event.spans = event.spans?.filter(span => {
130-
const httpUrlAttribute: unknown = span.data?.['http.url'];
131-
if (typeof httpUrlAttribute === 'string') {
132-
return !httpUrlAttribute.includes('__nextjs_original-stack-frame');
133-
}
134-
135-
return true;
136-
});
137-
}
138-
139-
// Due to changes across Next.js versions, there are a million things that can go wrong here so we just try-catch the // entire event processor.Symbolicated stack traces are just a nice to have.
140-
try {
141-
if (hint.originalException && hint.originalException instanceof Error && hint.originalException.stack) {
142-
const frames = stackTraceParser.parse(hint.originalException.stack);
143-
144-
const resolvedFrames = await Promise.all(
145-
frames.map(frame => resolveStackFrame(frame, hint.originalException as Error)),
146-
);
147-
148-
if (event.exception?.values?.[0]?.stacktrace?.frames) {
149-
event.exception.values[0].stacktrace.frames = event.exception.values[0].stacktrace.frames.map(
150-
(frame, i, frames) => {
151-
const resolvedFrame = resolvedFrames[frames.length - 1 - i];
152-
if (!resolvedFrame?.originalStackFrame || !resolvedFrame.originalCodeFrame) {
153-
return {
154-
...frame,
155-
platform: frame.filename?.startsWith('node:internal') ? 'nodejs' : undefined, // simple hack that will prevent a source mapping error from showing up
156-
in_app: false,
157-
};
158-
}
159-
160-
const { contextLine, preContextLines, postContextLines } = parseOriginalCodeFrame(
161-
resolvedFrame.originalCodeFrame,
162-
);
163-
164-
return {
165-
...frame,
166-
pre_context: preContextLines,
167-
context_line: contextLine,
168-
post_context: postContextLines,
169-
function: resolvedFrame.originalStackFrame.methodName,
170-
filename: resolvedFrame.originalStackFrame.file || undefined,
171-
lineno: resolvedFrame.originalStackFrame.lineNumber || undefined,
172-
colno: resolvedFrame.originalStackFrame.column || undefined,
173-
};
174-
},
175-
);
176-
}
177-
}
178-
} catch (e) {
179-
return event;
180-
}
181-
182-
return event;
183-
}

0 commit comments

Comments
 (0)