Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nextjs): Add route handler instrumentation #8832

Merged
merged 17 commits into from
Aug 30, 2023
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