diff --git a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/DDLLMObsPropagator.java b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/DDLLMObsPropagator.java
new file mode 100644
index 00000000000..ef9b4416d36
--- /dev/null
+++ b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/DDLLMObsPropagator.java
@@ -0,0 +1,102 @@
+package datadog.trace.llmobs;
+
+import datadog.context.Context;
+import datadog.context.ContextScope;
+import datadog.context.propagation.Propagators;
+import datadog.trace.api.llmobs.LLMObs;
+import datadog.trace.api.llmobs.LLMObsContext;
+import datadog.trace.api.llmobs.LLMObsSpan;
+import datadog.trace.bootstrap.instrumentation.api.AgentScope;
+import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
+import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext;
+import datadog.trace.bootstrap.instrumentation.api.AgentTracer;
+import datadog.trace.llmobs.domain.DDLLMObsSpan;
+import java.io.Closeable;
+import java.util.Map;
+import java.util.Objects;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Explicit, manual distributed tracing propagation for LLM Observability, for boundaries that
+ * automatic instrumentation doesn't cover — e.g. an SQS worker reading its own message attributes.
+ *
+ *
Boundaries that are auto-instrumented (HTTP, gRPC, ...) need none of this: {@link
+ * LLMObsContextPropagator} is registered as a propagation concern and stages the same {@code
+ * _dd.p.llmobs_*} tags on every injection. This class is the manual equivalent for carriers no
+ * instrumentation reaches, mirroring dd-trace-py's {@code inject_distributed_headers} / {@code
+ * activate_distributed_headers}.
+ *
+ *
The standard APM trace context (trace id, parent id, sampling, {@code x-datadog-tags}, ...) is
+ * injected/extracted via the normal {@link Propagators}. LLMObs-specific values (ml_app,
+ * session_id, agent attribution) are written onto the span's {@link AgentSpanContext} as dedicated
+ * propagation-tags fields before injection, so the same {@link Propagators} call serializes them as
+ * additional {@code _dd.p.llmobs_*} tags — via {@code x-datadog-tags} or {@code tracestate},
+ * whichever the configured propagation style carries — the wire container dd-trace-py/js/go already
+ * use for these tags, so a mixed-language pipeline can still join a trace across this hop.
+ */
+public class DDLLMObsPropagator implements LLMObs.LLMObsPropagator {
+ private static final Logger LOGGER = LoggerFactory.getLogger(DDLLMObsPropagator.class);
+
+ @Override
+ public Map injectDistributedHeaders(
+ LLMObsSpan span, Map headers) {
+ Objects.requireNonNull(span, "span");
+ Objects.requireNonNull(headers, "headers");
+ if (!(span instanceof DDLLMObsSpan)) {
+ LOGGER.debug(
+ "injectDistributedHeaders requires a span started by the LLM Observability SDK, got {}; ignoring",
+ span.getClass());
+ return headers;
+ }
+ DDLLMObsSpan llmObsSpan = (DDLLMObsSpan) span;
+ AgentSpan agentSpan = llmObsSpan.getAgentSpan();
+
+ // Stage this span's own LLMObs values rather than relying on the ambient context: the caller
+ // may hand us a span that isn't the innermost active one, and may not even be inside its scope.
+ AgentSpanContext spanContext = agentSpan.spanContext();
+ spanContext.updateLLMObsMlApp(llmObsSpan.getMlApp());
+ spanContext.updateLLMObsSessionId(llmObsSpan.getSessionId());
+ spanContext.updateLLMObsParentAgentSpanId(llmObsSpan.getParentAgentSpanId());
+ spanContext.updateLLMObsParentAgentName(llmObsSpan.getParentAgentName());
+
+ Propagators.defaultPropagator().inject(agentSpan, headers, Map::put);
+ return headers;
+ }
+
+ @Override
+ public Closeable activateDistributedHeaders(Map headers) {
+ Objects.requireNonNull(headers, "headers");
+
+ Context extracted =
+ Propagators.defaultPropagator()
+ .extract(Context.root(), headers, (carrier, visitor) -> carrier.forEach(visitor));
+ AgentSpan extractedSpan = AgentSpan.fromContext(extracted);
+ if (extractedSpan == null) {
+ LOGGER.debug(
+ "no distributed trace context found in headers; activateDistributedHeaders is a no-op");
+ return () -> {};
+ }
+
+ AgentSpanContext extractedContext = extractedSpan.spanContext();
+ CharSequence mlApp = extractedContext.getLLMObsMlApp();
+ CharSequence sessionId = extractedContext.getLLMObsSessionId();
+ CharSequence pagentSpanId = extractedContext.getLLMObsParentAgentSpanId();
+ CharSequence pagentName = extractedContext.getLLMObsParentAgentName();
+
+ AgentScope apmScope = AgentTracer.get().activateSpan(extractedSpan);
+ ContextScope llmObsScope =
+ LLMObsContext.attach(
+ extractedContext,
+ mlApp == null ? null : mlApp.toString(),
+ sessionId == null ? null : sessionId.toString(),
+ null,
+ pagentSpanId == null ? null : pagentSpanId.toString(),
+ pagentName == null ? null : pagentName.toString());
+
+ return () -> {
+ llmObsScope.close();
+ apmScope.close();
+ };
+ }
+}
diff --git a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/LLMObsContextPropagator.java b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/LLMObsContextPropagator.java
new file mode 100644
index 00000000000..92d6f3e0e26
--- /dev/null
+++ b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/LLMObsContextPropagator.java
@@ -0,0 +1,62 @@
+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 — HTTP, gRPC, SQS, Kafka, ... — carries
+ * LLMObs context without the application having to propagate it by hand.
+ *
+ * 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. This mirrors
+ * dd-trace-py, where LLMObs subscribes to the generic {@code http.span_inject} hook that {@code
+ * HTTPPropagator.inject} fires on every outbound request, rather than owning a separate wire
+ * format.
+ *
+ *
Values are resolved from the ambient {@link LLMObsContext} at injection time rather than being
+ * written once when a span starts. That way the innermost active LLMObs span always wins, and
+ * leaving an LLMObs scope stops contributing its tags without any save/restore bookkeeping.
+ */
+public class LLMObsContextPropagator implements Propagator {
+
+ @Override
+ public void inject(Context context, C carrier, CarrierSetter 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() != spanContext.getTraceId()) {
+ return;
+ }
+
+ spanContext.updateLLMObsMlApp(LLMObsContext.currentMlApp());
+ spanContext.updateLLMObsSessionId(LLMObsContext.currentSessionId());
+ spanContext.updateLLMObsParentAgentSpanId(LLMObsContext.currentParentAgentSpanId());
+ spanContext.updateLLMObsParentAgentName(LLMObsContext.currentParentAgentName());
+ }
+
+ @Override
+ public Context extract(Context context, C carrier, CarrierVisitor 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;
+ }
+}
diff --git a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/LLMObsSystem.java b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/LLMObsSystem.java
index 864cf27eb2c..e9ea2a06723 100644
--- a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/LLMObsSystem.java
+++ b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/LLMObsSystem.java
@@ -1,6 +1,7 @@
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;
@@ -8,6 +9,7 @@
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;
@@ -51,6 +53,13 @@ public static void start(Instrumentation inst, SharedCommunicationObjects sco) {
LLMObsInternal.setEvalProcessor(new LLMObsCustomEvalProcessor(mlApp, sco, config));
LLMObsInternal.setFeedbackProcessor(new LLMObsCustomFeedbackProcessor(mlApp, sco, config));
+
+ LLMObsInternal.setPropagator(new DDLLMObsPropagator());
+
+ // Automatic propagation: every boundary that injects trace context now carries the LLMObs
+ // propagation tags too, matching dd-trace-py. DDLLMObsPropagator stays as the manual entry
+ // point for carriers no instrumentation covers (e.g. SQS message attributes).
+ Propagators.register(AgentPropagation.LLMOBS_CONCERN, new LLMObsContextPropagator());
}
private static class LLMObsCustomFeedbackProcessor implements LLMObs.LLMObsFeedbackProcessor {
diff --git a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/DDLLMObsSpan.java b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/DDLLMObsSpan.java
index 42c6790a0c2..b0f1de9577b 100644
--- a/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/DDLLMObsSpan.java
+++ b/dd-java-agent/agent-llmobs/src/main/java/datadog/trace/llmobs/domain/DDLLMObsSpan.java
@@ -68,6 +68,9 @@ public class DDLLMObsSpan implements LLMObsSpan {
private final String spanKind;
private final String mlApp;
private final boolean hasSessionId;
+ private final String sessionId;
+ private final String parentAgentSpanId;
+ private final String parentAgentName;
private final ContextScope scope;
// Non-null only for agent-kind spans started without an ambient APM root. Activating the
// agent's APM span keeps children in the same APM trace so the trace-ID gate passes and
@@ -159,7 +162,18 @@ public DDLLMObsSpan(
}
}
+ // No in-process LLMObs parent: this span may still be continuing a trace that arrived from
+ // another service, in which case the upstream session_id is on the span context's propagation
+ // tags (parsed back out of x-datadog-tags / tracestate by the tracing propagator).
+ if (null == parent && (sessionId == null || sessionId.isEmpty())) {
+ CharSequence propagated = span.spanContext().getLLMObsSessionId();
+ if (propagated != null && propagated.length() > 0) {
+ sessionId = propagated.toString();
+ }
+ }
+
this.hasSessionId = sessionId != null && !sessionId.isEmpty();
+ this.sessionId = this.hasSessionId ? sessionId : null;
if (this.hasSessionId) {
span.setTag(LLMOBS_TAG_PREFIX + LLMObsTags.SESSION_ID, sessionId);
}
@@ -187,6 +201,14 @@ public DDLLMObsSpan(
if (null != parent && parent.getTraceId() == span.getTraceId()) {
resolvedParentAgentSpanId = LLMObsContext.currentParentAgentSpanId();
resolvedParentAgentName = LLMObsContext.currentParentAgentName();
+ } else if (null == parent) {
+ // Continuing a distributed trace: attribute to the upstream agent carried on the wire.
+ CharSequence propagatedId = span.spanContext().getLLMObsParentAgentSpanId();
+ if (propagatedId != null && propagatedId.length() > 0) {
+ resolvedParentAgentSpanId = propagatedId.toString();
+ CharSequence propagatedName = span.spanContext().getLLMObsParentAgentName();
+ resolvedParentAgentName = propagatedName == null ? null : propagatedName.toString();
+ }
}
}
@@ -197,11 +219,14 @@ public DDLLMObsSpan(
span.setTag(PAGENT_NAME_TAG_INTERNAL, resolvedParentAgentName);
}
}
+ this.parentAgentSpanId = resolvedParentAgentSpanId;
+ this.parentAgentName = resolvedParentAgentName;
// Propagate the effective sessionId and agent attribution to descendant LLMObs spans.
scope =
LLMObsContext.attach(
span.spanContext(),
+ mlApp,
sessionId,
resolvedAgentVersion,
resolvedParentAgentSpanId,
@@ -681,4 +706,35 @@ public DDTraceId getTraceId() {
public long getSpanId() {
return span.getSpanId();
}
+
+ /** Internal accessor for the underlying APM span, used by {@code DDLLMObsPropagator}. */
+ public AgentSpan getAgentSpan() {
+ return span;
+ }
+
+ /** Internal accessor for this span's effective ml_app, used by {@code DDLLMObsPropagator}. */
+ public String getMlApp() {
+ return mlApp;
+ }
+
+ /**
+ * Internal accessor for this span's effective session_id (including one inherited from an
+ * enclosing LLMObs span), used by {@code DDLLMObsPropagator}. May be {@code null}.
+ */
+ public String getSessionId() {
+ return sessionId;
+ }
+
+ /**
+ * Internal accessor for this span's effective agent attribution, used by {@code
+ * DDLLMObsPropagator}. May be {@code null}.
+ */
+ public String getParentAgentSpanId() {
+ return parentAgentSpanId;
+ }
+
+ /** See {@link #getParentAgentSpanId()}. May be {@code null}. */
+ public String getParentAgentName() {
+ return parentAgentName;
+ }
}
diff --git a/dd-java-agent/agent-llmobs/src/test/groovy/datadog/trace/llmobs/domain/DDLLMObsSpanTest.groovy b/dd-java-agent/agent-llmobs/src/test/groovy/datadog/trace/llmobs/domain/DDLLMObsSpanTest.groovy
index 2f45f9268db..4dd70688efb 100644
--- a/dd-java-agent/agent-llmobs/src/test/groovy/datadog/trace/llmobs/domain/DDLLMObsSpanTest.groovy
+++ b/dd-java-agent/agent-llmobs/src/test/groovy/datadog/trace/llmobs/domain/DDLLMObsSpanTest.groovy
@@ -796,6 +796,9 @@ class DDLLMObsSpanTest extends DDSpecification{
def innerSpan = (AgentSpan) test.span
innerSpan.getTag(LLMOBS_TAG_PREFIX + "team") == "backend"
innerSpan.getTag(LLMOBS_TAG_PREFIX + "owner") == "ml-platform"
+
+ cleanup:
+ test.finish()
}
def "agent manifest full annotation sets correct tag"() {
diff --git a/dd-java-agent/agent-llmobs/src/test/java/datadog/trace/llmobs/DDLLMObsPropagatorTest.java b/dd-java-agent/agent-llmobs/src/test/java/datadog/trace/llmobs/DDLLMObsPropagatorTest.java
new file mode 100644
index 00000000000..67c63beac9c
--- /dev/null
+++ b/dd-java-agent/agent-llmobs/src/test/java/datadog/trace/llmobs/DDLLMObsPropagatorTest.java
@@ -0,0 +1,175 @@
+package datadog.trace.llmobs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import datadog.context.propagation.Propagators;
+import datadog.trace.agent.tooling.TracerInstaller;
+import datadog.trace.api.WellKnownTags;
+import datadog.trace.api.llmobs.LLMObsContext;
+import datadog.trace.bootstrap.instrumentation.api.AgentPropagation;
+import datadog.trace.bootstrap.instrumentation.api.AgentScope;
+import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
+import datadog.trace.bootstrap.instrumentation.api.AgentTracer;
+import datadog.trace.bootstrap.instrumentation.api.Tags;
+import datadog.trace.core.CoreTracer;
+import datadog.trace.llmobs.domain.DDLLMObsSpan;
+import java.io.Closeable;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Round-trips {@link DDLLMObsPropagator} through a plain {@code Map} carrier — the
+ * shape a customer's own SQS message-attribute map would take.
+ */
+class DDLLMObsPropagatorTest {
+
+ private static CoreTracer tracer;
+ private final DDLLMObsPropagator propagator = new DDLLMObsPropagator();
+
+ @BeforeAll
+ static void installTracer() {
+ tracer = CoreTracer.builder().build();
+ TracerInstaller.forceInstallGlobalTracer(tracer);
+ }
+
+ @AfterAll
+ static void closeTracer() {
+ TracerInstaller.forceInstallGlobalTracer(null);
+ tracer.close();
+ }
+
+ private static DDLLMObsSpan newAgentSpan(String name, String mlApp, String sessionId) {
+ return newSpan(Tags.LLMOBS_AGENT_SPAN_KIND, name, mlApp, sessionId);
+ }
+
+ private static DDLLMObsSpan newToolSpan(String name, String mlApp, String sessionId) {
+ return newSpan(Tags.LLMOBS_TOOL_SPAN_KIND, name, mlApp, sessionId);
+ }
+
+ private static DDLLMObsSpan newSpan(String kind, String name, String mlApp, String sessionId) {
+ WellKnownTags tags =
+ new WellKnownTags("runtime-id", "hostname", "test", "service", "version", "java");
+ return new DDLLMObsSpan(kind, name, mlApp, sessionId, "service", tags);
+ }
+
+ private static AgentScope startRootApmScope() {
+ AgentSpan root = AgentTracer.get().buildSpan("apm", "sqs.produce").start();
+ return AgentTracer.activateSpan(root);
+ }
+
+ @Test
+ void injectRequiresNonNullSpanAndHeaders() {
+ assertThrows(
+ NullPointerException.class,
+ () -> propagator.injectDistributedHeaders(null, new HashMap<>()));
+ }
+
+ @Test
+ void activateRequiresNonNullHeaders() {
+ assertThrows(NullPointerException.class, () -> propagator.activateDistributedHeaders(null));
+ }
+
+ @Test
+ void activateWithoutTraceContextIsNoOp() throws Exception {
+ // Compare against whatever LLMObsContext happened to be ambient going in, rather than
+ // asserting a global null baseline — this test runs alongside many others in the same JVM
+ // and must not assume it is the only thing that has ever touched ambient context.
+ Object ambientBefore = LLMObsContext.current();
+ try (Closeable scope = propagator.activateDistributedHeaders(new HashMap<>())) {
+ assertEquals(ambientBefore, LLMObsContext.current());
+ }
+ }
+
+ @Test
+ void injectThenActivateJoinsSameTraceAndPropagatesLlmObsContext() throws Exception {
+ Map headers = new HashMap<>();
+
+ try (AgentScope apmScope = startRootApmScope()) {
+ DDLLMObsSpan producerAgent = newAgentSpan("producer-agent", "my-ml-app", "session-123");
+ long producerTraceId = producerAgent.getTraceId().toLong();
+ try {
+ propagator.injectDistributedHeaders(producerAgent, headers);
+ } finally {
+ producerAgent.finish();
+ }
+
+ // Simulate the consumer side: a different message handled with no ambient context.
+ try (Closeable consumerScope = propagator.activateDistributedHeaders(headers)) {
+ DDLLMObsSpan consumerTool = newToolSpan("consumer-tool", "my-ml-app", null);
+ try {
+ assertEquals(producerTraceId, consumerTool.getTraceId().toLong());
+ assertEquals("session-123", consumerTool.getSessionId());
+ } finally {
+ consumerTool.finish();
+ }
+ }
+ }
+ }
+
+ /**
+ * The automatic path: no LLMObs propagation API is called at all. Injecting the active span the
+ * way any auto-instrumented HTTP/gRPC client does must still carry the LLMObs context, matching
+ * dd-trace-py's {@code http.span_inject} hook.
+ */
+ @Test
+ void autoInstrumentedInjectCarriesLlmObsContextWithoutManualPropagation() {
+ Propagators.register(AgentPropagation.LLMOBS_CONCERN, new LLMObsContextPropagator());
+ Map headers = new HashMap<>();
+
+ try (AgentScope apmScope = startRootApmScope()) {
+ DDLLMObsSpan agent = newAgentSpan("producer-agent", "my-ml-app", "session-123");
+ try {
+ Propagators.defaultPropagator().inject(agent.getAgentSpan(), headers, Map::put);
+ } finally {
+ agent.finish();
+ }
+ }
+
+ String xDatadogTags = headers.get("x-datadog-tags");
+ assertNotNull(xDatadogTags, "expected x-datadog-tags to be injected");
+ assertTrue(
+ xDatadogTags.contains("_dd.p.llmobs_ml_app=my-ml-app"),
+ () -> "ml_app missing from " + xDatadogTags);
+ assertTrue(
+ xDatadogTags.contains("_dd.p.llmobs_sid=session-123"),
+ () -> "session_id missing from " + xDatadogTags);
+ }
+
+ /** An outbound call made with no LLMObs span active must not pick up LLMObs tags. */
+ @Test
+ void autoInstrumentedInjectAddsNothingWithoutAnActiveLlmObsSpan() {
+ Propagators.register(AgentPropagation.LLMOBS_CONCERN, new LLMObsContextPropagator());
+ Map headers = new HashMap<>();
+
+ try (AgentScope apmScope = startRootApmScope()) {
+ Propagators.defaultPropagator().inject(apmScope.span(), headers, Map::put);
+ }
+
+ String xDatadogTags = headers.get("x-datadog-tags");
+ assertTrue(
+ xDatadogTags == null || !xDatadogTags.contains("_dd.p.llmobs_"),
+ () -> "unexpected LLMObs tags in " + xDatadogTags);
+ }
+
+ @Test
+ void injectAlwaysIncludesMlAppEvenWithoutSessionIdOrAttribution() {
+ Map headers = new HashMap<>();
+ try (AgentScope apmScope = startRootApmScope()) {
+ DDLLMObsSpan standaloneTool = newToolSpan("standalone-tool", "my-ml-app", null);
+ try {
+ propagator.injectDistributedHeaders(standaloneTool, headers);
+ String xDatadogTags = headers.get("x-datadog-tags");
+ // ml_app is always present since it's required on every LLMObs span.
+ assertTrue(xDatadogTags != null && xDatadogTags.contains("_dd.p.llmobs_ml_app=my-ml-app"));
+ } finally {
+ standaloneTool.finish();
+ }
+ }
+ }
+}
diff --git a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObs.java b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObs.java
index 2f9cfbcb619..fa8fa9429b1 100644
--- a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObs.java
+++ b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObs.java
@@ -2,7 +2,9 @@
import datadog.trace.api.llmobs.noop.NoOpLLMObsEvalProcessor;
import datadog.trace.api.llmobs.noop.NoOpLLMObsFeedbackProcessor;
+import datadog.trace.api.llmobs.noop.NoOpLLMObsPropagator;
import datadog.trace.api.llmobs.noop.NoOpLLMObsSpanFactory;
+import java.io.Closeable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -23,6 +25,7 @@ protected LLMObs() {}
@Nullable protected static volatile LLMObsSpanProcessor SPAN_PROCESSOR;
protected static LLMObsFeedbackProcessor FEEDBACK_PROCESSOR =
NoOpLLMObsFeedbackProcessor.INSTANCE;
+ protected static LLMObsPropagator PROPAGATOR = NoOpLLMObsPropagator.INSTANCE;
public static LLMObsSpan startLLMSpan(
String spanName,
@@ -182,6 +185,44 @@ public static void submitFeedback(Feedback feedback) {
FEEDBACK_PROCESSOR.submitFeedback(feedback);
}
+ /**
+ * Injects {@code span}'s distributed tracing context into {@code headers}, for manual propagation
+ * across a boundary automatic instrumentation doesn't cover — e.g. a queue/worker hop such as
+ * SQS, where a customer reads and writes message attributes themselves.
+ *
+ * This populates the standard APM propagation headers (trace id, parent id, sampling, etc.) as
+ * well as LLMObs-specific propagating tags (ml_app, session_id, agent attribution) so a
+ * downstream call to {@link #activateDistributedHeaders} can resume the same LLMObs trace.
+ *
+ *
{@code headers} is mutated in place and returned for convenience.
+ *
+ * @param span the span whose context to inject; must be a span started by this SDK
+ * @param headers the carrier to inject propagation headers into
+ * @throws NullPointerException if {@code span} or {@code headers} is {@code null}
+ */
+ public static Map injectDistributedHeaders(
+ LLMObsSpan span, Map headers) {
+ return PROPAGATOR.injectDistributedHeaders(span, headers);
+ }
+
+ /**
+ * Activates a distributed tracing context previously injected by {@link
+ * #injectDistributedHeaders}, so that LLMObs spans started while the returned {@link Closeable}
+ * is open join the originating trace (same trace id, correct parent, inherited session_id and
+ * agent attribution).
+ *
+ * Callers must close the returned {@link Closeable} once done processing under this context —
+ * e.g. per message in an SQS consumer loop, so unrelated messages don't share a trace.
+ *
+ * @param headers the carrier to extract propagation headers from
+ * @return a {@link Closeable} that deactivates the context when closed; never {@code null}, a
+ * no-op if {@code headers} carries no recognizable distributed tracing context
+ * @throws NullPointerException if {@code headers} is {@code null}
+ */
+ public static Closeable activateDistributedHeaders(Map headers) {
+ return PROPAGATOR.activateDistributedHeaders(headers);
+ }
+
public interface LLMObsSpanFactory {
LLMObsSpan startLLMSpan(
String spanName,
@@ -244,6 +285,12 @@ public interface LLMObsFeedbackProcessor {
void submitFeedback(Feedback feedback);
}
+ public interface LLMObsPropagator {
+ Map injectDistributedHeaders(LLMObsSpan span, Map headers);
+
+ Closeable activateDistributedHeaders(Map headers);
+ }
+
/**
* End-user feedback on a span, trace, session or customer-defined join key.
*
diff --git a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObsTags.java b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObsTags.java
index 0a7931589b5..f56f5c38104 100644
--- a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObsTags.java
+++ b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/LLMObsTags.java
@@ -19,4 +19,13 @@ public class LLMObsTags {
// Agent attribution
public static final String PAGENT_SPAN_ID = "pagent_span_id";
public static final String PAGENT_NAME = "pagent_name";
+
+ // Distributed tracing propagation tags. These ride alongside the standard APM `x-datadog-tags`
+ // propagating tags so a mixed-language pipeline can still join an LLMObs trace across a
+ // process boundary that isn't covered by automatic instrumentation (e.g. an SQS worker).
+ // Naming matches the `_dd.p.llmobs_*` convention used by dd-trace-py/js/go.
+ public static final String PROPAGATED_ML_APP = "_dd.p.llmobs_ml_app";
+ public static final String PROPAGATED_SESSION_ID = "_dd.p.llmobs_sid";
+ public static final String PROPAGATED_PAGENT_SPAN_ID = "_dd.p.llmobs_pagent_span_id";
+ public static final String PROPAGATED_PAGENT_NAME = "_dd.p.llmobs_pagent_name";
}
diff --git a/dd-trace-api/src/main/java/datadog/trace/api/llmobs/noop/NoOpLLMObsPropagator.java b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/noop/NoOpLLMObsPropagator.java
new file mode 100644
index 00000000000..4025791b4ec
--- /dev/null
+++ b/dd-trace-api/src/main/java/datadog/trace/api/llmobs/noop/NoOpLLMObsPropagator.java
@@ -0,0 +1,21 @@
+package datadog.trace.api.llmobs.noop;
+
+import datadog.trace.api.llmobs.LLMObs;
+import datadog.trace.api.llmobs.LLMObsSpan;
+import java.io.Closeable;
+import java.util.Map;
+
+public class NoOpLLMObsPropagator implements LLMObs.LLMObsPropagator {
+ public static final NoOpLLMObsPropagator INSTANCE = new NoOpLLMObsPropagator();
+
+ @Override
+ public Map injectDistributedHeaders(
+ LLMObsSpan span, Map headers) {
+ return headers;
+ }
+
+ @Override
+ public Closeable activateDistributedHeaders(Map headers) {
+ return () -> {};
+ }
+}
diff --git a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java
index adf4cd66156..73b3e2b24e1 100644
--- a/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/DDSpanContext.java
@@ -1492,6 +1492,46 @@ public PropagationTags getPropagationTags() {
return getRootSpanContextOrThis().propagationTags;
}
+ @Override
+ public CharSequence getLLMObsMlApp() {
+ return getPropagationTags().getLLMObsMlApp();
+ }
+
+ @Override
+ public void updateLLMObsMlApp(CharSequence mlApp) {
+ getPropagationTags().updateLLMObsMlApp(mlApp);
+ }
+
+ @Override
+ public CharSequence getLLMObsSessionId() {
+ return getPropagationTags().getLLMObsSessionId();
+ }
+
+ @Override
+ public void updateLLMObsSessionId(CharSequence sessionId) {
+ getPropagationTags().updateLLMObsSessionId(sessionId);
+ }
+
+ @Override
+ public CharSequence getLLMObsParentAgentSpanId() {
+ return getPropagationTags().getLLMObsParentAgentSpanId();
+ }
+
+ @Override
+ public void updateLLMObsParentAgentSpanId(CharSequence parentAgentSpanId) {
+ getPropagationTags().updateLLMObsParentAgentSpanId(parentAgentSpanId);
+ }
+
+ @Override
+ public CharSequence getLLMObsParentAgentName() {
+ return getPropagationTags().getLLMObsParentAgentName();
+ }
+
+ @Override
+ public void updateLLMObsParentAgentName(CharSequence parentAgentName) {
+ getPropagationTags().updateLLMObsParentAgentName(parentAgentName);
+ }
+
/** TraceSegment Implementation */
@Override
public void setTagTop(String key, Object value, boolean sanitize) {
diff --git a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ExtractedContext.java b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ExtractedContext.java
index af503e6a6ed..52a40a94e4c 100644
--- a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ExtractedContext.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ExtractedContext.java
@@ -117,6 +117,26 @@ public PropagationTags getPropagationTags() {
return propagationTags;
}
+ @Override
+ public CharSequence getLLMObsMlApp() {
+ return propagationTags.getLLMObsMlApp();
+ }
+
+ @Override
+ public CharSequence getLLMObsSessionId() {
+ return propagationTags.getLLMObsSessionId();
+ }
+
+ @Override
+ public CharSequence getLLMObsParentAgentSpanId() {
+ return propagationTags.getLLMObsParentAgentSpanId();
+ }
+
+ @Override
+ public CharSequence getLLMObsParentAgentName() {
+ return propagationTags.getLLMObsParentAgentName();
+ }
+
@Override
public String toString() {
StringBuilder builder = new StringBuilder("ExtractedContext{");
diff --git a/dd-trace-core/src/main/java/datadog/trace/core/propagation/PropagationTags.java b/dd-trace-core/src/main/java/datadog/trace/core/propagation/PropagationTags.java
index 3a0c57a4dd8..47161ef1276 100644
--- a/dd-trace-core/src/main/java/datadog/trace/core/propagation/PropagationTags.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/propagation/PropagationTags.java
@@ -169,6 +169,42 @@ public interface Factory {
*/
public abstract void updateOrgPropagationMarker(CharSequence opm);
+ /**
+ * Returns the LLM Observability {@code ml_app} currently propagated with this trace, encoded as
+ * {@code _dd.p.llmobs_ml_app}. Returns {@code null} if none is set.
+ */
+ public abstract CharSequence getLLMObsMlApp();
+
+ /** Sets the LLM Observability {@code ml_app} to propagate with this trace. */
+ public abstract void updateLLMObsMlApp(CharSequence mlApp);
+
+ /**
+ * Returns the LLM Observability {@code session_id} currently propagated with this trace, encoded
+ * as {@code _dd.p.llmobs_sid}. Returns {@code null} if none is set.
+ */
+ public abstract CharSequence getLLMObsSessionId();
+
+ /** Sets the LLM Observability {@code session_id} to propagate with this trace. */
+ public abstract void updateLLMObsSessionId(CharSequence sessionId);
+
+ /**
+ * Returns the span id of the parent LLM Observability agent span currently propagated with this
+ * trace, encoded as {@code _dd.p.llmobs_pagent_span_id}. Returns {@code null} if none is set.
+ */
+ public abstract CharSequence getLLMObsParentAgentSpanId();
+
+ /** Sets the parent LLM Observability agent span id to propagate with this trace. */
+ public abstract void updateLLMObsParentAgentSpanId(CharSequence parentAgentSpanId);
+
+ /**
+ * Returns the name of the parent LLM Observability agent span currently propagated with this
+ * trace, encoded as {@code _dd.p.llmobs_pagent_name}. Returns {@code null} if none is set.
+ */
+ public abstract CharSequence getLLMObsParentAgentName();
+
+ /** Sets the parent LLM Observability agent span name to propagate with this trace. */
+ public abstract void updateLLMObsParentAgentName(CharSequence parentAgentName);
+
public HashMap createTagMap() {
HashMap result = new HashMap<>();
fillTagMap(result);
diff --git a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/DatadogPTagsCodec.java b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/DatadogPTagsCodec.java
index 3ac0c7ad712..907fab25e36 100644
--- a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/DatadogPTagsCodec.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/DatadogPTagsCodec.java
@@ -64,6 +64,10 @@ PropagationTags fromHeaderValue(PTagsFactory tagsFactory, String value) {
TagValue traceIdTagValue = null;
int traceSource = 0;
TagValue orgPropagationMarkerTagValue = null;
+ TagValue llmObsMlAppTagValue = null;
+ TagValue llmObsSessionIdTagValue = null;
+ TagValue llmObsParentAgentSpanIdTagValue = null;
+ TagValue llmObsParentAgentNameTagValue = null;
while (tagPos < len) {
int tagKeyEndsAt =
validateCharsUntilSeparatorOrEnd(
@@ -102,6 +106,14 @@ PropagationTags fromHeaderValue(PTagsFactory tagsFactory, String value) {
traceSource = ProductTraceSource.parseBitfieldHex(tagValue.toString());
} else if (tagKey.equals(ORG_PROPAGATION_MARKER_TAG)) {
orgPropagationMarkerTagValue = tagValue;
+ } else if (tagKey.equals(LLMOBS_ML_APP_TAG)) {
+ llmObsMlAppTagValue = tagValue;
+ } else if (tagKey.equals(LLMOBS_SESSION_ID_TAG)) {
+ llmObsSessionIdTagValue = tagValue;
+ } else if (tagKey.equals(LLMOBS_PAGENT_SPAN_ID_TAG)) {
+ llmObsParentAgentSpanIdTagValue = tagValue;
+ } else if (tagKey.equals(LLMOBS_PAGENT_NAME_TAG)) {
+ llmObsParentAgentNameTagValue = tagValue;
} else {
if (tagPairs == null) {
// This is roughly the size of a two element linked list but can hold six
@@ -119,7 +131,12 @@ PropagationTags fromHeaderValue(PTagsFactory tagsFactory, String value) {
decisionMakerTagValue,
traceIdTagValue,
traceSource,
- orgPropagationMarkerTagValue);
+ orgPropagationMarkerTagValue,
+ new LLMObsTagValues(
+ llmObsMlAppTagValue,
+ llmObsSessionIdTagValue,
+ llmObsParentAgentSpanIdTagValue,
+ llmObsParentAgentNameTagValue));
}
@Override
diff --git a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/LLMObsTagValues.java b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/LLMObsTagValues.java
new file mode 100644
index 00000000000..7d34fdab011
--- /dev/null
+++ b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/LLMObsTagValues.java
@@ -0,0 +1,23 @@
+package datadog.trace.core.propagation.ptags;
+
+/**
+ * Bundles the four LLM Observability propagation tag values ({@code ml_app}, {@code session_id},
+ * parent agent span id, parent agent name) extracted from an incoming header, so they can be
+ * threaded through {@link PTagsFactory.PTags} construction as a single parameter.
+ */
+final class LLMObsTagValues {
+ static final LLMObsTagValues EMPTY = new LLMObsTagValues(null, null, null, null);
+
+ final TagValue mlApp;
+ final TagValue sessionId;
+ final TagValue parentAgentSpanId;
+ final TagValue parentAgentName;
+
+ LLMObsTagValues(
+ TagValue mlApp, TagValue sessionId, TagValue parentAgentSpanId, TagValue parentAgentName) {
+ this.mlApp = mlApp;
+ this.sessionId = sessionId;
+ this.parentAgentSpanId = parentAgentSpanId;
+ this.parentAgentName = parentAgentName;
+ }
+}
diff --git a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsCodec.java b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsCodec.java
index e2c0658a1d2..99875e7f0a3 100644
--- a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsCodec.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsCodec.java
@@ -23,6 +23,10 @@ abstract class PTagsCodec {
protected static final String PROPAGATION_ERROR_MALFORMED_TID = "malformed_tid ";
protected static final String PROPAGATION_ERROR_INCONSISTENT_TID = "inconsistent_tid ";
protected static final TagKey UPSTREAM_SERVICES_DEPRECATED_TAG = TagKey.from("upstream_services");
+ protected static final TagKey LLMOBS_ML_APP_TAG = TagKey.from("llmobs_ml_app");
+ protected static final TagKey LLMOBS_SESSION_ID_TAG = TagKey.from("llmobs_sid");
+ protected static final TagKey LLMOBS_PAGENT_SPAN_ID_TAG = TagKey.from("llmobs_pagent_span_id");
+ protected static final TagKey LLMOBS_PAGENT_NAME_TAG = TagKey.from("llmobs_pagent_name");
static String headerValue(PTagsCodec codec, PTags ptags) {
return headerValue(codec, ptags, null);
@@ -65,6 +69,22 @@ static String headerValue(PTagsCodec codec, PTags ptags, CharSequence lastParent
codec.appendTag(
sb, ORG_PROPAGATION_MARKER_TAG, ptags.getOrgPropagationMarkerTagValue(), size);
}
+ if (ptags.getLLMObsMlAppTagValue() != null) {
+ size = codec.appendTag(sb, LLMOBS_ML_APP_TAG, ptags.getLLMObsMlAppTagValue(), size);
+ }
+ if (ptags.getLLMObsSessionIdTagValue() != null) {
+ size = codec.appendTag(sb, LLMOBS_SESSION_ID_TAG, ptags.getLLMObsSessionIdTagValue(), size);
+ }
+ if (ptags.getLLMObsParentAgentSpanIdTagValue() != null) {
+ size =
+ codec.appendTag(
+ sb, LLMOBS_PAGENT_SPAN_ID_TAG, ptags.getLLMObsParentAgentSpanIdTagValue(), size);
+ }
+ if (ptags.getLLMObsParentAgentNameTagValue() != null) {
+ size =
+ codec.appendTag(
+ sb, LLMOBS_PAGENT_NAME_TAG, ptags.getLLMObsParentAgentNameTagValue(), size);
+ }
Iterator it = ptags.getTagPairs().iterator();
while (it.hasNext() && !codec.isTooLarge(sb, size)) {
TagElement tagKey = it.next();
@@ -137,6 +157,29 @@ static void fillTagMap(PTags propagationTags, Map tagMap) {
.forType(Encoding.DATADOG)
.toString());
}
+ if (propagationTags.getLLMObsMlAppTagValue() != null) {
+ tagMap.put(
+ LLMOBS_ML_APP_TAG.forType(Encoding.DATADOG).toString(),
+ propagationTags.getLLMObsMlAppTagValue().forType(Encoding.DATADOG).toString());
+ }
+ if (propagationTags.getLLMObsSessionIdTagValue() != null) {
+ tagMap.put(
+ LLMOBS_SESSION_ID_TAG.forType(Encoding.DATADOG).toString(),
+ propagationTags.getLLMObsSessionIdTagValue().forType(Encoding.DATADOG).toString());
+ }
+ if (propagationTags.getLLMObsParentAgentSpanIdTagValue() != null) {
+ tagMap.put(
+ LLMOBS_PAGENT_SPAN_ID_TAG.forType(Encoding.DATADOG).toString(),
+ propagationTags
+ .getLLMObsParentAgentSpanIdTagValue()
+ .forType(Encoding.DATADOG)
+ .toString());
+ }
+ if (propagationTags.getLLMObsParentAgentNameTagValue() != null) {
+ tagMap.put(
+ LLMOBS_PAGENT_NAME_TAG.forType(Encoding.DATADOG).toString(),
+ propagationTags.getLLMObsParentAgentNameTagValue().forType(Encoding.DATADOG).toString());
+ }
if (propagationTags.getError() != null) {
tagMap.put(PROPAGATION_ERROR_TAG_KEY, propagationTags.getError());
}
diff --git a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsFactory.java b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsFactory.java
index 0b5184d448a..a93fccc3bd0 100644
--- a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsFactory.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsFactory.java
@@ -4,6 +4,10 @@
import static datadog.trace.core.propagation.PropagationTags.HeaderType.W3C;
import static datadog.trace.core.propagation.ptags.PTagsCodec.DECISION_MAKER_TAG;
import static datadog.trace.core.propagation.ptags.PTagsCodec.KNUTH_SAMPLING_RATE_TAG;
+import static datadog.trace.core.propagation.ptags.PTagsCodec.LLMOBS_ML_APP_TAG;
+import static datadog.trace.core.propagation.ptags.PTagsCodec.LLMOBS_PAGENT_NAME_TAG;
+import static datadog.trace.core.propagation.ptags.PTagsCodec.LLMOBS_PAGENT_SPAN_ID_TAG;
+import static datadog.trace.core.propagation.ptags.PTagsCodec.LLMOBS_SESSION_ID_TAG;
import static datadog.trace.core.propagation.ptags.PTagsCodec.ORG_PROPAGATION_MARKER_TAG;
import static datadog.trace.core.propagation.ptags.PTagsCodec.TRACE_ID_TAG;
import static datadog.trace.core.propagation.ptags.PTagsCodec.TRACE_SOURCE_TAG;
@@ -50,7 +54,7 @@ PTagsCodec getDecoderEncoder(@Nonnull HeaderType headerType) {
@Override
public final PropagationTags empty() {
- return createValid(null, null, null, ProductTraceSource.UNSET, null);
+ return createValid(null, null, null, ProductTraceSource.UNSET, null, null);
}
@Override
@@ -71,14 +75,16 @@ PropagationTags createValid(
TagValue decisionMakerTagValue,
TagValue traceIdTagValue,
int productTraceSource,
- TagValue orgPropagationMarkerTagValue) {
+ TagValue orgPropagationMarkerTagValue,
+ LLMObsTagValues llmObsTagValues) {
return new PTags(
this,
tagPairs,
decisionMakerTagValue,
traceIdTagValue,
productTraceSource,
- orgPropagationMarkerTagValue);
+ orgPropagationMarkerTagValue,
+ llmObsTagValues);
}
PropagationTags createInvalid(String error) {
@@ -112,6 +118,11 @@ static class PTags extends PropagationTags {
private volatile TagValue orgPropagationMarkerTagValue;
+ private volatile TagValue llmObsMlAppTagValue;
+ private volatile TagValue llmObsSessionIdTagValue;
+ private volatile TagValue llmObsParentAgentSpanIdTagValue;
+ private volatile TagValue llmObsParentAgentNameTagValue;
+
// Static cache for the most-recently-seen rate → TagValue. In steady state a service uses one
// rate, so this eliminates the char[] + String allocation on every new PTags instance.
// Writes are benign-racy: two threads computing the same rate produce equal TagValues.
@@ -158,7 +169,8 @@ static class PTags extends PropagationTags {
TagValue decisionMakerTagValue,
TagValue traceIdTagValue,
int traceSource,
- TagValue orgPropagationMarkerTagValue) {
+ TagValue orgPropagationMarkerTagValue,
+ LLMObsTagValues llmObsTagValues) {
this(
factory,
tagPairs,
@@ -168,7 +180,8 @@ static class PTags extends PropagationTags {
PrioritySampling.UNSET,
null,
null,
- orgPropagationMarkerTagValue);
+ orgPropagationMarkerTagValue,
+ llmObsTagValues);
}
PTags(
@@ -180,7 +193,8 @@ static class PTags extends PropagationTags {
int samplingPriority,
CharSequence origin,
CharSequence lastParentId,
- TagValue orgPropagationMarkerTagValue) {
+ TagValue orgPropagationMarkerTagValue,
+ LLMObsTagValues llmObsTagValues) {
assert tagPairs == null || tagPairs.size() % 2 == 0;
this.factory = factory;
this.tagPairs = tagPairs;
@@ -191,6 +205,11 @@ static class PTags extends PropagationTags {
this.origin = origin;
this.lastParentId = lastParentId;
this.orgPropagationMarkerTagValue = orgPropagationMarkerTagValue;
+ LLMObsTagValues lov = llmObsTagValues != null ? llmObsTagValues : LLMObsTagValues.EMPTY;
+ this.llmObsMlAppTagValue = lov.mlApp;
+ this.llmObsSessionIdTagValue = lov.sessionId;
+ this.llmObsParentAgentSpanIdTagValue = lov.parentAgentSpanId;
+ this.llmObsParentAgentNameTagValue = lov.parentAgentName;
if (traceIdTagValue != null) {
CharSequence traceIdHighOrderBitsHex = traceIdTagValue.forType(TagElement.Encoding.DATADOG);
this.traceIdHighOrderBits =
@@ -212,6 +231,7 @@ static PTags withError(PTagsFactory factory, String error) {
PrioritySampling.UNSET,
null,
null,
+ null,
null);
pTags.error = error;
return pTags;
@@ -377,6 +397,96 @@ TagValue getOrgPropagationMarkerTagValue() {
return orgPropagationMarkerTagValue;
}
+ @Override
+ public CharSequence getLLMObsMlApp() {
+ return llmObsMlAppTagValue;
+ }
+
+ @Override
+ public void updateLLMObsMlApp(CharSequence mlApp) {
+ TagValue newValue = toTagValue(mlApp);
+ if (!Objects.equals(this.llmObsMlAppTagValue, newValue)) {
+ clearCachedHeader(DATADOG);
+ clearCachedHeader(W3C);
+ this.llmObsMlAppTagValue = newValue;
+ }
+ }
+
+ TagValue getLLMObsMlAppTagValue() {
+ return llmObsMlAppTagValue;
+ }
+
+ @Override
+ public CharSequence getLLMObsSessionId() {
+ return llmObsSessionIdTagValue;
+ }
+
+ @Override
+ public void updateLLMObsSessionId(CharSequence sessionId) {
+ TagValue newValue = toTagValue(sessionId);
+ if (!Objects.equals(this.llmObsSessionIdTagValue, newValue)) {
+ clearCachedHeader(DATADOG);
+ clearCachedHeader(W3C);
+ this.llmObsSessionIdTagValue = newValue;
+ }
+ }
+
+ TagValue getLLMObsSessionIdTagValue() {
+ return llmObsSessionIdTagValue;
+ }
+
+ @Override
+ public CharSequence getLLMObsParentAgentSpanId() {
+ return llmObsParentAgentSpanIdTagValue;
+ }
+
+ @Override
+ public void updateLLMObsParentAgentSpanId(CharSequence parentAgentSpanId) {
+ TagValue newValue = toTagValue(parentAgentSpanId);
+ if (!Objects.equals(this.llmObsParentAgentSpanIdTagValue, newValue)) {
+ clearCachedHeader(DATADOG);
+ clearCachedHeader(W3C);
+ this.llmObsParentAgentSpanIdTagValue = newValue;
+ }
+ }
+
+ TagValue getLLMObsParentAgentSpanIdTagValue() {
+ return llmObsParentAgentSpanIdTagValue;
+ }
+
+ @Override
+ public CharSequence getLLMObsParentAgentName() {
+ return llmObsParentAgentNameTagValue;
+ }
+
+ @Override
+ public void updateLLMObsParentAgentName(CharSequence parentAgentName) {
+ TagValue newValue = toTagValue(parentAgentName);
+ if (!Objects.equals(this.llmObsParentAgentNameTagValue, newValue)) {
+ clearCachedHeader(DATADOG);
+ clearCachedHeader(W3C);
+ this.llmObsParentAgentNameTagValue = newValue;
+ }
+ }
+
+ TagValue getLLMObsParentAgentNameTagValue() {
+ return llmObsParentAgentNameTagValue;
+ }
+
+ /**
+ * Wraps a non-empty value as a {@link TagValue}, or {@code null} if empty. No length capping is
+ * applied here — matching dd-trace-py, which writes these free-form values (ml_app, session_id,
+ * agent id/name) as-is and relies on the codecs' own overflow handling (dropping the whole
+ * {@code x-datadog-tags} header on the Datadog codec, or dropping individual overlong tags on
+ * the W3C codec) rather than a fixed per-field character limit.
+ */
+ private static TagValue toTagValue(CharSequence value) {
+ if (value == null || value.length() == 0) {
+ return null;
+ }
+ return TagValue.from(value);
+ }
+
@Override
public int getSamplingPriority() {
return samplingPriority;
@@ -512,6 +622,15 @@ int getXDatadogTagsSize() {
size =
PTagsCodec.calcXDatadogTagsSize(
size, ORG_PROPAGATION_MARKER_TAG, getOrgPropagationMarkerTagValue());
+ size = PTagsCodec.calcXDatadogTagsSize(size, LLMOBS_ML_APP_TAG, llmObsMlAppTagValue);
+ size =
+ PTagsCodec.calcXDatadogTagsSize(size, LLMOBS_SESSION_ID_TAG, llmObsSessionIdTagValue);
+ size =
+ PTagsCodec.calcXDatadogTagsSize(
+ size, LLMOBS_PAGENT_SPAN_ID_TAG, llmObsParentAgentSpanIdTagValue);
+ size =
+ PTagsCodec.calcXDatadogTagsSize(
+ size, LLMOBS_PAGENT_NAME_TAG, llmObsParentAgentNameTagValue);
int currentProductTraceSource = traceSource;
if (currentProductTraceSource != ProductTraceSource.UNSET) {
size =
diff --git a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/W3CPTagsCodec.java b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/W3CPTagsCodec.java
index c0018544188..0a6e18bd10a 100644
--- a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/W3CPTagsCodec.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/W3CPTagsCodec.java
@@ -99,6 +99,10 @@ PropagationTags fromHeaderValue(PTagsFactory tagsFactory, String value) {
int maxUnknownSize = 0;
CharSequence lastParentId = null;
TagValue orgPropagationMarkerTagValue = null;
+ TagValue llmObsMlAppTagValue = null;
+ TagValue llmObsSessionIdTagValue = null;
+ TagValue llmObsParentAgentSpanIdTagValue = null;
+ TagValue llmObsParentAgentNameTagValue = null;
while (tagPos < ddMemberValueEnd) {
tagPos = skipEmptyElements(value, tagPos, ddMemberValueEnd);
if (tagPos >= ddMemberValueEnd) {
@@ -168,6 +172,14 @@ PropagationTags fromHeaderValue(PTagsFactory tagsFactory, String value) {
traceSource = ProductTraceSource.parseBitfieldHex(tagValue.toString());
} else if (tagKey.equals(ORG_PROPAGATION_MARKER_TAG)) {
orgPropagationMarkerTagValue = tagValue;
+ } else if (tagKey.equals(LLMOBS_ML_APP_TAG)) {
+ llmObsMlAppTagValue = tagValue;
+ } else if (tagKey.equals(LLMOBS_SESSION_ID_TAG)) {
+ llmObsSessionIdTagValue = tagValue;
+ } else if (tagKey.equals(LLMOBS_PAGENT_SPAN_ID_TAG)) {
+ llmObsParentAgentSpanIdTagValue = tagValue;
+ } else if (tagKey.equals(LLMOBS_PAGENT_NAME_TAG)) {
+ llmObsParentAgentNameTagValue = tagValue;
} else {
if (tagPairs == null) {
// This is roughly the size of a two element linked list but can hold six
@@ -201,7 +213,12 @@ PropagationTags fromHeaderValue(PTagsFactory tagsFactory, String value) {
ddMemberValueEnd,
maxUnknownSize,
lastParentId,
- orgPropagationMarkerTagValue);
+ orgPropagationMarkerTagValue,
+ new LLMObsTagValues(
+ llmObsMlAppTagValue,
+ llmObsSessionIdTagValue,
+ llmObsParentAgentSpanIdTagValue,
+ llmObsParentAgentNameTagValue));
}
@Override
@@ -764,6 +781,7 @@ private static W3CPTags empty(
ddMemberValueEnd,
0,
null,
+ null,
null);
}
@@ -799,7 +817,8 @@ public W3CPTags(
int ddMemberValueEnd,
int maxUnknownSize,
CharSequence lastParentId,
- TagValue orgPropagationMarkerTagValue) {
+ TagValue orgPropagationMarkerTagValue,
+ LLMObsTagValues llmObsTagValues) {
super(
factory,
tagPairs,
@@ -809,7 +828,8 @@ public W3CPTags(
samplingPriority,
origin,
lastParentId,
- orgPropagationMarkerTagValue);
+ orgPropagationMarkerTagValue,
+ llmObsTagValues);
this.tracestate = original;
this.firstMemberStart = firstMemberStart;
this.ddMemberStart = ddMemberStart;
diff --git a/internal-api/src/main/java/datadog/trace/api/llmobs/LLMObsContext.java b/internal-api/src/main/java/datadog/trace/api/llmobs/LLMObsContext.java
index e925a04f42f..c18dc51666e 100644
--- a/internal-api/src/main/java/datadog/trace/api/llmobs/LLMObsContext.java
+++ b/internal-api/src/main/java/datadog/trace/api/llmobs/LLMObsContext.java
@@ -13,6 +13,7 @@ private LLMObsContext() {
}
private static final ContextKey CONTEXT_KEY = ContextKey.named("llmobs_span");
+ private static final ContextKey ML_APP_KEY = ContextKey.named("llmobs_ml_app");
private static final ContextKey SESSION_ID_KEY = ContextKey.named("llmobs_session_id");
private static final ContextKey AGENT_VERSION_KEY =
ContextKey.named("llmobs_agent_version");
@@ -71,7 +72,26 @@ public static ContextScope attach(
String agentVersion,
String parentAgentSpanId,
String parentAgentName) {
+ return attach(ctx, null, sessionId, agentVersion, parentAgentSpanId, parentAgentName);
+ }
+
+ /**
+ * Attach an LLMObs span context, propagating ml_app alongside session_id, agent_version, and
+ * agent attribution. See {@link #attach(AgentSpanContext, String, String, String, String)}.
+ *
+ * ml_app is carried here — rather than only as a span tag — so that distributed propagation
+ * can read the innermost active LLMObs span's ml_app at injection time, without needing a
+ * reference to the span itself.
+ */
+ public static ContextScope attach(
+ AgentSpanContext ctx,
+ String mlApp,
+ String sessionId,
+ String agentVersion,
+ String parentAgentSpanId,
+ String parentAgentName) {
Context updated = Context.current().with(CONTEXT_KEY, ctx);
+ updated = updated.with(ML_APP_KEY, mlApp != null && !mlApp.isEmpty() ? mlApp : null);
if (sessionId != null && !sessionId.isEmpty()) {
updated = updated.with(SESSION_ID_KEY, sessionId);
}
@@ -94,6 +114,11 @@ public static AgentSpanContext current() {
return Context.current().get(CONTEXT_KEY);
}
+ /** Return the ml_app of the innermost active LLMObs span, or null if none is active. */
+ public static String currentMlApp() {
+ return Context.current().get(ML_APP_KEY);
+ }
+
/**
* Return the session_id propagated from an enclosing LLMObs span, or null if no parent set one.
*/
diff --git a/internal-api/src/main/java/datadog/trace/api/llmobs/LLMObsInternal.java b/internal-api/src/main/java/datadog/trace/api/llmobs/LLMObsInternal.java
index ed32f10118a..52335b14044 100644
--- a/internal-api/src/main/java/datadog/trace/api/llmobs/LLMObsInternal.java
+++ b/internal-api/src/main/java/datadog/trace/api/llmobs/LLMObsInternal.java
@@ -21,6 +21,11 @@ public static void setFeedbackProcessor(LLMObsFeedbackProcessor feedbackProcesso
FEEDBACK_PROCESSOR = feedbackProcessor;
}
+ /** Sets the LLM Observability distributed tracing propagator. */
+ public static void setPropagator(LLMObsPropagator propagator) {
+ PROPAGATOR = propagator;
+ }
+
/** Returns the registered user span processor, if any. */
@Nullable
public static LLMObsSpanProcessor getSpanProcessor() {
diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentPropagation.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentPropagation.java
index 46a01b3f70f..0cf43469604 100644
--- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentPropagation.java
+++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentPropagation.java
@@ -23,6 +23,11 @@ public final class AgentPropagation {
// TODO DSM propagator should run after the other propagators as it stores the pathway context
// TODO into the span context for now. Remove priority after the migration is complete.
public static final Concern DSM_CONCERN = withPriority("data-stream-monitoring", 110);
+ // LLM Observability contributes no headers of its own: it stages the _dd.p.llmobs_* propagation
+ // tags onto the span context, which the tracing propagator then serializes into x-datadog-tags /
+ // tracestate. Composite injection runs in reverse priority order, so this must sort after
+ // TRACING_CONCERN to actually inject before it.
+ public static final Concern LLMOBS_CONCERN = withPriority("llm-observability", 115);
private AgentPropagation() {}
diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpanContext.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpanContext.java
index 1dba9438168..77466ef92cd 100644
--- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpanContext.java
+++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentSpanContext.java
@@ -55,6 +55,58 @@ default void mergePathwayContext(PathwayContext pathwayContext) {}
default void setIntegrationName(CharSequence componentName) {}
+ /**
+ * Gets the LLM Observability {@code ml_app} propagated with this trace, or {@code null} if none
+ * is set or this context implementation doesn't have propagation-tags access.
+ */
+ default CharSequence getLLMObsMlApp() {
+ return null;
+ }
+
+ /** Sets the LLM Observability {@code ml_app} to propagate with this trace. No-op by default. */
+ default void updateLLMObsMlApp(CharSequence mlApp) {}
+
+ /**
+ * Gets the LLM Observability {@code session_id} propagated with this trace, or {@code null} if
+ * none is set or this context implementation doesn't have propagation-tags access.
+ */
+ default CharSequence getLLMObsSessionId() {
+ return null;
+ }
+
+ /**
+ * Sets the LLM Observability {@code session_id} to propagate with this trace. No-op by default.
+ */
+ default void updateLLMObsSessionId(CharSequence sessionId) {}
+
+ /**
+ * Gets the span id of the parent LLM Observability agent span propagated with this trace, or
+ * {@code null} if none is set or this context implementation doesn't have propagation-tags
+ * access.
+ */
+ default CharSequence getLLMObsParentAgentSpanId() {
+ return null;
+ }
+
+ /**
+ * Sets the parent LLM Observability agent span id to propagate with this trace. No-op by default.
+ */
+ default void updateLLMObsParentAgentSpanId(CharSequence parentAgentSpanId) {}
+
+ /**
+ * Gets the name of the parent LLM Observability agent span propagated with this trace, or {@code
+ * null} if none is set or this context implementation doesn't have propagation-tags access.
+ */
+ default CharSequence getLLMObsParentAgentName() {
+ return null;
+ }
+
+ /**
+ * Sets the parent LLM Observability agent span name to propagate with this trace. No-op by
+ * default.
+ */
+ default void updateLLMObsParentAgentName(CharSequence parentAgentName) {}
+
/**
* Gets whether the span context used is part of the local trace or from another service
*