Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';

export const runtime = 'edge';

export async function PATCH() {
return NextResponse.json({ name: 'John Doe' }, { status: 401 });
}

export async function DELETE() {
throw new Error('route-handler-edge-error');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export async function PUT() {
throw new Error('route-handler-error');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';

export async function GET() {
return NextResponse.json({ name: 'John Doe' });
}

export async function POST() {
return NextResponse.json({ name: 'John Doe' }, { status: 404 });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { test, expect } from '@playwright/test';
import { waitForTransaction, waitForError } from '../event-proxy-server';

test('Should create a transaction for route handlers', async ({ request }) => {
const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
return transactionEvent?.transaction === 'GET /route-handlers/[param]';
});

const response = await request.get('/route-handlers/foo');
expect(await response.json()).toStrictEqual({ name: 'John Doe' });

const routehandlerTransaction = await routehandlerTransactionPromise;

expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok');
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server');
});

test('Should create a transaction for route handlers and correctly set span status depending on http status', async ({
request,
}) => {
const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
return transactionEvent?.transaction === 'POST /route-handlers/[param]';
});

const response = await request.post('/route-handlers/bar');
expect(await response.json()).toStrictEqual({ name: 'John Doe' });

const routehandlerTransaction = await routehandlerTransactionPromise;

expect(routehandlerTransaction.contexts?.trace?.status).toBe('not_found');
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server');
});

test('Should record exceptions and transactions for faulty route handlers', async ({ request }) => {
const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'route-handler-error';
});

const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
return transactionEvent?.transaction === 'PUT /route-handlers/[param]/error';
});

await request.put('/route-handlers/baz/error').catch(() => {
// noop
});

const routehandlerTransaction = await routehandlerTransactionPromise;
const routehandlerError = await errorEventPromise;

expect(routehandlerTransaction.contexts?.trace?.status).toBe('internal_error');
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server');

expect(routehandlerError.exception?.values?.[0].value).toBe('route-handler-error');
expect(routehandlerError.tags?.transaction).toBe('PUT /route-handlers/[param]/error');
});

test.describe('Edge runtime', () => {
test('should create a transaction for route handlers', async ({ request }) => {
const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
return transactionEvent?.transaction === 'PATCH /route-handlers/[param]/edge';
});

const response = await request.patch('/route-handlers/bar/edge');
expect(await response.json()).toStrictEqual({ name: 'John Doe' });

const routehandlerTransaction = await routehandlerTransactionPromise;

expect(routehandlerTransaction.contexts?.trace?.status).toBe('unauthenticated');
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server');
});

test('should record exceptions and transactions for faulty route handlers', async ({ request }) => {
const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'route-handler-edge-error';
});

const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
return transactionEvent?.transaction === 'DELETE /route-handlers/[param]/edge';
});

await request.delete('/route-handlers/baz/edge').catch(() => {
// noop
});

const routehandlerTransaction = await routehandlerTransactionPromise;
const routehandlerError = await errorEventPromise;

expect(routehandlerTransaction.contexts?.trace?.status).toBe('internal_error');
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server');
expect(routehandlerTransaction.contexts?.runtime?.name).toBe('edge');

expect(routehandlerError.exception?.values?.[0].value).toBe('route-handler-edge-error');
expect(routehandlerError.contexts?.runtime?.name).toBe('edge');
});
});
1 change: 1 addition & 0 deletions packages/nextjs/rollup.npm.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default [
'src/config/templates/requestAsyncStorageShim.ts',
'src/config/templates/sentryInitWrapperTemplate.ts',
'src/config/templates/serverComponentWrapperTemplate.ts',
'src/config/templates/routeHandlerWrapperTemplate.ts',
],

