From 6916c84b41189abec22522345d5fb4c43379cbd1 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 25 Apr 2026 14:51:49 +0100 Subject: [PATCH 01/82] [Prometheus.HttpListener] Add env vars support - Add support for configuring OpenTelemetry.Exporter.Prometheus.HttpListener with the `OTEL_EXPORTER_PROMETHEUS_HOST` and `OTEL_EXPORTER_PROMETHEUS_PORT` environment variables. - Remove field for UriPrefixes and use auto-property. - Remove `UriPrefixes` from the README. Fixes #4158. Fixes #7154. --- .../CHANGELOG.md | 5 ++ .../Internal/PrometheusExporterEventSource.cs | 28 +++---- ...ry.Exporter.Prometheus.HttpListener.csproj | 2 + .../PrometheusHttpListenerOptions.cs | 39 ++++++++-- .../README.md | 56 +++++++++++--- ...orter.Prometheus.HttpListener.Tests.csproj | 1 + ...enerMeterProviderBuilderExtensionsTests.cs | 77 +++++++++++++++++++ 7 files changed, 177 insertions(+), 31 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index 37e1f4f3343..60fb381a223 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -46,6 +46,11 @@ Notes](../../RELEASENOTES.md). * Emit OpenMetrics exemplars for counters and histogram buckets. ([#7222](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7222)) +* Add support for configuring the HTTP listener endpoint host and port using + the `OTEL_EXPORTER_PROMETHEUS_HOST` and `OTEL_EXPORTER_PROMETHEUS_PORT` + environment variables. + ([#7167](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7167)) + ## 1.15.3-beta.1 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs index a55cc6ef553..ab58347170e 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using System.Diagnostics.Tracing; +using Microsoft.Extensions.Configuration; using OpenTelemetry.Internal; namespace OpenTelemetry.Exporter.Prometheus; @@ -10,9 +11,9 @@ namespace OpenTelemetry.Exporter.Prometheus; /// EventSource events emitted from the project. /// [EventSource(Name = "OpenTelemetry-Exporter-Prometheus")] -internal sealed class PrometheusExporterEventSource : EventSource +internal sealed class PrometheusExporterEventSource : EventSource, IConfigurationExtensionsLogger { - public static PrometheusExporterEventSource Log = new(); + public static readonly PrometheusExporterEventSource Log = new(); [NonEvent] public void FailedExport(Exception ex) @@ -43,25 +44,24 @@ public void CanceledExport(Exception ex) [Event(1, Message = "Failed to export metrics: '{0}'", Level = EventLevel.Error)] public void FailedExport(string exception) - { - this.WriteEvent(1, exception); - } + => this.WriteEvent(1, exception); [Event(2, Message = "Canceled to export metrics: '{0}'", Level = EventLevel.Error)] public void CanceledExport(string exception) - { - this.WriteEvent(2, exception); - } + => this.WriteEvent(2, exception); [Event(3, Message = "Failed to shutdown Metrics server '{0}'", Level = EventLevel.Error)] public void FailedShutdown(string exception) - { - this.WriteEvent(3, exception); - } + => this.WriteEvent(3, exception); [Event(4, Message = "No metrics are available for export.", Level = EventLevel.Warning)] public void NoMetrics() - { - this.WriteEvent(4); - } + => this.WriteEvent(4); + + [Event(5, Message = "Configuration key '{0}' has an invalid value: '{1}'", Level = EventLevel.Warning)] + public void InvalidConfigurationValue(string key, string value) + => this.WriteEvent(5, key, value); + + void IConfigurationExtensionsLogger.LogInvalidConfigurationValue(string key, string value) + => this.InvalidConfigurationValue(key, value); } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/OpenTelemetry.Exporter.Prometheus.HttpListener.csproj b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/OpenTelemetry.Exporter.Prometheus.HttpListener.csproj index 18268381e0e..b0a1510524e 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/OpenTelemetry.Exporter.Prometheus.HttpListener.csproj +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/OpenTelemetry.Exporter.Prometheus.HttpListener.csproj @@ -17,6 +17,8 @@ + + diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListenerOptions.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListenerOptions.cs index 016571cc3e1..ffb55d69b2a 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListenerOptions.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListenerOptions.cs @@ -1,6 +1,8 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +using Microsoft.Extensions.Configuration; +using OpenTelemetry.Exporter.Prometheus; using OpenTelemetry.Internal; namespace OpenTelemetry.Exporter; @@ -15,7 +17,32 @@ public class PrometheusHttpListenerOptions /// internal const string DefaultScrapeEndpointPath = "/metrics"; - private IReadOnlyCollection uriPrefixes = ["http://localhost:9464/"]; + internal const string PrometheusHostEnvVar = "OTEL_EXPORTER_PROMETHEUS_HOST"; + internal const string PrometheusPortEnvVar = "OTEL_EXPORTER_PROMETHEUS_PORT"; + + /// + /// Initializes a new instance of the class. + /// + public PrometheusHttpListenerOptions() + : this(new ConfigurationBuilder().AddEnvironmentVariables().Build()) + { + } + + internal PrometheusHttpListenerOptions(IConfiguration configuration) + { + if (!configuration.TryGetStringValue(PrometheusHostEnvVar, out var host)) + { + host = "localhost"; + } + + if (!configuration.TryGetIntValue(PrometheusExporterEventSource.Log, PrometheusPortEnvVar, out var port)) + { + port = 9464; + } + + this.Host = host; + this.Port = port; + } /// /// Initializes a new instance of the class. @@ -28,12 +55,12 @@ public PrometheusHttpListenerOptions() /// /// Gets or sets the Host name the HTTP listener will bind to. Defaults to localhost. /// - public string Host { get; set; } = "localhost"; + public string Host { get; set; } /// /// Gets or sets the TCP port used by the HTTP listener. Defaults to 9464. /// - public int Port { get; set; } = 9464; + public int Port { get; set; } /// /// Gets or sets the path to use for the scraping endpoint. Default value: "/metrics". @@ -68,16 +95,16 @@ public int ScrapeResponseCacheDurationMilliseconds [Obsolete("UriPrefixes is deprecated. Use Host and Port. This will be removed in a future stable release.")] public IReadOnlyCollection UriPrefixes { - get => this.uriPrefixes; + get => field ?? ["http://localhost:9464/"]; set { Guard.ThrowIfNull(value); if (value.Count == 0) { - throw new ArgumentException("Empty list provided.", nameof(this.UriPrefixes)); + throw new ArgumentException("Empty list provided.", nameof(value)); } - this.uriPrefixes = value; + field = value; this.UriPrefixesExplicitlySet = true; } } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/README.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/README.md index c75047d5472..c9d610960e2 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/README.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/README.md @@ -19,7 +19,7 @@ instance for Prometheus to scrape. * [Get Prometheus](https://prometheus.io/docs/introduction/first_steps/) -## Steps to enable OpenTelemetry.Exporter.Prometheus.HttpListener +## Installation ### Step 1: Install Package @@ -32,23 +32,57 @@ dotnet add package --prerelease OpenTelemetry.Exporter.Prometheus.HttpListener ```csharp var meterProvider = Sdk.CreateMeterProviderBuilder() .AddMeter(MyMeter.Name) - .AddPrometheusHttpListener( - options => options.UriPrefixes = new string[] { "http://localhost:9464/" }) + .AddPrometheusHttpListener(); .Build(); ``` -### UriPrefixes +## Configuration -Defines one or more URI (Uniform Resource Identifier) prefixes which will be -used by the HTTP listener. The default value is `["http://localhost:9464/"]`. +You can configure the `PrometheusHttpListener` through +`PrometheusHttpListenerOptions` and environment variables. The +`PrometheusHttpListenerOptions` setters take precedence over the environment +variables. -Refer to -[HttpListenerPrefixCollection.Add(String)](https://docs.microsoft.com/dotnet/api/system.net.httplistenerprefixcollection.add) -for more details. +### Configuration using Properties -### ScrapeEndpointPath +* `Host`: The host used by the Prometheus exporter (default `localhost`). -Defines the Prometheus scrape endpoint path. Default value: `"/metrics"`. +* `Port`: The port used by the Prometheus exporter (default `9464`). + +* `ScrapeEndpointPath`: Defines the Prometheus scrape endpoint path. Default value: `"/metrics"`. + +* `DisableTotalNameSuffixForCounters`: Whether to disable the `_total` suffix for counter metrics (default `false`). + +* `DisableTimestamp`: Whether to disable the timestamp for metrics (default `false`). + +### Configuration using Dependency Injection + +This exporter allows easy configuration of `PrometheusHttpListenerOptions` from +the dependency injection container, when used in conjunction with +[`OpenTelemetry.Extensions.Hosting`](../OpenTelemetry.Extensions.Hosting/README.md). + +For example: + +```csharp +var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter(MyMeter.Name) + .AddPrometheusHttpListener(options => + { + options.Host = "localhost"; + options.Port = 9464; + }); + .Build(); +``` + +### Configuration using Environment Variables + +The following environment variables can be used to override the default +values of the `PrometheusHttpListenerOptions`. + +| Environment variable | `PrometheusHttpListenerOptions` property | +| --------------------------------| ---------------------------------------- | +| `OTEL_EXPORTER_PROMETHEUS_HOST` | `Host` | +| `OTEL_EXPORTER_PROMETHEUS_PORT` | `Port` | ### ScrapeResponseCacheDurationMilliseconds diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests.csproj b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests.csproj index 358fec4ccbf..0a7a29d1941 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests.csproj +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests.csproj @@ -27,6 +27,7 @@ + diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerMeterProviderBuilderExtensionsTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerMeterProviderBuilderExtensionsTests.cs index 1ac4d2e8547..0617fddb09f 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerMeterProviderBuilderExtensionsTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerMeterProviderBuilderExtensionsTests.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using OpenTelemetry.Metrics; using Xunit; @@ -30,6 +31,82 @@ public void TestAddPrometheusHttpListener_NamedOptions() Assert.Equal(1, namedExporterOptionsConfigureOptionsInvocations); } + [Fact] + public void TestAddPrometheusHttpListener_Defaults_Are_Correct() + { + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddPrometheusHttpListener() + .Build(); + + var serviceProvider = meterProvider.GetServiceProvider(); + + Assert.NotNull(serviceProvider); + + var options = serviceProvider.GetRequiredService>(); + + Assert.NotNull(options); + Assert.NotNull(options.CurrentValue); + + Assert.Equal("localhost", options.CurrentValue.Host); + Assert.Equal(9464, options.CurrentValue.Port); + Assert.Equal("/metrics", options.CurrentValue.ScrapeEndpointPath); + } + + [Fact] + public void TestAddPrometheusHttpListener_Configuration_From_Environment_Variables() + { + using (new EnvironmentVariableScope("OTEL_EXPORTER_PROMETHEUS_HOST", "127.0.0.1")) + using (new EnvironmentVariableScope("OTEL_EXPORTER_PROMETHEUS_PORT", "4649")) + { + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddPrometheusHttpListener() + .Build(); + + var serviceProvider = meterProvider.GetServiceProvider(); + + Assert.NotNull(serviceProvider); + + var options = serviceProvider.GetRequiredService>(); + + Assert.NotNull(options); + Assert.NotNull(options.CurrentValue); + + Assert.Equal("127.0.0.1", options.CurrentValue.Host); + Assert.Equal(4649, options.CurrentValue.Port); + Assert.Equal("/metrics", options.CurrentValue.ScrapeEndpointPath); + } + } + + [Fact] + public void TestAddPrometheusHttpListener_Manual_Configuration_Overrides_Environment_Variables() + { + using (new EnvironmentVariableScope("OTEL_EXPORTER_PROMETHEUS_HOST", "prometheus.local")) + using (new EnvironmentVariableScope("OTEL_EXPORTER_PROMETHEUS_PORT", "4649")) + { + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddPrometheusHttpListener((options) => + { + options.Host = "127.0.0.1"; + options.Port = 5464; + options.ScrapeEndpointPath = "/custom-metrics"; + }) + .Build(); + + var serviceProvider = meterProvider.GetServiceProvider(); + + Assert.NotNull(serviceProvider); + + var options = serviceProvider.GetRequiredService>(); + + Assert.NotNull(options); + Assert.NotNull(options.CurrentValue); + + Assert.Equal("127.0.0.1", options.CurrentValue.Host); + Assert.Equal(5464, options.CurrentValue.Port); + Assert.Equal("/custom-metrics", options.CurrentValue.ScrapeEndpointPath); + } + } + [Fact] public void TestAddPrometheusHttpListener_UsesConfiguredCacheDuration() { From 8495e5a11e5f53bdb4b2b1a20e3c2d680067f0a5 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 25 Apr 2026 14:56:13 +0100 Subject: [PATCH 02/82] [Prometheus.HttpListener] Fix lint warnings Fix markdownlint warnings. --- .../README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/README.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/README.md index c9d610960e2..3f5eee920ed 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/README.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/README.md @@ -46,13 +46,11 @@ variables. ### Configuration using Properties * `Host`: The host used by the Prometheus exporter (default `localhost`). - * `Port`: The port used by the Prometheus exporter (default `9464`). - -* `ScrapeEndpointPath`: Defines the Prometheus scrape endpoint path. Default value: `"/metrics"`. - -* `DisableTotalNameSuffixForCounters`: Whether to disable the `_total` suffix for counter metrics (default `false`). - +* `ScrapeEndpointPath`: Defines the Prometheus scrape endpoint path. + (default `"/metrics"`). +* `DisableTotalNameSuffixForCounters`: Whether to disable the `_total` suffix for + counter metrics (default `false`). * `DisableTimestamp`: Whether to disable the timestamp for metrics (default `false`). ### Configuration using Dependency Injection From 1d08846abb0e26c7c78ce969bdf359692daa6691 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 25 Apr 2026 15:09:46 +0100 Subject: [PATCH 03/82] [Prometheus.HttpListener] Add coverage Add coverage for invalid environment variables. --- ...enerMeterProviderBuilderExtensionsTests.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerMeterProviderBuilderExtensionsTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerMeterProviderBuilderExtensionsTests.cs index 0617fddb09f..a7fe338d64c 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerMeterProviderBuilderExtensionsTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerMeterProviderBuilderExtensionsTests.cs @@ -77,6 +77,31 @@ public void TestAddPrometheusHttpListener_Configuration_From_Environment_Variabl } } + [Fact] + public void TestAddPrometheusHttpListener_Configuration_From_Environment_Variables_Ignores_Invalid_Values() + { + using (new EnvironmentVariableScope("OTEL_EXPORTER_PROMETHEUS_HOST", string.Empty)) + using (new EnvironmentVariableScope("OTEL_EXPORTER_PROMETHEUS_PORT", "not-a-number")) + { + using var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddPrometheusHttpListener() + .Build(); + + var serviceProvider = meterProvider.GetServiceProvider(); + + Assert.NotNull(serviceProvider); + + var options = serviceProvider.GetRequiredService>(); + + Assert.NotNull(options); + Assert.NotNull(options.CurrentValue); + + Assert.Equal("localhost", options.CurrentValue.Host); + Assert.Equal(9464, options.CurrentValue.Port); + Assert.Equal("/metrics", options.CurrentValue.ScrapeEndpointPath); + } + } + [Fact] public void TestAddPrometheusHttpListener_Manual_Configuration_Overrides_Environment_Variables() { From bfbc2469e053bad92b78947b724fbe913145d9ac Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Sat, 25 Apr 2026 16:28:12 +0100 Subject: [PATCH 04/82] [Prometheus.HttpListener] Fix README snippets Remove extra semicolons. --- src/OpenTelemetry.Exporter.Prometheus.HttpListener/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/README.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/README.md index 3f5eee920ed..72ca798e7e0 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/README.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/README.md @@ -32,7 +32,7 @@ dotnet add package --prerelease OpenTelemetry.Exporter.Prometheus.HttpListener ```csharp var meterProvider = Sdk.CreateMeterProviderBuilder() .AddMeter(MyMeter.Name) - .AddPrometheusHttpListener(); + .AddPrometheusHttpListener() .Build(); ``` @@ -68,7 +68,7 @@ var meterProvider = Sdk.CreateMeterProviderBuilder() { options.Host = "localhost"; options.Port = 9464; - }); + }) .Build(); ``` From e598fbb0514a665eb3c5ae0f2857d17462d7c289 Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 08:30:54 +0100 Subject: [PATCH 05/82] [Exporter.Prometheus] Fix-up merge React to changes from #7175. --- ...enerMeterProviderBuilderExtensionsTests.cs | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerMeterProviderBuilderExtensionsTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerMeterProviderBuilderExtensionsTests.cs index a7fe338d64c..a1f803f4b07 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerMeterProviderBuilderExtensionsTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerMeterProviderBuilderExtensionsTests.cs @@ -55,8 +55,11 @@ public void TestAddPrometheusHttpListener_Defaults_Are_Correct() [Fact] public void TestAddPrometheusHttpListener_Configuration_From_Environment_Variables() { - using (new EnvironmentVariableScope("OTEL_EXPORTER_PROMETHEUS_HOST", "127.0.0.1")) - using (new EnvironmentVariableScope("OTEL_EXPORTER_PROMETHEUS_PORT", "4649")) + using (EnvironmentVariableScope.Create( + [ + ("OTEL_EXPORTER_PROMETHEUS_HOST", "127.0.0.1"), + ("OTEL_EXPORTER_PROMETHEUS_PORT", "4649"), + ])) { using var meterProvider = Sdk.CreateMeterProviderBuilder() .AddPrometheusHttpListener() @@ -80,8 +83,11 @@ public void TestAddPrometheusHttpListener_Configuration_From_Environment_Variabl [Fact] public void TestAddPrometheusHttpListener_Configuration_From_Environment_Variables_Ignores_Invalid_Values() { - using (new EnvironmentVariableScope("OTEL_EXPORTER_PROMETHEUS_HOST", string.Empty)) - using (new EnvironmentVariableScope("OTEL_EXPORTER_PROMETHEUS_PORT", "not-a-number")) + using (EnvironmentVariableScope.Create( + [ + ("OTEL_EXPORTER_PROMETHEUS_HOST", string.Empty), + ("OTEL_EXPORTER_PROMETHEUS_PORT", "not-a-number"), + ])) { using var meterProvider = Sdk.CreateMeterProviderBuilder() .AddPrometheusHttpListener() @@ -105,8 +111,11 @@ public void TestAddPrometheusHttpListener_Configuration_From_Environment_Variabl [Fact] public void TestAddPrometheusHttpListener_Manual_Configuration_Overrides_Environment_Variables() { - using (new EnvironmentVariableScope("OTEL_EXPORTER_PROMETHEUS_HOST", "prometheus.local")) - using (new EnvironmentVariableScope("OTEL_EXPORTER_PROMETHEUS_PORT", "4649")) + using (EnvironmentVariableScope.Create( + [ + ("OTEL_EXPORTER_PROMETHEUS_HOST", "prometheus.local"), + ("OTEL_EXPORTER_PROMETHEUS_PORT", "4649"), + ])) { using var meterProvider = Sdk.CreateMeterProviderBuilder() .AddPrometheusHttpListener((options) => From 7adc4f453dfaf222f8b6c1045490e3c660bccbe1 Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Tue, 28 Apr 2026 21:38:57 +0100 Subject: [PATCH 06/82] [Prometheus.HttpListener] Fix merge Remove duplicated constructor declaration. --- .../PrometheusHttpListenerOptions.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListenerOptions.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListenerOptions.cs index ffb55d69b2a..42ff761a7cf 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListenerOptions.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListenerOptions.cs @@ -42,13 +42,6 @@ internal PrometheusHttpListenerOptions(IConfiguration configuration) this.Host = host; this.Port = port; - } - - /// - /// Initializes a new instance of the class. - /// - public PrometheusHttpListenerOptions() - { this.ScrapeResponseCacheDurationMilliseconds = 300; } From 2faa7aee1d3ecab9a200499e811645f18ae72648 Mon Sep 17 00:00:00 2001 From: martincostello Date: Thu, 30 Apr 2026 15:43:08 +0100 Subject: [PATCH 07/82] [Exporter.Prometheus] Update Accept handling - Add missing SHOULD requirement to specify the `escaping` value for 1.0.0 protocols. - Update `PrometheusSerializer` to be compliant with `escaping=underscores` when using OpenMetrics. --- .../CHANGELOG.md | 9 ++ .../PrometheusExporterMiddleware.cs | 18 ++-- .../CHANGELOG.md | 9 ++ .../Internal/PrometheusCollectionManager.cs | 4 +- .../Internal/PrometheusHeadersParser.cs | 13 ++- .../Internal/PrometheusMetric.cs | 52 ++++++++++-- .../Internal/PrometheusSerializer.cs | 82 +++++++++++++------ .../Internal/PrometheusSerializerExt.cs | 8 +- .../PrometheusHttpListener.cs | 2 +- .../PrometheusExporterMiddlewareTests.cs | 8 +- .../PrometheusSerializerFuzzTests.cs | 61 +++++++++++++- .../PrometheusHeadersParserTests.cs | 6 ++ .../PrometheusHttpListenerTests.cs | 2 +- .../PrometheusMetricTests.cs | 16 ++++ .../PrometheusSerializerTests.cs | 46 ++++++++++- 15 files changed, 285 insertions(+), 51 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 2230fe55c65..3b7d444343b 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -34,6 +34,15 @@ Notes](../../RELEASENOTES.md). * Emit OpenMetrics exemplars for counters and histogram buckets. ([#7222](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7222)) +* Fix Prometheus/OpenMetrics serialization to emit metric and label names + 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)) + +* Add `escaping=underscores` to the `Accept` header handling for content + negotiation so OpenMetrics are handled correctly. + ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209)) + ## 1.15.3-beta.1 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs index 75408ef422e..694f4de93f1 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs @@ -15,9 +15,10 @@ namespace OpenTelemetry.Exporter; /// internal sealed class PrometheusExporterMiddleware { + private const string OpenMetricsEscapingScheme = "underscores"; private const string OpenMetricsMediaType = "application/openmetrics-text"; private const string OpenMetricsVersion = "1.0.0"; - private const string OpenMetricsContentType = $"application/openmetrics-text; version={OpenMetricsVersion}; charset=utf-8"; + private const string OpenMetricsContentType = $"application/openmetrics-text; version={OpenMetricsVersion}; charset=utf-8; escaping={OpenMetricsEscapingScheme}"; private const string PrometheusTextMediaType = "text/plain"; @@ -123,7 +124,7 @@ internal static bool AcceptsOpenMetrics(HttpRequest request) } if (string.Equals(mediaType.MediaType.Value, OpenMetricsMediaType, StringComparison.OrdinalIgnoreCase) && - HasSupportedOpenMetricsVersion(mediaType)) + HasSupportedOpenMetricsParameters(mediaType)) { bestOpenMetricsQuality = bestOpenMetricsQuality is not { } comparison || quality > comparison ? @@ -143,16 +144,23 @@ bestPrometheusQuality is not { } comparison || quality > comparison ? (bestPrometheusQuality is not { } prometheusQuality || openMetricsQuality >= prometheusQuality); } - private static bool HasSupportedOpenMetricsVersion(MediaTypeHeaderValue value) + private static bool HasSupportedOpenMetricsParameters(MediaTypeHeaderValue value) { + var hasSupportedOpenMetricsEscaping = true; + var hasSupportedOpenMetricsVersion = true; + foreach (var parameter in value.Parameters) { if (string.Equals(parameter.Name.Value, "version", StringComparison.OrdinalIgnoreCase)) { - return string.Equals(parameter.Value.Value?.Trim('"'), OpenMetricsVersion, StringComparison.Ordinal); + hasSupportedOpenMetricsVersion = string.Equals(parameter.Value.Value?.Trim('"'), OpenMetricsVersion, StringComparison.Ordinal); + } + else if (string.Equals(parameter.Name.Value, "escaping", StringComparison.OrdinalIgnoreCase)) + { + hasSupportedOpenMetricsEscaping = string.Equals(parameter.Value.Value?.Trim('"'), OpenMetricsEscapingScheme, StringComparison.Ordinal); } } - return true; + return hasSupportedOpenMetricsVersion && hasSupportedOpenMetricsEscaping; } } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index 60fb381a223..7cb82d4b439 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -51,6 +51,15 @@ Notes](../../RELEASENOTES.md). environment variables. ([#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. + Invalid characters are replaced with `_` instead of being dropped. + ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209)) + +* Add `escaping=underscores` to the `Accept` header handling for content + negotiation so OpenMetrics are handled correctly. + ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209)) + ## 1.15.3-beta.1 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs index d34c1288c8f..6c5ed638001 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs @@ -238,7 +238,7 @@ private ExportResult OnCollect(in Batch metrics) { try { - cursor = PrometheusSerializer.WriteScopeInfo(buffer, cursor, metric.MeterName); + cursor = PrometheusSerializer.WriteScopeInfo(buffer, cursor, metric.MeterName, openMetricsRequested: true); break; } @@ -342,7 +342,7 @@ private int WriteTargetInfo(ref byte[] buffer) { try { - this.targetInfoBufferLength = PrometheusSerializer.WriteTargetInfo(buffer, 0, this.exporter.Resource); + this.targetInfoBufferLength = PrometheusSerializer.WriteTargetInfo(buffer, 0, this.exporter.Resource, openMetricsRequested: true); break; } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs index da068a5ee8e..f64e291a315 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs @@ -7,6 +7,7 @@ namespace OpenTelemetry.Exporter.Prometheus; internal static class PrometheusHeadersParser { + private const string OpenMetricsEscapingScheme = "underscores"; private const string OpenMetricsMediaType = "application/openmetrics-text"; private const string OpenMetricsVersion = "1.0.0"; private const string PrometheusTextMediaType = "text/plain"; @@ -23,6 +24,7 @@ internal static bool AcceptsOpenMetrics(string? contentType) var mediaType = TrimWhitespace(SplitNext(ref headerValue, ';')); var quality = 1.0; var hasValidQuality = true; + var hasSupportedOpenMetricsEscaping = true; var hasSupportedOpenMetricsVersion = true; while (headerValue.Length > 0) @@ -35,6 +37,10 @@ internal static bool AcceptsOpenMetrics(string? contentType) { hasSupportedOpenMetricsVersion = IsSupportedOpenMetricsVersion(parameter.Slice("version=".Length)); } + else if (parameter.StartsWith("escaping=".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + hasSupportedOpenMetricsEscaping = IsSupportedOpenMetricsEscaping(parameter.Slice("escaping=".Length)); + } continue; } @@ -59,7 +65,9 @@ internal static bool AcceptsOpenMetrics(string? contentType) continue; } - if (mediaType.Equals(OpenMetricsMediaType.AsSpan(), StringComparison.OrdinalIgnoreCase) && hasSupportedOpenMetricsVersion) + if (mediaType.Equals(OpenMetricsMediaType.AsSpan(), StringComparison.OrdinalIgnoreCase) && + hasSupportedOpenMetricsVersion && + hasSupportedOpenMetricsEscaping) { bestOpenMetricsQuality = bestOpenMetricsQuality is not { } comparison || quality > comparison ? @@ -82,6 +90,9 @@ bestPrometheusQuality is not { } comparison || quality > comparison ? private static bool IsSupportedOpenMetricsVersion(ReadOnlySpan value) => TrimQuotes(value).Equals(OpenMetricsVersion.AsSpan(), StringComparison.Ordinal); + private static bool IsSupportedOpenMetricsEscaping(ReadOnlySpan value) + => TrimQuotes(value).Equals(OpenMetricsEscapingScheme.AsSpan(), StringComparison.Ordinal); + private static ReadOnlySpan SplitNext(ref ReadOnlySpan span, char character) { var index = span.IndexOf(character); diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs index c4ca9e356b1..559f731983e 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs @@ -17,12 +17,13 @@ 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 = SanitizeOpenMetricsName(sanitizedName); + var openMetricsName = EscapeOpenMetricsName(RemoveOpenMetricsCounterNameSuffix(name)); string? sanitizedUnit = null; if (!string.IsNullOrEmpty(unit)) { sanitizedUnit = GetUnit(unit); + var openMetricsUnitSuffix = EscapeOpenMetricsName(sanitizedUnit); // The resulting unit SHOULD be added to the metric as // [OpenMetrics UNIT metadata](https://github.com/prometheus/OpenMetrics/blob/v1.0.0/specification/OpenMetrics.md#metricfamily) @@ -31,7 +32,7 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa if (!sanitizedName.EndsWith(sanitizedUnit, StringComparison.Ordinal)) { sanitizedName += $"_{sanitizedUnit}"; - openMetricsName += $"_{sanitizedUnit}"; + openMetricsName += $"_{openMetricsUnitSuffix}"; } } @@ -54,8 +55,8 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa // In OpenMetrics format, the UNIT, TYPE and HELP metadata must be suffixed with the unit (handled above), and not the '_total' suffix, as in the case for counters. // https://github.com/prometheus/OpenMetrics/blob/v1.0.0/specification/OpenMetrics.md#unit var openMetricsMetadataName = type == PrometheusType.Counter - ? SanitizeOpenMetricsName(openMetricsName) - : sanitizedName; + ? RemoveOpenMetricsCounterNameSuffix(openMetricsName) + : openMetricsName; this.Name = sanitizedName; this.OpenMetricsName = openMetricsName; @@ -148,6 +149,47 @@ static StringBuilder CreateStringBuilder(string value) } } + internal static string EscapeOpenMetricsName(string metricName) + { + StringBuilder? sb = null; + var lastCharUnderscore = false; + + for (var i = 0; i < metricName.Length; i++) + { + var c = metricName[i]; + + if (i == 0 && char.IsAsciiDigit(c)) + { + sb ??= CreateStringBuilder(metricName); + sb.Append('_'); + lastCharUnderscore = true; + } + + if (!char.IsAsciiLetterOrDigit(c) && c != ':') + { + if (!lastCharUnderscore) + { + sb ??= CreateStringBuilder(metricName); + sb.Append('_'); + lastCharUnderscore = true; + } + } + else + { + sb ??= CreateStringBuilder(metricName); + sb.Append(c); + lastCharUnderscore = c == '_'; + } + } + + return sb?.ToString() ?? metricName; + + static StringBuilder CreateStringBuilder(string value) + { + return new(value.Length + 1); + } + } + internal static string RemoveAnnotations(string unit) { // UCUM standard says the curly braces shouldn't be nested: @@ -215,7 +257,7 @@ UpDownCounter becomes gauge }; } - private static string SanitizeOpenMetricsName(string metricName) + private static string RemoveOpenMetricsCounterNameSuffix(string metricName) => metricName.EndsWith("_total", StringComparison.Ordinal) ? metricName.Substring(0, metricName.Length - 6) : metricName; private static string GetUnit(string unit) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 5a5b1b1ddb6..5e7c73e003c 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -150,7 +150,7 @@ public static int WriteUnicodeString(byte[] buffer, int cursor, string value) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteLabelKey(byte[] buffer, int cursor, string value) + public static int WriteLabelKey(byte[] buffer, int cursor, string value, bool openMetricsRequested) { if (string.IsNullOrEmpty(value)) { @@ -158,18 +158,9 @@ public static int WriteLabelKey(byte[] buffer, int cursor, string value) return cursor; } - if (char.IsAsciiDigit(value[0])) - { - buffer[cursor++] = unchecked((byte)'_'); - } - - for (var i = 0; i < value.Length; i++) - { - var ch = value[i]; - buffer[cursor++] = char.IsAsciiLetterOrDigit(ch) ? (byte)ch : (byte)'_'; - } - - return cursor; + return openMetricsRequested ? + WriteOpenMetricsLabelKey(buffer, cursor, value) : + WritePrometheusLabelKey(buffer, cursor, value); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -202,9 +193,9 @@ public static int WriteLabelValue(byte[] buffer, int cursor, string value) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteLabel(byte[] buffer, int cursor, string labelKey, object? labelValue) + public static int WriteLabel(byte[] buffer, int cursor, string labelKey, object? labelValue, bool openMetricsRequested) { - cursor = WriteLabelKey(buffer, cursor, labelKey); + cursor = WriteLabelKey(buffer, cursor, labelKey, openMetricsRequested); buffer[cursor++] = unchecked((byte)'='); buffer[cursor++] = unchecked((byte)'"'); @@ -431,7 +422,7 @@ public static int WriteUnitMetadata(byte[] buffer, int cursor, PrometheusMetric } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteScopeInfo(byte[] buffer, int cursor, string scopeName) + public static int WriteScopeInfo(byte[] buffer, int cursor, string scopeName, bool openMetricsRequested) { if (string.IsNullOrEmpty(scopeName)) { @@ -446,7 +437,7 @@ public static int WriteScopeInfo(byte[] buffer, int cursor, string scopeName) cursor = WriteAsciiStringNoEscape(buffer, cursor, "otel_scope_info"); buffer[cursor++] = unchecked((byte)'{'); - cursor = WriteLabel(buffer, cursor, "otel_scope_name", scopeName); + cursor = WriteLabel(buffer, cursor, "otel_scope_name", scopeName, openMetricsRequested); buffer[cursor++] = unchecked((byte)'}'); buffer[cursor++] = unchecked((byte)' '); buffer[cursor++] = unchecked((byte)'1'); @@ -456,19 +447,25 @@ public static int WriteScopeInfo(byte[] buffer, int cursor, string scopeName) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteTags(byte[] buffer, int cursor, Metric metric, ReadOnlyTagCollection tags, bool writeEnclosingBraces = true) + public static int WriteTags( + byte[] buffer, + int cursor, + Metric metric, + ReadOnlyTagCollection tags, + bool openMetricsRequested, + bool writeEnclosingBraces = true) { if (writeEnclosingBraces) { buffer[cursor++] = unchecked((byte)'{'); } - cursor = WriteLabel(buffer, cursor, "otel_scope_name", metric.MeterName); + cursor = WriteLabel(buffer, cursor, "otel_scope_name", metric.MeterName, openMetricsRequested); buffer[cursor++] = unchecked((byte)','); if (!string.IsNullOrEmpty(metric.MeterVersion)) { - cursor = WriteLabel(buffer, cursor, "otel_scope_version", metric.MeterVersion); + cursor = WriteLabel(buffer, cursor, "otel_scope_version", metric.MeterVersion, openMetricsRequested); buffer[cursor++] = unchecked((byte)','); } @@ -476,14 +473,14 @@ public static int WriteTags(byte[] buffer, int cursor, Metric metric, ReadOnlyTa { foreach (var tag in metric.MeterTags) { - cursor = WriteLabel(buffer, cursor, tag.Key, tag.Value); + cursor = WriteLabel(buffer, cursor, tag.Key, tag.Value, openMetricsRequested); buffer[cursor++] = unchecked((byte)','); } } foreach (var tag in tags) { - cursor = WriteLabel(buffer, cursor, tag.Key, tag.Value); + cursor = WriteLabel(buffer, cursor, tag.Key, tag.Value, openMetricsRequested); buffer[cursor++] = unchecked((byte)','); } @@ -496,7 +493,7 @@ public static int WriteTags(byte[] buffer, int cursor, Metric metric, ReadOnlyTa } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource) + public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource, bool openMetricsRequested) { if (resource == Resource.Empty) { @@ -514,7 +511,7 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource) foreach (var attribute in resource.Attributes) { - cursor = WriteLabel(buffer, cursor, attribute.Key, attribute.Value); + cursor = WriteLabel(buffer, cursor, attribute.Key, attribute.Value, openMetricsRequested); buffer[cursor++] = unchecked((byte)','); } @@ -529,6 +526,43 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource) return cursor; } + private static int WritePrometheusLabelKey(byte[] buffer, int cursor, string value) + => WriteNormalizedLabelKey(buffer, cursor, value, isOpenMetrics: false); + + private static int WriteOpenMetricsLabelKey(byte[] buffer, int cursor, string value) + => WriteNormalizedLabelKey(buffer, cursor, value, isOpenMetrics: true); + + private static int WriteNormalizedLabelKey(byte[] buffer, int cursor, string value, bool isOpenMetrics) + { + var lastCharUnderscore = false; + + for (var i = 0; i < value.Length; i++) + { + var ch = value[i]; + + if ((i == 0 && char.IsAsciiDigit(ch)) || + !IsAllowedMetricsLabelCharacter(ch, isOpenMetrics)) + { + if (!lastCharUnderscore) + { + buffer[cursor++] = unchecked((byte)'_'); + lastCharUnderscore = true; + } + + continue; + } + + buffer[cursor++] = unchecked((byte)ch); + lastCharUnderscore = ch == '_'; + } + + return cursor; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsAllowedMetricsLabelCharacter(char value, bool isOpenMetrics) => + char.IsAsciiLetterOrDigit(value) || value is '_' || (isOpenMetrics && value == ':'); + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int WriteUnicodeScalar(byte[] buffer, int cursor, string value, ref int index) { diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs index 80041888bcd..00ca7670750 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -36,7 +36,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe { // Counter and Gauge cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); - cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags); + cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags, openMetricsRequested); buffer[cursor++] = unchecked((byte)' '); @@ -77,7 +77,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); cursor = WriteAsciiStringNoEscape(buffer, cursor, "_bucket{"); - cursor = WriteTags(buffer, cursor, metric, tags, writeEnclosingBraces: false); + cursor = WriteTags(buffer, cursor, metric, tags, openMetricsRequested, writeEnclosingBraces: false); cursor = WriteAsciiStringNoEscape(buffer, cursor, "le=\""); @@ -102,7 +102,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe // Histogram sum cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); cursor = WriteAsciiStringNoEscape(buffer, cursor, "_sum"); - cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags); + cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags, openMetricsRequested); buffer[cursor++] = unchecked((byte)' '); @@ -113,7 +113,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe // Histogram count cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); cursor = WriteAsciiStringNoEscape(buffer, cursor, "_count"); - cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags); + cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags, openMetricsRequested); buffer[cursor++] = unchecked((byte)' '); diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs index 194d5971c69..8687dbc189a 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs @@ -196,7 +196,7 @@ private async Task ProcessRequestAsync(HttpListenerContext context) context.Response.StatusCode = 200; context.Response.Headers.Add("Last-Modified", collectionResponse.GeneratedAtUtc.ToString("R")); context.Response.ContentType = openMetricsRequested - ? "application/openmetrics-text; version=1.0.0; charset=utf-8" + ? "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores" : "text/plain; charset=utf-8; version=0.0.4"; #if NET diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs index 8a6105200ff..d0473cb9961 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs @@ -226,8 +226,11 @@ public Task PrometheusExporterMiddlewareIntegration_UseOpenMetricsVersionHeader( [InlineData("application/openmetrics-text", true)] [InlineData("application/openmetrics-text; version=1.0.0", true)] [InlineData("application/openmetrics-text; version=\"1.0.0\"", true)] + [InlineData("application/openmetrics-text; version=1.0.0; escaping=underscores", true)] + [InlineData("application/openmetrics-text; version=\"1.0.0\"; escaping=\"underscores\"", true)] [InlineData("application/openmetrics-text; version=1.0.0; charset=utf-8", true)] [InlineData("Application/OpenMetrics-Text; version=1.0.0", true)] + [InlineData("application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores", true)] [InlineData("text/plain,application/openmetrics-text; version=1.0.0; charset=utf-8", true)] [InlineData("text/plain, application/openmetrics-text; version=1.0.0; charset=utf-8", true)] [InlineData("text/plain; charset=utf-8,application/openmetrics-text; version=1.0.0; charset=utf-8", true)] @@ -238,6 +241,9 @@ public Task PrometheusExporterMiddlewareIntegration_UseOpenMetricsVersionHeader( [InlineData("application/openmetrics-text; version=\"0.0.1\"", false)] [InlineData("application/openmetrics-text; version=0.0.1; charset=utf-8", false)] [InlineData("application/openmetrics-text; version=1.0.0; q=0", false)] + [InlineData("application/openmetrics-text; version=1.0.0; escaping=allow-utf-8", false)] + [InlineData("application/openmetrics-text; version=1.0.0; escaping=dots", false)] + [InlineData("application/openmetrics-text; version=1.0.0; escaping=values", false)] [InlineData("text/plain", false)] [InlineData("text/plain; charset=utf-8", false)] [InlineData("text/plain; charset=utf-8; version=0.0.4", false)] @@ -470,7 +476,7 @@ private static async Task VerifyAsync(HttpResponseMessage response, bool request if (requestOpenMetrics) { - Assert.Equal("application/openmetrics-text; version=1.0.0; charset=utf-8", response.Content.Headers.ContentType!.ToString()); + Assert.Equal("application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores", response.Content.Headers.ContentType!.ToString()); } else { diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs index 402c12496ec..113ffdff7c7 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs @@ -19,9 +19,14 @@ public Property WriteAsciiStringNoEscapeMatchesReferenceImplementation() => Prop static (value) => Serialize(value, PrometheusSerializer.WriteAsciiStringNoEscape).SequenceEqual(ReferenceWriteAsciiStringNoEscape(value))); [Property(MaxTest = MaxTests)] - public Property WriteLabelKeyMatchesReferenceImplementation() => Prop.ForAll( + public Property WritePrometheusLabelKeyMatchesReferenceImplementation() => Prop.ForAll( Generators.PrometheusStringArbitrary(), - static (value) => Serialize(value, PrometheusSerializer.WriteLabelKey).SequenceEqual(ReferenceWriteLabelKey(value))); + static (value) => Serialize(value, static (buffer, cursor, text) => PrometheusSerializer.WriteLabelKey(buffer, cursor, text, openMetricsRequested: false)).SequenceEqual(ReferenceWriteLabelKey(value))); + + [Property(MaxTest = MaxTests)] + public Property WriteOpenMetricsLabelKeyMatchesReferenceImplementation() => Prop.ForAll( + Generators.PrometheusStringArbitrary(), + static (value) => SerializeOpenMetricsLabelKey(value).SequenceEqual(ReferenceWriteOpenMetricsLabelKey(value))); [Property(MaxTest = MaxTests)] public Property WriteLabelValueMatchesReferenceImplementation() => Prop.ForAll( @@ -50,6 +55,13 @@ private static byte[] Serialize(string value, Func wri return buffer.AsSpan(0, cursor).ToArray(); } + private static byte[] SerializeOpenMetricsLabelKey(string value) + { + var buffer = new byte[(value.Length * 8) + 16]; + var cursor = PrometheusSerializer.WriteLabelKey(buffer, 0, value, openMetricsRequested: true); + return buffer.AsSpan(0, cursor).ToArray(); + } + private static byte[] SerializeLong(long value) { var buffer = new byte[64]; @@ -84,14 +96,55 @@ private static byte[] ReferenceWriteLabelKey(string value) return [.. bytes]; } - if (value[0] is >= '0' and <= '9') + var lastCharUnderscore = false; + foreach (var c in value) + { + if ((bytes.Count == 0 && c is >= '0' and <= '9') || + !(c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) + { + if (!lastCharUnderscore) + { + bytes.Add((byte)'_'); + lastCharUnderscore = true; + } + + continue; + } + + bytes.Add((byte)c); + lastCharUnderscore = c == '_'; + } + + return [.. bytes]; + } + + private static byte[] ReferenceWriteOpenMetricsLabelKey(string value) + { + var bytes = new List(value.Length + 1); + if (string.IsNullOrEmpty(value)) { bytes.Add((byte)'_'); + return [.. bytes]; } + var lastCharUnderscore = false; + foreach (var c in value) { - bytes.Add(c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') ? (byte)c : (byte)'_'); + if ((bytes.Count == 0 && c is >= '0' and <= '9') || + !(c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_' or ':')) + { + if (!lastCharUnderscore) + { + bytes.Add((byte)'_'); + lastCharUnderscore = true; + } + + continue; + } + + bytes.Add((byte)c); + lastCharUnderscore = c == '_'; } return [.. bytes]; diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHeadersParserTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHeadersParserTests.cs index dd1d509c25b..ebea6879486 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHeadersParserTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHeadersParserTests.cs @@ -11,8 +11,11 @@ public class PrometheusHeadersParserTests [InlineData("application/openmetrics-text")] [InlineData("application/openmetrics-text; version=1.0.0")] [InlineData("application/openmetrics-text; version=\"1.0.0\"")] + [InlineData("application/openmetrics-text; version=1.0.0; escaping=underscores")] + [InlineData("application/openmetrics-text; version=\"1.0.0\"; escaping=\"underscores\"")] [InlineData("application/openmetrics-text; version=1.0.0; charset=utf-8")] [InlineData("Application/OpenMetrics-Text; version=1.0.0")] + [InlineData("application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores")] [InlineData("text/plain,application/openmetrics-text; version=1.0.0; charset=utf-8")] [InlineData("text/plain, application/openmetrics-text; version=1.0.0; charset=utf-8")] [InlineData("text/plain; charset=utf-8,application/openmetrics-text; version=1.0.0; charset=utf-8")] @@ -39,6 +42,9 @@ public void ParseHeader_AcceptHeaders_OpenMetricsValid(string header) [InlineData("application/openmetrics-text; version=0.0.1")] [InlineData("application/openmetrics-text; version=\"0.0.1\"")] [InlineData("application/openmetrics-text; version=0.0.1; charset=utf-8")] + [InlineData("application/openmetrics-text; version=1.0.0; escaping=allow-utf-8")] + [InlineData("application/openmetrics-text; version=1.0.0; escaping=dots")] + [InlineData("application/openmetrics-text; version=1.0.0; escaping=values")] public void ParseHeader_AcceptHeaders_OtherHeadersInvalid(string header) { var result = PrometheusHeadersParser.AcceptsOpenMetrics(header); diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs index 9edaf5e4b37..cbfa3459e9f 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs @@ -318,7 +318,7 @@ private static async Task RunPrometheusExporterHttpServerIntegrationTest( if (requestOpenMetrics) { - Assert.Equal("application/openmetrics-text; version=1.0.0; charset=utf-8", response.Content.Headers.ContentType?.ToString()); + Assert.Equal("application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores", response.Content.Headers.ContentType?.ToString()); } else { diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs index 3b3e82b4d29..d03b8d96e9f 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs @@ -160,6 +160,22 @@ public void Name_StartWithNumber_UnderscoreStart() public void OpenMetricsName_UnitAlreadyPresentInName_Appended() => AssertOpenMetricsName("db_bytes_written", "By", PrometheusType.Gauge, false, "db_bytes_written_bytes"); + [Fact] + 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"); + + [Fact] + public void OpenMetricsName_CollapsesConsecutiveUnsupportedCharacters() + => AssertOpenMetricsName("s%%ple", "%/m", PrometheusType.Summary, false, "s_ple_percent_per_minute"); + + [Fact] + public void OpenMetricsName_NameEscapingAndUnitNormalization_AreAppliedIndependently() + => AssertOpenMetricsName("s%%ple", "req__per__s", PrometheusType.Summary, false, "s_ple_req_per_s"); + [Fact] public void OpenMetricsName_SuffixedWithUnit_NotAppended() => AssertOpenMetricsName("db_written_bytes", "By", PrometheusType.Gauge, false, "db_written_bytes"); diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index b4f4c19c620..6f71724cbaa 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -255,11 +255,51 @@ public void WriteLabelKeyNullOrEmptyName(string? labelName) { var buffer = new byte[32]; - var cursor = PrometheusSerializer.WriteLabelKey(buffer, 0, labelName!); + var cursor = PrometheusSerializer.WriteLabelKey(buffer, 0, labelName!, openMetricsRequested: false); Assert.Equal("_", Encoding.UTF8.GetString(buffer, 0, cursor)); } + [Fact] + public void WriteLabelKeyNormalizesPrometheusLabelNames() + { + var buffer = new byte[32]; + + var cursor = PrometheusSerializer.WriteLabelKey(buffer, 0, "a_b:c", openMetricsRequested: false); + + Assert.Equal("a_b_c", Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Fact] + public void WriteLabelKeyCollapsesPrometheusInvalidCharacters() + { + var buffer = new byte[32]; + + var cursor = PrometheusSerializer.WriteLabelKey(buffer, 0, "a../b", openMetricsRequested: false); + + Assert.Equal("a_b", Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Fact] + public void WriteLabelKeyPreservesOpenMetricsLegacyValidCharacters() + { + var buffer = new byte[32]; + + var cursor = PrometheusSerializer.WriteLabelKey(buffer, 0, "a_b:c", openMetricsRequested: true); + + Assert.Equal("a_b:c", Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Fact] + public void WriteLabelKeyCollapsesOpenMetricsInvalidCharacters() + { + var buffer = new byte[32]; + + var cursor = PrometheusSerializer.WriteLabelKey(buffer, 0, "a../b", openMetricsRequested: true); + + Assert.Equal("a_b", Encoding.UTF8.GetString(buffer, 0, cursor)); + } + [Fact] public void WriteMetricNameSanitizesNonAsciiCharacters() { @@ -867,7 +907,7 @@ public void ScopeInfo() { var buffer = new byte[85000]; - var cursor = PrometheusSerializer.WriteScopeInfo(buffer, 0, "test_meter"); + var cursor = PrometheusSerializer.WriteScopeInfo(buffer, 0, "test_meter", openMetricsRequested: true); Assert.Matches( ("^" @@ -993,7 +1033,7 @@ public void WriteLabelFormatsTypedValues() { var buffer = new byte[128]; - var cursor = PrometheusSerializer.WriteLabel(buffer, 0, "value", 18446744073709551615UL); + var cursor = PrometheusSerializer.WriteLabel(buffer, 0, "value", 18446744073709551615UL, openMetricsRequested: false); Assert.Equal("value=\"18446744073709551615\"", Encoding.UTF8.GetString(buffer, 0, cursor)); } From d61ed6c3c0c627992c24904f4801f3d6cc11f2ab Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 10:51:30 +0100 Subject: [PATCH 08/82] [Exporter.Prometheus] Address review comments Fix missing prefixing for metrics that start with a digit. --- .../Internal/PrometheusSerializer.cs | 12 ++++++++++-- .../PrometheusSerializerTests.cs | 12 ++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 5e7c73e003c..50c13d1f57b 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -540,8 +540,16 @@ private static int WriteNormalizedLabelKey(byte[] buffer, int cursor, string val { var ch = value[i]; - if ((i == 0 && char.IsAsciiDigit(ch)) || - !IsAllowedMetricsLabelCharacter(ch, isOpenMetrics)) + if (i == 0 && char.IsAsciiDigit(ch)) + { + if (!lastCharUnderscore) + { + buffer[cursor++] = unchecked((byte)'_'); + lastCharUnderscore = true; + } + } + + if (!IsAllowedMetricsLabelCharacter(ch, isOpenMetrics)) { if (!lastCharUnderscore) { diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 6f71724cbaa..2ac7e8da11c 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -280,6 +280,18 @@ public void WriteLabelKeyCollapsesPrometheusInvalidCharacters() Assert.Equal("a_b", Encoding.UTF8.GetString(buffer, 0, cursor)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void WriteLabelKeyPrefixesLeadingDigits(bool openMetricsRequested) + { + var buffer = new byte[32]; + + var cursor = PrometheusSerializer.WriteLabelKey(buffer, 0, "2foo", openMetricsRequested); + + Assert.Equal("_2foo", Encoding.UTF8.GetString(buffer, 0, cursor)); + } + [Fact] public void WriteLabelKeyPreservesOpenMetricsLegacyValidCharacters() { From ab6f94410531d8c7fa1eb0c5b9c40d20cd653a02 Mon Sep 17 00:00:00 2001 From: martincostello Date: Wed, 29 Apr 2026 20:03:24 +0100 Subject: [PATCH 09/82] [Exporter.Prometheus] Use canonical representations - Use canonical representations for numbers for "le" label values of histograms and "quantile" label values of summary metrics for OpenMetrics. - Resolve TODO by moving check outside loop. See https://prometheus.io/docs/specs/om/open_metrics_spec/#considerations-canonical-numbers. --- .../Internal/PrometheusSerializer.cs | 244 ++++++++++++++++-- .../Internal/PrometheusSerializerExt.cs | 19 +- .../PrometheusSerializerTests.cs | 147 +++++++---- 3 files changed, 330 insertions(+), 80 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 50c13d1f57b..f4ed2307c19 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -1,6 +1,10 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +#if NET +using System.Buffers; +using System.Buffers.Text; +#endif using System.Diagnostics; using System.Globalization; using System.Runtime.CompilerServices; @@ -31,55 +35,47 @@ public static int WriteDouble(byte[] buffer, int cursor, double value) // The standard precision of %f, %e and %g is only six significant digits. 17 significant // digits are required for full precision, e.g. printf("%.17g", d). #if NET - Span span = stackalloc char[128]; - - var result = value.TryFormat(span, out var cchWritten, "G17", CultureInfo.InvariantCulture); - Debug.Assert(result, $"{nameof(result)} should be true."); - - for (var i = 0; i < cchWritten; i++) - { - buffer[cursor++] = unchecked((byte)span[i]); - } + var result = Utf8Formatter.TryFormat(value, buffer.AsSpan(cursor), out var bytesWritten, new StandardFormat('G', 17)); + return AdvanceCursorOrThrow(result, cursor, bytesWritten); #else - cursor = WriteAsciiStringNoEscape(buffer, cursor, value.ToString("G17", CultureInfo.InvariantCulture)); + return WriteAsciiStringNoEscape(buffer, cursor, value.ToString("G17", CultureInfo.InvariantCulture)); #endif } else if (double.IsPositiveInfinity(value)) { - cursor = WriteAsciiStringNoEscape(buffer, cursor, "+Inf"); + return WriteAsciiStringNoEscape(buffer, cursor, "+Inf"); } else if (double.IsNegativeInfinity(value)) { - cursor = WriteAsciiStringNoEscape(buffer, cursor, "-Inf"); + return WriteAsciiStringNoEscape(buffer, cursor, "-Inf"); } else { // See https://prometheus.io/docs/instrumenting/exposition_formats/#comments-help-text-and-type-information Debug.Assert(double.IsNaN(value), $"{nameof(value)} should be NaN."); - cursor = WriteAsciiStringNoEscape(buffer, cursor, "NaN"); + return WriteAsciiStringNoEscape(buffer, cursor, "NaN"); } - - return cursor; } + // Histogram "le" and summary "quantile" label values use OpenMetrics canonical numbers. + // See https://prometheus.io/docs/specs/om/open_metrics_spec/#considerations-canonical-numbers + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteCanonicalLabelValue(byte[] buffer, int cursor, double value) => +#if NET + cursor + FormatCanonicalLabelValue(buffer.AsSpan(cursor), value); +#else + WriteAsciiStringNoEscape(buffer, cursor, GetCanonicalLabelValueString(value)); +#endif + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteLong(byte[] buffer, int cursor, long value) { #if NET - Span span = stackalloc char[20]; - - var result = value.TryFormat(span, out var cchWritten, "G", CultureInfo.InvariantCulture); - Debug.Assert(result, $"{nameof(result)} should be true."); - - for (var i = 0; i < cchWritten; i++) - { - buffer[cursor++] = unchecked((byte)span[i]); - } + var result = Utf8Formatter.TryFormat(value, buffer.AsSpan(cursor), out var bytesWritten); + return AdvanceCursorOrThrow(result, cursor, bytesWritten); #else - cursor = WriteAsciiStringNoEscape(buffer, cursor, value.ToString(CultureInfo.InvariantCulture)); + return WriteAsciiStringNoEscape(buffer, cursor, value.ToString(CultureInfo.InvariantCulture)); #endif - - return cursor; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -604,4 +600,198 @@ private static int WriteUnixTimeSeconds(byte[] buffer, int cursor, DateTimeOffse PrometheusType.Histogram => "histogram", PrometheusType.Untyped or _ => "untyped", }; + +#if NET + private static int FormatCanonicalLabelValue(Span destination, double value) + { + if (double.IsPositiveInfinity(value)) + { + "+Inf"u8.CopyTo(destination); + return 4; + } + else if (double.IsNegativeInfinity(value)) + { + "-Inf"u8.CopyTo(destination); + return 4; + } + else if (double.IsNaN(value)) + { + "NaN"u8.CopyTo(destination); + return 3; + } + else if (value == 0) + { + "0.0"u8.CopyTo(destination); + return 3; + } + + var absoluteValue = Math.Abs(value); + if (absoluteValue <= 10 && value == Math.Round(value, 3)) + { + return FormatFixedAndTrim(destination, value, 3); + } + + if (absoluteValue < 1e6 && value == Math.Round(value)) + { + return FormatFixedAndTrim(destination, value, 1); + } + + if (TryGetPowerOfTenExponent(absoluteValue, out var exponent)) + { + return exponent is >= 6 or <= -5 + ? FormatPowerOfTenScientific(destination, value < 0, exponent) + : FormatFixedAndTrim(destination, value, Math.Max(1, -exponent)); + } + + char symbol = absoluteValue >= 1e6 || absoluteValue < 1e-4 ? 'e' : 'G'; + + return TryFormat(destination, value, new(symbol, 17)); + + static int FormatFixedAndTrim(Span destination, double value, int decimalPlaces) + { + var bytesWritten = TryFormat(destination, value, new StandardFormat('F', (byte)decimalPlaces)); + var decimalIndex = destination.Slice(0, bytesWritten).IndexOf((byte)'.'); + Debug.Assert(decimalIndex >= 0, $"{nameof(decimalIndex)} should be non-negative."); + + while (bytesWritten > decimalIndex + 2 && destination[bytesWritten - 1] == (byte)'0') + { + bytesWritten--; + } + + return bytesWritten; + } + + static int FormatPowerOfTenScientific(Span destination, bool isNegative, int exponent) + { + var bytesWritten = 0; + if (isNegative) + { + destination[bytesWritten++] = (byte)'-'; + } + + destination[bytesWritten++] = (byte)'1'; + return bytesWritten + WriteExponent(destination.Slice(bytesWritten), exponent); + } + + static int TryFormat(Span destination, double value, StandardFormat format) + { + var result = Utf8Formatter.TryFormat(value, destination, out var bytesWritten, format); + return GetBytesWrittenOrThrow(result, bytesWritten); + } + + static int WriteExponent(Span destination, int exponent) + { + destination[0] = (byte)'e'; + destination[1] = exponent >= 0 ? (byte)'+' : (byte)'-'; + + var absoluteExponent = Math.Abs(exponent); + if (absoluteExponent >= 100) + { + destination[2] = unchecked((byte)('0' + (absoluteExponent / 100))); + destination[3] = unchecked((byte)('0' + ((absoluteExponent / 10) % 10))); + destination[4] = unchecked((byte)('0' + (absoluteExponent % 10))); + return 5; + } + + (var quotient, var remainder) = Math.DivRem(absoluteExponent, 10); + + destination[2] = unchecked((byte)('0' + quotient)); + destination[3] = unchecked((byte)('0' + remainder)); + return 4; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int GetBytesWrittenOrThrow(bool result, int bytesWritten) => + result ? bytesWritten : throw new ArgumentException("Destination buffer too small."); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int AdvanceCursorOrThrow(bool result, int cursor, int bytesWritten) => + result ? cursor + bytesWritten : throw new ArgumentException("Destination buffer too small."); +#else + private static string GetCanonicalLabelValueString(double value) + { + if (double.IsPositiveInfinity(value)) + { + return "+Inf"; + } + else if (double.IsNegativeInfinity(value)) + { + return "-Inf"; + } + else if (double.IsNaN(value)) + { + return "NaN"; + } + else if (value == 0) + { + return "0.0"; + } + + var absoluteValue = Math.Abs(value); + if (absoluteValue <= 10 && value == Math.Round(value, 3)) + { + return FormatFixedAndTrim(value, 3); + } + + if (absoluteValue < 1e6 && value == Math.Round(value)) + { + return FormatFixedAndTrim(value, 1); + } + + if (TryGetPowerOfTenExponent(absoluteValue, out var exponent)) + { + return exponent is >= 6 or <= -5 + ? string.Concat(value < 0 ? "-1" : "1", FormatExponent(exponent)) + : FormatFixedAndTrim(value, Math.Max(1, -exponent)); + } + + return value.ToString(absoluteValue >= 1e6 || absoluteValue < 1e-4 ? "e17" : "G17", CultureInfo.InvariantCulture); + + static string FormatFixedAndTrim(double value, int decimalPlaces) + { + var formattedValue = value.ToString($"F{decimalPlaces}", CultureInfo.InvariantCulture); + var minimumLength = formattedValue.IndexOf('.') + 2; + while (formattedValue.Length > minimumLength && formattedValue[formattedValue.Length - 1] == '0') + { + formattedValue = formattedValue.Substring(0, formattedValue.Length - 1); + } + + return formattedValue; + } + + static string FormatExponent(int exponent) + { + return string.Concat( + "e", + exponent >= 0 ? "+" : "-", + Math.Abs(exponent).ToString("00", CultureInfo.InvariantCulture)); + } + } +#endif + + private static bool TryGetPowerOfTenExponent(double absoluteValue, out int exponent) + { + exponent = 0; + if (absoluteValue <= 0) + { + return false; + } + + var roundedExponent = (int)Math.Round(Math.Log10(absoluteValue)); + if (roundedExponent is < -10 or > 10) + { + return false; + } + + var powerOfTen = Math.Pow(10, roundedExponent); + + if (Math.Abs(absoluteValue - powerOfTen) > powerOfTen * 1e-12) + { + return false; + } + + exponent = roundedExponent; + return true; + } } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs index 00ca7670750..8ecb95cb204 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -30,7 +30,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe if (!metric.MetricType.IsHistogram()) { - var isLongValue = ((int)metric.MetricType & 0b_0000_1111) == 0x0a; // I8 + var isLong = ((int)metric.MetricType & 0b_0000_1111) == 0x0a; // I8 foreach (ref readonly var metricPoint in metric.GetMetricPoints()) { @@ -40,7 +40,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe buffer[cursor++] = unchecked((byte)' '); - if (isLongValue) + if (isLong) { cursor = metric.MetricType.IsSum() ? WriteLong(buffer, cursor, metricPoint.GetSumLong()) @@ -57,7 +57,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe prometheusMetric.Type == PrometheusType.Counter && TryGetLatestExemplar(metricPoint, out var exemplar)) { - cursor = WriteExemplar(buffer, cursor, in exemplar, isLongValue); + cursor = WriteExemplar(buffer, cursor, in exemplar, isLong); } buffer[cursor++] = ASCII_LINEFEED; @@ -81,9 +81,16 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe cursor = WriteAsciiStringNoEscape(buffer, cursor, "le=\""); - cursor = histogramMeasurement.ExplicitBound != double.PositiveInfinity - ? WriteDouble(buffer, cursor, histogramMeasurement.ExplicitBound) - : WriteAsciiStringNoEscape(buffer, cursor, "+Inf"); + if (histogramMeasurement.ExplicitBound != double.PositiveInfinity) + { + cursor = openMetricsRequested + ? WriteCanonicalLabelValue(buffer, cursor, histogramMeasurement.ExplicitBound) + : WriteDouble(buffer, cursor, histogramMeasurement.ExplicitBound); + } + else + { + cursor = WriteAsciiStringNoEscape(buffer, cursor, "+Inf"); + } cursor = WriteAsciiStringNoEscape(buffer, cursor, "\"} "); diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 2ac7e8da11c..8ba13849c84 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -104,7 +104,7 @@ public void GaugeZeroDimensionWithDescription() provider.ForceFlush(); - var cursor = WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); Assert.Matches( ("^" + "# TYPE test_gauge gauge\n" @@ -130,7 +130,7 @@ public void GaugeZeroDimensionWithUnit() provider.ForceFlush(); - var cursor = WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); Assert.Matches( ("^" + "# TYPE test_gauge_seconds gauge\n" @@ -156,7 +156,7 @@ public void GaugeZeroDimensionWithDescriptionAndUnit() provider.ForceFlush(); - var cursor = WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); Assert.Matches( ("^" + "# TYPE test_gauge_seconds gauge\n" @@ -185,7 +185,7 @@ public void GaugeOneDimension() provider.ForceFlush(); - var cursor = WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); Assert.Matches( ("^" + "# TYPE test_gauge gauge\n" @@ -212,7 +212,7 @@ public void GaugeBoolDimension() provider.ForceFlush(); - var cursor = WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); Assert.Matches( ("^" + "# TYPE test_gauge gauge\n" @@ -239,7 +239,7 @@ public void GaugeEmptyDimensionName() provider.ForceFlush(); - var cursor = WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); Assert.Matches( ("^" + "# TYPE test_gauge gauge\n" @@ -355,7 +355,7 @@ public void GaugeDoubleSubnormal() provider.ForceFlush(); - var cursor = WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); Assert.Matches( ("^" + "# TYPE test_gauge gauge\n" @@ -384,7 +384,7 @@ public void SumDoubleInfinities() provider.ForceFlush(); - var cursor = WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); Assert.Matches( ("^" + "# TYPE test_counter_total counter\n" @@ -413,7 +413,7 @@ public void SumLongSerializesBoundaryValues(long value) provider.ForceFlush(); } - var cursor = WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); Assert.Matches( ("^" + "# TYPE test_counter_total counter\n" @@ -440,7 +440,7 @@ public void SumNonMonotonicDouble() provider.ForceFlush(); - var cursor = WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); Assert.Matches( ("^" + "# TYPE test_updown_counter gauge\n" @@ -512,7 +512,7 @@ public void HistogramOneDimension() provider.ForceFlush(); - var cursor = WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); Assert.Matches( ("^" + "# TYPE test_histogram histogram\n" @@ -556,7 +556,7 @@ public void HistogramTwoDimensions() provider.ForceFlush(); - var cursor = WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); Assert.Matches( ("^" + "# TYPE test_histogram histogram\n" @@ -601,7 +601,7 @@ public void HistogramInfinities() provider.ForceFlush(); - var cursor = WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); Assert.Matches( ("^" + "# TYPE test_histogram histogram\n" @@ -646,7 +646,7 @@ public void HistogramNaN() provider.ForceFlush(); - var cursor = WriteMetric(buffer, 0, metrics[0]); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); Assert.Matches( ("^" + "# TYPE test_histogram histogram\n" @@ -836,21 +836,21 @@ public void HistogramOneDimensionWithOpenMetricsFormat() var expected = ("^" + "# TYPE test_histogram histogram\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='0'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='5'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='10'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='25'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='50'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='75'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='100'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='250'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='500'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='750'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='1000'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='2500'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='5000'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='7500'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='10000'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='0.0'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='5.0'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='10.0'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='25.0'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='50.0'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='75.0'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='100.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='250.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='500.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='750.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='1000.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='2500.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='5000.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='7500.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='10000.0'}} 2\n" + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='\\+Inf'}} 2\n" + $"test_histogram_sum{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1'}} 118\n" + $"test_histogram_count{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1'}} 2\n" @@ -971,25 +971,25 @@ public void HistogramOneDimensionWithScopeVersion() provider.ForceFlush(); - var cursor = WriteMetric(buffer, 0, metrics[0], true); + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: true); Assert.Matches( ("^" + "# TYPE test_histogram histogram\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='0'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='5'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='10'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='25'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='50'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='75'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='100'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='250'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='500'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='750'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='1000'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='2500'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='5000'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='7500'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='10000'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='0.0'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='5.0'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='10.0'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='25.0'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='50.0'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='75.0'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='100.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='250.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='500.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='750.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='1000.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='2500.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='5000.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='7500.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='10000.0'}} 2\n" + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='\\+Inf'}} 2\n" + $"test_histogram_sum{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1'}} 118\n" + $"test_histogram_count{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1'}} 2\n" @@ -1090,6 +1090,59 @@ public void WriteDoubleFormatsNaN() Assert.Equal("NaN", Encoding.UTF8.GetString(buffer, 0, cursor)); } + [Theory] + [InlineData(double.NegativeInfinity, "-Inf")] + [InlineData(0d, "0.0")] + [InlineData(0.001d, "0.001")] + [InlineData(0.002d, "0.002")] + [InlineData(0.01d, "0.01")] + [InlineData(0.1d, "0.1")] + [InlineData(0.9d, "0.9")] + [InlineData(0.95d, "0.95")] + [InlineData(0.99d, "0.99")] + [InlineData(0.999d, "0.999")] + [InlineData(1d, "1.0")] + [InlineData(1.7d, "1.7")] + [InlineData(10d, "10.0")] + [InlineData(1e-10d, "1e-10")] + [InlineData(1e-09d, "1e-09")] + [InlineData(1e-05d, "1e-05")] + [InlineData(0.0001d, "0.0001")] + [InlineData(100000d, "100000.0")] + [InlineData(1e6d, "1e+06")] + [InlineData(1e10d, "1e+10")] + [InlineData(double.PositiveInfinity, "+Inf")] + public void WriteCanonicalLabelValueUsesOpenMetricsCanonicalNumbers(double value, string expected) + { + var buffer = new byte[64]; + + var cursor = PrometheusSerializer.WriteCanonicalLabelValue(buffer, 0, value); + + Assert.Equal(expected, Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Fact] + public void WriteCanonicalLabelValueFormatsNaN() + { + var buffer = new byte[64]; + + var cursor = PrometheusSerializer.WriteCanonicalLabelValue(buffer, 0, double.NaN); + + Assert.Equal("NaN", Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Theory] + [InlineData(0.00011d, "0.00011")] + [InlineData(1234567.89d, "1.23456788999999990e+006")] + public void WriteCanonicalLabelValueUsesBuiltInFormattingForNonCanonicalNumbers(double value, string expected) + { + var buffer = new byte[64]; + + var cursor = PrometheusSerializer.WriteCanonicalLabelValue(buffer, 0, value); + + Assert.Equal(expected, Encoding.UTF8.GetString(buffer, 0, cursor)); + } + [Fact] public void WriteUnicodeStringEncodesSurrogatePairsAsUtf8ScalarValues() { @@ -1172,7 +1225,7 @@ private static string ToHexString(byte[] buffer, int length) static char GetHexValue(int value) => (char)(value < 10 ? '0' + value : 'A' + (value - 10)); } - private static int WriteMetric(byte[] buffer, int cursor, Metric metric, bool useOpenMetrics = false) + private static int WriteMetric(byte[] buffer, int cursor, Metric metric, bool useOpenMetrics) => PrometheusSerializer.WriteMetric(buffer, cursor, metric, PrometheusMetric.Create(metric, false), useOpenMetrics); private static void WaitForNextExemplarTimestamp() @@ -1200,7 +1253,7 @@ private static string WriteGaugeMetricWithMeterTags(params KeyValuePair Date: Wed, 29 Apr 2026 20:06:49 +0100 Subject: [PATCH 10/82] [Exporter.Prometheus] Update CHANGELOGs Add CHANGELOG entries. --- src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md | 4 ++++ .../CHANGELOG.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 3b7d444343b..412fd5c9e3c 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -43,6 +43,10 @@ Notes](../../RELEASENOTES.md). negotiation so OpenMetrics are handled correctly. ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209)) +* Use the canonical representation for "quantile" and "le" label values when + using OpenMetrics. + ([#7218](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7218)) + ## 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 7cb82d4b439..ec59597454e 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -60,6 +60,10 @@ Notes](../../RELEASENOTES.md). negotiation so OpenMetrics are handled correctly. ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209)) +* Use the canonical representation for "quantile" and "le" label values when + using OpenMetrics. + ([#7218](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7218)) + ## 1.15.3-beta.1 Released 2026-Apr-21 From 2fef7d3cb1f77b6a5a9f581994214ea6fefd6b25 Mon Sep 17 00:00:00 2001 From: martincostello Date: Wed, 29 Apr 2026 20:37:32 +0100 Subject: [PATCH 11/82] [Exporter.Prometheus] Extend test coverage --- .../PrometheusSerializerTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 8ba13849c84..65c6352f39d 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -1092,6 +1092,8 @@ public void WriteDoubleFormatsNaN() [Theory] [InlineData(double.NegativeInfinity, "-Inf")] + [InlineData(-1e10d, "-1e+10")] + [InlineData(-1e-10d, "-1e-10")] [InlineData(0d, "0.0")] [InlineData(0.001d, "0.001")] [InlineData(0.002d, "0.002")] @@ -1133,6 +1135,7 @@ public void WriteCanonicalLabelValueFormatsNaN() [Theory] [InlineData(0.00011d, "0.00011")] + [InlineData(1e11d, "1.00000000000000000e+011")] [InlineData(1234567.89d, "1.23456788999999990e+006")] public void WriteCanonicalLabelValueUsesBuiltInFormattingForNonCanonicalNumbers(double value, string expected) { From 135b40b502e89948ff21e8705b1f6b03bfd2f91e Mon Sep 17 00:00:00 2001 From: martincostello Date: Wed, 29 Apr 2026 21:22:33 +0100 Subject: [PATCH 12/82] [Exporter.Prometheus] Remove unreachable code Remove branches that could not be reached. --- .../Internal/PrometheusSerializer.cs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index f4ed2307c19..b54d677dbbc 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -685,14 +685,6 @@ static int WriteExponent(Span destination, int exponent) destination[1] = exponent >= 0 ? (byte)'+' : (byte)'-'; var absoluteExponent = Math.Abs(exponent); - if (absoluteExponent >= 100) - { - destination[2] = unchecked((byte)('0' + (absoluteExponent / 100))); - destination[3] = unchecked((byte)('0' + ((absoluteExponent / 10) % 10))); - destination[4] = unchecked((byte)('0' + (absoluteExponent % 10))); - return 5; - } - (var quotient, var remainder) = Math.DivRem(absoluteExponent, 10); destination[2] = unchecked((byte)('0' + quotient)); @@ -773,10 +765,7 @@ static string FormatExponent(int exponent) private static bool TryGetPowerOfTenExponent(double absoluteValue, out int exponent) { exponent = 0; - if (absoluteValue <= 0) - { - return false; - } + Debug.Assert(absoluteValue > 0, $"{nameof(absoluteValue)} should be positive."); var roundedExponent = (int)Math.Round(Math.Log10(absoluteValue)); if (roundedExponent is < -10 or > 10) From 534ef9c8a3aa46afe8b4e5424ac286fa3312d0ef Mon Sep 17 00:00:00 2001 From: martincostello Date: Wed, 29 Apr 2026 21:57:32 +0100 Subject: [PATCH 13/82] [Exporter.Prometheus] Address feedback - Check destination size. - Update CHANGELOGs. --- .../CHANGELOG.md | 4 ++-- .../CHANGELOG.md | 5 +++-- .../Internal/PrometheusSerializer.cs | 22 ++++++++++++------- .../PrometheusSerializerTests.cs | 15 +++++++++++++ 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 412fd5c9e3c..919ee2598cc 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -43,8 +43,8 @@ Notes](../../RELEASENOTES.md). negotiation so OpenMetrics are handled correctly. ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209)) -* Use the canonical representation for "quantile" and "le" label values when - using OpenMetrics. +* Use the canonical representation for histogram "le" label values when using + OpenMetrics. ([#7218](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7218)) ## 1.15.3-beta.1 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index ec59597454e..3492154807e 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -36,6 +36,7 @@ Notes](../../RELEASENOTES.md). response caching. ([#7189](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7189)) +<<<<<<< HEAD * Fix case where reader tracking could be reset while readers were still active. ([#7190](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7190)) @@ -60,8 +61,8 @@ Notes](../../RELEASENOTES.md). negotiation so OpenMetrics are handled correctly. ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209)) -* Use the canonical representation for "quantile" and "le" label values when - using OpenMetrics. +* Use the canonical representation for histogram "le" label values when using + OpenMetrics. ([#7218](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7218)) ## 1.15.3-beta.1 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index b54d677dbbc..201ab27cbba 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -606,23 +606,19 @@ private static int FormatCanonicalLabelValue(Span destination, double valu { if (double.IsPositiveInfinity(value)) { - "+Inf"u8.CopyTo(destination); - return 4; + return GetBytesWrittenOrThrow("+Inf"u8.TryCopyTo(destination), 4); } else if (double.IsNegativeInfinity(value)) { - "-Inf"u8.CopyTo(destination); - return 4; + return GetBytesWrittenOrThrow("-Inf"u8.TryCopyTo(destination), 4); } else if (double.IsNaN(value)) { - "NaN"u8.CopyTo(destination); - return 3; + return GetBytesWrittenOrThrow("NaN"u8.TryCopyTo(destination), 3); } else if (value == 0) { - "0.0"u8.CopyTo(destination); - return 3; + return GetBytesWrittenOrThrow("0.0"u8.TryCopyTo(destination), 3); } var absoluteValue = Math.Abs(value); @@ -663,6 +659,11 @@ static int FormatFixedAndTrim(Span destination, double value, int decimalP static int FormatPowerOfTenScientific(Span destination, bool isNegative, int exponent) { + if (destination.Length < (isNegative ? 6 : 5)) + { + throw new ArgumentException("Destination buffer too small."); + } + var bytesWritten = 0; if (isNegative) { @@ -681,6 +682,11 @@ static int TryFormat(Span destination, double value, StandardFormat format static int WriteExponent(Span destination, int exponent) { + if (destination.Length < 4) + { + throw new ArgumentException("Destination buffer too small."); + } + destination[0] = (byte)'e'; destination[1] = exponent >= 0 ? (byte)'+' : (byte)'-'; diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 65c6352f39d..18954f9d993 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -1133,6 +1133,21 @@ public void WriteCanonicalLabelValueFormatsNaN() Assert.Equal("NaN", Encoding.UTF8.GetString(buffer, 0, cursor)); } +#if NET + [Theory] + [InlineData(double.PositiveInfinity, 3)] + [InlineData(0d, 2)] + [InlineData(1e6d, 4)] + public void WriteCanonicalLabelValueThrowsArgumentExceptionWhenBufferTooSmall(double value, int bufferLength) + { + var buffer = new byte[bufferLength]; + + var exception = Assert.Throws(() => PrometheusSerializer.WriteCanonicalLabelValue(buffer, 0, value)); + + Assert.Equal("Destination buffer too small.", exception.Message); + } +#endif + [Theory] [InlineData(0.00011d, "0.00011")] [InlineData(1e11d, "1.00000000000000000e+011")] From 38772fbb6eed85c6fe613ba0ed893c71113b3eef Mon Sep 17 00:00:00 2001 From: martincostello Date: Wed, 29 Apr 2026 20:26:02 +0100 Subject: [PATCH 14/82] [Exporter.Prometheus] Fix untyped for OpenMetrics Fix incorrect serialized value for `PrometheusType.Untyped` when using OpenMetrics. --- .../CHANGELOG.md | 4 ++++ .../CHANGELOG.md | 4 ++++ .../Internal/PrometheusSerializer.cs | 9 +++++--- .../PrometheusSerializerTests.cs | 22 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 919ee2598cc..a4c0c804272 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -24,6 +24,7 @@ Notes](../../RELEASENOTES.md). correctly during Prometheus serialization. ([#7184](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7184)) +<<<<<<< HEAD * Fix case where reader tracking could be reset while readers were still active. ([#7190](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7190)) @@ -43,6 +44,9 @@ Notes](../../RELEASENOTES.md). negotiation so OpenMetrics are handled correctly. ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209)) +* Fix incorrect handling of untyped metrics when using OpenMetrics format. + ([#7219](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7219)) + * Use the canonical representation for histogram "le" label values when using OpenMetrics. ([#7218](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7218)) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index 3492154807e..fc131803243 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -36,6 +36,7 @@ Notes](../../RELEASENOTES.md). response caching. ([#7189](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7189)) +<<<<<<< HEAD <<<<<<< HEAD * Fix case where reader tracking could be reset while readers were still active. ([#7190](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7190)) @@ -61,6 +62,9 @@ Notes](../../RELEASENOTES.md). negotiation so OpenMetrics are handled correctly. ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209)) +* Fix incorrect handling of untyped metrics when using OpenMetrics format. + ([#7219](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7219)) + * Use the canonical representation for histogram "le" label values when using OpenMetrics. ([#7218](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7218)) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 201ab27cbba..eb7d5fce8c2 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -376,7 +376,7 @@ public static int WriteHelpMetadata(byte[] buffer, int cursor, PrometheusMetric [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteTypeMetadata(byte[] buffer, int cursor, PrometheusMetric metric, bool openMetricsRequested) { - var metricType = MapPrometheusType(metric.Type); + var metricType = MapPrometheusType(metric.Type, openMetricsRequested); Debug.Assert(!string.IsNullOrEmpty(metricType), $"{nameof(metricType)} should not be null or empty."); @@ -592,13 +592,16 @@ private static int WriteUnicodeScalar(byte[] buffer, int cursor, string value, r private static int WriteUnixTimeSeconds(byte[] buffer, int cursor, DateTimeOffset value) => WriteDouble(buffer, cursor, value.ToUnixTimeMilliseconds() / 1000.0); - private static string MapPrometheusType(PrometheusType type) => type switch + private static string MapPrometheusType(PrometheusType type, bool openMetricsRequested) => type switch { PrometheusType.Gauge => "gauge", PrometheusType.Counter => "counter", PrometheusType.Summary => "summary", PrometheusType.Histogram => "histogram", - PrometheusType.Untyped or _ => "untyped", + + // OpenMetrics 1.0 uses "unknown" while Prometheus text format 0.0.4 uses "untyped". + // See https://prometheus.io/docs/specs/om/open_metrics_spec/#unknown-1 + PrometheusType.Untyped or _ => openMetricsRequested ? "unknown" : "untyped", }; #if NET diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 18954f9d993..7b11f747f93 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -1008,6 +1008,28 @@ public void WriteAsciiStringNoEscapeWritesAsciiBytes() Assert.Equal("metric_name_total", Encoding.UTF8.GetString(buffer, 0, cursor)); } + [Fact] + public void UntypedMetricUsesUnknownTypeForOpenMetrics() + { + var buffer = new byte[64]; + var metric = new PrometheusMetric("test_metric", string.Empty, PrometheusType.Untyped, disableTotalNameSuffixForCounters: false); + + var cursor = PrometheusSerializer.WriteTypeMetadata(buffer, 0, metric, openMetricsRequested: true); + + Assert.Equal("# TYPE test_metric unknown\n", Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Fact] + public void UntypedMetricUsesUntypedTypeForPrometheusTextFormat() + { + var buffer = new byte[64]; + var metric = new PrometheusMetric("test_metric", string.Empty, PrometheusType.Untyped, disableTotalNameSuffixForCounters: false); + + var cursor = PrometheusSerializer.WriteTypeMetadata(buffer, 0, metric, openMetricsRequested: false); + + Assert.Equal("# TYPE test_metric untyped\n", Encoding.UTF8.GetString(buffer, 0, cursor)); + } + [Fact] public void WriteAsciiStringNoEscapeThrowsExceptionWhenBufferTooSmall() { From e38b8a70195cd376385af42df6af096502cba559 Mon Sep 17 00:00:00 2001 From: martincostello Date: Wed, 29 Apr 2026 20:58:05 +0100 Subject: [PATCH 15/82] [Exporter.Prometheus] Fix OpenMetrics histograms Omit histogram `_sum` and `_count` in OpenMetrics when negative bucket thresholds are present. --- .../CHANGELOG.md | 5 ++- .../CHANGELOG.md | 6 ++- .../Internal/PrometheusSerializerExt.cs | 39 ++++++++++++------- .../PrometheusSerializerTests.cs | 29 ++++++++++++++ 4 files changed, 62 insertions(+), 17 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index a4c0c804272..575f4179bb4 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -24,7 +24,6 @@ Notes](../../RELEASENOTES.md). correctly during Prometheus serialization. ([#7184](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7184)) -<<<<<<< HEAD * Fix case where reader tracking could be reset while readers were still active. ([#7190](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7190)) @@ -51,6 +50,10 @@ Notes](../../RELEASENOTES.md). OpenMetrics. ([#7218](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7218)) +* Omit histogram `_sum` and `_count` in OpenMetrics when negative bucket + thresholds are present. + ([#7220](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7220)) + ## 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 fc131803243..891ccd80977 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -36,8 +36,6 @@ Notes](../../RELEASENOTES.md). response caching. ([#7189](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7189)) -<<<<<<< HEAD -<<<<<<< HEAD * Fix case where reader tracking could be reset while readers were still active. ([#7190](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7190)) @@ -69,6 +67,10 @@ Notes](../../RELEASENOTES.md). OpenMetrics. ([#7218](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7218)) +* Omit histogram `_sum` and `_count` in OpenMetrics when negative bucket + thresholds are present. + ([#7220](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7220)) + ## 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 8ecb95cb204..71ddb6e210c 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -69,10 +69,16 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe { var tags = metricPoint.Tags; var previousBound = double.NegativeInfinity; + var hasNegativeBucketBounds = false; 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); @@ -106,27 +112,32 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe 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 7b11f747f93..cd3f0dea809 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() { From 4656b1fef98c463a6d501339a0891592a3a0f0bb Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Wed, 29 Apr 2026 20:59:27 +0100 Subject: [PATCH 16/82] [Exporter.Prometheus] Fix CHANGELOGs Update PR number. --- src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md | 2 +- src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 575f4179bb4..72f3e3a6d91 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -52,7 +52,7 @@ Notes](../../RELEASENOTES.md). * Omit histogram `_sum` and `_count` in OpenMetrics when negative bucket thresholds are present. - ([#7220](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7220)) + ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221)) ## 1.15.3-beta.1 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index 891ccd80977..810d5e21bee 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -69,7 +69,7 @@ Notes](../../RELEASENOTES.md). * Omit histogram `_sum` and `_count` in OpenMetrics when negative bucket thresholds are present. - ([#7220](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7220)) + ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221)) ## 1.15.3-beta.1 From aaf830ebf6a165726f1cbd393bd5a9aff50604ea Mon Sep 17 00:00:00 2001 From: martincostello Date: Wed, 29 Apr 2026 22:26:06 +0100 Subject: [PATCH 17/82] [Exporter.Prometheus] Export _created series Export `{name}_created` series for counters and histograms when start time is available when using OpenMetrics. --- .../CHANGELOG.md | 4 ++ .../CHANGELOG.md | 4 ++ .../Internal/PrometheusSerializerExt.cs | 36 +++++++++++ .../PrometheusExporterMiddlewareTests.cs | 7 +- .../PrometheusHttpListenerTests.cs | 5 ++ .../PrometheusSerializerTests.cs | 64 +++++++++++++++++++ 6 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 72f3e3a6d91..8f18c1bdf28 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -54,6 +54,10 @@ Notes](../../RELEASENOTES.md). thresholds are present. ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221)) +* Export `{name}_created` series for counters and histograms when using + OpenMetrics and a start time is available. + ([#7223](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7223)) + ## 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 810d5e21bee..215e03180c3 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -71,6 +71,10 @@ Notes](../../RELEASENOTES.md). thresholds are present. ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221)) +* Export `{name}_created` series for counters and histograms when using + OpenMetrics and a start time is available. + ([#7223](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7223)) + ## 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 71ddb6e210c..cd2783225c9 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -61,6 +61,11 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe } buffer[cursor++] = ASCII_LINEFEED; + + if (openMetricsRequested && prometheusMetric.Type == PrometheusType.Counter) + { + cursor = WriteCreatedMetric(buffer, cursor, metric, prometheusMetric, metricPoint); + } } } else @@ -138,6 +143,11 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe buffer[cursor++] = ASCII_LINEFEED; } + + if (openMetricsRequested) + { + cursor = WriteCreatedMetric(buffer, cursor, metric, prometheusMetric, metricPoint); + } } } @@ -222,4 +232,30 @@ private static bool TryGetLatestHistogramBucketExemplar( return metricPoint.TryGetExemplars(out var exemplars) && TryGetLatestHistogramBucketExemplar(exemplars, lowerBoundExclusive, upperBoundInclusive, out exemplar); } + + private static int WriteCreatedMetric( + byte[] buffer, + int cursor, + Metric metric, + PrometheusMetric prometheusMetric, + in MetricPoint metricPoint) + { + if (metricPoint.StartTime == default) + { + return cursor; + } + + cursor = WriteMetricMetadataName(buffer, cursor, prometheusMetric, openMetricsRequested: true); + + cursor = WriteAsciiStringNoEscape(buffer, cursor, "_created"); + cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags, openMetricsRequested: true); + + buffer[cursor++] = unchecked((byte)' '); + + cursor = WriteUnixTimeSeconds(buffer, cursor, metricPoint.StartTime); + + buffer[cursor++] = ASCII_LINEFEED; + + return cursor; + } } diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs index d0473cb9961..d919f4ee088 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs @@ -486,6 +486,9 @@ private static async Task VerifyAsync(HttpResponseMessage response, bool request var additionalTags = meterTags is { Length: > 0 } ? $"{string.Join(",", meterTags.Select(x => $"{x.Key}=\"{x.Value}\""))}," : string.Empty; + var createdMetric = requestOpenMetrics + ? $"\ncounter_double_bytes_created{{otel_scope_name=\"{MeterName}\",otel_scope_version=\"{MeterVersion}\",{additionalTags}key1=\"value1\",key2=\"value2\"}} [0-9]+(?:\\.[0-9]+)?" + : string.Empty; var content = (await response.Content.ReadAsStringAsync()).ReplaceLineEndings(); @@ -499,14 +502,14 @@ private static async Task VerifyAsync(HttpResponseMessage response, bool request otel_scope_info{otel_scope_name="{{MeterName}}"} 1 # TYPE counter_double_bytes counter # UNIT counter_double_bytes bytes - counter_double_bytes_total{otel_scope_name="{{MeterName}}",otel_scope_version="{{MeterVersion}}",{{additionalTags}}key1="value1",key2="value2"} 101.17 + counter_double_bytes_total{otel_scope_name="{{MeterName}}",otel_scope_version="{{MeterVersion}}",{{additionalTags}}key1="value1",key2="value2"} 101.17{{createdMetric}} # EOF """.ReplaceLineEndings() : $$""" # TYPE counter_double_bytes_total counter # UNIT counter_double_bytes_total bytes - counter_double_bytes_total{otel_scope_name="{{MeterName}}",otel_scope_version="{{MeterVersion}}",{{additionalTags}}key1="value1",key2="value2"} 101.17 + counter_double_bytes_total{otel_scope_name="{{MeterName}}",otel_scope_version="{{MeterVersion}}",{{additionalTags}}key1="value1",key2="value2"} 101.17{{createdMetric}} # EOF """.ReplaceLineEndings(); diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs index cbfa3459e9f..78e67087c04 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs @@ -328,6 +328,9 @@ private static async Task RunPrometheusExporterHttpServerIntegrationTest( var additionalTags = meterTags is { Length: > 0 } ? $"{string.Join(",", meterTags.Select(x => $"{x.Key}='{x.Value}'"))}," : string.Empty; + var createdMetric = requestOpenMetrics + ? $"counter_double_bytes_created{{otel_scope_name='{MeterName}',otel_scope_version='{MeterVersion}',{additionalTags}key1='value1',key2='value2'}} [0-9]+(?:\\.[0-9]+)?\n" + : string.Empty; var content = await response.Content.ReadAsStringAsync(); @@ -341,10 +344,12 @@ private static async Task RunPrometheusExporterHttpServerIntegrationTest( + "# TYPE counter_double_bytes counter\n" + "# UNIT counter_double_bytes bytes\n" + $"counter_double_bytes_total{{otel_scope_name='{MeterName}',otel_scope_version='{MeterVersion}',{additionalTags}key1='value1',key2='value2'}} 101.17\n" + + createdMetric + "# EOF\n" : "# TYPE counter_double_bytes_total counter\n" + "# UNIT counter_double_bytes_total bytes\n" + $"counter_double_bytes_total{{otel_scope_name='{MeterName}',otel_scope_version='{MeterVersion}',{additionalTags}key1='value1',key2='value2'}} 101.17\n" + + createdMetric + "# EOF\n"; Assert.Matches(("^" + expected + "$").Replace('\'', '"'), content); diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index cd3f0dea809..d0bb5f35f80 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -1212,6 +1212,70 @@ public void WriteCanonicalLabelValueUsesBuiltInFormattingForNonCanonicalNumbers( Assert.Equal(expected, Encoding.UTF8.GetString(buffer, 0, cursor)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CounterExportsCreatedMetric(bool useOpenMetrics) + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = new Meter("test_meter"); + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + var counter = meter.CreateCounter("test_counter"); + counter.Add(1, [new KeyValuePair("key", "value")]); + + provider.ForceFlush(); + + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics); + var output = Encoding.UTF8.GetString(buffer, 0, cursor); + + if (useOpenMetrics) + { + Assert.Matches("test_counter_created\\{otel_scope_name=\"test_meter\",key=\"value\"\\} [0-9]+(?:\\.[0-9]+)?", output); + } + else + { + Assert.DoesNotContain("_created{", output, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void HistogramExportsCreatedMetric(bool useOpenMetrics) + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = new Meter("test_meter"); + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + var histogram = meter.CreateHistogram("test_histogram"); + histogram.Record(1, [new KeyValuePair("key", "value")]); + + provider.ForceFlush(); + + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics); + var output = Encoding.UTF8.GetString(buffer, 0, cursor); + + if (useOpenMetrics) + { + Assert.Matches("test_histogram_created\\{otel_scope_name=\"test_meter\",key=\"value\"\\} [0-9]+(?:\\.[0-9]+)?", output); + } + else + { + Assert.DoesNotContain("_created{", output, StringComparison.Ordinal); + } + } + [Fact] public void WriteUnicodeStringEncodesSurrogatePairsAsUtf8ScalarValues() { From e26bafb32056c30a5df8568cd6c56729084c64d2 Mon Sep 17 00:00:00 2001 From: martincostello Date: Wed, 29 Apr 2026 23:03:11 +0100 Subject: [PATCH 18/82] [Exporter.Prometheus] Address feedback Add missing `TYPE` metadata. --- .../Internal/PrometheusSerializerExt.cs | 34 +++++++++++++++++++ .../PrometheusExporterMiddlewareTests.cs | 8 +++-- .../PrometheusHttpListenerTests.cs | 6 +++- .../PrometheusSerializerTests.cs | 6 ++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs index cd2783225c9..2bf3c3ad93e 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -28,6 +28,11 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe cursor = WriteUnitMetadata(buffer, cursor, prometheusMetric, openMetricsRequested); cursor = WriteHelpMetadata(buffer, cursor, prometheusMetric, metric.Description, openMetricsRequested); + if (openMetricsRequested && HasCreatedMetric(metric, prometheusMetric)) + { + cursor = WriteCreatedTypeMetadata(buffer, cursor, prometheusMetric); + } + if (!metric.MetricType.IsHistogram()) { var isLong = ((int)metric.MetricType & 0b_0000_1111) == 0x0a; // I8 @@ -233,6 +238,35 @@ private static bool TryGetLatestHistogramBucketExemplar( TryGetLatestHistogramBucketExemplar(exemplars, lowerBoundExclusive, upperBoundInclusive, out exemplar); } + private static bool HasCreatedMetric(Metric metric, PrometheusMetric prometheusMetric) + { + if (prometheusMetric.Type != PrometheusType.Counter && !metric.MetricType.IsHistogram()) + { + return false; + } + + foreach (ref readonly var metricPoint in metric.GetMetricPoints()) + { + if (metricPoint.StartTime != default) + { + return true; + } + } + + return false; + } + + private static int WriteCreatedTypeMetadata(byte[] buffer, int cursor, PrometheusMetric prometheusMetric) + { + cursor = WriteAsciiStringNoEscape(buffer, cursor, "# TYPE "); + cursor = WriteMetricMetadataName(buffer, cursor, prometheusMetric, openMetricsRequested: true); + cursor = WriteAsciiStringNoEscape(buffer, cursor, "_created gauge"); + + buffer[cursor++] = ASCII_LINEFEED; + + return cursor; + } + private static int WriteCreatedMetric( byte[] buffer, int cursor, diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs index d919f4ee088..3d42ed348a9 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs @@ -487,6 +487,9 @@ private static async Task VerifyAsync(HttpResponseMessage response, bool request ? $"{string.Join(",", meterTags.Select(x => $"{x.Key}=\"{x.Value}\""))}," : string.Empty; var createdMetric = requestOpenMetrics + ? "# TYPE counter_double_bytes_created gauge" + : string.Empty; + var createdMetricSample = requestOpenMetrics ? $"\ncounter_double_bytes_created{{otel_scope_name=\"{MeterName}\",otel_scope_version=\"{MeterVersion}\",{additionalTags}key1=\"value1\",key2=\"value2\"}} [0-9]+(?:\\.[0-9]+)?" : string.Empty; @@ -502,14 +505,15 @@ private static async Task VerifyAsync(HttpResponseMessage response, bool request otel_scope_info{otel_scope_name="{{MeterName}}"} 1 # TYPE counter_double_bytes counter # UNIT counter_double_bytes bytes - counter_double_bytes_total{otel_scope_name="{{MeterName}}",otel_scope_version="{{MeterVersion}}",{{additionalTags}}key1="value1",key2="value2"} 101.17{{createdMetric}} + {{createdMetric}} + counter_double_bytes_total{otel_scope_name="{{MeterName}}",otel_scope_version="{{MeterVersion}}",{{additionalTags}}key1="value1",key2="value2"} 101.17{{createdMetricSample}} # EOF """.ReplaceLineEndings() : $$""" # TYPE counter_double_bytes_total counter # UNIT counter_double_bytes_total bytes - counter_double_bytes_total{otel_scope_name="{{MeterName}}",otel_scope_version="{{MeterVersion}}",{{additionalTags}}key1="value1",key2="value2"} 101.17{{createdMetric}} + counter_double_bytes_total{otel_scope_name="{{MeterName}}",otel_scope_version="{{MeterVersion}}",{{additionalTags}}key1="value1",key2="value2"} 101.17 # EOF """.ReplaceLineEndings(); diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs index 78e67087c04..b3d1354a75c 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs @@ -329,6 +329,9 @@ private static async Task RunPrometheusExporterHttpServerIntegrationTest( ? $"{string.Join(",", meterTags.Select(x => $"{x.Key}='{x.Value}'"))}," : string.Empty; var createdMetric = requestOpenMetrics + ? "# TYPE counter_double_bytes_created gauge\n" + : string.Empty; + var createdMetricSample = requestOpenMetrics ? $"counter_double_bytes_created{{otel_scope_name='{MeterName}',otel_scope_version='{MeterVersion}',{additionalTags}key1='value1',key2='value2'}} [0-9]+(?:\\.[0-9]+)?\n" : string.Empty; @@ -343,8 +346,9 @@ private static async Task RunPrometheusExporterHttpServerIntegrationTest( + $"otel_scope_info{{otel_scope_name='{MeterName}'}} 1\n" + "# TYPE counter_double_bytes counter\n" + "# UNIT counter_double_bytes bytes\n" - + $"counter_double_bytes_total{{otel_scope_name='{MeterName}',otel_scope_version='{MeterVersion}',{additionalTags}key1='value1',key2='value2'}} 101.17\n" + createdMetric + + $"counter_double_bytes_total{{otel_scope_name='{MeterName}',otel_scope_version='{MeterVersion}',{additionalTags}key1='value1',key2='value2'}} 101.17\n" + + createdMetricSample + "# EOF\n" : "# TYPE counter_double_bytes_total counter\n" + "# UNIT counter_double_bytes_total bytes\n" diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index d0bb5f35f80..b84530fe167 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -836,6 +836,7 @@ public void HistogramOneDimensionWithOpenMetricsFormat() var expected = ("^" + "# TYPE test_histogram histogram\n" + + "# TYPE test_histogram_created gauge\n" + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='0.0'}} 0\n" + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='5.0'}} 0\n" + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='10.0'}} 0\n" @@ -854,6 +855,7 @@ public void HistogramOneDimensionWithOpenMetricsFormat() + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='\\+Inf'}} 2\n" + $"test_histogram_sum{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1'}} 118\n" + $"test_histogram_count{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1'}} 2\n" + + $"test_histogram_created{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1'}} [0-9]+(?:\\.[0-9]+)?\n" + "$").Replace('\'', '"'); Assert.Matches(expected, output); } @@ -975,6 +977,7 @@ public void HistogramOneDimensionWithScopeVersion() Assert.Matches( ("^" + "# TYPE test_histogram histogram\n" + + "# TYPE test_histogram_created gauge\n" + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='0.0'}} 0\n" + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='5.0'}} 0\n" + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='10.0'}} 0\n" @@ -993,6 +996,7 @@ public void HistogramOneDimensionWithScopeVersion() + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='\\+Inf'}} 2\n" + $"test_histogram_sum{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1'}} 118\n" + $"test_histogram_count{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1'}} 2\n" + + $"test_histogram_created{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1'}} [0-9]+(?:\\.[0-9]+)?\n" + "$").Replace('\'', '"'), Encoding.UTF8.GetString(buffer, 0, cursor)); } @@ -1236,6 +1240,7 @@ public void CounterExportsCreatedMetric(bool useOpenMetrics) if (useOpenMetrics) { + Assert.Contains("# TYPE test_counter_created gauge", output, StringComparison.Ordinal); Assert.Matches("test_counter_created\\{otel_scope_name=\"test_meter\",key=\"value\"\\} [0-9]+(?:\\.[0-9]+)?", output); } else @@ -1268,6 +1273,7 @@ public void HistogramExportsCreatedMetric(bool useOpenMetrics) if (useOpenMetrics) { + Assert.Contains("# TYPE test_histogram_created gauge", output, StringComparison.Ordinal); Assert.Matches("test_histogram_created\\{otel_scope_name=\"test_meter\",key=\"value\"\\} [0-9]+(?:\\.[0-9]+)?", output); } else From f6d8c671e24539250e05fc8f76f677ad1c1e784d Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 21:22:16 +0100 Subject: [PATCH 19/82] [Exporter.Prometheus] Address feedback - Remove non-spec `TYPE` for `_counter`. - Fix-up timestamp precision. --- .../Internal/PrometheusSerializer.cs | 12 +++- .../Internal/PrometheusSerializerExt.cs | 34 ---------- .../PrometheusExporterMiddlewareTests.cs | 4 -- .../PrometheusHttpListenerTests.cs | 5 -- .../PrometheusSerializerTests.cs | 64 +++++++++---------- 5 files changed, 40 insertions(+), 79 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index eb7d5fce8c2..e1d0f52fe49 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -19,6 +19,10 @@ namespace OpenTelemetry.Exporter.Prometheus; /// internal static partial class PrometheusSerializer { +#if !NET + private static readonly DateTimeOffset UnixEpoch = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); +#endif + #pragma warning disable SA1310 // Field name should not contain an underscore private const byte ASCII_QUOTATION_MARK = 0x22; // '"' private const byte ASCII_REVERSE_SOLIDUS = 0x5C; // '\\' @@ -589,8 +593,12 @@ private static int WriteUnicodeScalar(byte[] buffer, int cursor, string value, r } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int WriteUnixTimeSeconds(byte[] buffer, int cursor, DateTimeOffset value) - => WriteDouble(buffer, cursor, value.ToUnixTimeMilliseconds() / 1000.0); + private static int WriteUnixTimeSeconds(byte[] buffer, int cursor, DateTimeOffset value) => +#if NET + WriteDouble(buffer, cursor, (value.UtcDateTime.Ticks - DateTimeOffset.UnixEpoch.Ticks) / (double)TimeSpan.TicksPerSecond); +#else + WriteDouble(buffer, cursor, (value.UtcDateTime.Ticks - UnixEpoch.Ticks) / (double)TimeSpan.TicksPerSecond); +#endif private static string MapPrometheusType(PrometheusType type, bool openMetricsRequested) => type switch { diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs index 2bf3c3ad93e..cd2783225c9 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -28,11 +28,6 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe cursor = WriteUnitMetadata(buffer, cursor, prometheusMetric, openMetricsRequested); cursor = WriteHelpMetadata(buffer, cursor, prometheusMetric, metric.Description, openMetricsRequested); - if (openMetricsRequested && HasCreatedMetric(metric, prometheusMetric)) - { - cursor = WriteCreatedTypeMetadata(buffer, cursor, prometheusMetric); - } - if (!metric.MetricType.IsHistogram()) { var isLong = ((int)metric.MetricType & 0b_0000_1111) == 0x0a; // I8 @@ -238,35 +233,6 @@ private static bool TryGetLatestHistogramBucketExemplar( TryGetLatestHistogramBucketExemplar(exemplars, lowerBoundExclusive, upperBoundInclusive, out exemplar); } - private static bool HasCreatedMetric(Metric metric, PrometheusMetric prometheusMetric) - { - if (prometheusMetric.Type != PrometheusType.Counter && !metric.MetricType.IsHistogram()) - { - return false; - } - - foreach (ref readonly var metricPoint in metric.GetMetricPoints()) - { - if (metricPoint.StartTime != default) - { - return true; - } - } - - return false; - } - - private static int WriteCreatedTypeMetadata(byte[] buffer, int cursor, PrometheusMetric prometheusMetric) - { - cursor = WriteAsciiStringNoEscape(buffer, cursor, "# TYPE "); - cursor = WriteMetricMetadataName(buffer, cursor, prometheusMetric, openMetricsRequested: true); - cursor = WriteAsciiStringNoEscape(buffer, cursor, "_created gauge"); - - buffer[cursor++] = ASCII_LINEFEED; - - return cursor; - } - private static int WriteCreatedMetric( byte[] buffer, int cursor, diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs index 3d42ed348a9..2ba7c0dd40b 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs @@ -486,9 +486,6 @@ private static async Task VerifyAsync(HttpResponseMessage response, bool request var additionalTags = meterTags is { Length: > 0 } ? $"{string.Join(",", meterTags.Select(x => $"{x.Key}=\"{x.Value}\""))}," : string.Empty; - var createdMetric = requestOpenMetrics - ? "# TYPE counter_double_bytes_created gauge" - : string.Empty; var createdMetricSample = requestOpenMetrics ? $"\ncounter_double_bytes_created{{otel_scope_name=\"{MeterName}\",otel_scope_version=\"{MeterVersion}\",{additionalTags}key1=\"value1\",key2=\"value2\"}} [0-9]+(?:\\.[0-9]+)?" : string.Empty; @@ -505,7 +502,6 @@ private static async Task VerifyAsync(HttpResponseMessage response, bool request otel_scope_info{otel_scope_name="{{MeterName}}"} 1 # TYPE counter_double_bytes counter # UNIT counter_double_bytes bytes - {{createdMetric}} counter_double_bytes_total{otel_scope_name="{{MeterName}}",otel_scope_version="{{MeterVersion}}",{{additionalTags}}key1="value1",key2="value2"} 101.17{{createdMetricSample}} # EOF diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs index b3d1354a75c..021d04f86a8 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs @@ -328,9 +328,6 @@ private static async Task RunPrometheusExporterHttpServerIntegrationTest( var additionalTags = meterTags is { Length: > 0 } ? $"{string.Join(",", meterTags.Select(x => $"{x.Key}='{x.Value}'"))}," : string.Empty; - var createdMetric = requestOpenMetrics - ? "# TYPE counter_double_bytes_created gauge\n" - : string.Empty; var createdMetricSample = requestOpenMetrics ? $"counter_double_bytes_created{{otel_scope_name='{MeterName}',otel_scope_version='{MeterVersion}',{additionalTags}key1='value1',key2='value2'}} [0-9]+(?:\\.[0-9]+)?\n" : string.Empty; @@ -346,14 +343,12 @@ private static async Task RunPrometheusExporterHttpServerIntegrationTest( + $"otel_scope_info{{otel_scope_name='{MeterName}'}} 1\n" + "# TYPE counter_double_bytes counter\n" + "# UNIT counter_double_bytes bytes\n" - + createdMetric + $"counter_double_bytes_total{{otel_scope_name='{MeterName}',otel_scope_version='{MeterVersion}',{additionalTags}key1='value1',key2='value2'}} 101.17\n" + createdMetricSample + "# EOF\n" : "# TYPE counter_double_bytes_total counter\n" + "# UNIT counter_double_bytes_total bytes\n" + $"counter_double_bytes_total{{otel_scope_name='{MeterName}',otel_scope_version='{MeterVersion}',{additionalTags}key1='value1',key2='value2'}} 101.17\n" - + createdMetric + "# EOF\n"; Assert.Matches(("^" + expected + "$").Replace('\'', '"'), content); diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index b84530fe167..abafc43e05e 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -836,22 +836,21 @@ public void HistogramOneDimensionWithOpenMetricsFormat() var expected = ("^" + "# TYPE test_histogram histogram\n" - + "# TYPE test_histogram_created gauge\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='0.0'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='5.0'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='10.0'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='25.0'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='50.0'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='75.0'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='100.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='250.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='500.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='750.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='1000.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='2500.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='5000.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='7500.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='10000.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='0'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='5'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='10'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='25'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='50'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='75'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='100'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='250'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='500'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='750'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='1000'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='2500'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='5000'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='7500'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='10000'}} 2\n" + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='\\+Inf'}} 2\n" + $"test_histogram_sum{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1'}} 118\n" + $"test_histogram_count{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1'}} 2\n" @@ -977,22 +976,21 @@ public void HistogramOneDimensionWithScopeVersion() Assert.Matches( ("^" + "# TYPE test_histogram histogram\n" - + "# TYPE test_histogram_created gauge\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='0.0'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='5.0'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='10.0'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='25.0'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='50.0'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='75.0'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='100.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='250.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='500.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='750.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='1000.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='2500.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='5000.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='7500.0'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='10000.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='0'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='5'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='10'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='25'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='50'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='75'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='100'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='250'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='500'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='750'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='1000'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='2500'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='5000'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='7500'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='10000'}} 2\n" + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='\\+Inf'}} 2\n" + $"test_histogram_sum{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1'}} 118\n" + $"test_histogram_count{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1'}} 2\n" @@ -1240,7 +1238,6 @@ public void CounterExportsCreatedMetric(bool useOpenMetrics) if (useOpenMetrics) { - Assert.Contains("# TYPE test_counter_created gauge", output, StringComparison.Ordinal); Assert.Matches("test_counter_created\\{otel_scope_name=\"test_meter\",key=\"value\"\\} [0-9]+(?:\\.[0-9]+)?", output); } else @@ -1273,7 +1270,6 @@ public void HistogramExportsCreatedMetric(bool useOpenMetrics) if (useOpenMetrics) { - Assert.Contains("# TYPE test_histogram_created gauge", output, StringComparison.Ordinal); Assert.Matches("test_histogram_created\\{otel_scope_name=\"test_meter\",key=\"value\"\\} [0-9]+(?:\\.[0-9]+)?", output); } else From ffb65597b1b3550559877bb6261ae6fc884518ff Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 21:28:08 +0100 Subject: [PATCH 20/82] [Exporter.Prometheus] Fix-up merge Fix-up duplicated definitions. --- .../Internal/PrometheusSerializer.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index e1d0f52fe49..88a46116c4a 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -29,6 +29,10 @@ internal static partial class PrometheusSerializer private const byte ASCII_LINEFEED = 0x0A; // `\n` #pragma warning restore SA1310 // Field name should not contain an underscore +#if !NET + private static readonly DateTimeOffset UnixEpoch = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); +#endif + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteDouble(byte[] buffer, int cursor, double value) { @@ -592,6 +596,7 @@ private static int WriteUnicodeScalar(byte[] buffer, int cursor, string value, r return WriteUnicodeNoEscape(buffer, cursor, 0xFFFD); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int WriteUnixTimeSeconds(byte[] buffer, int cursor, DateTimeOffset value) => #if NET From b8609aafc98540bcfd1d604819aa70a6d8f364e7 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 21:33:03 +0100 Subject: [PATCH 21/82] [Exporter.Prometheus] Fix SA1507 warning Fix-up merge. --- .../Internal/PrometheusSerializer.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 88a46116c4a..d95f5cf72fc 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -596,7 +596,6 @@ private static int WriteUnicodeScalar(byte[] buffer, int cursor, string value, r return WriteUnicodeNoEscape(buffer, cursor, 0xFFFD); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int WriteUnixTimeSeconds(byte[] buffer, int cursor, DateTimeOffset value) => #if NET From 9454950b4cde685991657dd46e4cc47bf7b61280 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 21:36:14 +0100 Subject: [PATCH 22/82] [Exporter.Prometheus] Fix test Add missing suffix. --- .../PrometheusSerializerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index abafc43e05e..60271eeda02 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -757,7 +757,7 @@ public void CounterWithOpenMetricsFormatEmitsLatestExemplar() var cursor = WriteMetric(buffer, 0, metrics[0], true); var output = Encoding.UTF8.GetString(buffer, 0, cursor); var counterLine = output.Split(['\n'], StringSplitOptions.RemoveEmptyEntries) - .Single(line => line.StartsWith("test_counter", StringComparison.Ordinal)); + .Single(line => line.StartsWith("test_counter_total{", StringComparison.Ordinal)); Assert.Contains(" 3 # ", counterLine, StringComparison.Ordinal); Assert.Contains( @@ -797,7 +797,7 @@ public void CounterWithOpenMetricsFormatEmitsExemplarWithoutLabelsWhenOnlyReserv var cursor = WriteMetric(buffer, 0, metrics[0], true); var output = Encoding.UTF8.GetString(buffer, 0, cursor); var counterLine = output.Split(['\n'], StringSplitOptions.RemoveEmptyEntries) - .Single(line => line.StartsWith("test_counter", StringComparison.Ordinal)); + .Single(line => line.StartsWith("test_counter_total{", StringComparison.Ordinal)); Assert.Contains(" 2 # {} 2 ", counterLine, StringComparison.Ordinal); Assert.DoesNotContain("ignored-trace", counterLine, StringComparison.Ordinal); From 995e4cc2e2d64c017568d1b8f455b5f111c4f41f Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 19:05:44 +0100 Subject: [PATCH 23/82] [Exporter.Prometheus] Fix scope metadata - Emit OpenMetrics scope metadata as a single `otel_scope` metric family with `otel_scope_info` samples instead of repeating metadata for every scope. - Include instrumentation scope metadata on samples using `otel_scope_*` labels, including scope version, schema URL, and prefixed scope attributes. - Drop conflicting scope attributes named `name`, `version`, and `schema_url` to avoid collisions with generated scope labels. --- .../Internal/PrometheusCollectionManager.cs | 34 +++++- .../Internal/PrometheusSerializer.cs | 106 +++++++++++++----- .../Internal/PrometheusSerializerExt.cs | 1 + .../PrometheusExporterMiddlewareTests.cs | 10 +- .../PrometheusCollectionManagerTests.cs | 48 ++++++++ .../PrometheusHttpListenerTests.cs | 10 +- .../PrometheusSerializerTests.cs | 76 ++++++++++++- 7 files changed, 245 insertions(+), 40 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs index 6c5ed638001..e704cbe20be 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs @@ -166,6 +166,29 @@ private static bool IncreaseBufferSize(ref byte[] buffer) return true; } + private static string CreateScopeIdentity(Metric metric) + { + var builder = new StringBuilder(metric.MeterName); + + builder.Append('\0') + .Append(metric.MeterVersion) + .Append('\0') + .Append(metric.MeterSchemaUrl); + + if (metric.MeterTags != null) + { + foreach (var tag in metric.MeterTags.OrderBy(static t => t.Key, StringComparer.Ordinal)) + { + builder.Append('\0') + .Append(tag.Key) + .Append('\0') + .Append(tag.Value); + } + } + + return builder.ToString(); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void EnterGlobalLock() { @@ -224,6 +247,7 @@ private ExportResult OnCollect(in Batch metrics) cursor = this.WriteTargetInfo(ref buffer); this.scopes.Clear(); + var scopeInfoMetadataWritten = false; foreach (var metric in metrics) { @@ -232,13 +256,19 @@ private ExportResult OnCollect(in Batch metrics) continue; } - if (this.scopes.Add(metric.MeterName)) + if (this.scopes.Add(CreateScopeIdentity(metric))) { while (true) { try { - cursor = PrometheusSerializer.WriteScopeInfo(buffer, cursor, metric.MeterName, openMetricsRequested: true); + if (!scopeInfoMetadataWritten) + { + cursor = PrometheusSerializer.WriteScopeInfoMetadata(buffer, cursor); + scopeInfoMetadataWritten = true; + } + + cursor = PrometheusSerializer.WriteScopeInfoMetric(buffer, cursor, metric); break; } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index d95f5cf72fc..9f9dac6bd98 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -22,6 +22,7 @@ internal static partial class PrometheusSerializer #if !NET private static readonly DateTimeOffset UnixEpoch = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); #endif + private static readonly HashSet ReservedScopeAttributeNames = ["name", "schema_url", "version"]; #pragma warning disable SA1310 // Field name should not contain an underscore private const byte ASCII_QUOTATION_MARK = 0x22; // '"' @@ -426,23 +427,34 @@ public static int WriteUnitMetadata(byte[] buffer, int cursor, PrometheusMetric } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteScopeInfo(byte[] buffer, int cursor, string scopeName, bool openMetricsRequested) + public static int WriteScopeInfo(byte[] buffer, int cursor, Metric metric) { - if (string.IsNullOrEmpty(scopeName)) - { - return cursor; - } + cursor = WriteScopeInfoMetadata(buffer, cursor); + return WriteScopeInfoMetric(buffer, cursor, metric); + } - cursor = WriteAsciiStringNoEscape(buffer, cursor, "# TYPE otel_scope_info info"); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteScopeInfoMetadata(byte[] buffer, int cursor) + { + cursor = WriteAsciiStringNoEscape(buffer, cursor, "# TYPE otel_scope info"); buffer[cursor++] = ASCII_LINEFEED; - cursor = WriteAsciiStringNoEscape(buffer, cursor, "# HELP otel_scope_info Scope metadata"); + cursor = WriteAsciiStringNoEscape(buffer, cursor, "# HELP otel_scope Scope metadata"); buffer[cursor++] = ASCII_LINEFEED; + return cursor; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteScopeInfoMetric(byte[] buffer, int cursor, Metric metric) + { + if (string.IsNullOrEmpty(metric.MeterName)) + { + return cursor; + } + cursor = WriteAsciiStringNoEscape(buffer, cursor, "otel_scope_info"); - buffer[cursor++] = unchecked((byte)'{'); - cursor = WriteLabel(buffer, cursor, "otel_scope_name", scopeName, openMetricsRequested); - buffer[cursor++] = unchecked((byte)'}'); + cursor = WriteScopeLabels(buffer, cursor, metric, openMetricsRequested: true); buffer[cursor++] = unchecked((byte)' '); buffer[cursor++] = unchecked((byte)'1'); buffer[cursor++] = ASCII_LINEFEED; @@ -464,33 +476,23 @@ public static int WriteTags( buffer[cursor++] = unchecked((byte)'{'); } - cursor = WriteLabel(buffer, cursor, "otel_scope_name", metric.MeterName, openMetricsRequested); - buffer[cursor++] = unchecked((byte)','); + var wroteLabel = false; + cursor = WriteScopeLabels(buffer, cursor, metric, openMetricsRequested, ref wroteLabel); - if (!string.IsNullOrEmpty(metric.MeterVersion)) - { - cursor = WriteLabel(buffer, cursor, "otel_scope_version", metric.MeterVersion, openMetricsRequested); - buffer[cursor++] = unchecked((byte)','); - } - - if (metric.MeterTags != null) + foreach (var tag in tags) { - foreach (var tag in metric.MeterTags) + if (wroteLabel) { - cursor = WriteLabel(buffer, cursor, tag.Key, tag.Value, openMetricsRequested); buffer[cursor++] = unchecked((byte)','); } - } - foreach (var tag in tags) - { cursor = WriteLabel(buffer, cursor, tag.Key, tag.Value, openMetricsRequested); - buffer[cursor++] = unchecked((byte)','); + wroteLabel = true; } if (writeEnclosingBraces) { - buffer[cursor - 1] = unchecked((byte)'}'); // Note: We write the '}' over the last written comma, which is extra. + buffer[cursor++] = unchecked((byte)'}'); } return cursor; @@ -571,6 +573,58 @@ private static int WriteNormalizedLabelKey(byte[] buffer, int cursor, string val return cursor; } + private static int WriteScopeLabels(byte[] buffer, int cursor, Metric metric, bool openMetricsRequested) + { + buffer[cursor++] = unchecked((byte)'{'); + var wroteLabel = false; + + cursor = WriteScopeLabels(buffer, cursor, metric, openMetricsRequested, ref wroteLabel); + + buffer[cursor++] = unchecked((byte)'}'); + return cursor; + } + + private static int WriteScopeLabels(byte[] buffer, int cursor, Metric metric, bool openMetricsRequested, ref bool wroteLabel) + { + cursor = WriteScopeLabel(buffer, cursor, "otel_scope_name", metric.MeterName, openMetricsRequested, ref wroteLabel); + + if (!string.IsNullOrEmpty(metric.MeterVersion)) + { + cursor = WriteScopeLabel(buffer, cursor, "otel_scope_version", metric.MeterVersion, openMetricsRequested, ref wroteLabel); + } + + if (!string.IsNullOrEmpty(metric.MeterSchemaUrl)) + { + cursor = WriteScopeLabel(buffer, cursor, "otel_scope_schema_url", metric.MeterSchemaUrl, openMetricsRequested, ref wroteLabel); + } + + if (metric.MeterTags != null) + { + foreach (var tag in metric.MeterTags) + { + if (!ReservedScopeAttributeNames.Contains(tag.Key)) + { + cursor = WriteScopeLabel(buffer, cursor, $"otel_scope_{tag.Key}", tag.Value, openMetricsRequested, ref wroteLabel); + } + } + } + + return cursor; + } + + private static int WriteScopeLabel(byte[] buffer, int cursor, string key, object? value, bool openMetricsRequested, ref bool wroteLabel) + { + if (wroteLabel) + { + buffer[cursor++] = unchecked((byte)','); + } + + cursor = WriteLabel(buffer, cursor, key, value, openMetricsRequested); + wroteLabel = true; + + return cursor; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool IsAllowedMetricsLabelCharacter(char value, bool isOpenMetrics) => char.IsAsciiLetterOrDigit(value) || value is '_' || (isOpenMetrics && value == ':'); diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs index cd2783225c9..e47b2196f09 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -89,6 +89,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); cursor = WriteAsciiStringNoEscape(buffer, cursor, "_bucket{"); cursor = WriteTags(buffer, cursor, metric, tags, openMetricsRequested, writeEnclosingBraces: false); + buffer[cursor++] = unchecked((byte)','); cursor = WriteAsciiStringNoEscape(buffer, cursor, "le=\""); diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs index 2ba7c0dd40b..938e1afe899 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs @@ -484,12 +484,14 @@ private static async Task VerifyAsync(HttpResponseMessage response, bool request } var additionalTags = meterTags is { Length: > 0 } - ? $"{string.Join(",", meterTags.Select(x => $"{x.Key}=\"{x.Value}\""))}," + ? $"{string.Join(",", meterTags.Select(x => $"otel_scope_{x.Key}=\"{x.Value}\""))}," : string.Empty; var createdMetricSample = requestOpenMetrics ? $"\ncounter_double_bytes_created{{otel_scope_name=\"{MeterName}\",otel_scope_version=\"{MeterVersion}\",{additionalTags}key1=\"value1\",key2=\"value2\"}} [0-9]+(?:\\.[0-9]+)?" : string.Empty; + var scopeInfoMetric = $"otel_scope_info{{otel_scope_name=\"{MeterName}\",otel_scope_version=\"{MeterVersion}\"{(string.IsNullOrEmpty(additionalTags) ? string.Empty : "," + additionalTags.TrimEnd(','))}}} 1"; + var content = (await response.Content.ReadAsStringAsync()).ReplaceLineEndings(); var expected = requestOpenMetrics @@ -497,9 +499,9 @@ private static async Task VerifyAsync(HttpResponseMessage response, bool request # TYPE target info # HELP target Target metadata target_info{service_name="my_service",service_instance_id="id1"} 1 - # TYPE otel_scope_info info - # HELP otel_scope_info Scope metadata - otel_scope_info{otel_scope_name="{{MeterName}}"} 1 + # TYPE otel_scope info + # HELP otel_scope Scope metadata + {{scopeInfoMetric}} # TYPE counter_double_bytes counter # UNIT counter_double_bytes bytes counter_double_bytes_total{otel_scope_name="{{MeterName}}",otel_scope_version="{{MeterVersion}}",{{additionalTags}}key1="value1",key2="value2"} 101.17{{createdMetricSample}} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs index f999e7cf222..1b635b0b3e8 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 using System.Diagnostics.Metrics; +using System.Text; +using System.Text.RegularExpressions; using OpenTelemetry.Metrics; using OpenTelemetry.Tests; using Xunit; @@ -291,6 +293,52 @@ public async Task EnterCollectWaitsForActiveReadersToExit() } } + [Fact] + public async Task OpenMetricsScopeInfoIsWrittenAsASingleMetricFamily() + { + using var meter1 = new Meter("test_meter", "1.0.0"); + using var meter2 = new Meter("test_meter", "2.0.0", [new("library.mascot", "gopher")], scope: null); + + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter1.Name) +#if PROMETHEUS_HTTP_LISTENER + .AddPrometheusHttpListener() +#elif PROMETHEUS_ASPNETCORE + .AddPrometheusExporter() +#endif + .Build(); + +#pragma warning disable CA2000 // MeterProvider owns exporter lifecycle + Assert.True(provider.TryFindExporter(out PrometheusExporter? exporter)); +#pragma warning restore CA2000 // MeterProvider owns exporter lifecycle + + meter1.CreateCounter("counter_1").Add(1); + meter2.CreateCounter("counter_2").Add(1); + + var response = await exporter!.CollectionManager.EnterCollect(openMetricsRequested: true); + try + { + var output = Encoding.UTF8.GetString( + response.OpenMetricsView.Array!, + response.OpenMetricsView.Offset, + response.OpenMetricsView.Count); + +#if NET + Assert.Equal(1, Regex.Count(output, "^# TYPE otel_scope info$", RegexOptions.Multiline)); + Assert.Equal(1, Regex.Count(output, "^# HELP otel_scope Scope metadata$", RegexOptions.Multiline)); +#else + Assert.Single(Regex.Matches(output, "^# TYPE otel_scope info$", RegexOptions.Multiline)); + Assert.Single(Regex.Matches(output, "^# HELP otel_scope Scope metadata$", RegexOptions.Multiline)); +#endif + Assert.Contains("otel_scope_info{otel_scope_name=\"test_meter\",otel_scope_version=\"1.0.0\"} 1", output, StringComparison.Ordinal); + Assert.Contains("otel_scope_info{otel_scope_name=\"test_meter\",otel_scope_version=\"2.0.0\",otel_scope_library_mascot=\"gopher\"} 1", output, StringComparison.Ordinal); + } + finally + { + exporter.CollectionManager.ExitCollect(); + } + } + #if PROMETHEUS_HTTP_LISTENER private static MeterProvider CreateMeterProviderWithRandomPort(Meter meter) { diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs index 021d04f86a8..5925d026cbe 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs @@ -326,21 +326,23 @@ private static async Task RunPrometheusExporterHttpServerIntegrationTest( } var additionalTags = meterTags is { Length: > 0 } - ? $"{string.Join(",", meterTags.Select(x => $"{x.Key}='{x.Value}'"))}," + ? $"{string.Join(",", meterTags.Select(x => $"otel_scope_{x.Key}='{x.Value}'"))}," : string.Empty; var createdMetricSample = requestOpenMetrics ? $"counter_double_bytes_created{{otel_scope_name='{MeterName}',otel_scope_version='{MeterVersion}',{additionalTags}key1='value1',key2='value2'}} [0-9]+(?:\\.[0-9]+)?\n" : string.Empty; + var scopeInfoMetric = $"otel_scope_info{{otel_scope_name='{MeterName}',otel_scope_version='{MeterVersion}'{(string.IsNullOrEmpty(additionalTags) ? string.Empty : "," + additionalTags.TrimEnd(','))}}} 1\n"; + var content = await response.Content.ReadAsStringAsync(); var expected = requestOpenMetrics ? "# TYPE target info\n" + "# HELP target Target metadata\n" + "target_info{service_name='my_service',service_instance_id='id1'} 1\n" - + "# TYPE otel_scope_info info\n" - + "# HELP otel_scope_info Scope metadata\n" - + $"otel_scope_info{{otel_scope_name='{MeterName}'}} 1\n" + + "# TYPE otel_scope info\n" + + "# HELP otel_scope Scope metadata\n" + + scopeInfoMetric + "# TYPE counter_double_bytes counter\n" + "# UNIT counter_double_bytes bytes\n" + $"counter_double_bytes_total{{otel_scope_name='{MeterName}',otel_scope_version='{MeterVersion}',{additionalTags}key1='value1',key2='value2'}} 101.17\n" diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 60271eeda02..216c8d405a1 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -919,18 +919,86 @@ public void TryGetLatestHistogramBucketExemplarMatchesNegativeInfinityInFirstBuc public void ScopeInfo() { var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = new Meter("test_meter", "1.0.0", [new("library.mascot", "gopher")], scope: null); + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + meter.CreateObservableGauge("test_gauge", () => 1); + + provider.ForceFlush(); - var cursor = PrometheusSerializer.WriteScopeInfo(buffer, 0, "test_meter", openMetricsRequested: true); + var cursor = PrometheusSerializer.WriteScopeInfo(buffer, 0, metrics[0]); Assert.Matches( ("^" - + "# TYPE otel_scope_info info\n" - + "# HELP otel_scope_info Scope metadata\n" - + "otel_scope_info{otel_scope_name='test_meter'} 1\n" + + "# TYPE otel_scope info\n" + + "# HELP otel_scope Scope metadata\n" + + "otel_scope_info{otel_scope_name='test_meter',otel_scope_version='1.0.0',otel_scope_library_mascot='gopher'} 1\n" + "$").Replace('\'', '"'), Encoding.UTF8.GetString(buffer, 0, cursor)); } + [Fact] + public void WriteMetricPrefixesScopeAttributesAndDropsConflictingScopeAttributeNames() + { + var buffer = new byte[85000]; + var metrics = new List(); + +#if NET + using var meter = new Meter( + new MeterOptions("test_meter") + { + Version = "1.0.0", + TelemetrySchemaUrl = "https://opentelemetry.io/schemas/1.0.0", + Tags = + [ + new("library.mascot", "gopher"), + new("name", "ignored-name"), + new("version", "ignored-version"), + new("schema_url", "ignored-schema"), + ], + }); +#else + using var meter = new Meter( + name: "test_meter", + version: "1.0.0", + tags: + [ + new("library.mascot", "gopher"), + new("name", "ignored-name"), + new("version", "ignored-version"), + new("schema_url", "ignored-schema"), + ]); +#endif + + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + meter.CreateObservableGauge( + "test_gauge", + () => [new Measurement(123, new KeyValuePair("metric_tag", "value"))]); + + provider.ForceFlush(); + + var cursor = WriteMetric(buffer, 0, metrics[0]); + var output = Encoding.UTF8.GetString(buffer, 0, cursor); + + Assert.Contains("otel_scope_library_mascot=\"gopher\"", output, StringComparison.Ordinal); + Assert.Contains("metric_tag=\"value\"", output, StringComparison.Ordinal); + Assert.DoesNotContain("otel_scope_name=\"ignored-name\"", output, StringComparison.Ordinal); + Assert.DoesNotContain("otel_scope_version=\"ignored-version\"", output, StringComparison.Ordinal); +#if NET + Assert.Contains("otel_scope_schema_url=\"https://opentelemetry.io/schemas/1.0.0\"", output, StringComparison.Ordinal); +#endif + Assert.DoesNotContain("otel_scope_schema_url=\"ignored-schema\"", output, StringComparison.Ordinal); + } + [Fact] public void SumWithScopeVersion() { From 138f4ef6a5aa0b1a6ba929e68c25310c8bbed13b Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 19:18:32 +0100 Subject: [PATCH 24/82] [Exporter.Prometheus] Update test cases Remove Go theme for .NET. --- .../PrometheusCollectionManagerTests.cs | 4 ++-- .../PrometheusSerializerTests.cs | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs index 1b635b0b3e8..b2149f99f9f 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs @@ -297,7 +297,7 @@ public async Task EnterCollectWaitsForActiveReadersToExit() public async Task OpenMetricsScopeInfoIsWrittenAsASingleMetricFamily() { using var meter1 = new Meter("test_meter", "1.0.0"); - using var meter2 = new Meter("test_meter", "2.0.0", [new("library.mascot", "gopher")], scope: null); + using var meter2 = new Meter("test_meter", "2.0.0", [new("library.mascot", "dotnetbot")], scope: null); using var provider = Sdk.CreateMeterProviderBuilder() .AddMeter(meter1.Name) @@ -331,7 +331,7 @@ public async Task OpenMetricsScopeInfoIsWrittenAsASingleMetricFamily() Assert.Single(Regex.Matches(output, "^# HELP otel_scope Scope metadata$", RegexOptions.Multiline)); #endif Assert.Contains("otel_scope_info{otel_scope_name=\"test_meter\",otel_scope_version=\"1.0.0\"} 1", output, StringComparison.Ordinal); - Assert.Contains("otel_scope_info{otel_scope_name=\"test_meter\",otel_scope_version=\"2.0.0\",otel_scope_library_mascot=\"gopher\"} 1", output, StringComparison.Ordinal); + Assert.Contains("otel_scope_info{otel_scope_name=\"test_meter\",otel_scope_version=\"2.0.0\",otel_scope_library_mascot=\"dotnetbot\"} 1", output, StringComparison.Ordinal); } finally { diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 216c8d405a1..b0b4b2b7dcc 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -921,7 +921,7 @@ public void ScopeInfo() var buffer = new byte[85000]; var metrics = new List(); - using var meter = new Meter("test_meter", "1.0.0", [new("library.mascot", "gopher")], scope: null); + using var meter = new Meter("test_meter", "1.0.0", [new("library.mascot", "dotnetbot")], scope: null); using var provider = Sdk.CreateMeterProviderBuilder() .AddMeter(meter.Name) .AddInMemoryExporter(metrics) @@ -937,7 +937,7 @@ public void ScopeInfo() ("^" + "# TYPE otel_scope info\n" + "# HELP otel_scope Scope metadata\n" - + "otel_scope_info{otel_scope_name='test_meter',otel_scope_version='1.0.0',otel_scope_library_mascot='gopher'} 1\n" + + "otel_scope_info{otel_scope_name='test_meter',otel_scope_version='1.0.0',otel_scope_library_mascot='dotnetbot'} 1\n" + "$").Replace('\'', '"'), Encoding.UTF8.GetString(buffer, 0, cursor)); } @@ -956,7 +956,7 @@ public void WriteMetricPrefixesScopeAttributesAndDropsConflictingScopeAttributeN TelemetrySchemaUrl = "https://opentelemetry.io/schemas/1.0.0", Tags = [ - new("library.mascot", "gopher"), + new("library.mascot", "dotnetbot"), new("name", "ignored-name"), new("version", "ignored-version"), new("schema_url", "ignored-schema"), @@ -968,7 +968,7 @@ public void WriteMetricPrefixesScopeAttributesAndDropsConflictingScopeAttributeN version: "1.0.0", tags: [ - new("library.mascot", "gopher"), + new("library.mascot", "dotnetbot"), new("name", "ignored-name"), new("version", "ignored-version"), new("schema_url", "ignored-schema"), @@ -989,7 +989,7 @@ public void WriteMetricPrefixesScopeAttributesAndDropsConflictingScopeAttributeN var cursor = WriteMetric(buffer, 0, metrics[0]); var output = Encoding.UTF8.GetString(buffer, 0, cursor); - Assert.Contains("otel_scope_library_mascot=\"gopher\"", output, StringComparison.Ordinal); + Assert.Contains("otel_scope_library_mascot=\"dotnetbot\"", output, StringComparison.Ordinal); Assert.Contains("metric_tag=\"value\"", output, StringComparison.Ordinal); Assert.DoesNotContain("otel_scope_name=\"ignored-name\"", output, StringComparison.Ordinal); Assert.DoesNotContain("otel_scope_version=\"ignored-version\"", output, StringComparison.Ordinal); From 5b8217348a8f1eaf2380635bd35eeab9fd14e5c9 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 19:22:08 +0100 Subject: [PATCH 25/82] [Exporter.Prometheus] Update CHANGELOGs Add CHANGELOG entries. --- .../CHANGELOG.md | 11 +++++++++++ .../CHANGELOG.md | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 8f18c1bdf28..102edf9f65f 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -58,6 +58,17 @@ Notes](../../RELEASENOTES.md). OpenMetrics and a start time is available. ([#7223](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7223)) +* Emit OpenMetrics scope metadata as a single `otel_scope` metric family with + `otel_scope_info` samples instead of repeating metadata for every scope. + ([#7237](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7237)) + +* Include instrumentation scope metadata on samples using `otel_scope_*` labels + including scope version, schema URL, and prefixed scope attributes. + ([#7237](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7237)) + +* Drop conflicting scope attributes named `name`, `version`, and `schema_url`. + ([#7237](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7237)) + ## 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 215e03180c3..2c0db064a1f 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -75,6 +75,17 @@ Notes](../../RELEASENOTES.md). OpenMetrics and a start time is available. ([#7223](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7223)) +* Emit OpenMetrics scope metadata as a single `otel_scope` metric family with + `otel_scope_info` samples instead of repeating metadata for every scope. + ([#7237](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7237)) + +* Include instrumentation scope metadata on samples using `otel_scope_*` labels + including scope version, schema URL, and prefixed scope attributes. + ([#7237](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7237)) + +* Drop conflicting scope attributes named `name`, `version`, and `schema_url`. + ([#7237](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7237)) + ## 1.15.3-beta.1 Released 2026-Apr-21 From fa5ddcc173bdbf8952c91d34ac287197f38cf5d4 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 21:55:30 +0100 Subject: [PATCH 26/82] [Exporter.Prometheus] Address feedback Address Copilot review feedback. --- .../Internal/PrometheusCollectionManager.cs | 25 +-- .../Internal/PrometheusSerializer.cs | 178 +++++++++++++----- .../PrometheusCollectionManagerTests.cs | 44 +++++ .../PrometheusSerializerTests.cs | 38 ++++ 4 files changed, 212 insertions(+), 73 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs index e704cbe20be..ee94a659e4c 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs @@ -166,29 +166,6 @@ private static bool IncreaseBufferSize(ref byte[] buffer) return true; } - private static string CreateScopeIdentity(Metric metric) - { - var builder = new StringBuilder(metric.MeterName); - - builder.Append('\0') - .Append(metric.MeterVersion) - .Append('\0') - .Append(metric.MeterSchemaUrl); - - if (metric.MeterTags != null) - { - foreach (var tag in metric.MeterTags.OrderBy(static t => t.Key, StringComparer.Ordinal)) - { - builder.Append('\0') - .Append(tag.Key) - .Append('\0') - .Append(tag.Value); - } - } - - return builder.ToString(); - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private void EnterGlobalLock() { @@ -256,7 +233,7 @@ private ExportResult OnCollect(in Batch metrics) continue; } - if (this.scopes.Add(CreateScopeIdentity(metric))) + if (this.scopes.Add(PrometheusSerializer.CreateScopeIdentity(metric))) { while (true) { diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 9f9dac6bd98..73f03a9f1f4 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -8,6 +8,7 @@ using System.Diagnostics; using System.Globalization; using System.Runtime.CompilerServices; +using System.Text; using OpenTelemetry.Internal; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; @@ -33,6 +34,7 @@ internal static partial class PrometheusSerializer #if !NET private static readonly DateTimeOffset UnixEpoch = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); #endif + private static readonly HashSet ReservedScopeLabelNames = ["otel_scope_name", "otel_scope_schema_url", "otel_scope_version"]; [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteDouble(byte[] buffer, int cursor, double value) @@ -209,52 +211,6 @@ public static int WriteLabel(byte[] buffer, int cursor, string labelKey, object? buffer[cursor++] = unchecked((byte)'"'); return cursor; - - static string GetLabelValueString(object? labelValue) - { - // TODO: Attribute values should be written as their JSON representation. Extra logic may need to be added here to correctly convert other .NET types. - // More detail: https://github.com/open-telemetry/opentelemetry-dotnet/issues/4822#issuecomment-1707328495 - if (labelValue is bool booleanValue) - { - return booleanValue ? "true" : "false"; - } - else if (labelValue is double doubleValue) - { - return DoubleToString(doubleValue); - } - else if (labelValue is float floatValue) - { - return DoubleToString(floatValue); - } - - return labelValue?.ToString() ?? string.Empty; - - static string DoubleToString(double value) - { - // From https://prometheus.io/docs/specs/om/open_metrics_spec/#considerations-canonical-numbers: - // A warning to implementers in C and other languages that share its printf implementation: - // The standard precision of %f, %e and %g is only six significant digits. 17 significant - // digits are required for full precision, e.g. printf("%.17g", d). - if (MathHelper.IsFinite(value)) - { - return value.ToString("G17", CultureInfo.InvariantCulture); - } - else if (double.IsPositiveInfinity(value)) - { - return "+Inf"; - } - else if (double.IsNegativeInfinity(value)) - { - return "-Inf"; - } - else - { - // See https://prometheus.io/docs/instrumenting/exposition_formats/#comments-help-text-and-type-information - Debug.Assert(double.IsNaN(value), $"{nameof(value)} should be NaN."); - return "NaN"; - } - } - } } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -518,7 +474,6 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource, foreach (var attribute in resource.Attributes) { cursor = WriteLabel(buffer, cursor, attribute.Key, attribute.Value, openMetricsRequested); - buffer[cursor++] = unchecked((byte)','); } @@ -602,9 +557,9 @@ private static int WriteScopeLabels(byte[] buffer, int cursor, Metric metric, bo { foreach (var tag in metric.MeterTags) { - if (!ReservedScopeAttributeNames.Contains(tag.Key)) + if (TryCreateScopeLabel(tag, out var scopeLabel)) { - cursor = WriteScopeLabel(buffer, cursor, $"otel_scope_{tag.Key}", tag.Value, openMetricsRequested, ref wroteLabel); + cursor = WriteScopeLabel(buffer, cursor, scopeLabel.Key, scopeLabel.Value, openMetricsRequested, ref wroteLabel); } } } @@ -612,6 +567,131 @@ private static int WriteScopeLabels(byte[] buffer, int cursor, Metric metric, bo return cursor; } + internal static string CreateScopeIdentity(Metric metric) + { + var scopeLabels = new List>(3) + { + new("otel_scope_name", GetLabelValueString(metric.MeterName)), + }; + + if (!string.IsNullOrEmpty(metric.MeterVersion)) + { + scopeLabels.Add(new("otel_scope_version", GetLabelValueString(metric.MeterVersion))); + } + + if (!string.IsNullOrEmpty(metric.MeterSchemaUrl)) + { + scopeLabels.Add(new("otel_scope_schema_url", GetLabelValueString(metric.MeterSchemaUrl))); + } + + if (metric.MeterTags != null) + { + foreach (var tag in metric.MeterTags) + { + if (TryCreateScopeLabel(tag, out var scopeLabel)) + { + scopeLabels.Add(scopeLabel); + } + } + } + + scopeLabels.Sort(static (x, y) => + { + var keyCompare = string.CompareOrdinal(x.Key, y.Key); + return keyCompare != 0 ? keyCompare : string.CompareOrdinal(x.Value, y.Value); + }); + + var builder = new StringBuilder(); + + foreach (var scopeLabel in scopeLabels) + { + builder.Append('\0') + .Append(scopeLabel.Key) + .Append('\0') + .Append(scopeLabel.Value); + } + + return builder.ToString(); + } + + private static string GetLabelValueString(object? labelValue) + { + // TODO: Attribute values should be written as their JSON representation. Extra logic may need to be added here to correctly convert other .NET types. + // More detail: https://github.com/open-telemetry/opentelemetry-dotnet/issues/4822#issuecomment-1707328495 + if (labelValue is bool booleanValue) + { + return booleanValue ? "true" : "false"; + } + else if (labelValue is double doubleValue) + { + return DoubleToString(doubleValue); + } + else if (labelValue is float floatValue) + { + return DoubleToString(floatValue); + } + + return labelValue?.ToString() ?? string.Empty; + + static string DoubleToString(double value) + { + if (MathHelper.IsFinite(value)) + { + return value.ToString("G17", CultureInfo.InvariantCulture); + } + else if (double.IsPositiveInfinity(value)) + { + return "+Inf"; + } + else if (double.IsNegativeInfinity(value)) + { + return "-Inf"; + } + else + { + Debug.Assert(double.IsNaN(value), $"{nameof(value)} should be NaN."); + return "NaN"; + } + } + } + + private static string NormalizeLabelKey(string value) + { + if (string.IsNullOrEmpty(value)) + { + return "_"; + } + + var builder = new StringBuilder(value.Length + 1); + + if (char.IsAsciiDigit(value[0])) + { + builder.Append('_'); + } + + for (var i = 0; i < value.Length; i++) + { + var ch = value[i]; + builder.Append(char.IsAsciiLetterOrDigit(ch) ? ch : '_'); + } + + return builder.ToString(); + } + + private static bool TryCreateScopeLabel(KeyValuePair tag, out KeyValuePair scopeLabel) + { + var labelKey = NormalizeLabelKey($"otel_scope_{tag.Key}"); + + if (ReservedScopeLabelNames.Contains(labelKey)) + { + scopeLabel = default; + return false; + } + + scopeLabel = new(labelKey, GetLabelValueString(tag.Value)); + return true; + } + private static int WriteScopeLabel(byte[] buffer, int cursor, string key, object? value, bool openMetricsRequested, ref bool wroteLabel) { if (wroteLabel) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs index b2149f99f9f..5c70358b3ab 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs @@ -339,6 +339,50 @@ public async Task OpenMetricsScopeInfoIsWrittenAsASingleMetricFamily() } } + [Fact] + public async Task OpenMetricsScopeInfoIsDeduplicatedUsingSerializedScopeLabels() + { + using var meter1 = new Meter("test_meter", "1.0.0", [new("library.mascot", true)], scope: null); + using var meter2 = new Meter("test_meter", "1.0.0", [new("library.mascot", "true")], scope: null); + + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter1.Name) +#if PROMETHEUS_HTTP_LISTENER + .AddPrometheusHttpListener() +#elif PROMETHEUS_ASPNETCORE + .AddPrometheusExporter() +#endif + .Build(); + +#pragma warning disable CA2000 // MeterProvider owns exporter lifecycle + Assert.True(provider.TryFindExporter(out PrometheusExporter? exporter)); +#pragma warning restore CA2000 // MeterProvider owns exporter lifecycle + + meter1.CreateCounter("counter_1").Add(1); + meter2.CreateCounter("counter_2").Add(1); + + var response = await exporter!.CollectionManager.EnterCollect(openMetricsRequested: true); + try + { + var output = Encoding.UTF8.GetString( + response.OpenMetricsView.Array!, + response.OpenMetricsView.Offset, + response.OpenMetricsView.Count); + +#if NET + Assert.Equal(1, Regex.Count(output, "^otel_scope_info\\{otel_scope_name=\"test_meter\",otel_scope_version=\"1.0.0\",otel_scope_library_mascot=\"true\"\\} 1$", RegexOptions.Multiline)); +#else + Assert.Single(Regex.Matches(output, "^otel_scope_info\\{otel_scope_name=\"test_meter\",otel_scope_version=\"1.0.0\",otel_scope_library_mascot=\"true\"\\} 1$", RegexOptions.Multiline)); +#endif + Assert.Contains("counter_1_total{otel_scope_name=\"test_meter\",otel_scope_version=\"1.0.0\",otel_scope_library_mascot=\"true\"} 1", output, StringComparison.Ordinal); + Assert.Contains("counter_2_total{otel_scope_name=\"test_meter\",otel_scope_version=\"1.0.0\",otel_scope_library_mascot=\"true\"} 1", output, StringComparison.Ordinal); + } + finally + { + exporter.CollectionManager.ExitCollect(); + } + } + #if PROMETHEUS_HTTP_LISTENER private static MeterProvider CreateMeterProviderWithRandomPort(Meter meter) { diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index b0b4b2b7dcc..99ce898186c 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -999,6 +999,44 @@ public void WriteMetricPrefixesScopeAttributesAndDropsConflictingScopeAttributeN Assert.DoesNotContain("otel_scope_schema_url=\"ignored-schema\"", output, StringComparison.Ordinal); } +#if NET + [Fact] + public void WriteMetricDropsScopeAttributesWhoseNormalizedNamesConflictWithGeneratedScopeLabels() + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = new Meter( + new MeterOptions("test_meter") + { + Version = "1.0.0", + TelemetrySchemaUrl = "https://opentelemetry.io/schemas/1.0.0", + Tags = + [ + new("library.mascot", "dotnetbot"), + new("schema-url", "ignored-schema"), + ], + }); + + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + meter.CreateObservableGauge( + "test_gauge", + () => [new Measurement(123, new KeyValuePair("metric_tag", "value"))]); + + provider.ForceFlush(); + + var cursor = WriteMetric(buffer, 0, metrics[0]); + var output = Encoding.UTF8.GetString(buffer, 0, cursor); + + Assert.Contains("otel_scope_schema_url=\"https://opentelemetry.io/schemas/1.0.0\"", output, StringComparison.Ordinal); + Assert.DoesNotContain("otel_scope_schema_url=\"ignored-schema\"", output, StringComparison.Ordinal); + } +#endif + [Fact] public void SumWithScopeVersion() { From 767aa01175267981230cd67a91ec8de1b98f2220 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 23:36:01 +0100 Subject: [PATCH 27/82] [Exporter.Prometheus] Extend coverage Add more test coverage for patch. --- .../PrometheusSerializerTests.cs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 99ce898186c..1f02e27ad8d 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -1035,6 +1035,93 @@ public void WriteMetricDropsScopeAttributesWhoseNormalizedNamesConflictWithGener Assert.Contains("otel_scope_schema_url=\"https://opentelemetry.io/schemas/1.0.0\"", output, StringComparison.Ordinal); Assert.DoesNotContain("otel_scope_schema_url=\"ignored-schema\"", output, StringComparison.Ordinal); } + + [Fact] + public void WriteMetricDropsScopeAttributesWhoseNormalizedNamesConflictWithGeneratedScopeNameAndVersionLabels() + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = new Meter( + new MeterOptions("test_meter") + { + Version = "1.0.0", + Tags = + [ + new("na-me", "ignored-name"), + new("ver-sion", "ignored-version"), + new("library.mascot", "dotnetbot"), + ], + }); + + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + meter.CreateObservableGauge( + "test_gauge", + () => [new Measurement(123, new KeyValuePair("metric_tag", "value"))]); + + provider.ForceFlush(); + + var cursor = WriteMetric(buffer, 0, metrics[0]); + var output = Encoding.UTF8.GetString(buffer, 0, cursor); + + Assert.Contains("otel_scope_name=\"test_meter\"", output, StringComparison.Ordinal); + Assert.Contains("otel_scope_version=\"1.0.0\"", output, StringComparison.Ordinal); + Assert.Contains("otel_scope_library_mascot=\"dotnetbot\"", output, StringComparison.Ordinal); + Assert.DoesNotContain("otel_scope_name=\"ignored-name\"", output, StringComparison.Ordinal); + Assert.DoesNotContain("otel_scope_version=\"ignored-version\"", output, StringComparison.Ordinal); + } + + [Fact] + public void CreateScopeIdentityIgnoresNormalizedReservedScopeAttributeNames() + { + var metricsWithConflicts = new List(); + using var meterWithConflicts = new Meter( + new MeterOptions("test_meter") + { + Version = "1.0.0", + TelemetrySchemaUrl = "https://opentelemetry.io/schemas/1.0.0", + Tags = + [ + new("na-me", "ignored-name"), + new("ver-sion", "ignored-version"), + new("schema-url", "ignored-schema"), + new("library.mascot", "dotnetbot"), + ], + }); + using var providerWithConflicts = Sdk.CreateMeterProviderBuilder() + .AddMeter(meterWithConflicts.Name) + .AddInMemoryExporter(metricsWithConflicts) + .Build(); + meterWithConflicts.CreateObservableGauge("test_gauge", () => 1); + providerWithConflicts.ForceFlush(); + + var metricsWithoutConflicts = new List(); + using var meterWithoutConflicts = new Meter( + new MeterOptions("test_meter") + { + Version = "1.0.0", + TelemetrySchemaUrl = "https://opentelemetry.io/schemas/1.0.0", + Tags = + [ + new("library.mascot", "dotnetbot"), + ], + }); + using var providerWithoutConflicts = Sdk.CreateMeterProviderBuilder() + .AddMeter(meterWithoutConflicts.Name) + .AddInMemoryExporter(metricsWithoutConflicts) + .Build(); + meterWithoutConflicts.CreateObservableGauge("test_gauge", () => 1); + providerWithoutConflicts.ForceFlush(); + + var identityWithConflicts = PrometheusSerializer.CreateScopeIdentity(metricsWithConflicts[0]); + var identityWithoutConflicts = PrometheusSerializer.CreateScopeIdentity(metricsWithoutConflicts[0]); + + Assert.Equal(identityWithoutConflicts, identityWithConflicts); + } #endif [Fact] From d7ad53648ef08ab6bef6257be0c32ac793ea3e52 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 19:52:02 +0100 Subject: [PATCH 28/82] [Exporter.Prometheus] Add target_info fallback Add Prometheus text fallback `target_info` output as a gauge so resource metadata is still exposed as Info-typed metrics are unavailable for PrometheusText exposition format. --- .../CHANGELOG.md | 5 +++++ .../CHANGELOG.md | 5 +++++ .../Internal/PrometheusCollectionManager.cs | 17 +++++++++++------ .../Internal/PrometheusSerializer.cs | 12 ++++++++++-- .../PrometheusExporterMiddlewareTests.cs | 3 +++ .../PrometheusHttpListenerTests.cs | 5 ++++- 6 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 102edf9f65f..cc86bda8d2b 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -31,6 +31,7 @@ Notes](../../RELEASENOTES.md). selected correctly by considering whitespace and `q` weights. ([#7208](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7208)) +<<<<<<< HEAD * Emit OpenMetrics exemplars for counters and histogram buckets. ([#7222](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7222)) @@ -69,6 +70,10 @@ Notes](../../RELEASENOTES.md). * Drop conflicting scope attributes named `name`, `version`, and `schema_url`. ([#7237](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7237)) +* Add a Prometheus text fallback `target_info` gauge when OpenMetrics-only scope + metadata is unavailable. + ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7238)) + ## 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 2c0db064a1f..15f4e0724a2 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -43,6 +43,7 @@ Notes](../../RELEASENOTES.md). selected correctly by considering whitespace and `q` weights. ([#7208](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7208)) +<<<<<<< HEAD * Emit OpenMetrics exemplars for counters and histogram buckets. ([#7222](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7222)) @@ -86,6 +87,10 @@ Notes](../../RELEASENOTES.md). * Drop conflicting scope attributes named `name`, `version`, and `schema_url`. ([#7237](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7237)) +* Add a Prometheus text fallback `target_info` gauge when OpenMetrics-only scope + metadata is unavailable. + ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7238)) + ## 1.15.3-beta.1 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs index ee94a659e4c..8683aa421b0 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs @@ -18,7 +18,8 @@ internal sealed class PrometheusCollectionManager private int metricsCacheCount; private byte[] plainTextBuffer = new byte[85000]; // encourage the object to live in LOH (large object heap) private byte[] openMetricsBuffer = new byte[85000]; // encourage the object to live in LOH (large object heap) - private int targetInfoBufferLength = -1; // zero or positive when target_info has been written for the first time + private int plainTextTargetInfoBufferLength = -1; + private int openMetricsTargetInfoBufferLength = -1; private ArraySegment previousPlainTextDataView; private ArraySegment previousOpenMetricsDataView; private int globalLockState; @@ -219,10 +220,10 @@ private ExportResult OnCollect(in Batch metrics) try { + cursor = this.WriteTargetInfo(ref buffer); + if (this.exporter.OpenMetricsRequested) { - cursor = this.WriteTargetInfo(ref buffer); - this.scopes.Clear(); var scopeInfoMetadataWritten = false; @@ -343,13 +344,17 @@ private ExportResult OnCollect(in Batch metrics) private int WriteTargetInfo(ref byte[] buffer) { - if (this.targetInfoBufferLength < 0) + ref var targetInfoBufferLength = ref this.exporter.OpenMetricsRequested + ? ref this.openMetricsTargetInfoBufferLength + : ref this.plainTextTargetInfoBufferLength; + + if (targetInfoBufferLength < 0) { while (true) { try { - this.targetInfoBufferLength = PrometheusSerializer.WriteTargetInfo(buffer, 0, this.exporter.Resource, openMetricsRequested: true); + targetInfoBufferLength = PrometheusSerializer.WriteTargetInfo(buffer, 0, this.exporter.Resource, this.exporter.OpenMetricsRequested); break; } @@ -363,7 +368,7 @@ private int WriteTargetInfo(ref byte[] buffer) } } - return this.targetInfoBufferLength; + return targetInfoBufferLength; } private PrometheusMetric GetPrometheusMetric(Metric metric) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 73f03a9f1f4..cfa4e4b3398 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -462,10 +462,18 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource, return cursor; } - cursor = WriteAsciiStringNoEscape(buffer, cursor, "# TYPE target info"); + // "If info-typed metric families are not yet supported...a gauge-typed metric + // family named target_info with a constant value of 1 MUST be used instead.". + // See https://opentelemetry.io/docs/specs/otel/compatibility/prometheus_and_openmetrics/#resource-attributes-1 + cursor = WriteAsciiStringNoEscape(buffer, cursor, "# TYPE "); + cursor = WriteAsciiStringNoEscape(buffer, cursor, openMetricsRequested ? "target" : "target_info"); + buffer[cursor++] = unchecked((byte)' '); + cursor = WriteAsciiStringNoEscape(buffer, cursor, openMetricsRequested ? "info" : "gauge"); buffer[cursor++] = ASCII_LINEFEED; - cursor = WriteAsciiStringNoEscape(buffer, cursor, "# HELP target Target metadata"); + cursor = WriteAsciiStringNoEscape(buffer, cursor, "# HELP "); + cursor = WriteAsciiStringNoEscape(buffer, cursor, openMetricsRequested ? "target" : "target_info"); + cursor = WriteAsciiStringNoEscape(buffer, cursor, " Target metadata"); buffer[cursor++] = ASCII_LINEFEED; cursor = WriteAsciiStringNoEscape(buffer, cursor, "target_info"); diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs index 938e1afe899..4c3659f66cc 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs @@ -509,6 +509,9 @@ private static async Task VerifyAsync(HttpResponseMessage response, bool request """.ReplaceLineEndings() : $$""" + # TYPE target_info gauge + # HELP target_info Target metadata + target_info{service_name="my_service",service_instance_id="id1"} 1 # TYPE counter_double_bytes_total counter # UNIT counter_double_bytes_total bytes counter_double_bytes_total{otel_scope_name="{{MeterName}}",otel_scope_version="{{MeterVersion}}",{{additionalTags}}key1="value1",key2="value2"} 101.17 diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs index 5925d026cbe..e675093f5af 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs @@ -348,7 +348,10 @@ private static async Task RunPrometheusExporterHttpServerIntegrationTest( + $"counter_double_bytes_total{{otel_scope_name='{MeterName}',otel_scope_version='{MeterVersion}',{additionalTags}key1='value1',key2='value2'}} 101.17\n" + createdMetricSample + "# EOF\n" - : "# TYPE counter_double_bytes_total counter\n" + : "# TYPE target_info gauge\n" + + "# HELP target_info Target metadata\n" + + "target_info{service_name='my_service',service_instance_id='id1'} 1\n" + + "# TYPE counter_double_bytes_total counter\n" + "# UNIT counter_double_bytes_total bytes\n" + $"counter_double_bytes_total{{otel_scope_name='{MeterName}',otel_scope_version='{MeterVersion}',{additionalTags}key1='value1',key2='value2'}} 101.17\n" + "# EOF\n"; From 574fde1b9aa4d433961a01c6d24c3bcbe5750750 Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Fri, 1 May 2026 19:55:40 +0100 Subject: [PATCH 29/82] [Exporter.Prometheus] Update CHANGELOGs Add PR number. --- .../CHANGELOG.md | 6 +++--- .../CHANGELOG.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index cc86bda8d2b..eb36a2f988a 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -31,6 +31,7 @@ Notes](../../RELEASENOTES.md). selected correctly by considering whitespace and `q` weights. ([#7208](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7208)) +<<<<<<< HEAD <<<<<<< HEAD * Emit OpenMetrics exemplars for counters and histogram buckets. ([#7222](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7222)) @@ -70,9 +71,8 @@ Notes](../../RELEASENOTES.md). * Drop conflicting scope attributes named `name`, `version`, and `schema_url`. ([#7237](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7237)) -* Add a Prometheus text fallback `target_info` gauge when OpenMetrics-only scope - metadata is unavailable. - ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7238)) +* Add Prometheus text fallback `target_info` output as a gauge. + ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7238)) ## 1.15.3-beta.1 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index 15f4e0724a2..4a2203279f5 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -43,6 +43,7 @@ Notes](../../RELEASENOTES.md). selected correctly by considering whitespace and `q` weights. ([#7208](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7208)) +<<<<<<< HEAD <<<<<<< HEAD * Emit OpenMetrics exemplars for counters and histogram buckets. ([#7222](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7222)) @@ -87,9 +88,8 @@ Notes](../../RELEASENOTES.md). * Drop conflicting scope attributes named `name`, `version`, and `schema_url`. ([#7237](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7237)) -* Add a Prometheus text fallback `target_info` gauge when OpenMetrics-only scope - metadata is unavailable. - ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7238)) +* Add Prometheus text fallback `target_info` output as a gauge. + ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7238)) ## 1.15.3-beta.1 From 9bfbdb16c89e5c519e009f546017af11b43cb877 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 20:29:17 +0100 Subject: [PATCH 30/82] [Exporter.Prometheus] Merge colliding label keys Merge colliding sanitized label keys by concatenating values in lexicographic order of the original keys. --- .../CHANGELOG.md | 3 + .../CHANGELOG.md | 3 + .../Internal/PrometheusSerializer.cs | 216 +++++++++++------- .../PrometheusSerializerTests.cs | 31 +++ 4 files changed, 173 insertions(+), 80 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index eb36a2f988a..32685b95a10 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -74,6 +74,9 @@ Notes](../../RELEASENOTES.md). * Add Prometheus text fallback `target_info` output as a gauge. ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7238)) +* Merge colliding sanitized label keys. + ([#7239](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7239)) + ## 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 4a2203279f5..8d51e9020df 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -91,6 +91,9 @@ Notes](../../RELEASENOTES.md). * Add Prometheus text fallback `target_info` output as a gauge. ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7238)) +* Merge colliding sanitized label keys. + ([#7239](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7239)) + ## 1.15.3-beta.1 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index cfa4e4b3398..f099bbeaf33 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -427,31 +427,15 @@ public static int WriteTags( bool openMetricsRequested, bool writeEnclosingBraces = true) { - if (writeEnclosingBraces) - { - buffer[cursor++] = unchecked((byte)'{'); - } - - var wroteLabel = false; - cursor = WriteScopeLabels(buffer, cursor, metric, openMetricsRequested, ref wroteLabel); + List? labels = null; + AddScopeLabels(metric, openMetricsRequested, ref labels); foreach (var tag in tags) { - if (wroteLabel) - { - buffer[cursor++] = unchecked((byte)','); - } - - cursor = WriteLabel(buffer, cursor, tag.Key, tag.Value, openMetricsRequested); - wroteLabel = true; + AddLabel(tag.Key, tag.Value, openMetricsRequested, ref labels); } - if (writeEnclosingBraces) - { - buffer[cursor++] = unchecked((byte)'}'); - } - - return cursor; + return WriteLabels(buffer, cursor, labels, openMetricsRequested, writeEnclosingBraces); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -477,17 +461,14 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource, buffer[cursor++] = ASCII_LINEFEED; cursor = WriteAsciiStringNoEscape(buffer, cursor, "target_info"); - buffer[cursor++] = unchecked((byte)'{'); + List? labels = null; foreach (var attribute in resource.Attributes) { - cursor = WriteLabel(buffer, cursor, attribute.Key, attribute.Value, openMetricsRequested); - buffer[cursor++] = unchecked((byte)','); + AddLabel(attribute.Key, attribute.Value, openMetricsRequested, ref labels); } - cursor--; // Write over the last written comma - - buffer[cursor++] = unchecked((byte)'}'); + cursor = WriteLabels(buffer, cursor, labels, openMetricsRequested, writeEnclosingBraces: true); buffer[cursor++] = unchecked((byte)' '); buffer[cursor++] = unchecked((byte)'1'); buffer[cursor++] = ASCII_LINEFEED; @@ -538,71 +519,43 @@ private static int WriteNormalizedLabelKey(byte[] buffer, int cursor, string val private static int WriteScopeLabels(byte[] buffer, int cursor, Metric metric, bool openMetricsRequested) { - buffer[cursor++] = unchecked((byte)'{'); - var wroteLabel = false; - - cursor = WriteScopeLabels(buffer, cursor, metric, openMetricsRequested, ref wroteLabel); - - buffer[cursor++] = unchecked((byte)'}'); - return cursor; + List? labels = null; + AddScopeLabels(metric, openMetricsRequested, ref labels); + return WriteLabels(buffer, cursor, labels, openMetricsRequested, writeEnclosingBraces: true); } - private static int WriteScopeLabels(byte[] buffer, int cursor, Metric metric, bool openMetricsRequested, ref bool wroteLabel) + private static void AddScopeLabels(Metric metric, bool openMetricsRequested, ref List? labels) { - cursor = WriteScopeLabel(buffer, cursor, "otel_scope_name", metric.MeterName, openMetricsRequested, ref wroteLabel); + AddLabel("otel_scope_name", "otel_scope_name", metric.MeterName, openMetricsRequested, ref labels); if (!string.IsNullOrEmpty(metric.MeterVersion)) { - cursor = WriteScopeLabel(buffer, cursor, "otel_scope_version", metric.MeterVersion, openMetricsRequested, ref wroteLabel); + AddLabel("otel_scope_version", "otel_scope_version", metric.MeterVersion, openMetricsRequested, ref labels); } if (!string.IsNullOrEmpty(metric.MeterSchemaUrl)) { - cursor = WriteScopeLabel(buffer, cursor, "otel_scope_schema_url", metric.MeterSchemaUrl, openMetricsRequested, ref wroteLabel); + AddLabel("otel_scope_schema_url", "otel_scope_schema_url", metric.MeterSchemaUrl, openMetricsRequested, ref labels); } if (metric.MeterTags != null) { foreach (var tag in metric.MeterTags) { - if (TryCreateScopeLabel(tag, out var scopeLabel)) + if (TryCreateScopeLabel(tag, openMetricsRequested, out var scopeLabel)) { - cursor = WriteScopeLabel(buffer, cursor, scopeLabel.Key, scopeLabel.Value, openMetricsRequested, ref wroteLabel); + labels ??= []; + labels.Add(scopeLabel); } } } - - return cursor; } internal static string CreateScopeIdentity(Metric metric) { - var scopeLabels = new List>(3) - { - new("otel_scope_name", GetLabelValueString(metric.MeterName)), - }; - - if (!string.IsNullOrEmpty(metric.MeterVersion)) - { - scopeLabels.Add(new("otel_scope_version", GetLabelValueString(metric.MeterVersion))); - } - - if (!string.IsNullOrEmpty(metric.MeterSchemaUrl)) - { - scopeLabels.Add(new("otel_scope_schema_url", GetLabelValueString(metric.MeterSchemaUrl))); - } - - if (metric.MeterTags != null) - { - foreach (var tag in metric.MeterTags) - { - if (TryCreateScopeLabel(tag, out var scopeLabel)) - { - scopeLabels.Add(scopeLabel); - } - } - } - + List? labels = null; + AddScopeLabels(metric, openMetricsRequested: true, ref labels); + var scopeLabels = MergeLabels(labels); scopeLabels.Sort(static (x, y) => { var keyCompare = string.CompareOrdinal(x.Key, y.Key); @@ -663,7 +616,7 @@ static string DoubleToString(double value) } } - private static string NormalizeLabelKey(string value) + private static string GetSanitizedLabelKey(string value, bool openMetricsRequested) { if (string.IsNullOrEmpty(value)) { @@ -671,52 +624,155 @@ private static string NormalizeLabelKey(string value) } var builder = new StringBuilder(value.Length + 1); + var lastCharUnderscore = false; if (char.IsAsciiDigit(value[0])) { builder.Append('_'); + lastCharUnderscore = true; } for (var i = 0; i < value.Length; i++) { var ch = value[i]; - builder.Append(char.IsAsciiLetterOrDigit(ch) ? ch : '_'); + + if (!IsAllowedMetricsLabelCharacter(ch, openMetricsRequested)) + { + if (!lastCharUnderscore) + { + builder.Append('_'); + lastCharUnderscore = true; + } + + continue; + } + + builder.Append(ch); + lastCharUnderscore = ch == '_'; } return builder.ToString(); } - private static bool TryCreateScopeLabel(KeyValuePair tag, out KeyValuePair scopeLabel) + private static void AddLabel(string originalKey, object? value, bool openMetricsRequested, ref List? labels) + => AddLabel(originalKey, GetSanitizedLabelKey(originalKey, openMetricsRequested), value, openMetricsRequested, ref labels); + + private static void AddLabel(string originalKey, string outputKey, object? value, bool openMetricsRequested, ref List? labels) { - var labelKey = NormalizeLabelKey($"otel_scope_{tag.Key}"); + labels ??= []; + labels.Add(new LabelData(originalKey, GetSanitizedLabelKey(outputKey, openMetricsRequested), GetLabelValueString(value))); + } - if (ReservedScopeLabelNames.Contains(labelKey)) + private static List> MergeLabels(IReadOnlyList? labels) + { + if (labels == null || labels.Count == 0) { - scopeLabel = default; - return false; + return []; } - scopeLabel = new(labelKey, GetLabelValueString(tag.Value)); - return true; + List orderedKeys = []; + Dictionary> labelsBySanitizedKey = []; + + foreach (var label in labels) + { + if (!labelsBySanitizedKey.TryGetValue(label.OutputKey, out var bucket)) + { + bucket = []; + labelsBySanitizedKey[label.OutputKey] = bucket; + orderedKeys.Add(label.OutputKey); + } + + bucket.Add(label); + } + + var mergedLabels = new List>(orderedKeys.Count); + + foreach (var key in orderedKeys) + { + mergedLabels.Add(new(key, GetMergedLabelValue(labelsBySanitizedKey[key]))); + } + + return mergedLabels; } - private static int WriteScopeLabel(byte[] buffer, int cursor, string key, object? value, bool openMetricsRequested, ref bool wroteLabel) + private static int WriteLabels(byte[] buffer, int cursor, IReadOnlyList? labels, bool openMetricsRequested, bool writeEnclosingBraces) { - if (wroteLabel) + if (writeEnclosingBraces) { + buffer[cursor++] = unchecked((byte)'{'); + } + + foreach (var label in MergeLabels(labels)) + { + cursor = WriteLabel(buffer, cursor, label.Key, label.Value, openMetricsRequested); buffer[cursor++] = unchecked((byte)','); } - cursor = WriteLabel(buffer, cursor, key, value, openMetricsRequested); - wroteLabel = true; + if (writeEnclosingBraces) + { + if (labels != null && labels.Count > 0) + { + buffer[cursor - 1] = unchecked((byte)'}'); + } + else + { + buffer[cursor++] = unchecked((byte)'}'); + } + } return cursor; } + private static string GetMergedLabelValue(List labels) + { + if (labels.Count == 1) + { + return labels[0].Value; + } + + labels.Sort(static (left, right) => string.CompareOrdinal(left.OriginalKey, right.OriginalKey)); + var builder = new StringBuilder(); + + for (var i = 0; i < labels.Count; i++) + { + if (i > 0) + { + builder.Append(';'); + } + + builder.Append(labels[i].Value); + } + + return builder.ToString(); + } + + private static bool TryCreateScopeLabel(KeyValuePair tag, bool openMetricsRequested, out LabelData scopeLabel) + { + var labelKey = GetSanitizedLabelKey($"otel_scope_{tag.Key}", openMetricsRequested); + + if (ReservedScopeLabelNames.Contains(labelKey)) + { + scopeLabel = default; + return false; + } + + scopeLabel = new(tag.Key, labelKey, GetLabelValueString(tag.Value)); + return true; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool IsAllowedMetricsLabelCharacter(char value, bool isOpenMetrics) => char.IsAsciiLetterOrDigit(value) || value is '_' || (isOpenMetrics && value == ':'); + private readonly struct LabelData(string originalKey, string outputKey, string value) + { + public readonly string OriginalKey { get; } = originalKey; + + public readonly string OutputKey { get; } = outputKey; + + public readonly string Value { get; } = value; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int WriteUnicodeScalar(byte[] buffer, int cursor, string value, ref int index) { diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 1f02e27ad8d..19e2238b3b7 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -813,6 +813,37 @@ public void TryGetLatestExemplarPrefersLaterCandidateWhenTimestampsMatch() Assert.False(PrometheusSerializer.ShouldPreferExemplar(timestamp, timestamp.AddTicks(-1))); } + [Fact] + public void WriteMetricConcatenatesCollidingSanitizedLabelValuesInLexicographicOrder() + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = new Meter("test_meter"); + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + meter.CreateObservableGauge( + "test_gauge", + () => + [ + new Measurement( + 123, + new("foo.bar", "dot"), + new("foo-bar", "hyphen"), + new("foo_bar", "underscore")), + ]); + + provider.ForceFlush(); + + var cursor = WriteMetric(buffer, 0, metrics[0]); + var output = Encoding.UTF8.GetString(buffer, 0, cursor); + + Assert.Contains("foo_bar=\"hyphen;dot;underscore\"", output, StringComparison.Ordinal); + } + [Fact] public void HistogramOneDimensionWithOpenMetricsFormat() { From 65072460415609e6d869f9d9bef8709642a3bf03 Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Fri, 1 May 2026 20:33:27 +0100 Subject: [PATCH 31/82] [Exporter.Prometheus] Update CHANGELOGs Add PR number. --- src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md | 2 +- src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 32685b95a10..2c304314828 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -75,7 +75,7 @@ Notes](../../RELEASENOTES.md). ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7238)) * Merge colliding sanitized label keys. - ([#7239](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7239)) + ([#7239](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7239)) ## 1.15.3-beta.1 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index 8d51e9020df..b30139537f3 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -92,7 +92,7 @@ Notes](../../RELEASENOTES.md). ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7238)) * Merge colliding sanitized label keys. - ([#7239](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7239)) + ([#7239](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7239)) ## 1.15.3-beta.1 From 70ef846f9e3f528354b613940b6c9e4788d03bc3 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 22:33:05 +0100 Subject: [PATCH 32/82] [Exporter.Prometheus] Address feedback - Improve efficiency when no collisions. - Refactor duplicative code. - Improve test assertion. --- .../Internal/PrometheusSerializer.cs | 124 +++++++++++++++--- .../PrometheusSerializerTests.cs | 9 +- 2 files changed, 113 insertions(+), 20 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index f099bbeaf33..c46724bfc7d 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -158,17 +158,7 @@ public static int WriteUnicodeString(byte[] buffer, int cursor, string value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteLabelKey(byte[] buffer, int cursor, string value, bool openMetricsRequested) - { - if (string.IsNullOrEmpty(value)) - { - buffer[cursor++] = unchecked((byte)'_'); - return cursor; - } - - return openMetricsRequested ? - WriteOpenMetricsLabelKey(buffer, cursor, value) : - WritePrometheusLabelKey(buffer, cursor, value); - } + => WriteSanitizedLabelKey(buffer, cursor, value, openMetricsRequested, builder: null); [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteLabelValue(byte[] buffer, int cursor, string value) @@ -427,6 +417,33 @@ public static int WriteTags( bool openMetricsRequested, bool writeEnclosingBraces = true) { + var startCursor = cursor; + List? writtenOutputKeys = null; + var wroteLabel = false; + + if (writeEnclosingBraces) + { + buffer[cursor++] = unchecked((byte)'{'); + } + + if (TryWriteLabel("otel_scope_name", metric.MeterName) && + (string.IsNullOrEmpty(metric.MeterVersion) || TryWriteLabel("otel_scope_version", metric.MeterVersion)) && + TryWriteMetricTags() && + TryWritePointTags()) + { + if (writeEnclosingBraces) + { + buffer[cursor++] = unchecked((byte)'}'); + } + else if (wroteLabel) + { + buffer[cursor++] = unchecked((byte)','); + } + + return cursor; + } + + cursor = startCursor; List? labels = null; AddScopeLabels(metric, openMetricsRequested, ref labels); @@ -436,6 +453,58 @@ public static int WriteTags( } return WriteLabels(buffer, cursor, labels, openMetricsRequested, writeEnclosingBraces); + + bool TryWriteMetricTags() + { + if (metric.MeterTags != null) + { + foreach (var tag in metric.MeterTags) + { + if (!TryWriteLabel(tag.Key, tag.Value)) + { + return false; + } + } + } + + return true; + } + + bool TryWritePointTags() + { + foreach (var tag in tags) + { + if (!TryWriteLabel(tag.Key, tag.Value)) + { + return false; + } + } + + return true; + } + + bool TryWriteLabel(string key, object? value) + { + var outputKey = GetSanitizedLabelKey(key, openMetricsRequested); + + if (writtenOutputKeys?.Contains(outputKey) == true) + { + return false; + } + + writtenOutputKeys ??= []; + writtenOutputKeys.Add(outputKey); + + if (wroteLabel) + { + buffer[cursor++] = unchecked((byte)','); + } + + cursor = WriteLabel(buffer, cursor, key, value, openMetricsRequested); + wroteLabel = true; + + return true; + } } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -617,18 +686,24 @@ static string DoubleToString(double value) } private static string GetSanitizedLabelKey(string value, bool openMetricsRequested) + { + var builder = new StringBuilder(value.Length + 1); + _ = WriteSanitizedLabelKey(null, 0, value, openMetricsRequested, builder); + return builder.ToString(); + } + + private static int WriteSanitizedLabelKey(byte[]? buffer, int cursor, string value, bool openMetricsRequested, StringBuilder? builder) { if (string.IsNullOrEmpty(value)) { - return "_"; + return AppendSanitizedLabelKeyCharacter(buffer, cursor, builder, '_'); } - var builder = new StringBuilder(value.Length + 1); var lastCharUnderscore = false; if (char.IsAsciiDigit(value[0])) { - builder.Append('_'); + cursor = AppendSanitizedLabelKeyCharacter(buffer, cursor, builder, '_'); lastCharUnderscore = true; } @@ -640,18 +715,33 @@ private static string GetSanitizedLabelKey(string value, bool openMetricsRequest { if (!lastCharUnderscore) { - builder.Append('_'); + cursor = AppendSanitizedLabelKeyCharacter(buffer, cursor, builder, '_'); lastCharUnderscore = true; } continue; } - builder.Append(ch); + cursor = AppendSanitizedLabelKeyCharacter(buffer, cursor, builder, ch); lastCharUnderscore = ch == '_'; } - return builder.ToString(); + return cursor; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int AppendSanitizedLabelKeyCharacter(byte[]? buffer, int cursor, StringBuilder? builder, char value) + { + if (buffer != null) + { + buffer[cursor++] = unchecked((byte)value); + } + else + { + builder!.Append(value); + } + + return cursor; } private static void AddLabel(string originalKey, object? value, bool openMetricsRequested, ref List? labels) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 19e2238b3b7..a4425d11710 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -839,9 +839,12 @@ public void WriteMetricConcatenatesCollidingSanitizedLabelValuesInLexicographicO provider.ForceFlush(); var cursor = WriteMetric(buffer, 0, metrics[0]); - var output = Encoding.UTF8.GetString(buffer, 0, cursor); - - Assert.Contains("foo_bar=\"hyphen;dot;underscore\"", output, StringComparison.Ordinal); + Assert.Matches( + ("^" + + "# TYPE test_gauge gauge\n" + + "test_gauge{otel_scope_name='test_meter',foo_bar='hyphen;dot;underscore'} 123\n" + + "$").Replace('\'', '"'), + Encoding.UTF8.GetString(buffer, 0, cursor)); } [Fact] From 67be1ee18c0f7d4fc9a916554a1b6a455dbf1acd Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 23:20:50 +0100 Subject: [PATCH 33/82] [Exporter.Prometheus] Extend coverage Add more tests for patch coverage. --- .../PrometheusSerializerTests.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index a4425d11710..b4bb93c9485 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -1255,6 +1255,72 @@ public void HistogramWithNegativeBucketBoundsOmitsSumAndCountWithOpenMetricsForm Assert.DoesNotContain("test_histogram_count{", output, StringComparison.Ordinal); } + [Fact] + public void WriteMetricConcatenatesCollidingEmptyAndUnderscoreLabelKeys() + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = new Meter("test_meter"); + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + meter.CreateObservableGauge( + "test_gauge", + () => + [ + new Measurement( + 123, + new(string.Empty, "empty"), + new("_", "underscore")), + ]); + + provider.ForceFlush(); + + var cursor = WriteMetric(buffer, 0, metrics[0]); + Assert.Matches( + ("^" + + "# TYPE test_gauge gauge\n" + + "test_gauge{otel_scope_name='test_meter',_='empty;underscore'} 123\n" + + "$").Replace('\'', '"'), + Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Fact] + public void WriteMetricConcatenatesCollidingLeadingDigitLabelKeys() + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = new Meter("test_meter"); + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + meter.CreateObservableGauge( + "test_gauge", + () => + [ + new Measurement( + 123, + new("1foo", "digit"), + new("_1foo", "underscore")), + ]); + + provider.ForceFlush(); + + var cursor = WriteMetric(buffer, 0, metrics[0]); + Assert.Matches( + ("^" + + "# TYPE test_gauge gauge\n" + + "test_gauge{otel_scope_name='test_meter',_1foo='digit;underscore'} 123\n" + + "$").Replace('\'', '"'), + Encoding.UTF8.GetString(buffer, 0, cursor)); + } + [Fact] public void WriteAsciiStringNoEscapeWritesAsciiBytes() { From 8d97d0249a1d1eb4bf3129e2618f30a922c61365 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 20:58:23 +0100 Subject: [PATCH 34/82] [Exporter.Prometheus] Dedupe metadata Deduplicate HELP/UNIT/TYPE metadata within a scrape, and drop entire metric families when TYPE metadata conflicts would make the output invalid. --- .../Internal/PrometheusCollectionManager.cs | 150 ++++++++++++++++-- .../Internal/PrometheusExporterEventSource.cs | 18 +++ .../Internal/PrometheusSerializerExt.cs | 27 +++- .../PrometheusCollectionManagerTests.cs | 80 ++++++++++ 4 files changed, 258 insertions(+), 17 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs index 8683aa421b0..f2c266b9652 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs @@ -268,13 +268,10 @@ private ExportResult OnCollect(in Batch metrics) } } - foreach (var metric in metrics) - { - if (!PrometheusSerializer.CanWriteMetric(metric)) - { - continue; - } + var metricStates = this.GetMetricStates(metrics, this.exporter.OpenMetricsRequested); + foreach (var metricState in metricStates) + { while (true) { try @@ -282,9 +279,12 @@ private ExportResult OnCollect(in Batch metrics) cursor = PrometheusSerializer.WriteMetric( buffer, cursor, - metric, - this.GetPrometheusMetric(metric), - this.exporter.OpenMetricsRequested); + metricState.Metric, + metricState.PrometheusMetric, + this.exporter.OpenMetricsRequested, + writeType: metricState.WriteType, + writeUnit: metricState.WriteUnit, + writeHelp: metricState.WriteHelp); break; } @@ -389,6 +389,92 @@ private PrometheusMetric GetPrometheusMetric(Metric metric) return prometheusMetric; } + private List GetMetricStates(in Batch metrics, bool openMetricsRequested) + { + var metricStates = new List(); + var metadataStates = new Dictionary(StringComparer.Ordinal); + var droppedMetricNames = new HashSet(StringComparer.Ordinal); + + foreach (var metric in metrics) + { + if (!PrometheusSerializer.CanWriteMetric(metric)) + { + continue; + } + + var prometheusMetric = this.GetPrometheusMetric(metric); + var metadataName = openMetricsRequested ? prometheusMetric.OpenMetricsMetadataName : prometheusMetric.Name; + + if (!metadataStates.TryGetValue(metadataName, out var metadataState)) + { + metadataStates[metadataName] = new MetadataState(prometheusMetric.Type, metric.Description, prometheusMetric.Unit); + metricStates.Add( + new MetricState( + metric, + prometheusMetric, + true, + !string.IsNullOrEmpty(prometheusMetric.Unit), + !string.IsNullOrEmpty(metric.Description))); + continue; + } + + if (metadataState.Type != prometheusMetric.Type) + { + droppedMetricNames.Add(metadataName); + PrometheusExporterEventSource.Log.ConflictingType(metadataName, metadataState.Type.ToString(), prometheusMetric.Type.ToString()); + } + + var writeUnit = false; + + if (!string.IsNullOrEmpty(prometheusMetric.Unit) && + metadataState.Unit == null) + { + metadataStates[metadataName] = new MetadataState(metadataState.Type, metadataState.Help, prometheusMetric.Unit); + writeUnit = true; + } + else if (!string.IsNullOrEmpty(prometheusMetric.Unit) && + metadataState.Unit != null && + metadataState.Unit != prometheusMetric.Unit) + { + PrometheusExporterEventSource.Log.ConflictingUnit(metadataName, metadataState.Unit, prometheusMetric.Unit!); + } + + var writeHelp = false; + + if (!string.IsNullOrEmpty(metric.Description) && + metadataState.Help == null) + { + metadataStates[metadataName] = new MetadataState(metadataState.Type, metric.Description, metadataState.Unit); + writeHelp = true; + } + else if (!string.IsNullOrEmpty(metric.Description) && + metadataState.Help != null && + metadataState.Help != metric.Description) + { + PrometheusExporterEventSource.Log.ConflictingHelp(metadataName, metadataState.Help, metric.Description); + } + + metricStates.Add(new MetricState(metric, prometheusMetric, false, writeUnit, writeHelp)); + } + + if (droppedMetricNames.Count == 0) + { + return metricStates; + } + + var filteredMetricStates = new List(metricStates.Count); + foreach (var metricState in metricStates) + { + var metadataName = openMetricsRequested ? metricState.PrometheusMetric.OpenMetricsMetadataName : metricState.PrometheusMetric.Name; + if (!droppedMetricNames.Contains(metadataName)) + { + filteredMetricStates.Add(metricState); + } + } + + return filteredMetricStates; + } + public readonly struct CollectionResponse { public CollectionResponse(ArraySegment openMetricsView, ArraySegment plainTextView, DateTime generatedAtUtc, bool fromCache) @@ -399,12 +485,50 @@ public CollectionResponse(ArraySegment openMetricsView, ArraySegment this.FromCache = fromCache; } - public ArraySegment OpenMetricsView { get; } + public readonly ArraySegment OpenMetricsView { get; } + + public readonly ArraySegment PlainTextView { get; } + + public readonly DateTime GeneratedAtUtc { get; } + + public readonly bool FromCache { get; } + } + + private readonly struct MetricState + { + public MetricState(Metric metric, PrometheusMetric prometheusMetric, bool writeType, bool writeUnit, bool writeHelp) + { + this.Metric = metric; + this.PrometheusMetric = prometheusMetric; + this.WriteType = writeType; + this.WriteUnit = writeUnit; + this.WriteHelp = writeHelp; + } + + public readonly Metric Metric { get; } + + public readonly PrometheusMetric PrometheusMetric { get; } + + public readonly bool WriteType { get; } + + public readonly bool WriteUnit { get; } + + public readonly bool WriteHelp { get; } + } + + private readonly struct MetadataState + { + public MetadataState(PrometheusType type, string? help, string? unit) + { + this.Type = type; + this.Help = help; + this.Unit = unit; + } - public ArraySegment PlainTextView { get; } + public readonly PrometheusType Type { get; } - public DateTime GeneratedAtUtc { get; } + public readonly string? Help { get; } - public bool FromCache { get; } + public readonly string? Unit { get; } } } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs index ab58347170e..e4b9d5afeaf 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs @@ -64,4 +64,22 @@ public void InvalidConfigurationValue(string key, string value) void IConfigurationExtensionsLogger.LogInvalidConfigurationValue(string key, string value) => this.InvalidConfigurationValue(key, value); + + [Event(6, Message = "Dropping metric family '{0}' due to conflicting TYPE metadata '{1}' and '{2}'.", Level = EventLevel.Warning)] + public void ConflictingType(string metricName, string firstType, string conflictingType) + { + this.WriteEvent(6, metricName, firstType, conflictingType); + } + + [Event(7, Message = "Dropping duplicate HELP metadata for metric family '{0}' because values '{1}' and '{2}' conflict.", Level = EventLevel.Warning)] + public void ConflictingHelp(string metricName, string firstHelp, string conflictingHelp) + { + this.WriteEvent(7, metricName, firstHelp, conflictingHelp); + } + + [Event(8, Message = "Dropping duplicate UNIT metadata for metric family '{0}' because values '{1}' and '{2}' conflict.", Level = EventLevel.Warning)] + public void ConflictingUnit(string metricName, string firstUnit, string conflictingUnit) + { + this.WriteEvent(8, metricName, firstUnit, conflictingUnit); + } } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs index e47b2196f09..08786772926 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -22,11 +22,30 @@ public static bool CanWriteMetric(Metric metric) return true; } - public static int WriteMetric(byte[] buffer, int cursor, Metric metric, PrometheusMetric prometheusMetric, bool openMetricsRequested) + public static int WriteMetric( + byte[] buffer, + int cursor, + Metric metric, + PrometheusMetric prometheusMetric, + bool openMetricsRequested, + bool writeType = true, + bool writeUnit = true, + bool writeHelp = true) { - cursor = WriteTypeMetadata(buffer, cursor, prometheusMetric, openMetricsRequested); - cursor = WriteUnitMetadata(buffer, cursor, prometheusMetric, openMetricsRequested); - cursor = WriteHelpMetadata(buffer, cursor, prometheusMetric, metric.Description, openMetricsRequested); + if (writeType) + { + cursor = WriteTypeMetadata(buffer, cursor, prometheusMetric, openMetricsRequested); + } + + if (writeUnit) + { + cursor = WriteUnitMetadata(buffer, cursor, prometheusMetric, openMetricsRequested); + } + + if (writeHelp) + { + cursor = WriteHelpMetadata(buffer, cursor, prometheusMetric, metric.Description, openMetricsRequested); + } if (!metric.MetricType.IsHistogram()) { diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs index 5c70358b3ab..f3d0f0ed5f8 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs @@ -383,6 +383,86 @@ public async Task OpenMetricsScopeInfoIsDeduplicatedUsingSerializedScopeLabels() } } + [Fact] + public async Task DuplicateMetricMetadataIsWrittenOncePerScrape() + { + using var meter = new Meter(Utils.GetCurrentMethodName()); + + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) +#if PROMETHEUS_HTTP_LISTENER + .AddPrometheusHttpListener() +#elif PROMETHEUS_ASPNETCORE + .AddPrometheusExporter() +#endif + .Build(); + +#pragma warning disable CA2000 + Assert.True(provider.TryFindExporter(out PrometheusExporter? exporter)); +#pragma warning restore CA2000 + + var counter1 = meter.CreateCounter("test.metric", unit: "By", description: "Test help"); + var counter2 = meter.CreateCounter("test-metric", unit: "By", description: "Test help"); + + counter1.Add(1, [new("source", "a")]); + counter2.Add(2, [new("source", "b")]); + + var response = await exporter!.CollectionManager.EnterCollect(openMetricsRequested: false); + try + { + var view = response.PlainTextView; + var output = Encoding.UTF8.GetString(view.Array!, view.Offset, view.Count); + + Assert.Single(Regex.Matches(output, "^# TYPE test_metric_bytes_total counter$", RegexOptions.Multiline).Cast()); + Assert.Single(Regex.Matches(output, "^# UNIT test_metric_bytes_total bytes$", RegexOptions.Multiline).Cast()); + Assert.Single(Regex.Matches(output, "^# HELP test_metric_bytes_total Test help$", RegexOptions.Multiline).Cast()); + Assert.Contains("test_metric_bytes_total{otel_scope_name=\"" + meter.Name + "\",source=\"a\"} 1", output, StringComparison.Ordinal); + Assert.Contains("test_metric_bytes_total{otel_scope_name=\"" + meter.Name + "\",source=\"b\"} 2", output, StringComparison.Ordinal); + } + finally + { + exporter.CollectionManager.ExitCollect(); + } + } + + [Fact] + public async Task ConflictingMetricTypesAreDroppedFromAScrape() + { + using var meter = new Meter(Utils.GetCurrentMethodName()); + + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) +#if PROMETHEUS_HTTP_LISTENER + .AddPrometheusHttpListener() +#elif PROMETHEUS_ASPNETCORE + .AddPrometheusExporter() +#endif + .Build(); + +#pragma warning disable CA2000 + Assert.True(provider.TryFindExporter(out PrometheusExporter? exporter)); +#pragma warning restore CA2000 + + var counter = meter.CreateCounter("test.metric"); + meter.CreateObservableGauge("test-metric", () => 1); + counter.Add(1); + + var response = await exporter!.CollectionManager.EnterCollect(openMetricsRequested: true); + try + { + var view = response.OpenMetricsView; + var output = Encoding.UTF8.GetString(view.Array!, view.Offset, view.Count); + + Assert.DoesNotContain("# TYPE test_metric", output, StringComparison.Ordinal); + Assert.DoesNotContain("test_metric_total", output, StringComparison.Ordinal); + Assert.Contains("# EOF", output, StringComparison.Ordinal); + } + finally + { + exporter.CollectionManager.ExitCollect(); + } + } + #if PROMETHEUS_HTTP_LISTENER private static MeterProvider CreateMeterProviderWithRandomPort(Meter meter) { From 7d3aad5eec8140a59355932048ee9d0ffc135c54 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 22:57:19 +0100 Subject: [PATCH 35/82] [Exporter.Prometheus] Address feedback - Write data in the correct order. - Avoid `Enum.ToString()` if event source not enabled. --- .../Internal/PrometheusCollectionManager.cs | 89 ++++++++++++------- .../Internal/PrometheusExporterEventSource.cs | 9 ++ .../Internal/PrometheusSerializer.cs | 8 +- .../Internal/PrometheusSerializerExt.cs | 8 +- .../PrometheusCollectionManagerTests.cs | 50 +++++++++++ 5 files changed, 127 insertions(+), 37 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs index f2c266b9652..bc4519dff28 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs @@ -284,7 +284,9 @@ private ExportResult OnCollect(in Batch metrics) this.exporter.OpenMetricsRequested, writeType: metricState.WriteType, writeUnit: metricState.WriteUnit, - writeHelp: metricState.WriteHelp); + writeHelp: metricState.WriteHelp, + unitOverride: metricState.Unit, + helpOverride: metricState.Help); break; } @@ -391,7 +393,7 @@ private PrometheusMetric GetPrometheusMetric(Metric metric) private List GetMetricStates(in Batch metrics, bool openMetricsRequested) { - var metricStates = new List(); + var precomputedMetricStates = new List(); var metadataStates = new Dictionary(StringComparer.Ordinal); var droppedMetricNames = new HashSet(StringComparer.Ordinal); @@ -404,33 +406,27 @@ private List GetMetricStates(in Batch metrics, bool openMet var prometheusMetric = this.GetPrometheusMetric(metric); var metadataName = openMetricsRequested ? prometheusMetric.OpenMetricsMetadataName : prometheusMetric.Name; + precomputedMetricStates.Add(new PrecomputedMetricState(metric, prometheusMetric, metadataName)); if (!metadataStates.TryGetValue(metadataName, out var metadataState)) { - metadataStates[metadataName] = new MetadataState(prometheusMetric.Type, metric.Description, prometheusMetric.Unit); - metricStates.Add( - new MetricState( - metric, - prometheusMetric, - true, - !string.IsNullOrEmpty(prometheusMetric.Unit), - !string.IsNullOrEmpty(metric.Description))); + metadataStates[metadataName] = new MetadataState( + prometheusMetric.Type, + string.IsNullOrEmpty(metric.Description) ? null : metric.Description, + string.IsNullOrEmpty(prometheusMetric.Unit) ? null : prometheusMetric.Unit); continue; } if (metadataState.Type != prometheusMetric.Type) { droppedMetricNames.Add(metadataName); - PrometheusExporterEventSource.Log.ConflictingType(metadataName, metadataState.Type.ToString(), prometheusMetric.Type.ToString()); + PrometheusExporterEventSource.Log.ConflictingType(metadataName, metadataState.Type, prometheusMetric.Type); } - var writeUnit = false; - if (!string.IsNullOrEmpty(prometheusMetric.Unit) && metadataState.Unit == null) { metadataStates[metadataName] = new MetadataState(metadataState.Type, metadataState.Help, prometheusMetric.Unit); - writeUnit = true; } else if (!string.IsNullOrEmpty(prometheusMetric.Unit) && metadataState.Unit != null && @@ -439,13 +435,10 @@ private List GetMetricStates(in Batch metrics, bool openMet PrometheusExporterEventSource.Log.ConflictingUnit(metadataName, metadataState.Unit, prometheusMetric.Unit!); } - var writeHelp = false; - if (!string.IsNullOrEmpty(metric.Description) && metadataState.Help == null) { metadataStates[metadataName] = new MetadataState(metadataState.Type, metric.Description, metadataState.Unit); - writeHelp = true; } else if (!string.IsNullOrEmpty(metric.Description) && metadataState.Help != null && @@ -453,26 +446,33 @@ private List GetMetricStates(in Batch metrics, bool openMet { PrometheusExporterEventSource.Log.ConflictingHelp(metadataName, metadataState.Help, metric.Description); } - - metricStates.Add(new MetricState(metric, prometheusMetric, false, writeUnit, writeHelp)); } - if (droppedMetricNames.Count == 0) - { - return metricStates; - } + var metricStates = new List(precomputedMetricStates.Count); + var emittedMetricNames = new HashSet(StringComparer.Ordinal); - var filteredMetricStates = new List(metricStates.Count); - foreach (var metricState in metricStates) + foreach (var metricState in precomputedMetricStates) { - var metadataName = openMetricsRequested ? metricState.PrometheusMetric.OpenMetricsMetadataName : metricState.PrometheusMetric.Name; - if (!droppedMetricNames.Contains(metadataName)) + if (droppedMetricNames.Contains(metricState.MetadataName)) { - filteredMetricStates.Add(metricState); + continue; } + + var writeMetadata = emittedMetricNames.Add(metricState.MetadataName); + var metadataState = metadataStates[metricState.MetadataName]; + + metricStates.Add( + new MetricState( + metricState.Metric, + metricState.PrometheusMetric, + writeMetadata, + writeMetadata && metadataState.Unit != null, + writeMetadata && metadataState.Help != null, + metadataState.Unit, + metadataState.Help)); } - return filteredMetricStates; + return metricStates; } public readonly struct CollectionResponse @@ -496,13 +496,22 @@ public CollectionResponse(ArraySegment openMetricsView, ArraySegment private readonly struct MetricState { - public MetricState(Metric metric, PrometheusMetric prometheusMetric, bool writeType, bool writeUnit, bool writeHelp) + public MetricState( + Metric metric, + PrometheusMetric prometheusMetric, + bool writeType, + bool writeUnit, + bool writeHelp, + string? unit, + string? help) { this.Metric = metric; this.PrometheusMetric = prometheusMetric; this.WriteType = writeType; this.WriteUnit = writeUnit; this.WriteHelp = writeHelp; + this.Unit = unit; + this.Help = help; } public readonly Metric Metric { get; } @@ -514,6 +523,26 @@ public MetricState(Metric metric, PrometheusMetric prometheusMetric, bool writeT public readonly bool WriteUnit { get; } public readonly bool WriteHelp { get; } + + public readonly string? Unit { get; } + + public readonly string? Help { get; } + } + + private readonly struct PrecomputedMetricState + { + public PrecomputedMetricState(Metric metric, PrometheusMetric prometheusMetric, string metadataName) + { + this.Metric = metric; + this.PrometheusMetric = prometheusMetric; + this.MetadataName = metadataName; + } + + public readonly Metric Metric { get; } + + public readonly PrometheusMetric PrometheusMetric { get; } + + public readonly string MetadataName { get; } } private readonly struct MetadataState diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs index e4b9d5afeaf..488398877c4 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs @@ -42,6 +42,15 @@ public void CanceledExport(Exception ex) } } + [NonEvent] + public void ConflictingType(string metricName, PrometheusType firstType, PrometheusType conflictingType) + { + if (this.IsEnabled(EventLevel.Warning, EventKeywords.All)) + { + this.ConflictingType(metricName, firstType.ToString(), conflictingType.ToString()); + } + } + [Event(1, Message = "Failed to export metrics: '{0}'", Level = EventLevel.Error)] public void FailedExport(string exception) => this.WriteEvent(1, exception); diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index c46724bfc7d..5f4202f1acd 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -346,9 +346,9 @@ public static int WriteTypeMetadata(byte[] buffer, int cursor, PrometheusMetric } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteUnitMetadata(byte[] buffer, int cursor, PrometheusMetric metric, bool openMetricsRequested) + public static int WriteUnitMetadata(byte[] buffer, int cursor, PrometheusMetric metric, string? unit, bool openMetricsRequested) { - if (string.IsNullOrEmpty(metric.Unit)) + if (string.IsNullOrEmpty(unit)) { return cursor; } @@ -360,10 +360,10 @@ public static int WriteUnitMetadata(byte[] buffer, int cursor, PrometheusMetric // Unit name has already been escaped. #pragma warning disable IDE0370 // Remove unnecessary suppression - for (var i = 0; i < metric.Unit!.Length; i++) + for (var i = 0; i < unit!.Length; i++) #pragma warning restore IDE0370 // Remove unnecessary suppression { - var ordinal = (ushort)metric.Unit[i]; + var ordinal = (ushort)unit[i]; buffer[cursor++] = unchecked((byte)ordinal); } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs index 08786772926..7f248262b4c 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -30,7 +30,9 @@ public static int WriteMetric( bool openMetricsRequested, bool writeType = true, bool writeUnit = true, - bool writeHelp = true) + bool writeHelp = true, + string? unitOverride = null, + string? helpOverride = null) { if (writeType) { @@ -39,12 +41,12 @@ public static int WriteMetric( if (writeUnit) { - cursor = WriteUnitMetadata(buffer, cursor, prometheusMetric, openMetricsRequested); + cursor = WriteUnitMetadata(buffer, cursor, prometheusMetric, unitOverride ?? prometheusMetric.Unit, openMetricsRequested); } if (writeHelp) { - cursor = WriteHelpMetadata(buffer, cursor, prometheusMetric, metric.Description, openMetricsRequested); + cursor = WriteHelpMetadata(buffer, cursor, prometheusMetric, helpOverride ?? metric.Description, openMetricsRequested); } if (!metric.MetricType.IsHistogram()) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs index f3d0f0ed5f8..226788a676a 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs @@ -425,6 +425,56 @@ public async Task DuplicateMetricMetadataIsWrittenOncePerScrape() } } + [Fact] + public async Task MetricMetadataDiscoveredLaterIsWrittenBeforeSamples() + { + using var meter = new Meter(Utils.GetCurrentMethodName()); + + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) +#if PROMETHEUS_HTTP_LISTENER + .AddPrometheusHttpListener() +#elif PROMETHEUS_ASPNETCORE + .AddPrometheusExporter() +#endif + .Build(); + +#pragma warning disable CA2000 + Assert.True(provider.TryFindExporter(out PrometheusExporter? exporter)); +#pragma warning restore CA2000 + + var counter1 = meter.CreateCounter("test.metric"); + var counter2 = meter.CreateCounter("test-metric", description: "Test help"); + + counter1.Add(1, [new("source", "a")]); + counter2.Add(2, [new("source", "b")]); + + var response = await exporter!.CollectionManager.EnterCollect(openMetricsRequested: false); + try + { + var view = response.PlainTextView; + var output = Encoding.UTF8.GetString(view.Array!, view.Offset, view.Count); + + var typeIndex = output.IndexOf("# TYPE test_metric_total counter", StringComparison.Ordinal); + var helpIndex = output.IndexOf("# HELP test_metric_total Test help", StringComparison.Ordinal); + var sampleAIndex = output.IndexOf("test_metric_total{otel_scope_name=\"" + meter.Name + "\",source=\"a\"} 1", StringComparison.Ordinal); + var sampleBIndex = output.IndexOf("test_metric_total{otel_scope_name=\"" + meter.Name + "\",source=\"b\"} 2", StringComparison.Ordinal); + + Assert.True(typeIndex >= 0, "No TYPE found."); + Assert.True(helpIndex >= 0, "No HELP found."); + Assert.True(sampleAIndex >= 0, "No sample A found."); + Assert.True(sampleBIndex >= 0, "No sample B found."); + Assert.True(typeIndex < sampleAIndex, "TYPE appears after sample A."); + Assert.True(typeIndex < sampleBIndex, "TYPE appears after sample B."); + Assert.True(helpIndex < sampleAIndex, "HELP appears after sample A."); + Assert.True(helpIndex < sampleBIndex, "HELP appears after sample B."); + } + finally + { + exporter.CollectionManager.ExitCollect(); + } + } + [Fact] public async Task ConflictingMetricTypesAreDroppedFromAScrape() { From 38515915cfbf964c2e91fb768d51d30c75ef7c9b Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 23:56:05 +0100 Subject: [PATCH 36/82] [Exporter.Prometheus] Extend coverage Add coverage for additional branches. --- .../PrometheusCollectionManagerTests.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs index 226788a676a..893ab2aa8f9 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs @@ -475,6 +475,56 @@ public async Task MetricMetadataDiscoveredLaterIsWrittenBeforeSamples() } } + [Fact] + public async Task MetricUnitDiscoveredLaterIsWrittenBeforeSamples() + { + using var meter = new Meter(Utils.GetCurrentMethodName()); + + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) +#if PROMETHEUS_HTTP_LISTENER + .AddPrometheusHttpListener() +#elif PROMETHEUS_ASPNETCORE + .AddPrometheusExporter() +#endif + .Build(); + +#pragma warning disable CA2000 + Assert.True(provider.TryFindExporter(out PrometheusExporter? exporter)); +#pragma warning restore CA2000 + + var counter1 = meter.CreateCounter("test.metric.bytes"); + var counter2 = meter.CreateCounter("test-metric-bytes", unit: "By"); + + counter1.Add(1, [new("source", "a")]); + counter2.Add(2, [new("source", "b")]); + + var response = await exporter!.CollectionManager.EnterCollect(openMetricsRequested: false); + try + { + var view = response.PlainTextView; + var output = Encoding.UTF8.GetString(view.Array!, view.Offset, view.Count); + + var typeIndex = output.IndexOf("# TYPE test_metric_bytes_total counter", StringComparison.Ordinal); + var unitIndex = output.IndexOf("# UNIT test_metric_bytes_total bytes", StringComparison.Ordinal); + var sampleAIndex = output.IndexOf("test_metric_bytes_total{otel_scope_name=\"" + meter.Name + "\",source=\"a\"} 1", StringComparison.Ordinal); + var sampleBIndex = output.IndexOf("test_metric_bytes_total{otel_scope_name=\"" + meter.Name + "\",source=\"b\"} 2", StringComparison.Ordinal); + + Assert.True(typeIndex >= 0, "No TYPE found."); + Assert.True(unitIndex >= 0, "No UNIT found."); + Assert.True(sampleAIndex >= 0, "No sample A found."); + Assert.True(sampleBIndex >= 0, "No sample B found."); + Assert.True(typeIndex < sampleAIndex, "TYPE appears after sample A."); + Assert.True(typeIndex < sampleBIndex, "TYPE appears after sample B."); + Assert.True(unitIndex < sampleAIndex, "UNIT appears after sample A."); + Assert.True(unitIndex < sampleBIndex, "UNIT appears after sample B."); + } + finally + { + exporter.CollectionManager.ExitCollect(); + } + } + [Fact] public async Task ConflictingMetricTypesAreDroppedFromAScrape() { From d91f75e7a70ce569e4cfcf9ab7c846d2a3133408 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 May 2026 17:11:33 +0100 Subject: [PATCH 37/82] Fix Prometheus merge regressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Internal/PrometheusSerializer.cs | 160 +++++------------- .../PrometheusSerializerTests.cs | 78 ++++----- 2 files changed, 79 insertions(+), 159 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 5f4202f1acd..8b5f0eedcf4 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -20,11 +20,6 @@ namespace OpenTelemetry.Exporter.Prometheus; /// internal static partial class PrometheusSerializer { -#if !NET - private static readonly DateTimeOffset UnixEpoch = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); -#endif - private static readonly HashSet ReservedScopeAttributeNames = ["name", "schema_url", "version"]; - #pragma warning disable SA1310 // Field name should not contain an underscore private const byte ASCII_QUOTATION_MARK = 0x22; // '"' private const byte ASCII_REVERSE_SOLIDUS = 0x5C; // '\\' @@ -258,14 +253,14 @@ public static int WriteExemplar(byte[] buffer, int cursor, in Exemplar exemplar, if (exemplar.TraceId != default) { - cursor = WriteLabel(buffer, cursor, "trace_id", exemplar.TraceId.ToHexString()); + cursor = WriteLabel(buffer, cursor, "trace_id", exemplar.TraceId.ToHexString(), openMetricsRequested: true); buffer[cursor++] = unchecked((byte)','); hasLabels = true; } if (exemplar.SpanId != default) { - cursor = WriteLabel(buffer, cursor, "span_id", exemplar.SpanId.ToHexString()); + cursor = WriteLabel(buffer, cursor, "span_id", exemplar.SpanId.ToHexString(), openMetricsRequested: true); buffer[cursor++] = unchecked((byte)','); hasLabels = true; } @@ -277,7 +272,7 @@ public static int WriteExemplar(byte[] buffer, int cursor, in Exemplar exemplar, continue; } - cursor = WriteLabel(buffer, cursor, tag.Key, tag.Value); + cursor = WriteLabel(buffer, cursor, tag.Key, tag.Value, openMetricsRequested: true); buffer[cursor++] = unchecked((byte)','); hasLabels = true; } @@ -417,33 +412,6 @@ public static int WriteTags( bool openMetricsRequested, bool writeEnclosingBraces = true) { - var startCursor = cursor; - List? writtenOutputKeys = null; - var wroteLabel = false; - - if (writeEnclosingBraces) - { - buffer[cursor++] = unchecked((byte)'{'); - } - - if (TryWriteLabel("otel_scope_name", metric.MeterName) && - (string.IsNullOrEmpty(metric.MeterVersion) || TryWriteLabel("otel_scope_version", metric.MeterVersion)) && - TryWriteMetricTags() && - TryWritePointTags()) - { - if (writeEnclosingBraces) - { - buffer[cursor++] = unchecked((byte)'}'); - } - else if (wroteLabel) - { - buffer[cursor++] = unchecked((byte)','); - } - - return cursor; - } - - cursor = startCursor; List? labels = null; AddScopeLabels(metric, openMetricsRequested, ref labels); @@ -453,58 +421,6 @@ public static int WriteTags( } return WriteLabels(buffer, cursor, labels, openMetricsRequested, writeEnclosingBraces); - - bool TryWriteMetricTags() - { - if (metric.MeterTags != null) - { - foreach (var tag in metric.MeterTags) - { - if (!TryWriteLabel(tag.Key, tag.Value)) - { - return false; - } - } - } - - return true; - } - - bool TryWritePointTags() - { - foreach (var tag in tags) - { - if (!TryWriteLabel(tag.Key, tag.Value)) - { - return false; - } - } - - return true; - } - - bool TryWriteLabel(string key, object? value) - { - var outputKey = GetSanitizedLabelKey(key, openMetricsRequested); - - if (writtenOutputKeys?.Contains(outputKey) == true) - { - return false; - } - - writtenOutputKeys ??= []; - writtenOutputKeys.Add(outputKey); - - if (wroteLabel) - { - buffer[cursor++] = unchecked((byte)','); - } - - cursor = WriteLabel(buffer, cursor, key, value, openMetricsRequested); - wroteLabel = true; - - return true; - } } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -545,6 +461,30 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource, return cursor; } + internal static string CreateScopeIdentity(Metric metric) + { + List? labels = null; + AddScopeLabels(metric, openMetricsRequested: true, ref labels); + var scopeLabels = MergeLabels(labels); + scopeLabels.Sort(static (x, y) => + { + var keyCompare = string.CompareOrdinal(x.Key, y.Key); + return keyCompare != 0 ? keyCompare : string.CompareOrdinal(x.Value, y.Value); + }); + + var builder = new StringBuilder(); + + foreach (var scopeLabel in scopeLabels) + { + builder.Append('\0') + .Append(scopeLabel.Key) + .Append('\0') + .Append(scopeLabel.Value); + } + + return builder.ToString(); + } + private static int WritePrometheusLabelKey(byte[] buffer, int cursor, string value) => WriteNormalizedLabelKey(buffer, cursor, value, isOpenMetrics: false); @@ -620,30 +560,6 @@ private static void AddScopeLabels(Metric metric, bool openMetricsRequested, ref } } - internal static string CreateScopeIdentity(Metric metric) - { - List? labels = null; - AddScopeLabels(metric, openMetricsRequested: true, ref labels); - var scopeLabels = MergeLabels(labels); - scopeLabels.Sort(static (x, y) => - { - var keyCompare = string.CompareOrdinal(x.Key, y.Key); - return keyCompare != 0 ? keyCompare : string.CompareOrdinal(x.Value, y.Value); - }); - - var builder = new StringBuilder(); - - foreach (var scopeLabel in scopeLabels) - { - builder.Append('\0') - .Append(scopeLabel.Key) - .Append('\0') - .Append(scopeLabel.Value); - } - - return builder.ToString(); - } - private static string GetLabelValueString(object? labelValue) { // TODO: Attribute values should be written as their JSON representation. Extra logic may need to be added here to correctly convert other .NET types. @@ -809,6 +725,10 @@ private static int WriteLabels(byte[] buffer, int cursor, IReadOnlyList 0) + { + cursor--; + } return cursor; } @@ -854,15 +774,6 @@ private static bool TryCreateScopeLabel(KeyValuePair tag, bool private static bool IsAllowedMetricsLabelCharacter(char value, bool isOpenMetrics) => char.IsAsciiLetterOrDigit(value) || value is '_' || (isOpenMetrics && value == ':'); - private readonly struct LabelData(string originalKey, string outputKey, string value) - { - public readonly string OriginalKey { get; } = originalKey; - - public readonly string OutputKey { get; } = outputKey; - - public readonly string Value { get; } = value; - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int WriteUnicodeScalar(byte[] buffer, int cursor, string value, ref int index) { @@ -1092,4 +1003,13 @@ private static bool TryGetPowerOfTenExponent(double absoluteValue, out int expon exponent = roundedExponent; return true; } + + private readonly struct LabelData(string originalKey, string outputKey, string value) + { + public readonly string OriginalKey { get; } = originalKey; + + public readonly string OutputKey { get; } = outputKey; + + public readonly string Value { get; } = value; + } } diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index b4bb93c9485..53915c41d88 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -870,21 +870,21 @@ public void HistogramOneDimensionWithOpenMetricsFormat() var expected = ("^" + "# TYPE test_histogram histogram\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='0'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='5'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='10'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='25'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='50'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='75'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='100'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='250'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='500'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='750'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='1000'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='2500'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='5000'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='7500'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='10000'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='0.0'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='5.0'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='10.0'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='25.0'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='50.0'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='75.0'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='100.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='250.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='500.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='750.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='1000.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='2500.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='5000.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='7500.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='10000.0'}} 2\n" + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1',le='\\+Inf'}} 2\n" + $"test_histogram_sum{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1'}} 118\n" + $"test_histogram_count{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1'}} 2\n" @@ -931,7 +931,7 @@ public void HistogramWithOpenMetricsFormatEmitsLatestBucketExemplar() var output = Encoding.UTF8.GetString(buffer, 0, cursor); var bucketLine = output.Split(['\n'], StringSplitOptions.RemoveEmptyEntries) .Single(line => line.Contains("test_histogram_bucket{", StringComparison.Ordinal) - && line.Contains("le=\"10\"", StringComparison.Ordinal)); + && line.Contains("le=\"10.0\"", StringComparison.Ordinal)); Assert.Contains("} 3 # ", bucketLine, StringComparison.Ordinal); Assert.Contains( @@ -1203,21 +1203,21 @@ public void HistogramOneDimensionWithScopeVersion() Assert.Matches( ("^" + "# TYPE test_histogram histogram\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='0'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='5'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='10'}} 0\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='25'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='50'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='75'}} 1\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='100'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='250'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='500'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='750'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='1000'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='2500'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='5000'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='7500'}} 2\n" - + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='10000'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='0.0'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='5.0'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='10.0'}} 0\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='25.0'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='50.0'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='75.0'}} 1\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='100.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='250.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='500.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='750.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='1000.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='2500.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='5000.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='7500.0'}} 2\n" + + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='10000.0'}} 2\n" + $"test_histogram_bucket{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1',le='\\+Inf'}} 2\n" + $"test_histogram_sum{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1'}} 118\n" + $"test_histogram_count{{otel_scope_name='{Utils.GetCurrentMethodName()}',otel_scope_version='1.0.0',x='1'}} 2\n" @@ -1247,9 +1247,9 @@ public void HistogramWithNegativeBucketBoundsOmitsSumAndCountWithOpenMetricsForm 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=\"-1.0\"}} 0\n", output, StringComparison.Ordinal); + Assert.Contains($"test_histogram_bucket{{otel_scope_name=\"{Utils.GetCurrentMethodName()}\",x=\"1\",le=\"0.0\"}} 1\n", output, StringComparison.Ordinal); + Assert.Contains($"test_histogram_bucket{{otel_scope_name=\"{Utils.GetCurrentMethodName()}\",x=\"1\",le=\"1.0\"}} 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); @@ -1382,7 +1382,7 @@ public void WriteMetricSerializesStaticMeterTagBoundaryValues(object? meterTagVa Assert.Equal( ("# TYPE test_gauge gauge\n" - + $"test_gauge{{otel_scope_name='test_meter',meter_tag='{expectedTagValue}'}} 123\n").Replace('\'', '"'), + + $"test_gauge{{otel_scope_name='test_meter',otel_scope_meter_tag='{expectedTagValue}'}} 123\n").Replace('\'', '"'), output); } @@ -1612,9 +1612,9 @@ public void WriteHistogramMetricSerializesStaticTagsWithoutPreSerializedTags() var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metric, prometheusMetric, openMetricsRequested: false); var output = Encoding.UTF8.GetString(buffer, 0, cursor); - Assert.Contains("test_histogram_bucket{otel_scope_name=\"\u65e5\u672c\",_=\"meterTagValue\",le=\"0\"} 0\n", output, StringComparison.Ordinal); - Assert.Contains("test_histogram_sum{otel_scope_name=\"\u65e5\u672c\",_=\"meterTagValue\"} 18\n", output, StringComparison.Ordinal); - Assert.Contains("test_histogram_count{otel_scope_name=\"\u65e5\u672c\",_=\"meterTagValue\"} 1\n", output, StringComparison.Ordinal); + Assert.Contains("test_histogram_bucket{otel_scope_name=\"\u65e5\u672c\",otel_scope_=\"meterTagValue\",le=\"0\"} 0\n", output, StringComparison.Ordinal); + Assert.Contains("test_histogram_sum{otel_scope_name=\"\u65e5\u672c\",otel_scope_=\"meterTagValue\"} 18\n", output, StringComparison.Ordinal); + Assert.Contains("test_histogram_count{otel_scope_name=\"\u65e5\u672c\",otel_scope_=\"meterTagValue\"} 1\n", output, StringComparison.Ordinal); } private static Metric GetSingleHistogramMetric(string meterName, params KeyValuePair[] meterTags) @@ -1653,7 +1653,7 @@ private static string ToHexString(byte[] buffer, int length) static char GetHexValue(int value) => (char)(value < 10 ? '0' + value : 'A' + (value - 10)); } - private static int WriteMetric(byte[] buffer, int cursor, Metric metric, bool useOpenMetrics) + private static int WriteMetric(byte[] buffer, int cursor, Metric metric, bool useOpenMetrics = false) => PrometheusSerializer.WriteMetric(buffer, cursor, metric, PrometheusMetric.Create(metric, false), useOpenMetrics); private static void WaitForNextExemplarTimestamp() From c4a22421bf79ba0606df7805425615140ccea3f8 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 May 2026 17:14:43 +0100 Subject: [PATCH 38/82] [Exporter.Prometheus] Tweak formatting Fix-up merge. --- .../Internal/PrometheusSerializer.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 8b5f0eedcf4..92687874b56 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -464,8 +464,11 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource, internal static string CreateScopeIdentity(Metric metric) { List? labels = null; + AddScopeLabels(metric, openMetricsRequested: true, ref labels); + var scopeLabels = MergeLabels(labels); + scopeLabels.Sort(static (x, y) => { var keyCompare = string.CompareOrdinal(x.Key, y.Key); @@ -474,12 +477,12 @@ internal static string CreateScopeIdentity(Metric metric) var builder = new StringBuilder(); - foreach (var scopeLabel in scopeLabels) + foreach (var label in scopeLabels) { builder.Append('\0') - .Append(scopeLabel.Key) + .Append(label.Key) .Append('\0') - .Append(scopeLabel.Value); + .Append(label.Value); } return builder.ToString(); @@ -950,8 +953,7 @@ private static string GetCanonicalLabelValueString(double value) { return FormatFixedAndTrim(value, 1); } - - if (TryGetPowerOfTenExponent(absoluteValue, out var exponent)) + else if (TryGetPowerOfTenExponent(absoluteValue, out var exponent)) { return exponent is >= 6 or <= -5 ? string.Concat(value < 0 ? "-1" : "1", FormatExponent(exponent)) From 936b177d099b0a116eee1c0ca98ec9501abdd31c Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 May 2026 14:07:08 +0100 Subject: [PATCH 39/82] [Exporter.Prometheus] Validate with promtool Add integration tests that use promtool to validate that the output appears to be valid. --- Directory.Packages.props | 1 + ...xporter.Prometheus.AspNetCore.Tests.csproj | 21 +++ .../PrometheusIntegrationTests.cs | 170 ++++++++++++++++++ ...orter.Prometheus.HttpListener.Tests.csproj | 24 ++- .../PromToolCollection.cs | 14 ++ .../PromToolFixture.cs | 47 +++++ .../PrometheusCollection.cs | 14 ++ .../PrometheusFixture.cs | 18 ++ .../PrometheusHttpListenerTests.cs | 96 +++++----- .../PrometheusIntegrationTests.cs | 138 ++++++++++++++ .../prometheus.Dockerfile | 1 + test/Shared/ContainerFixture.cs | 55 ++++++ test/Shared/ContainerFixture{T}.cs | 24 +++ test/Shared/DockerHelper.cs | 73 ++++++++ test/Shared/DockerPlatform.cs | 17 ++ .../EnabledOnDockerPlatformFactAttribute.cs | 23 +++ .../EnabledOnDockerPlatformTheoryAttribute.cs | 23 +++ test/Shared/XunitContainerFixture{T}.cs | 13 ++ 18 files changed, 724 insertions(+), 48 deletions(-) create mode 100644 test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs create mode 100644 test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolCollection.cs create mode 100644 test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolFixture.cs create mode 100644 test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollection.cs create mode 100644 test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs create mode 100644 test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs create mode 100644 test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/prometheus.Dockerfile create mode 100644 test/Shared/ContainerFixture.cs create mode 100644 test/Shared/ContainerFixture{T}.cs create mode 100644 test/Shared/DockerHelper.cs create mode 100644 test/Shared/DockerPlatform.cs create mode 100644 test/Shared/EnabledOnDockerPlatformFactAttribute.cs create mode 100644 test/Shared/EnabledOnDockerPlatformTheoryAttribute.cs create mode 100644 test/Shared/XunitContainerFixture{T}.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 74bc614812b..a3e18346fc2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -121,6 +121,7 @@ + diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj index 013d882a599..fbc962e518d 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj @@ -8,6 +8,8 @@ + + @@ -26,12 +28,31 @@ + + + + + + + + + + + + + + + + + + + diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs new file mode 100644 index 00000000000..fe49449da7f --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -0,0 +1,170 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Exporter.Prometheus.Tests; +using OpenTelemetry.Metrics; +using OpenTelemetry.Tests; +using Xunit; +using Xunit.Abstractions; + +namespace OpenTelemetry.Exporter.Prometheus.HttpListener.Tests; + +[Collection(PromToolCollection.Name)] +public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelper outputHelper) +{ + [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] + [InlineData("")] + [InlineData("text/plain")] + [InlineData("text/plain;version=0.0.4")] + [InlineData("text/plain;version=1.0.0", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] + [InlineData("application/openmetrics-text", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] + [InlineData("application/openmetrics-text;version=0.0.4")] + [InlineData("application/openmetrics-text;version=1.0.0", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] + [InlineData("application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5,application/openmetrics-text;version=0.0.1;q=0.4,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.3,text/plain;version=0.0.4;q=0.2,/;q=0.1", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] + + public async Task Can_Scrape_Prometheus(string accept) + { + // Arrange + const string meterName = "prometheus.integration.tests"; + const string meterVersion = "1.2.3"; + + using var meter = new Meter( + meterName, + meterVersion, + [new("meter_tag", "meter-value")]); + + var counter = meter.CreateCounter("kestrel.rejected_connections", unit: "{connection}", description: "Number of connections rejected by the server."); + var upDownCounter = meter.CreateUpDownCounter("kestrel.active_connections", unit: "{connection}", description: "Number of connections that are currently active on the server."); + var histogram = meter.CreateHistogram("kestrel.connection.duration", unit: "s", description: "The duration of connections on the server."); + + var ignoredHistogram = meter.CreateHistogram("exponential_latency", unit: "ms", description: "Ignored exponential histogram."); + + meter.CreateObservableCounter( + "processed_bytes", + () => new Measurement(4, [new("source", "scheduler")]), + unit: "bytes", + description: "Background processed bytes."); + + meter.CreateObservableGauge( + "temperature", + () => new Measurement(22.5, [new("region", "eu-west-1")]), + unit: "celsius", + description: "Current temperature."); + + meter.CreateObservableUpDownCounter( + "queue_balance", + () => new Measurement(-2, [new("pool", "shared")]), + description: "Current queue balance."); + + const string KeepTag = "keep"; + + var builder = WebApplication.CreateBuilder(); + + // Listen on any available port + builder.WebHost.UseUrls("http://127.0.0.1:0"); + + builder.Services + .AddOpenTelemetry() + .WithMetrics((builder) => + { + builder.AddAspNetCoreInstrumentation() + .AddPrometheusExporter() + .SetExemplarFilter(ExemplarFilterType.AlwaysOn) + .AddView( + counter.Name, + new MetricStreamConfiguration + { + ExemplarReservoirFactory = () => new SimpleFixedSizeExemplarReservoir(3), + TagKeys = [KeepTag], + }) + .AddView( + histogram.Name, + new ExplicitBucketHistogramConfiguration + { + Boundaries = [5, 10], + ExemplarReservoirFactory = () => new SimpleFixedSizeExemplarReservoir(3), + TagKeys = [KeepTag], + }) + .AddView( + ignoredHistogram.Name, + new Base2ExponentialBucketHistogramConfiguration()); + }); + + using var app = builder.Build(); + + app.MapGet("ping", () => "pong"); + app.MapPrometheusScrapingEndpoint(); + + await app.StartAsync(); + + var server = app.Services.GetRequiredService(); + var addresses = server.Features.Get(); + + var baseAddress = addresses!.Addresses + .Select((p) => new Uri(p)) + .Last(); + + using (var httpClient = new HttpClient()) + { + _ = await httpClient.GetStringAsync(new Uri(baseAddress, "ping")); + } + + counter.Add(1, new(KeepTag, "value"), new("filtered", "older")); + + upDownCounter.Add(5, [new("queue", "critical")]); + upDownCounter.Add(-2, [new("queue", "critical")]); + + histogram.Record(4, new(KeepTag, "value"), new("filtered", "older")); + histogram.Record(8, new(KeepTag, "value"), new("filtered", "first")); + + ignoredHistogram.Record(42, [new("kind", "exp")]); + + WaitForNextExemplarTimestamp(); + + using var activity = new Activity("test"); + activity.Start(); + + counter.Add(2, new("keep", "value"), new("filtered", "counter-latest"), new("trace_id", "ignored-trace"), new("span_id", "ignored-span")); + histogram.Record(9, new("keep", "value"), new("filtered", "histogram-latest"), new("trace_id", "ignored-trace"), new("span_id", "ignored-span")); + + activity.Stop(); + + // Act + var actual = await fixture.CheckMetricsAsync(new(baseAddress, "metrics"), accept); + + outputHelper.WriteLine($"[promtool] ExitCode: {actual.ExitCode}"); + outputHelper.WriteLine("[promtool] stdout:"); + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine(actual.Stdout); + + if (!string.IsNullOrEmpty(actual.Stderr)) + { + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine("[promtool] stderr:"); + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine(actual.Stderr); + } + + // Assert + Assert.Equal(0, actual.ExitCode); + Assert.NotEmpty(actual.Stdout); + Assert.Empty(actual.Stderr); + } + + private static void WaitForNextExemplarTimestamp() + { + var timestamp = DateTimeOffset.UtcNow; + + while (DateTimeOffset.UtcNow <= timestamp) + { + Thread.Sleep(1); + } + } +} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests.csproj b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests.csproj index 0a7a29d1941..7da057eb5de 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests.csproj +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests.csproj @@ -4,10 +4,9 @@ Unit test project for Prometheus Exporter HttpListener for OpenTelemetry $(TargetFrameworksForTests) $(DefineConstants);PROMETHEUS_HTTP_LISTENER + $(NoWarn);CA1019;CA1062;CA1515 false - - $(NoWarn);CA1062 @@ -28,10 +27,31 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolCollection.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolCollection.cs new file mode 100644 index 00000000000..a239f8aef68 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolCollection.cs @@ -0,0 +1,14 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Xunit; + +namespace OpenTelemetry.Exporter.Prometheus.Tests; + +[CollectionDefinition(Name)] +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix +public sealed class PromToolCollection : ICollectionFixture +#pragma warning restore CA1711 // Identifiers should not have incorrect suffix +{ + public const string Name = "PromTool"; +} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolFixture.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolFixture.cs new file mode 100644 index 00000000000..a2165fcd975 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolFixture.cs @@ -0,0 +1,47 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; + +namespace OpenTelemetry.Exporter.Prometheus.Tests; + +public sealed class PromToolFixture : PrometheusFixture +{ + public async Task CheckMetricsAsync( + Uri targetUri, + string accept, + CancellationToken cancellationToken = default) + { + // Route the request through Docker's internal host to + // avoid issues with localhost resolution inside the container + var metricsUri = new UriBuilder(targetUri) + { + Host = "host.docker.internal", + }; + + // Use wget to fetch the metrics and pipe them to promtool for validation. + // The metrics text is output to a temporary file so that we can capture + // the response to print to stdout to aid with debugging if neccessary. + string[] command = + [ + "sh", + "-c", + $"set -eu;" + + $"tmp=/tmp/metrics.$$;" + + $"wget -qO \"$tmp\" --header=\"Accept: {accept}\" --header=\"Host: {targetUri.Host}\" \"{metricsUri}\"; " + + $"cat \"$tmp\"; " + + $"promtool check metrics --lint=all < \"$tmp\"", + ]; + + return await this.Container + .ExecAsync(command, cancellationToken) + .ConfigureAwait(false); + } + + protected override IContainer CreateContainer() => + new ContainerBuilder(this.GetImage()) + .WithEntrypoint("sh", "-c") + .WithCommand("sleep infinity") + .Build(); +} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollection.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollection.cs new file mode 100644 index 00000000000..98867155320 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollection.cs @@ -0,0 +1,14 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Xunit; + +namespace OpenTelemetry.Exporter.Prometheus.Tests; + +[CollectionDefinition(Name)] +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix +public sealed class PrometheusCollection : ICollectionFixture +#pragma warning restore CA1711 // Identifiers should not have incorrect suffix +{ + public const string Name = "Prometheus"; +} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs new file mode 100644 index 00000000000..a7e0189b76f --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs @@ -0,0 +1,18 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using OpenTelemetry.Tests; + +namespace OpenTelemetry.Exporter.Prometheus.Tests; + +public class PrometheusFixture : XunitContainerFixture +{ + protected override string DockerfileName => "prometheus.Dockerfile"; + + protected override IContainer CreateContainer() => + new ContainerBuilder(this.GetImage()) + .WithPortBinding(9090) + .Build(); +} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs index e675093f5af..0ba6264d151 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs @@ -170,7 +170,7 @@ public async Task HostAndPort_Used_When_UriPrefixesNotSet() var host = "localhost"; var port = GetRandomPort(); - using var context = CreateMeterProvider(meter, configure: (options) => + using var context = CreateMeterProvider(meter, configureListener: (options) => { options.Host = host; options.Port = port; @@ -194,7 +194,7 @@ public async Task PortOnly_Set_HostDefaultsToLocalhost() var port = GetRandomPort(); - using var context = CreateMeterProvider(meter, configure: (options) => + using var context = CreateMeterProvider(meter, configureListener: (options) => { options.Port = port; return port; @@ -224,7 +224,7 @@ public async Task HostOnly_Set_Port_DefaultsTo9464() var host = "127.0.0.1"; - using var context = CreateMeterProvider(meter, configure: (options) => + using var context = CreateMeterProvider(meter, configureListener: (options) => { options.Host = host; return options.Port; @@ -246,7 +246,7 @@ public async Task ExplicitUriPrefixes_TakePrecedence_Over_HostPort() int port = 0; - using var context = CreateMeterProvider(meter, configure: (options) => + using var context = CreateMeterProvider(meter, configureListener: (options) => { options.Host = "prometheus.local"; options.Port = 9999; @@ -275,6 +275,51 @@ public void Host_DefaultValue_Is_Localhost() public void Port_DefaultValue_Is_9464() => Assert.Equal(9464, new PrometheusHttpListenerOptions().Port); + internal static MeterProviderTestContext CreateMeterProvider( + Meter meter, + Func? configureListener = null, + Action? configureMeterProvider = null, + IEnumerable>? attributes = null) + { + var maximumAttempts = 5; + var attemptsLeft = maximumAttempts; + + configureListener ??= static (options) => + { + options.Port = GetRandomPort(); + return options.Port; + }; + + while (attemptsLeft-- > 0) + { + int port = -1; + + var builder = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .ConfigureResource((p) => + { + p.Clear().AddService("my_service", serviceInstanceId: "id1"); + + if (attributes is not null) + { + p.AddAttributes(attributes); + } + }) + .AddPrometheusHttpListener((options) => + { + port = configureListener(options); + }); + + configureMeterProvider?.Invoke(builder); + + var provider = builder.Build(); + + return new(provider, port); + } + + throw new InvalidOperationException($"{nameof(MeterProvider)} could not be created within {maximumAttempts} attempts."); + } + private static async Task RunPrometheusExporterHttpServerIntegrationTest( bool skipMetrics = false, string acceptHeader = "application/openmetrics-text", @@ -425,47 +470,6 @@ private static PrometheusTestContext CreateListener(int? port = null) } } - private static MeterProviderTestContext CreateMeterProvider( - Meter meter, - Func? configure = null, - IEnumerable>? attributes = null) - { - var maximumAttempts = 5; - var attemptsLeft = maximumAttempts; - - configure ??= static (options) => - { - options.Port = GetRandomPort(); - return options.Port; - }; - - while (attemptsLeft-- > 0) - { - int port = -1; - - var provider = Sdk.CreateMeterProviderBuilder() - .AddMeter(meter.Name) - .ConfigureResource((p) => - { - p.Clear().AddService("my_service", serviceInstanceId: "id1"); - - if (attributes is not null) - { - p.AddAttributes(attributes); - } - }) - .AddPrometheusHttpListener((options) => - { - port = configure(options); - }) - .Build(); - - return new(provider, port); - } - - throw new InvalidOperationException($"{nameof(MeterProvider)} could not be created within {maximumAttempts} attempts."); - } - [Obsolete("Supports tests for the obsolete UriPrefixes property.")] private static PrometheusHttpListenerOptions TestPrometheusHttpListenerUriPrefixOptions(string[] uriPrefixes) { @@ -482,7 +486,7 @@ private static PrometheusHttpListenerOptions TestPrometheusHttpListenerUriPrefix return options; } - private sealed class MeterProviderTestContext(MeterProvider provider, int port) : IDisposable + internal sealed class MeterProviderTestContext(MeterProvider provider, int port) : IDisposable { public MeterProvider Provider { get; } = provider; diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs new file mode 100644 index 00000000000..ecb0f6dc067 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs @@ -0,0 +1,138 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using OpenTelemetry.Exporter.Prometheus.Tests; +using OpenTelemetry.Metrics; +using OpenTelemetry.Tests; +using Xunit; +using Xunit.Abstractions; + +namespace OpenTelemetry.Exporter.Prometheus.HttpListener.Tests; + +[Collection(PromToolCollection.Name)] +public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelper outputHelper) +{ + [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] + [InlineData("")] + [InlineData("text/plain")] + [InlineData("text/plain;version=0.0.4")] + [InlineData("text/plain;version=1.0.0", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] + [InlineData("application/openmetrics-text", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] + [InlineData("application/openmetrics-text;version=0.0.4")] + [InlineData("application/openmetrics-text;version=1.0.0", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] + [InlineData("application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5,application/openmetrics-text;version=0.0.1;q=0.4,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.3,text/plain;version=0.0.4;q=0.2,/;q=0.1", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] + + public async Task Can_Scrape_Prometheus(string accept) + { + // Arrange + const string meterName = "prometheus.integration.tests"; + const string meterVersion = "1.2.3"; + + using var meter = new Meter( + meterName, + meterVersion, + [new("meter_tag", "meter-value")]); + + var counter = meter.CreateCounter("kestrel.rejected_connections", unit: "{connection}", description: "Number of connections rejected by the server."); + var upDownCounter = meter.CreateUpDownCounter("kestrel.active_connections", unit: "{connection}", description: "Number of connections that are currently active on the server."); + var histogram = meter.CreateHistogram("kestrel.connection.duration", unit: "s", description: "The duration of connections on the server."); + + var ignoredHistogram = meter.CreateHistogram("exponential_latency", unit: "ms", description: "Ignored exponential histogram."); + + meter.CreateObservableCounter( + "processed_bytes", + () => new Measurement(4, [new("source", "scheduler")]), + unit: "bytes", + description: "Background processed bytes."); + + meter.CreateObservableGauge( + "temperature", + () => new Measurement(22.5, [new("region", "eu-west-1")]), + unit: "celsius", + description: "Current temperature."); + + meter.CreateObservableUpDownCounter( + "queue_balance", + () => new Measurement(-2, [new("pool", "shared")]), + description: "Current queue balance."); + + const string KeepTag = "keep"; + + using var context = PrometheusHttpListenerTests.CreateMeterProvider(meter, configureMeterProvider: (builder) => + { + builder + .SetExemplarFilter(ExemplarFilterType.AlwaysOn) + .AddView( + counter.Name, + new MetricStreamConfiguration + { + ExemplarReservoirFactory = () => new SimpleFixedSizeExemplarReservoir(3), + TagKeys = [KeepTag], + }) + .AddView( + histogram.Name, + new ExplicitBucketHistogramConfiguration + { + Boundaries = [5, 10], + ExemplarReservoirFactory = () => new SimpleFixedSizeExemplarReservoir(3), + TagKeys = [KeepTag], + }) + .AddView( + ignoredHistogram.Name, + new Base2ExponentialBucketHistogramConfiguration()); + }); + + counter.Add(1, new(KeepTag, "value"), new("filtered", "older")); + + upDownCounter.Add(5, [new("queue", "critical")]); + upDownCounter.Add(-2, [new("queue", "critical")]); + + histogram.Record(4, new(KeepTag, "value"), new("filtered", "older")); + histogram.Record(8, new(KeepTag, "value"), new("filtered", "first")); + + ignoredHistogram.Record(42, [new("kind", "exp")]); + + WaitForNextExemplarTimestamp(); + + using var activity = new Activity("test"); + activity.Start(); + + counter.Add(2, new("keep", "value"), new("filtered", "counter-latest"), new("trace_id", "ignored-trace"), new("span_id", "ignored-span")); + histogram.Record(9, new("keep", "value"), new("filtered", "histogram-latest"), new("trace_id", "ignored-trace"), new("span_id", "ignored-span")); + + activity.Stop(); + + // Act + var actual = await fixture.CheckMetricsAsync(new(context.BaseAddress, "metrics"), accept); + + outputHelper.WriteLine($"[promtool] ExitCode: {actual.ExitCode}"); + outputHelper.WriteLine("[promtool] stdout:"); + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine(actual.Stdout); + + if (!string.IsNullOrEmpty(actual.Stderr)) + { + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine("[promtool] stderr:"); + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine(actual.Stderr); + } + + // Assert + Assert.Equal(0, actual.ExitCode); + Assert.NotEmpty(actual.Stdout); + Assert.Empty(actual.Stderr); + } + + private static void WaitForNextExemplarTimestamp() + { + var timestamp = DateTimeOffset.UtcNow; + + while (DateTimeOffset.UtcNow <= timestamp) + { + Thread.Sleep(1); + } + } +} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/prometheus.Dockerfile b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/prometheus.Dockerfile new file mode 100644 index 00000000000..76736e13d70 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/prometheus.Dockerfile @@ -0,0 +1 @@ +FROM prom/prometheus:v3.11.3@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3 diff --git a/test/Shared/ContainerFixture.cs b/test/Shared/ContainerFixture.cs new file mode 100644 index 00000000000..49a128816e6 --- /dev/null +++ b/test/Shared/ContainerFixture.cs @@ -0,0 +1,55 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using DotNet.Testcontainers.Containers; + +namespace OpenTelemetry.Tests; + +public abstract class ContainerFixture : IAsyncDisposable +{ + private bool started; + + protected abstract IContainer Container { get; } + + protected abstract string DockerfileName { get; } + + public async ValueTask DisposeAsync() + { + if (this.started) + { + await this.Container.DisposeAsync().ConfigureAwait(false); + } + + GC.SuppressFinalize(this); + } + + public async Task StartAsync() + { + if (!this.started) + { + await this.Container.StartAsync().ConfigureAwait(false); + this.started = true; + } + } + + public Uri GetBaseAddress(int port) => + new UriBuilder(Uri.UriSchemeHttp, this.Container.Hostname, this.Container.GetMappedPublicPort(port)).Uri; + + protected string GetImage() + { + var assembly = this.GetType().Assembly; + + using var stream = assembly.GetManifestResourceStream(this.DockerfileName); + +#if NET + using var reader = new StreamReader(stream!); +#else + using var reader = new StreamReader(stream); +#endif + + var raw = reader.ReadToEnd(); + + // Exclude FROM + return raw.Substring(4).Trim(); + } +} diff --git a/test/Shared/ContainerFixture{T}.cs b/test/Shared/ContainerFixture{T}.cs new file mode 100644 index 00000000000..5a9bf0ad230 --- /dev/null +++ b/test/Shared/ContainerFixture{T}.cs @@ -0,0 +1,24 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using DotNet.Testcontainers.Containers; + +namespace OpenTelemetry.Tests; + +public abstract class ContainerFixture : ContainerFixture + where T : IContainer +{ + public T TypedContainer => field ??= this.CreateContainer(); + + protected override IContainer Container => this.TypedContainer; + + public virtual async Task InitializeAsync() + { + if (DockerHelper.IsAvailable(DockerPlatform.Linux)) + { + await this.StartAsync().ConfigureAwait(false); + } + } + + protected abstract T CreateContainer(); +} diff --git a/test/Shared/DockerHelper.cs b/test/Shared/DockerHelper.cs new file mode 100644 index 00000000000..05f817f6f27 --- /dev/null +++ b/test/Shared/DockerHelper.cs @@ -0,0 +1,73 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics; +using System.Text; + +namespace OpenTelemetry.Tests; + +/// +/// Determines if a required Docker engine is available. +/// +internal static class DockerHelper +{ + /// + /// Gets whether the specified Docker platform is available. + /// + /// + /// if the specified Docker platform is available, otherwise . + /// + public static bool IsAvailable(DockerPlatform dockerPlatform) + { + const string executable = "docker"; + + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + + void AppendStdout(object sender, DataReceivedEventArgs e) + { + stdout.Append(e.Data); + } + + void AppendStderr(object sender, DataReceivedEventArgs e) + { + stderr.Append(e.Data); + } + + var processStartInfo = new ProcessStartInfo + { + FileName = executable, + Arguments = string.Join(" ", "version", "--format '{{.Server.Os}}'"), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + using var process = new Process + { + StartInfo = processStartInfo, + }; + process.OutputDataReceived += AppendStdout; + process.ErrorDataReceived += AppendStderr; + + try + { + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + } + catch (System.ComponentModel.Win32Exception) + { + // Thrown if Docker is not installed + return false; + } + finally + { + process.OutputDataReceived -= AppendStdout; + process.ErrorDataReceived -= AppendStderr; + } + + return process.ExitCode == 0 && stdout.ToString().IndexOf(dockerPlatform.ToString(), StringComparison.OrdinalIgnoreCase) > 0; + } +} diff --git a/test/Shared/DockerPlatform.cs b/test/Shared/DockerPlatform.cs new file mode 100644 index 00000000000..5e1fe4905b5 --- /dev/null +++ b/test/Shared/DockerPlatform.cs @@ -0,0 +1,17 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace OpenTelemetry.Tests; + +internal enum DockerPlatform +{ + /// + /// Docker Linux engine. + /// + Linux, + + /// + /// Docker Windows engine. + /// + Windows, +} diff --git a/test/Shared/EnabledOnDockerPlatformFactAttribute.cs b/test/Shared/EnabledOnDockerPlatformFactAttribute.cs new file mode 100644 index 00000000000..d847efc567a --- /dev/null +++ b/test/Shared/EnabledOnDockerPlatformFactAttribute.cs @@ -0,0 +1,23 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Xunit; + +namespace OpenTelemetry.Tests; + +/// +/// This skips tests if the required Docker engine is not available. +/// +internal sealed class EnabledOnDockerPlatformFactAttribute : FactAttribute +{ + /// + /// Initializes a new instance of the class. + /// + public EnabledOnDockerPlatformFactAttribute(DockerPlatform dockerPlatform) + { + if (!DockerHelper.IsAvailable(dockerPlatform)) + { + this.Skip = $"The Docker {dockerPlatform} engine is not available."; + } + } +} diff --git a/test/Shared/EnabledOnDockerPlatformTheoryAttribute.cs b/test/Shared/EnabledOnDockerPlatformTheoryAttribute.cs new file mode 100644 index 00000000000..363d5882069 --- /dev/null +++ b/test/Shared/EnabledOnDockerPlatformTheoryAttribute.cs @@ -0,0 +1,23 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Xunit; + +namespace OpenTelemetry.Tests; + +/// +/// This skips tests if the required Docker engine is not available. +/// +internal sealed class EnabledOnDockerPlatformTheoryAttribute : TheoryAttribute +{ + /// + /// Initializes a new instance of the class. + /// + public EnabledOnDockerPlatformTheoryAttribute(DockerPlatform dockerPlatform) + { + if (!DockerHelper.IsAvailable(dockerPlatform)) + { + this.Skip = $"The Docker {dockerPlatform} engine is not available."; + } + } +} diff --git a/test/Shared/XunitContainerFixture{T}.cs b/test/Shared/XunitContainerFixture{T}.cs new file mode 100644 index 00000000000..6686bc6eaf8 --- /dev/null +++ b/test/Shared/XunitContainerFixture{T}.cs @@ -0,0 +1,13 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using DotNet.Testcontainers.Containers; +using Xunit; + +namespace OpenTelemetry.Tests; + +public abstract class XunitContainerFixture : ContainerFixture, IAsyncLifetime + where T : IContainer +{ + Task IAsyncLifetime.DisposeAsync() => this.DisposeAsync().AsTask(); +} From 2c678a661f02b42a71d1fd95ae3b0340a98e23f1 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 May 2026 14:12:28 +0100 Subject: [PATCH 40/82] [Exporter.Prometheus] Fix warnings - Fix typo. - Suppress warnings. --- .../OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj | 1 + .../PromToolFixture.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj index fbc962e518d..183805cc014 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj @@ -4,6 +4,7 @@ Unit test project for Prometheus Exporter AspNetCore for OpenTelemetry $(TargetFrameworksForAspNetCoreTests) $(DefineConstants);PROMETHEUS_ASPNETCORE + $(NoWarn);CA1062;CA1515 diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolFixture.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolFixture.cs index a2165fcd975..7f713031313 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolFixture.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolFixture.cs @@ -22,7 +22,7 @@ public async Task CheckMetricsAsync( // Use wget to fetch the metrics and pipe them to promtool for validation. // The metrics text is output to a temporary file so that we can capture - // the response to print to stdout to aid with debugging if neccessary. + // the response to print to stdout to aid with debugging if necessary. string[] command = [ "sh", From 1d426b95da75bb6916d0ed172775c976b62a6738 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 May 2026 14:18:20 +0100 Subject: [PATCH 41/82] [Exporter.Prometheus] Fix build Suppress CA1019 warning. --- .../OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj index 183805cc014..a80eacdd61f 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj @@ -4,7 +4,7 @@ Unit test project for Prometheus Exporter AspNetCore for OpenTelemetry $(TargetFrameworksForAspNetCoreTests) $(DefineConstants);PROMETHEUS_ASPNETCORE - $(NoWarn);CA1062;CA1515 + $(NoWarn);CA1019;CA1062;CA1515 From b007718327d8a01200eb5247359f9380703033d1 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 May 2026 14:28:25 +0100 Subject: [PATCH 42/82] [Exporter.Prometheus] Fix tests Fix new tests when running on Linux. --- .../PromToolFixture.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolFixture.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolFixture.cs index 7f713031313..d1ef8c33b75 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolFixture.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PromToolFixture.cs @@ -8,6 +8,8 @@ namespace OpenTelemetry.Exporter.Prometheus.Tests; public sealed class PromToolFixture : PrometheusFixture { + private const string DockerInternalHost = "host.docker.internal"; + public async Task CheckMetricsAsync( Uri targetUri, string accept, @@ -17,7 +19,7 @@ public async Task CheckMetricsAsync( // avoid issues with localhost resolution inside the container var metricsUri = new UriBuilder(targetUri) { - Host = "host.docker.internal", + Host = DockerInternalHost, }; // Use wget to fetch the metrics and pipe them to promtool for validation. @@ -43,5 +45,6 @@ protected override IContainer CreateContainer() => new ContainerBuilder(this.GetImage()) .WithEntrypoint("sh", "-c") .WithCommand("sleep infinity") + .WithExtraHost(DockerInternalHost, "host-gateway") .Build(); } From 260aa7f989a342daa609c852cd724aa9be48645b Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 May 2026 14:53:34 +0100 Subject: [PATCH 43/82] [Exporter.Prometheus] Fix tests Attempt to fix test failures on Linux with the Docker internal host. --- .../PrometheusIntegrationTests.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index fe49449da7f..b8b2645f2dd 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -68,7 +68,8 @@ public async Task Can_Scrape_Prometheus(string accept) var builder = WebApplication.CreateBuilder(); // Listen on any available port - builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.WebHost.UseUrls("http://0.0.0.0:0"); + builder.WebHost.UseSetting("AllowedHosts", "*"); builder.Services .AddOpenTelemetry() @@ -111,6 +112,20 @@ public async Task Can_Scrape_Prometheus(string accept) .Select((p) => new Uri(p)) .Last(); + // Remap bind-any addresses with loopback address + baseAddress = new UriBuilder(baseAddress) + { + Host = baseAddress.Host switch + { + "0.0.0.0" => "127.0.0.1", + "[::]" => "localhost", + "::" => "localhost", + "::0" => "localhost", + "0:0:0:0:0:0:0:0" => "127.0.0.1", + _ => baseAddress.Host, + }, + }.Uri; + using (var httpClient = new HttpClient()) { _ = await httpClient.GetStringAsync(new Uri(baseAddress, "ping")); From 7dd8ebe0a7e1bdafce8398ab92298ba6b26a64f5 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 May 2026 15:11:37 +0100 Subject: [PATCH 44/82] [Exporter.Prometheus] FIx namespace Fix incorrect namespace for tests in OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests. --- .../PrometheusIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index b8b2645f2dd..08612576dc6 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -14,7 +14,7 @@ using Xunit; using Xunit.Abstractions; -namespace OpenTelemetry.Exporter.Prometheus.HttpListener.Tests; +namespace OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests; [Collection(PromToolCollection.Name)] public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelper outputHelper) From 7cdfc977a548c42f9a6c669d930b75272e119cd3 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 May 2026 15:28:09 +0100 Subject: [PATCH 45/82] [Exporter.Prometheus] Fix tests Attempt to fix HttpListener tests in CI. --- .../PrometheusIntegrationTests.cs | 74 +++++++++++++------ 1 file changed, 51 insertions(+), 23 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs index ecb0f6dc067..898d0a4989d 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs @@ -60,29 +60,57 @@ public async Task Can_Scrape_Prometheus(string accept) const string KeepTag = "keep"; - using var context = PrometheusHttpListenerTests.CreateMeterProvider(meter, configureMeterProvider: (builder) => - { - builder - .SetExemplarFilter(ExemplarFilterType.AlwaysOn) - .AddView( - counter.Name, - new MetricStreamConfiguration - { - ExemplarReservoirFactory = () => new SimpleFixedSizeExemplarReservoir(3), - TagKeys = [KeepTag], - }) - .AddView( - histogram.Name, - new ExplicitBucketHistogramConfiguration - { - Boundaries = [5, 10], - ExemplarReservoirFactory = () => new SimpleFixedSizeExemplarReservoir(3), - TagKeys = [KeepTag], - }) - .AddView( - ignoredHistogram.Name, - new Base2ExponentialBucketHistogramConfiguration()); - }); + using var context = PrometheusHttpListenerTests.CreateMeterProvider( + meter, +#if NET + configureListener: (options) => + { + int port = TcpPortProvider.GetOpenPort(); + + // On Linux we need to explicitly use the internal Docker + // host address to reach the Prometheus listener from promtool. + if (OperatingSystem.IsLinux()) + { +#pragma warning disable CS0618 // Type or member is obsolete + options.UriPrefixes = + [ + $"http://127.0.0.1:{port}", + $"http://host.docker.internal:{port}", + $"http://localhost:{port}", + ]; +#pragma warning restore CS0618 // Type or member is obsolete + } + else + { + options.Port = port; + } + + return port; + }, +#endif + configureMeterProvider: (builder) => + { + builder + .SetExemplarFilter(ExemplarFilterType.AlwaysOn) + .AddView( + counter.Name, + new MetricStreamConfiguration + { + ExemplarReservoirFactory = () => new SimpleFixedSizeExemplarReservoir(3), + TagKeys = [KeepTag], + }) + .AddView( + histogram.Name, + new ExplicitBucketHistogramConfiguration + { + Boundaries = [5, 10], + ExemplarReservoirFactory = () => new SimpleFixedSizeExemplarReservoir(3), + TagKeys = [KeepTag], + }) + .AddView( + ignoredHistogram.Name, + new Base2ExponentialBucketHistogramConfiguration()); + }); counter.Add(1, new(KeepTag, "value"), new("filtered", "older")); From b98a6440bbab47fb9d95959da677dbc47ebea6f9 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 May 2026 15:35:21 +0100 Subject: [PATCH 46/82] [Exporter.Prometheus] Fix tests Try another approach to fix the `HttpListener` tests. --- .../PrometheusIntegrationTests.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs index 898d0a4989d..248168cb666 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs @@ -72,12 +72,7 @@ public async Task Can_Scrape_Prometheus(string accept) if (OperatingSystem.IsLinux()) { #pragma warning disable CS0618 // Type or member is obsolete - options.UriPrefixes = - [ - $"http://127.0.0.1:{port}", - $"http://host.docker.internal:{port}", - $"http://localhost:{port}", - ]; + options.UriPrefixes = [$"http://*:{port}/"]; #pragma warning restore CS0618 // Type or member is obsolete } else From a65aad3806b1424075a6b64845286eee39de444f Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 May 2026 15:53:17 +0100 Subject: [PATCH 47/82] [Exporter.Prometheus] Update comment Update to match the behaviour. --- .../PrometheusIntegrationTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs index 248168cb666..4f9b56c9664 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs @@ -67,8 +67,8 @@ public async Task Can_Scrape_Prometheus(string accept) { int port = TcpPortProvider.GetOpenPort(); - // On Linux we need to explicitly use the internal Docker - // host address to reach the Prometheus listener from promtool. + // On Linux we need to bind to all available hosts to reach the + // Prometheus listener from promtool using Docker's internal host. if (OperatingSystem.IsLinux()) { #pragma warning disable CS0618 // Type or member is obsolete From 2bba11e05f30fae6a38ad4ce4021a42e4910422d Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 May 2026 17:51:21 +0100 Subject: [PATCH 48/82] [Exporter.Prometheus] Update tests Enable some tests and update the skip reason for others. --- .../PrometheusIntegrationTests.cs | 8 ++++---- .../PrometheusIntegrationTests.cs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index 08612576dc6..fcb9441b3d1 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -23,11 +23,11 @@ public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelp [InlineData("")] [InlineData("text/plain")] [InlineData("text/plain;version=0.0.4")] - [InlineData("text/plain;version=1.0.0", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] - [InlineData("application/openmetrics-text", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] + [InlineData("text/plain;version=1.0.0")] + [InlineData("application/openmetrics-text", Skip = "https://github.com/prometheus/prometheus/issues/8932")] [InlineData("application/openmetrics-text;version=0.0.4")] - [InlineData("application/openmetrics-text;version=1.0.0", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] - [InlineData("application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5,application/openmetrics-text;version=0.0.1;q=0.4,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.3,text/plain;version=0.0.4;q=0.2,/;q=0.1", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] + [InlineData("application/openmetrics-text;version=1.0.0", Skip = "https://github.com/prometheus/prometheus/issues/8932")] + [InlineData("application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5,application/openmetrics-text;version=0.0.1;q=0.4,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.3,text/plain;version=0.0.4;q=0.2,/;q=0.1", Skip = "https://github.com/prometheus/prometheus/issues/8932")] public async Task Can_Scrape_Prometheus(string accept) { diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs index 4f9b56c9664..ed3c59a0ce0 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs @@ -18,11 +18,11 @@ public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelp [InlineData("")] [InlineData("text/plain")] [InlineData("text/plain;version=0.0.4")] - [InlineData("text/plain;version=1.0.0", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] - [InlineData("application/openmetrics-text", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] + [InlineData("text/plain;version=1.0.0")] + [InlineData("application/openmetrics-text", Skip = "https://github.com/prometheus/prometheus/issues/8932")] [InlineData("application/openmetrics-text;version=0.0.4")] - [InlineData("application/openmetrics-text;version=1.0.0", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] - [InlineData("application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5,application/openmetrics-text;version=0.0.1;q=0.4,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.3,text/plain;version=0.0.4;q=0.2,/;q=0.1", Skip = "https://github.com/open-telemetry/opentelemetry-dotnet/issues/7207")] + [InlineData("application/openmetrics-text;version=1.0.0", Skip = "https://github.com/prometheus/prometheus/issues/8932")] + [InlineData("application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5,application/openmetrics-text;version=0.0.1;q=0.4,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.3,text/plain;version=0.0.4;q=0.2,/;q=0.1", Skip = "https://github.com/prometheus/prometheus/issues/8932")] public async Task Can_Scrape_Prometheus(string accept) { From 0c4a4ed40f99f1dbe2053f9f6644f40b24f125e5 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 May 2026 17:58:10 +0100 Subject: [PATCH 49/82] [Exporter.Prometheus] Address comments - Fix-up DockerHelper check. - Fix metrics not being consumed. --- .../PrometheusIntegrationTests.cs | 1 + test/Shared/DockerHelper.cs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index fcb9441b3d1..63d0e0a6b81 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -76,6 +76,7 @@ public async Task Can_Scrape_Prometheus(string accept) .WithMetrics((builder) => { builder.AddAspNetCoreInstrumentation() + .AddMeter(meter.Name) .AddPrometheusExporter() .SetExemplarFilter(ExemplarFilterType.AlwaysOn) .AddView( diff --git a/test/Shared/DockerHelper.cs b/test/Shared/DockerHelper.cs index 05f817f6f27..21d60ebcede 100644 --- a/test/Shared/DockerHelper.cs +++ b/test/Shared/DockerHelper.cs @@ -68,6 +68,7 @@ void AppendStderr(object sender, DataReceivedEventArgs e) process.ErrorDataReceived -= AppendStderr; } - return process.ExitCode == 0 && stdout.ToString().IndexOf(dockerPlatform.ToString(), StringComparison.OrdinalIgnoreCase) > 0; + return process.ExitCode == 0 && + stdout.ToString().Contains(dockerPlatform.ToString(), StringComparison.OrdinalIgnoreCase); } } From 2aba3759a70f16441017096c4f2657e7fd3ae7ba Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 3 May 2026 18:14:04 +0100 Subject: [PATCH 50/82] [Exporter.Prometheus] Fix CHANGELOGs Remove leftover merge markers. --- src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md | 2 -- src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 2c304314828..d1d45844d63 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -31,8 +31,6 @@ Notes](../../RELEASENOTES.md). selected correctly by considering whitespace and `q` weights. ([#7208](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7208)) -<<<<<<< HEAD -<<<<<<< HEAD * Emit OpenMetrics exemplars for counters and histogram buckets. ([#7222](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7222)) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index b30139537f3..efefbbeda54 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -43,8 +43,6 @@ Notes](../../RELEASENOTES.md). selected correctly by considering whitespace and `q` weights. ([#7208](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7208)) -<<<<<<< HEAD -<<<<<<< HEAD * Emit OpenMetrics exemplars for counters and histogram buckets. ([#7222](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7222)) From 5b98a4eeb8785a2f7460961e3020431a9724446f Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 4 May 2026 10:03:05 +0100 Subject: [PATCH 51/82] [Exporter.Prometheus] Fix fuzz tests Update generator for label keys. --- .../PrometheusSerializerFuzzTests.cs | 52 +++++++------------ 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs index 113ffdff7c7..16909d77699 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs @@ -88,37 +88,12 @@ private static byte[] ReferenceWriteAsciiStringNoEscape(string value) } private static byte[] ReferenceWriteLabelKey(string value) - { - var bytes = new List(value.Length + 1); - if (string.IsNullOrEmpty(value)) - { - bytes.Add((byte)'_'); - return [.. bytes]; - } - - var lastCharUnderscore = false; - foreach (var c in value) - { - if ((bytes.Count == 0 && c is >= '0' and <= '9') || - !(c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) - { - if (!lastCharUnderscore) - { - bytes.Add((byte)'_'); - lastCharUnderscore = true; - } - - continue; - } - - bytes.Add((byte)c); - lastCharUnderscore = c == '_'; - } - - return [.. bytes]; - } + => ReferenceWriteLabelKey(value, openMetricsRequested: false); private static byte[] ReferenceWriteOpenMetricsLabelKey(string value) + => ReferenceWriteLabelKey(value, openMetricsRequested: true); + + private static byte[] ReferenceWriteLabelKey(string value, bool openMetricsRequested) { var bytes = new List(value.Length + 1); if (string.IsNullOrEmpty(value)) @@ -129,10 +104,23 @@ private static byte[] ReferenceWriteOpenMetricsLabelKey(string value) var lastCharUnderscore = false; - foreach (var c in value) + for (var i = 0; i < value.Length; i++) { - if ((bytes.Count == 0 && c is >= '0' and <= '9') || - !(c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_' or ':')) + var c = value[i]; + var isAllowed = + (c is >= 'A' and <= 'Z') || + (c is >= 'a' and <= 'z') || + (c is >= '0' and <= '9') || + c == '_' || + (openMetricsRequested && c == ':'); + + if (i == 0 && c is >= '0' and <= '9') + { + bytes.Add((byte)'_'); + lastCharUnderscore = true; + } + + if (!isAllowed) { if (!lastCharUnderscore) { From 530b6c7e2eb605fb2f2307c09045133e122322dd Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 4 May 2026 16:38:46 +0100 Subject: [PATCH 52/82] [Prometheus.AspNetCore] Extend integration tests Extend the AspNetCore integration tests to include interop between ASP.NET Core and Prometheus itself to scrape metrics. --- .../PrometheusIntegrationTests.cs | 212 ++++++++++++++++-- .../PrometheusFixture.cs | 50 ++++- 2 files changed, 238 insertions(+), 24 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index 63d0e0a6b81..0db706f100b 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -3,13 +3,17 @@ using System.Diagnostics; using System.Diagnostics.Metrics; +using System.Net.Http.Json; +using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; using OpenTelemetry.Exporter.Prometheus.Tests; using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; using OpenTelemetry.Tests; using Xunit; using Xunit.Abstractions; @@ -17,8 +21,164 @@ namespace OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests; [Collection(PromToolCollection.Name)] -public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelper outputHelper) +public class PrometheusIntegrationTests(PromToolFixture promtool, ITestOutputHelper outputHelper) { + [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] + [InlineData("")] + [InlineData("OpenMetricsText0.0.1")] + [InlineData("OpenMetricsText1.0.0")] + [InlineData("PrometheusText0.0.4")] + [InlineData("PrometheusText1.0.0")] + + public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await GenerateMetricsAsync(async (baseAddress) => + { + // Arrange + var prometheus = new PrometheusFixture() + { + TargetPort = baseAddress.Port, + }; + + if (!string.IsNullOrEmpty(scrapeProtocol)) + { + prometheus.ScrapeProtocols = [scrapeProtocol]; + } + + try + { + // Act + await prometheus.StartAsync(); + + var prometheusBaseAddress = prometheus.GetBaseAddress(9090); + + await WaitForServiceDiscoveryAsync(prometheusBaseAddress); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + IReadOnlyList series = []; + + // Assert + while (!cts.IsCancellationRequested) + { + series = await WaitForMetricsSeriesAsync(prometheusBaseAddress); + + if (series.Contains("temperature_celsius")) + { + break; + } + } + + Assert.Contains("aspnetcore_memory_pool_allocated_bytes_total", series); + Assert.Contains("http_server_active_requests", series); + Assert.Contains("http_server_request_duration_seconds_bucket", series); + Assert.Contains("http_server_request_duration_seconds_count", series); + Assert.Contains("http_server_request_duration_seconds_sum", series); + Assert.Contains("kestrel_active_connections", series); + Assert.Contains("kestrel_connection_duration_seconds_bucket", series); + Assert.Contains("kestrel_connection_duration_seconds_count", series); + Assert.Contains("kestrel_connection_duration_seconds_sum", series); + Assert.Contains("processed_bytes_total", series); + Assert.Contains("queue_balance", series); + Assert.Contains("temperature_celsius", series); + } + finally + { + await prometheus.DisposeAsync(); + } + + static async Task> WaitForMetricsSeriesAsync(Uri baseAddress) + { + // See https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers + var seriesUrl = QueryHelpers.AddQueryString( + "/api/v1/series", + [ + KeyValuePair.Create("limit", "0"), + KeyValuePair.Create("match[]", "{job=\"prometheus-target\"}"), + ]); + + var seriesUri = new Uri(seriesUrl, UriKind.Relative); + + var frequency = TimeSpan.FromMilliseconds(250); + using var client = new HttpClient() { BaseAddress = baseAddress }; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + while (!cts.IsCancellationRequested) + { + try + { + using var metrics = await client.GetFromJsonAsync(seriesUri, cts.Token); + + if (metrics!.RootElement.ValueKind is JsonValueKind.Object && + metrics.RootElement.TryGetProperty("status", out var status) && + status.GetString() == "success") + { + var data = metrics.RootElement.GetProperty("data"); + + if (data.GetArrayLength() > 0) + { + var series = new HashSet(); + + foreach (var seriesElement in data.EnumerateArray()) + { + if (seriesElement.ValueKind is JsonValueKind.Object && + seriesElement.TryGetProperty("__name__", out var name)) + { + series.Add(name.GetString()!); + } + } + + return [.. series]; + } + } + } + catch (Exception) + { + await Task.Delay(frequency); + } + } + + cts.Token.ThrowIfCancellationRequested(); + return []; + } + + static async Task WaitForServiceDiscoveryAsync(Uri baseAddress) + { + // See https://prometheus.io/docs/prometheus/latest/querying/api/#targets + using var client = new HttpClient() { BaseAddress = baseAddress }; + var targetsUri = new Uri("/api/v1/targets", UriKind.Relative); + + var frequency = TimeSpan.FromMilliseconds(250); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + while (!cts.IsCancellationRequested) + { + try + { + using var targets = await client.GetFromJsonAsync(targetsUri, cts.Token); + + if (targets!.RootElement.ValueKind is JsonValueKind.Object && + targets.RootElement.TryGetProperty("status", out var status) && + status.GetString() == "success") + { + var activeTargets = targets.RootElement + .GetProperty("data") + .GetProperty("activeTargets"); + + if (activeTargets.GetArrayLength() > 0) + { + break; + } + } + } + catch (Exception) + { + await Task.Delay(frequency); + } + } + + cts.Token.ThrowIfCancellationRequested(); + } + }); + [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] [InlineData("")] [InlineData("text/plain")] @@ -29,7 +189,32 @@ public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelp [InlineData("application/openmetrics-text;version=1.0.0", Skip = "https://github.com/prometheus/prometheus/issues/8932")] [InlineData("application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5,application/openmetrics-text;version=0.0.1;q=0.4,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.3,text/plain;version=0.0.4;q=0.2,/;q=0.1", Skip = "https://github.com/prometheus/prometheus/issues/8932")] - public async Task Can_Scrape_Prometheus(string accept) + public async Task Promtool_Considers_Scrape_Response_Valid(string accept) => await GenerateMetricsAsync(async (baseAddress) => + { + // Act + var actual = await promtool.CheckMetricsAsync(new(baseAddress, "metrics"), accept); + + outputHelper.WriteLine($"[promtool] ExitCode: {actual.ExitCode}"); + outputHelper.WriteLine("[promtool] stdout:"); + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine(actual.Stdout); + + if (!string.IsNullOrEmpty(actual.Stderr)) + { + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine("[promtool] stderr:"); + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine(actual.Stderr); + } + + // Assert + Assert.Equal(0, actual.ExitCode); + Assert.NotEmpty(actual.Stdout); + Assert.Empty(actual.Stderr); + }); + + private static async Task GenerateMetricsAsync( + Func actAndAssert) { // Arrange const string meterName = "prometheus.integration.tests"; @@ -73,6 +258,7 @@ public async Task Can_Scrape_Prometheus(string accept) builder.Services .AddOpenTelemetry() + .ConfigureResource((builder) => builder.AddService("my-service", "my-namespce", "1.2.3")) .WithMetrics((builder) => { builder.AddAspNetCoreInstrumentation() @@ -152,26 +338,8 @@ public async Task Can_Scrape_Prometheus(string accept) activity.Stop(); - // Act - var actual = await fixture.CheckMetricsAsync(new(baseAddress, "metrics"), accept); - - outputHelper.WriteLine($"[promtool] ExitCode: {actual.ExitCode}"); - outputHelper.WriteLine("[promtool] stdout:"); - outputHelper.WriteLine(string.Empty); - outputHelper.WriteLine(actual.Stdout); - - if (!string.IsNullOrEmpty(actual.Stderr)) - { - outputHelper.WriteLine(string.Empty); - outputHelper.WriteLine("[promtool] stderr:"); - outputHelper.WriteLine(string.Empty); - outputHelper.WriteLine(actual.Stderr); - } - - // Assert - Assert.Equal(0, actual.ExitCode); - Assert.NotEmpty(actual.Stdout); - Assert.Empty(actual.Stderr); + // Act and Assert + await actAndAssert(baseAddress); } private static void WaitForNextExemplarTimestamp() diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs index a7e0189b76f..a09099f4552 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs @@ -9,10 +9,56 @@ namespace OpenTelemetry.Exporter.Prometheus.Tests; public class PrometheusFixture : XunitContainerFixture { + public IList? ScrapeProtocols { get; set; } + + public int? TargetPort { get; set; } + protected override string DockerfileName => "prometheus.Dockerfile"; - protected override IContainer CreateContainer() => - new ContainerBuilder(this.GetImage()) + protected override IContainer CreateContainer() + { + if (this.TargetPort is not { } targetPort) + { + throw new InvalidOperationException($"No scrape target port configured."); + } + + var prometheusConfigurationPath = Path.GetTempFileName(); + File.WriteAllText(prometheusConfigurationPath, CreatePrometheusConfiguration(ScrapeProtocols)); + + var sdPath = Path.GetTempFileName(); + File.WriteAllText(sdPath, CreateServiceDiscoveryConfiguration(targetPort)); + + return new ContainerBuilder(this.GetImage()) + .WithBindMount(prometheusConfigurationPath, "/etc/prometheus/prometheus.yml") + .WithBindMount(sdPath, "/etc/prometheus/targets/targets.json") + .WithCommand("--config.file=/etc/prometheus/prometheus.yml") + .WithPortBinding(4318) .WithPortBinding(9090) + .WithWaitStrategy(Wait.ForUnixContainer().UntilExternalTcpPortIsAvailable(4318)) + .WithWaitStrategy(Wait.ForUnixContainer().UntilExternalTcpPortIsAvailable(9090)) .Build(); + } + + private static string CreatePrometheusConfiguration(IList? scrapeProtocols) => + $""" + global: + scrape_interval: 2s + {(scrapeProtocols is not null ? $"scrape_protocols: [\"{string.Join("\", \"", scrapeProtocols)}\"]" : string.Empty)} + scrape_configs: + - job_name: "prometheus-target" + file_sd_configs: + - files: + - /etc/prometheus/targets/targets.json + refresh_interval: 1s + """; + + private static string CreateServiceDiscoveryConfiguration(int port) => + $$""" + [ + { + "labels": { "job": "prometheus-target" }, + "targets": ["host.docker.internal:{{port}}"] + } + ] + """; } From b7052a07e3ae5331e17c930914aa60de7184907b Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 4 May 2026 16:41:27 +0100 Subject: [PATCH 53/82] [Prometheus.AspNetCore] Fix warnings Fix two code analysis warnings. --- .../PrometheusIntegrationTests.cs | 2 +- .../PrometheusFixture.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index 0db706f100b..4eda8a51e4a 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -40,7 +40,7 @@ public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await if (!string.IsNullOrEmpty(scrapeProtocol)) { - prometheus.ScrapeProtocols = [scrapeProtocol]; + prometheus.ScrapeProtocols.Add(scrapeProtocol); } try diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs index a09099f4552..06c9accded7 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs @@ -9,7 +9,7 @@ namespace OpenTelemetry.Exporter.Prometheus.Tests; public class PrometheusFixture : XunitContainerFixture { - public IList? ScrapeProtocols { get; set; } + public IList ScrapeProtocols { get; } = []; public int? TargetPort { get; set; } @@ -23,7 +23,7 @@ protected override IContainer CreateContainer() } var prometheusConfigurationPath = Path.GetTempFileName(); - File.WriteAllText(prometheusConfigurationPath, CreatePrometheusConfiguration(ScrapeProtocols)); + File.WriteAllText(prometheusConfigurationPath, CreatePrometheusConfiguration(this.ScrapeProtocols)); var sdPath = Path.GetTempFileName(); File.WriteAllText(sdPath, CreateServiceDiscoveryConfiguration(targetPort)); @@ -43,7 +43,7 @@ private static string CreatePrometheusConfiguration(IList? scrapeProtoco $""" global: scrape_interval: 2s - {(scrapeProtocols is not null ? $"scrape_protocols: [\"{string.Join("\", \"", scrapeProtocols)}\"]" : string.Empty)} + {(scrapeProtocols.Count > 0 ? $"scrape_protocols: [\"{string.Join("\", \"", scrapeProtocols)}\"]" : string.Empty)} scrape_configs: - job_name: "prometheus-target" file_sd_configs: From e143acf621856e1a88fb5d0d00547d404e99e85b Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Mon, 4 May 2026 17:56:57 +0100 Subject: [PATCH 54/82] [Prometheus.AspNetCore] Fix typo Fix typo. --- .../PrometheusIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index 4eda8a51e4a..b2789735e24 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -258,7 +258,7 @@ private static async Task GenerateMetricsAsync( builder.Services .AddOpenTelemetry() - .ConfigureResource((builder) => builder.AddService("my-service", "my-namespce", "1.2.3")) + .ConfigureResource((builder) => builder.AddService("my-service", "my-namespace", "1.2.3")) .WithMetrics((builder) => { builder.AddAspNetCoreInstrumentation() From e447fa3e5660b25c3058b228214d6c4f7c7e5ca0 Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Mon, 4 May 2026 18:00:26 +0100 Subject: [PATCH 55/82] [Prometheus.AspNetCore] Fix build Check for null. --- .../PrometheusFixture.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs index 06c9accded7..888ecb45703 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs @@ -43,7 +43,7 @@ private static string CreatePrometheusConfiguration(IList? scrapeProtoco $""" global: scrape_interval: 2s - {(scrapeProtocols.Count > 0 ? $"scrape_protocols: [\"{string.Join("\", \"", scrapeProtocols)}\"]" : string.Empty)} + {(scrapeProtocols?.Count > 0 ? $"scrape_protocols: [\"{string.Join("\", \"", scrapeProtocols)}\"]" : string.Empty)} scrape_configs: - job_name: "prometheus-target" file_sd_configs: From 3c9ac8110c5bb4d46c7aceb0c8b83a60d87607a3 Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 4 May 2026 20:47:30 +0100 Subject: [PATCH 56/82] [Prometheus.AspNetCore] Add extra host Add extra host for the Docker host gateway when running on Linux. --- .../PrometheusIntegrationTests.cs | 5 ++++- .../PrometheusFixture.cs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index b2789735e24..a76a96ff545 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -67,7 +67,6 @@ public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await } } - Assert.Contains("aspnetcore_memory_pool_allocated_bytes_total", series); Assert.Contains("http_server_active_requests", series); Assert.Contains("http_server_request_duration_seconds_bucket", series); Assert.Contains("http_server_request_duration_seconds_count", series); @@ -79,6 +78,10 @@ public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await Assert.Contains("processed_bytes_total", series); Assert.Contains("queue_balance", series); Assert.Contains("temperature_celsius", series); + +#if NET10_0_OR_GREATER + Assert.Contains("aspnetcore_memory_pool_allocated_bytes_total", series); +#endif } finally { diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs index 888ecb45703..cbf85a22364 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs @@ -9,6 +9,8 @@ namespace OpenTelemetry.Exporter.Prometheus.Tests; public class PrometheusFixture : XunitContainerFixture { + private const string DockerInternalHost = "host.docker.internal"; + public IList ScrapeProtocols { get; } = []; public int? TargetPort { get; set; } @@ -32,6 +34,7 @@ protected override IContainer CreateContainer() .WithBindMount(prometheusConfigurationPath, "/etc/prometheus/prometheus.yml") .WithBindMount(sdPath, "/etc/prometheus/targets/targets.json") .WithCommand("--config.file=/etc/prometheus/prometheus.yml") + .WithExtraHost(DockerInternalHost, "host-gateway") .WithPortBinding(4318) .WithPortBinding(9090) .WithWaitStrategy(Wait.ForUnixContainer().UntilExternalTcpPortIsAvailable(4318)) @@ -57,7 +60,7 @@ private static string CreateServiceDiscoveryConfiguration(int port) => [ { "labels": { "job": "prometheus-target" }, - "targets": ["host.docker.internal:{{port}}"] + "targets": ["{{DockerInternalHost}}:{{port}}"] } ] """; From f655912c684517e6f59b2476c1b54cf86acc6dfb Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 4 May 2026 21:28:17 +0100 Subject: [PATCH 57/82] [Prometheus.AspNetCore] Fix-up asserts Improve assertion messages. --- .../PrometheusIntegrationTests.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index a76a96ff545..141219ab9ab 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -101,8 +101,10 @@ static async Task> WaitForMetricsSeriesAsync(Uri baseAddre var seriesUri = new Uri(seriesUrl, UriKind.Relative); var frequency = TimeSpan.FromMilliseconds(250); + var timeout = TimeSpan.FromSeconds(15); + using var client = new HttpClient() { BaseAddress = baseAddress }; - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var cts = new CancellationTokenSource(timeout); while (!cts.IsCancellationRequested) { @@ -139,7 +141,7 @@ static async Task> WaitForMetricsSeriesAsync(Uri baseAddre } } - cts.Token.ThrowIfCancellationRequested(); + Assert.Fail($"Timed out after {timeout} waiting for metric series."); return []; } @@ -150,7 +152,9 @@ static async Task WaitForServiceDiscoveryAsync(Uri baseAddress) var targetsUri = new Uri("/api/v1/targets", UriKind.Relative); var frequency = TimeSpan.FromMilliseconds(250); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var timeout = TimeSpan.FromSeconds(15); + + using var cts = new CancellationTokenSource(timeout); while (!cts.IsCancellationRequested) { @@ -178,7 +182,7 @@ static async Task WaitForServiceDiscoveryAsync(Uri baseAddress) } } - cts.Token.ThrowIfCancellationRequested(); + Assert.Fail($"Timed out after {timeout} waiting for service discovery active targets."); } }); From 23fb958b384b13fe11e107f29f83eb396c9dd0e3 Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 4 May 2026 21:34:05 +0100 Subject: [PATCH 58/82] [Prometheus.AspNetCore] Output logs Output Prometheus logs to diagnose failure. --- .../PrometheusIntegrationTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index 141219ab9ab..1a92fa7f172 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -85,6 +85,11 @@ public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await } finally { + (var stdout, var stderr) = await prometheus.TypedContainer.GetLogsAsync(); + + outputHelper.WriteLine($"[prometheus] [stdout]: {stdout}"); + outputHelper.WriteLine($"[prometheus] [stderr]: {stderr}"); + await prometheus.DisposeAsync(); } @@ -172,7 +177,7 @@ static async Task WaitForServiceDiscoveryAsync(Uri baseAddress) if (activeTargets.GetArrayLength() > 0) { - break; + return; } } } From a133dcab9272749ce6411e1f501535ea90602104 Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 4 May 2026 21:45:28 +0100 Subject: [PATCH 59/82] [Prometheus.AspNetCore] Set file permissions Set bind-mount file permissions on Linux. --- .../PrometheusFixture.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs index cbf85a22364..6dcce5191f8 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs @@ -27,12 +27,21 @@ protected override IContainer CreateContainer() var prometheusConfigurationPath = Path.GetTempFileName(); File.WriteAllText(prometheusConfigurationPath, CreatePrometheusConfiguration(this.ScrapeProtocols)); - var sdPath = Path.GetTempFileName(); - File.WriteAllText(sdPath, CreateServiceDiscoveryConfiguration(targetPort)); + var serviceDiscoveryTargetsPath = Path.GetTempFileName(); + File.WriteAllText(serviceDiscoveryTargetsPath, CreateServiceDiscoveryConfiguration(targetPort)); + +#if NET + if (OperatingSystem.IsLinux()) + { + var mode = UnixFileMode.UserRead | UnixFileMode.GroupRead | UnixFileMode.OtherRead; + File.SetUnixFileMode(prometheusConfigurationPath, mode); + File.SetUnixFileMode(serviceDiscoveryTargetsPath, mode); + } +#endif return new ContainerBuilder(this.GetImage()) .WithBindMount(prometheusConfigurationPath, "/etc/prometheus/prometheus.yml") - .WithBindMount(sdPath, "/etc/prometheus/targets/targets.json") + .WithBindMount(serviceDiscoveryTargetsPath, "/etc/prometheus/targets/targets.json") .WithCommand("--config.file=/etc/prometheus/prometheus.yml") .WithExtraHost(DockerInternalHost, "host-gateway") .WithPortBinding(4318) From 019a8267afbd55c71eed16af2edb290edc0bfd95 Mon Sep 17 00:00:00 2001 From: martincostello Date: Tue, 5 May 2026 16:32:20 +0100 Subject: [PATCH 60/82] [Prometheus.AspNetCore] Extend integration tests Extend the ASP.NET Core integration tests to include interop between ASP.NET Core and Prometheus itself to scrape metrics. --- .../PrometheusIntegrationTests.cs | 224 ++++++++++++++++-- .../PrometheusFixture.cs | 62 ++++- 2 files changed, 262 insertions(+), 24 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index 63d0e0a6b81..1a92fa7f172 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -3,13 +3,17 @@ using System.Diagnostics; using System.Diagnostics.Metrics; +using System.Net.Http.Json; +using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; using OpenTelemetry.Exporter.Prometheus.Tests; using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; using OpenTelemetry.Tests; using Xunit; using Xunit.Abstractions; @@ -17,8 +21,176 @@ namespace OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests; [Collection(PromToolCollection.Name)] -public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelper outputHelper) +public class PrometheusIntegrationTests(PromToolFixture promtool, ITestOutputHelper outputHelper) { + [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] + [InlineData("")] + [InlineData("OpenMetricsText0.0.1")] + [InlineData("OpenMetricsText1.0.0")] + [InlineData("PrometheusText0.0.4")] + [InlineData("PrometheusText1.0.0")] + + public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await GenerateMetricsAsync(async (baseAddress) => + { + // Arrange + var prometheus = new PrometheusFixture() + { + TargetPort = baseAddress.Port, + }; + + if (!string.IsNullOrEmpty(scrapeProtocol)) + { + prometheus.ScrapeProtocols.Add(scrapeProtocol); + } + + try + { + // Act + await prometheus.StartAsync(); + + var prometheusBaseAddress = prometheus.GetBaseAddress(9090); + + await WaitForServiceDiscoveryAsync(prometheusBaseAddress); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + IReadOnlyList series = []; + + // Assert + while (!cts.IsCancellationRequested) + { + series = await WaitForMetricsSeriesAsync(prometheusBaseAddress); + + if (series.Contains("temperature_celsius")) + { + break; + } + } + + Assert.Contains("http_server_active_requests", series); + Assert.Contains("http_server_request_duration_seconds_bucket", series); + Assert.Contains("http_server_request_duration_seconds_count", series); + Assert.Contains("http_server_request_duration_seconds_sum", series); + Assert.Contains("kestrel_active_connections", series); + Assert.Contains("kestrel_connection_duration_seconds_bucket", series); + Assert.Contains("kestrel_connection_duration_seconds_count", series); + Assert.Contains("kestrel_connection_duration_seconds_sum", series); + Assert.Contains("processed_bytes_total", series); + Assert.Contains("queue_balance", series); + Assert.Contains("temperature_celsius", series); + +#if NET10_0_OR_GREATER + Assert.Contains("aspnetcore_memory_pool_allocated_bytes_total", series); +#endif + } + finally + { + (var stdout, var stderr) = await prometheus.TypedContainer.GetLogsAsync(); + + outputHelper.WriteLine($"[prometheus] [stdout]: {stdout}"); + outputHelper.WriteLine($"[prometheus] [stderr]: {stderr}"); + + await prometheus.DisposeAsync(); + } + + static async Task> WaitForMetricsSeriesAsync(Uri baseAddress) + { + // See https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers + var seriesUrl = QueryHelpers.AddQueryString( + "/api/v1/series", + [ + KeyValuePair.Create("limit", "0"), + KeyValuePair.Create("match[]", "{job=\"prometheus-target\"}"), + ]); + + var seriesUri = new Uri(seriesUrl, UriKind.Relative); + + var frequency = TimeSpan.FromMilliseconds(250); + var timeout = TimeSpan.FromSeconds(15); + + using var client = new HttpClient() { BaseAddress = baseAddress }; + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + try + { + using var metrics = await client.GetFromJsonAsync(seriesUri, cts.Token); + + if (metrics!.RootElement.ValueKind is JsonValueKind.Object && + metrics.RootElement.TryGetProperty("status", out var status) && + status.GetString() == "success") + { + var data = metrics.RootElement.GetProperty("data"); + + if (data.GetArrayLength() > 0) + { + var series = new HashSet(); + + foreach (var seriesElement in data.EnumerateArray()) + { + if (seriesElement.ValueKind is JsonValueKind.Object && + seriesElement.TryGetProperty("__name__", out var name)) + { + series.Add(name.GetString()!); + } + } + + return [.. series]; + } + } + } + catch (Exception) + { + await Task.Delay(frequency); + } + } + + Assert.Fail($"Timed out after {timeout} waiting for metric series."); + return []; + } + + static async Task WaitForServiceDiscoveryAsync(Uri baseAddress) + { + // See https://prometheus.io/docs/prometheus/latest/querying/api/#targets + using var client = new HttpClient() { BaseAddress = baseAddress }; + var targetsUri = new Uri("/api/v1/targets", UriKind.Relative); + + var frequency = TimeSpan.FromMilliseconds(250); + var timeout = TimeSpan.FromSeconds(15); + + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + try + { + using var targets = await client.GetFromJsonAsync(targetsUri, cts.Token); + + if (targets!.RootElement.ValueKind is JsonValueKind.Object && + targets.RootElement.TryGetProperty("status", out var status) && + status.GetString() == "success") + { + var activeTargets = targets.RootElement + .GetProperty("data") + .GetProperty("activeTargets"); + + if (activeTargets.GetArrayLength() > 0) + { + return; + } + } + } + catch (Exception) + { + await Task.Delay(frequency); + } + } + + Assert.Fail($"Timed out after {timeout} waiting for service discovery active targets."); + } + }); + [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] [InlineData("")] [InlineData("text/plain")] @@ -29,7 +201,32 @@ public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelp [InlineData("application/openmetrics-text;version=1.0.0", Skip = "https://github.com/prometheus/prometheus/issues/8932")] [InlineData("application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5,application/openmetrics-text;version=0.0.1;q=0.4,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.3,text/plain;version=0.0.4;q=0.2,/;q=0.1", Skip = "https://github.com/prometheus/prometheus/issues/8932")] - public async Task Can_Scrape_Prometheus(string accept) + public async Task Promtool_Considers_Scrape_Response_Valid(string accept) => await GenerateMetricsAsync(async (baseAddress) => + { + // Act + var actual = await promtool.CheckMetricsAsync(new(baseAddress, "metrics"), accept); + + outputHelper.WriteLine($"[promtool] ExitCode: {actual.ExitCode}"); + outputHelper.WriteLine("[promtool] stdout:"); + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine(actual.Stdout); + + if (!string.IsNullOrEmpty(actual.Stderr)) + { + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine("[promtool] stderr:"); + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine(actual.Stderr); + } + + // Assert + Assert.Equal(0, actual.ExitCode); + Assert.NotEmpty(actual.Stdout); + Assert.Empty(actual.Stderr); + }); + + private static async Task GenerateMetricsAsync( + Func actAndAssert) { // Arrange const string meterName = "prometheus.integration.tests"; @@ -73,6 +270,7 @@ public async Task Can_Scrape_Prometheus(string accept) builder.Services .AddOpenTelemetry() + .ConfigureResource((builder) => builder.AddService("my-service", "my-namespace", "1.2.3")) .WithMetrics((builder) => { builder.AddAspNetCoreInstrumentation() @@ -152,26 +350,8 @@ public async Task Can_Scrape_Prometheus(string accept) activity.Stop(); - // Act - var actual = await fixture.CheckMetricsAsync(new(baseAddress, "metrics"), accept); - - outputHelper.WriteLine($"[promtool] ExitCode: {actual.ExitCode}"); - outputHelper.WriteLine("[promtool] stdout:"); - outputHelper.WriteLine(string.Empty); - outputHelper.WriteLine(actual.Stdout); - - if (!string.IsNullOrEmpty(actual.Stderr)) - { - outputHelper.WriteLine(string.Empty); - outputHelper.WriteLine("[promtool] stderr:"); - outputHelper.WriteLine(string.Empty); - outputHelper.WriteLine(actual.Stderr); - } - - // Assert - Assert.Equal(0, actual.ExitCode); - Assert.NotEmpty(actual.Stdout); - Assert.Empty(actual.Stderr); + // Act and Assert + await actAndAssert(baseAddress); } private static void WaitForNextExemplarTimestamp() diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs index a7e0189b76f..6dcce5191f8 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs @@ -9,10 +9,68 @@ namespace OpenTelemetry.Exporter.Prometheus.Tests; public class PrometheusFixture : XunitContainerFixture { + private const string DockerInternalHost = "host.docker.internal"; + + public IList ScrapeProtocols { get; } = []; + + public int? TargetPort { get; set; } + protected override string DockerfileName => "prometheus.Dockerfile"; - protected override IContainer CreateContainer() => - new ContainerBuilder(this.GetImage()) + protected override IContainer CreateContainer() + { + if (this.TargetPort is not { } targetPort) + { + throw new InvalidOperationException($"No scrape target port configured."); + } + + var prometheusConfigurationPath = Path.GetTempFileName(); + File.WriteAllText(prometheusConfigurationPath, CreatePrometheusConfiguration(this.ScrapeProtocols)); + + var serviceDiscoveryTargetsPath = Path.GetTempFileName(); + File.WriteAllText(serviceDiscoveryTargetsPath, CreateServiceDiscoveryConfiguration(targetPort)); + +#if NET + if (OperatingSystem.IsLinux()) + { + var mode = UnixFileMode.UserRead | UnixFileMode.GroupRead | UnixFileMode.OtherRead; + File.SetUnixFileMode(prometheusConfigurationPath, mode); + File.SetUnixFileMode(serviceDiscoveryTargetsPath, mode); + } +#endif + + return new ContainerBuilder(this.GetImage()) + .WithBindMount(prometheusConfigurationPath, "/etc/prometheus/prometheus.yml") + .WithBindMount(serviceDiscoveryTargetsPath, "/etc/prometheus/targets/targets.json") + .WithCommand("--config.file=/etc/prometheus/prometheus.yml") + .WithExtraHost(DockerInternalHost, "host-gateway") + .WithPortBinding(4318) .WithPortBinding(9090) + .WithWaitStrategy(Wait.ForUnixContainer().UntilExternalTcpPortIsAvailable(4318)) + .WithWaitStrategy(Wait.ForUnixContainer().UntilExternalTcpPortIsAvailable(9090)) .Build(); + } + + private static string CreatePrometheusConfiguration(IList? scrapeProtocols) => + $""" + global: + scrape_interval: 2s + {(scrapeProtocols?.Count > 0 ? $"scrape_protocols: [\"{string.Join("\", \"", scrapeProtocols)}\"]" : string.Empty)} + scrape_configs: + - job_name: "prometheus-target" + file_sd_configs: + - files: + - /etc/prometheus/targets/targets.json + refresh_interval: 1s + """; + + private static string CreateServiceDiscoveryConfiguration(int port) => + $$""" + [ + { + "labels": { "job": "prometheus-target" }, + "targets": ["{{DockerInternalHost}}:{{port}}"] + } + ] + """; } From 0f018e2ee9b0b99d56cfff14ea025b719a0e1782 Mon Sep 17 00:00:00 2001 From: martincostello Date: Tue, 5 May 2026 17:30:46 +0100 Subject: [PATCH 61/82] [Exporter.Prometheus] Observe request timeout Observe client requests to abort scrape request processing, including via `X-Prometheus-Scrape-Timeout-Seconds`, and respond with an HTTP 408. --- .../CHANGELOG.md | 4 ++ .../PrometheusExporterMiddleware.cs | 27 ++++++-- .../CHANGELOG.md | 4 ++ .../Internal/PrometheusExporterEventSource.cs | 4 ++ .../PrometheusHttpListener.cs | 25 +++++++- .../PrometheusExporterMiddlewareTests.cs | 61 +++++++++++++++++++ .../PrometheusHttpListenerTests.cs | 39 ++++++++++++ 7 files changed, 155 insertions(+), 9 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index c40c52b79a2..11073b634f1 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -50,6 +50,10 @@ Notes](../../RELEASENOTES.md). thresholds are present. ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221)) +* Abort scrape request processing if request exceeds the value specified by the + `X-Prometheus-Scrape-Timeout-Seconds` HTTP request header. + ([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/issues/TODO)) + ## 1.15.3-beta.1 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs index 694f4de93f1..21a764fa833 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using System.Diagnostics; +using System.Globalization; using Microsoft.AspNetCore.Http; using Microsoft.Net.Http.Headers; using OpenTelemetry.Exporter.Prometheus; @@ -49,11 +50,6 @@ internal PrometheusExporterMiddleware(PrometheusExporter exporter) this.exporter = exporter; } - /// - /// Invoke. - /// - /// context. - /// Task. public async Task InvokeAsync(HttpContext httpContext) { Debug.Assert(httpContext != null, "httpContext should not be null"); @@ -62,11 +58,25 @@ public async Task InvokeAsync(HttpContext httpContext) try { + using var requestCancelled = new CancellationTokenSource(); + + int scrapeTimeoutSeconds = -1; + if (httpContext.Request.Headers.TryGetValue("X-Prometheus-Scrape-Timeout-Seconds", out var value) && + int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out scrapeTimeoutSeconds) && + scrapeTimeoutSeconds > 0) + { + requestCancelled.CancelAfter(TimeSpan.FromSeconds(scrapeTimeoutSeconds)); + } + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(requestCancelled.Token, httpContext.RequestAborted); + var openMetricsRequested = AcceptsOpenMetrics(httpContext.Request); var collectionResponse = await this.exporter.CollectionManager.EnterCollect(openMetricsRequested).ConfigureAwait(false); try { + linkedCts.Token.ThrowIfCancellationRequested(); + var dataView = openMetricsRequested ? collectionResponse.OpenMetricsView : collectionResponse.PlainTextView; response.StatusCode = StatusCodes.Status200OK; @@ -79,7 +89,7 @@ public async Task InvokeAsync(HttpContext httpContext) ? OpenMetricsContentType : "text/plain; charset=utf-8; version=0.0.4"; - await response.Body.WriteAsync(dataView.Array.AsMemory(0, dataView.Count)).ConfigureAwait(false); + await response.Body.WriteAsync(dataView.Array.AsMemory(0, dataView.Count), linkedCts.Token).ConfigureAwait(false); } else { @@ -87,6 +97,11 @@ public async Task InvokeAsync(HttpContext httpContext) PrometheusExporterEventSource.Log.NoMetrics(); } } + catch (OperationCanceledException) when (linkedCts.Token.IsCancellationRequested) + { + PrometheusExporterEventSource.Log.ScrapeTimedOut(scrapeTimeoutSeconds); + response.StatusCode = StatusCodes.Status408RequestTimeout; + } finally { this.exporter.CollectionManager.ExitCollect(); diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index 20d62fc4ddd..b6561433960 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -67,6 +67,10 @@ Notes](../../RELEASENOTES.md). thresholds are present. ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221)) +* Abort scrape request processing if request exceeds the value specified by the + `X-Prometheus-Scrape-Timeout-Seconds` HTTP request header. + ([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/issues/TODO)) + ## 1.15.3-beta.1 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs index 0ecbdb29ee3..64a9e979c1d 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporterEventSource.cs @@ -85,4 +85,8 @@ public void ConflictingHelp(string metricName, string firstHelp, string conflict [Event(8, Message = "Dropping duplicate UNIT metadata for metric family '{0}' because values '{1}' and '{2}' conflict.", Level = EventLevel.Warning)] public void ConflictingUnit(string metricName, string firstUnit, string conflictingUnit) => this.WriteEvent(8, metricName, firstUnit, conflictingUnit); + + [Event(9, Message = "Metrics scrape request timed out after {0} seconds.", Level = EventLevel.Warning)] + public void ScrapeTimedOut(int scrapeTimeoutSeconds) + => this.WriteEvent(9, scrapeTimeoutSeconds); } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs index d70b64fed01..a3b0b0899bd 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs @@ -1,6 +1,7 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +using System.Globalization; using System.Net; using OpenTelemetry.Exporter.Prometheus; using OpenTelemetry.Internal; @@ -258,12 +259,24 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation try { + using var requestCancelled = new CancellationTokenSource(); + + int scrapeTimeoutSeconds = -1; + if (context.Request.Headers["X-Prometheus-Scrape-Timeout-Seconds"] is { Length: > 0 } value && + int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out scrapeTimeoutSeconds) && + scrapeTimeoutSeconds > 0) + { + requestCancelled.CancelAfter(TimeSpan.FromSeconds(scrapeTimeoutSeconds)); + } + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(requestCancelled.Token, cancellationToken); + var openMetricsRequested = AcceptsOpenMetrics(context.Request); var collectionResponse = await this.exporter.CollectionManager.EnterCollect(openMetricsRequested).ConfigureAwait(false); try { - cancellationToken.ThrowIfCancellationRequested(); + linkedCts.Token.ThrowIfCancellationRequested(); context.Response.Headers.Add("Server", string.Empty); @@ -278,9 +291,9 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation : "text/plain; charset=utf-8; version=0.0.4"; #if NET - await context.Response.OutputStream.WriteAsync(dataView.Array.AsMemory(0, dataView.Count), cancellationToken).ConfigureAwait(false); + await context.Response.OutputStream.WriteAsync(dataView.Array.AsMemory(0, dataView.Count), linkedCts.Token).ConfigureAwait(false); #else - await context.Response.OutputStream.WriteAsync(dataView.Array, 0, dataView.Count, cancellationToken).ConfigureAwait(false); + await context.Response.OutputStream.WriteAsync(dataView.Array, 0, dataView.Count, linkedCts.Token).ConfigureAwait(false); #endif } else @@ -290,6 +303,12 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation PrometheusExporterEventSource.Log.NoMetrics(); } } + catch (OperationCanceledException) when (requestCancelled.Token.IsCancellationRequested) + { + PrometheusExporterEventSource.Log.ScrapeTimedOut(scrapeTimeoutSeconds); + context.Response.StatusCode = 408; + context.Response.ContentLength64 = 0; + } finally { this.exporter.CollectionManager.ExitCollect(); diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs index d0473cb9961..93aa4116baa 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs @@ -378,6 +378,67 @@ public async Task PrometheusExporterMiddlewareInvokeAsync_WhenExceptionOccursAft Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); } + [Fact] + public async Task PrometheusExporterMiddlewareInvokeAsync_WhenRequest_TimesOut_Returns408() + { + using var exporter = new PrometheusExporter(new PrometheusExporterOptions()); + exporter.Collect = _ => true; + var middleware = new PrometheusExporterMiddleware(exporter); + + var context = new DefaultHttpContext() + { + RequestAborted = new CancellationToken(canceled: true), + }; + + await middleware.InvokeAsync(context); + + Assert.Equal(StatusCodes.Status408RequestTimeout, context.Response.StatusCode); + } + + [Fact] + public async Task PrometheusExporterMiddlewareInvokeAsync_WhenRequestDeadlineExceeded_Returns408() + { + using var exporter = new PrometheusExporter(new PrometheusExporterOptions()); + + exporter.Collect = _ => + { + Thread.Sleep(TimeSpan.FromSeconds(2)); + return true; + }; + + var middleware = new PrometheusExporterMiddleware(exporter); + + var context = new DefaultHttpContext(); + + context.Request.Headers.Append("X-Prometheus-Scrape-Timeout-Seconds", "1"); + + await middleware.InvokeAsync(context); + + Assert.Equal(StatusCodes.Status408RequestTimeout, context.Response.StatusCode); + } + + [Theory] + [InlineData("-1")] + [InlineData("0")] + [InlineData("0.9")] + [InlineData("1.1")] + [InlineData("foo")] + public async Task PrometheusExporterMiddlewareInvokeAsync_WhenRequestDeadlineInvalid_Returns200(string scrapeTimeoutSeconds) + { + using var exporter = new PrometheusExporter(new PrometheusExporterOptions()); + exporter.Collect = _ => true; + + var middleware = new PrometheusExporterMiddleware(exporter); + + var context = new DefaultHttpContext(); + + context.Request.Headers.Append("X-Prometheus-Scrape-Timeout-Seconds", scrapeTimeoutSeconds); + + await middleware.InvokeAsync(context); + + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + private static async Task RunPrometheusExporterMiddlewareIntegrationTestWithBothFormats(KeyValuePair[]? meterTags = null) { using var host = await StartTestHostAsync( diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs index ad4114be53e..b35b3612fae 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs @@ -464,6 +464,45 @@ public async Task PrometheusHttpListenerHandlesConcurrentScrapes() } } + [Fact] + public async Task WhenRequestDeadlineExceeded_Returns408() + { + using var context = CreateListener(); + + context.Exporter.Collect = _ => + { + Thread.Sleep(TimeSpan.FromSeconds(2)); + return true; + }; + + using var client = new HttpClient { BaseAddress = context.BaseAddress }; + client.DefaultRequestHeaders.Add("X-Prometheus-Scrape-Timeout-Seconds", "1"); + + using var response = await client.GetAsync(new Uri("metrics", UriKind.Relative)); + + Assert.Equal(HttpStatusCode.RequestTimeout, response.StatusCode); + } + + [Theory] + [InlineData("-1")] + [InlineData("0")] + [InlineData("0.9")] + [InlineData("1.1")] + [InlineData("foo")] + public async Task WhenRequestDeadlineInvalid_Returns200(string scrapeTimeoutSeconds) + { + using var meter = new Meter(MeterName, MeterVersion); + + using var context = CreateMeterProvider(meter); + + using var client = new HttpClient { BaseAddress = context.BaseAddress }; + client.DefaultRequestHeaders.Add("X-Prometheus-Scrape-Timeout-Seconds", scrapeTimeoutSeconds); + + using var response = await client.GetAsync(new Uri("metrics", UriKind.Relative)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + internal static MeterProviderTestContext CreateMeterProvider( Meter meter, Func? configureListener = null, From 9bdc57aedee8f5730159cbf126c7bc8f48cb03ec Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Tue, 5 May 2026 17:35:36 +0100 Subject: [PATCH 62/82] [Exporter.Prometheus] Update CHANGELOGs Add PR number. --- src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md | 2 +- src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 11073b634f1..7a268bfbf87 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -52,7 +52,7 @@ Notes](../../RELEASENOTES.md). * Abort scrape request processing if request exceeds the value specified by the `X-Prometheus-Scrape-Timeout-Seconds` HTTP request header. - ([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/issues/TODO)) + ([#7252](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7252)) ## 1.15.3-beta.1 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index b6561433960..1c049300e19 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -69,7 +69,7 @@ Notes](../../RELEASENOTES.md). * Abort scrape request processing if request exceeds the value specified by the `X-Prometheus-Scrape-Timeout-Seconds` HTTP request header. - ([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/issues/TODO)) + ([#7252](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7252)) ## 1.15.3-beta.1 From 83a2c13da7e24bd53bc9c0861f3746ae3859c92b Mon Sep 17 00:00:00 2001 From: martincostello Date: Tue, 5 May 2026 17:53:33 +0100 Subject: [PATCH 63/82] [Prometheus.AspNetCore] Address review comments - Log exceptions calling Prometheus. - Use top-level cancellation instead of nested. - Delete temporary configuration files. --- .../PrometheusIntegrationTests.cs | 59 ++++++++++++------- .../PrometheusFixture.cs | 25 ++++++++ test/Shared/ContainerFixture.cs | 2 +- 3 files changed, 65 insertions(+), 21 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index 1a92fa7f172..bb4ed5e12a0 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -50,16 +50,16 @@ public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await var prometheusBaseAddress = prometheus.GetBaseAddress(9090); - await WaitForServiceDiscoveryAsync(prometheusBaseAddress); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await WaitForServiceDiscoveryAsync(prometheusBaseAddress, outputHelper, cts.Token); IReadOnlyList series = []; // Assert while (!cts.IsCancellationRequested) { - series = await WaitForMetricsSeriesAsync(prometheusBaseAddress); + series = await WaitForMetricsSeriesAsync(prometheusBaseAddress, outputHelper, cts.Token); if (series.Contains("temperature_celsius")) { @@ -93,7 +93,10 @@ public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await await prometheus.DisposeAsync(); } - static async Task> WaitForMetricsSeriesAsync(Uri baseAddress) + static async Task> WaitForMetricsSeriesAsync( + Uri baseAddress, + ITestOutputHelper outputHelper, + CancellationToken cancellationToken) { // See https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers var seriesUrl = QueryHelpers.AddQueryString( @@ -106,16 +109,14 @@ static async Task> WaitForMetricsSeriesAsync(Uri baseAddre var seriesUri = new Uri(seriesUrl, UriKind.Relative); var frequency = TimeSpan.FromMilliseconds(250); - var timeout = TimeSpan.FromSeconds(15); using var client = new HttpClient() { BaseAddress = baseAddress }; - using var cts = new CancellationTokenSource(timeout); - while (!cts.IsCancellationRequested) + while (!cancellationToken.IsCancellationRequested) { try { - using var metrics = await client.GetFromJsonAsync(seriesUri, cts.Token); + using var metrics = await client.GetFromJsonAsync(seriesUri, cancellationToken); if (metrics!.RootElement.ValueKind is JsonValueKind.Object && metrics.RootElement.TryGetProperty("status", out var status) && @@ -140,32 +141,41 @@ static async Task> WaitForMetricsSeriesAsync(Uri baseAddre } } } - catch (Exception) + catch (Exception ex) { - await Task.Delay(frequency); + outputHelper.WriteLine($"[prometheus] Exception while waiting for metric series: {ex}"); + + try + { + await Task.Delay(frequency, cancellationToken); + } + catch (TaskCanceledException) + { + break; + } } } - Assert.Fail($"Timed out after {timeout} waiting for metric series."); + Assert.Fail($"Timed out waiting for metric series."); return []; } - static async Task WaitForServiceDiscoveryAsync(Uri baseAddress) + static async Task WaitForServiceDiscoveryAsync( + Uri baseAddress, + ITestOutputHelper outputHelper, + CancellationToken cancellationToken) { // See https://prometheus.io/docs/prometheus/latest/querying/api/#targets using var client = new HttpClient() { BaseAddress = baseAddress }; var targetsUri = new Uri("/api/v1/targets", UriKind.Relative); var frequency = TimeSpan.FromMilliseconds(250); - var timeout = TimeSpan.FromSeconds(15); - - using var cts = new CancellationTokenSource(timeout); - while (!cts.IsCancellationRequested) + while (!cancellationToken.IsCancellationRequested) { try { - using var targets = await client.GetFromJsonAsync(targetsUri, cts.Token); + using var targets = await client.GetFromJsonAsync(targetsUri, cancellationToken); if (targets!.RootElement.ValueKind is JsonValueKind.Object && targets.RootElement.TryGetProperty("status", out var status) && @@ -181,13 +191,22 @@ static async Task WaitForServiceDiscoveryAsync(Uri baseAddress) } } } - catch (Exception) + catch (Exception ex) { - await Task.Delay(frequency); + outputHelper.WriteLine($"[prometheus] Exception while waiting for service discovery: {ex}"); + + try + { + await Task.Delay(frequency, cancellationToken); + } + catch (TaskCanceledException) + { + break; + } } } - Assert.Fail($"Timed out after {timeout} waiting for service discovery active targets."); + Assert.Fail($"Timed out waiting for service discovery active targets."); } }); diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs index 6dcce5191f8..ca68cc72746 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs @@ -11,12 +11,33 @@ public class PrometheusFixture : XunitContainerFixture { private const string DockerInternalHost = "host.docker.internal"; + private readonly HashSet temporaryFiles = []; + public IList ScrapeProtocols { get; } = []; public int? TargetPort { get; set; } protected override string DockerfileName => "prometheus.Dockerfile"; + public override ValueTask DisposeAsync() + { + foreach (var path in this.temporaryFiles) + { + try + { + File.Delete(path); + } + catch (Exception) + { + // Ignore + } + } + + GC.SuppressFinalize(this); + + return base.DisposeAsync(); + } + protected override IContainer CreateContainer() { if (this.TargetPort is not { } targetPort) @@ -25,9 +46,13 @@ protected override IContainer CreateContainer() } var prometheusConfigurationPath = Path.GetTempFileName(); + this.temporaryFiles.Add(prometheusConfigurationPath); + File.WriteAllText(prometheusConfigurationPath, CreatePrometheusConfiguration(this.ScrapeProtocols)); var serviceDiscoveryTargetsPath = Path.GetTempFileName(); + this.temporaryFiles.Add(serviceDiscoveryTargetsPath); + File.WriteAllText(serviceDiscoveryTargetsPath, CreateServiceDiscoveryConfiguration(targetPort)); #if NET diff --git a/test/Shared/ContainerFixture.cs b/test/Shared/ContainerFixture.cs index 49a128816e6..cdad67fa094 100644 --- a/test/Shared/ContainerFixture.cs +++ b/test/Shared/ContainerFixture.cs @@ -13,7 +13,7 @@ public abstract class ContainerFixture : IAsyncDisposable protected abstract string DockerfileName { get; } - public async ValueTask DisposeAsync() + public virtual async ValueTask DisposeAsync() { if (this.started) { From 0823dd0ce5c1272755f4182e27bc450d98c3e5fa Mon Sep 17 00:00:00 2001 From: martincostello Date: Wed, 6 May 2026 14:38:15 +0100 Subject: [PATCH 64/82] [Exporter.Prometheus] Extend content negotiation Refactor Prometheus content negotiation to more closely follow https://prometheus.io/docs/instrumenting/content_negotiation/. Contributes to #7156, #7207 and #7246. --- .../CHANGELOG.md | 5 + ...etry.Exporter.Prometheus.AspNetCore.csproj | 1 + .../PrometheusExporterMiddleware.cs | 168 +++++++++++------ .../CHANGELOG.md | 5 + .../Internal/PrometheusHeadersParser.cs | 169 +++++++++++++----- .../Internal/PrometheusProtocol.cs | 74 ++++++++ .../PrometheusHttpListener.cs | 16 +- ...xporter.Prometheus.AspNetCore.Tests.csproj | 1 + .../PrometheusExporterMiddlewareTests.cs | 91 +++++----- .../PrometheusAcceptHeaders.cs | 102 +++++++++++ .../PrometheusHeadersParserTests.cs | 50 ++---- .../PrometheusHttpListenerTests.cs | 22 ++- 12 files changed, 508 insertions(+), 196 deletions(-) create mode 100644 src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusProtocol.cs create mode 100644 test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusAcceptHeaders.cs diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index c40c52b79a2..ffba03d4ce1 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -50,6 +50,11 @@ Notes](../../RELEASENOTES.md). thresholds are present. ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221)) +* Update `Accept` header parsing to more closely follow the Prometheus + [Scrape protocol content negotiation](https://prometheus.io/docs/instrumenting/content_negotiation/) + specification. + ([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/issues/TODO)) + ## 1.15.3-beta.1 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj index fed1f9066ee..a3dc1a94770 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj @@ -26,6 +26,7 @@ + diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs index 694f4de93f1..a70f198dea3 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Http; using Microsoft.Net.Http.Headers; using OpenTelemetry.Exporter.Prometheus; @@ -15,13 +16,6 @@ namespace OpenTelemetry.Exporter; /// internal sealed class PrometheusExporterMiddleware { - private const string OpenMetricsEscapingScheme = "underscores"; - private const string OpenMetricsMediaType = "application/openmetrics-text"; - private const string OpenMetricsVersion = "1.0.0"; - private const string OpenMetricsContentType = $"application/openmetrics-text; version={OpenMetricsVersion}; charset=utf-8; escaping={OpenMetricsEscapingScheme}"; - - private const string PrometheusTextMediaType = "text/plain"; - private readonly PrometheusExporter exporter; /// @@ -49,11 +43,6 @@ internal PrometheusExporterMiddleware(PrometheusExporter exporter) this.exporter = exporter; } - /// - /// Invoke. - /// - /// context. - /// Task. public async Task InvokeAsync(HttpContext httpContext) { Debug.Assert(httpContext != null, "httpContext should not be null"); @@ -62,22 +51,20 @@ public async Task InvokeAsync(HttpContext httpContext) try { - var openMetricsRequested = AcceptsOpenMetrics(httpContext.Request); - var collectionResponse = await this.exporter.CollectionManager.EnterCollect(openMetricsRequested).ConfigureAwait(false); + var protocol = Negotiate(httpContext.Request); + + var collectionResponse = await this.exporter.CollectionManager.EnterCollect(protocol.IsOpenMetrics).ConfigureAwait(false); try { - var dataView = openMetricsRequested ? collectionResponse.OpenMetricsView : collectionResponse.PlainTextView; + var dataView = protocol.IsOpenMetrics ? collectionResponse.OpenMetricsView : collectionResponse.PlainTextView; response.StatusCode = StatusCodes.Status200OK; if (dataView.Count > 0) { response.Headers.Append("Last-Modified", collectionResponse.GeneratedAtUtc.ToString("R")); - - response.ContentType = openMetricsRequested - ? OpenMetricsContentType - : "text/plain; charset=utf-8; version=0.0.4"; + response.ContentType = PrometheusProtocol.GetContentType(protocol); await response.Body.WriteAsync(dataView.Array.AsMemory(0, dataView.Count)).ConfigureAwait(false); } @@ -102,65 +89,144 @@ public async Task InvokeAsync(HttpContext httpContext) } } - internal static bool AcceptsOpenMetrics(HttpRequest request) + internal static PrometheusProtocol Negotiate(HttpRequest request) { var acceptHeader = request.GetTypedHeaders().Accept; if (acceptHeader is not { Count: > 0 }) { - return false; + return PrometheusProtocol.Fallback; } - double? bestOpenMetricsQuality = null; - double? bestPrometheusQuality = null; - - foreach (var mediaType in acceptHeader) + if (acceptHeader is { Count: 1 }) { - var quality = mediaType.Quality ?? 1.0; + return TryParse(acceptHeader[0], out var protocol, out _) + ? protocol.GetValueOrDefault(PrometheusProtocol.Fallback) + : PrometheusProtocol.Fallback; + } - if (quality is <= 0 or > 1) - { - continue; - } + const int SupportedProtocols = 4; + var preferences = new PriorityQueue(SupportedProtocols); - if (string.Equals(mediaType.MediaType.Value, OpenMetricsMediaType, StringComparison.OrdinalIgnoreCase) && - HasSupportedOpenMetricsParameters(mediaType)) - { - bestOpenMetricsQuality = - bestOpenMetricsQuality is not { } comparison || quality > comparison ? - quality : - bestOpenMetricsQuality ?? quality; - } - else if (string.Equals(mediaType.MediaType.Value, PrometheusTextMediaType, StringComparison.OrdinalIgnoreCase)) + foreach (var mediaType in acceptHeader) + { + if (TryParse(mediaType, out var protocol, out var quality)) { - bestPrometheusQuality = - bestPrometheusQuality is not { } comparison || quality > comparison ? - quality : - bestPrometheusQuality ?? quality; + preferences.Enqueue(protocol.Value, -quality); } } - return bestOpenMetricsQuality is { } openMetricsQuality && - (bestPrometheusQuality is not { } prometheusQuality || openMetricsQuality >= prometheusQuality); + // Use the first supported protocol that was parsed that has the highest quality factor + return preferences.TryDequeue(out var preferred, out _) + ? preferred + : PrometheusProtocol.Fallback; } - private static bool HasSupportedOpenMetricsParameters(MediaTypeHeaderValue value) + private static bool TryParse( + MediaTypeHeaderValue value, + [NotNullWhen(true)] out PrometheusProtocol? protocol, + out double quality) { - var hasSupportedOpenMetricsEscaping = true; - var hasSupportedOpenMetricsVersion = true; + protocol = null; + quality = default; + + bool isOpenMetrics; + string mediaType; + + var supportedEscapingSchemes = PrometheusProtocol.SupportedEscapingSchemes; + HashSet supportedVersions; + + if (string.Equals(value.MediaType.Value, PrometheusProtocol.OpenMetricsMediaType, StringComparison.OrdinalIgnoreCase)) + { + isOpenMetrics = true; + mediaType = PrometheusProtocol.OpenMetricsMediaType; + supportedVersions = PrometheusProtocol.SupportedOpenMetricsVersions; + } + else if (string.Equals(value.MediaType.Value, PrometheusProtocol.PrometheusTextMediaType, StringComparison.OrdinalIgnoreCase)) + { + isOpenMetrics = false; + mediaType = PrometheusProtocol.PrometheusTextMediaType; + supportedVersions = PrometheusProtocol.SupportedPrometheusVersions; + } + else + { + // Unsupported media type + return false; + } + + // Quality ignores values greater than one and returns null so we cannot + // distinguish between an invalid quality and the quality not be provided. + // Default to a value of 0.99 so that an invalid quality value will be treated + // as a lower preference than a valid quality value of 1.0. + quality = value.Quality ?? 0.99; + + if (quality <= 0) + { + return false; + } + + string? escaping = null; + Version? version = null; foreach (var parameter in value.Parameters) { if (string.Equals(parameter.Name.Value, "version", StringComparison.OrdinalIgnoreCase)) { - hasSupportedOpenMetricsVersion = string.Equals(parameter.Value.Value?.Trim('"'), OpenMetricsVersion, StringComparison.Ordinal); + if (Version.TryParse(parameter.Value.Value?.Trim('"'), out var parsedVersion) && + supportedVersions.Contains(parsedVersion)) + { + version = parsedVersion; + } + else + { + // Unsupported version + return false; + } } else if (string.Equals(parameter.Name.Value, "escaping", StringComparison.OrdinalIgnoreCase)) { - hasSupportedOpenMetricsEscaping = string.Equals(parameter.Value.Value?.Trim('"'), OpenMetricsEscapingScheme, StringComparison.Ordinal); + var escapedValue = parameter.Value.Value?.Trim('"'); + + if (escapedValue == null || !supportedEscapingSchemes.Contains(escapedValue)) + { + // TODO Support other escaping schemes, including at least "allow-utf-8". + // For now we treat "allow-utf-8" as if it were "underscores" to avoid fallback + // to PrometheusText0.0.4 where it would previously match to OpenMetricsText1.0.0. + // See https://github.com/open-telemetry/opentelemetry-dotnet/issues/7246. + if (string.Equals(escapedValue, PrometheusProtocol.AllowUtf8Escaping, StringComparison.Ordinal)) + { + escaping = PrometheusProtocol.UnderscoresEscaping; + } + else + { + // Unsupported escaping scheme + return false; + } + } + else + { + escaping = escapedValue; + } } } - return hasSupportedOpenMetricsVersion && hasSupportedOpenMetricsEscaping; + if (version is null) + { + // Use the oldest version if no version preference was specified + version = isOpenMetrics ? PrometheusProtocol.OpenMetricsV0 : PrometheusProtocol.PrometheusVersion0; + } + else if (version.Major is not > 0) + { + // From https://prometheus.io/docs/instrumenting/content_negotiation/#content-type-response: + // "The Content-Type header MUST include [...] For text formats version 1.0.0 and above, the escaping scheme parameter." + escaping = null; + } + else + { + escaping ??= PrometheusProtocol.UnderscoresEscaping; + } + + protocol = new(mediaType, escaping, version, isOpenMetrics); + return true; } } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index 20d62fc4ddd..6d78e834ae8 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -67,6 +67,11 @@ Notes](../../RELEASENOTES.md). thresholds are present. ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221)) +* Update `Accept` header parsing to more closely follow the Prometheus + [Scrape protocol content negotiation](https://prometheus.io/docs/instrumenting/content_negotiation/) + specification. + ([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/issues/TODO)) + ## 1.15.3-beta.1 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs index f64e291a315..e44d2883904 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs @@ -7,91 +7,164 @@ namespace OpenTelemetry.Exporter.Prometheus; internal static class PrometheusHeadersParser { - private const string OpenMetricsEscapingScheme = "underscores"; - private const string OpenMetricsMediaType = "application/openmetrics-text"; - private const string OpenMetricsVersion = "1.0.0"; - private const string PrometheusTextMediaType = "text/plain"; - - internal static bool AcceptsOpenMetrics(string? contentType) + internal static PrometheusProtocol Negotiate(string? contentType) { + if (string.IsNullOrWhiteSpace(contentType)) + { + return PrometheusProtocol.Fallback; + } + var value = contentType.AsSpan(); - double? bestOpenMetricsQuality = null; - double? bestPrometheusQuality = null; + + const int SupportedProtocols = 4; + var preferences = new List<(PrometheusProtocol Protocol, double Quality)>(SupportedProtocols); + + var supportedEscapingSchemes = PrometheusProtocol.SupportedEscapingSchemes; + HashSet supportedVersions; while (value.Length > 0) { var headerValue = TrimWhitespace(SplitNext(ref value, ',')); - var mediaType = TrimWhitespace(SplitNext(ref headerValue, ';')); + var mediaType = TrimWhitespace(SplitNext(ref headerValue, ';')).ToString(); + + bool isOpenMetrics; + + if (string.Equals(mediaType, PrometheusProtocol.PrometheusTextMediaType, StringComparison.OrdinalIgnoreCase)) + { + isOpenMetrics = false; + mediaType = PrometheusProtocol.PrometheusTextMediaType; + supportedVersions = PrometheusProtocol.SupportedPrometheusVersions; + } + else + { + if (!string.Equals(mediaType, PrometheusProtocol.OpenMetricsMediaType, StringComparison.OrdinalIgnoreCase)) + { + // Unsupported media type + continue; + } + + isOpenMetrics = true; + mediaType = PrometheusProtocol.OpenMetricsMediaType; + supportedVersions = PrometheusProtocol.SupportedOpenMetricsVersions; + } + + string? escaping = null; + Version? version = null; + var quality = 1.0; - var hasValidQuality = true; - var hasSupportedOpenMetricsEscaping = true; - var hasSupportedOpenMetricsVersion = true; + + var valid = true; while (headerValue.Length > 0) { var parameter = TrimWhitespace(SplitNext(ref headerValue, ';')); - if (!parameter.StartsWith("q=".AsSpan(), StringComparison.OrdinalIgnoreCase)) + if (parameter.StartsWith("q=".AsSpan(), StringComparison.OrdinalIgnoreCase)) { - if (parameter.StartsWith("version=".AsSpan(), StringComparison.OrdinalIgnoreCase)) + if (double.TryParse( + parameter.Slice(2).ToString(), + NumberStyles.AllowDecimalPoint, + CultureInfo.InvariantCulture, + out var parsedQuality) && + parsedQuality is > 0 and <= 1) { - hasSupportedOpenMetricsVersion = IsSupportedOpenMetricsVersion(parameter.Slice("version=".Length)); + quality = parsedQuality; } - else if (parameter.StartsWith("escaping=".AsSpan(), StringComparison.OrdinalIgnoreCase)) + else { - hasSupportedOpenMetricsEscaping = IsSupportedOpenMetricsEscaping(parameter.Slice("escaping=".Length)); + valid = false; + break; } - - continue; } - - if (double.TryParse( - parameter.Slice(2).ToString(), - NumberStyles.AllowDecimalPoint, - CultureInfo.InvariantCulture, - out var parsedQuality) && - parsedQuality is > 0 and <= 1) + else if (parameter.StartsWith("version=".AsSpan(), StringComparison.OrdinalIgnoreCase)) { - quality = parsedQuality; + version = GetVersion(parameter.Slice("version=".Length), supportedVersions); + + if (version is null) + { + // Unsupported version + valid = false; + break; + } } - else + else if (parameter.StartsWith("escaping=".AsSpan(), StringComparison.OrdinalIgnoreCase)) { - hasValidQuality = false; + escaping = GetEscaping(parameter.Slice("escaping=".Length)); + + if (escaping is null) + { + // Unsupported escaping scheme + valid = false; + break; + } } } - if (!hasValidQuality) + if (!valid) { continue; } - if (mediaType.Equals(OpenMetricsMediaType.AsSpan(), StringComparison.OrdinalIgnoreCase) && - hasSupportedOpenMetricsVersion && - hasSupportedOpenMetricsEscaping) + if (version is null) + { + // Use the oldest version if no version preference was specified + version = isOpenMetrics ? PrometheusProtocol.OpenMetricsV0 : PrometheusProtocol.PrometheusVersion0; + } + else if (version.Major is not > 0) { - bestOpenMetricsQuality = - bestOpenMetricsQuality is not { } comparison || quality > comparison ? - quality : - bestOpenMetricsQuality ?? quality; + // From https://prometheus.io/docs/instrumenting/content_negotiation/#content-type-response: + // "The Content-Type header MUST include [...] For text formats version 1.0.0 and above, the escaping scheme parameter." + escaping = null; } - else if (mediaType.Equals(PrometheusTextMediaType.AsSpan(), StringComparison.OrdinalIgnoreCase)) + else { - bestPrometheusQuality = - bestPrometheusQuality is not { } comparison || quality > comparison ? - quality : - bestPrometheusQuality ?? quality; + escaping ??= PrometheusProtocol.UnderscoresEscaping; } + + var protocol = new PrometheusProtocol( + mediaType, + escaping, + version, + isOpenMetrics); + + preferences.Add((protocol, quality)); } - return bestOpenMetricsQuality is { } openMetricsQuality && - (bestPrometheusQuality is not { } prometheusQuality || openMetricsQuality >= prometheusQuality); + // Use the first supported protocol that was parsed that has the highest quality factor + return preferences + .OrderByDescending((p) => p.Quality) + .Select((p) => p.Protocol) + .DefaultIfEmpty(PrometheusProtocol.Fallback) + .FirstOrDefault(); + } + + private static Version? GetVersion(ReadOnlySpan value, HashSet supportedVersions) + { + var trimmed = TrimQuotes(value); + + return Version.TryParse(trimmed.ToString(), out var version) && supportedVersions.Contains(version) + ? version + : null; } - private static bool IsSupportedOpenMetricsVersion(ReadOnlySpan value) - => TrimQuotes(value).Equals(OpenMetricsVersion.AsSpan(), StringComparison.Ordinal); + private static string? GetEscaping(ReadOnlySpan value) + { + var trimmed = TrimQuotes(value); + var escaping = trimmed.ToString(); + + if (PrometheusProtocol.SupportedEscapingSchemes.Contains(escaping)) + { + return escaping; + } - private static bool IsSupportedOpenMetricsEscaping(ReadOnlySpan value) - => TrimQuotes(value).Equals(OpenMetricsEscapingScheme.AsSpan(), StringComparison.Ordinal); + // TODO Support other escaping schemes, including at least "allow-utf-8". + // For now we treat "allow-utf-8" as if it were "underscores" to avoid fallback + // to PrometheusText0.0.4 where it would previously match to OpenMetricsText1.0.0. + // See https://github.com/open-telemetry/opentelemetry-dotnet/issues/7246. + return string.Equals(escaping, PrometheusProtocol.AllowUtf8Escaping, StringComparison.Ordinal) + ? PrometheusProtocol.UnderscoresEscaping + : null; + } private static ReadOnlySpan SplitNext(ref ReadOnlySpan span, char character) { diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusProtocol.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusProtocol.cs new file mode 100644 index 00000000000..abd4ef6fc0e --- /dev/null +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusProtocol.cs @@ -0,0 +1,74 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Text; + +namespace OpenTelemetry.Exporter.Prometheus; + +internal readonly struct PrometheusProtocol +{ + public const string AllowUtf8Escaping = "allow-utf-8"; + public const string UnderscoresEscaping = "underscores"; + + public const string OpenMetricsMediaType = "application/openmetrics-text"; + public const string PrometheusTextMediaType = "text/plain"; + + public static readonly Version PrometheusVersion0 = new(0, 0, 4); + public static readonly Version PrometheusVersion1 = new(1, 0, 0); + public static readonly Version OpenMetricsV0 = new(0, 0, 1); + public static readonly Version OpenMetricsV1 = new(1, 0, 0); + + public static readonly PrometheusProtocol Fallback = new(PrometheusTextMediaType, null, PrometheusVersion0, false); + + // TODO Support other escaping schemes, including at least "allow-utf-8". + // See https://github.com/open-telemetry/opentelemetry-dotnet/issues/7246. + internal static readonly HashSet SupportedEscapingSchemes = + [ + UnderscoresEscaping, + ]; + + internal static readonly HashSet SupportedOpenMetricsVersions = + [ + OpenMetricsV0, + OpenMetricsV1, + ]; + + internal static readonly HashSet SupportedPrometheusVersions = + [ + PrometheusVersion0, + PrometheusVersion1, + ]; + + public PrometheusProtocol(string mediaType, string? escaping, Version version, bool isOpenMetrics) + { + this.MediaType = mediaType; + this.Escaping = escaping; + this.IsOpenMetrics = isOpenMetrics; + this.Version = version; + } + + public readonly string MediaType { get; } + + public readonly string? Escaping { get; } + + public readonly bool IsOpenMetrics { get; } + + public readonly Version Version { get; } + + public static string GetContentType(PrometheusProtocol protocol) + { + var builder = new StringBuilder() + .Append(protocol.MediaType) + .Append("; version=") + .Append(protocol.Version.ToString(3)) + .Append("; charset=utf-8"); + + if (protocol.Escaping is not null) + { + builder.Append("; escaping=") + .Append(protocol.Escaping); + } + + return builder.ToString(); + } +} diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs index d70b64fed01..296c4850319 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs @@ -140,11 +140,10 @@ public void Dispose() } } - private static bool AcceptsOpenMetrics(HttpListenerRequest request) + private static PrometheusProtocol Negotiate(HttpListenerRequest request) { var acceptHeader = request.Headers["Accept"]; - - return !string.IsNullOrEmpty(acceptHeader) && PrometheusHeadersParser.AcceptsOpenMetrics(acceptHeader); + return PrometheusHeadersParser.Negotiate(acceptHeader); } /// @@ -258,8 +257,9 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation try { - var openMetricsRequested = AcceptsOpenMetrics(context.Request); - var collectionResponse = await this.exporter.CollectionManager.EnterCollect(openMetricsRequested).ConfigureAwait(false); + var protocol = Negotiate(context.Request); + + var collectionResponse = await this.exporter.CollectionManager.EnterCollect(protocol.IsOpenMetrics).ConfigureAwait(false); try { @@ -267,15 +267,13 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation context.Response.Headers.Add("Server", string.Empty); - var dataView = openMetricsRequested ? collectionResponse.OpenMetricsView : collectionResponse.PlainTextView; + var dataView = protocol.IsOpenMetrics ? collectionResponse.OpenMetricsView : collectionResponse.PlainTextView; if (dataView.Count > 0) { context.Response.StatusCode = 200; context.Response.Headers.Add("Last-Modified", collectionResponse.GeneratedAtUtc.ToString("R")); - context.Response.ContentType = openMetricsRequested - ? "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores" - : "text/plain; charset=utf-8; version=0.0.4"; + context.Response.ContentType = PrometheusProtocol.GetContentType(protocol); #if NET await context.Response.OutputStream.WriteAsync(dataView.Array.AsMemory(0, dataView.Count), cancellationToken).ConfigureAwait(false); diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj index a80eacdd61f..9ca01ad07bc 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj @@ -29,6 +29,7 @@ + diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs index d0473cb9961..d1382b6dd99 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Primitives; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Tests; @@ -220,45 +221,39 @@ public Task PrometheusExporterMiddlewareIntegration_UseOpenMetricsVersionHeader( RunPrometheusExporterMiddlewareIntegrationTest( "/metrics", app => app.UseOpenTelemetryPrometheusScrapingEndpoint(), - acceptHeader: "application/openmetrics-text; version=1.0.0"); + acceptHeader: "application/openmetrics-text; version=1.0.0", + contentType: "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores"); + + [Theory] + [MemberData(nameof(PrometheusAcceptHeaders.Valid), MemberType = typeof(PrometheusAcceptHeaders))] + public void PrometheusExporterMiddlewareNegotiate_UsesTypedAcceptHeaders( + string accept, + string mediaType, + bool isOpenMetrics, + string version, + string? escaping) + { + var context = new DefaultHttpContext(); + context.Request.Headers.Accept = accept; + + var actual = PrometheusExporterMiddleware.Negotiate(context.Request); + + Assert.Equal(mediaType, actual.MediaType); + Assert.Equal(isOpenMetrics, actual.IsOpenMetrics); + Assert.Equal(Version.Parse(version), actual.Version); + Assert.Equal(escaping, actual.Escaping); + } [Theory] - [InlineData("application/openmetrics-text", true)] - [InlineData("application/openmetrics-text; version=1.0.0", true)] - [InlineData("application/openmetrics-text; version=\"1.0.0\"", true)] - [InlineData("application/openmetrics-text; version=1.0.0; escaping=underscores", true)] - [InlineData("application/openmetrics-text; version=\"1.0.0\"; escaping=\"underscores\"", true)] - [InlineData("application/openmetrics-text; version=1.0.0; charset=utf-8", true)] - [InlineData("Application/OpenMetrics-Text; version=1.0.0", true)] - [InlineData("application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores", true)] - [InlineData("text/plain,application/openmetrics-text; version=1.0.0; charset=utf-8", true)] - [InlineData("text/plain, application/openmetrics-text; version=1.0.0; charset=utf-8", true)] - [InlineData("text/plain; charset=utf-8,application/openmetrics-text; version=1.0.0; charset=utf-8", true)] - [InlineData("text/plain, */*;q=0.8,application/openmetrics-text; version=1.0.0; charset=utf-8", true)] - [InlineData("text/plain; q=0.3, application/openmetrics-text; version=1.0.0; q=0.9", true)] - [InlineData("TEXT/PLAIN; q=0.3, Application/OpenMetrics-Text; version=1.0.0; q=0.9", true)] - [InlineData("application/openmetrics-text; version=0.0.1", false)] - [InlineData("application/openmetrics-text; version=\"0.0.1\"", false)] - [InlineData("application/openmetrics-text; version=0.0.1; charset=utf-8", false)] - [InlineData("application/openmetrics-text; version=1.0.0; q=0", false)] - [InlineData("application/openmetrics-text; version=1.0.0; escaping=allow-utf-8", false)] - [InlineData("application/openmetrics-text; version=1.0.0; escaping=dots", false)] - [InlineData("application/openmetrics-text; version=1.0.0; escaping=values", false)] - [InlineData("text/plain", false)] - [InlineData("text/plain; charset=utf-8", false)] - [InlineData("text/plain; charset=utf-8; version=0.0.4", false)] - [InlineData("text/plain; q=0.9, application/openmetrics-text; version=1.0.0; q=0.1", false)] - [InlineData("TEXT/PLAIN; q=0.9, Application/OpenMetrics-Text; version=1.0.0; q=0.1", false)] - [InlineData("text/plain; q=0, application/openmetrics-text; version=1.0.0; q=0", false)] - [InlineData("*/*;q=0.8,text/plain; charset=utf-8; version=0.0.4", false)] - public void PrometheusExporterMiddlewareAcceptsOpenMetrics_UsesTypedAcceptHeaders(string header, bool expected) + [MemberData(nameof(PrometheusAcceptHeaders.Invalid), MemberType = typeof(PrometheusAcceptHeaders))] + public void PrometheusExporterMiddlewareNegotiate_UsesFallbackForInvalidHeader(string accept) { var context = new DefaultHttpContext(); - context.Request.Headers.Accept = header; + context.Request.Headers.Accept = accept; - var result = PrometheusExporterMiddleware.AcceptsOpenMetrics(context.Request); + var actual = PrometheusExporterMiddleware.Negotiate(context.Request); - Assert.Equal(expected, result); + Assert.Equivalent(PrometheusProtocol.Fallback, actual); } [Fact] @@ -290,6 +285,7 @@ public Task PrometheusExporterMiddlewareIntegration_UseOpenMetricsVersionHeader_ "/metrics", app => app.UseOpenTelemetryPrometheusScrapingEndpoint(), acceptHeader: "application/openmetrics-text; version=1.0.0", + contentType: "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores", meterTags: meterTags); } @@ -378,7 +374,9 @@ public async Task PrometheusExporterMiddlewareInvokeAsync_WhenExceptionOccursAft Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); } - private static async Task RunPrometheusExporterMiddlewareIntegrationTestWithBothFormats(KeyValuePair[]? meterTags = null) + private static async Task RunPrometheusExporterMiddlewareIntegrationTestWithBothFormats( + KeyValuePair[]? meterTags = null, + string? contentType = null) { using var host = await StartTestHostAsync( app => app.UseOpenTelemetryPrometheusScrapingEndpoint()); @@ -408,7 +406,7 @@ private static async Task RunPrometheusExporterMiddlewareIntegrationTestWithBoth Method = HttpMethod.Get, }; using var response = await client.SendAsync(request); - await VerifyAsync(response, testCase, meterTags); + await VerifyAsync(response, testCase, meterTags, contentType); } await host.StopAsync(); @@ -423,6 +421,7 @@ private static async Task RunPrometheusExporterMiddlewareIntegrationTest( Action? configureOptions = null, bool skipMetrics = false, string acceptHeader = "application/openmetrics-text", + string? contentType = null, KeyValuePair[]? meterTags = null) { var requestOpenMetrics = acceptHeader.StartsWith("application/openmetrics-text", StringComparison.Ordinal); @@ -457,7 +456,7 @@ private static async Task RunPrometheusExporterMiddlewareIntegrationTest( { var options = new PrometheusAspNetCoreOptions(); configureOptions?.Invoke(options); - await VerifyAsync(response, requestOpenMetrics, meterTags); + await VerifyAsync(response, requestOpenMetrics, meterTags, contentType); } else { @@ -469,19 +468,21 @@ private static async Task RunPrometheusExporterMiddlewareIntegrationTest( await host.StopAsync(); } - private static async Task VerifyAsync(HttpResponseMessage response, bool requestOpenMetrics, KeyValuePair[]? meterTags) + private static async Task VerifyAsync( + HttpResponseMessage response, + bool requestOpenMetrics, + KeyValuePair[]? meterTags, + string? contentType) { Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.True(response.Content.Headers.Contains("Last-Modified")); - if (requestOpenMetrics) - { - Assert.Equal("application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores", response.Content.Headers.ContentType!.ToString()); - } - else - { - Assert.Equal("text/plain; charset=utf-8; version=0.0.4", response.Content.Headers.ContentType!.ToString()); - } + contentType ??= + requestOpenMetrics ? + "application/openmetrics-text; version=0.0.1; charset=utf-8" : + "text/plain; version=0.0.4; charset=utf-8"; + + Assert.Equal(contentType, response.Content.Headers.ContentType!.ToString()); var additionalTags = meterTags is { Length: > 0 } ? $"{string.Join(",", meterTags.Select(x => $"{x.Key}=\"{x.Value}\""))}," diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusAcceptHeaders.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusAcceptHeaders.cs new file mode 100644 index 00000000000..b1634905139 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusAcceptHeaders.cs @@ -0,0 +1,102 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using Xunit; + +namespace OpenTelemetry.Exporter.Prometheus; + +public static class PrometheusAcceptHeaders +{ + public static TheoryData Valid() + { + var testCases = new TheoryData(); + + string[] prometheusV0 = + [ + "text/plain", + "text/plain; charset=utf-8", + "text/plain; charset=utf-8; version=0.0.4", + "text/plain,application/openmetrics-text; version=1.0.0; charset=utf-8", + "text/plain, application/openmetrics-text; version=1.0.0; charset=utf-8", + "text/plain, application/openmetrics-text; version=1.0.0; charset=utf-8", + "text/plain; charset=utf-8,application/openmetrics-text; version=1.0.0; charset=utf-8", + "text/plain, */*;q=0.8,application/openmetrics-text; version=1.0.0; charset=utf-8", + "text/plain;version=0.0.4;q=0.6,*/*;q=0.5", + "text/plain; q=0.9, application/openmetrics-text; version=1.0.0; q=0.1", + "TEXT/PLAIN; q=0.9, Application/OpenMetrics-Text; version=1.0.0; q=0.1", + "*/*;q=0.8,text/plain; charset=utf-8; version=0.0.4", + ]; + + foreach (var accept in prometheusV0) + { + testCases.Add(accept, "text/plain", false, "0.0.4", null); + } + + string[] prometheusV1 = + [ + "text/plain;version=1.0.0;escaping=allow-utf-8;q=0.6,*/*;q=0.5", + ]; + + foreach (var accept in prometheusV1) + { + testCases.Add(accept, "text/plain", false, "1.0.0", "underscores"); + } + + string[] openMetricsV0 = + [ + "application/openmetrics-text", + "application/openmetrics-text;version=0.0.1;q=0.6,*/*;q=0.5", + "application/openmetrics-text; version=0.0.1", + "application/openmetrics-text; version=0.0.1; charset=utf-8", + "application/openmetrics-text; version=\"0.0.1\"", + ]; + + foreach (var accept in openMetricsV0) + { + testCases.Add(accept, "application/openmetrics-text", true, "0.0.1", null); + } + + string[] openMetricsV1 = + [ + "application/openmetrics-text; version=1.0.0", + "application/openmetrics-text; version=\"1.0.0\"", + "application/openmetrics-text; version=1.0.0; escaping=allow-utf-8", + "application/openmetrics-text; version=1.0.0; escaping=underscores", + "application/openmetrics-text; version=\"1.0.0\"; escaping=\"underscores\"", + "application/openmetrics-text; version=1.0.0; charset=utf-8", + "Application/OpenMetrics-Text; version=1.0.0", + "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores", + "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=allow-utf-8", + "text/plain; q=0.3, application/openmetrics-text; version=1.0.0; q=0.9", + "TEXT/PLAIN; q=0.3, Application/OpenMetrics-Text; version=1.0.0; q=0.9", + "application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.6,*/*;q=0.5", + "application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.6,application/openmetrics-text;version=0.0.1;q=0.5,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.4,text/plain;version=0.0.4;q=0.3,*/*;q=0.2", + "application/openmetrics-text;version=1.0.0;escaping=underscores;q=0.6,*/*;q=0.5", + "application/openmetrics-text;version=1.0.0;escaping=underscores;q=0.6,application/openmetrics-text;version=0.0.1;q=0.5,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.4,text/plain;version=0.0.4;q=0.3,*/*;q=0.2", + ]; + + foreach (var accept in openMetricsV1) + { + testCases.Add(accept, "application/openmetrics-text", true, "1.0.0", "underscores"); + } + + return testCases; + } + +#pragma warning disable CA1825 // Avoid zero-length array allocations + public static TheoryData Invalid() => + [ + string.Empty, + "foo", + "application/json", + "application/openmetrics-text; version=0.0.5", + "application/openmetrics-text; version=foo", + "application/openmetrics-text; version=1.0.0; q=0", + "application/openmetrics-text; version=1.0.0; escaping=dots", + "application/openmetrics-text; version=1.0.0; escaping=foo", + "application/openmetrics-text; version=1.0.0; escaping=values", + "application/openmetrics-text; version=2.0.0", + "text/plain; q=0, application/openmetrics-text; version=1.0.0; q=0", + ]; +#pragma warning restore CA1825 // Avoid zero-length array allocations +} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHeadersParserTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHeadersParserTests.cs index ebea6879486..a690e3fcff5 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHeadersParserTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHeadersParserTests.cs @@ -8,47 +8,29 @@ namespace OpenTelemetry.Exporter.Prometheus.Tests; public class PrometheusHeadersParserTests { [Theory] - [InlineData("application/openmetrics-text")] - [InlineData("application/openmetrics-text; version=1.0.0")] - [InlineData("application/openmetrics-text; version=\"1.0.0\"")] - [InlineData("application/openmetrics-text; version=1.0.0; escaping=underscores")] - [InlineData("application/openmetrics-text; version=\"1.0.0\"; escaping=\"underscores\"")] - [InlineData("application/openmetrics-text; version=1.0.0; charset=utf-8")] - [InlineData("Application/OpenMetrics-Text; version=1.0.0")] - [InlineData("application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores")] - [InlineData("text/plain,application/openmetrics-text; version=1.0.0; charset=utf-8")] - [InlineData("text/plain, application/openmetrics-text; version=1.0.0; charset=utf-8")] - [InlineData("text/plain; charset=utf-8,application/openmetrics-text; version=1.0.0; charset=utf-8")] - [InlineData("text/plain, */*;q=0.8,application/openmetrics-text; version=1.0.0; charset=utf-8")] - [InlineData("text/plain; q=0.3, application/openmetrics-text; version=1.0.0; q=0.9")] - [InlineData("TEXT/PLAIN; q=0.3, Application/OpenMetrics-Text; version=1.0.0; q=0.9")] - public void ParseHeader_AcceptHeaders_OpenMetricsValid(string header) + [MemberData(nameof(PrometheusAcceptHeaders.Valid), MemberType = typeof(PrometheusAcceptHeaders))] + public void Negotiate_Parses_Valid_Header( + string accept, + string mediaType, + bool isOpenMetrics, + string version, + string? escaping) { - var result = PrometheusHeadersParser.AcceptsOpenMetrics(header); + var actual = PrometheusHeadersParser.Negotiate(accept); - Assert.True(result); + Assert.Equal(mediaType, actual.MediaType); + Assert.Equal(isOpenMetrics, actual.IsOpenMetrics); + Assert.Equal(Version.Parse(version), actual.Version); + Assert.Equal(escaping, actual.Escaping); } [Theory] - [InlineData("text/plain")] - [InlineData("text/plain; charset=utf-8")] - [InlineData("text/plain; charset=utf-8; version=0.0.4")] - [InlineData("*/*;q=0.8,text/plain; charset=utf-8; version=0.0.4")] - [InlineData("text/plain; q=0.9, application/openmetrics-text; version=1.0.0; q=0.1")] - [InlineData("TEXT/PLAIN; q=0.9, Application/OpenMetrics-Text; version=1.0.0; q=0.1")] - [InlineData("application/openmetrics-text; version=1.0.0; q=0")] + [MemberData(nameof(PrometheusAcceptHeaders.Invalid), MemberType = typeof(PrometheusAcceptHeaders))] [InlineData("application/openmetrics-text; version=1.0.0; q=1.1")] - [InlineData("text/plain; q=0, application/openmetrics-text; version=1.0.0; q=0")] - [InlineData("application/openmetrics-text; version=0.0.1")] - [InlineData("application/openmetrics-text; version=\"0.0.1\"")] - [InlineData("application/openmetrics-text; version=0.0.1; charset=utf-8")] - [InlineData("application/openmetrics-text; version=1.0.0; escaping=allow-utf-8")] - [InlineData("application/openmetrics-text; version=1.0.0; escaping=dots")] - [InlineData("application/openmetrics-text; version=1.0.0; escaping=values")] - public void ParseHeader_AcceptHeaders_OtherHeadersInvalid(string header) + public void Negotiate_Uses_Fallback_For_Invalid_Header(string header) { - var result = PrometheusHeadersParser.AcceptsOpenMetrics(header); + var actual = PrometheusHeadersParser.Negotiate(header); - Assert.False(result); + Assert.Equal(PrometheusProtocol.Fallback, actual); } } diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs index ad4114be53e..19340fdcb5f 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs @@ -70,7 +70,9 @@ public async Task PrometheusExporterHttpServerIntegration_NoOpenMetrics() [Fact] public async Task PrometheusExporterHttpServerIntegration_UseOpenMetricsVersionHeader() - => await RunPrometheusExporterHttpServerIntegrationTest(acceptHeader: "application/openmetrics-text; version=1.0.0"); + => await RunPrometheusExporterHttpServerIntegrationTest( + acceptHeader: "application/openmetrics-text; version=1.0.0", + contentType: "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores"); [Fact] public async Task PrometheusExporterHttpServerIntegration_NoOpenMetrics_WithMeterTags() @@ -97,6 +99,7 @@ public async Task PrometheusExporterHttpServerIntegration_OpenMetrics_WithMeterT await RunPrometheusExporterHttpServerIntegrationTest( acceptHeader: "application/openmetrics-text; version=1.0.0", + contentType: "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores", meterTags: tags); } @@ -512,6 +515,7 @@ internal static MeterProviderTestContext CreateMeterProvider( private static async Task RunPrometheusExporterHttpServerIntegrationTest( bool skipMetrics = false, string acceptHeader = "application/openmetrics-text", + string? contentType = null, KeyValuePair[]? meterTags = null) { var requestOpenMetrics = acceptHeader.StartsWith("application/openmetrics-text", StringComparison.Ordinal); @@ -550,14 +554,14 @@ private static async Task RunPrometheusExporterHttpServerIntegrationTest( Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.True(response.Content.Headers.Contains("Last-Modified")); - if (requestOpenMetrics) - { - Assert.Equal("application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores", response.Content.Headers.ContentType?.ToString()); - } - else - { - Assert.Equal("text/plain; charset=utf-8; version=0.0.4", response.Content.Headers.ContentType?.ToString()); - } + contentType ??= + requestOpenMetrics ? + "application/openmetrics-text; version=0.0.1; charset=utf-8" : + "text/plain; version=0.0.4; charset=utf-8"; + + Assert.NotNull(response.Content); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal(contentType, response.Content.Headers.ContentType.ToString()); var additionalTags = meterTags is { Length: > 0 } ? $"{string.Join(",", meterTags.Select(x => $"{x.Key}='{x.Value}'"))}," From c23bf438c251d5d429943ddf87e87ae2b29dcd87 Mon Sep 17 00:00:00 2001 From: martincostello Date: Wed, 6 May 2026 14:56:44 +0100 Subject: [PATCH 65/82] [Exporter.Prometheus] Use immutable collections Use `ImmutableHashSet` where available. --- .../PrometheusExporterMiddleware.cs | 3 ++- .../Internal/PrometheusHeadersParser.cs | 15 +++++++++++++-- .../Internal/PrometheusProtocol.cs | 14 +++++++++++--- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs index a70f198dea3..82f6fd0878b 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs @@ -1,6 +1,7 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Http; @@ -134,7 +135,7 @@ private static bool TryParse( string mediaType; var supportedEscapingSchemes = PrometheusProtocol.SupportedEscapingSchemes; - HashSet supportedVersions; + ImmutableHashSet supportedVersions; if (string.Equals(value.MediaType.Value, PrometheusProtocol.OpenMetricsMediaType, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs index e44d2883904..1d168491514 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs @@ -3,6 +3,12 @@ using System.Globalization; +#if NET8_0_OR_GREATER +using SupportedVersions = System.Collections.Immutable.ImmutableHashSet; +#else +using SupportedVersions = System.Collections.Generic.HashSet; +#endif + namespace OpenTelemetry.Exporter.Prometheus; internal static class PrometheusHeadersParser @@ -20,7 +26,12 @@ internal static PrometheusProtocol Negotiate(string? contentType) var preferences = new List<(PrometheusProtocol Protocol, double Quality)>(SupportedProtocols); var supportedEscapingSchemes = PrometheusProtocol.SupportedEscapingSchemes; - HashSet supportedVersions; + +#if NET8_0_OR_GREATER + SupportedVersions supportedVersions; +#else + SupportedVersions supportedVersions; +#endif while (value.Length > 0) { @@ -138,7 +149,7 @@ internal static PrometheusProtocol Negotiate(string? contentType) .FirstOrDefault(); } - private static Version? GetVersion(ReadOnlySpan value, HashSet supportedVersions) + private static Version? GetVersion(ReadOnlySpan value, SupportedVersions supportedVersions) { var trimmed = TrimQuotes(value); diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusProtocol.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusProtocol.cs index abd4ef6fc0e..72477a76116 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusProtocol.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusProtocol.cs @@ -3,6 +3,14 @@ using System.Text; +#if NET8_0_OR_GREATER +using SupportedEscapingSchemes = System.Collections.Immutable.ImmutableHashSet; +using SupportedVersions = System.Collections.Immutable.ImmutableHashSet; +#else +using SupportedEscapingSchemes = System.Collections.Generic.HashSet; +using SupportedVersions = System.Collections.Generic.HashSet; +#endif + namespace OpenTelemetry.Exporter.Prometheus; internal readonly struct PrometheusProtocol @@ -22,18 +30,18 @@ internal readonly struct PrometheusProtocol // TODO Support other escaping schemes, including at least "allow-utf-8". // See https://github.com/open-telemetry/opentelemetry-dotnet/issues/7246. - internal static readonly HashSet SupportedEscapingSchemes = + internal static readonly SupportedEscapingSchemes SupportedEscapingSchemes = [ UnderscoresEscaping, ]; - internal static readonly HashSet SupportedOpenMetricsVersions = + internal static readonly SupportedVersions SupportedOpenMetricsVersions = [ OpenMetricsV0, OpenMetricsV1, ]; - internal static readonly HashSet SupportedPrometheusVersions = + internal static readonly SupportedVersions SupportedPrometheusVersions = [ PrometheusVersion0, PrometheusVersion1, From 260a36704a72729a0b518953815b006a2d05d75a Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Wed, 6 May 2026 15:22:17 +0100 Subject: [PATCH 66/82] [Exporter.Prometheus] Update CHANGELOGs Add PR numbers. --- src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md | 2 +- src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index ffba03d4ce1..31d8a597878 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -53,7 +53,7 @@ Notes](../../RELEASENOTES.md). * Update `Accept` header parsing to more closely follow the Prometheus [Scrape protocol content negotiation](https://prometheus.io/docs/instrumenting/content_negotiation/) specification. - ([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/issues/TODO)) + ([#7266](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7266)) ## 1.15.3-beta.1 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index 6d78e834ae8..f8dd8f2d4c8 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -70,7 +70,7 @@ Notes](../../RELEASENOTES.md). * Update `Accept` header parsing to more closely follow the Prometheus [Scrape protocol content negotiation](https://prometheus.io/docs/instrumenting/content_negotiation/) specification. - ([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/issues/TODO)) + ([#7266](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7266)) ## 1.15.3-beta.1 From b7ff4db75dfbbed2c96ae3bfaf95f631a1b60472 Mon Sep 17 00:00:00 2001 From: martincostello Date: Wed, 6 May 2026 16:11:43 +0100 Subject: [PATCH 67/82] [Exporter.Prometheus] Address feedback - Constrain accepted `X-Prometheus-Scrape-Timeout-Seconds` values. - Check cancellation token source. - Do not try to set the status if response has started. --- .../PrometheusExporterMiddleware.cs | 22 +++++++++++++------ .../PrometheusHttpListener.cs | 20 ++++++++++------- .../PrometheusExporterMiddlewareTests.cs | 1 + .../PrometheusHttpListenerTests.cs | 1 + 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs index 21a764fa833..94ee0fa000c 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs @@ -60,12 +60,13 @@ public async Task InvokeAsync(HttpContext httpContext) { using var requestCancelled = new CancellationTokenSource(); - int scrapeTimeoutSeconds = -1; + int? scrapeTimeoutSeconds = null; if (httpContext.Request.Headers.TryGetValue("X-Prometheus-Scrape-Timeout-Seconds", out var value) && - int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out scrapeTimeoutSeconds) && - scrapeTimeoutSeconds > 0) + int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsedValue) && + parsedValue is > 0 and < int.MaxValue / 1_000) { - requestCancelled.CancelAfter(TimeSpan.FromSeconds(scrapeTimeoutSeconds)); + scrapeTimeoutSeconds = parsedValue; + requestCancelled.CancelAfter(TimeSpan.FromSeconds(scrapeTimeoutSeconds.Value)); } using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(requestCancelled.Token, httpContext.RequestAborted); @@ -97,10 +98,17 @@ public async Task InvokeAsync(HttpContext httpContext) PrometheusExporterEventSource.Log.NoMetrics(); } } - catch (OperationCanceledException) when (linkedCts.Token.IsCancellationRequested) + catch (OperationCanceledException ex) when (ex.CancellationToken == linkedCts.Token) { - PrometheusExporterEventSource.Log.ScrapeTimedOut(scrapeTimeoutSeconds); - response.StatusCode = StatusCodes.Status408RequestTimeout; + if (scrapeTimeoutSeconds is { } timeout) + { + PrometheusExporterEventSource.Log.ScrapeTimedOut(timeout); + } + + if (!response.HasStarted) + { + response.StatusCode = StatusCodes.Status408RequestTimeout; + } } finally { diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs index a3b0b0899bd..41b1f45c764 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs @@ -261,12 +261,13 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation { using var requestCancelled = new CancellationTokenSource(); - int scrapeTimeoutSeconds = -1; + int? scrapeTimeoutSeconds = null; if (context.Request.Headers["X-Prometheus-Scrape-Timeout-Seconds"] is { Length: > 0 } value && - int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out scrapeTimeoutSeconds) && - scrapeTimeoutSeconds > 0) + int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsedValue) && + parsedValue is > 0 and < int.MaxValue / 1_000) { - requestCancelled.CancelAfter(TimeSpan.FromSeconds(scrapeTimeoutSeconds)); + scrapeTimeoutSeconds = parsedValue; + requestCancelled.CancelAfter(TimeSpan.FromSeconds(scrapeTimeoutSeconds.Value)); } using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(requestCancelled.Token, cancellationToken); @@ -276,7 +277,7 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation try { - linkedCts.Token.ThrowIfCancellationRequested(); + requestCancelled.Token.ThrowIfCancellationRequested(); context.Response.Headers.Add("Server", string.Empty); @@ -303,9 +304,13 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation PrometheusExporterEventSource.Log.NoMetrics(); } } - catch (OperationCanceledException) when (requestCancelled.Token.IsCancellationRequested) + catch (OperationCanceledException ex) when (ex.CancellationToken == requestCancelled.Token) { - PrometheusExporterEventSource.Log.ScrapeTimedOut(scrapeTimeoutSeconds); + if (scrapeTimeoutSeconds is { } timeout) + { + PrometheusExporterEventSource.Log.ScrapeTimedOut(timeout); + } + context.Response.StatusCode = 408; context.Response.ContentLength64 = 0; } @@ -322,7 +327,6 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation catch (Exception ex) { PrometheusExporterEventSource.Log.FailedExport(ex); - context.Response.StatusCode = 500; } diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs index 93aa4116baa..f35af2030f6 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs @@ -422,6 +422,7 @@ public async Task PrometheusExporterMiddlewareInvokeAsync_WhenRequestDeadlineExc [InlineData("0")] [InlineData("0.9")] [InlineData("1.1")] + [InlineData("2147484")] [InlineData("foo")] public async Task PrometheusExporterMiddlewareInvokeAsync_WhenRequestDeadlineInvalid_Returns200(string scrapeTimeoutSeconds) { diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs index b35b3612fae..d81c64258c1 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs @@ -488,6 +488,7 @@ public async Task WhenRequestDeadlineExceeded_Returns408() [InlineData("0")] [InlineData("0.9")] [InlineData("1.1")] + [InlineData("2147484")] [InlineData("foo")] public async Task WhenRequestDeadlineInvalid_Returns200(string scrapeTimeoutSeconds) { From 14938cc650eac1a0b19e4b92f90d19659fb5a24d Mon Sep 17 00:00:00 2001 From: martincostello Date: Thu, 7 May 2026 12:11:18 +0100 Subject: [PATCH 68/82] [Prometheus.AspNetCore] Fix merge Sort usings. --- .../PrometheusExporterMiddleware.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs index 886314eab10..e9d90075ff4 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs @@ -3,8 +3,8 @@ using System.Collections.Immutable; using System.Diagnostics; -using System.Globalization; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using Microsoft.AspNetCore.Http; using Microsoft.Net.Http.Headers; using OpenTelemetry.Exporter.Prometheus; From 0458ee89b5217ae48400b5c9551e238d8a0ea285 Mon Sep 17 00:00:00 2001 From: martincostello Date: Thu, 7 May 2026 15:57:38 +0100 Subject: [PATCH 69/82] [Prometheus.AspNetCore] Support OTEL_SDK_DISABLED Fix exception being thrown if `OTEL_SDK_DISABLED=true`. See https://github.com/open-telemetry/opentelemetry-dotnet/discussions/7272. --- .../CHANGELOG.md | 3 ++ .../PrometheusExporterMiddleware.cs | 5 ++- .../Internal/PrometheusCollectionManager.cs | 12 +++-- ...xporter.Prometheus.AspNetCore.Tests.csproj | 1 + .../PrometheusIntegrationTests.cs | 45 +++++++++++++++++++ 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 4ec510d2e21..1de08a05f80 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -54,6 +54,9 @@ Notes](../../RELEASENOTES.md). thresholds are present. ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221)) +* Fix `ArgumentException` if `OTEL_SDK_DISABLED=true`. + ([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/issues/TODO)) + ## 1.15.3-beta.1 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs index 694f4de93f1..fd68e56ba8f 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs @@ -36,7 +36,10 @@ public PrometheusExporterMiddleware(MeterProvider meterProvider, RequestDelegate if (!meterProvider.TryFindExporter(out PrometheusExporter? exporter)) { - throw new ArgumentException("A PrometheusExporter could not be found configured on the provided MeterProvider."); + // If the SDK is disabled, just configure a no-op exporter + exporter = meterProvider is OpenTelemetrySdk.NoopMeterProvider + ? new(new()) { Collect = static (_) => true } + : throw new ArgumentException("A PrometheusExporter could not be found configured on the provided MeterProvider."); } this.exporter = exporter; diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs index 2b90298c94a..6d776a370e1 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs @@ -219,9 +219,15 @@ private bool ExecuteCollect(bool openMetricsRequested) { this.exporter.OnExport = this.onCollectRef; this.exporter.OpenMetricsRequested = openMetricsRequested; - var result = this.exporter.Collect!(Timeout.Infinite); - this.exporter.OnExport = null; - return result; + + try + { + return this.exporter.Collect!(Timeout.Infinite); + } + finally + { + this.exporter.OnExport = null; + } } private ExportResult OnCollect(in Batch metrics) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj index a80eacdd61f..5c2beca3d43 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj @@ -35,6 +35,7 @@ + diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index 63d0e0a6b81..29cc78e4d6c 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Diagnostics.Metrics; +using System.Net; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; @@ -19,6 +20,48 @@ namespace OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests; [Collection(PromToolCollection.Name)] public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelper outputHelper) { + [Fact] + + public async Task Scrape_Endpoint_Returns_No_Content_If_Sdk_Disabled() + { + // Arrange + using (EnvironmentVariableScope.Create("OTEL_SDK_DISABLED", "true")) + { + var builder = WebApplication.CreateBuilder(); + + // Listen on any available port + builder.WebHost.UseUrls("http://127.0.0.1:0"); + + builder.Services + .AddOpenTelemetry() + .WithMetrics((builder) => builder.AddPrometheusExporter()); + + using var app = builder.Build(); + + app.MapPrometheusScrapingEndpoint(); + + await app.StartAsync(); + + var server = app.Services.GetRequiredService(); + var addresses = server.Features.Get(); + + var baseAddress = addresses!.Addresses + .Select((p) => new Uri(p)) + .Last(); + + using var httpClient = new HttpClient(); + using var response = await httpClient.GetAsync(new Uri(baseAddress, "metrics")); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(response.Content.Headers.ContentType); + + var content = await response.Content.ReadAsStringAsync(); + + Assert.Empty(content); + } + } + [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] [InlineData("")] [InlineData("text/plain")] @@ -31,6 +74,8 @@ public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelp public async Task Can_Scrape_Prometheus(string accept) { + using var scope = EnvironmentVariableScope.Create("OTEL_SDK_DISABLED", "true"); + // Arrange const string meterName = "prometheus.integration.tests"; const string meterVersion = "1.2.3"; From 54bc9f1c81da5eaa0b9edaa859d7be3e0e36e23e Mon Sep 17 00:00:00 2001 From: martincostello Date: Thu, 7 May 2026 16:24:31 +0100 Subject: [PATCH 70/82] [Prometheus.AspNetCore] Fix test - Disable SDK with in-memory configuration instead of environment variables. - Remove accidentally added environment variable to other integration test. --- ...xporter.Prometheus.AspNetCore.Tests.csproj | 1 - .../PrometheusIntegrationTests.cs | 53 ++++++++++--------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj index 5c2beca3d43..a80eacdd61f 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj @@ -35,7 +35,6 @@ - diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index 29cc78e4d6c..f3ccb46c2a3 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using OpenTelemetry.Exporter.Prometheus.Tests; using OpenTelemetry.Metrics; @@ -25,41 +26,43 @@ public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelp public async Task Scrape_Endpoint_Returns_No_Content_If_Sdk_Disabled() { // Arrange - using (EnvironmentVariableScope.Create("OTEL_SDK_DISABLED", "true")) - { - var builder = WebApplication.CreateBuilder(); + var builder = WebApplication.CreateBuilder(); - // Listen on any available port - builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Configuration.AddInMemoryCollection( + [ + KeyValuePair.Create("OTEL_SDK_DISABLED", "true"), + ]); - builder.Services - .AddOpenTelemetry() - .WithMetrics((builder) => builder.AddPrometheusExporter()); + // Listen on any available port + builder.WebHost.UseUrls("http://127.0.0.1:0"); - using var app = builder.Build(); + builder.Services + .AddOpenTelemetry() + .WithMetrics((builder) => builder.AddPrometheusExporter()); - app.MapPrometheusScrapingEndpoint(); + using var app = builder.Build(); - await app.StartAsync(); + app.MapPrometheusScrapingEndpoint(); - var server = app.Services.GetRequiredService(); - var addresses = server.Features.Get(); + await app.StartAsync(); - var baseAddress = addresses!.Addresses - .Select((p) => new Uri(p)) - .Last(); + var server = app.Services.GetRequiredService(); + var addresses = server.Features.Get(); + + var baseAddress = addresses!.Addresses + .Select((p) => new Uri(p)) + .Last(); - using var httpClient = new HttpClient(); - using var response = await httpClient.GetAsync(new Uri(baseAddress, "metrics")); + using var httpClient = new HttpClient(); + using var response = await httpClient.GetAsync(new Uri(baseAddress, "metrics")); - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Null(response.Content.Headers.ContentType); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(response.Content.Headers.ContentType); - var content = await response.Content.ReadAsStringAsync(); + var content = await response.Content.ReadAsStringAsync(); - Assert.Empty(content); - } + Assert.Empty(content); } [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] @@ -74,8 +77,6 @@ public async Task Scrape_Endpoint_Returns_No_Content_If_Sdk_Disabled() public async Task Can_Scrape_Prometheus(string accept) { - using var scope = EnvironmentVariableScope.Create("OTEL_SDK_DISABLED", "true"); - // Arrange const string meterName = "prometheus.integration.tests"; const string meterVersion = "1.2.3"; From 75234c457b73f042a8778dd529b9191a0a9187d6 Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Thu, 7 May 2026 16:26:03 +0100 Subject: [PATCH 71/82] [Prometheus.AspNetCore] Update CHANGELOG Add PR number. --- src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 1de08a05f80..62274f0b842 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -55,7 +55,7 @@ Notes](../../RELEASENOTES.md). ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221)) * Fix `ArgumentException` if `OTEL_SDK_DISABLED=true`. - ([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/issues/TODO)) + ([#7273](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7273)) ## 1.15.3-beta.1 From fb0b57bb6a9ae70c8107d24b09478652d8b8ce14 Mon Sep 17 00:00:00 2001 From: martincostello Date: Thu, 7 May 2026 17:54:00 +0100 Subject: [PATCH 72/82] [Prometheus.AspNetCore] Address feedback Avoid creating an `PrometheusExporter` and `PrometheusCollectionManager` with 170KB memory overhead when the SDK is disabled. --- .../PrometheusExporterMiddleware.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs index fd68e56ba8f..de23cab98dc 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs @@ -22,7 +22,7 @@ internal sealed class PrometheusExporterMiddleware private const string PrometheusTextMediaType = "text/plain"; - private readonly PrometheusExporter exporter; + private readonly PrometheusExporter? exporter; /// /// Initializes a new instance of the class. @@ -38,7 +38,7 @@ public PrometheusExporterMiddleware(MeterProvider meterProvider, RequestDelegate { // If the SDK is disabled, just configure a no-op exporter exporter = meterProvider is OpenTelemetrySdk.NoopMeterProvider - ? new(new()) { Collect = static (_) => true } + ? null : throw new ArgumentException("A PrometheusExporter could not be found configured on the provided MeterProvider."); } @@ -65,6 +65,14 @@ public async Task InvokeAsync(HttpContext httpContext) try { + if (this.exporter is null) + { + // The SDK was disabled, so we don't have an exporter to use. + // Just return 200 OK with no content as an effective no-op. + response.StatusCode = StatusCodes.Status200OK; + return; + } + var openMetricsRequested = AcceptsOpenMetrics(httpContext.Request); var collectionResponse = await this.exporter.CollectionManager.EnterCollect(openMetricsRequested).ConfigureAwait(false); From 5e90244db07ed127b89ea2cc42b221002911b390 Mon Sep 17 00:00:00 2001 From: martincostello Date: Tue, 5 May 2026 16:32:20 +0100 Subject: [PATCH 73/82] [Prometheus.AspNetCore] Extend integration tests Extend the ASP.NET Core integration tests to include interop between ASP.NET Core and Prometheus itself to scrape metrics. --- .../PrometheusIntegrationTests.cs | 224 ++++++++++++++++-- .../PrometheusFixture.cs | 62 ++++- 2 files changed, 262 insertions(+), 24 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index 63d0e0a6b81..1a92fa7f172 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -3,13 +3,17 @@ using System.Diagnostics; using System.Diagnostics.Metrics; +using System.Net.Http.Json; +using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; using OpenTelemetry.Exporter.Prometheus.Tests; using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; using OpenTelemetry.Tests; using Xunit; using Xunit.Abstractions; @@ -17,8 +21,176 @@ namespace OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests; [Collection(PromToolCollection.Name)] -public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelper outputHelper) +public class PrometheusIntegrationTests(PromToolFixture promtool, ITestOutputHelper outputHelper) { + [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] + [InlineData("")] + [InlineData("OpenMetricsText0.0.1")] + [InlineData("OpenMetricsText1.0.0")] + [InlineData("PrometheusText0.0.4")] + [InlineData("PrometheusText1.0.0")] + + public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await GenerateMetricsAsync(async (baseAddress) => + { + // Arrange + var prometheus = new PrometheusFixture() + { + TargetPort = baseAddress.Port, + }; + + if (!string.IsNullOrEmpty(scrapeProtocol)) + { + prometheus.ScrapeProtocols.Add(scrapeProtocol); + } + + try + { + // Act + await prometheus.StartAsync(); + + var prometheusBaseAddress = prometheus.GetBaseAddress(9090); + + await WaitForServiceDiscoveryAsync(prometheusBaseAddress); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + IReadOnlyList series = []; + + // Assert + while (!cts.IsCancellationRequested) + { + series = await WaitForMetricsSeriesAsync(prometheusBaseAddress); + + if (series.Contains("temperature_celsius")) + { + break; + } + } + + Assert.Contains("http_server_active_requests", series); + Assert.Contains("http_server_request_duration_seconds_bucket", series); + Assert.Contains("http_server_request_duration_seconds_count", series); + Assert.Contains("http_server_request_duration_seconds_sum", series); + Assert.Contains("kestrel_active_connections", series); + Assert.Contains("kestrel_connection_duration_seconds_bucket", series); + Assert.Contains("kestrel_connection_duration_seconds_count", series); + Assert.Contains("kestrel_connection_duration_seconds_sum", series); + Assert.Contains("processed_bytes_total", series); + Assert.Contains("queue_balance", series); + Assert.Contains("temperature_celsius", series); + +#if NET10_0_OR_GREATER + Assert.Contains("aspnetcore_memory_pool_allocated_bytes_total", series); +#endif + } + finally + { + (var stdout, var stderr) = await prometheus.TypedContainer.GetLogsAsync(); + + outputHelper.WriteLine($"[prometheus] [stdout]: {stdout}"); + outputHelper.WriteLine($"[prometheus] [stderr]: {stderr}"); + + await prometheus.DisposeAsync(); + } + + static async Task> WaitForMetricsSeriesAsync(Uri baseAddress) + { + // See https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers + var seriesUrl = QueryHelpers.AddQueryString( + "/api/v1/series", + [ + KeyValuePair.Create("limit", "0"), + KeyValuePair.Create("match[]", "{job=\"prometheus-target\"}"), + ]); + + var seriesUri = new Uri(seriesUrl, UriKind.Relative); + + var frequency = TimeSpan.FromMilliseconds(250); + var timeout = TimeSpan.FromSeconds(15); + + using var client = new HttpClient() { BaseAddress = baseAddress }; + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + try + { + using var metrics = await client.GetFromJsonAsync(seriesUri, cts.Token); + + if (metrics!.RootElement.ValueKind is JsonValueKind.Object && + metrics.RootElement.TryGetProperty("status", out var status) && + status.GetString() == "success") + { + var data = metrics.RootElement.GetProperty("data"); + + if (data.GetArrayLength() > 0) + { + var series = new HashSet(); + + foreach (var seriesElement in data.EnumerateArray()) + { + if (seriesElement.ValueKind is JsonValueKind.Object && + seriesElement.TryGetProperty("__name__", out var name)) + { + series.Add(name.GetString()!); + } + } + + return [.. series]; + } + } + } + catch (Exception) + { + await Task.Delay(frequency); + } + } + + Assert.Fail($"Timed out after {timeout} waiting for metric series."); + return []; + } + + static async Task WaitForServiceDiscoveryAsync(Uri baseAddress) + { + // See https://prometheus.io/docs/prometheus/latest/querying/api/#targets + using var client = new HttpClient() { BaseAddress = baseAddress }; + var targetsUri = new Uri("/api/v1/targets", UriKind.Relative); + + var frequency = TimeSpan.FromMilliseconds(250); + var timeout = TimeSpan.FromSeconds(15); + + using var cts = new CancellationTokenSource(timeout); + + while (!cts.IsCancellationRequested) + { + try + { + using var targets = await client.GetFromJsonAsync(targetsUri, cts.Token); + + if (targets!.RootElement.ValueKind is JsonValueKind.Object && + targets.RootElement.TryGetProperty("status", out var status) && + status.GetString() == "success") + { + var activeTargets = targets.RootElement + .GetProperty("data") + .GetProperty("activeTargets"); + + if (activeTargets.GetArrayLength() > 0) + { + return; + } + } + } + catch (Exception) + { + await Task.Delay(frequency); + } + } + + Assert.Fail($"Timed out after {timeout} waiting for service discovery active targets."); + } + }); + [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] [InlineData("")] [InlineData("text/plain")] @@ -29,7 +201,32 @@ public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelp [InlineData("application/openmetrics-text;version=1.0.0", Skip = "https://github.com/prometheus/prometheus/issues/8932")] [InlineData("application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5,application/openmetrics-text;version=0.0.1;q=0.4,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.3,text/plain;version=0.0.4;q=0.2,/;q=0.1", Skip = "https://github.com/prometheus/prometheus/issues/8932")] - public async Task Can_Scrape_Prometheus(string accept) + public async Task Promtool_Considers_Scrape_Response_Valid(string accept) => await GenerateMetricsAsync(async (baseAddress) => + { + // Act + var actual = await promtool.CheckMetricsAsync(new(baseAddress, "metrics"), accept); + + outputHelper.WriteLine($"[promtool] ExitCode: {actual.ExitCode}"); + outputHelper.WriteLine("[promtool] stdout:"); + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine(actual.Stdout); + + if (!string.IsNullOrEmpty(actual.Stderr)) + { + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine("[promtool] stderr:"); + outputHelper.WriteLine(string.Empty); + outputHelper.WriteLine(actual.Stderr); + } + + // Assert + Assert.Equal(0, actual.ExitCode); + Assert.NotEmpty(actual.Stdout); + Assert.Empty(actual.Stderr); + }); + + private static async Task GenerateMetricsAsync( + Func actAndAssert) { // Arrange const string meterName = "prometheus.integration.tests"; @@ -73,6 +270,7 @@ public async Task Can_Scrape_Prometheus(string accept) builder.Services .AddOpenTelemetry() + .ConfigureResource((builder) => builder.AddService("my-service", "my-namespace", "1.2.3")) .WithMetrics((builder) => { builder.AddAspNetCoreInstrumentation() @@ -152,26 +350,8 @@ public async Task Can_Scrape_Prometheus(string accept) activity.Stop(); - // Act - var actual = await fixture.CheckMetricsAsync(new(baseAddress, "metrics"), accept); - - outputHelper.WriteLine($"[promtool] ExitCode: {actual.ExitCode}"); - outputHelper.WriteLine("[promtool] stdout:"); - outputHelper.WriteLine(string.Empty); - outputHelper.WriteLine(actual.Stdout); - - if (!string.IsNullOrEmpty(actual.Stderr)) - { - outputHelper.WriteLine(string.Empty); - outputHelper.WriteLine("[promtool] stderr:"); - outputHelper.WriteLine(string.Empty); - outputHelper.WriteLine(actual.Stderr); - } - - // Assert - Assert.Equal(0, actual.ExitCode); - Assert.NotEmpty(actual.Stdout); - Assert.Empty(actual.Stderr); + // Act and Assert + await actAndAssert(baseAddress); } private static void WaitForNextExemplarTimestamp() diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs index a7e0189b76f..6dcce5191f8 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs @@ -9,10 +9,68 @@ namespace OpenTelemetry.Exporter.Prometheus.Tests; public class PrometheusFixture : XunitContainerFixture { + private const string DockerInternalHost = "host.docker.internal"; + + public IList ScrapeProtocols { get; } = []; + + public int? TargetPort { get; set; } + protected override string DockerfileName => "prometheus.Dockerfile"; - protected override IContainer CreateContainer() => - new ContainerBuilder(this.GetImage()) + protected override IContainer CreateContainer() + { + if (this.TargetPort is not { } targetPort) + { + throw new InvalidOperationException($"No scrape target port configured."); + } + + var prometheusConfigurationPath = Path.GetTempFileName(); + File.WriteAllText(prometheusConfigurationPath, CreatePrometheusConfiguration(this.ScrapeProtocols)); + + var serviceDiscoveryTargetsPath = Path.GetTempFileName(); + File.WriteAllText(serviceDiscoveryTargetsPath, CreateServiceDiscoveryConfiguration(targetPort)); + +#if NET + if (OperatingSystem.IsLinux()) + { + var mode = UnixFileMode.UserRead | UnixFileMode.GroupRead | UnixFileMode.OtherRead; + File.SetUnixFileMode(prometheusConfigurationPath, mode); + File.SetUnixFileMode(serviceDiscoveryTargetsPath, mode); + } +#endif + + return new ContainerBuilder(this.GetImage()) + .WithBindMount(prometheusConfigurationPath, "/etc/prometheus/prometheus.yml") + .WithBindMount(serviceDiscoveryTargetsPath, "/etc/prometheus/targets/targets.json") + .WithCommand("--config.file=/etc/prometheus/prometheus.yml") + .WithExtraHost(DockerInternalHost, "host-gateway") + .WithPortBinding(4318) .WithPortBinding(9090) + .WithWaitStrategy(Wait.ForUnixContainer().UntilExternalTcpPortIsAvailable(4318)) + .WithWaitStrategy(Wait.ForUnixContainer().UntilExternalTcpPortIsAvailable(9090)) .Build(); + } + + private static string CreatePrometheusConfiguration(IList? scrapeProtocols) => + $""" + global: + scrape_interval: 2s + {(scrapeProtocols?.Count > 0 ? $"scrape_protocols: [\"{string.Join("\", \"", scrapeProtocols)}\"]" : string.Empty)} + scrape_configs: + - job_name: "prometheus-target" + file_sd_configs: + - files: + - /etc/prometheus/targets/targets.json + refresh_interval: 1s + """; + + private static string CreateServiceDiscoveryConfiguration(int port) => + $$""" + [ + { + "labels": { "job": "prometheus-target" }, + "targets": ["{{DockerInternalHost}}:{{port}}"] + } + ] + """; } From ca448e53feace9ed6bccbac40f4981d9c35b74ba Mon Sep 17 00:00:00 2001 From: martincostello Date: Tue, 5 May 2026 17:53:33 +0100 Subject: [PATCH 74/82] [Prometheus.AspNetCore] Address review comments - Log exceptions calling Prometheus. - Use top-level cancellation instead of nested. - Delete temporary configuration files. --- .../PrometheusIntegrationTests.cs | 59 ++++++++++++------- .../PrometheusFixture.cs | 25 ++++++++ test/Shared/ContainerFixture.cs | 2 +- 3 files changed, 65 insertions(+), 21 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index 1a92fa7f172..bb4ed5e12a0 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -50,16 +50,16 @@ public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await var prometheusBaseAddress = prometheus.GetBaseAddress(9090); - await WaitForServiceDiscoveryAsync(prometheusBaseAddress); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await WaitForServiceDiscoveryAsync(prometheusBaseAddress, outputHelper, cts.Token); IReadOnlyList series = []; // Assert while (!cts.IsCancellationRequested) { - series = await WaitForMetricsSeriesAsync(prometheusBaseAddress); + series = await WaitForMetricsSeriesAsync(prometheusBaseAddress, outputHelper, cts.Token); if (series.Contains("temperature_celsius")) { @@ -93,7 +93,10 @@ public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await await prometheus.DisposeAsync(); } - static async Task> WaitForMetricsSeriesAsync(Uri baseAddress) + static async Task> WaitForMetricsSeriesAsync( + Uri baseAddress, + ITestOutputHelper outputHelper, + CancellationToken cancellationToken) { // See https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers var seriesUrl = QueryHelpers.AddQueryString( @@ -106,16 +109,14 @@ static async Task> WaitForMetricsSeriesAsync(Uri baseAddre var seriesUri = new Uri(seriesUrl, UriKind.Relative); var frequency = TimeSpan.FromMilliseconds(250); - var timeout = TimeSpan.FromSeconds(15); using var client = new HttpClient() { BaseAddress = baseAddress }; - using var cts = new CancellationTokenSource(timeout); - while (!cts.IsCancellationRequested) + while (!cancellationToken.IsCancellationRequested) { try { - using var metrics = await client.GetFromJsonAsync(seriesUri, cts.Token); + using var metrics = await client.GetFromJsonAsync(seriesUri, cancellationToken); if (metrics!.RootElement.ValueKind is JsonValueKind.Object && metrics.RootElement.TryGetProperty("status", out var status) && @@ -140,32 +141,41 @@ static async Task> WaitForMetricsSeriesAsync(Uri baseAddre } } } - catch (Exception) + catch (Exception ex) { - await Task.Delay(frequency); + outputHelper.WriteLine($"[prometheus] Exception while waiting for metric series: {ex}"); + + try + { + await Task.Delay(frequency, cancellationToken); + } + catch (TaskCanceledException) + { + break; + } } } - Assert.Fail($"Timed out after {timeout} waiting for metric series."); + Assert.Fail($"Timed out waiting for metric series."); return []; } - static async Task WaitForServiceDiscoveryAsync(Uri baseAddress) + static async Task WaitForServiceDiscoveryAsync( + Uri baseAddress, + ITestOutputHelper outputHelper, + CancellationToken cancellationToken) { // See https://prometheus.io/docs/prometheus/latest/querying/api/#targets using var client = new HttpClient() { BaseAddress = baseAddress }; var targetsUri = new Uri("/api/v1/targets", UriKind.Relative); var frequency = TimeSpan.FromMilliseconds(250); - var timeout = TimeSpan.FromSeconds(15); - - using var cts = new CancellationTokenSource(timeout); - while (!cts.IsCancellationRequested) + while (!cancellationToken.IsCancellationRequested) { try { - using var targets = await client.GetFromJsonAsync(targetsUri, cts.Token); + using var targets = await client.GetFromJsonAsync(targetsUri, cancellationToken); if (targets!.RootElement.ValueKind is JsonValueKind.Object && targets.RootElement.TryGetProperty("status", out var status) && @@ -181,13 +191,22 @@ static async Task WaitForServiceDiscoveryAsync(Uri baseAddress) } } } - catch (Exception) + catch (Exception ex) { - await Task.Delay(frequency); + outputHelper.WriteLine($"[prometheus] Exception while waiting for service discovery: {ex}"); + + try + { + await Task.Delay(frequency, cancellationToken); + } + catch (TaskCanceledException) + { + break; + } } } - Assert.Fail($"Timed out after {timeout} waiting for service discovery active targets."); + Assert.Fail($"Timed out waiting for service discovery active targets."); } }); diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs index 6dcce5191f8..ca68cc72746 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusFixture.cs @@ -11,12 +11,33 @@ public class PrometheusFixture : XunitContainerFixture { private const string DockerInternalHost = "host.docker.internal"; + private readonly HashSet temporaryFiles = []; + public IList ScrapeProtocols { get; } = []; public int? TargetPort { get; set; } protected override string DockerfileName => "prometheus.Dockerfile"; + public override ValueTask DisposeAsync() + { + foreach (var path in this.temporaryFiles) + { + try + { + File.Delete(path); + } + catch (Exception) + { + // Ignore + } + } + + GC.SuppressFinalize(this); + + return base.DisposeAsync(); + } + protected override IContainer CreateContainer() { if (this.TargetPort is not { } targetPort) @@ -25,9 +46,13 @@ protected override IContainer CreateContainer() } var prometheusConfigurationPath = Path.GetTempFileName(); + this.temporaryFiles.Add(prometheusConfigurationPath); + File.WriteAllText(prometheusConfigurationPath, CreatePrometheusConfiguration(this.ScrapeProtocols)); var serviceDiscoveryTargetsPath = Path.GetTempFileName(); + this.temporaryFiles.Add(serviceDiscoveryTargetsPath); + File.WriteAllText(serviceDiscoveryTargetsPath, CreateServiceDiscoveryConfiguration(targetPort)); #if NET diff --git a/test/Shared/ContainerFixture.cs b/test/Shared/ContainerFixture.cs index 49a128816e6..cdad67fa094 100644 --- a/test/Shared/ContainerFixture.cs +++ b/test/Shared/ContainerFixture.cs @@ -13,7 +13,7 @@ public abstract class ContainerFixture : IAsyncDisposable protected abstract string DockerfileName { get; } - public async ValueTask DisposeAsync() + public virtual async ValueTask DisposeAsync() { if (this.started) { From af91589a40e060b1be26fc02adec7db964892399 Mon Sep 17 00:00:00 2001 From: martincostello Date: Thu, 7 May 2026 20:01:44 +0100 Subject: [PATCH 75/82] [Prometheus.AspNetCore] Support GZip Add support for GZip compression. Resolves #7213. --- .../CHANGELOG.md | 4 ++ ...etry.Exporter.Prometheus.AspNetCore.csproj | 1 + .../PrometheusExporterMiddleware.cs | 54 ++++++++++++++++-- .../PrometheusExporterMiddlewareTests.cs | 2 +- .../PrometheusIntegrationTests.cs | 55 ++++++++++++++++++- 5 files changed, 108 insertions(+), 8 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 7a268bfbf87..76132f16ebd 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -54,6 +54,10 @@ Notes](../../RELEASENOTES.md). `X-Prometheus-Scrape-Timeout-Seconds` HTTP request header. ([#7252](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7252)) +* GZip compress scrape endpoint responses when `Accept-Encoding: gzip` is + specified by the HTTP request headers. + ([#7274](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7274)) + ## 1.15.3-beta.1 Released 2026-Apr-21 diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj index fed1f9066ee..e7d6371d727 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj @@ -7,6 +7,7 @@ $(PackageTags);prometheus;metrics coreunstable- $(DefineConstants);PROMETHEUS_ASPNETCORE + $(NoWarn);CA2007 diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs index 94ee0fa000c..538a7046af7 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs @@ -3,7 +3,9 @@ using System.Diagnostics; using System.Globalization; +using System.IO.Compression; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Headers; using Microsoft.Net.Http.Headers; using OpenTelemetry.Exporter.Prometheus; using OpenTelemetry.Internal; @@ -71,8 +73,10 @@ public async Task InvokeAsync(HttpContext httpContext) using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(requestCancelled.Token, httpContext.RequestAborted); - var openMetricsRequested = AcceptsOpenMetrics(httpContext.Request); - var collectionResponse = await this.exporter.CollectionManager.EnterCollect(openMetricsRequested).ConfigureAwait(false); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + + var openMetricsRequested = AcceptsOpenMetrics(requestHeaders); + var collectionResponse = await this.exporter.CollectionManager.EnterCollect(openMetricsRequested); try { @@ -90,7 +94,7 @@ public async Task InvokeAsync(HttpContext httpContext) ? OpenMetricsContentType : "text/plain; charset=utf-8; version=0.0.4"; - await response.Body.WriteAsync(dataView.Array.AsMemory(0, dataView.Count), linkedCts.Token).ConfigureAwait(false); + await WriteResponseAsync(response, dataView.Array.AsMemory(0, dataView.Count), AcceptsGZip(requestHeaders), linkedCts.Token); } else { @@ -125,9 +129,9 @@ public async Task InvokeAsync(HttpContext httpContext) } } - internal static bool AcceptsOpenMetrics(HttpRequest request) + internal static bool AcceptsOpenMetrics(RequestHeaders headers) { - var acceptHeader = request.GetTypedHeaders().Accept; + var acceptHeader = headers.Accept; if (acceptHeader is not { Count: > 0 }) { @@ -186,4 +190,44 @@ private static bool HasSupportedOpenMetricsParameters(MediaTypeHeaderValue value return hasSupportedOpenMetricsVersion && hasSupportedOpenMetricsEscaping; } + + private static bool AcceptsGZip(RequestHeaders headers) + { + if (headers.AcceptEncoding is { Count: > 0 } acceptEncoding) + { + foreach (var parameter in acceptEncoding) + { + if (parameter.Value.Equals("gzip", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + return false; + } + + private static async Task WriteResponseAsync( + HttpResponse response, + ReadOnlyMemory content, + bool compress, + CancellationToken cancellationToken) + { + if (compress) + { + response.Headers.Append("Content-Encoding", "gzip"); + + await using var gzip = new GZipStream( + response.Body, + CompressionLevel.Optimal, + leaveOpen: true); + + await gzip.WriteAsync(content, cancellationToken); + await gzip.FlushAsync(cancellationToken); + } + else + { + await response.BodyWriter.WriteAsync(content, cancellationToken); + } + } } diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs index f35af2030f6..c143598441a 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs @@ -256,7 +256,7 @@ public void PrometheusExporterMiddlewareAcceptsOpenMetrics_UsesTypedAcceptHeader var context = new DefaultHttpContext(); context.Request.Headers.Accept = header; - var result = PrometheusExporterMiddleware.AcceptsOpenMetrics(context.Request); + var result = PrometheusExporterMiddleware.AcceptsOpenMetrics(context.Request.GetTypedHeaders()); Assert.Equal(expected, result); } diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index bb4ed5e12a0..34676aa5e53 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -3,6 +3,8 @@ using System.Diagnostics; using System.Diagnostics.Metrics; +using System.IO.Compression; +using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Builder; @@ -10,6 +12,7 @@ using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using OpenTelemetry.Exporter.Prometheus.Tests; using OpenTelemetry.Metrics; @@ -23,13 +26,62 @@ namespace OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests; [Collection(PromToolCollection.Name)] public class PrometheusIntegrationTests(PromToolFixture promtool, ITestOutputHelper outputHelper) { + [Fact] + public async Task Scrape_Endpoint_Uses_GZip_When_Requested() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + + // Listen on any available port + builder.WebHost.UseUrls("http://127.0.0.1:0"); + + builder.Services + .AddOpenTelemetry() + .WithMetrics((builder) => builder.AddPrometheusExporter()); + + using var app = builder.Build(); + + app.MapPrometheusScrapingEndpoint(); + + await app.StartAsync(); + + var server = app.Services.GetRequiredService(); + var addresses = server.Features.Get(); + + var baseAddress = addresses!.Addresses + .Select((p) => new Uri(p)) + .Last(); + + using var httpClient = new HttpClient(); + + httpClient.DefaultRequestHeaders.Add("Accept-Encoding", "gzip"); + + using var response = await httpClient.GetAsync(new Uri(baseAddress, "metrics")); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + Assert.NotNull(response.Content.Headers.ContentEncoding); + Assert.Equal(["gzip"], response.Content.Headers.ContentEncoding); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain; charset=utf-8; version=0.0.4", response.Content.Headers.ContentType.ToString()); + + using var compressed = await response.Content.ReadAsStreamAsync(); + using var decompressed = new GZipStream(compressed, CompressionMode.Decompress); + using var reader = new StreamReader(decompressed); + + var content = await reader.ReadToEndAsync(); + + Assert.NotEmpty(content); + Assert.EndsWith(content, "# EOF\n", StringComparison.Ordinal); + } + [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] [InlineData("")] [InlineData("OpenMetricsText0.0.1")] [InlineData("OpenMetricsText1.0.0")] [InlineData("PrometheusText0.0.4")] [InlineData("PrometheusText1.0.0")] - public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await GenerateMetricsAsync(async (baseAddress) => { // Arrange @@ -219,7 +271,6 @@ static async Task WaitForServiceDiscoveryAsync( [InlineData("application/openmetrics-text;version=0.0.4")] [InlineData("application/openmetrics-text;version=1.0.0", Skip = "https://github.com/prometheus/prometheus/issues/8932")] [InlineData("application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.5,application/openmetrics-text;version=0.0.1;q=0.4,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.3,text/plain;version=0.0.4;q=0.2,/;q=0.1", Skip = "https://github.com/prometheus/prometheus/issues/8932")] - public async Task Promtool_Considers_Scrape_Response_Valid(string accept) => await GenerateMetricsAsync(async (baseAddress) => { // Act From 3638d11fe8dad7efde8065eddc60bcf216561c7f Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 9 May 2026 17:49:38 +0100 Subject: [PATCH 76/82] [Exporter.Prometheus] Improve performance - Improve the performance of `PrometheusSerializer` by: - Using `SearchValues` on .NET 8+ - Writing common label value types directly instead of relying on `ToString()` - Formatting numeric values directly as UTF-8 on .NET 8+ - Caching serialized metric names, metadata names, units, and static tag prefixes - Reusing serialized tags across histogram bucket/sum/count output --- .../Internal/PrometheusCollectionManager.cs | 8 +- .../Internal/PrometheusMetric.cs | 24 + .../Internal/PrometheusSerializer.cs | 432 +++++++++++++----- .../Internal/PrometheusSerializerExt.cs | 33 +- .../PrometheusSerializerTests.cs | 46 ++ 5 files changed, 411 insertions(+), 132 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs index 60851921d58..9f4346f3743 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs @@ -268,7 +268,7 @@ private ExportResult OnCollect(in Batch metrics) break; } - catch (IndexOutOfRangeException) + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) { if (!IncreaseBufferSize(ref buffer)) { @@ -308,7 +308,7 @@ private ExportResult OnCollect(in Batch metrics) break; } - catch (IndexOutOfRangeException) + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) { if (!IncreaseBufferSize(ref buffer)) { @@ -325,7 +325,7 @@ private ExportResult OnCollect(in Batch metrics) cursor = PrometheusSerializer.WriteEof(buffer, cursor); break; } - catch (IndexOutOfRangeException) + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) { if (!IncreaseBufferSize(ref buffer)) { @@ -379,7 +379,7 @@ private int WriteTargetInfo(ref byte[] buffer) break; } - catch (IndexOutOfRangeException) + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) { if (!IncreaseBufferSize(ref buffer)) { diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs index f4086aeb1f7..47fd01bba4b 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs @@ -65,6 +65,22 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa this.OpenMetricsMetadataName = openMetricsMetadataName; this.Unit = sanitizedUnit; this.Type = type; + this.NameBytes = ConvertToAsciiBytes(sanitizedName); + this.OpenMetricsNameBytes = ConvertToAsciiBytes(openMetricsName); + this.OpenMetricsMetadataNameBytes = ConvertToAsciiBytes(openMetricsMetadataName); + this.UnitBytes = sanitizedUnit == null ? null : ConvertToAsciiBytes(sanitizedUnit); + + static byte[] ConvertToAsciiBytes(string value) + { + var bytes = new byte[value.Length]; + + for (var i = 0; i < value.Length; i++) + { + bytes[i] = unchecked((byte)value[i]); + } + + return bytes; + } } public string Name { get; } @@ -77,6 +93,14 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa public PrometheusType Type { get; } + internal byte[] NameBytes { get; } + + internal byte[] OpenMetricsNameBytes { get; } + + internal byte[] OpenMetricsMetadataNameBytes { get; } + + internal byte[]? UnitBytes { get; } + public static PrometheusMetric Create(Metric metric, bool disableTotalNameSuffixForCounters) => new(metric.Name, metric.Unit, GetPrometheusType(metric.MetricType), disableTotalNameSuffixForCounters); diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 3e46f3edc6c..4fabc398c59 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -26,6 +26,11 @@ internal static partial class PrometheusSerializer private const byte ASCII_LINEFEED = 0x0A; // `\n` #pragma warning restore SA1310 // Field name should not contain an underscore +#if NET8_0_OR_GREATER + private static readonly SearchValues UnicodeEscapeChars = SearchValues.Create("\\\n"); + private static readonly SearchValues LabelValueEscapeChars = SearchValues.Create("\"\\\n"); +#endif + #if !NET private static readonly DateTimeOffset UnixEpoch = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); #endif @@ -84,15 +89,30 @@ public static int WriteLong(byte[] buffer, int cursor, long value) #endif } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteUnsignedLong(byte[] buffer, int cursor, ulong value) + { +#if NET + var result = Utf8Formatter.TryFormat(value, buffer.AsSpan(cursor), out var bytesWritten); + return AdvanceCursorOrThrow(result, cursor, bytesWritten); +#else + return WriteAsciiStringNoEscape(buffer, cursor, value.ToString(CultureInfo.InvariantCulture)); +#endif + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteAsciiStringNoEscape(byte[] buffer, int cursor, string value) { +#if NET8_0_OR_GREATER + return WriteUtf8NoEscape(buffer, cursor, value.AsSpan()); +#else for (var i = 0; i < value.Length; i++) { buffer[cursor++] = unchecked((byte)value[i]); } return cursor; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -128,28 +148,7 @@ public static int WriteUnicodeNoEscape(byte[] buffer, int cursor, int ordinal) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteUnicodeString(byte[] buffer, int cursor, string value) - { - for (var i = 0; i < value.Length; i++) - { - var ordinal = (ushort)value[i]; - switch (ordinal) - { - case ASCII_REVERSE_SOLIDUS: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - break; - case ASCII_LINEFEED: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = unchecked((byte)'n'); - break; - default: - cursor = WriteUnicodeScalar(buffer, cursor, value, ref i); - break; - } - } - - return cursor; - } + => WriteEscapedString(buffer, cursor, value, escapeQuotationMarks: false); [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteLabelKey(byte[] buffer, int cursor, string value, bool openMetricsRequested) @@ -157,81 +156,83 @@ public static int WriteLabelKey(byte[] buffer, int cursor, string value, bool op [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteLabelValue(byte[] buffer, int cursor, string value) + => WriteEscapedString(buffer, cursor, value, escapeQuotationMarks: true); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteLabelValue(byte[] buffer, int cursor, object? value) { - for (var i = 0; i < value.Length; i++) + switch (value) { - var ordinal = (ushort)value[i]; - switch (ordinal) - { - case ASCII_QUOTATION_MARK: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = ASCII_QUOTATION_MARK; - break; - case ASCII_REVERSE_SOLIDUS: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - break; - case ASCII_LINEFEED: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = unchecked((byte)'n'); - break; - default: - cursor = WriteUnicodeScalar(buffer, cursor, value, ref i); - break; - } - } + case null: + return cursor; - return cursor; - } + case string stringValue: + return WriteLabelValue(buffer, cursor, stringValue); - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteLabel(byte[] buffer, int cursor, string labelKey, object? labelValue, bool openMetricsRequested) - { - cursor = WriteLabelKey(buffer, cursor, labelKey, openMetricsRequested); - buffer[cursor++] = unchecked((byte)'='); - buffer[cursor++] = unchecked((byte)'"'); + case bool boolValue: + return WriteAsciiStringNoEscape(buffer, cursor, boolValue ? "true" : "false"); - // In Prometheus, a label with an empty label value is considered equivalent to a label that does not exist. - cursor = WriteLabelValue(buffer, cursor, GetLabelValueString(labelValue)); - buffer[cursor++] = unchecked((byte)'"'); + case sbyte signedByteValue: + return WriteLong(buffer, cursor, signedByteValue); - return cursor; - } + case byte byteValue: + return WriteLong(buffer, cursor, byteValue); - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteMetricName(byte[] buffer, int cursor, PrometheusMetric metric, bool openMetricsRequested) - { - // Metric name has already been escaped. - var name = openMetricsRequested ? metric.OpenMetricsName : metric.Name; + case short shortValue: + return WriteLong(buffer, cursor, shortValue); - Debug.Assert(!string.IsNullOrWhiteSpace(name), "name was null or whitespace"); + case ushort unsignedShortValue: + return WriteLong(buffer, cursor, unsignedShortValue); - for (var i = 0; i < name.Length; i++) - { - var ordinal = (ushort)name[i]; - buffer[cursor++] = unchecked((byte)ordinal); - } + case int intValue: + return WriteLong(buffer, cursor, intValue); - return cursor; - } + case uint unsignedIntValue: + return WriteLong(buffer, cursor, unsignedIntValue); - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteMetricMetadataName(byte[] buffer, int cursor, PrometheusMetric metric, bool openMetricsRequested) - { - // Metric name has already been escaped. - var name = openMetricsRequested ? metric.OpenMetricsMetadataName : metric.Name; + case long longValue: + return WriteLong(buffer, cursor, longValue); - Debug.Assert(!string.IsNullOrWhiteSpace(name), "name was null or whitespace"); + case ulong unsignedLongValue: + return WriteUnsignedLong(buffer, cursor, unsignedLongValue); - for (var i = 0; i < name.Length; i++) - { - var ordinal = (ushort)name[i]; - buffer[cursor++] = unchecked((byte)ordinal); + case float floatValue: + return WriteDouble(buffer, cursor, floatValue); + + case double doubleValue: + return WriteDouble(buffer, cursor, doubleValue); + + case decimal decimalValue: +#if NET + var result = Utf8Formatter.TryFormat(decimalValue, buffer.AsSpan(cursor), out var bytesWritten); + return AdvanceCursorOrThrow(result, cursor, bytesWritten); +#else + return WriteLabelValue(buffer, cursor, decimalValue.ToString(CultureInfo.InvariantCulture)); +#endif + + case IFormattable formattableValue: + return WriteLabelValue(buffer, cursor, formattableValue.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty); + + default: + return WriteLabelValue(buffer, cursor, value.ToString() ?? string.Empty); } + } - return cursor; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteLabel(byte[] buffer, int cursor, string labelKey, object? labelValue, bool openMetricsRequested) + { + cursor = WriteLabelKey(buffer, cursor, labelKey, openMetricsRequested); + return WriteSanitizedLabel(buffer, cursor, labelValue); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteMetricName(byte[] buffer, int cursor, PrometheusMetric metric, bool openMetricsRequested) + => WriteUtf8NoEscape(buffer, cursor, openMetricsRequested ? metric.OpenMetricsNameBytes : metric.NameBytes); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteMetricMetadataName(byte[] buffer, int cursor, PrometheusMetric metric, bool openMetricsRequested) + => WriteUtf8NoEscape(buffer, cursor, openMetricsRequested ? metric.OpenMetricsMetadataNameBytes : metric.NameBytes); + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteEof(byte[] buffer, int cursor) { @@ -354,12 +355,19 @@ public static int WriteUnitMetadata(byte[] buffer, int cursor, PrometheusMetric buffer[cursor++] = unchecked((byte)' '); // Unit name has already been escaped. + if (string.Equals(unit, metric.Unit, StringComparison.Ordinal) && metric.UnitBytes != null) + { + cursor = WriteUtf8NoEscape(buffer, cursor, metric.UnitBytes); + } + else + { #pragma warning disable IDE0370 // Remove unnecessary suppression - for (var i = 0; i < unit!.Length; i++) + for (var i = 0; i < unit!.Length; i++) #pragma warning restore IDE0370 // Remove unnecessary suppression - { - var ordinal = (ushort)unit[i]; - buffer[cursor++] = unchecked((byte)ordinal); + { + var ordinal = (ushort)unit[i]; + buffer[cursor++] = unchecked((byte)ordinal); + } } buffer[cursor++] = ASCII_LINEFEED; @@ -420,7 +428,7 @@ public static int WriteTags( AddLabel(tag.Key, tag.Value, openMetricsRequested, ref labels); } - return WriteLabels(buffer, cursor, labels, openMetricsRequested, writeEnclosingBraces); + return WriteLabels(buffer, cursor, labels, writeEnclosingBraces); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -453,7 +461,7 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource, AddLabel(attribute.Key, attribute.Value, openMetricsRequested, ref labels); } - cursor = WriteLabels(buffer, cursor, labels, openMetricsRequested, writeEnclosingBraces: true); + cursor = WriteLabels(buffer, cursor, labels, writeEnclosingBraces: true); buffer[cursor++] = unchecked((byte)' '); buffer[cursor++] = unchecked((byte)'1'); buffer[cursor++] = ASCII_LINEFEED; @@ -471,8 +479,8 @@ internal static string CreateScopeIdentity(Metric metric) scopeLabels.Sort(static (x, y) => { - var keyCompare = string.CompareOrdinal(x.Key, y.Key); - return keyCompare != 0 ? keyCompare : string.CompareOrdinal(x.Value, y.Value); + var keyCompare = string.CompareOrdinal(x.OutputKey, y.OutputKey); + return keyCompare != 0 ? keyCompare : string.CompareOrdinal(GetLabelValueString(x.Value), GetLabelValueString(y.Value)); }); var builder = new StringBuilder(); @@ -480,9 +488,9 @@ internal static string CreateScopeIdentity(Metric metric) foreach (var label in scopeLabels) { builder.Append('\0') - .Append(label.Key) + .Append(label.OutputKey) .Append('\0') - .Append(label.Value); + .Append(GetLabelValueString(label.Value)); } return builder.ToString(); @@ -533,21 +541,46 @@ private static int WriteScopeLabels(byte[] buffer, int cursor, Metric metric, bo { List? labels = null; AddScopeLabels(metric, openMetricsRequested, ref labels); - return WriteLabels(buffer, cursor, labels, openMetricsRequested, writeEnclosingBraces: true); + return WriteLabels(buffer, cursor, labels, writeEnclosingBraces: true); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan value) + { + if (value.Length > buffer.Length - cursor) + { + throw new ArgumentException("Destination buffer too small.", nameof(buffer)); + } + + value.CopyTo(buffer.AsSpan(cursor)); + return cursor + value.Length; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int WriteSanitizedLabel(byte[] buffer, int cursor, object? labelValue) + { + buffer[cursor++] = unchecked((byte)'='); + buffer[cursor++] = unchecked((byte)'"'); + + // In Prometheus, a label with an empty label value is considered equivalent to a label that does not exist. + cursor = WriteLabelValue(buffer, cursor, labelValue); + buffer[cursor++] = unchecked((byte)'"'); + + return cursor; } private static void AddScopeLabels(Metric metric, bool openMetricsRequested, ref List? labels) { - AddLabel("otel_scope_name", "otel_scope_name", metric.MeterName, openMetricsRequested, ref labels); + AddLabel("otel_scope_name", "otel_scope_name", metric.MeterName, ref labels); if (!string.IsNullOrEmpty(metric.MeterVersion)) { - AddLabel("otel_scope_version", "otel_scope_version", metric.MeterVersion, openMetricsRequested, ref labels); + AddLabel("otel_scope_version", "otel_scope_version", metric.MeterVersion, ref labels); } if (!string.IsNullOrEmpty(metric.MeterSchemaUrl)) { - AddLabel("otel_scope_schema_url", "otel_scope_schema_url", metric.MeterSchemaUrl, openMetricsRequested, ref labels); + AddLabel("otel_scope_schema_url", "otel_scope_schema_url", metric.MeterSchemaUrl, ref labels); } if (metric.MeterTags != null) @@ -565,22 +598,25 @@ private static void AddScopeLabels(Metric metric, bool openMetricsRequested, ref private static string GetLabelValueString(object? labelValue) { - // TODO: Attribute values should be written as their JSON representation. Extra logic may need to be added here to correctly convert other .NET types. - // More detail: https://github.com/open-telemetry/opentelemetry-dotnet/issues/4822#issuecomment-1707328495 - if (labelValue is bool booleanValue) - { - return booleanValue ? "true" : "false"; - } - else if (labelValue is double doubleValue) - { - return DoubleToString(doubleValue); - } - else if (labelValue is float floatValue) - { - return DoubleToString(floatValue); - } - - return labelValue?.ToString() ?? string.Empty; + return labelValue switch + { + null => string.Empty, + string stringValue => stringValue, + bool booleanValue => booleanValue ? "true" : "false", + sbyte signedByteValue => signedByteValue.ToString(CultureInfo.InvariantCulture), + byte byteValue => byteValue.ToString(CultureInfo.InvariantCulture), + short shortValue => shortValue.ToString(CultureInfo.InvariantCulture), + ushort unsignedShortValue => unsignedShortValue.ToString(CultureInfo.InvariantCulture), + int intValue => intValue.ToString(CultureInfo.InvariantCulture), + uint unsignedIntValue => unsignedIntValue.ToString(CultureInfo.InvariantCulture), + long longValue => longValue.ToString(CultureInfo.InvariantCulture), + ulong unsignedLongValue => unsignedLongValue.ToString(CultureInfo.InvariantCulture), + double doubleValue => DoubleToString(doubleValue), + float floatValue => DoubleToString(floatValue), + decimal decimalValue => decimalValue.ToString(CultureInfo.InvariantCulture), + IFormattable formattableValue => formattableValue.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty, + _ => labelValue.ToString() ?? string.Empty, + }; static string DoubleToString(double value) { @@ -664,15 +700,15 @@ private static int AppendSanitizedLabelKeyCharacter(byte[]? buffer, int cursor, } private static void AddLabel(string originalKey, object? value, bool openMetricsRequested, ref List? labels) - => AddLabel(originalKey, GetSanitizedLabelKey(originalKey, openMetricsRequested), value, openMetricsRequested, ref labels); + => AddLabel(originalKey, GetSanitizedLabelKey(originalKey, openMetricsRequested), value, ref labels); - private static void AddLabel(string originalKey, string outputKey, object? value, bool openMetricsRequested, ref List? labels) + private static void AddLabel(string originalKey, string outputKey, object? value, ref List? labels) { labels ??= []; - labels.Add(new LabelData(originalKey, GetSanitizedLabelKey(outputKey, openMetricsRequested), GetLabelValueString(value))); + labels.Add(new LabelData(originalKey, outputKey, value)); } - private static List> MergeLabels(IReadOnlyList? labels) + private static List MergeLabels(IReadOnlyList? labels) { if (labels == null || labels.Count == 0) { @@ -694,32 +730,43 @@ private static List> MergeLabels(IReadOnlyList>(orderedKeys.Count); + var mergedLabels = new List(orderedKeys.Count); foreach (var key in orderedKeys) { - mergedLabels.Add(new(key, GetMergedLabelValue(labelsBySanitizedKey[key]))); + var bucket = labelsBySanitizedKey[key]; + mergedLabels.Add( + bucket.Count == 1 + ? bucket[0] + : new LabelData(bucket[0].OriginalKey, key, GetMergedLabelValue(bucket))); } return mergedLabels; } - private static int WriteLabels(byte[] buffer, int cursor, IReadOnlyList? labels, bool openMetricsRequested, bool writeEnclosingBraces) + private static int WriteLabels(byte[] buffer, int cursor, IReadOnlyList? labels, bool writeEnclosingBraces) { if (writeEnclosingBraces) { buffer[cursor++] = unchecked((byte)'{'); } - foreach (var label in MergeLabels(labels)) + IReadOnlyList labelsToWrite = labels ?? []; + if (labels is { Count: > 1 } && HasDuplicateOutputKeys(labels)) { - cursor = WriteLabel(buffer, cursor, label.Key, label.Value, openMetricsRequested); + labelsToWrite = MergeLabels(labels); + } + + foreach (var label in labelsToWrite) + { + cursor = WriteAsciiStringNoEscape(buffer, cursor, label.OutputKey); + cursor = WriteSanitizedLabel(buffer, cursor, label.Value); buffer[cursor++] = unchecked((byte)','); } if (writeEnclosingBraces) { - if (labels != null && labels.Count > 0) + if (labelsToWrite.Count > 0) { buffer[cursor - 1] = unchecked((byte)'}'); } @@ -728,7 +775,7 @@ private static int WriteLabels(byte[] buffer, int cursor, IReadOnlyList 0) + else if (labelsToWrite.Count > 0) { cursor--; } @@ -736,11 +783,27 @@ private static int WriteLabels(byte[] buffer, int cursor, IReadOnlyList labels) + { + for (var i = 0; i < labels.Count - 1; i++) + { + for (var j = i + 1; j < labels.Count; j++) + { + if (string.Equals(labels[i].OutputKey, labels[j].OutputKey, StringComparison.Ordinal)) + { + return true; + } + } + } + + return false; + } + private static string GetMergedLabelValue(List labels) { if (labels.Count == 1) { - return labels[0].Value; + return GetLabelValueString(labels[0].Value); } labels.Sort(static (left, right) => string.CompareOrdinal(left.OriginalKey, right.OriginalKey)); @@ -753,7 +816,7 @@ private static string GetMergedLabelValue(List labels) builder.Append(';'); } - builder.Append(labels[i].Value); + builder.Append(GetLabelValueString(labels[i].Value)); } return builder.ToString(); @@ -777,6 +840,97 @@ private static bool TryCreateScopeLabel(KeyValuePair tag, bool private static bool IsAllowedMetricsLabelCharacter(char value, bool isOpenMetrics) => char.IsAsciiLetterOrDigit(value) || value is '_' || (isOpenMetrics && value == ':'); +#if NET8_0_OR_GREATER + private static int WriteEscapedString(byte[] buffer, int cursor, string value, bool escapeQuotationMarks) + => WriteEscapedUtf8String(buffer, cursor, value.AsSpan(), escapeQuotationMarks ? LabelValueEscapeChars : UnicodeEscapeChars); + + private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan value) + { + var bytesRequired = Encoding.UTF8.GetByteCount(value); + return bytesRequired > buffer.Length - cursor + ? throw new ArgumentException("Destination buffer too small.", nameof(buffer)) + : cursor + Encoding.UTF8.GetBytes(value, buffer.AsSpan(cursor)); + } + + private static int WriteEscapedUtf8String(byte[] buffer, int cursor, ReadOnlySpan value, SearchValues escapedChars) + { + while (!value.IsEmpty) + { + var escapedIndex = value.IndexOfAny(escapedChars); + var nonAsciiIndex = value.IndexOfAnyExceptInRange((char)0x00, (char)0x7F); + + var specialIndex = + escapedIndex < 0 ? nonAsciiIndex + : nonAsciiIndex < 0 ? escapedIndex + : Math.Min(escapedIndex, nonAsciiIndex); + + if (specialIndex < 0) + { + return WriteUtf8NoEscape(buffer, cursor, value); + } + + if (specialIndex > 0) + { + cursor = WriteUtf8NoEscape(buffer, cursor, value[..specialIndex]); + value = value[specialIndex..]; + } + + switch (value[0]) + { + case '"': + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = ASCII_QUOTATION_MARK; + value = value[1..]; + break; + case '\\': + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + value = value[1..]; + break; + case '\n': + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = unchecked((byte)'n'); + value = value[1..]; + break; + default: + cursor = WriteUnicodeNoEscape(buffer, cursor, GetUnicodeOrdinal(value, out var charsConsumed)); + value = value[charsConsumed..]; + break; + } + } + + return cursor; + } +#else + private static int WriteEscapedString(byte[] buffer, int cursor, string value, bool escapeQuotationMarks) + { + for (var i = 0; i < value.Length; i++) + { + var ordinal = (ushort)value[i]; + switch (ordinal) + { + case ASCII_QUOTATION_MARK when escapeQuotationMarks: + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = ASCII_QUOTATION_MARK; + break; + case ASCII_REVERSE_SOLIDUS: + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + break; + case ASCII_LINEFEED: + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = unchecked((byte)'n'); + break; + default: + cursor = WriteUnicodeScalar(buffer, cursor, value, ref i); + break; + } + } + + return cursor; + } +#endif + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int WriteUnicodeScalar(byte[] buffer, int cursor, string value, ref int index) { @@ -798,6 +952,34 @@ private static int WriteUnicodeScalar(byte[] buffer, int cursor, string value, r return WriteUnicodeNoEscape(buffer, cursor, 0xFFFD); } + private static int GetUnicodeOrdinal(ReadOnlySpan value, out int charsConsumed) + { + const int UnicodeReplacementCharacter = 0xFFFD; + + var character = value[0]; + + if (char.IsHighSurrogate(character)) + { + if (value.Length > 1 && char.IsLowSurrogate(value[1])) + { + charsConsumed = 2; + return char.ConvertToUtf32(character, value[1]); + } + + charsConsumed = 1; + return UnicodeReplacementCharacter; + } + + if (char.IsLowSurrogate(character)) + { + charsConsumed = 1; + return UnicodeReplacementCharacter; + } + + charsConsumed = 1; + return character; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int WriteUnixTimeSeconds(byte[] buffer, int cursor, DateTimeOffset value) => #if NET @@ -1006,12 +1188,12 @@ private static bool TryGetPowerOfTenExponent(double absoluteValue, out int expon return true; } - private readonly struct LabelData(string originalKey, string outputKey, string value) + private readonly struct LabelData(string originalKey, string outputKey, object? value) { public readonly string OriginalKey { get; } = originalKey; public readonly string OutputKey { get; } = outputKey; - public readonly string Value { get; } = value; + public readonly object? Value { get; } = value; } } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs index a9402a09eef..e8ce2222fd4 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -94,6 +94,7 @@ public static int WriteMetric( foreach (ref readonly var metricPoint in metric.GetMetricPoints()) { var tags = metricPoint.Tags; + var serializedTags = SerializeTags(metric, tags, openMetricsRequested); var hasNegativeBucketBounds = false; var previousBound = double.NegativeInfinity; @@ -109,7 +110,7 @@ public static int WriteMetric( cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); cursor = WriteAsciiStringNoEscape(buffer, cursor, "_bucket{"); - cursor = WriteTags(buffer, cursor, metric, tags, openMetricsRequested, writeEnclosingBraces: false); + cursor = WriteUtf8NoEscape(buffer, cursor, serializedTags); buffer[cursor++] = unchecked((byte)','); cursor = WriteAsciiStringNoEscape(buffer, cursor, "le=\""); @@ -146,7 +147,7 @@ public static int WriteMetric( // 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); + cursor = WriteSerializedTags(buffer, cursor, serializedTags); buffer[cursor++] = unchecked((byte)' '); @@ -157,7 +158,7 @@ public static int WriteMetric( // Histogram count cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); cursor = WriteAsciiStringNoEscape(buffer, cursor, "_count"); - cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags, openMetricsRequested); + cursor = WriteSerializedTags(buffer, cursor, serializedTags); buffer[cursor++] = unchecked((byte)' '); @@ -280,4 +281,30 @@ private static int WriteCreatedMetric( return cursor; } + + private static byte[] SerializeTags(Metric metric, ReadOnlyTagCollection tags, bool openMetricsRequested) + { + var buffer = new byte[128]; + + while (true) + { + try + { + var cursor = WriteTags(buffer, 0, metric, tags, openMetricsRequested, writeEnclosingBraces: false); + return buffer.AsSpan(0, cursor).ToArray(); + } + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) + { + buffer = new byte[checked(buffer.Length * 2)]; + } + } + } + + private static int WriteSerializedTags(byte[] buffer, int cursor, ReadOnlySpan serializedTags) + { + buffer[cursor++] = unchecked((byte)'{'); + cursor = WriteUtf8NoEscape(buffer, cursor, serializedTags); + buffer[cursor++] = unchecked((byte)'}'); + return cursor; + } } diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 53eb80056a0..a703ffe17ca 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -221,6 +221,48 @@ public void GaugeBoolDimension() Encoding.UTF8.GetString(buffer, 0, cursor)); } + [Fact] + public void GaugeMergesCollidingNormalizedDimensionNames() + { + var previousCulture = CultureInfo.CurrentCulture; + var previousUiCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + CultureInfo.CurrentUICulture = CultureInfo.CurrentCulture; + + try + { + var buffer = new byte[85_000]; + var metrics = new List(); + + using var meter = new Meter(Utils.GetCurrentMethodName()); + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + meter.CreateObservableGauge( + "test_gauge", + () => new Measurement( + 123, + new("a.b", true), + new("a/b", 1.5), + new("a-b", double.PositiveInfinity))); + + provider.ForceFlush(); + + var cursor = WriteMetric(buffer, 0, metrics[0], useOpenMetrics: false); + var output = Encoding.UTF8.GetString(buffer, 0, cursor); + + Assert.Contains($"test_gauge{{otel_scope_name=\"{Utils.GetCurrentMethodName()}\",a_b=\"+Inf;true;1.5\"}} 123\n", output, StringComparison.Ordinal); + Assert.Contains("+Inf", output, StringComparison.Ordinal); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + CultureInfo.CurrentUICulture = previousUiCulture; + } + } + [Fact] public void GaugeEmptyDimensionName() { @@ -1363,7 +1405,11 @@ public void WriteAsciiStringNoEscapeThrowsExceptionWhenBufferTooSmall() { var buffer = new byte[4]; +#if NET8_0_OR_GREATER + Assert.Throws(() => PrometheusSerializer.WriteAsciiStringNoEscape(buffer, 0, "metric")); +#else Assert.Throws(() => PrometheusSerializer.WriteAsciiStringNoEscape(buffer, 0, "metric")); +#endif } [Fact] From d64c5f77fd17d5c3eadbcff5c7a2d66cb23a9682 Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 18 May 2026 22:50:36 +0100 Subject: [PATCH 77/82] [Prometheus.AspNetCore] Fix merge Fix-up merge. --- .../PrometheusIntegrationTests.cs | 187 ------------------ 1 file changed, 187 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index 4d2b00451ba..dec79f37a46 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -305,193 +305,6 @@ static async Task WaitForServiceDiscoveryAsync( } }); - [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] - [InlineData("")] - [InlineData("OpenMetricsText0.0.1")] - [InlineData("OpenMetricsText1.0.0")] - [InlineData("PrometheusText0.0.4")] - [InlineData("PrometheusText1.0.0")] - - public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await GenerateMetricsAsync(async (baseAddress) => - { - // Arrange - var prometheus = new PrometheusFixture() - { - TargetPort = baseAddress.Port, - }; - - if (!string.IsNullOrEmpty(scrapeProtocol)) - { - prometheus.ScrapeProtocols.Add(scrapeProtocol); - } - - try - { - // Act - await prometheus.StartAsync(); - - var prometheusBaseAddress = prometheus.GetBaseAddress(9090); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); - - await WaitForServiceDiscoveryAsync(prometheusBaseAddress, outputHelper, cts.Token); - - IReadOnlyList series = []; - - // Assert - while (!cts.IsCancellationRequested) - { - series = await WaitForMetricsSeriesAsync(prometheusBaseAddress, outputHelper, cts.Token); - - if (series.Contains("temperature_celsius")) - { - break; - } - } - - Assert.Contains("http_server_active_requests", series); - Assert.Contains("http_server_request_duration_seconds_bucket", series); - Assert.Contains("http_server_request_duration_seconds_count", series); - Assert.Contains("http_server_request_duration_seconds_sum", series); - Assert.Contains("kestrel_active_connections", series); - Assert.Contains("kestrel_connection_duration_seconds_bucket", series); - Assert.Contains("kestrel_connection_duration_seconds_count", series); - Assert.Contains("kestrel_connection_duration_seconds_sum", series); - Assert.Contains("processed_bytes_total", series); - Assert.Contains("queue_balance", series); - Assert.Contains("temperature_celsius", series); - -#if NET10_0_OR_GREATER - Assert.Contains("aspnetcore_memory_pool_allocated_bytes_total", series); -#endif - } - finally - { - (var stdout, var stderr) = await prometheus.TypedContainer.GetLogsAsync(); - - outputHelper.WriteLine($"[prometheus] [stdout]: {stdout}"); - outputHelper.WriteLine($"[prometheus] [stderr]: {stderr}"); - - await prometheus.DisposeAsync(); - } - - static async Task> WaitForMetricsSeriesAsync( - Uri baseAddress, - ITestOutputHelper outputHelper, - CancellationToken cancellationToken) - { - // See https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers - var seriesUrl = QueryHelpers.AddQueryString( - "/api/v1/series", - [ - KeyValuePair.Create("limit", "0"), - KeyValuePair.Create("match[]", "{job=\"prometheus-target\"}"), - ]); - - var seriesUri = new Uri(seriesUrl, UriKind.Relative); - - var frequency = TimeSpan.FromMilliseconds(250); - - using var client = new HttpClient() { BaseAddress = baseAddress }; - - while (!cancellationToken.IsCancellationRequested) - { - try - { - using var metrics = await client.GetFromJsonAsync(seriesUri, cancellationToken); - - if (metrics!.RootElement.ValueKind is JsonValueKind.Object && - metrics.RootElement.TryGetProperty("status", out var status) && - status.GetString() == "success") - { - var data = metrics.RootElement.GetProperty("data"); - - if (data.GetArrayLength() > 0) - { - var series = new HashSet(); - - foreach (var seriesElement in data.EnumerateArray()) - { - if (seriesElement.ValueKind is JsonValueKind.Object && - seriesElement.TryGetProperty("__name__", out var name)) - { - series.Add(name.GetString()!); - } - } - - return [.. series]; - } - } - } - catch (Exception ex) - { - outputHelper.WriteLine($"[prometheus] Exception while waiting for metric series: {ex}"); - - try - { - await Task.Delay(frequency, cancellationToken); - } - catch (TaskCanceledException) - { - break; - } - } - } - - Assert.Fail($"Timed out waiting for metric series."); - return []; - } - - static async Task WaitForServiceDiscoveryAsync( - Uri baseAddress, - ITestOutputHelper outputHelper, - CancellationToken cancellationToken) - { - // See https://prometheus.io/docs/prometheus/latest/querying/api/#targets - using var client = new HttpClient() { BaseAddress = baseAddress }; - var targetsUri = new Uri("/api/v1/targets", UriKind.Relative); - - var frequency = TimeSpan.FromMilliseconds(250); - - while (!cancellationToken.IsCancellationRequested) - { - try - { - using var targets = await client.GetFromJsonAsync(targetsUri, cancellationToken); - - if (targets!.RootElement.ValueKind is JsonValueKind.Object && - targets.RootElement.TryGetProperty("status", out var status) && - status.GetString() == "success") - { - var activeTargets = targets.RootElement - .GetProperty("data") - .GetProperty("activeTargets"); - - if (activeTargets.GetArrayLength() > 0) - { - return; - } - } - } - catch (Exception ex) - { - outputHelper.WriteLine($"[prometheus] Exception while waiting for service discovery: {ex}"); - - try - { - await Task.Delay(frequency, cancellationToken); - } - catch (TaskCanceledException) - { - break; - } - } - } - - Assert.Fail($"Timed out waiting for service discovery active targets."); - } - }); - [EnabledOnDockerPlatformTheory(DockerPlatform.Linux)] [InlineData("")] [InlineData("text/plain")] From 8d2d604f918d7c832af1056710c021110002c84d Mon Sep 17 00:00:00 2001 From: martincostello Date: Thu, 4 Jun 2026 16:48:51 +0100 Subject: [PATCH 78/82] [Exporter.Prometheus] Revert some changes Revert some changes from merge with main which aren't needed. --- .../Internal/PrometheusCollectionManager.cs | 11 ++++++----- .../Internal/PrometheusSerializer.cs | 4 ---- .../Internal/PrometheusSerializerExt.cs | 16 ++++++++-------- .../PrometheusSerializerFuzzTests.cs | 17 +++++------------ 4 files changed, 19 insertions(+), 29 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs index 59ec8578411..e6bb23ecfcd 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs @@ -236,6 +236,7 @@ private ExportResult OnCollect(in Batch metrics) try { cursor = this.WriteTargetInfo(ref buffer); + var metricStates = this.GetMetricStates(metrics, this.exporter.OpenMetricsRequested); foreach (var metricState in metricStates) @@ -250,11 +251,11 @@ private ExportResult OnCollect(in Batch metrics) metricState.Metric, metricState.PrometheusMetric, this.exporter.OpenMetricsRequested, - writeType: metricState.WriteType, - writeUnit: metricState.WriteUnit, - writeHelp: metricState.WriteHelp, - unitOverride: metricState.Unit, - helpOverride: metricState.Help); + metricState.WriteType, + metricState.WriteUnit, + metricState.WriteHelp, + metricState.Unit, + metricState.Help); break; } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index da02dc1218b..dac18ab8d1b 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -1102,12 +1102,8 @@ private static string GetCanonicalLabelValueString(double value) static string FormatFixedAndTrim(double value, int decimalPlaces) { var formattedValue = value.ToString($"F{decimalPlaces}", CultureInfo.InvariantCulture); -#if NET #if NET var minimumLength = formattedValue.IndexOf('.', StringComparison.Ordinal) + 2; -#else - var minimumLength = formattedValue.IndexOf('.') + 2; -#endif #else var minimumLength = formattedValue.IndexOf('.') + 2; #endif diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs index 953eb56a0b0..74600dabb23 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -28,11 +28,11 @@ public static int WriteMetric( Metric metric, PrometheusMetric prometheusMetric, bool openMetricsRequested, - bool writeType = true, - bool writeUnit = true, - bool writeHelp = true, - string? unitOverride = null, - string? helpOverride = null) + bool writeType, + bool writeUnit, + bool writeHelp, + string? unitOverride, + string? helpOverride) { if (writeType) { @@ -51,7 +51,7 @@ public static int WriteMetric( if (!metric.MetricType.IsHistogram()) { - var isLong = ((int)metric.MetricType & 0b_0000_1111) == 0x0a; // I8 + var isLongValue = ((int)metric.MetricType & 0b_0000_1111) == 0x0a; // I8 foreach (ref readonly var metricPoint in metric.GetMetricPoints()) { @@ -61,7 +61,7 @@ public static int WriteMetric( buffer[cursor++] = unchecked((byte)' '); - if (isLong) + if (isLongValue) { cursor = metric.MetricType.IsSum() ? WriteLong(buffer, cursor, metricPoint.GetSumLong()) @@ -78,7 +78,7 @@ public static int WriteMetric( prometheusMetric.Type == PrometheusType.Counter && TryGetLatestExemplar(metricPoint, out var exemplar)) { - cursor = WriteExemplar(buffer, cursor, in exemplar, isLong, openMetricsRequested); + cursor = WriteExemplar(buffer, cursor, in exemplar, isLongValue, openMetricsRequested); } buffer[cursor++] = ASCII_LINEFEED; diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs index 16909d77699..40a244dcbed 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs @@ -26,7 +26,7 @@ public Property WritePrometheusLabelKeyMatchesReferenceImplementation() => Prop. [Property(MaxTest = MaxTests)] public Property WriteOpenMetricsLabelKeyMatchesReferenceImplementation() => Prop.ForAll( Generators.PrometheusStringArbitrary(), - static (value) => SerializeOpenMetricsLabelKey(value).SequenceEqual(ReferenceWriteOpenMetricsLabelKey(value))); + static (value) => SerializeOpenMetricsLabelKey(value).SequenceEqual(ReferenceWriteLabelKey(value))); [Property(MaxTest = MaxTests)] public Property WriteLabelValueMatchesReferenceImplementation() => Prop.ForAll( @@ -88,12 +88,6 @@ private static byte[] ReferenceWriteAsciiStringNoEscape(string value) } private static byte[] ReferenceWriteLabelKey(string value) - => ReferenceWriteLabelKey(value, openMetricsRequested: false); - - private static byte[] ReferenceWriteOpenMetricsLabelKey(string value) - => ReferenceWriteLabelKey(value, openMetricsRequested: true); - - private static byte[] ReferenceWriteLabelKey(string value, bool openMetricsRequested) { var bytes = new List(value.Length + 1); if (string.IsNullOrEmpty(value)) @@ -108,11 +102,10 @@ private static byte[] ReferenceWriteLabelKey(string value, bool openMetricsReque { var c = value[i]; var isAllowed = - (c is >= 'A' and <= 'Z') || - (c is >= 'a' and <= 'z') || - (c is >= '0' and <= '9') || - c == '_' || - (openMetricsRequested && c == ':'); + c is (>= 'A' and <= 'Z') or + (>= 'a' and <= 'z') or + (>= '0' and <= '9') or + '_'; if (i == 0 && c is >= '0' and <= '9') { From b8cc7398dd8f70ee9251194787920153e1b0abcf Mon Sep 17 00:00:00 2001 From: martincostello Date: Thu, 4 Jun 2026 17:37:45 +0100 Subject: [PATCH 79/82] [Exporter.Prometheus] Restore optimisations Restore optimisations that were lost in merge. --- .../Internal/PrometheusSerializer.cs | 316 +++++++++++++----- 1 file changed, 232 insertions(+), 84 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index dac18ab8d1b..c1d3abc8587 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -33,6 +33,11 @@ internal static partial class PrometheusSerializer private const int MaxExemplarLabelSetCharacters = 128; +#if NET8_0_OR_GREATER + private static readonly SearchValues UnicodeEscapeChars = SearchValues.Create("\\\n"); + private static readonly SearchValues LabelValueEscapeChars = SearchValues.Create("\"\\\n"); +#endif + #if NET9_0_OR_GREATER private static readonly FrozenSet ReservedScopeLabelNames = FrozenSet.Create(["otel_scope_name", "otel_scope_schema_url", "otel_scope_version"]); #elif NET @@ -122,6 +127,17 @@ public static int WriteLong(byte[] buffer, int cursor, long value) #endif } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteUnsignedLong(byte[] buffer, int cursor, ulong value) + { +#if NET + var result = Utf8Formatter.TryFormat(value, buffer.AsSpan(cursor), out var bytesWritten); + return AdvanceCursorOrThrow(result, cursor, bytesWritten); +#else + return WriteAsciiStringNoEscape(buffer, cursor, value.ToString(CultureInfo.InvariantCulture)); +#endif + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteAsciiStringNoEscape(byte[] buffer, int cursor, string value) { @@ -166,28 +182,7 @@ public static int WriteUnicodeNoEscape(byte[] buffer, int cursor, int ordinal) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteUnicodeString(byte[] buffer, int cursor, string value) - { - for (var i = 0; i < value.Length; i++) - { - var ordinal = (ushort)value[i]; - switch (ordinal) - { - case ASCII_REVERSE_SOLIDUS: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - break; - case ASCII_LINEFEED: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = unchecked((byte)'n'); - break; - default: - cursor = WriteUnicodeScalar(buffer, cursor, value, ref i); - break; - } - } - - return cursor; - } + => WriteEscapedString(buffer, cursor, value, escapeQuotationMarks: false); [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteLabelKey(byte[] buffer, int cursor, string value, bool openMetricsRequested) @@ -205,81 +200,83 @@ public static int WriteLabelKey(byte[] buffer, int cursor, string value, bool op [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteLabelValue(byte[] buffer, int cursor, string value) + => WriteEscapedString(buffer, cursor, value, escapeQuotationMarks: true); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteLabelValue(byte[] buffer, int cursor, object? value) { - for (var i = 0; i < value.Length; i++) + switch (value) { - var ordinal = (ushort)value[i]; - switch (ordinal) - { - case ASCII_QUOTATION_MARK: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = ASCII_QUOTATION_MARK; - break; - case ASCII_REVERSE_SOLIDUS: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - break; - case ASCII_LINEFEED: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = unchecked((byte)'n'); - break; - default: - cursor = WriteUnicodeScalar(buffer, cursor, value, ref i); - break; - } - } + case null: + return cursor; - return cursor; - } + case string stringValue: + return WriteLabelValue(buffer, cursor, stringValue); - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteLabel(byte[] buffer, int cursor, string labelKey, object? labelValue, bool openMetricsRequested) - { - cursor = WriteLabelKey(buffer, cursor, labelKey, openMetricsRequested); - buffer[cursor++] = unchecked((byte)'='); - buffer[cursor++] = unchecked((byte)'"'); + case bool boolValue: + return WriteAsciiStringNoEscape(buffer, cursor, boolValue ? "true" : "false"); - // In Prometheus, a label with an empty label value is considered equivalent to a label that does not exist. - cursor = WriteLabelValue(buffer, cursor, GetLabelValueString(labelValue)); - buffer[cursor++] = unchecked((byte)'"'); + case sbyte signedByteValue: + return WriteLong(buffer, cursor, signedByteValue); - return cursor; - } + case byte byteValue: + return WriteLong(buffer, cursor, byteValue); - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteMetricName(byte[] buffer, int cursor, PrometheusMetric metric, bool openMetricsRequested) - { - // Metric name has already been escaped. - var name = openMetricsRequested ? metric.OpenMetricsName : metric.Name; + case short shortValue: + return WriteLong(buffer, cursor, shortValue); - Debug.Assert(!string.IsNullOrWhiteSpace(name), "name was null or whitespace"); + case ushort unsignedShortValue: + return WriteLong(buffer, cursor, unsignedShortValue); - for (var i = 0; i < name.Length; i++) - { - var ordinal = (ushort)name[i]; - buffer[cursor++] = unchecked((byte)ordinal); - } + case int intValue: + return WriteLong(buffer, cursor, intValue); - return cursor; - } + case uint unsignedIntValue: + return WriteLong(buffer, cursor, unsignedIntValue); - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteMetricMetadataName(byte[] buffer, int cursor, PrometheusMetric metric, bool openMetricsRequested) - { - // Metric name has already been escaped. - var name = openMetricsRequested ? metric.OpenMetricsMetadataName : metric.Name; + case long longValue: + return WriteLong(buffer, cursor, longValue); - Debug.Assert(!string.IsNullOrWhiteSpace(name), "name was null or whitespace"); + case ulong unsignedLongValue: + return WriteUnsignedLong(buffer, cursor, unsignedLongValue); - for (var i = 0; i < name.Length; i++) - { - var ordinal = (ushort)name[i]; - buffer[cursor++] = unchecked((byte)ordinal); + case float floatValue: + return WriteCanonicalLabelValue(buffer, cursor, floatValue); + + case double doubleValue: + return WriteCanonicalLabelValue(buffer, cursor, doubleValue); + + case decimal decimalValue: +#if NET + var result = Utf8Formatter.TryFormat(decimalValue, buffer.AsSpan(cursor), out var bytesWritten); + return AdvanceCursorOrThrow(result, cursor, bytesWritten); +#else + return WriteLabelValue(buffer, cursor, decimalValue.ToString(CultureInfo.InvariantCulture)); +#endif + + case IFormattable formattableValue: + return WriteLabelValue(buffer, cursor, formattableValue.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty); + + default: + return WriteLabelValue(buffer, cursor, value.ToString() ?? string.Empty); } + } - return cursor; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteLabel(byte[] buffer, int cursor, string labelKey, object? labelValue, bool openMetricsRequested) + { + cursor = WriteLabelKey(buffer, cursor, labelKey, openMetricsRequested); + return WriteSanitizedLabel(buffer, cursor, labelValue); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteMetricName(byte[] buffer, int cursor, PrometheusMetric metric, bool openMetricsRequested) + => WriteUtf8NoEscape(buffer, cursor, openMetricsRequested ? metric.OpenMetricsNameBytes : metric.NameBytes); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteMetricMetadataName(byte[] buffer, int cursor, PrometheusMetric metric, bool openMetricsRequested) + => WriteUtf8NoEscape(buffer, cursor, openMetricsRequested ? metric.OpenMetricsMetadataNameBytes : metric.NameBytes); + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteEof(byte[] buffer, int cursor) { @@ -388,12 +385,19 @@ public static int WriteUnitMetadata(byte[] buffer, int cursor, PrometheusMetric buffer[cursor++] = unchecked((byte)' '); // Unit name has already been escaped. + if (string.Equals(unit, metric.Unit, StringComparison.Ordinal) && metric.UnitBytes != null) + { + cursor = WriteUtf8NoEscape(buffer, cursor, metric.UnitBytes); + } + else + { #pragma warning disable IDE0370 // Remove unnecessary suppression - for (var i = 0; i < unit!.Length; i++) + for (var i = 0; i < unit!.Length; i++) #pragma warning restore IDE0370 // Remove unnecessary suppression - { - var ordinal = (ushort)unit[i]; - buffer[cursor++] = unchecked((byte)ordinal); + { + var ordinal = (ushort)unit[i]; + buffer[cursor++] = unchecked((byte)ordinal); + } } buffer[cursor++] = ASCII_LINEFEED; @@ -714,6 +718,109 @@ private static int WriteNormalizedLabelKey(byte[] buffer, int cursor, string val private static bool IsAllowedMetricsLabelCharacter(char value) => char.IsAsciiLetterOrDigit(value) || value is '_'; +#if NET8_0_OR_GREATER + private static int WriteEscapedString(byte[] buffer, int cursor, string value, bool escapeQuotationMarks) + => WriteEscapedUtf8String(buffer, cursor, value.AsSpan(), escapeQuotationMarks ? LabelValueEscapeChars : UnicodeEscapeChars); + + private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan value) + { + var bytesRequired = Encoding.UTF8.GetByteCount(value); + return bytesRequired > buffer.Length - cursor + ? throw new ArgumentException("Destination buffer too small.", nameof(buffer)) + : cursor + Encoding.UTF8.GetBytes(value, buffer.AsSpan(cursor)); + } + + private static int WriteEscapedUtf8String(byte[] buffer, int cursor, ReadOnlySpan value, SearchValues escapedChars) + { + while (!value.IsEmpty) + { + var escapedIndex = value.IndexOfAny(escapedChars); + var nonAsciiIndex = value.IndexOfAnyExceptInRange((char)0x00, (char)0x7F); + + var specialIndex = + escapedIndex < 0 ? nonAsciiIndex + : nonAsciiIndex < 0 ? escapedIndex + : Math.Min(escapedIndex, nonAsciiIndex); + + if (specialIndex < 0) + { + return WriteUtf8NoEscape(buffer, cursor, value); + } + + if (specialIndex > 0) + { + cursor = WriteUtf8NoEscape(buffer, cursor, value[..specialIndex]); + value = value[specialIndex..]; + } + + switch (value[0]) + { + case '"': + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = ASCII_QUOTATION_MARK; + value = value[1..]; + break; + case '\\': + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + value = value[1..]; + break; + case '\n': + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = unchecked((byte)'n'); + value = value[1..]; + break; + default: + cursor = WriteUnicodeNoEscape(buffer, cursor, GetUnicodeOrdinal(value, out var charsConsumed)); + value = value[charsConsumed..]; + break; + } + } + + return cursor; + } +#else + private static int WriteEscapedString(byte[] buffer, int cursor, string value, bool escapeQuotationMarks) + { + for (var i = 0; i < value.Length; i++) + { + var ordinal = (ushort)value[i]; + switch (ordinal) + { + case ASCII_QUOTATION_MARK when escapeQuotationMarks: + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = ASCII_QUOTATION_MARK; + break; + case ASCII_REVERSE_SOLIDUS: + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + break; + case ASCII_LINEFEED: + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = unchecked((byte)'n'); + break; + default: + cursor = WriteUnicodeScalar(buffer, cursor, value, ref i); + break; + } + } + + return cursor; + } +#endif + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan value) + { + if (value.Length > buffer.Length - cursor) + { + throw new ArgumentException("Destination buffer too small.", nameof(buffer)); + } + + value.CopyTo(buffer.AsSpan(cursor)); + return cursor + value.Length; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int WriteUnicodeScalar(byte[] buffer, int cursor, string value, ref int index) { @@ -735,6 +842,47 @@ private static int WriteUnicodeScalar(byte[] buffer, int cursor, string value, r return WriteUnicodeNoEscape(buffer, cursor, 0xFFFD); } + private static int GetUnicodeOrdinal(ReadOnlySpan value, out int charsConsumed) + { + const int UnicodeReplacementCharacter = 0xFFFD; + + var character = value[0]; + + if (char.IsHighSurrogate(character)) + { + if (value.Length > 1 && char.IsLowSurrogate(value[1])) + { + charsConsumed = 2; + return char.ConvertToUtf32(character, value[1]); + } + + charsConsumed = 1; + return UnicodeReplacementCharacter; + } + + if (char.IsLowSurrogate(character)) + { + charsConsumed = 1; + return UnicodeReplacementCharacter; + } + + charsConsumed = 1; + return character; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int WriteSanitizedLabel(byte[] buffer, int cursor, object? labelValue) + { + buffer[cursor++] = unchecked((byte)'='); + buffer[cursor++] = unchecked((byte)'"'); + + // In Prometheus, a label with an empty label value is considered equivalent to a label that does not exist. + cursor = WriteLabelValue(buffer, cursor, labelValue); + buffer[cursor++] = unchecked((byte)'"'); + + return cursor; + } + private static string GetSanitizedLabelKey(string value) { var builder = new StringBuilder(value.Length + 1); From 4e79170d3df445de31286e1ab4638b75b0957959 Mon Sep 17 00:00:00 2001 From: martincostello Date: Thu, 4 Jun 2026 18:06:40 +0100 Subject: [PATCH 80/82] [Exporter.Prometheus] Revert change Revert change from merge that is now redundant (and wrong). --- .../Internal/PrometheusMetric.cs | 3 +-- .../PrometheusMetricTests.cs | 4 ++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs index 47fd01bba4b..0263739e680 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs @@ -23,7 +23,6 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa if (!string.IsNullOrEmpty(unit)) { sanitizedUnit = GetUnit(unit); - var openMetricsUnitSuffix = EscapeOpenMetricsName(sanitizedUnit); // The resulting unit SHOULD be added to the metric as // [OpenMetrics UNIT metadata](https://github.com/prometheus/OpenMetrics/blob/v1.0.0/specification/OpenMetrics.md#metricfamily) @@ -32,7 +31,7 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa if (!sanitizedName.EndsWith(sanitizedUnit, StringComparison.Ordinal)) { sanitizedName += $"_{sanitizedUnit}"; - openMetricsName += $"_{openMetricsUnitSuffix}"; + openMetricsName += $"_{sanitizedUnit}"; } } diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs index f9647567ba0..dffc1adc004 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs @@ -171,6 +171,10 @@ public void OpenMetricsName_PreserveLeadingNumber() public void OpenMetricsName_UnitStartingWithNumber_DoesNotAddExtraSeparator() => AssertOpenMetricsName("metric", "2", PrometheusType.Gauge, false, "metric_2"); + [Fact] + public void OpenMetricsName_UnitStartingWithMultipleDigits_PreservesSingleSeparator() + => AssertOpenMetricsName("metric", "10ms", PrometheusType.Gauge, false, "metric_10ms"); + [Fact] public void OpenMetricsName_CollapsesConsecutiveUnsupportedCharacters() => AssertOpenMetricsName("s%%ple", "%/m", PrometheusType.Summary, false, "s_ple_percent_per_minute"); From 691286e8e6f4d74413399d609c018b48c8d13846 Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Fri, 5 Jun 2026 07:19:17 +0100 Subject: [PATCH 81/82] [Exporter.Prometheus] Simplify defines Shorten to just NET. --- .../Internal/PrometheusSerializer.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index c1d3abc8587..36f66802546 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -33,7 +33,7 @@ internal static partial class PrometheusSerializer private const int MaxExemplarLabelSetCharacters = 128; -#if NET8_0_OR_GREATER +#if NET private static readonly SearchValues UnicodeEscapeChars = SearchValues.Create("\\\n"); private static readonly SearchValues LabelValueEscapeChars = SearchValues.Create("\"\\\n"); #endif @@ -718,7 +718,7 @@ private static int WriteNormalizedLabelKey(byte[] buffer, int cursor, string val private static bool IsAllowedMetricsLabelCharacter(char value) => char.IsAsciiLetterOrDigit(value) || value is '_'; -#if NET8_0_OR_GREATER +#if NET private static int WriteEscapedString(byte[] buffer, int cursor, string value, bool escapeQuotationMarks) => WriteEscapedUtf8String(buffer, cursor, value.AsSpan(), escapeQuotationMarks ? LabelValueEscapeChars : UnicodeEscapeChars); From a751364f29314f9340c72f841d521fb7e39a1bd2 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 5 Jun 2026 09:07:48 +0100 Subject: [PATCH 82/82] [Exporter.Prometheus] Address feedback - Add comment. - Fix slow-path to also use invariant formatting. - Simplify some tests. --- .../Internal/PrometheusMetric.cs | 1 + .../Internal/PrometheusSerializer.cs | 34 +++++++++--------- .../PrometheusSerializerTests.cs | 36 +++++++++++++++---- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs index 0263739e680..ad9025c021c 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs @@ -71,6 +71,7 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa static byte[] ConvertToAsciiBytes(string value) { + // Metric names and units are sanitized before conversion, so every character here is ASCII var bytes = new byte[value.Length]; for (var i = 0; i < value.Length; i++) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 36f66802546..3c674da1345 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -554,23 +554,25 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource, return cursor; } - private static string GetLabelValueString(object? labelValue) + private static string GetLabelValueString(object? labelValue) => labelValue switch { - if (labelValue is bool booleanValue) - { - return booleanValue ? "true" : "false"; - } - else if (labelValue is double doubleValue) - { - return GetCanonicalLabelValueString(doubleValue); - } - else if (labelValue is float floatValue) - { - return GetCanonicalLabelValueString(floatValue); - } - - return labelValue?.ToString() ?? string.Empty; - } + null => string.Empty, + string stringValue => stringValue, + bool booleanValue => booleanValue ? "true" : "false", + sbyte signedByteValue => signedByteValue.ToString(CultureInfo.InvariantCulture), + byte byteValue => byteValue.ToString(CultureInfo.InvariantCulture), + short shortValue => shortValue.ToString(CultureInfo.InvariantCulture), + ushort unsignedShortValue => unsignedShortValue.ToString(CultureInfo.InvariantCulture), + int intValue => intValue.ToString(CultureInfo.InvariantCulture), + uint unsignedIntValue => unsignedIntValue.ToString(CultureInfo.InvariantCulture), + long longValue => longValue.ToString(CultureInfo.InvariantCulture), + ulong unsignedLongValue => unsignedLongValue.ToString(CultureInfo.InvariantCulture), + float floatValue => GetCanonicalLabelValueString(floatValue), + double doubleValue => GetCanonicalLabelValueString(doubleValue), + decimal decimalValue => decimalValue.ToString(CultureInfo.InvariantCulture), + IFormattable formattableValue => formattableValue.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty, + _ => labelValue.ToString() ?? string.Empty, + }; private static string NormalizeLabelKey(string value) { diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index d02c7c8f481..206e36a3cd5 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -1247,6 +1247,30 @@ public void WriteMetricSerializesStaticMeterTagBoundaryValues(object? meterTagVa output); } + [Fact] + public void WriteMetricSerializesCollidingStaticMeterTagValuesUsingInvariantFormatting() + { + var previousCulture = CultureInfo.CurrentCulture; + + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + + var output = WriteGaugeMetricWithMeterTags( + new("meter tag", 1.23m), + new("meter_tag", 4.56m)); + + Assert.Equal( + "# TYPE test_gauge gauge\n" + + "test_gauge{otel_scope_name=\"test_meter\",otel_scope_meter_tag=\"1.23;4.56\"} 123\n", + output); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + [Fact] public void WriteLabelFormatsTypedValues() { @@ -1312,8 +1336,8 @@ public async Task CounterExportsCreatedMetric(bool useOpenMetrics) .Build(); var counter = meter.CreateCounter("test_counter"); - counter.Add(1, [new KeyValuePair("key", "value1")]); - counter.Add(2, [new KeyValuePair("key", "value2")]); + counter.Add(1, [new("key", "value1")]); + counter.Add(2, [new("key", "value2")]); provider.ForceFlush(); @@ -1338,8 +1362,8 @@ public async Task HistogramExportsCreatedMetric(bool useOpenMetrics) .Build(); var histogram = meter.CreateHistogram("test_histogram"); - histogram.Record(1, [new KeyValuePair("key", "value1")]); - histogram.Record(2, [new KeyValuePair("key", "value2")]); + histogram.Record(1, [new("key", "value1")]); + histogram.Record(2, [new("key", "value2")]); provider.ForceFlush(); @@ -1362,7 +1386,7 @@ public async Task HistogramCreatedMetricSkipsReservedHistogramLabels() .Build(); var histogram = meter.CreateHistogram("test_histogram"); - histogram.Record(1, [new KeyValuePair("key", "value1"), new KeyValuePair("le", "reserved")]); + histogram.Record(1, [new("key", "value1"), new("le", "reserved")]); provider.ForceFlush(); @@ -1513,7 +1537,7 @@ public async Task WriteHistogramMetricSerializesStaticTagsWithoutPreSerializedTa var metric = GetSingleHistogramMetric( meterName: "\u65e5\u672c", - meterTags: [new KeyValuePair(string.Empty, "meterTagValue")]); + meterTags: [new(string.Empty, "meterTagValue")]); var prometheusMetric = new PrometheusMetric(metric.Name, metric.Unit, PrometheusType.Histogram, disableTotalNameSuffixForCounters: false);