Skip to content

Commit ca14e8d

Browse files
chargomeclaude
andcommitted
fix(nextjs): Include basePath in request url of Pages Router errors
Next.js strips the basePath from req.url before running Pages Router data fetchers and API routes, so captured errors reported a request url without it. Use the originally requested path and query from Next.js' initURL request meta instead, keeping the origin from the request headers. Fixes #24975 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 89ac4c2 commit ca14e8d

8 files changed

Lines changed: 152 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { NextApiRequest, NextApiResponse } from 'next';
2+
3+
export default function handler(_req: NextApiRequest, _res: NextApiResponse) {
4+
throw new Error('Pages Router API route error with basePath');
5+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Page() {
2+
return <p>This page should never render</p>;
3+
}
4+
5+
export async function getServerSideProps() {
6+
throw new Error('Pages Router getServerSideProps error with basePath');
7+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError } from '@sentry-internal/test-utils';
3+
4+
test('Includes the basePath in the request url of getServerSideProps errors', async ({ request }) => {
5+
const errorEventPromise = waitForError('nextjs-15-basepath', errorEvent => {
6+
return errorEvent.exception?.values?.[0]?.value === 'Pages Router getServerSideProps error with basePath';
7+
});
8+
9+
await request.get('/my-app/pages-router/gssp-error?q=1');
10+
11+
const errorEvent = await errorEventPromise;
12+
13+
expect(errorEvent.request).toMatchObject({
14+
url: 'http://localhost:3030/my-app/pages-router/gssp-error?q=1',
15+
query_string: 'q=1',
16+
});
17+
});
18+
19+
test('Includes the basePath in the request url of Pages Router API route errors', async ({ request }) => {
20+
const errorEventPromise = waitForError('nextjs-15-basepath', errorEvent => {
21+
return errorEvent.exception?.values?.[0]?.value === 'Pages Router API route error with basePath';
22+
});
23+
24+
await request.get('/my-app/api/pages-router-api-error');
25+
26+
const errorEvent = await errorEventPromise;
27+
28+
expect(errorEvent.request?.url).toBe('http://localhost:3030/my-app/api/pages-router-api-error');
29+
});

‎packages/nextjs/src/common/pages-router-instrumentation/_error.ts‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
1-
import {
2-
captureException,
3-
getIsolationScope,
4-
httpRequestToRequestData,
5-
isAlreadyCaptured,
6-
withScope,
7-
} from '@sentry/core';
1+
import { captureException, getIsolationScope, isAlreadyCaptured, withScope } from '@sentry/core';
82
import type { NextPageContext } from 'next';
93
import { flushSafelyWithTimeout, waitUntil } from '../utils/responseEnd';
4+
import { pagesRouterRequestToRequestData } from '../utils/pagesRouterRequestToRequestData';
105

116
type ContextOrProps = {
127
req?: NextPageContext['req'];
@@ -62,7 +57,7 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP
6257

6358
const eventId = withScope(scope => {
6459
if (req) {
65-
const normalizedRequest = httpRequestToRequestData(req);
60+
const normalizedRequest = pagesRouterRequestToRequestData(req);
6661
scope.setSDKProcessingMetadata({ normalizedRequest });
6762
}
6863

‎packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import {
44
getActiveSpan,
55
getCurrentScope,
66
getRootSpan,
7-
httpRequestToRequestData,
87
objectify,
98
setCapturedScopesOnSpan,
109
withIsolationScope,
1110
} from '@sentry/core';
1211
import type { NextApiRequest } from 'next';
1312
import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../span-attributes-with-logic-attached';
1413
import type { AugmentedNextApiResponse, NextApiHandler } from '../types';
14+
import { pagesRouterRequestToRequestData } from '../utils/pagesRouterRequestToRequestData';
1515
import { flushSafelyWithTimeout, waitUntil } from '../utils/responseEnd';
1616

1717
export type AugmentedNextApiRequest = NextApiRequest & {
@@ -56,7 +56,7 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz
5656
return withIsolationScope(async isolationScope => {
5757
const reqMethod = `${(req.method || 'GET').toUpperCase()} `;
5858

59-
isolationScope.setSDKProcessingMetadata({ normalizedRequest: httpRequestToRequestData(req) });
59+
isolationScope.setSDKProcessingMetadata({ normalizedRequest: pagesRouterRequestToRequestData(req) });
6060
isolationScope.setTransactionName(`${reqMethod}${parameterizedRoute}`);
6161

6262
// We no longer create the transaction ourselves: it's the Next.js root span, which captured a different
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { RequestEventData } from '@sentry/core';
2+
import { httpRequestToRequestData } from '@sentry/core';
3+
import type { IncomingMessage } from 'http';
4+
5+
const NEXT_REQUEST_META = Symbol.for('NextInternalRequestMeta');
6+
7+
type RequestWithNextMeta = IncomingMessage & {
8+
[NEXT_REQUEST_META]?: { initURL?: unknown };
9+
};
10+
11+
/**
12+
* Converts a Pages Router request into request data for events.
13+
*
14+
* Next.js strips `basePath` from `req.url` before running pages and API routes, but keeps the URL as it was originally
15+
* requested in its internal request meta (`initURL`). We use that one so the reported URL matches what was requested.
16+
*/
17+
export function pagesRouterRequestToRequestData(req: IncomingMessage): RequestEventData {
18+
const requestData = httpRequestToRequestData(req);
19+
20+
const originalUrl = getOriginalPathAndQuery(req);
21+
if (!originalUrl || originalUrl === req.url) {
22+
return requestData;
23+
}
24+
25+
const { url, query_string } = httpRequestToRequestData({
26+
url: originalUrl,
27+
headers: req.headers,
28+
socket: req.socket,
29+
});
30+
31+
return { ...requestData, url, query_string };
32+
}
33+
34+
function getOriginalPathAndQuery(req: RequestWithNextMeta): string | undefined {
35+
const initUrl = req[NEXT_REQUEST_META]?.initURL;
36+
if (typeof initUrl !== 'string') {
37+
return undefined;
38+
}
39+
40+
// `initURL` can be absolute, but its origin is built from the Next.js server's own hostname and port rather than the
41+
// request headers, so we only keep path and query and let the headers decide the origin like everywhere else.
42+
try {
43+
const { pathname, search } = new URL(initUrl, 'http://n');
44+
return `${pathname}${search}`;
45+
} catch {
46+
return undefined;
47+
}
48+
}

‎packages/nextjs/src/common/utils/wrapperUtils.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import {
66
getIsolationScope,
77
getRootSpan,
88
getTraceData,
9-
httpRequestToRequestData,
109
isThenable,
1110
} from '@sentry/core';
1211
import type { IncomingMessage, ServerResponse } from 'http';
1312
import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../span-attributes-with-logic-attached';
13+
import { pagesRouterRequestToRequestData } from './pagesRouterRequestToRequestData';
1414

1515
/**
1616
* Wraps a function that potentially throws. If it does, the error is passed to `captureException` and rethrown.
@@ -68,7 +68,7 @@ export function withTracedServerSideDataFetcher<F extends (...args: any[]) => Pr
6868
this: unknown,
6969
...args: Parameters<F>
7070
): Promise<{ data: ReturnType<F>; sentryTrace?: string; baggage?: string }> {
71-
const normalizedRequest = httpRequestToRequestData(req);
71+
const normalizedRequest = pagesRouterRequestToRequestData(req);
7272
getCurrentScope().setTransactionName(`${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`);
7373
getIsolationScope().setSDKProcessingMetadata({ normalizedRequest });
7474

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { IncomingMessage } from 'http';
2+
import { describe, expect, it } from 'vitest';
3+
import { pagesRouterRequestToRequestData } from '../../../src/common/utils/pagesRouterRequestToRequestData';
4+
5+
function createRequest(url: string, initURL?: unknown): IncomingMessage {
6+
const req = {
7+
method: 'GET',
8+
url,
9+
headers: { host: 'example.com' },
10+
socket: {},
11+
} as unknown as IncomingMessage;
12+
13+
if (initURL !== undefined) {
14+
(req as unknown as Record<symbol, unknown>)[Symbol.for('NextInternalRequestMeta')] = { initURL };
15+
}
16+
17+
return req;
18+
}
19+
20+
describe('pagesRouterRequestToRequestData', () => {
21+
it('uses `req.url` when there is no Next.js request meta', () => {
22+
expect(pagesRouterRequestToRequestData(createRequest('/foo/bar?q=1'))).toMatchObject({
23+
url: 'http://example.com/foo/bar?q=1',
24+
query_string: 'q=1',
25+
method: 'GET',
26+
});
27+
});
28+
29+
it('restores the basePath from a relative `initURL`', () => {
30+
expect(pagesRouterRequestToRequestData(createRequest('/foo/bar?q=1', '/base/foo/bar?q=1'))).toMatchObject({
31+
url: 'http://example.com/base/foo/bar?q=1',
32+
query_string: 'q=1',
33+
method: 'GET',
34+
});
35+
});
36+
37+
it('restores the basePath for the basePath root', () => {
38+
expect(pagesRouterRequestToRequestData(createRequest('/', '/base'))).toMatchObject({
39+
url: 'http://example.com/base',
40+
});
41+
});
42+
43+
it('takes only path and query from an absolute `initURL` and keeps the origin from the request headers', () => {
44+
expect(
45+
pagesRouterRequestToRequestData(createRequest('/foo/bar', 'http://localhost:3000/base/foo/bar')),
46+
).toMatchObject({
47+
url: 'http://example.com/base/foo/bar',
48+
});
49+
});
50+
51+
it('ignores a non-string `initURL`', () => {
52+
expect(pagesRouterRequestToRequestData(createRequest('/foo/bar', 123))).toMatchObject({
53+
url: 'http://example.com/foo/bar',
54+
});
55+
});
56+
});

0 commit comments

Comments
 (0)