Skip to content

Commit 0a943fb

Browse files
committed
Add OTEP-4947 thread context writer
New module packages/dd-trace/src/otel-thread-ctx.js that mirrors the active trace ID, span ID and current endpoint into a thread-local OTEP-4947 record. An out-of-process eBPF reader discovers the record via the otel_thread_ctx_nodejs_v1 TLS symbol exported by the @datadog/pprof addon. Highlights: - Gates on Linux + AsyncContextFrame (Node 24+ default, or Node 22/23 with --experimental-async-context-frame). isACFActive from datadog-core/src/storage is the single source of truth for that check. - One ThreadContext is allocated per span the first time it's activated and cached on the span via a Symbol slot. Re-entries in any async-context frame re-install the same wrap via setContext; `getContext() === cachedContext` is the JS-reference identity check that replaces any byte-level comparison (same allocation- churn fix as the wall profiler in dd-trace-js#8638). - The record always carries the local root span ID (16-char hex, index 0 by libdatadog convention), thread name and thread id (stable per-thread, computed once at module load). The endpoint attribute is appended in place when a web-server ancestor is discovered via the span:tags:update channel. - On span:finish, if the writer's record currently belongs to the finishing span, setContext(undefined) is called so an out-of- process reader doesn't keep seeing a finished span as the active thread context (matters in enterWith-style sticky activation). Activation gate: - DD_TRACE_OTEL_CTX_ENABLED (boolean, default false) is registered in supported-configurations.json as implementation A, exposed as config.DD_TRACE_OTEL_CTX_ENABLED. proxy.js calls otel-thread-ctx.start() iff the flag is set; the module itself starts in a no-op state otherwise. Tests + docker rig: - packages/dd-trace/test/otel-thread-ctx.spec.js covers start() gating, enter/skip behavior, span-drift round-trip, span-finish, and the late-tags append path with the wire-record attribute layout (15 cases). - scripts/docker/{Dockerfile,run-otel-thread-ctx-spec.sh} build a node:24-bookworm image and run the spec against the locally built sibling pprof-nodejs; driven by `npm run test:docker:otel-thread-ctx`. Forthcoming follow-up: publishing the corresponding threadlocal.attribute_key_map via process discovery, which requires bumping @DataDog/libdatadog.
1 parent 82e9f3c commit 0a943fb

9 files changed

Lines changed: 678 additions & 0 deletions

File tree

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,8 +318,10 @@
318318
/integration-tests/profiler/ @DataDog/profiling-js
319319

320320
/packages/dd-trace/*/profiling/ @DataDog/profiling-js
321+
/packages/dd-trace/src/otel-thread-ctx.js @DataDog/profiling-js
321322
/packages/dd-trace/src/storage-channels.js @DataDog/profiling-js @DataDog/apm-sdk-capabilities-js
322323
/packages/dd-trace/test/exporters/common/form-data.spec.js @DataDog/profiling-js
324+
/packages/dd-trace/test/otel-thread-ctx.spec.js @DataDog/profiling-js
323325

324326
# Language Platform
325327
/* @DataDog/lang-platform-js

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@
6666
"test:plugins:upstream": "node ./packages/dd-trace/test/plugins/suite.js",
6767
"test:profiler": "node scripts/mocha-parallel-files.js --expose-gc --timeout 30000 -- \"packages/dd-trace/test/profiling/**/*.spec.js\"",
6868
"test:profiler:ci": "node scripts/c8-ci.js test:profiler",
69+
"test:otel-thread-ctx": "mocha --timeout 60000 packages/dd-trace/test/otel-thread-ctx.spec.js",
70+
"test:docker:otel-thread-ctx": "./scripts/docker/run-otel-thread-ctx-spec.sh",
6971
"test:integration": "mocha --timeout 60000 \"integration-tests/*.spec.js\"",
7072
"test:integration:coverage": "node ./integration-tests/coverage/run-suite.js --timeout 60000 \"integration-tests/*.spec.js\"",
7173
"test:integration:aiguard": "mocha --timeout 60000 \"integration-tests/aiguard/*.spec.js\"",

