Skip to content

feat(opentelemetry): Fix & align isolation scope usage in node-experimental #10570

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Feb 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/tracing/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ type SpanWithScopes = Span & {
[ISOLATION_SCOPE_ON_START_SPAN_FIELD]?: Scope;
};

/** Store the scope & isolation scope for a span, which can the be used when it is finished. */
function setCapturedScopesOnSpan(span: Span | undefined, scope: Scope, isolationScope: Scope): void {
if (span) {
addNonEnumerableProperty(span, ISOLATION_SCOPE_ON_START_SPAN_FIELD, isolationScope);
Expand Down
6 changes: 2 additions & 4 deletions packages/node-experimental/src/sdk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { trace } from '@opentelemetry/api';
import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
import { applySdkMetadata } from '@sentry/core';
import type { CaptureContext, Event, EventHint } from '@sentry/types';
import { Scope, getIsolationScope } from './scope';
import { Scope } from './scope';

/** A client for using Sentry with Node & OpenTelemetry. */
export class NodeExperimentalClient extends NodeClient {
Expand Down Expand Up @@ -54,7 +54,7 @@ export class NodeExperimentalClient extends NodeClient {
event: Event,
hint: EventHint,
scope?: Scope,
_isolationScope?: Scope,
isolationScope?: Scope,
): PromiseLike<Event | null> {
let actualScope = scope;

Expand All @@ -64,8 +64,6 @@ export class NodeExperimentalClient extends NodeClient {
delete hint.captureContext;
}

const isolationScope = _isolationScope || (scope && scope.isolationScope) || getIsolationScope();

return super._prepareEvent(event, hint, actualScope, isolationScope);
}
}
Expand Down
3 changes: 0 additions & 3 deletions packages/node-experimental/src/sdk/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,6 @@ export function isInitialized(): boolean {

/** A fork of the classic scope with some otel specific stuff. */
export class Scope extends OpenTelemetryScope implements ScopeInterface {
// Overwrite this if you want to use a specific isolation scope here
public isolationScope: Scope | undefined;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't need this anymore 🚀


protected _client: Client | undefined;

protected _lastEventId: string | undefined;
Expand Down
16 changes: 1 addition & 15 deletions packages/node-experimental/src/sdk/spanProcessor.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import type { Context } from '@opentelemetry/api';
import { SpanKind } from '@opentelemetry/api';
import type { Span } from '@opentelemetry/sdk-trace-base';
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
import { SentrySpanProcessor, getClient, getSpanFinishScope } from '@sentry/opentelemetry';
import { SentrySpanProcessor, getClient } from '@sentry/opentelemetry';

import type { Http } from '../integrations/http';
import type { NodeFetch } from '../integrations/node-fetch';
import type { NodeExperimentalClient } from '../types';
import { getIsolationScope } from './api';
import { Scope } from './scope';

/**
Expand All @@ -18,18 +16,6 @@ export class NodeExperimentalSentrySpanProcessor extends SentrySpanProcessor {
super({ scopeClass: Scope });
}

/** @inheritDoc */
public onStart(span: Span, parentContext: Context): void {
super.onStart(span, parentContext);

// We need to make sure that we use the correct isolation scope when finishing the span
// so we store it on the span finish scope for later use
const scope = getSpanFinishScope(span) as Scope | undefined;
if (scope) {
scope.isolationScope = getIsolationScope();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is simply handled in the base span processor now.

}
}

/** @inheritDoc */
protected _shouldSendSpanToSentry(span: Span): boolean {
const client = getClient<NodeExperimentalClient>();
Expand Down
2 changes: 0 additions & 2 deletions packages/node-experimental/src/sdk/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ export interface ScopeData {
}

export interface Scope extends BaseScope {
// @ts-expect-error typeof this is what we want here
isolationScope: typeof this | undefined;
// @ts-expect-error typeof this is what we want here
clone(scope?: Scope): typeof this;
/**
Expand Down
8 changes: 6 additions & 2 deletions packages/node-experimental/test/integration/scope.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getCurrentScope, setGlobalScope } from '@sentry/core';
import { getClient, getSpanScope } from '@sentry/opentelemetry';
import { getClient, getSpanScopes } from '@sentry/opentelemetry';

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

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

spanId = span.spanContext().spanId;
traceId = span.spanContext().traceId;
Expand Down
157 changes: 155 additions & 2 deletions packages/node-experimental/test/integration/transactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ describe('Integration | Transactions', () => {
environment: 'production',
event_id: expect.any(String),
platform: 'node',
sdkProcessingMetadata: {
sdkProcessingMetadata: expect.objectContaining({
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this has some more fields now, but we don't care about that.

dynamicSamplingContext: expect.objectContaining({
environment: 'production',
public_key: expect.any(String),
Expand All @@ -112,7 +112,7 @@ describe('Integration | Transactions', () => {
source: 'task',
spanMetadata: expect.any(Object),
requestPath: 'test-path',
},
}),
server_name: expect.any(String),
// spans are circular (they have a reference to the transaction), which leads to jest choking on this
// instead we compare them in detail below
Expand Down Expand Up @@ -329,6 +329,159 @@ describe('Integration | Transactions', () => {
);
});

it('correctly creates concurrent transaction & spans when using native OTEL tracer', async () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a test to actually check that it works with native otel tracing too.

const beforeSendTransaction = jest.fn(() => null);

mockSdkInit({ enableTracing: true, beforeSendTransaction });

const client = Sentry.getClient<NodeExperimentalClient>();

Sentry.addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 });

Sentry.withIsolationScope(() => {
client.tracer.startActiveSpan('test name', span => {
Sentry.addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 });

span.setAttributes({
'test.outer': 'test value',
});

const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1' });
subSpan.end();

Sentry.setTag('test.tag', 'test value');

client.tracer.startActiveSpan('inner span 2', innerSpan => {
Sentry.addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 });

innerSpan.setAttributes({
'test.inner': 'test value',
});

innerSpan.end();
});

span.end();
});
});

Sentry.withIsolationScope(() => {
client.tracer.startActiveSpan('test name b', span => {
Sentry.addBreadcrumb({ message: 'test breadcrumb 2b', timestamp: 123456 });

span.setAttributes({
'test.outer': 'test value b',
});

const subSpan = Sentry.startInactiveSpan({ name: 'inner span 1b' });
subSpan.end();

Sentry.setTag('test.tag', 'test value b');

client.tracer.startActiveSpan('inner span 2b', innerSpan => {
Sentry.addBreadcrumb({ message: 'test breadcrumb 3b', timestamp: 123456 });

innerSpan.setAttributes({
'test.inner': 'test value b',
});

innerSpan.end();
});

span.end();
});
});

await client.flush();

expect(beforeSendTransaction).toHaveBeenCalledTimes(2);
expect(beforeSendTransaction).toHaveBeenCalledWith(
expect.objectContaining({
breadcrumbs: [
{ message: 'test breadcrumb 1', timestamp: 123456 },
{ message: 'test breadcrumb 2', timestamp: 123456 },
{ message: 'test breadcrumb 3', timestamp: 123456 },
],
contexts: expect.objectContaining({
otel: expect.objectContaining({
attributes: {
'test.outer': 'test value',
},
}),
trace: {
data: {
'otel.kind': 'INTERNAL',
'sentry.origin': 'manual',
},
span_id: expect.any(String),
status: 'ok',
trace_id: expect.any(String),
origin: 'manual',
},
}),
spans: [
expect.objectContaining({
description: 'inner span 1',
}),
expect.objectContaining({
description: 'inner span 2',
}),
],
start_timestamp: expect.any(Number),
tags: { 'test.tag': 'test value' },
timestamp: expect.any(Number),
transaction: 'test name',
type: 'transaction',
}),
{
event_id: expect.any(String),
},
);

expect(beforeSendTransaction).toHaveBeenCalledWith(
expect.objectContaining({
breadcrumbs: [
{ message: 'test breadcrumb 1', timestamp: 123456 },
{ message: 'test breadcrumb 2b', timestamp: 123456 },
{ message: 'test breadcrumb 3b', timestamp: 123456 },
],
contexts: expect.objectContaining({
otel: expect.objectContaining({
attributes: {
'test.outer': 'test value b',
},
}),
trace: {
data: {
'otel.kind': 'INTERNAL',
'sentry.origin': 'manual',
},
span_id: expect.any(String),
status: 'ok',
trace_id: expect.any(String),
origin: 'manual',
},
}),
spans: [
expect.objectContaining({
description: 'inner span 1b',
}),
expect.objectContaining({
description: 'inner span 2b',
}),
],
start_timestamp: expect.any(Number),
tags: { 'test.tag': 'test value b' },
timestamp: expect.any(Number),
transaction: 'test name b',
type: 'transaction',
}),
{
event_id: expect.any(String),
},
);
});

it('correctly creates transaction & spans with a trace header data', async () => {
const beforeSendTransaction = jest.fn(() => null);

Expand Down
7 changes: 6 additions & 1 deletion packages/opentelemetry/src/asyncContextStrategy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as api from '@opentelemetry/api';
import type { Hub, RunWithAsyncContextOptions } from '@sentry/core';
import { setAsyncContextStrategy } from '@sentry/core';
import { SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY } from './constants';

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

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

// We depend on the otelContextManager to handle the context/hub
return api.context.with(ctx, () => {
// We set the `SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY` context value, which is picked up by
// the OTEL context manager, which uses the presence of this key to determine if it should
// fork the isolation scope, or not
// as by default, we don't want to fork this, unless triggered explicitly by `runWithAsyncContext`
return api.context.with(ctx.setValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY, true), () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we use this attribute to tell the otel context manager if the isolation scope should be forked or not.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we explain this a bit further in comments? I still don't fully get it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will do!

return callback();
});
}
Expand Down
2 changes: 2 additions & 0 deletions packages/opentelemetry/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ export const SENTRY_PROPAGATION_CONTEXT_CONTEXT_KEY = createContextKey('SENTRY_P

/** Context Key to hold a Hub. */
export const SENTRY_HUB_CONTEXT_KEY = createContextKey('sentry_hub');

export const SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY = createContextKey('sentry_fork_isolation_scope');
39 changes: 30 additions & 9 deletions packages/opentelemetry/src/contextManager.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import type { Context, ContextManager } from '@opentelemetry/api';
import type { Carrier, Hub } from '@sentry/core';
import { getCurrentHub, getHubFromCarrier } from '@sentry/core';
import { ensureHubOnCarrier } from '@sentry/core';
import type { Hub } from '@sentry/core';
import { getCurrentHub } from '@sentry/core';
import { SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY } from './constants';
import { OpenTelemetryHub } from './custom/hub';

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

function createNewHub(parent: Hub | undefined): Hub {
const carrier: Carrier = {};
ensureHubOnCarrier(carrier, parent);
return getHubFromCarrier(carrier);
function createNewHub(parent: Hub | undefined, shouldForkIsolationScope: boolean): Hub {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensureHubOnCarrier used to always fork the isolation scope, which is not what we want here. Now, we only fork this if withIsolationScope is used.

if (parent) {
// eslint-disable-next-line deprecation/deprecation
const client = parent.getClient();
// eslint-disable-next-line deprecation/deprecation
const scope = parent.getScope();
// eslint-disable-next-line deprecation/deprecation
const isolationScope = parent.getIsolationScope();

return new OpenTelemetryHub(
client,
scope.clone(),
shouldForkIsolationScope ? isolationScope.clone() : isolationScope,
);
}

return new OpenTelemetryHub();
}

// Typescript complains if we do not use `...args: any[]` for the mixin, with:
Expand Down Expand Up @@ -46,11 +60,18 @@ export function wrapContextManagerClass<ContextManagerInstance extends ContextMa
thisArg?: ThisParameterType<F>,
...args: A
): ReturnType<F> {
const shouldForkIsolationScope = context.getValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY) === true;

// eslint-disable-next-line deprecation/deprecation
const existingHub = getCurrentHub();
const newHub = createNewHub(existingHub);
const newHub = createNewHub(existingHub, shouldForkIsolationScope);

return super.with(setHubOnContext(context, newHub), fn, thisArg, ...args);
return super.with(
setHubOnContext(context.deleteValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY), newHub),
fn,
thisArg,
...args,
);
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/opentelemetry/src/custom/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function wrapClientClass<
event: Event,
hint: EventHint,
scope?: Scope,
_isolationScope?: Scope,
isolationScope?: Scope,
): PromiseLike<Event | null> {
let actualScope = scope;

Expand All @@ -81,7 +81,7 @@ export function wrapClientClass<
delete hint.captureContext;
}

return super._prepareEvent(event, hint, actualScope);
return super._prepareEvent(event, hint, actualScope, isolationScope);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was not correctly passed through here before, oops.

}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/opentelemetry/src/custom/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { OpenTelemetryScope } from './scope';
* Exported only for testing
*/
export class OpenTelemetryHub extends Hub {
public constructor(client?: Client, scope: Scope = new OpenTelemetryScope()) {
super(client, scope);
public constructor(client?: Client, scope: Scope = new OpenTelemetryScope(), isolationScope?: Scope) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we accept this in the upstream implementation too.

super(client, scope, isolationScope);
}
}

Expand Down
Loading