Skip to content

Commit 2fa615a

Browse files
committed
fix(profiling): resolve web tags for descendants cached before promotion
The shared web-tags cache records "no web-server ancestor" for a span and never revisits it, but that answer expires: plugins set `span.type` after creating the span — TracingPlugin.startSpan activates it before addRequestTags runs — so a child created in that window walks past an ancestor that is about to become a web-server span, and caches a miss for a chain that is about to have one. Promotion can't find those descendants, since the walk only goes upwards. So a promotion now bumps a generation counter, and an empty answer older than the counter is walked again on the next lookup. Resolved answers are untouched (the cached bag is the ancestor's live tag object), and a span with no parent is stamped as permanently empty, since only its own promotion could change it and onTagsUpdate already handles that. When a re-walk turns an empty answer into a real one, the cache publishes resolvedCh for that span, the same announcement a promoted span gets, so a consumer doesn't have to care which way the ancestry appeared. The counter lives on the trace, not in the module. A promotion can only invalidate empty answers within its own trace, because the walk follows `_parentId` through `_trace.started` and never leaves it — while a process-global counter would have every HTTP request's promotion invalidate empty answers in unrelated traces, making a long-lived non-web span re-walk its chain once per request served elsewhere, from the storage-enter path. Invalidation alone fixes nothing, because both consumers only ask the cache while building their per-span state, and the spans this affects already have theirs built. So each now asks again for state built from an empty answer: - the OTEP-4947 writer re-checks on re-entry when a record was built with no web-server ancestor, and attaches the endpoint or enlists the record for the request's endpoint announcement. Guarded so a re-entrant announcement from inside the lookup can't append the endpoint twice. - the wall profiler re-checks in #getProfilingContext when the snapshot it holds has no webTags, since #spanTagsUpdated only fires for a span promoted itself, never for descendants that walked past it beforehand. Both re-checks cost two property reads plus, at most, the cache's own generation compare — a walk only happens when a promotion in that trace has actually invalidated something. Two test-harness inaccuracies are fixed along the way, both of which had been hiding behaviour rather than testing it: web-tags-cache.spec.js's makeSpan returned a fresh object from context() per call, so spying on it counted calls on a throwaway and the "walks the parent chain once" assertion held vacuously; and wall.spec.js's makeChildSpan gave the child its own _trace object, so parent and child were in different traces, which no real trace chunk is. Reported by codex on #9210 and #9805.
1 parent 988a3a4 commit 2fa615a

6 files changed

Lines changed: 343 additions & 33 deletions

File tree

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

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,14 @@ let clearContext
114114

115115
function getOrBuildContext (span) {
116116
let cached = span[CachedSym]
117-
if (cached !== undefined && cached.context !== undefined) return cached.context
117+
if (cached !== undefined && cached.context !== undefined) {
118+
// This record was built when the span had no web-server ancestor at all, so
119+
// it never enlisted for an endpoint. Ask once more: webTagsCache re-walks an
120+
// answer that a later promotion invalidated, and nothing else will tell us —
121+
// the tags:update that promotes an ancestor is published for the ancestor.
122+
if (cached.awaitingWebTags) attachEndpoint(span, cached)
123+
return cached.context
124+
}
118125
const spanContext = span.context()
119126
const traceId = Uint8Array.from(Buffer.from(spanContext.toTraceId(true), 'hex'))
120127
const spanId = Uint8Array.from(Buffer.from(spanContext.toSpanId(true), 'hex'))
@@ -137,10 +144,29 @@ function getOrBuildContext (span) {
137144
span[CachedSym] = cached
138145
}
139146
cached.context = new ThreadContext(traceId, spanId, attrs)
147+
cached.awaitingWebTags = webTags === undefined
140148
if (endpoint === undefined) awaitEndpoint(webTags, cached)
141149
return cached.context
142150
}
143151

152+
// Give a record its endpoint now that its span's web-server ancestry is known:
153+
// write the value if it has settled, otherwise enlist for the announcement that
154+
// it has. A no-op while the span still has no web-server ancestor.
155+
function attachEndpoint (span, cached) {
156+
const webTags = webTagsCache.getCachedWebTags(span)
157+
// The lookup above can announce the resolution synchronously, and the handler
158+
// for that announcement lands right back here — so re-read the flag rather than
159+
// appending a second copy of the endpoint.
160+
if (webTags === undefined || !cached.awaitingWebTags) return
161+
cached.awaitingWebTags = false
162+
const endpoint = finalEndpoint(webTags)
163+
if (endpoint === undefined) {
164+
awaitEndpoint(webTags, cached)
165+
} else {
166+
appendEndpoint(cached.context, endpoint)
167+
}
168+
}
169+
144170
// Enlist a record built without an endpoint, so the resolution announcement for
145171
// its request fills it in.
146172
function awaitEndpoint (webTags, cached) {
@@ -231,21 +257,14 @@ function onEndpointResolved (span) {
231257
}
232258
}
233259

