From 5015d724e7c22caef7f7f421e5d2b3cd0d53e04d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 08:42:36 -0400 Subject: [PATCH 1/2] Add StatsDCounterKey/StatsDCountReporter glue for Accumulator Lets an enum used as an Accumulator key declare its own dogstatsd metric name, and drains an Accumulator.accumulateAndReset() result to StatsDClient in one call. No caller wired in yet -- TracerHealthMetrics migration is a follow-up PR. Co-Authored-By: Claude Sonnet 5 --- .../api/statsd/StatsDCountReporter.java | 28 +++++++++++++++++++ .../metrics/api/statsd/StatsDCounterKey.java | 22 +++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCountReporter.java create mode 100644 products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCounterKey.java diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCountReporter.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCountReporter.java new file mode 100644 index 00000000000..393abb91b85 --- /dev/null +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCountReporter.java @@ -0,0 +1,28 @@ +package datadog.metrics.api.statsd; + +/** + * Reports a drained {@code long[]} of counter values -- e.g. from {@code + * Accumulator.accumulateAndReset(data)} -- to a {@link StatsDClient}, one {@link + * StatsDClient#count} call per enum constant whose value changed. + * + * @see StatsDCounterKey + */ +public final class StatsDCountReporter { + private StatsDCountReporter() {} + + /** + * @param values the enum constants naming each counter, e.g. {@code MyCounters.values()} + * @param counts one value per constant, indexed by {@link Enum#ordinal()} -- e.g. the array + * returned by {@code Accumulator.accumulateAndReset(data)} + * @param tags tags applied uniformly to every reported counter + */ + public static & StatsDCounterKey> void report( + StatsDClient statsDClient, E[] values, long[] counts, String... tags) { + for (E value : values) { + long delta = counts[value.ordinal()]; + if (delta != 0) { + statsDClient.count(value.getMetricName(), delta, tags); + } + } + } +} diff --git a/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCounterKey.java b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCounterKey.java new file mode 100644 index 00000000000..03b1125ad9b --- /dev/null +++ b/products/metrics/metrics-api/src/main/java/datadog/metrics/api/statsd/StatsDCounterKey.java @@ -0,0 +1,22 @@ +package datadog.metrics.api.statsd; + +/** + * Lets an {@code enum} used as an {@code Accumulator} key declare its own dogstatsd metric name at + * the declaration site, so the schema and its reporting name can't drift apart. + * + *
{@code
+ * enum MyCounters implements StatsDCounterKey {
+ *   FOO("my.counters.foo"),
+ *   BAR("my.counters.bar");
+ *
+ *   private final String metricName;
+ *   MyCounters(String metricName) { this.metricName = metricName; }
+ *   @Override public String getMetricName() { return metricName; }
+ * }
+ * }
+ * + * @see StatsDCountReporter + */ +public interface StatsDCounterKey { + String getMetricName(); +} From 264a2ef4b7af7c65e85c3b5b427956a7a481b91e Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 1 Sep 2026 08:42:51 -0400 Subject: [PATCH 2/2] Add tests for StatsDCountReporter Adds JUnit5 test dependency to metrics-api (previously had none), a RecordingStatsDClient test fake, and coverage for non-zero/zero/ negative deltas and tag pass-through. Co-Authored-By: Claude Sonnet 5 --- products/metrics/metrics-api/build.gradle.kts | 2 + .../api/statsd/RecordingStatsDClient.java | 63 ++++++++++++++ .../api/statsd/StatsDCountReporterTest.java | 85 +++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/RecordingStatsDClient.java create mode 100644 products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDCountReporterTest.java diff --git a/products/metrics/metrics-api/build.gradle.kts b/products/metrics/metrics-api/build.gradle.kts index bc995a5c87d..24973772432 100644 --- a/products/metrics/metrics-api/build.gradle.kts +++ b/products/metrics/metrics-api/build.gradle.kts @@ -7,4 +7,6 @@ description = "Metrics API" dependencies { implementation(libs.slf4j) + + testImplementation(libs.bundles.junit5) } diff --git a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/RecordingStatsDClient.java b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/RecordingStatsDClient.java new file mode 100644 index 00000000000..84b96c6069e --- /dev/null +++ b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/RecordingStatsDClient.java @@ -0,0 +1,63 @@ +package datadog.metrics.api.statsd; + +import java.util.ArrayList; +import java.util.List; + +/** Test fake that records every {@link #count} call; every other method is a no-op. */ +final class RecordingStatsDClient implements StatsDClient { + + static final class Count { + final String metricName; + final long delta; + final String[] tags; + + Count(String metricName, long delta, String[] tags) { + this.metricName = metricName; + this.delta = delta; + this.tags = tags; + } + } + + final List counts = new ArrayList<>(); + + @Override + public void incrementCounter(String metricName, String... tags) {} + + @Override + public void count(String metricName, long delta, String... tags) { + counts.add(new Count(metricName, delta, tags)); + } + + @Override + public void gauge(String metricName, long value, String... tags) {} + + @Override + public void gauge(String metricName, double value, String... tags) {} + + @Override + public void histogram(String metricName, long value, String... tags) {} + + @Override + public void histogram(String metricName, double value, String... tags) {} + + @Override + public void distribution(String metricName, long value, String... tags) {} + + @Override + public void distribution(String metricName, double value, String... tags) {} + + @Override + public void serviceCheck( + String serviceCheckName, String status, String message, String... tags) {} + + @Override + public void error(Exception error) {} + + @Override + public int getErrorCount() { + return 0; + } + + @Override + public void close() {} +} diff --git a/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDCountReporterTest.java b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDCountReporterTest.java new file mode 100644 index 00000000000..66f8911dba7 --- /dev/null +++ b/products/metrics/metrics-api/src/test/java/datadog/metrics/api/statsd/StatsDCountReporterTest.java @@ -0,0 +1,85 @@ +package datadog.metrics.api.statsd; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class StatsDCountReporterTest { + + private enum TestCounters implements StatsDCounterKey { + FOO("test.counters.foo"), + BAR("test.counters.bar"); + + private final String metricName; + + TestCounters(String metricName) { + this.metricName = metricName; + } + + @Override + public String getMetricName() { + return metricName; + } + } + + @Test + void reportsNonZeroCounters() { + RecordingStatsDClient client = new RecordingStatsDClient(); + long[] counts = new long[TestCounters.values().length]; + counts[TestCounters.FOO.ordinal()] = 3L; + + StatsDCountReporter.report(client, TestCounters.values(), counts); + + assertEquals(1, client.counts.size()); + assertEquals("test.counters.foo", client.counts.get(0).metricName); + assertEquals(3L, client.counts.get(0).delta); + } + + @Test + void skipsZeroDeltaCounters() { + RecordingStatsDClient client = new RecordingStatsDClient(); + long[] counts = new long[TestCounters.values().length]; + counts[TestCounters.FOO.ordinal()] = 0L; + counts[TestCounters.BAR.ordinal()] = 5L; + + StatsDCountReporter.report(client, TestCounters.values(), counts); + + assertEquals(1, client.counts.size()); + assertEquals("test.counters.bar", client.counts.get(0).metricName); + } + + @Test + void reportsNegativeDeltas() { + RecordingStatsDClient client = new RecordingStatsDClient(); + long[] counts = new long[TestCounters.values().length]; + counts[TestCounters.FOO.ordinal()] = -2L; + + StatsDCountReporter.report(client, TestCounters.values(), counts); + + assertEquals(1, client.counts.size()); + assertEquals(-2L, client.counts.get(0).delta); + } + + @Test + void passesTagsThrough() { + RecordingStatsDClient client = new RecordingStatsDClient(); + long[] counts = new long[TestCounters.values().length]; + counts[TestCounters.FOO.ordinal()] = 1L; + + StatsDCountReporter.report(client, TestCounters.values(), counts, "env:test", "service:foo"); + + assertArrayEquals(new String[] {"env:test", "service:foo"}, client.counts.get(0).tags); + } + + @Test + void reportsNothingWhenAllCountersAreZero() { + RecordingStatsDClient client = new RecordingStatsDClient(); + long[] counts = new long[TestCounters.values().length]; + + StatsDCountReporter.report(client, TestCounters.values(), counts); + + assertTrue(client.counts.isEmpty()); + } +}