diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index eb19f4014c8..b89aadb1915 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -7,6 +7,10 @@ Notes](../../RELEASENOTES.md). ## Unreleased +* Fixed metric unit strings containing invalid Prometheus characters (e.g. `# RU`) + not being sanitized, resulting in malformed metric names. + ([#6187](https://github.com/open-telemetry/opentelemetry-dotnet/issues/6187)) + ## 1.15.2-beta.1 Released 2026-Apr-08 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index 477525dbe4d..1c19651dc39 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -7,6 +7,10 @@ Notes](../../RELEASENOTES.md). ## Unreleased +* Fixed metric unit strings containing invalid Prometheus characters (e.g. `# RU`) + not being sanitized, resulting in malformed metric names. + ([#6187](https://github.com/open-telemetry/opentelemetry-dotnet/issues/6187)) + ## 1.15.2-beta.1 Released 2026-Apr-08 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs index df1632ca467..73665c7a75a 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs @@ -77,6 +77,39 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa public static PrometheusMetric Create(Metric metric, bool disableTotalNameSuffixForCounters) => new(metric.Name, metric.Unit, GetPrometheusType(metric.MetricType), disableTotalNameSuffixForCounters); + internal static string SanitizeMetricUnit(string metricUnit) + { + StringBuilder? sb = null; + var lastCharUnderscore = false; + + for (var i = 0; i < metricUnit.Length; i++) + { + var c = metricUnit[i]; + + if (!char.IsLetterOrDigit(c) && c != ':') + { + if (!lastCharUnderscore) + { + lastCharUnderscore = true; + sb ??= new StringBuilder(metricUnit, 0, i, metricUnit.Length); + sb.Append('_'); + } + } + else + { + if (sb != null) + { + sb.Append(c); + } + + lastCharUnderscore = false; + } + } + + var result = sb?.ToString() ?? metricUnit; + return result.Trim('_'); + } + internal static string SanitizeMetricName(string metricName) { StringBuilder? sb = null; @@ -112,11 +145,6 @@ internal static string SanitizeMetricName(string metricName) } return sb?.ToString() ?? metricName; - - static StringBuilder CreateStringBuilder(string name) - { - return new(name.Length); - } } internal static string RemoveAnnotations(string unit) @@ -186,6 +214,11 @@ UpDownCounter becomes gauge }; } + private static StringBuilder CreateStringBuilder(string value) + { + return new(value.Length); + } + private static string SanitizeOpenMetricsName(string metricName) => metricName.EndsWith("_total", StringComparison.Ordinal) ? metricName.Substring(0, metricName.Length - 6) : metricName; @@ -208,7 +241,7 @@ private static string GetUnit(string unit) updatedUnit = MapUnit(updatedUnit.AsSpan()); } - return updatedUnit; + return SanitizeMetricUnit(updatedUnit); } private static bool TryProcessRateUnits(string updatedUnit, [NotNullWhen(true)] out string? updatedPerUnit) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs index 5e8856f4843..785253c25a2 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs @@ -361,7 +361,43 @@ public void GetPrometheusType_HistogramVariants_ReturnsHistogram(int metricTypeV [Fact] public void Name_MultipleSlashesInUnit_FirstSlashProcessed() - => AssertName("metric", "req/s/extra", PrometheusType.Gauge, false, "metric_req_per_s/extra"); // // Multiple slashes + => AssertName("metric", "req/s/extra", PrometheusType.Gauge, false, "metric_req_per_s_extra"); // // Multiple slashes + + [Fact] + public void SanitizeMetricUnit_Valid() + => AssertSanitizeMetricUnit("requests", "requests"); + + [Fact] + public void SanitizeMetricUnit_RemoveConsecutiveUnderscores() + => AssertSanitizeMetricUnit("req__per__s", "req_per_s"); + + [Fact] + public void SanitizeMetricUnit_RemoveUnsupportedCharacters() + => AssertSanitizeMetricUnit("# RU", "RU"); + + [Fact] + public void SanitizeMetricUnit_RemoveWhitespace() + => AssertSanitizeMetricUnit("req s", "req_s"); + + [Fact] + public void SanitizeMetricUnit_LeadingNumberAllowed() + => AssertSanitizeMetricUnit("2_unitname", "2_unitname"); + + [Fact] + public void SanitizeMetricUnit_RemoveMultipleUnsupportedCharacters() + => AssertSanitizeMetricUnit("##/RU!", "RU"); + + [Fact] + public void Name_UnitWithHash_Sanitized() + => AssertName("azure_cosmosdb_client_operation_request_charge", "# RU", PrometheusType.Histogram, false, "azure_cosmosdb_client_operation_request_charge_RU"); + + [Fact] + public void Name_UnitWithSpace_Sanitized() + => AssertName("metric", "req s", PrometheusType.Gauge, false, "metric_req_s"); + + [Fact] + public void Name_UnitWithSpecialChars_Sanitized() + => AssertName("metric", "req!", PrometheusType.Gauge, false, "metric_req"); [Theory] [InlineData(PrometheusType.Counter)] @@ -375,6 +411,12 @@ internal void Constructor_AllPrometheusTypes_Work(PrometheusType type) Assert.Equal(type, metric.Type); } + private static void AssertSanitizeMetricUnit(string unit, string expected) + { + var sanitizedUnit = PrometheusMetric.SanitizeMetricUnit(unit); + Assert.Equal(expected, sanitizedUnit); + } + private static void AssertName( string name, string unit, PrometheusType type, bool disableTotalNameSuffixForCounters, string expected) {