diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 8e54a69b8db..b0a13968c9e 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -13,6 +13,12 @@ Notes](../../RELEASENOTES.md). * The library is now marked as trim and AOT compatible. ([#7441](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7441)) +* Fix double unit suffixes in metric names when using OpenMetrics. + ([#7454](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7454)) + +* Fix incorrect handling of leading digits in metric names for OpenMetrics. + ([#7454](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7454)) + ## 1.16.0-beta.1 Released 2026-Jun-10 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index ad805c23a60..74f85cabbab 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -16,6 +16,12 @@ Notes](../../RELEASENOTES.md). * Removed the `PrometheusHttpListenerOptions.UriPrefixes` option. ([#7435](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7435)) +* Fix double unit suffixes in metric names when using OpenMetrics. + ([#7454](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7454)) + +* Fix incorrect handling of leading digits in metric names for OpenMetrics. + ([#7454](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7454)) + ## 1.16.0-beta.1 Released 2026-Jun-10 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusMetric.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusMetric.cs index ad9025c021c..57a22e875a9 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusMetric.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusMetric.cs @@ -17,7 +17,9 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa // consecutive `_` characters MUST be replaced with a single `_` character. // https://github.com/open-telemetry/opentelemetry-specification/blob/b2f923fb1650dde1f061507908b834035506a796/specification/compatibility/prometheus_and_openmetrics.md#L230-L233 var sanitizedName = SanitizeMetricName(name); - var openMetricsName = RemoveOpenMetricsCounterNameSuffix(name); + var openMetricsName = type == PrometheusType.Counter + ? RemoveOpenMetricsCounterNameSuffix(name) + : name; string? sanitizedUnit = null; if (!string.IsNullOrEmpty(unit)) @@ -28,9 +30,18 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa // [OpenMetrics UNIT metadata](https://github.com/prometheus/OpenMetrics/blob/v1.0.0/specification/OpenMetrics.md#metricfamily) // and as a suffix to the metric name. The unit suffix comes before any type-specific suffixes. // https://github.com/open-telemetry/opentelemetry-specification/blob/3dfb383fe583e3b74a2365c5a1d90256b273ee76/specification/compatibility/prometheus_and_openmetrics.md#metric-metadata-1 - if (!sanitizedName.EndsWith(sanitizedUnit, StringComparison.Ordinal)) + // Each name is checked independently: openMetricsName has the _total counter suffix stripped + // (by RemoveOpenMetricsCounterNameSuffix above), so it may already end with the unit even + // when sanitizedName (which still carries _total) does not. For counter sanitizedName, also + // check for the unit immediately before _total (e.g. "db_bytes_total" with unit "bytes"). + if (!sanitizedName.EndsWith(sanitizedUnit, StringComparison.Ordinal) && + (type != PrometheusType.Counter || !sanitizedName.EndsWith($"{sanitizedUnit}_total", StringComparison.Ordinal))) { sanitizedName += $"_{sanitizedUnit}"; + } + + if (!openMetricsName.EndsWith(sanitizedUnit, StringComparison.Ordinal)) + { openMetricsName += $"_{sanitizedUnit}"; } } @@ -189,6 +200,7 @@ internal static string EscapeOpenMetricsName(string metricName) sb ??= CreateStringBuilder(metricName); sb.Append('_'); lastCharUnderscore = true; + continue; } if (!char.IsAsciiLetterOrDigit(c) && c != ':') diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs index dffc1adc004..6f161fe3bf6 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs @@ -147,6 +147,14 @@ public void Name_UnitAlreadyPresentInName_CustomGauge_NotAppended() public void Name_UnitAlreadyPresentInName_CustomCounter_NotAppended() => AssertName("metric_hertz_total", "hertz_total", PrometheusType.Counter, false, "metric_hertz_total"); + [Fact] + public void Name_CounterWithUnitPrecedingTotal_UnitNotDuplicated() + => AssertName("db_bytes_total", "By", PrometheusType.Counter, false, "db_bytes_total"); + + [Fact] + public void Name_NonCounterWithUnitPrecedingTotal_UnitAppended() + => AssertName("db_bytes_total", "By", PrometheusType.Gauge, false, "db_bytes_total_bytes"); + [Fact] public void Name_UnitAlreadyPresentInName_OrderMatters_Appended() => AssertName("metric_total_hertz", "hertz_total", PrometheusType.Counter, false, "metric_total_hertz_hertz_total"); @@ -164,8 +172,24 @@ public void OpenMetricsName_CollapsesConsecutiveUnderscores() => AssertOpenMetricsName("cpu_sp__d_hertz", string.Empty, PrometheusType.Gauge, false, "cpu_sp_d_hertz"); [Fact] - public void OpenMetricsName_PreserveLeadingNumber() - => AssertOpenMetricsName("2_metric_name", "By", PrometheusType.Gauge, false, "_2_metric_name_bytes"); + public void OpenMetricsName_ReplacesLeadingNumber() + => AssertOpenMetricsName("2_metric_name", "By", PrometheusType.Gauge, false, "_metric_name_bytes"); + + [Fact] + public void OpenMetricsName_CounterWithUnitPrecedingTotal_UnitNotDuplicated() + => AssertOpenMetricsName("db_bytes_total", "By", PrometheusType.Counter, false, "db_bytes_total"); + + [Fact] + public void OpenMetricsMetadataName_CounterWithUnitPrecedingTotal_UnitNotDuplicated() + => AssertOpenMetricsMetadataName("db_bytes_total", "By", PrometheusType.Counter, false, "db_bytes"); + + [Fact] + public void OpenMetricsName_NonCounterWithUnitPrecedingTotal_UnitAppended() + => AssertOpenMetricsName("db_bytes_total", "By", PrometheusType.Gauge, false, "db_bytes_total_bytes"); + + [Fact] + public void OpenMetricsMetadataName_NonCounterWithUnitPrecedingTotal_UnitAppended() + => AssertOpenMetricsMetadataName("db_bytes_total", "By", PrometheusType.Gauge, false, "db_bytes_total_bytes"); [Fact] public void OpenMetricsName_UnitStartingWithNumber_DoesNotAddExtraSeparator() diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusProtocolTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusProtocolTests.cs index 2dff54d01ae..c5407580da4 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusProtocolTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusProtocolTests.cs @@ -711,4 +711,68 @@ public void Equals_Object_BoxingScenario() Assert.True(boxed1.Equals(boxed2)); Assert.True(boxed2.Equals(boxed1)); } + + [Fact] + public void GetContentType_PrometheusTextV0_IsCorrect() + { + var protocol = new PrometheusProtocol( + PrometheusProtocol.PrometheusTextMediaType, + null, + PrometheusProtocol.PrometheusV0, + false); + + Assert.Equal("text/plain; version=0.0.4; charset=utf-8", PrometheusProtocol.GetContentType(protocol)); + } + + [Fact] + public void GetContentType_PrometheusTextV1_IsCorrect() + { + var protocol = new PrometheusProtocol( + PrometheusProtocol.PrometheusTextMediaType, + PrometheusProtocol.UnderscoresEscaping, + PrometheusProtocol.PrometheusV1, + false); + + Assert.Equal("text/plain; version=1.0.0; charset=utf-8; escaping=underscores", PrometheusProtocol.GetContentType(protocol)); + } + + [Fact] + public void GetContentType_OpenMetricsV0_IsCorrect() + { + var protocol = new PrometheusProtocol( + PrometheusProtocol.OpenMetricsMediaType, + null, + PrometheusProtocol.OpenMetricsV0, + true); + + Assert.Equal("application/openmetrics-text; version=0.0.1; charset=utf-8", PrometheusProtocol.GetContentType(protocol)); + } + + [Fact] + public void GetContentType_OpenMetricsV1_IsCorrect() + { + var protocol = new PrometheusProtocol( + PrometheusProtocol.OpenMetricsMediaType, + PrometheusProtocol.UnderscoresEscaping, + PrometheusProtocol.OpenMetricsV1, + true); + + Assert.Equal("application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores", PrometheusProtocol.GetContentType(protocol)); + } + + [Fact] + public void GetContentType_FallbackProtocol_IsCorrect() => + Assert.Equal("text/plain; version=0.0.4; charset=utf-8", PrometheusProtocol.GetContentType(PrometheusProtocol.Fallback)); + + [Fact] + public void GetContentType_ToStringDelegatesToGetContentType() + { + var protocol = new PrometheusProtocol( + PrometheusProtocol.OpenMetricsMediaType, + null, + PrometheusProtocol.OpenMetricsV1, + true); + + Assert.Equal(PrometheusProtocol.GetContentType(protocol), protocol.ToString()); + } }