Skip to content

Commit 50b56d7

Browse files
committed
Defer OTEP-4947 endpoint until routing tags land; suppress metadata on unsupported runtimes
Addresses two P2 review points on the OTEP-4947 branch. Endpoint refresh (#2). HTTP server plugins set 'span.type=web' and 'http.method' when a request arrives and only add 'http.route' / 'resource.name' later, once framework routing has resolved the URL. The writer used to treat any non-undefined webTags result as a fully formed answer at build time — 'needsEndpoint' would flip to false immediately — so the OTEP-4947 record's endpoint attribute got frozen as e.g. 'GET' with no route, and later route arrival was silently ignored. Defer writing the endpoint until 'isEndpointFinal(tags)' is true (either 'resource.name' is set, or both 'http.method' and 'http.route' are), and subscribe onTagsUpdated to the raw 'dd-trace:span:tags:update' channel (not webTagsCache.resolvedCh) so we also see content changes on already-cached web-server spans. The shared cache still runs first (module-load subscribe), so its cached webTags is up to date by the time our handler queries it. Once the endpoint has been appended, further tags-update fires are no-ops (we can't overwrite a written entry — OTEP readers honor the first occurrence). Process-context metadata gate (#4). On non-Linux or non-ACF runtimes, otel-thread-ctx.start() returns false but tracer_metadata.js was still publishing the threadlocal_metadata block in the OTel process context — advertising a decodable OTEP-4947 stream that no writer was producing records for. Add the same 'process.platform === linux' and 'isACFActive' gates to getThreadLocalMetadata that start() applies, so the block is only advertised when the writer can actually run. Tests: three new cases for the defer-and-append endpoint path and two new cases for the platform/ACF gates on getThreadLocalMetadata. 24/24 passing.
1 parent defc89b commit 50b56d7

2 files changed

Lines changed: 90 additions & 15 deletions

File tree

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

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@
2525

2626
const { isMainThread, threadId } = require('worker_threads')
2727

28+
const { HTTP_METHOD, HTTP_ROUTE, RESOURCE_NAME } = require('../../../ext/tags')
2829
const { isACFActive } = require('../../datadog-core/src/storage')
2930
const log = require('./log')
3031
const {
3132
enterCh,
3233
spanFinishCh,
34+
tagsUpdateCh,
3335
getActiveSpan,
3436
ensureChannelsActivated,
3537
} = require('./storage-channels')
@@ -39,6 +41,20 @@ const {
3941
} = require('./profiling/webspan-utils')
4042
const webTagsCache = require('./web-tags-cache')
4143

44+
// The endpoint label the writer computes from a web-server span's tag bag
45+
// can change over the span's lifetime: HTTP plugins commonly set
46+
// `http.method` when the request arrives and only add `http.route` (and
47+
// often `resource.name`) once framework routing has resolved the URL.
48+
// The on-the-wire OTEP-4947 record only lets us write each attribute once
49+
// (readers honor the first occurrence and ignore duplicates), so we defer
50+
// writing the endpoint until the value looks stable — either `resource.name`
51+
// is set, or both `http.method` and `http.route` are — and re-check on
52+
// every subsequent tags update until we can commit.
53+
function isEndpointFinal (tags) {
54+
return tags != null && (tags[RESOURCE_NAME] != null ||
55+
(tags[HTTP_METHOD] != null && tags[HTTP_ROUTE] != null))
56+
}
57+
4258
// Positional attribute layout. The local root span ID stays at index 0 by
4359
// convention (mirrors libdatadog's libdd-otel-thread-ctx, where
4460
// `local_root_span_id` is always the first entry in
@@ -100,17 +116,21 @@ function getOrBuildContext (span) {
100116
const startedSpans = getStartedSpans(spanContext)
101117
const rootContext = startedSpans.length ? startedSpans[0].context() : spanContext
102118
const webTags = webTagsCache.getCachedWebTags(span)
119+
// Only publish the endpoint when the value is stable — see isEndpointFinal.
120+
// Otherwise leave a hole and let onTagsUpdated fill it in when the
121+
// remaining routing tags arrive.
122+
const endpointReady = isEndpointFinal(webTags)
103123
const attrs = []
104124
attrs[LOCAL_ROOT_SPAN_ID_IDX] = rootContext.toSpanId(true)
105-
if (webTags) attrs[ENDPOINT_IDX] = endpointNameFromTags(webTags)
125+
if (endpointReady) attrs[ENDPOINT_IDX] = endpointNameFromTags(webTags)
106126
attrs[THREAD_NAME_IDX] = THREAD_NAME
107127
attrs[THREAD_ID_IDX] = THREAD_ID
108128
if (cached === undefined) {
109129
cached = {}
110130
span[CachedSym] = cached
111131
}
112132
cached.context = new ThreadContext(traceId, spanId, attrs)
113-
cached.needsEndpoint = webTags === undefined
133+
cached.needsEndpoint = !endpointReady
114134
return cached.context
115135
}
116136

@@ -145,16 +165,21 @@ function onSpanFinished (span) {
145165

146166
function onTagsUpdated (span) {
147167
if (!started) return
148-
// Invoked (via webTagsCache.resolvedCh) once per span at the moment the
149-
// shared cache promotes a previously-undefined webTags answer into a
150-
// real value.
168+
// Subscribed to `dd-trace:span:tags:update` (not to webTagsCache.resolvedCh)
169+
// because we need to catch content changes — e.g. `http.route` arriving on
170+
// an already-cached web-server span — not just presence transitions.
171+
// web-tags-cache subscribes to the same channel at module load and always
172+
// runs before us (module init happens before start()), so its cache is
173+
// already up to date when we query it here.
151174
const cached = span[CachedSym]
152175
if (cached === undefined || !cached.needsEndpoint || cached.context === undefined) return
176+
const webTags = webTagsCache.getCachedWebTags(span)
177+
if (!isEndpointFinal(webTags)) return
153178
// Append the endpoint in place. The record buffer is shared across every
154179
// async-context frame holding this context, so the endpoint becomes
155180
// visible everywhere at once.
156181
const append = []
157-
append[ENDPOINT_IDX] = endpointNameFromTags(webTagsCache.getCachedWebTags(span))
182+
append[ENDPOINT_IDX] = endpointNameFromTags(webTags)
158183
cached.context.appendAttributes(append)
159184
cached.needsEndpoint = false
160185
}
@@ -194,7 +219,7 @@ function start () {
194219
ensureChannelsActivated(isACFActive)
195220
enterCh.subscribe(onEnter)
196221
spanFinishCh.subscribe(onSpanFinished)
197-
webTagsCache.resolvedCh.subscribe(onTagsUpdated)
222+
tagsUpdateCh.subscribe(onTagsUpdated)
198223

199224
started = true
200225
log.info('OTEP-4947 thread context writer started')
@@ -210,9 +235,14 @@ function start () {
210235
// { attributeKeys, schemaVersion, extraAttributes: [{ key, intValue|stringValue }] }
211236
//
212237
// Returns undefined if @datadog/pprof isn't installed or doesn't expose the
213-
// otelThreadCtx.getProcessContextAttributes helper; callers should treat that
214-
// as "no threadlocal block" (equivalent to the flag being off).
238+
// otelThreadCtx.getProcessContextAttributes helper, or if the runtime
239+
// can't actually run the writer (non-Linux, or Linux without
240+
// AsyncContextFrame). Callers should treat that as "no threadlocal
241+
// block" (equivalent to the flag being off) — otherwise we'd publish
242+
// process-context metadata advertising a decodable OTEP-4947 stream
243+
// while no writer is producing records.
215244
function getThreadLocalMetadata () {
245+
if (process.platform !== 'linux' || !isACFActive) return
216246
let pprofMod
217247
try {
218248
pprofMod = require('@datadog/pprof')

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

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -307,30 +307,64 @@ describe('otel-thread-ctx', () => {
307307
sinon.assert.notCalled(setActive)
308308
})
309309

310-
it('web-tags resolvedCh appends endpoint to the cached context when one was already built', () => {
310+
it('tagsUpdate appends endpoint when the shared cache now has final routing tags', () => {
311311
activeSpan = makeSpan({ tags: {} })
312312
// No web tags cached at build time → context is built without an
313313
// endpoint and left with `needsEndpoint = true`.
314314
enterCh.publish()
315315
const context = constructedContexts[0]
316316
sinon.assert.notCalled(context.appendAttributes)
317317

318-
// Shared cache promoted this span to a web-server span and published
319-
// on its resolvedCh. Seed the cache and fire the event.
318+
// Simulate the shared cache picking up http.method + http.route on
319+
// this span; on the next tagsUpdate we should append the endpoint.
320320
cachedWebTags.set(activeSpan,
321321
{ 'span.type': 'web', 'http.method': 'GET', 'http.route': '/x' })
322-
webTagsResolvedCh.publish(activeSpan)
322+
tagsUpdateCh.publish(activeSpan)
323323
sinon.assert.calledOnce(context.appendAttributes)
324324
// Endpoint lands at index 1 (local-root-span id occupies index 0).
325325
const appended = context.appendAttributes.firstCall.args[0]
326326
assert.equal(appended[1], 'GET /x')
327327
})
328328

329-
it('web-tags resolvedCh is a no-op when the span has not been entered yet', () => {
329+
it('defers the endpoint until http.route arrives (method-only is not final)', () => {
330+
// Simulates the common HTTP server flow: `span.type=web` and
331+
// `http.method` set on request start, `http.route` added later by
332+
// the routing plugin.
333+
const webTags = { 'span.type': 'web', 'http.method': 'GET' }
334+
activeSpan = makeSpan({ tags: webTags })
335+
cachedWebTags.set(activeSpan, webTags)
336+
enterCh.publish()
337+
const context = constructedContexts[0]
338+
// Endpoint is not final yet — no endpoint in the initial attrs.
339+
assert.strictEqual(context.attributes[1], undefined)
340+
341+
// Route arrives on a subsequent tagsUpdate.
342+
webTags['http.route'] = '/x'
343+
tagsUpdateCh.publish(activeSpan)
344+
sinon.assert.calledOnce(context.appendAttributes)
345+
assert.equal(context.appendAttributes.firstCall.args[0][1], 'GET /x')
346+
})
347+
348+
it('does not re-append the endpoint once it has been written', () => {
349+
// Final endpoint at build time → nothing to append on subsequent
350+
// tagsUpdate events, even if the tag bag continues to mutate.
351+
const webTags = { 'span.type': 'web', 'http.method': 'GET', 'http.route': '/x' }
352+
activeSpan = makeSpan({ tags: webTags })
353+
cachedWebTags.set(activeSpan, webTags)
354+
enterCh.publish()
355+
const context = constructedContexts[0]
356+
sinon.assert.notCalled(context.appendAttributes)
357+
358+
webTags['http.status_code'] = '200'
359+
tagsUpdateCh.publish(activeSpan)
360+
sinon.assert.notCalled(context.appendAttributes)
361+
})
362+
363+
it('tagsUpdate is a no-op when the span has not been entered yet', () => {
330364
const span = makeSpan({ tags: { 'span.type': 'web' } })
331365
cachedWebTags.set(span,
332366
{ 'span.type': 'web', 'http.method': 'GET', 'http.route': '/x' })
333-
webTagsResolvedCh.publish(span)
367+
tagsUpdateCh.publish(span)
334368
assert.equal(constructedContexts.length, 0)
335369
})
336370
})
@@ -365,6 +399,17 @@ describe('otel-thread-ctx', () => {
365399
sinon.assert.calledWithMatch(log.warn, /pprof unavailable|does not expose/)
366400
})
367401

402+
it('returns undefined on non-Linux platforms', () => {
403+
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
404+
const m = loadModule()
405+
assert.equal(m.getThreadLocalMetadata(), undefined)
406+
})
407+
408+
it('returns undefined when AsyncContextFrame is inactive', () => {
409+
const m = loadModule({ storage: { '@noCallThru': true, isACFActive: false } })
410+
assert.equal(m.getThreadLocalMetadata(), undefined)
411+
})
412+
368413
it('returns undefined when otelThreadCtx.getProcessContextAttributes is missing', () => {
369414
const m = loadModule({
370415
pprof: {

0 commit comments

Comments
 (0)