From 0ac8a82ccc749ae7f83e4ad9da580218501790be Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 21 Oct 2026 08:45:55 -0400 Subject: [PATCH 1/3] Precompute decision maker tag values per sampling mechanism Every keep decision built "-" + samplingMechanism and then validated, hashed and looked it up in TagValue's cache. The mechanisms are a small fixed set, so build their TagValues once and index by mechanism, falling back to TagValue.from for anything beyond the table. Co-Authored-By: Claude Opus 5.5 --- .../core/propagation/ptags/PTagsFactory.java | 23 ++++++++++- .../ptags/DecisionMakerTagValueTest.java | 39 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/propagation/ptags/DecisionMakerTagValueTest.java diff --git a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsFactory.java b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsFactory.java index 066727c3baf..d4a01896252 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsFactory.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsFactory.java @@ -126,6 +126,20 @@ static class PTags extends PropagationTags { */ private final LLMObsTagValues extractedLLMObsTags; + /** + * Pre-built decision maker values ("-0", "-1", ...) indexed by sampling mechanism, so a + * sampling decision doesn't concatenate, validate and look up the same few strings every time. + * Sized with headroom over the current mechanisms; anything beyond falls back to {@link + * TagValue#from}. + */ + private static final TagValue[] DECISION_MAKER_TAG_VALUES = new TagValue[16]; + + static { + for (int mechanism = 0; mechanism < DECISION_MAKER_TAG_VALUES.length; ++mechanism) { + DECISION_MAKER_TAG_VALUES[mechanism] = TagValue.from("-" + mechanism); + } + } + // Static cache for the most-recently-seen rate → TagValue. In steady state a service uses one // rate, so this eliminates the char[] + String allocation on every new PTags instance. // Writes are benign-racy: two threads computing the same rate produce equal TagValues. @@ -360,7 +374,7 @@ private void installSamplingState( // Protect against possible SamplingMechanism.UNKNOWN (-1) that doesn't comply with the // format if (samplingMechanism >= 0) { - TagValue newDM = TagValue.from("-" + samplingMechanism); + TagValue newDM = decisionMakerTagValue(samplingMechanism); if (!newDM.equals(nextDecisionMakerTagValue)) { // This should invalidate any cached w3c and datadog header clearCachedHeaders(); @@ -455,6 +469,13 @@ public String getDebugPropagation() { return debugPropagation; } + /** Returns the decision maker value for a non-negative sampling mechanism. */ + static TagValue decisionMakerTagValue(int samplingMechanism) { + return samplingMechanism < DECISION_MAKER_TAG_VALUES.length + ? DECISION_MAKER_TAG_VALUES[samplingMechanism] + : TagValue.from("-" + samplingMechanism); + } + private static TagValue knuthSamplingRateTagValue(double rate) { if (Double.isNaN(rate)) { return null; diff --git a/dd-trace-core/src/test/java/datadog/trace/core/propagation/ptags/DecisionMakerTagValueTest.java b/dd-trace-core/src/test/java/datadog/trace/core/propagation/ptags/DecisionMakerTagValueTest.java new file mode 100644 index 00000000000..5c8146ba720 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/propagation/ptags/DecisionMakerTagValueTest.java @@ -0,0 +1,39 @@ +package datadog.trace.core.propagation.ptags; + +import static datadog.trace.core.propagation.ptags.PTagsFactory.PTags.decisionMakerTagValue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import datadog.trace.api.sampling.SamplingMechanism; +import datadog.trace.core.propagation.ptags.TagElement.Encoding; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class DecisionMakerTagValueTest { + @TableTest({ + "scenario | mechanism | expected", + "default | 0 | '-0' ", + "agent rate | 1 | '-1' ", + "manual | 4 | '-4' ", + "ai guard | 13 | '-13' ", + "last table entry | 15 | '-15' ", + "beyond table | 16 | '-16' ", + "well beyond table | 127 | '-127' " + }) + void formatsMechanismAsDecisionMaker(int mechanism, String expected) { + TagValue value = decisionMakerTagValue(mechanism); + + assertEquals(expected, value.forType(Encoding.DATADOG).toString()); + assertEquals(TagValue.from(expected), value); + } + + @Test + void reusesInstanceForKnownMechanisms() { + assertSame( + decisionMakerTagValue(SamplingMechanism.MANUAL), + decisionMakerTagValue(SamplingMechanism.MANUAL)); + assertSame( + decisionMakerTagValue(SamplingMechanism.AI_GUARD), + decisionMakerTagValue(SamplingMechanism.AI_GUARD)); + } +} From 4ddb5d9df759ff34988a128c01c33eea83d5e047 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Wed, 21 Oct 2026 09:07:06 -0400 Subject: [PATCH 2/3] Add a sampling decision benchmark Measures the per-trace propagation-tag bookkeeping done on a root sampling decision, both in a bare start/sample/finish span and on fresh PropagationTags with no span around it, for the default, rule and manual keep paths. Co-Authored-By: Claude Opus 5.5 --- .../trace/core/SamplingDecisionBenchmark.java | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/SamplingDecisionBenchmark.java diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SamplingDecisionBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SamplingDecisionBenchmark.java new file mode 100644 index 00000000000..05f8421c91b --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SamplingDecisionBenchmark.java @@ -0,0 +1,149 @@ +package datadog.trace.core; + +import static datadog.trace.api.config.TracerConfig.TRACE_RATE_LIMIT; +import static datadog.trace.api.config.TracerConfig.TRACE_SAMPLE_RATE; +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +import datadog.trace.api.sampling.PrioritySampling; +import datadog.trace.api.sampling.SamplingMechanism; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.common.sampling.Sampler; +import datadog.trace.core.propagation.PropagationTags; +import java.util.Properties; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Per-trace cost of the sampling decision: the propagation-tag bookkeeping (decision maker, Knuth + * sampling rate, OTel tracestate) that runs on every root decision, even though headers are only + * written at inject time. + * + *

Two lenses on the same work: + * + *

    + *
  • {@code span*} arms run a bare root span start → sample → finish, so the decision is + * measured in context. Compare them against {@link SpanCreationBenchmark#bareStartSpan}, + * which samples through the same default sampler. + *
  • {@code ptags*} arms make the decision on a fresh {@link PropagationTags} (one per trace, as + * in production) with no span around it, so a change to the decision path isn't lost in + * span-creation noise. + *
+ * + *

Read the allocation columns. Run with {@code -prof gc}; {@code gc.alloc.rate.norm} + * (B/op) is deterministic and is the primary signal. Throughput is directional only. + * + *

+ *   ./gradlew :dd-trace-core:jmhJar
+ *   java -jar dd-trace-core/build/libs/dd-trace-core-*-jmh.jar SamplingDecisionBenchmark -prof gc
+ * 
+ */ +@State(Scope.Benchmark) +@Warmup(iterations = 5) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.Throughput) +@Threads(8) +@OutputTimeUnit(MICROSECONDS) +@Fork(value = 3, jvmArgsAppend = "-DTEST_LOG_LEVEL=warn") +public class SamplingDecisionBenchmark { + private static final String INSTRUMENTATION_NAME = "bench"; + private static final String OPERATION_NAME = "servlet.request"; + + /** Default sampler with no agent rates: keeps every trace as SAMPLER_KEEP / DEFAULT. */ + CoreTracer defaultSamplerTracer; + + /** Rule sampler at rate 1.0: keeps every trace as USER_KEEP / LOCAL_USER_RULE. */ + CoreTracer ruleSamplerTracer; + + PropagationTags.Factory ptagsFactory; + + @Setup + public void setup(Blackhole blackhole) { + defaultSamplerTracer = CoreTracer.builder().writer(new DropWriter(blackhole)).build(); + + Properties ruleConfig = new Properties(); + ruleConfig.setProperty(TRACE_SAMPLE_RATE, "1.0"); + // The default limit (100/s) would reject almost every benchmark trace, which skips the keep + // path entirely. + ruleConfig.setProperty(TRACE_RATE_LIMIT, String.valueOf(Integer.MAX_VALUE)); + ruleSamplerTracer = + CoreTracer.builder() + .writer(new DropWriter(blackhole)) + .sampler(Sampler.Builder.forConfig(ruleConfig)) + .build(); + + ptagsFactory = PropagationTags.factory(); + } + + @TearDown + public void tearDown() { + defaultSamplerTracer.close(); + ruleSamplerTracer.close(); + } + + /** Root span kept by the default sampler at finish. */ + @Benchmark + public void spanDefaultSamplerKeep() { + AgentSpan span = defaultSamplerTracer.startSpan(INSTRUMENTATION_NAME, OPERATION_NAME); + span.finish(); + } + + /** Root span kept by a sampling rule at finish (probability decision with OTel tracestate). */ + @Benchmark + public void spanRuleSamplerKeep() { + AgentSpan span = ruleSamplerTracer.startSpan(INSTRUMENTATION_NAME, OPERATION_NAME); + span.finish(); + } + + /** Root span kept manually before finish, so the sampler is skipped. */ + @Benchmark + public void spanManualKeep() { + AgentSpan span = defaultSamplerTracer.startSpan(INSTRUMENTATION_NAME, OPERATION_NAME); + span.setSamplingPriority(PrioritySampling.USER_KEEP, SamplingMechanism.MANUAL); + span.finish(); + } + + /** The probability decision samplers make, on fresh trace-level propagation tags. */ + @Benchmark + public PropagationTags ptagsProbabilityKeep(ThreadState thread) { + PropagationTags ptags = ptagsFactory.empty(); + ptags.tryUpdateProbabilitySamplingDecision( + PrioritySampling.SAMPLER_KEEP, + SamplingMechanism.AGENT_RATE, + 1.0, + false, + thread.nextTraceIdLowOrderBits(), + false); + return ptags; + } + + /** A priority-only decision (manual keep), on fresh trace-level propagation tags. */ + @Benchmark + public PropagationTags ptagsPriorityKeep() { + PropagationTags ptags = ptagsFactory.empty(); + ptags.tryUpdateTraceSamplingPriority( + PrioritySampling.USER_KEEP, SamplingMechanism.MANUAL, false); + return ptags; + } + + /** Varies the trace id so the OTel random value isn't a constant. */ + @State(Scope.Thread) + public static class ThreadState { + long traceIdLowOrderBits = 0x5DEECE66DL; + + long nextTraceIdLowOrderBits() { + traceIdLowOrderBits = traceIdLowOrderBits * 6364136223846793005L + 1442695040888963407L; + return traceIdLowOrderBits; + } + } +} From 1c462878a143a02249853a540cbba1762b02944f Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Thu, 22 Oct 2026 11:18:36 -0400 Subject: [PATCH 3/3] Defer building the OpenTelemetry tracestate until it's injected or exported (#12982) * Defer building the OpenTelemetry tracestate for fresh probability decisions Every root probability decision built an OtelTraceState inside the sampling lock, though only W3C injection and OTLP export read it. Install a ProbabilitySamplingState that keeps the trace id bits and rate, and builds the ot member on first read. Co-Authored-By: Claude Opus 5.5 * Clarify why lazy otel state readers agree on one instance Co-Authored-By: Claude Opus 5.5 * Add benchmarks for the lazy otel state's inject, export and service-shape cost SamplingDecisionBenchmark gains arms that follow a probability decision with the Datadog header, the W3C tracestate header and the OTLP tracestate, so the deferred OtelTraceState is measured where it's built or skipped. ServiceShapePropagationBenchmark measures head, leaf and intermediate services with a default-configured tracer. It only uses API unchanged since v1.66.0, so it can be built onto release tags to put a cost on #12397. Co-Authored-By: Claude Opus 5.5 * Rename ServiceShapePropagationBenchmark to PropagationScenariosBenchmark Describe it as a scenario benchmark: each arm is a whole request driven through the tracer's public API, rather than one method in isolation. Co-Authored-By: Claude Opus 5.5 * Test that racing readers get one OpenTelemetry state instance Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../core/PropagationScenariosBenchmark.java | 241 ++++++++++++++++++ .../trace/core/SamplingDecisionBenchmark.java | 24 ++ .../core/propagation/PropagationTags.java | 13 +- .../core/propagation/ptags/PTagsFactory.java | 63 +++-- .../ptags/ProbabilitySamplingState.java | 52 ++++ .../ptags/ProbabilitySamplingStateTest.java | 125 +++++++++ 6 files changed, 496 insertions(+), 22 deletions(-) create mode 100644 dd-trace-core/src/jmh/java/datadog/trace/core/PropagationScenariosBenchmark.java create mode 100644 dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/ProbabilitySamplingState.java create mode 100644 dd-trace-core/src/test/java/datadog/trace/core/propagation/ptags/ProbabilitySamplingStateTest.java diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/PropagationScenariosBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/PropagationScenariosBenchmark.java new file mode 100644 index 00000000000..746357ebadc --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/PropagationScenariosBenchmark.java @@ -0,0 +1,241 @@ +package datadog.trace.core; + +import static datadog.trace.api.config.TracerConfig.TRACE_RATE_LIMIT; +import static datadog.trace.api.config.TracerConfig.TRACE_SAMPLE_RATE; +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +import datadog.context.Context; +import datadog.context.propagation.CarrierSetter; +import datadog.context.propagation.CarrierVisitor; +import datadog.context.propagation.Propagators; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext; +import datadog.trace.common.sampling.Sampler; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import java.util.Random; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Per-request tracing cost of the standard propagation scenarios, with a default-configured tracer + * (default propagation styles: Datadog, W3C trace context and baggage). It puts a cost on the + * per-trace sampling and propagation bookkeeping, such as the OpenTelemetry {@code ot} tracestate + * member that v1.67.0 started generating, as a service actually sees it. + * + *

This is a scenario benchmark: each arm is a whole request as a service handles it, + * driven through the tracer's public API, rather than one method in isolation. A change to any + * piece shows up as what it costs a real request, and the result doesn't depend on which internals + * the benchmark happens to call. The scenarios: + * + *

    + *
  • {@link #headNoPropagation}: a root service span that makes the sampling decision (at + * finish) and calls nothing downstream. + *
  • {@link #headPropagation}: a root service span plus an outbound client span that injects, so + * the decision is made at injection and written into the headers. + *
  • {@link #leaf}: extracts an upstream decision and calls nothing downstream. + *
  • {@link #intermediate}: extracts an upstream decision and relays it through an outbound + * client span. + *
+ * + *

{@code upstream} selects what the extracting scenarios receive: {@code sameVersion} is what + * this tracer itself injects (a fleet on one version), and {@code withOtelMember} is a fixed + * current format carrying an {@code ot} member (an upgraded upstream, whatever this tracer's + * version). Upstream headers cycle through 1024 distinct trace ids so caches keyed by header + * content see realistic variety. + * + *

Read the allocation columns. Run with {@code -prof gc}; {@code gc.alloc.rate.norm} + * (B/op) is the primary signal. Throughput is directional only. + * + *

Drift-stable so it can be built onto past release tags (e.g. v1.66.0, the last release + * before the {@code ot} member was generated). It uses only the {@code datadog.context} propagation + * API, {@link CoreTracer} builder/span API, {@link Sampler.Builder#forConfig} and {@link + * DropWriter}, all unchanged from v1.66.0 to master. Keep it inside that surface. + * + *

+ *   ./gradlew :dd-trace-core:jmhJar
+ *   java -jar dd-trace-core/build/libs/dd-trace-core-*-jmh.jar PropagationScenariosBenchmark -prof gc
+ * 
+ */ +@State(Scope.Benchmark) +@Warmup(iterations = 5) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.Throughput) +@Threads(8) +@OutputTimeUnit(MICROSECONDS) +@Fork(value = 3, jvmArgsAppend = "-DTEST_LOG_LEVEL=warn") +public class PropagationScenariosBenchmark { + private static final String INSTRUMENTATION_NAME = "bench"; + private static final String SERVER_OPERATION_NAME = "servlet.request"; + private static final String CLIENT_OPERATION_NAME = "http.request"; + private static final int UPSTREAM_TRACE_COUNT = 1024; + // 128-bit trace ids carry their creation time in the top 32 bits, the rest of the high half zero + private static final long TRACE_ID_EPOCH_SECONDS = 1_760_000_000L; + + /** Injects into the benchmark's blackhole, so header values are consumed without a map. */ + private static final CarrierSetter BLACKHOLE_SETTER = + (blackhole, key, value) -> blackhole.consume(value); + + private static final CarrierVisitor> MAP_VISITOR = Map::forEach; + + /** + * {@code default}: the default sampler with no agent rates, as most services run. {@code rule}: a + * configured sample rate (1.0, limit raised so the benchmark isn't rate limited). + */ + @Param({"default", "rule"}) + String sampler; + + CoreTracer tracer; + + @Setup + public void setup(Blackhole blackhole) { + CoreTracer.CoreTracerBuilder builder = CoreTracer.builder().writer(new DropWriter(blackhole)); + if ("rule".equals(sampler)) { + Properties ruleConfig = new Properties(); + ruleConfig.setProperty(TRACE_SAMPLE_RATE, "1.0"); + ruleConfig.setProperty(TRACE_RATE_LIMIT, String.valueOf(Integer.MAX_VALUE)); + builder.sampler(Sampler.Builder.forConfig(ruleConfig)); + } + // Building the tracer registers its propagators as the default ones. + tracer = builder.build(); + } + + @TearDown + public void tearDown() { + tracer.close(); + } + + @Benchmark + public void headNoPropagation() { + AgentSpan server = tracer.buildSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME).start(); + server.finish(); + } + + @Benchmark + public void headPropagation(Blackhole blackhole) { + AgentSpan server = tracer.buildSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME).start(); + callDownstream(server, blackhole); + server.finish(); + } + + @Benchmark + public void leaf(Upstream upstream, Cursor cursor) { + AgentSpan server = + tracer + .buildSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME) + .asChildOf(extract(upstream.headers[cursor.next()])) + .start(); + server.finish(); + } + + @Benchmark + public void intermediate(Upstream upstream, Cursor cursor, Blackhole blackhole) { + AgentSpan server = + tracer + .buildSpan(INSTRUMENTATION_NAME, SERVER_OPERATION_NAME) + .asChildOf(extract(upstream.headers[cursor.next()])) + .start(); + callDownstream(server, blackhole); + server.finish(); + } + + private void callDownstream(AgentSpan server, Blackhole blackhole) { + AgentSpan client = + tracer + .buildSpan(INSTRUMENTATION_NAME, CLIENT_OPERATION_NAME) + .asChildOf(server.spanContext()) + .start(); + Propagators.defaultPropagator().inject(client, blackhole, BLACKHOLE_SETTER); + client.finish(); + } + + private static AgentSpanContext extract(Map headers) { + Context extracted = + Propagators.defaultPropagator().extract(Context.root(), headers, MAP_VISITOR); + return AgentSpan.fromContext(extracted).spanContext(); + } + + /** The incoming headers the extracting scenarios cycle through. */ + @State(Scope.Benchmark) + public static class Upstream { + @Param({"sameVersion", "withOtelMember"}) + String upstream; + + @SuppressWarnings("unchecked") + final Map[] headers = new Map[UPSTREAM_TRACE_COUNT]; + + @Setup + public void setup(PropagationScenariosBenchmark benchmark) { + Random random = new Random(42); + for (int i = 0; i < UPSTREAM_TRACE_COUNT; ++i) { + headers[i] = + "sameVersion".equals(upstream) + ? injectedBy(benchmark.tracer) + : currentFormat( + random.nextLong() | 1, + (TRACE_ID_EPOCH_SECONDS + i) << 32, + random.nextLong() | 1); + } + } + + private static Map injectedBy(CoreTracer tracer) { + Map headers = new HashMap<>(); + AgentSpan span = tracer.buildSpan(INSTRUMENTATION_NAME, CLIENT_OPERATION_NAME).start(); + Propagators.defaultPropagator().inject(span, headers, Map::put); + span.finish(); + return headers; + } + + /** What a current Datadog tracer sends after a default-sampler keep. */ + private static Map currentFormat( + long traceIdLow, long traceIdHigh, long parentId) { + String tid = hex16(traceIdHigh); + String parent = hex16(parentId); + Map headers = new HashMap<>(); + headers.put("x-datadog-trace-id", Long.toUnsignedString(traceIdLow)); + headers.put("x-datadog-parent-id", Long.toUnsignedString(parentId)); + headers.put("x-datadog-sampling-priority", "1"); + headers.put("x-datadog-tags", "_dd.p.dm=-1,_dd.p.tid=" + tid + ",_dd.p.ksr=1"); + headers.put("traceparent", "00-" + tid + hex16(traceIdLow) + "-" + parent + "-01"); + headers.put( + "tracestate", + "dd=s:1;p:" + + parent + + ";t.dm:-1;t.tid:" + + tid + + ";t.ksr:1,ot=rv:" + + hex16(traceIdLow).substring(2) + + ";th:0"); + return headers; + } + + private static String hex16(long value) { + String hex = Long.toHexString(value); + return "0000000000000000".substring(hex.length()) + hex; + } + } + + /** Per-thread position in the upstream headers. */ + @State(Scope.Thread) + public static class Cursor { + int index; + + int next() { + index = (index + 1) & (UPSTREAM_TRACE_COUNT - 1); + return index; + } + } +} diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/SamplingDecisionBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/SamplingDecisionBenchmark.java index 05f8421c91b..8fa9e42982e 100644 --- a/dd-trace-core/src/jmh/java/datadog/trace/core/SamplingDecisionBenchmark.java +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/SamplingDecisionBenchmark.java @@ -127,6 +127,30 @@ public PropagationTags ptagsProbabilityKeep(ThreadState thread) { return ptags; } + /** + * A probability decision followed by the Datadog header, which doesn't read the OTel tracestate. + */ + @Benchmark + public String ptagsProbabilityKeepDatadogHeader(ThreadState thread) { + return ptagsProbabilityKeep(thread).headerValue(PropagationTags.HeaderType.DATADOG); + } + + /** + * A probability decision followed by the W3C {@code tracestate} header, which includes the OTel + * {@code ot} member. + */ + @Benchmark + public String ptagsProbabilityKeepW3CHeader(ThreadState thread) { + return ptagsProbabilityKeep(thread).headerValue(PropagationTags.HeaderType.W3C); + } + + /** A probability decision followed by the tracestate OTLP export writes, OTel member included. */ + @Benchmark + public String ptagsProbabilityKeepOtlpTracestate(ThreadState thread) { + PropagationTags ptags = ptagsProbabilityKeep(thread); + return ptags.getW3CTracestate(ptags.samplingState()); + } + /** A priority-only decision (manual keep), on fresh trace-level propagation tags. */ @Benchmark public PropagationTags ptagsPriorityKeep() { diff --git a/dd-trace-core/src/main/java/datadog/trace/core/propagation/PropagationTags.java b/dd-trace-core/src/main/java/datadog/trace/core/propagation/PropagationTags.java index 3660a6bae81..86552fdf1cf 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/propagation/PropagationTags.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/propagation/PropagationTags.java @@ -24,7 +24,11 @@ */ public abstract class PropagationTags { - public static final class SamplingState { + /** + * An immutable snapshot of the trace's sampling decision. Not final only so that a fresh + * probability decision can defer building its OpenTelemetry tracestate until it's read. + */ + public static class SamplingState { private static final byte OTEL_POSITION_UNKNOWN = 0; private static final byte OTEL_POSITION_PRESERVED = 1; private static final byte OTEL_POSITION_REPLACED = 2; @@ -57,6 +61,7 @@ public String getTracestate() { return tracestate; } + /** The {@code ot} tracestate member, which a subclass may build on first read. */ public CharSequence getOtelTraceState() { return otelTraceState; } @@ -70,7 +75,11 @@ public CharSequence getKnuthSamplingRate() { } public boolean preservesInheritedOtelPosition() { - if (tracestate == null || otelTraceState == null) { + if (tracestate == null) { + return false; + } + CharSequence otelTraceState = getOtelTraceState(); + if (otelTraceState == null) { return false; } byte position = otelPosition; diff --git a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsFactory.java b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsFactory.java index d4a01896252..fb197efa226 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsFactory.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/PTagsFactory.java @@ -308,25 +308,37 @@ public boolean tryUpdateProbabilitySamplingDecision( if (!allowOverride && current.getSamplingPriority() != PrioritySampling.UNSET) { return false; } + TagValue nextKnuthSamplingRate = knuthSamplingRateTagValue(sampleRate); + boolean updateDecisionMaker = + canChangeDecisionMaker || samplingMechanism == SamplingMechanism.EXTERNAL_OVERRIDE; OtelTraceState nextOtelTraceState = getOtelTraceState(); if (nextOtelTraceState == null) { if (!rateLimiterRejected) { - nextOtelTraceState = - OtelTraceState.fromProbabilityDecision( - traceIdLowOrderBits, sampleRate, samplingPriority); + // A fresh decision always yields a new otel state, so there's no no-op check to make + TagValue nextDecisionMakerTagValue = + nextDecisionMakerTagValue(samplingPriority, samplingMechanism, updateDecisionMaker); + clearCachedHeader(W3C); + samplingState = + new ProbabilitySamplingState( + samplingPriority, + tracestate, + nextDecisionMakerTagValue, + nextKnuthSamplingRate, + traceIdLowOrderBits, + sampleRate); + return true; } } else if (rateLimiterRejected) { nextOtelTraceState = nextOtelTraceState.withoutThreshold(); } else { nextOtelTraceState = nextOtelTraceState.withoutInheritedThreshold(); } - TagValue nextKnuthSamplingRate = knuthSamplingRateTagValue(sampleRate); installSamplingState( samplingPriority, samplingMechanism, nextOtelTraceState, nextKnuthSamplingRate, - canChangeDecisionMaker || samplingMechanism == SamplingMechanism.EXTERNAL_OVERRIDE); + updateDecisionMaker); return true; } } @@ -364,6 +376,31 @@ private void installSamplingState( TagValue nextKnuthSamplingRateTagValue, boolean updateDecisionMaker) { SamplingState currentState = samplingState; + TagValue nextDecisionMakerTagValue = + nextDecisionMakerTagValue(samplingPriority, samplingMechanism, updateDecisionMaker); + if (currentState.getSamplingPriority() == samplingPriority + && currentState.getTracestate() == tracestate + && currentState.getOtelTraceState() == nextOtelTraceState + && Objects.equals(currentState.getDecisionMaker(), nextDecisionMakerTagValue) + && Objects.equals(currentState.getKnuthSamplingRate(), nextKnuthSamplingRateTagValue)) { + return; + } + clearCachedHeader(W3C); + samplingState = + new SamplingState( + samplingPriority, + tracestate, + nextOtelTraceState, + nextDecisionMakerTagValue, + nextKnuthSamplingRateTagValue); + } + + /** + * The decision maker value after a decision, clearing the cached headers if it changes. Called + * with the sampling state lock held. + */ + private TagValue nextDecisionMakerTagValue( + int samplingPriority, int samplingMechanism, boolean updateDecisionMaker) { TagValue nextDecisionMakerTagValue = getDecisionMakerTagValue(); if (updateDecisionMaker && samplingPriority > 0) { // TODO should try to keep the old sampling mechanism if we override the value? @@ -389,21 +426,7 @@ private void installSamplingState( } nextDecisionMakerTagValue = null; } - if (currentState.getSamplingPriority() == samplingPriority - && currentState.getTracestate() == tracestate - && currentState.getOtelTraceState() == nextOtelTraceState - && Objects.equals(currentState.getDecisionMaker(), nextDecisionMakerTagValue) - && Objects.equals(currentState.getKnuthSamplingRate(), nextKnuthSamplingRateTagValue)) { - return; - } - clearCachedHeader(W3C); - samplingState = - new SamplingState( - samplingPriority, - tracestate, - nextOtelTraceState, - nextDecisionMakerTagValue, - nextKnuthSamplingRateTagValue); + return nextDecisionMakerTagValue; } private static SamplingState initialSamplingState( diff --git a/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/ProbabilitySamplingState.java b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/ProbabilitySamplingState.java new file mode 100644 index 00000000000..92c7d55709c --- /dev/null +++ b/dd-trace-core/src/main/java/datadog/trace/core/propagation/ptags/ProbabilitySamplingState.java @@ -0,0 +1,52 @@ +package datadog.trace.core.propagation.ptags; + +import datadog.trace.core.propagation.PropagationTags.SamplingState; +import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; + +/** + * The sampling state for a fresh probability decision, one with no inherited OpenTelemetry + * tracestate. Every such root decision used to build its {@link OtelTraceState} up front, though + * only W3C injection and OTLP export ever read it. This keeps the two inputs instead and builds the + * {@code ot} member on first read, so a trace that is never injected or exported over OTLP doesn't + * pay for it. + */ +final class ProbabilitySamplingState extends SamplingState { + private static final AtomicReferenceFieldUpdater + OTEL_TRACE_STATE_UPDATER = + AtomicReferenceFieldUpdater.newUpdater( + ProbabilitySamplingState.class, OtelTraceState.class, "otelTraceState"); + + private final long traceIdLowOrderBits; + private final double sampleRate; + + private volatile OtelTraceState otelTraceState; + + ProbabilitySamplingState( + int samplingPriority, + String tracestate, + CharSequence decisionMaker, + CharSequence knuthSamplingRate, + long traceIdLowOrderBits, + double sampleRate) { + super(samplingPriority, tracestate, null, decisionMaker, knuthSamplingRate); + this.traceIdLowOrderBits = traceIdLowOrderBits; + this.sampleRate = sampleRate; + } + + /** + * Builds the {@code ot} member on first read. Racing readers agree on one instance, so identity + * checks on otel states keep skipping redundant work, and publication doesn't depend on {@link + * OtelTraceState} keeping its fields final. + */ + @Override + public CharSequence getOtelTraceState() { + OtelTraceState current = otelTraceState; + if (current != null) { + return current; + } + OtelTraceState built = + OtelTraceState.fromProbabilityDecision( + traceIdLowOrderBits, sampleRate, getSamplingPriority()); + return OTEL_TRACE_STATE_UPDATER.compareAndSet(this, null, built) ? built : otelTraceState; + } +} diff --git a/dd-trace-core/src/test/java/datadog/trace/core/propagation/ptags/ProbabilitySamplingStateTest.java b/dd-trace-core/src/test/java/datadog/trace/core/propagation/ptags/ProbabilitySamplingStateTest.java new file mode 100644 index 00000000000..b5e0e81f381 --- /dev/null +++ b/dd-trace-core/src/test/java/datadog/trace/core/propagation/ptags/ProbabilitySamplingStateTest.java @@ -0,0 +1,125 @@ +package datadog.trace.core.propagation.ptags; + +import static datadog.trace.api.sampling.PrioritySampling.SAMPLER_DROP; +import static datadog.trace.api.sampling.PrioritySampling.SAMPLER_KEEP; +import static datadog.trace.api.sampling.SamplingMechanism.AGENT_RATE; +import static datadog.trace.api.sampling.SamplingMechanism.MANUAL; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import datadog.trace.core.propagation.PropagationTags; +import datadog.trace.core.propagation.PropagationTags.SamplingState; +import java.util.concurrent.CountDownLatch; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class ProbabilitySamplingStateTest { + private static final long TRACE_ID_LOW_ORDER_BITS = 0xfff972474538efffL; + + private final PropagationTags.Factory factory = PropagationTags.factory(); + + @TableTest({ + "scenario | priority | rate", + "keep | SAMPLER_KEEP | 0.1 ", + "drop | SAMPLER_DROP | 0.1 ", + "always | SAMPLER_KEEP | 1.0 ", + "never | SAMPLER_DROP | 0.0 " + }) + void freshDecisionBuildsSameOtelStateAsEagerConversion(String priority, double rate) { + int samplingPriority = "SAMPLER_KEEP".equals(priority) ? SAMPLER_KEEP : SAMPLER_DROP; + PropagationTags ptags = factory.empty(); + + ptags.tryUpdateProbabilitySamplingDecision( + samplingPriority, AGENT_RATE, rate, false, TRACE_ID_LOW_ORDER_BITS, false); + + SamplingState state = ptags.samplingState(); + assertInstanceOf(ProbabilitySamplingState.class, state); + assertEquals( + OtelTraceState.fromProbabilityDecision(TRACE_ID_LOW_ORDER_BITS, rate, samplingPriority) + .toString(), + state.getOtelTraceState().toString()); + } + + @Test + void repeatedReadsReturnOneInstance() { + PropagationTags ptags = factory.empty(); + ptags.tryUpdateProbabilitySamplingDecision( + SAMPLER_KEEP, AGENT_RATE, 0.1, false, TRACE_ID_LOW_ORDER_BITS, false); + + SamplingState state = ptags.samplingState(); + + assertSame(state.getOtelTraceState(), state.getOtelTraceState()); + } + + /** + * Readers racing to build the state agree on one instance. A stress test can't force the race, so + * this is a smoke test of the compare-and-set: it can't fail while that holds. + */ + @Test + void racingReadsReturnOneInstance() throws InterruptedException { + int readers = 8; + for (int trial = 0; trial < 100; trial++) { + PropagationTags ptags = factory.empty(); + ptags.tryUpdateProbabilitySamplingDecision( + SAMPLER_KEEP, AGENT_RATE, 0.1, false, TRACE_ID_LOW_ORDER_BITS, false); + SamplingState state = ptags.samplingState(); + + CharSequence[] seen = new CharSequence[readers]; + CountDownLatch start = new CountDownLatch(1); + Thread[] threads = new Thread[readers]; + for (int i = 0; i < readers; i++) { + int reader = i; + threads[i] = + new Thread( + () -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + seen[reader] = state.getOtelTraceState(); + }); + threads[i].start(); + } + start.countDown(); + for (Thread thread : threads) { + thread.join(); + } + + for (CharSequence otelTraceState : seen) { + assertSame(seen[0], otelTraceState); + } + assertSame(seen[0], state.getOtelTraceState()); + } + } + + @Test + void rateLimiterRejectionRecordsNoOtelState() { + PropagationTags ptags = factory.empty(); + ptags.tryUpdateProbabilitySamplingDecision( + SAMPLER_DROP, AGENT_RATE, 0.1, true, TRACE_ID_LOW_ORDER_BITS, false); + + SamplingState state = ptags.samplingState(); + + assertEquals(SamplingState.class, state.getClass()); + assertNull(state.getOtelTraceState()); + } + + @Test + void laterDecisionStartsFromDeferredOtelState() { + PropagationTags ptags = factory.empty(); + ptags.tryUpdateProbabilitySamplingDecision( + SAMPLER_KEEP, AGENT_RATE, 0.1, false, TRACE_ID_LOW_ORDER_BITS, false); + + ptags.forceKeep(MANUAL); + + // A locally made probability state has nothing inherited to keep, so a manual keep drops it + assertNull( + OtelTraceState.fromProbabilityDecision(TRACE_ID_LOW_ORDER_BITS, 0.1, SAMPLER_KEEP) + .forNonProbabilityDecision()); + assertNull(ptags.samplingState().getOtelTraceState()); + } +}