Skip to content

Add head-based sampling for LLM Observability traces - #12277

Open
ncybul wants to merge 15 commits into
masterfrom
nicole.cybul/llmobs-java-head-based-sampling
Open

Add head-based sampling for LLM Observability traces#12277
ncybul wants to merge 15 commits into
masterfrom
nicole.cybul/llmobs-java-head-based-sampling

Conversation

@ncybul

@ncybul ncybul commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Jira: MLOB-7815

What Does This Do

Adds head-based retention sampling for LLM Observability traces, controlled by a new DD_LLMOBS_SAMPLE_RATE (dd.llmobs.sample.rate) config, defaulting to 1.0.

At the root of an LLMObs trace, the tracer makes a deterministic keep/drop decision keyed on the APM trace ID, then stamps sampling_decision and sample_rate into the _dd block of that span. The decision is carried on the LLMObs context and inherited unchanged by descendant spans, so a trace is retained or dropped as a whole.

The SDK never drops a span. Every LLMObs span is still sent to the intake at 100%; the intake performs the drop. This is deliberate: token and cost metrics are computed over full volume regardless of the configured sample rate.

Auto-instrumented OpenAI spans

Manual (DDLLMObsSpan) and auto-instrumented (OpenAiDecorator) spans are two independent creation paths, and both reach the LLMObs span mapper. OpenAiDecorator.doAfterStart therefore inherits the verdict from the enclosing LLMObs context, the same way it already inherits session_id. If there is no sampling decision that can be inherited (no LLMObs parent), the sampling decision is computed within the integration.

The trace-id gate guards against an LLMObsContext leaked across an async boundary outliving its trace: a verdict computed from a different trace ID says nothing about this one. Both values are stamped together or not at all, so a span can never report a decision without the rate that produced it.

A span that reaches the mapper unstamped falls back to sampling_decision=1 / sample_rate=1.

Motivation

Python and Node already support LLMObs head-based sampling; Java did not. Customers running high-volume LLM workloads have no way to control retention volume from the Java tracer. The deterministic algorithm matches dd-trace-py's (SAMPLING_KNUTH_FACTOR).

Distributed Tracing

The LLMObs Java SDK does not yet support distributed tracing. This is tracked in a follow-up ticket. Because of this, sampling decisions are not propagated across service boundaries.

Testing

  • LLMObsSamplerTest covers the sampler in isolation: keep-everything at 1.0, drop-everything at 0.0, clamping of out-of-range rates (including NaN), the formatted rate string, determinism for a given sampling ID, retention of roughly the configured fraction across 0.10.9, and that a higher rate keeps a superset of what a lower rate keeps.
  • DDLLMObsSpanSamplingTest covers the manual SDK path: stamping a retained decision at the default rate, stamping a dropped decision on a root, and children inheriting the root's verdict rather than recomputing it — including a child of a dropped root staying dropped.
  • LLMObsSpanMapperTest pins the decoded _dd map for every combination the writer can produce — the sub-map is hand-sized msgpack, so an off-by-one corrupts the stream rather than failing loudly. That includes the unstamped fallback, which is now a defensive default rather than a path either producer takes.

Auto-instrumented coverage is split across two forked classes, because the rate is read once when the sampler singleton initializes and so cannot vary within a JVM:

  • LlmObsContextPropagationForkedTest (renamed from SessionIdPropagationForkedTest, since it now covers more than session_id) runs at the default rate: a dropped verdict is inherited, a retained verdict is inherited, the trace-id gate rejects a stale cross-trace context, and — the behavior this PR adds — a span with no LLMObs parent computes its own verdict instead of arriving unstamped.
  • LlmObsZeroSampleRateForkedTest runs under @WithConfig(key = "llmobs.sample.rate", value = "0") and asserts a parentless openai.request span is stamped dropped. This is the direct regression test: before this change such a span was retained at every configured rate.

