diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/monitor/LegacyTracerHealthMetrics.java b/dd-trace-core/src/jmh/java/datadog/trace/core/monitor/LegacyTracerHealthMetrics.java new file mode 100644 index 00000000000..0a08f2b6d90 --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/monitor/LegacyTracerHealthMetrics.java @@ -0,0 +1,264 @@ +package datadog.trace.core.monitor; + +import static datadog.trace.api.sampling.PrioritySampling.SAMPLER_DROP; +import static datadog.trace.api.sampling.PrioritySampling.SAMPLER_KEEP; +import static datadog.trace.api.sampling.PrioritySampling.USER_DROP; +import static datadog.trace.api.sampling.PrioritySampling.USER_KEEP; + +import datadog.metrics.api.statsd.StatsDClient; +import datadog.trace.common.writer.RemoteApi; +import java.util.concurrent.atomic.LongAdder; + +/** + * A faithful reconstruction of the pre-{@code Accumulator} {@code TracerHealthMetrics} -- one + * {@link LongAdder} field per counter, hand-rolled switch statements, a hand-concatenated {@code + * summary()} -- as it stood at {@code 77964b3996} (the last commit on {@code master} before the + * migration), restricted to the exact methods {@link TracerHealthMetricsBenchmark} exercises. Kept + * as a standalone class here (not resurrected via checkout) purely for a same-run, same-JVM + * before/after comparison; it is not wired into anything and should never be. + */ +class LegacyTracerHealthMetrics { + + private final LongAdder apiRequests = new LongAdder(); + private final LongAdder apiErrors = new LongAdder(); + private final LongAdder apiResponsesOK = new LongAdder(); + + private final LongAdder userDropEnqueuedTraces = new LongAdder(); + private final LongAdder userKeepEnqueuedTraces = new LongAdder(); + private final LongAdder samplerDropEnqueuedTraces = new LongAdder(); + private final LongAdder samplerKeepEnqueuedTraces = new LongAdder(); + private final LongAdder unsetPriorityEnqueuedTraces = new LongAdder(); + + private final LongAdder userDropDroppedTraces = new LongAdder(); + private final LongAdder userKeepDroppedTraces = new LongAdder(); + private final LongAdder samplerDropDroppedTraces = new LongAdder(); + private final LongAdder samplerKeepDroppedTraces = new LongAdder(); + private final LongAdder serialFailedDroppedTraces = new LongAdder(); + private final LongAdder unsetPriorityDroppedTraces = new LongAdder(); + + private final LongAdder userDropDroppedSpans = new LongAdder(); + private final LongAdder userKeepDroppedSpans = new LongAdder(); + private final LongAdder samplerDropDroppedSpans = new LongAdder(); + private final LongAdder samplerKeepDroppedSpans = new LongAdder(); + private final LongAdder serialFailedDroppedSpans = new LongAdder(); + private final LongAdder unsetPriorityDroppedSpans = new LongAdder(); + + private final LongAdder enqueuedSpans = new LongAdder(); + private final LongAdder enqueuedBytes = new LongAdder(); + private final LongAdder createdTraces = new LongAdder(); + private final LongAdder createdSpans = new LongAdder(); + private final LongAdder finishedSpans = new LongAdder(); + private final LongAdder flushedTraces = new LongAdder(); + private final LongAdder flushedBytes = new LongAdder(); + private final LongAdder partialTraces = new LongAdder(); + private final LongAdder partialBytes = new LongAdder(); + private final LongAdder clientSpansWithoutContext = new LongAdder(); + + private final LongAdder singleSpanSampled = new LongAdder(); + private final LongAdder singleSpanUnsampled = new LongAdder(); + + private final LongAdder capturedContinuations = new LongAdder(); + private final LongAdder cancelledContinuations = new LongAdder(); + private final LongAdder finishedContinuations = new LongAdder(); + + private final LongAdder activatedScopes = new LongAdder(); + private final LongAdder closedScopes = new LongAdder(); + private final LongAdder scopeStackOverflow = new LongAdder(); + private final LongAdder scopeCloseErrors = new LongAdder(); + private final LongAdder userScopeCloseErrors = new LongAdder(); + + private final LongAdder longRunningTracesWrite = new LongAdder(); + private final LongAdder longRunningTracesDropped = new LongAdder(); + private final LongAdder longRunningTracesExpired = new LongAdder(); + + private final LongAdder clientStatsProcessedSpans = new LongAdder(); + private final LongAdder clientStatsProcessedTraces = new LongAdder(); + private final LongAdder clientStatsP0DroppedSpans = new LongAdder(); + private final LongAdder clientStatsP0DroppedTraces = new LongAdder(); + private final LongAdder clientStatsRequests = new LongAdder(); + private final LongAdder clientStatsErrors = new LongAdder(); + private final LongAdder clientStatsDowngrades = new LongAdder(); + + private final LongAdder statsAggregateDropped = new LongAdder(); + private final LongAdder statsInboxFull = new LongAdder(); + + private final StatsDClient statsd; + + LegacyTracerHealthMetrics(StatsDClient statsd) { + this.statsd = statsd; + } + + void onCreateSpan() { + createdSpans.increment(); + } + + void onFailedPublish(final int samplingPriority, final int spanCount) { + switch (samplingPriority) { + case USER_DROP: + userDropDroppedSpans.add(spanCount); + userDropDroppedTraces.increment(); + break; + case USER_KEEP: + userKeepDroppedSpans.add(spanCount); + userKeepDroppedTraces.increment(); + break; + case SAMPLER_DROP: + samplerDropDroppedSpans.add(spanCount); + samplerDropDroppedTraces.increment(); + break; + case SAMPLER_KEEP: + samplerKeepDroppedSpans.add(spanCount); + samplerKeepDroppedTraces.increment(); + break; + default: + unsetPriorityDroppedSpans.add(spanCount); + unsetPriorityDroppedTraces.increment(); + } + } + + void onPartialPublish(final int numberOfDroppedSpans) { + partialTraces.increment(); + samplerDropDroppedSpans.add(numberOfDroppedSpans); + } + + void onSend(final int traceCount, final int sizeInBytes, final RemoteApi.Response response) { + onSendAttempt(traceCount, sizeInBytes, response); + } + + private void onSendAttempt( + final int traceCount, final int sizeInBytes, final RemoteApi.Response response) { + apiRequests.increment(); + flushedTraces.add(traceCount); + flushedBytes.add(sizeInBytes); + + if (response.exception().isPresent()) { + apiErrors.increment(); + } + + int status = response.status().orElse(0); + if (status != 0) { + if (200 == status) { + apiResponsesOK.increment(); + } else { + statsd.incrementCounter("api.responses.total", "status:" + status); + } + } + } + + String summary() { + return "apiRequests=" + + apiRequests.sum() + + "\napiErrors=" + + apiErrors.sum() + + "\napiResponsesOK=" + + apiResponsesOK.sum() + + "\n" + + "\nuserDropEnqueuedTraces=" + + userDropEnqueuedTraces.sum() + + "\nuserKeepEnqueuedTraces=" + + userKeepEnqueuedTraces.sum() + + "\nsamplerDropEnqueuedTraces=" + + samplerDropEnqueuedTraces.sum() + + "\nsamplerKeepEnqueuedTraces=" + + samplerKeepEnqueuedTraces.sum() + + "\nunsetPriorityEnqueuedTraces=" + + unsetPriorityEnqueuedTraces.sum() + + "\n" + + "\nuserDropDroppedTraces=" + + userDropDroppedTraces.sum() + + "\nuserKeepDroppedTraces=" + + userKeepDroppedTraces.sum() + + "\nsamplerDropDroppedTraces=" + + samplerDropDroppedTraces.sum() + + "\nsamplerKeepDroppedTraces=" + + samplerKeepDroppedTraces.sum() + + "\nserialFailedDroppedTraces=" + + serialFailedDroppedTraces.sum() + + "\nunsetPriorityDroppedTraces=" + + unsetPriorityDroppedTraces.sum() + + "\n" + + "\nuserDropDroppedSpans=" + + userDropDroppedSpans.sum() + + "\nuserKeepDroppedSpans=" + + userKeepDroppedSpans.sum() + + "\nsamplerDropDroppedSpans=" + + samplerDropDroppedSpans.sum() + + "\nsamplerKeepDroppedSpans=" + + samplerKeepDroppedSpans.sum() + + "\nserialFailedDroppedSpans=" + + serialFailedDroppedSpans.sum() + + "\nunsetPriorityDroppedSpans=" + + unsetPriorityDroppedSpans.sum() + + "\n" + + "\nenqueuedSpans=" + + enqueuedSpans.sum() + + "\nenqueuedBytes=" + + enqueuedBytes.sum() + + "\ncreatedTraces=" + + createdTraces.sum() + + "\ncreatedSpans=" + + createdSpans.sum() + + "\nfinishedSpans=" + + finishedSpans.sum() + + "\nflushedTraces=" + + flushedTraces.sum() + + "\nflushedBytes=" + + flushedBytes.sum() + + "\npartialTraces=" + + partialTraces.sum() + + "\npartialBytes=" + + partialBytes.sum() + + "\n" + + "\nclientSpansWithoutContext=" + + clientSpansWithoutContext.sum() + + "\n" + + "\nsingleSpanSampled=" + + singleSpanSampled.sum() + + "\nsingleSpanUnsampled=" + + singleSpanUnsampled.sum() + + "\n" + + "\ncapturedContinuations=" + + capturedContinuations.sum() + + "\ncancelledContinuations=" + + cancelledContinuations.sum() + + "\nfinishedContinuations=" + + finishedContinuations.sum() + + "\n" + + "\nactivatedScopes=" + + activatedScopes.sum() + + "\nclosedScopes=" + + closedScopes.sum() + + "\nscopeStackOverflow=" + + scopeStackOverflow.sum() + + "\nscopeCloseErrors=" + + scopeCloseErrors.sum() + + "\nuserScopeCloseErrors=" + + userScopeCloseErrors.sum() + + "\n" + + "\nlongRunningTracesWrite=" + + longRunningTracesWrite.sum() + + "\nlongRunningTracesDropped=" + + longRunningTracesDropped.sum() + + "\nlongRunningTracesExpired=" + + longRunningTracesExpired.sum() + + "\n" + + "\nclientStatsRequests=" + + clientStatsRequests.sum() + + "\nclientStatsErrors=" + + clientStatsErrors.sum() + + "\nclientStatsDowngrades=" + + clientStatsDowngrades.sum() + + "\nclientStatsP0DroppedSpans=" + + clientStatsP0DroppedSpans.sum() + + "\nclientStatsP0DroppedTraces=" + + clientStatsP0DroppedTraces.sum() + + "\nclientStatsProcessedSpans=" + + clientStatsProcessedSpans.sum() + + "\nclientStatsProcessedTraces=" + + clientStatsProcessedTraces.sum() + + "\nstatsAggregateDropped=" + + statsAggregateDropped.sum() + + "\nstatsInboxFull=" + + statsInboxFull.sum(); + } +} diff --git a/dd-trace-core/src/jmh/java/datadog/trace/core/monitor/TracerHealthMetricsBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/core/monitor/TracerHealthMetricsBenchmark.java new file mode 100644 index 00000000000..89de8beaef5 --- /dev/null +++ b/dd-trace-core/src/jmh/java/datadog/trace/core/monitor/TracerHealthMetricsBenchmark.java @@ -0,0 +1,243 @@ +package datadog.trace.core.monitor; + +import static datadog.trace.api.sampling.PrioritySampling.SAMPLER_DROP; +import static java.util.concurrent.TimeUnit.MICROSECONDS; + +import datadog.metrics.api.statsd.StatsDClient; +import datadog.trace.common.writer.RemoteApi; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Group; +import org.openjdk.jmh.annotations.GroupThreads; +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.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Direct measurement of the real {@link TracerHealthMetrics} entry points hit on the tracing hot + * path -- the {@link datadog.trace.util.Accumulator}-backed implementation, not a synthetic + * stand-in -- so the {@code AccumulatorBenchmark} numbers (raw {@code Accumulator} vs {@code + * LongAdder}) can be checked against what the real per-call-site shapes cost once real switches, + * multi-counter updates, and response-as-context dispatch are involved. All calls go through {@link + * StatsDClient#NO_OP} so only the accumulator-side cost is measured, not statsd transport. + * + *

{@code onCreateSpan}/{@code onFinishSpan}/{@code onActivateScope}/{@code onCloseScope} are the + * highest-frequency calls (once per span/scope) and are each a single {@code inc()}. {@code + * onFailedPublish} exercises a switch plus two independent (ungrouped) counter updates. {@code + * onPartialPublish} exercises the boxing-free {@code update(long, ObjLongConsumer)} grouped-update + * path. {@code onSend} exercises {@code onSendAttempt}'s split shape: three top-level calls plus + * one {@code update(response, ...)} grouping the two response-derived counters under one lock. + * + *

{@code summaryWhileWriting} pairs concurrent {@code onCreateSpan} writers against a single + * reader repeatedly calling {@code summary()} -- {@code summary()} only peeks ({@code + * Accumulator#sum()}), never drains, so it should not stall the periodic {@code Flush} task nor + * meaningfully slow down concurrent writers; this checks that design assumption under load rather + * than just asserting it. + * + *

Before/after. The {@code legacy*} benchmarks run the identical inputs against {@link + * LegacyTracerHealthMetrics}, a faithful reconstruction of pre-migration {@code + * TracerHealthMetrics} (one {@code LongAdder} field per counter, hand-rolled switches, a + * hand-concatenated {@code summary()}) as it stood at {@code 77964b3996}, the last commit before + * this migration -- a same-run, same-JVM before/after comparison of the real class, not just the + * underlying primitive. + * + *

Results. After {@link datadog.trace.util.Accumulator}'s lock-free {@code + * AtomicLongArray}-striping rewrite, every hot single-counter call (uncontended) lands at + * 0.007-0.009 us/op -- indistinguishable from {@code AccumulatorBenchmark}'s raw {@code + * accumulatorIncrement_lowContention} (0.007 us/op). At {@code Threads.MAX} the real call sites + * stay just as flat (0.009-0.013 us/op): the CAS-based {@code getAndAdd} no longer pays the + * 3-4x {@code synchronized}-stripe contention penalty the earlier design did. {@code + * summaryWhileWriting} confirms the peek-not-drain design for {@code summary()}: concurrent + * readers don't measurably slow writers (0.008 us/op, same as uncontended {@code onCreateSpan}), + * at the cost of the read itself walking all 54 stripes non-destructively (~1.83 us/op) -- + * acceptable for a diagnostic/tracer-flare call, never on the span-emission path. Results are + * stable across JDK 17 and JDK 25 (point estimates agree to the millisecond-precision printed + * below), confirming this is the striping rewrite's effect, not a JIT/JVM-version artifact. + * + * Apple M1 Max, 10 CPUs - macOS/aarch64 - JDK 17 (Zulu) / JDK 25 (Zulu) + * Benchmark JDK17 JDK25 Units + * TracerHealthMetricsBenchmark.onCreateSpan_lowContention 0.007 0.007 us/op + * TracerHealthMetricsBenchmark.onCreateSpan_highContention 0.009 0.009 us/op + * TracerHealthMetricsBenchmark.onFailedPublish_lowContention 0.007 0.007 us/op + * TracerHealthMetricsBenchmark.onFailedPublish_highContention 0.010 0.010 us/op + * TracerHealthMetricsBenchmark.onPartialPublish_lowContention 0.007 0.007 us/op + * TracerHealthMetricsBenchmark.onPartialPublish_highContention 0.009 0.010 us/op + * TracerHealthMetricsBenchmark.onSend_lowContention 0.009 0.009 us/op + * TracerHealthMetricsBenchmark.onSend_highContention 0.013 0.013 us/op + * TracerHealthMetricsBenchmark.summaryWhileWriting 0.373 0.373 us/op + * TracerHealthMetricsBenchmark.summaryWhileWriting:...write 0.008 0.008 us/op + * TracerHealthMetricsBenchmark.summaryWhileWriting:...read 1.835 1.833 us/op + * + * + *

Before/after results. The {@code Accumulator}-backed implementation is now at parity + * with, or measurably faster than, the {@code LongAdder} baseline it replaced on every + * single-call-site benchmark, including under {@code Threads.MAX} contention -- a reversal of the + * earlier {@code synchronized}-stripe design's 1.1-4.6x cost documented before the lock-free + * rewrite (commit {@code 3ea84793d1}). {@code onSend}, the most expensive real call site (three + * top-level calls plus one two-counter grouped update), now costs 0.6-0.75x of legacy at both + * contention levels. {@code summary()} remains the one real cost: walking 54 stripes + * non-destructively still costs ~2.5-2.6x what summing 52 plain {@code LongAdder} fields does + * (~1.83 vs ~0.71 us/op) -- still far below the periodic (30s-default) {@code Flush} cadence and + * the ad hoc/diagnostic calls that trigger it, so not disqualifying. Neither implementation's + * writers are measurably slowed by a concurrent {@code summary()}/reader. + * Apple M1 Max, 10 CPUs - macOS/aarch64 - JDK 17 (Zulu) / JDK 25 (Zulu) + * Benchmark New (JDK17/25) Legacy (JDK17/25) Ratio + * onCreateSpan_lowContention 0.007 / 0.007 0.007 / 0.007 1.0x / 1.0x + * onCreateSpan_highContention 0.009 / 0.009 0.010 / 0.010 0.9x / 0.9x + * onFailedPublish_lowContention 0.007 / 0.007 0.008 / 0.008 0.9x / 0.9x + * onFailedPublish_highContention 0.010 / 0.010 0.011 / 0.012 0.9x / 0.8x + * onPartialPublish_lowContention 0.007 / 0.007 0.008 / 0.008 0.9x / 0.9x + * onPartialPublish_highContention 0.009 / 0.010 0.012 / 0.012 0.75x / 0.8x + * onSend_lowContention 0.009 / 0.009 0.012 / 0.013 0.75x / 0.7x + * onSend_highContention 0.013 / 0.013 0.020 / 0.022 0.65x / 0.6x + * summaryWhileWriting_write 0.008 / 0.008 0.009 / 0.008 0.9x / 1.0x + * summaryWhileWriting_read 1.835 / 1.833 0.705 / 0.720 2.6x / 2.55x + * (all figures us/op, avgt; JDK17 / JDK25) + * This means the migration's case no longer rests solely on eliminating the {@code + * previousCounts}/{@code countIndex} hand-tracking ceremony and giving each counter an atomic + * multi-field grouped update -- the lock-free rewrite makes it a speedup too, on every path except + * the diagnostic {@code summary()} read. + */ +@State(Scope.Benchmark) +@Warmup(iterations = 1, time = 10) +@Measurement(iterations = 3, time = 10) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(MICROSECONDS) +@Fork(2) +public class TracerHealthMetricsBenchmark { + + private final TracerHealthMetrics metrics = new TracerHealthMetrics(StatsDClient.NO_OP); + private final LegacyTracerHealthMetrics legacyMetrics = + new LegacyTracerHealthMetrics(StatsDClient.NO_OP); + private final RemoteApi.Response okResponse = RemoteApi.Response.success(200); + + @Benchmark + @Threads(1) + public void onCreateSpan_lowContention() { + metrics.onCreateSpan(); + } + + @Benchmark + @Threads(Threads.MAX) + public void onCreateSpan_highContention() { + metrics.onCreateSpan(); + } + + @Benchmark + @Threads(1) + public void onFailedPublish_lowContention() { + metrics.onFailedPublish(SAMPLER_DROP, 5); + } + + @Benchmark + @Threads(Threads.MAX) + public void onFailedPublish_highContention() { + metrics.onFailedPublish(SAMPLER_DROP, 5); + } + + @Benchmark + @Threads(1) + public void onPartialPublish_lowContention() { + metrics.onPartialPublish(3); + } + + @Benchmark + @Threads(Threads.MAX) + public void onPartialPublish_highContention() { + metrics.onPartialPublish(3); + } + + @Benchmark + @Threads(1) + public void onSend_lowContention() { + metrics.onSend(1, 512, okResponse); + } + + @Benchmark + @Threads(Threads.MAX) + public void onSend_highContention() { + metrics.onSend(1, 512, okResponse); + } + + @Benchmark + @Group("summaryWhileWriting") + @GroupThreads(4) + public void summaryWhileWriting_write() { + metrics.onCreateSpan(); + } + + @Benchmark + @Group("summaryWhileWriting") + @GroupThreads(1) + public void summaryWhileWriting_read(Blackhole blackhole) { + blackhole.consume(metrics.summary()); + } + + @Benchmark + @Threads(1) + public void legacyOnCreateSpan_lowContention() { + legacyMetrics.onCreateSpan(); + } + + @Benchmark + @Threads(Threads.MAX) + public void legacyOnCreateSpan_highContention() { + legacyMetrics.onCreateSpan(); + } + + @Benchmark + @Threads(1) + public void legacyOnFailedPublish_lowContention() { + legacyMetrics.onFailedPublish(SAMPLER_DROP, 5); + } + + @Benchmark + @Threads(Threads.MAX) + public void legacyOnFailedPublish_highContention() { + legacyMetrics.onFailedPublish(SAMPLER_DROP, 5); + } + + @Benchmark + @Threads(1) + public void legacyOnPartialPublish_lowContention() { + legacyMetrics.onPartialPublish(3); + } + + @Benchmark + @Threads(Threads.MAX) + public void legacyOnPartialPublish_highContention() { + legacyMetrics.onPartialPublish(3); + } + + @Benchmark + @Threads(1) + public void legacyOnSend_lowContention() { + legacyMetrics.onSend(1, 512, okResponse); + } + + @Benchmark + @Threads(Threads.MAX) + public void legacyOnSend_highContention() { + legacyMetrics.onSend(1, 512, okResponse); + } + + @Benchmark + @Group("legacySummaryWhileWriting") + @GroupThreads(4) + public void legacySummaryWhileWriting_write() { + legacyMetrics.onCreateSpan(); + } + + @Benchmark + @Group("legacySummaryWhileWriting") + @GroupThreads(1) + public void legacySummaryWhileWriting_read(Blackhole blackhole) { + blackhole.consume(legacyMetrics.summary()); + } +} diff --git a/dd-trace-core/src/main/java/datadog/trace/core/monitor/TracerHealthMetrics.java b/dd-trace-core/src/main/java/datadog/trace/core/monitor/TracerHealthMetrics.java index f9c76ff0766..9418d7d0fe4 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/monitor/TracerHealthMetrics.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/monitor/TracerHealthMetrics.java @@ -10,100 +10,34 @@ import static java.util.concurrent.TimeUnit.SECONDS; import datadog.metrics.api.statsd.StatsDClient; +import datadog.metrics.api.statsd.StatsDCountReporter; +import datadog.metrics.api.statsd.StatsDCounterKey; import datadog.trace.api.cache.RadixTreeCache; import datadog.trace.common.writer.RemoteApi; import datadog.trace.core.DDSpan; import datadog.trace.core.propagation.opg.OrgGuard; +import datadog.trace.util.Accumulator; import datadog.trace.util.AgentTaskScheduler; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.LongAdder; import java.util.function.IntFunction; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; public class TracerHealthMetrics extends HealthMetrics implements AutoCloseable { - private static final Logger log = LoggerFactory.getLogger(TracerHealthMetrics.class); private static final IntFunction STATUS_TAGS = httpStatus -> new String[] {"status:" + httpStatus}; private static final String[] NO_TAGS = new String[0]; private static final String[] COLLAPSED_WHOLE_KEY_TAGS = new String[] {"collapsed:whole_key"}; - private static final String[] STATUS_OK_TAGS = STATUS_TAGS.apply(200); private final RadixTreeCache statusTagsCache = new RadixTreeCache<>(16, 32, STATUS_TAGS, 200, 400); private final AtomicBoolean started = new AtomicBoolean(false); private volatile AgentTaskScheduler.Scheduled cancellation; - private final LongAdder apiRequests = new LongAdder(); - private final LongAdder apiErrors = new LongAdder(); - private final LongAdder apiResponsesOK = new LongAdder(); - - private final LongAdder userDropEnqueuedTraces = new LongAdder(); - private final LongAdder userKeepEnqueuedTraces = new LongAdder(); - private final LongAdder samplerDropEnqueuedTraces = new LongAdder(); - private final LongAdder samplerKeepEnqueuedTraces = new LongAdder(); - private final LongAdder unsetPriorityEnqueuedTraces = new LongAdder(); - - private final LongAdder userDropDroppedTraces = new LongAdder(); - private final LongAdder userKeepDroppedTraces = new LongAdder(); - private final LongAdder samplerDropDroppedTraces = new LongAdder(); - private final LongAdder samplerKeepDroppedTraces = new LongAdder(); - private final LongAdder serialFailedDroppedTraces = new LongAdder(); - private final LongAdder unsetPriorityDroppedTraces = new LongAdder(); - - private final LongAdder userDropDroppedSpans = new LongAdder(); - private final LongAdder userKeepDroppedSpans = new LongAdder(); - private final LongAdder samplerDropDroppedSpans = new LongAdder(); - private final LongAdder samplerKeepDroppedSpans = new LongAdder(); - private final LongAdder serialFailedDroppedSpans = new LongAdder(); - private final LongAdder unsetPriorityDroppedSpans = new LongAdder(); - - private final LongAdder enqueuedSpans = new LongAdder(); - private final LongAdder enqueuedBytes = new LongAdder(); - private final LongAdder createdTraces = new LongAdder(); - private final LongAdder createdSpans = new LongAdder(); - private final LongAdder finishedSpans = new LongAdder(); - private final LongAdder flushedTraces = new LongAdder(); - private final LongAdder flushedBytes = new LongAdder(); - private final LongAdder partialTraces = new LongAdder(); - private final LongAdder partialBytes = new LongAdder(); - private final LongAdder clientSpansWithoutContext = new LongAdder(); - - private final LongAdder singleSpanSampled = new LongAdder(); - private final LongAdder singleSpanUnsampled = new LongAdder(); - - private final LongAdder capturedContinuations = new LongAdder(); - private final LongAdder cancelledContinuations = new LongAdder(); - private final LongAdder finishedContinuations = new LongAdder(); - - private final LongAdder activatedScopes = new LongAdder(); - private final LongAdder closedScopes = new LongAdder(); - private final LongAdder scopeStackOverflow = new LongAdder(); - private final LongAdder scopeCloseErrors = new LongAdder(); - private final LongAdder userScopeCloseErrors = new LongAdder(); - - private final LongAdder longRunningTracesWrite = new LongAdder(); - private final LongAdder longRunningTracesDropped = new LongAdder(); - private final LongAdder longRunningTracesExpired = new LongAdder(); - - private final LongAdder orgGuardEnforceMismatch = new LongAdder(); - private final LongAdder orgGuardEnforceStrictMissing = new LongAdder(); - - private final LongAdder clientStatsProcessedSpans = new LongAdder(); - private final LongAdder clientStatsProcessedTraces = new LongAdder(); - private final LongAdder clientStatsP0DroppedSpans = new LongAdder(); - private final LongAdder clientStatsP0DroppedTraces = new LongAdder(); - private final LongAdder clientStatsRequests = new LongAdder(); - private final LongAdder clientStatsErrors = new LongAdder(); - private final LongAdder clientStatsDowngrades = new LongAdder(); - - private final LongAdder statsAggregateDropped = new LongAdder(); - private final LongAdder statsInboxFull = new LongAdder(); + private final Accumulator metricAccumulator = Accumulator.of(Metric.class); + private volatile Accumulator.Counts storedTotal = Accumulator.Counts.zero(Metric.class); private final StatsDClient statsd; private final long interval; @@ -140,21 +74,21 @@ public void onShutdown(final boolean flushSuccess) {} public void onPublish(final List trace, final int samplingPriority) { switch (samplingPriority) { case USER_DROP: - userDropEnqueuedTraces.increment(); + metricAccumulator.inc(Metric.USER_DROP_ENQUEUED_TRACES); break; case USER_KEEP: - userKeepEnqueuedTraces.increment(); + metricAccumulator.inc(Metric.USER_KEEP_ENQUEUED_TRACES); break; case SAMPLER_DROP: - samplerDropEnqueuedTraces.increment(); + metricAccumulator.inc(Metric.SAMPLER_DROP_ENQUEUED_TRACES); break; case SAMPLER_KEEP: - samplerKeepEnqueuedTraces.increment(); + metricAccumulator.inc(Metric.SAMPLER_KEEP_ENQUEUED_TRACES); break; default: - unsetPriorityEnqueuedTraces.increment(); + metricAccumulator.inc(Metric.UNSET_PRIORITY_ENQUEUED_TRACES); } - enqueuedSpans.add(trace.size()); + metricAccumulator.add(Metric.ENQUEUED_SPANS, trace.size()); checkForClientSpansWithoutContext(trace); } @@ -163,7 +97,7 @@ private void checkForClientSpansWithoutContext(final List trace) { if (span != null && span.getParentId() == ZERO) { String spanKind = span.getTag(SPAN_KIND, "undefined"); if (SPAN_KIND_CLIENT.equals(spanKind)) { - this.clientSpansWithoutContext.increment(); + metricAccumulator.inc(Metric.CLIENT_SPANS_WITHOUT_CONTEXT); } } } @@ -173,31 +107,31 @@ private void checkForClientSpansWithoutContext(final List trace) { public void onFailedPublish(final int samplingPriority, final int spanCount) { switch (samplingPriority) { case USER_DROP: - userDropDroppedSpans.add(spanCount); - userDropDroppedTraces.increment(); + metricAccumulator.add(Metric.USER_DROP_DROPPED_SPANS, spanCount); + metricAccumulator.inc(Metric.USER_DROP_DROPPED_TRACES); break; case USER_KEEP: - userKeepDroppedSpans.add(spanCount); - userKeepDroppedTraces.increment(); + metricAccumulator.add(Metric.USER_KEEP_DROPPED_SPANS, spanCount); + metricAccumulator.inc(Metric.USER_KEEP_DROPPED_TRACES); break; case SAMPLER_DROP: - samplerDropDroppedSpans.add(spanCount); - samplerDropDroppedTraces.increment(); + metricAccumulator.add(Metric.SAMPLER_DROP_DROPPED_SPANS, spanCount); + metricAccumulator.inc(Metric.SAMPLER_DROP_DROPPED_TRACES); break; case SAMPLER_KEEP: - samplerKeepDroppedSpans.add(spanCount); - samplerKeepDroppedTraces.increment(); + metricAccumulator.add(Metric.SAMPLER_KEEP_DROPPED_SPANS, spanCount); + metricAccumulator.inc(Metric.SAMPLER_KEEP_DROPPED_TRACES); break; default: - unsetPriorityDroppedSpans.add(spanCount); - unsetPriorityDroppedTraces.increment(); + metricAccumulator.add(Metric.UNSET_PRIORITY_DROPPED_SPANS, spanCount); + metricAccumulator.inc(Metric.UNSET_PRIORITY_DROPPED_TRACES); } } @Override public void onPartialPublish(final int numberOfDroppedSpans) { - partialTraces.increment(); - samplerDropDroppedSpans.add(numberOfDroppedSpans); + metricAccumulator.inc(Metric.PARTIAL_TRACES); + metricAccumulator.add(Metric.SAMPLER_DROP_DROPPED_SPANS, numberOfDroppedSpans); } @Override @@ -210,95 +144,97 @@ public void onFlush(final boolean early) {} @Override public void onPartialFlush(final int sizeInBytes) { - partialBytes.add(sizeInBytes); + metricAccumulator.add(Metric.PARTIAL_BYTES, sizeInBytes); } @Override public void onSingleSpanSample() { - singleSpanSampled.increment(); + metricAccumulator.inc(Metric.SINGLE_SPAN_SAMPLED); } @Override public void onSingleSpanUnsampled() { - singleSpanUnsampled.increment(); + metricAccumulator.inc(Metric.SINGLE_SPAN_UNSAMPLED); } @Override public void onSerialize(final int serializedSizeInBytes) { // DQH - Because of Java tracer's 2 phase acceptance and serialization scheme, this doesn't // map precisely - enqueuedBytes.add(serializedSizeInBytes); + metricAccumulator.add(Metric.ENQUEUED_BYTES, serializedSizeInBytes); } @Override public void onFailedSerialize(final List trace, final Throwable optionalCause) { if (trace != null) { - serialFailedDroppedTraces.increment(); - serialFailedDroppedSpans.add(trace.size()); + metricAccumulator.inc(Metric.SERIAL_FAILED_DROPPED_TRACES); + metricAccumulator.add(Metric.SERIAL_FAILED_DROPPED_SPANS, trace.size()); } } @Override public void onCreateSpan() { - createdSpans.increment(); + metricAccumulator.inc(Metric.CREATED_SPANS); } @Override public void onFinishSpan() { - finishedSpans.increment(); + metricAccumulator.inc(Metric.FINISHED_SPANS); } @Override public void onCreateTrace() { - createdTraces.increment(); + metricAccumulator.inc(Metric.CREATED_TRACES); } @Override public void onScopeCloseError(boolean manual) { - scopeCloseErrors.increment(); if (manual) { - userScopeCloseErrors.increment(); + metricAccumulator.inc(Metric.SCOPE_CLOSE_ERRORS); + metricAccumulator.inc(Metric.USER_SCOPE_CLOSE_ERRORS); + } else { + metricAccumulator.inc(Metric.SCOPE_CLOSE_ERRORS); } } @Override public void onCaptureContinuation() { - capturedContinuations.increment(); + metricAccumulator.inc(Metric.CAPTURED_CONTINUATIONS); } @Override public void onCancelContinuation() { - cancelledContinuations.increment(); + metricAccumulator.inc(Metric.CANCELLED_CONTINUATIONS); } @Override public void onFinishContinuation() { - finishedContinuations.increment(); + metricAccumulator.inc(Metric.FINISHED_CONTINUATIONS); } @Override public void onActivateScope() { - activatedScopes.increment(); + metricAccumulator.inc(Metric.ACTIVATED_SCOPES); } @Override public void onCloseScope() { - closedScopes.increment(); + metricAccumulator.inc(Metric.CLOSED_SCOPES); } @Override public void onScopeStackOverflow() { - scopeStackOverflow.increment(); + metricAccumulator.inc(Metric.SCOPE_STACK_OVERFLOW); } @Override public void onOrgGuardEnforce(OrgGuard.Reason reason) { switch (reason) { case MISMATCH: - orgGuardEnforceMismatch.increment(); + metricAccumulator.inc(Metric.ORG_GUARD_ENFORCE_MISMATCH); break; case STRICT_MISSING: - orgGuardEnforceStrictMissing.increment(); + metricAccumulator.inc(Metric.ORG_GUARD_ENFORCE_STRICT_MISSING); break; } } @@ -317,68 +253,68 @@ public void onFailedSend( @Override public void onLongRunningUpdate(final int dropped, final int write, final int expired) { - longRunningTracesWrite.add(write); - longRunningTracesDropped.add(dropped); - longRunningTracesExpired.add(expired); + metricAccumulator.add(Metric.LONG_RUNNING_TRACES_WRITE, write); + metricAccumulator.add(Metric.LONG_RUNNING_TRACES_DROPPED, dropped); + metricAccumulator.add(Metric.LONG_RUNNING_TRACES_EXPIRED, expired); } private void onSendAttempt( final int traceCount, final int sizeInBytes, final RemoteApi.Response response) { - apiRequests.increment(); - flushedTraces.add(traceCount); + metricAccumulator.inc(Metric.API_REQUESTS); + metricAccumulator.add(Metric.FLUSHED_TRACES, traceCount); // TODO: missing queue.spans (# of spans being sent) - flushedBytes.add(sizeInBytes); + metricAccumulator.add(Metric.FLUSHED_BYTES, sizeInBytes); if (response.exception().isPresent()) { // covers communication errors -- both not receiving a response or // receiving malformed response (even when otherwise successful) - apiErrors.increment(); + metricAccumulator.inc(Metric.API_ERRORS); } - int status = response.status().orElse(0); - if (status != 0) { - if (200 == status) { - apiResponsesOK.increment(); - } else { - statsd.incrementCounter("api.responses.total", statusTagsCache.get(status)); - } + if (200 == response.status().orElse(0)) { + metricAccumulator.inc(Metric.API_RESPONSES_OK); + } + + final int status = response.status().orElse(0); + if (status != 0 && 200 != status) { + statsd.incrementCounter("api.responses.total", statusTagsCache.get(status)); } } @Override public void onClientStatTraceComputed(int countedSpans, int totalSpans, boolean dropped) { - clientStatsProcessedTraces.increment(); - clientStatsProcessedSpans.add(countedSpans); + metricAccumulator.inc(Metric.CLIENT_STATS_PROCESSED_TRACES); + metricAccumulator.add(Metric.CLIENT_STATS_PROCESSED_SPANS, countedSpans); if (dropped) { - clientStatsP0DroppedTraces.increment(); - clientStatsP0DroppedSpans.add(totalSpans); + metricAccumulator.inc(Metric.CLIENT_STATS_P0_DROPPED_TRACES); + metricAccumulator.add(Metric.CLIENT_STATS_P0_DROPPED_SPANS, totalSpans); } } @Override public void onClientStatPayloadSent() { - clientStatsRequests.increment(); + metricAccumulator.inc(Metric.CLIENT_STATS_REQUESTS); } @Override public void onClientStatDowngraded() { - clientStatsDowngrades.increment(); + metricAccumulator.inc(Metric.CLIENT_STATS_DOWNGRADES); } @Override public void onClientStatErrorReceived() { - clientStatsErrors.increment(); + metricAccumulator.inc(Metric.CLIENT_STATS_ERRORS); } @Override public void onStatsAggregateDropped() { - statsAggregateDropped.increment(); + metricAccumulator.inc(Metric.STATS_AGGREGATE_DROPPED); statsd.count("datadog.tracer.stats.collapsed_spans", 1, COLLAPSED_WHOLE_KEY_TAGS); } @Override public void onStatsInboxFull() { - statsInboxFull.increment(); + metricAccumulator.inc(Metric.STATS_INBOX_FULL); } @Override @@ -395,299 +331,139 @@ public void close() { private static class Flush implements AgentTaskScheduler.Task { - private static final String[] USER_DROP_TAG = new String[] {"priority:user_drop"}; - private static final String[] USER_KEEP_TAG = new String[] {"priority:user_keep"}; - private static final String[] SAMPLER_DROP_TAG = new String[] {"priority:sampler_drop"}; - private static final String[] SAMPLER_KEEP_TAG = new String[] {"priority:sampler_keep"}; - private static final String[] SERIAL_FAILED_TAG = new String[] {"failure:serial"}; - private static final String[] UNSET_TAG = new String[] {"priority:unset"}; - private static final String[] SINGLE_SPAN_SAMPLER = new String[] {"sampler:single-span"}; - private static final String[] REASON_LRU_EVICTION_TAG = new String[] {"reason:lru_eviction"}; - private static final String[] REASON_INBOX_FULL_TAG = new String[] {"reason:inbox_full"}; - private static final String[] ORG_GUARD_MISMATCH_TAGS = new String[] {"reason:mismatch"}; - private static final String[] ORG_GUARD_STRICT_MISSING_TAGS = - new String[] {"reason:strict_missing"}; - - private final long[] previousCounts = new long[54]; - - @SuppressFBWarnings("AT_STALE_THREAD_WRITE_OF_PRIMITIVE") - private int countIndex; - @Override public void run(TracerHealthMetrics target) { - countIndex = -1; // reposition so _next_ value is 0 - try { - - reportIfChanged(target.statsd, "api.requests.total", target.apiRequests, NO_TAGS); - reportIfChanged(target.statsd, "api.errors.total", target.apiErrors, NO_TAGS); - // non-OK responses are reported immediately in onSendAttempt with different status tags - reportIfChanged( - target.statsd, "api.responses.total", target.apiResponsesOK, STATUS_OK_TAGS); - - reportIfChanged( - target.statsd, "queue.enqueued.traces", target.userDropEnqueuedTraces, USER_DROP_TAG); - reportIfChanged( - target.statsd, "queue.enqueued.traces", target.userKeepEnqueuedTraces, USER_KEEP_TAG); - reportIfChanged( - target.statsd, - "queue.enqueued.traces", - target.samplerDropEnqueuedTraces, - SAMPLER_DROP_TAG); - reportIfChanged( - target.statsd, - "queue.enqueued.traces", - target.samplerKeepEnqueuedTraces, - SAMPLER_KEEP_TAG); - reportIfChanged( - target.statsd, "queue.enqueued.traces", target.unsetPriorityEnqueuedTraces, UNSET_TAG); - - reportIfChanged( - target.statsd, "queue.dropped.traces", target.userDropDroppedTraces, USER_DROP_TAG); - reportIfChanged( - target.statsd, "queue.dropped.traces", target.userKeepDroppedTraces, USER_KEEP_TAG); - reportIfChanged( - target.statsd, - "queue.dropped.traces", - target.samplerDropDroppedTraces, - SAMPLER_DROP_TAG); - reportIfChanged( - target.statsd, - "queue.dropped.traces", - target.samplerKeepDroppedTraces, - SAMPLER_KEEP_TAG); - reportIfChanged( - target.statsd, - "queue.dropped.traces", - target.serialFailedDroppedTraces, - SERIAL_FAILED_TAG); - reportIfChanged( - target.statsd, "queue.dropped.traces", target.unsetPriorityDroppedTraces, UNSET_TAG); - - reportIfChanged( - target.statsd, "queue.dropped.spans", target.userDropDroppedSpans, USER_DROP_TAG); - reportIfChanged( - target.statsd, "queue.dropped.spans", target.userKeepDroppedSpans, USER_KEEP_TAG); - reportIfChanged( - target.statsd, "queue.dropped.spans", target.samplerDropDroppedSpans, SAMPLER_DROP_TAG); - reportIfChanged( - target.statsd, "queue.dropped.spans", target.samplerKeepDroppedSpans, SAMPLER_KEEP_TAG); - reportIfChanged( - target.statsd, - "queue.dropped.spans", - target.serialFailedDroppedSpans, - SERIAL_FAILED_TAG); - reportIfChanged( - target.statsd, "queue.dropped.spans", target.unsetPriorityDroppedSpans, UNSET_TAG); - - reportIfChanged(target.statsd, "queue.enqueued.spans", target.enqueuedSpans, NO_TAGS); - reportIfChanged(target.statsd, "queue.enqueued.bytes", target.enqueuedBytes, NO_TAGS); - reportIfChanged(target.statsd, "trace.pending.created", target.createdTraces, NO_TAGS); - reportIfChanged(target.statsd, "span.pending.created", target.createdSpans, NO_TAGS); - reportIfChanged(target.statsd, "span.pending.finished", target.finishedSpans, NO_TAGS); - reportIfChanged(target.statsd, "flush.traces.total", target.flushedTraces, NO_TAGS); - reportIfChanged(target.statsd, "flush.bytes.total", target.flushedBytes, NO_TAGS); - reportIfChanged(target.statsd, "queue.partial.traces", target.partialTraces, NO_TAGS); - reportIfChanged(target.statsd, "span.flushed.partial", target.partialBytes, NO_TAGS); - reportIfChanged( - target.statsd, "span.client.no-context", target.clientSpansWithoutContext, NO_TAGS); - - reportIfChanged( - target.statsd, "span.sampling.sampled", target.singleSpanSampled, SINGLE_SPAN_SAMPLER); - reportIfChanged( - target.statsd, - "span.sampling.unsampled", - target.singleSpanUnsampled, - SINGLE_SPAN_SAMPLER); - - reportIfChanged( - target.statsd, "span.continuations.captured", target.capturedContinuations, NO_TAGS); - reportIfChanged( - target.statsd, "span.continuations.canceled", target.cancelledContinuations, NO_TAGS); - reportIfChanged( - target.statsd, "span.continuations.finished", target.finishedContinuations, NO_TAGS); - - reportIfChanged(target.statsd, "scope.activate.count", target.activatedScopes, NO_TAGS); - reportIfChanged(target.statsd, "scope.close.count", target.closedScopes, NO_TAGS); - reportIfChanged( - target.statsd, "scope.error.stack-overflow", target.scopeStackOverflow, NO_TAGS); - reportIfChanged(target.statsd, "scope.close.error", target.scopeCloseErrors, NO_TAGS); - reportIfChanged( - target.statsd, "scope.user.close.error", target.userScopeCloseErrors, NO_TAGS); - - reportIfChanged( - target.statsd, "long-running.write", target.longRunningTracesWrite, NO_TAGS); - reportIfChanged( - target.statsd, "long-running.dropped", target.longRunningTracesDropped, NO_TAGS); - reportIfChanged( - target.statsd, "long-running.expired", target.longRunningTracesExpired, NO_TAGS); - - reportIfChanged( - target.statsd, - "org_guard.enforce", - target.orgGuardEnforceMismatch, - ORG_GUARD_MISMATCH_TAGS); - reportIfChanged( - target.statsd, - "org_guard.enforce", - target.orgGuardEnforceStrictMissing, - ORG_GUARD_STRICT_MISSING_TAGS); - - reportIfChanged( - target.statsd, "stats.traces_in", target.clientStatsProcessedTraces, NO_TAGS); - reportIfChanged(target.statsd, "stats.spans_in", target.clientStatsProcessedSpans, NO_TAGS); - reportIfChanged( - target.statsd, "stats.dropped_p0_traces", target.clientStatsP0DroppedTraces, NO_TAGS); - reportIfChanged( - target.statsd, "stats.dropped_p0_spans", target.clientStatsP0DroppedSpans, NO_TAGS); - reportIfChanged(target.statsd, "stats.flush_payloads", target.clientStatsRequests, NO_TAGS); - reportIfChanged(target.statsd, "stats.flush_errors", target.clientStatsErrors, NO_TAGS); - reportIfChanged( - target.statsd, "stats.agent_downgrades", target.clientStatsDowngrades, NO_TAGS); - reportIfChanged( - target.statsd, - "stats.dropped_aggregates", - target.statsAggregateDropped, - REASON_LRU_EVICTION_TAG); - reportIfChanged( - target.statsd, - "stats.dropped_aggregates", - target.statsInboxFull, - REASON_INBOX_FULL_TAG); - - } catch (ArrayIndexOutOfBoundsException e) { - log.warn( - "previousCounts array needs resizing to at least {}, was {}", - countIndex + 1, - previousCounts.length); - } - } - - private void reportIfChanged( - StatsDClient statsDClient, String aspect, LongAdder counter, String[] tags) { - long count = counter.sum(); - long delta = count - previousCounts[++countIndex]; - if (delta > 0) { - statsDClient.count(aspect, delta, tags); - previousCounts[countIndex] = count; - } + Accumulator.Counts delta = target.metricAccumulator.accumulateAndReset(); + StatsDCountReporter.report(target.statsd, delta); + target.storedTotal = target.storedTotal.plus(delta); } } @Override public String summary() { - return "apiRequests=" - + apiRequests.sum() - + "\napiErrors=" - + apiErrors.sum() - + "\napiResponsesOK=" - + apiResponsesOK.sum() - + "\n" - + "\nuserDropEnqueuedTraces=" - + userDropEnqueuedTraces.sum() - + "\nuserKeepEnqueuedTraces=" - + userKeepEnqueuedTraces.sum() - + "\nsamplerDropEnqueuedTraces=" - + samplerDropEnqueuedTraces.sum() - + "\nsamplerKeepEnqueuedTraces=" - + samplerKeepEnqueuedTraces.sum() - + "\nunsetPriorityEnqueuedTraces=" - + unsetPriorityEnqueuedTraces.sum() - + "\n" - + "\nuserDropDroppedTraces=" - + userDropDroppedTraces.sum() - + "\nuserKeepDroppedTraces=" - + userKeepDroppedTraces.sum() - + "\nsamplerDropDroppedTraces=" - + samplerDropDroppedTraces.sum() - + "\nsamplerKeepDroppedTraces=" - + samplerKeepDroppedTraces.sum() - + "\nserialFailedDroppedTraces=" - + serialFailedDroppedTraces.sum() - + "\nunsetPriorityDroppedTraces=" - + unsetPriorityDroppedTraces.sum() - + "\n" - + "\nuserDropDroppedSpans=" - + userDropDroppedSpans.sum() - + "\nuserKeepDroppedSpans=" - + userKeepDroppedSpans.sum() - + "\nsamplerDropDroppedSpans=" - + samplerDropDroppedSpans.sum() - + "\nsamplerKeepDroppedSpans=" - + samplerKeepDroppedSpans.sum() - + "\nserialFailedDroppedSpans=" - + serialFailedDroppedSpans.sum() - + "\nunsetPriorityDroppedSpans=" - + unsetPriorityDroppedSpans.sum() - + "\n" - + "\nenqueuedSpans=" - + enqueuedSpans.sum() - + "\nenqueuedBytes=" - + enqueuedBytes.sum() - + "\ncreatedTraces=" - + createdTraces.sum() - + "\ncreatedSpans=" - + createdSpans.sum() - + "\nfinishedSpans=" - + finishedSpans.sum() - + "\nflushedTraces=" - + flushedTraces.sum() - + "\nflushedBytes=" - + flushedBytes.sum() - + "\npartialTraces=" - + partialTraces.sum() - + "\npartialBytes=" - + partialBytes.sum() - + "\n" - + "\nclientSpansWithoutContext=" - + clientSpansWithoutContext.sum() - + "\n" - + "\nsingleSpanSampled=" - + singleSpanSampled.sum() - + "\nsingleSpanUnsampled=" - + singleSpanUnsampled.sum() - + "\n" - + "\ncapturedContinuations=" - + capturedContinuations.sum() - + "\ncancelledContinuations=" - + cancelledContinuations.sum() - + "\nfinishedContinuations=" - + finishedContinuations.sum() - + "\n" - + "\nactivatedScopes=" - + activatedScopes.sum() - + "\nclosedScopes=" - + closedScopes.sum() - + "\nscopeStackOverflow=" - + scopeStackOverflow.sum() - + "\nscopeCloseErrors=" - + scopeCloseErrors.sum() - + "\nuserScopeCloseErrors=" - + userScopeCloseErrors.sum() - + "\n" - + "\nlongRunningTracesWrite=" - + longRunningTracesWrite.sum() - + "\nlongRunningTracesDropped=" - + longRunningTracesDropped.sum() - + "\nlongRunningTracesExpired=" - + longRunningTracesExpired.sum() - + "\n" - + "\nclientStatsRequests=" - + clientStatsRequests.sum() - + "\nclientStatsErrors=" - + clientStatsErrors.sum() - + "\nclientStatsDowngrades=" - + clientStatsDowngrades.sum() - + "\nclientStatsP0DroppedSpans=" - + clientStatsP0DroppedSpans.sum() - + "\nclientStatsP0DroppedTraces=" - + clientStatsP0DroppedTraces.sum() - + "\nclientStatsProcessedSpans=" - + clientStatsProcessedSpans.sum() - + "\nclientStatsProcessedTraces=" - + clientStatsProcessedTraces.sum() - + "\nstatsAggregateDropped=" - + statsAggregateDropped.sum() - + "\nstatsInboxFull=" - + statsInboxFull.sum(); + Accumulator.Counts live = storedTotal.plus(metricAccumulator.sum()); + StringBuilder summary = new StringBuilder(); + for (Metric metric : live.keys()) { + if (summary.length() > 0) { + summary.append('\n'); + } + summary.append(metric.getSummaryLabel()).append('=').append(live.get(metric)); + } + return summary.toString(); + } + + /** + * One counter tracked by {@link TracerHealthMetrics}: a dogstatsd metric name + tags, plus the + * label {@link TracerHealthMetrics#summary()} renders it under. One constant per (counter, tag) + * combination -- several constants can share a metric name but differ by tag, mirroring the + * distinct {@code LongAdder} fields this enum replaces. + */ + enum Metric implements StatsDCounterKey { + API_REQUESTS("apiRequests", "api.requests.total"), + API_ERRORS("apiErrors", "api.errors.total"), + // non-OK responses are reported immediately in onSendAttempt with different status tags + API_RESPONSES_OK("apiResponsesOK", "api.responses.total", "status:200"), + + USER_DROP_ENQUEUED_TRACES( + "userDropEnqueuedTraces", "queue.enqueued.traces", "priority:user_drop"), + USER_KEEP_ENQUEUED_TRACES( + "userKeepEnqueuedTraces", "queue.enqueued.traces", "priority:user_keep"), + SAMPLER_DROP_ENQUEUED_TRACES( + "samplerDropEnqueuedTraces", "queue.enqueued.traces", "priority:sampler_drop"), + SAMPLER_KEEP_ENQUEUED_TRACES( + "samplerKeepEnqueuedTraces", "queue.enqueued.traces", "priority:sampler_keep"), + UNSET_PRIORITY_ENQUEUED_TRACES( + "unsetPriorityEnqueuedTraces", "queue.enqueued.traces", "priority:unset"), + + USER_DROP_DROPPED_TRACES("userDropDroppedTraces", "queue.dropped.traces", "priority:user_drop"), + USER_KEEP_DROPPED_TRACES("userKeepDroppedTraces", "queue.dropped.traces", "priority:user_keep"), + SAMPLER_DROP_DROPPED_TRACES( + "samplerDropDroppedTraces", "queue.dropped.traces", "priority:sampler_drop"), + SAMPLER_KEEP_DROPPED_TRACES( + "samplerKeepDroppedTraces", "queue.dropped.traces", "priority:sampler_keep"), + SERIAL_FAILED_DROPPED_TRACES( + "serialFailedDroppedTraces", "queue.dropped.traces", "failure:serial"), + UNSET_PRIORITY_DROPPED_TRACES( + "unsetPriorityDroppedTraces", "queue.dropped.traces", "priority:unset"), + + USER_DROP_DROPPED_SPANS("userDropDroppedSpans", "queue.dropped.spans", "priority:user_drop"), + USER_KEEP_DROPPED_SPANS("userKeepDroppedSpans", "queue.dropped.spans", "priority:user_keep"), + SAMPLER_DROP_DROPPED_SPANS( + "samplerDropDroppedSpans", "queue.dropped.spans", "priority:sampler_drop"), + SAMPLER_KEEP_DROPPED_SPANS( + "samplerKeepDroppedSpans", "queue.dropped.spans", "priority:sampler_keep"), + SERIAL_FAILED_DROPPED_SPANS( + "serialFailedDroppedSpans", "queue.dropped.spans", "failure:serial"), + UNSET_PRIORITY_DROPPED_SPANS( + "unsetPriorityDroppedSpans", "queue.dropped.spans", "priority:unset"), + + ENQUEUED_SPANS("enqueuedSpans", "queue.enqueued.spans"), + ENQUEUED_BYTES("enqueuedBytes", "queue.enqueued.bytes"), + CREATED_TRACES("createdTraces", "trace.pending.created"), + CREATED_SPANS("createdSpans", "span.pending.created"), + FINISHED_SPANS("finishedSpans", "span.pending.finished"), + FLUSHED_TRACES("flushedTraces", "flush.traces.total"), + FLUSHED_BYTES("flushedBytes", "flush.bytes.total"), + PARTIAL_TRACES("partialTraces", "queue.partial.traces"), + PARTIAL_BYTES("partialBytes", "span.flushed.partial"), + CLIENT_SPANS_WITHOUT_CONTEXT("clientSpansWithoutContext", "span.client.no-context"), + + SINGLE_SPAN_SAMPLED("singleSpanSampled", "span.sampling.sampled", "sampler:single-span"), + SINGLE_SPAN_UNSAMPLED("singleSpanUnsampled", "span.sampling.unsampled", "sampler:single-span"), + + CAPTURED_CONTINUATIONS("capturedContinuations", "span.continuations.captured"), + CANCELLED_CONTINUATIONS("cancelledContinuations", "span.continuations.canceled"), + FINISHED_CONTINUATIONS("finishedContinuations", "span.continuations.finished"), + + ACTIVATED_SCOPES("activatedScopes", "scope.activate.count"), + CLOSED_SCOPES("closedScopes", "scope.close.count"), + SCOPE_STACK_OVERFLOW("scopeStackOverflow", "scope.error.stack-overflow"), + SCOPE_CLOSE_ERRORS("scopeCloseErrors", "scope.close.error"), + USER_SCOPE_CLOSE_ERRORS("userScopeCloseErrors", "scope.user.close.error"), + + LONG_RUNNING_TRACES_WRITE("longRunningTracesWrite", "long-running.write"), + LONG_RUNNING_TRACES_DROPPED("longRunningTracesDropped", "long-running.dropped"), + LONG_RUNNING_TRACES_EXPIRED("longRunningTracesExpired", "long-running.expired"), + + ORG_GUARD_ENFORCE_MISMATCH("orgGuardEnforceMismatch", "org_guard.enforce", "reason:mismatch"), + ORG_GUARD_ENFORCE_STRICT_MISSING( + "orgGuardEnforceStrictMissing", "org_guard.enforce", "reason:strict_missing"), + + CLIENT_STATS_PROCESSED_TRACES("clientStatsProcessedTraces", "stats.traces_in"), + CLIENT_STATS_PROCESSED_SPANS("clientStatsProcessedSpans", "stats.spans_in"), + CLIENT_STATS_P0_DROPPED_TRACES("clientStatsP0DroppedTraces", "stats.dropped_p0_traces"), + CLIENT_STATS_P0_DROPPED_SPANS("clientStatsP0DroppedSpans", "stats.dropped_p0_spans"), + CLIENT_STATS_REQUESTS("clientStatsRequests", "stats.flush_payloads"), + CLIENT_STATS_ERRORS("clientStatsErrors", "stats.flush_errors"), + CLIENT_STATS_DOWNGRADES("clientStatsDowngrades", "stats.agent_downgrades"), + + STATS_AGGREGATE_DROPPED( + "statsAggregateDropped", "stats.dropped_aggregates", "reason:lru_eviction"), + STATS_INBOX_FULL("statsInboxFull", "stats.dropped_aggregates", "reason:inbox_full"), + ; + + private final String summaryLabel; + private final String metricName; + private final String[] tags; + + Metric(String summaryLabel, String metricName, String... tags) { + this.summaryLabel = summaryLabel; + this.metricName = metricName; + this.tags = tags; + } + + @Override + public String getMetricName() { + return metricName; + } + + @Override + public String[] getTags() { + return tags; + } + + String getSummaryLabel() { + return summaryLabel; + } } } diff --git a/internal-api/src/jmh/java/datadog/trace/util/AccumulatorBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/AccumulatorBenchmark.java index 3b7c7eeb05f..5345f490096 100644 --- a/internal-api/src/jmh/java/datadog/trace/util/AccumulatorBenchmark.java +++ b/internal-api/src/jmh/java/datadog/trace/util/AccumulatorBenchmark.java @@ -3,6 +3,7 @@ import static java.util.concurrent.TimeUnit.MICROSECONDS; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.LongAdder; import org.openjdk.jmh.annotations.Benchmark; @@ -20,128 +21,135 @@ import org.openjdk.jmh.infra.Blackhole; /** - * {@link Accumulator} vs {@link LongAdder} vs the {@code ConcurrentHashMap.computeIfAbsent(key, k - * -> new AtomicLong())} anti-pattern, at one thread (no contention) and at {@link Threads#MAX} - * (heavy contention). The CHM variant allocates its counter under the bucket's bin lock the first - * time its one constant key is seen -- exactly the pathology {@link Accumulator} exists to avoid -- - * but since the map is a {@code @State(Scope.Benchmark)} field shared across the whole run, that - * allocation happens exactly once; every sampled op after it hits the warmed, already-present fast - * path. So this measures steady-state {@code computeIfAbsent} lookup overhead on an - * already-populated map, not the one-time allocation-under-lock cost -- still a useful number (a - * fixed, small key set that's allocated once and hit for the life of the process, as {@code + * {@link Accumulator} vs the alternatives it actually displaces: a single {@code LongAdder} (the + * collision-free baseline it can never beat, only approach), an independent {@code LongAdder} per + * counter guarded by a per-counter lock (the "just fix it with LongAdder" natural migration target + * -- {@code longAdderGroup*}), and the {@code ConcurrentHashMap.computeIfAbsent(key, k -> new + * AtomicLong())} anti-pattern ({@code chmAtomicLongIncrement*}) that {@link Accumulator} exists to + * avoid. The CHM variant allocates its counter under the bucket's bin lock the first time its one + * constant key is seen, but since the map is a {@code @State(Scope.Benchmark)} field shared across + * the whole run, that allocation happens exactly once; every sampled op after it hits the warmed, + * already-present fast path. So this measures steady-state {@code computeIfAbsent} lookup overhead + * on an already-populated map, not the one-time allocation-under-lock cost -- still a useful number + * (a fixed, small key set that's allocated once and hit for the life of the process, as {@code * WafMetricCollector}-style CHM counters are, spends nearly all its time in this same warmed path), * just not the pathology the name of this benchmark might suggest. * - *

Contention result to note: at low contention, {@code accumulatorIncrement} is - * essentially free and on par with {@code longAdderIncrement}. At {@code Threads.MAX} (10 threads - * on the measurement machine), oversizing {@link Accumulator}'s stripe count from 8 (one per core) - * to 16 (roughly 2x cores, see {@code stripeCount()}) cut {@code - * accumulatorIncrement_highContention} from ~0.097 us/op to ~0.040 us/op -- fewer threads collide - * on a stripe, so fewer of them pay {@code synchronized}'s blocking wait instead of a cheap - * fast-path lock. It is still roughly 4-5x slower than {@code longAdderIncrement} (a collision-free - * CAS retry beats even an uncontended monitor enter/exit), and {@code accumulateAndReset} under - * concurrent writers got correspondingly more expensive (~7.5us to ~15.5us) since draining now - * walks twice as many stripes while writers are actively landing on them. Read {@code - * accumulatorIncrement_highContention} not as "Accumulator beats LongAdder under contention" (it - * doesn't, on this shape) but as the honest cost of the drain-under-lock design that buys atomic - * combine+reset; a caller trading that safety for raw increment throughput should measure their own - * contention level before choosing between them. - * Apple M1 Max, 10 CPUs - JDK 1.8.0_382 (Zulu) - macOS/arm64 - stripeCount() = 16 - * Benchmark Mode Cnt Score Error Units - * AccumulatorBenchmark.longAdderIncrement_lowContention avgt 6 0.007 ± 0.001 us/op - * AccumulatorBenchmark.longAdderIncrement_highContention avgt 6 0.009 ± 0.001 us/op - * AccumulatorBenchmark.accumulatorIncrement_lowContention avgt 6 0.010 ± 0.001 us/op - * AccumulatorBenchmark.accumulatorIncrement_highContention avgt 6 0.040 ± 0.002 us/op - * AccumulatorBenchmark.chmAtomicLongIncrement_lowContention avgt 6 0.010 ± 0.001 us/op - * AccumulatorBenchmark.chmAtomicLongIncrement_highContention avgt 6 0.417 ± 0.543 us/op - * AccumulatorBenchmark.longAdderSumThenReset_lowContention avgt 6 0.012 ± 0.001 us/op - * AccumulatorBenchmark.longAdderSumThenReset_highContention avgt 6 2.433 ± 0.203 us/op - * AccumulatorBenchmark.accumulatorAccumulateAndReset_lowContention avgt 6 0.162 ± 0.009 us/op - * AccumulatorBenchmark.accumulatorAccumulateAndReset_highContention avgt 6 15.515 ± 4.094 us/op - * - * - *

(This run had some background noise from another session on the measurement machine; the - * {@code lowContention} rows and the {@code highContention} directional deltas are reliable, but - * treat the exact {@code highContention} magnitudes as approximate.) - * *

{@code longAdderGroup*}: is a "just fix it with LongAdder" helper actually cheaper? * {@code groupInc}/{@code groupAccumulateAnd} are the natural correct fix using {@code LongAdder} * as the payload: one {@code LongAdder} per counter, with a per-counter lock guarding both * the increment and the drain (locking only the drain does nothing -- {@code sumThenReset()}'s * internal race is against the {@code LongAdder}'s own CAS-based {@code add()}, not against any - * lock a caller takes). This closes the same reset hazard as {@link Accumulator}, but stripes by - * counter instead of by thread. - * AccumulatorBenchmark.accumulatorIncrement_highContention avgt 6 0.029 ± 0.051 us/op - * AccumulatorBenchmark.accumulatorAccumulateAndReset_highContention avgt 6 13.431 ± 5.876 us/op - * AccumulatorBenchmark.longAdderGroupIncrement_highContention avgt 6 0.294 ± 0.088 us/op - * AccumulatorBenchmark.longAdderGroupAccumulateAnd_highContention avgt 6 0.549 ± 0.291 us/op - * Not "similar cost" -- a clean trade-off inversion. With this benchmark's single counter, - * {@code longAdderGroup}'s per-counter lock collapses to one lock for every thread (no thread-based - * distribution at all), so it loses badly on the write path: ~10x worse than {@code Accumulator}'s - * thread-sharded stripes. But its drain only has that one lock to acquire, so it wins big there: - * ~24x better than {@code Accumulator}, which always walks all 16 stripes on every drain regardless - * of counter count. That asymmetry is the whole story: {@code longAdderGroup}'s drain cost scales - * with number of counters (more counters -> more locks to drain), while {@code - * Accumulator}'s drain cost is fixed at stripe count, independent of counter count. Which design - * actually wins for a given caller depends on that caller's counter cardinality and whether its - * write traffic concentrates on a few hot counters (favors thread-sharding) or spreads across many - * (favors counter-sharding) -- not measured here, and worth checking against the real migration - * targets before treating either number as the general answer. - * - *

{@code typed*}: what does the {@link Accumulator}/{@link Accumulator.Stripe}/{@link - * Accumulator.Counts} wrapping actually cost over calling {@link Accumulator.EmbeddingSupport} - * directly? {@code typedIncrement}/{@code typedUpdate} pair against {@code - * accumulatorIncrement} (the same underlying call), and {@code typedAccumulateAndReset} pairs - * against {@code accumulatorAccumulateAndReset}. - * AccumulatorBenchmark.accumulatorIncrement_lowContention avgt 6 0.010 ± 0.001 us/op - * AccumulatorBenchmark.typedIncrement_lowContention avgt 6 0.010 ± 0.001 us/op - * AccumulatorBenchmark.accumulatorIncrement_highContention avgt 6 0.033 ± 0.008 us/op - * AccumulatorBenchmark.typedIncrement_highContention avgt 6 0.025 ± 0.015 us/op - * AccumulatorBenchmark.typedUpdate_lowContention avgt 6 0.010 ± 0.001 us/op - * AccumulatorBenchmark.typedUpdate_highContention avgt 6 0.037 ± 0.017 us/op - * AccumulatorBenchmark.accumulatorAccumulateAndReset_lowContention avgt 6 0.161 ± 0.003 us/op - * AccumulatorBenchmark.typedAccumulateAndReset_lowContention avgt 6 0.164 ± 0.005 us/op - * AccumulatorBenchmark.accumulatorAccumulateAndReset_highContention avgt 6 17.399 ± 3.091 us/op - * AccumulatorBenchmark.typedAccumulateAndReset_highContention avgt 6 12.666 ± 1.272 us/op - * {@code typedIncrement}/{@code typedUpdate} track the raw calls within noise at both - * contention levels -- the field-load indirection through the {@link Accumulator} instance and the - * fresh {@link Accumulator.Stripe} constructed under {@code update}'s held lock both disappear, - * consistent with a small, non-capturing mutator letting escape analysis scalar-replace the {@code - * Stripe}. {@code typedAccumulateAndReset} also tracks the raw drain within noise at low contention - * (0.164 vs 0.161 us/op) -- the one-{@link Accumulator.Counts}-object-per-drain allocation it's - * documented to pay doesn't show up at this granularity. The high-contention gap in the other - * direction (12.666 vs 17.399) is not a real typed-vs-raw effect -- wrapping an already-drained - * array can only add cost, never remove it -- it's the same run-to-run lock-contention noise this - * exact measurement already shows above (13.431, 15.515, 17.399 us/op across three otherwise - * identical runs). Net: the wrapper's cost was not measurable in this run. + * lock a caller takes). The single-counter {@code *_*} benchmarks below collapse that per-counter + * lock to one lock shared by every thread -- the degenerate worst case for {@code longAdderGroup}, + * with no thread-based distribution at all. The {@code *8_*} benchmarks fix that: each JMH worker + * thread is pinned to one of 8 counters for its lifetime (see {@link #threadCounterIndex}), so + * {@code longAdderGroup8}'s threads split into up to 8 groups each contending their own lock -- + * the topology where distributed locking should actually pay off, forcing {@link Accumulator}'s + * thread-striped design to earn its write-side win rather than facing a single-counter worst case. + * Fork(5), 15 samples per benchmark: + * AccumulatorBenchmark.accumulatorAccumulateAndReset_highContention avgt 15 2.746 ± 0.050 us/op + * AccumulatorBenchmark.accumulatorAccumulateAndReset_lowContention avgt 15 0.056 ± 0.001 us/op + * AccumulatorBenchmark.accumulatorAccumulateAndReset8_highContention avgt 15 6.875 ± 0.422 us/op + * AccumulatorBenchmark.accumulatorAccumulateAndReset8_lowContention avgt 15 0.363 ± 0.003 us/op + * AccumulatorBenchmark.accumulatorIncrement_highContention avgt 15 0.009 ± 0.001 us/op + * AccumulatorBenchmark.accumulatorIncrement_lowContention avgt 15 0.007 ± 0.001 us/op + * AccumulatorBenchmark.accumulatorIncrement8_highContention avgt 15 0.017 ± 0.009 us/op + * AccumulatorBenchmark.accumulatorIncrement8_lowContention avgt 15 0.007 ± 0.001 us/op + * AccumulatorBenchmark.longAdderGroupAccumulateAnd_highContention avgt 15 4.770 ± 1.795 us/op + * AccumulatorBenchmark.longAdderGroupAccumulateAnd_lowContention avgt 15 0.061 ± 0.007 us/op + * AccumulatorBenchmark.longAdderGroupAccumulateAnd8_highContention avgt 15 6.025 ± 0.712 us/op + * AccumulatorBenchmark.longAdderGroupAccumulateAnd8_lowContention avgt 15 0.085 ± 0.004 us/op + * AccumulatorBenchmark.longAdderGroupIncrement_highContention avgt 15 2.294 ± 0.101 us/op + * AccumulatorBenchmark.longAdderGroupIncrement_lowContention avgt 15 0.019 ± 0.001 us/op + * AccumulatorBenchmark.longAdderGroupIncrement8_highContention avgt 15 0.786 ± 0.078 us/op + * AccumulatorBenchmark.longAdderGroupIncrement8_lowContention avgt 15 0.020 ± 0.001 us/op + * On the write side, {@link Accumulator} beats {@code longAdderGroup} at high contention by + * ~255x in the degenerate single-shared-lock case and still by ~46x once counters are fairly spread + * across 8 locks -- a large, reproducible win either way, on the call that runs on every event. + * On the drain side, the two designs are close and the comparison is noisy under contention for + * both: at width 1 {@link Accumulator}'s drain (2.746 us/op) is actually faster than {@code + * longAdderGroup}'s (4.770 ± 1.795 us/op, itself high-variance), and at width 8 it's only ~1.14x + * slower (6.875 vs 6.025 us/op) -- not the regression an earlier reading of this benchmark + * suggested. That earlier reading (13.357 us/op at Fork(2)) turned out to be a correlated anomaly + * across two independent low-sample runs, not a reproducible result; escalating to Fork(5) (15 + * samples) settled it. Net: a large, robust win on the call that fires on every event, and no + * confirmed cost on the call that fires once per reporting cycle. */ @State(Scope.Benchmark) @Warmup(iterations = 1, time = 10) @Measurement(iterations = 3, time = 10) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(MICROSECONDS) -@Fork(2) +@Fork(5) public class AccumulatorBenchmark { enum Counter { HITS } + /** + * An 8-constant counterpart to {@link Counter}, used only by the {@code *8_*} benchmarks below. + * Unlike {@link Counter}, where every thread hits the single {@code HITS} constant (the worst + * case for {@code longAdderGroup}'s per-counter locking -- one lock shared by every thread, + * regardless of core count), these benchmarks spread writes across all 8 constants: each JMH + * worker thread is pinned to one fixed counter for its lifetime (see {@link #threadCounterIndex}), + * so under high contention, threads split into up to 8 groups each contending on their own lock + * instead of all threads sharing one. This is the topology where {@code longAdderGroup}'s + * distributed locking should actually pay off, and where {@link Accumulator}'s thread-striped + * design has to earn its win on the write side rather than facing a single-counter worst case. + * {@code accumulateAndReset}/{@code groupAccumulateAnd} also now walk 8 slots per drain instead of + * 1, sizing the drain cost closer to {@code TracerHealthMetric}'s 54-constant production shape. + */ + enum Counter8 { + COUNTER_0, + COUNTER_1, + COUNTER_2, + COUNTER_3, + COUNTER_4, + COUNTER_5, + COUNTER_6, + COUNTER_7 + } + + private static final Counter8[] COUNTER8_VALUES = Counter8.values(); + private final LongAdder adder = new LongAdder(); - private final long[][] accumulator = Accumulator.EmbeddingSupport.create(Counter.values()); - private final Accumulator typedAccumulator = Accumulator.of(Counter.values()); + private final Accumulator accumulator = Accumulator.of(Counter.values()); + private final Accumulator accumulator8 = Accumulator.of(Counter8.values()); private final ConcurrentHashMap chm = new ConcurrentHashMap<>(); private final LongAdder[] longAdderGroup = {new LongAdder()}; + private final LongAdder[] longAdderGroup8 = { + new LongAdder(), + new LongAdder(), + new LongAdder(), + new LongAdder(), + new LongAdder(), + new LongAdder(), + new LongAdder(), + new LongAdder() + }; + + /** + * Assigns each JMH worker thread a fixed {@code Counter8} index (round-robin over 8) the first + * time it calls into any {@code *8_*} benchmark, and keeps returning that same index for the + * thread's lifetime -- so under {@code Threads.MAX}, writes spread across all 8 counters instead + * of every thread hammering one. + */ + private final AtomicInteger threadIndexAssigner = new AtomicInteger(); + + private final ThreadLocal threadCounterIndex = + ThreadLocal.withInitial(() -> threadIndexAssigner.getAndIncrement() % COUNTER8_VALUES.length); /** * The natural "just use LongAdder" fix for the reset hazard: one {@code LongAdder} per counter, * with a per-counter lock guarding both the increment and the drain -- external locking around * only the drain does nothing, since {@code sumThenReset()}'s internal race is against the {@code * LongAdder}'s own CAS-based {@code add()}, not against any lock a caller takes. This is the fair - * comparison point: it closes the same hazard {@link Accumulator} does, but stripes by - * counter (one lock per enum constant) instead of by thread (one lock per - * stripe, shared by all counters) -- so N threads hammering the *same* counter contend on one - * lock regardless of core count, with no thread-bucket distribution at all. + * comparison point: it closes the same reset hazard {@link Accumulator} does, but stripes by + * counter (one lock per enum constant) instead of by thread (one shared table + * across all counters) -- so N threads hammering the *same* counter contend on one lock + * regardless of core count, with no thread-bucket distribution at all. */ private static void groupInc(LongAdder[] group, int ordinal) { LongAdder counter = group[ordinal]; @@ -176,31 +184,13 @@ public void longAdderIncrement_highContention() { @Benchmark @Threads(1) public void accumulatorIncrement_lowContention() { - Accumulator.EmbeddingSupport.inc(accumulator, Counter.HITS); + accumulator.inc(Counter.HITS); } @Benchmark @Threads(Threads.MAX) public void accumulatorIncrement_highContention() { - Accumulator.EmbeddingSupport.inc(accumulator, Counter.HITS); - } - - /** - * The typed {@link Accumulator} wrapper's {@link Accumulator#inc}, paired against {@code - * accumulatorIncrement*} above: same underlying {@link Accumulator.EmbeddingSupport#inc} call, - * one extra field-load indirection through the instance. Should track the raw numbers closely -- - * a divergence here would mean the indirection isn't being inlined away. - */ - @Benchmark - @Threads(1) - public void typedIncrement_lowContention() { - typedAccumulator.inc(Counter.HITS); - } - - @Benchmark - @Threads(Threads.MAX) - public void typedIncrement_highContention() { - typedAccumulator.inc(Counter.HITS); + accumulator.inc(Counter.HITS); } @Benchmark @@ -232,9 +222,8 @@ public void longAdderSumThenReset_highContention(Blackhole blackhole) { @Benchmark @Threads(1) public void accumulatorAccumulateAndReset_lowContention(Blackhole blackhole) { - Accumulator.EmbeddingSupport.inc(accumulator, Counter.HITS); - blackhole.consume( - Accumulator.EmbeddingSupport.accumulateAndReset(accumulator, Counter.values().length)); + accumulator.inc(Counter.HITS); + blackhole.consume(accumulator.accumulateAndReset()); } /** @@ -242,105 +231,113 @@ public void accumulatorAccumulateAndReset_lowContention(Blackhole blackhole) { * Threads.MAX} threads are all draining concurrently. Real callers don't do this -- see {@code * accumulatorMixed-write}/{@code accumulatorMixed-drain} below for the "many writers, one rare * drainer" shape this class actually targets. Kept as the worst-case upper bound: no production - * topology should be more contended on {@link Accumulator.EmbeddingSupport#accumulateAndReset} - * than this. + * topology should be more contended on {@link Accumulator#accumulateAndReset} than this. */ @Benchmark @Threads(Threads.MAX) public void accumulatorAccumulateAndReset_highContention(Blackhole blackhole) { - Accumulator.EmbeddingSupport.inc(accumulator, Counter.HITS); - blackhole.consume( - Accumulator.EmbeddingSupport.accumulateAndReset(accumulator, Counter.values().length)); + accumulator.inc(Counter.HITS); + blackhole.consume(accumulator.accumulateAndReset()); } /** * The realistic counterpart to {@code accumulatorAccumulateAndReset_highContention}: many writer * threads incrementing, and a single dedicated thread polling {@link - * Accumulator.EmbeddingSupport#accumulateAndReset} -- not every thread doing both on every op. - * {@code accumulatorMixed-write} measures increment cost while a drain is actively contending for - * stripe locks; {@code accumulatorMixed-drain} measures the drain's own cost under that same live - * write pressure. The 4:1 writer:drainer ratio is illustrative of "many writers, rare drain," not - * tuned to a specific core count. + * Accumulator#accumulateAndReset} -- not every thread doing both on every op. {@code + * accumulatorMixed-write} measures increment cost while a drain is actively running; {@code + * accumulatorMixed-drain} measures the drain's own cost under that same live write pressure. The + * 4:1 writer:drainer ratio is illustrative of "many writers, rare drain," not tuned to a specific + * core count. */ @Benchmark @Group("accumulatorMixed") @GroupThreads(4) public void accumulatorMixed_write() { - Accumulator.EmbeddingSupport.inc(accumulator, Counter.HITS); + accumulator.inc(Counter.HITS); } @Benchmark @Group("accumulatorMixed") @GroupThreads(1) public void accumulatorMixed_drain(Blackhole blackhole) { - blackhole.consume( - Accumulator.EmbeddingSupport.accumulateAndReset(accumulator, Counter.values().length)); + blackhole.consume(accumulator.accumulateAndReset()); } - /** - * {@link Accumulator#update}, paired against the raw {@link Accumulator.EmbeddingSupport#update} - * lock/dispatch it wraps: the mutator here constructs a {@link Accumulator.Stripe} under the held - * lock and immediately lets it go, which is exactly the "small, non-capturing, non-escaping" - * shape documented as a scalar-replacement candidate. If escape analysis is doing its job, this - * tracks the raw call closely; if it regresses (e.g. after a JIT/JDK change, or a mutator shape - * that stops inlining), this is the number that would move. - */ @Benchmark @Threads(1) - public void typedUpdate_lowContention() { - typedAccumulator.update(stripe -> stripe.inc(Counter.HITS)); + public void longAdderGroupIncrement_lowContention() { + groupInc(longAdderGroup, Counter.HITS.ordinal()); } @Benchmark @Threads(Threads.MAX) - public void typedUpdate_highContention() { - typedAccumulator.update(stripe -> stripe.inc(Counter.HITS)); + public void longAdderGroupIncrement_highContention() { + groupInc(longAdderGroup, Counter.HITS.ordinal()); + } + + @Benchmark + @Threads(1) + public void longAdderGroupAccumulateAnd_lowContention(Blackhole blackhole) { + groupInc(longAdderGroup, Counter.HITS.ordinal()); + blackhole.consume(groupAccumulateAnd(longAdderGroup)); + } + + @Benchmark + @Threads(Threads.MAX) + public void longAdderGroupAccumulateAnd_highContention(Blackhole blackhole) { + groupInc(longAdderGroup, Counter.HITS.ordinal()); + blackhole.consume(groupAccumulateAnd(longAdderGroup)); } - /** - * {@link Accumulator#accumulateAndReset}, paired against the raw {@link - * Accumulator.EmbeddingSupport#accumulateAndReset} it wraps. Unlike {@link Accumulator.Stripe}, - * {@link Accumulator.Counts} is documented to escape (the caller holds and reads it after - * return), so this is expected to run measurably slower than the raw call by roughly one small - * object allocation per drain -- not a scalar-replacement candidate, and not meant to look free. - */ @Benchmark @Threads(1) - public void typedAccumulateAndReset_lowContention(Blackhole blackhole) { - typedAccumulator.inc(Counter.HITS); - blackhole.consume(typedAccumulator.accumulateAndReset()); + public void accumulatorIncrement8_lowContention() { + accumulator8.inc(COUNTER8_VALUES[threadCounterIndex.get()]); } @Benchmark @Threads(Threads.MAX) - public void typedAccumulateAndReset_highContention(Blackhole blackhole) { - typedAccumulator.inc(Counter.HITS); - blackhole.consume(typedAccumulator.accumulateAndReset()); + public void accumulatorIncrement8_highContention() { + accumulator8.inc(COUNTER8_VALUES[threadCounterIndex.get()]); } @Benchmark @Threads(1) - public void longAdderGroupIncrement_lowContention() { - groupInc(longAdderGroup, Counter.HITS.ordinal()); + public void accumulatorAccumulateAndReset8_lowContention(Blackhole blackhole) { + accumulator8.inc(COUNTER8_VALUES[threadCounterIndex.get()]); + blackhole.consume(accumulator8.accumulateAndReset()); } @Benchmark @Threads(Threads.MAX) - public void longAdderGroupIncrement_highContention() { - groupInc(longAdderGroup, Counter.HITS.ordinal()); + public void accumulatorAccumulateAndReset8_highContention(Blackhole blackhole) { + accumulator8.inc(COUNTER8_VALUES[threadCounterIndex.get()]); + blackhole.consume(accumulator8.accumulateAndReset()); } @Benchmark @Threads(1) - public void longAdderGroupAccumulateAnd_lowContention(Blackhole blackhole) { - groupInc(longAdderGroup, Counter.HITS.ordinal()); - blackhole.consume(groupAccumulateAnd(longAdderGroup)); + public void longAdderGroupIncrement8_lowContention() { + groupInc(longAdderGroup8, threadCounterIndex.get()); } @Benchmark @Threads(Threads.MAX) - public void longAdderGroupAccumulateAnd_highContention(Blackhole blackhole) { - groupInc(longAdderGroup, Counter.HITS.ordinal()); - blackhole.consume(groupAccumulateAnd(longAdderGroup)); + public void longAdderGroupIncrement8_highContention() { + groupInc(longAdderGroup8, threadCounterIndex.get()); + } + + @Benchmark + @Threads(1) + public void longAdderGroupAccumulateAnd8_lowContention(Blackhole blackhole) { + groupInc(longAdderGroup8, threadCounterIndex.get()); + blackhole.consume(groupAccumulateAnd(longAdderGroup8)); + } + + @Benchmark + @Threads(Threads.MAX) + public void longAdderGroupAccumulateAnd8_highContention(Blackhole blackhole) { + groupInc(longAdderGroup8, threadCounterIndex.get()); + blackhole.consume(groupAccumulateAnd(longAdderGroup8)); } } diff --git a/internal-api/src/main/java/datadog/trace/util/Accumulator.java b/internal-api/src/main/java/datadog/trace/util/Accumulator.java index 746c985a5b6..bb830b4acce 100644 --- a/internal-api/src/main/java/datadog/trace/util/Accumulator.java +++ b/internal-api/src/main/java/datadog/trace/util/Accumulator.java @@ -1,44 +1,47 @@ package datadog.trace.util; import datadog.environment.ThreadSupport; -import datadog.trace.api.function.Strategy; -import datadog.trace.api.function.StrategyConsumer; -import java.util.Arrays; -import java.util.function.BiConsumer; -import java.util.function.Consumer; -import java.util.function.ObjLongConsumer; -import javax.annotation.ParametersAreNonnullByDefault; -import javax.annotation.concurrent.GuardedBy; +import java.util.concurrent.atomic.AtomicLongArray; /** - * A typed, instance-owning wrapper over {@link EmbeddingSupport}: ties an enum's type to its - * backing {@code long[][]} at construction, so {@link #inc}/{@link #add} can't be called with a key - * from a different enum than the one this accumulator was {@link #of created} for. Costs one - * field-load indirection per call versus calling {@link EmbeddingSupport} directly -- the same - * trade {@code StringIndex} makes over its own nested {@code EmbeddingSupport}. + * A striped, lock-free counter primitive keyed by enum ordinal: {@code LongAdder}'s write + * scalability, but as one shared, thread-sharded table instead of one independent {@code LongAdder} + * per counter -- which avoids paying {@code LongAdder}'s per-instance striping overhead {@code + * E.values().length} times over. * *

{@code
  * enum MyCounters { FOO, BAR }
  *
  * Accumulator counters = Accumulator.of(MyCounters.values());
  * counters.inc(MyCounters.FOO);
- * counters.update(stripe -> {
- *   stripe.inc(MyCounters.FOO);
- *   stripe.inc(MyCounters.BAR);
- * });
+ * counters.add(MyCounters.BAR, 5L);
  *
- * Accumulator.Counts drained = counters.accumulateAndReset(); // atomically per stripe
+ * Accumulator.Counts drained = counters.accumulateAndReset();
  * long foo = drained.get(MyCounters.FOO);
  * }
* - * @see EmbeddingSupport + *

Each counter's own {@link #accumulateAndReset} slot is read-and-zeroed with a single atomic + * {@code getAndSet}, so -- like {@code Accumulator}'s previous {@code synchronized}-stripe design, + * and unlike {@code LongAdder#sumThenReset()} -- no individual increment can land in the gap + * between summing and zeroing and be silently lost. What's gone is the previous design's + * row-wide atomicity: {@link #inc}/{@link #add} for two different counters are no longer + * guaranteed to be seen together by a concurrent {@link #accumulateAndReset}. There is no {@code + * update}-style escape hatch for grouping several counters under one atomic operation -- callers + * needing that must weigh whether the guarantee was load-bearing (most call sites are logging + * unrelated aspects of the same event, not maintaining a cross-counter invariant a reader depends + * on) or bring their own coordination. */ public final class Accumulator> { - private final long[][] data; + /** One full cache line of {@code long}s (64 bytes), used to pad each stripe row. */ + private static final int CACHE_LINE_LONGS = 8; + + private final AtomicLongArray[] data; + private final int width; private final E[] values; - private Accumulator(long[][] data, E[] values) { + private Accumulator(AtomicLongArray[] data, int width, E[] values) { this.data = data; + this.width = width; this.values = values; } @@ -46,7 +49,14 @@ private Accumulator(long[][] data, E[] values) { * @param values the enum constants naming each counter, e.g. {@code MyCounters.values()} */ public static > Accumulator of(E[] values) { - return new Accumulator<>(EmbeddingSupport.create(values), values); + int width = values.length; + int paddedWidth = paddedWidth(width); + int stripes = stripeCount(); + AtomicLongArray[] data = new AtomicLongArray[stripes]; + for (int i = 0; i < stripes; i++) { + data[i] = new AtomicLongArray(paddedWidth); + } + return new Accumulator<>(data, width, values); } /** @@ -58,118 +68,29 @@ public static > Accumulator of(Class enumType) { /** Increments the counter named by {@code key} in the calling thread's stripe by one. */ public void inc(E key) { - EmbeddingSupport.inc(data, key); + add(key, 1L); } /** Adds {@code delta} to the counter named by {@code key} in the calling thread's stripe. */ public void add(E key, long delta) { - EmbeddingSupport.add(data, key, delta); - } - - /** - * Runs {@code mutator} against a typed view of the calling thread's stripe under a single held - * lock -- the escape hatch for performing several related updates atomically with respect to a - * concurrent {@link #accumulateAndReset}. - * - * @param mutator a strategy over the selected stripe; keep it small and non-capturing so it - * inlines into the lock's critical section, and don't let the {@link Stripe} escape it (store - * it, return it, hand it to another thread) -- see {@link Stripe} - */ - @StrategyConsumer - public void update(@Strategy Consumer> mutator) { - long[] stripe = EmbeddingSupport.stripeOf(data); - synchronized (stripe) { - mutator.accept(new Stripe<>(stripe)); - } - } - - /** - * Like {@link #update(Consumer)}, but passes {@code context} to {@code mutator} as an explicit - * parameter instead of letting the mutator capture it -- for a caller that would otherwise need - * to close over a local (e.g. a count) just to get it into the critical section. Note {@code - * context} is boxed if it's a primitive at the call site; that's a real allocation trade against - * the capturing lambda it replaces, not a free win -- prefer this only when {@code context} would - * otherwise be the only thing forcing a capture. For an {@code int} or {@code long} context, use - * {@link #update(long, ObjLongConsumer)} instead to avoid that boxing entirely. - * - * @param context a value the mutator needs, passed in rather than captured - * @param mutator a strategy over {@code context} and the selected stripe; keep it small and - * non-capturing so it inlines into the lock's critical section, and don't let the {@link - * Stripe} escape it (store it, return it, hand it to another thread) -- see {@link Stripe} - */ - @StrategyConsumer - public void update(C context, @Strategy BiConsumer> mutator) { - long[] stripe = EmbeddingSupport.stripeOf(data); - synchronized (stripe) { - mutator.accept(context, new Stripe<>(stripe)); - } - } - - /** - * Like {@link #update(Object, BiConsumer)}, but for a {@code long} context -- reuses the JDK's - * {@link ObjLongConsumer} instead of the generic {@link BiConsumer}, so {@code context} is passed - * as a primitive {@code long} rather than boxed into a {@link Long}. Covers an {@code int} - * context too: it widens to {@code long} for free at the call site, no boxing either way. (A - * dedicated {@code int} overload isn't offered alongside this one -- an {@code int} argument - * would be ambiguous between the two, since it's an exact match for one and a free widening - * conversion to the other, and unrelated functional-interface types block the usual most-specific - * tiebreak.) - * - *

Note the parameter order this forces: {@link ObjLongConsumer#accept} takes {@code (T, - * long)}, so the mutator sees the stripe first and the context second -- the opposite order from - * {@link #update(Object, BiConsumer)}. - * - * @param context a primitive value the mutator needs, passed in rather than captured or boxed - * @param mutator a strategy over the selected stripe and {@code context}; keep it small and - * non-capturing so it inlines into the lock's critical section, and don't let the {@link - * Stripe} escape it (store it, return it, hand it to another thread) -- see {@link Stripe} - */ - @StrategyConsumer - public void update(long context, @Strategy ObjLongConsumer> mutator) { - long[] stripe = EmbeddingSupport.stripeOf(data); - synchronized (stripe) { - mutator.accept(new Stripe<>(stripe), context); - } + stripeOf(data).getAndAdd(key.ordinal(), delta); } /** - * A typed view over one stripe, handed to an {@link #update} strategy: the same enum-ordinal type - * checking {@link Accumulator} provides at the top level, applied inside the critical section - * too. - * - *

Constructed fresh under the held lock on every {@link #update} call. A well-behaved {@link - * Strategy} mutator -- small, non-capturing, and never storing or returning this object -- lets - * escape analysis prove it doesn't escape the inlined call and scalar-replace it, so no - * allocation survives to run time. Break those rules (capture it in a field, return it, hand it - * to another thread) and it degrades to a real, per-call allocation instead of a compile-time - * fiction with no correctness difference either way -- just a cost one. - */ - public static final class Stripe> { - private final long[] stripe; - - private Stripe(long[] stripe) { - this.stripe = stripe; - } - - /** Increments the counter named by {@code key} in this stripe by one. */ - public void inc(E key) { - EmbeddingSupport.inc(stripe, key); - } - - /** Adds {@code delta} to the counter named by {@code key} in this stripe. */ - public void add(E key, long delta) { - EmbeddingSupport.add(stripe, key, delta); - } - } - - /** - * Combines and resets every stripe, returning the sum as a typed view. + * Combines and resets every stripe, returning the sum as a typed view. Each counter is + * read-and-zeroed with one atomic {@code getAndSet} -- see the class-level note on what atomicity + * this does and doesn't provide across different counters. * * @return the sum, keyed by the enum's {@code ordinal()} - * @see EmbeddingSupport#accumulateAndReset */ public Counts accumulateAndReset() { - return new Counts<>(EmbeddingSupport.accumulateAndReset(data, values.length), values); + long[] acc = new long[width]; + for (AtomicLongArray stripe : data) { + for (int i = 0; i < width; i++) { + acc[i] += stripe.getAndSet(i, 0L); + } + } + return new Counts<>(acc, values); } /** @@ -178,21 +99,21 @@ public Counts accumulateAndReset() { * the delta a concurrent {@link #accumulateAndReset} on a reporting cadence is about to report. * * @return the sum, keyed by the enum's {@code ordinal()} - * @see EmbeddingSupport#sum(long[][], int) */ public Counts sum() { - return new Counts<>(EmbeddingSupport.sum(data, values.length), values); + long[] acc = new long[width]; + for (AtomicLongArray stripe : data) { + for (int i = 0; i < width; i++) { + acc[i] += stripe.get(i); + } + } + return new Counts<>(acc, values); } /** - * A typed view over a drained {@code long[]}, returned by {@link #accumulateAndReset} or {@link - * #sum}: the same enum-ordinal type checking {@link Accumulator} provides on writes, applied to - * the read side too. - * - *

Unlike {@link Stripe}, this is expected to escape -- the caller holds and reads it after the - * call returns -- so it's a real, per-drain allocation, not a scalar-replacement candidate. - * That's fine: {@link #accumulateAndReset} runs on a reporting cadence, not per {@link - * #inc}/{@link #add} call. + * A typed view over a drained snapshot, returned by {@link #accumulateAndReset} or {@link #sum}: + * the same enum-ordinal type checking {@link Accumulator} provides on writes, applied to the read + * side too. */ public static final class Counts> { private final long[] counts; @@ -251,232 +172,39 @@ public Counts plus(Counts other) { } /** - * The static, raw-array tier of the striped accumulator primitive: {@code LongAdder}'s write - * scalability, without {@code LongAdder}'s reset hazard. + * The calling thread's stripe: cheap masking, no allocation, no map lookup. * - *

{@code LongAdder#sumThenReset()} is documented as not atomic against concurrent - * updates: an increment landing on a cell after it's summed but before it's zeroed is silently - * and permanently lost. {@link #accumulateAndReset} closes that window by combining and resetting - * each stripe under the same lock that guards its writers. - * - *

Each stripe's state is a bare {@code long[]}, not a named-field struct. An {@code enum} - * assigns a name to each position via its ordinal, so name and position are the same declaration - * and cannot drift apart. This also makes {@link #combine} and {@link #reset} generic, - * branchless, fixed-trip-count array loops -- the shape designed to take advantage of SIMD / - * vector operations on modern hardware -- so they are implemented once here instead of once per - * caller. - * - *

This is a pure namespace over caller-owned {@code long[][]} state -- it allocates no - * container object and is not itself a strategy consumer's receiver. That means {@code create}'s - * type parameter is not bound to the one later {@code inc}/{@code add} calls infer: nothing stops - * a caller from indexing the same {@code long[][]} with a different enum than the one it was - * {@link #create}d for, which silently reads/writes the wrong slot rather than failing to - * compile. Prefer the owning {@link Accumulator} instance, which closes that hole for one - * field-load indirection per call; reach for this class directly only when that indirection is - * worth removing. - * - *

{@code
-   * enum MyCounters { FOO, BAR }
-   *
-   * long[][] data = Accumulator.EmbeddingSupport.create(MyCounters.values());
-   * Accumulator.EmbeddingSupport.inc(data, MyCounters.FOO);
-   * Accumulator.EmbeddingSupport.update(data, stripe -> {
-   *   Accumulator.EmbeddingSupport.inc(stripe, MyCounters.FOO);
-   *   Accumulator.EmbeddingSupport.inc(stripe, MyCounters.BAR);
-   * });
-   *
-   * long[] drained =
-   *     Accumulator.EmbeddingSupport.accumulateAndReset(data, MyCounters.values().length);
-   * long foo = drained[MyCounters.FOO.ordinal()];
-   * }
+ *

Multiple threads can map to the same stripe (this is masking, not a bijection); each + * counter's own atomic slot makes that safe, just not maximally scalable under a hash collision. */ - @ParametersAreNonnullByDefault - public static final class EmbeddingSupport { - private EmbeddingSupport() {} - - /** One full cache line of {@code long}s (64 bytes), used to pad each stripe's row. */ - private static final int CACHE_LINE_LONGS = 8; - - /** - * Creates the backing storage for an accumulator over {@code values}: one {@code long[]} row - * per stripe, sized to {@code values.length} plus at least one trailing cache line of padding - * so adjacent stripe rows don't false-share. - * - *

Stripe count is fixed at a power of two oversized to roughly 2x {@link - * Runtime#availableProcessors()} (minimum 4); it is not a per-call knob (see {@link - * #stripeCount()}). - * - * @param values the enum constants naming each counter, e.g. {@code MyCounters.values()} - * @return a new {@code long[stripeCount][paddedWidth]} array, zero-initialized - */ - public static > long[][] create(E[] values) { - int paddedWidth = paddedWidth(values.length); - int stripes = stripeCount(); - long[][] data = new long[stripes][]; - for (int i = 0; i < stripes; i++) { - data[i] = new long[paddedWidth]; - } - return data; - } - - /** - * Increments the counter named by {@code key} in the calling thread's stripe by one. - * - *

Convenience for the common case: selects the calling thread's stripe, takes its lock, and - * increments. To perform several increments under a single held lock, use {@link #update}. - */ - public static > void inc(long[][] data, E key) { - add(data, key, 1L); - } - - /** - * Adds {@code delta} to the counter named by {@code key} in the calling thread's stripe. - * - * @see #inc(long[][], Enum) - */ - public static > void add(long[][] data, E key, long delta) { - add(stripeOf(data), key, delta); - } - - /** - * Increments the counter named by {@code key} in {@code stripe} by one, under {@code stripe}'s - * own lock. - * - *

Intended for use inside an {@link #update} lambda, where {@code stripe} is already the - * calling thread's selected row: {@code synchronized} is reentrant, so calling this here does - * not deadlock or take a second lock. - */ - public static > void inc(long[] stripe, E key) { - add(stripe, key, 1L); - } - - /** - * Adds {@code delta} to the counter named by {@code key} in {@code stripe}, under {@code - * stripe}'s own lock. - * - * @see #inc(long[], Enum) - */ - public static > void add(long[] stripe, E key, long delta) { - synchronized (stripe) { - stripe[key.ordinal()] += delta; - } - } - - /** - * Runs {@code mutator} against the calling thread's stripe under a single held lock -- the - * escape hatch for performing several related updates atomically with respect to a concurrent - * {@link #accumulateAndReset}. - * - * @param mutator a strategy over the selected stripe; keep it small and non-capturing so it - * inlines into the lock's critical section - */ - @StrategyConsumer - public static void update(long[][] data, @Strategy Consumer mutator) { - long[] stripe = stripeOf(data); - synchronized (stripe) { - mutator.accept(stripe); - } - } - - /** - * Combines and resets every stripe, returning the sum. Each stripe is locked for exactly as - * long as it takes to fold its values into the result and zero it -- the same lock held by - * {@link #inc}/{@link #add}/{@link #update} -- so no writer can land an increment in the gap - * between summing and zeroing the way {@code LongAdder#sumThenReset()} allows. - * - *

Only the first {@code width} positions of each stripe are read or written -- the trailing - * cache line {@link #paddedWidth} reserves past that point is never touched again after {@link - * #create} zero-initializes it, so it stays a genuinely dead buffer between adjacent stripe - * rows instead of being read-and-rewritten (dirtying that cache line) on every drain. - * - * @param width the number of counters actually in use, e.g. {@code values.length} - * @return a new array of length {@code width}, indexed by the enum's {@code ordinal()} - */ - public static long[] accumulateAndReset(long[][] data, int width) { - long[] acc = new long[width]; - for (long[] stripe : data) { - synchronized (stripe) { - combine(acc, stripe, width); - reset(stripe, width); - } - } - return acc; - } - - /** - * Combines every stripe without resetting it, returning the sum -- a live, non-destructive - * snapshot for a diagnostic read that must not perturb the delta a concurrent {@link - * #accumulateAndReset} on a reporting cadence is about to report. - * - * @param width the number of counters actually in use, e.g. {@code values.length} - * @return a new array of length {@code width}, indexed by the enum's {@code ordinal()} - * @see #accumulateAndReset(long[][], int) - */ - public static long[] sum(long[][] data, int width) { - long[] acc = new long[width]; - for (long[] stripe : data) { - synchronized (stripe) { - combine(acc, stripe, width); - } - } - return acc; - } - - /** - * {@code acc[i] += stripe[i]} for {@code i} in {@code [0, width)} -- a fixed-trip-count loop C2 - * can auto-vectorize. - */ - @GuardedBy("stripe") - private static void combine(long[] acc, long[] stripe, int width) { - for (int i = 0; i < width; i++) { - acc[i] += stripe[i]; - } - } - - /** - * Zeroes {@code stripe}'s first {@code width} positions, via the JVM-intrinsic {@link - * Arrays#fill}. Deliberately stops at {@code width}, leaving the trailing padding untouched. - */ - @GuardedBy("stripe") - private static void reset(long[] stripe, int width) { - Arrays.fill(stripe, 0, width, 0L); - } - - /** - * The calling thread's stripe: cheap masking, no allocation, no map lookup. - * - *

Multiple threads can map to the same stripe (this is masking, not a bijection); each - * stripe's own lock makes that safe, just not maximally scalable under a hash collision. - */ - private static long[] stripeOf(long[][] data) { - int mask = data.length - 1; - int idx = (int) (ThreadSupport.threadId() & mask); - return data[idx]; - } + private static AtomicLongArray stripeOf(AtomicLongArray[] data) { + int mask = data.length - 1; + int idx = (int) (ThreadSupport.threadId() & mask); + return data[idx]; + } - /** - * A fixed, power-of-two stripe count deliberately oversized to roughly 2x {@link - * Runtime#availableProcessors()} (minimum 4). Not exposed as a per-call override: a mandatory - * sizing knob on every caller fails the "print test" of self-explanatory API design. - * - *

Sizing to exactly the core count leaves stripe collisions likely under real contention - * (birthday-paradox math: with {@code n} contending threads and {@code m} stripes, expected - * colliding pairs are {@code n(n-1)/(2m)}) -- and a collision costs a blocking {@code - * synchronized} wait, not a cheap CAS retry. Doubling the stripe count roughly halves that - * collision count for a one-time, per-accumulator memory cost, at the price of a slightly more - * expensive (but far rarer) {@link #accumulateAndReset} drain -- the right trade given {@link - * #inc}/ {@link #add} run on every call while {@link #accumulateAndReset} runs on a reporting - * cadence. - */ - private static int stripeCount() { - int cpus = Runtime.getRuntime().availableProcessors(); - return Math.max(4, 2 * Integer.highestOneBit(Math.max(1, cpus))); - } + /** + * A fixed, power-of-two stripe count deliberately oversized to roughly 2x {@link + * Runtime#availableProcessors()} (minimum 4). Not exposed as a per-call override: a mandatory + * sizing knob on every caller fails the "print test" of self-explanatory API design. + * + *

Sizing to exactly the core count leaves stripe collisions likely under real contention + * (birthday-paradox math: with {@code n} contending threads and {@code m} stripes, expected + * colliding pairs are {@code n(n-1)/(2m)}) -- and a collision costs CAS-retry/cache-line-bounce + * cost, the same problem {@code LongAdder}'s own {@code Cell[]} table exists to avoid. Doubling + * the stripe count roughly halves that collision count for a one-time, per-accumulator memory + * cost, at the price of a slightly more expensive (but far rarer) {@link #accumulateAndReset} + * drain -- the right trade given {@link #inc}/{@link #add} run on every call while {@link + * #accumulateAndReset} runs on a reporting cadence. + */ + private static int stripeCount() { + int cpus = Runtime.getRuntime().availableProcessors(); + return Math.max(4, 2 * Integer.highestOneBit(Math.max(1, cpus))); + } - /** Rounds {@code width} up to a whole number of cache lines, plus one full trailing line. */ - private static int paddedWidth(int width) { - int wholeLines = ((width + CACHE_LINE_LONGS - 1) / CACHE_LINE_LONGS) * CACHE_LINE_LONGS; - return wholeLines + CACHE_LINE_LONGS; - } + /** Rounds {@code width} up to a whole number of cache lines, plus one full trailing line. */ + private static int paddedWidth(int width) { + int wholeLines = ((width + CACHE_LINE_LONGS - 1) / CACHE_LINE_LONGS) * CACHE_LINE_LONGS; + return wholeLines + CACHE_LINE_LONGS; } } diff --git a/internal-api/src/test/java/datadog/trace/util/AccumulatorFootprintTest.java b/internal-api/src/test/java/datadog/trace/util/AccumulatorFootprintTest.java index 868f2ab6d0d..a8f2f6f39d8 100644 --- a/internal-api/src/test/java/datadog/trace/util/AccumulatorFootprintTest.java +++ b/internal-api/src/test/java/datadog/trace/util/AccumulatorFootprintTest.java @@ -27,24 +27,14 @@ * what each actually costs once the counters they represent are hit by real concurrent writers, as * they are on the telemetry paths this class targets. * - *

Measured on a 10-CPU machine (JDK 1.8.0_382 Zulu), 4 counters, {@code - * Accumulator.EmbeddingSupport.stripeCount()} = 16: - * - *

{@code
- * fresh:      4 LongAdders =    160 bytes, Accumulator = 2384 bytes
- * contended:  4 LongAdders =  17560 bytes, Accumulator = 2384 bytes
- * }
- * - * Finding: fresh, {@code LongAdder} looks ~15x lighter -- but that's an artifact of never having - * been written to concurrently. Once real contention forces each {@code LongAdder}'s {@code Cell[]} - * table to grow (each {@code Cell} is {@code @Contended}-padded against false sharing, the same - * problem {@link Accumulator}'s own padding solves), the four {@code LongAdder}s alone end up over - * 7x heavier than {@code Accumulator}'s entire fixed footprint -- and {@code Accumulator} does not - * grow further as more contention arrives within its existing stripe count, while every additional - * concurrently-written {@code LongAdder} keeps paying this cost independently. {@code - * Accumulator}'s up-front cost is the more predictable one: fixed at creation, independent of - * runtime contention, and shared (one striped array) across however many counters the caller's enum - * declares, rather than paid per counter. + *

{@link Accumulator}'s stripe count is fixed at creation (roughly 2x {@link + * Runtime#availableProcessors()}, minimum 4) and does not grow further as more contention arrives + * within it, while every additional concurrently-written {@code LongAdder} keeps paying its own + * {@code Cell[]} growth cost independently. {@code Accumulator}'s up-front cost is the more + * predictable one: fixed at creation, independent of runtime contention, and shared (one striped + * table) across however many counters the caller's enum declares, rather than paid per counter. The + * printed numbers below vary by run/JVM -- see the assertions for the invariants that actually + * matter. */ class AccumulatorFootprintTest { @@ -78,7 +68,7 @@ static LongAdder[] freshAdders() { @Test void freshFootprint() { LongAdder[] adders = freshAdders(); - long[][] accumulator = Accumulator.EmbeddingSupport.create(Counters.values()); + Accumulator accumulator = Accumulator.of(Counters.values()); long adderBytes = bytes((Object) adders); long accumulatorBytes = bytes(accumulator); @@ -133,7 +123,7 @@ void contendedFootprint() throws InterruptedException { } long contendedAdderBytes = bytes((Object) adders); - long[][] accumulator = Accumulator.EmbeddingSupport.create(Counters.values()); + Accumulator accumulator = Accumulator.of(Counters.values()); long accumulatorBytes = bytes(accumulator); System.out.printf( diff --git a/internal-api/src/test/java/datadog/trace/util/AccumulatorTest.java b/internal-api/src/test/java/datadog/trace/util/AccumulatorTest.java index a767b803476..f17eab823fb 100644 --- a/internal-api/src/test/java/datadog/trace/util/AccumulatorTest.java +++ b/internal-api/src/test/java/datadog/trace/util/AccumulatorTest.java @@ -23,82 +23,53 @@ enum Counters { @Test void freshAccumulatorSumsToZero() { - long[][] data = Accumulator.EmbeddingSupport.create(Counters.values()); - long[] drained = - Accumulator.EmbeddingSupport.accumulateAndReset(data, Counters.values().length); + Accumulator counters = Accumulator.of(Counters.values()); + Accumulator.Counts drained = counters.accumulateAndReset(); for (Counters c : Counters.values()) { - assertEquals(0L, drained[c.ordinal()]); + assertEquals(0L, drained.get(c)); } } @Test void incIncrementsByOne() { - long[][] data = Accumulator.EmbeddingSupport.create(Counters.values()); - Accumulator.EmbeddingSupport.inc(data, Counters.FOO); - Accumulator.EmbeddingSupport.inc(data, Counters.FOO); - Accumulator.EmbeddingSupport.inc(data, Counters.BAR); - - long[] drained = - Accumulator.EmbeddingSupport.accumulateAndReset(data, Counters.values().length); - assertEquals(2L, drained[Counters.FOO.ordinal()]); - assertEquals(1L, drained[Counters.BAR.ordinal()]); - assertEquals(0L, drained[Counters.BAZ.ordinal()]); + Accumulator counters = Accumulator.of(Counters.values()); + counters.inc(Counters.FOO); + counters.inc(Counters.FOO); + counters.inc(Counters.BAR); + + Accumulator.Counts drained = counters.accumulateAndReset(); + assertEquals(2L, drained.get(Counters.FOO)); + assertEquals(1L, drained.get(Counters.BAR)); + assertEquals(0L, drained.get(Counters.BAZ)); } @Test void addAppliesArbitraryDelta() { - long[][] data = Accumulator.EmbeddingSupport.create(Counters.values()); - Accumulator.EmbeddingSupport.add(data, Counters.BAZ, 41L); - Accumulator.EmbeddingSupport.add(data, Counters.BAZ, 1L); - - long[] drained = - Accumulator.EmbeddingSupport.accumulateAndReset(data, Counters.values().length); - assertEquals(42L, drained[Counters.BAZ.ordinal()]); - } + Accumulator counters = Accumulator.of(Counters.values()); + counters.add(Counters.BAZ, 41L); + counters.add(Counters.BAZ, 1L); - @Test - void updateAppliesSeveralOpsUnderOneLock() { - long[][] data = Accumulator.EmbeddingSupport.create(Counters.values()); - Accumulator.EmbeddingSupport.update( - data, - stripe -> { - Accumulator.EmbeddingSupport.inc(stripe, Counters.FOO); - Accumulator.EmbeddingSupport.inc(stripe, Counters.FOO); - Accumulator.EmbeddingSupport.add(stripe, Counters.BAR, 5L); - }); - - long[] drained = - Accumulator.EmbeddingSupport.accumulateAndReset(data, Counters.values().length); - assertEquals(2L, drained[Counters.FOO.ordinal()]); - assertEquals(5L, drained[Counters.BAR.ordinal()]); + Accumulator.Counts drained = counters.accumulateAndReset(); + assertEquals(42L, drained.get(Counters.BAZ)); } @Test void accumulateAndResetsSoASecondDrainIsZero() { - long[][] data = Accumulator.EmbeddingSupport.create(Counters.values()); - Accumulator.EmbeddingSupport.inc(data, Counters.FOO); + Accumulator counters = Accumulator.of(Counters.values()); + counters.inc(Counters.FOO); - long[] first = Accumulator.EmbeddingSupport.accumulateAndReset(data, Counters.values().length); - assertEquals(1L, first[Counters.FOO.ordinal()]); + Accumulator.Counts first = counters.accumulateAndReset(); + assertEquals(1L, first.get(Counters.FOO)); - long[] second = Accumulator.EmbeddingSupport.accumulateAndReset(data, Counters.values().length); + Accumulator.Counts second = counters.accumulateAndReset(); for (Counters c : Counters.values()) { - assertEquals(0L, second[c.ordinal()]); + assertEquals(0L, second.get(c)); } } - @Test - void drainedArrayIsExactlyWidthLong() { - long[][] data = Accumulator.EmbeddingSupport.create(Counters.values()); - long[] drained = - Accumulator.EmbeddingSupport.accumulateAndReset(data, Counters.values().length); - assertEquals(Counters.values().length, drained.length); - assertTrue(drained.length < data[0].length, "drained array should exclude stripe padding"); - } - @Test void concurrentIncrementsAreNotLost() throws InterruptedException { - long[][] data = Accumulator.EmbeddingSupport.create(Counters.values()); + Accumulator counters = Accumulator.of(Counters.values()); int threadCount = 16; int incrementsPerThread = 10_000; @@ -112,7 +83,7 @@ void concurrentIncrementsAreNotLost() throws InterruptedException { try { start.await(); for (int i = 0; i < incrementsPerThread; i++) { - Accumulator.EmbeddingSupport.inc(data, Counters.FOO); + counters.inc(Counters.FOO); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -127,15 +98,14 @@ void concurrentIncrementsAreNotLost() throws InterruptedException { pool.shutdown(); } - long[] drained = - Accumulator.EmbeddingSupport.accumulateAndReset(data, Counters.values().length); - assertEquals((long) threadCount * incrementsPerThread, drained[Counters.FOO.ordinal()]); + Accumulator.Counts drained = counters.accumulateAndReset(); + assertEquals((long) threadCount * incrementsPerThread, drained.get(Counters.FOO)); } @Test void concurrentAccumulateAndDuringWritesNeverExceedsWritten() throws InterruptedException, ExecutionException, TimeoutException { - long[][] data = Accumulator.EmbeddingSupport.create(Counters.values()); + Accumulator counters = Accumulator.of(Counters.values()); int threadCount = 8; int incrementsPerThread = 5_000; @@ -149,11 +119,9 @@ void concurrentAccumulateAndDuringWritesNeverExceedsWritten() pool.submit( () -> { while (!stop.get()) { - long[] drained = - Accumulator.EmbeddingSupport.accumulateAndReset( - data, Counters.values().length); + Accumulator.Counts drained = counters.accumulateAndReset(); synchronized (runningTotal) { - runningTotal[0] += drained[Counters.FOO.ordinal()]; + runningTotal[0] += drained.get(Counters.FOO); } } }); @@ -162,7 +130,7 @@ void concurrentAccumulateAndDuringWritesNeverExceedsWritten() pool.execute( () -> { for (int i = 0; i < incrementsPerThread; i++) { - Accumulator.EmbeddingSupport.inc(data, Counters.FOO); + counters.inc(Counters.FOO); } done.countDown(); }); @@ -172,10 +140,9 @@ void concurrentAccumulateAndDuringWritesNeverExceedsWritten() stop.set(true); drainer.get(30, TimeUnit.SECONDS); - long[] finalDrain = - Accumulator.EmbeddingSupport.accumulateAndReset(data, Counters.values().length); + Accumulator.Counts finalDrain = counters.accumulateAndReset(); synchronized (runningTotal) { - runningTotal[0] += finalDrain[Counters.FOO.ordinal()]; + runningTotal[0] += finalDrain.get(Counters.FOO); } assertEquals((long) threadCount * incrementsPerThread, runningTotal[0]); @@ -186,47 +153,30 @@ void concurrentAccumulateAndDuringWritesNeverExceedsWritten() @Test void sumDoesNotResetStripes() { - long[][] data = Accumulator.EmbeddingSupport.create(Counters.values()); - Accumulator.EmbeddingSupport.inc(data, Counters.FOO); + Accumulator counters = Accumulator.of(Counters.values()); + counters.inc(Counters.FOO); - long[] first = Accumulator.EmbeddingSupport.sum(data, Counters.values().length); - assertEquals(1L, first[Counters.FOO.ordinal()]); + Accumulator.Counts first = counters.sum(); + assertEquals(1L, first.get(Counters.FOO)); // sum() didn't reset anything, so a second sum() sees the same total - long[] second = Accumulator.EmbeddingSupport.sum(data, Counters.values().length); - assertEquals(1L, second[Counters.FOO.ordinal()]); + Accumulator.Counts second = counters.sum(); + assertEquals(1L, second.get(Counters.FOO)); // and a real drain afterwards still sees the value sum() didn't consume - long[] drained = - Accumulator.EmbeddingSupport.accumulateAndReset(data, Counters.values().length); - assertEquals(1L, drained[Counters.FOO.ordinal()]); + Accumulator.Counts drained = counters.accumulateAndReset(); + assertEquals(1L, drained.get(Counters.FOO)); } @Test void sumReflectsIncrementsMadeAfterAnEarlierSum() { - long[][] data = Accumulator.EmbeddingSupport.create(Counters.values()); - Accumulator.EmbeddingSupport.inc(data, Counters.FOO); - Accumulator.EmbeddingSupport.sum(data, Counters.values().length); - - Accumulator.EmbeddingSupport.inc(data, Counters.FOO); - long[] second = Accumulator.EmbeddingSupport.sum(data, Counters.values().length); - assertEquals(2L, second[Counters.FOO.ordinal()]); - } - - @Test - void typedWrapperSumDoesNotReset() { Accumulator counters = Accumulator.of(Counters.values()); counters.inc(Counters.FOO); - counters.add(Counters.BAR, 5L); + counters.sum(); - Accumulator.Counts sum = counters.sum(); - assertEquals(1L, sum.get(Counters.FOO)); - assertEquals(5L, sum.get(Counters.BAR)); - - // still there for the real drain - Accumulator.Counts drained = counters.accumulateAndReset(); - assertEquals(1L, drained.get(Counters.FOO)); - assertEquals(5L, drained.get(Counters.BAR)); + counters.inc(Counters.FOO); + Accumulator.Counts second = counters.sum(); + assertEquals(2L, second.get(Counters.FOO)); } @Test @@ -288,68 +238,4 @@ void plusCombinesAStoredRunningTotalWithALiveSumWithoutMutatingEither() { assertEquals(1L, storedTotal.get(Counters.FOO)); assertEquals(1L, counters.sum().get(Counters.FOO)); } - - @Test - void typedWrapperDelegatesToEmbeddingSupport() { - Accumulator counters = Accumulator.of(Counters.values()); - counters.inc(Counters.FOO); - counters.inc(Counters.FOO); - counters.add(Counters.BAR, 5L); - counters.update(stripe -> stripe.inc(Counters.BAZ)); - - Accumulator.Counts drained = counters.accumulateAndReset(); - assertEquals(2L, drained.get(Counters.FOO)); - assertEquals(5L, drained.get(Counters.BAR)); - assertEquals(1L, drained.get(Counters.BAZ)); - } - - @Test - void contextualUpdatePassesContextInsteadOfCapturingIt() { - Accumulator counters = Accumulator.of(Counters.values()); - String context = "abcde"; - - counters.update( - context, - (ctx, stripe) -> { - stripe.inc(Counters.FOO); - stripe.add(Counters.BAR, ctx.length()); - }); - - Accumulator.Counts drained = counters.accumulateAndReset(); - assertEquals(1L, drained.get(Counters.FOO)); - assertEquals(5L, drained.get(Counters.BAR)); - } - - @Test - void intContextWidensIntoTheLongOverloadWithoutBoxing() { - Accumulator counters = Accumulator.of(Counters.values()); - int delta = 5; - - counters.update( - delta, - (stripe, d) -> { - stripe.inc(Counters.FOO); - stripe.add(Counters.BAR, d); - }); - - Accumulator.Counts drained = counters.accumulateAndReset(); - assertEquals(1L, drained.get(Counters.FOO)); - assertEquals(5L, drained.get(Counters.BAR)); - } - - @Test - void longContextualUpdateAvoidsBoxing() { - Accumulator counters = Accumulator.of(Counters.values()); - - counters.update( - 5L, - (stripe, delta) -> { - stripe.inc(Counters.FOO); - stripe.add(Counters.BAR, delta); - }); - - Accumulator.Counts drained = counters.accumulateAndReset(); - assertEquals(1L, drained.get(Counters.FOO)); - assertEquals(5L, drained.get(Counters.BAR)); - } } diff --git a/products/metrics/metrics-api/build.gradle.kts b/products/metrics/metrics-api/build.gradle.kts index bc995a5c87d..d092baf0a58 100644 --- a/products/metrics/metrics-api/build.gradle.kts +++ b/products/metrics/metrics-api/build.gradle.kts @@ -7,4 +7,7 @@ description = "Metrics API" dependencies { implementation(libs.slf4j) + implementation(project(":internal-api")) + + testImplementation(libs.bundles.junit5) } diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCountReporter.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCountReporter.java new file mode 100644 index 00000000000..089215f7caf --- /dev/null +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCountReporter.java @@ -0,0 +1,28 @@ +package datadog.metrics.api.statsd; + +import datadog.trace.util.Accumulator; +import java.util.function.ToLongFunction; + +/** Reports a batch of per-key deltas to a {@link StatsDClient}, skipping unchanged keys. */ +public final class StatsDCountReporter { + private StatsDCountReporter() {} + + public static & StatsDCounterKey> void report( + StatsDClient statsDClient, E[] values, ToLongFunction counts) { + for (E value : values) { + long delta = counts.applyAsLong(value); + if (delta != 0) { + statsDClient.count(value.getMetricName(), delta, value.getTags()); + } + } + } + + /** + * Convenience for the common case of reporting a drained {@link Accumulator.Counts} directly -- + * the keys come along with it, so the caller doesn't need to separately pass {@code E.values()}. + */ + public static & StatsDCounterKey> void report( + StatsDClient statsDClient, Accumulator.Counts counts) { + report(statsDClient, counts.keys(), counts::get); + } +} diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCounterKey.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCounterKey.java new file mode 100644 index 00000000000..3aadbfc24f5 --- /dev/null +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCounterKey.java @@ -0,0 +1,8 @@ +package datadog.metrics.api.statsd; + +/** A counter identity: the dogstatsd metric name and tags a batch of counts should report under. */ +public interface StatsDCounterKey { + String getMetricName(); + + String[] getTags(); +} diff --git a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/RecordingStatsDClient.java b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/RecordingStatsDClient.java new file mode 100644 index 00000000000..f017a134086 --- /dev/null +++ b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/RecordingStatsDClient.java @@ -0,0 +1,63 @@ +package datadog.metrics.api.statsd; + +import java.util.ArrayList; +import java.util.List; + +/** Test fake that records every {@link #count} call for assertion. */ +final class RecordingStatsDClient implements StatsDClient { + + static final class Count { + final String metricName; + final long delta; + final String[] tags; + + Count(String metricName, long delta, String[] tags) { + this.metricName = metricName; + this.delta = delta; + this.tags = tags; + } + } + + final List counts = new ArrayList<>(); + + @Override + public void incrementCounter(String metricName, String... tags) {} + + @Override + public void count(String metricName, long delta, String... tags) { + counts.add(new Count(metricName, delta, tags)); + } + + @Override + public void gauge(String metricName, long value, String... tags) {} + + @Override + public void gauge(String metricName, double value, String... tags) {} + + @Override + public void histogram(String metricName, long value, String... tags) {} + + @Override + public void histogram(String metricName, double value, String... tags) {} + + @Override + public void distribution(String metricName, long value, String... tags) {} + + @Override + public void distribution(String metricName, double value, String... tags) {} + + @Override + public void serviceCheck( + String serviceCheckName, String status, String message, String... tags) {} + + @Override + public void error(Exception error) {} + + @Override + public int getErrorCount() { + return 0; + } + + @Override + public void close() {} +} diff --git a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDCountReporterTest.java b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDCountReporterTest.java new file mode 100644 index 00000000000..1b7263060c4 --- /dev/null +++ b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDCountReporterTest.java @@ -0,0 +1,94 @@ +package datadog.metrics.api.statsd; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class StatsDCountReporterTest { + + private static final String[] TAG_A = {"env:a"}; + private static final String[] TAG_B = {"env:b"}; + + enum Counters implements StatsDCounterKey { + FOO("foo.total", TAG_A), + BAR("bar.total", TAG_A), + SHARED_A("shared.total", TAG_A), + SHARED_B("shared.total", TAG_B); + + private final String metricName; + private final String[] tags; + + Counters(String metricName, String[] tags) { + this.metricName = metricName; + this.tags = tags; + } + + @Override + public String getMetricName() { + return metricName; + } + + @Override + public String[] getTags() { + return tags; + } + } + + @Test + void reportsNonZeroCounterWithItsOwnMetricNameAndTags() { + RecordingStatsDClient statsD = new RecordingStatsDClient(); + Map deltas = new HashMap<>(); + deltas.put(Counters.FOO, 3L); + + StatsDCountReporter.report(statsD, Counters.values(), c -> deltas.getOrDefault(c, 0L)); + + assertEquals(1, statsD.counts.size()); + RecordingStatsDClient.Count count = statsD.counts.get(0); + assertEquals("foo.total", count.metricName); + assertEquals(3L, count.delta); + assertArrayEquals(TAG_A, count.tags); + } + + @Test + void skipsZeroDeltaCounters() { + RecordingStatsDClient statsD = new RecordingStatsDClient(); + + StatsDCountReporter.report(statsD, Counters.values(), c -> 0L); + + assertTrue(statsD.counts.isEmpty()); + } + + @Test + void reportsNothingWhenEveryCounterIsZero() { + RecordingStatsDClient statsD = new RecordingStatsDClient(); + Map deltas = new HashMap<>(); + + StatsDCountReporter.report(statsD, Counters.values(), c -> deltas.getOrDefault(c, 0L)); + + assertTrue(statsD.counts.isEmpty()); + } + + @Test + void reportsConstantsSharingAMetricNameIndependentlyByTag() { + RecordingStatsDClient statsD = new RecordingStatsDClient(); + Map deltas = new HashMap<>(); + deltas.put(Counters.SHARED_A, 5L); + deltas.put(Counters.SHARED_B, 7L); + + StatsDCountReporter.report(statsD, Counters.values(), c -> deltas.getOrDefault(c, 0L)); + + assertEquals(2, statsD.counts.size()); + RecordingStatsDClient.Count a = statsD.counts.get(0); + RecordingStatsDClient.Count b = statsD.counts.get(1); + assertEquals("shared.total", a.metricName); + assertEquals(5L, a.delta); + assertArrayEquals(TAG_A, a.tags); + assertEquals("shared.total", b.metricName); + assertEquals(7L, b.delta); + assertArrayEquals(TAG_B, b.tags); + } +}