-
Notifications
You must be signed in to change notification settings - Fork 361
Propagate LLM Observability context across service boundaries #12416
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
a66d66d
4f08a5f
77546ab
f26dec0
3d9361f
d91534d
5a58e5c
0b3c363
6e389d3
b474277
d6f6d3b
17780e8
bfa7670
30323f3
ffc5015
17cdcd6
d726035
f119b78
9944896
8a4a379
3621c74
350005e
280b124
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,10 @@ public static void forEachProperty(AgentPropagation.KeyClassifier classifier, St | |
| if (acceptJsonProperty(classifier, json, "x-datadog-trace-id")) { | ||
| acceptJsonProperty(classifier, json, "x-datadog-parent-id"); | ||
| acceptJsonProperty(classifier, json, "x-datadog-sampling-priority"); | ||
| // Propagation tags travel in x-datadog-tags. Without this the whole _dd.p.* set is | ||
| // silently dropped at a messaging boundary — including _dd.p.tid, which truncates a | ||
| // 128-bit trace id to 64 bits downstream, and the _dd.p.llmobs_* attribution tags. | ||
| acceptJsonProperty(classifier, json, "x-datadog-tags"); | ||
|
ncybul marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth looking into this. I'm not too sure how ASM works here but this does sound concerning how we might be altering ASM consumer sampling and trace ID formats Forwarding x-datadog-tags un-gated enables all _dd.p.* across the parser's three callers, so _dd.p.ts now survives a queue hop and setSamplingPriorityIfNecessary() skips the sampler override on ASM/AI_GUARD-marked traces (its comment says exactly that) — moving standalone-ASM consumers (DD_APM_TRACING_ENABLED=false) from AsmStandaloneSampler's 1 trace/min to retaining every ASM-marked trace; separately _dd.p.tid flips every queue consumer's dd.trace_id in logs from decimal to 32-char hex on upgrade, breaking existing log-trace joins for APM-only customers. |
||
| } | ||
| if (Config.get().isDataStreamsEnabled()) { | ||
| acceptJsonProperty(classifier, json, "dd-pathway-ctx-base64"); | ||
|
|
||
|
ncybul marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package datadog.trace.bootstrap.instrumentation.messaging; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
|
|
||
| import java.util.LinkedHashMap; | ||
| import java.util.Map; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| /** | ||
| * Covers the one behaviour this branch adds to the {@code _datadog} message attribute parser shared | ||
| * by the AWS messaging instrumentations (SQS, SNS, EventBridge, Step Functions): {@code | ||
| * x-datadog-tags} is forwarded to the extractor. | ||
| */ | ||
| class DatadogAttributeParserTest { | ||
|
|
||
| @Test | ||
| void forwardsPropagationTags() { | ||
| Map<String, String> collected = new LinkedHashMap<>(); | ||
| DatadogAttributeParser.forEachProperty( | ||
| (key, value) -> { | ||
| collected.put(key, value); | ||
| return true; | ||
| }, | ||
| "{\"x-datadog-trace-id\":\"1234567890\"," | ||
| + "\"x-datadog-parent-id\":\"9876543210\"," | ||
| + "\"x-datadog-sampling-priority\":\"1\"," | ||
| + "\"x-datadog-tags\":\"_dd.p.dm=-1,_dd.p.tid=6aa01c5400000000\"}"); | ||
|
|
||
| assertEquals("_dd.p.dm=-1,_dd.p.tid=6aa01c5400000000", collected.get("x-datadog-tags")); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| package datadog.trace.llmobs; | ||
|
|
||
| import datadog.context.Context; | ||
| import datadog.context.propagation.CarrierSetter; | ||
| import datadog.context.propagation.CarrierVisitor; | ||
| import datadog.context.propagation.Propagator; | ||
| import datadog.trace.api.llmobs.LLMObsContext; | ||
| import datadog.trace.bootstrap.instrumentation.api.AgentSpan; | ||
| import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext; | ||
|
|
||
| /** | ||
| * Stages the LLM Observability propagation tags onto the span context being injected, so that every | ||
| * boundary already covered by automatic instrumentation carries LLMObs context without the | ||
| * application having to propagate it by hand. | ||
| * | ||
| * <p>This propagator writes nothing to the carrier itself. It runs ahead of the tracing propagator | ||
| * (see {@code AgentPropagation.LLMOBS_CONCERN}) and only populates the {@code _dd.p.llmobs_*} | ||
| * fields on the span context; the tracing propagator then serializes them into {@code | ||
| * x-datadog-tags} / {@code tracestate} along with every other propagation tag. | ||
| * | ||
| * <p>Values are resolved from the ambient {@link LLMObsContext} at injection time rather than being | ||
| * written once when a span starts, and every injection rewrites the whole set — falling back to | ||
| * whatever arrived on the inbound headers when no LLMObs context applies. That way the innermost | ||
| * active LLMObs span always wins, leaving an LLMObs scope stops contributing its tags, and a | ||
| * service that opens no LLMObs span of its own still forwards its caller's context — all without | ||
| * any save/restore bookkeeping. | ||
| */ | ||
| public class LLMObsContextPropagator implements Propagator { | ||
|
|
||
| @Override | ||
| public <C> void inject(Context context, C carrier, CarrierSetter<C> setter) { | ||
| AgentSpan span = AgentSpan.fromContext(context); | ||
| if (span == null) { | ||
| return; | ||
| } | ||
| AgentSpanContext spanContext = span.spanContext(); | ||
| if (spanContext == null) { | ||
| return; | ||
| } | ||
|
|
||
| // Gate on trace-id consistency, the same way DDLLMObsSpan gates parent_id/session_id | ||
| // inheritance. An LLMObs context leaked across an async boundary must not tag an outbound | ||
| // request that belongs to an unrelated trace. | ||
| AgentSpanContext llmObsContext = LLMObsContext.current(); | ||
| if (llmObsContext == null || !llmObsContext.getTraceId().equals(spanContext.getTraceId())) { | ||
| // Reset rather than return. These tags are staged on the root span context's propagation | ||
| // tags, which the whole local trace shares, so anything an earlier injection wrote would | ||
| // otherwise ride along on this one too — shipping a session and an agent attribution that | ||
| // are no longer active. Reset restores the extracted values instead of clearing outright: | ||
| // the same object also holds what came in on the wire, and a pass-through service must keep | ||
| // forwarding its caller's context. | ||
| spanContext.resetLLMObsContext(); | ||
| return; | ||
|
ncybul marked this conversation as resolved.
|
||
| } | ||
|
|
||
| spanContext.updateLLMObsContext( | ||
|
ncybul marked this conversation as resolved.
|
||
| LLMObsContext.currentMlApp(), | ||
| LLMObsContext.currentSessionId(), | ||
| LLMObsContext.currentParentAgentSpanId(), | ||
| LLMObsContext.currentParentAgentName(), | ||
| String.valueOf(llmObsContext.getSpanId()), | ||
| LLMObsContext.currentSampleRate(), | ||
| LLMObsContext.currentSamplingDecision()); | ||
| } | ||
|
Comment on lines
+44
to
+64
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this work for concurrent traces? Claude is telling me that it won't. Issue: updateLLMObsContext writes the bundle to getRootSpanContextOrThis().propagationTags — one object shared by the whole local trace — and the tracing propagator serializes it one propagator later, so two concurrent outbound calls under one agent span (parallel tool calls: the normal agentic shape) interleave stage/stage/serialize and ship each other's parent_id/session_id, mis-parenting the downstream service under the wrong agent. volatile prevents a torn bundle but cannot make stage→serialize atomic — the AgentSpanContext.updateLLMObsContext javadoc's atomicity claim covers only the former. |
||
|
|
||
| @Override | ||
| public <C> Context extract(Context context, C carrier, CarrierVisitor<C> visitor) { | ||
| // Nothing to do: the tracing propagator's codecs already parse the _dd.p.llmobs_* tags back | ||
| // into the extracted context's propagation tags, and DDLLMObsSpan reads them from there when | ||
| // no in-process LLMObs parent applies. | ||
| return context; | ||
|
ncybul marked this conversation as resolved.
|
||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.