Skip to content

Commit 1cc9bbc

Browse files
committed
feat(core): Ensure trace APIs always return a span
When tracing is disabled or otherwise skipped, they return a non recording span instead of undefined.
1 parent 1cdab67 commit 1cc9bbc

38 files changed

+291
-177
lines changed

packages/bun/src/integrations/bunserver.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,7 @@ function instrumentBunServeOptions(serveOptions: Parameters<typeof Bun.serve>[0]
9696
typeof serveOptions.fetch
9797
>);
9898
if (response && response.status) {
99-
if (span) {
100-
setHttpStatus(span, response.status);
101-
}
99+
setHttpStatus(span, response.status);
102100
if (span instanceof Transaction) {
103101
const scope = getCurrentScope();
104102
scope.setContext('response', {

packages/core/src/fetch.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
setHttpStatus,
1515
startInactiveSpan,
1616
} from './tracing';
17+
import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan';
1718
import { hasTracingEnabled } from './utils/hasTracingEnabled';
1819
import { spanToTraceHeader } from './utils/spanUtils';
1920

@@ -92,12 +93,10 @@ export function instrumentFetchRequest(
9293
},
9394
op: 'http.client',
9495
})
95-
: undefined;
96+
: new SentryNonRecordingSpan();
9697

97-
if (span) {
98-
handlerData.fetchData.__span = span.spanContext().spanId;
99-
spans[span.spanContext().spanId] = span;
100-
}
98+
handlerData.fetchData.__span = span.spanContext().spanId;
99+
spans[span.spanContext().spanId] = span;
101100

102101
if (shouldAttachHeaders(handlerData.fetchData.url) && client) {
103102
const request: string | Request = handlerData.args[0];

packages/core/src/tracing/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { startIdleTransaction, addTracingExtensions } from './hubextensions';
22
export { IdleTransaction, TRACING_DEFAULTS } from './idletransaction';
33
export type { BeforeFinishCallback } from './idletransaction';
44
export { SentrySpan } from './sentrySpan';
5+
export { SentryNonRecordingSpan } from './sentryNonRecordingSpan';
56
export { Transaction } from './transaction';
67
// eslint-disable-next-line deprecation/deprecation
78
export { getActiveTransaction, getActiveSpan, getSpanDescendants } from './utils';
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type {
2+
Span,
3+
SpanAttributeValue,
4+
SpanAttributes,
5+
SpanContext,
6+
SpanContextData,
7+
SpanStatus,
8+
SpanTimeInput,
9+
} from '@sentry/types';
10+
import { uuid4 } from '@sentry/utils';
11+
import { TRACE_FLAG_NONE } from '../utils/spanUtils';
12+
13+
/**
14+
* A Sentry Span that is non-recording, meaning it will not be sent to Sentry.
15+
*/
16+
export class SentryNonRecordingSpan implements Span {
17+
private _traceId: string;
18+
private _spanId: string;
19+
20+
public constructor(spanContext: SpanContext = {}) {
21+
this._traceId = spanContext.traceId || uuid4();
22+
this._spanId = spanContext.spanId || uuid4().substring(16);
23+
}
24+
25+
/** @inheritdoc */
26+
public spanContext(): SpanContextData {
27+
return {
28+
spanId: this._spanId,
29+
traceId: this._traceId,
30+
traceFlags: TRACE_FLAG_NONE,
31+
};
32+
}
33+
34+
/** @inheritdoc */
35+
// eslint-disable-next-line @typescript-eslint/no-empty-function
36+
public end(_timestamp?: SpanTimeInput): void {}
37+
38+
/** @inheritdoc */
39+
public setAttribute(_key: string, _value: SpanAttributeValue | undefined): this {
40+
return this;
41+
}
42+
43+
/** @inheritdoc */
44+
public setAttributes(_values: SpanAttributes): this {
45+
return this;
46+
}
47+
48+
/** @inheritdoc */
49+
public setStatus(_status: SpanStatus): this {
50+
return this;
51+
}
52+
53+
/** @inheritdoc */
54+
public updateName(_name: string): this {
55+
return this;
56+
}
57+
58+
/** @inheritdoc */
59+
public isRecording(): boolean {
60+
return false;
61+
}
62+
}

packages/core/src/tracing/trace.ts

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { handleCallbackErrors } from '../utils/handleCallbackErrors';
99
import { hasTracingEnabled } from '../utils/hasTracingEnabled';
1010
import { spanIsSampled, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils';
1111
import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext';
12+
import { SentryNonRecordingSpan } from './sentryNonRecordingSpan';
1213
import type { SentrySpan } from './sentrySpan';
1314
import { SPAN_STATUS_ERROR } from './spanstatus';
1415
import { addChildSpanToSpan, getActiveSpan, setCapturedScopesOnSpan } from './utils';
@@ -24,7 +25,7 @@ import { addChildSpanToSpan, getActiveSpan, setCapturedScopesOnSpan } from './ut
2425
* or you didn't set `tracesSampleRate`, this function will not generate spans
2526
* and the `span` returned from the callback will be undefined.
2627
*/
27-
export function startSpan<T>(context: StartSpanOptions, callback: (span: Span | undefined) => T): T {
28+
export function startSpan<T>(context: StartSpanOptions, callback: (span: Span) => T): T {
2829
const spanContext = normalizeContext(context);
2930

3031
return withScope(context.scope, scope => {
@@ -35,18 +36,16 @@ export function startSpan<T>(context: StartSpanOptions, callback: (span: Span |
3536

3637
const shouldSkipSpan = context.onlyIfParent && !parentSpan;
3738
const activeSpan = shouldSkipSpan
38-
? undefined
39+
? new SentryNonRecordingSpan()
3940
: createChildSpanOrTransaction(hub, {
4041
parentSpan,
4142
spanContext,
4243
forceTransaction: context.forceTransaction,
4344
scope,
4445
});
4546

46-
if (activeSpan) {
47-
// eslint-disable-next-line deprecation/deprecation
48-
scope.setSpan(activeSpan);
49-
}
47+
// eslint-disable-next-line deprecation/deprecation
48+
scope.setSpan(activeSpan);
5049

5150
return handleCallbackErrors(
5251
() => callback(activeSpan),
@@ -75,10 +74,7 @@ export function startSpan<T>(context: StartSpanOptions, callback: (span: Span |
7574
* or you didn't set `tracesSampleRate`, this function will not generate spans
7675
* and the `span` returned from the callback will be undefined.
7776
*/
78-
export function startSpanManual<T>(
79-
context: StartSpanOptions,
80-
callback: (span: Span | undefined, finish: () => void) => T,
81-
): T {
77+
export function startSpanManual<T>(context: StartSpanOptions, callback: (span: Span, finish: () => void) => T): T {
8278
const spanContext = normalizeContext(context);
8379

8480
return withScope(context.scope, scope => {
@@ -89,18 +85,16 @@ export function startSpanManual<T>(
8985

9086
const shouldSkipSpan = context.onlyIfParent && !parentSpan;
9187
const activeSpan = shouldSkipSpan
92-
? undefined
88+
? new SentryNonRecordingSpan()
9389
: createChildSpanOrTransaction(hub, {
9490
parentSpan,
9591
spanContext,
9692
forceTransaction: context.forceTransaction,
9793
scope,
9894
});
9995

100-
if (activeSpan) {
101-
// eslint-disable-next-line deprecation/deprecation
102-
scope.setSpan(activeSpan);
103-
}
96+
// eslint-disable-next-line deprecation/deprecation
97+
scope.setSpan(activeSpan);
10498

10599
function finishAndSetSpan(): void {
106100
activeSpan && activeSpan.end();
@@ -131,11 +125,7 @@ export function startSpanManual<T>(
131125
* or you didn't set `tracesSampleRate` or `tracesSampler`, this function will not generate spans
132126
* and the `span` returned from the callback will be undefined.
133127
*/
134-
export function startInactiveSpan(context: StartSpanOptions): Span | undefined {
135-
if (!hasTracingEnabled()) {
136-
return undefined;
137-
}
138-
128+
export function startInactiveSpan(context: StartSpanOptions): Span {
139129
const spanContext = normalizeContext(context);
140130
// eslint-disable-next-line deprecation/deprecation
141131
const hub = getCurrentHub();
@@ -147,7 +137,7 @@ export function startInactiveSpan(context: StartSpanOptions): Span | undefined {
147137
const shouldSkipSpan = context.onlyIfParent && !parentSpan;
148138

149139
if (shouldSkipSpan) {
150-
return undefined;
140+
return new SentryNonRecordingSpan();
151141
}
152142

153143
const scope = context.scope || getCurrentScope();
@@ -275,14 +265,14 @@ function createChildSpanOrTransaction(
275265
forceTransaction?: boolean;
276266
scope: Scope;
277267
},
278-
): Span | undefined {
268+
): Span {
279269
if (!hasTracingEnabled()) {
280-
return undefined;
270+
return new SentryNonRecordingSpan();
281271
}
282272

283273
const isolationScope = getIsolationScope();
284274

285-
let span: Span | undefined;
275+
let span: Span;
286276
if (parentSpan && !forceTransaction) {
287277
// eslint-disable-next-line deprecation/deprecation
288278
span = parentSpan.startChild(spanContext);

packages/core/src/tracing/utils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getCurrentScope } from '../currentScopes';
55

66
import type { Hub } from '../hub';
77
import { getCurrentHub } from '../hub';
8+
import { spanIsSampled } from '../utils/spanUtils';
89

910
/**
1011
* Grabs active transaction off scope.
@@ -58,7 +59,8 @@ export function getSpanDescendants(span: SpanWithPotentialChildren): Span[] {
5859
// This exit condition is required to not infinitely loop in case of a circular dependency.
5960
if (resultSet.has(span)) {
6061
return;
61-
} else {
62+
// We want to ignore unsampled spans (e.g. non recording spans)
63+
} else if (spanIsSampled(span)) {
6264
resultSet.add(span);
6365
const childSpans = span[CHILD_SPANS_FIELD] ? Array.from(span[CHILD_SPANS_FIELD]) : [];
6466
for (const childSpan of childSpans) {

packages/core/src/utils/spanUtils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import type {
1010
import { dropUndefinedKeys, generateSentryTraceHeader, timestampInSeconds } from '@sentry/utils';
1111
import { getMetricSummaryJsonForSpan } from '../metrics/metric-summary';
1212
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes';
13-
import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing';
1413
import type { SentrySpan } from '../tracing/sentrySpan';
14+
import { SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus';
1515

1616
// These are aligned with OpenTelemetry trace flags
1717
export const TRACE_FLAG_NONE = 0x0;

packages/core/test/lib/scope.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -956,21 +956,21 @@ describe('withActiveSpan()', () => {
956956

957957
expect(getActiveSpan()).not.toBe(inactiveSpan);
958958

959-
withActiveSpan(inactiveSpan!, () => {
959+
withActiveSpan(inactiveSpan, () => {
960960
expect(getActiveSpan()).toBe(inactiveSpan);
961961
});
962962
});
963963

964964
it('should create child spans when calling startSpan within the callback', () => {
965965
const inactiveSpan = startInactiveSpan({ name: 'inactive-span' });
966966

967-
const parentSpanId = withActiveSpan(inactiveSpan!, () => {
967+
const parentSpanId = withActiveSpan(inactiveSpan, () => {
968968
return startSpan({ name: 'child-span' }, childSpan => {
969-
return spanToJSON(childSpan!).parent_span_id;
969+
return spanToJSON(childSpan).parent_span_id;
970970
});
971971
});
972972

973-
expect(parentSpanId).toBe(inactiveSpan?.spanContext().spanId);
973+
expect(parentSpanId).toBe(inactiveSpan.spanContext().spanId);
974974
});
975975

976976
it('when `null` is passed, no span should be active within the callback', () => {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ describe('registerErrorHandlers()', () => {
6868

6969
startSpan({ name: 'test' }, span => {
7070
mockErrorCallback({} as HandlerDataError);
71-
expect(spanToJSON(span!).status).toBe('internal_error');
71+
expect(spanToJSON(span).status).toBe('internal_error');
7272
});
7373
});
7474

@@ -77,7 +77,7 @@ describe('registerErrorHandlers()', () => {
7777

7878
startSpan({ name: 'test' }, span => {
7979
mockUnhandledRejectionCallback({});
80-
expect(spanToJSON(span!).status).toBe('internal_error');
80+
expect(spanToJSON(span).status).toBe('internal_error');
8181
});
8282
});
8383
});

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,10 @@ describe('IdleTransaction', () => {
170170
startSpanManual({ name: 'inner1' }, span => {
171171
const childSpan = startInactiveSpan({ name: 'inner2' })!;
172172
expect(transaction.activities).toMatchObject({
173-
[span!.spanContext().spanId]: true,
173+
[span.spanContext().spanId]: true,
174174
[childSpan.spanContext().spanId]: true,
175175
});
176-
span?.end();
176+
span.end();
177177
jest.advanceTimersByTime(TRACING_DEFAULTS.idleTimeout + 1);
178178

179179
expect(mockFinish).toHaveBeenCalledTimes(0);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { Span } from '@sentry/types';
2+
import { SPAN_STATUS_ERROR } from '../../../src/tracing';
3+
import { SentryNonRecordingSpan } from '../../../src/tracing/sentryNonRecordingSpan';
4+
import { TRACE_FLAG_NONE, spanIsSampled, spanToJSON } from '../../../src/utils/spanUtils';
5+
6+
describe('SentryNonRecordingSpan', () => {
7+
it('satisfies the Span interface', () => {
8+
const span: Span = new SentryNonRecordingSpan();
9+
10+
expect(span.spanContext()).toEqual({
11+
spanId: expect.any(String),
12+
traceId: expect.any(String),
13+
traceFlags: TRACE_FLAG_NONE,
14+
});
15+
16+
expect(spanIsSampled(span)).toBe(false);
17+
expect(span.isRecording()).toBe(false);
18+
expect(spanToJSON(span)).toEqual({
19+
span_id: expect.any(String),
20+
trace_id: expect.any(String),
21+
});
22+
23+
// Ensure all methods work
24+
span.end();
25+
span.end(123);
26+
span.updateName('name');
27+
span.setAttribute('key', 'value');
28+
span.setAttributes({ key: 'value' });
29+
span.setStatus({ code: SPAN_STATUS_ERROR });
30+
31+
// but nothing is actually set/readable
32+
expect(spanToJSON(span)).toEqual({
33+
span_id: expect.any(String),
34+
trace_id: expect.any(String),
35+
});
36+
});
37+
});

packages/core/test/lib/tracing/span.test.ts renamed to packages/core/test/lib/tracing/sentrySpan.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { timestampInSeconds } from '@sentry/utils';
2-
import { SentrySpan } from '../../../src';
2+
import { SentrySpan } from '../../../src/tracing/sentrySpan';
33
import { SPAN_STATUS_ERROR } from '../../../src/tracing/spanstatus';
44
import { TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED, spanToJSON, spanToTraceContext } from '../../../src/utils/spanUtils';
55

6-
describe('span', () => {
6+
describe('SentrySpan', () => {
77
describe('name', () => {
88
it('works with name', () => {
99
const span = new SentrySpan({ name: 'span name' });

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ describe('setHttpStatus', () => {
1818
])('applies the correct span status and http status code to the span (%s - $%s)', (code, status) => {
1919
const span = new SentrySpan({ name: 'test' });
2020

21-
setHttpStatus(span!, code);
21+
setHttpStatus(span, code);
2222

23-
const { status: spanStatus, data } = spanToJSON(span!);
23+
const { status: spanStatus, data } = spanToJSON(span);
2424

2525
expect(spanStatus).toBe(status);
2626
expect(data).toMatchObject({ 'http.response.status_code': code });
@@ -29,9 +29,9 @@ describe('setHttpStatus', () => {
2929
it("doesn't set the status for an unknown http status code", () => {
3030
const span = new SentrySpan({ name: 'test' });
3131

32-
setHttpStatus(span!, 600);
32+
setHttpStatus(span, 600);
3333

34-
const { status: spanStatus, data } = spanToJSON(span!);
34+
const { status: spanStatus, data } = spanToJSON(span);
3535

3636
expect(spanStatus).toBeUndefined();
3737
expect(data).toMatchObject({ 'http.response.status_code': 600 });

0 commit comments

Comments
 (0)