Repository: micrometer-metrics/micrometer
Pull request #6102: Make per-meter OTLP configuration more flexible
Author: login: shakuzen
Created: 2025-04-09T01:57:48Z | Merged: 2025-04-15T04:12:40Z

The previous logic for matching required the exact meter name, which was straightforward but cumbersome for configuring groups of meters. This changes the default matching for the per-meter config to use the longest dot-separated segments match.

Resolves #6099

Linked issue #6099: More flexible OTLP per meter configuration
https://github.com/micrometer-metrics/micrometer/pull/5974 introduced the support for configuring HistogramFlavour/buckets per meter for OTLPMeterRegistry. This was done as part of a configuration that was supplied during the registry creation and does not allow overriding via APIs. This configuration does a full match of the meter name and requires the fully qualified meter name to be provided and this can get longer when we need to supply configuration to a family of meters.  

[Spring boot has a solution](https://docs.spring.io/spring-boot/reference/actuator/metrics.html#actuator.metrics.customizing.per-meter-properties) to address this issue by supporting the prefix and if there are more values the longest match wins. If users can supply a Function to be applied for determining these values also be an option to try out. 

Linked issue comments: user: login: shakuzen
created_at: 2025-04-15T04:18:21Z
body: In the end, we changed the lookup logic to match the mentioned logic used for some Spring Boot configuration properties related to configuring MeterFilters. That is, a longest match by dot-separated segment will be used to apply the per-meter config even in cases where it is not an exact match. See the updated JavaDoc for the methods on OtlpConfig for an example. We did not make this logic customizable, as it didn't seem necessary for the underlying issue (configuring groups of meters). If someone else has the need for that, please let us know the details and we can re-consider that change in a separate issue.
author_association: MEMBER

Diff:
diff --git a/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpConfig.java b/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpConfig.java
index f1a4e6263b..d326cc6f5d 100644
--- a/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpConfig.java
+++ b/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpConfig.java
@@ -15,12 +15,13 @@
  */
 package io.micrometer.registry.otlp;
 
+import io.micrometer.core.instrument.Meter;
 import io.micrometer.core.instrument.config.InvalidConfigurationException;
 import io.micrometer.core.instrument.config.validate.Validated;
 import io.micrometer.core.instrument.push.PushRegistryConfig;
 
-import java.time.Duration;
 import java.net.URLDecoder;
+import java.time.Duration;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
@@ -228,10 +229,16 @@ default HistogramFlavor histogramFlavor() {
     }
 
     /**
-     * Configures the histogram flavor to use on a per-meter level. This will override the
-     * {@link #histogramFlavor()} configuration for matching Meters. The key is used to do
-     * an exact match on the Meter's name.
-     * @return mapping of meter name to histogram flavor
+     * Configures the histogram flavor mapping to use on a per-meter level. This can
+     * override the {@link #histogramFlavor()} configuration for matching Meters.
+     * {@link OtlpMeterRegistry} uses the result of this method to look up the
+     * {@link HistogramFlavor} by {@link Meter.Id}. The longest dot-separated match wins.
+     * For example, if the returned Map has keys {@literal http} and
+     * {@literal http.server}, an ID with a name {@literal http.server.requests} would
+     * match with the entry having key {@literal http.server}, whereas an ID with name
+     * {@literal http.client.requests} would match with the entry having the key
+     * {@literal http}.
+     * @return mapping of meter name (or prefix) to histogram flavor
      * @since 1.15.0
      * @see #histogramFlavor()
      */
@@ -266,10 +273,15 @@ default int maxBucketCount() {
     }
 
     /**
-     * Configures the max bucket count to use on a per-meter level. This will override the
-     * {@link #maxBucketCount()} configuration for matching Meters. The key is used to do
-     * an exact match on the Meter's name. This has no effect on a meter if it does not
-     * have an exponential bucket histogram configured.
+     * Configures the max bucket count mapping to use on a per-meter level. This can
+     * override the {@link #maxBucketCount()} configuration for matching Meters.
+     * {@link OtlpMeterRegistry} uses the result of this method to look up the max bucket
+     * count by {@link Meter.Id}. The longest dot-separated match wins. For example, if
+     * the returned Map has keys {@literal http} and {@literal http.server}, an ID with a
+     * name {@literal http.server.requests} would match with the entry having key
+     * {@literal http.server}, whereas an ID with name {@literal http.client.requests}
+     * would match with the entry having the key {@literal http}. This has no effect on a
+     * meter if it does not have an exponential bucket histogram configured.
      * @return mapping of meter name to max bucket count
      * @since 1.15.0
      * @see #maxBucketCount()
diff --git a/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMeterRegistry.java b/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMeterRegistry.java
index b38646cd7a..2e6cb4d624 100644
--- a/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMeterRegistry.java
+++ b/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMeterRegistry.java
@@ -16,10 +16,11 @@
 package io.micrometer.registry.otlp;
 
 import io.micrometer.common.lang.Nullable;
+import io.micrometer.common.util.StringUtils;
 import io.micrometer.common.util.internal.logging.InternalLogger;
 import io.micrometer.common.util.internal.logging.InternalLoggerFactory;
-import io.micrometer.core.instrument.Timer;
 import io.micrometer.core.instrument.*;
+import io.micrometer.core.instrument.Timer;
 import io.micrometer.core.instrument.config.NamingConvention;
 import io.micrometer.core.instrument.distribution.*;
 import io.micrometer.core.instrument.distribution.pause.PauseDetector;
@@ -82,6 +83,10 @@ public class OtlpMeterRegistry extends PushMeterRegistry {
 
     private final OtlpMetricsSender metricsSender;
 
+    private final HistogramFlavorPerMeterLookup histogramFlavorPerMeterLookup;
+
+    private final MaxBucketsPerMeterLookup maxBucketsPerMeterLookup;
+
     private final Resource resource;
 
     private final AggregationTemporality aggregationTemporality;
@@ -120,6 +125,8 @@ private OtlpMeterRegistry(OtlpConfig config, Clock clock, ThreadFactory threadFa
         this.config = config;
         this.baseTimeUnit = config.baseTimeUnit();
         this.metricsSender = metricsSender;
+        this.histogramFlavorPerMeterLookup = HistogramFlavorPerMeterLookup.DEFAULT;
+        this.maxBucketsPerMeterLookup = MaxBucketsPerMeterLookup.DEFAULT;
         this.resource = Resource.newBuilder().addAllAttributes(getResourceAttributes()).build();
         this.aggregationTemporality = config.aggregationTemporality();
         config().namingConvention(NamingConvention.dot);
@@ -430,14 +437,16 @@ private Histogram getHistogram(Meter.Id id, DistributionStatisticConfig distribu
     }
 
     private int getMaxBuckets(Meter.Id id) {
-        return config.maxBucketsPerMeter().getOrDefault(id.getName(), config.maxBucketCount());
+        Integer maxBuckets = maxBucketsPerMeterLookup.getMaxBuckets(config.maxBucketsPerMeter(), id);
+        return (maxBuckets == null) ? config.maxBucketCount() : maxBuckets;
     }
 
     private HistogramFlavor histogramFlavor(Meter.Id id, OtlpConfig otlpConfig,
             DistributionStatisticConfig distributionStatisticConfig) {
-        HistogramFlavor preferredHistogramFlavor = otlpConfig.histogramFlavorPerMeter()
-            .getOrDefault(id.getName(), otlpConfig.histogramFlavor());
-
+        HistogramFlavor preferredHistogramFlavor = histogramFlavorPerMeterLookup
+            .getHistogramFlavor(otlpConfig.histogramFlavorPerMeter(), id);
+        preferredHistogramFlavor = preferredHistogramFlavor == null ? otlpConfig.histogramFlavor()
+                : preferredHistogramFlavor;
         final double[] serviceLevelObjectiveBoundaries = distributionStatisticConfig
             .getServiceLevelObjectiveBoundaries();
         if (distributionStatisticConfig.isPublishingHistogram()
@@ -497,6 +506,91 @@ static double[] getSloWithPositiveInf(DistributionStatisticConfig distributionSt
         return sloWithPositiveInf;
     }
 
+    /**
+     * Overridable lookup mechanism for {@link HistogramFlavor}.
+     */
+    // VisibleForTesting
+    @FunctionalInterface
+    interface HistogramFlavorPerMeterLookup {
+
+        /**
+         * Default implementation.
+         */
+        HistogramFlavorPerMeterLookup DEFAULT = OtlpMeterRegistry::lookup;
+
+        /**
+         * Looks up the histogram flavor to use on a per-meter level. This will override
+         * the default {@link OtlpConfig#histogramFlavor()} for matching Meters.
+         * {@link OtlpConfig#histogramFlavorPerMeter()} provides the data while this
+         * method provides the logic for the lookup, and you can override them
+         * independently.
+         * @param perMeterMapping configured mapping data
+         * @param id the {@link Meter.Id} the {@link HistogramFlavor} is configured for
+         * @return the histogram flavor mapped to the {@link Meter.Id} or {@code null} if
+         * mapping is undefined
+         * @see OtlpConfig#histogramFlavorPerMeter()
+         * @see OtlpConfig#histogramFlavor()
+         */
+        @Nullable
+        HistogramFlavor getHistogramFlavor(Map<String, HistogramFlavor> perMeterMapping, Meter.Id id);
+
+    }
+
+    /**
+     * Overridable lookup mechanism for max bucket count. This has no effect on a meter if
+     * it does not have an exponential bucket histogram configured.
+     */
+    // VisibleForTesting
+    @FunctionalInterface
+    interface MaxBucketsPerMeterLookup {
+
+        /**
+         * Default implementation.
+         */
+        MaxBucketsPerMeterLookup DEFAULT = OtlpMeterRegistry::lookup;
+
+        /**
+         * Looks up the max bucket count to use on a per-meter level. This will override
+         * the default {@link OtlpConfig#maxBucketCount()} for matching Meters.
+         * {@link OtlpConfig#maxBucketsPerMeter()} provides the data while this method
+         * provides the logic for the lookup, and you can override them independently.
+         * This has no effect on a meter if it does not have an exponential bucket
+         * histogram configured.
+         * @param perMeterMapping configured mapping data
+         * @param id the {@link Meter.Id} the max bucket count is configured for
+         * @return the max bucket count mapped to the {@link Meter.Id} or {@code null} if
+         * the mapping is undefined
+         * @see OtlpConfig#maxBucketsPerMeter()
+         * @see OtlpConfig#maxBucketCount()
+         */
+        @Nullable
+        Integer getMaxBuckets(Map<String, Integer> perMeterMapping, Meter.Id id);
+
+    }
+
+    @Nullable
+    private static <T> T lookup(Map<String, T> values, Meter.Id id) {
+        if (values.isEmpty()) {
+            return null;
+        }
+        return doLookup(values, id);
+    }
+
+    @Nullable
+    private static <T> T doLookup(Map<String, T> values, Meter.Id id) {
+        String name = id.getName();
+        while (StringUtils.isNotEmpty(name)) {
+            T result = values.get(name);
+            if (result != null) {
+                return result;
+            }
+            int lastDot = name.lastIndexOf('.');
+            name = (lastDot != -1) ? name.substring(0, lastDot) : "";
+        }
+
+        return null;
+    }
+
     /**
      * Builder for {@link OtlpMeterRegistry}.
      *
diff --git a/implementations/micrometer-registry-otlp/src/test/java/io/micrometer/registry/otlp/OtlpConfigTest.java b/implementations/micrometer-registry-otlp/src/test/java/io/micrometer/registry/otlp/OtlpConfigTest.java
index f0efa86663..4b02ce3d37 100644
--- a/implementations/micrometer-registry-otlp/src/test/java/io/micrometer/registry/otlp/OtlpConfigTest.java
+++ b/implementations/micrometer-registry-otlp/src/test/java/io/micrometer/registry/otlp/OtlpConfigTest.java
@@ -25,9 +25,9 @@
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Stream;
 
-import static java.util.Map.entry;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static io.micrometer.registry.otlp.HistogramFlavor.BASE2_EXPONENTIAL_BUCKET_HISTOGRAM;
+import static io.micrometer.registry.otlp.HistogramFlavor.EXPLICIT_BUCKET_HISTOGRAM;
+import static org.assertj.core.api.Assertions.*;
 import static uk.org.webcompere.systemstubs.SystemStubs.withEnvironmentVariable;
 import static uk.org.webcompere.systemstubs.SystemStubs.withEnvironmentVariables;
 
@@ -257,15 +257,14 @@ void histogramPreference() {
 
         OtlpConfig otlpConfig = properties::get;
         assertThat(otlpConfig.validate().isValid()).isTrue();
-        assertThat(otlpConfig.histogramFlavor()).isEqualTo(HistogramFlavor.BASE2_EXPONENTIAL_BUCKET_HISTOGRAM);
+        assertThat(otlpConfig.histogramFlavor()).isEqualTo(BASE2_EXPONENTIAL_BUCKET_HISTOGRAM);
     }
 
     @Test
     void histogramPreferenceConfigTakesPrecedenceOverEnvVars() throws Exception {
         OtlpConfig config = k -> "base2_exponential_bucket_histogram";
         withEnvironmentVariable("OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION", "explicit_bucket_histogram")
-            .execute(() -> assertThat(config.histogramFlavor())
-                .isEqualTo(HistogramFlavor.BASE2_EXPONENTIAL_BUCKET_HISTOGRAM));
+            .execute(() -> assertThat(config.histogramFlavor()).isEqualTo(BASE2_EXPONENTIAL_BUCKET_HISTOGRAM));
     }
 
     @Test
@@ -273,8 +272,7 @@ void histogramPreferenceUseEnvVarWhenConfigNotSet() throws Exception {
         OtlpConfig config = k -> null;
         withEnvironmentVariable("OTEL_EXPORTER_OTLP_METRICS_DEFAULT_HISTOGRAM_AGGREGATION",
                 "base2_exponential_bucket_histogram")
-            .execute(() -> assertThat(config.histogramFlavor())
-                .isEqualTo(HistogramFlavor.BASE2_EXPONENTIAL_BUCKET_HISTOGRAM));
+            .execute(() -> assertThat(config.histogramFlavor()).isEqualTo(BASE2_EXPONENTIAL_BUCKET_HISTOGRAM));
     }
 
     @Test
@@ -284,9 +282,8 @@ void histogramFlavorPerMeter() {
                 "a.b.c=explicit_bucket_histogram ,expo =base2_exponential_bucket_histogram");
         OtlpConfig otlpConfig = properties::get;
         assertThat(otlpConfig.validate().isValid()).isTrue();
-        assertThat(otlpConfig.histogramFlavorPerMeter()).containsExactly(
-                entry("a.b.c", HistogramFlavor.EXPLICIT_BUCKET_HISTOGRAM),
-                entry("expo", HistogramFlavor.BASE2_EXPONENTIAL_BUCKET_HISTOGRAM));
+        assertThat(otlpConfig.histogramFlavorPerMeter()).containsExactly(entry("a.b.c", EXPLICIT_BUCKET_HISTOGRAM),
+                entry("expo", BASE2_EXPONENTIAL_BUCKET_HISTOGRAM));
     }
 
     @Test
diff --git a/implementations/micrometer-registry-otlp/src/test/java/io/micrometer/registry/otlp/OtlpMeterRegistryTest.java b/implementations/micrometer-registry-otlp/src/test/java/io/micrometer/registry/otlp/OtlpMeterRegistryTest.java
index 5a59a0bb91..123b121a50 100644
--- a/implementations/micrometer-registry-otlp/src/test/java/io/micrometer/registry/otlp/OtlpMeterRegistryTest.java
+++ b/implementations/micrometer-registry-otlp/src/test/java/io/micrometer/registry/otlp/OtlpMeterRegistryTest.java
@@ -16,8 +16,8 @@
 package io.micrometer.registry.otlp;
 
 import io.micrometer.core.Issue;
-import io.micrometer.core.instrument.Timer;
 import io.micrometer.core.instrument.*;
+import io.micrometer.core.instrument.Timer;
 import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
 import io.micrometer.core.ipc.http.HttpSender;
 import io.opentelemetry.proto.metrics.v1.ExponentialHistogramDataPoint;
@@ -33,6 +33,8 @@
 import java.util.concurrent.TimeUnit;
 import java.util.stream.IntStream;
 
+import static io.micrometer.registry.otlp.HistogramFlavor.BASE2_EXPONENTIAL_BUCKET_HISTOGRAM;
+import static io.micrometer.registry.otlp.HistogramFlavor.EXPLICIT_BUCKET_HISTOGRAM;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.assertArg;
 import static org.mockito.Mockito.*;
@@ -409,7 +411,33 @@ void testGetSloWithPositiveInf() {
     abstract void testMetricsStartAndEndTime();
 
     @Test
-    void perMeterHistogramFlavorOverridesGeneralHistogramFlavor() {
+    void defaultHistogramFlavorShouldBeUsedIfNoOverrides() {
+        OtlpConfig config = new OtlpConfig() {
+            @Override
+            public String get(String key) {
+                return null;
+            }
+
+            @Override
+            public AggregationTemporality aggregationTemporality() {
+                return otlpConfig().aggregationTemporality();
+            }
+        };
+        OtlpMeterRegistry meterRegistry = OtlpMeterRegistry.builder(config).clock(clock).build();
+
+        Timer timer = Timer.builder("test.timer").publishPercentileHistogram().register(meterRegistry);
+        assertThat(writeToMetric(timer).getDataCase().getNumber()).isEqualTo(Metric.DataCase.HISTOGRAM.getNumber());
+
+        meterRegistry.clear();
+
+        DistributionSummary ds = DistributionSummary.builder("test.ds")
+            .publishPercentileHistogram()
+            .register(meterRegistry);
+        assertThat(writeToMetric(ds).getDataCase().getNumber()).isEqualTo(Metric.DataCase.HISTOGRAM.getNumber());
+    }
+
+    @Test
+    void globalHistogramFlavorShouldBeUsedIfNoPerMeterConfig() {
         OtlpConfig config = new OtlpConfig() {
             @Override
             public String get(String key) {
@@ -423,20 +451,57 @@ public AggregationTemporality aggregationTemporality() {
 
             @Override
             public HistogramFlavor histogramFlavor() {
-                return HistogramFlavor.EXPLICIT_BUCKET_HISTOGRAM;
+                return BASE2_EXPONENTIAL_BUCKET_HISTOGRAM;
+            }
+        };
+        OtlpMeterRegistry meterRegistry = OtlpMeterRegistry.builder(config).clock(clock).build();
+        Timer timer = Timer.builder("test.timer").publishPercentileHistogram().register(meterRegistry);
+        assertThat(writeToMetric(timer).getDataCase().getNumber())
+            .isEqualTo(Metric.DataCase.EXPONENTIAL_HISTOGRAM.getNumber());
+
+        meterRegistry.clear();
+
+        DistributionSummary ds = DistributionSummary.builder("test.ds")
+            .publishPercentileHistogram()
+            .register(meterRegistry);
+        assertThat(writeToMetric(ds).getDataCase().getNumber())
+            .isEqualTo(Metric.DataCase.EXPONENTIAL_HISTOGRAM.getNumber());
+    }
+
+    @Test
+    void perMeterHistogramFlavorShouldBeUsedFromConfigIfNoLookupOverrides() {
+        OtlpConfig config = new OtlpConfig() {
+            @Override
+            public String get(String key) {
+                return null;
+            }
+
+            @Override
+            public AggregationTemporality aggregationTemporality() {
+                return otlpConfig().aggregationTemporality();
+            }
+
+            @Override
+            public HistogramFlavor histogramFlavor() {
+                return EXPLICIT_BUCKET_HISTOGRAM;
             }
 
             @Override
             public Map<String, HistogramFlavor> histogramFlavorPerMeter() {
-                return Collections.singletonMap("expo", HistogramFlavor.BASE2_EXPONENTIAL_BUCKET_HISTOGRAM);
+                Map<String, HistogramFlavor> histogramFlavors = new HashMap<>();
+                histogramFlavors.put("expo", BASE2_EXPONENTIAL_BUCKET_HISTOGRAM);
+                return histogramFlavors;
             }
         };
-        OtlpMeterRegistry meterRegistry = new OtlpMeterRegistry(config, clock);
+        OtlpMeterRegistry meterRegistry = OtlpMeterRegistry.builder(config).clock(clock).build();
+
         Timer expo = Timer.builder("expo").publishPercentileHistogram().register(meterRegistry);
+        Timer expoOther = Timer.builder("expo.other").publishPercentileHistogram().register(meterRegistry);
         Timer other = Timer.builder("other").publishPercentileHistogram().register(meterRegistry);
-
         assertThat(writeToMetric(expo).getDataCase().getNumber())
             .isEqualTo(Metric.DataCase.EXPONENTIAL_HISTOGRAM.getNumber());
+        assertThat(writeToMetric(expoOther).getDataCase().getNumber())
+            .isEqualTo(Metric.DataCase.EXPONENTIAL_HISTOGRAM.getNumber());
         assertThat(writeToMetric(other).getDataCase().getNumber()).isEqualTo(Metric.DataCase.HISTOGRAM.getNumber());
 
         meterRegistry.clear();
@@ -444,17 +509,21 @@ public Map<String, HistogramFlavor> histogramFlavorPerMeter() {
         DistributionSummary expo2 = DistributionSummary.builder("expo")
             .publishPercentileHistogram()
             .register(meterRegistry);
+        DistributionSummary expoOther2 = DistributionSummary.builder("expo.other")
+            .publishPercentileHistogram()
+            .register(meterRegistry);
         DistributionSummary other2 = DistributionSummary.builder("other")
             .publishPercentileHistogram()
             .register(meterRegistry);
-
         assertThat(writeToMetric(expo2).getDataCase().getNumber())
             .isEqualTo(Metric.DataCase.EXPONENTIAL_HISTOGRAM.getNumber());
+        assertThat(writeToMetric(expoOther2).getDataCase().getNumber())
+            .isEqualTo(Metric.DataCase.EXPONENTIAL_HISTOGRAM.getNumber());
         assertThat(writeToMetric(other2).getDataCase().getNumber()).isEqualTo(Metric.DataCase.HISTOGRAM.getNumber());
     }
 
     @Test
-    void maxBucketsPerMeter() {
+    void globalMaxBucketsShouldBeUsedIfNoPerMeterConfig() {
         OtlpConfig config = new OtlpConfig() {
             @Override
             public String get(String key) {
@@ -468,7 +537,52 @@ public AggregationTemporality aggregationTemporality() {
 
             @Override
             public HistogramFlavor histogramFlavor() {
-                return HistogramFlavor.BASE2_EXPONENTIAL_BUCKET_HISTOGRAM;
+                return BASE2_EXPONENTIAL_BUCKET_HISTOGRAM;
+            }
+
+            @Override
+            public int maxBucketCount() {
+                return 56;
+            }
+        };
+        OtlpMeterRegistry meterRegistry = OtlpMeterRegistry.builder(config).clock(clock).build();
+        Timer timer = Timer.builder("test.timer").publishPercentileHistogram().register(meterRegistry);
+        IntStream.range(1, 111).forEach(i -> timer.record(i, TimeUnit.MILLISECONDS));
+
+        clock.add(config.step());
+
+        assertThat(writeToMetric(timer).getExponentialHistogram().getDataPoints(0).getPositive().getBucketCountsList())
+            .hasSize(56);
+
+        meterRegistry.clear();
+
+        DistributionSummary ds = DistributionSummary.builder("test.ds")
+            .publishPercentileHistogram()
+            .register(meterRegistry);
+        IntStream.range(1, 111).forEach(ds::record);
+
+        clock.add(config.step());
+
+        assertThat(writeToMetric(ds).getExponentialHistogram().getDataPoints(0).getPositive().getBucketCountsList())
+            .hasSize(56);
+    }
+
+    @Test
+    void perMeterMaxBucketsShouldBeUsedFromConfigIfNoLookupOverrides() {
+        OtlpConfig config = new OtlpConfig() {
+            @Override
+            public String get(String key) {
+                return null;
+            }
+
+            @Override
+            public AggregationTemporality aggregationTemporality() {
+                return otlpConfig().aggregationTemporality();
+            }
+
+            @Override
+            public HistogramFlavor histogramFlavor() {
+                return BASE2_EXPONENTIAL_BUCKET_HISTOGRAM;
             }
 
             @Override
@@ -478,14 +592,20 @@ public int maxBucketCount() {
 
             @Override
             public Map<String, Integer> maxBucketsPerMeter() {
-                return Collections.singletonMap("low.variation", 15);
+                Map<String, Integer> maxBuckets = new HashMap<>();
+                maxBuckets.put("low.variation", 15);
+                return maxBuckets;
             }
         };
-        OtlpMeterRegistry meterRegistry = new OtlpMeterRegistry(config, clock);
+        OtlpMeterRegistry meterRegistry = OtlpMeterRegistry.builder(config).clock(clock).build();
+
         Timer lowVariation = Timer.builder("low.variation").publishPercentileHistogram().register(meterRegistry);
+        Timer lowVariationOther = Timer.builder("low.variation.other")
+            .publishPercentileHistogram()
+            .register(meterRegistry);
         Timer other = Timer.builder("other").publishPercentileHistogram().register(meterRegistry);
 
-        List.of(lowVariation, other)
+        List.of(lowVariation, lowVariationOther, other)
             .forEach(t -> IntStream.range(1, 111).forEach(i -> t.record(i, TimeUnit.MILLISECONDS)));
         clock.add(config.step());
 
@@ -493,6 +613,10 @@ public Map<String, Integer> maxBucketsPerMeter() {
             .getDataPoints(0)
             .getPositive()
             .getBucketCountsList()).hasSize(15);
+        assertThat(writeToMetric(lowVariationOther).getExponentialHistogram()
+            .getDataPoints(0)
+            .getPositive()
+            .getBucketCountsList()).hasSize(15);
         assertThat(writeToMetric(other).getExponentialHistogram().getDataPoints(0).getPositive().getBucketCountsList())
             .hasSize(56);
 
@@ -501,21 +625,54 @@ public Map<String, Integer> maxBucketsPerMeter() {
         DistributionSummary lowVariation2 = DistributionSummary.builder("low.variation")
             .publishPercentileHistogram()
             .register(meterRegistry);
+        DistributionSummary lowVariationOther2 = DistributionSummary.builder("low.variation.other")
+            .publishPercentileHistogram()
+            .register(meterRegistry);
         DistributionSummary other2 = DistributionSummary.builder("other")
             .publishPercentileHistogram()
             .register(meterRegistry);
 
-        List.of(lowVariation2, other2).forEach(t -> IntStream.range(1, 111).forEach(t::record));
+        List.of(lowVariation2, lowVariationOther2, other2).forEach(t -> IntStream.range(1, 111).forEach(t::record));
         clock.add(config.step());
 
         assertThat(writeToMetric(lowVariation2).getExponentialHistogram()
             .getDataPoints(0)
             .getPositive()
             .getBucketCountsList()).hasSize(15);
+        assertThat(writeToMetric(lowVariationOther2).getExponentialHistogram()
+            .getDataPoints(0)
+            .getPositive()
+            .getBucketCountsList()).hasSize(15);
         assertThat(writeToMetric(other2).getExponentialHistogram().getDataPoints(0).getPositive().getBucketCountsList())
             .hasSize(56);
     }
 
+    @Test
+    void longestMatchWinsByDefaultHistogramFlavorPerMeter() {
+        Map<String, HistogramFlavor> histogramFlavorPerMeter = new HashMap<>();
+        histogramFlavorPerMeter.put("http", EXPLICIT_BUCKET_HISTOGRAM);
+        histogramFlavorPerMeter.put("http.server", BASE2_EXPONENTIAL_BUCKET_HISTOGRAM);
+
+        assertThat(OtlpMeterRegistry.HistogramFlavorPerMeterLookup.DEFAULT.getHistogramFlavor(histogramFlavorPerMeter,
+                createIdWithName("http.server.requests")))
+            .isEqualTo(BASE2_EXPONENTIAL_BUCKET_HISTOGRAM);
+    }
+
+    @Test
+    void longestMatchWinsByDefaultMaxBucketsPerMeter() {
+        Map<String, Integer> maxBucketsPerMeter = new HashMap<>();
+        maxBucketsPerMeter.put("http", 10);
+        maxBucketsPerMeter.put("http.server", 20);
+
+        assertThat(OtlpMeterRegistry.MaxBucketsPerMeterLookup.DEFAULT.getMaxBuckets(maxBucketsPerMeter,
+                createIdWithName("http.server.requests")))
+            .isEqualTo(20);
+    }
+
+    private Meter.Id createIdWithName(String name) {
+        return new Meter.Id(name, Tags.empty(), null, null, Meter.Type.OTHER);
+    }
+
     protected Metric writeToMetric(Meter meter) {
         OtlpMetricConverter otlpMetricConverter = new OtlpMetricConverter(clock, otlpConfig().step(),
                 registry.getBaseTimeUnit(), otlpConfig().aggregationTemporality(),


Reviews:
user: login: shakuzen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-09T07:44:17Z
user: login: jonatan-ivanov
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-10T20:10:28Z
user: login: shakuzen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T09:20:56Z
user: login: jonatan-ivanov
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-12T01:02:39Z
user: login: shakuzen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T07:23:06Z
user: login: shakuzen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T13:29:51Z
user: login: jonatan-ivanov
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-14T23:43:30Z

Review comments:
diff_hunk: @@ -229,15 +231,16 @@ default HistogramFlavor histogramFlavor() {
 
     /**
      * Configures the histogram flavor to use on a per-meter level. This will override the
-     * {@link #histogramFlavor()} configuration for matching Meters. The key is used to do
-     * an exact match on the Meter's name.
-     * @return mapping of meter name to histogram flavor
+     * {@link #histogramFlavor()} configuration for matching Meters. By default, the key
+     * is used to do an exact match on the Meter's name.
+     * @return mapping of meter ID to histogram flavor
      * @since 1.15.0
      * @see #histogramFlavor()
      */
-    default Map<String, HistogramFlavor> histogramFlavorPerMeter() {
-        return getStringMap(this, "histogramFlavorPerMeter", HistogramFlavor::fromString)
-            .orElse(Collections.emptyMap());
+    default Function<Meter.Id, HistogramFlavor> histogramFlavorPerMeter() {
path: implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpConfig.java
user: login: shakuzen
body: I made the signature `Function<Meter.Id, HistogramFlavor>` for the most flexibility, but perhaps `<String, HistogramFlavor>` is better where the `String` is the meter name. While programmatically the implementation of the `Function` could do anything, we usually try to keep it reasonable to be configured from a Properties/Map source. I don't see a reasonable implementation from properties that can be deserialized into a Function accepting a Meter.Id. I also wonder if users in practice would want to make this configuration on some basis other than name anyway.
created_at: 2025-04-09T07:06:08Z
author_association: MEMBER
diff_hunk: @@ -228,18 +229,40 @@ default HistogramFlavor histogramFlavor() {
     }
 
     /**
-     * Configures the histogram flavor to use on a per-meter level. This will override the
-     * {@link #histogramFlavor()} configuration for matching Meters. The key is used to do
-     * an exact match on the Meter's name.
+     * Configures the histogram flavor mapping to use on a per-meter level. This can
+     * override the {@link #histogramFlavor()} configuration for matching Meters. By
+     * default, {@link #histogramFlavorPerMeter(Meter.Id)} uses the result of this method
+     * to look up the {@link HistogramFlavor} by {@link Meter.Id} (exact match on the
+     * Meter's name by default). This means that this method provides the data while
+     * {@link #histogramFlavorPerMeter(Meter.Id)} provides the logic for the lookup, and
+     * you can override them independently.
      * @return mapping of meter name to histogram flavor
      * @since 1.15.0
+     * @see #histogramFlavorPerMeter(Meter.Id)
      * @see #histogramFlavor()
      */
     default Map<String, HistogramFlavor> histogramFlavorPerMeter() {
         return getStringMap(this, "histogramFlavorPerMeter", HistogramFlavor::fromString)
             .orElse(Collections.emptyMap());
     }
 
+    /**
+     * Looks up the histogram flavor to use on a per-meter level. This will override the
+     * {@link #histogramFlavor()} lookup behavior for matching Meters. By default, the key
+     * is used to do an exact match on the Meter's name in the map that
+     * {@link #histogramFlavorPerMeter()} returns. This means that
+     * {@link #histogramFlavorPerMeter()} provides the data while this method provides the
+     * logic for the lookup, and you can override them independently.
+     * @param id the {@link Meter.Id} the {@link HistogramFlavor} is configured for
+     * @return the histogram flavor mapped to the {@link Meter.Id}
+     * @since 1.15.0
+     * @see #histogramFlavorPerMeter()
+     * @see #histogramFlavor()
+     */
+    default HistogramFlavor histogramFlavorPerMeter(Meter.Id id) {
+        return histogramFlavorPerMeter().getOrDefault(id.getName(), histogramFlavor());
path: implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpConfig.java
user: login: shakuzen
body: As mentioned offline, I think we should make a type for the function that is this method's signature and make it configurable on the registry Builder rather than in the OtlpConfig. The reason is to keep logic in the registry and config in the config interface - this feels a bit too much like logic for how the config is used. Probably it would be most usable for most people for the default implementation to copy the longest starts-with match wins behavior they have in Spring Boot for some similar types of configuration properties. If that were the case, I suspect the vast majority of users would not want to customize the logic anyway, so the fact it needs to be done via the builder is not much of an issue. Thoughts?
created_at: 2025-04-11T09:20:56Z
author_association: MEMBER
diff_hunk: @@ -497,6 +503,76 @@ static double[] getSloWithPositiveInf(DistributionStatisticConfig distributionSt
         return sloWithPositiveInf;
     }
 
+    private static HistogramFlavor histogramFlavorPerMeter(OtlpConfig config, Meter.Id id) {
+        for (Map.Entry<String, HistogramFlavor> entry : config.histogramFlavorPerMeter().entrySet()) {
+            if (id.getName().startsWith(entry.getKey())) {
+                return entry.getValue();
+            }
+        }
+        return config.histogramFlavor();
+    }
+
+    private static Integer maxBucketsPerMeter(OtlpConfig config, Meter.Id id) {
+        for (Map.Entry<String, Integer> entry : config.maxBucketsPerMeter().entrySet()) {
+            if (id.getName().startsWith(entry.getKey())) {
+                return entry.getValue();
+            }
+        }
+        return config.maxBucketCount();
+    }
+
+    /**
+     * Overridable lookup mechanism for {@link HistogramFlavor}.
+     *
+     * @since 1.15.0
+     */
+    @FunctionalInterface
+    public interface HistogramFlavorPerMeterLookup {
+
+        /**
+         * Looks up the histogram flavor to use on a per-meter level. This will override
+         * the {@link OtlpConfig#histogramFlavor()} for matching Meters. The default
+         * implementation in {@link OtlpMeterRegistry} does a prefix match on the Meter's
+         * name in the map that {@link OtlpConfig#histogramFlavorPerMeter()} returns. This
+         * means that {@link OtlpConfig#histogramFlavorPerMeter()} provides the data while
+         * this method provides the logic for the lookup, and you can override them
+         * independently.
+         * @param id the {@link Meter.Id} the {@link HistogramFlavor} is configured for
+         * @return the histogram flavor mapped to the {@link Meter.Id}
+         * @see OtlpConfig#histogramFlavorPerMeter()
+         * @see OtlpConfig#histogramFlavor()
+         */
+        HistogramFlavor getHistogramFlavor(OtlpConfig config, Meter.Id id);
path: implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMeterRegistry.java
user: login: jonatan-ivanov
body: Should we make this and `MaxBucketsPerMeterLookup#getMaxBuckets` `@Nullable`?
In the current case the lookup should provide a fallback, if we make it nullable we can do that for the user.
created_at: 2025-04-12T01:01:31Z
author_association: MEMBER
diff_hunk: @@ -497,6 +503,76 @@ static double[] getSloWithPositiveInf(DistributionStatisticConfig distributionSt
         return sloWithPositiveInf;
     }
 
+    private static HistogramFlavor histogramFlavorPerMeter(OtlpConfig config, Meter.Id id) {
+        for (Map.Entry<String, HistogramFlavor> entry : config.histogramFlavorPerMeter().entrySet()) {
+            if (id.getName().startsWith(entry.getKey())) {
+                return entry.getValue();
+            }
+        }
+        return config.histogramFlavor();
+    }
+
+    private static Integer maxBucketsPerMeter(OtlpConfig config, Meter.Id id) {
+        for (Map.Entry<String, Integer> entry : config.maxBucketsPerMeter().entrySet()) {
+            if (id.getName().startsWith(entry.getKey())) {
+                return entry.getValue();
+            }
+        }
+        return config.maxBucketCount();
+    }
+
+    /**
+     * Overridable lookup mechanism for {@link HistogramFlavor}.
+     *
+     * @since 1.15.0
+     */
+    @FunctionalInterface
+    public interface HistogramFlavorPerMeterLookup {
+
+        /**
+         * Looks up the histogram flavor to use on a per-meter level. This will override
+         * the {@link OtlpConfig#histogramFlavor()} for matching Meters. The default
+         * implementation in {@link OtlpMeterRegistry} does a prefix match on the Meter's
+         * name in the map that {@link OtlpConfig#histogramFlavorPerMeter()} returns. This
+         * means that {@link OtlpConfig#histogramFlavorPerMeter()} provides the data while
+         * this method provides the logic for the lookup, and you can override them
+         * independently.
+         * @param id the {@link Meter.Id} the {@link HistogramFlavor} is configured for
+         * @return the histogram flavor mapped to the {@link Meter.Id}
+         * @see OtlpConfig#histogramFlavorPerMeter()
+         * @see OtlpConfig#histogramFlavor()
+         */
+        HistogramFlavor getHistogramFlavor(OtlpConfig config, Meter.Id id);
path: implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMeterRegistry.java
user: login: shakuzen
body: I think allowing nulls and falling back to the default value makes sense.
created_at: 2025-04-14T07:23:06Z
author_association: MEMBER
diff_hunk: @@ -497,6 +503,76 @@ static double[] getSloWithPositiveInf(DistributionStatisticConfig distributionSt
         return sloWithPositiveInf;
     }
 
+    private static HistogramFlavor histogramFlavorPerMeter(OtlpConfig config, Meter.Id id) {
+        for (Map.Entry<String, HistogramFlavor> entry : config.histogramFlavorPerMeter().entrySet()) {
+            if (id.getName().startsWith(entry.getKey())) {
+                return entry.getValue();
+            }
+        }
+        return config.histogramFlavor();
+    }
+
+    private static Integer maxBucketsPerMeter(OtlpConfig config, Meter.Id id) {
+        for (Map.Entry<String, Integer> entry : config.maxBucketsPerMeter().entrySet()) {
+            if (id.getName().startsWith(entry.getKey())) {
+                return entry.getValue();
+            }
+        }
+        return config.maxBucketCount();
+    }
+
+    /**
+     * Overridable lookup mechanism for {@link HistogramFlavor}.
+     *
+     * @since 1.15.0
+     */
+    @FunctionalInterface
+    public interface HistogramFlavorPerMeterLookup {
+
+        /**
+         * Looks up the histogram flavor to use on a per-meter level. This will override
+         * the {@link OtlpConfig#histogramFlavor()} for matching Meters. The default
+         * implementation in {@link OtlpMeterRegistry} does a prefix match on the Meter's
+         * name in the map that {@link OtlpConfig#histogramFlavorPerMeter()} returns. This
+         * means that {@link OtlpConfig#histogramFlavorPerMeter()} provides the data while
+         * this method provides the logic for the lookup, and you can override them
+         * independently.
+         * @param id the {@link Meter.Id} the {@link HistogramFlavor} is configured for
+         * @return the histogram flavor mapped to the {@link Meter.Id}
+         * @see OtlpConfig#histogramFlavorPerMeter()
+         * @see OtlpConfig#histogramFlavor()
+         */
+        HistogramFlavor getHistogramFlavor(OtlpConfig config, Meter.Id id);
path: implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMeterRegistry.java
user: login: shakuzen
body: I've updated the signature along with incorporating this change such that the mapping function should not deal with the default value other than to return `null`. This feels cleaner than the expectation of implementations calling specific methods on OtlpConfig
created_at: 2025-04-14T13:29:50Z
author_association: MEMBER

PR comments:
user: login: shakuzen
created_at: 2025-04-15T04:05:55Z
body: Final change: remove the API to customize the lookup logic and just change the logic so it is more usable, which solves the original ask without adding more API that doesn't seem necessary (yet).
author_association: MEMBER
user: login: shakuzen
created_at: 2025-04-15T04:11:15Z
body: Thanks to @lenin-jaganathan for the offline review of these changes.
author_association: MEMBER