From 5965e0de4837f86cd885bf381e9fe41ae4ed236b Mon Sep 17 00:00:00 2001 From: KR Ravindra <42912207+KR-Ravindra@users.noreply.github.com> Date: Sun, 13 Sep 2026 06:20:04 +0000 Subject: [PATCH] fix: drop +Inf bound from OpenTelemetry classic histogram boundaries OpenTelemetry explicit bucket boundaries must be finite, with the +Inf bucket implicit (counts.size() == boundaries.size() + 1). PrometheusClassicHistogram.makeBoundaries() copied the trailing +Inf upper bound, so the exported HistogramPointData had as many counts as boundaries and the OTLP explicit_bounds contained Infinity. Skip the +Inf bound; the non-cumulative counts already end with the +Inf bucket count. Signed-off-by: KR Ravindra <42912207+KR-Ravindra@users.noreply.github.com> --- .../otelmodel/PrometheusClassicHistogram.java | 11 ++- .../exporter/opentelemetry/ExportTest.java | 2 +- .../PrometheusClassicHistogramTest.java | 97 +++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/PrometheusClassicHistogramTest.java diff --git a/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/PrometheusClassicHistogram.java b/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/PrometheusClassicHistogram.java index 1a2152fe0..d3ac7dc5f 100644 --- a/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/PrometheusClassicHistogram.java +++ b/prometheus-metrics-exporter-opentelemetry/src/main/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/PrometheusClassicHistogram.java @@ -67,10 +67,19 @@ private long calculateCount(ClassicHistogramBuckets buckets) { return result; } + /** + * OpenTelemetry explicit bucket boundaries are finite: the {@code +Inf} bucket is implicit, so + * {@code counts} has one more entry than {@code boundaries}. The count of the {@code +Inf} bucket + * is kept as the last element of {@link #makeCounts(ClassicHistogramBuckets)}. + */ private List makeBoundaries(ClassicHistogramBuckets buckets) { List result = new ArrayList<>(buckets.size()); for (int i = 0; i < buckets.size(); i++) { - result.add(buckets.getUpperBound(i)); + double upperBound = buckets.getUpperBound(i); + if (upperBound == Double.POSITIVE_INFINITY) { + continue; + } + result.add(upperBound); } return result; } diff --git a/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/ExportTest.java b/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/ExportTest.java index 4329df4b8..467015194 100644 --- a/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/ExportTest.java +++ b/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/ExportTest.java @@ -134,7 +134,7 @@ void histogram() { assertThat(p.getEpochNanos()).isPositive(); }) .hasExemplars() - .hasBucketBoundaries(1, 2, 3, Double.POSITIVE_INFINITY) + .hasBucketBoundaries(1, 2, 3) .hasBucketCounts(1, 0, 0, 0))); } diff --git a/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/PrometheusClassicHistogramTest.java b/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/PrometheusClassicHistogramTest.java new file mode 100644 index 000000000..5ab0f3be5 --- /dev/null +++ b/prometheus-metrics-exporter-opentelemetry/src/test/java/io/prometheus/metrics/exporter/opentelemetry/otelmodel/PrometheusClassicHistogramTest.java @@ -0,0 +1,97 @@ +package io.prometheus.metrics.exporter.opentelemetry.otelmodel; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import io.opentelemetry.exporter.internal.otlp.metrics.MetricsRequestMarshaler; +import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest; +import io.opentelemetry.proto.metrics.v1.HistogramDataPoint; +import io.opentelemetry.sdk.common.InstrumentationScopeInfo; +import io.opentelemetry.sdk.metrics.data.HistogramPointData; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.metrics.internal.data.ImmutableHistogramPointData; +import io.opentelemetry.sdk.resources.Resource; +import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets; +import io.prometheus.metrics.model.snapshots.HistogramSnapshot; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Collections; +import java.util.Objects; +import org.junit.jupiter.api.Test; + +class PrometheusClassicHistogramTest { + + // Prometheus classic buckets: le=1 -> 2, le=5 -> 3, le=+Inf -> 4 (non-cumulative counts). + private static final HistogramSnapshot SNAPSHOT = + HistogramSnapshot.builder() + .name("request_size_bytes") + .dataPoint( + HistogramSnapshot.HistogramDataPointSnapshot.builder() + .classicHistogramBuckets( + ClassicHistogramBuckets.of( + new double[] {1.0, 5.0, Double.POSITIVE_INFINITY}, new long[] {2, 3, 4})) + .sum(42.0) + .build()) + .build(); + + private static HistogramPointData toOtelPoint() { + PrometheusClassicHistogram histogram = new PrometheusClassicHistogram(SNAPSHOT, 1_000L); + assertThat(histogram.getPoints()).hasSize(1); + return histogram.getPoints().iterator().next(); + } + + @Test + void infBucketIsImplicitInOtelBoundaries() { + HistogramPointData point = toOtelPoint(); + // OTel explicit bounds are finite; the +Inf bucket is implicit, so counts has one more entry. + assertThat(point.getBoundaries()).containsExactly(1.0, 5.0); + assertThat(point.getBoundaries()).noneMatch(bound -> bound.isInfinite()); + assertThat(point.getCounts()).containsExactly(2L, 3L, 4L); + assertThat(point.getCounts()).hasSize(point.getBoundaries().size() + 1); + assertThat(point.getCount()).isEqualTo(9); + assertThat(point.getSum()).isEqualTo(42.0); + } + + @Test + void pointSatisfiesOtelSdkHistogramContract() { + HistogramPointData point = toOtelPoint(); + // The OTel SDK's own histogram point implementation validates the data model contract. + assertThatCode( + () -> + ImmutableHistogramPointData.create( + point.getStartEpochNanos(), + point.getEpochNanos(), + point.getAttributes(), + point.getSum(), + point.hasMin(), + point.getMin(), + point.hasMax(), + point.getMax(), + point.getBoundaries(), + point.getCounts(), + point.getExemplars())) + .doesNotThrowAnyException(); + } + + @Test + void otlpExplicitBoundsAreFinite() throws IOException { + MetricDataFactory factory = + new MetricDataFactory( + Resource.empty(), InstrumentationScopeInfo.create("test"), 1_000L, false); + MetricData metricData = Objects.requireNonNull(factory.create(SNAPSHOT)); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + MetricsRequestMarshaler.create(Collections.singletonList(metricData)).writeBinaryTo(out); + HistogramDataPoint dataPoint = + ExportMetricsServiceRequest.parseFrom(out.toByteArray()) + .getResourceMetrics(0) + .getInstrumentationLibraryMetrics(0) + .getMetrics(0) + .getHistogram() + .getDataPoints(0); + + assertThat(dataPoint.getExplicitBoundsList()).containsExactly(1.0, 5.0); + assertThat(dataPoint.getBucketCountsList()).containsExactly(2L, 3L, 4L); + assertThat(dataPoint.getCount()).isEqualTo(9); + } +}