Skip to content

Make per-meter OTLP configuration more flexible #6102

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 15, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -228,11 +229,18 @@ 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 OtlpMeterRegistry} (through
* {@link io.micrometer.registry.otlp.OtlpMeterRegistry.HistogramFlavorPerMeterLookup})
* uses the result of this method to look up the {@link HistogramFlavor} by
* {@link Meter.Id} (prefix match on the Meter's name by default). This means that
* this method provides the data while
* {@link io.micrometer.registry.otlp.OtlpMeterRegistry.HistogramFlavorPerMeterLookup}
* 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 io.micrometer.registry.otlp.OtlpMeterRegistry.HistogramFlavorPerMeterLookup
* @see #histogramFlavor()
*/
default Map<String, HistogramFlavor> histogramFlavorPerMeter() {
Expand Down Expand Up @@ -266,12 +274,20 @@ 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. By
* default, {@link OtlpMeterRegistry} (through
* {@link io.micrometer.registry.otlp.OtlpMeterRegistry.MaxBucketsPerMeterLookup})
* uses the result of this method to look up the max bucket count by {@link Meter.Id}
* (prefix match on the Meter's name by default). This means that this method provides
* the data while
* {@link io.micrometer.registry.otlp.OtlpMeterRegistry.MaxBucketsPerMeterLookup}
* 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.
* @return mapping of meter name to max bucket count
* @since 1.15.0
* @see io.micrometer.registry.otlp.OtlpMeterRegistry.MaxBucketsPerMeterLookup
* @see #maxBucketCount()
*/
default Map<String, Integer> maxBucketsPerMeter() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -111,15 +116,19 @@ public OtlpMeterRegistry(OtlpConfig config, Clock clock) {
* @since 1.14.0
*/
public OtlpMeterRegistry(OtlpConfig config, Clock clock, ThreadFactory threadFactory) {
this(config, clock, threadFactory, new OtlpHttpMetricsSender(new HttpUrlConnectionSender()));
this(config, clock, threadFactory, new OtlpHttpMetricsSender(new HttpUrlConnectionSender()),
HistogramFlavorPerMeterLookup.DEFAULT, MaxBucketsPerMeterLookup.DEFAULT);
}

private OtlpMeterRegistry(OtlpConfig config, Clock clock, ThreadFactory threadFactory,
OtlpMetricsSender metricsSender) {
OtlpMetricsSender metricsSender, HistogramFlavorPerMeterLookup histogramFlavorPerMeterLookup,
MaxBucketsPerMeterLookup maxBucketsPerMeterLookup) {
super(config, clock);
this.config = config;
this.baseTimeUnit = config.baseTimeUnit();
this.metricsSender = metricsSender;
this.histogramFlavorPerMeterLookup = histogramFlavorPerMeterLookup;
this.maxBucketsPerMeterLookup = maxBucketsPerMeterLookup;
this.resource = Resource.newBuilder().addAllAttributes(getResourceAttributes()).build();
this.aggregationTemporality = config.aggregationTemporality();
config().namingConvention(NamingConvention.dot);
Expand Down Expand Up @@ -430,14 +439,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()
Expand Down Expand Up @@ -497,6 +508,93 @@ static double[] getSloWithPositiveInf(DistributionStatisticConfig distributionSt
return sloWithPositiveInf;
}

/**
* Overridable lookup mechanism for {@link HistogramFlavor}.
*
* @since 1.15.0
*/
@FunctionalInterface
public 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.
*
* @since 1.15.0
*/
@FunctionalInterface
public 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}.
*
Expand All @@ -512,9 +610,15 @@ public static class Builder {

private OtlpMetricsSender metricsSender;

private HistogramFlavorPerMeterLookup histogramFlavorPerMeterLookup;

private MaxBucketsPerMeterLookup maxBucketsPerMeterLookup;

private Builder(OtlpConfig otlpConfig) {
this.otlpConfig = otlpConfig;
this.metricsSender = new OtlpHttpMetricsSender(new HttpUrlConnectionSender());
this.histogramFlavorPerMeterLookup = HistogramFlavorPerMeterLookup.DEFAULT;
this.maxBucketsPerMeterLookup = MaxBucketsPerMeterLookup.DEFAULT;
}

/** Override the default clock. */
Expand All @@ -540,8 +644,43 @@ public Builder metricsSender(OtlpMetricsSender metricsSender) {
return this;
}

/**
* Override the default matching behavior to use with
* {@link OtlpConfig#histogramFlavorPerMeter()}. The default behavior is longest
* dot-separated match wins. For example, if
* {@link OtlpConfig#histogramFlavorPerMeter()} 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}.
*
* @see OtlpConfig#histogramFlavorPerMeter()
*/
public Builder histogramFlavorPerMeterLookup(HistogramFlavorPerMeterLookup histogramFlavorPerMeterLookup) {
this.histogramFlavorPerMeterLookup = histogramFlavorPerMeterLookup;
return this;
}

/**
* Override the default matching behavior to use with
* {@link OtlpConfig#maxBucketsPerMeter()}. The default behavior is longest
* dot-separated match wins. For example, if
* {@link OtlpConfig#maxBucketsPerMeter()} 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}.
*
* @see OtlpConfig#maxBucketsPerMeter()
*/
public Builder maxBucketsPerMeterLookup(MaxBucketsPerMeterLookup maxBucketsPerMeterLookup) {
this.maxBucketsPerMeterLookup = maxBucketsPerMeterLookup;
return this;
}

public OtlpMeterRegistry build() {
return new OtlpMeterRegistry(otlpConfig, clock, threadFactory, metricsSender);
return new OtlpMeterRegistry(otlpConfig, clock, threadFactory, metricsSender, histogramFlavorPerMeterLookup,
maxBucketsPerMeterLookup);
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -257,24 +257,22 @@ 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
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
Expand All @@ -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
Expand Down
Loading