Skip to content

Commit 2e1c0f8

Browse files
committed
Publish OTEP-4947 process-context metadata via process discovery
Sets up the OTEP-4719 process context so an out-of-process reader can decode the on-the-wire records the thread-context writer emits. The metadata is published through libdatadog-nodejs's process-discovery napi crate: bumped here to 0.12.0, which exposes the ThreadLocalMetadata substruct with the full 'threadlocal.*' block (attribute key map, schema-version string, and extra KeyValues for reader-side layout constants). The pieces: - Add getThreadLocalMetadata() in otel-thread-ctx.js. Pulls the process-context snapshot from @datadog/pprof (its otelThreadCtx.getProcessContextAttributes is the source of truth for the schema-version string and V8 layout constants the reader needs) and reshapes it into the napi ThreadLocalMetadata form: { attributeKeys, schemaVersion, extraAttributes: [{ key, intValue|stringValue }] }. Returns undefined when @datadog/pprof isn't installed or doesn't expose the helper. - Wire tracer_metadata.js to pass the substruct (or undefined) as the last positional arg to processDiscovery.TracerMetadata(...), replacing the flat threadlocalAttributeKeys array. Gated on the same DD_TRACE_OTEL_CTX_ENABLED flag that activates the writer. - Bump the @DataDog/libdatadog optionalDependency from 0.10.0 to 0.12.0.
1 parent 30c3200 commit 2e1c0f8

4 files changed

Lines changed: 312 additions & 10 deletions

File tree

packages/dd-trace/src/otel-thread-ctx.js

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,26 @@ const pendingEndpoints = new WeakMap()
6969
// convention (mirrors libdatadog's libdd-otel-thread-ctx, where
7070
// `local_root_span_id` is always the first entry in
7171
// `threadlocal.attribute_key_map`), encoded as a 16-character lowercase
72-
// hex string. Endpoint, thread name, and thread id follow.
72+
// hex string. Endpoint, thread name, and thread id follow. Adding more
73+
// means assigning the next index and updating ATTRIBUTE_KEYS
74+
// accordingly.
7375
const LOCAL_ROOT_SPAN_ID_IDX = 0
7476
const ENDPOINT_IDX = 1
7577
const THREAD_NAME_IDX = 2
7678
const THREAD_ID_IDX = 3
7779

80+
// The dd-trace-js-supplied subset of the OTEP-4719 attribute_key_map
81+
// (the implicit `datadog.local_root_span_id` at wire index 0 is
82+
// prepended by libdatadog when it publishes the process context, so it
83+
// is NOT listed here). Index N here corresponds to wire key index N+1.
84+
// Kept in sync with the positional indices above.
85+
// Also see https://docs.google.com/document/d/1IwjjVJzEChcFPcnVV2N5Kkjg-4_Q4v4Q3ojpxntbdvY/edit?pli=1&tab=t.efaosgjya44c#bookmark=id.700gvw31vb7h
86+
const ATTRIBUTE_KEYS = [
87+
'datadog.trace_endpoint',
88+
'datadog.thread_name',
89+
'datadog.thread_id',
90+
]
91+
7892
// Stable per-thread values baked into every record. Same shape as the
7993
// profiler's `eventLoopThreadName` in profiling/profilers/shared.js.
8094
const THREAD_NAME = (isMainThread ? 'Main' : `Worker #${threadId}`) + ' Event Loop'
@@ -224,6 +238,51 @@ function onWebTagsResolved (span) {
224238
}
225239
}
226240

