Propagate LLM Observability context across service boundaries - #12416
Draft
ncybul wants to merge 1 commit into
Draft
Propagate LLM Observability context across service boundaries#12416ncybul wants to merge 1 commit into
ncybul wants to merge 1 commit 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.
|
🎯 Code Coverage (details) 🔗 Commit SHA: a66d66d | Docs | View more details | Give us feedback! |
Contributor
🟢 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What Does This Do
Carries LLM Observability context —
ml_app,session_id, and agent attribution — across process boundaries, so an LLM trace stays whole when work crosses a queue or a service call.The driving case is SQS: an agent dispatches work onto a queue, and the worker that picks it up currently starts with no session and no agent attribution, fragmenting what is logically one trace.
Motivation
LLMObs context has been in-process only.
LLMObsContextpropagatessession_idand agent attribution to descendant spans on the same thread, but nothing reaches the wire — so every service boundary splits the trace.dd-trace-py already does this automatically: LLMObs subscribes to the generic
http.span_injecthook thatHTTPPropagator.injectfires on every outbound request, and writes_dd.p.llmobs_*onto the span context there. Any auto-instrumented call carries LLMObs context with no application code. This brings Java to the same behaviour, using the same tag names, so a mixed-language pipeline joins up.Approach
Rather than teaching each integration about LLMObs, register an
LLMObsContextPropagatoras a propagation concern (AgentPropagation.LLMOBS_CONCERN).It contributes no headers of its own. It stages the
_dd.p.llmobs_*tags onto the span context being injected, and the tracing propagator then serializes them like any other propagation tag. Composite injection runs in reverse priority order, so the concern sorts afterTRACING_CONCERN(priority 115 vs 100) in order to inject before it. This is the same shape as the existingDSM_CONCERN, which likewise stashes state on the span context rather than writing headers.Consequences:
LLMObsContextat injection time, not written once when a span starts. The innermost active LLMObs span wins, and leaving a scope stops contributing tags, with no save/restore bookkeeping.DDLLMObsSpanalready applies to in-process inheritance.Why SQS needs no code of its own
SqsInterceptor.modifyRequestalready injects throughdefaultPropagator()into the_datadogmessage attribute, and on the receive sideTracingIteratorextracts it (Config.isSqsPropagationEnabled()defaults to true) and activates the consume span while the consumer's per-message code runs (TracingIterator.java:117-124). So once the concern is registered, both directions work: the producer's tags ride the existing_datadogattribute, and the worker's LLMObs spans inherit from the activated consume span.Receive side
DDLLMObsSpannow readssession_idand agent attribution off the propagated context whenever no same-trace in-process parent contributed them. Note this deliberately covers the trace-mismatch case as well as the no-parent case: a staleLLMObsContextleaked from an unrelated trace must not suppress attribution that legitimately arrived over the wire — which is exactly what an SQS worker hits when a previous message's scope leaks on the same thread.ml_appmoves intoLLMObsContext(it previously lived only as a span tag), so the propagator can read the innermost active span's value without holding a reference to the span.Behaviour change
This is on by default whenever LLM Observability is enabled: outbound requests made while an LLMObs span is active now carry a few extra tag bytes in
x-datadog-tags/tracestate. That matches dd-trace-py. Happy to put it behind a config flag if reviewers would prefer an opt-in.Relationship to #12402
#12402 adds a manual
LLMObs.injectDistributedHeaders/activateDistributedHeadersAPI for carriers no instrumentation reaches. That API is not in this PR — this is the automatic path only, which is what the SQS use case actually needs. #12402 will rebase on top of this and keep only the public manual API.Testing
LLMObsContextPropagatorTest— five tests, none of which call any LLMObs propagation API:Injecting the active span the way auto-instrumentation does carries
ml_app,session_id,pagent_span_id,pagent_nameNothing is added when no LLMObs span is active
Tags stop being contributed once the LLMObs scope closes
Producer → worker round trip: a worker inherits session and agent attribution across the boundary with zero application plumbing, using a
Mapcarrier — the shape the SQS_datadogattribute reduces to at the propagator boundaryA worker with no upstream LLMObs context inherits nothing
:dd-java-agent:agent-llmobs:test— green:internal-api:test— green:dd-trace-core:test --tests "datadog.trace.core.propagation.*"— greenNote:
PendingTraceBufferTestandTracerConnectionReliabilityTestfail in the fulldd-trace-coresuite. These were confirmed earlier to fail identically on unmodified master and are unrelated to this change.Follow-ups
aws-java-sqs-2.0exercising this against the real AWS SDK, alongsideSqsClientTest.java. That module does not currently haveagent-llmobson its test classpath, so it is a non-trivial addition and is left out here.DatadogPTagsCodec/W3CPTagsCodec.