diff --git a/dd-trace-core/src/jmh/java/datadog/trace/util/concurrent/RingVsQueueBenchmark.java b/dd-trace-core/src/jmh/java/datadog/trace/util/concurrent/RingVsQueueBenchmark.java
new file mode 100644
index 00000000000..6a1e3d7c802
--- /dev/null
+++ b/dd-trace-core/src/jmh/java/datadog/trace/util/concurrent/RingVsQueueBenchmark.java
@@ -0,0 +1,191 @@
+package datadog.trace.util.concurrent;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import java.util.function.BiConsumer;
+import org.jctools.queues.MpscArrayQueue;
+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.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+/**
+ * Head-to-head comparison of {@link MpscRingBuffer} (mutable pre-allocated slots) against the
+ * conventional approach of a jctools {@link MpscArrayQueue} with a fresh {@code Slot} allocated per
+ * publish. The latter is the pattern the current CSS code uses for {@code SpanSnapshot} on the
+ * producer side, so the delta between the two measures the actual allocation/handoff saving of the
+ * ring-buffer rewrite.
+ *
+ *
+ * - {@code write_*_8p} — 8 producers, background drainer keeps the structure empty so the
+ * measurement reflects publish cost, not full-structure drop cost. Pair-compare ring vs queue
+ * at matched capacity.
+ *
- {@code e2e_*_8p} — JMH {@code @Group} pairing 8 producers with 1 consumer for each
+ * structure. End-to-end ops/s under realistic backpressure.
+ *
+ *
+ * Run with {@code -prof gc} to also see per-op allocation rate — that's where the ring's win is
+ * loudest, since the queue allocates one {@code Slot} per publish and the ring allocates none.
+ */
+@State(Scope.Benchmark)
+@Warmup(iterations = 2, time = 15, timeUnit = SECONDS)
+@Measurement(iterations = 5, time = 15, timeUnit = SECONDS)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(SECONDS)
+@Fork(value = 1)
+public class RingVsQueueBenchmark {
+
+ /**
+ * Shared slot type. {@code MpscRingBuffer} pre-allocates these and the producer mutates in place;
+ * the queue path allocates a fresh one per publish and offers the reference. Two constructors so
+ * both styles read naturally.
+ */
+ public static final class Slot {
+ long value;
+
+ Slot() {}
+
+ Slot(final long value) {
+ this.value = value;
+ }
+ }
+
+ // Static (non-capturing) handlers. Passing ts/bh as context lets the JIT keep these as
+ // singleton functions and avoid per-call lambda allocation.
+ private static final BiConsumer RING_FILLER =
+ (ts, slot) -> {
+ slot.value = ts.counter;
+ ts.counter++;
+ };
+
+ private static final BiConsumer RING_CONSUMER =
+ (bh, slot) -> bh.consume(slot.value);
+
+ @Param({"1024"})
+ public int capacity;
+
+ /** Write-side benchmark structures. Drained by a background thread so they never fill. */
+ MpscRingBuffer ring;
+
+ MpscArrayQueue queue;
+
+ /** E2e benchmark structures. JMH drives both sides via {@code @Group}; no background drainer. */
+ MpscRingBuffer e2eRing;
+
+ MpscArrayQueue e2eQueue;
+
+ private volatile boolean stopDrainers;
+ private Thread ringDrainer;
+ private Thread queueDrainer;
+
+ @Setup(Level.Trial)
+ public void setup() {
+ ring = new MpscRingBuffer<>(Slot::new, capacity);
+ queue = new MpscArrayQueue<>(capacity);
+ e2eRing = new MpscRingBuffer<>(Slot::new, capacity);
+ e2eQueue = new MpscArrayQueue<>(capacity);
+
+ stopDrainers = false;
+ ringDrainer =
+ new Thread(
+ () -> {
+ while (!stopDrainers) {
+ if (ring.drain((Slot s) -> {}) == 0) Thread.yield();
+ }
+ },
+ "RingVsQueueBenchmark-ringDrainer");
+ ringDrainer.setDaemon(true);
+ ringDrainer.start();
+
+ queueDrainer =
+ new Thread(
+ () -> {
+ while (!stopDrainers) {
+ Slot s = queue.poll();
+ if (s == null) Thread.yield();
+ }
+ },
+ "RingVsQueueBenchmark-queueDrainer");
+ queueDrainer.setDaemon(true);
+ queueDrainer.start();
+ }
+
+ @TearDown(Level.Trial)
+ public void teardown() throws InterruptedException {
+ stopDrainers = true;
+ ringDrainer.join(5_000);
+ queueDrainer.join(5_000);
+ }
+
+ @State(Scope.Thread)
+ public static class ThreadState {
+ long counter;
+ }
+
+ // ============ Write-side throughput ============
+
+ @Threads(8)
+ @Benchmark
+ public boolean write_ring_8p(final ThreadState ts) {
+ return ring.tryWrite(ts, RING_FILLER);
+ }
+
+ /** Mirrors the SpanSnapshot pattern: allocate a fresh instance per publish, offer it. */
+ @Threads(8)
+ @Benchmark
+ public boolean write_queue_8p(final ThreadState ts) {
+ return queue.offer(new Slot(ts.counter++));
+ }
+
+ // ============ End-to-end producer/consumer ============
+
+ @Group("e2e_ring_8p")
+ @GroupThreads(8)
+ @Benchmark
+ public boolean e2e_ring_producer(final ThreadState ts) {
+ return e2eRing.tryWrite(ts, RING_FILLER);
+ }
+
+ @Group("e2e_ring_8p")
+ @GroupThreads(1)
+ @Benchmark
+ public int e2e_ring_consumer(final Blackhole bh) {
+ int drained = e2eRing.drain(bh, RING_CONSUMER);
+ if (drained == 0) Thread.yield();
+ return drained;
+ }
+
+ @Group("e2e_queue_8p")
+ @GroupThreads(8)
+ @Benchmark
+ public boolean e2e_queue_producer(final ThreadState ts) {
+ return e2eQueue.offer(new Slot(ts.counter++));
+ }
+
+ @Group("e2e_queue_8p")
+ @GroupThreads(1)
+ @Benchmark
+ public int e2e_queue_consumer(final Blackhole bh) {
+ int drained = 0;
+ Slot slot;
+ while ((slot = e2eQueue.poll()) != null) {
+ bh.consume(slot.value);
+ drained++;
+ }
+ if (drained == 0) Thread.yield();
+ return drained;
+ }
+}
diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateEntry.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateEntry.java
new file mode 100644
index 00000000000..a518090f93b
--- /dev/null
+++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateEntry.java
@@ -0,0 +1,440 @@
+package datadog.trace.common.metrics;
+
+import static datadog.trace.bootstrap.instrumentation.api.UTF8BytesString.EMPTY;
+
+import datadog.metrics.api.Histogram;
+import datadog.trace.api.Pair;
+import datadog.trace.api.cache.DDCache;
+import datadog.trace.api.cache.DDCaches;
+import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
+import datadog.trace.util.Hashtable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Function;
+import javax.annotation.Nullable;
+
+/**
+ * Hashtable entry for the consumer-side aggregator. Holds the UTF8-encoded label fields that {@link
+ * SerializingMetricWriter} writes to the wire plus the mutable counter/histogram state for the key.
+ *
+ * {@link #matches(SpanSnapshot)} compares the entry's stored UTF8 forms against the snapshot's
+ * raw {@code CharSequence}/{@code String}/{@code String[]} fields via content-equality, so {@code
+ * String} vs {@code UTF8BytesString} mixing on the same logical key collapses into one entry
+ * instead of splitting.
+ *
+ *
The static UTF8 caches that used to live on {@code MetricKey} and {@code
+ * ConflatingMetricsAggregator} are consolidated here.
+ *
+ *
Deliberate cohesion. This class concentrates five responsibilities -- the static UTF8
+ * caches, the canonicalized label fields, the raw {@code peerTagNames}/{@code peerTagValues} arrays
+ * used by {@link #matches}, the pre-encoded {@code peerTags} list used by the serializer, and the
+ * mutable counter/histogram aggregate state -- on a single object. The prior design split the label
+ * fields and aggregate state across separate {@code MetricKey} and {@code AggregateMetric}
+ * instances, allocating both per unique key on miss; folding them yields one allocation per unique
+ * key. The class is wider than its predecessors as a result, but that's the trade we explicitly
+ * chose.
+ *
+ *
Required vs optional field absence. Required label fields ({@code resource}, {@code
+ * service}, {@code operationName}, {@code type}, {@code spanKind}) canonicalize a {@code null}
+ * snapshot value into {@link UTF8BytesString#EMPTY} via {@link #canonicalize} -- they are never
+ * {@code null} on a constructed entry. Optional label fields ({@code serviceSource}, {@code
+ * httpMethod}, {@code httpEndpoint}, {@code grpcStatusCode}) stay {@code null} on the entry when
+ * the snapshot value was {@code null}; the serializer uses {@code != null} to decide whether to
+ * emit them on the wire. {@link #contentEquals} treats {@code null} and length-0 as equivalent so
+ * {@link #matches} works against either form.
+ *
+ *
Not thread-safe. Counter and histogram updates are performed by the single aggregator
+ * thread; producer threads tag durations via {@link #ERROR_TAG} / {@link #TOP_LEVEL_TAG} bits and
+ * hand them off through the snapshot inbox.
+ *
+ *
Single-writer invariant relies on convention. The aggregator thread is the only mutator
+ * of this class and of {@link AggregateTable}. Nothing enforces this at runtime -- a stray mutation
+ * from a different thread (e.g. an HTTP-client callback) would corrupt counters or hashtable chains
+ * silently. The {@code ClearSignal} routing in {@link Aggregator} is the explicit mechanism for
+ * funneling cross-thread requests (e.g. {@code disable()}) back onto the aggregator thread; any new
+ * entry point that mutates aggregate state must do the same.
+ */
+final class AggregateEntry extends Hashtable.Entry {
+
+ static final long ERROR_TAG = 0x8000000000000000L;
+ static final long TOP_LEVEL_TAG = 0x4000000000000000L;
+
+ // UTF8 caches. Package-private so ConflatingMetricsAggregator's fillSlot can canonicalize
+ // producer-side via the same caches the entry constructor used to consult.
+ static final DDCache RESOURCE_CACHE = DDCaches.newFixedSizeCache(32);
+ static final DDCache SERVICE_CACHE = DDCaches.newFixedSizeCache(32);
+ static final DDCache OPERATION_CACHE = DDCaches.newFixedSizeCache(64);
+ static final DDCache SERVICE_SOURCE_CACHE =
+ DDCaches.newFixedSizeCache(16);
+ static final DDCache TYPE_CACHE = DDCaches.newFixedSizeCache(8);
+ static final DDCache SPAN_KIND_CACHE = DDCaches.newFixedSizeCache(16);
+ static final DDCache HTTP_METHOD_CACHE = DDCaches.newFixedSizeCache(8);
+ static final DDCache HTTP_ENDPOINT_CACHE =
+ DDCaches.newFixedSizeCache(32);
+ static final DDCache GRPC_STATUS_CODE_CACHE =
+ DDCaches.newFixedSizeCache(32);
+
+ /**
+ * Outer cache keyed by peer-tag name, with an inner per-name cache keyed by value. The inner
+ * cache produces the "name:value" encoded form the serializer writes.
+ */
+ private static final DDCache<
+ String, Pair, Function>>
+ PEER_TAGS_CACHE = DDCaches.newFixedSizeCache(64);
+
+ private static final Function<
+ String, Pair, Function>>
+ PEER_TAGS_CACHE_ADDER =
+ key ->
+ Pair.of(
+ DDCaches.newFixedSizeCache(512),
+ value -> UTF8BytesString.create(key + ":" + value));
+
+ private final UTF8BytesString resource;
+ private final UTF8BytesString service;
+ private final UTF8BytesString operationName;
+ @Nullable private final UTF8BytesString serviceSource;
+ private final UTF8BytesString type;
+ private final UTF8BytesString spanKind;
+ @Nullable private final UTF8BytesString httpMethod;
+ @Nullable private final UTF8BytesString httpEndpoint;
+ @Nullable private final UTF8BytesString grpcStatusCode;
+ private final short httpStatusCode;
+
+ /** Whether the root span carried the {@code synthetics} origin tag (synthetic-monitoring run). */
+ private final boolean synthetic;
+
+ /** Whether this span is the trace root ({@code parentId == 0}). */
+ private final boolean traceRoot;
+
+ // Peer tags carried in two forms: parallel String[] arrays mirroring the snapshot's (schema +
+ // values) shape for matches(), and pre-encoded List ("name:value") for the
+ // serializer. peerTagNames is the schema's names array (shared by-reference when the schema
+ // hasn't been replaced); peerTagValues is the per-span String[] parallel to it.
+ //
+ // Package-private so the in-package test helper (AggregateEntryTestUtils) can compare entries
+ // by raw layout; production access comes from this class's own matches() + constructor.
+ @Nullable final String[] peerTagNames;
+ @Nullable final String[] peerTagValues;
+ private final List peerTags;
+
+ // Mutable aggregate state -- single-thread (consumer/aggregator) writer.
+ private final Histogram okLatencies = Histogram.newHistogram();
+
+ /**
+ * Lazily allocated on the first recorded error. Most entries never see an error and keep this
+ * null for life; {@link SerializingMetricWriter} writes a cached empty-histogram form when null
+ * to keep the wire payload identical. Once allocated, it survives {@link #clear()} (cleared, not
+ * nulled) since an entry that errored once tends to error again.
+ */
+ @Nullable private Histogram errorLatencies;
+
+ private int errorCount;
+ private int hitCount;
+ private int topLevelCount;
+ private long duration;
+
+ /** Hot-path constructor for the producer/consumer flow. Slot fields are already canonical. */
+ AggregateEntry(SpanSnapshot s) {
+ super(s.keyHash);
+ // The slot's string fields are already canonical UTF8BytesString instances (canonicalized by
+ // the producer in fillSlot via the same caches we used to consult here). Just copy refs;
+ // matches() can do identity comparison instead of contentEquals.
+ this.resource = s.resourceName;
+ this.service = s.serviceName;
+ this.operationName = s.operationName;
+ this.serviceSource = s.serviceNameSource;
+ this.type = s.spanType;
+ this.spanKind = s.spanKind;
+ this.httpMethod = s.httpMethod;
+ this.httpEndpoint = s.httpEndpoint;
+ this.grpcStatusCode = s.grpcStatusCode;
+ this.httpStatusCode = s.httpStatusCode;
+ this.synthetic = s.synthetic;
+ this.traceRoot = s.traceRoot;
+ this.peerTagNames = s.peerTagSchema == null ? null : s.peerTagSchema.names;
+ // The slot's peerTagValues is a reusable scratch buffer owned by the ring -- the producer
+ // overwrites it on every claim of this slot. Snapshot it here by value so this entry's
+ // identity doesn't drift when the slot is reclaimed.
+ this.peerTagValues =
+ s.peerTagSchema == null ? null : Arrays.copyOf(s.peerTagValues, s.peerTagValues.length);
+ this.peerTags = materializePeerTags(this.peerTagNames, this.peerTagValues);
+ }
+
+ /**
+ * Records a single hit. {@code tagAndDuration} carries the duration nanos with optional {@link
+ * #ERROR_TAG} / {@link #TOP_LEVEL_TAG} bits OR-ed in.
+ */
+ void recordOneDuration(long tagAndDuration) {
+ ++hitCount;
+ if ((tagAndDuration & TOP_LEVEL_TAG) == TOP_LEVEL_TAG) {
+ tagAndDuration ^= TOP_LEVEL_TAG;
+ ++topLevelCount;
+ }
+ if ((tagAndDuration & ERROR_TAG) == ERROR_TAG) {
+ tagAndDuration ^= ERROR_TAG;
+ errorLatenciesForWrite().accept(tagAndDuration);
+ ++errorCount;
+ } else {
+ okLatencies.accept(tagAndDuration);
+ }
+ duration += tagAndDuration;
+ }
+
+ int getErrorCount() {
+ return errorCount;
+ }
+
+ int getHitCount() {
+ return hitCount;
+ }
+
+ int getTopLevelCount() {
+ return topLevelCount;
+ }
+
+ long getDuration() {
+ return duration;
+ }
+
+ Histogram getOkLatencies() {
+ return okLatencies;
+ }
+
+ /**
+ * Returns the entry's error-latency histogram, or {@code null} if no error has been recorded.
+ * Callers serializing this should treat {@code null} as "emit a cached empty histogram"; see
+ * {@link SerializingMetricWriter}.
+ */
+ @Nullable
+ Histogram getErrorLatencies() {
+ return errorLatencies;
+ }
+
+ /** Lazy-allocates {@link #errorLatencies} on the first error. */
+ private Histogram errorLatenciesForWrite() {
+ Histogram h = errorLatencies;
+ if (h == null) {
+ h = Histogram.newHistogram();
+ errorLatencies = h;
+ }
+ return h;
+ }
+
+ /**
+ * Resets the per-cycle counters and histograms. Label fields ({@code resource}, {@code service},
+ * ..., {@code peerTagNames}, {@code peerTagValues}) are deliberately left intact -- they're the
+ * entry's bucket identity and must persist so a subsequent snapshot with the same key reuses this
+ * entry instead of allocating a fresh one. Entries that stay at {@code hitCount == 0} across a
+ * cycle are reaped by {@link AggregateTable#expungeStaleAggregates}.
+ */
+ void clear() {
+ this.errorCount = 0;
+ this.hitCount = 0;
+ this.topLevelCount = 0;
+ this.duration = 0;
+ this.okLatencies.clear();
+ // errorLatencies stays null on entries that never errored. Only clear if it was allocated.
+ if (this.errorLatencies != null) {
+ this.errorLatencies.clear();
+ }
+ }
+
+ boolean matches(SpanSnapshot s) {
+ // All string fields are canonical UTF8BytesString references from the per-field DDCaches.
+ // Same content => same canonical instance => identity comparison suffices.
+ // peerTagSchema is also shared by reference (producer reads cachedPeerTagSchema), so its
+ // names array reference is stable too. peerTagValues stays content-compared since values
+ // vary per publish and aren't canonicalized.
+ return httpStatusCode == s.httpStatusCode
+ && synthetic == s.synthetic
+ && traceRoot == s.traceRoot
+ && resource == s.resourceName
+ && service == s.serviceName
+ && operationName == s.operationName
+ && serviceSource == s.serviceNameSource
+ && type == s.spanType
+ && spanKind == s.spanKind
+ // PeerTagSchema instances aren't guaranteed to be shared by reference across all
+ // producers (cachedPeerTagSchema can be swapped during reconcile; tests build fresh
+ // instances), so compare names by content. The same-reference case short-circuits to
+ // O(1) via Arrays.equals's a==b fast path.
+ && Arrays.equals(peerTagNames, s.peerTagSchema == null ? null : s.peerTagSchema.names)
+ && (peerTagNames == null || Arrays.equals(peerTagValues, s.peerTagValues))
+ && httpMethod == s.httpMethod
+ && httpEndpoint == s.httpEndpoint
+ && grpcStatusCode == s.grpcStatusCode;
+ }
+
+ /**
+ * Pre-checks {@link #keyHash} against {@code keyHash} before delegating to {@link
+ * #matches(SpanSnapshot)}. The hash check is cheap and rules out most mismatches without touching
+ * the field-by-field comparison.
+ */
+ boolean matches(long keyHash, SpanSnapshot s) {
+ return this.keyHash == keyHash && matches(s);
+ }
+
+ // Accessors for SerializingMetricWriter.
+ UTF8BytesString getResource() {
+ return resource;
+ }
+
+ UTF8BytesString getService() {
+ return service;
+ }
+
+ UTF8BytesString getOperationName() {
+ return operationName;
+ }
+
+ @Nullable
+ UTF8BytesString getServiceSource() {
+ return serviceSource;
+ }
+
+ UTF8BytesString getType() {
+ return type;
+ }
+
+ UTF8BytesString getSpanKind() {
+ return spanKind;
+ }
+
+ @Nullable
+ UTF8BytesString getHttpMethod() {
+ return httpMethod;
+ }
+
+ @Nullable
+ UTF8BytesString getHttpEndpoint() {
+ return httpEndpoint;
+ }
+
+ @Nullable
+ UTF8BytesString getGrpcStatusCode() {
+ return grpcStatusCode;
+ }
+
+ int getHttpStatusCode() {
+ return httpStatusCode;
+ }
+
+ boolean isSynthetics() {
+ return synthetic;
+ }
+
+ boolean isTraceRoot() {
+ return traceRoot;
+ }
+
+ List getPeerTags() {
+ return peerTags;
+ }
+
+ // Production AggregateEntry intentionally has no equals/hashCode override -- AggregateTable
+ // bucketing uses keyHash + matches(SpanSnapshot) directly and never invokes Object.equals.
+ // For tests that need value-equality (Spock argument matchers), use AggregateEntryTestUtils in
+ // src/test, which provides equals/hashCode helpers without exposing the contract in production.
+
+ // ----- helpers -----
+
+ static UTF8BytesString canonicalize(
+ DDCache cache, CharSequence charSeq) {
+ // Treat null and length-zero as the same canonical EMPTY instance so the producer-side
+ // canonicalization produces identity-equal results for null/"" inputs. matches() now uses
+ // identity comparison, so the previous content-equals collapse needs to happen here.
+ if (charSeq == null || charSeq.length() == 0) {
+ return EMPTY;
+ }
+ if (charSeq instanceof UTF8BytesString) {
+ return (UTF8BytesString) charSeq;
+ }
+ return cache.computeIfAbsent(charSeq.toString(), UTF8BytesString::create);
+ }
+
+ /**
+ * Like {@link #canonicalize} but returns {@code null} for a {@code null} input (rather than
+ * {@link UTF8BytesString#EMPTY}). Used for the four optional fields so the serializer can
+ * distinguish "absent" via a {@code != null} check and elide the field on the wire.
+ *
+ * The {@code instanceof UTF8BytesString} short-circuit is dead code for {@link
+ * SpanSnapshot#httpMethod}/{@code httpEndpoint}/{@code grpcStatusCode} (statically {@code
+ * String}) but live for {@link SpanSnapshot#serviceNameSource} ({@link CharSequence}); keeping a
+ * single helper keeps the constructor consistent.
+ */
+ @Nullable
+ static UTF8BytesString canonicalizeOptional(
+ DDCache cache, @Nullable CharSequence charSeq) {
+ // Treat null and length-zero the same: both map to null (i.e. "absent"). matches() uses
+ // identity comparison, so the prior content-equals collapse needs to happen here.
+ if (charSeq == null || charSeq.length() == 0) {
+ return null;
+ }
+ if (charSeq instanceof UTF8BytesString) {
+ return (UTF8BytesString) charSeq;
+ }
+ return cache.computeIfAbsent(charSeq.toString(), UTF8BytesString::create);
+ }
+
+ /**
+ * UTF8 vs raw CharSequence content-equality, no allocation in the common (String) case.
+ *
+ * Treats {@code null} and empty (length 0) as equivalent on either side. This matches the
+ * canonicalization semantics: {@link #canonicalize} maps a {@code null} input to {@link
+ * UTF8BytesString#EMPTY}, so an entry built from a snapshot with a null field needs to match a
+ * subsequent snapshot whose field is still null. {@code intHash(null) == 0 == "".hashCode()}, so
+ * the hash already agrees with this view.
+ */
+ private static boolean contentEquals(UTF8BytesString a, CharSequence b) {
+ if (a == null || a.length() == 0) {
+ return b == null || b.length() == 0;
+ }
+ // UTF8BytesString.toString() returns the underlying String -- O(1), no allocation.
+ return b != null && a.toString().contentEquals(b);
+ }
+
+ /**
+ * Encodes the per-span peer-tag values into the {@code List} the serializer
+ * consumes. Reads name/value pairs at the same index from the schema's names and the snapshot's
+ * values; null value slots are skipped (the span didn't set that peer tag). Counts hits once for
+ * exact-size allocation and preserves the singletonList fast path for the common one-entry case
+ * (e.g. internal-kind base.service).
+ */
+ private static List materializePeerTags(
+ @Nullable String[] names, @Nullable String[] values) {
+ if (names == null || values == null) {
+ return Collections.emptyList();
+ }
+ int n = names.length;
+ int firstHit = -1;
+ int hitCount = 0;
+ for (int i = 0; i < n; i++) {
+ if (values[i] != null) {
+ if (hitCount == 0) firstHit = i;
+ hitCount++;
+ }
+ }
+ if (hitCount == 0) {
+ return Collections.emptyList();
+ }
+ if (hitCount == 1) {
+ return Collections.singletonList(encodePeerTag(names[firstHit], values[firstHit]));
+ }
+ List tags = new ArrayList<>(hitCount);
+ for (int i = firstHit; i < n; i++) {
+ if (values[i] != null) {
+ tags.add(encodePeerTag(names[i], values[i]));
+ }
+ }
+ return tags;
+ }
+
+ private static UTF8BytesString encodePeerTag(String name, String value) {
+ final Pair, Function>
+ cacheAndCreator = PEER_TAGS_CACHE.computeIfAbsent(name, PEER_TAGS_CACHE_ADDER);
+ return cacheAndCreator.getLeft().computeIfAbsent(value, cacheAndCreator.getRight());
+ }
+}
diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateMetric.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateMetric.java
deleted file mode 100644
index dba66a5ab9c..00000000000
--- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateMetric.java
+++ /dev/null
@@ -1,103 +0,0 @@
-package datadog.trace.common.metrics;
-
-import datadog.metrics.api.Histogram;
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
-import java.util.concurrent.atomic.AtomicLongArray;
-
-/** Not thread-safe. Accumulates counts and durations. */
-@SuppressFBWarnings(
- value = {"AT_NONATOMIC_OPERATIONS_ON_SHARED_VARIABLE", "AT_STALE_THREAD_WRITE_OF_PRIMITIVE"},
- justification = "Explicitly not thread-safe. Accumulates counts and durations.")
-public final class AggregateMetric {
-
- static final long ERROR_TAG = 0x8000000000000000L;
- static final long TOP_LEVEL_TAG = 0x4000000000000000L;
-
- private final Histogram okLatencies;
- private final Histogram errorLatencies;
- private int errorCount;
- private int hitCount;
- private int topLevelCount;
- private long duration;
-
- public AggregateMetric() {
- okLatencies = Histogram.newHistogram();
- errorLatencies = Histogram.newHistogram();
- }
-
- public AggregateMetric recordDurations(int count, AtomicLongArray durations) {
- this.hitCount += count;
- for (int i = 0; i < count && i < durations.length(); ++i) {
- long duration = durations.getAndSet(i, 0);
- if ((duration & TOP_LEVEL_TAG) == TOP_LEVEL_TAG) {
- duration ^= TOP_LEVEL_TAG;
- ++topLevelCount;
- }
- if ((duration & ERROR_TAG) == ERROR_TAG) {
- // then it's an error
- duration ^= ERROR_TAG;
- errorLatencies.accept(duration);
- ++errorCount;
- } else {
- okLatencies.accept(duration);
- }
- this.duration += duration;
- }
- return this;
- }
-
- /**
- * Records a single hit. {@code tagAndDuration} carries the duration nanos with optional {@link
- * #ERROR_TAG} / {@link #TOP_LEVEL_TAG} bits OR-ed in.
- */
- public AggregateMetric recordOneDuration(long tagAndDuration) {
- ++hitCount;
- if ((tagAndDuration & TOP_LEVEL_TAG) == TOP_LEVEL_TAG) {
- tagAndDuration ^= TOP_LEVEL_TAG;
- ++topLevelCount;
- }
- if ((tagAndDuration & ERROR_TAG) == ERROR_TAG) {
- tagAndDuration ^= ERROR_TAG;
- errorLatencies.accept(tagAndDuration);
- ++errorCount;
- } else {
- okLatencies.accept(tagAndDuration);
- }
- duration += tagAndDuration;
- return this;
- }
-
- public int getErrorCount() {
- return errorCount;
- }
-
- public int getHitCount() {
- return hitCount;
- }
-
- public int getTopLevelCount() {
- return topLevelCount;
- }
-
- public long getDuration() {
- return duration;
- }
-
- public Histogram getOkLatencies() {
- return okLatencies;
- }
-
- public Histogram getErrorLatencies() {
- return errorLatencies;
- }
-
- @SuppressFBWarnings("AT_NONATOMIC_64BIT_PRIMITIVE")
- public void clear() {
- this.errorCount = 0;
- this.hitCount = 0;
- this.topLevelCount = 0;
- this.duration = 0;
- this.okLatencies.clear();
- this.errorLatencies.clear();
- }
-}
diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateTable.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateTable.java
new file mode 100644
index 00000000000..b23e492ce82
--- /dev/null
+++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/AggregateTable.java
@@ -0,0 +1,142 @@
+package datadog.trace.common.metrics;
+
+import datadog.trace.util.Hashtable;
+import datadog.trace.util.Hashtable.MutatingTableIterator;
+import datadog.trace.util.Hashtable.Support;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+/**
+ * Consumer-side {@link AggregateEntry} store, keyed on the raw fields of a {@link SpanSnapshot}.
+ *
+ * Replaces the prior {@code LRUCache}. The win is on the
+ * steady-state hit path: a snapshot lookup is a 64-bit hash compute + bucket walk + field-wise
+ * {@code matches}, with no per-snapshot {@link AggregateEntry} allocation and no UTF8 cache
+ * lookups. The UTF8-encoded forms (formerly held on {@code MetricKey}) and the mutable counters
+ * (formerly held on {@code AggregateMetric}) both live on the {@link AggregateEntry} now, built
+ * once per unique key at insert time.
+ *
+ * Not thread-safe. The aggregator thread is the sole writer of both this table and its
+ * contained {@link AggregateEntry} state. Any cross-thread request that needs to mutate -- e.g.
+ * {@link ConflatingMetricsAggregator#disable()} -- must funnel onto the aggregator thread via the
+ * inbox (see the {@code ClearSignal} routing in {@link Aggregator}). The invariant is convention-
+ * enforced; nothing here checks the calling thread at runtime, so a wrong-thread call would corrupt
+ * bucket chains silently.
+ */
+final class AggregateTable {
+
+ private final Hashtable.Entry[] buckets;
+ private final int maxAggregates;
+ private int size;
+
+ /**
+ * Bucket index where the last {@link #evictOneStale} successfully removed an entry. The next call
+ * resumes from this bucket so a fast-evicting workload doesn't repeatedly re-walk the same hot
+ * entries clustered near bucket 0. Reset to {@code 0} by {@link #clear}.
+ */
+ private int evictCursor;
+
+ AggregateTable(int maxAggregates) {
+ this.buckets = Support.create(maxAggregates, Support.MAX_RATIO);
+ this.maxAggregates = maxAggregates;
+ }
+
+ int size() {
+ return size;
+ }
+
+ boolean isEmpty() {
+ return size == 0;
+ }
+
+ /**
+ * Returns the {@link AggregateEntry} to update for {@code snapshot}, lazily creating one on miss.
+ * Returns {@code null} when the table is at capacity and no stale entry can be evicted -- the
+ * caller should drop the data point in that case.
+ */
+ AggregateEntry findOrInsert(SpanSnapshot snapshot) {
+ // keyHash is precomputed by the producer in SpanSnapshot.computeAndSetKeyHash, no
+ // re-hashing on the aggregator thread.
+ final long keyHash = snapshot.keyHash;
+ for (AggregateEntry candidate = Support.bucket(buckets, keyHash);
+ candidate != null;
+ candidate = candidate.next()) {
+ if (candidate.matches(keyHash, snapshot)) {
+ return candidate;
+ }
+ }
+ if (size >= maxAggregates && !evictOneStale()) {
+ return null;
+ }
+ AggregateEntry entry = new AggregateEntry(snapshot);
+ Support.insertHeadEntry(buckets, keyHash, entry);
+ size++;
+ return entry;
+ }
+
+ /**
+ * Unlinks the first entry whose {@code getHitCount() == 0}, resuming the scan from {@link
+ * #evictCursor} so back-to-back evictions amortize to O(1) per call. Worst case for a single call
+ * is still O(N) when nearly every entry is hot, but a sustained eviction stream never re-scans
+ * the hot prefix more than twice across N evictions.
+ *
+ *
The semantic intent: at cap with all entries live, drop the new key (reported via {@code
+ * onStatsAggregateDropped}) rather than evicting an established one. Cap is sized to the
+ * steady-state working set, so eviction is rare; this cursor optimization handles the
+ * pathological "persistently at cap" case.
+ */
+ private boolean evictOneStale() {
+ // Two passes -- [cursor, length) then [0, cursor) -- using the half-open-range iterator. The
+ // second pass is naturally empty when cursor==0, so no extra check needed.
+ return evictOneStaleInRange(evictCursor, buckets.length)
+ || evictOneStaleInRange(0, evictCursor);
+ }
+
+ /** Scans {@code [startBucket, endBucket)} for the first stale entry and unlinks it. */
+ private boolean evictOneStaleInRange(int startBucket, int endBucket) {
+ MutatingTableIterator iter =
+ Support.mutatingTableIterator(buckets, startBucket, endBucket);
+ while (iter.hasNext()) {
+ AggregateEntry e = iter.next();
+ if (e.getHitCount() == 0) {
+ int bucket = iter.currentBucket();
+ iter.remove();
+ size--;
+ evictCursor = bucket;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ void forEach(Consumer consumer) {
+ Support.forEach(buckets, consumer);
+ }
+
+ /**
+ * Context-passing forEach. Useful for callers that want to avoid a capturing-lambda allocation on
+ * each invocation -- pass a non-capturing {@link BiConsumer} (typically a {@code static final})
+ * plus whatever side-band state it needs as {@code context}.
+ */
+ void forEach(T context, BiConsumer consumer) {
+ Support.forEach(buckets, context, consumer);
+ }
+
+ /** Removes entries whose {@code getHitCount() == 0}. */
+ void expungeStaleAggregates() {
+ for (MutatingTableIterator iter = Support.mutatingTableIterator(buckets);
+ iter.hasNext(); ) {
+ AggregateEntry e = iter.next();
+ if (e.getHitCount() == 0) {
+ iter.remove();
+ size--;
+ }
+ }
+ }
+
+ void clear() {
+ Support.clear(buckets);
+ size = 0;
+ evictCursor = 0;
+ }
+}
diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/Aggregator.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/Aggregator.java
index 9998c21ed0b..d70a1fbaae1 100644
--- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/Aggregator.java
+++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/Aggregator.java
@@ -1,26 +1,15 @@
package datadog.trace.common.metrics;
-import static datadog.trace.api.Functions.UTF8_ENCODE;
-import static datadog.trace.common.metrics.ConflatingMetricsAggregator.PEER_TAGS_CACHE;
-import static datadog.trace.common.metrics.ConflatingMetricsAggregator.PEER_TAGS_CACHE_ADDER;
-import static datadog.trace.common.metrics.ConflatingMetricsAggregator.SERVICE_NAMES;
-import static datadog.trace.common.metrics.ConflatingMetricsAggregator.SPAN_KINDS;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import datadog.trace.api.Pair;
-import datadog.trace.api.cache.DDCache;
-import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
+import datadog.trace.common.metrics.SignalItem.ClearSignal;
import datadog.trace.common.metrics.SignalItem.StopSignal;
import datadog.trace.core.monitor.HealthMetrics;
-import datadog.trace.core.util.LRUCache;
+import datadog.trace.util.concurrent.MpscRingBuffer;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
import java.util.concurrent.TimeUnit;
-import java.util.function.Function;
+import java.util.concurrent.locks.LockSupport;
+import java.util.function.BiConsumer;
import org.jctools.queues.MessagePassingQueue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -31,9 +20,11 @@ final class Aggregator implements Runnable {
private static final Logger log = LoggerFactory.getLogger(Aggregator.class);
- private final MessagePassingQueue inbox;
- private final LRUCache aggregates;
+ private final MpscRingBuffer dataInbox;
+ private final MessagePassingQueue signalInbox;
+ private final AggregateTable aggregates;
private final MetricWriter writer;
+ private final HealthMetrics healthMetrics;
// the reporting interval controls how much history will be buffered
// when the agent is unresponsive (only 10 pending requests will be
// buffered by OkHttpSink)
@@ -55,9 +46,36 @@ final class Aggregator implements Runnable {
justification = "the field is confined to the agent thread running the Aggregator")
private boolean dirty;
+ @SuppressFBWarnings(
+ value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE",
+ justification = "the field is confined to the agent thread running the Aggregator")
+ private boolean stopped;
+
+ /**
+ * True iff the aggregator thread is currently parked (or about to park) waiting for new work.
+ * Producers volatile-read this after publishing; on true they call {@link #unparkIfWaiting()} to
+ * wake the aggregator without paying the {@code sleepMillis} latency.
+ *
+ * Volatile so the producer side sees the latest value. Set true before park, false on wake.
+ * The aggregator re-checks the inbox after setting this true (see {@link #run}) to close the race
+ * window: if a producer published just before the aggregator set the flag, the aggregator would
+ * have missed the unpark; the re-check catches that work and skips the park.
+ */
+ private volatile boolean waiting;
+
+ private volatile Thread aggregatorThread;
+
+ /**
+ * Static-singleton snapshot handler. Reads from the slot via {@code this} (passed as the context)
+ * and avoids per-drain lambda capture.
+ */
+ private static final BiConsumer SNAPSHOT_HANDLER =
+ Aggregator::handleSnapshot;
+
Aggregator(
MetricWriter writer,
- MessagePassingQueue inbox,
+ MpscRingBuffer dataInbox,
+ MessagePassingQueue signalInbox,
int maxAggregates,
long reportingInterval,
TimeUnit reportingIntervalTimeUnit,
@@ -65,7 +83,8 @@ final class Aggregator implements Runnable {
Runnable onReportCycle) {
this(
writer,
- inbox,
+ dataInbox,
+ signalInbox,
maxAggregates,
reportingInterval,
reportingIntervalTimeUnit,
@@ -76,7 +95,8 @@ final class Aggregator implements Runnable {
Aggregator(
MetricWriter writer,
- MessagePassingQueue inbox,
+ MpscRingBuffer dataInbox,
+ MessagePassingQueue signalInbox,
int maxAggregates,
long reportingInterval,
TimeUnit reportingIntervalTimeUnit,
@@ -84,48 +104,45 @@ final class Aggregator implements Runnable {
HealthMetrics healthMetrics,
Runnable onReportCycle) {
this.writer = writer;
- this.inbox = inbox;
- this.aggregates =
- new LRUCache<>(
- new AggregateExpiry(healthMetrics), maxAggregates * 4 / 3, 0.75f, maxAggregates);
+ this.dataInbox = dataInbox;
+ this.signalInbox = signalInbox;
+ this.aggregates = new AggregateTable(maxAggregates);
this.reportingIntervalNanos = reportingIntervalTimeUnit.toNanos(reportingInterval);
this.sleepMillis = sleepMillis;
+ this.healthMetrics = healthMetrics;
this.onReportCycle = onReportCycle;
}
- private static final class AggregateExpiry
- implements LRUCache.ExpiryListener {
- private final HealthMetrics healthMetrics;
-
- AggregateExpiry(HealthMetrics healthMetrics) {
- this.healthMetrics = healthMetrics;
- }
-
- @Override
- public void accept(Map.Entry expired) {
- if (expired.getValue().getHitCount() > 0) {
- healthMetrics.onStatsAggregateDropped();
- }
- }
- }
-
- public void clearAggregates() {
- this.aggregates.clear();
- }
-
@Override
public void run() {
- Thread currentThread = Thread.currentThread();
- Drainer drainer = new Drainer();
- while (!currentThread.isInterrupted() && !drainer.stopped) {
+ final Thread currentThread = Thread.currentThread();
+ aggregatorThread = currentThread;
+ final long parkNanos = TimeUnit.MILLISECONDS.toNanos(sleepMillis);
+ while (!currentThread.isInterrupted() && !stopped) {
try {
- if (!inbox.isEmpty()) {
- inbox.drain(drainer);
- } else {
- Thread.sleep(sleepMillis);
+ int drainedData = dataInbox.drain(this, SNAPSHOT_HANDLER);
+ // Signals are processed after the data drain. REPORT/STOP additionally drain any
+ // snapshots that arrived between the drain above and the signal's enqueue -- the
+ // signal-channel offer happens-before the poll here, so any publish that completed
+ // before report()/stop() is guaranteed visible to the inline drain in handleSignal.
+ SignalItem signal;
+ while (!stopped && (signal = signalInbox.poll()) != null) {
+ handleSignal(signal);
+ }
+ if (stopped) break;
+ if (drainedData == 0 && signalInbox.isEmpty()) {
+ waiting = true;
+ try {
+ // Re-check after publishing the waiting flag. A producer that published just before
+ // we set waiting=true won't have unparked us (they read waiting=false); the re-check
+ // catches that work via the volatile producerCursor.
+ if (dataInbox.isEmpty() && signalInbox.isEmpty()) {
+ LockSupport.parkNanos(this, parkNanos);
+ }
+ } finally {
+ waiting = false;
+ }
}
- } catch (InterruptedException e) {
- currentThread.interrupt();
} catch (Throwable error) {
log.debug("error aggregating metrics", error);
}
@@ -133,93 +150,70 @@ public void run() {
log.debug("metrics aggregator exited");
}
- private final class Drainer implements MessagePassingQueue.Consumer {
-
- boolean stopped = false;
-
- @Override
- public void accept(InboxItem item) {
- if (item instanceof SignalItem) {
- SignalItem signal = (SignalItem) item;
- if (!stopped) {
- report(wallClockTime(), signal);
- stopped = item instanceof StopSignal;
- if (stopped) {
- signal.complete();
- }
- } else {
- signal.ignore();
- }
- } else if (item instanceof SpanSnapshot && !stopped) {
- SpanSnapshot snapshot = (SpanSnapshot) item;
- MetricKey key = buildMetricKey(snapshot);
- AggregateMetric aggregate = aggregates.computeIfAbsent(key, k -> new AggregateMetric());
- aggregate.recordOneDuration(snapshot.tagAndDuration);
- dirty = true;
- }
- }
- }
-
- private static MetricKey buildMetricKey(SpanSnapshot s) {
- return new MetricKey(
- s.resourceName,
- SERVICE_NAMES.computeIfAbsent(s.serviceName, UTF8_ENCODE),
- s.operationName,
- s.serviceNameSource,
- s.spanType,
- s.httpStatusCode,
- s.synthetic,
- s.traceRoot,
- SPAN_KINDS.computeIfAbsent(s.spanKind, UTF8BytesString::create),
- materializePeerTags(s.peerTagSchema, s.peerTagValues),
- s.httpMethod,
- s.httpEndpoint,
- s.grpcStatusCode);
- }
-
/**
- * Encodes the per-span peer-tag values into the {@code List} the {@link
- * MetricKey} consumes. Reads name/value pairs at the same index from the schema's names and the
- * snapshot's values; null value slots are skipped (the span didn't set that peer tag).
+ * Wake the aggregator if it's currently parked. Called from producer threads after publishing.
+ *
+ * Cost on the producer hot path is one volatile read; only on a {@code true} read does the
+ * producer pay an {@code unpark} (cheap idempotent atomic). At saturating workloads where the
+ * aggregator is never parked, the volatile read is the only cost.
*/
- private static List materializePeerTags(PeerTagSchema schema, String[] values) {
- if (schema == null || values == null) {
- return Collections.emptyList();
- }
- String[] names = schema.names;
- int n = names.length;
- // First pass: count how many tags fired and remember the first index. The single-entry case
- // is common (e.g. INTERNAL spans only emit base.service) and gets a singletonList to avoid an
- // ArrayList allocation on the hot path.
- int firstHit = -1;
- int hitCount = 0;
- for (int i = 0; i < n; i++) {
- if (values[i] != null) {
- if (hitCount == 0) {
- firstHit = i;
- }
- hitCount++;
+ void unparkIfWaiting() {
+ if (waiting) {
+ final Thread t = aggregatorThread;
+ if (t != null) {
+ LockSupport.unpark(t);
}
}
- if (hitCount == 0) {
- return Collections.emptyList();
- }
- if (hitCount == 1) {
- return Collections.singletonList(encodePeerTag(names[firstHit], values[firstHit]));
+ }
+
+ private static void handleSnapshot(Aggregator agg, SpanSnapshot snapshot) {
+ // Skip-sentinel from the producer's overclaim path: the producer claimed this slot
+ // speculatively (one slot per span in the trace) but the span turned out to be non-eligible
+ // or the trace hit an ignored-resource. shouldComputeMetric guarantees duration > 0 for any
+ // real publish, so tagAndDuration == 0 uniquely identifies a skip.
+ if (snapshot.tagAndDuration == 0L) {
+ return;
}
- List tags = new ArrayList<>(hitCount);
- for (int i = firstHit; i < n; i++) {
- if (values[i] != null) {
- tags.add(encodePeerTag(names[i], values[i]));
- }
+ AggregateEntry entry = agg.aggregates.findOrInsert(snapshot);
+ if (entry != null) {
+ entry.recordOneDuration(snapshot.tagAndDuration);
+ agg.dirty = true;
+ } else {
+ // table at cap with no stale entry available to evict
+ agg.healthMetrics.onStatsAggregateDropped();
}
- return tags;
}
- private static UTF8BytesString encodePeerTag(String name, String value) {
- final Pair, Function>
- cacheAndCreator = PEER_TAGS_CACHE.computeIfAbsent(name, PEER_TAGS_CACHE_ADDER);
- return cacheAndCreator.getLeft().computeIfAbsent(value, cacheAndCreator.getRight());
+ private void handleSignal(SignalItem signal) {
+ if (signal == ClearSignal.CLEAR) {
+ // ClearSignal is routed through the signal channel (rather than letting the caller mutate
+ // AggregateTable directly) so the aggregator thread stays the sole writer. AggregateTable
+ // is not thread-safe; a direct clear() from e.g. the OkHttpSink callback thread would race
+ // with the data-drain on this thread.
+ //
+ // We do NOT clear the snapshot data ring here. Any in-flight snapshots will drain
+ // naturally into the just-cleared table, get re-aggregated, and flushed on the next
+ // report -- where the agent rejects them again, triggering another DOWNGRADED -> disable()
+ // -> CLEAR cycle. Worst case: one extra reporting cycle of wasted work.
+ aggregates.clear();
+ // Clear dirty too -- without this, the next report() would see dirty=true, run
+ // expungeStaleAggregates against the (now-empty) table, find isEmpty()=true, and skip
+ // the flush anyway. Same observable outcome, but resetting here keeps the invariant
+ // "dirty implies there's data to flush" honest.
+ dirty = false;
+ signal.complete();
+ } else {
+ // STOP or REPORT: catch up on any data that arrived between the loop's main drain and the
+ // signal being enqueued. Producer's signalInbox.offer happens-after its dataInbox publish,
+ // so by the time we observe the signal, every snapshot the producer had published is
+ // visible to drain() here. This is what gives report() bucket-boundary determinism.
+ dataInbox.drain(this, SNAPSHOT_HANDLER);
+ report(wallClockTime(), signal);
+ if (signal instanceof StopSignal) {
+ stopped = true;
+ signal.complete();
+ }
+ }
}
private void report(long when, SignalItem signal) {
@@ -234,14 +228,16 @@ private void report(long when, SignalItem signal) {
boolean skipped = true;
if (dirty) {
try {
- expungeStaleAggregates();
+ aggregates.expungeStaleAggregates();
if (!aggregates.isEmpty()) {
skipped = false;
writer.startBucket(aggregates.size(), when, reportingIntervalNanos);
- for (Map.Entry aggregate : aggregates.entrySet()) {
- writer.add(aggregate.getKey(), aggregate.getValue());
- aggregate.getValue().clear();
- }
+ aggregates.forEach(
+ writer,
+ (w, entry) -> {
+ w.add(entry);
+ entry.clear();
+ });
// note that this may do IO and block
writer.finishBucket();
}
@@ -257,17 +253,6 @@ private void report(long when, SignalItem signal) {
}
}
- private void expungeStaleAggregates() {
- Iterator> it = aggregates.entrySet().iterator();
- while (it.hasNext()) {
- Map.Entry pair = it.next();
- AggregateMetric metric = pair.getValue();
- if (metric.getHitCount() == 0) {
- it.remove();
- }
- }
- }
-
private long wallClockTime() {
return MILLISECONDS.toNanos(System.currentTimeMillis());
}
diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/ConflatingMetricsAggregator.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/ConflatingMetricsAggregator.java
index dc5d698bcc1..dc6ea86bac7 100644
--- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/ConflatingMetricsAggregator.java
+++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/ConflatingMetricsAggregator.java
@@ -5,8 +5,9 @@
import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_ENDPOINT;
import static datadog.trace.bootstrap.instrumentation.api.Tags.HTTP_METHOD;
import static datadog.trace.bootstrap.instrumentation.api.Tags.SPAN_KIND;
-import static datadog.trace.common.metrics.AggregateMetric.ERROR_TAG;
-import static datadog.trace.common.metrics.AggregateMetric.TOP_LEVEL_TAG;
+import static datadog.trace.common.metrics.AggregateEntry.ERROR_TAG;
+import static datadog.trace.common.metrics.AggregateEntry.TOP_LEVEL_TAG;
+import static datadog.trace.common.metrics.SignalItem.ClearSignal.CLEAR;
import static datadog.trace.common.metrics.SignalItem.ReportSignal.REPORT;
import static datadog.trace.common.metrics.SignalItem.StopSignal.STOP;
import static datadog.trace.util.AgentThreadFactory.AgentThread.METRICS_AGGREGATOR;
@@ -18,12 +19,8 @@
import datadog.communication.ddagent.DDAgentFeaturesDiscovery;
import datadog.communication.ddagent.SharedCommunicationObjects;
import datadog.trace.api.Config;
-import datadog.trace.api.Pair;
import datadog.trace.api.WellKnownTags;
-import datadog.trace.api.cache.DDCache;
-import datadog.trace.api.cache.DDCaches;
import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags;
-import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
import datadog.trace.common.metrics.SignalItem.ReportSignal;
import datadog.trace.common.writer.ddagent.DDAgentApi;
import datadog.trace.core.CoreSpan;
@@ -31,6 +28,7 @@
import datadog.trace.core.SpanKindFilter;
import datadog.trace.core.monitor.HealthMetrics;
import datadog.trace.util.AgentTaskScheduler;
+import datadog.trace.util.concurrent.MpscRingBuffer;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -39,7 +37,6 @@
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
-import java.util.function.Function;
import org.jctools.queues.MessagePassingQueue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -51,22 +48,6 @@ public final class ConflatingMetricsAggregator implements MetricsAggregator, Eve
private static final Map DEFAULT_HEADERS =
Collections.singletonMap(DDAgentApi.DATADOG_META_TRACER_VERSION, DDTraceCoreInfo.VERSION);
- static final DDCache SERVICE_NAMES = DDCaches.newFixedSizeCache(32);
-
- static final DDCache SPAN_KINDS = DDCaches.newFixedSizeCache(16);
- static final DDCache<
- String, Pair, Function>>
- PEER_TAGS_CACHE =
- DDCaches.newFixedSizeCache(
- 64); // it can be unbounded since those values are returned by the agent and should be
- // under control. 64 entries is enough in this case to contain all the peer tags.
- static final Function<
- String, Pair, Function>>
- PEER_TAGS_CACHE_ADDER =
- key ->
- Pair.of(
- DDCaches.newFixedSizeCache(512),
- value -> UTF8BytesString.create(key + ":" + value));
private static final CharSequence SYNTHETICS_ORIGIN = "synthetics";
private static final SpanKindFilter METRICS_ELIGIBLE_KINDS =
@@ -83,9 +64,27 @@ public final class ConflatingMetricsAggregator implements MetricsAggregator, Eve
private static final SpanKindFilter INTERNAL_KIND =
SpanKindFilter.builder().includeInternal().build();
+ /** Capacity of the small MPSC queue carrying control signals (REPORT / STOP / CLEAR). */
+ private static final int SIGNAL_INBOX_CAPACITY = 16;
+
private final Set ignoredResources;
private final Thread thread;
- private final MessagePassingQueue inbox;
+
+ /**
+ * Producer/consumer data channel: a {@link MpscRingBuffer} of pre-allocated, recyclable {@link
+ * SpanSnapshot} slots. {@link #publish(List)} claims a range of slots in one CAS via {@link
+ * MpscRingBuffer#tryClaimRange} and fills each in place via {@link #fillSlot} -- no per-publish
+ * allocation.
+ */
+ private final MpscRingBuffer dataInbox;
+
+ /**
+ * Separate small queue for control signals. Kept off the data ring because the ring only holds
+ * one element type (the slot), and routing signals through a side channel lets the aggregator
+ * service them ahead of the data backlog each loop iteration.
+ */
+ private final MessagePassingQueue signalInbox;
+
private final Sink sink;
private final Aggregator aggregator;
private final long reportingInterval;
@@ -193,14 +192,16 @@ public ConflatingMetricsAggregator(
boolean includeEndpointInMetrics) {
this.ignoredResources = ignoredResources;
this.includeEndpointInMetrics = includeEndpointInMetrics;
- this.inbox = Queues.mpscArrayQueue(queueSize);
+ this.dataInbox = new MpscRingBuffer<>(SpanSnapshot::new, queueSize);
+ this.signalInbox = Queues.mpscArrayQueue(SIGNAL_INBOX_CAPACITY);
this.features = features;
this.healthMetrics = healthMetric;
this.sink = sink;
this.aggregator =
new Aggregator(
metricWriter,
- inbox,
+ dataInbox,
+ signalInbox,
maxAggregates,
reportingInterval,
timeUnit,
@@ -238,11 +239,11 @@ public boolean report() {
boolean published;
int attempts = 0;
do {
- published = inbox.offer(REPORT);
+ published = signalInbox.offer(REPORT);
++attempts;
} while (!published && attempts < 10);
if (!published) {
- log.debug("Skipped metrics reporting because the queue is full");
+ log.debug("Skipped metrics reporting because the signal queue is full");
}
return published;
}
@@ -265,7 +266,7 @@ public Future forceReport() {
ReportSignal reportSignal = new ReportSignal();
boolean published = false;
while (thread.isAlive() && !published) {
- published = inbox.offer(reportSignal);
+ published = signalInbox.offer(reportSignal);
if (!published) {
try {
Thread.sleep(10);
@@ -284,27 +285,81 @@ public Future forceReport() {
@Override
public boolean publish(List extends CoreSpan>> trace) {
+ if (!features.supportsMetrics()) {
+ return false;
+ }
+ final int traceSize = trace.size();
+ if (traceSize == 0) {
+ return false;
+ }
+
+ // Overclaim the worst case: assume every span in the trace is metrics-eligible and claim
+ // that many ring slots in a single CAS. Non-eligible spans get a "skip" sentinel
+ // (tagAndDuration = 0L); the aggregator recognises this and bypasses findOrInsert for those
+ // slots. We expect most spans in a typical trace to be eligible (top-level / measured /
+ // server-client-producer-consumer kinds), so the slot waste is small in exchange for a
+ // single-pass producer loop. Indexed iteration (vs. enhanced-for) avoids per-call Iterator
+ // allocation -- trace is typically a SpanList, but even Collections.singletonList's
+ // iterator() allocates.
+ final long startSeq = dataInbox.tryClaimRange(traceSize);
+ if (startSeq < 0L) {
+ healthMetrics.onStatsInboxFull();
+ return false;
+ }
+
boolean forceKeep = false;
- int counted = 0;
- if (features.supportsMetrics()) {
- for (CoreSpan> span : trace) {
- boolean isTopLevel = span.isTopLevel();
- if (shouldComputeMetric(span, isTopLevel)) {
- final CharSequence resourceName = span.getResourceName();
- if (resourceName != null && ignoredResources.contains(resourceName.toString())) {
- // skip publishing all children
- forceKeep = false;
- break;
+ int filled = 0;
+
+ for (int i = 0; i < traceSize; i++) {
+ final CoreSpan> span = trace.get(i);
+ final long seq = startSeq + i;
+ final SpanSnapshot slot = dataInbox.slotAt(seq);
+ final boolean isTopLevel = span.isTopLevel();
+
+ if (shouldComputeMetric(span, isTopLevel)) {
+ final CharSequence resourceName = span.getResourceName();
+ if (resourceName != null && ignoredResources.contains(resourceName.toString())) {
+ // Ignored resource: drop metrics for the whole trace. Mark this slot AND every
+ // remaining slot in the claimed range as skip-sentinel so the aggregator advances past
+ // them.
+ slot.tagAndDuration = 0L;
+ dataInbox.publish(seq);
+ for (int j = i + 1; j < traceSize; j++) {
+ final long skipSeq = startSeq + j;
+ dataInbox.slotAt(skipSeq).tagAndDuration = 0L;
+ dataInbox.publish(skipSeq);
}
- counted++;
- forceKeep |= publish(span, isTopLevel);
+ healthMetrics.onClientStatTraceComputed(0, traceSize, true);
+ aggregator.unparkIfWaiting();
+ return false;
+ }
+ try {
+ fillSlot(span, isTopLevel, slot);
+ } finally {
+ // Publish in finally so a throwing fillSlot can't strand the consumer at this
+ // sequence -- matches the publish-on-throw guarantee in MpscRingBuffer.tryWrite.
+ dataInbox.publish(seq);
}
+ forceKeep |= span.getError() > 0;
+ filled++;
+ } else {
+ // Non-eligible span: mark slot as skip-sentinel and publish so the aggregator advances.
+ slot.tagAndDuration = 0L;
+ dataInbox.publish(seq);
}
- healthMetrics.onClientStatTraceComputed(counted, trace.size(), !forceKeep);
}
+
+ healthMetrics.onClientStatTraceComputed(filled, traceSize, !forceKeep);
+ // Wake the aggregator if it parked while we were filling. Producer-side cost is one volatile
+ // read when the aggregator isn't parked.
+ aggregator.unparkIfWaiting();
return forceKeep;
}
+ // Other site that posts work the aggregator needs to see: ignored-resource short-circuit
+ // returns above before reaching unparkIfWaiting. Mirror that wake-up there too so the
+ // aggregator picks up the skip-sentinel slots quickly.
+
private boolean shouldComputeMetric(CoreSpan> span, boolean isTopLevel) {
return (span.isMeasured() || isTopLevel || span.isKind(METRICS_ELIGIBLE_KINDS))
&& span.getLongRunningVersion()
@@ -312,21 +367,16 @@ private boolean shouldComputeMetric(CoreSpan> span, boolean isTopLevel) {
&& span.getDurationNano() > 0;
}
- private boolean publish(CoreSpan> span, boolean isTopLevel) {
- // Error decision drives force-keep sampling regardless of whether the snapshot gets queued.
- boolean error = span.getError() > 0;
-
- // Fast-path the inbox-full case before any tag extraction or snapshot allocation. size() is
- // approximate on jctools' MPSC queue but that's fine: if we under-estimate, we fall through
- // and let inbox.offer be the source of truth (existing behavior); if we over-estimate, we
- // drop a snapshot that would have fit -- acceptable, onStatsInboxFull was going to fire
- // imminently anyway.
- if (inbox.size() >= inbox.capacity()) {
- healthMetrics.onStatsInboxFull();
- return error;
- }
+ /**
+ * Producer-side slot fill. Called between a {@link MpscRingBuffer#tryClaimRange} and the matching
+ * {@link MpscRingBuffer#publish}: reads from {@code span} and instance state, writes every field
+ * on {@code slot} (including {@code null} where applicable) so stale values from a prior occupant
+ * of this slot don't bleed through.
+ */
+ private void fillSlot(CoreSpan> span, boolean isTopLevel, SpanSnapshot slot) {
+ final boolean error = span.getError() > 0;
- // Extract HTTP method and endpoint only if the feature is enabled
+ // Extract HTTP method and endpoint only if the feature is enabled.
String httpMethod = null;
String httpEndpoint = null;
if (includeEndpointInMetrics) {
@@ -349,37 +399,63 @@ private boolean publish(CoreSpan> span, boolean isTopLevel) {
long tagAndDuration =
span.getDurationNano() | (error ? ERROR_TAG : 0L) | (isTopLevel ? TOP_LEVEL_TAG : 0L);
+ // Fill the slot's reusable peerTagValues scratch in place. The slot owns its array across
+ // publishes; we only allocate when the slot has none or the schema's length changed. When
+ // no tags fire, leave the scratch in place and clear peerTagSchema -- consumers gate on
+ // peerTagSchema (see AggregateEntry.matches / hashOf), so stale scratch contents are inert.
PeerTagSchema peerTagSchema = peerTagSchemaFor(span);
- String[] peerTagValues =
- peerTagSchema == null ? null : capturePeerTagValues(span, peerTagSchema);
- if (peerTagValues == null) {
- // No tags fired -- drop the schema reference so the consumer doesn't bother iterating an
- // all-null array.
- peerTagSchema = null;
+ if (peerTagSchema != null) {
+ final String[] names = peerTagSchema.names;
+ final int n = names.length;
+ String[] valuesArr = slot.peerTagValues;
+ if (valuesArr == null || valuesArr.length != n) {
+ valuesArr = new String[n];
+ slot.peerTagValues = valuesArr;
+ }
+ boolean anyHit = false;
+ for (int i = 0; i < n; i++) {
+ Object v = span.unsafeGetTag(names[i]);
+ String tag = v == null ? null : v.toString();
+ valuesArr[i] = tag;
+ anyHit |= (tag != null);
+ }
+ if (!anyHit) {
+ // Schema fired no tags -- treat like the no-peer-tag case. Keep valuesArr alive on the
+ // slot for the next publish; nullifying peerTagSchema makes downstream gates skip it.
+ peerTagSchema = null;
+ }
}
- SpanSnapshot snapshot =
- new SpanSnapshot(
- span.getResourceName(),
- span.getServiceName(),
- span.getOperationName(),
- span.getServiceNameSource(),
- spanType,
- span.getHttpStatusCode(),
- isSynthetic(span),
- span.getParentId() == 0,
- spanKind,
- peerTagSchema,
- peerTagValues,
- httpMethod,
- httpEndpoint,
- grpcStatusCode,
- tagAndDuration);
- if (!inbox.offer(snapshot)) {
- healthMetrics.onStatsInboxFull();
- }
- // force keep keys if there are errors
- return error;
+ // Canonicalize every string field to UTF8BytesString via the per-field DDCaches on
+ // AggregateEntry. This work used to happen on the aggregator thread; doing it here
+ // distributes the cost across producer threads and gives the aggregator's matches() identity
+ // comparisons instead of contentEquals chains.
+ slot.resourceName =
+ AggregateEntry.canonicalize(AggregateEntry.RESOURCE_CACHE, span.getResourceName());
+ slot.serviceName =
+ AggregateEntry.canonicalize(AggregateEntry.SERVICE_CACHE, span.getServiceName());
+ slot.operationName =
+ AggregateEntry.canonicalize(AggregateEntry.OPERATION_CACHE, span.getOperationName());
+ slot.serviceNameSource =
+ AggregateEntry.canonicalizeOptional(
+ AggregateEntry.SERVICE_SOURCE_CACHE, span.getServiceNameSource());
+ slot.spanType = AggregateEntry.canonicalize(AggregateEntry.TYPE_CACHE, spanType);
+ slot.httpStatusCode = span.getHttpStatusCode();
+ slot.synthetic = isSynthetic(span);
+ slot.traceRoot = span.getParentId() == 0;
+ slot.spanKind = AggregateEntry.canonicalize(AggregateEntry.SPAN_KIND_CACHE, spanKind);
+ slot.peerTagSchema = peerTagSchema;
+ slot.httpMethod =
+ AggregateEntry.canonicalizeOptional(AggregateEntry.HTTP_METHOD_CACHE, httpMethod);
+ slot.httpEndpoint =
+ AggregateEntry.canonicalizeOptional(AggregateEntry.HTTP_ENDPOINT_CACHE, httpEndpoint);
+ slot.grpcStatusCode =
+ AggregateEntry.canonicalizeOptional(AggregateEntry.GRPC_STATUS_CODE_CACHE, grpcStatusCode);
+ slot.tagAndDuration = tagAndDuration;
+
+ // Precompute the table-lookup key hash on the producer side. AggregateTable#findOrInsert
+ // reads this directly instead of running the chained hashOf on the aggregator thread.
+ SpanSnapshot.computeAndSetKeyHash(slot);
}
/**
@@ -464,28 +540,6 @@ private void reconcilePeerTagSchema() {
}
}
- /**
- * Captures the span's peer-tag values into a {@code String[]} parallel to {@code schema.names}.
- * Slots remain {@code null} for tags the span didn't set; the array itself is lazily allocated on
- * the first hit so spans that fire no peer tags pay zero allocation. Returns {@code null} when
- * none of the configured peer tags are set on the span.
- */
- private static String[] capturePeerTagValues(CoreSpan> span, PeerTagSchema schema) {
- String[] names = schema.names;
- int n = names.length;
- String[] values = null;
- for (int i = 0; i < n; i++) {
- Object v = span.unsafeGetTag(names[i]);
- if (v != null) {
- if (values == null) {
- values = new String[n];
- }
- values[i] = v.toString();
- }
- }
- return values;
- }
-
private static boolean isSynthetic(CoreSpan> span) {
return span.getOrigin() != null && SYNTHETICS_ORIGIN.equals(span.getOrigin().toString());
}
@@ -494,7 +548,7 @@ public void stop() {
if (null != cancellation) {
cancellation.cancel();
}
- inbox.offer(STOP);
+ signalInbox.offer(STOP);
}
@Override
@@ -532,8 +586,18 @@ private void disable() {
features.discover();
if (!features.supportsMetrics()) {
log.debug("Disabling metric reporting because an agent downgrade was detected");
- this.inbox.clear();
- this.aggregator.clearAggregates();
+ // Route the clear through the signal channel so the aggregator thread is the only writer.
+ // AggregateTable is not thread-safe; mutating it directly from this thread would race
+ // with snapshot processing on the aggregator thread.
+ //
+ // Best-effort single offer rather than the retry-loop pattern in report(). If the signal
+ // queue is full at downgrade time the clear is dropped, but the system self-heals:
+ // features.discover() already flipped supportsMetrics() false, so producer publish() calls
+ // now skip the data ring; the aggregator drains existing snapshots and ships them on the
+ // next report cycle; the sink rejects that payload and fires DOWNGRADED again, which
+ // retries disable() against a now-empty signal queue. Worst case: one extra reporting
+ // cycle of stale data.
+ signalInbox.offer(CLEAR);
}
}
diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/InboxItem.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/InboxItem.java
index 7d66cad6a15..e7c37f91768 100644
--- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/InboxItem.java
+++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/InboxItem.java
@@ -4,6 +4,17 @@
interface InboxItem {}
+/**
+ * Inbox-routed control message. Each subclass exposes a process-wide {@code static final} singleton
+ * ({@link StopSignal#STOP}, {@link ReportSignal#REPORT}, {@link ClearSignal#CLEAR}) for the common
+ * fire-and-forget case and is also directly instantiable when a caller needs to await handling.
+ *
+ * Singletons are fire-and-forget. The inherited {@link #future} is completed on first
+ * handling by the aggregator thread and never reset, so a second posting of the same singleton
+ * cannot signal completion to a fresh awaiter -- the future is already done. Callers that want
+ * one-shot completion semantics (e.g. {@code forceReport()}) must allocate a fresh instance ({@code
+ * new ReportSignal()}) rather than reusing the singleton.
+ */
abstract class SignalItem implements InboxItem {
final CompletableFuture future;
@@ -20,12 +31,26 @@ void ignore() {
}
static final class StopSignal extends SignalItem {
+ /** Fire-and-forget singleton. See class-level note on {@link SignalItem}. */
static final StopSignal STOP = new StopSignal();
private StopSignal() {}
}
static final class ReportSignal extends SignalItem {
+ /** Fire-and-forget singleton; {@code forceReport()} allocates fresh instances. */
static final ReportSignal REPORT = new ReportSignal();
}
+
+ /**
+ * Posted from arbitrary threads (e.g. the Sink event thread during agent downgrade) so the
+ * aggregator thread is the one that actually performs the table reset. Keeps {@link
+ * AggregateTable} and {@code inbox.clear()} single-writer.
+ */
+ static final class ClearSignal extends SignalItem {
+ /** Fire-and-forget singleton. See class-level note on {@link SignalItem}. */
+ static final ClearSignal CLEAR = new ClearSignal();
+
+ private ClearSignal() {}
+ }
}
diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/MetricKey.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/MetricKey.java
deleted file mode 100644
index 9e2e2098d1f..00000000000
--- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/MetricKey.java
+++ /dev/null
@@ -1,178 +0,0 @@
-package datadog.trace.common.metrics;
-
-import static datadog.trace.bootstrap.instrumentation.api.UTF8BytesString.EMPTY;
-
-import datadog.trace.api.cache.DDCache;
-import datadog.trace.api.cache.DDCaches;
-import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
-import datadog.trace.util.HashingUtils;
-import java.util.Collections;
-import java.util.List;
-import java.util.Objects;
-
-/** The aggregation key for tracked metrics. */
-public final class MetricKey {
- static final DDCache RESOURCE_CACHE = DDCaches.newFixedSizeCache(32);
- static final DDCache SERVICE_CACHE = DDCaches.newFixedSizeCache(8);
- static final DDCache SERVICE_SOURCE_CACHE =
- DDCaches.newFixedSizeCache(16);
- static final DDCache OPERATION_CACHE = DDCaches.newFixedSizeCache(64);
- static final DDCache TYPE_CACHE = DDCaches.newFixedSizeCache(8);
- static final DDCache KIND_CACHE = DDCaches.newFixedSizeCache(8);
- static final DDCache HTTP_METHOD_CACHE = DDCaches.newFixedSizeCache(8);
- static final DDCache HTTP_ENDPOINT_CACHE =
- DDCaches.newFixedSizeCache(32);
- static final DDCache GRPC_STATUS_CODE_CACHE =
- DDCaches.newFixedSizeCache(32);
-
- private final UTF8BytesString resource;
- private final UTF8BytesString service;
- private final UTF8BytesString serviceSource;
- private final UTF8BytesString operationName;
- private final UTF8BytesString type;
- private final int httpStatusCode;
- private final boolean synthetics;
- private final int hash;
- private final boolean isTraceRoot;
- private final UTF8BytesString spanKind;
- private final List peerTags;
- private final UTF8BytesString httpMethod;
- private final UTF8BytesString httpEndpoint;
- private final UTF8BytesString grpcStatusCode;
-
- public MetricKey(
- CharSequence resource,
- CharSequence service,
- CharSequence operationName,
- CharSequence serviceSource,
- CharSequence type,
- int httpStatusCode,
- boolean synthetics,
- boolean isTraceRoot,
- CharSequence spanKind,
- List peerTags,
- CharSequence httpMethod,
- CharSequence httpEndpoint,
- CharSequence grpcStatusCode) {
- this.resource = null == resource ? EMPTY : utf8(RESOURCE_CACHE, resource);
- this.service = null == service ? EMPTY : utf8(SERVICE_CACHE, service);
- this.serviceSource = null == serviceSource ? null : utf8(SERVICE_SOURCE_CACHE, serviceSource);
- this.operationName = null == operationName ? EMPTY : utf8(OPERATION_CACHE, operationName);
- this.type = null == type ? EMPTY : utf8(TYPE_CACHE, type);
- this.httpStatusCode = httpStatusCode;
- this.synthetics = synthetics;
- this.isTraceRoot = isTraceRoot;
- this.spanKind = null == spanKind ? EMPTY : utf8(KIND_CACHE, spanKind);
- this.peerTags = peerTags == null ? Collections.emptyList() : peerTags;
- this.httpMethod = httpMethod == null ? null : utf8(HTTP_METHOD_CACHE, httpMethod);
- this.httpEndpoint = httpEndpoint == null ? null : utf8(HTTP_ENDPOINT_CACHE, httpEndpoint);
- this.grpcStatusCode =
- grpcStatusCode == null ? null : utf8(GRPC_STATUS_CODE_CACHE, grpcStatusCode);
-
- int tmpHash = 0;
- tmpHash = HashingUtils.addToHash(tmpHash, this.isTraceRoot);
- tmpHash = HashingUtils.addToHash(tmpHash, this.spanKind);
- tmpHash = HashingUtils.addToHash(tmpHash, this.peerTags);
- tmpHash = HashingUtils.addToHash(tmpHash, this.resource);
- tmpHash = HashingUtils.addToHash(tmpHash, this.service);
- tmpHash = HashingUtils.addToHash(tmpHash, this.operationName);
- tmpHash = HashingUtils.addToHash(tmpHash, this.type);
- tmpHash = HashingUtils.addToHash(tmpHash, this.httpStatusCode);
- tmpHash = HashingUtils.addToHash(tmpHash, this.synthetics);
- tmpHash = HashingUtils.addToHash(tmpHash, this.serviceSource);
- tmpHash = HashingUtils.addToHash(tmpHash, this.httpEndpoint);
- tmpHash = HashingUtils.addToHash(tmpHash, this.httpMethod);
- tmpHash = HashingUtils.addToHash(tmpHash, this.grpcStatusCode);
- this.hash = tmpHash;
- }
-
- static UTF8BytesString utf8(DDCache cache, CharSequence charSeq) {
- if (charSeq instanceof UTF8BytesString) {
- return (UTF8BytesString) charSeq;
- } else {
- return cache.computeIfAbsent(charSeq.toString(), UTF8BytesString::create);
- }
- }
-
- public UTF8BytesString getResource() {
- return resource;
- }
-
- public UTF8BytesString getService() {
- return service;
- }
-
- public UTF8BytesString getServiceSource() {
- return serviceSource;
- }
-
- public UTF8BytesString getOperationName() {
- return operationName;
- }
-
- public UTF8BytesString getType() {
- return type;
- }
-
- public int getHttpStatusCode() {
- return httpStatusCode;
- }
-
- public boolean isSynthetics() {
- return synthetics;
- }
-
- public boolean isTraceRoot() {
- return isTraceRoot;
- }
-
- public UTF8BytesString getSpanKind() {
- return spanKind;
- }
-
- public List getPeerTags() {
- return peerTags;
- }
-
- public UTF8BytesString getHttpMethod() {
- return httpMethod;
- }
-
- public UTF8BytesString getHttpEndpoint() {
- return httpEndpoint;
- }
-
- public UTF8BytesString getGrpcStatusCode() {
- return grpcStatusCode;
- }
-
- @Override
- public boolean equals(Object o) {
- if (this == o) {
- return true;
- }
- if ((o instanceof MetricKey)) {
- MetricKey metricKey = (MetricKey) o;
- return hash == metricKey.hash
- && synthetics == metricKey.synthetics
- && httpStatusCode == metricKey.httpStatusCode
- && resource.equals(metricKey.resource)
- && service.equals(metricKey.service)
- && operationName.equals(metricKey.operationName)
- && type.equals(metricKey.type)
- && isTraceRoot == metricKey.isTraceRoot
- && spanKind.equals(metricKey.spanKind)
- && peerTags.equals(metricKey.peerTags)
- && Objects.equals(serviceSource, metricKey.serviceSource)
- && Objects.equals(httpMethod, metricKey.httpMethod)
- && Objects.equals(httpEndpoint, metricKey.httpEndpoint)
- && Objects.equals(grpcStatusCode, metricKey.grpcStatusCode);
- }
- return false;
- }
-
- @Override
- public int hashCode() {
- return hash;
- }
-}
diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/MetricWriter.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/MetricWriter.java
index fa26ed2e5db..905ba498760 100644
--- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/MetricWriter.java
+++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/MetricWriter.java
@@ -3,7 +3,11 @@
public interface MetricWriter {
void startBucket(int metricCount, long start, long duration);
- void add(MetricKey key, AggregateMetric aggregate);
+ /**
+ * Serialize one aggregate. The {@link AggregateEntry} carries both the label fields (resource,
+ * service, span.kind, peer tags, etc.) and the counters being reported.
+ */
+ void add(AggregateEntry entry);
void finishBucket();
diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/PeerTagSchema.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/PeerTagSchema.java
index 4821d1b33a4..d3a3d47d65a 100644
--- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/PeerTagSchema.java
+++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/PeerTagSchema.java
@@ -43,6 +43,14 @@ final class PeerTagSchema {
final String[] names;
+ /**
+ * Precomputed {@code Arrays.hashCode(names)}. The schema is shared across many publishes so
+ * recomputing it on the aggregator hot path (per-publish call to {@code AggregateEntry.hashOf})
+ * was waste -- it showed up as a top aggregator-thread sample. Cached here, computed once at
+ * construction.
+ */
+ final int namesHash;
+
/**
* The {@code DDAgentFeaturesDiscovery.state()} hash this schema was built from. The aggregator
* thread reads and updates this once per reporting cycle when reconciling against the latest
@@ -54,6 +62,7 @@ final class PeerTagSchema {
private PeerTagSchema(String[] names, String state) {
this.names = names;
+ this.namesHash = java.util.Arrays.hashCode(names);
this.state = state;
}
diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/SerializingMetricWriter.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/SerializingMetricWriter.java
index 0f84964e9db..c9fb15b4d0c 100644
--- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/SerializingMetricWriter.java
+++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/SerializingMetricWriter.java
@@ -142,12 +142,12 @@ public void startBucket(int metricCount, long start, long duration) {
}
@Override
- public void add(MetricKey key, AggregateMetric aggregate) {
+ public void add(AggregateEntry entry) {
// Calculate dynamic map size based on optional fields
- final boolean hasHttpMethod = key.getHttpMethod() != null;
- final boolean hasHttpEndpoint = key.getHttpEndpoint() != null;
- final boolean hasServiceSource = key.getServiceSource() != null;
- final boolean hasGrpcStatusCode = key.getGrpcStatusCode() != null;
+ final boolean hasHttpMethod = entry.getHttpMethod() != null;
+ final boolean hasHttpEndpoint = entry.getHttpEndpoint() != null;
+ final boolean hasServiceSource = entry.getServiceSource() != null;
+ final boolean hasGrpcStatusCode = entry.getGrpcStatusCode() != null;
final int mapSize =
15
+ (hasServiceSource ? 1 : 0)
@@ -158,31 +158,31 @@ public void add(MetricKey key, AggregateMetric aggregate) {
writer.startMap(mapSize);
writer.writeUTF8(NAME);
- writer.writeUTF8(key.getOperationName());
+ writer.writeUTF8(entry.getOperationName());
writer.writeUTF8(SERVICE);
- writer.writeUTF8(key.getService());
+ writer.writeUTF8(entry.getService());
writer.writeUTF8(RESOURCE);
- writer.writeUTF8(key.getResource());
+ writer.writeUTF8(entry.getResource());
writer.writeUTF8(TYPE);
- writer.writeUTF8(key.getType());
+ writer.writeUTF8(entry.getType());
writer.writeUTF8(HTTP_STATUS_CODE);
- writer.writeInt(key.getHttpStatusCode());
+ writer.writeInt(entry.getHttpStatusCode());
writer.writeUTF8(SYNTHETICS);
- writer.writeBoolean(key.isSynthetics());
+ writer.writeBoolean(entry.isSynthetics());
writer.writeUTF8(IS_TRACE_ROOT);
- writer.writeInt(key.isTraceRoot() ? TRISTATE_TRUE : TRISTATE_FALSE);
+ writer.writeInt(entry.isTraceRoot() ? TRISTATE_TRUE : TRISTATE_FALSE);
writer.writeUTF8(SPAN_KIND);
- writer.writeUTF8(key.getSpanKind());
+ writer.writeUTF8(entry.getSpanKind());
writer.writeUTF8(PEER_TAGS);
- final List peerTags = key.getPeerTags();
+ final List peerTags = entry.getPeerTags();
writer.startArray(peerTags.size());
for (UTF8BytesString peerTag : peerTags) {
@@ -191,43 +191,68 @@ public void add(MetricKey key, AggregateMetric aggregate) {
if (hasServiceSource) {
writer.writeUTF8(SERVICE_SOURCE);
- writer.writeUTF8(key.getServiceSource());
+ writer.writeUTF8(entry.getServiceSource());
}
// Only include HTTPMethod if present
if (hasHttpMethod) {
writer.writeUTF8(HTTP_METHOD);
- writer.writeUTF8(key.getHttpMethod());
+ writer.writeUTF8(entry.getHttpMethod());
}
// Only include HTTPEndpoint if present
if (hasHttpEndpoint) {
writer.writeUTF8(HTTP_ENDPOINT);
- writer.writeUTF8(key.getHttpEndpoint());
+ writer.writeUTF8(entry.getHttpEndpoint());
}
// Only include GRPCStatusCode if present (rpc-type spans)
if (hasGrpcStatusCode) {
writer.writeUTF8(GRPC_STATUS_CODE);
- writer.writeUTF8(key.getGrpcStatusCode());
+ writer.writeUTF8(entry.getGrpcStatusCode());
}
writer.writeUTF8(HITS);
- writer.writeInt(aggregate.getHitCount());
+ writer.writeInt(entry.getHitCount());
writer.writeUTF8(ERRORS);
- writer.writeInt(aggregate.getErrorCount());
+ writer.writeInt(entry.getErrorCount());
writer.writeUTF8(TOP_LEVEL_HITS);
- writer.writeInt(aggregate.getTopLevelCount());
+ writer.writeInt(entry.getTopLevelCount());
writer.writeUTF8(DURATION);
- writer.writeLong(aggregate.getDuration());
+ writer.writeLong(entry.getDuration());
writer.writeUTF8(OK_SUMMARY);
- writer.writeBinary(aggregate.getOkLatencies().serialize());
+ writer.writeBinary(entry.getOkLatencies().serialize());
writer.writeUTF8(ERROR_SUMMARY);
- writer.writeBinary(aggregate.getErrorLatencies().serialize());
+ final datadog.metrics.api.Histogram errorLatencies = entry.getErrorLatencies();
+ if (errorLatencies != null) {
+ writer.writeBinary(errorLatencies.serialize());
+ } else {
+ // Entry never saw an error; emit a cached empty-histogram payload so the wire format is
+ // unchanged without allocating a histogram per entry.
+ writer.writeBinary(emptyErrorHistogramBytes());
+ }
+ }
+
+ private byte[] emptyHistogramBytesCache;
+
+ /**
+ * Returns the cached serialized form of an empty histogram. Computed lazily on first call so the
+ * {@link datadog.metrics.api.Histograms} factory has been registered (by the producer-side tracer
+ * startup or test setup) before we sample its output.
+ */
+ private byte[] emptyErrorHistogramBytes() {
+ byte[] cached = emptyHistogramBytesCache;
+ if (cached == null) {
+ java.nio.ByteBuffer buf = datadog.metrics.api.Histogram.newHistogram().serialize();
+ cached = new byte[buf.remaining()];
+ buf.get(cached);
+ emptyHistogramBytesCache = cached;
+ }
+ return cached;
}
@Override
diff --git a/dd-trace-core/src/main/java/datadog/trace/common/metrics/SpanSnapshot.java b/dd-trace-core/src/main/java/datadog/trace/common/metrics/SpanSnapshot.java
index eb9b741cea6..f10d60167cc 100644
--- a/dd-trace-core/src/main/java/datadog/trace/common/metrics/SpanSnapshot.java
+++ b/dd-trace-core/src/main/java/datadog/trace/common/metrics/SpanSnapshot.java
@@ -1,75 +1,118 @@
package datadog.trace.common.metrics;
+import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
+import datadog.trace.util.LongHashingUtils;
+import java.util.Arrays;
+import javax.annotation.Nullable;
+
/**
- * Immutable per-span value posted from the producer to the aggregator thread. Carries the raw
- * inputs the aggregator needs to build a {@link MetricKey} and update an {@link AggregateMetric}.
+ * Per-span value posted from the producer to the aggregator thread. Carries the canonical inputs
+ * the aggregator needs to look up or build an {@link AggregateEntry} and update its counters.
+ *
+ * Fields are mutable so this class can serve as a slot in {@link
+ * datadog.trace.util.concurrent.MpscRingBuffer}: the producer claims a slot, writes its fields, and
+ * publishes; the aggregator reads them. There is exactly one writer per outstanding sequence (the
+ * producer that claimed it) and one reader (the aggregator), so no synchronization on fields is
+ * required.
*
- *
All cache-canonicalization (service-name, span-kind, peer-tag string interning) happens on the
- * aggregator thread; the producer just shuffles references.
+ *
Producer-side canonicalization and hashing. String fields are canonicalized to {@link
+ * UTF8BytesString} on the producer side (see {@link AggregateEntry#canonicalize}); same-content
+ * strings collapse to the same canonical reference through the per-field {@code DDCache} instances
+ * on {@link AggregateEntry}. The producer also precomputes {@link #keyHash} via the same chain the
+ * consumer used to use. The aggregator then does identity comparisons in {@link
+ * AggregateEntry#matches} and a precomputed-hash table lookup, with all the per-field work
+ * distributed across producer threads where there's CPU headroom.
*/
-final class SpanSnapshot implements InboxItem {
-
- final CharSequence resourceName;
- final String serviceName;
- final CharSequence operationName;
- final CharSequence serviceNameSource;
- final CharSequence spanType;
- final short httpStatusCode;
- final boolean synthetic;
- final boolean traceRoot;
- final String spanKind;
+final class SpanSnapshot {
+
+ /** Canonical UTF-8 form of the span's resource name. {@link UTF8BytesString#EMPTY} when null. */
+ UTF8BytesString resourceName;
+
+ /** Canonical UTF-8 form of the span's service. {@link UTF8BytesString#EMPTY} when null. */
+ UTF8BytesString serviceName;
+
+ /** Canonical UTF-8 form of the span's operation name. {@link UTF8BytesString#EMPTY} when null. */
+ UTF8BytesString operationName;
+
+ /** Canonical UTF-8 form, or {@code null} when the span had no service-name source set. */
+ @Nullable UTF8BytesString serviceNameSource;
+
+ /** Canonical UTF-8 form of the span's type. {@link UTF8BytesString#EMPTY} when null. */
+ UTF8BytesString spanType;
+
+ short httpStatusCode;
+ boolean synthetic;
+ boolean traceRoot;
+
+ /** Canonical UTF-8 form of the span kind. {@link UTF8BytesString#EMPTY} when not set. */
+ UTF8BytesString spanKind;
/**
* Schema for {@link #peerTagValues}. {@code null} when the span has no peer tags. The schema
* carries the names in parallel-array form; {@code peerTagValues} holds the per-span tag values
* at the same indices.
*/
- final PeerTagSchema peerTagSchema;
+ @Nullable PeerTagSchema peerTagSchema;
/**
* Peer tag values captured from the span, parallel to {@code peerTagSchema.names}. A {@code null}
- * entry means the span didn't have that peer tag set. {@code null} (the whole array) when {@link
- * #peerTagSchema} is {@code null}.
+ * entry means the span didn't have that peer tag set. These are per-publish strings (typically
+ * not shared across calls), so they stay as {@link String} -- canonicalizing them is more
+ * expensive than the aggregator's content-comparison on a 1-3 element array.
+ *
+ *
The slot owns this array as a reusable scratch buffer; see {@code
+ * ConflatingMetricsAggregator#fillSlot}.
*/
- final String[] peerTagValues;
+ @Nullable String[] peerTagValues;
- final String httpMethod;
- final String httpEndpoint;
- final String grpcStatusCode;
+ @Nullable UTF8BytesString httpMethod;
+ @Nullable UTF8BytesString httpEndpoint;
+ @Nullable UTF8BytesString grpcStatusCode;
/** Duration in nanoseconds, OR-ed with {@code ERROR_TAG} / {@code TOP_LEVEL_TAG} as needed. */
- final long tagAndDuration;
-
- SpanSnapshot(
- CharSequence resourceName,
- String serviceName,
- CharSequence operationName,
- CharSequence serviceNameSource,
- CharSequence spanType,
- short httpStatusCode,
- boolean synthetic,
- boolean traceRoot,
- String spanKind,
- PeerTagSchema peerTagSchema,
- String[] peerTagValues,
- String httpMethod,
- String httpEndpoint,
- String grpcStatusCode,
- long tagAndDuration) {
- this.resourceName = resourceName;
- this.serviceName = serviceName;
- this.operationName = operationName;
- this.serviceNameSource = serviceNameSource;
- this.spanType = spanType;
- this.httpStatusCode = httpStatusCode;
- this.synthetic = synthetic;
- this.traceRoot = traceRoot;
- this.spanKind = spanKind;
- this.peerTagSchema = peerTagSchema;
- this.peerTagValues = peerTagValues;
- this.httpMethod = httpMethod;
- this.httpEndpoint = httpEndpoint;
- this.grpcStatusCode = grpcStatusCode;
- this.tagAndDuration = tagAndDuration;
+ long tagAndDuration;
+
+ /**
+ * Hashtable bucket key, computed by the producer at the end of {@code fillSlot} via {@link
+ * #computeAndSetKeyHash}. {@code AggregateTable#findOrInsert} uses this directly instead of
+ * re-running the chained hash on the aggregator thread.
+ */
+ long keyHash;
+
+ /** No-arg constructor used by {@link datadog.trace.util.concurrent.MpscRingBuffer}. */
+ SpanSnapshot() {}
+
+ /**
+ * Computes and stores {@link #keyHash} from the snapshot's current canonical fields. The producer
+ * calls this at the end of {@code fillSlot}; the aggregator then uses {@code keyHash} directly
+ * for bucket lookup in {@code AggregateTable}.
+ *
+ *
Mixing identical to the prior {@code AggregateEntry.hashOf} so the bucket distribution is
+ * unchanged: {@code UTF8BytesString.hashCode} returns the underlying {@code String} hash, so
+ * snapshots built from {@code String} and {@code UTF8BytesString} of the same content fold to the
+ * same long.
+ */
+ static void computeAndSetKeyHash(SpanSnapshot s) {
+ long h = 0;
+ h = LongHashingUtils.addToHash(h, s.resourceName);
+ h = LongHashingUtils.addToHash(h, s.serviceName);
+ h = LongHashingUtils.addToHash(h, s.operationName);
+ h = LongHashingUtils.addToHash(h, s.serviceNameSource);
+ h = LongHashingUtils.addToHash(h, s.spanType);
+ h = LongHashingUtils.addToHash(h, s.httpStatusCode);
+ h = LongHashingUtils.addToHash(h, s.synthetic);
+ h = LongHashingUtils.addToHash(h, s.traceRoot);
+ h = LongHashingUtils.addToHash(h, s.spanKind);
+ // peerTagValues is gated by peerTagSchema -- the slot's array is a reusable scratch buffer
+ // that may carry stale contents from a prior tag-firing publish when this publish had no
+ // peer tags. Hash it only when the schema says it's meaningful.
+ h = LongHashingUtils.addToHash(h, s.peerTagSchema == null ? 0 : s.peerTagSchema.namesHash);
+ h =
+ LongHashingUtils.addToHash(
+ h, s.peerTagSchema == null ? 0 : Arrays.hashCode(s.peerTagValues));
+ h = LongHashingUtils.addToHash(h, s.httpMethod);
+ h = LongHashingUtils.addToHash(h, s.httpEndpoint);
+ h = LongHashingUtils.addToHash(h, s.grpcStatusCode);
+ s.keyHash = h;
}
}
diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/AggregateMetricTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/AggregateMetricTest.groovy
deleted file mode 100644
index 140149d8324..00000000000
--- a/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/AggregateMetricTest.groovy
+++ /dev/null
@@ -1,105 +0,0 @@
-package datadog.trace.common.metrics
-
-import datadog.metrics.agent.AgentMeter
-import datadog.metrics.impl.DDSketchHistograms
-import datadog.metrics.impl.MonitoringImpl
-import datadog.metrics.api.statsd.StatsDClient
-import datadog.trace.test.util.DDSpecification
-
-import java.util.concurrent.TimeUnit
-import java.util.concurrent.atomic.AtomicLongArray
-
-import static datadog.trace.common.metrics.AggregateMetric.ERROR_TAG
-import static datadog.trace.common.metrics.AggregateMetric.TOP_LEVEL_TAG
-
-class AggregateMetricTest extends DDSpecification {
-
- def setupSpec() {
- // Initialize AgentMeter with monitoring - this is the standard mechanism used in production
- def monitoring = new MonitoringImpl(StatsDClient.NO_OP, 1, TimeUnit.SECONDS)
- AgentMeter.registerIfAbsent(StatsDClient.NO_OP, monitoring, DDSketchHistograms.FACTORY)
- // Create a timer to trigger DDSketchHistograms loading and Factory registration
- // This simulates what happens during CoreTracer initialization (traceWriteTimer)
- monitoring.newTimer("test.init")
- }
-
- def "record durations sums up to total"() {
- given:
- AggregateMetric aggregate = new AggregateMetric()
- when:
- aggregate.recordDurations(3, new AtomicLongArray(1, 2, 3))
- then:
- aggregate.getDuration() == 6
- }
-
- def "total durations include errors"() {
- given:
- AggregateMetric aggregate = new AggregateMetric()
- when:
- aggregate.recordDurations(3, new AtomicLongArray(1, 2, 3))
- then:
- aggregate.getDuration() == 6
- }
-
- def "clear"() {
- given:
- AggregateMetric aggregate = new AggregateMetric()
- .recordDurations(3, new AtomicLongArray(5, ERROR_TAG | 6, TOP_LEVEL_TAG | 7))
- when:
- aggregate.clear()
- then:
- aggregate.getDuration() == 0
- aggregate.getErrorCount() == 0
- aggregate.getTopLevelCount() == 0
- aggregate.getHitCount() == 0
- }
-
- def "recordOneDuration accumulates ok and error and top-level"() {
- given:
- AggregateMetric aggregate = new AggregateMetric()
- .recordOneDuration(10L)
- .recordOneDuration(10L | TOP_LEVEL_TAG)
- .recordOneDuration(10L | ERROR_TAG)
-
- expect:
- aggregate.getHitCount() == 3
- aggregate.getDuration() == 30
- aggregate.getErrorCount() == 1
- aggregate.getTopLevelCount() == 1
- }
-
- def "ignore trailing zeros"() {
- given:
- AggregateMetric aggregate = new AggregateMetric()
- when:
- aggregate.recordDurations(3, new AtomicLongArray(1, 2, 3, 0, 0, 0))
- then:
- aggregate.getDuration() == 6
- aggregate.getHitCount() == 3
- aggregate.getErrorCount() == 0
- }
-
- def "hit count includes errors"() {
- given:
- AggregateMetric aggregate = new AggregateMetric()
- when:
- aggregate.recordDurations(3, new AtomicLongArray(1, 2, 3 | ERROR_TAG))
- then:
- aggregate.getHitCount() == 3
- aggregate.getErrorCount() == 1
- }
-
- def "ok and error durations tracked separately"() {
- given:
- AggregateMetric aggregate = new AggregateMetric()
- when:
- aggregate.recordDurations(10,
- new AtomicLongArray(1, 100 | ERROR_TAG, 2, 99 | ERROR_TAG, 3,
- 98 | ERROR_TAG, 4, 97 | ERROR_TAG))
- then:
- def errorLatencies = aggregate.getErrorLatencies()
- def okLatencies = aggregate.getOkLatencies()
- errorLatencies.getMaxValue() >= 99
- okLatencies.getMaxValue() <= 5
- }
-}
diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/ConflatingMetricAggregatorTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/ConflatingMetricAggregatorTest.groovy
index a95f6bcbdbc..193d46b18bd 100644
--- a/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/ConflatingMetricAggregatorTest.groovy
+++ b/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/ConflatingMetricAggregatorTest.groovy
@@ -27,7 +27,12 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
@Shared
long reportingInterval = 1
@Shared
- int queueSize = 256
+ // The CSS overclaim path claims traceSize slots per publish (not just the eligible count),
+ // so tests with multi-span traces and tight ring sizes can spuriously hit capacity. 1024 leaves
+ // plenty of headroom for the largest test traces (~10 spans) across hundreds of iterations.
+ // ConflatingMetricsAggregatorInboxFullTest deliberately keeps a tiny ring to exercise full-ring
+ // behaviour.
+ int queueSize = 1024
def "should ignore traces with no measured spans"() {
setup:
@@ -119,23 +124,25 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then:
latchTriggered
1 * writer.startBucket(1, _, _)
- 1 * writer.add(new MetricKey(
- null,
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "baz",
- [],
- null,
- null,
- null
- ), _) >> { MetricKey key, AggregateMetric value ->
- value.getHitCount() == 1 && value.getTopLevelCount() == 1 && value.getDuration() == 100
- }
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ null,
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "baz",
+ [],
+ null,
+ null,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getTopLevelCount() == 1 && e.getDuration() == 100
+ }
1 * writer.finishBucket() >> { latch.countDown() }
cleanup:
@@ -165,23 +172,25 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then:
latchTriggered
1 * writer.startBucket(1, _, _)
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "baz",
- [],
- null,
- null,
- null
- ), _) >> { MetricKey key, AggregateMetric value ->
- value.getHitCount() == 1 && value.getTopLevelCount() == 1 && value.getDuration() == 100
- }
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "baz",
+ [],
+ null,
+ null,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getTopLevelCount() == 1 && e.getDuration() == 100
+ }
1 * writer.finishBucket() >> { latch.countDown() }
cleanup:
@@ -216,24 +225,26 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then:
latchTriggered == statsComputed
(statsComputed ? 1 : 0) * writer.startBucket(1, _, _)
- (statsComputed ? 1 : 0) * writer.add(
- new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- kind,
- [],
- httpMethod,
- httpEndpoint,
- null
- ), { AggregateMetric aggregateMetric ->
- aggregateMetric.getHitCount() == 1 && aggregateMetric.getTopLevelCount() == 0 && aggregateMetric.getDuration() == 100
- })
+ (statsComputed ? 1 : 0) * writer.add({
+ AggregateEntryTestUtils.equals(it,
+ AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ kind,
+ [],
+ httpMethod,
+ httpEndpoint,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getTopLevelCount() == 0 && e.getDuration() == 100
+ }
(statsComputed ? 1 : 0) * writer.finishBucket() >> { latch.countDown() }
cleanup:
@@ -293,42 +304,46 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then:
cycle1Triggered
cycle2Triggered
- 1 * writer.add(
- new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "client",
- [UTF8BytesString.create("country:france")],
- null,
- null,
- null
- ), { AggregateMetric aggregateMetric ->
- aggregateMetric.getHitCount() == 1 && aggregateMetric.getTopLevelCount() == 0 && aggregateMetric.getDuration() == 100
- })
- 1 * writer.add(
- new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "client",
- [UTF8BytesString.create("country:france"), UTF8BytesString.create("georegion:europe")],
- null,
- null,
- null
- ), { AggregateMetric aggregateMetric ->
- aggregateMetric.getHitCount() == 1 && aggregateMetric.getTopLevelCount() == 0 && aggregateMetric.getDuration() == 100
- })
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,
+ AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "client",
+ [UTF8BytesString.create("country:france")],
+ null,
+ null,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getTopLevelCount() == 0 && e.getDuration() == 100
+ }
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,
+ AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "client",
+ [UTF8BytesString.create("country:france"), UTF8BytesString.create("georegion:europe")],
+ null,
+ null,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getTopLevelCount() == 0 && e.getDuration() == 100
+ }
2 * writer.finishBucket() >> { latch1.countDown(); latch2.countDown() }
cleanup:
@@ -358,24 +373,26 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then:
latchTriggered
1 * writer.startBucket(1, _, _)
- 1 * writer.add(
- new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- kind,
- expectedPeerTags,
- null,
- null,
- null
- ), { AggregateMetric aggregateMetric ->
- aggregateMetric.getHitCount() == 1 && aggregateMetric.getTopLevelCount() == 0 && aggregateMetric.getDuration() == 100
- })
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,
+ AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ kind,
+ expectedPeerTags,
+ null,
+ null,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getTopLevelCount() == 0 && e.getDuration() == 100
+ }
1 * writer.finishBucket() >> { latch.countDown() }
cleanup:
@@ -411,23 +428,25 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then:
latchTriggered
1 * writer.startBucket(1, _, _)
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "baz",
- [],
- null,
- null,
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getTopLevelCount() == topLevelCount && value.getDuration() == 100
- })
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "baz",
+ [],
+ null,
+ null,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getTopLevelCount() == topLevelCount && e.getDuration() == 100
+ }
1 * writer.finishBucket() >> { latch.countDown() }
cleanup:
@@ -470,40 +489,44 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
latchTriggered
1 * writer.finishBucket() >> { latch.countDown() }
1 * writer.startBucket(2, _, SECONDS.toNanos(reportingInterval))
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "baz",
- [],
- null,
- null,
- null
- ), { AggregateMetric value ->
- value.getHitCount() == count && value.getDuration() == count * duration
- })
- 1 * writer.add(new MetricKey(
- "resource2",
- "service2",
- "operation2",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "baz",
- [],
- null,
- null,
- null
- ), { AggregateMetric value ->
- value.getHitCount() == count && value.getDuration() == count * duration * 2
- })
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "baz",
+ [],
+ null,
+ null,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == count && e.getDuration() == count * duration
+ }
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource2",
+ "service2",
+ "operation2",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "baz",
+ [],
+ null,
+ null,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == count && e.getDuration() == count * duration * 2
+ }
cleanup:
aggregator.close()
@@ -541,23 +564,25 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then: "should aggregate into single metric"
latchTriggered
1 * writer.startBucket(1, _, _)
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "server",
- [],
- "GET",
- "/api/users/:id",
- null
- ), { AggregateMetric value ->
- value.getHitCount() == count && value.getDuration() == count * duration
- })
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "server",
+ [],
+ "GET",
+ "/api/users/:id",
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == count && e.getDuration() == count * duration
+ }
1 * writer.finishBucket() >> { latch.countDown() }
when: "publish spans with different endpoints"
@@ -582,57 +607,63 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then: "should create separate metrics for each endpoint/method combination"
latchTriggered2
1 * writer.startBucket(3, _, _)
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "server",
- [],
- "GET",
- "/api/users/:id",
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration
- })
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "server",
- [],
- "GET",
- "/api/orders/:id",
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration * 2
- })
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "server",
- [],
- "POST",
- "/api/users/:id",
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration * 3
- })
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "server",
+ [],
+ "GET",
+ "/api/users/:id",
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration
+ }
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "server",
+ [],
+ "GET",
+ "/api/orders/:id",
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration * 2
+ }
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "server",
+ [],
+ "POST",
+ "/api/users/:id",
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration * 3
+ }
1 * writer.finishBucket() >> { latch2.countDown() }
cleanup:
@@ -680,74 +711,82 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then: "should create 4 separate metrics"
latchTriggered
1 * writer.startBucket(4, _, _)
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- 200,
- false,
- false,
- "server",
- [],
- "GET",
- "/api/users/:id",
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration
- })
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- 200,
- false,
- false,
- "server",
- [],
- "POST",
- "/api/users/:id",
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration * 2
- })
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- 404,
- false,
- false,
- "server",
- [],
- "GET",
- "/api/users/:id",
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration * 3
- })
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- 200,
- false,
- false,
- "server",
- [],
- "GET",
- "/api/orders/:id",
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration * 4
- })
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ 200,
+ false,
+ false,
+ "server",
+ [],
+ "GET",
+ "/api/users/:id",
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration
+ }
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ 200,
+ false,
+ false,
+ "server",
+ [],
+ "POST",
+ "/api/users/:id",
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration * 2
+ }
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ 404,
+ false,
+ false,
+ "server",
+ [],
+ "GET",
+ "/api/users/:id",
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration * 3
+ }
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ 200,
+ false,
+ false,
+ "server",
+ [],
+ "GET",
+ "/api/orders/:id",
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration * 4
+ }
1 * writer.finishBucket() >> { latch.countDown() }
cleanup:
@@ -784,40 +823,44 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then: "should create separate metric keys for spans with and without HTTP tags"
latchTriggered
1 * writer.startBucket(2, _, _)
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- 200,
- false,
- false,
- "server",
- [],
- null,
- null,
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration
- })
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- 200,
- false,
- false,
- "server",
- [],
- "GET",
- "/api/users/:id",
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration * 2
- })
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ 200,
+ false,
+ false,
+ "server",
+ [],
+ null,
+ null,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration
+ }
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ 200,
+ false,
+ false,
+ "server",
+ [],
+ "GET",
+ "/api/users/:id",
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration * 2
+ }
1 * writer.finishBucket() >> { latch.countDown() }
cleanup:
@@ -852,47 +895,54 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then: "should create the different metric keys for spans with and without sources"
latchTriggered
1 * writer.startBucket(2, _, _)
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- "source",
- "type",
- 200,
- false,
- false,
- "server",
- [],
- null,
- null,
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 2 && value.getDuration() == 2 * duration
- })
- 1 * writer.add(new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- 200,
- false,
- false,
- "server",
- [],
- null,
- null,
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration
- })
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ "source",
+ "type",
+ 200,
+ false,
+ false,
+ "server",
+ [],
+ null,
+ null,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 2 && e.getDuration() == 2 * duration
+ }
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ 200,
+ false,
+ false,
+ "server",
+ [],
+ null,
+ null,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration
+ }
1 * writer.finishBucket() >> { latch.countDown() }
cleanup:
aggregator.close()
}
- def "test least recently written to aggregate flushed when size limit exceeded"() {
+ def "new aggregates beyond size limit are dropped when no stale entries can be evicted"() {
+ // The table only evicts entries with hitCount == 0 to make room. When all entries are live
+ // (all have been recorded against), an over-cap insert drops the new key rather than evicting
+ // an established one. This protects the data we've already collected from a burst of new keys.
setup:
int maxAggregates = 10
MetricWriter writer = Mock(MetricWriter)
@@ -916,11 +966,11 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
aggregator.report()
def latchTriggered = latch.await(2, SECONDS)
- then: "the first aggregate should be dropped but the rest reported"
+ then: "the established service0..service9 are reported; service10 is dropped"
latchTriggered
1 * writer.startBucket(10, _, SECONDS.toNanos(reportingInterval))
- for (int i = 1; i < 11; ++i) {
- 1 * writer.add(new MetricKey(
+ for (int i = 0; i < 10; ++i) {
+ def expected = AggregateEntryTestUtils.of(
"resource",
"service" + i,
"operation",
@@ -933,26 +983,28 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
[],
null,
null,
- null
- ), _) >> { MetricKey key, AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration
- }
+ null)
+ 1 * writer.add({ AggregateEntryTestUtils.equals(it, expected) }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration
+ }
}
- 0 * writer.add(new MetricKey(
- "resource",
- "service0",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "baz",
- [],
- null,
- null,
- null
- ), _)
+ 0 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service10",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "baz",
+ [],
+ null,
+ null,
+ null
+ ))
+ })
1 * writer.finishBucket() >> { latch.countDown() }
cleanup:
@@ -1067,7 +1119,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
latchTriggered
1 * writer.startBucket(5, _, SECONDS.toNanos(reportingInterval))
for (int i = 0; i < 5; ++i) {
- 1 * writer.add(new MetricKey(
+ def expected = AggregateEntryTestUtils.of(
"resource",
"service" + i,
"operation",
@@ -1080,10 +1132,10 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
[],
null,
null,
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration
- })
+ null)
+ 1 * writer.add({ AggregateEntryTestUtils.equals(it, expected) }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration
+ }
}
1 * writer.finishBucket() >> { latch.countDown() }
@@ -1102,7 +1154,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
latchTriggered
1 * writer.startBucket(4, _, SECONDS.toNanos(reportingInterval))
for (int i = 1; i < 5; ++i) {
- 1 * writer.add(new MetricKey(
+ def expected = AggregateEntryTestUtils.of(
"resource",
"service" + i,
"operation",
@@ -1115,26 +1167,28 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
[],
null,
null,
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration
- })
+ null)
+ 1 * writer.add({ AggregateEntryTestUtils.equals(it, expected) }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration
+ }
}
- 0 * writer.add(new MetricKey(
- "resource",
- "service0",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "baz",
- [],
- null,
- null,
- null
- ), _)
+ 0 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "resource",
+ "service0",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "baz",
+ [],
+ null,
+ null,
+ null
+ ))
+ })
1 * writer.finishBucket() >> { latch.countDown() }
cleanup:
@@ -1169,7 +1223,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
latchTriggered
1 * writer.startBucket(5, _, SECONDS.toNanos(reportingInterval))
for (int i = 0; i < 5; ++i) {
- 1 * writer.add(new MetricKey(
+ def expected = AggregateEntryTestUtils.of(
"resource",
"service" + i,
"operation",
@@ -1182,10 +1236,10 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
[],
null,
null,
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration
- })
+ null)
+ 1 * writer.add({ AggregateEntryTestUtils.equals(it, expected) }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration
+ }
}
1 * writer.finishBucket() >> { latch.countDown() }
@@ -1195,7 +1249,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then: "aggregate not updated in cycle is not reported"
0 * writer.finishBucket()
0 * writer.startBucket(_, _, _)
- 0 * writer.add(_, _)
+ 0 * writer.add(_)
cleanup:
aggregator.close()
@@ -1228,7 +1282,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
latchTriggered
1 * writer.startBucket(5, _, SECONDS.toNanos(1))
for (int i = 0; i < 5; ++i) {
- 1 * writer.add(new MetricKey(
+ def expected = AggregateEntryTestUtils.of(
"resource",
"service" + i,
"operation",
@@ -1241,10 +1295,10 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
[],
null,
null,
- null
- ), { AggregateMetric value ->
- value.getHitCount() == 1 && value.getDuration() == duration
- })
+ null)
+ 1 * writer.add({ AggregateEntryTestUtils.equals(it, expected) }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getDuration() == duration
+ }
}
1 * writer.finishBucket() >> { latch.countDown() }
@@ -1394,24 +1448,26 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then:
latchTriggered
1 * writer.startBucket(1, _, _)
- 1 * writer.add(
- new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- true,
- "",
- [],
- null,
- null,
- null
- ), { AggregateMetric aggregateMetric ->
- aggregateMetric.getHitCount() == 1 && aggregateMetric.getTopLevelCount() == 1 && aggregateMetric.getDuration() == 100
- })
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,
+ AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ true,
+ "",
+ [],
+ null,
+ null,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getTopLevelCount() == 1 && e.getDuration() == 100
+ }
1 * writer.finishBucket() >> { latch.countDown() }
cleanup:
@@ -1449,24 +1505,26 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then: "all spans should go to the same bucket (httpMethod and httpEndpoint are ignored)"
latchTriggered
1 * writer.startBucket(1, _, _)
- 1 * writer.add(
- new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "server",
- [],
- null,
- null,
- null
- ), { AggregateMetric aggregateMetric ->
- aggregateMetric.getHitCount() == 3 && aggregateMetric.getTopLevelCount() == 3 && aggregateMetric.getDuration() == 450
- })
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,
+ AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "server",
+ [],
+ null,
+ null,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 3 && e.getTopLevelCount() == 3 && e.getDuration() == 450
+ }
1 * writer.finishBucket() >> { latch.countDown() }
cleanup:
@@ -1504,60 +1562,66 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then: "spans should go to separate buckets based on httpMethod and httpEndpoint"
latchTriggered
1 * writer.startBucket(3, _, _)
- 1 * writer.add(
- new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "server",
- [],
- "GET",
- "/api/users/:id",
- null
- ), { AggregateMetric aggregateMetric ->
- aggregateMetric.getHitCount() == 1 && aggregateMetric.getTopLevelCount() == 1 && aggregateMetric.getDuration() == 100
- })
- 1 * writer.add(
- new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "server",
- [],
- "POST",
- "/api/orders",
- null
- ), { AggregateMetric aggregateMetric ->
- aggregateMetric.getHitCount() == 1 && aggregateMetric.getTopLevelCount() == 1 && aggregateMetric.getDuration() == 200
- })
- 1 * writer.add(
- new MetricKey(
- "resource",
- "service",
- "operation",
- null,
- "type",
- HTTP_OK,
- false,
- false,
- "server",
- [],
- null,
- null,
- null
- ), { AggregateMetric aggregateMetric ->
- aggregateMetric.getHitCount() == 1 && aggregateMetric.getTopLevelCount() == 1 && aggregateMetric.getDuration() == 150
- })
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,
+ AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "server",
+ [],
+ "GET",
+ "/api/users/:id",
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getTopLevelCount() == 1 && e.getDuration() == 100
+ }
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,
+ AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "server",
+ [],
+ "POST",
+ "/api/orders",
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getTopLevelCount() == 1 && e.getDuration() == 200
+ }
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,
+ AggregateEntryTestUtils.of(
+ "resource",
+ "service",
+ "operation",
+ null,
+ "type",
+ HTTP_OK,
+ false,
+ false,
+ "server",
+ [],
+ null,
+ null,
+ null
+ ))
+ }) >> { AggregateEntry e ->
+ assert e.getHitCount() == 1 && e.getTopLevelCount() == 1 && e.getDuration() == 150
+ }
1 * writer.finishBucket() >> { latch.countDown() }
cleanup:
@@ -1593,51 +1657,57 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
then:
latchTriggered
1 * writer.startBucket(3, _, _)
- 1 * writer.add(new MetricKey(
- "grpc.service/Method",
- "service",
- "grpc.server",
- null,
- "rpc",
- 0,
- false,
- false,
- "server",
- [],
- null,
- null,
- "0"
- ), _)
- 1 * writer.add(new MetricKey(
- "grpc.service/Method",
- "service",
- "grpc.server",
- null,
- "rpc",
- 0,
- false,
- false,
- "server",
- [],
- null,
- null,
- "5"
- ), _)
- 1 * writer.add(new MetricKey(
- "GET /api",
- "service",
- "http.request",
- null,
- "web",
- 200,
- false,
- false,
- "server",
- [],
- null,
- null,
- null
- ), _)
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "grpc.service/Method",
+ "service",
+ "grpc.server",
+ null,
+ "rpc",
+ 0,
+ false,
+ false,
+ "server",
+ [],
+ null,
+ null,
+ "0"
+ ))
+ })
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "grpc.service/Method",
+ "service",
+ "grpc.server",
+ null,
+ "rpc",
+ 0,
+ false,
+ false,
+ "server",
+ [],
+ null,
+ null,
+ "5"
+ ))
+ })
+ 1 * writer.add({
+ AggregateEntryTestUtils.equals(it,AggregateEntryTestUtils.of(
+ "GET /api",
+ "service",
+ "http.request",
+ null,
+ "web",
+ 200,
+ false,
+ false,
+ "server",
+ [],
+ null,
+ null,
+ null
+ ))
+ })
1 * writer.finishBucket() >> { latch.countDown() }
cleanup:
@@ -1653,7 +1723,7 @@ class ConflatingMetricAggregatorTest extends DDSpecification {
def waitUntilEmpty(ConflatingMetricsAggregator aggregator) {
int i = 0
- while (!aggregator.inbox.isEmpty() && i++ < 100) {
+ while ((!aggregator.dataInbox.isEmpty() || !aggregator.signalInbox.isEmpty()) && i++ < 100) {
Thread.sleep(10)
}
}
diff --git a/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/SerializingMetricWriterTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/SerializingMetricWriterTest.groovy
index 3ff81de9851..cc0880bc30a 100644
--- a/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/SerializingMetricWriterTest.groovy
+++ b/dd-trace-core/src/test/groovy/datadog/trace/common/metrics/SerializingMetricWriterTest.groovy
@@ -7,7 +7,6 @@ import static java.util.concurrent.TimeUnit.SECONDS
import datadog.metrics.api.Histograms
import datadog.metrics.impl.DDSketchHistograms
import datadog.trace.api.Config
-import datadog.trace.api.Pair
import datadog.trace.api.ProcessTags
import datadog.trace.api.WellKnownTags
import datadog.trace.api.git.CommitInfo
@@ -16,7 +15,6 @@ import datadog.trace.api.git.GitInfoProvider
import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString
import datadog.trace.test.util.DDSpecification
import java.nio.ByteBuffer
-import java.util.concurrent.atomic.AtomicLongArray
import org.msgpack.core.MessagePack
import org.msgpack.core.MessageUnpacker
@@ -26,6 +24,30 @@ class SerializingMetricWriterTest extends DDSpecification {
Histograms.register(DDSketchHistograms.FACTORY)
}
+ /** Build an {@link AggregateEntry} with a pre-recorded duration count. */
+ private static AggregateEntry entry(
+ CharSequence resource,
+ CharSequence service,
+ CharSequence operationName,
+ CharSequence serviceSource,
+ CharSequence type,
+ int httpStatusCode,
+ boolean synthetic,
+ boolean traceRoot,
+ CharSequence spanKind,
+ List peerTags,
+ CharSequence httpMethod,
+ CharSequence httpEndpoint,
+ CharSequence grpcStatusCode,
+ int hitCount) {
+ AggregateEntry e = AggregateEntryTestUtils.of(
+ resource, service, operationName, serviceSource, type,
+ httpStatusCode, synthetic, traceRoot, spanKind, peerTags,
+ httpMethod, httpEndpoint, grpcStatusCode)
+ hitCount.times { e.recordOneDuration(1L) }
+ return e
+ }
+
def "should produce correct message #iterationIndex with process tags enabled #withProcessTags" () {
setup:
if (!withProcessTags) {
@@ -40,8 +62,8 @@ class SerializingMetricWriterTest extends DDSpecification {
when:
writer.startBucket(content.size(), startTime, duration)
- for (Pair pair : content) {
- writer.add(pair.getLeft(), pair.getRight())
+ for (AggregateEntry e : content) {
+ writer.add(e)
}
writer.finishBucket()
@@ -55,88 +77,40 @@ class SerializingMetricWriterTest extends DDSpecification {
where:
content << [
[
- Pair.of(
- new MetricKey(
- "resource1",
- "service1",
- "operation1",
- null,
- "type",
- 0,
- false,
- false,
- "client",
+ entry(
+ "resource1", "service1", "operation1", null, "type", 0,
+ false, false, "client",
[
UTF8BytesString.create("country:canada"),
UTF8BytesString.create("georegion:amer"),
UTF8BytesString.create("peer.service:remote-service")
],
- null,
- null,
- null
- ),
- new AggregateMetric().recordDurations(10, new AtomicLongArray(1L))
- ),
- Pair.of(
- new MetricKey(
- "resource2",
- "service2",
- "operation2",
- null,
- "type2",
- 200,
- true,
- false,
- "producer",
+ null, null, null,
+ 10),
+ entry(
+ "resource2", "service2", "operation2", null, "type2", 200,
+ true, false, "producer",
[
UTF8BytesString.create("country:canada"),
UTF8BytesString.create("georegion:amer"),
UTF8BytesString.create("peer.service:remote-service")
],
- null,
- null,
- null
- ),
- new AggregateMetric().recordDurations(9, new AtomicLongArray(1L))
- ),
- Pair.of(
- new MetricKey(
- "GET /api/users/:id",
- "web-service",
- "http.request",
- null,
- "web",
- 200,
- false,
- true,
- "server",
+ null, null, null,
+ 9),
+ entry(
+ "GET /api/users/:id", "web-service", "http.request", null, "web", 200,
+ false, true, "server",
[],
- "GET",
- "/api/users/:id",
- null
- ),
- new AggregateMetric().recordDurations(5, new AtomicLongArray(1L))
- )
+ null, null, null,
+ 5)
],
(0..10000).collect({ i ->
- Pair.of(
- new MetricKey(
- "resource" + i,
- "service" + i,
- "operation" + i,
- null,
- "type",
- 0,
- false,
- false,
- "producer",
+ entry(
+ "resource" + i, "service" + i, "operation" + i, null, "type", 0,
+ false, false, "producer",
[UTF8BytesString.create("messaging.destination:dest" + i)],
- null,
- null,
- null
- ),
- new AggregateMetric().recordDurations(10, new AtomicLongArray(1L))
- )
+ null, null, null,
+ 10)
})
]
withProcessTags << [true, false]
@@ -148,22 +122,18 @@ class SerializingMetricWriterTest extends DDSpecification {
long duration = SECONDS.toNanos(10)
WellKnownTags wellKnownTags = new WellKnownTags("runtimeid", "hostname", "env", "service", "version", "language")
- // Create keys with different combinations of HTTP fields
- def keyWithNoSource = new MetricKey("resource", "service", "operation", null, "type", 200, false, false, "server", [], "GET", "/api/users", null)
- def keyWithSource = new MetricKey("resource", "service", "operation", "source", "type", 200, false, false, "server", [], "POST", null, null)
+ def entryNoSource = entry("resource", "service", "operation", null, "type", 200, false, false, "server", [], "GET", "/api/users", null, 1)
+ def entryWithSource = entry("resource", "service", "operation", "source", "type", 200, false, false, "server", [], "POST", null, null, 1)
- def content = [
- Pair.of(keyWithNoSource, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L))),
- Pair.of(keyWithSource, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L))),
- ]
+ def content = [entryNoSource, entryWithSource]
ValidatingSink sink = new ValidatingSink(wellKnownTags, startTime, duration, content)
SerializingMetricWriter writer = new SerializingMetricWriter(wellKnownTags, sink, 128)
when:
writer.startBucket(content.size(), startTime, duration)
- for (Pair pair : content) {
- writer.add(pair.getLeft(), pair.getRight())
+ for (AggregateEntry e : content) {
+ writer.add(e)
}
writer.finishBucket()
@@ -177,34 +147,25 @@ class SerializingMetricWriterTest extends DDSpecification {
long duration = SECONDS.toNanos(10)
WellKnownTags wellKnownTags = new WellKnownTags("runtimeid", "hostname", "env", "service", "version", "language")
- // Create keys with different combinations of HTTP fields
- def keyWithBoth = new MetricKey("resource", "service", "operation", null, "type", 200, false, false, "server", [], "GET", "/api/users", null)
- def keyWithMethodOnly = new MetricKey("resource", "service", "operation", null, "type", 200, false, false, "server", [], "POST", null,null)
- def keyWithEndpointOnly = new MetricKey("resource", "service", "operation", null, "type", 200, false, false, "server", [], null, "/api/orders",null)
- def keyWithNeither = new MetricKey("resource", "service", "operation", null, "type", 200, false, false, "client", [], null, null, null)
-
- def content = [
- Pair.of(keyWithBoth, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L))),
- Pair.of(keyWithMethodOnly, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L))),
- Pair.of(keyWithEndpointOnly, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L))),
- Pair.of(keyWithNeither, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L)))
- ]
+ def entryWithBoth = entry("resource", "service", "operation", null, "type", 200, false, false, "server", [], "GET", "/api/users", null, 1)
+ def entryWithMethodOnly = entry("resource", "service", "operation", null, "type", 200, false, false, "server", [], "POST", null, null, 1)
+ def entryWithEndpointOnly = entry("resource", "service", "operation", null, "type", 200, false, false, "server", [], null, "/api/orders", null, 1)
+ def entryWithNeither = entry("resource", "service", "operation", null, "type", 200, false, false, "client", [], null, null, null, 1)
+
+ def content = [entryWithBoth, entryWithMethodOnly, entryWithEndpointOnly, entryWithNeither]
ValidatingSink sink = new ValidatingSink(wellKnownTags, startTime, duration, content)
SerializingMetricWriter writer = new SerializingMetricWriter(wellKnownTags, sink, 128)
when:
writer.startBucket(content.size(), startTime, duration)
- for (Pair pair : content) {
- writer.add(pair.getLeft(), pair.getRight())
+ for (AggregateEntry e : content) {
+ writer.add(e)
}
writer.finishBucket()
then:
sink.validatedInput()
- // Test passes if validation in ValidatingSink succeeds
- // ValidatingSink verifies that map size matches actual number of fields
- // and that HTTPMethod/HTTPEndpoint are only present when non-empty
}
def "add git sha commit info when sha commit is #shaCommit"() {
@@ -216,40 +177,63 @@ class SerializingMetricWriterTest extends DDSpecification {
long duration = SECONDS.toNanos(10)
WellKnownTags wellKnownTags = new WellKnownTags("runtimeid", "hostname", "env", "service", "version", "language")
- // Create keys with different combinations of HTTP fields
- def key = new MetricKey("resource", "service", "operation", null, "type", 200, false, false, "server", [], "GET", "/api/users", null)
+ def e = entry("resource", "service", "operation", null, "type", 200, false, false, "server", [], "GET", "/api/users", null, 1)
- def content = [Pair.of(key, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L))),]
+ def content = [e]
ValidatingSink sink = new ValidatingSink(wellKnownTags, startTime, duration, content)
SerializingMetricWriter writer = new SerializingMetricWriter(wellKnownTags, sink, 128, gitInfoProvider)
when:
-
writer.startBucket(content.size(), startTime, duration)
- for (Pair pair : content) {
- writer.add(pair.getLeft(), pair.getRight())
+ for (AggregateEntry entryItem : content) {
+ writer.add(entryItem)
}
writer.finishBucket()
then:
-
sink.validatedInput()
where:
shaCommit << [null, "123456"]
}
+ def "GRPCStatusCode field is present in payload for rpc-type spans"() {
+ setup:
+ long startTime = MILLISECONDS.toNanos(System.currentTimeMillis())
+ long duration = SECONDS.toNanos(10)
+ WellKnownTags wellKnownTags = new WellKnownTags("runtimeid", "hostname", "env", "service", "version", "language")
+
+ def entryWithGrpc = entry("grpc.service/Method", "grpc-service", "grpc.server", null, "rpc", 0, false, false, "server", [], null, null, "OK", 1)
+ def entryWithGrpcError = entry("grpc.service/Method", "grpc-service", "grpc.server", null, "rpc", 0, false, false, "client", [], null, null, "NOT_FOUND", 1)
+ def entryWithoutGrpc = entry("resource", "service", "operation", null, "web", 200, false, false, "server", [], null, null, null, 1)
+
+ def content = [entryWithGrpc, entryWithGrpcError, entryWithoutGrpc]
+
+ ValidatingSink sink = new ValidatingSink(wellKnownTags, startTime, duration, content)
+ SerializingMetricWriter writer = new SerializingMetricWriter(wellKnownTags, sink, 128)
+
+ when:
+ writer.startBucket(content.size(), startTime, duration)
+ for (AggregateEntry e : content) {
+ writer.add(e)
+ }
+ writer.finishBucket()
+
+ then:
+ sink.validatedInput()
+ }
+
static class ValidatingSink implements Sink {
private final WellKnownTags wellKnownTags
private final long startTimeNanos
private final long duration
private boolean validated = false
- private List> content
+ private List content
ValidatingSink(WellKnownTags wellKnownTags, long startTimeNanos, long duration,
- List> content) {
+ List content) {
this.wellKnownTags = wellKnownTags
this.startTimeNanos = startTimeNanos
this.duration = duration
@@ -298,83 +282,81 @@ class SerializingMetricWriterTest extends DDSpecification {
assert unpacker.unpackString() == "Stats"
int statCount = unpacker.unpackArrayHeader()
assert statCount == content.size()
- for (Pair pair : content) {
- MetricKey key = pair.getLeft()
- AggregateMetric value = pair.getRight()
+ for (AggregateEntry entry : content) {
int metricMapSize = unpacker.unpackMapHeader()
// Calculate expected map size based on optional fields
- boolean hasHttpMethod = key.getHttpMethod() != null
- boolean hasHttpEndpoint = key.getHttpEndpoint() != null
- boolean hasServiceSource = key.getServiceSource() != null
- boolean hasGrpcStatusCode = key.getGrpcStatusCode() != null
+ boolean hasHttpMethod = entry.getHttpMethod() != null
+ boolean hasHttpEndpoint = entry.getHttpEndpoint() != null
+ boolean hasServiceSource = entry.getServiceSource() != null
+ boolean hasGrpcStatusCode = entry.getGrpcStatusCode() != null
int expectedMapSize = 15 + (hasServiceSource ? 1 : 0) + (hasHttpMethod ? 1 : 0) + (hasHttpEndpoint ? 1 : 0) + (hasGrpcStatusCode ? 1 : 0)
assert metricMapSize == expectedMapSize
int elementCount = 0
assert unpacker.unpackString() == "Name"
- assert unpacker.unpackString() == key.getOperationName() as String
+ assert unpacker.unpackString() == entry.getOperationName() as String
++elementCount
assert unpacker.unpackString() == "Service"
- assert unpacker.unpackString() == key.getService() as String
+ assert unpacker.unpackString() == entry.getService() as String
++elementCount
assert unpacker.unpackString() == "Resource"
- assert unpacker.unpackString() == key.getResource() as String
+ assert unpacker.unpackString() == entry.getResource() as String
++elementCount
assert unpacker.unpackString() == "Type"
- assert unpacker.unpackString() == key.getType() as String
+ assert unpacker.unpackString() == entry.getType() as String
++elementCount
assert unpacker.unpackString() == "HTTPStatusCode"
- assert unpacker.unpackInt() == key.getHttpStatusCode()
+ assert unpacker.unpackInt() == entry.getHttpStatusCode()
++elementCount
assert unpacker.unpackString() == "Synthetics"
- assert unpacker.unpackBoolean() == key.isSynthetics()
+ assert unpacker.unpackBoolean() == entry.isSynthetics()
++elementCount
assert unpacker.unpackString() == "IsTraceRoot"
- assert unpacker.unpackInt() == (key.isTraceRoot() ? TriState.TRUE.serialValue : TriState.FALSE.serialValue)
+ assert unpacker.unpackInt() == (entry.isTraceRoot() ? TriState.TRUE.serialValue : TriState.FALSE.serialValue)
++elementCount
assert unpacker.unpackString() == "SpanKind"
- assert unpacker.unpackString() == key.getSpanKind() as String
+ assert unpacker.unpackString() == entry.getSpanKind() as String
++elementCount
assert unpacker.unpackString() == "PeerTags"
int peerTagsLength = unpacker.unpackArrayHeader()
- assert peerTagsLength == key.getPeerTags().size()
+ assert peerTagsLength == entry.getPeerTags().size()
for (int i = 0; i < peerTagsLength; i++) {
def unpackedPeerTag = unpacker.unpackString()
- assert unpackedPeerTag == key.getPeerTags()[i].toString()
+ assert unpackedPeerTag == entry.getPeerTags()[i].toString()
}
++elementCount
// Service source is only present when the service name has been overridden by the tracer
if (hasServiceSource) {
assert unpacker.unpackString() == "srv_src"
- assert unpacker.unpackString() == key.getServiceSource().toString()
+ assert unpacker.unpackString() == entry.getServiceSource().toString()
++elementCount
}
// HTTPMethod and HTTPEndpoint are optional - only present if non-null
if (hasHttpMethod) {
assert unpacker.unpackString() == "HTTPMethod"
- assert unpacker.unpackString() == key.getHttpMethod() as String
+ assert unpacker.unpackString() == entry.getHttpMethod() as String
++elementCount
}
if (hasHttpEndpoint) {
assert unpacker.unpackString() == "HTTPEndpoint"
- assert unpacker.unpackString() == key.getHttpEndpoint() as String
+ assert unpacker.unpackString() == entry.getHttpEndpoint() as String
++elementCount
}
if (hasGrpcStatusCode) {
assert unpacker.unpackString() == "GRPCStatusCode"
- assert unpacker.unpackString() == key.getGrpcStatusCode() as String
+ assert unpacker.unpackString() == entry.getGrpcStatusCode() as String
++elementCount
}
assert unpacker.unpackString() == "Hits"
- assert unpacker.unpackInt() == value.getHitCount()
+ assert unpacker.unpackInt() == entry.getHitCount()
++elementCount
assert unpacker.unpackString() == "Errors"
- assert unpacker.unpackInt() == value.getErrorCount()
+ assert unpacker.unpackInt() == entry.getErrorCount()
++elementCount
assert unpacker.unpackString() == "TopLevelHits"
- assert unpacker.unpackInt() == value.getTopLevelCount()
+ assert unpacker.unpackInt() == entry.getTopLevelCount()
++elementCount
assert unpacker.unpackString() == "Duration"
- assert unpacker.unpackLong() == value.getDuration()
+ assert unpacker.unpackLong() == entry.getDuration()
++elementCount
assert unpacker.unpackString() == "OkSummary"
validateSketch(unpacker)
@@ -397,99 +379,4 @@ class SerializingMetricWriterTest extends DDSpecification {
return validated
}
}
-
- def "ServiceSource optional in the payload"() {
- setup:
- long startTime = MILLISECONDS.toNanos(System.currentTimeMillis())
- long duration = SECONDS.toNanos(10)
- WellKnownTags wellKnownTags = new WellKnownTags("runtimeid", "hostname", "env", "service", "version", "language")
-
- // Create keys with different combinations of HTTP fields
- def keyWithNoSource = new MetricKey("resource", "service", "operation", null, "type", 200, false, false, "server", [], "GET", "/api/users", null)
- def keyWithSource = new MetricKey("resource", "service", "operation", "source", "type", 200, false, false, "server", [], "POST", null, null)
-
- def content = [
- Pair.of(keyWithNoSource, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L))),
- Pair.of(keyWithSource, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L))),
- ]
-
- ValidatingSink sink = new ValidatingSink(wellKnownTags, startTime, duration, content)
- SerializingMetricWriter writer = new SerializingMetricWriter(wellKnownTags, sink, 128)
-
- when:
- writer.startBucket(content.size(), startTime, duration)
- for (Pair pair : content) {
- writer.add(pair.getLeft(), pair.getRight())
- }
- writer.finishBucket()
-
- then:
- sink.validatedInput()
- }
-
- def "GRPCStatusCode field is present in payload for rpc-type spans"() {
- setup:
- long startTime = MILLISECONDS.toNanos(System.currentTimeMillis())
- long duration = SECONDS.toNanos(10)
- WellKnownTags wellKnownTags = new WellKnownTags("runtimeid", "hostname", "env", "service", "version", "language")
-
- def keyWithGrpc = new MetricKey("grpc.service/Method", "grpc-service", "grpc.server", null, "rpc", 0, false, false, "server", [], null, null, "OK")
- def keyWithGrpcError = new MetricKey("grpc.service/Method", "grpc-service", "grpc.server", null, "rpc", 0, false, false, "client", [], null, null, "NOT_FOUND")
- def keyWithoutGrpc = new MetricKey("resource", "service", "operation", null, "web", 200, false, false, "server", [], null, null, null)
-
- def content = [
- Pair.of(keyWithGrpc, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L))),
- Pair.of(keyWithGrpcError, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L))),
- Pair.of(keyWithoutGrpc, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L)))
- ]
-
- ValidatingSink sink = new ValidatingSink(wellKnownTags, startTime, duration, content)
- SerializingMetricWriter writer = new SerializingMetricWriter(wellKnownTags, sink, 128)
-
- when:
- writer.startBucket(content.size(), startTime, duration)
- for (Pair pair : content) {
- writer.add(pair.getLeft(), pair.getRight())
- }
- writer.finishBucket()
-
- then:
- sink.validatedInput()
- }
-
- def "HTTPMethod and HTTPEndpoint fields are optional in payload"() {
- setup:
- long startTime = MILLISECONDS.toNanos(System.currentTimeMillis())
- long duration = SECONDS.toNanos(10)
- WellKnownTags wellKnownTags = new WellKnownTags("runtimeid", "hostname", "env", "service", "version", "language")
-
- // Create keys with different combinations of HTTP fields
- def keyWithBoth = new MetricKey("resource", "service", "operation", null, "type", 200, false, false, "server", [], "GET", "/api/users", null)
- def keyWithMethodOnly = new MetricKey("resource", "service", "operation", null, "type", 200, false, false, "server", [], "POST", null, null)
- def keyWithEndpointOnly = new MetricKey("resource", "service", "operation", null, "type", 200, false, false, "server", [], null, "/api/orders", null)
- def keyWithNeither = new MetricKey("resource", "service", "operation", null, "type", 200, false, false, "client", [], null, null, null)
-
- def content = [
- Pair.of(keyWithBoth, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L))),
- Pair.of(keyWithMethodOnly, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L))),
- Pair.of(keyWithEndpointOnly, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L))),
- Pair.of(keyWithNeither, new AggregateMetric().recordDurations(1, new AtomicLongArray(1L)))
- ]
-
- ValidatingSink sink = new ValidatingSink(wellKnownTags, startTime, duration, content)
- SerializingMetricWriter writer = new SerializingMetricWriter(wellKnownTags, sink, 128)
-
- when:
- writer.startBucket(content.size(), startTime, duration)
- for (Pair pair : content) {
- writer.add(pair.getLeft(), pair.getRight())
- }
- writer.finishBucket()
-
- then:
- sink.validatedInput()
- // Test passes if validation in ValidatingSink succeeds
- // ValidatingSink verifies that map size matches actual number of fields
- // and that HTTPMethod/HTTPEndpoint are only present when non-empty
- }
}
diff --git a/dd-trace-core/src/test/java/datadog/trace/common/metrics/AggregateEntryTest.java b/dd-trace-core/src/test/java/datadog/trace/common/metrics/AggregateEntryTest.java
new file mode 100644
index 00000000000..b35dd4cd938
--- /dev/null
+++ b/dd-trace-core/src/test/java/datadog/trace/common/metrics/AggregateEntryTest.java
@@ -0,0 +1,165 @@
+package datadog.trace.common.metrics;
+
+import static datadog.trace.common.metrics.AggregateEntry.ERROR_TAG;
+import static datadog.trace.common.metrics.AggregateEntry.TOP_LEVEL_TAG;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import datadog.metrics.agent.AgentMeter;
+import datadog.metrics.api.statsd.StatsDClient;
+import datadog.metrics.impl.DDSketchHistograms;
+import datadog.metrics.impl.MonitoringImpl;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class AggregateEntryTest {
+
+ @BeforeAll
+ static void initAgentMeter() {
+ // recordOneDuration -> Histogram.accept needs AgentMeter to be initialized.
+ MonitoringImpl monitoring = new MonitoringImpl(StatsDClient.NO_OP, 1, TimeUnit.SECONDS);
+ AgentMeter.registerIfAbsent(StatsDClient.NO_OP, monitoring, DDSketchHistograms.FACTORY);
+ monitoring.newTimer("test.init");
+ }
+
+ @Test
+ void recordOneDurationSumsToTotal() {
+ AggregateEntry entry = newEntry();
+ entry.recordOneDuration(1L);
+ entry.recordOneDuration(2L);
+ entry.recordOneDuration(3L);
+ assertEquals(6, entry.getDuration());
+ }
+
+ @Test
+ void clearResetsAllCounters() {
+ AggregateEntry entry = newEntry();
+ entry.recordOneDuration(5L);
+ entry.recordOneDuration(ERROR_TAG | 6L);
+ entry.recordOneDuration(TOP_LEVEL_TAG | 7L);
+ entry.clear();
+ assertEquals(0, entry.getDuration());
+ assertEquals(0, entry.getErrorCount());
+ assertEquals(0, entry.getTopLevelCount());
+ assertEquals(0, entry.getHitCount());
+ }
+
+ @Test
+ void recordOneDurationAccumulatesOkErrorAndTopLevel() {
+ AggregateEntry entry = newEntry();
+ entry.recordOneDuration(10L);
+ entry.recordOneDuration(10L | TOP_LEVEL_TAG);
+ entry.recordOneDuration(10L | ERROR_TAG);
+
+ assertEquals(3, entry.getHitCount());
+ assertEquals(30, entry.getDuration());
+ assertEquals(1, entry.getErrorCount());
+ assertEquals(1, entry.getTopLevelCount());
+ }
+
+ @Test
+ void hitCountIncludesErrors() {
+ AggregateEntry entry = newEntry();
+ entry.recordOneDuration(1L);
+ entry.recordOneDuration(2L);
+ entry.recordOneDuration(3L | ERROR_TAG);
+ assertEquals(3, entry.getHitCount());
+ assertEquals(1, entry.getErrorCount());
+ }
+
+ @Test
+ void okAndErrorLatenciesTrackedSeparately() {
+ AggregateEntry entry = newEntry();
+ long[] durations = {
+ 1L, 100L | ERROR_TAG, 2L, 99L | ERROR_TAG, 3L, 98L | ERROR_TAG, 4L, 97L | ERROR_TAG
+ };
+ for (long d : durations) {
+ entry.recordOneDuration(d);
+ }
+ assertTrue(entry.getErrorLatencies().getMaxValue() >= 99);
+ assertTrue(entry.getOkLatencies().getMaxValue() <= 5);
+ }
+
+ @Test
+ void testUtilsEqualsIsConsistentWithHashCodeAcrossDifferentSchemaLayouts() {
+ // Contract test for AggregateEntryTestUtils (the test-side equality helper used by Spock
+ // mock matchers). Production AggregateEntry has no equals override.
+ //
+ // Two entries with identical encoded peerTags but different raw layouts must not be equal,
+ // because hashOf folds in the raw arrays. Equality on the encoded list would let them
+ // collapse while their hashCodes differ -- violating the contract.
+ //
+ // A: schema ["a","b"], values [null,"x"] -> encoded ["b:x"]
+ // B: schema ["b","c"], values ["x",null] -> encoded ["b:x"]
+ AggregateEntry a =
+ AggregateEntryTestUtils.forSnapshot(
+ snapshotWithPeerTags(new String[] {"a", "b"}, new String[] {null, "x"}));
+ AggregateEntry b =
+ AggregateEntryTestUtils.forSnapshot(
+ snapshotWithPeerTags(new String[] {"b", "c"}, new String[] {"x", null}));
+
+ // Sanity: same encoded peer tags, despite different raw layout.
+ assertEquals(a.getPeerTags(), b.getPeerTags());
+
+ // Different raw layouts -> entries must not be equal via the test helper.
+ assertFalse(AggregateEntryTestUtils.equals(a, b));
+ // And different hashCodes (matching the inequality).
+ assertNotEquals(AggregateEntryTestUtils.hashCode(a), AggregateEntryTestUtils.hashCode(b));
+ }
+
+ @Test
+ void testUtilsEqualEntriesHaveEqualHashCodes() {
+ AggregateEntry a =
+ AggregateEntryTestUtils.forSnapshot(
+ snapshotWithPeerTags(new String[] {"a", "b"}, new String[] {null, "x"}));
+ AggregateEntry b =
+ AggregateEntryTestUtils.forSnapshot(
+ snapshotWithPeerTags(new String[] {"a", "b"}, new String[] {null, "x"}));
+
+ assertTrue(AggregateEntryTestUtils.equals(a, b));
+ assertEquals(AggregateEntryTestUtils.hashCode(a), AggregateEntryTestUtils.hashCode(b));
+ }
+
+ private static SpanSnapshot snapshotWithPeerTags(String[] names, String[] values) {
+ return AggregateEntryTestUtils.buildSnapshot(
+ "resource",
+ "svc",
+ "op",
+ null,
+ "type",
+ (short) 200,
+ false,
+ true,
+ "client",
+ PeerTagSchema.testSchema(names),
+ values,
+ null,
+ null,
+ null,
+ 0L);
+ }
+
+ private static AggregateEntry newEntry() {
+ SpanSnapshot snapshot =
+ AggregateEntryTestUtils.buildSnapshot(
+ "resource",
+ "svc",
+ "op",
+ null,
+ "type",
+ (short) 200,
+ false,
+ true,
+ "client",
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0L);
+ return AggregateEntryTestUtils.forSnapshot(snapshot);
+ }
+}
diff --git a/dd-trace-core/src/test/java/datadog/trace/common/metrics/AggregateEntryTestUtils.java b/dd-trace-core/src/test/java/datadog/trace/common/metrics/AggregateEntryTestUtils.java
new file mode 100644
index 00000000000..acc298fd436
--- /dev/null
+++ b/dd-trace-core/src/test/java/datadog/trace/common/metrics/AggregateEntryTestUtils.java
@@ -0,0 +1,175 @@
+package datadog.trace.common.metrics;
+
+import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import javax.annotation.Nullable;
+
+/**
+ * Test-side helpers for {@link AggregateEntry}: a positional-args fixture factory plus a field-wise
+ * equality contract for use with Spock mock argument matchers and JUnit assertions. Lives in {@code
+ * src/test} so the production class stays free of test-only API; same {@code
+ * datadog.trace.common.metrics} package so this helper can reach package-private fields and
+ * constructors.
+ *
+ * Production {@code AggregateEntry} intentionally has no {@code equals}/{@code hashCode}
+ * override -- {@link AggregateTable} bucketing goes through {@link AggregateEntry#matches} keyed on
+ * {@link AggregateEntry#keyHash}, and no production code path invokes {@link Object#equals}.
+ *
+ *
The equality helper compares the raw {@code peerTagNames}/{@code peerTagValues} arrays (not
+ * the encoded {@code peerTags} list) so it stays consistent with {@link AggregateEntry#hashOf},
+ * which folds in raw arrays via {@link PeerTagSchema#hashCode()} and {@link
+ * Arrays#hashCode(Object[])}. Comparing the encoded list would let two entries with different raw
+ * layouts (e.g. tag {@code "b"} at index 1 in schema A vs index 0 in schema B, with matching
+ * values) collapse to the same encoded form -- a real bug surfaced during PR #11382 review.
+ */
+public final class AggregateEntryTestUtils {
+ private AggregateEntryTestUtils() {}
+
+ /**
+ * Builds an {@link AggregateEntry} from the same positional shape the prior {@code new
+ * MetricKey(...)} took. Accepts a pre-encoded {@code List} of {@code
+ * "name:value"} peer tags and recovers the parallel-array {@code (names, values)} form by
+ * splitting on the {@code ':'} delimiter.
+ *
+ * Test-only. The split is at the first {@code ':'}, so peer-tag values
+ * containing a colon (URLs, IPv6 addresses, {@code service:env} patterns) will be silently
+ * misparsed and the recovered (name, value) pair will be wrong. Keep test data colon-free in
+ * peer-tag values, or wire a production-style snapshot through {@link #forSnapshot(SpanSnapshot)}
+ * directly instead.
+ */
+ public static AggregateEntry of(
+ CharSequence resource,
+ CharSequence service,
+ CharSequence operationName,
+ @Nullable CharSequence serviceSource,
+ CharSequence type,
+ int httpStatusCode,
+ boolean synthetic,
+ boolean traceRoot,
+ CharSequence spanKind,
+ @Nullable List peerTags,
+ @Nullable CharSequence httpMethod,
+ @Nullable CharSequence httpEndpoint,
+ @Nullable CharSequence grpcStatusCode) {
+ PeerTagSchema schema = null;
+ String[] values = null;
+ if (peerTags != null && !peerTags.isEmpty()) {
+ String[] names = new String[peerTags.size()];
+ values = new String[peerTags.size()];
+ int i = 0;
+ for (UTF8BytesString t : peerTags) {
+ String s = t.toString();
+ int colon = s.indexOf(':');
+ names[i] = colon < 0 ? s : s.substring(0, colon);
+ values[i] = colon < 0 ? "" : s.substring(colon + 1);
+ i++;
+ }
+ schema = PeerTagSchema.testSchema(names);
+ }
+ SpanSnapshot syntheticSnapshot =
+ buildSnapshot(
+ resource,
+ service,
+ operationName,
+ serviceSource,
+ type,
+ (short) httpStatusCode,
+ synthetic,
+ traceRoot,
+ spanKind,
+ schema,
+ values,
+ httpMethod,
+ httpEndpoint,
+ grpcStatusCode,
+ 0L);
+ return forSnapshot(syntheticSnapshot);
+ }
+
+ /**
+ * Test-only snapshot builder. Production fillSlot canonicalizes each field via the per-field
+ * DDCaches on {@link AggregateEntry} and precomputes {@link SpanSnapshot#keyHash}; this helper
+ * does the same so tests exercise the production matches/hash path.
+ */
+ static SpanSnapshot buildSnapshot(
+ CharSequence resource,
+ CharSequence service,
+ CharSequence operationName,
+ @Nullable CharSequence serviceSource,
+ CharSequence type,
+ short httpStatusCode,
+ boolean synthetic,
+ boolean traceRoot,
+ CharSequence spanKind,
+ @Nullable PeerTagSchema peerTagSchema,
+ @Nullable String[] peerTagValues,
+ @Nullable CharSequence httpMethod,
+ @Nullable CharSequence httpEndpoint,
+ @Nullable CharSequence grpcStatusCode,
+ long tagAndDuration) {
+ SpanSnapshot s = new SpanSnapshot();
+ s.resourceName = AggregateEntry.canonicalize(AggregateEntry.RESOURCE_CACHE, resource);
+ s.serviceName = AggregateEntry.canonicalize(AggregateEntry.SERVICE_CACHE, service);
+ s.operationName = AggregateEntry.canonicalize(AggregateEntry.OPERATION_CACHE, operationName);
+ s.serviceNameSource =
+ AggregateEntry.canonicalizeOptional(AggregateEntry.SERVICE_SOURCE_CACHE, serviceSource);
+ s.spanType = AggregateEntry.canonicalize(AggregateEntry.TYPE_CACHE, type);
+ s.httpStatusCode = httpStatusCode;
+ s.synthetic = synthetic;
+ s.traceRoot = traceRoot;
+ s.spanKind = AggregateEntry.canonicalize(AggregateEntry.SPAN_KIND_CACHE, spanKind);
+ s.peerTagSchema = peerTagSchema;
+ s.peerTagValues = peerTagValues;
+ s.httpMethod =
+ AggregateEntry.canonicalizeOptional(AggregateEntry.HTTP_METHOD_CACHE, httpMethod);
+ s.httpEndpoint =
+ AggregateEntry.canonicalizeOptional(AggregateEntry.HTTP_ENDPOINT_CACHE, httpEndpoint);
+ s.grpcStatusCode =
+ AggregateEntry.canonicalizeOptional(AggregateEntry.GRPC_STATUS_CODE_CACHE, grpcStatusCode);
+ s.tagAndDuration = tagAndDuration;
+ SpanSnapshot.computeAndSetKeyHash(s);
+ return s;
+ }
+
+ /**
+ * Builds an {@link AggregateEntry} from {@code s}. Production callers route through {@link
+ * AggregateTable#findOrInsert}; tests use this helper for direct construction.
+ */
+ public static AggregateEntry forSnapshot(SpanSnapshot s) {
+ return new AggregateEntry(s);
+ }
+
+ /**
+ * Whether {@code a} and {@code b} carry identical label fields. Counter and histogram state is
+ * intentionally excluded -- this compares the key identity, not the aggregate.
+ */
+ public static boolean equals(AggregateEntry a, AggregateEntry b) {
+ if (a == b) return true;
+ if (a == null || b == null) return false;
+ return a.getHttpStatusCode() == b.getHttpStatusCode()
+ && a.isSynthetics() == b.isSynthetics()
+ && a.isTraceRoot() == b.isTraceRoot()
+ && Objects.equals(a.getResource(), b.getResource())
+ && Objects.equals(a.getService(), b.getService())
+ && Objects.equals(a.getOperationName(), b.getOperationName())
+ && Objects.equals(a.getServiceSource(), b.getServiceSource())
+ && Objects.equals(a.getType(), b.getType())
+ && Objects.equals(a.getSpanKind(), b.getSpanKind())
+ && Arrays.equals(a.peerTagNames, b.peerTagNames)
+ && Arrays.equals(a.peerTagValues, b.peerTagValues)
+ && Objects.equals(a.getHttpMethod(), b.getHttpMethod())
+ && Objects.equals(a.getHttpEndpoint(), b.getHttpEndpoint())
+ && Objects.equals(a.getGrpcStatusCode(), b.getGrpcStatusCode());
+ }
+
+ /**
+ * Stable hash matching {@link #equals(AggregateEntry, AggregateEntry)} -- derived from {@link
+ * AggregateEntry#keyHash}, which {@link AggregateEntry#hashOf} computes from the same raw fields
+ * the helper's {@code equals} compares.
+ */
+ public static int hashCode(AggregateEntry e) {
+ return e == null ? 0 : (int) e.keyHash;
+ }
+}
diff --git a/dd-trace-core/src/test/java/datadog/trace/common/metrics/AggregateTableTest.java b/dd-trace-core/src/test/java/datadog/trace/common/metrics/AggregateTableTest.java
new file mode 100644
index 00000000000..e807dfdd338
--- /dev/null
+++ b/dd-trace-core/src/test/java/datadog/trace/common/metrics/AggregateTableTest.java
@@ -0,0 +1,429 @@
+package datadog.trace.common.metrics;
+
+import static datadog.trace.common.metrics.AggregateEntry.ERROR_TAG;
+import static datadog.trace.common.metrics.AggregateEntry.TOP_LEVEL_TAG;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import datadog.metrics.agent.AgentMeter;
+import datadog.metrics.api.statsd.StatsDClient;
+import datadog.metrics.impl.DDSketchHistograms;
+import datadog.metrics.impl.MonitoringImpl;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class AggregateTableTest {
+
+ @BeforeAll
+ static void initAgentMeter() {
+ // AggregateEntry.recordOneDuration -> Histogram.accept needs AgentMeter to be initialized.
+ MonitoringImpl monitoring = new MonitoringImpl(StatsDClient.NO_OP, 1, TimeUnit.SECONDS);
+ AgentMeter.registerIfAbsent(StatsDClient.NO_OP, monitoring, DDSketchHistograms.FACTORY);
+ monitoring.newTimer("test.init");
+ }
+
+ @Test
+ void insertOnMissReturnsNewAggregate() {
+ AggregateTable table = new AggregateTable(8);
+ SpanSnapshot s = snapshot("svc", "op", "client");
+
+ AggregateEntry agg = table.findOrInsert(s);
+
+ assertNotNull(agg);
+ assertEquals(1, table.size());
+ assertEquals(0, agg.getHitCount());
+ }
+
+ @Test
+ void hitReturnsSameAggregateInstance() {
+ AggregateTable table = new AggregateTable(8);
+ SpanSnapshot s1 = snapshot("svc", "op", "client");
+ SpanSnapshot s2 = snapshot("svc", "op", "client");
+
+ AggregateEntry first = table.findOrInsert(s1);
+ AggregateEntry second = table.findOrInsert(s2);
+
+ assertSame(first, second);
+ assertEquals(1, table.size());
+ }
+
+ @Test
+ void differentKindFieldsAreDistinct() {
+ AggregateTable table = new AggregateTable(8);
+
+ AggregateEntry clientAgg = table.findOrInsert(snapshot("svc", "op", "client"));
+ AggregateEntry serverAgg = table.findOrInsert(snapshot("svc", "op", "server"));
+
+ assertNotSame(clientAgg, serverAgg);
+ assertEquals(2, table.size());
+ }
+
+ @Test
+ void peerTagPairsParticipateInIdentity() {
+ AggregateTable table = new AggregateTable(8);
+ SpanSnapshot withTags =
+ builder("svc", "op", "client").peerTags("peer.hostname", "host-a").build();
+ SpanSnapshot otherTags =
+ builder("svc", "op", "client").peerTags("peer.hostname", "host-b").build();
+ SpanSnapshot noTags = builder("svc", "op", "client").build();
+
+ AggregateEntry a = table.findOrInsert(withTags);
+ AggregateEntry b = table.findOrInsert(otherTags);
+ AggregateEntry c = table.findOrInsert(noTags);
+
+ assertNotSame(a, b);
+ assertNotSame(a, c);
+ assertNotSame(b, c);
+ assertEquals(3, table.size());
+ }
+
+ @Test
+ void capOverrunEvictsStaleEntry() {
+ AggregateTable table = new AggregateTable(2);
+
+ AggregateEntry stale = table.findOrInsert(snapshot("svc-a", "op", "client"));
+ // do not record on stale -> hitCount stays at 0
+
+ AggregateEntry live = table.findOrInsert(snapshot("svc-b", "op", "client"));
+ live.recordOneDuration(10L | TOP_LEVEL_TAG); // hitCount=1, not evictable
+
+ // table is full (size=2). Inserting a third should evict the stale one and succeed.
+ AggregateEntry newcomer = table.findOrInsert(snapshot("svc-c", "op", "client"));
+ assertNotNull(newcomer);
+ assertEquals(2, table.size());
+
+ // re-inserting the stale snapshot should miss now (it was evicted) and produce a fresh entry
+ AggregateEntry staleAgain = table.findOrInsert(snapshot("svc-a", "op", "client"));
+ assertNotSame(stale, staleAgain);
+ }
+
+ @Test
+ void backToBackEvictionsAllSucceed() {
+ // Cursor amortization regression: cap the table, fill with stale entries, then force a
+ // sequence of cap-overrun inserts. Each insert must succeed (evicting one stale entry and
+ // inserting one new). The cursor field is internal, but if it were ever wedged (e.g.
+ // pointing past the end of buckets, or not advancing after a successful eviction), some
+ // later insert would fail to find a stale entry. Drives ~3x the capacity worth of inserts to
+ // give wrap-around plenty of chances to misbehave.
+ AggregateTable table = new AggregateTable(8);
+ for (int i = 0; i < 8; i++) {
+ table.findOrInsert(snapshot("init-" + i, "op", "client"));
+ }
+ for (int i = 0; i < 32; i++) {
+ AggregateEntry inserted = table.findOrInsert(snapshot("post-" + i, "op", "client"));
+ assertNotNull(
+ inserted, "insert #" + i + " should evict a stale entry and succeed (table full)");
+ }
+ assertEquals(8, table.size());
+ }
+
+ @Test
+ void clearResetsCursorForSubsequentEvictions() {
+ // The cursor must reset to 0 on clear so a re-filled table doesn't start eviction at a
+ // stale bucket index. Verified indirectly: clear and re-fill, then force an eviction; the
+ // newcomer must successfully take a slot (which only works if a stale entry was found).
+ AggregateTable table = new AggregateTable(4);
+
+ // Fill, age, evict once -- cursor lands at some non-zero bucket
+ for (int i = 0; i < 4; i++) {
+ table.findOrInsert(snapshot("warm-" + i, "op", "client"));
+ }
+ table.findOrInsert(snapshot("evict-trigger", "op", "client"));
+
+ table.clear();
+ assertEquals(0, table.size());
+
+ // Re-fill, age, force eviction -- should still find a stale entry from bucket 0 onward
+ for (int i = 0; i < 4; i++) {
+ table.findOrInsert(snapshot("fresh-" + i, "op", "client"));
+ }
+ AggregateEntry newcomer = table.findOrInsert(snapshot("post-clear", "op", "client"));
+ assertNotNull(newcomer, "post-clear cap-overrun insert must succeed via cursor-reset evict");
+ }
+
+ @Test
+ void capOverrunWithNoStaleReturnsNull() {
+ AggregateTable table = new AggregateTable(2);
+
+ AggregateEntry a = table.findOrInsert(snapshot("svc-a", "op", "client"));
+ AggregateEntry b = table.findOrInsert(snapshot("svc-b", "op", "client"));
+ a.recordOneDuration(10L);
+ b.recordOneDuration(20L);
+
+ AggregateEntry c = table.findOrInsert(snapshot("svc-c", "op", "client"));
+ assertNull(c);
+ assertEquals(2, table.size());
+ }
+
+ @Test
+ void expungeStaleAggregatesRemovesZeroHitsOnly() {
+ AggregateTable table = new AggregateTable(16);
+
+ AggregateEntry live = table.findOrInsert(snapshot("svc-live", "op", "client"));
+ live.recordOneDuration(10L);
+ AggregateEntry stale1 = table.findOrInsert(snapshot("svc-stale1", "op", "client"));
+ AggregateEntry stale2 = table.findOrInsert(snapshot("svc-stale2", "op", "client"));
+ assertEquals(3, table.size());
+ assertEquals(0, stale1.getHitCount());
+ assertEquals(0, stale2.getHitCount());
+
+ table.expungeStaleAggregates();
+
+ assertEquals(1, table.size());
+ // the live entry must still be reachable
+ assertSame(live, table.findOrInsert(snapshot("svc-live", "op", "client")));
+ }
+
+ @Test
+ void forEachVisitsEveryEntry() {
+ AggregateTable table = new AggregateTable(8);
+ table.findOrInsert(snapshot("a", "op", "client")).recordOneDuration(1L);
+ table.findOrInsert(snapshot("b", "op", "client")).recordOneDuration(2L);
+ table.findOrInsert(snapshot("c", "op", "client")).recordOneDuration(3L | ERROR_TAG);
+
+ Map visited = new HashMap<>();
+ table.forEach(e -> visited.put(e.getService().toString(), e.getDuration()));
+
+ assertEquals(3, visited.size());
+ assertEquals(1L, visited.get("a"));
+ assertEquals(2L, visited.get("b"));
+ assertEquals(3L, visited.get("c"));
+ }
+
+ @Test
+ void clearEmptiesTheTable() {
+ AggregateTable table = new AggregateTable(8);
+ table.findOrInsert(snapshot("a", "op", "client"));
+ table.findOrInsert(snapshot("b", "op", "client"));
+ assertEquals(2, table.size());
+
+ table.clear();
+
+ assertTrue(table.isEmpty());
+ assertEquals(0, table.size());
+ // and re-insertion works after clear
+ assertNotNull(table.findOrInsert(snapshot("a", "op", "client")));
+ }
+
+ @Test
+ void encodedLabelsAreBuiltOnInsert() {
+ AggregateTable table = new AggregateTable(4);
+ List seen = new ArrayList<>();
+ table.findOrInsert(snapshot("svc", "op", "client"));
+ table.forEach(seen::add);
+
+ assertEquals(1, seen.size());
+ AggregateEntry e = seen.get(0);
+ assertEquals("svc", e.getService().toString());
+ assertEquals("op", e.getOperationName().toString());
+ assertEquals("client", e.getSpanKind().toString());
+ }
+
+ @Test
+ void nullAndEmptyOptionalFieldsCollapseToOneEntry() {
+ // null and length-zero are treated as equivalent for optional fields, so snapshots that
+ // differ only in null-vs-"" land on the same entry.
+ AggregateTable table = new AggregateTable(8);
+
+ SpanSnapshot snapNull = nullableSnapshot(null, null, null, null);
+ SpanSnapshot snapEmpty = nullableSnapshot("", "", "", "");
+
+ AggregateEntry first = table.findOrInsert(snapNull);
+ AggregateEntry secondNull = table.findOrInsert(nullableSnapshot(null, null, null, null));
+ AggregateEntry forEmpty = table.findOrInsert(snapEmpty);
+
+ assertSame(first, secondNull, "two null-fielded snapshots must hit the same entry");
+ assertSame(first, forEmpty, "null- and empty-fielded snapshots must hit the same entry");
+ assertEquals(1, table.size());
+ }
+
+ @Test
+ void nullServiceAndSpanKindDoNotNpeAndCollapseWithEmpty() {
+ // Null service and spanKind are accepted (canonicalize to length-zero) and collapse with
+ // empty-string variants onto the same entry.
+ AggregateTable table = new AggregateTable(8);
+
+ SpanSnapshot allNulls = nullServiceKindSnapshot(null, null);
+ SpanSnapshot allEmpty = nullServiceKindSnapshot("", "");
+
+ AggregateEntry first = table.findOrInsert(allNulls);
+ AggregateEntry secondNull = table.findOrInsert(nullServiceKindSnapshot(null, null));
+ AggregateEntry forEmpty = table.findOrInsert(allEmpty);
+
+ assertSame(first, secondNull, "two null-service/-kind snapshots must hit the same entry");
+ assertSame(first, forEmpty, "null- and empty-service/-kind snapshots must hit the same entry");
+ assertEquals(1, table.size());
+ assertEquals(0, first.getService().length(), "null serviceName should canonicalize to EMPTY");
+ assertEquals(0, first.getSpanKind().length(), "null spanKind should canonicalize to EMPTY");
+ }
+
+ @Test
+ void noPeerTagSnapshotMatchesEntryEvenWithStaleScratchPeerTagValues() {
+ // Models the ring-buffer slot-reuse pattern: a slot whose previous publish fired peer tags
+ // ends up reused for a span with no peer tags. The producer clears peerTagSchema but leaves
+ // peerTagValues populated (the array is the slot's reusable scratch). The consumer must
+ // gate on peerTagSchema and ignore stale scratch values for both hashOf and matches --
+ // otherwise the slot's stale state would steer the snapshot to a different bucket or fail
+ // the field-by-field comparison, producing a duplicate aggregate.
+ AggregateTable table = new AggregateTable(8);
+ SpanSnapshot cleanNoPeerTags = snapshot("svc", "op", "internal");
+ AggregateEntry inserted = table.findOrInsert(cleanNoPeerTags);
+
+ SpanSnapshot withStaleScratch = snapshot("svc", "op", "internal");
+ // Simulate stale scratch: same logical contents (no peer tags -> schema null), but the
+ // values array carries leftover data the slot's prior tag-firing publish wrote into it.
+ withStaleScratch.peerTagSchema = null;
+ withStaleScratch.peerTagValues = new String[] {"leftover.host", "leftover.service"};
+
+ AggregateEntry hit = table.findOrInsert(withStaleScratch);
+ assertSame(inserted, hit, "stale peerTagValues must be ignored when peerTagSchema is null");
+ assertEquals(1, table.size());
+ }
+
+ @Test
+ void aggregateEntryCopiesPeerTagValuesSoSlotReuseDoesntCorruptIt() {
+ // The AggregateEntry must snapshot peerTagValues by value at insert time -- otherwise
+ // when the producer reuses the slot's array on the next publish, the entry's identity
+ // would silently change. Verify by mutating the source array after insert and confirming
+ // the entry still matches its original-content snapshot.
+ AggregateTable table = new AggregateTable(8);
+ String[] values = new String[] {"host1", "svc1"};
+ SpanSnapshot s1 = snapshotWithPeerTags("svc", "op", "client", values);
+ AggregateEntry inserted = table.findOrInsert(s1);
+
+ // Producer "reuses" the array: same reference, new contents.
+ values[0] = "host2";
+ values[1] = "svc2";
+
+ // A FRESH snapshot with the original peer tag values should still match the entry.
+ SpanSnapshot s2 = snapshotWithPeerTags("svc", "op", "client", new String[] {"host1", "svc1"});
+ AggregateEntry hit = table.findOrInsert(s2);
+ assertSame(inserted, hit, "entry's peer tag values must be a snapshot, not a live ref");
+ }
+
+ private static SpanSnapshot snapshotWithPeerTags(
+ String service, String operation, String spanKind, String[] peerTagValues) {
+ PeerTagSchema schema = PeerTagSchema.testSchema(new String[] {"peer.host", "peer.service"});
+ return AggregateEntryTestUtils.buildSnapshot(
+ "resource",
+ service,
+ operation,
+ null,
+ "web",
+ (short) 200,
+ false,
+ true,
+ spanKind,
+ schema,
+ peerTagValues,
+ null,
+ null,
+ null,
+ 0L);
+ }
+
+ private static SpanSnapshot nullServiceKindSnapshot(String service, String spanKind) {
+ return AggregateEntryTestUtils.buildSnapshot(
+ "resource",
+ service,
+ "op",
+ null,
+ "web",
+ (short) 200,
+ false,
+ true,
+ spanKind,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0L);
+ }
+
+ private static SpanSnapshot nullableSnapshot(
+ String resource, String operation, String type, String serviceNameSource) {
+ return AggregateEntryTestUtils.buildSnapshot(
+ resource,
+ "svc",
+ operation,
+ serviceNameSource,
+ type,
+ (short) 200,
+ false,
+ true,
+ "client",
+ null,
+ null,
+ null,
+ null,
+ null,
+ 0L);
+ }
+
+ // ---------- helpers ----------
+
+ private static SpanSnapshot snapshot(String service, String operation, String spanKind) {
+ return builder(service, operation, spanKind).build();
+ }
+
+ private static SnapshotBuilder builder(String service, String operation, String spanKind) {
+ return new SnapshotBuilder(service, operation, spanKind);
+ }
+
+ private static final class SnapshotBuilder {
+ private final String service;
+ private final String operation;
+ private final String spanKind;
+ private PeerTagSchema peerTagSchema;
+ private String[] peerTagValues;
+ private long tagAndDuration = 0L;
+
+ SnapshotBuilder(String service, String operation, String spanKind) {
+ this.service = service;
+ this.operation = operation;
+ this.spanKind = spanKind;
+ }
+
+ SnapshotBuilder peerTags(String... namesAndValues) {
+ int pairCount = namesAndValues.length / 2;
+ String[] names = new String[pairCount];
+ String[] values = new String[pairCount];
+ for (int i = 0; i < pairCount; i++) {
+ names[i] = namesAndValues[2 * i];
+ values[i] = namesAndValues[2 * i + 1];
+ }
+ this.peerTagSchema = PeerTagSchema.testSchema(names);
+ this.peerTagValues = values;
+ return this;
+ }
+
+ SpanSnapshot build() {
+ return AggregateEntryTestUtils.buildSnapshot(
+ "resource",
+ service,
+ operation,
+ null,
+ "web",
+ (short) 200,
+ false,
+ true,
+ spanKind,
+ peerTagSchema,
+ peerTagValues,
+ null,
+ null,
+ null,
+ tagAndDuration);
+ }
+ }
+}
diff --git a/dd-trace-core/src/test/java/datadog/trace/common/metrics/ConflatingMetricsAggregatorBootstrapTest.java b/dd-trace-core/src/test/java/datadog/trace/common/metrics/ConflatingMetricsAggregatorBootstrapTest.java
index ef07e0fbc19..59681d4724e 100644
--- a/dd-trace-core/src/test/java/datadog/trace/common/metrics/ConflatingMetricsAggregatorBootstrapTest.java
+++ b/dd-trace-core/src/test/java/datadog/trace/common/metrics/ConflatingMetricsAggregatorBootstrapTest.java
@@ -217,9 +217,9 @@ void reconcileSurvivesStateChangeWhenTagsUnchanged() throws Exception {
void reconcileSwapsSchemaWhenTagSetChanges() throws Exception {
// The reconcile slow-path's swap branch: discovery refreshes the state AND the tag set
// grows. Cached schema is rebuilt and the volatile reference points at the new schema.
- // Verification is end-to-end -- we look at the MetricKey the writer receives. Pre-swap the
- // span snapshot was pinned to the old schema so only peer.hostname appears; post-swap a new
- // publish reads the new schema and the next flush carries both peer tags.
+ // Verification is end-to-end -- we look at the AggregateEntry the writer receives. Pre-swap
+ // the span snapshot was pinned to the old schema so only peer.hostname appears; post-swap a
+ // new publish reads the new schema and the next flush carries both peer tags.
HealthMetrics healthMetrics = mock(HealthMetrics.class);
MetricWriter writer = mock(MetricWriter.class);
Sink sink = mock(Sink.class);
@@ -266,8 +266,8 @@ void reconcileSwapsSchemaWhenTagSetChanges() throws Exception {
.finishBucket();
// Publish 1: snapshot pinned to the original {peer.hostname} schema. cycle 1's reconcile
- // will swap the cached schema BEFORE the flush, but this snapshot is already pinned so its
- // MetricKey will still carry only peer.hostname.
+ // will swap the cached schema BEFORE the flush, but this snapshot is already pinned so the
+ // resulting AggregateEntry will still carry only peer.hostname.
aggregator.publish(
Collections.>singletonList(peerAggregationSpanWithBothPeerTags()));
aggregator.report();
@@ -280,20 +280,20 @@ void reconcileSwapsSchemaWhenTagSetChanges() throws Exception {
aggregator.report();
assertTrue(cycle2.await(2, SECONDS));
- // Capture every (MetricKey, AggregateMetric) the writer saw across both cycles. Pre-swap
- // snapshot has 1 peer tag, post-swap has 2.
- ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(MetricKey.class);
- verify(writer, times(2)).add(keyCaptor.capture(), any(AggregateMetric.class));
- List keys = keyCaptor.getAllValues();
+ // Capture every AggregateEntry the writer saw across both cycles. Pre-swap snapshot has 1
+ // peer tag, post-swap has 2.
+ ArgumentCaptor entryCaptor = ArgumentCaptor.forClass(AggregateEntry.class);
+ verify(writer, times(2)).add(entryCaptor.capture());
+ List entries = entryCaptor.getAllValues();
assertEquals(
Collections.singletonList(UTF8BytesString.create("peer.hostname:localhost")),
- keys.get(0).getPeerTags(),
+ entries.get(0).getPeerTags(),
"pre-swap snapshot should encode only peer.hostname");
assertEquals(
Arrays.asList(
UTF8BytesString.create("peer.hostname:localhost"),
UTF8BytesString.create("peer.service:billing")),
- keys.get(1).getPeerTags(),
+ entries.get(1).getPeerTags(),
"post-swap snapshot should encode both peer.hostname and peer.service");
// Bootstrap (1) + cycle 1 slow-path (1) -- cycle 2 is fast-path so doesn't reach peerTags().
diff --git a/dd-trace-core/src/test/java/datadog/trace/common/metrics/ConflatingMetricsAggregatorDisableTest.java b/dd-trace-core/src/test/java/datadog/trace/common/metrics/ConflatingMetricsAggregatorDisableTest.java
new file mode 100644
index 00000000000..d072371d25d
--- /dev/null
+++ b/dd-trace-core/src/test/java/datadog/trace/common/metrics/ConflatingMetricsAggregatorDisableTest.java
@@ -0,0 +1,234 @@
+package datadog.trace.common.metrics;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.after;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import datadog.communication.ddagent.DDAgentFeaturesDiscovery;
+import datadog.trace.bootstrap.instrumentation.api.Tags;
+import datadog.trace.core.CoreSpan;
+import datadog.trace.core.SpanKindFilter;
+import datadog.trace.core.monitor.HealthMetrics;
+import java.util.Collections;
+import java.util.concurrent.CountDownLatch;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+/**
+ * Coverage for the {@code disable() -> ClearSignal.CLEAR} threading routing introduced in this PR.
+ *
+ * The bundled fix routes the agent-downgrade clear through the inbox so the aggregator thread
+ * stays the sole writer to {@link AggregateTable} (which is not thread-safe). The behavioral
+ * contract this test pins:
+ *
+ *
+ * - {@code onEvent(DOWNGRADED)} can fire from a non-aggregator thread (in production, the
+ * OkHttpSink callback thread).
+ *
- By the time the next report cycle reconciles peer-tag schema on the aggregator thread, the
+ * {@code AggregateTable} has been cleared -- {@code CLEAR} arrived in the FIFO inbox before
+ * the {@code REPORT} signal triggered by {@code aggregator.report()}.
+ *
- The aggregator therefore flushes nothing on that next report cycle: no {@code startBucket},
+ * no {@code add}, no {@code finishBucket}.
+ *
+ *
+ * The test would fail if {@code disable()} reverted to mutating {@code AggregateTable} directly
+ * (the pre-fix path) only via races -- not deterministically -- so the assertions here are about
+ * the observable end-to-end shape rather than thread identity.
+ */
+class ConflatingMetricsAggregatorDisableTest {
+
+ @Test
+ void downgradeRoutesClearThroughInboxBeforeNextReport() throws Exception {
+ HealthMetrics healthMetrics = mock(HealthMetrics.class);
+ MetricWriter writer = mock(MetricWriter.class);
+ Sink sink = mock(Sink.class);
+ DDAgentFeaturesDiscovery features = mock(DDAgentFeaturesDiscovery.class);
+ when(features.supportsMetrics()).thenReturn(true);
+ when(features.peerTags()).thenReturn(Collections.emptySet());
+ when(features.state()).thenReturn("state-1");
+
+ ConflatingMetricsAggregator aggregator =
+ new ConflatingMetricsAggregator(
+ Collections.emptySet(),
+ features,
+ healthMetrics,
+ sink,
+ writer,
+ /* maxAggregates */ 16,
+ /* queueSize */ 64,
+ /* reportingInterval */ 10,
+ SECONDS,
+ /* includeEndpointInMetrics */ false);
+ aggregator.start();
+ try {
+ // Baseline: publish a span, run a report, verify the table flushes normally. This gives
+ // us a clean post-first-report state with the aggregator's reconcile already having fired
+ // once on the aggregator thread.
+ CountDownLatch firstFlush = new CountDownLatch(1);
+ org.mockito.Mockito.doAnswer(
+ invocation -> {
+ firstFlush.countDown();
+ return null;
+ })
+ .when(writer)
+ .finishBucket();
+
+ aggregator.publish(Collections.>singletonList(metricsEligibleSpan()));
+ aggregator.report();
+ assertTrue(firstFlush.await(2, SECONDS));
+
+ // Reset writer-side mock interactions so the post-disable verify() blocks below only see
+ // what happens after the downgrade. features mock keeps accumulating call counts -- we use
+ // those counts as a latch on aggregator-thread reconcile timing.
+ reset(writer);
+
+ // Flip the discovery state. disable()'s first action is features.discover() followed by a
+ // features.supportsMetrics() check; returning false here selects the clear path.
+ when(features.supportsMetrics()).thenReturn(false);
+
+ // Fire DOWNGRADED on the test thread. This is the production scenario where the OkHttpSink
+ // callback thread triggers onEvent. disable() offers ClearSignal.CLEAR to the inbox but
+ // does not (and must not) mutate AggregateTable directly here.
+ aggregator.onEvent(EventListener.EventType.DOWNGRADED, "");
+
+ // First: verify nothing flushes immediately after disable. We can't pin reconcile-on-the-
+ // aggregator-thread as a latch here because CLEAR's inbox.clear() drops any REPORT we'd
+ // queue behind it -- so we just wait a window for any flush attempt to materialize.
+ verify(writer, after(500).never()).startBucket(anyInt(), anyLong(), anyLong());
+
+ // Stronger contract: prove the table is actually empty after CLEAR by re-enabling metrics
+ // and publishing a *marker* span with a distinct resource name. The next report should
+ // flush exactly one entry -- the marker -- with the original "resource" gone. If disable()
+ // had failed to clear the table (or had cleared it from the wrong thread and corrupted
+ // bucket chains), this assertion would catch it.
+ when(features.supportsMetrics()).thenReturn(true);
+ CountDownLatch postClearFlush = new CountDownLatch(1);
+ org.mockito.Mockito.doAnswer(
+ invocation -> {
+ postClearFlush.countDown();
+ return null;
+ })
+ .when(writer)
+ .finishBucket();
+ aggregator.publish(Collections.>singletonList(markerSpan()));
+ aggregator.report();
+ assertTrue(postClearFlush.await(2, SECONDS));
+
+ ArgumentCaptor entryCaptor = ArgumentCaptor.forClass(AggregateEntry.class);
+ verify(writer, times(1)).add(entryCaptor.capture());
+ assertEquals(
+ "marker-resource",
+ entryCaptor.getValue().getResource().toString(),
+ "post-CLEAR bucket should contain only the marker -- the original entry was wiped");
+ } finally {
+ aggregator.close();
+ }
+ }
+
+ @Test
+ void clearDoesNotTrampleQueuedStopSignal() throws Exception {
+ // CLEAR handler clears only the aggregates table; queued signals (STOP, REPORT) survive and
+ // get processed normally.
+ HealthMetrics healthMetrics = mock(HealthMetrics.class);
+ MetricWriter writer = mock(MetricWriter.class);
+ Sink sink = mock(Sink.class);
+ DDAgentFeaturesDiscovery features = mock(DDAgentFeaturesDiscovery.class);
+ when(features.supportsMetrics()).thenReturn(true);
+ when(features.peerTags()).thenReturn(Collections.emptySet());
+ when(features.state()).thenReturn("state-1");
+
+ ConflatingMetricsAggregator aggregator =
+ new ConflatingMetricsAggregator(
+ Collections.emptySet(),
+ features,
+ healthMetrics,
+ sink,
+ writer,
+ /* maxAggregates */ 16,
+ /* queueSize */ 64,
+ /* reportingInterval */ 10,
+ SECONDS,
+ /* includeEndpointInMetrics */ false);
+ aggregator.start();
+
+ // Force at least one snapshot into the inbox so the aggregator has something to drain.
+ aggregator.publish(Collections.>singletonList(metricsEligibleSpan()));
+
+ // Fire DOWNGRADED on this thread. disable() flips supportsMetrics() to false and offers
+ // CLEAR. Then immediately call close() which offers STOP. If CLEAR's handler clears the
+ // inbox, STOP gets trampled and close() hangs until the join timeout.
+ when(features.supportsMetrics()).thenReturn(false);
+ aggregator.onEvent(EventListener.EventType.DOWNGRADED, "");
+
+ // close() is synchronous; bound it ourselves rather than trusting THREAD_JOIN_TIMEOUT_MS.
+ long deadlineNanos = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(2);
+ Thread closer = new Thread(aggregator::close, "test-closer");
+ closer.start();
+ while (closer.isAlive() && System.nanoTime() < deadlineNanos) {
+ closer.join(50);
+ }
+ assertTrue(
+ !closer.isAlive(),
+ "close() must return promptly -- if CLEAR trampled STOP, this hangs out the join timeout");
+ }
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private static CoreSpan> metricsEligibleSpan() {
+ CoreSpan span = mock(CoreSpan.class);
+ when(span.isMeasured()).thenReturn(false);
+ when(span.isTopLevel()).thenReturn(true);
+ // Return true for any SpanKindFilter so peerTagSchemaFor enters the bootstrap path on the
+ // first publish. We want that bootstrap to fire (it's what makes features.state()
+ // observable), even though peerTags() returns emptySet here and the resulting schema has
+ // size 0.
+ when(span.isKind(any(SpanKindFilter.class))).thenReturn(true);
+ when(span.getLongRunningVersion()).thenReturn(0);
+ when(span.getDurationNano()).thenReturn(100L);
+ when(span.getError()).thenReturn(0);
+ when(span.getResourceName()).thenReturn("resource");
+ when(span.getServiceName()).thenReturn("svc");
+ when(span.getOperationName()).thenReturn("op");
+ when(span.getServiceNameSource()).thenReturn(null);
+ when(span.getType()).thenReturn("web");
+ when(span.getHttpStatusCode()).thenReturn((short) 200);
+ when(span.getParentId()).thenReturn(0L);
+ when(span.getOrigin()).thenReturn(null);
+ when(span.unsafeGetTag(eq(Tags.SPAN_KIND), any(CharSequence.class))).thenReturn("client");
+ return span;
+ }
+
+ /**
+ * Distinct from {@link #metricsEligibleSpan()} via the resource name: post-CLEAR the writer
+ * should see "marker-resource", proving the original "resource" entry is gone from the table.
+ */
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private static CoreSpan> markerSpan() {
+ CoreSpan span = mock(CoreSpan.class);
+ when(span.isMeasured()).thenReturn(false);
+ when(span.isTopLevel()).thenReturn(true);
+ when(span.isKind(any(SpanKindFilter.class))).thenReturn(true);
+ when(span.getLongRunningVersion()).thenReturn(0);
+ when(span.getDurationNano()).thenReturn(100L);
+ when(span.getError()).thenReturn(0);
+ when(span.getResourceName()).thenReturn("marker-resource");
+ when(span.getServiceName()).thenReturn("svc");
+ when(span.getOperationName()).thenReturn("op");
+ when(span.getServiceNameSource()).thenReturn(null);
+ when(span.getType()).thenReturn("web");
+ when(span.getHttpStatusCode()).thenReturn((short) 200);
+ when(span.getParentId()).thenReturn(0L);
+ when(span.getOrigin()).thenReturn(null);
+ when(span.unsafeGetTag(eq(Tags.SPAN_KIND), any(CharSequence.class))).thenReturn("client");
+ return span;
+ }
+}
diff --git a/dd-trace-core/src/traceAgentTest/groovy/MetricsIntegrationTest.groovy b/dd-trace-core/src/traceAgentTest/groovy/MetricsIntegrationTest.groovy
index 2972ffa2c18..4c4ee81b276 100644
--- a/dd-trace-core/src/traceAgentTest/groovy/MetricsIntegrationTest.groovy
+++ b/dd-trace-core/src/traceAgentTest/groovy/MetricsIntegrationTest.groovy
@@ -7,15 +7,14 @@ import datadog.metrics.api.Histograms
import datadog.metrics.impl.DDSketchHistograms
import datadog.trace.api.Config
import datadog.trace.api.WellKnownTags
-import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString
-import datadog.trace.common.metrics.AggregateMetric
+import datadog.trace.common.metrics.AggregateEntry
import datadog.trace.common.metrics.EventListener
-import datadog.trace.common.metrics.MetricKey
import datadog.trace.common.metrics.OkHttpSink
+import datadog.trace.common.metrics.PeerTagSchema
import datadog.trace.common.metrics.SerializingMetricWriter
+import datadog.trace.common.metrics.SpanSnapshot
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.CountDownLatch
-import java.util.concurrent.atomic.AtomicLongArray
import okhttp3.HttpUrl
class MetricsIntegrationTest extends AbstractTraceAgentTest {
@@ -39,14 +38,22 @@ class MetricsIntegrationTest extends AbstractTraceAgentTest {
sink
)
writer.startBucket(2, System.nanoTime(), SECONDS.toNanos(10))
- writer.add(
- new MetricKey("resource1", "service1", "operation1", null, "sql", 0, false, true, "xyzzy", [UTF8BytesString.create("grault:quux")], null, null, null),
- new AggregateMetric().recordDurations(5, new AtomicLongArray(2, 1, 2, 250, 4, 5))
- )
- writer.add(
- new MetricKey("resource2", "service2", "operation2", null, "web", 200, false, true, "xyzzy", [UTF8BytesString.create("grault:quux")], null, null, null),
- new AggregateMetric().recordDurations(10, new AtomicLongArray(1, 1, 200, 2, 3, 4, 5, 6, 7, 8, 9))
- )
+ // Build entries via the production AggregateEntry.forSnapshot(snap, keyHash) path -- same
+ // construction as AggregateTable.findOrInsert. Both entries use one peer tag (grault:quux)
+ // -> schema names=["grault"], values=["quux"].
+ PeerTagSchema schema = PeerTagSchema.testSchema(["grault"] as String[])
+ SpanSnapshot snap1 = new SpanSnapshot(
+ "resource1", "service1", "operation1", null, "sql", (short) 0,
+ false, true, "xyzzy", schema, ["quux"] as String[], null, null, null, 0L)
+ def entry1 = new AggregateEntry(snap1, AggregateEntry.hashOf(snap1))
+ [2, 1, 2, 250, 4].each { entry1.recordOneDuration(it as long) }
+ writer.add(entry1)
+ SpanSnapshot snap2 = new SpanSnapshot(
+ "resource2", "service2", "operation2", null, "web", (short) 200,
+ false, true, "xyzzy", schema, ["quux"] as String[], null, null, null, 0L)
+ def entry2 = new AggregateEntry(snap2, AggregateEntry.hashOf(snap2))
+ [1, 1, 200, 2, 3, 4, 5, 6, 7, 8].each { entry2.recordOneDuration(it as long) }
+ writer.add(entry2)
writer.finishBucket()
then:
diff --git a/internal-api/src/jmh/java/datadog/trace/util/concurrent/MpscRingBufferBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/concurrent/MpscRingBufferBenchmark.java
new file mode 100644
index 00000000000..e9d084f3334
--- /dev/null
+++ b/internal-api/src/jmh/java/datadog/trace/util/concurrent/MpscRingBufferBenchmark.java
@@ -0,0 +1,146 @@
+package datadog.trace.util.concurrent;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import java.util.function.BiConsumer;
+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.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+/**
+ * Throughput benchmarks for {@link MpscRingBuffer}.
+ *
+ *
+ * - {@code write_1p / write_8p / write_16p} — producer-side throughput with a background
+ * drainer consuming what's published. Measures the cost of one {@code tryWrite} including CAS
+ * contention on the producer cursor at the given thread count.
+ *
- {@code e2e_8p} — JMH {@code @Group} pairing 8 producers with 1 consumer. Aggregate
+ * throughput reflects whichever side is the bottleneck under realistic pressure.
+ *
+ *
+ * Run with {@code -p capacity=...} to override the default ring capacity.
+ */
+@State(Scope.Benchmark)
+@Warmup(iterations = 2, time = 15, timeUnit = SECONDS)
+@Measurement(iterations = 5, time = 15, timeUnit = SECONDS)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(SECONDS)
+@Fork(value = 1)
+public class MpscRingBufferBenchmark {
+
+ /**
+ * Static filler so the lambda is non-capturing and the JIT can hoist it past the {@code tryWrite}
+ * call. Context arg comes first, slot last — matches {@code TagMap.forEach} convention.
+ */
+ private static final BiConsumer FILLER = (v, slot) -> slot.value = v;
+
+ /** Mutable slot. Replicates the per-publish allocation a real producer wants to avoid. */
+ public static final class Slot {
+ long value;
+ }
+
+ @Param({"1024"})
+ public int capacity;
+
+ /**
+ * Shared ring for the {@code write_*} benches. A background drainer keeps space available so
+ * producer benchmarks measure write throughput rather than full-ring drop throughput.
+ */
+ MpscRingBuffer ring;
+
+ private volatile boolean stopDrainer;
+ private Thread drainerThread;
+
+ /**
+ * Separate ring for the {@code e2e_*} group benches. JMH drives both sides directly so we don't
+ * want our own background drainer for those.
+ */
+ MpscRingBuffer e2eRing;
+
+ @Setup(Level.Trial)
+ public void setup() {
+ ring = new MpscRingBuffer<>(Slot::new, capacity);
+ e2eRing = new MpscRingBuffer<>(Slot::new, capacity);
+ stopDrainer = false;
+ drainerThread =
+ new Thread(
+ () -> {
+ while (!stopDrainer) {
+ if (ring.drain((Slot s) -> {}) == 0) Thread.yield();
+ }
+ },
+ "MpscRingBufferBenchmark-drainer");
+ drainerThread.setDaemon(true);
+ drainerThread.start();
+ }
+
+ @TearDown(Level.Trial)
+ public void teardown() throws InterruptedException {
+ stopDrainer = true;
+ drainerThread.join(5_000);
+ }
+
+ @State(Scope.Thread)
+ public static class ThreadState {
+ long counter;
+ }
+
+ // ============ Write throughput with background drainer ============
+
+ @Threads(1)
+ @Benchmark
+ public boolean write_1p(ThreadState ts) {
+ return ring.tryWrite(ts.counter++, FILLER);
+ }
+
+ @Threads(8)
+ @Benchmark
+ public boolean write_8p(ThreadState ts) {
+ return ring.tryWrite(ts.counter++, FILLER);
+ }
+
+ @Threads(16)
+ @Benchmark
+ public boolean write_16p(ThreadState ts) {
+ return ring.tryWrite(ts.counter++, FILLER);
+ }
+
+ // ============ End-to-end producer/consumer pairing ============
+ //
+ // JMH runs both methods in the same trial: 8 producer threads + 1 consumer thread. Throughput
+ // is reported as ops/sec aggregated across all 9 threads, but the consumer's drain count
+ // dwarfs the producer ops since one call processes many slots -- in practice the bottleneck
+ // is the producer side (CAS contention), and that's what the number reflects.
+
+ private static final BiConsumer CONSUMER = (bh, slot) -> bh.consume(slot.value);
+
+ @Group("e2e_8p")
+ @GroupThreads(8)
+ @Benchmark
+ public boolean e2e_producer(ThreadState ts) {
+ return e2eRing.tryWrite(ts.counter++, FILLER);
+ }
+
+ @Group("e2e_8p")
+ @GroupThreads(1)
+ @Benchmark
+ public int e2e_consumer(Blackhole bh) {
+ int drained = e2eRing.drain(bh, CONSUMER);
+ if (drained == 0) Thread.yield();
+ return drained;
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java
index 07f10672273..b90fcb0fd87 100644
--- a/internal-api/src/main/java/datadog/trace/api/Config.java
+++ b/internal-api/src/main/java/datadog/trace/api/Config.java
@@ -2184,7 +2184,23 @@ private Config(final ConfigProvider configProvider, final InstrumenterConfig ins
configProvider.getBoolean(TRACE_STATS_COMPUTATION_IGNORE_AGENT_VERSION, false);
tracerMetricsBufferingEnabled =
configProvider.getBoolean(TRACER_METRICS_BUFFERING_ENABLED, false);
- tracerMetricsMaxAggregates = configProvider.getInteger(TRACER_METRICS_MAX_AGGREGATES, 2048);
+ // The MpscRingBuffer pre-allocates one SpanSnapshot per slot at construction, so capacity
+ // becomes a resident-RSS cost. The old TRACER_METRICS_MAX_PENDING default of 2048 (logical)
+ // was sized for the previous on-demand batch-allocation model; with the ring it would pin
+ // ~15 MB upfront for what only needs to absorb sub-second consumer stalls. Cut the default
+ // accordingly:
+ // - normal heap: 128 logical * 64 LEGACY_BATCH_SIZE = 8192 slots, ~1 MB upfront, ~0.8 s
+ // of buffer at 10K spans/s. Plenty of margin for GC pauses.
+ // - tight heap (<128 MB): 64 logical * 64 = 4096 slots, ~500 KB upfront. Observed
+ // catastrophic at Xmx64m with the prior 131072-slot default (Full-GC death spiral).
+ // Customers who explicitly configured TRACER_METRICS_MAX_PENDING keep their value (the
+ // LEGACY_BATCH_SIZE multiplier still applies to it) -- only the implicit default shrinks.
+ final boolean tightHeap = Runtime.getRuntime().maxMemory() < 128L * 1024 * 1024;
+ final int defaultMaxAggregates = tightHeap ? 256 : 2048;
+ final int defaultMaxPending = tightHeap ? 64 : 128;
+
+ tracerMetricsMaxAggregates =
+ configProvider.getInteger(TRACER_METRICS_MAX_AGGREGATES, defaultMaxAggregates);
/*
* TRACER_METRICS_MAX_PENDING historically counted conflating Batch slots (~64 spans per batch
* via Batch.MAX_BATCH_SIZE). The inbox now holds 1 SpanSnapshot per metrics-eligible span, so
@@ -2199,7 +2215,8 @@ private Config(final ConfigProvider configProvider, final InstrumenterConfig ins
* see java.util.ArraysSupport.SOFT_MAX_ARRAY_LENGTH for the same convention.
*/
long requestedMaxPending =
- (long) configProvider.getInteger(TRACER_METRICS_MAX_PENDING, 2048) * LEGACY_BATCH_SIZE;
+ (long) configProvider.getInteger(TRACER_METRICS_MAX_PENDING, defaultMaxPending)
+ * LEGACY_BATCH_SIZE;
tracerMetricsMaxPending = (int) Math.min(requestedMaxPending, MAX_SAFE_ARRAY_SIZE);
reportHostName =
diff --git a/internal-api/src/main/java/datadog/trace/util/Hashtable.java b/internal-api/src/main/java/datadog/trace/util/Hashtable.java
index 8f40e4609bc..ff3202c1f33 100644
--- a/internal-api/src/main/java/datadog/trace/util/Hashtable.java
+++ b/internal-api/src/main/java/datadog/trace/util/Hashtable.java
@@ -482,7 +482,24 @@ MutatingBucketIterator mutatingBucketIterator(
*/
public static final
MutatingTableIterator mutatingTableIterator(Hashtable.Entry[] buckets) {
- return new MutatingTableIterator(buckets);
+ return new MutatingTableIterator(buckets, 0, buckets.length);
+ }
+
+ /**
+ * Variant of {@link #mutatingTableIterator(Hashtable.Entry[])} that walks only the half-open
+ * bucket range {@code [startBucket, endBucket)}. Useful for resumable sweeps -- e.g. cursor-
+ * based eviction in {@code AggregateTable} -- where one call drives {@code [cursor, length)}
+ * and a wrap-around call drives {@code [0, cursor)}. The iterator does not wrap around
+ * within a single instance; callers compose two iterators when wrap-around is desired. An empty
+ * range ({@code startBucket == endBucket}) produces an immediately exhausted iterator.
+ *
+ * @param startBucket inclusive lower bound; must be in {@code [0, buckets.length]}.
+ * @param endBucket exclusive upper bound; must be in {@code [startBucket, buckets.length]}.
+ */
+ public static final
+ MutatingTableIterator mutatingTableIterator(
+ Hashtable.Entry[] buckets, int startBucket, int endBucket) {
+ return new MutatingTableIterator(buckets, startBucket, endBucket);
}
public static final int bucketIndex(Object[] buckets, long keyHash) {
@@ -752,6 +769,9 @@ public static final class MutatingTableIterator
implements Iterator {
private final Hashtable.Entry[] buckets;
+ /** Exclusive upper bound for bucket indices visited by this iterator. */
+ private final int endBucket;
+
/**
* Index of the bucket holding {@link #nextEntry} (or holding {@link #curEntry} after remove).
*/
@@ -782,9 +802,34 @@ public static final class MutatingTableIterator
*/
private Hashtable.Entry curEntry;
- MutatingTableIterator(Hashtable.Entry[] buckets) {
+ MutatingTableIterator(Hashtable.Entry[] buckets, int startBucket, int endBucket) {
this.buckets = buckets;
- seekFromBucket(0);
+ if (startBucket < 0 || startBucket > buckets.length) {
+ throw new IndexOutOfBoundsException(
+ "startBucket " + startBucket + " out of range [0, " + buckets.length + "]");
+ }
+ if (endBucket < startBucket || endBucket > buckets.length) {
+ throw new IndexOutOfBoundsException(
+ "endBucket "
+ + endBucket
+ + " out of range ["
+ + startBucket
+ + ", "
+ + buckets.length
+ + "]");
+ }
+ this.endBucket = endBucket;
+ seekFromBucket(startBucket);
+ }
+
+ /**
+ * Bucket index of the entry last returned by {@link #next()}, or {@code -1} if {@code next} has
+ * not yet been called or the most recent call was {@link #remove()}. Useful for callers driving
+ * a cursor — e.g. resumable eviction sweeps that want to remember where the last successful
+ * removal landed.
+ */
+ public int currentBucket() {
+ return this.curBucketIndex;
}
@Override
@@ -841,12 +886,12 @@ public void remove() {
}
/**
- * Advance {@code nextBucketIndex} / {@code nextEntry} to the first non-empty bucket >= {@code
- * from}.
+ * Advance {@code nextBucketIndex} / {@code nextEntry} to the first non-empty bucket {@code >=
+ * from} within {@code [0, endBucket)}.
*/
private void seekFromBucket(int from) {
Hashtable.Entry[] thisBuckets = this.buckets;
- for (int i = from; i < thisBuckets.length; i++) {
+ for (int i = from; i < this.endBucket; i++) {
Hashtable.Entry head = thisBuckets[i];
if (head != null) {
this.nextBucketIndex = i;
diff --git a/internal-api/src/main/java/datadog/trace/util/concurrent/MpscRingBuffer.java b/internal-api/src/main/java/datadog/trace/util/concurrent/MpscRingBuffer.java
new file mode 100644
index 00000000000..4cefa43cac0
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/util/concurrent/MpscRingBuffer.java
@@ -0,0 +1,434 @@
+package datadog.trace.util.concurrent;
+
+import datadog.trace.api.function.TriConsumer;
+import java.util.concurrent.atomic.AtomicLongArray;
+import java.util.concurrent.atomic.AtomicLongFieldUpdater;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+// Disruptor-style cache-line padding around the cursors. The two cursors live on different cache
+// lines so consumer-side writes to consumerCursor don't invalidate the line producers read for
+// producerCursor (and vice versa). Each padding class declares 7 longs (56 bytes); combined with
+// the cursor's own 8 bytes plus the JVM object header, each cursor + its surrounding pad fills
+// a 64-byte cache line. The HotSpot field-layout strategy preserves the declaration order across
+// the class hierarchy, so this pattern is reliable on all production JVMs we target.
+
+abstract class MpscRingBufferPad0 {
+ long p01, p02, p03, p04, p05, p06, p07;
+}
+
+abstract class MpscRingBufferProducerCursor extends MpscRingBufferPad0 {
+ /** Next sequence to claim. Producers increment via CAS through {@code PRODUCER_CURSOR}. */
+ volatile long producerCursor = -1L;
+}
+
+abstract class MpscRingBufferPad1 extends MpscRingBufferProducerCursor {
+ long p11, p12, p13, p14, p15, p16, p17;
+}
+
+abstract class MpscRingBufferConsumerCursor extends MpscRingBufferPad1 {
+ /** Highest sequence consumed. Only the consumer thread writes; producers read volatile. */
+ volatile long consumerCursor = -1L;
+}
+
+abstract class MpscRingBufferPad2 extends MpscRingBufferConsumerCursor {
+ long p21, p22, p23, p24, p25, p26, p27;
+}
+
+/**
+ * Bounded multi-producer / single-consumer ring buffer of pre-allocated {@code T} instances.
+ *
+ * Each slot is a long-lived {@code T} instance created at construction time via the supplied
+ * factory and recycled forever. Producers mutate slots in place via callbacks; the consumer reads
+ * them the same way. No allocation occurs per write/read after construction, which is the entire
+ * point of this class over a queue of references.
+ *
+ *
The {@code BiConsumer} and {@link TriConsumer} variants take their context object(s)
+ * before the slot, matching the convention of {@code TagMap.forEach} and {@code
+ * Hashtable.forEach}. That ordering lets callers declare the callback as a {@code static final}
+ * non-capturing lambda and pass per-call context at the call site without allocating a closure.
+ *
+ *
Thread safety contract
+ *
+ * The ring buffer is thread-safe for any number of producer threads plus exactly one consumer
+ * thread. Calling {@code drain} from multiple threads concurrently is not supported and will
+ * corrupt state.
+ *
+ *
For the slot type {@code T}:
+ *
+ *
+ * - Slot fields can be plain ({@code int}, {@code long}, object references) -- they do
+ * not need to be {@code volatile} or guarded by synchronization. Happens-before
+ * between the producer's slot mutation and the consumer's slot read is provided by the ring's
+ * internal publication-sequence machinery: a release write on the per-slot sequence inside
+ * {@code tryWrite}, paired with an acquire read inside {@code drain}.
+ *
- Don't retain slot references past your handler's return. Once a {@code tryWrite}
+ * filler returns, the slot becomes visible to the consumer; once a {@code drain} handler
+ * returns, the slot may be reclaimed by another producer and its fields overwritten. If the
+ * consumer needs to keep any state from a slot, it must extract by value (or copy references)
+ * before returning.
+ *
- Don't expose slot references outside the ring. Treat {@code T} as ring-buffer-owned;
+ * sharing a slot reference with code that doesn't follow the same discipline breaks the
+ * happens-before story.
+ *
+ *
+ * For producer fillers:
+ *
+ *
+ * - Filler invocations on the same slot are serialized (one producer wins the sequence CAS), so
+ * the filler can write fields without synchronization.
+ *
- If a filler throws, the slot is published anyway (with whatever the filler had
+ * written so far) and the exception propagates to the caller. This prevents the consumer from
+ * getting stuck waiting for an unfinished slot; the cost is that the consumer may observe a
+ * partially-filled or stale-fielded slot. Fillers should be written to either not throw or to
+ * leave the slot in a state the consumer can recognize and skip.
+ *
+ *
+ * Implementation
+ *
+ * Producer cursor is CAS-claimed; visibility of a claimed slot to the consumer is gated by a
+ * per-slot publication-sequence array. Consumer cursor is updated with a volatile write so
+ * producers observe space being freed. Cursors are cache-line-padded against each other (see the
+ * {@code MpscRingBufferPad*} hierarchy at the top of this file) and the publication-sequence array
+ * is strided so each logical entry occupies a distinct cache line.
+ */
+public final class MpscRingBuffer extends MpscRingBufferPad2 {
+
+ /**
+ * Cache line size in {@code long}-units. 64-byte cache lines on every common CPU we ship to (x86,
+ * ARM); 8 bytes per long. Each logical slot in {@link #publishedSequences} is spread out by this
+ * stride so adjacent logical sequences don't share a cache line and don't ping-pong between
+ * producer cores under heavy contention.
+ */
+ private static final int CACHE_LINE_LONGS = 8;
+
+ @SuppressWarnings("rawtypes") // AtomicLongFieldUpdater can't take a parameterized class
+ private static final AtomicLongFieldUpdater PRODUCER_CURSOR =
+ AtomicLongFieldUpdater.newUpdater(MpscRingBufferProducerCursor.class, "producerCursor");
+
+ private final T[] slots;
+
+ /**
+ * Per-slot publication sequence, strided by {@link #CACHE_LINE_LONGS} to avoid false sharing.
+ * Producers write the claimed sequence here as the last step of a publish (release write via
+ * {@link AtomicLongArray#set}); the consumer reads it (acquire read) to determine whether the
+ * slot at the next position is ready. A slot is considered published for sequence {@code s} iff
+ * {@code publishedSequences[(s & mask) * CACHE_LINE_LONGS] == s}.
+ */
+ private final AtomicLongArray publishedSequences;
+
+ private final int capacity;
+ private final int mask;
+
+ @SuppressWarnings("unchecked")
+ public MpscRingBuffer(final Supplier factory, final int capacityHint) {
+ if (capacityHint < 1) {
+ throw new IllegalArgumentException("capacityHint must be >= 1, got " + capacityHint);
+ }
+ this.capacity = nextPowerOfTwo(capacityHint);
+ if (this.capacity < 1) {
+ throw new IllegalArgumentException("capacity overflow for hint " + capacityHint);
+ }
+ this.mask = capacity - 1;
+ this.slots = (T[]) new Object[capacity];
+ this.publishedSequences = new AtomicLongArray(capacity * CACHE_LINE_LONGS);
+ for (int i = 0; i < capacity; i++) {
+ slots[i] = factory.get();
+ // Initial: sentinel "no sequence published here yet" -- anything < 0 works since
+ // sequences are 0-based and monotonically increasing.
+ publishedSequences.set(i * CACHE_LINE_LONGS, Long.MIN_VALUE);
+ }
+ }
+
+ public int capacity() {
+ return capacity;
+ }
+
+ /** Approximate count of slots holding unread items. May briefly exceed capacity under race. */
+ public int size() {
+ final long p = producerCursor;
+ final long c = consumerCursor;
+ final long diff = p - c;
+ if (diff <= 0) return 0;
+ if (diff > capacity) return capacity;
+ return (int) diff;
+ }
+
+ public boolean isEmpty() {
+ return producerCursor == consumerCursor;
+ }
+
+ /** {@code true} if the slot was filled and published; {@code false} if the ring is full. */
+ public boolean tryWrite(final Consumer super T> filler) {
+ final long seq = claim();
+ if (seq < 0L) return false;
+ // publish in finally so a throwing filler doesn't leave the slot un-published -- the
+ // consumer would otherwise wait at that sequence forever. See class javadoc.
+ try {
+ filler.accept(slots[(int) (seq & mask)]);
+ } finally {
+ publish(seq);
+ }
+ return true;
+ }
+
+ public boolean tryWrite(final C context, final BiConsumer super C, ? super T> filler) {
+ final long seq = claim();
+ if (seq < 0L) return false;
+ try {
+ filler.accept(context, slots[(int) (seq & mask)]);
+ } finally {
+ publish(seq);
+ }
+ return true;
+ }
+
+ public boolean tryWrite(
+ final C1 context1,
+ final C2 context2,
+ final TriConsumer super C1, ? super C2, ? super T> filler) {
+ final long seq = claim();
+ if (seq < 0L) return false;
+ try {
+ filler.accept(context1, context2, slots[(int) (seq & mask)]);
+ } finally {
+ publish(seq);
+ }
+ return true;
+ }
+
+ /** Drains all currently-available slots. Returns the count processed. */
+ public int drain(final Consumer super T> handler) {
+ long cursor = consumerCursor;
+ int count = 0;
+ while (true) {
+ final long nextSeq = cursor + 1L;
+ final int idx = (int) (nextSeq & mask);
+ if (publishedSequences.get(idx * CACHE_LINE_LONGS) != nextSeq) break;
+ handler.accept(slots[idx]);
+ cursor = nextSeq;
+ count++;
+ }
+ if (count > 0) consumerCursor = cursor;
+ return count;
+ }
+
+ public int drain(final C context, final BiConsumer super C, ? super T> handler) {
+ long cursor = consumerCursor;
+ int count = 0;
+ while (true) {
+ final long nextSeq = cursor + 1L;
+ final int idx = (int) (nextSeq & mask);
+ if (publishedSequences.get(idx * CACHE_LINE_LONGS) != nextSeq) break;
+ handler.accept(context, slots[idx]);
+ cursor = nextSeq;
+ count++;
+ }
+ if (count > 0) consumerCursor = cursor;
+ return count;
+ }
+
+ public int drain(
+ final C1 context1,
+ final C2 context2,
+ final TriConsumer super C1, ? super C2, ? super T> handler) {
+ long cursor = consumerCursor;
+ int count = 0;
+ while (true) {
+ final long nextSeq = cursor + 1L;
+ final int idx = (int) (nextSeq & mask);
+ if (publishedSequences.get(idx * CACHE_LINE_LONGS) != nextSeq) break;
+ handler.accept(context1, context2, slots[idx]);
+ cursor = nextSeq;
+ count++;
+ }
+ if (count > 0) consumerCursor = cursor;
+ return count;
+ }
+
+ /**
+ * Try to claim a contiguous range of {@code n} sequences in a single CAS. Returns {@code null} if
+ * the ring doesn't have room for the whole batch -- the caller treats that as "drop all {@code
+ * n}", which is the natural shape for callers that batch by a higher-level unit (e.g. one CSS
+ * publish per completed trace). When the caller has a list of N items to write, this amortizes
+ * producer-cursor contention from O(N) CASes to O(1) per call.
+ *
+ * The returned {@link Batch} must be filled via {@link Batch#fillAndPublish} exactly {@code n}
+ * times. Under-publishing leaves the ring stuck at the unfilled sequence -- the consumer waits
+ * there forever. Over-publishing throws {@link IllegalStateException}.
+ *
+ * @throws IllegalArgumentException if {@code n < 1}
+ */
+ public Batch tryClaim(final int n) {
+ if (n < 1) {
+ throw new IllegalArgumentException("n must be >= 1, got " + n);
+ }
+ while (true) {
+ final long current = producerCursor;
+ // Stale read of consumerCursor is fine: a false "full" reading just causes a drop, and a
+ // real one is correctly identified because consumerCursor only advances.
+ final long consumed = consumerCursor;
+ final long next = current + n;
+ if (next - consumed > capacity) {
+ return null;
+ }
+ if (PRODUCER_CURSOR.compareAndSet(this, current, next)) {
+ // Claimed sequences [current + 1, next] inclusive (== n sequences total).
+ return new Batch(current + 1L, n);
+ }
+ // CAS failure -> another producer claimed; retry.
+ }
+ }
+
+ /** CAS-claim the next sequence, or return {@code -1} if the ring is full. */
+ private long claim() {
+ while (true) {
+ final long current = producerCursor;
+ // Stale read of consumerCursor is fine: a false "full" reading just causes a drop, and
+ // a real "full" reading is correctly identified because consumerCursor only advances.
+ final long consumed = consumerCursor;
+ if (current - consumed >= capacity) {
+ return -1L;
+ }
+ final long next = current + 1L;
+ if (PRODUCER_CURSOR.compareAndSet(this, current, next)) {
+ return next;
+ }
+ // CAS failure -> another producer claimed; retry.
+ }
+ }
+
+ /**
+ * Handle returned by {@link MpscRingBuffer#tryClaim}. Holds a contiguous range of pre-claimed
+ * sequences belonging to the producer thread that called {@code tryClaim}; the caller must fill
+ * and publish each via {@link #fillAndPublish}.
+ *
+ *
Not thread-safe -- the producer thread owns it for the lifetime of the call. Do not
+ * share across threads.
+ */
+ public final class Batch {
+ private final long startSeq;
+ private final int size;
+ private int published;
+
+ Batch(final long startSeq, final int size) {
+ this.startSeq = startSeq;
+ this.size = size;
+ }
+
+ /** Total slots in this batch (the {@code n} passed to {@code tryClaim}). */
+ public int size() {
+ return size;
+ }
+
+ /** Slots not yet filled. */
+ public int remaining() {
+ return size - published;
+ }
+
+ public void fillAndPublish(final Consumer super T> filler) {
+ final long seq = nextSeq();
+ final int idx = (int) (seq & mask);
+ try {
+ filler.accept(slots[idx]);
+ } finally {
+ publishedSequences.set(idx * CACHE_LINE_LONGS, seq);
+ }
+ }
+
+ public void fillAndPublish(final C context, final BiConsumer super C, ? super T> filler) {
+ final long seq = nextSeq();
+ final int idx = (int) (seq & mask);
+ try {
+ filler.accept(context, slots[idx]);
+ } finally {
+ publishedSequences.set(idx * CACHE_LINE_LONGS, seq);
+ }
+ }
+
+ public void fillAndPublish(
+ final C1 context1,
+ final C2 context2,
+ final TriConsumer super C1, ? super C2, ? super T> filler) {
+ final long seq = nextSeq();
+ final int idx = (int) (seq & mask);
+ try {
+ filler.accept(context1, context2, slots[idx]);
+ } finally {
+ publishedSequences.set(idx * CACHE_LINE_LONGS, seq);
+ }
+ }
+
+ private long nextSeq() {
+ if (published >= size) {
+ throw new IllegalStateException(
+ "Batch over-published: size=" + size + " published=" + published);
+ }
+ final long seq = startSeq + published;
+ published++;
+ return seq;
+ }
+ }
+
+ // ============ Low-level primitives ============
+ //
+ // These three methods (tryClaimRange / slotAt / publish) expose the producer-side machinery
+ // directly. Callers manage the claimed sequence range themselves -- no per-call handle
+ // allocated, no callback dispatched. This is intended for hot paths where the higher-level
+ // tryWrite/tryClaim+Batch APIs would allocate per call (Iterator on the iterable being
+ // batched, or the Batch object itself) that escape analysis doesn't eliminate.
+ //
+ // Misuse hazards: every claimed sequence MUST be published exactly once; sequences must be
+ // published in [start, start+n) range only; the slot returned by slotAt() must not escape past
+ // the next publish() of any sequence (the producer "owns" the slot until publish).
+
+ /**
+ * Try to claim a contiguous range of {@code n} sequences in a single CAS. Returns the
+ * start sequence on success (a non-negative long) or {@code -1L} if the ring doesn't have
+ * room for the whole batch. The caller must publish each sequence in {@code [start, start + n)}
+ * exactly once via {@link #publish(long)}.
+ *
+ * @throws IllegalArgumentException if {@code n < 1}
+ */
+ public long tryClaimRange(final int n) {
+ if (n < 1) {
+ throw new IllegalArgumentException("n must be >= 1, got " + n);
+ }
+ while (true) {
+ final long current = producerCursor;
+ final long consumed = consumerCursor;
+ final long next = current + n;
+ if (next - consumed > capacity) {
+ return -1L;
+ }
+ if (PRODUCER_CURSOR.compareAndSet(this, current, next)) {
+ return current + 1L;
+ }
+ }
+ }
+
+ /**
+ * Slot for sequence {@code seq}. Only safe when {@code seq} is in a range currently owned by the
+ * caller (claimed via {@link #tryClaimRange} and not yet published).
+ */
+ public T slotAt(final long seq) {
+ return slots[(int) (seq & mask)];
+ }
+
+ /**
+ * Publish sequence {@code seq}, making the slot at {@code slotAt(seq)} visible to the consumer.
+ * Release semantics via {@link AtomicLongArray#set}.
+ */
+ public void publish(final long seq) {
+ publishedSequences.set(((int) (seq & mask)) * CACHE_LINE_LONGS, seq);
+ }
+
+ private static int nextPowerOfTwo(final int n) {
+ if (n <= 1) return 1;
+ // 32 - leadingZeros(n-1) gives the number of bits needed to represent n-1; shifting 1 by that
+ // gives the smallest power of two >= n.
+ final int bits = 32 - Integer.numberOfLeadingZeros(n - 1);
+ return 1 << bits;
+ }
+}
diff --git a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java
index 2992279be6d..953453ca3aa 100644
--- a/internal-api/src/test/java/datadog/trace/util/HashtableTest.java
+++ b/internal-api/src/test/java/datadog/trace/util/HashtableTest.java
@@ -349,5 +349,64 @@ void removeTwiceWithoutInterveningNextThrows() {
it.remove();
assertThrows(IllegalStateException.class, it::remove);
}
+
+ @Test
+ void halfOpenRangeOmitsBucketsOutsideTheRange() {
+ // CollidingKey lets us pin entries to specific buckets via controlled hashCode. 16-slot
+ // table -> bucketIndex = hash & 15. Place entries in buckets 0, 5, and 10; iterate
+ // [5, 10) -- should see only bucket 5.
+ Hashtable.D1 table = new Hashtable.D1<>(16);
+ table.insert(new CollidingKeyEntry(new CollidingKey("b0", 0), 1));
+ table.insert(new CollidingKeyEntry(new CollidingKey("b5", 5), 2));
+ table.insert(new CollidingKeyEntry(new CollidingKey("b10", 10), 3));
+
+ Set seen = new HashSet<>();
+ for (MutatingTableIterator it =
+ Support.mutatingTableIterator(table.buckets, 5, 10);
+ it.hasNext(); ) {
+ seen.add(it.next().key.label);
+ }
+ assertEquals(1, seen.size());
+ assertTrue(seen.contains("b5"));
+ }
+
+ @Test
+ void emptyHalfOpenRangeIsExhausted() {
+ // start == end -> immediately-exhausted iterator. Important: this is the wrap-around
+ // pass [0, cursor) when cursor == 0 in resumable sweeps.
+ Hashtable.D1 table = new Hashtable.D1<>(8);
+ table.insert(new StringIntEntry("a", 1));
+ MutatingTableIterator it = Support.mutatingTableIterator(table.buckets, 0, 0);
+ assertFalse(it.hasNext());
+ }
+
+ @Test
+ void rangeBoundsOutOfOrderThrows() {
+ Hashtable.D1 table = new Hashtable.D1<>(8);
+ assertThrows(
+ IndexOutOfBoundsException.class,
+ () -> Support.mutatingTableIterator(table.buckets, -1, 4));
+ assertThrows(
+ IndexOutOfBoundsException.class,
+ () -> Support.mutatingTableIterator(table.buckets, 4, 2)); // end < start
+ assertThrows(
+ IndexOutOfBoundsException.class,
+ () ->
+ Support.mutatingTableIterator(
+ table.buckets, 0, table.buckets.length + 1)); // end > len
+ }
+
+ @Test
+ void currentBucketReportsLandingIndex() {
+ // Pin one entry to a known bucket and check currentBucket() after next() reports that
+ // bucket. Before any next() (or after remove()), currentBucket() returns -1.
+ Hashtable.D1 table = new Hashtable.D1<>(16);
+ table.insert(new CollidingKeyEntry(new CollidingKey("b3", 3), 1));
+
+ MutatingTableIterator it = Support.mutatingTableIterator(table.buckets);
+ assertEquals(-1, it.currentBucket(), "before any next() currentBucket should be -1");
+ it.next();
+ assertEquals(3, it.currentBucket(), "currentBucket should report the entry's bucket");
+ }
}
}
diff --git a/internal-api/src/test/java/datadog/trace/util/concurrent/MpscRingBufferTest.java b/internal-api/src/test/java/datadog/trace/util/concurrent/MpscRingBufferTest.java
new file mode 100644
index 00000000000..6aeb635abd0
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/util/concurrent/MpscRingBufferTest.java
@@ -0,0 +1,498 @@
+package datadog.trace.util.concurrent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import datadog.trace.api.function.TriConsumer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BiConsumer;
+import org.junit.jupiter.api.Test;
+
+class MpscRingBufferTest {
+
+ /** Mutable slot used by the tests; replaces the per-publish allocation a real consumer avoids. */
+ static final class Slot {
+ int value;
+ String tag;
+ }
+
+ // ============ Construction ============
+
+ @Test
+ void capacityRoundsUpToPowerOfTwo() {
+ assertEquals(1, new MpscRingBuffer<>(Slot::new, 1).capacity());
+ assertEquals(2, new MpscRingBuffer<>(Slot::new, 2).capacity());
+ assertEquals(4, new MpscRingBuffer<>(Slot::new, 3).capacity());
+ assertEquals(16, new MpscRingBuffer<>(Slot::new, 10).capacity());
+ assertEquals(1024, new MpscRingBuffer<>(Slot::new, 1024).capacity());
+ assertEquals(2048, new MpscRingBuffer<>(Slot::new, 1025).capacity());
+ }
+
+ @Test
+ void rejectsNonPositiveCapacityHint() {
+ assertThrows(IllegalArgumentException.class, () -> new MpscRingBuffer<>(Slot::new, 0));
+ assertThrows(IllegalArgumentException.class, () -> new MpscRingBuffer<>(Slot::new, -1));
+ }
+
+ @Test
+ void slotsArePreAllocatedFromFactory() {
+ AtomicInteger calls = new AtomicInteger();
+ MpscRingBuffer ring =
+ new MpscRingBuffer<>(
+ () -> {
+ calls.incrementAndGet();
+ return new Slot();
+ },
+ 8);
+ assertEquals(8, ring.capacity());
+ assertEquals(8, calls.get(), "factory must run capacity times during construction");
+ }
+
+ // ============ Basic produce / consume ============
+
+ @Test
+ void emptyRingIsEmpty() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 4);
+ assertTrue(ring.isEmpty());
+ assertEquals(0, ring.size());
+ assertEquals(0, ring.drain(s -> {}));
+ }
+
+ @Test
+ void singleWriteThenDrain() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 4);
+ assertTrue(
+ ring.tryWrite(
+ s -> {
+ s.value = 42;
+ s.tag = "hello";
+ }));
+ assertEquals(1, ring.size());
+
+ List seen = new ArrayList<>();
+ int drained = ring.drain(s -> seen.add(s.value));
+ assertEquals(1, drained);
+ assertEquals(Arrays.asList(42), seen);
+ assertTrue(ring.isEmpty());
+ }
+
+ @Test
+ void writesPreserveFIFOOrder() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 8);
+ for (int i = 0; i < 6; i++) {
+ final int v = i;
+ assertTrue(ring.tryWrite(s -> s.value = v));
+ }
+ List seen = new ArrayList<>();
+ ring.drain(s -> seen.add(s.value));
+ assertEquals(Arrays.asList(0, 1, 2, 3, 4, 5), seen);
+ }
+
+ // ============ Full / drop behavior ============
+
+ @Test
+ void writesBeyondCapacityFail() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 4);
+ for (int i = 0; i < 4; i++) {
+ assertTrue(ring.tryWrite(s -> s.value = 0));
+ }
+ assertFalse(ring.tryWrite(s -> s.value = 0), "5th write must fail when capacity == 4");
+ assertEquals(4, ring.size());
+ }
+
+ @Test
+ void writesResumeAfterDrain() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 4);
+ for (int i = 0; i < 4; i++) {
+ final int v = i;
+ assertTrue(ring.tryWrite(s -> s.value = v));
+ }
+ assertFalse(ring.tryWrite(s -> {}));
+
+ AtomicInteger sum = new AtomicInteger();
+ ring.drain(s -> sum.addAndGet(s.value));
+ assertEquals(0 + 1 + 2 + 3, sum.get());
+
+ // Now should accept another full round.
+ for (int i = 100; i < 104; i++) {
+ final int v = i;
+ assertTrue(ring.tryWrite(s -> s.value = v));
+ }
+ assertFalse(ring.tryWrite(s -> {}));
+ }
+
+ // ============ Context-passing variants (context first, slot last) ============
+
+ @Test
+ void writeAndDrainWithOneContext() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 4);
+ BiConsumer filler = (ctx, s) -> s.value = ctx;
+ assertTrue(ring.tryWrite(7, filler));
+ assertTrue(ring.tryWrite(8, filler));
+
+ List seen = new ArrayList<>();
+ BiConsumer, Slot> reader = (sink, s) -> sink.add(s.value);
+ assertEquals(2, ring.drain(seen, reader));
+ assertEquals(Arrays.asList(7, 8), seen);
+ }
+
+ @Test
+ void writeAndDrainWithTwoContexts() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 4);
+ TriConsumer filler =
+ (v, t, s) -> {
+ s.value = v;
+ s.tag = t;
+ };
+ assertTrue(ring.tryWrite(1, "a", filler));
+ assertTrue(ring.tryWrite(2, "b", filler));
+
+ List tags = new ArrayList<>();
+ List vals = new ArrayList<>();
+ TriConsumer, List, Slot> reader =
+ (ts, vs, s) -> {
+ ts.add(s.tag);
+ vs.add(s.value);
+ };
+ assertEquals(2, ring.drain(tags, vals, reader));
+ assertEquals(Arrays.asList("a", "b"), tags);
+ assertEquals(Arrays.asList(1, 2), vals);
+ }
+
+ // ============ Concurrency ============
+
+ @Test
+ void manyProducersSingleConsumerSeesEveryWrittenValue() throws InterruptedException {
+ final int producers = 8;
+ final int perProducer = 50_000;
+ final int total = producers * perProducer;
+
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 1024);
+
+ ExecutorService producerPool = Executors.newFixedThreadPool(producers);
+ AtomicInteger dropped = new AtomicInteger();
+ AtomicInteger written = new AtomicInteger();
+ CountDownLatch start = new CountDownLatch(1);
+
+ BiConsumer filler = (v, s) -> s.value = v;
+
+ for (int p = 0; p < producers; p++) {
+ final int base = p * perProducer;
+ producerPool.submit(
+ () -> {
+ try {
+ start.await();
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ for (int i = 0; i < perProducer; i++) {
+ int v = base + i;
+ // Spin until the consumer makes room. We're testing correctness, not throughput.
+ while (!ring.tryWrite(v, filler)) {
+ dropped.incrementAndGet();
+ Thread.yield();
+ }
+ written.incrementAndGet();
+ }
+ });
+ }
+
+ Set seen = new HashSet<>(total);
+ BiConsumer, Slot> reader = (sink, s) -> sink.add(s.value);
+
+ Thread consumer =
+ new Thread(
+ () -> {
+ while (seen.size() < total) {
+ if (ring.drain(seen, reader) == 0) Thread.yield();
+ }
+ },
+ "ring-consumer");
+ consumer.start();
+
+ start.countDown();
+ producerPool.shutdown();
+ assertTrue(producerPool.awaitTermination(30, TimeUnit.SECONDS), "producers timed out");
+ assertTrue(consumer.isAlive() || seen.size() == total);
+ consumer.join(30_000);
+ assertFalse(consumer.isAlive(), "consumer timed out");
+
+ assertEquals(total, written.get(), "every producer call should eventually succeed");
+ assertEquals(total, seen.size(), "consumer must see every value exactly once");
+ }
+
+ @Test
+ void sizeReflectsOutstandingWrites() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 8);
+ assertEquals(0, ring.size());
+ ring.tryWrite(s -> {});
+ assertEquals(1, ring.size());
+ ring.tryWrite(s -> {});
+ assertEquals(2, ring.size());
+ ring.drain(s -> {});
+ assertEquals(0, ring.size());
+ }
+
+ @Test
+ void throwingFillerStillPublishesSoConsumerDoesntHang() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 4);
+
+ // First write succeeds.
+ assertTrue(ring.tryWrite(s -> s.value = 1));
+
+ // Second write throws midway through filling. Slot must still be published so the consumer's
+ // drain can advance past it.
+ RuntimeException boom = new RuntimeException("boom");
+ RuntimeException thrown =
+ assertThrows(
+ RuntimeException.class,
+ () ->
+ ring.tryWrite(
+ s -> {
+ s.value = 2;
+ throw boom;
+ }));
+ assertSame(boom, thrown);
+
+ // Third write proves the ring's cursors are still healthy after the throw.
+ assertTrue(ring.tryWrite(s -> s.value = 3));
+
+ // Consumer drains all three: it must not hang on the partially-filled slot.
+ List seen = new ArrayList<>();
+ int drained = ring.drain(s -> seen.add(s.value));
+ assertEquals(3, drained, "consumer must advance past the throwing slot");
+ assertEquals(Arrays.asList(1, 2, 3), seen, "throwing slot keeps whatever filler had written");
+ }
+
+ // ============ Batch claim (tryClaim) ============
+
+ @Test
+ void tryClaimReturnsBatchOfRequestedSize() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 8);
+ MpscRingBuffer.Batch batch = ring.tryClaim(3);
+
+ assertNotNull(batch);
+ assertEquals(3, batch.size());
+ assertEquals(3, batch.remaining());
+ }
+
+ @Test
+ void tryClaimRejectsZeroOrNegative() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 8);
+ assertThrows(IllegalArgumentException.class, () -> ring.tryClaim(0));
+ assertThrows(IllegalArgumentException.class, () -> ring.tryClaim(-1));
+ }
+
+ @Test
+ void tryClaimReturnsNullWhenRingCantFitBatch() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 4);
+ assertNotNull(ring.tryClaim(3));
+ // Only 1 slot left; claiming 2 must fail wholesale.
+ assertNull(ring.tryClaim(2), "all-or-nothing: partial batches are not allowed");
+ // But one more slot does fit.
+ assertNotNull(ring.tryClaim(1));
+ // Now full.
+ assertNull(ring.tryClaim(1));
+ }
+
+ @Test
+ void tryClaimFillAndPublishDeliversAllToDrain() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 8);
+ MpscRingBuffer.Batch batch = ring.tryClaim(5);
+
+ for (int i = 0; i < 5; i++) {
+ final int v = i;
+ batch.fillAndPublish(s -> s.value = v);
+ }
+ assertEquals(0, batch.remaining());
+
+ List seen = new ArrayList<>();
+ int drained = ring.drain(s -> seen.add(s.value));
+ assertEquals(5, drained);
+ assertEquals(Arrays.asList(0, 1, 2, 3, 4), seen, "batch publishes in order");
+ }
+
+ @Test
+ void overPublishingBatchThrows() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 8);
+ MpscRingBuffer.Batch batch = ring.tryClaim(2);
+
+ batch.fillAndPublish(s -> s.value = 1);
+ batch.fillAndPublish(s -> s.value = 2);
+ assertThrows(IllegalStateException.class, () -> batch.fillAndPublish(s -> s.value = 3));
+ }
+
+ @Test
+ void batchSupportsContextFillers() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 8);
+ MpscRingBuffer.Batch batch = ring.tryClaim(3);
+
+ BiConsumer oneCtx = (v, s) -> s.value = v;
+ TriConsumer twoCtx =
+ (v, t, s) -> {
+ s.value = v;
+ s.tag = t;
+ };
+
+ batch.fillAndPublish(s -> s.value = 1);
+ batch.fillAndPublish(2, oneCtx);
+ batch.fillAndPublish(3, "three", twoCtx);
+
+ List seen = new ArrayList<>();
+ ring.drain(s -> seen.add(s.value + "/" + s.tag));
+ assertEquals(Arrays.asList("1/null", "2/null", "3/three"), seen);
+ }
+
+ @Test
+ void batchFillerThrowStillPublishesAndAdvances() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 8);
+ MpscRingBuffer.Batch batch = ring.tryClaim(3);
+
+ batch.fillAndPublish(s -> s.value = 1);
+ RuntimeException boom = new RuntimeException("boom");
+ assertThrows(
+ RuntimeException.class,
+ () ->
+ batch.fillAndPublish(
+ s -> {
+ s.value = 2;
+ throw boom;
+ }));
+ // The throwing slot's sequence has already been consumed; published counter advanced.
+ assertEquals(1, batch.remaining(), "throwing slot still counts as published");
+ batch.fillAndPublish(s -> s.value = 3);
+
+ List seen = new ArrayList<>();
+ int drained = ring.drain(s -> seen.add(s.value));
+ assertEquals(3, drained);
+ assertEquals(Arrays.asList(1, 2, 3), seen);
+ }
+
+ // ============ Low-level primitives (tryClaimRange / slotAt / publish) ============
+
+ @Test
+ void tryClaimRangeReturnsStartSequence() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 8);
+ long start1 = ring.tryClaimRange(3);
+ long start2 = ring.tryClaimRange(2);
+
+ assertEquals(0L, start1, "first range starts at sequence 0");
+ assertEquals(3L, start2, "second range begins immediately after the first");
+ }
+
+ @Test
+ void tryClaimRangeRejectsZeroOrNegative() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 8);
+ assertThrows(IllegalArgumentException.class, () -> ring.tryClaimRange(0));
+ assertThrows(IllegalArgumentException.class, () -> ring.tryClaimRange(-1));
+ }
+
+ @Test
+ void tryClaimRangeReturnsMinusOneWhenFull() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 4);
+ assertTrue(ring.tryClaimRange(3) >= 0);
+ assertEquals(-1L, ring.tryClaimRange(2), "all-or-nothing");
+ assertTrue(ring.tryClaimRange(1) >= 0);
+ assertEquals(-1L, ring.tryClaimRange(1));
+ }
+
+ @Test
+ void slotAtAndPublishRoundTrip() {
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 8);
+ long start = ring.tryClaimRange(3);
+ assertTrue(start >= 0);
+
+ for (int i = 0; i < 3; i++) {
+ long seq = start + i;
+ Slot slot = ring.slotAt(seq);
+ slot.value = (int) (seq + 100);
+ ring.publish(seq);
+ }
+
+ List seen = new ArrayList<>();
+ int drained = ring.drain(s -> seen.add(s.value));
+ assertEquals(3, drained);
+ assertEquals(Arrays.asList(100, 101, 102), seen);
+ }
+
+ @Test
+ void slotAtReturnsSameInstanceForSameModuloPosition() {
+ // After publish+drain wraps around, the slot at sequence N and sequence N+capacity are the
+ // same physical object (this is the whole point of the ring).
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 4);
+ Slot firstSlot = ring.slotAt(0L);
+ Slot wrappedSlot = ring.slotAt(4L); // 4 & mask(3) == 0
+ assertSame(firstSlot, wrappedSlot);
+ }
+
+ @Test
+ void concurrentBatchClaimsAreOrderedAndDontInterleave() throws InterruptedException {
+ final int producers = 8;
+ final int batchesPerProducer = 200;
+ final int batchSize = 16;
+ final int total = producers * batchesPerProducer * batchSize;
+
+ MpscRingBuffer ring = new MpscRingBuffer<>(Slot::new, 256);
+ ExecutorService pool = Executors.newFixedThreadPool(producers);
+ AtomicInteger writes = new AtomicInteger();
+ CountDownLatch start = new CountDownLatch(1);
+
+ for (int p = 0; p < producers; p++) {
+ final int base = p * batchesPerProducer * batchSize;
+ pool.submit(
+ () -> {
+ try {
+ start.await();
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ for (int b = 0; b < batchesPerProducer; b++) {
+ MpscRingBuffer.Batch batch;
+ while ((batch = ring.tryClaim(batchSize)) == null) {
+ Thread.yield();
+ }
+ for (int i = 0; i < batchSize; i++) {
+ final int v = base + b * batchSize + i;
+ batch.fillAndPublish(s -> s.value = v);
+ }
+ writes.addAndGet(batchSize);
+ }
+ });
+ }
+
+ Set seen = new HashSet<>(total);
+ Thread consumer =
+ new Thread(
+ () -> {
+ while (seen.size() < total) {
+ if (ring.drain((Slot s) -> seen.add(s.value)) == 0) Thread.yield();
+ }
+ },
+ "ring-batch-consumer");
+ consumer.start();
+
+ start.countDown();
+ pool.shutdown();
+ assertTrue(pool.awaitTermination(30, TimeUnit.SECONDS), "producers timed out");
+ consumer.join(30_000);
+ assertFalse(consumer.isAlive(), "consumer timed out");
+ assertEquals(total, writes.get());
+ assertEquals(total, seen.size(), "consumer must see every value exactly once");
+ }
+}