Skip to content

feat(nextjs): Use OTEL instrumentation for route handlers #13887

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

Merged
merged 6 commits into from
Oct 8, 2024
Merged
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
Expand Up @@ -54,9 +54,9 @@ test('Should record exceptions and transactions for faulty route handlers', asyn
expect(routehandlerError.tags?.['my-isolated-tag']).toBe(true);
expect(routehandlerError.tags?.['my-global-scope-isolated-tag']).not.toBeDefined();

expect(routehandlerTransaction.contexts?.trace?.status).toBe('unknown_error');
expect(routehandlerTransaction.contexts?.trace?.status).toBe('internal_error');
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server');
expect(routehandlerTransaction.contexts?.trace?.origin).toBe('auto.function.nextjs');
expect(routehandlerTransaction.contexts?.trace?.origin).toContain('auto.http.otel.http');

expect(routehandlerError.exception?.values?.[0].value).toBe('route-handler-error');

Expand Down
127 changes: 50 additions & 77 deletions packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
SPAN_STATUS_ERROR,
Scope,
captureException,
getActiveSpan,
getCapturedScopesOnSpan,
getRootSpan,
handleCallbackErrors,
setHttpStatus,
startSpan,
setCapturedScopesOnSpan,
withIsolationScope,
withScope,
} from '@sentry/core';
import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils';
import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils';

import type { RouteHandlerContext } from './types';
import { flushSafelyWithTimeout } from './utils/responseEnd';
import {
commonObjectToIsolationScope,
commonObjectToPropagationContext,
escapeNextjsTracing,
} from './utils/tracingUtils';
import { vercelWaitUntil } from './utils/vercelWaitUntil';

import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils';

import { isRedirectNavigationError } from './nextNavigationErrorUtils';
import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils';

/**
* Wraps a Next.js App Router Route handler with Sentry error and performance instrumentation.
Expand All @@ -34,74 +30,51 @@ export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>(
const { method, parameterizedRoute, headers } = context;

return new Proxy(routeHandler, {
apply: (originalFunction, thisArg, args) => {
return escapeNextjsTracing(() => {
const isolationScope = commonObjectToIsolationScope(headers);
apply: async (originalFunction, thisArg, args) => {
const isolationScope = commonObjectToIsolationScope(headers);

const completeHeadersDict: Record<string, string> = headers ? winterCGHeadersToDict(headers) : {};
const completeHeadersDict: Record<string, string> = headers ? winterCGHeadersToDict(headers) : {};

isolationScope.setSDKProcessingMetadata({
request: {
headers: completeHeadersDict,
},
});
isolationScope.setSDKProcessingMetadata({
request: {
headers: completeHeadersDict,
},
});

const incomingPropagationContext = propagationContextFromHeaders(
completeHeadersDict['sentry-trace'],
completeHeadersDict['baggage'],
);
const incomingPropagationContext = propagationContextFromHeaders(
completeHeadersDict['sentry-trace'],
completeHeadersDict['baggage'],
);

const propagationContext = commonObjectToPropagationContext(headers, incomingPropagationContext);
const propagationContext = commonObjectToPropagationContext(headers, incomingPropagationContext);

return withIsolationScope(isolationScope, () => {
return withScope(async scope => {
scope.setTransactionName(`${method} ${parameterizedRoute}`);
scope.setPropagationContext(propagationContext);
try {
return startSpan(
{
name: `${method} ${parameterizedRoute}`,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs',
},
forceTransaction: true,
},
async span => {
const response: Response = await handleCallbackErrors(
() => originalFunction.apply(thisArg, args),
error => {
// Next.js throws errors when calling `redirect()`. We don't wanna report these.
if (isRedirectNavigationError(error)) {
// Don't do anything
} else if (isNotFoundNavigationError(error) && span) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
} else {
captureException(error, {
mechanism: {
handled: false,
},
});
}
},
);
const activeSpan = getActiveSpan();
if (activeSpan) {
const rootSpan = getRootSpan(activeSpan);
rootSpan.setAttribute('sentry.route_handler', true);
const { scope } = getCapturedScopesOnSpan(rootSpan);
setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope);
}

try {
if (span && response.status) {
setHttpStatus(span, response.status);
}
} catch {
// best effort - response may be undefined?
}

return response;
},
);
} finally {
vercelWaitUntil(flushSafelyWithTimeout());
}
});
return withIsolationScope(isolationScope, () => {
return withScope(scope => {
scope.setTransactionName(`${method} ${parameterizedRoute}`);
scope.setPropagationContext(propagationContext);
return handleCallbackErrors(
() => originalFunction.apply(thisArg, args),
error => {
// Next.js throws errors when calling `redirect()`. We don't wanna report these.
if (isRedirectNavigationError(error)) {
// Don't do anything
} else {
captureException(error, {
mechanism: {
handled: false,
},
});
}
},
);
});
});
},
Expand Down
26 changes: 22 additions & 4 deletions packages/nextjs/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,10 @@ export function init(options: NodeOptions): NodeClient | undefined {
// We want to rename these spans because they look like "GET /path/to/route" and we already emit spans that look
// like this with our own http instrumentation.
if (spanAttributes?.['next.span_type'] === 'BaseServer.handleRequest') {
span.updateName('next server handler'); // This is all lowercase because the spans that Next.js emits by itself generally look like this.
const rootSpan = getRootSpan(span);
if (span !== rootSpan) {
span.updateName('next server handler'); // This is all lowercase because the spans that Next.js emits by itself generally look like this.
}
}
});

Expand All @@ -211,11 +214,13 @@ export function init(options: NodeOptions): NodeClient | undefined {
return null;
}

// We only want to use our HTTP integration/instrumentation for app router requests, which are marked with the `sentry.rsc` attribute.
// We only want to use our HTTP integration/instrumentation for app router requests,
// which are marked with the `sentry.rsc` or `sentry.route_handler` attribute.
if (
(event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.http.otel.http' ||
event.contexts?.trace?.data?.['next.span_type'] === 'BaseServer.handleRequest') &&
event.contexts?.trace?.data?.['sentry.rsc'] !== true
event.contexts?.trace?.data?.['sentry.rsc'] !== true &&
event.contexts?.trace?.data?.['sentry.route_handler'] !== true
) {
return null;
}
Expand Down Expand Up @@ -297,13 +302,26 @@ export function init(options: NodeOptions): NodeClient | undefined {
event.type === 'transaction' &&
event.transaction?.match(/^(RSC )?GET /) &&
event.contexts?.trace?.data?.['sentry.rsc'] === true &&
!event.contexts.trace.op
!event.contexts?.trace?.op
) {
event.contexts.trace.data = event.contexts.trace.data || {};
event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server';
event.contexts.trace.op = 'http.server';
}

// Enhance route handler transactions
if (event.type === 'transaction' && event.contexts?.trace?.data?.['sentry.route_handler'] === true) {
event.contexts.trace.data = event.contexts.trace.data || {};
event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server';
event.contexts.trace.op = 'http.server';
if (typeof event.contexts.trace.data[ATTR_HTTP_ROUTE] === 'string') {
// eslint-disable-next-line deprecation/deprecation
event.transaction = `${event.contexts.trace.data[SEMATTRS_HTTP_METHOD]} ${event.contexts.trace.data[
ATTR_HTTP_ROUTE
].replace(/\/route$/, '')}`;
}
}

return event;
}) satisfies EventProcessor,
{ id: 'NextjsTransactionEnhancer' },
Expand Down
Loading