Skip to content
Merged
Prev Previous commit
Next Next commit
fix spans
  • Loading branch information
chargome committed Dec 4, 2025
commit 960dcd1a6e2c91d6d1e2674e5579a0762be63f2d
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { PropsWithChildren } from 'react';

export const dynamic = 'force-dynamic';

export default function Layout({ children }: PropsWithChildren<{}>) {
return (
<div>
<p>DynamicLayout</p>
{children}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const dynamic = 'force-dynamic';

export default async function Page() {
return (
<div>
<p>Dynamic Page</p>
</div>
);
}

export async function generateMetadata() {
return {
title: 'I am dynamic page generated metadata',
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,43 @@ test('Will create a transaction with spans for every server component and metada
return span.description;
});

expect(spanDescriptions).toContainEqual('resolve root layout module');
expect(spanDescriptions).toContainEqual('resolve layout module "(nested-layout)"');
expect(spanDescriptions).toContainEqual('resolve layout module "nested-layout"');
expect(spanDescriptions).toContainEqual('resolve page module');
expect(spanDescriptions).toContainEqual('resolve page components');
expect(spanDescriptions).toContainEqual('render route (app) /nested-layout');
expect(spanDescriptions).toContainEqual('build component tree');
expect(spanDescriptions).toContainEqual('resolve root layout server component');
expect(spanDescriptions).toContainEqual('resolve layout server component "(nested-layout)"');
expect(spanDescriptions).toContainEqual('resolve layout server component "nested-layout"');
expect(spanDescriptions).toContainEqual('resolve page server component "/nested-layout"');
expect(spanDescriptions).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page');
expect(spanDescriptions).toContainEqual('Page.generateMetadata (/(nested-layout)/nested-layout)');
expect(spanDescriptions).toContainEqual('start response');
expect(spanDescriptions).toContainEqual('NextNodeServer.clientComponentLoading');
});

test('Will create a transaction with spans for every server component and metadata generation functions when visiting a dynamic page', async ({
page,
}) => {
const serverTransactionEventPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => {
console.log(transactionEvent?.transaction);
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: Debug console.log left in test code

A console.log(transactionEvent?.transaction) statement appears to have been left in the test code, likely from debugging. While this is in test code, it will produce unnecessary output during test runs.

Fix in Cursor Fix in Web

return transactionEvent?.transaction === 'GET /nested-layout/[dynamic]';
});

await page.goto('/nested-layout/123');

const spanDescriptions = (await serverTransactionEventPromise).spans?.map(span => {
return span.description;
});

expect(spanDescriptions).toContainEqual('resolve page components');
expect(spanDescriptions).toContainEqual('render route (app) /nested-layout/[dynamic]');
expect(spanDescriptions).toContainEqual('build component tree');
expect(spanDescriptions).toContainEqual('resolve root layout server component');
expect(spanDescriptions).toContainEqual('resolve layout server component "(nested-layout)"');
expect(spanDescriptions).toContainEqual('resolve layout server component "nested-layout"');
expect(spanDescriptions).toContainEqual('resolve layout server component "[dynamic]"');
expect(spanDescriptions).toContainEqual('resolve page server component "/nested-layout/[dynamic]"');
expect(spanDescriptions).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page');
expect(spanDescriptions).toContainEqual('Page.generateMetadata (/(nested-layout)/nested-layout/[dynamic])');
expect(spanDescriptions).toContainEqual('start response');
expect(spanDescriptions).toContainEqual('NextNodeServer.clientComponentLoading');
});
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,20 @@ test('Should set a "not_found" status on a server component span when notFound()

const transactionEvent = await serverComponentTransactionPromise;

// Transaction should have status ok, because the http status is ok, but the server component span should be not_found
// Transaction should have status ok, because the http status is ok, but the render component span should be not_found
expect(transactionEvent.contexts?.trace?.status).toBe('ok');
expect(transactionEvent.spans).toContainEqual(
expect.objectContaining({
description: 'Page Server Component (/server-component/not-found)',
op: 'function.nextjs',
description: 'render route (app) /server-component/not-found',
status: 'not_found',
}),
);

// Page server component span should have the right name and attributes
expect(transactionEvent.spans).toContainEqual(
expect.objectContaining({
description: 'resolve page server component "/server-component/not-found"',
op: 'function.nextjs',
data: expect.objectContaining({
'sentry.nextjs.ssr.function.type': 'Page',
'sentry.nextjs.ssr.function.route': '/server-component/not-found',
Expand All @@ -102,13 +109,20 @@ test('Should capture an error and transaction for a app router page', async ({ p
// Error event should have the right transaction name
expect(errorEvent.transaction).toBe(`Page Server Component (/server-component/faulty)`);

// Transaction should have status ok, because the http status is ok, but the server component span should be internal_error
// Transaction should have status ok, because the http status is ok, but the render component span should be internal_error
expect(transactionEvent.contexts?.trace?.status).toBe('ok');
expect(transactionEvent.spans).toContainEqual(
expect.objectContaining({
description: 'Page Server Component (/server-component/faulty)',
op: 'function.nextjs',
description: 'render route (app) /server-component/faulty',
status: 'internal_error',
}),
);

// The page server component span should have the right name and attributes
expect(transactionEvent.spans).toContainEqual(
expect.objectContaining({
description: 'resolve page server component "/server-component/faulty"',
op: 'function.nextjs',
data: expect.objectContaining({
'sentry.nextjs.ssr.function.type': 'Page',
'sentry.nextjs.ssr.function.route': '/server-component/faulty',
Expand Down
54 changes: 47 additions & 7 deletions packages/nextjs/src/common/utils/tracingUtils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import type { PropagationContext, SpanAttributes } from '@sentry/core';
import { debug, getActiveSpan, getRootSpan, GLOBAL_OBJ, Scope, spanToJSON, startNewTrace } from '@sentry/core';
import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions';
import type { PropagationContext, Span, SpanAttributes } from '@sentry/core';
import {
debug,
getActiveSpan,
getRootSpan,
GLOBAL_OBJ,
Scope,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
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>();

const PAGE_SEGMENT = '__PAGE__';

/**
* Takes a shared (garbage collectable) object between resources, e.g. a headers object shared between Next.js server components and returns a common propagation context.
*
Expand Down Expand Up @@ -126,16 +138,44 @@ export function isResolveSegmentSpan(spanAttributes: SpanAttributes): boolean {
/**
* Returns the enhanced name for a resolve segment span.
* @param segment The segment of the resolve segment span.
* @param route The route 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';
export function getEnhancedResolveSegmentSpanName({ segment, route }: { segment: string; route: string }): string {
if (segment === PAGE_SEGMENT) {
return `resolve page server component "${route}"`;
}

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

return `resolve layout server component "${segment}"`;
}

/**
* Maybe enhances the span name for a resolve segment span.
* If the span is not a resolve segment span, this function does nothing.
* @param activeSpan The active span.
* @param spanAttributes The attributes of the span to check.
* @param rootSpanAttributes The attributes of the according root span.
*/
export function maybeEnhanceServerComponentSpanName(
activeSpan: Span,
spanAttributes: SpanAttributes,
rootSpanAttributes: SpanAttributes,
): void {
if (!isResolveSegmentSpan(spanAttributes)) {
return;
}

return `resolve layout module "${segment}"`;
const segment = spanAttributes[ATTR_NEXT_SEGMENT] as string;
const route = rootSpanAttributes[ATTR_HTTP_ROUTE];
const enhancedName = getEnhancedResolveSegmentSpanName({ segment, route: typeof route === 'string' ? route : '' });
activeSpan.updateName(enhancedName);
activeSpan.setAttributes({
'sentry.nextjs.ssr.function.type': segment === PAGE_SEGMENT ? 'Page' : 'Layout',
'sentry.nextjs.ssr.function.route': route,
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: Inconsistent type handling for route span attribute

The route variable from rootSpanAttributes[ATTR_HTTP_ROUTE] can be undefined or a non-string type. On line 174, this is properly handled with typeof route === 'string' ? route : '' for the span name. However, on line 178, route is passed directly to setAttributes as 'sentry.nextjs.ssr.function.route': route, which could set the attribute to undefined or an unexpected type instead of being omitted or defaulted consistently.

Fix in Cursor Fix in Web

});
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'function.nextjs');
}
Comment thread
chargome marked this conversation as resolved.
51 changes: 34 additions & 17 deletions packages/nextjs/src/common/wrapServerComponentWithSentry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { RequestEventData } from '@sentry/core';
import { captureException, handleCallbackErrors, winterCGHeadersToDict, withIsolationScope } from '@sentry/core';
import {
captureException,
getActiveSpan,
getIsolationScope,
handleCallbackErrors,
SPAN_STATUS_ERROR,
SPAN_STATUS_OK,
winterCGHeadersToDict,
} from '@sentry/core';
import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils';
import type { ServerComponentContext } from '../common/types';
import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd';
Expand Down Expand Up @@ -28,28 +36,37 @@ export function wrapServerComponentWithSentry<F extends (...args: any[]) => any>
} satisfies RequestEventData,
});

return withIsolationScope(isolationScope, () => {
return handleCallbackErrors(
() => originalFunction.apply(thisArg, args),
error => {
return handleCallbackErrors(
() => originalFunction.apply(thisArg, args),
error => {
const isolationScope = getIsolationScope();
const span = getActiveSpan();
const { componentRoute, componentType } = context;
isolationScope.setTransactionName(`${componentType} Server Component (${componentRoute})`);
Comment on lines +39 to +44

This comment was marked as outdated.


if (span) {
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 {
captureException(error, {
mechanism: {
handled: false,
type: 'auto.function.nextjs.server_component',
},
});
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
}
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());
},
);
});
}
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: Navigation errors captured when no active span exists

The navigation error checks (isNotFoundNavigationError and isRedirectNavigationError) that set shouldCapture = false are inside the if (span) block. If getActiveSpan() returns null or undefined, the entire block is skipped and shouldCapture remains true. This causes navigation errors (not-found and redirect) to be incorrectly captured via captureException when there's no active span, whereas the old code never captured these navigation errors regardless of span existence.

Fix in Cursor Fix in Web

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.

Next will always emit a span here


captureException(error, {
mechanism: {
handled: false,
type: 'auto.function.nextjs.server_component',
},
});
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
},
() => {
waitUntil(flushSafelyWithTimeout());
},
Comment on lines 59 to +71

This comment was marked as outdated.

);
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: Missing isolation scope context breaks request metadata attachment

The refactored code retrieves an isolation scope via commonObjectToIsolationScope at line 29 and sets normalizedRequest metadata on it, but the handleCallbackErrors callback is not wrapped with withIsolationScope. When captureException is called in the error handler, getIsolationScope() at line 42 returns the current global isolation scope - not the one with the request metadata. This causes captured exceptions to be missing request context (like headers) that was set up earlier. The original code used withIsolationScope(isolationScope, ...) to ensure all nested code ran with the correct isolation scope.

Fix in Cursor Fix in Web

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.

the scope already gets forked earlier

Comment on lines +62 to +72

This comment was marked as outdated.

Comment on lines +62 to +72

This comment was marked as outdated.

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.

this is just relevant for the error case

},
});
}
Comment on lines 65 to 75

This comment was marked as outdated.

Comment on lines 65 to 75

This comment was marked as outdated.

Comment on lines 65 to 75
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 refactoring removed propagation context processing from wrapServerComponentWithSentry for the edge runtime, breaking distributed tracing continuity from incoming requests.
Severity: CRITICAL | Confidence: High

🔍 Detailed Analysis

The refactoring removed logic that processes sentry-trace and baggage headers for server components running in the edge runtime. The previous code explicitly called propagationContextFromHeaders() and scope.setPropagationContext(), but this logic was not moved to the new centralized handleOnSpanStart handler or any other location. Other wrappers like wrapGenerationFunctionWithSentry.ts retain this logic, indicating its removal was an oversight. This breaks distributed tracing, as trace context from incoming requests is no longer propagated, leading to disconnected traces for server components in the edge runtime.

💡 Suggested Fix

Reintroduce the logic to parse sentry-trace and baggage headers and set the propagation context on the scope within wrapServerComponentWithSentry when process.env.NEXT_RUNTIME === 'edge'. This can be done by calling propagationContextFromHeaders() and scope.setPropagationContext() as was done previously.

🤖 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#L35-L75

Potential issue: The refactoring removed logic that processes `sentry-trace` and
`baggage` headers for server components running in the edge runtime. The previous code
explicitly called `propagationContextFromHeaders()` and `scope.setPropagationContext()`,
but this logic was not moved to the new centralized `handleOnSpanStart` handler or any
other location. Other wrappers like `wrapGenerationFunctionWithSentry.ts` retain this
logic, indicating its removal was an oversight. This breaks distributed tracing, as
trace context from incoming requests is no longer propagated, leading to disconnected
traces for server components in the edge runtime.

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

17 changes: 4 additions & 13 deletions packages/nextjs/src/server/handleOnSpanStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,10 @@ import {
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 { ATTR_NEXT_ROUTE, 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';
import { maybeEnhanceServerComponentSpanName } from '../common/utils/tracingUtils';

/**
* Handles the on span start event for Next.js spans.
Expand All @@ -30,14 +25,14 @@ import { getEnhancedResolveSegmentSpanName, isResolveSegmentSpan } from '../comm
export function handleOnSpanStart(span: Span): void {
const spanAttributes = spanToJSON(span).data;
const rootSpan = getRootSpan(span);
const rootSpanAttributes = spanToJSON(rootSpan).data;
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
Expand Down Expand Up @@ -88,9 +83,5 @@ export function handleOnSpanStart(span: Span): void {
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));
}
maybeEnhanceServerComponentSpanName(span, spanAttributes, rootSpanAttributes);
}