Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a66d66d
Propagate LLM Observability context across service boundaries
ncybul Sep 4, 2026
4f08a5f
Propagate LLM Observability parent span id across service boundaries
ncybul Sep 8, 2026
77546ab
Extract propagation tags from the _datadog message attribute
ncybul Sep 8, 2026
f26dec0
Update LLM Observability propagation tags as one atomic bundle
ncybul Sep 8, 2026
3d9361f
Inline the LLM Observability tag map writes to match the surrounding …
ncybul Sep 8, 2026
d91534d
Apply review feedback: trim comments and group the LLMObs accessors
ncybul Sep 8, 2026
5a58e5c
Reject LLMObs propagation tag values that x-datadog-tags cannot repre…
ncybul Sep 8, 2026
0b3c363
Clear staged LLMObs propagation tags when no LLMObs context applies
ncybul Sep 8, 2026
6e389d3
Resolve ml_app from the in-process parent and propagated context befo…
ncybul Sep 8, 2026
b474277
Override equals/hashCode on LLMObsTagValues instead of a bespoke sameAs
ncybul Sep 9, 2026
d6f6d3b
Drop the dead 7-arg LLMObsContext.attach overload and the redundant l…
ncybul Sep 9, 2026
17780e8
Trim DatadogAttributeParserTest to the tag-forwarding case
ncybul Sep 9, 2026
bfa7670
Trim review comments per PR feedback
ncybul Sep 9, 2026
30323f3
Preserve extracted LLMObs context when no local LLMObs span is active
ncybul Sep 9, 2026
ffc5015
Reject LLMObs tag values that no carrier can round-trip
ncybul Sep 9, 2026
17cdcd6
Read LLMObs propagation tags from the extracted values, decoded
ncybul Sep 10, 2026
d726035
Consolidate the LLMObs propagation tests onto the existing helpers an…
ncybul Sep 10, 2026
f119b78
Compare trace IDs with equals instead of reference identity
ncybul Sep 10, 2026
9944896
Trim redundant comments from DatadogAttributeParserTest
ncybul Sep 10, 2026
8a4a379
Merge branch 'master' into llmobs/sqs-context-propagation
ncybul Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Comment thread
ncybul marked this conversation as resolved.
Comment thread
ncybul marked this conversation as resolved.
}
if (Config.get().isDataStreamsEnabled()) {
acceptJsonProperty(classifier, json, "dd-pathway-ctx-base64");
Expand Down
Comment thread
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,71 @@
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;
Comment thread
ncybul marked this conversation as resolved.
}

spanContext.updateLLMObsContext(
Comment thread
ncybul marked this conversation as resolved.
LLMObsContext.currentMlApp(),
LLMObsContext.currentSessionId(),
LLMObsContext.currentParentAgentSpanId(),
LLMObsContext.currentParentAgentName(),
String.valueOf(llmObsContext.getSpanId()));
}

@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;
Comment thread
ncybul marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package datadog.trace.llmobs;

import datadog.communication.ddagent.SharedCommunicationObjects;
import datadog.context.propagation.Propagators;
import datadog.trace.api.Config;
import datadog.trace.api.WellKnownTags;
import datadog.trace.api.llmobs.LLMObs;
import datadog.trace.api.llmobs.LLMObsInternal;
import datadog.trace.api.llmobs.LLMObsSpan;
import datadog.trace.api.llmobs.LLMObsTags;
import datadog.trace.api.telemetry.LLMObsMetricCollector;
import datadog.trace.bootstrap.instrumentation.api.AgentPropagation;
import datadog.trace.bootstrap.instrumentation.api.Tags;
import datadog.trace.llmobs.domain.DDLLMObsSpan;
import datadog.trace.llmobs.domain.LLMObsEval;
Expand Down Expand Up @@ -46,11 +48,15 @@ public static void start(Instrumentation inst, SharedCommunicationObjects sco) {

String mlApp = config.getLlmObsMlApp();
WellKnownTags wellKnownTags = config.getWellKnownTags();
LLMObsInternal.setSpanFactory(new LLMObsManualSpanFactory(mlApp, wellKnownTags));
// The span factory deliberately gets no default ml_app: DDLLMObsSpan applies it last, after
// in-process and propagated values have had their chance.
LLMObsInternal.setSpanFactory(new LLMObsManualSpanFactory(wellKnownTags));

LLMObsInternal.setEvalProcessor(new LLMObsCustomEvalProcessor(mlApp, sco, config));

LLMObsInternal.setFeedbackProcessor(new LLMObsCustomFeedbackProcessor(mlApp, sco, config));

Propagators.register(AgentPropagation.LLMOBS_CONCERN, new LLMObsContextPropagator());
}

