Skip to content

Commit 5a2b676

Browse files
mydeaLuca Forstner
andauthored
feat(opentelemetry): Fix & align isolation scope usage in node-experimental (#10570)
Co-authored-by: Luca Forstner <luca.forstner@sentry.io>
1 parent 245a9fa commit 5a2b676

File tree

20 files changed

+476
-302
lines changed

20 files changed

+476
-302
lines changed

packages/core/src/tracing/trace.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ type SpanWithScopes = Span & {
399399
[ISOLATION_SCOPE_ON_START_SPAN_FIELD]?: Scope;
400400
};
401401

402+
/** Store the scope & isolation scope for a span, which can the be used when it is finished. */
402403
function setCapturedScopesOnSpan(span: Span | undefined, scope: Scope, isolationScope: Scope): void {
403404
if (span) {
404405
addNonEnumerableProperty(span, ISOLATION_SCOPE_ON_START_SPAN_FIELD, isolationScope);

packages/node-experimental/src/sdk/client.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { trace } from '@opentelemetry/api';
55
import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
66
import { applySdkMetadata } from '@sentry/core';
77
import type { CaptureContext, Event, EventHint } from '@sentry/types';
8-
import { Scope, getIsolationScope } from './scope';
8+
import { Scope } from './scope';
99

1010
/** A client for using Sentry with Node & OpenTelemetry. */
1111
export class NodeExperimentalClient extends NodeClient {
@@ -54,7 +54,7 @@ export class NodeExperimentalClient extends NodeClient {
5454
event: Event,
5555
hint: EventHint,
5656
scope?: Scope,
57-
_isolationScope?: Scope,
57+
isolationScope?: Scope,
5858
): PromiseLike<Event | null> {
5959
let actualScope = scope;
6060

@@ -64,8 +64,6 @@ export class NodeExperimentalClient extends NodeClient {
6464
delete hint.captureContext;
6565
}
6666

67-
const isolationScope = _isolationScope || (scope && scope.isolationScope) || getIsolationScope();
68-
6967
return super._prepareEvent(event, hint, actualScope, isolationScope);
7068
}
7169
}

packages/node-experimental/src/sdk/scope.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,6 @@ export function isInitialized(): boolean {
7272

7373
/** A fork of the classic scope with some otel specific stuff. */
7474
export class Scope extends OpenTelemetryScope implements ScopeInterface {
75-
// Overwrite this if you want to use a specific isolation scope here
76-
public isolationScope: Scope | undefined;
77-
7875
protected _client: Client | undefined;
7976

8077
protected _lastEventId: string | undefined;

packages/node-experimental/src/sdk/spanProcessor.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1-
import type { Context } from '@opentelemetry/api';
21
import { SpanKind } from '@opentelemetry/api';
32
import type { Span } from '@opentelemetry/sdk-trace-base';
43
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
5-
import { SentrySpanProcessor, getClient, getSpanFinishScope } from '@sentry/opentelemetry';
4+
import { SentrySpanProcessor, getClient } from '@sentry/opentelemetry';
65

76
import type { Http } from '../integrations/http';
87
import type { NodeFetch } from '../integrations/node-fetch';
98
import type { NodeExperimentalClient } from '../types';
10-
import { getIsolationScope } from './api';
119
import { Scope } from './scope';
1210

1311
/**
@@ -18,18 +16,6 @@ export class NodeExperimentalSentrySpanProcessor extends SentrySpanProcessor {
1816
super({ scopeClass: Scope });
1917
}
2018

21-
/** @inheritDoc */
22-
public onStart(span: Span, parentContext: Context): void {
23-
super.onStart(span, parentContext);
24-
25-
// We need to make sure that we use the correct isolation scope when finishing the span
26-
// so we store it on the span finish scope for later use
27-
const scope = getSpanFinishScope(span) as Scope | undefined;
28-
if (scope) {
29-
scope.isolationScope = getIsolationScope();
30-
}
31-
}
32-
3319
/** @inheritDoc */
3420
protected _shouldSendSpanToSentry(span: Span): boolean {
3521
const client = getClient<NodeExperimentalClient>();

packages/node-experimental/src/sdk/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ export interface ScopeData {
2828
}
2929

3030
export interface Scope extends BaseScope {
31-
// @ts-expect-error typeof this is what we want here
32-
isolationScope: typeof this | undefined;
3331
// @ts-expect-error typeof this is what we want here
3432
clone(scope?: Scope): typeof this;
3533
/**

packages/node-experimental/test/integration/scope.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getCurrentScope, setGlobalScope } from '@sentry/core';
2-
import { getClient, getSpanScope } from '@sentry/opentelemetry';
2+
import { getClient, getSpanScopes } from '@sentry/opentelemetry';
33

44
import * as Sentry from '../../src/';
55
import type { NodeExperimentalClient } from '../../src/types';
@@ -41,7 +41,11 @@ describe('Integration | Scope', () => {
4141
scope2.setTag('tag3', 'val3');
4242

4343
Sentry.startSpan({ name: 'outer' }, span => {
44-
expect(getSpanScope(span)).toBe(enableTracing ? scope2 : undefined);
44+
// TODO: This is "incorrect" until we stop cloning the current scope for setSpanScopes
45+
// Once we change this, the scopes _should_ be the same again
46+
if (enableTracing) {
47+
expect(getSpanScopes(span)?.scope).not.toBe(scope2);
48+
}
4549

4650
spanId = span.spanContext().spanId;
4751
traceId = span.spanContext().traceId;

packages/node-experimental/test/integration/transactions.test.ts

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ describe('Integration | Transactions', () => {
9999
environment: 'production',
100100
event_id: expect.any(String),
101101
platform: 'node',
102-
sdkProcessingMetadata: {
102+
sdkProcessingMetadata: expect.objectContaining({
103103
dynamicSamplingContext: expect.objectContaining({
104104
environment: 'production',
105105
public_key: expect.any(String),
@@ -112,7 +112,7 @@ describe('Integration | Transactions', () => {
112112
source: 'task',
113113
spanMetadata: expect.any(Object),
114114
requestPath: 'test-path',
115-
},
115+
}),
116116
server_name: expect.any(String),
117117
// spans are circular (they have a reference to the transaction), which leads to jest choking on this
118118
// instead we compare them in detail below
@@ -329,6 +329,159 @@ describe('Integration | Transactions', () => {
329329
);
330330
});
331331

332+
it('correctly creates concurrent transaction & spans when using native OTEL tracer', async () => {
333+
const beforeSendTransaction = jest.fn(() => null);
334+
335+
mockSdkInit({ enableTracing: true, beforeSendTransaction });
336+
337+
const client = Sentry.getClient<NodeExperimentalClient>();
338+
339+
Sentry.addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 });
340+
341+
Sentry.withIsolationScope(() => {
342+
client.tracer.startActiveSpan('test name', span => {
343+
Sentry.addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 });
344+
345+
span.setAttributes({
346+
'test.outer': 'test value',
347+
});
348+
349+
const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' });
350+
subSpan.end();
351+
352+
Sentry.setTag('test.tag', 'test value');
353+
354+
client.tracer.startActiveSpan('inner span 2', innerSpan => {
355+
Sentry.addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 });
356+
357+
innerSpan.setAttributes({
358+
'test.inner': 'test value',
359+
});
360+
361+
innerSpan.end();
362+
});
363+
364+
span.end();
365+
});
366+
});
367+
368+
Sentry.withIsolationScope(() => {
369+
client.tracer.startActiveSpan('test name b', span => {
370+
Sentry.addBreadcrumb({ message: 'test breadcrumb 2b', timestamp: 123456 });
371+
372+
span.setAttributes({
373+
'test.outer': 'test value b',
374+
});
375+
376+
const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1b' });
377+
subSpan.end();
378+
379+
Sentry.setTag('test.tag', 'test value b');
380+
381+
client.tracer.startActiveSpan('inner span 2b', innerSpan => {
382+
Sentry.addBreadcrumb({ message: 'test breadcrumb 3b', timestamp: 123456 });
383+
384+
innerSpan.setAttributes({
385+
'test.inner': 'test value b',
386+
});
387+
388+
innerSpan.end();
389+
});
390+
391+
span.end();
392+
});
393+
});
394+
395+
await client.flush();
396+
397+
expect(beforeSendTransaction).toHaveBeenCalledTimes(2);
398+
expect(beforeSendTransaction).toHaveBeenCalledWith(
399+
expect.objectContaining({
400+
breadcrumbs: [
401+
{ message: 'test breadcrumb 1', timestamp: 123456 },
402+
{ message: 'test breadcrumb 2', timestamp: 123456 },
403+
{ message: 'test breadcrumb 3', timestamp: 123456 },
404+
],
405+
contexts: expect.objectContaining({
406+
otel: expect.objectContaining({
407+
attributes: {
408+
'test.outer': 'test value',
409+
},
410+
}),
411+
trace: {
412+
data: {
413+
'otel.kind': 'INTERNAL',
414+
'sentry.origin': 'manual',
415+
},
416+
span_id: expect.any(String),
417+
status: 'ok',
418+
trace_id: expect.any(String),
419+
origin: 'manual',
420+
},
421+
}),
422+
spans: [
423+
expect.objectContaining({
424+
description: 'inner span 1',
425+
}),
426+
expect.objectContaining({
427+
description: 'inner span 2',
428+
}),
429+
],
430+
start_timestamp: expect.any(Number),
431+
tags: { 'test.tag': 'test value' },
432+
timestamp: expect.any(Number),
433+
transaction: 'test name',
434+
type: 'transaction',
435+
}),
436+
{
437+
event_id: expect.any(String),
438+
},
439+
);
440+
441+
expect(beforeSendTransaction).toHaveBeenCalledWith(
442+
expect.objectContaining({
443+
breadcrumbs: [
444+
{ message: 'test breadcrumb 1', timestamp: 123456 },
445+
{ message: 'test breadcrumb 2b', timestamp: 123456 },
446+
{ message: 'test breadcrumb 3b', timestamp: 123456 },
447+
],
448+
contexts: expect.objectContaining({
449+
otel: expect.objectContaining({
450+
attributes: {
451+
'test.outer': 'test value b',
452+
},
453+
}),
454+
trace: {
455+
data: {
456+
'otel.kind': 'INTERNAL',
457+
'sentry.origin': 'manual',
458+
},
459+
span_id: expect.any(String),
460+
status: 'ok',
461+
trace_id: expect.any(String),
462+
origin: 'manual',
463+
},
464+
}),
465+
spans: [
466+
expect.objectContaining({
467+
description: 'inner span 1b',
468+
}),
469+
expect.objectContaining({
470+
description: 'inner span 2b',
471+
}),
472+
],
473+
start_timestamp: expect.any(Number),
474+
tags: { 'test.tag': 'test value b' },
475+
timestamp: expect.any(Number),
476+
transaction: 'test name b',
477+
type: 'transaction',
478+
}),
479+
{
480+
event_id: expect.any(String),
481+
},
482+
);
483+
});
484+
332485
it('correctly creates transaction & spans with a trace header data', async () => {
333486
const beforeSendTransaction = jest.fn(() => null);
334487

packages/opentelemetry/src/asyncContextStrategy.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as api from '@opentelemetry/api';
22
import type { Hub, RunWithAsyncContextOptions } from '@sentry/core';
33
import { setAsyncContextStrategy } from '@sentry/core';
4+
import { SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY } from './constants';
45

56
import { getHubFromContext } from './utils/contextData';
67

@@ -30,7 +31,11 @@ export function setOpenTelemetryContextAsyncContextStrategy(): void {
3031
const ctx = api.context.active();
3132

3233
// We depend on the otelContextManager to handle the context/hub
33-
return api.context.with(ctx, () => {
34+
// We set the `SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY` context value, which is picked up by
35+
// the OTEL context manager, which uses the presence of this key to determine if it should
36+
// fork the isolation scope, or not
37+
// as by default, we don't want to fork this, unless triggered explicitly by `runWithAsyncContext`
38+
return api.context.with(ctx.setValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY, true), () => {
3439
return callback();
3540
});
3641
}

packages/opentelemetry/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ export const SENTRY_PROPAGATION_CONTEXT_CONTEXT_KEY = createContextKey('SENTRY_P
88

99
/** Context Key to hold a Hub. */
1010
export const SENTRY_HUB_CONTEXT_KEY = createContextKey('sentry_hub');
11+
12+
export const SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY = createContextKey('sentry_fork_isolation_scope');

packages/opentelemetry/src/contextManager.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
11
import type { Context, ContextManager } from '@opentelemetry/api';
2-
import type { Carrier, Hub } from '@sentry/core';
3-
import { getCurrentHub, getHubFromCarrier } from '@sentry/core';
4-
import { ensureHubOnCarrier } from '@sentry/core';
2+
import type { Hub } from '@sentry/core';
3+
import { getCurrentHub } from '@sentry/core';
4+
import { SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY } from './constants';
5+
import { OpenTelemetryHub } from './custom/hub';
56

67
import { setHubOnContext } from './utils/contextData';
78

8-
function createNewHub(parent: Hub | undefined): Hub {
9-
const carrier: Carrier = {};
10-
ensureHubOnCarrier(carrier, parent);
11-
return getHubFromCarrier(carrier);
9+
function createNewHub(parent: Hub | undefined, shouldForkIsolationScope: boolean): Hub {
10+
if (parent) {
11+
// eslint-disable-next-line deprecation/deprecation
12+
const client = parent.getClient();
13+
// eslint-disable-next-line deprecation/deprecation
14+
const scope = parent.getScope();
15+
// eslint-disable-next-line deprecation/deprecation
16+
const isolationScope = parent.getIsolationScope();
17+
18+
return new OpenTelemetryHub(
19+
client,
20+
scope.clone(),
21+
shouldForkIsolationScope ? isolationScope.clone() : isolationScope,
22+
);
23+
}
24+
25+
return new OpenTelemetryHub();
1226
}
1327

1428
// Typescript complains if we do not use `...args: any[]` for the mixin, with:
@@ -46,11 +60,18 @@ export function wrapContextManagerClass<ContextManagerInstance extends ContextMa
4660
thisArg?: ThisParameterType<F>,
4761
...args: A
4862
): ReturnType<F> {
63+
const shouldForkIsolationScope = context.getValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY) === true;
64+
4965
// eslint-disable-next-line deprecation/deprecation
5066
const existingHub = getCurrentHub();
51-
const newHub = createNewHub(existingHub);
67+
const newHub = createNewHub(existingHub, shouldForkIsolationScope);
5268

53-
return super.with(setHubOnContext(context, newHub), fn, thisArg, ...args);
69+
return super.with(
70+
setHubOnContext(context.deleteValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY), newHub),
71+
fn,
72+
thisArg,
73+
...args,
74+
);
5475
}
5576
}
5677

packages/opentelemetry/src/custom/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export function wrapClientClass<
7171
event: Event,
7272
hint: EventHint,
7373
scope?: Scope,
74-
_isolationScope?: Scope,
74+
isolationScope?: Scope,
7575
): PromiseLike<Event | null> {
7676
let actualScope = scope;
7777

@@ -81,7 +81,7 @@ export function wrapClientClass<
8181
delete hint.captureContext;
8282
}
8383

84-
return super._prepareEvent(event, hint, actualScope);
84+
return super._prepareEvent(event, hint, actualScope, isolationScope);
8585
}
8686
}
8787

packages/opentelemetry/src/custom/hub.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import { OpenTelemetryScope } from './scope';
1010
* Exported only for testing
1111
*/
1212
export class OpenTelemetryHub extends Hub {
13-
public constructor(client?: Client, scope: Scope = new OpenTelemetryScope()) {
14-
super(client, scope);
13+
public constructor(client?: Client, scope: Scope = new OpenTelemetryScope(), isolationScope?: Scope) {
14+
super(client, scope, isolationScope);
1515
}
1616
}
1717

0 commit comments

Comments
 (0)