Skip to content
Open
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 @@ -23,3 +23,19 @@ test('Sends exactly one transaction for a pages-router API route', async ({ requ

expect(apiRouteTransactions).toHaveLength(1);
});

test('Sends a well-formed transaction for a node-runtime pages-router API route', async ({ request }) => {
const transactionPromise = waitForTransaction('nextjs-pages-dir', transactionEvent => {
return transactionEvent?.transaction === 'GET /api/endpoint' && transactionEvent.contexts?.runtime?.name === 'node';
});

const response = await request.get('/api/endpoint');
expect(await response.json()).toStrictEqual({ name: 'John Doe' });

const transaction = await transactionPromise;

expect(transaction.contexts?.trace?.op).toBe('http.server');
expect(transaction.contexts?.trace?.status).toBe('ok');
expect(transaction.transaction_info?.source).toBe('route');
expect(transaction.contexts?.trace?.data?.['http.route']).toBe('/api/endpoint');
});
Original file line number Diff line number Diff line change
@@ -1,24 +1,18 @@
import {
captureException,
continueTrace,
debug,
getActiveSpan,
getCurrentScope,
getRootSpan,
httpRequestToRequestData,
isString,
isURLObjectRelative,
objectify,
parseStringToURLObject,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
setHttpStatus,
startSpanManual,
setCapturedScopesOnSpan,
withIsolationScope,
} from '@sentry/core';
import type { NextApiRequest } from 'next';
import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../span-attributes-with-logic-attached';
import type { AugmentedNextApiResponse, NextApiHandler } from '../types';
import { flushSafelyWithTimeout, waitUntil } from '../utils/responseEnd';
import { dropNextjsRootContext, escapeNextjsTracing } from '../utils/tracingUtils';
import { HTTP_ROUTE, SENTRY_KIND, URL_FULL, URL_PATH } from '@sentry/conventions/attributes';

export type AugmentedNextApiRequest = NextApiRequest & {
__withSentry_applied__?: boolean;
Expand All @@ -34,117 +28,85 @@ export type AugmentedNextApiRequest = NextApiRequest & {
*/
export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameterizedRoute: string): NextApiHandler {
return new Proxy(apiHandler, {
apply: (
apply: async (
wrappingTarget,
thisArg,
args: [AugmentedNextApiRequest | undefined, AugmentedNextApiResponse | undefined],
) => {
dropNextjsRootContext();
return escapeNextjsTracing(() => {
const [req, res] = args;
const [req, res] = args;
if (!req) {
debug.log(
`Wrapped API handler on route "${parameterizedRoute}" was not passed a request object. Will not instrument.`,
);
return wrappingTarget.apply(thisArg, args);
} else if (!res) {
debug.log(
`Wrapped API handler on route "${parameterizedRoute}" was not passed a response object. Will not instrument.`,
);
return wrappingTarget.apply(thisArg, args);
}

if (!req) {
debug.log(
`Wrapped API handler on route "${parameterizedRoute}" was not passed a request object. Will not instrument.`,
);
return wrappingTarget.apply(thisArg, args);
} else if (!res) {
debug.log(
`Wrapped API handler on route "${parameterizedRoute}" was not passed a response object. Will not instrument.`,
);
return wrappingTarget.apply(thisArg, args);
}

// Prevent double wrapping of the same request.
if (req.__withSentry_applied__) {
return wrappingTarget.apply(thisArg, args);
}
req.__withSentry_applied__ = true;
// Prevent double wrapping of the same request.
if (req.__withSentry_applied__) {
return wrappingTarget.apply(thisArg, args);
}

return withIsolationScope(isolationScope => {
// Normally, there is an active span here (from Next.js OTEL) and we just use that as parent
// Else, we manually continueTrace from the incoming headers
const continueTraceIfNoActiveSpan = getActiveSpan()
? <T>(_opts: unknown, callback: () => T) => callback()
: continueTrace;
req.__withSentry_applied__ = true;

return continueTraceIfNoActiveSpan(
{
sentryTrace:
req.headers && isString(req.headers['sentry-trace']) ? req.headers['sentry-trace'] : undefined,
baggage: req.headers?.baggage,
},
() => {
const reqMethod = `${(req.method || 'GET').toUpperCase()} `;
const normalizedRequest = httpRequestToRequestData(req);
return withIsolationScope(async isolationScope => {
const reqMethod = `${(req.method || 'GET').toUpperCase()} `;

isolationScope.setSDKProcessingMetadata({ normalizedRequest });
isolationScope.setTransactionName(`${reqMethod}${parameterizedRoute}`);
isolationScope.setSDKProcessingMetadata({ normalizedRequest: httpRequestToRequestData(req) });
isolationScope.setTransactionName(`${reqMethod}${parameterizedRoute}`);

const requestUrl = normalizedRequest.url || req.url;
const urlObject = requestUrl ? parseStringToURLObject(requestUrl) : undefined;
// We no longer create the transaction ourselves: it's the Next.js root span, which captured a different
// isolation scope than the one forked here. Bind this scope to that span so the request data and anything
// set on the scope during the handler (tags, breadcrumbs) land on the transaction.
const activeSpan = getActiveSpan();
const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;
if (rootSpan) {
setCapturedScopesOnSpan(rootSpan, getCurrentScope(), isolationScope);

return startSpanManual(
{
name: `${reqMethod}${parameterizedRoute}`,
op: 'http.server',
forceTransaction: true,
attributes: {
[SENTRY_KIND]: 'server',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nextjs',
[URL_FULL]: urlObject && !isURLObjectRelative(urlObject) ? urlObject.href : undefined,
[URL_PATH]: urlObject?.pathname,
[HTTP_ROUTE]: parameterizedRoute,
},
},
async span => {
// eslint-disable-next-line @typescript-eslint/unbound-method
res.end = new Proxy(res.end, {
apply(target, thisArg, argArray) {
setHttpStatus(span, res.statusCode);
span.end();
waitUntil(flushSafelyWithTimeout());
return target.apply(thisArg, argArray);
},
});
try {
return await wrappingTarget.apply(thisArg, args);
} catch (e) {
// In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
// store a seen flag on it. (Because of the one-way-on-Vercel-one-way-off-of-Vercel approach we've been forced
// to take, it can happen that the same thrown object gets caught in two different ways, and flagging it is a
// way to prevent it from actually being reported twice.)
const objectifiedErr = objectify(e);
// The `BaseServer.handleRequest` root span for a pages-router API route carries no `http.route`, so it would
// otherwise be named from the raw URL with a `url` source. Backfill the parameterized route so the transaction
// gets a `route` source and a parameterized `http.route`.
rootSpan.setAttribute(TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL, parameterizedRoute);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.

captureException(objectifiedErr, {
mechanism: {
type: 'auto.http.nextjs.api_handler',
handled: false,
data: {
wrapped_handler: wrappingTarget.name,
function: 'withSentry',
},
},
});
try {
const result = await wrappingTarget.apply(thisArg, args);

setHttpStatus(span, 500);
span.end();
// Flush non-blockingly so serverless runtimes (Vercel, Cloudflare) don't freeze before the event is sent
waitUntil(flushSafelyWithTimeout());

// we need to await the flush here to ensure that the error is captured
// as the runtime freezes as soon as the error is thrown below
await flushSafelyWithTimeout();
return result;
Comment thread
logaretm marked this conversation as resolved.
} catch (e) {
// In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
// store a seen flag on it. (Because of the one-way-on-Vercel-one-way-off-of-Vercel approach we've been forced
// to take, it can happen that the same thrown object gets caught in two different ways, and flagging it is a
// way to prevent it from actually being reported twice.)
const objectifiedErr = objectify(e);

// We rethrow here so that nextjs can do with the error whatever it would normally do. (Sometimes "whatever it
// would normally do" is to allow the error to bubble up to the global handlers - another reason we need to mark
// the error as already having been captured.)
throw objectifiedErr;
}
},
);
captureException(objectifiedErr, {
mechanism: {
type: 'auto.http.nextjs.api_handler',
handled: false,
data: {
wrapped_handler: wrappingTarget.name,
function: 'withSentry',
},
},
);
});
});

// we need to await the flush here to ensure that the error is captured
// as the runtime freezes as soon as the error is thrown below
await flushSafelyWithTimeout();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: Might make sense to flush in finally

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it double flush for the error path if we do it in finally? Right now we flush (non-blocking) on success and flush (blocking) on error.


// We rethrow here so that nextjs can do with the error whatever it would normally do. (Sometimes "whatever it
// would normally do" is to allow the error to bubble up to the global handlers - another reason we need to mark
// the error as already having been captured.)
throw objectifiedErr;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
});
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ export const TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION = 'sentry.drop_transaction
export const TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL = 'sentry.sentry_trace_backfill';

export const TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL = 'sentry.route_backfill';

export const ATTR_NEXT_PAGES_API_ROUTE_TYPE = 'executing api route (pages)';
42 changes: 42 additions & 0 deletions packages/nextjs/src/edge/enhanceRunHandlerRootSpan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { HTTP_METHOD, HTTP_REQUEST_METHOD } from '@sentry/conventions/attributes';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core';
import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes';
import { ATTR_NEXT_PAGES_API_ROUTE_TYPE } from '../common/span-attributes-with-logic-attached';

export interface MutableRootSpan {
attributes: Record<string, unknown>;
getName(): string | undefined;
setName(name: string): void;
setOp(op: string): void;
}

/**
* Normalizes name, op and source for the root span of a pages-router API route on the Edge runtime.
*
* We no longer create this transaction ourselves in `wrapApiHandlerWithSentry`, so the root span is the
* Next.js `Node.runHandler` span. Next.js names it `executing api route (pages) /some/route`, which we
* turn into a proper `${METHOD} ${route}` transaction with the `http.server` op and `route` source.
*
* Applied from both `preprocessEvent` (legacy transaction events) and `processSegmentSpan` (streamed spans),
* mirroring how `enhanceMiddlewareRootSpan` is wired.
*/
export function enhanceRunHandlerRootSpan(span: MutableRootSpan): void {
const { attributes } = span;

if (attributes[ATTR_NEXT_SPAN_TYPE] !== 'Node.runHandler') {
return;
}

const spanName = attributes[ATTR_NEXT_SPAN_NAME];
if (typeof spanName !== 'string' || !spanName.startsWith(ATTR_NEXT_PAGES_API_ROUTE_TYPE)) {
return;
}

span.setOp('http.server');
attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route';
Comment on lines +35 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The enhanceRunHandlerRootSpan function fails to set the SEMANTIC_ATTRIBUTE_SENTRY_OP attribute, causing the sentry.op attribute to be missing from some transaction events in the Edge runtime.
Severity: MEDIUM

Suggested Fix

In enhanceRunHandlerRootSpan, explicitly set the SEMANTIC_ATTRIBUTE_SENTRY_OP attribute on the span's attributes, similar to how it is done in enhanceHandleRequestRootSpan. This will ensure that sentry.op is correctly populated for both legacy transaction events and streamed spans.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/nextjs/src/edge/enhanceRunHandlerRootSpan.ts#L35-L36

Potential issue: In the Edge runtime, the `enhanceRunHandlerRootSpan` function only
calls `span.setOp()`, which does not set the `sentry.op` attribute for legacy
transaction events processed via the `preprocessEvent` path. This is inconsistent with
`enhanceHandleRequestRootSpan` which explicitly sets both the operation and the
corresponding `SEMANTIC_ATTRIBUTE_SENTRY_OP` attribute. This omission will cause the
`sentry.op` attribute to be missing from transaction events for pages-router API routes
on the Edge runtime when using the legacy span processing path, leading to incorrect
data in Sentry.

Did we get this right? 👍 / 👎 to inform future reviews.


const path = spanName.replace(ATTR_NEXT_PAGES_API_ROUTE_TYPE, '').trim();
// eslint-disable-next-line typescript/no-deprecated
const method = attributes[HTTP_REQUEST_METHOD] ?? attributes[HTTP_METHOD];
span.setName(`${typeof method === 'string' ? method : 'GET'} ${path}`);
Comment thread
cursor[bot] marked this conversation as resolved.
Comment on lines +38 to +41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The enhanceRunHandlerRootSpan function uses the raw URL path from the span name, creating high-cardinality transaction names for parameterized Edge API routes.
Severity: HIGH

Suggested Fix

Update the Edge runtime wrapper for API handlers (wrapApiHandlerWithSentry) to set the TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL attribute on the root span, similar to how the Node.js runtime wrapper does. This will allow the existing backfill mechanism to correctly use the parameterized route for the transaction name.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/nextjs/src/edge/enhanceRunHandlerRootSpan.ts#L38-L41

Potential issue: For Pages Router API routes running on the Edge runtime, the
`enhanceRunHandlerRootSpan` function derives the transaction name from the
`Node.runHandler` span's name. This span name contains the raw, unparameterized URL path
(e.g., `/api/users/123`) instead of the parameterized route (`/api/users/[id]`). As a
result, every unique URL path generates a distinct transaction, leading to
high-cardinality transaction names in Sentry. This undermines the benefit of
parameterized routes for grouping and analysis. The Node.js runtime wrapper correctly
handles this by setting a backfill attribute, but the Edge wrapper does not.

}
42 changes: 30 additions & 12 deletions packages/nextjs/src/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ import {
import type { VercelEdgeOptions } from '@sentry/vercel-edge';
import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge';
import { DEBUG_BUILD } from '../common/debug-build';
import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes';
import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached';
import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes';
import {
ATTR_NEXT_PAGES_API_ROUTE_TYPE,
TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION,
} from '../common/span-attributes-with-logic-attached';
import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes';
import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests';
import { maybeForkIsolationScopeForRootSpan } from '../common/utils/forkIsolationScopeForRootSpan';
Expand All @@ -27,6 +30,7 @@ import { flushSafelyWithTimeout, isCloudflareWaitUntilAvailable, waitUntil } fro
import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata';
import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration';
import { enhanceMiddlewareRootSpan } from '../common/enhanceMiddlewareRootSpan';
import { enhanceRunHandlerRootSpan } from './enhanceRunHandlerRootSpan';
import { SENTRY_KIND } from '@sentry/conventions/attributes';
import { WEB_SERVER_MIDDLEWARE_SPAN_OP } from '@sentry/conventions/op';

Expand Down Expand Up @@ -128,17 +132,27 @@ export function init(options: VercelEdgeOptions = {}): void {
dropMiddlewareTunnelRequests(span, spanAttributes);

// Mark all spans generated by Next.js as 'auto' & server
if (spanAttributes?.['next.span_type'] !== undefined) {
Comment thread
logaretm marked this conversation as resolved.
if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] !== undefined) {
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto');
span.setAttribute(SENTRY_KIND, 'server');
}

// Make sure middleware spans get the right op
if (spanAttributes?.['next.span_type'] === 'Middleware.execute') {
if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute') {
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, WEB_SERVER_MIDDLEWARE_SPAN_OP);
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url');
}

// Backfill op and source for pages-router API routes: we no longer create this span in the wrapper,
// so we rely on the Next.js `Node.runHandler` span becoming the transaction.
if (
spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'Node.runHandler' &&
String(spanAttributes?.[ATTR_NEXT_SPAN_NAME]).startsWith(ATTR_NEXT_PAGES_API_ROUTE_TYPE)
) {
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server');
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
}

// We want to fork the isolation scope for incoming requests
maybeForkIsolationScopeForRootSpan(span, spanAttributes);

Expand All @@ -157,16 +171,18 @@ export function init(options: VercelEdgeOptions = {}): void {
// Span streaming bypasses event processors entirely - see the `processSegmentSpan` hook below for that path.
client.on('preprocessEvent', event => {
if (event.type === 'transaction' && event.contexts?.trace?.data) {
enhanceMiddlewareRootSpan({
const mutableRootSpan = {
attributes: event.contexts.trace.data,
getName: () => event.transaction,
setName: name => {
setName: (name: string) => {
event.transaction = name;
},
setOp: op => {
setOp: (op: string) => {
event.contexts!.trace!.op = op;
},
});
};
enhanceMiddlewareRootSpan(mutableRootSpan);
enhanceRunHandlerRootSpan(mutableRootSpan);
}

setUrlProcessingMetadata(event);
Expand All @@ -176,16 +192,18 @@ export function init(options: VercelEdgeOptions = {}): void {
// transaction events, so the same enhancement has to be applied here directly on the span JSON.
client.on('processSegmentSpan', span => {
const attributes = (span.attributes ??= {});
enhanceMiddlewareRootSpan({
const mutableRootSpan = {
attributes,
getName: () => span.name,
setName: name => {
setName: (name: string) => {
span.name = name;
},
setOp: op => {
setOp: (op: string) => {
attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op;
},
});
};
enhanceMiddlewareRootSpan(mutableRootSpan);
enhanceRunHandlerRootSpan(mutableRootSpan);
});

client.on('spanEnd', span => {
Expand Down
Loading
Loading