Propagate LLM Observability context across service boundaries - #12416
Propagate LLM Observability context across service boundaries#12416ncybul wants to merge 20 commits into
Conversation
LLMObs context (ml_app, session_id, agent attribution) stayed within a single process. An agent that dispatched work over SQS, or called another service over HTTP, left the downstream side with no session and no agent attribution, fragmenting what is logically one LLM trace. Carry these as _dd.p.llmobs_* propagation tags, using the key names dd-trace-py/js/go already use so a mixed-language pipeline joins up. Rather than teaching each integration about LLMObs, register an LLMObsContextPropagator as a propagation concern: it contributes no headers of its own, it stages the tags onto the span context ahead of the tracing propagator, which then serializes them like any other propagation tag. This mirrors dd-trace-py, where LLMObs subscribes to the generic http.span_inject hook, and means every boundary automatic instrumentation already covers is handled at once. SQS needs no integration-specific code as a result. SqsInterceptor already injects through the default propagator, and the consume span is active while the consumer's per-message code runs, so a worker's LLMObs spans inherit the upstream context. Values are resolved from the ambient LLMObsContext at injection time, so the innermost active span wins and leaving a scope stops contributing. On the receive side, DDLLMObsSpan reads session_id and agent attribution off the propagated context whenever no same-trace in-process parent contributed them -- including when a stale context from an unrelated trace is present, which must not suppress attribution that legitimately arrived over the wire.
This comment has been minimized.
This comment has been minimized.
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 77546ab8f7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Replace the five per-tag setters with a single updateLLMObsContext across PropagationTags, AgentSpanContext, DDSpanContext and the injecting call site, and hold the values in PTags as one volatile LLMObsTagValues instead of five volatile fields. A concurrent reader can no longer serialize a header mixing values from two contexts, and getXDatadogTagsSize can no longer size a combination that never existed -- which matters because that total gates whether x-datadog-tags is emitted at all. Also make LLMObsTagValues non-null throughout (EMPTY plus an of() factory that reuses it, so the common no-LLMObs request doesn't allocate), and add a private clearCachedHeaders() for the 11 sites that invalidate both encodings, leaving the three deliberate single-encoding calls visibly deliberate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…style Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sent Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…re the service default Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lmObsTags initializer
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bfa7670f8a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
Parallel calls on one trace use the same LLMObs tag state. One call can send the session and parent data from another call.
🤖 Datadog Autotest · Commit bfa7670 · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest
|
Design concern beyond the specific bugs above: putting these 5 tags on Given that, would baggage (already a first-class, separately-propagated mechanism here — its own W3C header, own context key, independent of |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Following up on the size-cap comment above with a narrower point: the wire header names themselves are long — In retrospect, though — dd-trace-py/js/go have already shipped these exact key names on the wire, so changing them now isn't a Java-only decision, it's a cross-language migration. Given that commitment is already made, I don't think it's fair to hold this PR to fixing it. Noting it here mainly so it's on record for whenever the format does get revisited (e.g. if a versioned propagation-tag scheme ever gets designed). |
@dougqh We have plans to shift from propagation tags to baggage across all of the SDKs. However, given that we still need propagation tags for backwards compatibility as of right now, we're thinking of implementing both mechanisms and then removing |
What Does This Do
Carries LLM Observability context (ML app, session ID, agent attribution, and the parent span id) across process boundaries, so an LLM trace stays connected when work crosses a queue or a service call.
Approach
Register an
LLMObsContextPropagatoras a propagation concern (AgentPropagation.LLMOBS_CONCERN). This propagator stages_dd.p.llmobs_*onto the span context and the tracing propagator serializes them like any other propagation tag. Every boundary auto-instrumentation already covers (SQS, HTTP, gRPC, Kafka) is handled at once._dd.p.llmobs_ml_appml_app_dd.p.llmobs_sidsession_id_dd.p.llmobs_pagent_span_id_dd.p.llmobs_pagent_name_dd.p.llmobs_parent_idJava doesn't need dd-trace-py's
_dd.p.llmobs_trace_id— it reuses the APM trace id.Values are resolved from the ambient
LLMObsContextat injection time, not written when the span starts, and injection is gated on trace-id consistency (the same gateDDLLMObsSpanalready applies in-process).Receive side
There are two possible parents: the ambient in-process
LLMObsContext, and the_dd.p.llmobs_*values on the extracted span context.DDLLMObsSpanprefers the in-process one, but only when it belongs to the same trace. Otherwise (no ambient context, or one from a different trace) it falls back to the extracted values. An explicit value passed by the caller still wins over both.Falling back on the trace mismatch, and not only when the ambient context is absent, is what makes a queue consumer correct: a scope leaked from a previously handled message would otherwise shadow the attribution that arrived with the current one.
"The extracted values" means specifically what arrived on the inbound headers, never what a local injection staged for an outbound call.
PTagskeeps both: a mutable bundle the propagator rewrites on every injection, and afinalbaseline of what was decoded. They have to be separate becauseCoreTracerhands the extracted context'sPropagationTagsto the local root, so the two live in one object. Two behaviours fall out of that split:Other Changes
ml_appprecedenceThe service default now applies last, in
DDLLMObsSpan, instead of eagerly in the span factory. Substituting it at the factory left no way to tell "caller named no application" from an explicit value, so everyml_appinheritance step — in-process and propagated alike — would have been unreachable.Order is explicit > in-process LLMObs parent > propagated >
DD_LLMOBS_ML_APP>DD_SERVICE, matching dd-trace-py.The resolved value is unchanged for a span that names its own
ml_app, and for one that names none with no LLMObs parent — it still falls back toDD_LLMOBS_ML_APP/DD_SERVICE. What does change is that a nested span naming noml_appnow inherits its enclosing span's value rather than the default. That subtree inheritance is required by propagation rather than incidental to it: injection reads the innermost active span'sml_app, and on the receiving side only the first LLMObs span reads the propagated value — without it, the wire value would be wrong at the producer and lost one level down at the consumer.Validating application-supplied values
Unlike every other
_dd.p.*tag, these five come from the application rather than the tracer, so they are checked before they reach the wire. A value carrying a character the receiving codec mis-parses doesn't just lose itself — it fails the whole tagset withdecoding_errorand takes_dd.p.tidwith it, leaving the two services disagreeing about the upper 64 bits of the trace id. Dropping the one tag is the cheaper loss.Rejected: anything outside printable ASCII,
,(thex-datadog-tagsseparator),"and\(AWS messaging concatenates these headers into a_datadogJSON attribute that is written and parsed without escaping), and anything thetracestateconversion rewrites irreversibly —;and~, which both become_. The last of those asksTagValue's own conversion table rather than restating it, so it stays correct if the table changes:=is deliberately kept — it round-trips as~, and base64-ish session ids carry it routinely.Rejecting rather than sanitizing, matching dd-trace-py: a
_-mangledml_appcan silently collide two distinct applications into one bucket, which is worse than an absent tag falling back to the receiver's ownDD_LLMOBS_ML_APP/DD_SERVICE.Note the unescaped JSON concatenation in
MessageAttributeInjector/TextMapInjectAdapteris a property of those carriers, not of LLMObs — any product's_dd.p.*value containing a quote hits it. Rejecting here closes the LLMObs route to it; escaping at the injectors is the broader fix and is left toagent-bootstrap.Also fixes: propagation tags dropped at every AWS messaging boundary
DatadogAttributeParser— the shared_datadogmessage attribute parser used by SQS, SNS, EventBridge and Step Functions — never readx-datadog-tags, so every_dd.p.*tag was silently dropped crossing those boundaries. Inject was always correct; the raw attribute carries the full set.Pre-existing bug, not introduced here, but needed for LLMObs to cross a queue. It means
_dd.p.*now survives AWS messaging for all products — intended, but a change in what goes over the wire downstream of a queue.Follow-ups (not in this PR)
LLMObsContext. Covered here: tags onto outbound carriers, and parsed back into the extracted span context, whichDDLLMObsSpanreads. Not covered: the fully-automatic path where an inbound trace is followed by an auto-instrumented call with no manual LLMObs span —OpenAiDecoratorreadsLLMObsContext.current(), still null there. Separate because someone has to own attaching and closing a scope around the consumer's work, and that owner differs per integration.ml_appandsession_idare charset-checked before the wire but not length-checked; an oversized value drops the whole header viainject_max_size. Left as-is because dd-trace-py behaves identically — no per-value guard there either. Worth revisiting cross-language.DatadogAttributeParser. Step Functions doesn't: it writes trace context into the request input body rather than into message attributes, so it needs its own extract path before LLMObs context can cross it. A gap rather than a regression — nothing about it changes in this PR.volatile, so a reader always sees a coherent set, but it may be the other call's. dd-trace-py has the same property —LLMObs._inject_llmobs_contextwrites intospan_context._meta, andSpan.contextshares trace-level_metaacross the trace — so this isn't a Java-specific defect and shouldn't be fixed Java-only. Worth noting the exposure differs: a JVM service fanning out parallel calls from sibling agents hits it more readily than Python's usual shapes. The real question is broader than a lock — everything else staged there (sampling priority, org marker,_dd.p.tid) is a trace-level fact identical for every destination, whereas these are per-injection and vary by destination. So: how should a propagation concern contribute per-injection tags? Same question in every language.Testing
LLMObsContextPropagatorTest— 10 tests, none calling any LLMObs propagation API: injection carries all five tags; nothing added with no active span; tags stop once the scope closes; no leak into a later injection on the same trace; producer → worker round trip with zero application plumbing; a worker naming noml_appinherits the producer's; a worker with no upstream inherits nothing; a pass-through service forwards its caller's context; an injection before the local LLMObs span opens doesn't destroy the extracted context; a peer span doesn't inherit a finished sibling's staged tags.DDLLMObsSpanMlAppTest— 5 tests pinning the precedence chain (explicit, inherited, override, empty-as-absent, service default). Verified load-bearing by neutralising both fallbacks (4 of 5 fail). Only the propagated case overlaps with the propagator test.DatadogPropagationTagsTest/W3CPropagationTagsTest— the five tags through both codecs: round trip, per-value rejection of the characters listed above, and decoding on read (atracestatevalue written asapp~v1reads back asapp=v1).DatadogAttributeParserTest— 1 test covering only what this branch changes:x-datadog-tagsreaches the extractor. Fails without the one-line parser fix.Each fix landed in this PR was checked to be load-bearing by reverting it in isolation and confirming the intended test — and only that test — fails.
End to End Testing
Before
HTTP calls are split across more than one LLMObs trace (agent trace and tool trace) whereas the APM trace is connected.
SQS messages are split across more than one LLMObs trace (agent trace and tool trace). The APM trace is connected.
After
LLMObs trace with HTTP call and associated APM trace.
LLMObs trace with SQS message queueing and associated APM trace.
Claude session:
15543c2c-2abe-408e-b16e-05ddbe972287Resume:
claude --resume 15543c2c-2abe-408e-b16e-05ddbe972287