Skip to content

Commit 7fb7632

Browse files
authored
fix(core): Fork scope if custom scope is passed to startSpan (#14900)
Fix unexpected behaviour that would emerge when starting multiple spans in sequence with `startSpan` when passing on the same scope. Prior to this change, the second span would be started as the child span of the first one, because the span set active on the custom scope was not re-set to the parent span of the first span. This patch fixes this edge case by also forking the custom scope if it is passed into `startSpan`.
1 parent 957878a commit 7fb7632

File tree

5 files changed

+161
-10
lines changed

5 files changed

+161
-10
lines changed

docs/migration/v8-to-v9.md

+10
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ In v9, an `undefined` value will be treated the same as if the value is not defi
8282

8383
- The `getCurrentHub().getIntegration(IntegrationClass)` method will always return `null` in v9. This has already stopped working mostly in v8, because we stopped exposing integration classes. In v9, the fallback behavior has been removed. Note that this does not change the type signature and is thus not technically breaking, but still worth pointing out.
8484

85+
- The `startSpan` behavior was slightly changed if you pass a custom `scope` to the span start options: While in v8, the passed scope was set active directly on the passed scope, in v9, the scope is cloned. This behavior change does not apply to `@sentry/node` where the scope was already cloned. This change was made to ensure that the span only remains active within the callback and to align behavior between `@sentry/node` and all other SDKs. As a result of the change, your span hierarchy should be more accurate. However, be aware that modifying the scope (e.g. set tags) within the `startSpan` callback behaves a bit differently now.
86+
87+
```js
88+
startSpan({ name: 'example', scope: customScope }, () => {
89+
getCurrentScope().setTag('tag-a', 'a'); // this tag will only remain within the callback
90+
// set the tag directly on customScope in addition, if you want to to persist the tag outside of the callback
91+
customScope.setTag('tag-a', 'a');
92+
});
93+
```
94+
8595
### `@sentry/node`
8696

8797
- When `skipOpenTelemetrySetup: true` is configured, `httpIntegration({ spans: false })` will be configured by default. This means that you no longer have to specify this yourself in this scenario. With this change, no spans are emitted once `skipOpenTelemetrySetup: true` is configured, without any further configuration being needed.

packages/core/src/tracing/trace.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,13 @@ export function startSpan<T>(options: StartSpanOptions, callback: (span: Span) =
5151
}
5252

5353
const spanArguments = parseSentrySpanArguments(options);
54-
const { forceTransaction, parentSpan: customParentSpan } = options;
54+
const { forceTransaction, parentSpan: customParentSpan, scope: customScope } = options;
5555

56-
return withScope(options.scope, () => {
56+
// We still need to fork a potentially passed scope, as we set the active span on it
57+
// and we need to ensure that it is cleaned up properly once the span ends.
58+
const customForkedScope = customScope?.clone();
59+
60+
return withScope(customForkedScope, () => {
5761
// If `options.parentSpan` is defined, we want to wrap the callback in `withActiveSpan`
5862
const wrapper = getActiveSpanWrapper<T>(customParentSpan);
5963

@@ -82,7 +86,9 @@ export function startSpan<T>(options: StartSpanOptions, callback: (span: Span) =
8286
activeSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
8387
}
8488
},
85-
() => activeSpan.end(),
89+
() => {
90+
activeSpan.end();
91+
},
8692
);
8793
});
8894
});

