Skip to content
Merged
Next Next commit
handle segment spans
  • Loading branch information
chargome committed Dec 2, 2025
commit 45f14abac008354fe490544079ba74c0bcc46970
2 changes: 2 additions & 0 deletions packages/nextjs/src/common/nextSpanAttributes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const ATTR_NEXT_SPAN_TYPE = 'next.span_type';
export const ATTR_NEXT_SPAN_NAME = 'next.span_name';
export const ATTR_NEXT_ROUTE = 'next.route';
export const ATTR_NEXT_SPAN_DESCRIPTION = 'next.span_description';
export const ATTR_NEXT_SEGMENT = 'next.segment';
33 changes: 32 additions & 1 deletion packages/nextjs/src/common/utils/tracingUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { PropagationContext } from '@sentry/core';
import type { PropagationContext, SpanAttributes } from '@sentry/core';
import { debug, getActiveSpan, getRootSpan, GLOBAL_OBJ, Scope, spanToJSON, startNewTrace } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { ATTR_NEXT_SEGMENT, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes';
import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached';

const commonPropagationContextMap = new WeakMap<object, PropagationContext>();
Expand Down Expand Up @@ -108,3 +109,33 @@ export function dropNextjsRootContext(): void {
}
}
}

/**
* Checks if the span is a resolve segment span.
* @param spanAttributes The attributes of the span to check.
* @returns True if the span is a resolve segment span, false otherwise.
*/
export function isResolveSegmentSpan(spanAttributes: SpanAttributes): boolean {
return (
spanAttributes[ATTR_NEXT_SPAN_TYPE] === 'NextNodeServer.getLayoutOrPageModule' &&
spanAttributes[ATTR_NEXT_SPAN_NAME] === 'resolve segment modules' &&
typeof spanAttributes[ATTR_NEXT_SEGMENT] === 'string'
);
}

/**
* Returns the enhanced name for a resolve segment span.
* @param segment The segment of the resolve segment span.
* @returns The enhanced name for the resolve segment span.
*/
export function getEnhancedResolveSegmentSpanName(segment: string): string {
if (segment === '__PAGE__') {
return 'resolve page module';
}

if (segment === '') {
return 'resolve root layout module';
}

return `resolve layout module "${segment}"`;
}
Comment thread
chargome marked this conversation as resolved.
115 changes: 21 additions & 94 deletions packages/nextjs/src/common/wrapServerComponentWithSentry.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,9 @@
import type { RequestEventData } from '@sentry/core';
import {
captureException,
getActiveSpan,
getCapturedScopesOnSpan,
getRootSpan,
handleCallbackErrors,
propagationContextFromHeaders,
Scope,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
setCapturedScopesOnSpan,
SPAN_STATUS_ERROR,
SPAN_STATUS_OK,
startSpanManual,
winterCGHeadersToDict,
withIsolationScope,
withScope,
} from '@sentry/core';
import { captureException, handleCallbackErrors, winterCGHeadersToDict, withIsolationScope } from '@sentry/core';
import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils';
import type { ServerComponentContext } from '../common/types';
import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd';
import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached';
import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils';
import { commonObjectToIsolationScope } from './utils/tracingUtils';

