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 @@ -41,10 +41,16 @@ 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}/),
});

// The error is attributed to the route handler span that threw, which is a child of the request
// span the transaction is built from.
expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id);
expect(errorEvent.contexts?.trace?.span_id).toBe(transactionEvent.contexts?.trace?.span_id);
expect(errorEvent.contexts?.trace?.parent_span_id).toBe(transactionEvent.contexts?.trace?.span_id);

const blamedSpan = transactionEvent.spans?.find(span => span.span_id === errorEvent.contexts?.trace?.span_id);
expect(blamedSpan?.op).toBe('router');
});

test('sends error with parameterized transaction name', async ({ baseURL }) => {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DEFAULT_ENVIRONMENT } from './constants';
import { getCurrentScope, getIsolationScope, getTraceContextFromScope } from './currentScopes';
import { DEBUG_BUILD } from './debug-build';
import { createEventEnvelope, createSessionEnvelope } from './envelope';
import { applyEscapedErrorSpanToEvent } from './utils/errorSpanAttribution';
import type { IntegrationIndex } from './integration';
import { afterSetupIntegrations, setupIntegration, setupIntegrations } from './integration';
import { _INTERNAL_flushLogsBuffer } from './logs/internal';
Expand Down Expand Up @@ -1442,6 +1443,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
...evt.contexts,
};

// Deliberately after the merge above: an error captured with no active span has no trace
// context until then, and without its trace id we cannot tell whether the span we recorded
// belongs to the same trace, which risks the event disagreeing with the DSC we build below.
applyEscapedErrorSpanToEvent(evt, hint);

const dynamicSamplingContext = getDynamicSamplingContextFromScope(this, currentScope);