I tested an app with various sample rates set, sending 20 traces each time, to ensure that roughly sample rate = (# traces retained / # traces sent).

Sampling rate of 0

No traces were retained for this test as expected.

Sampling rate of 0.5

11 out of 20 traces survived my test:
Screenshot 2026-08-26 at 3 55 52 PM

Sampling rate of 1

All 20 traces survived the test:
Screenshot 2026-08-26 at 3 55 39 PM

Claude session: f94e7cd8-1c7d-41b1-835f-553f557f2d04
Resume: claude --resume f94e7cd8-1c7d-41b1-835f-553f557f2d04

@ncybul ncybul added tag: ai generated Largely based on code generated by an AI or LLM comp: mlobs ML Observability (LLMObs) type: feature Enhancements and improvements labels Aug 24, 2026
@ncybul ncybul changed the title Add head-based sampling for LLM Observability traces Add head-based sampling for LLM Observability traces [MLOB-7815] Aug 24, 2026
@datadog-datadog-prod-us1-2

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Aug 24, 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.97 s 13.99 s [-0.9%; +0.6%] (no difference)
startup:insecure-bank:tracing:Agent 12.93 s 13.01 s [-1.4%; +0.2%] (no difference)
startup:petclinic:appsec:Agent 17.00 s 16.93 s [-0.5%; +1.3%] (no difference)
startup:petclinic:iast:Agent 16.88 s 16.99 s [-1.5%; +0.2%] (no difference)
startup:petclinic:profiling:Agent 16.42 s 16.81 s [-3.4%; -1.2%] (significantly better)
startup:petclinic:sca:Agent 16.39 s 16.54 s [-5.5%; +3.7%] (no difference)
startup:petclinic:tracing:Agent 16.13 s 16.16 s [-1.1%; +0.8%] (no difference)

Commit: 328bce54 · 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.

Comment thread internal-api/src/main/java/datadog/trace/api/llmobs/LLMObsContext.java Outdated
Comment thread internal-api/src/main/java/datadog/trace/api/llmobs/LLMObsContext.java Outdated
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ncybul

ncybul commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

@codex

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f9f5c5c40a

ℹ️ 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".

Comment thread internal-api/src/main/java/datadog/trace/api/Config.java
Comment thread internal-api/src/main/java/datadog/trace/api/llmobs/LLMObsSampler.java Outdated
@ncybul
ncybul marked this pull request as ready for review August 26, 2026 20:12
@ncybul
ncybul requested review from a team as code owners August 26, 2026 20:12
@ncybul
ncybul requested review from dougqh and vandonr and removed request for a team August 26, 2026 20:12
@dd-octo-sts

dd-octo-sts Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Hi! 👋 Thanks for your pull request! 🎉

To help us review it, please make sure to:

  • Remove the tag from the pull request title

If you need help, please check our contributing guidelines.

@ncybul ncybul changed the title Add head-based sampling for LLM Observability traces [MLOB-7815] Add head-based sampling for LLM Observability traces Aug 26, 2026

@datadog-datadog-prod-us1-2 datadog-datadog-prod-us1-2 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.

Datadog Autotest: FAIL

A payload buffer retry removes the new sampling tags before the retry reads them. The retry then writes retain values, so the intake can keep a trace that the sampler drops.

Open Bits AI session

🤖 Datadog Autotest · Commit 7a261db · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest

Comment thread internal-api/src/main/java/datadog/trace/api/llmobs/LLMObsContext.java Outdated
ncybul and others added 2 commits August 27, 2026 16:25
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@Yun-Kim Yun-Kim 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.

I would recommend verifying one last time with https://app.datadoghq.com/dashboard/hr5-2cu-4fn/ai-observability?fromUser=false&refresh_mode=paused&tab_id=902bcec6-8230-4d1d-a6e1-084b24eae60e&from_ts=1787295861478&to_ts=1787900661478&live=false&tile_focus=2921063684666465 when running manual tests but LGTM!

The other thing is to note distributed tracing as a whole as a follow up feature (including sampling decision propagation) for Java.

Comment thread internal-api/src/main/java/datadog/trace/api/Config.java Outdated
@ncybul

ncybul commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Verified that sending 150 spans with sample_rate=0 resulted in exactly 150 dropped spans as expected.

Screenshot 2026-08-28 at 9 41 15 AM

@ncybul

ncybul commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Aug 28, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-08-28 15:12:56 UTC ℹ️ Start processing command /merge


2026-08-28 15:13:05 UTC ℹ️ MergeQueue: Pull request is not mergeable yet

It will be processed automatically as soon as GitHub reports it as mergeable. View in MergeQueue UI.

  • Run /code blockers to see what is blocking it.
  • Run /remove to cancel it.

2026-08-28 19:20:19 UTC ⚠️ MergeQueue: This merge request was unqueued

devflow unqueued this merge request: It did not become mergeable within the expected time

@ncybul

ncybul commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

/code blockers

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Aug 28, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-08-28 17:00:51 UTC ℹ️ Start processing command /code blockers


2026-08-28 17:00:52 UTC ℹ️ Devflow:

Checking merge blockers for #12277...


2026-08-28 17:00:58 UTC ℹ️ Devflow: /code blockers

Detected 1 merge blocker(s) to address:

🟠 Pending

@ncybul

ncybul commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Aug 28, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-08-28 20:13:28 UTC ℹ️ Start processing command /merge


2026-08-28 20:13:38 UTC ℹ️ MergeQueue: Pull request is not mergeable yet

It will be processed automatically as soon as GitHub reports it as mergeable. View in MergeQueue UI.

  • Run /code blockers to see what is blocking it.
  • Run /remove to cancel it.

2026-08-29 00:18:10 UTC ⚠️ MergeQueue: This merge request was unqueued

devflow unqueued this merge request: It did not become mergeable within the expected time

@ncybul

ncybul commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

/code blockers

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Aug 28, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-08-28 20:41:52 UTC ℹ️ Start processing command /code blockers


2026-08-28 20:41:53 UTC ℹ️ Devflow:

Checking merge blockers for #12277...


2026-08-28 20:41:58 UTC ℹ️ Devflow: /code blockers

Detected 1 merge blocker(s) to address:

🟠 Pending

@ncybul

ncybul commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

/code blockers

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Aug 31, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-08-31 13:41:33 UTC ℹ️ Start processing command /code blockers


2026-08-31 13:41:34 UTC ℹ️ Devflow:

Checking merge blockers for #12277...


2026-08-31 13:41:42 UTC ℹ️ Devflow: /code blockers

Detected 1 merge blocker(s) to address:

🟠 Pending

ncybul and others added 3 commits August 31, 2026 15:55
Resolves six conflicts with agent_version propagation (a9c94fe), which
extended the same LLMObsContext mechanism. All resolutions keep both sides.
Adopts .equals() over == for the trace-id inheritance gate in OpenAiDecorator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves six conflicts with agent attribution (pagent_span_id/pagent_name),
which extended the same LLMObsContext subtree-propagation mechanism.

The reported conflicts were adjacent additions, resolved keep-both. The real
break was a clean auto-merge: both sides added a 5-arg attach() overload with
the same erasure, so the merged file did not compile. DDLLMObsSpan assigns its
single scope from one attach() call, and that context must now carry session_id,
agent_version, the sampling verdict and agent attribution together, so the two
overloads are collapsed into one 7-arg overload rather than kept under separate
names.

Also:
- Folds master's attribution inheritance inside OpenAiDecorator's existing
  `if (inheritable)` gate, which uses .equals() rather than master's ==, so one
  flag drives every inherited value.
- Aligns the collapsed overload on clears-semantics for session_id, matching the
  2- and 3-arg overloads. Master's null/empty session_id tests assert null and
  still pass.
- Renames master's fiveArgAttach* tests to fullAttach*; there is no 5-arg
  overload any more.
- Extends the stale-cross-trace forked test to assert pagent tags do not leak,
  matching the coverage session_id and sampling already had.

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

vandonr commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

@autotest review

@datadog-datadog-prod-us1-2 datadog-datadog-prod-us1-2 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.

Datadog Autotest: PASS

More details

The sample rate flows from configuration to the root decision, child inheritance, and mapper output. The missing value in Config.toString affects diagnostics only, so it does not meet the reporting limit.

Was this helpful? React 👍 or 👎

Open Bits AI session

🤖 Datadog Autotest · Commit 328bce5 · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest

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.

4 participants