/**
* Wraps an `app` directory server component with Sentry error instrumentation.
Comment on lines 6 to 16

This comment was marked as outdated.

Expand All @@ -31,22 +13,13 @@ export function wrapServerComponentWithSentry<F extends (...args: any[]) => any>
appDirComponent: F,
context: ServerComponentContext,
): F {
const { componentRoute, componentType } = context;
// Even though users may define server components as async functions, for the client bundles
// Next.js will turn them into synchronous functions and it will transform any `await`s into instances of the `use`
// hook. 🤯
return new Proxy(appDirComponent, {
apply: (originalFunction, thisArg, args) => {
const requestTraceId = getActiveSpan()?.spanContext().traceId;
const isolationScope = commonObjectToIsolationScope(context.headers);

const activeSpan = getActiveSpan();
if (activeSpan) {
const rootSpan = getRootSpan(activeSpan);
const { scope } = getCapturedScopesOnSpan(rootSpan);
setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope);
}

const headersDict = context.headers ? winterCGHeadersToDict(context.headers) : undefined;
Comment on lines +28 to 30

This comment was marked as outdated.


isolationScope.setSDKProcessingMetadata({
Expand All @@ -56,72 +29,26 @@ export function wrapServerComponentWithSentry<F extends (...args: any[]) => any>
});

return withIsolationScope(isolationScope, () => {
return withScope(scope => {
scope.setTransactionName(`${componentType} Server Component (${componentRoute})`);

if (process.env.NEXT_RUNTIME === 'edge') {
const propagationContext = commonObjectToPropagationContext(
context.headers,
propagationContextFromHeaders(headersDict?.['sentry-trace'], headersDict?.['baggage']),
);

if (requestTraceId) {
propagationContext.traceId = requestTraceId;
}

scope.setPropagationContext(propagationContext);
}

const activeSpan = getActiveSpan();
if (activeSpan) {
const rootSpan = getRootSpan(activeSpan);
const sentryTrace = headersDict?.['sentry-trace'];
if (sentryTrace) {
rootSpan.setAttribute(TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL, sentryTrace);
}
}

return startSpanManual(
{
op: 'function.nextjs',
name: `${componentType} Server Component (${componentRoute})`,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.server_component',
'sentry.nextjs.ssr.function.type': componentType,
'sentry.nextjs.ssr.function.route': componentRoute,
},
},
span => {
return handleCallbackErrors(
() => originalFunction.apply(thisArg, args),
error => {
// When you read this code you might think: "Wait a minute, shouldn't we set the status on the root span too?"
// The answer is: "No." - The status of the root span is determined by whatever status code Next.js decides to put on the response.
if (isNotFoundNavigationError(error)) {
// We don't want to report "not-found"s
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
} else if (isRedirectNavigationError(error)) {
// We don't want to report redirects
span.setStatus({ code: SPAN_STATUS_OK });
} else {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureException(error, {
mechanism: {
handled: false,
type: 'auto.function.nextjs.server_component',
},
});
}
return handleCallbackErrors(
() => originalFunction.apply(thisArg, args),
error => {
if (isNotFoundNavigationError(error)) {
// We don't want to report "not-found"s
} else if (isRedirectNavigationError(error)) {
// We don't want to report redirects
} else {
captureException(error, {
mechanism: {
handled: false,
type: 'auto.function.nextjs.server_component',
},
() => {
span.end();
waitUntil(flushSafelyWithTimeout());
},
);
},
);
});
});
}
Comment on lines +47 to 57
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The refactored wrapServerComponentWithSentry no longer calls span.end(), which can lead to incomplete traces and lost span data for successful executions.
Severity: CRITICAL | Confidence: High

🔍 Detailed Analysis

In the refactored wrapServerComponentWithSentry, the active span is no longer explicitly ended. The previous implementation used startSpanManual and ensured span.end() was called in a finally block. The new code retrieves the active span via getActiveSpan() but omits the corresponding span.end() call upon completion. This violates the standard span lifecycle. As a result, spans for successful server component executions may not be correctly recorded or flushed, leading to incomplete traces and lost observability data.

💡 Suggested Fix

Ensure the active span is explicitly ended after the wrapped function executes. A potential fix is to get the active span before the function runs and call span.end() within the finally block of handleCallbackErrors, mirroring the pattern used in the previous implementation and other wrappers.

🤖 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/wrapServerComponentWithSentry.ts#L46-L57

Potential issue: In the refactored `wrapServerComponentWithSentry`, the active span is
no longer explicitly ended. The previous implementation used `startSpanManual` and
ensured `span.end()` was called in a `finally` block. The new code retrieves the active
span via `getActiveSpan()` but omits the corresponding `span.end()` call upon
completion. This violates the standard span lifecycle. As a result, spans for successful
server component executions may not be correctly recorded or flushed, leading to
incomplete traces and lost observability data.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 7618121

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.

we delegate the entire span lifecycle to next

},
() => {
waitUntil(flushSafelyWithTimeout());
},
);
});
},
});
Expand Down
96 changes: 96 additions & 0 deletions packages/nextjs/src/server/handleOnSpanStart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { context } from '@opentelemetry/api';
import { ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_ROUTE, SEMATTRS_HTTP_METHOD } from '@opentelemetry/semantic-conventions';
import type { Span } from '@sentry/core';
import {
getCapturedScopesOnSpan,
getCurrentScope,
getIsolationScope,
getRootSpan,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
setCapturedScopesOnSpan,
spanToJSON,
} from '@sentry/core';
import { getScopesFromContext } from '@sentry/opentelemetry';
import {
ATTR_NEXT_ROUTE,
ATTR_NEXT_SEGMENT,
ATTR_NEXT_SPAN_NAME,
ATTR_NEXT_SPAN_TYPE,
} from '../common/nextSpanAttributes';
import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes';
import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests';
import { getEnhancedResolveSegmentSpanName, isResolveSegmentSpan } from '../common/utils/tracingUtils';

/**
* Handles the on span start event for Next.js spans.
* This function is used to enhance the span with additional information such as the route, the method, the headers, etc.
* It is called for every span that is started by Next.js.
* @param span The span that is starting.
*/
export function handleOnSpanStart(span: Span): void {
const spanAttributes = spanToJSON(span).data;
const rootSpan = getRootSpan(span);
const isRootSpan = span === rootSpan;

dropMiddlewareTunnelRequests(span, spanAttributes);

// What we do in this glorious piece of code, is hoist any information about parameterized routes from spans emitted
// by Next.js via the `next.route` attribute, up to the transaction by setting the http.route attribute.
if (typeof spanAttributes?.[ATTR_NEXT_ROUTE] === 'string') {
const rootSpanAttributes = spanToJSON(rootSpan).data;
// Only hoist the http.route attribute if the transaction doesn't already have it
if (
// eslint-disable-next-line deprecation/deprecation
(rootSpanAttributes?.[ATTR_HTTP_REQUEST_METHOD] || rootSpanAttributes?.[SEMATTRS_HTTP_METHOD]) &&
!rootSpanAttributes?.[ATTR_HTTP_ROUTE]
) {
const route = spanAttributes[ATTR_NEXT_ROUTE].replace(/\/route$/, '');
rootSpan.updateName(route);
rootSpan.setAttribute(ATTR_HTTP_ROUTE, route);
// Preserving the original attribute despite internally not depending on it
rootSpan.setAttribute(ATTR_NEXT_ROUTE, route);
}
}

if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute') {
const middlewareName = spanAttributes[ATTR_NEXT_SPAN_NAME];
if (typeof middlewareName === 'string') {
rootSpan.updateName(middlewareName);
rootSpan.setAttribute(ATTR_HTTP_ROUTE, middlewareName);
rootSpan.setAttribute(ATTR_NEXT_SPAN_NAME, middlewareName);
}
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto');
}

// We want to skip span data inference for any spans generated by Next.js. Reason being that Next.js emits spans
// with patterns (e.g. http.server spans) that will produce confusing data.
if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] !== undefined) {
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto');
}

if (isRootSpan) {
const headers = getIsolationScope().getScopeData().sdkProcessingMetadata?.normalizedRequest?.headers;
addHeadersAsAttributes(headers, rootSpan);
}

// We want to fork the isolation scope for incoming requests
if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' && isRootSpan) {
const scopes = getCapturedScopesOnSpan(span);

const isolationScope = (scopes.isolationScope || getIsolationScope()).clone();
const scope = scopes.scope || getCurrentScope();

const currentScopesPointer = getScopesFromContext(context.active());
if (currentScopesPointer) {
currentScopesPointer.isolationScope = isolationScope;
}

setCapturedScopesOnSpan(span, scope, isolationScope);
}

// Enhancing server component span names
if (isResolveSegmentSpan(spanAttributes)) {
// type conversion is safe because we already know the attribute is a string
span.updateName(getEnhancedResolveSegmentSpanName(spanAttributes[ATTR_NEXT_SEGMENT] as string));
}
}
Loading