From 387085abd174d180774a5c333883a75faa8f867f Mon Sep 17 00:00:00 2001 From: Vikentiy Fesunov Date: Tue, 10 Mar 2026 16:17:17 +0100 Subject: [PATCH] Use default format for metric values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Numeric metric values were formatted using specialized number format that truncated values to 6 fractional digits and did not use exponential format for larger numbers. While it slightly reduces number of transmitted bytes for fractional numbers close zero, it is less efficient for larger values. A micro-benchmark also shows that not using NumberFormat is generally much faster. These numbers are from JDK 11, but the numbers are similar for JDK 8 and 25. Benchmark (value) Mode Cnt Score Error Units FullPrecisionBenchmark.format_default 3.141592653589793 thrpt 10 3.035 ± 0.182 ops/us FullPrecisionBenchmark.format_default 3.3E20 thrpt 10 3.465 ± 0.764 ops/us FullPrecisionBenchmark.format_default 42.0 thrpt 10 5.711 ± 1.908 ops/us FullPrecisionBenchmark.format_default 0.423 thrpt 10 4.977 ± 1.437 ops/us FullPrecisionBenchmark.format_default 243.5 thrpt 10 4.871 ± 0.232 ops/us FullPrecisionBenchmark.format_full_precision 3.141592653589793 thrpt 10 5.888 ± 0.411 ops/us FullPrecisionBenchmark.format_full_precision 3.3E20 thrpt 10 20.946 ± 0.681 ops/us FullPrecisionBenchmark.format_full_precision 42.0 thrpt 10 33.849 ± 0.391 ops/us FullPrecisionBenchmark.format_full_precision 0.423 thrpt 10 23.927 ± 0.685 ops/us FullPrecisionBenchmark.format_full_precision 243.5 thrpt 10 16.318 ± 0.777 ops/us This brings brings client behavior closer to that of other clients: Python uses built-in str() conversion which will use exponential format for large numbers, and Go, which, while doesn't use exponential format, does not truncate precision (on most metrics at least). All agent versions and ADP/Saluki are able to handle this format. An option is added to restore legacy behavior in case this causes any compatibility issues. --- .../statsd/FullPrecisionBenchmark.java | 41 +++++++++++++ .../statsd/NonBlockingStatsDClient.java | 8 ++- .../NonBlockingStatsDClientBuilder.java | 9 +++ .../statsd/NonBlockingStatsDClientTest.java | 57 ++++++++++++++++++- 4 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 src/benchmark/java/com/timgroup/statsd/FullPrecisionBenchmark.java diff --git a/src/benchmark/java/com/timgroup/statsd/FullPrecisionBenchmark.java b/src/benchmark/java/com/timgroup/statsd/FullPrecisionBenchmark.java new file mode 100644 index 00000000..fdfed7c1 --- /dev/null +++ b/src/benchmark/java/com/timgroup/statsd/FullPrecisionBenchmark.java @@ -0,0 +1,41 @@ +package com.timgroup.statsd; + +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Warmup(iterations = 5, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(2) +@State(Scope.Thread) +public class FullPrecisionBenchmark { + + @Param({"3.141592653589793", "3.3E20", "42.0", "0.423", "243.5"}) + public double value; + + private final StringBuilder builder = new StringBuilder(64); + + @Benchmark + public int format_default() { + builder.setLength(0); + builder.append(NonBlockingStatsDClient.format(NonBlockingStatsDClient.NUMBER_FORMATTER, value)); + return builder.length(); + } + + @Benchmark + public int format_full_precision() { + builder.setLength(0); + builder.append(value); + return builder.length(); + } +} diff --git a/src/main/java/com/timgroup/statsd/NonBlockingStatsDClient.java b/src/main/java/com/timgroup/statsd/NonBlockingStatsDClient.java index 4ea7591c..85b9462a 100644 --- a/src/main/java/com/timgroup/statsd/NonBlockingStatsDClient.java +++ b/src/main/java/com/timgroup/statsd/NonBlockingStatsDClient.java @@ -182,6 +182,7 @@ protected static String format(ThreadLocal formatter, Number value private final String containerID; private final String externalEnv; final TagsCardinality clientTagsCardinality; + private final boolean fullPrecision; /** * Create a new StatsD client communicating with a StatsD instance on the host and port @@ -207,6 +208,7 @@ public NonBlockingStatsDClient(final NonBlockingStatsDClientBuilder builder) } blocking = builder.blocking; + fullPrecision = builder.fullPrecision; maxPacketSizeBytes = builder.maxPacketSizeBytes; clientTagsCardinality = builder.tagsCardinality; @@ -625,7 +627,11 @@ private void send( tags) { @Override protected void writeValue(StringBuilder builder) { - builder.append(format(NUMBER_FORMATTER, this.value)); + if (fullPrecision) { + builder.append(this.value); + } else { + builder.append(format(NUMBER_FORMATTER, this.value)); + } } }); } diff --git a/src/main/java/com/timgroup/statsd/NonBlockingStatsDClientBuilder.java b/src/main/java/com/timgroup/statsd/NonBlockingStatsDClientBuilder.java index 289b16c2..39dfd368 100644 --- a/src/main/java/com/timgroup/statsd/NonBlockingStatsDClientBuilder.java +++ b/src/main/java/com/timgroup/statsd/NonBlockingStatsDClientBuilder.java @@ -52,6 +52,9 @@ public class NonBlockingStatsDClientBuilder implements Cloneable { public boolean enableAggregation = NonBlockingStatsDClient.DEFAULT_ENABLE_AGGREGATION; + /** Use full precision when formatting numeric metric values. */ + public boolean fullPrecision = true; + /** Telemetry flush interval, in milliseconds. */ public int telemetryFlushInterval = Telemetry.DEFAULT_FLUSH_INTERVAL; @@ -270,6 +273,12 @@ public NonBlockingStatsDClientBuilder enableAggregation(boolean val) { return this; } + /** Use full precision when formatting numeric metric values. */ + public NonBlockingStatsDClientBuilder fullPrecision(boolean val) { + fullPrecision = val; + return this; + } + /** Telemetry flush interval, in milliseconds. */ public NonBlockingStatsDClientBuilder telemetryFlushInterval(int val) { telemetryFlushInterval = val; diff --git a/src/test/java/com/timgroup/statsd/NonBlockingStatsDClientTest.java b/src/test/java/com/timgroup/statsd/NonBlockingStatsDClientTest.java index 8ed7a030..0e803793 100644 --- a/src/test/java/com/timgroup/statsd/NonBlockingStatsDClientTest.java +++ b/src/test/java/com/timgroup/statsd/NonBlockingStatsDClientTest.java @@ -43,6 +43,7 @@ public class NonBlockingStatsDClientTest { private static final int STATSD_SERVER_PORT = 17254; private NonBlockingStatsDClient client; private NonBlockingStatsDClient clientUnaggregated; + private NonBlockingStatsDClient clientFullPrecision; private static UDPDummyStatsDServer server; private static Logger log = Logger.getLogger("NonBlockingStatsDClientTest"); @@ -111,8 +112,20 @@ public void start() throws IOException { .originDetectionEnabled(originDetectionEnabled) .aggregationFlushInterval(100) .containerID(containerID) + .fullPrecision(false) .build(); clientUnaggregated = + new NonBlockingStatsDClientBuilder() + .prefix("my.prefix") + .hostname("localhost") + .port(server.getPort()) + .enableTelemetry(false) + .enableAggregation(false) + .originDetectionEnabled(originDetectionEnabled) + .containerID(containerID) + .fullPrecision(false) + .build(); + clientFullPrecision = new NonBlockingStatsDClientBuilder() .prefix("my.prefix") .hostname("localhost") @@ -128,6 +141,7 @@ public void start() throws IOException { public void stop() throws IOException { client.stop(); clientUnaggregated.stop(); + clientFullPrecision.stop(); server.close(); } @@ -236,6 +250,22 @@ public void sends_double_counter_value_with_incorrect_timestamp() throws Excepti assertPayload("my.prefix.mycount:42.5|c|T1|#baz,foo:bar"); } + @Test(timeout = 5000L) + public void sends_large_long_counter_to_statsd() throws Exception { + clientUnaggregated.count("mycount", 1L << 62); + server.waitForMessage("my.prefix"); + + assertPayload("my.prefix.mycount:4611686018427387904|c"); + } + + @Test(timeout = 5000L) + public void sends_large_long_counter_to_statsd_with_full_precision() throws Exception { + clientFullPrecision.count("mycount", 1L << 62); + server.waitForMessage("my.prefix"); + + assertPayload("my.prefix.mycount:4611686018427387904|c"); + } + @Test(timeout = 5000L) public void sends_counter_increment_to_statsd() throws Exception { @@ -354,10 +384,19 @@ public void sends_gauge_with_sample_rate_to_statsd() throws Exception { @Test(timeout = 5000L) public void sends_large_double_gauge_to_statsd() throws Exception { - client.recordGaugeValue("mygauge", 123456789012345.67890); + client.recordGaugeValue("mygauge", 3.3e20); + server.waitForMessage("my.prefix"); + + assertPayload("my.prefix.mygauge:330000000000000000000|g"); + } + + @Test(timeout = 5000L) + public void sends_large_double_gauge_to_statsd_with_full_precision() throws Exception { + + clientFullPrecision.recordGaugeValue("mygauge", 3.3e20); server.waitForMessage("my.prefix"); - assertPayload("my.prefix.mygauge:123456789012345.67|g"); + assertPayload("my.prefix.mygauge:3.3E20|g"); } @Test(timeout = 5000L) @@ -396,6 +435,20 @@ public void sends_gauge_with_sample_rate_to_statsd_with_tags() throws Exception assertPayload("my.prefix.mygauge:423|g|@1.000000|#baz,foo:bar"); } + @Test(timeout = 5000L) + public void sends_gauge_with_full_precision_to_statsd() throws Exception { + clientFullPrecision.recordGaugeValue("mygauge", Math.PI); + server.waitForMessage("my.prefix"); + assertPayload("my.prefix.mygauge:3.141592653589793|g"); + } + + @Test(timeout = 5000L) + public void sends_gauge_with_full_precision_integer_value_to_statsd() throws Exception { + clientFullPrecision.recordGaugeValue("mygauge", 42.0); + server.waitForMessage("my.prefix"); + assertPayload("my.prefix.mygauge:42.0|g"); + } + @Test(timeout = 5000L) public void sends_long_gauge_with_timestamp() throws Exception { clientUnaggregated.gaugeWithTimestamp("mygauge", 234l, 1205794800, "foo:bar", "baz");