Skip to content

Commit 463f561

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 0a943fb commit 463f561

4 files changed

Lines changed: 181 additions & 3 deletions

File tree

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

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,26 @@ const {
4444
// convention (mirrors libdatadog's libdd-otel-thread-ctx, where
4545
// `local_root_span_id` is always the first entry in
4646
// `threadlocal.attribute_key_map`), encoded as a 16-character lowercase
47-
// hex string. Endpoint, thread name, and thread id follow.
47+
// hex string. Endpoint, thread name, and thread id follow. Adding more
48+
// means assigning the next index and updating ATTRIBUTE_KEYS
49+
// accordingly.
4850
const LOCAL_ROOT_SPAN_ID_IDX = 0
4951
const ENDPOINT_IDX = 1
5052
const THREAD_NAME_IDX = 2
5153
const THREAD_ID_IDX = 3
5254

55+
// The dd-trace-js-supplied subset of the OTEP-4719 attribute_key_map
56+
// (the implicit `datadog.local_root_span_id` at wire index 0 is
57+
// prepended by libdatadog when it publishes the process context, so it
58+
// is NOT listed here). Index N here corresponds to wire key index N+1.
59+
// Kept in sync with the positional indices above.
60+
// Also see https://docs.google.com/document/d/1IwjjVJzEChcFPcnVV2N5Kkjg-4_Q4v4Q3ojpxntbdvY/edit?pli=1&tab=t.efaosgjya44c#bookmark=id.700gvw31vb7h
61+
const ATTRIBUTE_KEYS = [
62+
'datadog.trace_endpoint',
63+
'datadog.thread_name',
64+
'datadog.thread_id',
65+
]
66+
5367
// Stable per-thread values baked into every record. Same shape as the
5468
// profiler's `eventLoopThreadName` in profiling/profilers/shared.js.
5569
const THREAD_NAME = (isMainThread ? 'Main' : `Worker #${threadId}`) + ' Event Loop'
@@ -225,4 +239,51 @@ function start () {
225239
return true
226240
}
227241

228-
module.exports = { start }
242+
// Snapshot of the OTEP-4719 process-context attributes describing this
243+
// writer's on-the-wire record schema — schema-version string, the caller-side
244+
// attribute key map, and the V8 layout constants a reader needs to walk from
245+
// our discovery TLS symbol into the record. Returned in the shape libdatadog's
246+
// napi ThreadLocalMetadata expects:
247+
//
248+
// { attributeKeys, schemaVersion, extraAttributes: [{ key, intValue|stringValue }] }
249+
//
250+
// Returns undefined if @datadog/pprof isn't installed or doesn't expose the
251+
// otelThreadCtx.getProcessContextAttributes helper; callers should treat that
252+
// as "no threadlocal block" (equivalent to the flag being off).
253+
function getThreadLocalMetadata () {
254+
let pprofMod
255+
try {
256+
pprofMod = require('@datadog/pprof')
257+
} catch (e) {
258+
log.warn('OTEP-4947 thread context: @datadog/pprof unavailable', e)
259+
return
260+
}
261+
const ns = pprofMod.otelThreadCtx
262+
if (!ns || typeof ns.getProcessContextAttributes !== 'function') {
263+
log.warn(
264+
'OTEP-4947 thread context: installed @datadog/pprof does not expose getProcessContextAttributes'
265+
)
266+
return
267+
}
268+
const pca = ns.getProcessContextAttributes(ATTRIBUTE_KEYS)
269+
const extraAttributes = []
270+
for (const [key, value] of Object.entries(pca)) {
271+
if (key === 'threadlocal.schema_version' || key === 'threadlocal.attribute_key_map') continue
272+
if (typeof value === 'number' && Number.isInteger(value)) {
273+
extraAttributes.push({ key, intValue: value })
274+
} else if (typeof value === 'string') {
275+
extraAttributes.push({ key, stringValue: value })
276+
} else {
277+
throw new TypeError(
278+
`OTEP-4947 process-context attribute ${JSON.stringify(key)} has unsupported value type: ${typeof value}`
279+
)
280+
}
281+
}
282+
return {
283+
attributeKeys: [...pca['threadlocal.attribute_key_map']],
284+
schemaVersion: pca['threadlocal.schema_version'],
285+
extraAttributes,
286+
}
287+
}
288+
289+
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: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,4 +321,69 @@ describe('otel-thread-ctx', () => {
321321
assert.equal(constructedContexts.length, 0)
322322
})
323323
})
324+
325+
describe('getThreadLocalMetadata()', () => {
326+
it('returns the process-context snapshot in libdatadog-nodejs shape', () => {
327+
pprofStub.otelThreadCtx.getProcessContextAttributes = sinon.stub().returns({
328+
'threadlocal.schema_version': 'nodejs_v1_dev',
329+
'threadlocal.attribute_key_map': ['datadog.trace_endpoint', 'datadog.thread_name'],
330+
'threadlocal.wrapped_object_offset': 24,
331+
'threadlocal.tagged_size': 8,
332+
})
333+
const m = loadModule()
334+
const md = m.getThreadLocalMetadata()
335+
sinon.assert.calledOnceWithExactly(
336+
pprofStub.otelThreadCtx.getProcessContextAttributes,
337+
m.ATTRIBUTE_KEYS
338+
)
339+
assert.deepEqual(md.attributeKeys, ['datadog.trace_endpoint', 'datadog.thread_name'])
340+
assert.equal(md.schemaVersion, 'nodejs_v1_dev')
341+
// Order isn't guaranteed since Object.entries is object-key ordered.
342+
const byKey = Object.fromEntries(md.extraAttributes.map(a => [a.key, a]))
343+
assert.deepEqual(byKey['threadlocal.wrapped_object_offset'],
344+
{ key: 'threadlocal.wrapped_object_offset', intValue: 24 })
345+
assert.deepEqual(byKey['threadlocal.tagged_size'],
346+
{ key: 'threadlocal.tagged_size', intValue: 8 })
347+
})
348+
349+
it('returns undefined when @datadog/pprof is not installed', () => {
350+
const m = loadModule({ pprof: { '@noCallThru': true } })
351+
assert.equal(m.getThreadLocalMetadata(), undefined)
352+
sinon.assert.calledWithMatch(log.warn, /pprof unavailable|does not expose/)
353+
})
354+
355+
it('returns undefined when otelThreadCtx.getProcessContextAttributes is missing', () => {
356+
const m = loadModule({
357+
pprof: {
358+
'@noCallThru': true,
359+
otelThreadCtx: { /* no getProcessContextAttributes */ },
360+
},
361+
})
362+
assert.equal(m.getThreadLocalMetadata(), undefined)
363+
sinon.assert.calledWithMatch(log.warn, /does not expose getProcessContextAttributes/)
364+
})
365+
366+
it('encodes string-valued extra attributes as stringValue', () => {
367+
pprofStub.otelThreadCtx.getProcessContextAttributes = sinon.stub().returns({
368+
'threadlocal.schema_version': 'nodejs_v1_dev',
369+
'threadlocal.attribute_key_map': [],
370+
'threadlocal.runtime.name': 'nodejs',
371+
})
372+
const m = loadModule()
373+
const md = m.getThreadLocalMetadata()
374+
assert.deepEqual(md.extraAttributes, [
375+
{ key: 'threadlocal.runtime.name', stringValue: 'nodejs' },
376+
])
377+
})
378+
379+
it('throws on an extra attribute with an unsupported value type', () => {
380+
pprofStub.otelThreadCtx.getProcessContextAttributes = sinon.stub().returns({
381+
'threadlocal.schema_version': 'nodejs_v1_dev',
382+
'threadlocal.attribute_key_map': [],
383+
'threadlocal.weird': true, // booleans aren't wired through yet
384+
})
385+
const m = loadModule()
386+
assert.throws(() => m.getThreadLocalMetadata(), /unsupported value type/)
387+
})
388+
})
324389
})