packageSpecificConfig: {
Expand Down
2 changes: 2 additions & 0 deletions packages/nextjs/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export {

export { wrapServerComponentWithSentry } from './wrapServerComponentWithSentry';

export { wrapRouteHandlerWithSentry } from './wrapRouteHandlerWithSentry';

export { wrapApiHandlerWithSentryVercelCrons } from './wrapApiHandlerWithSentryVercelCrons';

export { wrapMiddlewareWithSentry } from './wrapMiddlewareWithSentry';
7 changes: 7 additions & 0 deletions packages/nextjs/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ export type ServerComponentContext = {
baggageHeader?: string;
};

export interface RouteHandlerContext {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
parameterizedRoute: string;
sentryTraceHeader?: string;
baggageHeader?: string;
}

export type VercelCronsConfig = { path?: string; schedule?: string }[] | undefined;

// The `NextApiHandler` and `WrappedNextApiHandler` types are the same as the official `NextApiHandler` type, except:
Expand Down
84 changes: 84 additions & 0 deletions packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { addTracingExtensions, captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core';
import { addExceptionMechanism, tracingContextFromHeaders } from '@sentry/utils';

import { isRedirectNavigationError } from './nextNavigationErrorUtils';
import type { RouteHandlerContext } from './types';
import { platformSupportsStreaming } from './utils/platformSupportsStreaming';

/**
* Wraps a Next.js route handler with performance and error instrumentation.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>(
routeHandler: F,
context: RouteHandlerContext,
): (...args: Parameters<F>) => ReturnType<F> extends Promise<unknown> ? ReturnType<F> : Promise<ReturnType<F>> {
addTracingExtensions();

const { method, parameterizedRoute, baggageHeader, sentryTraceHeader } = context;

return new Proxy(routeHandler, {
apply: (originalFunction, thisArg, args) => {
return runWithAsyncContext(async () => {
const hub = getCurrentHub();
const currentScope = hub.getScope();

const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders(
sentryTraceHeader,
baggageHeader,
);
currentScope.setPropagationContext(propagationContext);

let res;
try {
res = await trace(
{
op: 'http.server',
name: `${method} ${parameterizedRoute}`,
status: 'ok',
...traceparentData,
metadata: {
source: 'route',
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
},
},
async span => {
const response: Response = await originalFunction.apply(thisArg, args);

try {
span?.setHttpStatus(response.status);
} catch {
// best effort
}

return response;
},
error => {
// Next.js throws errors when calling `redirect()`. We don't wanna report these.
if (!isRedirectNavigationError(error)) {
captureException(error, scope => {
scope.addEventProcessor(event => {
addExceptionMechanism(event, {
handled: false,
});
return event;
});

return scope;
});
}
},
);
} finally {
if (!platformSupportsStreaming() || process.env.NEXT_RUNTIME === 'edge') {
// 1. Edge tranpsort requires manual flushing
// 2. Lambdas require manual flushing to prevent execution freeze before the event is sent
await flush(1000);
}
}

return res;
});
},
});
}
17 changes: 12 additions & 5 deletions packages/nextjs/src/config/loaders/wrappingLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,15 @@ const serverComponentWrapperTemplatePath = path.resolve(
);
const serverComponentWrapperTemplateCode = fs.readFileSync(serverComponentWrapperTemplatePath, { encoding: 'utf8' });

const routeHandlerWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'routeHandlerWrapperTemplate.js');
const routeHandlerWrapperTemplateCode = fs.readFileSync(routeHandlerWrapperTemplatePath, { encoding: 'utf8' });

type LoaderOptions = {
pagesDir: string;
appDir: string;
pageExtensionRegex: string;
excludeServerRoutes: Array<RegExp | string>;
wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'sentry-init';
wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'sentry-init' | 'route-handler';
sentryConfigFilePath?: string;
vercelCronsConfig?: VercelCronsConfig;
};
Expand Down Expand Up @@ -143,14 +146,14 @@ export default function wrappingLoader(

// Inject the route and the path to the file we're wrapping into the template
templateCode = templateCode.replace(/__ROUTE__/g, parameterizedPagesRoute.replace(/\\/g, '\\\\'));
} else if (wrappingTargetKind === 'server-component') {
} else if (wrappingTargetKind === 'server-component' || wrappingTargetKind === 'route-handler') {
// Get the parameterized route name from this page's filepath
const parameterizedPagesRoute = path.posix
.normalize(path.relative(appDir, this.resourcePath))
// Add a slash at the beginning
.replace(/(.*)/, '/$1')
// Pull off the file name
.replace(/\/[^/]+\.(js|jsx|tsx)$/, '')
.replace(/\/[^/]+\.(js|ts|jsx|tsx)$/, '')
// Remove routing groups: https://beta.nextjs.org/docs/routing/defining-routes#example-creating-multiple-root-layouts
.replace(/\/(\(.*?\)\/)+/g, '/')
// In case all of the above have left us with an empty string (which will happen if we're dealing with the
Expand All @@ -172,7 +175,11 @@ export default function wrappingLoader(
return;
}

templateCode = serverComponentWrapperTemplateCode;
if (wrappingTargetKind === 'server-component') {
templateCode = serverComponentWrapperTemplateCode;
} else {
templateCode = routeHandlerWrapperTemplateCode;
}

if (requestAsyncStorageModuleExists) {
templateCode = templateCode.replace(
Expand All @@ -199,7 +206,7 @@ export default function wrappingLoader(

const componentTypeMatch = path.posix
.normalize(path.relative(appDir, this.resourcePath))
.match(/\/?([^/]+)\.(?:js|jsx|tsx)$/);
.match(/\/?([^/]+)\.(?:js|ts|jsx|tsx)$/);

if (componentTypeMatch && componentTypeMatch[1]) {
let componentType;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// @ts-ignore Because we cannot be sure if the RequestAsyncStorage module exists (it is not part of the Next.js public
// API) we use a shim if it doesn't exist. The logic for this is in the wrapping loader.
// eslint-disable-next-line import/no-unresolved
import { requestAsyncStorage } from '__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__';
// @ts-ignore See above
// eslint-disable-next-line import/no-unresolved
import * as routeModule from '__SENTRY_WRAPPING_TARGET_FILE__';
// eslint-disable-next-line import/no-extraneous-dependencies
import * as Sentry from '@sentry/nextjs';

import type { RequestAsyncStorage } from './requestAsyncStorageShim';

declare const requestAsyncStorage: RequestAsyncStorage;

declare const routeModule: {
default: unknown;
GET?: (...args: unknown[]) => unknown;
POST?: (...args: unknown[]) => unknown;
PUT?: (...args: unknown[]) => unknown;
PATCH?: (...args: unknown[]) => unknown;
DELETE?: (...args: unknown[]) => unknown;
HEAD?: (...args: unknown[]) => unknown;
OPTIONS?: (...args: unknown[]) => unknown;
};

function wrapHandler<T>(handler: T, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'): T {
// Running the instrumentation code during the build phase will mark any function as "dynamic" because we're accessing
// the Request object. We do not want to turn handlers dynamic so we skip instrumentation in the build phase.
if (process.env.NEXT_PHASE === 'phase-production-build') {
return handler;
}

if (typeof handler !== 'function') {
return handler;
}

return new Proxy(handler, {
apply: (originalFunction, thisArg, args) => {
let sentryTraceHeader: string | undefined | null = undefined;
let baggageHeader: string | undefined | null = undefined;

// We try-catch here just in case the API around `requestAsyncStorage` changes unexpectedly since it is not public API
try {
const requestAsyncStore = requestAsyncStorage.getStore();
sentryTraceHeader = requestAsyncStore?.headers.get('sentry-trace') ?? undefined;
baggageHeader = requestAsyncStore?.headers.get('baggage') ?? undefined;
} catch (e) {
/** empty */
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
return Sentry.wrapRouteHandlerWithSentry(originalFunction as any, {
method,
parameterizedRoute: '__ROUTE__',
sentryTraceHeader,
baggageHeader,
}).apply(thisArg, args);
},
});
}

export const GET = wrapHandler(routeModule.GET, 'GET');
export const POST = wrapHandler(routeModule.POST, 'POST');
export const PUT = wrapHandler(routeModule.PUT, 'PUT');
export const PATCH = wrapHandler(routeModule.PATCH, 'PATCH');
export const DELETE = wrapHandler(routeModule.DELETE, 'DELETE');
export const HEAD = wrapHandler(routeModule.HEAD, 'HEAD');
export const OPTIONS = wrapHandler(routeModule.OPTIONS, 'OPTIONS');

// Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to
// not include anything whose name matchs something we've explicitly exported above.
// @ts-ignore See above
// eslint-disable-next-line import/no-unresolved
export * from '__SENTRY_WRAPPING_TARGET_FILE__';
export default routeModule.default;
Loading