Skip to content

Commit 5656aaf

Browse files
committed
move remote parent handling behind a prepareSpanScope client hook
1 parent a59d4c5 commit 5656aaf

6 files changed

Lines changed: 79 additions & 64 deletions

File tree

‎packages/core/src/client.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,17 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
648648
*/
649649
public on(hook: 'spanStart', callback: (span: Span) => void): () => void;
650650

651+
/**
652+
* Register a callback that can adjust the scope and the parent span right before a span is
653+
* created. Listeners mutate the passed `spanScope` object. The Node SDK uses this to continue
654+
* the trace of a remote (incoming) parent through the propagation context of a forked scope.
655+
* @returns {() => void} A function that, when executed, removes the registered callback.
656+
*/
657+
public on(
658+
hook: 'prepareSpanScope',
659+
callback: (spanScope: { scope: Scope; parentSpan: Span | undefined }) => void,
660+
): () => void;
661+
651662
/**
652663
* Register a callback before span sampling runs. Receives a `samplingDecision` object argument with a `decision`
653664
* property that can be used to make a sampling decision that will be enforced, before any span sampling runs.
@@ -986,6 +997,9 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
986997
/** Fire a hook whenever a span starts. */
987998
public emit(hook: 'spanStart', span: Span): void;
988999

1000+
/** A hook that is called right before a span is created; listeners mutate the passed object. */
1001+
public emit(hook: 'prepareSpanScope', spanScope: { scope: Scope; parentSpan: Span | undefined }): void;
1002+
9891003
/** A hook that is called every time before a span is sampled. */
9901004
public emit(
9911005
hook: 'beforeSampling',

‎packages/core/src/tracing/trace.ts‎

Lines changed: 10 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
1313
} from '../semanticAttributes';
1414
import type { ClientOptions } from '../types/options';
15-
import type { SentrySpanArguments, Span, SpanContextData, SpanTimeInput } from '../types/span';
15+
import type { SentrySpanArguments, Span, SpanTimeInput } from '../types/span';
1616
import type { StartSpanOptions } from '../types/startSpanOptions';
1717
import { baggageHeaderToDynamicSamplingContext } from '../utils/baggage';
1818
import { debug } from '../utils/debug-logger';
@@ -31,7 +31,6 @@ import {
3131
spanIsSampled,
3232
spanTimeInputToSeconds,
3333
spanToStaticSpanJSON,
34-
TRACE_FLAG_SAMPLED,
3534
} from '../utils/spanUtils';
3635
import { propagationContextFromHeaders, shouldContinueTrace } from '../utils/tracing';
3736
import { freezeDscOnSpan, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext';
@@ -326,12 +325,15 @@ function createChildOrRootSpan({
326325
}): Span {
327326
const isolationScope = getIsolationScope();
328327

329-
// A remote parent is an incoming trace on the ambient OTel context, set by the propagator. It
330-
// cannot be used as a local parent span. Instead, we continue its trace through the propagation
331-
// context and start a root span, like `SentryTracer` does for remote parents.
332-
const remoteParentSpan = resolvedParentSpan?.spanContext().isRemote ? resolvedParentSpan : undefined;
333-
const scope = remoteParentSpan ? scopeWithRemoteParentPropagation(currentScope, remoteParentSpan) : currentScope;
334-
const parentSpan = remoteParentSpan ? undefined : resolvedParentSpan;
328+
// Listeners can adjust the scope and the parent right before span creation. The Node SDK uses
329+
// this to turn a remote parent (an incoming trace on the ambient OTel context) into a propagation
330+
// context on a forked scope, so the span continues the incoming trace as a root span.
331+
const spanScope: { scope: Scope; parentSpan: Span | undefined } = {
332+
scope: currentScope,
333+
parentSpan: resolvedParentSpan,
334+
};
335+
getClient()?.emit('prepareSpanScope', spanScope);
336+
const { scope, parentSpan } = spanScope;
335337

336338
if (!hasSpansEnabled()) {
337339
const scopePropagationContext = scope.getPropagationContext();
@@ -585,61 +587,6 @@ function _startChildSpan(
585587
return childSpan;
586588
}
587589

588-
/**
589-
* Fork the scope with a propagation context continuing the remote parent's trace, so the root span
590-
* created from it picks up the incoming trace id, parent span id, and sampling decision. Mirrors
591-
* `SentryTracer._startRootSpanWithRemoteParent` in `@sentry/opentelemetry`.
592-
*/
593-
function scopeWithRemoteParentPropagation(scope: Scope, remoteParentSpan: Span): Scope {
594-
const { spanId, traceId, traceState } = remoteParentSpan.spanContext();
595-
const dsc = getDynamicSamplingContextFromSpan(remoteParentSpan);
596-
const sampleRand = typeof dsc.sample_rand === 'string' ? Number(dsc.sample_rand) : undefined;
597-
598-
// Only freeze the DSC when the remote parent actually carried one (i.e. there was incoming
599-
// baggage). Otherwise leave it unset so it is derived dynamically from the span — picking up the
600-
// span's `transaction` name and the generated `sample_rand`.
601-
const hasIncomingDsc = !!traceState?.get('sentry.dsc');
602-
603-
const forkedScope = scope.clone();
604-
forkedScope.setPropagationContext({
605-
traceId,
606-
parentSpanId: spanId,
607-
sampled: getSamplingDecisionFromRemoteSpanContext(remoteParentSpan.spanContext()),
608-
dsc: hasIncomingDsc ? dsc : undefined,
609-
sampleRand: typeof sampleRand === 'number' && !Number.isNaN(sampleRand) ? sampleRand : safeMathRandom(),
610-
});
611-
_setSpanForScope(forkedScope, undefined);
612-
return forkedScope;
613-
}
614-
615-
/**
616-
* OpenTelemetry only knows a SAMPLED or NONE decision, but for us it is important to differentiate
617-
* between unsampled and unset (deferred). Both arrive as `traceFlags: NONE`; the Sentry trace state
618-
* and the DSC disambiguate. Port of `getSamplingDecision` in `@sentry/opentelemetry`.
619-
*/
620-
function getSamplingDecisionFromRemoteSpanContext(spanContext: SpanContextData): boolean | undefined {
621-
const { traceFlags, traceState } = spanContext;
622-
623-
if (traceFlags === TRACE_FLAG_SAMPLED) {
624-
return true;
625-
}
626-
627-
if (traceState?.get('sentry.sampled_not_recording') === '1') {
628-
return false;
629-
}
630-
631-
const dscString = traceState?.get('sentry.dsc');
632-
const dsc = dscString ? baggageHeaderToDynamicSamplingContext(dscString) : undefined;
633-
if (dsc?.sampled === 'true') {
634-
return true;
635-
}
636-
if (dsc?.sampled === 'false') {
637-
return false;
638-
}
639-
640-
return undefined;
641-
}
642-
643590
function getParentSpan(scope: Scope, customParentSpan: Span | null | undefined): Span | undefined {
644591
// always use the passed in span directly
645592
if (customParentSpan) {

‎packages/node/src/sdk/client.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import {
1111
SDK_VERSION,
1212
ServerRuntimeClient,
1313
} from '@sentry/core';
14-
import { type AsyncLocalStorageLookup, type SentryTracerProvider } from '@sentry/opentelemetry';
14+
import {
15+
type AsyncLocalStorageLookup,
16+
registerPrepareSpanScope,
17+
type SentryTracerProvider,
18+
} from '@sentry/opentelemetry';
1519
import { isMainThread, threadId } from 'worker_threads';
1620
import { DEBUG_BUILD } from '../debug-build';
1721
import type { NodeClientOptions } from '../types';
@@ -72,6 +76,10 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
7276
// provider path produce OTel spans that never reach `SentrySpan`, so the strategy is simply never
7377
// consulted for them.
7478
_INTERNAL_setDeferSegmentSpanCapture(this);
79+
80+
// Same constructor anchoring as above: every client must continue incoming (remote) traces,
81+
// also manually constructed ones that never run `initOtel`.
82+
registerPrepareSpanScope(this);
7583
}
7684

7785
/** Get the OTEL tracer. */

‎packages/opentelemetry/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export { SentryTracerProvider } from './tracerProvider';
66
export { type AsyncLocalStorageLookup } from './asyncLocalStorageContextManager';
77

88
export { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy';
9+
export { registerPrepareSpanScope } from './prepareSpanScope';
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Client } from '@sentry/core';
2+
import { _INTERNAL_safeMathRandom, _INTERNAL_setSpanForScope, getDynamicSamplingContextFromSpan } from '@sentry/core';
3+
import { SENTRY_TRACE_STATE_DSC } from './constants';
4+
import { getSamplingDecision } from './utils/getSamplingDecision';
5+
6+
/**
7+
* Registers the `prepareSpanScope` hook on the client.
8+
*
9+
* A remote parent is an incoming trace on the ambient OTel context, set by the propagator. It
10+
* cannot be used as a local parent span. The hook continues its trace through the propagation
11+
* context of a forked scope instead, so the span is created as a root span of the incoming trace,
12+
* like the OpenTelemetry SDK does for remote parents.
13+
*/
14+
export function registerPrepareSpanScope(client: Client): void {
15+
client.on('prepareSpanScope', spanScope => {
16+
const { scope, parentSpan } = spanScope;
17+
if (!parentSpan?.spanContext().isRemote) {
18+
return;
19+
}
20+
21+
const { spanId, traceId, traceState } = parentSpan.spanContext();
22+
const dsc = getDynamicSamplingContextFromSpan(parentSpan);
23+
const sampleRand = typeof dsc.sample_rand === 'string' ? Number(dsc.sample_rand) : undefined;
24+
25+
// Only freeze the DSC when the remote parent actually carried one (i.e. there was incoming
26+
// baggage). Otherwise leave it unset so it is derived dynamically from the span.
27+
const hasIncomingDsc = !!traceState?.get(SENTRY_TRACE_STATE_DSC);
28+
29+
const forkedScope = scope.clone();
30+
forkedScope.setPropagationContext({
31+
traceId,
32+
parentSpanId: spanId,
33+
sampled: getSamplingDecision(parentSpan.spanContext()),
34+
dsc: hasIncomingDsc ? dsc : undefined,
35+
sampleRand: typeof sampleRand === 'number' && !Number.isNaN(sampleRand) ? sampleRand : _INTERNAL_safeMathRandom(),
36+
});
37+
_INTERNAL_setSpanForScope(forkedScope, undefined);
38+
39+
spanScope.scope = forkedScope;
40+
spanScope.parentSpan = undefined;
41+
});
42+
}

‎packages/opentelemetry/test/helpers/initOtel.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api';
22
import { debug, getClient } from '@sentry/core';
33
import { DEBUG_BUILD } from '../../src/debug-build';
44
import { SentryPropagator } from '../../src/propagator';
5+
import { registerPrepareSpanScope } from '../../src/prepareSpanScope';
56
import type { TestClient } from './TestClient';
67
import { SentryTracerProvider } from '../../src/tracerProvider';
78

@@ -19,6 +20,8 @@ export function initOtel(): void {
1920
return;
2021
}
2122

23+
registerPrepareSpanScope(client);
24+
2225
if (client.getOptions().debug) {
2326
// Disable diag, to ensure this works even if called multiple times
2427
diag.disable();

0 commit comments

Comments
 (0)