Skip to content

Commit 0019309

Browse files
authored
feat(core): Add parentSpan option to startSpan* APIs (#12567)
With this PR, you can now pass a `parentSpan` optionally to `startSpan*` APIs to create a span as a child of a specific span: ```js const span = Sentry.startInactiveSpan({ name: 'xxx', parentSpan: parent }); Sentry.startSpan({ name: 'xxx', parentSpan: parent }, () => {}); Sentry.startSpanManual({ name: 'xxx', parentSpan: parent }, () => {}); ``` With this, it should be easier to understand how you can manually work with spans in v8. Closes #12539 ref #12566, which I noticed while working on this. For now I just cast the span which should be fine, but is not ideal.
1 parent 424937f commit 0019309

File tree

5 files changed

+287
-139
lines changed

5 files changed

+287
-139
lines changed

packages/core/src/tracing/trace.ts

Lines changed: 122 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable max-lines */
2+
13
import type { ClientOptions, Scope, SentrySpanArguments, Span, SpanTimeInput, StartSpanOptions } from '@sentry/types';
24
import { generatePropagationContext, logger, propagationContextFromHeaders } from '@sentry/utils';
35
import type { AsyncContextStrategy } from '../asyncContext/types';
@@ -32,40 +34,47 @@ const SUPPRESS_TRACING_KEY = '__SENTRY_SUPPRESS_TRACING__';
3234
* You'll always get a span passed to the callback,
3335
* it may just be a non-recording span if the span is not sampled or if tracing is disabled.
3436
*/
35-
export function startSpan<T>(context: StartSpanOptions, callback: (span: Span) => T): T {
37+
export function startSpan<T>(options: StartSpanOptions, callback: (span: Span) => T): T {
3638
const acs = getAcs();
3739
if (acs.startSpan) {
38-
return acs.startSpan(context, callback);
40+
return acs.startSpan(options, callback);
3941
}
4042

41-
const spanContext = normalizeContext(context);
42-
43-
return withScope(context.scope, scope => {
44-
const parentSpan = getParentSpan(scope);
45-
46-
const shouldSkipSpan = context.onlyIfParent && !parentSpan;
47-
const activeSpan = shouldSkipSpan
48-
? new SentryNonRecordingSpan()
49-
: createChildOrRootSpan({
50-
parentSpan,
51-
spanContext,
52-
forceTransaction: context.forceTransaction,
53-
scope,
54-
});
55-
56-
_setSpanForScope(scope, activeSpan);
57-
58-
return handleCallbackErrors(
59-
() => callback(activeSpan),
60-
() => {
61-
// Only update the span status if it hasn't been changed yet, and the span is not yet finished
62-
const { status } = spanToJSON(activeSpan);
63-
if (activeSpan.isRecording() && (!status || status === 'ok')) {
64-
activeSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
65-
}
66-
},
67-
() => activeSpan.end(),
68-
);
43+
const spanArguments = parseSentrySpanArguments(options);
44+
const { forceTransaction, parentSpan: customParentSpan } = options;
45+
46+
return withScope(options.scope, () => {
47+
// If `options.parentSpan` is defined, we want to wrap the callback in `withActiveSpan`
48+
const wrapper = getActiveSpanWrapper<T>(customParentSpan);
49+
50+
return wrapper(() => {
51+
const scope = getCurrentScope();
52+
const parentSpan = getParentSpan(scope);
53+
54+
const shouldSkipSpan = options.onlyIfParent && !parentSpan;
55+
const activeSpan = shouldSkipSpan
56+
? new SentryNonRecordingSpan()
57+
: createChildOrRootSpan({
58+
parentSpan,
59+
spanArguments,
60+
forceTransaction,
61+
scope,
62+
});
63+
64+
_setSpanForScope(scope, activeSpan);
65+
66+
return handleCallbackErrors(
67+
() => callback(activeSpan),
68+
() => {
69+
// Only update the span status if it hasn't been changed yet, and the span is not yet finished
70+
const { status } = spanToJSON(activeSpan);
71+
if (activeSpan.isRecording() && (!status || status === 'ok')) {
72+
activeSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
73+
}
74+
},
75+
() => activeSpan.end(),
76+
);
77+
});
6978
});
7079
}
7180

@@ -79,43 +88,50 @@ export function startSpan<T>(context: StartSpanOptions, callback: (span: Span) =
7988
* You'll always get a span passed to the callback,
8089
* it may just be a non-recording span if the span is not sampled or if tracing is disabled.
8190
*/
82-
export function startSpanManual<T>(context: StartSpanOptions, callback: (span: Span, finish: () => void) => T): T {
91+
export function startSpanManual<T>(options: StartSpanOptions, callback: (span: Span, finish: () => void) => T): T {
8392
const acs = getAcs();
8493
if (acs.startSpanManual) {
85-
return acs.startSpanManual(context, callback);
94+
return acs.startSpanManual(options, callback);
8695
}
8796

88-
const spanContext = normalizeContext(context);
89-
90-
return withScope(context.scope, scope => {
91-
const parentSpan = getParentSpan(scope);
92-
93-
const shouldSkipSpan = context.onlyIfParent && !parentSpan;
94-
const activeSpan = shouldSkipSpan
95-
? new SentryNonRecordingSpan()
96-
: createChildOrRootSpan({
97-
parentSpan,
98-
spanContext,
99-
forceTransaction: context.forceTransaction,
100-
scope,
101-
});
102-
103-
_setSpanForScope(scope, activeSpan);
104-
105-
function finishAndSetSpan(): void {
106-
activeSpan.end();
107-
}
108-
109-
return handleCallbackErrors(
110-
() => callback(activeSpan, finishAndSetSpan),
111-
() => {
112-
// Only update the span status if it hasn't been changed yet, and the span is not yet finished
113-
const { status } = spanToJSON(activeSpan);
114-
if (activeSpan.isRecording() && (!status || status === 'ok')) {
115-
activeSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
116-
}
117-
},
118-
);
97+
const spanArguments = parseSentrySpanArguments(options);
98+
const { forceTransaction, parentSpan: customParentSpan } = options;
99+
100+
return withScope(options.scope, () => {
101+
// If `options.parentSpan` is defined, we want to wrap the callback in `withActiveSpan`
102+
const wrapper = getActiveSpanWrapper<T>(customParentSpan);
103+
104+
return wrapper(() => {
105+
const scope = getCurrentScope();
106+
const parentSpan = getParentSpan(scope);
107+
108+
const shouldSkipSpan = options.onlyIfParent && !parentSpan;
109+
const activeSpan = shouldSkipSpan
110+
? new SentryNonRecordingSpan()
111+
: createChildOrRootSpan({
112+
parentSpan,
113+
spanArguments,
114+
forceTransaction,
115+
scope,
116+
});
117+
118+
_setSpanForScope(scope, activeSpan);
119+
120+
function finishAndSetSpan(): void {
121+
activeSpan.end();
122+
}
123+
124+
return handleCallbackErrors(
125+
() => callback(activeSpan, finishAndSetSpan),
126+
() => {
127+
// Only update the span status if it hasn't been changed yet, and the span is not yet finished
128+
const { status } = spanToJSON(activeSpan);
129+
if (activeSpan.isRecording() && (!status || status === 'ok')) {
130+
activeSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
131+
}
132+
},
133+
);
134+
});
119135
});
120136
}
121137

@@ -128,28 +144,39 @@ export function startSpanManual<T>(context: StartSpanOptions, callback: (span: S
128144
* This function will always return a span,
129145
* it may just be a non-recording span if the span is not sampled or if tracing is disabled.
130146
*/
131-
export function startInactiveSpan(context: StartSpanOptions): Span {
147+
export function startInactiveSpan(options: StartSpanOptions): Span {
132148
const acs = getAcs();
133149
if (acs.startInactiveSpan) {
134-
return acs.startInactiveSpan(context);
150+
return acs.startInactiveSpan(options);
135151
}
136152

137-
const spanContext = normalizeContext(context);
153+
const spanArguments = parseSentrySpanArguments(options);
154+
const { forceTransaction, parentSpan: customParentSpan } = options;
138155

139-
const scope = context.scope || getCurrentScope();
140-
const parentSpan = getParentSpan(scope);
156+
// If `options.scope` is defined, we use this as as a wrapper,
157+
// If `options.parentSpan` is defined, we want to wrap the callback in `withActiveSpan`
158+
const wrapper = options.scope
159+
? (callback: () => Span) => withScope(options.scope, callback)
160+
: customParentSpan
161+
? (callback: () => Span) => withActiveSpan(customParentSpan, callback)
162+
: (callback: () => Span) => callback();
141163

142-
const shouldSkipSpan = context.onlyIfParent && !parentSpan;
164+
return wrapper(() => {
165+
const scope = getCurrentScope();
166+
const parentSpan = getParentSpan(scope);
143167

144-
if (shouldSkipSpan) {
145-
return new SentryNonRecordingSpan();
146-
}
168+
const shouldSkipSpan = options.onlyIfParent && !parentSpan;
169+
170+
if (shouldSkipSpan) {
171+
return new SentryNonRecordingSpan();
172+
}
147173

148-
return createChildOrRootSpan({
149-
parentSpan,
150-
spanContext,
151-
forceTransaction: context.forceTransaction,
152-
scope,
174+
return createChildOrRootSpan({
175+
parentSpan,
176+
spanArguments,
177+
forceTransaction,
178+
scope,
179+
});
153180
});
154181
}
155182

@@ -239,12 +266,12 @@ export function startNewTrace<T>(callback: () => T): T {
239266

240267
function createChildOrRootSpan({
241268
parentSpan,
242-
spanContext,
269+
spanArguments,
243270
forceTransaction,
244271
scope,
245272
}: {
246273
parentSpan: SentrySpan | undefined;
247-
spanContext: SentrySpanArguments;
274+
spanArguments: SentrySpanArguments;
248275
forceTransaction?: boolean;
249276
scope: Scope;
250277
}): Span {
@@ -256,7 +283,7 @@ function createChildOrRootSpan({
256283

257284
let span: Span;
258285
if (parentSpan && !forceTransaction) {
259-
span = _startChildSpan(parentSpan, scope, spanContext);
286+
span = _startChildSpan(parentSpan, scope, spanArguments);
260287
addChildSpanToSpan(parentSpan, span);
261288
} else if (parentSpan) {
262289
// If we forced a transaction but have a parent span, make sure to continue from the parent span, not the scope
@@ -268,7 +295,7 @@ function createChildOrRootSpan({
268295
{
269296
traceId,
270297
parentSpanId,
271-
...spanContext,
298+
...spanArguments,
272299
},
273300
scope,
274301
parentSampled,
@@ -290,7 +317,7 @@ function createChildOrRootSpan({
290317
{
291318
traceId,
292319
parentSpanId,
293-
...spanContext,
320+
...spanArguments,
294321
},
295322
scope,
296323
parentSampled,
@@ -312,19 +339,17 @@ function createChildOrRootSpan({
312339
* This converts StartSpanOptions to SentrySpanArguments.
313340
* For the most part (for now) we accept the same options,
314341
* but some of them need to be transformed.
315-
*
316-
* Eventually the StartSpanOptions will be more aligned with OpenTelemetry.
317342
*/
318-
function normalizeContext(context: StartSpanOptions): SentrySpanArguments {
319-
const exp = context.experimental || {};
343+
function parseSentrySpanArguments(options: StartSpanOptions): SentrySpanArguments {
344+
const exp = options.experimental || {};
320345
const initialCtx: SentrySpanArguments = {
321346
isStandalone: exp.standalone,
322-
...context,
347+
...options,
323348
};
324349

325-
if (context.startTime) {
350+
if (options.startTime) {
326351
const ctx: SentrySpanArguments & { startTime?: SpanTimeInput } = { ...initialCtx };
327-
ctx.startTimestamp = spanTimeInputToSeconds(context.startTime);
352+
ctx.startTimestamp = spanTimeInputToSeconds(options.startTime);
328353
delete ctx.startTime;
329354
return ctx;
330355
}
@@ -419,3 +444,11 @@ function getParentSpan(scope: Scope): SentrySpan | undefined {
419444

420445
return span;
421446
}
447+
448+
function getActiveSpanWrapper<T>(parentSpan?: Span): (callback: () => T) => T {
449+
return parentSpan
450+
? (callback: () => T) => {
451+
return withActiveSpan(parentSpan, callback);
452+
}
453+
: (callback: () => T) => callback();
454+
}

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

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,17 @@ describe('startSpan', () => {
271271
expect(getActiveSpan()).toBe(undefined);
272272
});
273273

274+
it('allows to pass a parentSpan', () => {
275+
const parentSpan = new SentrySpan({ spanId: 'parent-span-id', sampled: true, name: 'parent-span' });
276+
277+
startSpan({ name: 'GET users/[id]', parentSpan }, span => {
278+
expect(getActiveSpan()).toBe(span);
279+
expect(spanToJSON(span).parent_span_id).toBe('parent-span-id');
280+
});
281+
282+
expect(getActiveSpan()).toBe(undefined);
283+
});
284+
274285
it('allows to force a transaction with forceTransaction=true', async () => {
275286
const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 });
276287
client = new TestClient(options);
@@ -653,13 +664,13 @@ describe('startSpanManual', () => {
653664
const parentSpan = new SentrySpan({ spanId: 'parent-span-id', sampled: true });
654665
_setSpanForScope(manualScope, parentSpan);
655666

656-
startSpanManual({ name: 'GET users/[id]', scope: manualScope }, (span, finish) => {
667+
startSpanManual({ name: 'GET users/[id]', scope: manualScope }, span => {
657668
expect(getCurrentScope()).not.toBe(initialScope);
658669
expect(getCurrentScope()).toBe(manualScope);
659670
expect(getActiveSpan()).toBe(span);
660671
expect(spanToJSON(span).parent_span_id).toBe('parent-span-id');
661672

662-
finish();
673+
span.end();
663674

664675
// Is still the active span
665676
expect(getActiveSpan()).toBe(span);
@@ -669,6 +680,19 @@ describe('startSpanManual', () => {
669680
expect(getActiveSpan()).toBe(undefined);
670681
});
671682

683+
it('allows to pass a parentSpan', () => {
684+
const parentSpan = new SentrySpan({ spanId: 'parent-span-id', sampled: true, name: 'parent-span' });
685+
686+
startSpanManual({ name: 'GET users/[id]', parentSpan }, span => {
687+
expect(getActiveSpan()).toBe(span);
688+
expect(spanToJSON(span).parent_span_id).toBe('parent-span-id');
689+
690+
span.end();
691+
});
692+
693+
expect(getActiveSpan()).toBe(undefined);
694+
});
695+
672696
it('allows to force a transaction with forceTransaction=true', async () => {
673697
const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 });
674698
client = new TestClient(options);
@@ -977,6 +1001,19 @@ describe('startInactiveSpan', () => {
9771001
expect(getActiveSpan()).toBeUndefined();
9781002
});
9791003

1004+
it('allows to pass a parentSpan', () => {
1005+
const parentSpan = new SentrySpan({ spanId: 'parent-span-id', sampled: true, name: 'parent-span' });
1006+
1007+
const span = startInactiveSpan({ name: 'GET users/[id]', parentSpan });
1008+
1009+
expect(spanToJSON(span).parent_span_id).toBe('parent-span-id');
1010+
expect(getActiveSpan()).toBe(undefined);
1011+
1012+
span.end();
1013+
1014+
expect(getActiveSpan()).toBeUndefined();
1015+
});
1016+
9801017
it('allows to force a transaction with forceTransaction=true', async () => {
9811018
const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 });
9821019
client = new TestClient(options);

0 commit comments

Comments
 (0)