Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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('/');
Expand Down
98 changes: 48 additions & 50 deletions packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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),
);
});
});
});
Loading