Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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 @@ -8,6 +8,7 @@
import datadog.trace.api.WellKnownTags;
import datadog.trace.api.llmobs.LLMObs;
import datadog.trace.api.llmobs.LLMObsContext;
import datadog.trace.api.llmobs.LLMObsSampler;
import datadog.trace.api.llmobs.LLMObsSpan;
import datadog.trace.api.llmobs.LLMObsTags;
import datadog.trace.api.telemetry.LLMObsMetricCollector;
Expand Down Expand Up @@ -51,6 +52,8 @@ public class DDLLMObsSpan implements LLMObsSpan {
private static final String CONTEXT_VARIABLE_KEYS = "_dd_context_variable_keys";
private static final String QUERY_VARIABLE_KEYS = "_dd_query_variable_keys";
private static final String PARENT_ID_TAG_INTERNAL = "parent_id";
private static final String SAMPLE_RATE_TAG_INTERNAL = "sample_rate";
private static final String SAMPLING_DECISION_TAG_INTERNAL = "sampling_decision";
private static final String PAGENT_SPAN_ID_TAG_INTERNAL =
LLMOBS_TAG_PREFIX + LLMObsTags.PAGENT_SPAN_ID;
private static final String PAGENT_NAME_TAG_INTERNAL = LLMOBS_TAG_PREFIX + LLMObsTags.PAGENT_NAME;
Expand All @@ -64,6 +67,8 @@ public class DDLLMObsSpan implements LLMObsSpan {

private static final Logger LOGGER = LoggerFactory.getLogger(DDLLMObsSpan.class);

private static final LLMObsSampler CONFIGURED_SAMPLER = LLMObsSampler.fromConfig();

private final AgentSpan span;
private final String spanKind;
private final String mlApp;
Expand Down Expand Up @@ -94,6 +99,26 @@ public DDLLMObsSpan(
@Nonnull String serviceName,
WellKnownTags wellKnownTags,
String agentVersion) {
this(
kind,
spanName,
mlApp,
sessionId,
serviceName,
wellKnownTags,
agentVersion,
CONFIGURED_SAMPLER);
}

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

if (null == spanName || spanName.isEmpty()) {
spanName = kind;
Expand Down Expand Up @@ -122,12 +147,18 @@ public DDLLMObsSpan(
spanKind = kind;
this.mlApp = mlApp;
span.setTag(LLMOBS_TAG_PREFIX + LLMObsTags.ML_APP, mlApp);
// Resolve effective parent_id and session_id from the LLMObs context, both gated on
// trace-id consistency. A stale context from a different trace (e.g. async boundary
// leakage) must not contribute either tag.
// Resolve effective parent_id, session_id, agent_version, agent attribution and sampling
// decision from the LLMObs context, all gated on trace-id consistency. A stale context from a
// different trace (e.g. async boundary leakage) must not contribute any of them. Every
// inherited value is read inside the one same-trace branch below, so a newly propagated tag
// cannot ship with a weaker gate of its own.
AgentSpanContext parent = LLMObsContext.current();
String parentSpanID = LLMObsContext.ROOT_SPAN_ID;
String resolvedAgentVersion = agentVersion;
String sampleRate = null;
String samplingDecision = null;
String resolvedParentAgentSpanId = null;
String resolvedParentAgentName = null;
if (null != parent) {
if (parent.getTraceId() != span.getTraceId()) {
LOGGER.error(
Expand Down Expand Up @@ -156,9 +187,34 @@ public DDLLMObsSpan(
resolvedAgentVersion = inherited;
}
}
// Inherit the sampling decision from the context if present.
sampleRate = LLMObsContext.currentSampleRate();
samplingDecision = LLMObsContext.currentSamplingDecision();
// Inherit agent attribution: the nearest agent-kind ancestor. Overridden just below when
// this span is itself an agent.
resolvedParentAgentSpanId = LLMObsContext.currentParentAgentSpanId();
resolvedParentAgentName = LLMObsContext.currentParentAgentName();
}
}

// An agent span is its own descendants' nearest agent ancestor, replacing anything inherited.
// Use the span name as the initial pagent name; annotateAgentManifest() will update it to the
// manifest name if one is provided later.
if (Tags.LLMOBS_AGENT_SPAN_KIND.equals(kind)) {
resolvedParentAgentSpanId = String.valueOf(span.getSpanId());
resolvedParentAgentName = spanName;
}

if (samplingDecision == null || sampleRate == null) {
sampleRate = sampler.formattedRate();
samplingDecision =
sampler.sample(span.getTraceId().toLong())
? LLMObsContext.SAMPLING_DECISION_SAMPLED
: LLMObsContext.SAMPLING_DECISION_DROPPED;
}
span.setTag(LLMOBS_TAG_PREFIX + SAMPLE_RATE_TAG_INTERNAL, sampleRate);
span.setTag(LLMOBS_TAG_PREFIX + SAMPLING_DECISION_TAG_INTERNAL, samplingDecision);

this.hasSessionId = sessionId != null && !sessionId.isEmpty();
if (this.hasSessionId) {
span.setTag(LLMOBS_TAG_PREFIX + LLMObsTags.SESSION_ID, sessionId);
Expand All @@ -167,29 +223,6 @@ public DDLLMObsSpan(
span.setTag(LLMOBS_TAG_PREFIX + LLMObsTags.AGENT_VERSION, resolvedAgentVersion);
}
span.setTag(LLMOBS_TAG_PREFIX + PARENT_ID_TAG_INTERNAL, parentSpanID);

// Resolve agent attribution (O(1)): identify the nearest agent-kind ancestor.
String resolvedParentAgentSpanId = null;
String resolvedParentAgentName = null;

if (Tags.LLMOBS_AGENT_SPAN_KIND.equals(kind)) {
// This span is itself an agent — it becomes the nearest ancestor for its descendants.
// Use the span name as the initial pagent name; annotateAgentManifest() will update it
// to the manifest name if one is provided later.
resolvedParentAgentSpanId = String.valueOf(span.getSpanId());
resolvedParentAgentName = spanName;
} else {
// Inherit from in-process LLMObs parent only when the context belongs to the same trace.
// Matches the gate applied to parent_id and session_id above: a stale LLMObsContext
// leaked across an async boundary would otherwise attribute a span to an agent from a
// different trace. For standalone agent spans (no ambient APM root), standaloneApmScope
// ensures descendants are started under the agent's APM span so this gate passes.
if (null != parent && parent.getTraceId() == span.getTraceId()) {
resolvedParentAgentSpanId = LLMObsContext.currentParentAgentSpanId();
resolvedParentAgentName = LLMObsContext.currentParentAgentName();
}
}

// Store pagent values as internal tags so the serializer can emit agent_attribution.
if (resolvedParentAgentSpanId != null) {
span.setTag(PAGENT_SPAN_ID_TAG_INTERNAL, resolvedParentAgentSpanId);
Expand All @@ -198,12 +231,15 @@ public DDLLMObsSpan(
}
}

// Propagate the effective sessionId and agent attribution to descendant LLMObs spans.
// Propagate the effective sessionId, agent_version, sampling decision and agent attribution
// to descendant LLMObs spans via the context.
scope =
LLMObsContext.attach(
span.spanContext(),
sessionId,
resolvedAgentVersion,
sampleRate,
samplingDecision,
resolvedParentAgentSpanId,
resolvedParentAgentName);

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

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

import datadog.trace.agent.tooling.TracerInstaller;
import datadog.trace.api.WellKnownTags;
import datadog.trace.api.llmobs.LLMObsSampler;
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 where the head-based sampling decision is made and where it is inherited: the decision is
* computed once, at the root of an LLMObs trace, and every descendant reports it verbatim.
*/
class DDLLMObsSpanSamplingTest {
private static final String SAMPLE_RATE_TAG = "_ml_obs_tag.sample_rate";
private static final String SAMPLING_DECISION_TAG = "_ml_obs_tag.sampling_decision";

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 stampsRetainedDecisionAtTheDefaultRate() throws IllegalAccessException {
// The fields are stamped at every rate, including 1.0, matching dd-trace-py.
DDLLMObsSpan llmObsSpan = newSpan(new LLMObsSampler(1.0));
try {
AgentSpan span = spanOf(llmObsSpan);
assertEquals("1", span.getTag(SAMPLING_DECISION_TAG));
assertEquals("1", span.getTag(SAMPLE_RATE_TAG));
} finally {
llmObsSpan.finish();
}
}

@Test
void stampsDroppedDecisionOnRoot() throws IllegalAccessException {
DDLLMObsSpan llmObsSpan = newSpan(new LLMObsSampler(0.0));
try {
AgentSpan span = spanOf(llmObsSpan);
assertEquals("0", span.getTag(SAMPLING_DECISION_TAG));
assertEquals("0", span.getTag(SAMPLE_RATE_TAG));
} finally {
llmObsSpan.finish();
}
}

@Test
void childInheritsTheRootDecisionInsteadOfRecomputingIt() throws IllegalAccessException {
// The child's sampler drops everything. If the decision were recomputed per span, the child
// would report "0" and the trace would be torn in half at the intake.
DDLLMObsSpan root = newSpan(new LLMObsSampler(1.0));
try {
AgentSpan rootSpan = spanOf(root);
// Inheritance is gated on the two spans sharing an APM trace, so the root's APM span has to
// be active for the child to be started under it.
try (AgentScope ignored = AgentTracer.activateSpan(rootSpan)) {
DDLLMObsSpan child = newSpan(new LLMObsSampler(0.0));
try {
AgentSpan childSpan = spanOf(child);
assertEquals("1", childSpan.getTag(SAMPLING_DECISION_TAG));
assertEquals(
rootSpan.getTag(SAMPLE_RATE_TAG),
childSpan.getTag(SAMPLE_RATE_TAG),
"every span in a trace must report the rate the decision was made at");
} finally {
child.finish();
}
}
} finally {
root.finish();
}
}

@Test
void childOfADroppedRootStaysDropped() throws IllegalAccessException {
// Symmetric case. Because a decision is stamped at every rate, "the context carries a decision"
// is an unambiguous signal, so a child never mistakes an inherited drop for being a root.
DDLLMObsSpan root = newSpan(new LLMObsSampler(0.0));
try {
try (AgentScope ignored = AgentTracer.activateSpan(spanOf(root))) {
DDLLMObsSpan child = newSpan(new LLMObsSampler(1.0));
try {
AgentSpan childSpan = spanOf(child);
assertEquals("0", childSpan.getTag(SAMPLING_DECISION_TAG));
assertEquals("0", childSpan.getTag(SAMPLE_RATE_TAG));
} finally {
child.finish();
}
}
} finally {
root.finish();
}
}

private static DDLLMObsSpan newSpan(LLMObsSampler sampler) {
WellKnownTags tags =
new WellKnownTags("runtime-id", "hostname", "test", "service", "version", "java");
return new DDLLMObsSpan(
Tags.LLMOBS_LLM_SPAN_KIND, "span", "ml-app", null, "service", tags, null, sampler);
}

private static AgentSpan spanOf(DDLLMObsSpan llmObsSpan) throws IllegalAccessException {
return (AgentSpan) SPAN_FIELD.get(llmObsSpan);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ interface CommonTags {
String PARENT_ID = TAG_PREFIX + "parent_id";
String SESSION_ID = TAG_PREFIX + LLMObsTags.SESSION_ID;
String AGENT_VERSION = TAG_PREFIX + LLMObsTags.AGENT_VERSION;
String SAMPLE_RATE = TAG_PREFIX + "sample_rate";
String SAMPLING_DECISION = TAG_PREFIX + "sampling_decision";
String PAGENT_SPAN_ID = TAG_PREFIX + LLMObsTags.PAGENT_SPAN_ID;
String PAGENT_NAME = TAG_PREFIX + LLMObsTags.PAGENT_NAME;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import datadog.trace.api.DDTraceApiInfo;
import datadog.trace.api.WellKnownTags;
import datadog.trace.api.llmobs.LLMObsContext;
import datadog.trace.api.llmobs.LLMObsSampler;
import datadog.trace.api.telemetry.LLMObsMetricCollector;
import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext;
Expand Down Expand Up @@ -44,6 +45,7 @@ public class OpenAiDecorator extends ClientDecorator {

private final boolean llmObsEnabled = Config.get().isLlmObsEnabled();
private final WellKnownTags wellKnownTags = Config.get().getWellKnownTags();
private final LLMObsSampler sampler = LLMObsSampler.fromConfig();

public AgentSpan startSpan(ClientOptions clientOptions) {
AgentSpan span = AgentTracer.startSpan(INTEGRATION, SPAN_NAME);
Expand Down Expand Up @@ -110,10 +112,16 @@ protected void doAfterStart(@Nonnull AgentSpan span) {

// 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.
// session_id, agent_version, agent attribution, or a sampling verdict to this span.
// Matches DDLLMObsSpan's manual-span gate. One flag drives every inherited value, so a
// new propagated tag cannot accidentally ship with a weaker gate of its own.
AgentSpanContext parent = LLMObsContext.current();
boolean inheritable = parent != null && parent.getTraceId().equals(span.getTraceId());

String parentSpanId = LLMObsContext.ROOT_SPAN_ID;
if (parent != null && parent.getTraceId() == span.getTraceId()) {
String samplingDecision = null;
String sampleRate = null;
if (inheritable) {
parentSpanId = String.valueOf(parent.getSpanId());

// Inherit session_id from the active LLMObs parent (e.g. a manual workflow span).
Expand All @@ -131,13 +139,9 @@ protected void doAfterStart(@Nonnull AgentSpan span) {
if (agentVersion != null && !agentVersion.isEmpty()) {
span.setTag(CommonTags.AGENT_VERSION, agentVersion);
}
}
span.setTag(CommonTags.PARENT_ID, parentSpanId);

// Inherit agent attribution only when the LLMObs context belongs to the same trace.
// Mirrors the gate in DDLLMObsSpan: a stale LLMObsContext from a different async trace
// must not stamp its agent ID onto this span.
if (parent != null && parent.getTraceId() == span.getTraceId()) {
// Inherit agent attribution: the nearest agent-kind ancestor of this span. The name is
// only meaningful alongside an ID, so it is read inside the ID's branch.
String parentAgentSpanId = LLMObsContext.currentParentAgentSpanId();
if (parentAgentSpanId != null) {
span.setTag(CommonTags.PAGENT_SPAN_ID, parentAgentSpanId);
Expand All @@ -146,7 +150,24 @@ protected void doAfterStart(@Nonnull AgentSpan span) {
span.setTag(CommonTags.PAGENT_NAME, parentAgentName);
}
}

samplingDecision = LLMObsContext.currentSamplingDecision();
sampleRate = LLMObsContext.currentSampleRate();
}
span.setTag(CommonTags.PARENT_ID, parentSpanId);

// Compute the sampling decision if none was inherited (no LLMObs parent), which makes this
// span the root of its own LLMObs trace. Unlike the tags above, this cannot be skipped when
// there is nothing to inherit: an unstamped span is retained at any configured rate.
if (samplingDecision == null || sampleRate == null) {
sampleRate = sampler.formattedRate();
samplingDecision =
sampler.sample(span.getTraceId().toLong())
? LLMObsContext.SAMPLING_DECISION_SAMPLED
: LLMObsContext.SAMPLING_DECISION_DROPPED;
}
span.setTag(CommonTags.SAMPLING_DECISION, samplingDecision);
span.setTag(CommonTags.SAMPLE_RATE, sampleRate);
}
super.doAfterStart(span);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,8 @@ class ChatCompletionServiceTest extends OpenAiTest {
"_ml_obs_metric.cache_read_input_tokens" Long
}
"_ml_obs_tag.parent_id" "undefined"
"_ml_obs_tag.sampling_decision" "1"
"_ml_obs_tag.sample_rate" "1"
"_ml_obs_tag.ml_app" String
"_ml_obs_tag.service" String
"$CommonTags.DDTRACE_VERSION" String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ class CompletionServiceTest extends OpenAiTest {
"_ml_obs_metric.output_tokens" Long
"_ml_obs_metric.total_tokens" Long
"_ml_obs_tag.parent_id" "undefined"
"_ml_obs_tag.sampling_decision" "1"
"_ml_obs_tag.sample_rate" "1"
"_ml_obs_tag.ml_app" String
"_ml_obs_tag.service" String
"$CommonTags.DDTRACE_VERSION" String
Expand Down
Loading