Skip to content

Commit 723285f

Browse files
author
Luca Forstner
authored
feat(nextjs): Add route handler instrumentation (#8832)
1 parent aab71b1 commit 723285f

File tree

11 files changed

+324
-7
lines changed

11 files changed

+324
-7
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { NextResponse } from 'next/server';
2+
3+
export const runtime = 'edge';
4+
5+
export async function PATCH() {
6+
return NextResponse.json({ name: 'John Doe' }, { status: 401 });
7+
}
8+
9+
export async function DELETE() {
10+
throw new Error('route-handler-edge-error');
11+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export async function PUT() {
2+
throw new Error('route-handler-error');
3+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { NextResponse } from 'next/server';
2+
3+
export async function GET() {
4+
return NextResponse.json({ name: 'John Doe' });
5+
}
6+
7+
export async function POST() {
8+
return NextResponse.json({ name: 'John Doe' }, { status: 404 });
9+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { test, expect } from '@playwright/test';
2+
import { waitForTransaction, waitForError } from '../event-proxy-server';
3+
4+
test('Should create a transaction for route handlers', async ({ request }) => {
5+
const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
6+
return transactionEvent?.transaction === 'GET /route-handlers/[param]';
7+
});
8+
9+
const response = await request.get('/route-handlers/foo');
10+
expect(await response.json()).toStrictEqual({ name: 'John Doe' });
11+
12+
const routehandlerTransaction = await routehandlerTransactionPromise;
13+
14+
expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok');
15+
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server');
16+
});
17+
18+
test('Should create a transaction for route handlers and correctly set span status depending on http status', async ({
19+
request,
20+
}) => {
21+
const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
22+
return transactionEvent?.transaction === 'POST /route-handlers/[param]';
23+
});
24+
25+
const response = await request.post('/route-handlers/bar');
26+
expect(await response.json()).toStrictEqual({ name: 'John Doe' });
27+
28+
const routehandlerTransaction = await routehandlerTransactionPromise;
29+
30+
expect(routehandlerTransaction.contexts?.trace?.status).toBe('not_found');
31+
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server');
32+
});
33+
34+
test('Should record exceptions and transactions for faulty route handlers', async ({ request }) => {
35+
const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => {
36+
return errorEvent?.exception?.values?.[0]?.value === 'route-handler-error';
37+
});
38+
39+
const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
40+
return transactionEvent?.transaction === 'PUT /route-handlers/[param]/error';
41+
});
42+
43+
await request.put('/route-handlers/baz/error').catch(() => {
44+
// noop
45+
});
46+
47+
const routehandlerTransaction = await routehandlerTransactionPromise;
48+
const routehandlerError = await errorEventPromise;
49+
50+
expect(routehandlerTransaction.contexts?.trace?.status).toBe('internal_error');
51+
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server');
52+
53+
expect(routehandlerError.exception?.values?.[0].value).toBe('route-handler-error');
54+
expect(routehandlerError.tags?.transaction).toBe('PUT /route-handlers/[param]/error');
55+
});
56+
57+
test.describe('Edge runtime', () => {
58+
test('should create a transaction for route handlers', async ({ request }) => {
59+
const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
60+
return transactionEvent?.transaction === 'PATCH /route-handlers/[param]/edge';
61+
});
62+
63+
const response = await request.patch('/route-handlers/bar/edge');
64+
expect(await response.json()).toStrictEqual({ name: 'John Doe' });
65+
66+
const routehandlerTransaction = await routehandlerTransactionPromise;
67+
68+
expect(routehandlerTransaction.contexts?.trace?.status).toBe('unauthenticated');
69+
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server');
70+
});
71+
72+
test('should record exceptions and transactions for faulty route handlers', async ({ request }) => {
73+
const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => {
74+
return errorEvent?.exception?.values?.[0]?.value === 'route-handler-edge-error';
75+
});
76+
77+
const routehandlerTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
78+
return transactionEvent?.transaction === 'DELETE /route-handlers/[param]/edge';
79+
});
80+
81+
await request.delete('/route-handlers/baz/edge').catch(() => {
82+
// noop
83+
});
84+
85+
const routehandlerTransaction = await routehandlerTransactionPromise;
86+
const routehandlerError = await errorEventPromise;
87+
88+
expect(routehandlerTransaction.contexts?.trace?.status).toBe('internal_error');
89+
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server');
90+
expect(routehandlerTransaction.contexts?.runtime?.name).toBe('edge');
91+
92+
expect(routehandlerError.exception?.values?.[0].value).toBe('route-handler-edge-error');
93+
expect(routehandlerError.contexts?.runtime?.name).toBe('edge');
94+
});
95+
});

