Skip to content

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

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

ncybul wants to merge 5 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.

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).

End to End Testing

Two Java services, each in its own JVM. Neither calls an LLMObs distributed-tracing API:

planner-service                                  openai-service
  LLMObs agent span "planner-agent"   ──HTTP──▶   Jetty /ask  ──▶  openai-java chat completion
  ml_app=planner-app, session_id=…               (no LLMObs span of its own)  └─ OpenAI.createChatCompletion
  • planner-service opens a planner-agent LLMObs span with a session and makes a plain JDK HttpClient POST. Its LLMObs context goes out in x-datadog-tags (Propagate LLM Observability context across service boundaries #12416).
  • openai-service is an embedded Jetty endpoint that answers with openai-java. It opens no LLMObs span and has no DD_LLMOBS_ML_APP, so its ml_app defaults to its own DD_SERVICE, as a real second service's would. The only LLMObs span it produces is the integration's own.
  • OpenAI is a local mock, run without the agent, so the OpenAI span is the last span on that side.

The same scenario runs on two agent builds:

Built from
Before 179e8935d6, this PR's merge base: master with #12416, without this PR
After e9ef593613, this PR

Before

The OpenAI span is the root of its own LLMObs trace: parent_id: undefined, reported under the downstream service's ml_app:openai-service, with no session and no agent attribution. The two services do share an LLMObs trace id: between two Java services the APM trace id is shared, and with nothing to inherit both sides seed the LLMObs id from it. Even so, the OpenAI span is a second, unconnected root, filed under another application.

After

The OpenAI span is a child of the caller's planner-agent span, in the caller's ml_app and session, with planner-agent as its parent agent. Nothing is set locally.

  • Trace
  • planner-agent span, with OpenAI.createChatCompletion nested under it (parent_id: 8194838372146278380, ml_app:planner-app, session pr12878-after-session-0)

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

datadog-official Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

🎯 Code Coverage (details)
• Patch Coverage: 98.67%
• Overall Coverage: 59.61% (+0.04%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: e76c66c | Docs | Give us feedback!

@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.91 s 13.90 s [-0.7%; +0.8%] (no difference)
startup:insecure-bank:tracing:Agent 12.95 s 12.87 s [-0.1%; +1.4%] (no difference)
startup:petclinic:appsec:Agent 17.08 s 16.97 s [-0.4%; +1.7%] (no difference)
startup:petclinic:iast:Agent 16.90 s 16.99 s [-1.3%; +0.2%] (no difference)
startup:petclinic:profiling:Agent 16.82 s 16.66 s [-0.3%; +2.2%] (no difference)
startup:petclinic:sca:Agent 17.15 s 16.97 s [+0.2%; +1.8%] (maybe worse)
startup:petclinic:tracing:Agent 16.01 s 16.24 s [-2.3%; -0.5%] (maybe better)

Commit: e76c66ca · 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
Comment thread internal-api/src/main/java/datadog/trace/api/llmobs/LLMObsParent.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/api/llmobs/LLMObsParent.java Outdated
@ncybul

ncybul commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

@codex

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-19T15:45:01.707892Z b2333f7 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: e9ef593613

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ncybul
ncybul marked this pull request as ready for review October 19, 2026 15:41
@ncybul
ncybul requested review from a team as code owners October 19, 2026 15:41
@ncybul
ncybul requested review from ygree and removed request for a team October 19, 2026 15:41

@datadog-official datadog-official Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bits Code Review: PASS

More details

The propagated-context fallback is consistent with manual-span inheritance, preserving same-trace context precedence and paired sampling decisions. The completed static review reported no actionable defects.

Was this helpful? React 👍 or 👎

Open Bits AI session

🤖 Bits Code Review · Commit b2333f7

@ygree ygree left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDM approved

}

LLMObsPropagationValues propagated = span.spanContext().getExtractedLLMObsValues();
if (propagated != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There might sometimes be cases where the request headers have parent_id=undefined but ml_app/trace_id are provided. AFAIK In these cases this PR is currently treating this as a continuation of that trace, but we should not do that (i.e. create a new trace with a new root span if parent ID is undefined or missing). I'm pretty sure this is what dd-trace-py does as well. Can we fix this (and make sure the manually instrumented propagation also does the same)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha, this condition now checks that the parent ID is not undefined before continuing to extract the context from the headers.

…efined

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

3 participants