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