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('/'); diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index 9ae0a5ee0bb2..3b02d92d80fb 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 only the base path when running in the browser (client-side) for relative URLs. + */ +function getDevServerBaseUrl(): string { + 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}`; + } + + // eslint-disable-next-line no-restricted-globals + if (typeof window !== 'undefined') { + return basePath; + } + + const devServerPort = process.env.PORT || '3000'; + 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,28 +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 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 - }${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; @@ -191,34 +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 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 - }${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; 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), + ); + }); + }); });