Skip to content

feat(sampling): Support for OpenTelemetry consistent tracestate sampling - #12397

Open
MilanGarnier wants to merge 21 commits into
masterfrom
milan.garnier/ot.th
Open

MilanGarnier wants to merge 21 commits into
masterfrom
milan.garnier/ot.th

Conversation

@MilanGarnier

@MilanGarnier MilanGarnier commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

Implements OpenTelemetry consistent probability sampling with the ot
tracestate member.

  • Records locally made probability decisions as rv and th for agent-rate
    and rule-based sampling (and forwards them is they were extracted and valid).
  • Keeps rate-limiter outcomes and manual overrides from creating contradictory
    probability state.
  • Validates and normalizes inbound ot fields, rebuilding W3C tracestate
    within its member and size limits while preserving unmanaged vendor members.
  • Publishes sampling priority, Datadog sampling tags, and OTel sampling state
    together so W3C injection and OTLP JSON/protobuf export use one decision.
  • Clears inherited managed OTel state when Org Guard resamples a trace.

Adds coverage for parsing and normalization, W3C extraction and
reinjection, sampling decisions and overrides, Org Guard, and OTLP export.

Motivation

OpenTelemetry consistent probability sampling lets tracers make compatible
sampling decisions using a shared random value and threshold. The Java tracer
needs to retain compatible inbound state and publish locally made probability
decisions without conflicting with Datadog sampling behavior.

Additional Notes

Contributor Checklist

  • Format the title according to the contribution guidelines.
  • Assign the required type: and comp: labels.

Jira ticket: APMAPI-2171

@MilanGarnier MilanGarnier added type: feature Enhancements and improvements comp: context propagation Trace context propagation tag: ai generated Largely based on code generated by an AI or LLM labels Sep 3, 2026
@MilanGarnier MilanGarnier changed the title Support OpenTelemetry tracestate sampling feat(sampling): Support for OpenTelemetry consistent tracestate sampling Sep 3, 2026
@datadog-datadog-prod-us1-2

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Sep 3, 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 14.05 s 13.92 s [+0.1%; +1.8%] (maybe worse)
startup:insecure-bank:tracing:Agent 13.00 s 13.02 s [-0.8%; +0.6%] (no difference)
startup:petclinic:appsec:Agent 17.63 s 17.43 s [+0.1%; +2.2%] (maybe worse)
startup:petclinic:iast:Agent 17.39 s 17.55 s [-1.8%; +0.0%] (no difference)
startup:petclinic:profiling:Agent 17.47 s 17.10 s [+0.9%; +3.5%] (maybe worse)
startup:petclinic:sca:Agent 17.58 s 17.44 s [-0.2%; +1.8%] (no difference)
startup:petclinic:tracing:Agent 16.60 s 16.25 s [-2.2%; +6.4%] (no difference)

Commit: 90e53db2 · 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.

@MilanGarnier
MilanGarnier changed the base branch from master to milan.garnier/preserve-deterministic-sampler-rate September 3, 2026 13:59
Base automatically changed from milan.garnier/preserve-deterministic-sampler-rate to master September 3, 2026 18:45
@MilanGarnier
MilanGarnier changed the base branch from master to milan.garnier/otel-tracestate-refactor September 4, 2026 09:30
@MilanGarnier
MilanGarnier force-pushed the milan.garnier/ot.th branch 5 times, most recently from 7a1ddea to a0176c9 Compare September 8, 2026 14:26
@MilanGarnier

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@MilanGarnier
MilanGarnier marked this pull request as ready for review September 10, 2026 09:02
@MilanGarnier
MilanGarnier requested review from a team as code owners September 10, 2026 09:02
@MilanGarnier
MilanGarnier requested review from mhlidd and removed request for a team September 10, 2026 09:02
@MilanGarnier
MilanGarnier force-pushed the milan.garnier/otel-tracestate-refactor branch from db321c0 to 1c02400 Compare September 10, 2026 09:02
chatgpt-codex-connector[bot]

This comment was marked as resolved.

datadog-datadog-prod-us1-2[bot]

This comment was marked as resolved.

@MilanGarnier
MilanGarnier marked this pull request as ready for review September 15, 2026 09:47
@MilanGarnier
MilanGarnier removed this pull request from stack #12487 September 15, 2026 09:47
datadog-datadog-prod-us1-2[bot]

