diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index c7e15fca4c4..883b9cbf333 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -66,6 +66,9 @@ Notes](../../RELEASENOTES.md). `X-Prometheus-Scrape-Timeout-Seconds` HTTP request header. ([#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/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 b603fcdb3a2..7d2d3fd53cf 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -81,6 +81,9 @@ Notes](../../RELEASENOTES.md). `X-Prometheus-Scrape-Timeout-Seconds` HTTP request header. ([#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/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 33d0c6b5d0f..bbc9b54cc78 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs @@ -20,7 +20,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; @@ -237,10 +238,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) @@ -356,14 +357,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; } catch (IndexOutOfRangeException) @@ -376,7 +380,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 1f9f3c1c90b..38409566c4b 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -500,21 +500,37 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource, return cursor; } - cursor = WriteAsciiStringNoEscape(buffer, cursor, "# TYPE target info"); + 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 + 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"); 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.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs index fe5150c8a08..7c989377f1e 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs @@ -573,6 +573,9 @@ private static async Task VerifyAsync( """.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.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; } } } diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs index 313f3e8f504..fcce1fc036b 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs @@ -628,7 +628,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"; 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() {