packages/dd-trace/src/config/generated-config-types.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ export interface GeneratedConfig {
337337
DD_TRACE_OPENSEARCH_PROJECT_OPENSEARCH_ENABLED: boolean;
338338
DD_TRACE_OPENTELEMETRY_SDK_TRACE_NODE_ENABLED: boolean;
339339
DD_TRACE_ORACLEDB_ENABLED: boolean;
340+
DD_TRACE_OTEL_CTX_ENABLED: boolean;
340341
DD_TRACE_OTEL_ENABLED: boolean;
341342
DD_TRACE_OTEL_SEMANTICS_ENABLED: boolean;
342343
DD_TRACE_PASSPORT_ENABLED: boolean;
@@ -1013,6 +1014,7 @@ export interface GeneratedEnvVarConfig {
10131014
DD_TRACE_OPENSEARCH_PROJECT_OPENSEARCH_ENABLED: boolean;
10141015
DD_TRACE_OPENTELEMETRY_SDK_TRACE_NODE_ENABLED: boolean;
10151016
DD_TRACE_ORACLEDB_ENABLED: boolean;
1017+
DD_TRACE_OTEL_CTX_ENABLED: boolean;
10161018
DD_TRACE_OTEL_ENABLED: boolean;
10171019
DD_TRACE_OTEL_SEMANTICS_ENABLED: boolean;
10181020
DD_TRACE_PARTIAL_FLUSH_MIN_SPANS: number;

packages/dd-trace/src/config/supported-configurations.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3411,6 +3411,13 @@
34113411
"default": "false"
34123412
}
34133413
],
3414+
"DD_TRACE_OTEL_CTX_ENABLED": [
3415+
{
3416+
"implementation": "A",
3417+
"type": "boolean",
3418+
"default": "false"
3419+
}
3420+
],
34143421
"DD_TRACE_PARTIAL_FLUSH_MIN_SPANS": [
34153422
{
34163423
"implementation": "B",
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
'use strict'
2+
3+
// OTEP-4947 Thread Local Context Record writer integration.
4+
//
5+
// Hooks into the active-span lifecycle (storage:enter, span:finish,
6+
// span:tags:update channels) and mirrors the active trace ID, span ID
7+
// and current endpoint into a thread-local record that an
8+
// out-of-process eBPF reader can discover via the
9+
// otel_thread_ctx_nodejs_v1 TLS symbol exported by the @datadog/pprof
10+
// addon.
11+
//
12+
// Linux + AsyncContextFrame only. Degrades to a no-op start() on
13+
// platforms or Node versions where the writer can't operate; the
14+
// caller is expected to gate activation via the DD_TRACE_OTEL_CTX_ENABLED
15+
// env var (or future config flag).
16+
//
17+
// Covers both the dd-trace API and the OpenTelemetry API when the
18+
// latter is used through dd-trace-js's TracerProvider: an OTel Span
19+
// wraps a DatadogSpan (packages/dd-trace/src/opentelemetry/span.js)
20+
// and the OTel ContextManager activates spans via
21+
// storage('legacy').run({ span: ddSpan }, ...), so the active span
22+
// our writer reads from legacy storage is the same DatadogSpan in
23+
// both cases. The OTel-visible trace/span IDs come from the same
24+
// _ddContext.toTraceId/toSpanId calls we use here.
25+
26+
const { isMainThread, threadId } = require('worker_threads')
27+
28+
const { isACFActive } = require('../../datadog-core/src/storage')
29+
const log = require('./log')
30+
const {
31+
enterCh,
32+
spanFinishCh,
33+
tagsUpdateCh,
34+
getActiveSpan,
35+
ensureChannelsActivated,
36+
} = require('./storage-channels')
37+
const {
38+
isWebServerSpan,
39+
endpointNameFromTags,
40+
getStartedSpans,
41+
} = require('./profiling/webspan-utils')
42+
43+
// Positional attribute layout. The local root span ID stays at index 0 by
44+
// convention (mirrors libdatadog's libdd-otel-thread-ctx, where
45+
// `local_root_span_id` is always the first entry in
46+
// `threadlocal.attribute_key_map`), encoded as a 16-character lowercase
47+
// hex string. Endpoint, thread name, and thread id follow.
48+
const LOCAL_ROOT_SPAN_ID_IDX = 0
49+
const ENDPOINT_IDX = 1
50+
const THREAD_NAME_IDX = 2
51+
const THREAD_ID_IDX = 3
52+
53+
// Stable per-thread values baked into every record. Same shape as the
54+
// profiler's `eventLoopThreadName` in profiling/profilers/shared.js.
55+
const THREAD_NAME = (isMainThread ? 'Main' : `Worker #${threadId}`) + ' Event Loop'
56+
const THREAD_ID = String(threadId)
57+
58+
// Cache slot on span objects. One ThreadContext is built per span on first
59+
// activation and re-installed across every async-context frame that
60+
// re-enters the span — V8's AsyncContextFrame inherits the JS
61+
// reference verbatim, and the context's record buffer is mutated in
62+
// place by appendAttributes, so all frames observe the same record.
63+
//
64+
// Fields populated lazily:
65+
// context: ThreadContext from @datadog/pprof.otelThreadCtx — built the first
66+
// time onEnter activates the span.
67+
// webTagsResolved + webTags: true once the parent-chain walk has
68+
// run; webTags is the resolved tag bag (or undefined when no web
69+
// ancestor was found).
70+
const CachedSym = Symbol('OtelThreadCtx.cached')
71+
72+
let started = false
73+
let ThreadContext
74+
let getContext
75+
let clearContext
76+
77+
function getOrCreateCache (span) {
78+
let cached = span[CachedSym]
79+
if (cached === undefined) {
80+
cached = {}
81+
span[CachedSym] = cached
82+
}
83+
return cached
84+
}
85+
86+
// Walks up the started-spans stack to find the nearest ancestor whose
87+
// tags identify it as a web-server span. Mirrors the same walk in
88+
// profiling/profilers/wall.js (which keeps its own cache under a
89+
// different Symbol). If the two ever drift we should extract.
90+
function getCachedWebTags (span) {
91+
const cached = getOrCreateCache(span)
92+
if (cached.webTagsResolved) return cached.webTags
93+
const spanContext = span.context()
94+
const tags = spanContext.getTags()
95+
let webTags
96+
if (isWebServerSpan(tags)) {
97+
webTags = tags
98+
} else {
99+
const parentId = spanContext._parentId
100+
const startedSpans = getStartedSpans(spanContext)
101+
for (let i = startedSpans.length; --i >= 0;) {
102+
const ispan = startedSpans[i]
103+
if (ispan.context()._spanId === parentId) {
104+
webTags = getCachedWebTags(ispan)
105+
break
106+
}
107+
}
108+
}
109+
cached.webTags = webTags
110+
cached.webTagsResolved = true
111+
return webTags
112+
}
113+
114+
function getOrBuildContext (span) {
115+
const cached = getOrCreateCache(span)
116+
if (cached.context !== undefined) return cached.context
117+
const spanContext = span.context()
118+
const traceId = Uint8Array.from(Buffer.from(spanContext.toTraceId(true), 'hex'))
119+
const spanId = Uint8Array.from(Buffer.from(spanContext.toSpanId(true), 'hex'))
120+
// Local root span: the first entry in the trace's started-spans list, or
121+
// this span itself when it IS the root. Encoded as 16-char lowercase hex
122+
// per the libdatadog convention.
123+
const startedSpans = getStartedSpans(spanContext)
124+
const rootContext = startedSpans.length ? startedSpans[0].context() : spanContext
125+
const webTags = getCachedWebTags(span)
126+
const attrs = []
127+
attrs[LOCAL_ROOT_SPAN_ID_IDX] = rootContext.toSpanId(true)
128+
if (webTags) attrs[ENDPOINT_IDX] = endpointNameFromTags(webTags)
129+
attrs[THREAD_NAME_IDX] = THREAD_NAME
130+
attrs[THREAD_ID_IDX] = THREAD_ID
131+
cached.context = new ThreadContext(traceId, spanId, attrs)
132+
return cached.context
133+
}
134+
135+
function onEnter () {
136+
if (!started) return
137+
const span = getActiveSpan()
138+
if (!span) {
139+
clearContext()
140+
return
141+
}
142+
const context = getOrBuildContext(span)
143+
// Skip if this CPED already holds the same context. Same allocation-churn
144+
// fix as the wall profiler in dd-trace-js#8638.
145+
if (getContext() === context) return
146+
context.enter()
147+
}
148+
149+
function onSpanFinished (span) {
150+
if (!started) return
151+
const cached = span[CachedSym]
152+
if (cached === undefined) return
153+
// If the writer's record currently belongs to this span, detach it so an
154+
// out-of-process reader doesn't keep seeing a finished span as the active
155+
// thread context. The next storage:enter would normally overwrite the
156+
// record on its own, but with enterWith-style activation (sticky storage)
157+
// no such fire follows the span finish, leaving stale state.
158+
if (cached.context !== undefined && getContext() === cached.context) {
159+
clearContext()
160+
}
161+
span[CachedSym] = undefined
162+
}
163+
164+
function onTagsUpdated (span) {
165+
if (!started) return
166+
const cached = span[CachedSym]
167+
// Skip unless the prior parent-chain walk already ran and came up
168+
// empty. If the walk hasn't happened yet (cached.webTagsResolved
169+
// false), onEnter will resolve it the natural way. If it ran and
170+
// found a web span, we already have the endpoint.
171+
if (cached === undefined || !cached.webTagsResolved || cached.webTags !== undefined) return
172+
const tags = span.context().getTags()
173+
if (!isWebServerSpan(tags)) return
174+
cached.webTags = tags
175+
if (cached.context !== undefined) {
176+
// The context was already built without an endpoint; append it in
177+
// place. The record buffer is shared across every async-context
178+
// frame holding this context, so the endpoint becomes visible
179+
// everywhere at once.
180+
const append = []
181+
append[ENDPOINT_IDX] = endpointNameFromTags(tags)
182+
cached.context.appendAttributes(append)
183+
}
184+
}
185+
186+
function start () {
187+
if (started) return true
188+
if (process.platform !== 'linux') {
189+
log.debug('OTEP-4947 thread context writer: not on Linux, skipping')
190+
return false
191+
}
192+
if (!isACFActive) {
193+
log.warn(
194+
'OTEP-4947 thread context writer requires AsyncContextFrame to be active; not enabling'
195+
)
196+
return false
197+
}
198+
let pprofMod
199+
try {
200+
pprofMod = require('@datadog/pprof')
201+
} catch (e) {
202+
log.warn('OTEP-4947 thread context writer: @datadog/pprof unavailable', e)
203+
return false
204+
}
205+
const ns = pprofMod.otelThreadCtx
206+
if (!ns || typeof ns.ThreadContext !== 'function' ||
207+
typeof ns.getContext !== 'function' ||
208+
typeof ns.clearContext !== 'function') {
209+
log.warn(
210+
'OTEP-4947 thread context writer: installed @datadog/pprof does not expose the otelThreadCtx API'
211+
)
212+
return false
213+
}
214+
ThreadContext = ns.ThreadContext
215+
getContext = ns.getContext
216+
clearContext = ns.clearContext
217+
218+
ensureChannelsActivated(isACFActive)
219+
enterCh.subscribe(onEnter)
220+
spanFinishCh.subscribe(onSpanFinished)
221+
tagsUpdateCh.subscribe(onTagsUpdated)
222+
223+
started = true
224+
log.info('OTEP-4947 thread context writer started')
225+
return true
226+
}
227+
228+
module.exports = { start }

packages/dd-trace/src/proxy.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,17 @@ class Tracer extends NoopProxy {
195195
}
196196
}
197197

198+
// Experimental: mirror the active trace ID, span ID and endpoint into
199+
// an OTEP-4947 thread-local context record an out-of-process eBPF
200+
// reader can consume.
201+
if (config.DD_TRACE_OTEL_CTX_ENABLED) {
202+
try {
203+
require('./otel-thread-ctx').start()
204+
} catch (e) {
205+
log.error('Error starting OTEP-4947 thread context writer', e)
206+
}
207+
}
208+
198209
// OTel logs/metrics pipelines must be initialized BEFORE runtimeMetrics.start so that
199210
// when the OTLP runtime metrics module calls metrics.getMeterProvider(), it gets the
200211
// real provider, otherwise instruments register on the noop provider and never export.

0 commit comments

Comments
 (0)