Skip to content

Share LLMObs parent resolution and adopt propagated context in OpenAI spans - #12878

Draft
ncybul wants to merge 3 commits into
masterfrom
nicole.cybul/llmobs-openai-propagation
Draft

ncybul wants to merge 3 commits into
masterfrom
nicole.cybul/llmobs-openai-propagation

Conversation

@ncybul

@ncybul ncybul commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

What Does This Do

Follow-up to #12416. Auto-instrumented openai.request spans now pick up the LLM Observability context that arrived on the inbound request, so a service that calls OpenAI directly (an HTTP handler or queue consumer that never opens an LLMObs span of its own) stays in its caller's LLMObs trace instead of starting a new one.

To get there without copying DDLLMObsSpan's parent resolution a second time, both span types now share one resolver, LLMObsParent.

Motivation

#12416 taught DDLLMObsSpan to fall back to the extracted _dd.p.llmobs_* values, but OpenAiDecorator only read the in-process LLMObsContext.current(). That is null in the fully automatic path (inbound request → OpenAI call, no manual span), so the LLM span became an LLMObs root: new trace id, parent_id: undefined, no session, no agent attribution, and a locally rolled sampling decision that could disagree with the caller's.

The resolution rules (same-trace check, in-process before propagated, rate and decision adopted together, roll only at a true root) were already ~100 lines in DDLLMObsSpan. Every future LLM integration would need them too, so they now live in one place.

Approach

LLMObsParent (new, internal-api)

LLMObsParent.resolve(span) returns the parent a span inherits from, tried in order:

  1. The active in-process LLMObsContext, only if it's on the same trace as the span.
  2. Otherwise, the extracted values from span.spanContext().getExtractedLLMObsValues(), with the trace id converted from its decimal wire form by LLMObsTraceId.fromWire.
  3. Otherwise none. isRoot() is true, and only then does the caller roll a sampling decision.

It only resolves; it writes nothing to the span. Caller-specific rules stay with the caller: explicit values winning (with "" treated as unset), the service-default ml_app, an agent span naming itself as its descendants' parent agent, opening a context for children, and seeding the trace id at a root.

Two details keep it behaviour-preserving:

  • In-process values pass through as the context holds them, except that empty reads as absent, which is how every reader and the wire already treat it. DDLLMObsSpan re-attaches these values for its descendants, and the propagation source injects them one by one, so normalising them here would change the wire.
  • Propagated values follow the two existing pairing rules: the parent agent name only alongside its id, and the sample rate only together with its decision.

A context that is active but on another trace is exposed as ignoredContext, so DDLLMObsSpan keeps its existing error log. The OpenAI decorator still doesn't log.

LLMObsSampler.decide(DDTraceId) replaces the identical sample(...) ? "1" : "0" expression in both callers.

Callers

  • DDLLMObsSpan: the resolution block becomes one resolve call plus the explicit-value layering. Everything after it is unchanged: trace-id seeding, the ml_app default, the agent override, tag writes and LLMObsContext.attach.
  • OpenAiDecorator: applies the resolved parent to its tags. This is where the OpenAI fix comes from: it now gets the propagated fallback for free. It still leaves trace_id unset at a root, and the mapper seeds it the same way DDLLMObsSpan does.

LLMObsTraceId moved to internal-api

The resolver needs fromWire, and instrumentation modules can't see agent-llmobs. The class and its test move unchanged from datadog.trace.llmobs.domain to datadog.trace.api.llmobs. The only other change is that format is now public.

Reviewing

Commits are meant to be read in order:

  1. Adopt propagated LLMObs context in openai.request spans: the OpenAI fix, written inline, plus the LLMObsTraceId move and forked tests.
  2. Pin LLMObs parent resolution edge cases before refactoring: tests only, added and passing on the pre-refactor code. They cover cases the suite didn't pin:
    • an agent span that receives parent-agent values from another service names itself as its children's parent agent;
    • a stale context from another trace doesn't hide what arrived on the headers;
    • an explicit empty session_id / agent_version counts as unset.
  3. Share LLMObs parent resolution: the refactor. No existing test assertion changes, so any behaviour change would show up as a failure.

Testing

  • LLMObsParentTest (new, 11 cases): same-trace context wins over propagated values; in-process values pass through unpaired; empty reads as absent; stale context falls back to propagated, or to a root that reports what it ignored; propagated trace id converted from the wire; propagated context without a parent id or verdict is still not a root; each pairing rule.
  • LlmObsContextPropagationForkedTest: 6 new cases extract real x-datadog-* headers under a server span before calling the OpenAI client:
    • no LLMObs span in the service → adopts the caller's context, including a 128-bit LLMObs trace id that differs from the APM one;
    • a propagated drop decision is honoured;
    • context with no verdict → no local roll;
    • an in-process parent wins;
    • a stale context is ignored;
    • an APM-only caller → the span is an LLMObs root.
  • Unchanged and passing: all of agent-llmobs (105 tests), OpenAI unit tests (95) and forked tests (20), and internal-api's datadog.trace.api.llmobs (115).

Not yet done: an end-to-end run across two processes against a real agent, including the Python ↔ Java scenarios from #12416. That is the check that catches trace-id format mismatches.

Claude session: 0ad2d9f2-7e72-4f45-874f-bb9105d3daf9
Resume: claude --resume 0ad2d9f2-7e72-4f45-874f-bb9105d3daf9

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ncybul ncybul added type: feature Enhancements and improvements comp: mlobs ML Observability (LLMObs) tag: ai generated Largely based on code generated by an AI or LLM labels Oct 14, 2026
@datadog-official

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 13.92 s 13.98 s [-1.1%; +0.2%] (no difference)
startup:insecure-bank:tracing:Agent 12.85 s 12.99 s [-1.7%; -0.4%] (maybe better)
startup:petclinic:appsec:Agent 17.63 s 17.41 s [+0.4%; +2.0%] (maybe worse)
startup:petclinic:iast:Agent 17.33 s 17.51 s [-1.7%; -0.3%] (maybe better)
startup:petclinic:profiling:Agent 17.33 s 17.18 s [-0.1%; +1.9%] (no difference)
startup:petclinic:sca:Agent 17.70 s 17.59 s [-0.3%; +1.6%] (no difference)
startup:petclinic:tracing:Agent 16.52 s 16.65 s [-1.7%; +0.1%] (no difference)

Commit: e9ef5936 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

ncybul and others added 2 commits October 14, 2026 16:17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ncybul ncybul changed the title Adopt propagated LLMObs context in OpenAI spans Share LLMObs parent resolution and adopt propagated context in OpenAI spans Oct 14, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: mlobs ML Observability (LLMObs) tag: ai generated Largely based on code generated by an AI or LLM type: feature Enhancements and improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant