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