Skip to content

Commit 9fe67aa

Browse files
author
Luca Forstner
authored
fix: Fork scope and keep async context within startSpan and startSpanManual (#10413)
1 parent 71b8ac1 commit 9fe67aa

File tree

2 files changed

+202
-47
lines changed

2 files changed

+202
-47
lines changed

packages/core/src/tracing/trace.ts

Lines changed: 52 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { dropUndefinedKeys, logger, tracingContextFromHeaders } from '@sentry/ut
55
import { DEBUG_BUILD } from '../debug-build';
66
import { getCurrentScope, withScope } from '../exports';
77
import type { Hub } from '../hub';
8+
import { runWithAsyncContext } from '../hub';
89
import { getIsolationScope } from '../hub';
910
import { getCurrentHub } from '../hub';
1011
import { handleCallbackErrors } from '../utils/handleCallbackErrors';
@@ -74,31 +75,33 @@ export function trace<T>(
7475
export function startSpan<T>(context: StartSpanOptions, callback: (span: Span | undefined) => T): T {
7576
const ctx = normalizeContext(context);
7677

77-
return withScope(context.scope, scope => {
78-
// eslint-disable-next-line deprecation/deprecation
79-
const hub = getCurrentHub();
80-
// eslint-disable-next-line deprecation/deprecation
81-
const parentSpan = scope.getSpan();
78+
return runWithAsyncContext(() => {
79+
return withScope(context.scope, scope => {
80+
// eslint-disable-next-line deprecation/deprecation
81+
const hub = getCurrentHub();
82+
// eslint-disable-next-line deprecation/deprecation
83+
const parentSpan = scope.getSpan();
8284

83-
const shouldSkipSpan = context.onlyIfParent && !parentSpan;
84-
const activeSpan = shouldSkipSpan ? undefined : createChildSpanOrTransaction(hub, parentSpan, ctx);
85+
const shouldSkipSpan = context.onlyIfParent && !parentSpan;
86+
const activeSpan = shouldSkipSpan ? undefined : createChildSpanOrTransaction(hub, parentSpan, ctx);
8587

86-
// eslint-disable-next-line deprecation/deprecation
87-
scope.setSpan(activeSpan);
88-
89-
return handleCallbackErrors(
90-
() => callback(activeSpan),
91-
() => {
92-
// Only update the span status if it hasn't been changed yet
93-
if (activeSpan) {
94-
const { status } = spanToJSON(activeSpan);
95-
if (!status || status === 'ok') {
96-
activeSpan.setStatus('internal_error');
88+
// eslint-disable-next-line deprecation/deprecation
89+
scope.setSpan(activeSpan);
90+
91+
return handleCallbackErrors(
92+
() => callback(activeSpan),
93+
() => {
94+
// Only update the span status if it hasn't been changed yet
95+
if (activeSpan) {
96+
const { status } = spanToJSON(activeSpan);
97+
if (!status || status === 'ok') {
98+
activeSpan.setStatus('internal_error');
99+
}
97100
}
98-
}
99-
},
100-
() => activeSpan && activeSpan.end(),
101-
);
101+
},
102+
() => activeSpan && activeSpan.end(),
103+
);
104+
});
102105
});
103106
}
104107

@@ -124,34 +127,36 @@ export function startSpanManual<T>(
124127
): T {
125128
const ctx = normalizeContext(context);
126129

127-
return withScope(context.scope, scope => {
128-
// eslint-disable-next-line deprecation/deprecation
129-
const hub = getCurrentHub();
130-
// eslint-disable-next-line deprecation/deprecation
131-
const parentSpan = scope.getSpan();
132-
133-
const shouldSkipSpan = context.onlyIfParent && !parentSpan;
134-
const activeSpan = shouldSkipSpan ? undefined : createChildSpanOrTransaction(hub, parentSpan, ctx);
130+
return runWithAsyncContext(() => {
131+
return withScope(context.scope, scope => {
132+
// eslint-disable-next-line deprecation/deprecation
133+
const hub = getCurrentHub();
134+
// eslint-disable-next-line deprecation/deprecation
135+
const parentSpan = scope.getSpan();
135136

136-
// eslint-disable-next-line deprecation/deprecation
137-
scope.setSpan(activeSpan);
137+
const shouldSkipSpan = context.onlyIfParent && !parentSpan;
138+
const activeSpan = shouldSkipSpan ? undefined : createChildSpanOrTransaction(hub, parentSpan, ctx);
138139

139-
function finishAndSetSpan(): void {
140-
activeSpan && activeSpan.end();
141-
}
142-
143-
return handleCallbackErrors(
144-
() => callback(activeSpan, finishAndSetSpan),
145-
() => {
146-
// Only update the span status if it hasn't been changed yet, and the span is not yet finished
147-
if (activeSpan && activeSpan.isRecording()) {
148-
const { status } = spanToJSON(activeSpan);
149-
if (!status || status === 'ok') {
150-
activeSpan.setStatus('internal_error');
140+
// eslint-disable-next-line deprecation/deprecation
141+
scope.setSpan(activeSpan);
142+
143+
function finishAndSetSpan(): void {
144+
activeSpan && activeSpan.end();
145+
}
146+
147+
return handleCallbackErrors(
148+
() => callback(activeSpan, finishAndSetSpan),
149+
() => {
150+
// Only update the span status if it hasn't been changed yet, and the span is not yet finished
151+
if (activeSpan && activeSpan.isRecording()) {
152+
const { status } = spanToJSON(activeSpan);
153+
if (!status || status === 'ok') {
154+
activeSpan.setStatus('internal_error');
155+
}
151156
}
152-
}
153-
},
154-
);
157+
},
158+
);
159+
});
155160
});
156161
}
157162

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { setAsyncContextStrategy, setCurrentClient, startSpan, startSpanManual } from '@sentry/core';
2+
import type { TransactionEvent } from '@sentry/types';
3+
import { NodeClient, defaultStackParser } from '../src';
4+
import { setNodeAsyncContextStrategy } from '../src/async';
5+
import { getDefaultNodeClientOptions } from './helper/node-client-options';
6+
7+
const dsn = 'https://[email protected]/4291';
8+
9+
beforeAll(() => {
10+
setNodeAsyncContextStrategy();
11+
});
12+
13+
afterAll(() => {
14+
setAsyncContextStrategy(undefined);
15+
});
16+
17+
describe('startSpan()', () => {
18+
it('should correctly separate spans when called after one another with interwoven timings', async () => {
19+
const transactionEventPromise = new Promise<TransactionEvent>(resolve => {
20+
setCurrentClient(
21+
new NodeClient(
22+
getDefaultNodeClientOptions({
23+
stackParser: defaultStackParser,
24+
tracesSampleRate: 1,
25+
beforeSendTransaction: event => {
26+
resolve(event);
27+
return null;
28+
},
29+
dsn,
30+
}),
31+
),
32+
);
33+
});
34+
35+
startSpan({ name: 'first' }, () => {
36+
return new Promise<void>(resolve => {
37+
setTimeout(resolve, 500);
38+
});
39+
});
40+
41+
startSpan({ name: 'second' }, () => {
42+
return new Promise<void>(resolve => {
43+
setTimeout(resolve, 250);
44+
});
45+
});
46+
47+
const transactionEvent = await transactionEventPromise;
48+
49+
// Any transaction events happening shouldn't have any child spans
50+
expect(transactionEvent.spans).toStrictEqual([]);
51+
});
52+
53+
it('should correctly nest spans when called within one another', async () => {
54+
const transactionEventPromise = new Promise<TransactionEvent>(resolve => {
55+
setCurrentClient(
56+
new NodeClient(
57+
getDefaultNodeClientOptions({
58+
stackParser: defaultStackParser,
59+
tracesSampleRate: 1,
60+
beforeSendTransaction: event => {
61+
resolve(event);
62+
return null;
63+
},
64+
dsn,
65+
}),
66+
),
67+
);
68+
});
69+
70+
startSpan({ name: 'first' }, () => {
71+
startSpan({ name: 'second' }, () => undefined);
72+
});
73+
74+
const transactionEvent = await transactionEventPromise;
75+
76+
expect(transactionEvent.spans).toContainEqual(expect.objectContaining({ description: 'second' }));
77+
});
78+
});
79+
80+
describe('startSpanManual()', () => {
81+
it('should correctly separate spans when called after one another with interwoven timings', async () => {
82+
const transactionEventPromise = new Promise<TransactionEvent>(resolve => {
83+
setCurrentClient(
84+
new NodeClient(
85+
getDefaultNodeClientOptions({
86+
stackParser: defaultStackParser,
87+
tracesSampleRate: 1,
88+
beforeSendTransaction: event => {
89+
resolve(event);
90+
return null;
91+
},
92+
dsn,
93+
}),
94+
),
95+
);
96+
});
97+
98+
startSpanManual({ name: 'first' }, span => {
99+
return new Promise<void>(resolve => {
100+
setTimeout(() => {
101+
span?.end();
102+
resolve();
103+
}, 500);
104+
});
105+
});
106+
107+
startSpanManual({ name: 'second' }, span => {
108+
return new Promise<void>(resolve => {
109+
setTimeout(() => {
110+
span?.end();
111+
resolve();
112+
}, 500);
113+
});
114+
});
115+
116+
const transactionEvent = await transactionEventPromise;
117+
118+
// Any transaction events happening shouldn't have any child spans
119+
expect(transactionEvent.spans).toStrictEqual([]);
120+
});
121+
122+
it('should correctly nest spans when called within one another', async () => {
123+
const transactionEventPromise = new Promise<TransactionEvent>(resolve => {
124+
setCurrentClient(
125+
new NodeClient(
126+
getDefaultNodeClientOptions({
127+
stackParser: defaultStackParser,
128+
tracesSampleRate: 1,
129+
beforeSendTransaction: event => {
130+
resolve(event);
131+
return null;
132+
},
133+
dsn,
134+
}),
135+
),
136+
);
137+
});
138+
139+
startSpanManual({ name: 'first' }, span1 => {
140+
startSpanManual({ name: 'second' }, span2 => {
141+
span2?.end();
142+
});
143+
span1?.end();
144+
});
145+
146+
const transactionEvent = await transactionEventPromise;
147+
148+
expect(transactionEvent.spans).toContainEqual(expect.objectContaining({ description: 'second' }));
149+
});
150+
});

0 commit comments

Comments
 (0)