packages/nextjs/rollup.npm.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export default [
3030
'src/config/templates/requestAsyncStorageShim.ts',
3131
'src/config/templates/sentryInitWrapperTemplate.ts',
3232
'src/config/templates/serverComponentWrapperTemplate.ts',
33+
'src/config/templates/routeHandlerWrapperTemplate.ts',
3334
],
3435

3536
packageSpecificConfig: {

packages/nextjs/src/common/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export {
3636

3737
export { wrapServerComponentWithSentry } from './wrapServerComponentWithSentry';
3838

39+
export { wrapRouteHandlerWithSentry } from './wrapRouteHandlerWithSentry';
40+
3941
export { wrapApiHandlerWithSentryVercelCrons } from './wrapApiHandlerWithSentryVercelCrons';
4042

4143
export { wrapMiddlewareWithSentry } from './wrapMiddlewareWithSentry';

packages/nextjs/src/common/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ export type ServerComponentContext = {
88
baggageHeader?: string;
99
};
1010

11+
export interface RouteHandlerContext {
12+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
13+
parameterizedRoute: string;
14+
sentryTraceHeader?: string;
15+
baggageHeader?: string;
16+
}
17+
1118
export type VercelCronsConfig = { path?: string; schedule?: string }[] | undefined;
1219

1320
// The `NextApiHandler` and `WrappedNextApiHandler` types are the same as the official `NextApiHandler` type, except:
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { addTracingExtensions, captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core';
2+
import { addExceptionMechanism, tracingContextFromHeaders } from '@sentry/utils';
3+
4+
import { isRedirectNavigationError } from './nextNavigationErrorUtils';
5+
import type { RouteHandlerContext } from './types';
6+
import { platformSupportsStreaming } from './utils/platformSupportsStreaming';
7+
8+
/**
9+
* Wraps a Next.js route handler with performance and error instrumentation.
10+
*/
11+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
12+
export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>(
13+
routeHandler: F,
14+
context: RouteHandlerContext,
15+
): (...args: Parameters<F>) => ReturnType<F> extends Promise<unknown> ? ReturnType<F> : Promise<ReturnType<F>> {
16+
addTracingExtensions();
17+
18+
const { method, parameterizedRoute, baggageHeader, sentryTraceHeader } = context;
19+
20+
return new Proxy(routeHandler, {
21+
apply: (originalFunction, thisArg, args) => {
22+
return runWithAsyncContext(async () => {
23+
const hub = getCurrentHub();
24+
const currentScope = hub.getScope();
25+
26+
const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders(
27+
sentryTraceHeader,
28+
baggageHeader,
29+
);
30+
currentScope.setPropagationContext(propagationContext);
31+
32+
let res;
33+
try {
34+
res = await trace(
35+
{
36+
op: 'http.server',
37+
name: `${method} ${parameterizedRoute}`,
38+
status: 'ok',
39+
...traceparentData,
40+
metadata: {
41+
source: 'route',
42+
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
43+
},
44+
},
45+
async span => {
46+
const response: Response = await originalFunction.apply(thisArg, args);
47+
48+
try {
49+
span?.setHttpStatus(response.status);
50+
} catch {
51+
// best effort
52+
}
53+
54+
return response;
55+
},
56+
error => {
57+
// Next.js throws errors when calling `redirect()`. We don't wanna report these.
58+
if (!isRedirectNavigationError(error)) {
59+
captureException(error, scope => {
60+
scope.addEventProcessor(event => {
61+
addExceptionMechanism(event, {
62+
handled: false,
63+
});
64+
return event;
65+
});
66+
67+
return scope;
68+
});
69+
}
70+
},
71+
);
72+
} finally {
73+
if (!platformSupportsStreaming() || process.env.NEXT_RUNTIME === 'edge') {
74+
// 1. Edge tranpsort requires manual flushing
75+
// 2. Lambdas require manual flushing to prevent execution freeze before the event is sent
76+
await flush(1000);
77+
}
78+
}
79+
80+
return res;
81+
});
82+
},
83+
});
84+
}

packages/nextjs/src/config/loaders/wrappingLoader.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,15 @@ const serverComponentWrapperTemplatePath = path.resolve(
4040
);
4141
const serverComponentWrapperTemplateCode = fs.readFileSync(serverComponentWrapperTemplatePath, { encoding: 'utf8' });
4242

43+
const routeHandlerWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'routeHandlerWrapperTemplate.js');
44+
const routeHandlerWrapperTemplateCode = fs.readFileSync(routeHandlerWrapperTemplatePath, { encoding: 'utf8' });
45+
4346
type LoaderOptions = {
4447
pagesDir: string;
4548
appDir: string;
4649
pageExtensionRegex: string;
4750
excludeServerRoutes: Array<RegExp | string>;
48-
wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'sentry-init';
51+
wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'sentry-init' | 'route-handler';
4952
sentryConfigFilePath?: string;
5053
vercelCronsConfig?: VercelCronsConfig;
5154
};
@@ -143,14 +146,14 @@ export default function wrappingLoader(
143146

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

175-
templateCode = serverComponentWrapperTemplateCode;
178+
if (wrappingTargetKind === 'server-component') {
179+
templateCode = serverComponentWrapperTemplateCode;
180+
} else {
181+
templateCode = routeHandlerWrapperTemplateCode;
182+
}
176183

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

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

204211
if (componentTypeMatch && componentTypeMatch[1]) {
205212
let componentType;
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// @ts-ignore Because we cannot be sure if the RequestAsyncStorage module exists (it is not part of the Next.js public
2+
// API) we use a shim if it doesn't exist. The logic for this is in the wrapping loader.
3+
// eslint-disable-next-line import/no-unresolved
4+
import { requestAsyncStorage } from '__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__';
5+
// @ts-ignore See above
6+
// eslint-disable-next-line import/no-unresolved
7+
import * as routeModule from '__SENTRY_WRAPPING_TARGET_FILE__';
8+
// eslint-disable-next-line import/no-extraneous-dependencies
9+
import * as Sentry from '@sentry/nextjs';
10+
11+
import type { RequestAsyncStorage } from './requestAsyncStorageShim';
12+
13+
declare const requestAsyncStorage: RequestAsyncStorage;
14+
15+
declare const routeModule: {
16+
default: unknown;
17+
GET?: (...args: unknown[]) => unknown;
18+
POST?: (...args: unknown[]) => unknown;
19+
PUT?: (...args: unknown[]) => unknown;
20+
PATCH?: (...args: unknown[]) => unknown;
21+
DELETE?: (...args: unknown[]) => unknown;
22+
HEAD?: (...args: unknown[]) => unknown;
23+
OPTIONS?: (...args: unknown[]) => unknown;
24+
};
25+
26+
function wrapHandler<T>(handler: T, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'): T {
27+
// Running the instrumentation code during the build phase will mark any function as "dynamic" because we're accessing
28+
// the Request object. We do not want to turn handlers dynamic so we skip instrumentation in the build phase.
29+
if (process.env.NEXT_PHASE === 'phase-production-build') {
30+
return handler;
31+
}
32+
33+
if (typeof handler !== 'function') {
34+
return handler;
35+
}
36+
37+
return new Proxy(handler, {
38+
apply: (originalFunction, thisArg, args) => {
39+
let sentryTraceHeader: string | undefined | null = undefined;
40+
let baggageHeader: string | undefined | null = undefined;
41+
42+
// We try-catch here just in case the API around `requestAsyncStorage` changes unexpectedly since it is not public API
43+
try {
44+
const requestAsyncStore = requestAsyncStorage.getStore();
45+
sentryTraceHeader = requestAsyncStore?.headers.get('sentry-trace') ?? undefined;
46+
baggageHeader = requestAsyncStore?.headers.get('baggage') ?? undefined;
47+
} catch (e) {
48+
/** empty */
49+
}
50+
51+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
52+
return Sentry.wrapRouteHandlerWithSentry(originalFunction as any, {
53+
method,
54+
parameterizedRoute: '__ROUTE__',
55+
sentryTraceHeader,
56+
baggageHeader,
57+
}).apply(thisArg, args);
58+
},
59+
});
60+
}
61+
62+
export const GET = wrapHandler(routeModule.GET, 'GET');
63+
export const POST = wrapHandler(routeModule.POST, 'POST');
64+
export const PUT = wrapHandler(routeModule.PUT, 'PUT');
65+
export const PATCH = wrapHandler(routeModule.PATCH, 'PATCH');
66+
export const DELETE = wrapHandler(routeModule.DELETE, 'DELETE');
67+
export const HEAD = wrapHandler(routeModule.HEAD, 'HEAD');
68+
export const OPTIONS = wrapHandler(routeModule.OPTIONS, 'OPTIONS');
69+
70+
// Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to
71+
// not include anything whose name matchs something we've explicitly exported above.
72+
// @ts-ignore See above
73+
// eslint-disable-next-line import/no-unresolved
74+
export * from '__SENTRY_WRAPPING_TARGET_FILE__';
75+
export default routeModule.default;

0 commit comments

Comments
 (0)