Skip to content

Commit bb677d7

Browse files
committed
Seal tracer-provider spans against mutation after they end
1 parent 29ce501 commit bb677d7

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
@@ -49,7 +49,12 @@ import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext';
4949
import { logSpanEnd } from './logSpans';
5050
import { timedEventsToMeasurements } from './measurement';
5151
import { hasSpanStreamingEnabled } from './spans/hasSpanStreamingEnabled';
52-
import { getCapturedScopesOnSpan, markSpanSourceAsExplicit, spanShouldInferOtelSource } from './utils';
52+
import {
53+
getCapturedScopesOnSpan,
54+
markSpanSourceAsExplicit,
55+
spanIsTracerProviderSpan,
56+
spanShouldInferOtelSource,
57+
} from './utils';
5358

5459
const MAX_SPAN_COUNT = 1000;
5560

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

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

175183
/** @inheritDoc */
176184
public addLink(link: SpanLink): this {
185+
if (this._frozen) {
186+
return this;
187+
}
177188
if (this._links) {
178189
this._links.push(link);
179190
} else {
@@ -184,6 +195,9 @@ export class SentrySpan implements Span {
184195

185196
/** @inheritDoc */
186197
public addLinks(links: SpanLink[]): this {
198+
if (this._frozen) {
199+
return this;
200+
}
187201
if (this._links) {
188202
this._links.push(...links);
189203
} else {
@@ -215,6 +229,10 @@ export class SentrySpan implements Span {
215229

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

252273
/**
253274
* @inheritDoc
254275
*/
255276
public setStatus(value: SpanStatus): this {
277+
if (this._frozen) {
278+
return this;
279+
}
256280
this._status = value;
257281
return this;
258282
}
@@ -261,6 +285,9 @@ export class SentrySpan implements Span {
261285
* @inheritDoc
262286
*/
263287
public updateName(name: string): this {
288+
if (this._frozen) {
289+
return this;
290+
}
264291
this._name = name;
265292
// Renaming a span marks its name as explicitly chosen, so we stamp `custom`.
266293
// The exception is spans created by SentryTraceProvider: those are branded for
@@ -284,6 +311,16 @@ export class SentrySpan implements Span {
284311
logSpanEnd(this);
285312

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

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

357397
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)