Skip to content
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
import {
collectStreamedSpansUntilSegment,
getSpanOp,
waitForError,
waitForStreamedSpan,
} from '@sentry-internal/test-utils';

test('Sends thrown error to Sentry', async ({ baseURL }) => {
const errorEventPromise = waitForError('node-hapi', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'This is an error';
});

const segmentEventPromise = waitForStreamedSpan(
'node-hapi',
segment => segment.is_segment && segment.name === 'GET /test-failure',
);
const spansPromise = collectStreamedSpansUntilSegment('node-hapi', 'GET /test-failure');

await fetch(`${baseURL}/test-failure`);

const errorEvent = await errorEventPromise;
const segmentEvent = await segmentEventPromise;
const spans = await spansPromise;
const segmentSpan = spans.find(span => span.is_segment);

expect(segmentEvent.name).toBe('GET /test-failure');
expect(segmentEvent).toMatchObject({
expect(segmentSpan?.name).toBe('GET /test-failure');
expect(segmentSpan).toMatchObject({
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
});
Expand All @@ -42,10 +45,15 @@ test('Sends thrown error to Sentry', async ({ baseURL }) => {
expect(errorEvent.contexts?.trace).toEqual({
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
});

expect(errorEvent.contexts?.trace?.trace_id).toBe(segmentEvent?.trace_id);
expect(errorEvent.contexts?.trace?.span_id).toBe(segmentEvent?.span_id);
// The error is attributed to the route handler span that threw, which is a child of the request
// span the segment is built from.
const routeHandlerSpan = spans.find(span => getSpanOp(span) === 'router');
expect(errorEvent.contexts?.trace?.trace_id).toBe(segmentSpan?.trace_id);
expect(errorEvent.contexts?.trace?.span_id).toBe(routeHandlerSpan?.span_id);
expect(errorEvent.contexts?.trace?.parent_span_id).toBe(segmentSpan?.span_id);
});

test('sends error with parameterized transaction name', async ({ baseURL }) => {
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/tracing/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { StartSpanOptions } from '../types/startSpanOptions';
import { baggageHeaderToDynamicSamplingContext } from '../utils/baggage';
import { debug } from '../utils/debug-logger';
import { handleCallbackErrors } from '../utils/handleCallbackErrors';
import { recordEscapedErrorSpan } from '../utils/errorSpanAttribution';
import { hasSpansEnabled } from '../utils/hasSpansEnabled';
import { shouldIgnoreSpan } from '../utils/should-ignore-span';
import { hasSpanStreamingEnabled } from './spans/hasSpanStreamingEnabled';
Expand Down Expand Up @@ -670,7 +671,9 @@ function runCallback<T>(span: Span, makeSpanActive: boolean, callback: () => T,
return wrapper(() =>
handleCallbackErrors(
() => callback(),
() => {
error => {
recordEscapedErrorSpan(error, span);

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 error span attribution fix is incomplete. Next.js route handlers call handleCallbackErrors directly, bypassing the startSpan logic, so errors are not correctly attributed to the handler's span.
Severity: MEDIUM

Suggested Fix

To ensure consistent error attribution, wrap the route handler execution in wrapRouteHandlerWithSentry.ts with a startSpan call. This will ensure that runCallback is executed, which in turn calls recordEscapedErrorSpan upon an error, correctly associating the error with the active span before it's captured. This pattern should be applied to any other framework integrations that currently call handleCallbackErrors directly.

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/core/src/tracing/trace.ts#L675

Potential issue: The fix for error span attribution is incomplete. In certain framework
integrations, such as the Next.js route handler wrapper
(`wrapRouteHandlerWithSentry.ts`), errors are handled by calling `handleCallbackErrors`
directly, without being wrapped in a `startSpan`. The new attribution logic, which uses
`recordEscapedErrorSpan`, is only called from within the `runCallback` function used by
`startSpan`. As a result, errors thrown from these specific handlers will not be
correctly associated with their originating span, reverting to the old behavior where
attribution depends on the active span at the time of capture.


// Only update the span status if it hasn't been changed yet, and the span is not yet finished
const { status } = spanToStaticSpanJSON(span);
if (span.isRecording() && status === 'ok') {
Expand Down
78 changes: 78 additions & 0 deletions packages/core/src/utils/errorSpanAttribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { getTraceContextFromScope } from '../currentScopes';
import type { Scope } from '../scope';
import type { TraceContext } from '../types/context';
import type { Event, EventHint } from '../types/event';
import type { Span } from '../types/span';
import { isPrimitive } from './is';
import { spanIsSampled, spanToTraceContext } from './spanUtils';

/**
* The trace context of the span an error escaped, keyed by the error itself.
*
* We store the trace context rather than the span because that is the shape we apply to the event
* later, and it snapshots the span as it failed instead of reading it back once it has ended.
*/
const escapedSpanTraceContexts = new WeakMap<object, TraceContext>();

/**
* A `WeakMap` can only be keyed by an object, so an error thrown as a primitive (`throw 'boom'`)
* has nothing we can hang the span on and is left unattributed.
*/
function toWeakMapKey(error: unknown): object | undefined {
return isPrimitive(error) ? undefined : error;
}

/**
* Remember which span an error escaped, so a later `captureException` can attribute the error to
* the span that actually failed instead of whichever span happens to be active at capture time.
*
* The first span to see the error wins: as an error unwinds through nested spans, the innermost
* one is the one that failed. Unsampled spans are skipped because they are never sent, so their
* span id would point at a span that does not exist. Sampling rather than `isRecording()` is what
* matters here: a span ended before the error escaped it, which is the norm for `startSpanManual`,
* has stopped recording but is still sent.
*/
export function recordEscapedErrorSpan(error: unknown, span: Span): void {
const key = toWeakMapKey(error);

if (!key || !spanIsSampled(span) || escapedSpanTraceContexts.has(key)) {
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.

escapedSpanTraceContexts.set(key, spanToTraceContext(span));
}

/**
* Attribute an error event to the span the error escaped, if we recorded one.
*
* This only applies within the error's own trace. The stored span id is meaningless in another
* trace, and the event's dynamic sampling context (which the envelope header is built from) is
* derived from the root span of the trace the event is already on. Rewriting the trace id here
* would leave the envelope header and body naming different traces.
*/
export function applyEscapedErrorSpanToEvent(event: Event, hint: EventHint, scope: Scope | undefined): void {
const key = toWeakMapKey(hint.originalException);
const traceContext = key && escapedSpanTraceContexts.get(key);

if (!traceContext) {
return;
}

// An error captured with no active span has no trace context yet: the scope's is merged in
// further downstream. Resolve the trace the event will end up on the same way that merge does,
// so the check below still knows which trace we are on.
const eventTraceContext = event.contexts?.trace;
const eventTraceId = eventTraceContext?.trace_id ?? (scope && getTraceContextFromScope(scope).trace_id);

if (eventTraceId !== traceContext.trace_id) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Trace check uses the wrong scope

Medium Severity

The same-trace fallback reads finalScope, but the later merge and DSC still come from currentScope. Passing a Scope as captureContext can skip attribution, or write the escaped span's trace_id onto an event whose envelope DSC names a different trace.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4099fde. Configure here.

return;
}

event.contexts = {
...event.contexts,
trace: {
...eventTraceContext,
...traceContext,
},
};
Comment thread
logaretm marked this conversation as resolved.
}
6 changes: 6 additions & 0 deletions packages/core/src/utils/prepareEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ClientOptions } from '../types/options';
import type { StackParser } from '../types/stacktrace';
import { getFilenameToDebugIdMap } from './debug-ids';
import { getDataCategoryByType } from './envelope';
import { applyEscapedErrorSpanToEvent } from './errorSpanAttribution';
import { addExceptionMechanismToCapturedException, uuid4 } from './misc';
import { normalize } from './normalize';
import { applyScopeDataToEvent, applySpanToEvent, getCombinedScopeData } from './scopeData';
Expand Down Expand Up @@ -95,6 +96,11 @@ export function prepareEvent(
applySpanToEvent(prepared, span);
}

// After the active span, so an error that escaped a span is attributed to that span rather than
// to whichever one happened to be active at capture time. Done here rather than once the event is
// assembled so that event processors and the `postprocessEvent` hook see the corrected span id.
applyEscapedErrorSpanToEvent(prepared, hint, finalScope);

const eventProcessors = [
...clientEventProcessors,
// Run scope event processors _after_ all other processors
Expand Down
Loading
Loading