diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 78e71be871a..c40c52b79a2 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -38,7 +38,7 @@ Notes](../../RELEASENOTES.md). ([#7219](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7219)) * Fix Prometheus/OpenMetrics serialization to emit metric and label names - containing `:` and `_` instead of dropping them and prefixing leading digits. + containing `_` instead of dropping them and prefixing leading digits. Invalid characters are replaced with `_` instead of being dropped. ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209)) @@ -46,6 +46,10 @@ Notes](../../RELEASENOTES.md). negotiation so OpenMetrics are handled correctly. ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209)) +* Omit histogram `_sum` and `_count` in OpenMetrics when negative bucket + thresholds are present. + ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221)) + ## 1.15.3-beta.1 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index b83d4cb042a..20d62fc4ddd 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -55,7 +55,7 @@ Notes](../../RELEASENOTES.md). ([#7167](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7167)) * Fix Prometheus/OpenMetrics serialization to emit metric and label names - containing `:` and `_` instead of dropping them and prefixing leading digits. + containing and `_` instead of dropping them and prefixing leading digits. Invalid characters are replaced with `_` instead of being dropped. ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209)) @@ -63,6 +63,10 @@ Notes](../../RELEASENOTES.md). negotiation so OpenMetrics are handled correctly. ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209)) +* Omit histogram `_sum` and `_count` in OpenMetrics when negative bucket + thresholds are present. + ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221)) + ## 1.15.3-beta.1 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs index e0f04c00354..38a2943a8b0 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -89,11 +89,17 @@ public static int WriteMetric( foreach (ref readonly var metricPoint in metric.GetMetricPoints()) { var tags = metricPoint.Tags; + var hasNegativeBucketBounds = false; var previousBound = double.NegativeInfinity; long totalCount = 0; foreach (var histogramMeasurement in metricPoint.GetHistogramBuckets()) { + if (openMetricsRequested && histogramMeasurement.ExplicitBound < 0) + { + hasNegativeBucketBounds = true; + } + totalCount += histogramMeasurement.BucketCount; cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); @@ -120,27 +126,32 @@ public static int WriteMetric( previousBound = histogramMeasurement.ExplicitBound; } - // Histogram sum - cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); - cursor = WriteAsciiStringNoEscape(buffer, cursor, "_sum"); - cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags, openMetricsRequested); + if (!openMetricsRequested || !hasNegativeBucketBounds) + { + // OpenMetrics histograms with negative bucket thresholds MUST NOT expose + // _sum and therefore MUST NOT expose _count. + // See https://prometheus.io/docs/specs/om/open_metrics_spec/#histogram-1 + cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); + cursor = WriteAsciiStringNoEscape(buffer, cursor, "_sum"); + cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags, openMetricsRequested); - buffer[cursor++] = unchecked((byte)' '); + buffer[cursor++] = unchecked((byte)' '); - cursor = WriteDouble(buffer, cursor, metricPoint.GetHistogramSum()); + cursor = WriteDouble(buffer, cursor, metricPoint.GetHistogramSum()); - buffer[cursor++] = ASCII_LINEFEED; + buffer[cursor++] = ASCII_LINEFEED; - // Histogram count - cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); - cursor = WriteAsciiStringNoEscape(buffer, cursor, "_count"); - cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags, openMetricsRequested); + // Histogram count + cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); + cursor = WriteAsciiStringNoEscape(buffer, cursor, "_count"); + cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags, openMetricsRequested); - buffer[cursor++] = unchecked((byte)' '); + buffer[cursor++] = unchecked((byte)' '); - cursor = WriteLong(buffer, cursor, metricPoint.GetHistogramCount()); + cursor = WriteLong(buffer, cursor, metricPoint.GetHistogramCount()); - buffer[cursor++] = ASCII_LINEFEED; + buffer[cursor++] = ASCII_LINEFEED; + } } } diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 3f5675fbf26..35411b2b4c6 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -997,6 +997,35 @@ public void HistogramOneDimensionWithScopeVersion() Encoding.UTF8.GetString(buffer, 0, cursor)); } + [Fact] + public void HistogramWithNegativeBucketBoundsOmitsSumAndCountWithOpenMetricsFormat() + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = new Meter(Utils.GetCurrentMethodName()); + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddView(instrument => new ExplicitBucketHistogramConfiguration { Boundaries = [-1, 0, 1] }) + .AddInMemoryExporter(metrics) + .Build(); + + var histogram = meter.CreateHistogram("test_histogram"); + histogram.Record(-0.5, new KeyValuePair("x", "1")); + + provider.ForceFlush(); + + var cursor = WriteMetric(buffer, 0, metrics[0], true); + var output = Encoding.UTF8.GetString(buffer, 0, cursor); + + Assert.Contains($"test_histogram_bucket{{otel_scope_name=\"{Utils.GetCurrentMethodName()}\",x=\"1\",le=\"-1\"}} 0\n", output, StringComparison.Ordinal); + Assert.Contains($"test_histogram_bucket{{otel_scope_name=\"{Utils.GetCurrentMethodName()}\",x=\"1\",le=\"0\"}} 1\n", output, StringComparison.Ordinal); + Assert.Contains($"test_histogram_bucket{{otel_scope_name=\"{Utils.GetCurrentMethodName()}\",x=\"1\",le=\"1\"}} 1\n", output, StringComparison.Ordinal); + Assert.Contains($"test_histogram_bucket{{otel_scope_name=\"{Utils.GetCurrentMethodName()}\",x=\"1\",le=\"+Inf\"}} 1\n", output, StringComparison.Ordinal); + Assert.DoesNotContain("test_histogram_sum{", output, StringComparison.Ordinal); + Assert.DoesNotContain("test_histogram_count{", output, StringComparison.Ordinal); + } + [Fact] public void WriteAsciiStringNoEscapeWritesAsciiBytes() {