Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -261,13 +261,23 @@ public LLMObsSpan startLLMSpan(
@Override
public LLMObsSpan startAgentSpan(
String spanName, @Nullable String mlApp, @Nullable String sessionId) {
return startAgentSpan(spanName, mlApp, sessionId, null);
}

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

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public class DDLLMObsSpan implements LLMObsSpan {
private final String mlApp;
private final ContextScope scope;
private final boolean hasSessionId;
private final boolean hasAgentVersion;

private boolean finished = false;

Expand All @@ -73,6 +74,17 @@ public DDLLMObsSpan(
String sessionId,
@Nonnull String serviceName,
WellKnownTags wellKnownTags) {
this(kind, spanName, mlApp, sessionId, serviceName, wellKnownTags, null);
}

public DDLLMObsSpan(
@Nonnull String kind,
String spanName,
@Nonnull String mlApp,
String sessionId,
@Nonnull String serviceName,
WellKnownTags wellKnownTags,
String agentVersion) {

if (null == spanName || spanName.isEmpty()) {
spanName = kind;
Expand Down Expand Up @@ -106,6 +118,7 @@ public DDLLMObsSpan(
// leakage) must not contribute either tag.
AgentSpanContext parent = LLMObsContext.current();
String parentSpanID = LLMObsContext.ROOT_SPAN_ID;
String resolvedAgentVersion = agentVersion;
if (null != parent) {
if (parent.getTraceId() != span.getTraceId()) {
LOGGER.error(
Expand All @@ -125,16 +138,30 @@ public DDLLMObsSpan(
sessionId = inherited;
}
}
// Inherit agent_version from the enclosing agent span, if this span doesn't set its own.
// An explicit value always wins, so a nested agent's own version overrides an ancestor's
// for its own subtree, matching session_id's explicit-wins semantics.
if (agentVersion == null || agentVersion.isEmpty()) {
String inherited = LLMObsContext.currentAgentVersion();
if (inherited != null && !inherited.isEmpty()) {
resolvedAgentVersion = inherited;
}
}
}
}

this.hasSessionId = sessionId != null && !sessionId.isEmpty();
if (this.hasSessionId) {
span.setTag(LLMOBS_TAG_PREFIX + LLMObsTags.SESSION_ID, sessionId);
}
this.hasAgentVersion = resolvedAgentVersion != null && !resolvedAgentVersion.isEmpty();
if (this.hasAgentVersion) {
span.setTag(LLMOBS_TAG_PREFIX + LLMObsTags.AGENT_VERSION, resolvedAgentVersion);
}
span.setTag(LLMOBS_TAG_PREFIX + PARENT_ID_TAG_INTERNAL, parentSpanID);
// Propagate the effective sessionId to descendant LLMObs spans via the context.
scope = LLMObsContext.attach(span.spanContext(), sessionId);
// Propagate the effective sessionId and agent_version to descendant LLMObs spans via the
// context.
scope = LLMObsContext.attach(span.spanContext(), sessionId, resolvedAgentVersion);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package datadog.trace.llmobs.domain;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

import datadog.trace.agent.tooling.TracerInstaller;
import datadog.trace.api.WellKnownTags;
import datadog.trace.api.llmobs.LLMObsTags;
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 java.lang.reflect.Field;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

/**
* Covers agent_version propagation to a versioned agent's subtree, mirroring the session_id
* inheritance behavior in {@code DDLLMObsSpanTest}, but with the additional requirement that an
* explicit version always wins for its own subtree (so a nested agent's own version overrides an
* ancestor's).
*/
class DDLLMObsSpanAgentVersionTest {
private static final String AGENT_VERSION_TAG = "_ml_obs_tag." + LLMObsTags.AGENT_VERSION;

private static final Field SPAN_FIELD;

private static CoreTracer tracer;

static {
try {
SPAN_FIELD = DDLLMObsSpan.class.getDeclaredField("span");
SPAN_FIELD.setAccessible(true);
} catch (ReflectiveOperationException error) {
throw new ExceptionInInitializerError(error);
}
}

@BeforeAll
static void installTracer() {
tracer = CoreTracer.builder().build();
TracerInstaller.forceInstallGlobalTracer(tracer);
}

@AfterAll
static void closeTracer() {
TracerInstaller.forceInstallGlobalTracer(null);
tracer.close();
}

@Test
void agentSpanWithExplicitVersionTagsItself() {
DDLLMObsSpan agent = llmObsSpan(Tags.LLMOBS_AGENT_SPAN_KIND, "agent1", "v3");
try {
assertEquals("v3", spanOf(agent).getTag(AGENT_VERSION_TAG));
} finally {
agent.finish();
}
}

@Test
void childSpanInheritsAgentVersionFromParentContext() {
DDLLMObsSpan agent = llmObsSpan(Tags.LLMOBS_AGENT_SPAN_KIND, "agent1", "v3");
try (AgentScope ignored = AgentTracer.activateSpan(spanOf(agent))) {
DDLLMObsSpan child = llmObsSpan(Tags.LLMOBS_TOOL_SPAN_KIND, "tool1", null);
try {
assertEquals("v3", spanOf(child).getTag(AGENT_VERSION_TAG));
} finally {
child.finish();
}
} finally {
agent.finish();
}
}

@Test
void grandchildTransitivelyInheritsAgentVersionThroughIntermediateSpan() {
DDLLMObsSpan agent = llmObsSpan(Tags.LLMOBS_AGENT_SPAN_KIND, "agent1", "v3");
try (AgentScope agentScope = AgentTracer.activateSpan(spanOf(agent))) {
DDLLMObsSpan workflow = llmObsSpan(Tags.LLMOBS_WORKFLOW_SPAN_KIND, "workflow1", null);
try (AgentScope workflowScope = AgentTracer.activateSpan(spanOf(workflow))) {
DDLLMObsSpan grandchild = llmObsSpan(Tags.LLMOBS_LLM_SPAN_KIND, "llm1", null);
try {
assertEquals("v3", spanOf(grandchild).getTag(AGENT_VERSION_TAG));
} finally {
grandchild.finish();
}
} finally {
workflow.finish();
}
} finally {
agent.finish();
}
}

@Test
void nestedAgentWithOwnVersionOverridesForItsOwnSubtree() {
DDLLMObsSpan outerAgent = llmObsSpan(Tags.LLMOBS_AGENT_SPAN_KIND, "outer-agent", "v1");
try (AgentScope outerScope = AgentTracer.activateSpan(spanOf(outerAgent))) {
DDLLMObsSpan innerAgent = llmObsSpan(Tags.LLMOBS_AGENT_SPAN_KIND, "inner-agent", "v2");
try (AgentScope innerScope = AgentTracer.activateSpan(spanOf(innerAgent))) {
assertEquals("v2", spanOf(innerAgent).getTag(AGENT_VERSION_TAG));

DDLLMObsSpan child = llmObsSpan(Tags.LLMOBS_TOOL_SPAN_KIND, "inner-tool", null);
try {
assertEquals(
"v2",
spanOf(child).getTag(AGENT_VERSION_TAG),
"child of the nested agent must inherit the nested agent's own version, not the outer one");
} finally {
child.finish();
}
} finally {
innerAgent.finish();
}
} finally {
outerAgent.finish();
}
}

@Test
void noVersionSetAnywhereMeansNoTagOnAnySpanInTheSubtree() {
DDLLMObsSpan agent = llmObsSpan(Tags.LLMOBS_AGENT_SPAN_KIND, "agent1", null);
try (AgentScope agentScope = AgentTracer.activateSpan(spanOf(agent))) {
DDLLMObsSpan child = llmObsSpan(Tags.LLMOBS_TOOL_SPAN_KIND, "tool1", null);
try {
assertNull(spanOf(agent).getTag(AGENT_VERSION_TAG));
assertNull(spanOf(child).getTag(AGENT_VERSION_TAG));
} finally {
child.finish();
}
} finally {
agent.finish();
}
}

@Test
void childDoesNotInheritAgentVersionWhenStaleContextIsFromADifferentTrace() {
// Simulates a stale LLMObsContext (e.g. leaked across an async boundary): the parent's
// context is attached, but its AgentScope is deliberately NOT activated, so the next span
// started begins a fresh trace and the trace-consistency gate must skip inheritance.
DDLLMObsSpan agent = llmObsSpan(Tags.LLMOBS_AGENT_SPAN_KIND, "stale-agent", "stale-v1");
try {
DDLLMObsSpan child = llmObsSpan(Tags.LLMOBS_TOOL_SPAN_KIND, "tool1", null);
try (AgentScope childScope = AgentTracer.activateSpan(spanOf(child))) {
assertNotEquals(
spanOf(agent).getTraceId(),
spanOf(child).getTraceId(),
"sanity: traces must differ for this scenario to be meaningful");
assertNull(spanOf(child).getTag(AGENT_VERSION_TAG));

DDLLMObsSpan grandchild = llmObsSpan(Tags.LLMOBS_LLM_SPAN_KIND, "llm1", null);
try {
assertNull(
spanOf(grandchild).getTag(AGENT_VERSION_TAG),
"the stale agent_version must not leak transitively into a grandchild either");
} finally {
grandchild.finish();
}
} finally {
child.finish();
}
} finally {
agent.finish();
}
}

private static DDLLMObsSpan llmObsSpan(String kind, String name, String agentVersion) {
WellKnownTags tags =
new WellKnownTags("runtime-id", "hostname", "test", "service", "version", "java");
return new DDLLMObsSpan(kind, name, "test-ml-app", null, "service", tags, agentVersion);
}

private static AgentSpan spanOf(DDLLMObsSpan llmObsSpan) {
try {
return (AgentSpan) SPAN_FIELD.get(llmObsSpan);
} catch (IllegalAccessException error) {
throw new AssertionError(error);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface CommonTags {
String SERVICE = TAG_PREFIX + "service";
String PARENT_ID = TAG_PREFIX + "parent_id";
String SESSION_ID = TAG_PREFIX + LLMObsTags.SESSION_ID;
String AGENT_VERSION = TAG_PREFIX + LLMObsTags.AGENT_VERSION;

String TOOL_DEFINITIONS = TAG_PREFIX + "tool_definitions";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,22 +108,31 @@ protected void doAfterStart(@Nonnull AgentSpan span) {
span.setTag(CommonTags.SOURCE, "integration");
span.setTag(CommonTags.INTEGRATION, INTEGRATION);

// Resolve the LLMObs parent context, gated on trace-id consistency: a stale context
// from a different trace (e.g. async boundary leakage) must not contribute parent_id,
// session_id, or agent_version to this span. Matches DDLLMObsSpan's manual-span gate.
AgentSpanContext parent = LLMObsContext.current();
String parentSpanId = LLMObsContext.ROOT_SPAN_ID;
if (parent != null) {
if (parent != null && parent.getTraceId() == span.getTraceId()) {
parentSpanId = String.valueOf(parent.getSpanId());
}
span.setTag(CommonTags.PARENT_ID, parentSpanId);

// Inherit session_id from the active LLMObs parent (e.g. a manual workflow span).
// Matches dd-trace-py / dd-trace-js, where auto-instrumented LLM spans inherit
// session_id from the workflow root via context propagation. Without this, the
// auto-instrumented openai.request span would not appear under its session in
// the LLM Trace Explorer's Sessions view.
String sessionId = LLMObsContext.currentSessionId();
if (sessionId != null && !sessionId.isEmpty()) {
span.setTag(CommonTags.SESSION_ID, sessionId);
// Inherit session_id from the active LLMObs parent (e.g. a manual workflow span).
// Matches dd-trace-py / dd-trace-js, where auto-instrumented LLM spans inherit
// session_id from the workflow root via context propagation. Without this, the
// auto-instrumented openai.request span would not appear under its session in
// the LLM Trace Explorer's Sessions view.
String sessionId = LLMObsContext.currentSessionId();
if (sessionId != null && !sessionId.isEmpty()) {
span.setTag(CommonTags.SESSION_ID, sessionId);
}

// Inherit agent_version from the active LLMObs parent.
String agentVersion = LLMObsContext.currentAgentVersion();
if (agentVersion != null && !agentVersion.isEmpty()) {
span.setTag(CommonTags.AGENT_VERSION, agentVersion);
}
}
span.setTag(CommonTags.PARENT_ID, parentSpanId);
}
super.doAfterStart(span);
}
Expand Down
Loading
Loading