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
@@ -1,24 +1,17 @@
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 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';
import { flushSafelyWithTimeout } from '../utils/responseEnd';

export type AugmentedNextApiRequest = NextApiRequest & {
__withSentry_applied__?: boolean;
Expand All @@ -34,117 +27,75 @@ 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;

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);
req.__withSentry_applied__ = true;

isolationScope.setSDKProcessingMetadata({ normalizedRequest });
isolationScope.setTransactionName(`${reqMethod}${parameterizedRoute}`);
return withIsolationScope(async isolationScope => {
const reqMethod = `${(req.method || 'GET').toUpperCase()} `;

const requestUrl = normalizedRequest.url || req.url;
const urlObject = requestUrl ? parseStringToURLObject(requestUrl) : undefined;
isolationScope.setSDKProcessingMetadata({ normalizedRequest: httpRequestToRequestData(req) });
isolationScope.setTransactionName(`${reqMethod}${parameterizedRoute}`);

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);
// 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);
}

captureException(objectifiedErr, {
mechanism: {
type: 'auto.http.nextjs.api_handler',
handled: false,
data: {
wrapped_handler: wrappingTarget.name,
function: 'withSentry',
},
},
});
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);

setHttpStatus(span, 500);
span.end();
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();
// 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();

// 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;
}
},
);
},
);
});
// 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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing flush on successful API responses

High Severity

The Node pages-router wrapper no longer calls waitUntil(flushSafelyWithTimeout()) on successful responses. Flushing only happens in the catch path, so on serverless (e.g. Vercel) the runtime can freeze before successful transactions and other pending telemetry are sent. Edge still flushes on spanEnd, and App Router’s wrapRouteHandlerWithSentry still flushes on completion.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7d305c8. Configure here.

Comment on lines +88 to +98

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: In Node.js serverless environments, wrapApiHandlerWithSentry no longer flushes Sentry events for successful API requests, which can lead to lost transaction data if the function terminates quickly.
Severity: HIGH

Suggested Fix

Reintroduce a call to flushSafelyWithTimeout() in the success path for the Node.js wrapApiHandlerWithSentry wrapper. This can be done by restoring the proxy on res.end or by adding the flush call before the original handler's logic completes, ensuring event flushing for both successful and failed responses.

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/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts#L70-L98

Potential issue: The `wrapApiHandlerWithSentry` function for Node.js environments no
longer flushes Sentry events on successful API route executions. The previous
implementation called `flushSafelyWithTimeout()` on every response completion, but this
was removed from the success path. Unlike the Edge runtime, which has an automatic
flushing mechanism on the `spanEnd` hook, the Node.js server runtime relies on manual
flushing. This change means that for successful requests in a serverless Node.js
environment, pending events like transactions and breadcrumbs may be lost if the
function terminates after sending the response but before Sentry's background agent
sends the events.

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

});
},
});
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}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing http.route on Edge API spans

Medium Severity

enhanceRunHandlerRootSpan backfills op, source, and transaction name for pages API routes, but never sets http.route. The Edge wrapper previously wrote HTTP_ROUTE from parameterizedRoute, and that write was removed without a replacement, so Edge pages API transactions lose the route attribute.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7d305c8. Configure here.

}
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';

export * from '@sentry/vercel-edge';
Expand Down Expand Up @@ -127,17 +131,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) {
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, 'http.server.middleware');
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 @@ -156,16 +170,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 @@ -175,16 +191,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