This comment was marked as resolved.

@mcculls
mcculls self-requested a review September 15, 2026 10:28
@MilanGarnier
MilanGarnier requested a review from a team as a code owner September 15, 2026 12:23
@MilanGarnier
MilanGarnier requested review from vandonr and removed request for a team September 15, 2026 12:23

@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

The change can keep an inherited OpenTelemetry threshold that conflicts with a new local sampling decision. It also sends threshold values with extra trailing zeroes.

Open Bits AI session

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

int samplingPriority,
CharSequence rate,
double sampleRate,
boolean probabilitySamplingResult,

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.

Adding this parameter is the cause of much of the changes in this PR.

I would have thought you'd have this information already by combining samplingPriority and samplingMechanism - can you list the situations which aren't covered by the existing parameters?

@MilanGarnier MilanGarnier Sep 18, 2026

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.

This is a design decision that was already questioned in other implementations and I don't really know if there is a nicer solution to it... Essentially the issue comes from the rate limiter.
The two cases that are indistinguishable are:

  1. sampling rule rejects
  2. sampling rule accepts but limiter demotes (from a locally configured rate)

Both have a priority USER_DROP with mechanism LOCAL_USER_RULE
However, the first case falls in the probabilistic sampling category and therefore needs ot=... being emitted, whereas the second one isn't and requires not emitting a threshold for it

@mcculls mcculls Sep 18, 2026

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.

TBH I would consider whether internally we could encode the fact that the original mechanism has been overridden by the limiter. For example by setting the upper-bit of the mechanism parameter*. We could make sure to filter that bit out before encoding, but it would allow us to pass additional context internally that two mechanisms were involved without the internal codebase churn.

Especially since it wasn't immediately obvious to me what probabilitySamplingResult was meant to convey.

(*) this is just one idea - other encodings are possible, and we'd still have to treat a mechanism of -1 specially and not just clear its top bit :)

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.

I'm not sure I see the advantage of encoding things in the bits of the sampling mechanism. It moves the churn to its handling everywhere in the tracer.
I have some encodings in mind, but they introduce churn elsewhere, and another requirement is that it needs to be unambiguous

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.

Effectively the information you're trying to pass in is the original mechanism plus the fact that original mechanism was overridden/supplanted by a different mechanism (rate limiter)

The mechanism parameter is an int with enough space to capture both of those pieces of information. It saves the overhead of maintaining the extra parameter.

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.

I wonder if this could be achieved by only changing the internals of SamplingState, inferring it in its method calls. The sampling mechanism and the otel tracestate did not live together in every previous implrmentation, but since I already had to put them together here, this may remove the need of carrying the samplingResult altogether. I'll have a deeper look at this when I'm back on Tuesday

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.

Thanks, that would be appreciated - I'm not saying we can't change this internal API, but if we could avoid it then that might help simplify the overall PR.

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.

I've looked into it and it doesn't work as well as I thought by only changing the internals of SamplingState, so I went for your approach : af7e307
This adds a RATE_LIMITER_REJECTED flag (keeping the sign of the value) for the rate limiter path.
The thing I don't like about it is that it can very easily be misused (for example doing a switch case on the value without thinking about the flag possibly being present), but here the number of places where the flag is used is very limited.

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.

Is it just in RuleBasedTraceSampler where we need to make the distinction?

@mcculls mcculls 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.

The addition of a new parameter to setSamplingPriority has a large a ripple effect across the codebase - on the face of it, this new parameter looks derivable from existing sampling parameters.

Can you look at whether you really need to add this new parameter?

@MilanGarnier

MilanGarnier commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

The addition of a new parameter to setSamplingPriority has a large a ripple effect across the codebase - on the face of it, this new parameter looks derivable from existing sampling parameters.

Can you look at whether you really need to add this new parameter?

@mcculls I've answered in this comment

Essentially this is the same (valid though, I wish it was simpler) question than Zach asked in DataDog/dd-trace-dotnet#8983 (comment) . In the final implementation it ended up changing the constructor for sampling decisions...
tracer/src/Datadog.Trace/Sampling/SamplingDecision.cs

@mcculls

mcculls commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Code review (Claude)

Stale/unreconciled OTel rv/th state on locally-made probability decisions

