Skip to content

Commit 3ce0cf9

Browse files
committed
Seal tracer-provider spans against mutation after they end
1 parent 14cb421 commit 3ce0cf9

2 files changed

Lines changed: 106 additions & 3 deletions

File tree

packages/core/src/tracing/sentrySpan.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@ import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext';
5050
import { logSpanEnd } from './logSpans';
5151
import { timedEventsToMeasurements } from './measurement';
5252
import { hasSpanStreamingEnabled } from './spans/hasSpanStreamingEnabled';
53-
import { getCapturedScopesOnSpan, markSpanSourceAsExplicit, spanShouldInferOtelSource } from './utils';
53+
import {
54+
getCapturedScopesOnSpan,
55+
markSpanSourceAsExplicit,
56+
spanIsTracerProviderSpan,
57+
spanShouldInferOtelSource,
58+
} from './utils';
5459

5560
const MAX_SPAN_COUNT = 1000;
5661

@@ -130,6 +135,9 @@ export class SentrySpan implements Span {
130135
/** if true, treat span as a standalone span (not part of a transaction) */
131136
private _isStandaloneSpan?: boolean;
132137

138+
/** if true, the span is sealed and ignores further mutations (set after end for tracer-provider spans) */
139+
private _frozen?: boolean;
140+
133141
/**
134142
* You should never call the constructor manually, always use `Sentry.startSpan()`
135143
* or other span methods.
@@ -175,6 +183,9 @@ export class SentrySpan implements Span {
175183

176184
/** @inheritDoc */
177185
public addLink(link: SpanLink): this {
186+
if (this._frozen) {
187+
return this;
188+
}
178189
if (this._links) {
179190
this._links.push(link);
180191
} else {
@@ -185,6 +196,9 @@ export class SentrySpan implements Span {
185196

186197
/** @inheritDoc */
187198
public addLinks(links: SpanLink[]): this {
199+
if (this._frozen) {
200+
return this;
201+
}
188202
if (this._links) {
189203
this._links.push(...links);
190204
} else {
@@ -216,6 +230,10 @@ export class SentrySpan implements Span {
216230

217231
/** @inheritdoc */
218232
public setAttribute(key: string, value: SpanAttributeValue | undefined): this {
233+
if (this._frozen) {
234+
return this;
235+
}
236+
219237
if (value === undefined) {
220238
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
221239
delete this._attributes[key];
@@ -247,13 +265,19 @@ export class SentrySpan implements Span {
247265
* @internal
248266
*/
249267
public updateStartTime(timeInput: SpanTimeInput): void {
268+
if (this._frozen) {
269+
return;
270+
}
250271
this._startTime = spanTimeInputToSeconds(timeInput);
251272
}
252273

253274
/**
254275
* @inheritDoc
255276
*/
256277
public setStatus(value: SpanStatus): this {
278+
if (this._frozen) {
279+
return this;
280+
}
257281
this._status = value;
258282
return this;
259283
}
@@ -262,6 +286,9 @@ export class SentrySpan implements Span {
262286
* @inheritDoc
263287
*/
264288
public updateName(name: string): this {
289+
if (this._frozen) {
290+
return this;
291+
}
265292
this._name = name;
266293
// Renaming a span marks its name as explicitly chosen, so we stamp `custom`.
267294
// The exception is spans created by SentryTraceProvider: those are branded for
@@ -285,6 +312,16 @@ export class SentrySpan implements Span {
285312
logSpanEnd(this);
286313

287314
this._onSpanEnded();
315+
316+
// A span created by the SentryTracerProvider is handed to OTel instrumentations as an OTel span,
317+
// so once end-of-span processing is done (including the `spanEnd` hook where `applyOtelSpanData`
318+
// finalizes status/source) it is sealed against further writes — mirroring the OpenTelemetry SDK,
319+
// where setters no-op after a span has ended. Without this, an instrumentation that sets
320+
// status/attributes after `end()` (e.g. Next.js on a render error) would overwrite the finalized
321+
// values, and the deferred capture would then serialize those late writes. Spans created directly
322+
// through the core API (e.g. the browser SDK, which backfills resource-timing attributes after a
323+
// span ends) are not tracer-provider spans and stay mutable.
324+
this._frozen = spanIsTracerProviderSpan(this);
288325
}
289326

290327
/**
@@ -353,6 +390,9 @@ export class SentrySpan implements Span {
353390
attributesOrStartTime?: SpanAttributes | SpanTimeInput,
354391
startTime?: SpanTimeInput,
355392
): this {
393+
if (this._frozen) {
394+
return this;
395+
}
356396
DEBUG_BUILD && debug.log('[Tracing] Adding an event to span:', name);
357397

358398
const time = isSpanTimeInput(attributesOrStartTime) ? attributesOrStartTime : startTime || timestampInSeconds();

packages/core/test/lib/tracing/sentrySpan.test.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import { describe, expect, it, test, vi } from 'vitest';
22
import { getCurrentScope } from '../../../src/currentScopes';
33
import { setCurrentClient } from '../../../src/sdk';
4-
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '../../../src/semanticAttributes';
4+
import {
5+
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
6+
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE,
7+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
8+
} from '../../../src/semanticAttributes';
59
import { _INTERNAL_setDeferSegmentSpanCapture, SentrySpan } from '../../../src/tracing/sentrySpan';
610
import { SPAN_STATUS_ERROR } from '../../../src/tracing/spanstatus';
7-
import { markSpanForOtelSourceInference, spanSourceWasExplicitlySet } from '../../../src/tracing/utils';
11+
import {
12+
markSpanAsTracerProviderSpan,
13+
markSpanForOtelSourceInference,
14+
spanSourceWasExplicitlySet,
15+
} from '../../../src/tracing/utils';
816
import type { SpanJSON } from '../../../src/types/span';
917
import { spanToJSON, TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED } from '../../../src/utils/spanUtils';
1018
import { timestampInSeconds } from '../../../src/utils/time';
@@ -144,6 +152,61 @@ describe('SentrySpan', () => {
144152
});
145153
});
146154

155+
describe('tracer-provider span sealing', () => {
156+
it('seals a tracer-provider span against all mutation after it ends', () => {
157+
const span = new SentrySpan({ name: 'original', startTimestamp: 1, attributes: { key: 'before' } });
158+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'before' });
159+
span.addEvent('measurement', {
160+
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: 1,
161+
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: 'millisecond',
162+
});
163+
const linked = new SentrySpan({ name: 'linked' });
164+
165+
markSpanAsTracerProviderSpan(span);
166+
span.end();
167+
168+
// Every mutator must no-op on a tracer-provider span once it has ended, mirroring OTel SDK spans.
169+
span.setAttribute('key', 'after');
170+
span.setAttributes({ key2: 'after' });
171+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'after' });
172+
span.updateName('after');
173+
span.updateStartTime(999);
174+
span.addLink({ context: linked.spanContext() });
175+
span.addLinks([{ context: linked.spanContext() }]);
176+
span.addEvent('measurement', {
177+
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: 2,
178+
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: 'millisecond',
179+
});
180+
181+
const json = spanToJSON(span);
182+
expect(json.data?.['key']).toBe('before');
183+
expect(json.data?.['key2']).toBeUndefined();
184+
expect(json.status).toBe('before');
185+
expect(json.description).toBe('original');
186+
expect(json.start_timestamp).toBe(1);
187+
expect(json.links).toBeUndefined();
188+
expect(json.measurements).toEqual({ measurement: { value: 1, unit: 'millisecond' } });
189+
});
190+
191+
it('keeps a span that is not a tracer-provider span mutable after it ends', () => {
192+
const span = new SentrySpan({ name: 'original', startTimestamp: 1, attributes: { key: 'before' } });
193+
const linked = new SentrySpan({ name: 'linked' });
194+
195+
span.end();
196+
197+
span.setAttribute('key', 'after');
198+
span.updateName('after');
199+
span.updateStartTime(999);
200+
span.addLink({ context: linked.spanContext() });
201+
202+
const json = spanToJSON(span);
203+
expect(json.data?.['key']).toBe('after');
204+
expect(json.description).toBe('after');
205+
expect(json.start_timestamp).toBe(999);
206+
expect(json.links).toHaveLength(1);
207+
});
208+
});
209+
147210
describe('end', () => {
148211
test('simple', () => {
149212
const span = new SentrySpan({});

0 commit comments

Comments
 (0)