packages/dd-trace/test/tracer_metadata.spec.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ describe('tracer_metadata', () => {
1313
let libdatadogStub
1414
let dockerStub
1515
let processTagsStub
16+
let otelThreadCtxStub
1617

1718
const baseConfig = {
1819
tags: { 'runtime-id': 'test-runtime-id' },
@@ -21,6 +22,7 @@ describe('tracer_metadata', () => {
2122
env: 'test-env',
2223
version: '1.0.0',
2324
DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED: false,
25+
DD_TRACE_OTEL_CTX_ENABLED: false,
2426
}
2527

2628
beforeEach(() => {
@@ -38,11 +40,21 @@ describe('tracer_metadata', () => {
3840

3941
dockerStub = { containerId: undefined }
4042
processTagsStub = { serialized: 'tag1:val1,tag2:val2' }
43+
otelThreadCtxStub = {
44+
getThreadLocalMetadata: sinon.stub().returns({
45+
attributeKeys: ['datadog.thread_name'],
46+
schemaVersion: 'nodejs_v1_dev',
47+
extraAttributes: [
48+
{ key: 'threadlocal.wrapped_object_offset', intValue: 24 },
49+
],
50+
}),
51+
}
4152

4253
storeConfig = proxyquire('../src/tracer_metadata', {
4354
'@datadog/libdatadog': libdatadogStub,
4455
'./exporters/common/docker': dockerStub,
4556
'./process-tags': processTagsStub,
57+
'./otel-thread-ctx': otelThreadCtxStub,
4658
})
4759
})
4860

@@ -96,6 +108,33 @@ describe('tracer_metadata', () => {
96108
assert.strictEqual(args[7], null)
97109
})
98110

111+
it('passes undefined for threadlocal_metadata when DD_TRACE_OTEL_CTX_ENABLED is false', () => {
112+
storeConfig(baseConfig)
113+
const args = TracerMetadataStub.firstCall.args
114+
assert.strictEqual(args[8], undefined)
115+
sinon.assert.notCalled(otelThreadCtxStub.getThreadLocalMetadata)
116+
})
117+
118+
it('passes the OTEP-4947 process-context snapshot when DD_TRACE_OTEL_CTX_ENABLED is true', () => {
119+
storeConfig({ ...baseConfig, DD_TRACE_OTEL_CTX_ENABLED: true })
120+
const args = TracerMetadataStub.firstCall.args
121+
sinon.assert.calledOnce(otelThreadCtxStub.getThreadLocalMetadata)
122+
assert.deepStrictEqual(args[8], {
123+
attributeKeys: ['datadog.thread_name'],
124+
schemaVersion: 'nodejs_v1_dev',
125+
extraAttributes: [
126+
{ key: 'threadlocal.wrapped_object_offset', intValue: 24 },
127+
],
128+
})
129+
})
130+
131+
it('passes undefined when getThreadLocalMetadata is unavailable (e.g. no pprof)', () => {
132+
otelThreadCtxStub.getThreadLocalMetadata.returns(undefined)
133+
storeConfig({ ...baseConfig, DD_TRACE_OTEL_CTX_ENABLED: true })
134+
const args = TracerMetadataStub.firstCall.args
135+
assert.strictEqual(args[8], undefined)
136+
})
137+
99138
it('passes null for service when config.service is falsy', () => {
100139
storeConfig({ ...baseConfig, service: undefined })
101140

0 commit comments

Comments
 (0)