234-
// A span whose record was built before it looked like a web-server span at all
235-
// has just been recognized as one, so it has no endpoint and never enlisted for
236-
// this request. Its ancestry can't have changed, only its own tags, so this is
237-
// only ever about the announced span's own record.
260+
// A span whose record was built before it had any web-server ancestry now has
261+
// some — either because the span itself was recognized as a web-server span, or
262+
// because an ancestor was and webTagsCache re-walked this span's empty answer.
238263
function onWebTagsResolved (span) {
239264
if (!started) return
240265
const cached = span[CachedSym]
241266
if (cached === undefined || cached.context === undefined) return
242-
const webTags = webTagsCache.getCachedWebTags(span)
243-
const endpoint = finalEndpoint(webTags)
244-
if (endpoint === undefined) {
245-
awaitEndpoint(webTags, cached)
246-
} else {
247-
appendEndpoint(cached.context, endpoint)
248-
}
267+
attachEndpoint(span, cached)
249268
}
250269

251270
// Every otelThreadCtx member the writer calls, checked before it starts. Anything

packages/dd-trace/src/profiling/profilers/wall.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,12 @@ class NativeWallProfiler {
273273

274274
profilingContext = { spanId, rootSpanId, webTags }
275275
span[ProfilingContext] = profilingContext
276+
} else if (this.#endpointCollectionEnabled && profilingContext.webTags === undefined) {
277+
// Snapshotted before this span had a web-server ancestor. #spanTagsUpdated
278+
// only fires for a span promoted to web-server itself, not for descendants
279+
// that walked past it beforehand, so ask again — the shared cache re-walks
280+
// an answer a later promotion invalidated and answers from cache otherwise.
281+
profilingContext.webTags = webTagsCache.getCachedWebTags(span)
276282
}
277283
return profilingContext
278284
}

packages/dd-trace/src/web-tags-cache.js

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,40 @@ const dc = require('dc-polyfill')
4040
const { finalEndpoint, isWebServerSpan, getStartedSpans } = require('./profiling/webspan-utils')
4141

4242
// Fields on the cache entry:
43-
// resolved: true once the parent-chain walk has run.
44-
// webTags: the resolved tag bag, or undefined when the walk came up
45-
// empty (no web-server span found in the started-spans chain).
46-
// endpointFinal: true once this span is a web-server span whose endpoint name
47-
// has settled and endpointResolvedCh has been published for it.
43+
// resolved: true once the parent-chain walk has run.
44+
// webTags: the resolved tag bag, or undefined when the walk came up
45+
// empty (no web-server span found in the started-spans chain).
46+
// missGeneration: for an empty answer, the generation it was computed at, or
47+
// PERMANENT_MISS when no later promotion can change it.
48+
// endpointFinal: true once this span is a web-server span whose endpoint name
49+
// has settled and endpointResolvedCh has been published for it.
4850
const CachedSym = Symbol('WebTagsCache')
4951

