Skip to content

Commit 9482a02

Browse files
authored
fix(nextjs): Respect PORT variable for dev error symbolication (#18227)
Next.js respects the PORT variable. If for some reason this is not sufficient for users we can ship a follow up with a config option, which I wanted to avoid in the first step. Also did a small refactor of the fetching code. closes #18135 closes https://linear.app/getsentry/issue/JS-1139/handle-the-case-where-users-define-a-different-portprotocol
1 parent d729cdb commit 9482a02

File tree

3 files changed

+268
-52
lines changed

3 files changed

+268
-52
lines changed

dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-error.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { expect, test } from '@playwright/test';
22
import { waitForError } from '@sentry-internal/test-utils';
33

4-
test('should capture orpc error', async ({ page }) => {
4+
test('should capture server-side orpc error', async ({ page }) => {
55
const orpcErrorPromise = waitForError('nextjs-orpc', errorEvent => {
6-
return errorEvent.exception?.values?.[0]?.value === 'You are hitting an error';
6+
return (
7+
errorEvent.exception?.values?.[0]?.value === 'You are hitting an error' &&
8+
errorEvent.contexts?.['runtime']?.name === 'node'
9+
);
710
});
811

912
await page.goto('/');

packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts

Lines changed: 48 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,44 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
1515
_sentryNextJsVersion: string | undefined;
1616
};
1717

18+
/**
19+
* Constructs the base URL for the Next.js dev server, including the port and base path.
20+
* Returns only the base path when running in the browser (client-side) for relative URLs.
21+
*/
22+
function getDevServerBaseUrl(): string {
23+
let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? '';
24+
25+
// Prefix the basepath with a slash if it doesn't have one
26+
if (basePath !== '' && !basePath.match(/^\//)) {
27+
basePath = `/${basePath}`;
28+
}
29+
30+
// eslint-disable-next-line no-restricted-globals
31+
if (typeof window !== 'undefined') {
32+
return basePath;
33+
}
34+
35+
const devServerPort = process.env.PORT || '3000';
36+
return `http://localhost:${devServerPort}${basePath}`;
37+
}
38+
39+
/**
40+
* Fetches a URL with a 3-second timeout using AbortController.
41+
*/
42+
async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise<Response> {
43+
const controller = new AbortController();
44+
const timer = setTimeout(() => controller.abort(), 3000);
45+
46+
return suppressTracing(() =>
47+
fetch(url, {
48+
...options,
49+
signal: controller.signal,
50+
}).finally(() => {
51+
clearTimeout(timer);
52+
}),
53+
);
54+
}
55+
1856
/**
1957
* Event processor that will symbolicate errors by using the webpack/nextjs dev server that is used to show stack traces
2058
* in the dev overlay.
@@ -123,28 +161,8 @@ async function resolveStackFrame(
123161
params.append(key, (frame[key as keyof typeof frame] ?? '').toString());
124162
});
125163

126-
let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? '';
127-
128-
// Prefix the basepath with a slash if it doesn't have one
129-
if (basePath !== '' && !basePath.match(/^\//)) {
130-
basePath = `/${basePath}`;
131-
}
132-
133-
const controller = new AbortController();
134-
const timer = setTimeout(() => controller.abort(), 3000);
135-
const res = await suppressTracing(() =>
136-
fetch(
137-
`${
138-
// eslint-disable-next-line no-restricted-globals
139-
typeof window === 'undefined' ? 'http://localhost:3000' : '' // TODO: handle the case where users define a different port
140-
}${basePath}/__nextjs_original-stack-frame?${params.toString()}`,
141-
{
142-
signal: controller.signal,
143-
},
144-
).finally(() => {
145-
clearTimeout(timer);
146-
}),
147-
);
164+
const baseUrl = getDevServerBaseUrl();
165+
const res = await fetchWithTimeout(`${baseUrl}/__nextjs_original-stack-frame?${params.toString()}`);
148166

149167
if (!res.ok || res.status === 204) {
150168
return null;
@@ -191,34 +209,14 @@ async function resolveStackFrames(
191209
isAppDirectory: true,
192210
};
193211

194-
let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? '';
195-
196-
// Prefix the basepath with a slash if it doesn't have one
197-
if (basePath !== '' && !basePath.match(/^\//)) {
198-
basePath = `/${basePath}`;
199-
}
200-
201-
const controller = new AbortController();
202-
const timer = setTimeout(() => controller.abort(), 3000);
203-
204-
const res = await suppressTracing(() =>
205-
fetch(
206-
`${
207-
// eslint-disable-next-line no-restricted-globals
208-
typeof window === 'undefined' ? 'http://localhost:3000' : '' // TODO: handle the case where users define a different port
209-
}${basePath}/__nextjs_original-stack-frames`,
210-
{
211-
method: 'POST',
212-
headers: {
213-
'Content-Type': 'application/json',
214-
},
215-
signal: controller.signal,
216-
body: JSON.stringify(postBody),
217-
},
218-
).finally(() => {
219-
clearTimeout(timer);
220-
}),
221-
);
212+
const baseUrl = getDevServerBaseUrl();
213+
const res = await fetchWithTimeout(`${baseUrl}/__nextjs_original-stack-frames`, {
214+
method: 'POST',
215+
headers: {
216+
'Content-Type': 'application/json',
217+
},
218+
body: JSON.stringify(postBody),
219+
});
222220

223221
if (!res.ok || res.status === 204) {
224222
return null;

packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe('devErrorSymbolicationEventProcessor', () => {
2525
vi.clearAllMocks();
2626
delete (GLOBAL_OBJ as any)._sentryNextJsVersion;
2727
delete (GLOBAL_OBJ as any)._sentryBasePath;
28+
delete process.env.PORT;
2829
});
2930

3031
describe('Next.js version handling', () => {
@@ -258,4 +259,218 @@ describe('devErrorSymbolicationEventProcessor', () => {
258259
expect(result?.spans).toHaveLength(1);
259260
});
260261
});
262+
263+
describe('dev server URL construction', () => {
264+
it('should use default port 3000 when PORT env variable is not set (Next.js < 15.2)', async () => {
265+
const mockEvent: Event = {
266+
exception: {
267+
values: [
268+
{
269+
stacktrace: {
270+
frames: [{ filename: 'webpack-internal:///./test.js', lineno: 1 }],
271+
},
272+
},
273+
],
274+
},
275+
};
276+
277+
const testError = new Error('test error');
278+
testError.stack = 'Error: test error\n at webpack-internal:///./test.js:1:1';
279+
280+
const mockHint: EventHint = {
281+
originalException: testError,
282+
};
283+
284+
(GLOBAL_OBJ as any)._sentryNextJsVersion = '14.1.0';
285+
286+
const stackTraceParser = await import('stacktrace-parser');
287+
vi.mocked(stackTraceParser.parse).mockReturnValue([
288+
{
289+
file: 'webpack-internal:///./test.js',
290+
methodName: 'testMethod',
291+
lineNumber: 1,
292+
column: 1,
293+
arguments: [],
294+
},
295+
]);
296+
297+
vi.mocked(fetch).mockResolvedValueOnce({
298+
ok: true,
299+
status: 200,
300+
json: async () => ({
301+
originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' },
302+
originalCodeFrame: '> 1 | test code',
303+
}),
304+
} as any);
305+
306+
await devErrorSymbolicationEventProcessor(mockEvent, mockHint);
307+
308+
expect(fetch).toHaveBeenCalledWith(
309+
expect.stringContaining('http://localhost:3000/__nextjs_original-stack-frame'),
310+
expect.any(Object),
311+
);
312+
});
313+
314+
it('should use PORT env variable when set (Next.js < 15.2)', async () => {
315+
process.env.PORT = '4000';
316+
317+
const mockEvent: Event = {
318+
exception: {
319+
values: [
320+
{
321+
stacktrace: {
322+
frames: [{ filename: 'webpack-internal:///./test.js', lineno: 1 }],
323+
},
324+
},
325+
],
326+
},
327+
};
328+
329+
const testError = new Error('test error');
330+
testError.stack = 'Error: test error\n at webpack-internal:///./test.js:1:1';
331+
332+
const mockHint: EventHint = {
333+
originalException: testError,
334+
};
335+
336+
(GLOBAL_OBJ as any)._sentryNextJsVersion = '14.1.0';
337+
338+
const stackTraceParser = await import('stacktrace-parser');
339+
vi.mocked(stackTraceParser.parse).mockReturnValue([
340+
{
341+
file: 'webpack-internal:///./test.js',
342+
methodName: 'testMethod',
343+
lineNumber: 1,
344+
column: 1,
345+
arguments: [],
346+
},
347+
]);
348+
349+
vi.mocked(fetch).mockResolvedValueOnce({
350+
ok: true,
351+
status: 200,
352+
json: async () => ({
353+
originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' },
354+
originalCodeFrame: '> 1 | test code',
355+
}),
356+
} as any);
357+
358+
await devErrorSymbolicationEventProcessor(mockEvent, mockHint);
359+
360+
expect(fetch).toHaveBeenCalledWith(
361+
expect.stringContaining('http://localhost:4000/__nextjs_original-stack-frame'),
362+
expect.any(Object),
363+
);
364+
});
365+
366+
it('should use default port 3000 when PORT env variable is not set (Next.js >= 15.2)', async () => {
367+
const mockEvent: Event = {
368+
exception: {
369+
values: [
370+
{
371+
stacktrace: {
372+
frames: [{ filename: 'file:///test.js', lineno: 1 }],
373+
},
374+
},
375+
],
376+
},
377+
};
378+
379+
const testError = new Error('test error');
380+
testError.stack = 'Error: test error\n at file:///test.js:1:1';
381+
382+
const mockHint: EventHint = {
383+
originalException: testError,
384+
};
385+
386+
(GLOBAL_OBJ as any)._sentryNextJsVersion = '15.2.0';
387+
388+
const stackTraceParser = await import('stacktrace-parser');
389+
vi.mocked(stackTraceParser.parse).mockReturnValue([
390+
{
391+
file: 'file:///test.js',
392+
methodName: 'testMethod',
393+
lineNumber: 1,
394+
column: 1,
395+
arguments: [],
396+
},
397+
]);
398+
399+
vi.mocked(fetch).mockResolvedValueOnce({
400+
ok: true,
401+
status: 200,
402+
json: async () => [
403+
{
404+
value: {
405+
originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' },
406+
originalCodeFrame: '> 1 | test code',
407+
},
408+
},
409+
],
410+
} as any);
411+
412+
await devErrorSymbolicationEventProcessor(mockEvent, mockHint);
413+
414+
expect(fetch).toHaveBeenCalledWith(
415+
expect.stringContaining('http://localhost:3000/__nextjs_original-stack-frames'),
416+
expect.any(Object),
417+
);
418+
});
419+
420+
it('should use PORT env variable when set (Next.js >= 15.2)', async () => {
421+
process.env.PORT = '4000';
422+
423+
const mockEvent: Event = {
424+
exception: {
425+
values: [
426+
{
427+
stacktrace: {
428+
frames: [{ filename: 'file:///test.js', lineno: 1 }],
429+
},
430+
},
431+
],
432+
},
433+
};
434+
435+
const testError = new Error('test error');
436+
testError.stack = 'Error: test error\n at file:///test.js:1:1';
437+
438+
const mockHint: EventHint = {
439+
originalException: testError,
440+
};
441+
442+
(GLOBAL_OBJ as any)._sentryNextJsVersion = '15.2.0';
443+
444+
const stackTraceParser = await import('stacktrace-parser');
445+
vi.mocked(stackTraceParser.parse).mockReturnValue([
446+
{
447+
file: 'file:///test.js',
448+
methodName: 'testMethod',
449+
lineNumber: 1,
450+
column: 1,
451+
arguments: [],
452+
},
453+
]);
454+
455+
vi.mocked(fetch).mockResolvedValueOnce({
456+
ok: true,
457+
status: 200,
458+
json: async () => [
459+
{
460+
value: {
461+
originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' },
462+
originalCodeFrame: '> 1 | test code',
463+
},
464+
},
465+
],
466+
} as any);
467+
468+
await devErrorSymbolicationEventProcessor(mockEvent, mockHint);
469+
470+
expect(fetch).toHaveBeenCalledWith(
471+
expect.stringContaining('http://localhost:4000/__nextjs_original-stack-frames'),
472+
expect.any(Object),
473+
);
474+
});
475+
});
261476
});

0 commit comments

Comments
 (0)