private static class LLMObsCustomFeedbackProcessor implements LLMObs.LLMObsFeedbackProcessor {
Expand Down Expand Up @@ -219,12 +225,10 @@ public void SubmitEvaluation(

private static class LLMObsManualSpanFactory implements LLMObs.LLMObsSpanFactory {

private final String defaultMLApp;
private final String serviceName;
private final WellKnownTags wellKnownTags;

public LLMObsManualSpanFactory(String defaultMLApp, WellKnownTags wellKnownTags) {
this.defaultMLApp = defaultMLApp;
public LLMObsManualSpanFactory(WellKnownTags wellKnownTags) {
this.serviceName = wellKnownTags.getService().toString();
this.wellKnownTags = wellKnownTags;
}
Expand All @@ -239,12 +243,7 @@ public LLMObsSpan startLLMSpan(

DDLLMObsSpan span =
new DDLLMObsSpan(
Tags.LLMOBS_LLM_SPAN_KIND,
spanName,
getMLApp(mlApp),
sessionId,
serviceName,
wellKnownTags);
Tags.LLMOBS_LLM_SPAN_KIND, spanName, mlApp, sessionId, serviceName, wellKnownTags);

if (modelName == null || modelName.isEmpty()) {
modelName = CUSTOM_MODEL_VAL;
Expand Down Expand Up @@ -273,7 +272,7 @@ public LLMObsSpan startAgentSpan(
return new DDLLMObsSpan(
Tags.LLMOBS_AGENT_SPAN_KIND,
spanName,
getMLApp(mlApp),
mlApp,
sessionId,
serviceName,
wellKnownTags,
Expand All @@ -284,36 +283,21 @@ public LLMObsSpan startAgentSpan(
public LLMObsSpan startToolSpan(
String spanName, @Nullable String mlApp, @Nullable String sessionId) {
return new DDLLMObsSpan(
Tags.LLMOBS_TOOL_SPAN_KIND,
spanName,
getMLApp(mlApp),
sessionId,
serviceName,
wellKnownTags);
Tags.LLMOBS_TOOL_SPAN_KIND, spanName, mlApp, sessionId, serviceName, wellKnownTags);
}

@Override
public LLMObsSpan startTaskSpan(
String spanName, @Nullable String mlApp, @Nullable String sessionId) {
return new DDLLMObsSpan(
Tags.LLMOBS_TASK_SPAN_KIND,
spanName,
getMLApp(mlApp),
sessionId,
serviceName,
wellKnownTags);
Tags.LLMOBS_TASK_SPAN_KIND, spanName, mlApp, sessionId, serviceName, wellKnownTags);
}

@Override
public LLMObsSpan startWorkflowSpan(
String spanName, @Nullable String mlApp, @Nullable String sessionId) {
return new DDLLMObsSpan(
Tags.LLMOBS_WORKFLOW_SPAN_KIND,
spanName,
getMLApp(mlApp),
sessionId,
serviceName,
wellKnownTags);
Tags.LLMOBS_WORKFLOW_SPAN_KIND, spanName, mlApp, sessionId, serviceName, wellKnownTags);
}

@Override
Expand All @@ -330,7 +314,7 @@ public LLMObsSpan startEmbeddingSpan(
new DDLLMObsSpan(
Tags.LLMOBS_EMBEDDING_SPAN_KIND,
spanName,
getMLApp(mlApp),
mlApp,
sessionId,
serviceName,
wellKnownTags);
Expand All @@ -342,19 +326,7 @@ public LLMObsSpan startEmbeddingSpan(
public LLMObsSpan startRetrievalSpan(
String spanName, @Nullable String mlApp, @Nullable String sessionId) {
return new DDLLMObsSpan(
Tags.LLMOBS_RETRIEVAL_SPAN_KIND,
spanName,
getMLApp(mlApp),
sessionId,
serviceName,
wellKnownTags);
}

private String getMLApp(String mlApp) {
if (mlApp == null || mlApp.isEmpty()) {
return defaultMLApp;
}
return mlApp;
Tags.LLMOBS_RETRIEVAL_SPAN_KIND, spanName, mlApp, sessionId, serviceName, wellKnownTags);
}
}
}
Loading
Loading