In tryUpdateProbabilitySamplingDecision, when a span already carries an inherited OTel tracestate (getOtelTraceState() != null — e.g. extracted from an upstream service that sent ot=rv:...;th:... without a dd= member, so this service's own samplingPriority is still UNSET) and the mechanism is not a rate-limiter rejection, neither branch below fires:

OtelTraceState nextOtelTraceState = getOtelTraceState();
if (nextOtelTraceState == null) {
if (!rateLimiterRejected) {
nextOtelTraceState =
OtelTraceState.fromProbabilityDecision(
traceIdLowOrderBits, sampleRate, samplingPriority);
}
} else if (rateLimiterRejected) {
nextOtelTraceState = nextOtelTraceState.withoutThreshold();

The inherited rv/th is forwarded completely unchanged, with no consistency check against the new samplingPriority/sampleRate this call is installing — unlike the sibling methods updateTraceSamplingPriority and tryUpdateTraceSamplingPriority, which both call nextOtelTraceState.isConsistentWith(...) and strip the threshold when it disagrees with the priority being installed.

Concretely: a trace extracted from a pure-OTel upstream (tracestate has ot=rv:<x>;th:<y> but no dd=, so Datadog priority is UNSET) reaches a sampler here (RateByServiceTraceSampler/RuleBasedTraceSampler), which calls DDSpanContext.setSamplingPriority(priority, mechanism, sampleRate, traceIdLowOrderBits) → this method. The locally computed priority/rate is installed, but the outgoing ot= member still asserts the upstream threshold — so a downstream OTel/Datadog consumer reconstructing the effective sampling probability from rv/th gets a value that has nothing to do with the decision actually applied at this hop. This looks like exactly the "contradictory probability state" class of bug the PR description says it's guarding against.

🤖 Generated with Claude Code

@mcculls

mcculls commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Code review (Claude)

Divergent EXTERNAL_OVERRIDE handling between updateTraceSamplingPriority and tryUpdateTraceSamplingPriority — latent, not currently reachable

updateTraceSamplingPriority explicitly excludes EXTERNAL_OVERRIDE from the "strip to non-probability" branch, so a consistent inherited rv/th is left untouched for that mechanism:

OtelTraceState nextOtelTraceState = getOtelTraceState();
if (nextOtelTraceState != null) {
if (samplingMechanism == SamplingMechanism.EXTERNAL_OVERRIDE
&& !nextOtelTraceState.isConsistentWith(samplingPriority > 0)) {
nextOtelTraceState = nextOtelTraceState.withoutThreshold();
} else if (samplingMechanism != SamplingMechanism.UNKNOWN
&& samplingMechanism != SamplingMechanism.EXTERNAL_OVERRIDE) {
nextOtelTraceState = nextOtelTraceState.forNonProbabilityDecision();
}
}

tryUpdateTraceSamplingPriority does not carry the same exclusion into its else if:

OtelTraceState nextOtelTraceState = getOtelTraceState();
if (nextOtelTraceState != null) {
if ((samplingMechanism == SamplingMechanism.EXTERNAL_OVERRIDE
|| samplingMechanism == SamplingMechanism.UNKNOWN)
&& !nextOtelTraceState.isConsistentWith(samplingPriority > 0)) {
nextOtelTraceState = nextOtelTraceState.withoutThreshold();
} else if (samplingMechanism != SamplingMechanism.UNKNOWN) {
nextOtelTraceState = nextOtelTraceState.forNonProbabilityDecision();
}
}

For samplingMechanism == EXTERNAL_OVERRIDE where the existing rv/th is already consistent with the new priority, the first branch's !isConsistentWith(...) is false so it's skipped — but the else if (samplingMechanism != UNKNOWN) fires anyway (EXTERNAL_OVERRIDE != UNKNOWN), calling forNonProbabilityDecision() and unconditionally discarding a threshold that didn't need discarding. This is a genuine logic divergence between the two methods for the same mechanism value, and matches the exact bug class this PR's own commit history has been shaking out ("remove fast path... requires checking... probably more things").

Caveat: currently latent. I checked every call site of EXTERNAL_OVERRIDE in this diff and could not find one that reaches tryUpdateTraceSamplingPriority — the only live EXTERNAL_OVERRIDE caller (W3CHttpCodec) goes through updateTraceSamplingPriority instead. So this doesn't affect production behavior today, but tryUpdateTraceSamplingPriority is public, and the divergence is worth fixing before something calls it with EXTERNAL_OVERRIDE.

🤖 Generated with Claude Code

@mcculls

mcculls commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Note: this comment is from Claude (AI-assisted review), not a human reviewer.

Finding: installSamplingState unconditionally invalidates caches and allocates, even for no-op decisions

PTagsFactory.java#L313-L363

installSamplingState calls clearCachedHeader(W3C) unconditionally at the top (line 329) and unconditionally allocates a new SamplingState at the bottom (lines 357-364), on every invocation. None of its callers — updateTraceSamplingPriority, tryUpdateTraceSamplingPriority, tryUpdateProbabilitySamplingDecision, forceKeep — check whether the incoming decision (priority, mechanism, otel state) actually differs from the current SamplingState before reaching this method.

By contrast, the pre-#12397 doUpdateTraceSamplingPriority at least guarded its clearCachedHeader(W3C) call behind if (this.samplingPriority != samplingPriority).

Concretely: a second call with an identical priority/mechanism/otel-state (e.g. a rate limiter re-evaluating the same trace, or multiple call sites converging on the same KEEP decision) still:

  • allocates a new SamplingState object,
  • invalidates w3cHeaderCache / tracestateCache / xDatadogTagsSizeCache (they're identity-keyed against SamplingState, e.g. getCachedHeader),
  • forces a full header/tracestate rebuild on the next inject.

Since the PR's own perf commit is titled "reduce sampling state allocations," adding a short-circuit in installSamplingState — comparing the candidate samplingPriority, nextOtelTraceState, tracestate, and resulting nextDecisionMakerTagValue/nextKnuthSamplingRateTagValue against the current samplingState before touching any cache or allocating — would close a gap where repeat no-op decisions currently pay full cost.

🤖 Generated with Claude Code

@mcculls

mcculls commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Note: this comment is from Claude (AI-assisted review), not a human reviewer.

Suggested fix for the EXTERNAL_OVERRIDE/UNKNOWN divergence

Following up on the earlier note about updateTraceSamplingPriority vs tryUpdateTraceSamplingPriority reconciling OTel rv/th state differently:

  • updateTraceSamplingPriority only runs the consistency check (isConsistentWithwithoutThreshold()) for EXTERNAL_OVERRIDE. For UNKNOWN, it does nothing — the OTel state is left untouched even if it's inconsistent with the new priority.
  • tryUpdateTraceSamplingPriority runs the same consistency check for both EXTERNAL_OVERRIDE and UNKNOWN.

Today this divergence is unreachable in production (no caller passes EXTERNAL_OVERRIDE into tryUpdateTraceSamplingPriority, and no caller of updateTraceSamplingPriority currently exercises the UNKNOWN case with an inconsistent inherited OTel state), but the two methods encode two different policies for the same decision, and the next person adding a caller has no way to know which one is "correct."

Suggested fix: extract the shared reconciliation into one private helper both methods call, e.g.:

private OtelTraceState reconcileOtelTraceState(
    OtelTraceState otelTraceState, int samplingPriority, int samplingMechanism) {
  if (otelTraceState == null) {
    return null;
  }
  if ((samplingMechanism == SamplingMechanism.EXTERNAL_OVERRIDE
          || samplingMechanism == SamplingMechanism.UNKNOWN)
      && !otelTraceState.isConsistentWith(samplingPriority > 0)) {
    return otelTraceState.withoutThreshold();
  } else if (samplingMechanism != SamplingMechanism.UNKNOWN
      && samplingMechanism != SamplingMechanism.EXTERNAL_OVERRIDE) {
    return otelTraceState.forNonProbabilityDecision();
  }
  return otelTraceState;
}

(matching tryUpdateTraceSamplingPriority's broader UNKNOWN-inclusive check, since that's the more defensive of the two policies — it's the one that actually protects the consistency invariant isConsistentWith exists to enforce). Both updateTraceSamplingPriority and tryUpdateTraceSamplingPriority would then call this single helper instead of duplicating the branch logic, which removes the divergence risk entirely rather than relying on the two call sites staying in sync by convention.

🤖 Generated with Claude Code

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: context propagation Trace context propagation 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