From 5cfb8b614ba9a6fb985714f8b8133b9e284654a9 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 19:52:02 +0100 Subject: [PATCH 1/5] [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 | 3 +++ .../CHANGELOG.md | 3 +++ .../Internal/PrometheusCollectionManager.cs | 17 +++++++++++------ .../Internal/PrometheusSerializer.cs | 14 +++++++++++--- .../PrometheusExporterMiddlewareTests.cs | 3 +++ .../PrometheusHttpListenerTests.cs | 5 ++++- 6 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 8522b29394c..4fa2aea444c 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -31,6 +31,9 @@ Notes](../../RELEASENOTES.md). selected correctly by considering whitespace and `q` weights. ([#7208](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7208)) +* Add Prometheus text fallback `target_info` output as a gauge. + ([#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/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index cbd3b275661..89dcfa155af 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -43,6 +43,9 @@ Notes](../../RELEASENOTES.md). selected correctly by considering whitespace and `q` weights. ([#7208](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7208)) +* Add Prometheus text fallback `target_info` output as a gauge. + ([#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/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs index d34c1288c8f..81d14a48390 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(); foreach (var metric in metrics) @@ -336,13 +337,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); + targetInfoBufferLength = PrometheusSerializer.WriteTargetInfo(buffer, 0, this.exporter.Resource, this.exporter.OpenMetricsRequested); break; } @@ -356,7 +361,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 9ac4d11b236..d2c82f4c6af 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -436,17 +436,25 @@ 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) { 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 8a6105200ff..fb31f7b8495 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs @@ -498,6 +498,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 9edaf5e4b37..040b6daf2c4 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs @@ -342,7 +342,10 @@ private static async Task RunPrometheusExporterHttpServerIntegrationTest( + "# 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" + "# 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 d9c03e083d3e66e3ed201e9f5366f1dbde9a2b11 Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Fri, 1 May 2026 19:55:40 +0100 Subject: [PATCH 2/5] [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 4fa2aea444c..4999ab77833 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -32,7 +32,7 @@ Notes](../../RELEASENOTES.md). ([#7208](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7208)) * Add Prometheus text fallback `target_info` output as a gauge. - ([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/issues/TODO)) + ([#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 89dcfa155af..3a21d9d2d4b 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -44,7 +44,7 @@ Notes](../../RELEASENOTES.md). ([#7208](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7208)) * Add Prometheus text fallback `target_info` output as a gauge. - ([#TODO](https://github.com/open-telemetry/opentelemetry-dotnet/issues/TODO)) + ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7238)) ## 1.15.3-beta.1 From 6b19199ba95eab458766c7e8d5e985376d92dee7 Mon Sep 17 00:00:00 2001 From: martincostello Date: Tue, 19 May 2026 15:10:34 +0100 Subject: [PATCH 3/5] [Exporter.Prometheus] Address feedback Fix generated corrupted metrics if no resources are present. --- .../Internal/PrometheusSerializer.cs | 10 +++++++++- .../PrometheusSerializerTests.cs | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 423a624cf19..38409566c4b 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -500,6 +500,12 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource, return cursor; } + using var attributes = resource.Attributes.GetEnumerator(); + if (!attributes.MoveNext()) + { + return cursor; + } + // "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 @@ -517,12 +523,14 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource, cursor = WriteAsciiStringNoEscape(buffer, cursor, "target_info"); buffer[cursor++] = unchecked((byte)'{'); - foreach (var attribute in resource.Attributes) + do { + var attribute = attributes.Current; cursor = WriteLabel(buffer, cursor, attribute.Key, attribute.Value, openMetricsRequested); buffer[cursor++] = unchecked((byte)','); } + while (attributes.MoveNext()); cursor--; // Write over the last written comma diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 5bf28e7e0a3..7eaa538b963 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.Text; using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; using OpenTelemetry.Tests; using Xunit; @@ -1141,6 +1142,19 @@ public void WriteDoubleFormatsNaN() Assert.Equal("NaN", Encoding.UTF8.GetString(buffer, 0, cursor)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void WriteTargetInfoSkipsEmptyNonSingletonResource(bool openMetricsRequested) + { + var buffer = new byte[128]; + var resource = Resource.Empty.Merge(Resource.Empty); + + var cursor = PrometheusSerializer.WriteTargetInfo(buffer, 0, resource, openMetricsRequested); + + Assert.Equal(0, cursor); + } + [Fact] public void WriteUnicodeStringEncodesSurrogatePairsAsUtf8ScalarValues() { From 0205493d4117b5115c08ab78c3e27be51f649182 Mon Sep 17 00:00:00 2001 From: martincostello Date: Tue, 19 May 2026 15:30:32 +0100 Subject: [PATCH 4/5] [Exporter.Prometheus] Avoid flaky test Wait until all metrics are present, not just one. --- .../PrometheusIntegrationTests.cs | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index 084fe17602d..2faa464da16 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -99,34 +99,38 @@ public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await await WaitForServiceDiscoveryAsync(prometheusBaseAddress, outputHelper, cts.Token); - IReadOnlyList series = []; + HashSet expectedSeries = + [ +#if NET10_0_OR_GREATER + "aspnetcore_memory_pool_allocated_bytes_total", +#endif + "http_server_active_requests", + "http_server_request_duration_seconds_bucket", + "http_server_request_duration_seconds_count", + "http_server_request_duration_seconds_sum", + "kestrel_active_connections", + "kestrel_connection_duration_seconds_bucket", + "kestrel_connection_duration_seconds_count", + "kestrel_connection_duration_seconds_sum", + "processed_bytes_total", + "queue_balance", + "temperature_celsius", + ]; + + HashSet actualSeries = []; // Assert while (!cts.IsCancellationRequested) { - series = await WaitForMetricsSeriesAsync(prometheusBaseAddress, outputHelper, cts.Token); + actualSeries = await WaitForMetricsSeriesAsync(prometheusBaseAddress, outputHelper, cts.Token); - if (series.Contains("temperature_celsius")) + if (actualSeries.IsProperSupersetOf(expectedSeries)) { 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 + Assert.ProperSuperset(expectedSeries, actualSeries); } finally { @@ -138,7 +142,7 @@ public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await await prometheus.DisposeAsync(); } - static async Task> WaitForMetricsSeriesAsync( + static async Task> WaitForMetricsSeriesAsync( Uri baseAddress, ITestOutputHelper outputHelper, CancellationToken cancellationToken) @@ -182,7 +186,7 @@ static async Task> WaitForMetricsSeriesAsync( } } - return [.. series]; + return series; } } } From 1cb025eee6aa90cc85ddf3b7d2febb77588f95b0 Mon Sep 17 00:00:00 2001 From: Martin Costello Date: Tue, 19 May 2026 18:45:07 +0100 Subject: [PATCH 5/5] [Exporter.Prometheus] Fix CHANGELOGs Fix PR links. --- 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 2ff9623b912..883b9cbf333 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -67,7 +67,7 @@ Notes](../../RELEASENOTES.md). ([#7252](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7252)) * Add Prometheus text fallback `target_info` output as a gauge. - ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7238)) + ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/pull/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 edaaf875c0e..7d2d3fd53cf 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -82,7 +82,7 @@ Notes](../../RELEASENOTES.md). ([#7252](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7252)) * Add Prometheus text fallback `target_info` output as a gauge. - ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7238)) + ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7238)) ## 1.15.3-beta.1