packages/core/src/types-hoist/startSpanOptions.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,17 @@ export interface StartSpanOptions {
55
/** A manually specified start time for the created `Span` object. */
66
startTime?: SpanTimeInput;
77

8-
/** If defined, start this span off this scope instead off the current scope. */
8+
/**
9+
* If set, start the span on a fork of this scope instead of on the current scope.
10+
* To ensure proper span cleanup, the passed scope is cloned for the duration of the span.
11+
*
12+
* If you want to modify the passed scope inside the callback, calling `getCurrentScope()`
13+
* will return the cloned scope, meaning all scope modifications will be reset once the
14+
* callback finishes
15+
*
16+
* If you want to modify the passed scope and have the changes persist after the callback ends,
17+
* modify the scope directly instead of using `getCurrentScope()`
18+
*/
919
scope?: Scope;
1020

1121
/** The name of the span. */

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

+108-5
Original file line numberDiff line numberDiff line change
@@ -259,24 +259,127 @@ describe('startSpan', () => {
259259
expect(getActiveSpan()).toBe(undefined);
260260
});
261261

262-
it('allows to pass a scope', () => {
262+
it('starts the span on the fork of a passed custom scope', () => {
263263
const initialScope = getCurrentScope();
264264

265-
const manualScope = initialScope.clone();
265+
const customScope = initialScope.clone();
266+
customScope.setTag('dogs', 'great');
267+
266268
const parentSpan = new SentrySpan({ spanId: 'parent-span-id', sampled: true });
267-
_setSpanForScope(manualScope, parentSpan);
269+
_setSpanForScope(customScope, parentSpan);
268270

269-
startSpan({ name: 'GET users/[id]', scope: manualScope }, span => {
271+
startSpan({ name: 'GET users/[id]', scope: customScope }, span => {
272+
// current scope is forked from the customScope
270273
expect(getCurrentScope()).not.toBe(initialScope);
271-
expect(getCurrentScope()).toBe(manualScope);
274+
expect(getCurrentScope()).not.toBe(customScope);
275+
expect(getCurrentScope().getScopeData().tags).toEqual({ dogs: 'great' });
276+
277+
// active span is set correctly
272278
expect(getActiveSpan()).toBe(span);
279+
280+
// span has the correct parent span
273281
expect(spanToJSON(span).parent_span_id).toBe('parent-span-id');
282+
283+
// scope data modifications
284+
getCurrentScope().setTag('cats', 'great');
285+
customScope.setTag('bears', 'great');
286+
287+
expect(getCurrentScope().getScopeData().tags).toEqual({ dogs: 'great', cats: 'great' });
288+
expect(customScope.getScopeData().tags).toEqual({ dogs: 'great', bears: 'great' });
289+
});
290+
291+
// customScope modifications are persisted
292+
expect(customScope.getScopeData().tags).toEqual({ dogs: 'great', bears: 'great' });
293+
294+
// span is parent span again on customScope
295+
withScope(customScope, () => {
296+
expect(getActiveSpan()).toBe(parentSpan);
274297
});
275298

299+
// but activeSpan and currentScope are reset, since customScope was never active
276300
expect(getCurrentScope()).toBe(initialScope);
277301
expect(getActiveSpan()).toBe(undefined);
278302
});
279303

304+
describe('handles multiple spans in sequence with a custom scope', () => {
305+
it('with parent span', () => {
306+
const initialScope = getCurrentScope();
307+
308+
const customScope = initialScope.clone();
309+
const parentSpan = new SentrySpan({ spanId: 'parent-span-id', sampled: true });
310+
_setSpanForScope(customScope, parentSpan);
311+
312+
startSpan({ name: 'span 1', scope: customScope }, span1 => {
313+
// current scope is forked from the customScope
314+
expect(getCurrentScope()).not.toBe(initialScope);
315+
expect(getCurrentScope()).not.toBe(customScope);
316+
317+
expect(getActiveSpan()).toBe(span1);
318+
expect(spanToJSON(span1).parent_span_id).toBe('parent-span-id');
319+
});
320+
321+
// active span on customScope is reset
322+
withScope(customScope, () => {
323+
expect(getActiveSpan()).toBe(parentSpan);
324+
});
325+
326+
startSpan({ name: 'span 2', scope: customScope }, span2 => {
327+
// current scope is forked from the customScope
328+
expect(getCurrentScope()).not.toBe(initialScope);
329+
expect(getCurrentScope()).not.toBe(customScope);
330+
331+
expect(getActiveSpan()).toBe(span2);
332+
// both, span1 and span2 are children of the parent span
333+
expect(spanToJSON(span2).parent_span_id).toBe('parent-span-id');
334+
});
335+
336+
withScope(customScope, () => {
337+
expect(getActiveSpan()).toBe(parentSpan);
338+
});
339+
340+
expect(getCurrentScope()).toBe(initialScope);
341+
expect(getActiveSpan()).toBe(undefined);
342+
});
343+
344+
it('without parent span', () => {
345+
const initialScope = getCurrentScope();
346+
const customScope = initialScope.clone();
347+
348+
const traceId = customScope.getPropagationContext()?.traceId;
349+
350+
startSpan({ name: 'span 1', scope: customScope }, span1 => {
351+
expect(getCurrentScope()).not.toBe(initialScope);
352+
expect(getCurrentScope()).not.toBe(customScope);
353+
354+
expect(getActiveSpan()).toBe(span1);
355+
expect(getRootSpan(getActiveSpan()!)).toBe(span1);
356+
357+
expect(span1.spanContext().traceId).toBe(traceId);
358+
});
359+
360+
withScope(customScope, () => {
361+
expect(getActiveSpan()).toBe(undefined);
362+
});
363+
364+
startSpan({ name: 'span 2', scope: customScope }, span2 => {
365+
expect(getCurrentScope()).not.toBe(initialScope);
366+
expect(getCurrentScope()).not.toBe(customScope);
367+
368+
expect(getActiveSpan()).toBe(span2);
369+
expect(getRootSpan(getActiveSpan()!)).toBe(span2);
370+
371+
expect(span2.spanContext().traceId).toBe(traceId);
372+
});
373+
374+
withScope(customScope, () => {
375+
expect(getActiveSpan()).toBe(undefined);
376+
});
377+
378+
expect(getCurrentScope()).toBe(initialScope);
379+
expect(getActiveSpan()).toBe(undefined);
380+
});
381+
});
382+
280383
it('allows to pass a parentSpan', () => {
281384
const parentSpan = new SentrySpan({ spanId: 'parent-span-id', sampled: true, name: 'parent-span' });
282385

packages/opentelemetry/test/trace.test.ts

+23-1
Original file line numberDiff line numberDiff line change
@@ -290,24 +290,46 @@ describe('trace', () => {
290290
let manualScope: Scope;
291291
let parentSpan: Span;
292292

293+
// "hack" to create a manual scope with a parent span
293294
startSpanManual({ name: 'detached' }, span => {
294295
parentSpan = span;
295296
manualScope = getCurrentScope();
296297
manualScope.setTag('manual', 'tag');
297298
});
298299

300+
expect(manualScope!.getScopeData().tags).toEqual({ manual: 'tag' });
301+
expect(getCurrentScope()).not.toBe(manualScope!);
302+
299303
getCurrentScope().setTag('outer', 'tag');
300304

301305
startSpan({ name: 'GET users/[id]', scope: manualScope! }, span => {
306+
// the current scope in the callback is a fork of the manual scope
302307
expect(getCurrentScope()).not.toBe(initialScope);
308+
expect(getCurrentScope()).not.toBe(manualScope);
309+
expect(getCurrentScope().getScopeData().tags).toEqual({ manual: 'tag' });
303310

304-
expect(getCurrentScope()).toEqual(manualScope);
311+
// getActiveSpan returns the correct span
305312
expect(getActiveSpan()).toBe(span);
306313

314+
// span hierarchy is correct
307315
expect(getSpanParentSpanId(span)).toBe(parentSpan.spanContext().spanId);
316+
317+
// scope data modifications are isolated between original and forked manual scope
318+
getCurrentScope().setTag('inner', 'tag');
319+
manualScope!.setTag('manual-scope-inner', 'tag');
320+
321+
expect(getCurrentScope().getScopeData().tags).toEqual({ manual: 'tag', inner: 'tag' });
322+
expect(manualScope!.getScopeData().tags).toEqual({ manual: 'tag', 'manual-scope-inner': 'tag' });
308323
});
309324

325+
// manualScope modifications remain set outside the callback
326+
expect(manualScope!.getScopeData().tags).toEqual({ manual: 'tag', 'manual-scope-inner': 'tag' });
327+
328+
// current scope is reset back to initial scope
310329
expect(getCurrentScope()).toBe(initialScope);
330+
expect(getCurrentScope().getScopeData().tags).toEqual({ outer: 'tag' });
331+
332+
// although the manual span is still running, it's no longer active due to being outside of the callback
311333
expect(getActiveSpan()).toBe(undefined);
312334
});
313335

0 commit comments

Comments
 (0)