Skip to content
Open
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
@@ -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');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Page() {
return <p>This page should never render</p>;
}

export async function getServerSideProps() {
throw new Error('Pages Router getServerSideProps error with basePath');
}
Original file line number Diff line number Diff line change
@@ -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');
});
Original file line number Diff line number Diff line change
@@ -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'];
Expand Down Expand Up @@ -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 });
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import {
getActiveSpan,
getCurrentScope,
getRootSpan,
httpRequestToRequestData,
objectify,
setCapturedScopesOnSpan,
withIsolationScope,
} from '@sentry/core';
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 & {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
4 changes: 2 additions & 2 deletions packages/nextjs/src/common/utils/wrapperUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -68,7 +68,7 @@ export function withTracedServerSideDataFetcher<F extends (...args: any[]) => Pr
this: unknown,
...args: Parameters<F>
): Promise<{ data: ReturnType<F>; sentryTrace?: string; baggage?: string }> {
const normalizedRequest = httpRequestToRequestData(req);
const normalizedRequest = pagesRouterRequestToRequestData(req);
getCurrentScope().setTransactionName(`${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`);
getIsolationScope().setSDKProcessingMetadata({ normalizedRequest });

Expand Down
Original file line number Diff line number Diff line change
@@ -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, unknown>)[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',
});
});
});
Loading