52+
// A resolved answer is permanent, but an empty one is not: plugins commonly set
53+
// `span.type` after creating the span (TracingPlugin.startSpan activates the span
54+
// before addRequestTags runs), so a descendant that looked itself up in that
55+
// window recorded "no web-server ancestor" for a chain that is about to have
56+
// one. Promoting the ancestor can't find those descendants — the walk only goes
57+
// upwards — so instead a promotion bumps a counter, and an empty answer older
58+
// than the counter gets walked again on the next lookup.
59+
//
60+
// The counter lives on the trace rather than in this module, because a promotion
61+
// can only invalidate empty answers within its own trace: the walk follows
62+
// `_parentId` through `_trace.started` and never leaves it. A process-global
63+
// counter would make every HTTP request's promotion invalidate empty answers in
64+
// unrelated traces, and consumers re-check from storage-enter — so a long-lived
65+
// non-web span would re-walk its chain once per request served elsewhere.
66+
const GenerationSym = Symbol('WebTagsCache.generation')
67+
68+
// Stamp for an empty answer no promotion can change: a span with no parent can
69+
// only gain web-server tags on itself, and onTagsUpdate already handles that.
70+
// Never equal to a generation, which starts at 0 and only grows.
71+
const PERMANENT_MISS = -1
72+
73+
function traceGeneration (trace) {
74+
return trace[GenerationSym] ?? 0
75+
}
76+
5077
const tagsUpdateCh = dc.channel('dd-trace:span:tags:update')
5178
const resolvedCh = dc.channel('dd-trace:web-tags:resolved')
5279
const endpointResolvedCh = dc.channel('dd-trace:web-tags:endpoint-resolved')
@@ -66,8 +93,14 @@ function getCache (span) {
6693
// the result on the span.
6794
function getCachedWebTags (span) {
6895
const cached = getCache(span)
69-
if (cached.resolved) return cached.webTags
96+
// A resolved answer is never revisited: the cached bag is the ancestor's live
97+
// tag object, so later tag changes are already visible through it.
98+
if (cached.resolved && cached.webTags !== undefined) return cached.webTags
7099
const spanContext = span.context()
100+
if (cached.resolved && !isStaleMiss(cached, spanContext._trace)) return
101+
// Distinguishes a first walk from re-walking an answer consumers have already
102+
// seen and may have built something from.
103+
const wasMiss = cached.resolved
71104
const tags = spanContext.getTags()
72105
let webTags
73106
if (isWebServerSpan(tags)) {
@@ -85,9 +118,25 @@ function getCachedWebTags (span) {
85118
}
86119
cached.webTags = webTags
87120
cached.resolved = true
121+
if (webTags === undefined) {
122+
cached.missGeneration = spanContext._parentId == null
123+
? PERMANENT_MISS
124+
: traceGeneration(spanContext._trace)
125+
} else if (wasMiss) {
126+
// An empty answer turned into a real one. Consumers that snapshotted the
127+
// empty one get the same announcement a promoted span gets, so neither has to
128+
// special-case how the ancestry appeared.
129+
resolvedCh.publish(span)
130+
}
88131
return webTags
89132
}
90133

134+
// Whether an empty answer predates a promotion in its own trace, and so has to be
135+
// walked again.
136+
function isStaleMiss (cached, trace) {
137+
return cached.missGeneration !== PERMANENT_MISS && cached.missGeneration !== traceGeneration(trace)
138+
}
139+
91140
// Own the tagsUpdate → transition promotion here. The dc subscribe
92141
// happens lazily via activate(); each active consumer bumps a refcount,
93142
// the last deactivate() removes the subscription. When idle, this module
@@ -97,10 +146,16 @@ function getCachedWebTags (span) {
97146
function onTagsUpdate (span) {
98147
const cached = span[CachedSym]
99148
if (cached === undefined || !cached.resolved) return
100-
const tags = span.context().getTags()
149+
const spanContext = span.context()
150+
const tags = spanContext.getTags()
101151
if (cached.webTags === undefined) {
102152
if (!isWebServerSpan(tags)) return
103153
cached.webTags = tags
154+
// This span may be the ancestor a descendant walked past before it counted as
155+
// a web-server span, so every empty answer computed in this trace until now is
156+
// suspect. Other traces can't have walked through it.
157+
const trace = spanContext._trace
158+
trace[GenerationSym] = traceGeneration(trace) + 1
104159
resolvedCh.publish(span)
105160
}
106161
// Endpoint finality is a property of the web-server span itself: for a

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,81 @@ describe('otel-thread-ctx', () => {
596596
assert.equal(context.appendAttributes.firstCall.args[0][1], 'GET /x')
597597
})
598598

599+
it('appends the endpoint on re-entry to a record built with no web-server ancestor', () => {
600+
// The record was built while the span had no web-server ancestry at all, so
601+
// it never enlisted for a request. An ancestor being promoted afterwards is
602+
// announced for the ancestor, not for this span, and webTagsCache only
603+
// re-walks when asked — so re-entry is what has to notice.
604+
const webTags = { 'span.type': 'web', 'http.method': 'GET', 'http.route': '/x' }
605+
activeSpan = makeSpan({ tags: {} })
606+
enterCh.publish()
607+
const context = constructedContexts[0]
608+
assert.strictEqual(context.attributes[1], undefined)
609+
610+
cachedWebTags.set(activeSpan, webTags)
611+
enterCh.publish()
612+
sinon.assert.calledOnce(context.appendAttributes)
613+
assert.equal(context.appendAttributes.firstCall.args[0][1], 'GET /x')
614+
assert.equal(constructedContexts.length, 1)
615+
})
616+
617+
it('waits for the endpoint on re-entry when the ancestry resolves before the route', () => {
618+
// Re-entry finds an ancestor but no settled endpoint yet, so the record has
619+
// to join the request's waiting list — the announcement then names the
620+
// ancestor, whose tag bag is the key it is waiting under.
621+
const { parent, child, webTags } = makeWebSpanWithChild({ 'span.type': 'web', 'http.method': 'GET' })
622+
activeSpan = child
623+
enterCh.publish()
624+
const context = constructedContexts[0]
625+
assert.strictEqual(context.attributes[1], undefined)
626+
627+
cachedWebTags.set(child, webTags)
628+
enterCh.publish()
629+
sinon.assert.notCalled(context.appendAttributes)
630+
631+
webTags['http.route'] = '/x'
632+
endpointResolvedCh.publish(parent)
633+
sinon.assert.calledOnce(context.appendAttributes)
634+
assert.equal(context.appendAttributes.firstCall.args[0][1], 'GET /x')
635+
})
636+
637+
it('does not append twice when re-entry and the announcement coincide', () => {
638+
// getCachedWebTags can publish the resolution synchronously, landing the
639+
// announcement handler in the same code path the re-entry is running.
640+
const webTags = { 'span.type': 'web', 'http.method': 'GET', 'http.route': '/x' }
641+
activeSpan = makeSpan({ tags: {} })
642+
enterCh.publish()
643+
const context = constructedContexts[0]
644+
645+
cachedWebTags.set(activeSpan, webTags)
646+
// As the real cache does: the lookup that turns an empty answer into a real
647+
// one announces it, once, from inside the lookup.
648+
let announced = false
649+
webTagsCacheStub.getCachedWebTags = span => {
650+
const tags = cachedWebTags.get(span)
651+
if (tags !== undefined && !announced) {
652+
announced = true
653+
webTagsResolvedCh.publish(span)
654+
}
655+
return tags
656+
}
657+
enterCh.publish()
658+
sinon.assert.calledOnce(context.appendAttributes)
659+
assert.equal(context.appendAttributes.firstCall.args[0][1], 'GET /x')
660+
})
661+
662+
it('stops re-asking once a record has its endpoint', () => {
663+
const webTags = { 'span.type': 'web', 'http.method': 'GET', 'http.route': '/x' }
664+
activeSpan = makeSpan({ tags: {} })
665+
enterCh.publish()
666+
cachedWebTags.set(activeSpan, webTags)
667+
enterCh.publish()
668+
const lookups = sinon.spy(webTagsCacheStub, 'getCachedWebTags')
669+
enterCh.publish()
670+
enterCh.publish()
671+
sinon.assert.notCalled(lookups)
672+
})
673+
599674
it('endpoint announcements are a no-op for a span that has not been entered', () => {
600675
const { parent, webTags } = makeWebSpanWithChild(
601676
{ 'span.type': 'web', 'http.method': 'GET', 'http.route': '/x' })

packages/dd-trace/test/profiling/profilers/wall.spec.js

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -804,15 +804,19 @@ describe('profilers/native/wall', () => {
804804
function makeChildSpan (webSpanId, webSpan) {
805805
const tags = { 'span.type': 'router' }
806806
const spanId = {}
807+
// Shares the parent's _trace, as spans of one trace chunk do: it is the
808+
// object holding the started-spans list the parent walk reads, and what the
809+
// shared cache scopes its per-trace bookkeeping to.
810+
const trace = webSpan.context()._trace
807811
const ctx = {
808812
_tags: tags,
809813
_spanId: spanId,
810814
_parentId: webSpanId,
811-
_trace: { started: [webSpan] },
815+
_trace: trace,
812816
getTags () { return this._tags },
813817
}
814818
const span = { context: () => ctx }
815-
ctx._trace.started.push(span)
819+
trace.started.push(span)
816820
return { span, tags }
817821
}
818822

@@ -907,6 +911,36 @@ describe('profilers/native/wall', () => {
907911
profiler.stop()
908912
})
909913

914+
it('should refresh a child snapshot taken before its parent became a web span (ACF path)', () => {
915+
// The reverse order of the test below: the child is activated first, so its
916+
// snapshot records "no web-server ancestor". The parent's later promotion is
917+
// announced for the parent, so #spanTagsUpdated never sees the child, and
918+
// the endpoint label would be lost for the rest of the child's life.
919+
const { span: webSpan, tags: webSpanTags, spanId: webSpanId } = makeWebSpan()
920+
const { span: childSpan } = makeChildSpan(webSpanId, webSpan)
921+
922+
const profiler = makeWall(WallProfiler, {
923+
endpointCollectionEnabled: true,
924+
codeHotspotsEnabled: true,
925+
asyncContextFrameEnabled: true,
926+
})
927+
profiler.start()
928+
929+
currentStore = { span: childSpan }
930+
enterCh.publish()
931+
const childCtx = localPprof.time.setContext.lastCall.args[0]
932+
assert.strictEqual(childCtx.webTags, undefined)
933+
934+
webSpanTags['span.type'] = 'web'
935+
tagsUpdateCh.publish(webSpan)
936+
937+
// Re-activating the child is what notices, via the shared cache's re-walk.
938+
enterCh.publish()
939+
assert.strictEqual(childCtx.webTags, webSpanTags)
940+
941+
profiler.stop()
942+
})
943+
910944
it('should propagate webTags to child spans after tags update resolves parent (ACF path)', () => {
911945
const { span: webSpan, tags: webSpanTags, spanId: webSpanId } = makeWebSpan()
912946
const { span: childSpan } = makeChildSpan(webSpanId, webSpan)

0 commit comments

Comments
 (0)