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: + * + *

+ * + *

{@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()); + } +}