From ca14e8dbfdd59dc8032afc6ada0c4880998f7371 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 2 Oct 2026 12:52:31 +0200 Subject: [PATCH] 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 --- .../pages/api/pages-router-api-error.ts | 5 ++ .../pages/pages-router/gssp-error.tsx | 7 +++ .../tests/pages-router-request-url.test.ts | 29 ++++++++++ .../pages-router-instrumentation/_error.ts | 11 +--- .../wrapApiHandlerWithSentry.ts | 4 +- .../utils/pagesRouterRequestToRequestData.ts | 48 ++++++++++++++++ .../nextjs/src/common/utils/wrapperUtils.ts | 4 +- .../pagesRouterRequestToRequestData.test.ts | 56 +++++++++++++++++++ 8 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/pages/api/pages-router-api-error.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/pages/pages-router/gssp-error.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/pages-router-request-url.test.ts create mode 100644 packages/nextjs/src/common/utils/pagesRouterRequestToRequestData.ts create mode 100644 packages/nextjs/test/common/utils/pagesRouterRequestToRequestData.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/pages/api/pages-router-api-error.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/pages/api/pages-router-api-error.ts new file mode 100644 index 000000000000..bc0bcf0986e7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/pages/api/pages-router-api-error.ts @@ -0,0 +1,5 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +export default function handler(_req: NextApiRequest, _res: NextApiResponse) { + throw new Error('Pages Router API route error with basePath'); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/pages/pages-router/gssp-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/pages/pages-router/gssp-error.tsx new file mode 100644 index 000000000000..a410f412abf1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/pages/pages-router/gssp-error.tsx @@ -0,0 +1,7 @@ +export default function Page() { + return

This page should never render

; +} + +export async function getServerSideProps() { + throw new Error('Pages Router getServerSideProps error with basePath'); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/pages-router-request-url.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/pages-router-request-url.test.ts new file mode 100644 index 000000000000..61c41b63ebfd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/pages-router-request-url.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Includes the basePath in the request url of getServerSideProps errors', async ({ request }) => { + const errorEventPromise = waitForError('nextjs-15-basepath', errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'Pages Router getServerSideProps error with basePath'; + }); + + await request.get('/my-app/pages-router/gssp-error?q=1'); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.request).toMatchObject({ + url: 'http://localhost:3030/my-app/pages-router/gssp-error?q=1', + query_string: 'q=1', + }); +}); + +test('Includes the basePath in the request url of Pages Router API route errors', async ({ request }) => { + const errorEventPromise = waitForError('nextjs-15-basepath', errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'Pages Router API route error with basePath'; + }); + + await request.get('/my-app/api/pages-router-api-error'); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.request?.url).toBe('http://localhost:3030/my-app/api/pages-router-api-error'); +}); diff --git a/packages/nextjs/src/common/pages-router-instrumentation/_error.ts b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts index 80103b42568f..9efa068e4e0d 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/_error.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts @@ -1,12 +1,7 @@ -import { - captureException, - getIsolationScope, - httpRequestToRequestData, - isAlreadyCaptured, - withScope, -} from '@sentry/core'; +import { captureException, getIsolationScope, isAlreadyCaptured, withScope } from '@sentry/core'; import type { NextPageContext } from 'next'; import { flushSafelyWithTimeout, waitUntil } from '../utils/responseEnd'; +import { pagesRouterRequestToRequestData } from '../utils/pagesRouterRequestToRequestData'; type ContextOrProps = { req?: NextPageContext['req']; @@ -62,7 +57,7 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP const eventId = withScope(scope => { if (req) { - const normalizedRequest = httpRequestToRequestData(req); + const normalizedRequest = pagesRouterRequestToRequestData(req); scope.setSDKProcessingMetadata({ normalizedRequest }); } diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts index 77343c882239..d43567a87716 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts @@ -4,7 +4,6 @@ import { getActiveSpan, getCurrentScope, getRootSpan, - httpRequestToRequestData, objectify, setCapturedScopesOnSpan, withIsolationScope, @@ -12,6 +11,7 @@ import { import type { NextApiRequest } from 'next'; import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../span-attributes-with-logic-attached'; import type { AugmentedNextApiResponse, NextApiHandler } from '../types'; +import { pagesRouterRequestToRequestData } from '../utils/pagesRouterRequestToRequestData'; import { flushSafelyWithTimeout, waitUntil } from '../utils/responseEnd'; export type AugmentedNextApiRequest = NextApiRequest & { @@ -56,7 +56,7 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz return withIsolationScope(async isolationScope => { const reqMethod = `${(req.method || 'GET').toUpperCase()} `; - isolationScope.setSDKProcessingMetadata({ normalizedRequest: httpRequestToRequestData(req) }); + isolationScope.setSDKProcessingMetadata({ normalizedRequest: pagesRouterRequestToRequestData(req) }); isolationScope.setTransactionName(`${reqMethod}${parameterizedRoute}`); // We no longer create the transaction ourselves: it's the Next.js root span, which captured a different diff --git a/packages/nextjs/src/common/utils/pagesRouterRequestToRequestData.ts b/packages/nextjs/src/common/utils/pagesRouterRequestToRequestData.ts new file mode 100644 index 000000000000..0961a037637b --- /dev/null +++ b/packages/nextjs/src/common/utils/pagesRouterRequestToRequestData.ts @@ -0,0 +1,48 @@ +import type { RequestEventData } from '@sentry/core'; +import { httpRequestToRequestData } from '@sentry/core'; +import type { IncomingMessage } from 'http'; + +const NEXT_REQUEST_META = Symbol.for('NextInternalRequestMeta'); + +type RequestWithNextMeta = IncomingMessage & { + [NEXT_REQUEST_META]?: { initURL?: unknown }; +}; + +/** + * Converts a Pages Router request into request data for events. + * + * Next.js strips `basePath` from `req.url` before running pages and API routes, but keeps the URL as it was originally + * requested in its internal request meta (`initURL`). We use that one so the reported URL matches what was requested. + */ +export function pagesRouterRequestToRequestData(req: IncomingMessage): RequestEventData { + const requestData = httpRequestToRequestData(req); + + const originalUrl = getOriginalPathAndQuery(req); + if (!originalUrl || originalUrl === req.url) { + return requestData; + } + + const { url, query_string } = httpRequestToRequestData({ + url: originalUrl, + headers: req.headers, + socket: req.socket, + }); + + return { ...requestData, url, query_string }; +} + +function getOriginalPathAndQuery(req: RequestWithNextMeta): string | undefined { + const initUrl = req[NEXT_REQUEST_META]?.initURL; + if (typeof initUrl !== 'string') { + return undefined; + } + + // `initURL` can be absolute, but its origin is built from the Next.js server's own hostname and port rather than the + // request headers, so we only keep path and query and let the headers decide the origin like everywhere else. + try { + const { pathname, search } = new URL(initUrl, 'http://n'); + return `${pathname}${search}`; + } catch { + return undefined; + } +} diff --git a/packages/nextjs/src/common/utils/wrapperUtils.ts b/packages/nextjs/src/common/utils/wrapperUtils.ts index f340b1e69bf8..a0e6259d1404 100644 --- a/packages/nextjs/src/common/utils/wrapperUtils.ts +++ b/packages/nextjs/src/common/utils/wrapperUtils.ts @@ -6,11 +6,11 @@ import { getIsolationScope, getRootSpan, getTraceData, - httpRequestToRequestData, isThenable, } from '@sentry/core'; import type { IncomingMessage, ServerResponse } from 'http'; import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../span-attributes-with-logic-attached'; +import { pagesRouterRequestToRequestData } from './pagesRouterRequestToRequestData'; /** * Wraps a function that potentially throws. If it does, the error is passed to `captureException` and rethrown. @@ -68,7 +68,7 @@ export function withTracedServerSideDataFetcher Pr this: unknown, ...args: Parameters ): Promise<{ data: ReturnType; sentryTrace?: string; baggage?: string }> { - const normalizedRequest = httpRequestToRequestData(req); + const normalizedRequest = pagesRouterRequestToRequestData(req); getCurrentScope().setTransactionName(`${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`); getIsolationScope().setSDKProcessingMetadata({ normalizedRequest }); diff --git a/packages/nextjs/test/common/utils/pagesRouterRequestToRequestData.test.ts b/packages/nextjs/test/common/utils/pagesRouterRequestToRequestData.test.ts new file mode 100644 index 000000000000..8c730d180f62 --- /dev/null +++ b/packages/nextjs/test/common/utils/pagesRouterRequestToRequestData.test.ts @@ -0,0 +1,56 @@ +import type { IncomingMessage } from 'http'; +import { describe, expect, it } from 'vitest'; +import { pagesRouterRequestToRequestData } from '../../../src/common/utils/pagesRouterRequestToRequestData'; + +function createRequest(url: string, initURL?: unknown): IncomingMessage { + const req = { + method: 'GET', + url, + headers: { host: 'example.com' }, + socket: {}, + } as unknown as IncomingMessage; + + if (initURL !== undefined) { + (req as unknown as Record)[Symbol.for('NextInternalRequestMeta')] = { initURL }; + } + + return req; +} + +describe('pagesRouterRequestToRequestData', () => { + it('uses `req.url` when there is no Next.js request meta', () => { + expect(pagesRouterRequestToRequestData(createRequest('/foo/bar?q=1'))).toMatchObject({ + url: 'http://example.com/foo/bar?q=1', + query_string: 'q=1', + method: 'GET', + }); + }); + + it('restores the basePath from a relative `initURL`', () => { + expect(pagesRouterRequestToRequestData(createRequest('/foo/bar?q=1', '/base/foo/bar?q=1'))).toMatchObject({ + url: 'http://example.com/base/foo/bar?q=1', + query_string: 'q=1', + method: 'GET', + }); + }); + + it('restores the basePath for the basePath root', () => { + expect(pagesRouterRequestToRequestData(createRequest('/', '/base'))).toMatchObject({ + url: 'http://example.com/base', + }); + }); + + it('takes only path and query from an absolute `initURL` and keeps the origin from the request headers', () => { + expect( + pagesRouterRequestToRequestData(createRequest('/foo/bar', 'http://localhost:3000/base/foo/bar')), + ).toMatchObject({ + url: 'http://example.com/base/foo/bar', + }); + }); + + it('ignores a non-string `initURL`', () => { + expect(pagesRouterRequestToRequestData(createRequest('/foo/bar', 123))).toMatchObject({ + url: 'http://example.com/foo/bar', + }); + }); +});