241+
// Every otelThreadCtx member the writer calls, checked before it starts. Anything
242+
// missing would otherwise surface from inside a diagnostic-channel subscriber on
243+
// a hot path, where an exception lands in application code: a ThreadContext
244+
// without invalidate() would throw out of onSpanFinished and up through
245+
// DatadogSpan#finish() the first time an activated span finished.
246+
//
247+
// Returns the name of the first missing member, or undefined when the surface is
248+
// complete.
249+
function missingApiMember (ns) {
250+
if (!ns) return 'otelThreadCtx'
251+
for (const name of ['ThreadContext', 'getContext', 'clearContext', 'getProcessContextAttributes']) {
252+
if (typeof ns[name] !== 'function') return name
253+
}
254+
for (const name of ['appendAttributes', 'enter', 'invalidate']) {
255+
if (typeof ns.ThreadContext.prototype[name] !== 'function') return `ThreadContext.prototype.${name}`
256+
}
257+
}
258+
259+
// Install and detach one throwaway context, to establish that this process can
260+
// actually do it before any span depends on it.
261+
//
262+
// @datadog/pprof decides whether AsyncContextFrame is available by inspecting
263+
// `process.execArgv`, and throws from enter() when it concludes it isn't. That
264+
// disagrees with the feature detection behind `isACFActive` whenever the flag
265+
// reached Node by another route: `NODE_OPTIONS=--experimental-async-context-frame`
266+
// is accepted on Node 22 and 23 and leaves `execArgv` empty, and a worker thread
267+
// created with an explicit `execArgv` loses it too. Since our subscribers run
268+
// inline with `storage.enterWith`, letting that throw would put the exception in
269+
// application code on the first span activation, so find out here instead, where
270+
// declining to start is still an option.
271+
function canInstallContext (ns) {
272+
try {
273+
// Zero-filled ids: the record is only readable while it is installed, which
274+
// is for the length of this function, and an all-zero trace id identifies
275+
// nothing.
276+
const probe = new ns.ThreadContext(new Uint8Array(16), new Uint8Array(8))
277+
probe.enter()
278+
ns.clearContext()
279+
return true
280+
} catch (e) {
281+
log.warn('OTEP-4947 thread context writer: @datadog/pprof cannot install a thread context', e)
282+
return false
283+
}
284+
}
285+
227286
function start () {
228287
if (started) return true
229288
if (process.platform !== 'linux') {
@@ -244,14 +303,15 @@ function start () {
244303
return false
245304
}
246305
const ns = pprofMod.otelThreadCtx
247-
if (!ns || typeof ns.ThreadContext !== 'function' ||
248-
typeof ns.getContext !== 'function' ||
249-
typeof ns.clearContext !== 'function') {
306+
const missing = missingApiMember(ns)
307+
if (missing !== undefined) {
250308
log.warn(
251-
'OTEP-4947 thread context writer: installed @datadog/pprof does not expose the otelThreadCtx API'
309+
'OTEP-4947 thread context writer: installed @datadog/pprof does not expose the otelThreadCtx API (missing %s)',
310+
missing
252311
)
253312
return false
254313
}
314+
if (!canInstallContext(ns)) return false
255315
ThreadContext = ns.ThreadContext
256316
getContext = ns.getContext
257317
clearContext = ns.clearContext
@@ -275,4 +335,43 @@ function start () {
275335
return true
276336
}
277337

278-
module.exports = { start }
338+
// Snapshot of the OTEP-4719 process-context attributes describing this
339+
// writer's on-the-wire record schema — schema-version string, the caller-side
340+
// attribute key map, and the V8 layout constants a reader needs to walk from
341+
// our discovery TLS symbol into the record. Returned in the shape libdatadog's
342+
// napi ThreadLocalMetadata expects:
343+
//
344+
// { attributeKeys, schemaVersion, extraAttributes: [{ key, intValue|stringValue }] }
345+
//
346+
// Returns undefined unless start() succeeded. Callers should treat that as
347+
// "no threadlocal block" (equivalent to the flag being off) — otherwise we'd
348+
// publish process-context metadata advertising a decodable OTEP-4947 stream
349+
// while no writer is producing records.
350+
function getThreadLocalMetadata () {
351+
if (!started) return
352+
// start() verified @datadog/pprof.otelThreadCtx exposes the full API
353+
// surface (ThreadContext/getContext/clearContext/getProcessContextAttributes),
354+
// so this require is a cached lookup and the method is guaranteed present.
355+
const pca = require('@datadog/pprof').otelThreadCtx
356+
.getProcessContextAttributes(ATTRIBUTE_KEYS)
357+
const extraAttributes = []
358+
for (const [key, value] of Object.entries(pca)) {
359+
if (key === 'threadlocal.schema_version' || key === 'threadlocal.attribute_key_map') continue
360+
if (typeof value === 'number' && Number.isInteger(value)) {
361+
extraAttributes.push({ key, intValue: value })
362+
} else if (typeof value === 'string') {
363+
extraAttributes.push({ key, stringValue: value })
364+
} else {
365+
throw new TypeError(
366+
`OTEP-4947 process-context attribute ${JSON.stringify(key)} has unsupported value type: ${typeof value}`
367+
)
368+
}
369+
}
370+
return {
371+
attributeKeys: [...pca['threadlocal.attribute_key_map']],
372+
schemaVersion: pca['threadlocal.schema_version'],
373+
extraAttributes,
374+
}
375+
}
376+
377+
module.exports = { start, ATTRIBUTE_KEYS, getThreadLocalMetadata }

packages/dd-trace/src/tracer_metadata.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ function storeConfig (config) {
1818
? (processTags.serialized || null)
1919
: null
2020

21+
// OTEP-4947 thread-context writer metadata, published as part of the
22+
// OTel process context so an out-of-process reader can decode the
23+
// on-wire record: the attribute key map (libdatadog prepends the
24+
// implicit `datadog.local_root_span_id` at wire index 0, so we only
25+
// supply our own additional keys), the schema-version string, and
26+
// the V8 layout constants the reader needs. Gated on the same config
27+
// flag that activates the writer; undefined when off or when
28+
// @datadog/pprof isn't installed to provide the values.
29+
const threadlocalMetadata = config.DD_TRACE_OTEL_CTX_ENABLED
30+
? require('./otel-thread-ctx').getThreadLocalMetadata()
31+
: undefined
32+
2133
const metadata = new processDiscovery.TracerMetadata(
2234
config.tags['runtime-id'],
2335
tracerVersion,
@@ -26,7 +38,8 @@ function storeConfig (config) {
2638
config.env || null,
2739
config.version || null,
2840
processTagsSerialized,
29-
containerId || null
41+
containerId || null,
42+
threadlocalMetadata
3043
)
3144

3245
return processDiscovery.storeMetadata(metadata)

packages/dd-trace/test/otel-thread-ctx.spec.js

Lines changed: 154 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,20 @@ describe('otel-thread-ctx', () => {
9999
this.traceId = traceId
100100
this.spanId = spanId
101101
this.attributes = attributes
102-
this.appendAttributes = sinon.stub()
103-
this.invalidate = sinon.stub()
104-
this.isTruncated = sinon.stub().returns(false)
102+
// Spied per instance so tests can assert call history on the context,
103+
// while the methods themselves stay on the prototype where start()'s
104+
// compatibility check looks for them, as they are on the native class.
105+
sinon.spy(this, 'appendAttributes')
106+
sinon.spy(this, 'invalidate')
105107
constructedContexts.push(this)
106108
}
107109

110+
appendAttributes () {}
111+
112+
invalidate () {}
113+
114+
isTruncated () { return false }
115+
108116
enter () { setActive(this) }
109117
}
110118

@@ -114,6 +122,10 @@ describe('otel-thread-ctx', () => {
114122
ThreadContext: StubThreadContext,
115123
getContext: sinon.stub().callsFake(() => activeContext),
116124
clearContext: sinon.stub().callsFake(() => setActive()),
125+
getProcessContextAttributes: sinon.stub().returns({
126+
'threadlocal.schema_version': 'nodejs_v1_dev',
127+
'threadlocal.attribute_key_map': [],
128+
}),
117129
},
118130
}
119131

@@ -186,6 +198,78 @@ describe('otel-thread-ctx', () => {
186198
assert.equal(m.start(), false)
187199
sinon.assert.calledWithMatch(log.warn, /otelThreadCtx API/)
188200
})
201+
202+
it('returns false when ThreadContext is missing a method the writer calls', () => {
203+
// An older or overridden @datadog/pprof can expose the gated namespace
204+
// without every ThreadContext method. Starting anyway would defer the
205+
// failure to a diagnostic-channel subscriber: a missing invalidate() would
206+
// throw out of the span-finish path and up through DatadogSpan#finish()
207+
// into application code.
208+
for (const method of ['appendAttributes', 'enter', 'invalidate']) {
209+
const Incomplete = class extends StubThreadContext {}
210+
Incomplete.prototype[method] = undefined
211+
const m = loadModule({
212+
pprof: {
213+
'@noCallThru': true,
214+
otelThreadCtx: { ...pprofStub.otelThreadCtx, ThreadContext: Incomplete },
215+
},
216+
})
217+
assert.equal(m.start(), false, `expected start() to refuse a ThreadContext without ${method}`)
218+
sinon.assert.calledWithMatch(log.warn, /otelThreadCtx API/, `ThreadContext.prototype.${method}`)
219+
log.warn.resetHistory()
220+
}
221+
})
222+
223+
it('returns false when a context cannot be installed in this process', () => {
224+
// @datadog/pprof infers AsyncContextFrame availability from process.execArgv
225+
// and throws from enter() when it concludes it is unavailable, which
226+
// disagrees with our own feature detection when the flag arrived via
227+
// NODE_OPTIONS or a worker's execArgv was overridden. Subscribers run inline
228+
// with storage.enterWith, so this must not be discovered on first activation.
229+
const Throwing = class extends StubThreadContext {
230+
enter () { throw new Error('async_context_frame support is unavailable') }
231+
}
232+
const m = loadModule({
233+
pprof: {
234+
'@noCallThru': true,
235+
otelThreadCtx: { ...pprofStub.otelThreadCtx, ThreadContext: Throwing },
236+
},
237+
})
238+
assert.equal(m.start(), false)
239+
sinon.assert.calledWithMatch(log.warn, /cannot install a thread context/)
240+
// No subscriber may be left behind, so a subsequent activation must not
241+
// reach the writer — and so must not hit the throwing enter() either.
242+
activeSpan = makeSpan()
243+
enterCh.publish()
244+
assert.equal(constructedContexts.length, 1) // just the failed probe
245+
})
246+
247+
it('leaves no context installed after the start-up probe succeeds', () => {
248+
const m = loadModule()
249+
assert.equal(m.start(), true)
250+
assert.equal(constructedContexts.length, 1)
251+
sinon.assert.calledOnce(pprofStub.otelThreadCtx.clearContext)
252+
assert.equal(activeContext, undefined)
253+
})
254+
255+
it('returns false when otelThreadCtx is missing getProcessContextAttributes', () => {
256+
// start() must refuse to install subscribers if the metadata publisher
257+
// can't be produced — otherwise the writer emits records that no reader
258+
// can decode.
259+
const m = loadModule({
260+
pprof: {
261+
'@noCallThru': true,
262+
otelThreadCtx: {
263+
ThreadContext: StubThreadContext,
264+
getContext: sinon.stub(),
265+
clearContext: sinon.stub(),
266+
// no getProcessContextAttributes
267+
},
268+
},
269+
})
270+
assert.equal(m.start(), false)
271+
sinon.assert.calledWithMatch(log.warn, /otelThreadCtx API/)
272+
})
189273
})
190274

191275
describe('subscribed behavior', () => {
@@ -194,6 +278,11 @@ describe('otel-thread-ctx', () => {
194278
beforeEach(() => {
195279
otelThreadCtx = loadModule()
196280
assert.equal(otelThreadCtx.start(), true)
281+
// start() installs and detaches one throwaway context to prove the process
282+
// can; drop its traces so the tests below see only their own activity.
283+
constructedContexts.length = 0
284+
setActive.resetHistory()
285+
pprofStub.otelThreadCtx.clearContext.resetHistory()
197286
})
198287

199288
it('clearContext when no active span', () => {
@@ -513,4 +602,66 @@ describe('otel-thread-ctx', () => {
513602
assert.equal(constructedContexts.length, 0)
514603
})
515604
})
605+
606+
describe('getThreadLocalMetadata()', () => {
607+
// start() is always called before getThreadLocalMetadata() in the
608+
// real init sequence (proxy.js kicks off the writer, then storeConfig()
609+
// pulls the metadata). getThreadLocalMetadata is a no-op unless start
610+
// succeeded, so every happy-path test runs start() first.
611+
it('returns undefined when start() has not been called', () => {
612+
pprofStub.otelThreadCtx.getProcessContextAttributes = sinon.stub()
613+
const m = loadModule()
614+
assert.equal(m.getThreadLocalMetadata(), undefined)
615+
sinon.assert.notCalled(pprofStub.otelThreadCtx.getProcessContextAttributes)
616+
})
617+
618+
it('returns the process-context snapshot in libdatadog-nodejs shape', () => {
619+
pprofStub.otelThreadCtx.getProcessContextAttributes = sinon.stub().returns({
620+
'threadlocal.schema_version': 'nodejs_v1_dev',
621+
'threadlocal.attribute_key_map': ['datadog.trace_endpoint', 'datadog.thread_name'],
622+
'threadlocal.wrapped_object_offset': 24,
623+
'threadlocal.tagged_size': 8,
624+
})
625+
const m = loadModule()
626+
assert.equal(m.start(), true)
627+
const md = m.getThreadLocalMetadata()
628+
sinon.assert.calledOnceWithExactly(
629+
pprofStub.otelThreadCtx.getProcessContextAttributes,
630+
m.ATTRIBUTE_KEYS
631+
)
632+
assert.deepEqual(md.attributeKeys, ['datadog.trace_endpoint', 'datadog.thread_name'])
633+
assert.equal(md.schemaVersion, 'nodejs_v1_dev')
634+
// Order isn't guaranteed since Object.entries is object-key ordered.
635+
const byKey = Object.fromEntries(md.extraAttributes.map(a => [a.key, a]))
636+
assert.deepEqual(byKey['threadlocal.wrapped_object_offset'],
637+
{ key: 'threadlocal.wrapped_object_offset', intValue: 24 })
638+
assert.deepEqual(byKey['threadlocal.tagged_size'],
639+
{ key: 'threadlocal.tagged_size', intValue: 8 })
640+
})
641+
642+
it('encodes string-valued extra attributes as stringValue', () => {
643+
pprofStub.otelThreadCtx.getProcessContextAttributes = sinon.stub().returns({
644+
'threadlocal.schema_version': 'nodejs_v1_dev',
645+
'threadlocal.attribute_key_map': [],
646+
'threadlocal.runtime.name': 'nodejs',
647+
})
648+
const m = loadModule()
649+
assert.equal(m.start(), true)
650+
const md = m.getThreadLocalMetadata()
651+
assert.deepEqual(md.extraAttributes, [
652+
{ key: 'threadlocal.runtime.name', stringValue: 'nodejs' },
653+
])
654+
})
655+
656+
it('throws on an extra attribute with an unsupported value type', () => {
657+
pprofStub.otelThreadCtx.getProcessContextAttributes = sinon.stub().returns({
658+
'threadlocal.schema_version': 'nodejs_v1_dev',
659+
'threadlocal.attribute_key_map': [],
660+
'threadlocal.weird': true, // booleans aren't wired through yet
661+
})
662+
const m = loadModule()
663+
assert.equal(m.start(), true)
664+
assert.throws(() => m.getThreadLocalMetadata(), /unsupported value type/)
665+
})
666+
})
516667
})

0 commit comments

Comments
 (0)