From 146368612865a8777096cb092dcaac799065c789 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Fri, 10 Apr 2026 15:40:12 +0100 Subject: [PATCH] Fix handling of whole numbers for min/max expected value and slo Previously, a whole number string would be parsed into a Duration. Duration values are ignored for distribution summary meters which results in unexpected values when configuring minimum and maximum expected values and SLO boundaries. Strings like 20d are ambiguous as they can be either a duration of 20 days or a double of 20. To preserve existing behavior where 20d was interpreted as a 20-day duration, strings that contain only digits are parsed into a double which is ultimately treated as a number of milliseconds, the same unit as if it had been parsed into a Duration. Otherwise we fall back to the existing behavior of trying a Duration and then falling back to parsing a double. Note that negative values do not need to be considered. As per their javadoc, DistributionSummary and Timer implementations drop negative amounts passed to record. As a result, negative values for min and max expected values and SLO boundaries do not make sense. Fixes gh-50021 --- .../autoconfigure/metrics/MeterValue.java | 16 +++++++++ .../metrics/MeterValueTests.java | 16 +++++++-- .../metrics/PropertiesMeterFilterTests.java | 34 +++++++++++++++++-- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MeterValue.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MeterValue.java index 4f31eeec70a..34db43d6f28 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MeterValue.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MeterValue.java @@ -22,6 +22,7 @@ import java.util.concurrent.TimeUnit; import io.micrometer.core.instrument.Meter.Type; import org.springframework.boot.convert.DurationStyle; +import org.springframework.util.StringUtils; /** * A meter value that is used when configuring micrometer. Can be a String representation @@ -86,6 +87,9 @@ public final class MeterValue { * @return a {@link MeterValue} instance */ public static MeterValue valueOf(String value) { + if (onlyDigits(value)) { + return new MeterValue(Double.parseDouble(value)); + } Duration duration = safeParseDuration(value); if (duration != null) { return new MeterValue(duration); @@ -93,6 +97,18 @@ public final class MeterValue { return new MeterValue(Double.parseDouble(value)); } + private static boolean onlyDigits(String value) { + if (!StringUtils.hasLength(value)) { + return false; + } + for (char c : value.toCharArray()) { + if (!Character.isDigit(c)) { + return false; + } + } + return true; + } + /** * Return a new {@link MeterValue} instance for the given double value. * @param value the source value diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MeterValueTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MeterValueTests.java index 0d940540ec1..34405d7b28a 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MeterValueTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/MeterValueTests.java @@ -35,17 +35,29 @@ import static org.assertj.core.api.Assertions.assertThat; class MeterValueTests { @Test - void getValueForDistributionSummaryWhenFromNumberShouldReturnDoubleValue() { + void getValueForDistributionSummaryWhenFromDecimalNumberShouldReturnDoubleValue() { MeterValue meterValue = MeterValue.valueOf(123.42); assertThat(meterValue.getValue(Type.DISTRIBUTION_SUMMARY)).isEqualTo(123.42); } @Test - void getValueForDistributionSummaryWhenFromNumberStringShouldReturnDoubleValue() { + void getValueForDistributionSummaryWhenFromWholeNumberShouldReturnDoubleValue() { + MeterValue meterValue = MeterValue.valueOf(123); + assertThat(meterValue.getValue(Type.DISTRIBUTION_SUMMARY)).isEqualTo(123); + } + + @Test + void getValueForDistributionSummaryWhenFromDecimalNumberStringShouldReturnDoubleValue() { MeterValue meterValue = MeterValue.valueOf("123.42"); assertThat(meterValue.getValue(Type.DISTRIBUTION_SUMMARY)).isEqualTo(123.42); } + @Test + void getValueForDistributionSummaryWhenFromWholeNumberStringShouldReturnDoubleValue() { + MeterValue meterValue = MeterValue.valueOf("123"); + assertThat(meterValue.getValue(Type.DISTRIBUTION_SUMMARY)).isEqualTo(123); + } + @Test void getValueForDistributionSummaryWhenFromDurationStringShouldReturnNull() { MeterValue meterValue = MeterValue.valueOf("123ms"); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/PropertiesMeterFilterTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/PropertiesMeterFilterTests.java index d1b5b4a7c9d..3a6675b9b49 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/PropertiesMeterFilterTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/PropertiesMeterFilterTests.java @@ -205,13 +205,21 @@ class PropertiesMeterFilterTests { } @Test - void configureWhenHasSloShouldSetSloToValue() { + void configureWhenTimerMeterHasSloShouldSetSloToValue() { PropertiesMeterFilter filter = new PropertiesMeterFilter( createProperties("distribution.slo.spring.boot=1,2,3")); assertThat(filter.configure(createMeterId("spring.boot"), DistributionStatisticConfig.DEFAULT) .getServiceLevelObjectiveBoundaries()).containsExactly(1000000, 2000000, 3000000); } + @Test + void configureWhenDistributionSummaryMeterHasSloShouldSetSloToValue() { + PropertiesMeterFilter filter = new PropertiesMeterFilter(createProperties("distribution.slo.example=1,2,3")); + assertThat(filter + .configure(createMeterId("example", Meter.Type.DISTRIBUTION_SUMMARY), DistributionStatisticConfig.DEFAULT) + .getServiceLevelObjectiveBoundaries()).containsExactly(1.0, 2.0, 3.0); + } + @Test void configureWhenHasHigherSloShouldSetPercentilesToValue() { PropertiesMeterFilter filter = new PropertiesMeterFilter(createProperties("distribution.slo.spring=1,2,3")); @@ -228,13 +236,23 @@ class PropertiesMeterFilterTests { } @Test - void configureWhenHasMinimumExpectedValueShouldSetMinimumExpectedToValue() { + void configureWhenTimerMeterHasMinimumExpectedValueShouldSetMinimumExpectedToValue() { PropertiesMeterFilter filter = new PropertiesMeterFilter( createProperties("distribution.minimum-expected-value.spring.boot=10")); assertThat(filter.configure(createMeterId("spring.boot"), DistributionStatisticConfig.DEFAULT) .getMinimumExpectedValueAsDouble()).isEqualTo(Duration.ofMillis(10).toNanos()); } + @Test + void configureWhenDistributionSummaryMeterHasMinimumExpectedValueShouldSetMinimumExpectedToValue() { + PropertiesMeterFilter filter = new PropertiesMeterFilter( + createProperties("distribution.minimum-expected-value.spring.boot=10")); + assertThat(filter + .configure(createMeterId("spring.boot", Meter.Type.DISTRIBUTION_SUMMARY), + DistributionStatisticConfig.DEFAULT) + .getMinimumExpectedValueAsDouble()).isEqualTo(10); + } + @Test void configureWhenHasHigherMinimumExpectedValueShouldSetMinimumExpectedValueToValue() { PropertiesMeterFilter filter = new PropertiesMeterFilter( @@ -252,13 +270,23 @@ class PropertiesMeterFilterTests { } @Test - void configureWhenHasMaximumExpectedValueShouldSetMaximumExpectedToValue() { + void configureWhenTimerMeterHasMaximumExpectedValueShouldSetMaximumExpectedToValue() { PropertiesMeterFilter filter = new PropertiesMeterFilter( createProperties("distribution.maximum-expected-value.spring.boot=5000")); assertThat(filter.configure(createMeterId("spring.boot"), DistributionStatisticConfig.DEFAULT) .getMaximumExpectedValueAsDouble()).isEqualTo(Duration.ofMillis(5000).toNanos()); } + @Test + void configureWhenDistributionSummaryMeterHasMaximumExpectedValueShouldSetMaximumExpectedToValue() { + PropertiesMeterFilter filter = new PropertiesMeterFilter( + createProperties("distribution.maximum-expected-value.spring.boot=10")); + assertThat(filter + .configure(createMeterId("spring.boot", Meter.Type.DISTRIBUTION_SUMMARY), + DistributionStatisticConfig.DEFAULT) + .getMaximumExpectedValueAsDouble()).isEqualTo(10); + } + @Test void configureWhenHasHigherMaximumExpectedValueShouldSetMaximumExpectedValueToValue() { PropertiesMeterFilter filter = new PropertiesMeterFilter(