evt.sdkProcessingMetadata = {
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 @@ -667,7 +668,9 @@ function runCallback<T>(span: Span, makeSpanActive: boolean, callback: () => T,
return wrapper(() =>
handleCallbackErrors(
() => callback(),
() => {
error => {
recordEscapedErrorSpan(error, span);

// 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
66 changes: 66 additions & 0 deletions packages/core/src/utils/errorSpanAttribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { TraceContext } from '../types/context';
import type { Event, EventHint } from '../types/event';
import type { Span } from '../types/span';
import { isPrimitive } from './is';
import { 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. Non-recording spans are skipped because they are never sent, so
* their span id would point at a span that does not exist.
*/
export function recordEscapedErrorSpan(error: unknown, span: Span): void {
const key = toWeakMapKey(error);

if (!key || !span.isRecording() || escapedSpanTraceContexts.has(key)) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ended spans skip error attribution

Low Severity

recordEscapedErrorSpan bails out when span.isRecording() is false, and SentrySpan.isRecording() is also false after end(). A sampled span that already finished is still sent, so an error thrown in that callback after end() is not attributed to it and falls through to a parent or the capture-time span. The skip is meant for spans that are never sent, which does not apply here.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 07c56d2. Configure here.


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): void {
const key = toWeakMapKey(hint.originalException);
const traceContext = key && escapedSpanTraceContexts.get(key);
const eventTraceContext = event.contexts?.trace;

if (!traceContext || !eventTraceContext || eventTraceContext.trace_id !== traceContext.trace_id) {
return;
}

event.contexts = {
...event.contexts,
trace: {
...eventTraceContext,
span_id: traceContext.span_id,
parent_span_id: traceContext.parent_span_id,

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.

m: IMHO we should invert this, if a span is already set on this, likely we should not overwrite it I think? 🤔 but this also makes the logic a bit trickier, because we cannot just do:

trace: {
  span_id: traceContext.span_id,
  parent_span_id: traceContext.parent_span_id
  ...eventTraceContext
}

because that could lead to a case where eventTraceContext has a span_id but no parent_span_id and then they would be incorrectly in sync.

I guess we generally do have a traceContext here already set, right, even if we do not actually have the span? 🤔

Would it work if we move the invocation of applyEscapedErrorSpanToEvent to prepareEvent.ts like this:

if (span) {
    applySpanToEvent(prepared, span);
  } else {
   applyEscapedErrorSpanToEvent(prepared, hint);
}

or something along these lines? 🤔

@logaretm logaretm Aug 27, 2026

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.

So there are three cases here:

  • The span id is wrong: this is the main fix here because we cannot trust the span id already set because we know for a fact it is wrong, so we have to overwrite. The concern here would be, could the already set span id be more accurate than the span id that caught and re-thrown the error?
  • The span id matches the span the error escaped from: We write the same values so it is idempotent here.
  • No trace/span_id: This is the tricky part, if we change placement we won't have a trace_id to compare against and so we could have a mismatching dsc.

I clarified these in a comment just now if it makes sense, WDYT?

},
};
}
245 changes: 245 additions & 0 deletions packages/core/test/lib/tracing/errorSpanAttribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
captureException,
getActiveSpan,
setAsyncContextStrategy,
setCurrentClient,
startNewTrace,
startSpan,
} from '../../../src';
import type { Event } from '../../../src/types/event';
import type { TestClientOptions } from '../../mocks/client';
import { getDefaultTestClientOptions, TestClient } from '../../mocks/client';
import { resetGlobals } from '../../testutils';

const tick = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0));

let client: TestClient;
let events: Event[];

function initClient(extraOptions: Partial<TestClientOptions> = {}): void {
events = [];

const options = getDefaultTestClientOptions({
tracesSampleRate: 1,
beforeSend: event => {
// The test client strips `sdkProcessingMetadata` when it sends, so snapshot the event here.
events.push({ ...event });
return event;
},
...extraOptions,
});
client = new TestClient(options);
setCurrentClient(client);
client.init();
}

describe('error span attribution', () => {
beforeEach(() => {
resetGlobals();
setAsyncContextStrategy(undefined);
initClient();
});

it('attributes an error to the span it escaped, not the span it was caught in', async () => {
let innerSpanId: string | undefined;
let outerSpanId: string | undefined;

startSpan({ name: 'outer' }, outerSpan => {
outerSpanId = outerSpan.spanContext().spanId;

try {
startSpan({ name: 'inner' }, innerSpan => {
innerSpanId = innerSpan.spanContext().spanId;
throw new Error('inner failed');
});
} catch (error) {
captureException(error);
}
});

await client.flush();

expect(innerSpanId).not.toBe(outerSpanId);
expect(events).toHaveLength(1);
expect(events[0]?.contexts?.trace?.span_id).toBe(innerSpanId);
});

it('attributes an error to the failing branch of a concurrent group', async () => {
let failingSpanId: string | undefined;
let succeedingSpanId: string | undefined;
let reportingSpanId: string | undefined;

await startSpan({ name: 'root' }, async () => {
let escapedError: unknown;

try {
await Promise.all([
startSpan({ name: 'failing' }, async span => {
failingSpanId = span.spanContext().spanId;
await tick();
throw new Error('branch failed');
}),
startSpan({ name: 'succeeding' }, async span => {
succeedingSpanId = span.spanContext().spanId;
await tick();
}),
]);
} catch (error) {
escapedError = error;
}

// Report from a span that is unambiguously active, so the assertion does not depend on
// which scope the stack strategy happens to leak once the branches resume.
startSpan({ name: 'reporting' }, span => {
reportingSpanId = span.spanContext().spanId;
captureException(escapedError);
});
});

await client.flush();

expect(failingSpanId).not.toBe(succeedingSpanId);
expect(failingSpanId).not.toBe(reportingSpanId);
expect(events).toHaveLength(1);
expect(events[0]?.contexts?.trace?.span_id).toBe(failingSpanId);
});

it('attributes an error captured with no active span, in the same trace', async () => {
let escapedError: unknown;
let escapedSpanId: string | undefined;

try {
startSpan({ name: 'failing' }, span => {
escapedSpanId = span.spanContext().spanId;
throw new Error('boom');
});
} catch (error) {
escapedError = error;
}

expect(getActiveSpan()).toBeUndefined();
captureException(escapedError);

await client.flush();

expect(events).toHaveLength(1);
expect(events[0]?.contexts?.trace?.span_id).toBe(escapedSpanId);
});

it('attributes an error to the deepest span it escaped', async () => {
let deepestSpanId: string | undefined;

startSpan({ name: 'level-1' }, () => {
try {
startSpan({ name: 'level-2' }, () => {
startSpan({ name: 'level-3' }, span => {
deepestSpanId = span.spanContext().spanId;
throw new Error('level 3 failed');
});
});
} catch (error) {
captureException(error);
}
});

await client.flush();

expect(events).toHaveLength(1);
expect(events[0]?.contexts?.trace?.span_id).toBe(deepestSpanId);
});

// The stored span id is only meaningful inside its own trace, so an error that outlives its
// trace keeps today's behaviour rather than mixing a stale trace into the current scope's data.
it('does not attribute an error to a span from a previous trace', async () => {
let escapedError: unknown;
let currentTraceId: string | undefined;
let currentSpanId: string | undefined;

try {
startSpan({ name: 'previous-trace' }, () => {
throw new Error('escaped its trace');
});
} catch (error) {
escapedError = error;
}

startNewTrace(() => {
startSpan({ name: 'current-trace' }, span => {
currentTraceId = span.spanContext().traceId;
currentSpanId = span.spanContext().spanId;
captureException(escapedError);
});
});

await client.flush();

expect(events).toHaveLength(1);
expect(events[0]?.contexts?.trace?.trace_id).toBe(currentTraceId);
expect(events[0]?.contexts?.trace?.span_id).toBe(currentSpanId);
});

it('falls back to the active span when a non-object is thrown', async () => {
let outerSpanId: string | undefined;

startSpan({ name: 'outer' }, outerSpan => {
outerSpanId = outerSpan.spanContext().spanId;

try {
startSpan({ name: 'inner' }, () => {
throw 'a string, which cannot key a WeakMap';
});
} catch (error) {
captureException(error);
}
});

await client.flush();

expect(events).toHaveLength(1);
expect(events[0]?.contexts?.trace?.span_id).toBe(outerSpanId);
});

it('does not attribute an error to an ignored span, which is never sent', async () => {
initClient({ traceLifecycle: 'stream', ignoreSpans: ['ignored'] });

let outerSpanId: string | undefined;

startSpan({ name: 'outer' }, outerSpan => {
outerSpanId = outerSpan.spanContext().spanId;

try {
startSpan({ name: 'ignored' }, () => {
throw new Error('ignored span failed');
});
} catch (error) {
captureException(error);
}
});

await client.flush();

expect(events).toHaveLength(1);
expect(events[0]?.contexts?.trace?.span_id).toBe(outerSpanId);
});

// Why the attribution is gated on the trace: the envelope header is built from the dynamic
// sampling context, so it must never name a different trace than the trace context does.
it('keeps the dynamic sampling context in agreement with the trace context', async () => {
startSpan({ name: 'outer' }, () => {
try {
startSpan({ name: 'inner' }, () => {
throw new Error('inner failed');
});
} catch (error) {
captureException(error);
}
});

await client.flush();

const traceContext = events[0]?.contexts?.trace;
expect(traceContext?.trace_id).toBeDefined();
expect(events[0]?.sdkProcessingMetadata?.dynamicSamplingContext?.trace_id).toBe(traceContext?.trace_id);
});
});
Loading