From e1e526ecaa1a9db97b2205b583736eb40c29476d Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 17 Nov 2025 11:08:30 +0100 Subject: [PATCH 1/4] use port for dev error symbolication --- .../devErrorSymbolicationEventProcessor.ts | 6 +- ...evErrorSymbolicationEventProcessor.test.ts | 215 ++++++++++++++++++ 2 files changed, 219 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index 9ae0a5ee0bb2..b610f1419e15 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -132,11 +132,12 @@ async function resolveStackFrame( const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 3000); + const devServerPort = process.env.PORT || '3000'; const res = await suppressTracing(() => fetch( `${ // eslint-disable-next-line no-restricted-globals - typeof window === 'undefined' ? 'http://localhost:3000' : '' // TODO: handle the case where users define a different port + typeof window === 'undefined' ? `http://localhost:${devServerPort}` : '' }${basePath}/__nextjs_original-stack-frame?${params.toString()}`, { signal: controller.signal, @@ -201,11 +202,12 @@ async function resolveStackFrames( const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 3000); + const devServerPort = process.env.PORT || '3000'; const res = await suppressTracing(() => fetch( `${ // eslint-disable-next-line no-restricted-globals - typeof window === 'undefined' ? 'http://localhost:3000' : '' // TODO: handle the case where users define a different port + typeof window === 'undefined' ? `http://localhost:${devServerPort}` : '' }${basePath}/__nextjs_original-stack-frames`, { method: 'POST', diff --git a/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts b/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts index 4305aad537a8..130f8ea685df 100644 --- a/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts +++ b/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts @@ -25,6 +25,7 @@ describe('devErrorSymbolicationEventProcessor', () => { vi.clearAllMocks(); delete (GLOBAL_OBJ as any)._sentryNextJsVersion; delete (GLOBAL_OBJ as any)._sentryBasePath; + delete process.env.PORT; }); describe('Next.js version handling', () => { @@ -258,4 +259,218 @@ describe('devErrorSymbolicationEventProcessor', () => { expect(result?.spans).toHaveLength(1); }); }); + + describe('dev server URL construction', () => { + it('should use default port 3000 when PORT env variable is not set (Next.js < 15.2)', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'webpack-internal:///./test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const testError = new Error('test error'); + testError.stack = 'Error: test error\n at webpack-internal:///./test.js:1:1'; + + const mockHint: EventHint = { + originalException: testError, + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '14.1.0'; + + const stackTraceParser = await import('stacktrace-parser'); + vi.mocked(stackTraceParser.parse).mockReturnValue([ + { + file: 'webpack-internal:///./test.js', + methodName: 'testMethod', + lineNumber: 1, + column: 1, + arguments: [], + }, + ]); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' }, + originalCodeFrame: '> 1 | test code', + }), + } as any); + + await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:3000/__nextjs_original-stack-frame'), + expect.any(Object), + ); + }); + + it('should use PORT env variable when set (Next.js < 15.2)', async () => { + process.env.PORT = '4000'; + + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'webpack-internal:///./test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const testError = new Error('test error'); + testError.stack = 'Error: test error\n at webpack-internal:///./test.js:1:1'; + + const mockHint: EventHint = { + originalException: testError, + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '14.1.0'; + + const stackTraceParser = await import('stacktrace-parser'); + vi.mocked(stackTraceParser.parse).mockReturnValue([ + { + file: 'webpack-internal:///./test.js', + methodName: 'testMethod', + lineNumber: 1, + column: 1, + arguments: [], + }, + ]); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' }, + originalCodeFrame: '> 1 | test code', + }), + } as any); + + await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:4000/__nextjs_original-stack-frame'), + expect.any(Object), + ); + }); + + it('should use default port 3000 when PORT env variable is not set (Next.js >= 15.2)', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'file:///test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const testError = new Error('test error'); + testError.stack = 'Error: test error\n at file:///test.js:1:1'; + + const mockHint: EventHint = { + originalException: testError, + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '15.2.0'; + + const stackTraceParser = await import('stacktrace-parser'); + vi.mocked(stackTraceParser.parse).mockReturnValue([ + { + file: 'file:///test.js', + methodName: 'testMethod', + lineNumber: 1, + column: 1, + arguments: [], + }, + ]); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => [ + { + value: { + originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' }, + originalCodeFrame: '> 1 | test code', + }, + }, + ], + } as any); + + await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:3000/__nextjs_original-stack-frames'), + expect.any(Object), + ); + }); + + it('should use PORT env variable when set (Next.js >= 15.2)', async () => { + process.env.PORT = '4000'; + + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'file:///test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const testError = new Error('test error'); + testError.stack = 'Error: test error\n at file:///test.js:1:1'; + + const mockHint: EventHint = { + originalException: testError, + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '15.2.0'; + + const stackTraceParser = await import('stacktrace-parser'); + vi.mocked(stackTraceParser.parse).mockReturnValue([ + { + file: 'file:///test.js', + methodName: 'testMethod', + lineNumber: 1, + column: 1, + arguments: [], + }, + ]); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => [ + { + value: { + originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' }, + originalCodeFrame: '> 1 | test code', + }, + }, + ], + } as any); + + await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:4000/__nextjs_original-stack-frames'), + expect.any(Object), + ); + }); + }); }); From 869bcac3b1f81e7d2f168f8aff6085abf044a231 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 17 Nov 2025 11:13:06 +0100 Subject: [PATCH 2/4] ref --- .../devErrorSymbolicationEventProcessor.ts | 100 +++++++++--------- 1 file changed, 48 insertions(+), 52 deletions(-) diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index b610f1419e15..ab8f0954b045 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -15,6 +15,44 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryNextJsVersion: string | undefined; }; +/** + * Constructs the base URL for the Next.js dev server, including the port and base path. + * Returns an empty string when running in the browser (client-side). + */ +function getDevServerBaseUrl(): string { + // eslint-disable-next-line no-restricted-globals + if (typeof window !== 'undefined') { + return ''; + } + + const devServerPort = process.env.PORT || '3000'; + 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}`; + } + + return `http://localhost:${devServerPort}${basePath}`; +} + +/** + * Fetches a URL with a 3-second timeout using AbortController. + */ +async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + + return suppressTracing(() => + fetch(url, { + ...options, + signal: controller.signal, + }).finally(() => { + clearTimeout(timer); + }), + ); +} + /** * Event processor that will symbolicate errors by using the webpack/nextjs dev server that is used to show stack traces * in the dev overlay. @@ -123,29 +161,8 @@ async function resolveStackFrame( params.append(key, (frame[key as keyof typeof frame] ?? '').toString()); }); - 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 devServerPort = process.env.PORT || '3000'; - const res = await suppressTracing(() => - fetch( - `${ - // eslint-disable-next-line no-restricted-globals - typeof window === 'undefined' ? `http://localhost:${devServerPort}` : '' - }${basePath}/__nextjs_original-stack-frame?${params.toString()}`, - { - signal: controller.signal, - }, - ).finally(() => { - clearTimeout(timer); - }), - ); + const baseUrl = getDevServerBaseUrl(); + const res = await fetchWithTimeout(`${baseUrl}/__nextjs_original-stack-frame?${params.toString()}`); if (!res.ok || res.status === 204) { return null; @@ -192,35 +209,14 @@ async function resolveStackFrames( 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 devServerPort = process.env.PORT || '3000'; - const res = await suppressTracing(() => - fetch( - `${ - // eslint-disable-next-line no-restricted-globals - typeof window === 'undefined' ? `http://localhost:${devServerPort}` : '' - }${basePath}/__nextjs_original-stack-frames`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - signal: controller.signal, - body: JSON.stringify(postBody), - }, - ).finally(() => { - clearTimeout(timer); - }), - ); + const baseUrl = getDevServerBaseUrl(); + const res = await fetchWithTimeout(`${baseUrl}/__nextjs_original-stack-frames`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(postBody), + }); if (!res.ok || res.status === 204) { return null; From 033b81e6832a2ea9d227855dd92150846b779a8a Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 18 Nov 2025 10:09:20 +0100 Subject: [PATCH 3/4] fix test --- .../test-applications/nextjs-orpc/tests/orpc-error.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-error.test.ts index a503533b6f00..a5f5494ee61c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-error.test.ts @@ -1,9 +1,12 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; -test('should capture orpc error', async ({ page }) => { +test('should capture server-side orpc error', async ({ page }) => { const orpcErrorPromise = waitForError('nextjs-orpc', errorEvent => { - return errorEvent.exception?.values?.[0]?.value === 'You are hitting an error'; + return ( + errorEvent.exception?.values?.[0]?.value === 'You are hitting an error' && + errorEvent.contexts?.['runtime']?.name === 'node' + ); }); await page.goto('/'); From 98fe20d6b9c670f515af98c7e93a11263565723c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 18 Nov 2025 10:34:44 +0100 Subject: [PATCH 4/4] . --- .../common/devErrorSymbolicationEventProcessor.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index ab8f0954b045..3b02d92d80fb 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -17,15 +17,9 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { /** * Constructs the base URL for the Next.js dev server, including the port and base path. - * Returns an empty string when running in the browser (client-side). + * Returns only the base path when running in the browser (client-side) for relative URLs. */ function getDevServerBaseUrl(): string { - // eslint-disable-next-line no-restricted-globals - if (typeof window !== 'undefined') { - return ''; - } - - const devServerPort = process.env.PORT || '3000'; let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? ''; // Prefix the basepath with a slash if it doesn't have one @@ -33,6 +27,12 @@ function getDevServerBaseUrl(): string { basePath = `/${basePath}`; } + // eslint-disable-next-line no-restricted-globals + if (typeof window !== 'undefined') { + return basePath; + } + + const devServerPort = process.env.PORT || '3000'; return `http://localhost:${devServerPort}${basePath}`; }