From 3ea23d7d7c36f6d9052c77c700cae43ae4a5641b Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Fri, 7 Feb 2025 08:03:18 +0000 Subject: [PATCH 1/2] fix(nextjs): Use batched devserver symbolication endpoint --- .../devErrorSymbolicationEventProcessor.ts | 223 +++++++++++++----- 1 file changed, 160 insertions(+), 63 deletions(-) diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index b6851f18f3fa..a065e1f07eb5 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -1,7 +1,10 @@ import type { Event, EventHint } from '@sentry/core'; +import { parseSemver } from '@sentry/core'; import { GLOBAL_OBJ, suppressTracing } from '@sentry/core'; +import { logger } from '@sentry/core'; import type { StackFrame } from 'stacktrace-parser'; import * as stackTraceParser from 'stacktrace-parser'; +import { DEBUG_BUILD } from './debug-build'; type OriginalStackFrameResponse = { originalStackFrame: StackFrame; @@ -11,8 +14,92 @@ type OriginalStackFrameResponse = { const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string; + next?: { + version?: string; + }; }; +/** + * Event processor that will symbolicate errors by using the webpack/nextjs dev server that is used to show stack traces + * in the dev overlay. + */ +export async function devErrorSymbolicationEventProcessor(event: Event, hint: EventHint): Promise { + // Filter out spans for requests resolving source maps for stack frames in dev mode + if (event.type === 'transaction') { + event.spans = event.spans?.filter(span => { + const httpUrlAttribute: unknown = span.data?.['http.url']; + if (typeof httpUrlAttribute === 'string') { + return !httpUrlAttribute.includes('__nextjs_original-stack-frame'); // could also be __nextjs_original-stack-frames (plural) + } + + return true; + }); + } + + // 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. + try { + if (hint.originalException && hint.originalException instanceof Error && hint.originalException.stack) { + const frames = stackTraceParser.parse(hint.originalException.stack); + + const nextjsVersion = globalWithInjectedValues.next?.version || '0.0.0'; + const parsedNextjsVersion = nextjsVersion ? parseSemver(nextjsVersion) : {}; + + let resolvedFrames: ({ + originalCodeFrame: string | null; + originalStackFrame: StackFrame | null; + } | null)[]; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (parsedNextjsVersion.major! > 15 || (parsedNextjsVersion.major === 15 && parsedNextjsVersion.minor! >= 2)) { + const r = await resolveStackFrames(frames); + if (r === null) { + return event; + } + resolvedFrames = r; + } else { + resolvedFrames = await Promise.all( + frames.map(frame => resolveStackFrame(frame, hint.originalException as Error)), + ); + } + + if (event.exception?.values?.[0]?.stacktrace?.frames) { + event.exception.values[0].stacktrace.frames = event.exception.values[0].stacktrace.frames.map( + (frame, i, frames) => { + const resolvedFrame = resolvedFrames[frames.length - 1 - i]; + if (!resolvedFrame?.originalStackFrame || !resolvedFrame.originalCodeFrame) { + return { + ...frame, + platform: frame.filename?.startsWith('node:internal') ? 'nodejs' : undefined, // simple hack that will prevent a source mapping error from showing up + in_app: false, + }; + } + + const { contextLine, preContextLines, postContextLines } = parseOriginalCodeFrame( + resolvedFrame.originalCodeFrame, + ); + + return { + ...frame, + pre_context: preContextLines, + context_line: contextLine, + post_context: postContextLines, + function: resolvedFrame.originalStackFrame.methodName, + filename: resolvedFrame.originalStackFrame.file || undefined, + lineno: resolvedFrame.originalStackFrame.lineNumber || undefined, + colno: resolvedFrame.originalStackFrame.column || undefined, + }; + }, + ); + } + } + } catch (e) { + return event; + } + + return event; +} + async function resolveStackFrame( frame: StackFrame, error: Error, @@ -65,6 +152,79 @@ async function resolveStackFrame( originalStackFrame: body.originalStackFrame, }; } catch (e) { + DEBUG_BUILD && logger.error('Failed to symbolicate event with Next.js dev server', e); + return null; + } +} + +async function resolveStackFrames( + frames: StackFrame[], +): Promise<{ originalCodeFrame: string | null; originalStackFrame: StackFrame | null }[] | null> { + try { + const postBody = { + frames: frames + .filter(frame => { + return !!frame.file; + }) + .map(frame => { + // https://github.com/vercel/next.js/blob/df0573a478baa8b55478a7963c473dddd59a5e40/packages/next/src/client/components/react-dev-overlay/server/middleware-turbopack.ts#L129 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + frame.file = frame.file!.replace(/^rsc:\/\/React\/[^/]+\//, '').replace(/\?\d+$/, ''); + + return { + file: frame.file, + methodName: frame.methodName ?? '', + arguments: [], + lineNumber: frame.lineNumber ?? 0, + column: frame.column ?? 0, + }; + }), + isServer: false, + isEdgeServer: false, + isAppDirectory: true, + }; + + let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? ''; + + // Prefix the basepath with a slash if it doesn't have one + if (basePath !== '' && !basePath.match(/^\//)) { + basePath = `/${basePath}`; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + + const res = await fetch( + `${ + // eslint-disable-next-line no-restricted-globals + typeof window === 'undefined' ? 'http://localhost:3000' : '' // TODO: handle the case where users define a different port + }${basePath}/__nextjs_original-stack-frames`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + signal: controller.signal, + body: JSON.stringify(postBody), + }, + ).finally(() => { + clearTimeout(timer); + }); + + if (!res.ok || res.status === 204) { + return null; + } + + const body: { value: OriginalStackFrameResponse }[] = await res.json(); + + return body.map(frame => { + return { + originalCodeFrame: frame.value.originalCodeFrame, + originalStackFrame: frame.value.originalStackFrame, + }; + }); + } catch (e) { + DEBUG_BUILD && logger.error('Failed to symbolicate event with Next.js dev server', e); return null; } } @@ -118,66 +278,3 @@ function parseOriginalCodeFrame(codeFrame: string): { postContextLines, }; } - -/** - * Event processor that will symbolicate errors by using the webpack/nextjs dev server that is used to show stack traces - * in the dev overlay. - */ -export async function devErrorSymbolicationEventProcessor(event: Event, hint: EventHint): Promise { - // Filter out spans for requests resolving source maps for stack frames in dev mode - if (event.type === 'transaction') { - event.spans = event.spans?.filter(span => { - const httpUrlAttribute: unknown = span.data?.['http.url']; - if (typeof httpUrlAttribute === 'string') { - return !httpUrlAttribute.includes('__nextjs_original-stack-frame'); - } - - return true; - }); - } - - // 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. - try { - if (hint.originalException && hint.originalException instanceof Error && hint.originalException.stack) { - const frames = stackTraceParser.parse(hint.originalException.stack); - - const resolvedFrames = await Promise.all( - frames.map(frame => resolveStackFrame(frame, hint.originalException as Error)), - ); - - if (event.exception?.values?.[0]?.stacktrace?.frames) { - event.exception.values[0].stacktrace.frames = event.exception.values[0].stacktrace.frames.map( - (frame, i, frames) => { - const resolvedFrame = resolvedFrames[frames.length - 1 - i]; - if (!resolvedFrame?.originalStackFrame || !resolvedFrame.originalCodeFrame) { - return { - ...frame, - platform: frame.filename?.startsWith('node:internal') ? 'nodejs' : undefined, // simple hack that will prevent a source mapping error from showing up - in_app: false, - }; - } - - const { contextLine, preContextLines, postContextLines } = parseOriginalCodeFrame( - resolvedFrame.originalCodeFrame, - ); - - return { - ...frame, - pre_context: preContextLines, - context_line: contextLine, - post_context: postContextLines, - function: resolvedFrame.originalStackFrame.methodName, - filename: resolvedFrame.originalStackFrame.file || undefined, - lineno: resolvedFrame.originalStackFrame.lineNumber || undefined, - colno: resolvedFrame.originalStackFrame.column || undefined, - }; - }, - ); - } - } - } catch (e) { - return event; - } - - return event; -} From e3245699235e4d67b805e743e6a6a59d7382c83a Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Fri, 7 Feb 2025 08:04:06 +0000 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ace19a17087..fa61e8c6c70a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -148,6 +148,7 @@ A comprehensive migration guide outlining all changes for all the frameworks can - fix(core): Fork scope if custom scope is passed to `startSpan` (#14900) - fix(core): Only fall back to `sendDefaultPii` for IP collection in `requestDataIntegration` (#15125) - fix(nextjs): Flush with `waitUntil` in `captureRequestError` (#15146) +- fix(nextjs): Use batched devserver symbolication endpoint (#15335) - fix(node): Don't leak `__span` property into breadcrumbs (#14798) - fix(node): Ensure `httpIntegration` propagates traces (#15233) - fix(node): Fix sample rand propagation for negative sampling decisions (#15045)