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
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
50 changes: 50 additions & 0 deletions packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { addTracingExtensions, captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core';
import { RouteHandlerContext } from './types';
import { tracingContextFromHeaders } from '@sentry/utils';

export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>(
routeHandler: F,
context: RouteHandlerContext,
): F {
addTracingExtensions();

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

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

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

console.log({ traceparentData, baggageHeader, sentryTraceHeader });

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

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 @@ -41,12 +41,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 @@ -144,14 +147,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 @@ -173,7 +176,11 @@ export default function wrappingLoader(
return;
}

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

if (requestAsyncStorageModuleExists) {
templateCode = templateCode.replace(
Expand All @@ -197,7 +204,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,74 @@
// @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 */
}

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;
24 changes: 23 additions & 1 deletion packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ export function constructWebpackConfigFunction(
);
};

const isRouteHandlerResource = (resourcePath: string): boolean => {
const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath);
return (
normalizedAbsoluteResourcePath.startsWith(appDirPath + path.sep) &&
!!normalizedAbsoluteResourcePath.match(/[\\/]route\.(js|ts|jsx|tsx)$/)
);
};

if (isServer && userSentryOptions.autoInstrumentServerFunctions !== false) {
// It is very important that we insert our loaders at the beginning of the array because we expect any sort of transformations/transpilations (e.g. TS -> JS) to already have happened.

Expand Down Expand Up @@ -245,7 +253,7 @@ export function constructWebpackConfigFunction(
}

if (isServer && userSentryOptions.autoInstrumentAppDirectory !== false) {
// Wrap page server components
// Wrap server components
newConfig.module.rules.unshift({
test: isServerComponentResource,
use: [
Expand All @@ -258,6 +266,20 @@ export function constructWebpackConfigFunction(
},
],
});

// Wrap route handlers
newConfig.module.rules.unshift({
test: isRouteHandlerResource,
use: [
{
loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'),
options: {
...staticWrappingLoaderOptions,
wrappingTargetKind: 'route-handler',
},
},
],
});
}

if (isServer) {
Expand Down
Loading