From 7419d854e6619422a3b5a521fc2f67990d2ec4b7 Mon Sep 17 00:00:00 2001 From: Robert Coltheart <13191652+robertcoltheart@users.noreply.github.com> Date: Thu, 7 Dec 2023 11:18:25 +1100 Subject: [PATCH] Export OpenMetrics format for prometheus exporters (#5107) --- .../CHANGELOG.md | 2 + ...etry.Exporter.Prometheus.AspNetCore.csproj | 1 + .../PrometheusExporterMiddleware.cs | 29 ++++++- .../CHANGELOG.md | 2 + .../Internal/PrometheusCollectionManager.cs | 17 +++-- .../Internal/PrometheusExporter.cs | 2 + .../Internal/PrometheusHeadersParser.cs | 60 +++++++++++++++ .../Internal/PrometheusSerializer.cs | 26 +++++++ .../Internal/PrometheusSerializerExt.cs | 12 ++- .../PrometheusHttpListener.cs | 20 ++++- .../PrometheusExporterMiddlewareTests.cs | 61 ++++++++++++--- .../PrometheusCollectionManagerTests.cs | 14 ++-- .../PrometheusHeadersParserTests.cs | 48 ++++++++++++ .../PrometheusHttpListenerTests.cs | 72 +++++++++++++++--- .../PrometheusSerializerTests.cs | 75 ++++++++++++++++++- 15 files changed, 395 insertions(+), 46 deletions(-) create mode 100644 src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs create mode 100644 test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHeadersParserTests.cs diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index ba0192f9138..809994f353e 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +* Export OpenMetrics format from Prometheus exporters ([#5107](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5107)) + ## 1.7.0-rc.1 Released 2023-Nov-29 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 3a421b0e63d..a94c7fdb1ae 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj @@ -28,6 +28,7 @@ + diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs index bfbb178783a..72145bdaed2 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs @@ -16,6 +16,7 @@ using System.Diagnostics; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; using OpenTelemetry.Exporter.Prometheus; using OpenTelemetry.Internal; using OpenTelemetry.Metrics; @@ -64,7 +65,9 @@ public async Task InvokeAsync(HttpContext httpContext) try { - var collectionResponse = await this.exporter.CollectionManager.EnterCollect().ConfigureAwait(false); + var openMetricsRequested = AcceptsOpenMetrics(httpContext.Request); + var collectionResponse = await this.exporter.CollectionManager.EnterCollect(openMetricsRequested).ConfigureAwait(false); + try { if (collectionResponse.View.Count > 0) @@ -75,7 +78,9 @@ public async Task InvokeAsync(HttpContext httpContext) #else response.Headers.Add("Last-Modified", collectionResponse.GeneratedAtUtc.ToString("R")); #endif - response.ContentType = "text/plain; charset=utf-8; version=0.0.4"; + response.ContentType = openMetricsRequested + ? "application/openmetrics-text; version=1.0.0; charset=utf-8" + : "text/plain; charset=utf-8; version=0.0.4"; await response.Body.WriteAsync(collectionResponse.View.Array, 0, collectionResponse.View.Count).ConfigureAwait(false); } @@ -102,4 +107,24 @@ public async Task InvokeAsync(HttpContext httpContext) this.exporter.OnExport = null; } + + private static bool AcceptsOpenMetrics(HttpRequest request) + { + var acceptHeader = request.Headers.Accept; + + if (StringValues.IsNullOrEmpty(acceptHeader)) + { + return false; + } + + foreach (var header in acceptHeader) + { + if (PrometheusHeadersParser.AcceptsOpenMetrics(header)) + { + return true; + } + } + + return false; + } } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index 86cbc02b37b..16185be88da 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +* Export OpenMetrics format from Prometheus exporters ([#5107](https://github.com/open-telemetry/opentelemetry-dotnet/pull/5107)) + ## 1.7.0-rc.1 Released 2023-Nov-29 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs index 4d4ef30eda1..c0356597b64 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs @@ -45,9 +45,9 @@ public PrometheusCollectionManager(PrometheusExporter exporter) } #if NET6_0_OR_GREATER - public ValueTask EnterCollect() + public ValueTask EnterCollect(bool openMetricsRequested) #else - public Task EnterCollect() + public Task EnterCollect(bool openMetricsRequested) #endif { this.EnterGlobalLock(); @@ -93,7 +93,7 @@ public Task EnterCollect() this.ExitGlobalLock(); CollectionResponse response; - var result = this.ExecuteCollect(); + var result = this.ExecuteCollect(openMetricsRequested); if (result) { this.previousDataViewGeneratedAtUtc = DateTime.UtcNow; @@ -168,9 +168,10 @@ private void WaitForReadersToComplete() } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool ExecuteCollect() + 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; @@ -193,7 +194,13 @@ private ExportResult OnCollect(Batch metrics) { try { - cursor = PrometheusSerializer.WriteMetric(this.buffer, cursor, metric, this.GetPrometheusMetric(metric)); + cursor = PrometheusSerializer.WriteMetric( + this.buffer, + cursor, + metric, + this.GetPrometheusMetric(metric), + this.exporter.OpenMetricsRequested); + break; } catch (IndexOutOfRangeException) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporter.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporter.cs index ddc3df494c9..b02a3a64b67 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporter.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusExporter.cs @@ -63,6 +63,8 @@ internal Func, ExportResult> OnExport internal int ScrapeResponseCacheDurationMilliseconds { get; } + internal bool OpenMetricsRequested { get; set; } + /// public override ExportResult Export(in Batch metrics) { diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs new file mode 100644 index 00000000000..8548f6d16ec --- /dev/null +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs @@ -0,0 +1,60 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace OpenTelemetry.Exporter.Prometheus; + +internal static class PrometheusHeadersParser +{ + private const string OpenMetricsMediaType = "application/openmetrics-text"; + + internal static bool AcceptsOpenMetrics(string contentType) + { + var value = contentType.AsSpan(); + + while (value.Length > 0) + { + var headerValue = SplitNext(ref value, ','); + var mediaType = SplitNext(ref headerValue, ';'); + + if (mediaType.Equals(OpenMetricsMediaType.AsSpan(), StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static ReadOnlySpan SplitNext(ref ReadOnlySpan span, char character) + { + var index = span.IndexOf(character); + + if (index == -1) + { + var part = span; + span = span.Slice(span.Length); + + return part; + } + else + { + var part = span.Slice(0, index); + span = span.Slice(index + 1); + + return part; + } + } +} diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 3bcd4998f12..631de4eab3d 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -326,6 +326,32 @@ public static int WriteUnitMetadata(byte[] buffer, int cursor, PrometheusMetric return cursor; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteTimestamp(byte[] buffer, int cursor, long value, bool useOpenMetrics) + { + if (useOpenMetrics) + { + cursor = WriteLong(buffer, cursor, value / 1000); + buffer[cursor++] = unchecked((byte)'.'); + + long millis = value % 1000; + + if (millis < 100) + { + buffer[cursor++] = unchecked((byte)'0'); + } + + if (millis < 10) + { + buffer[cursor++] = unchecked((byte)'0'); + } + + return WriteLong(buffer, cursor, millis); + } + + return WriteLong(buffer, cursor, value); + } + private static string MapPrometheusType(PrometheusType type) { return type switch diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs index 0eb068b185d..d606a5ced5b 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -35,7 +35,7 @@ public static bool CanWriteMetric(Metric metric) return true; } - public static int WriteMetric(byte[] buffer, int cursor, Metric metric, PrometheusMetric prometheusMetric) + public static int WriteMetric(byte[] buffer, int cursor, Metric metric, PrometheusMetric prometheusMetric, bool openMetricsRequested = false) { cursor = WriteTypeMetadata(buffer, cursor, prometheusMetric); cursor = WriteUnitMetadata(buffer, cursor, prometheusMetric); @@ -94,7 +94,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe buffer[cursor++] = unchecked((byte)' '); - cursor = WriteLong(buffer, cursor, timestamp); + cursor = WriteTimestamp(buffer, cursor, timestamp, openMetricsRequested); buffer[cursor++] = ASCII_LINEFEED; } @@ -136,7 +136,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe cursor = WriteLong(buffer, cursor, totalCount); buffer[cursor++] = unchecked((byte)' '); - cursor = WriteLong(buffer, cursor, timestamp); + cursor = WriteTimestamp(buffer, cursor, timestamp, openMetricsRequested); buffer[cursor++] = ASCII_LINEFEED; } @@ -163,7 +163,7 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe cursor = WriteDouble(buffer, cursor, metricPoint.GetHistogramSum()); buffer[cursor++] = unchecked((byte)' '); - cursor = WriteLong(buffer, cursor, timestamp); + cursor = WriteTimestamp(buffer, cursor, timestamp, openMetricsRequested); buffer[cursor++] = ASCII_LINEFEED; @@ -189,14 +189,12 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe cursor = WriteLong(buffer, cursor, metricPoint.GetHistogramCount()); buffer[cursor++] = unchecked((byte)' '); - cursor = WriteLong(buffer, cursor, timestamp); + cursor = WriteTimestamp(buffer, cursor, timestamp, openMetricsRequested); buffer[cursor++] = ASCII_LINEFEED; } } - buffer[cursor++] = ASCII_LINEFEED; - return cursor; } } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs index 112bbf26206..914f448df94 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs @@ -110,6 +110,18 @@ public void Dispose() } } + private static bool AcceptsOpenMetrics(HttpListenerRequest request) + { + var acceptHeader = request.Headers["Accept"]; + + if (string.IsNullOrEmpty(acceptHeader)) + { + return false; + } + + return PrometheusHeadersParser.AcceptsOpenMetrics(acceptHeader); + } + private void WorkerProc() { this.httpListener.Start(); @@ -148,7 +160,9 @@ private async Task ProcessRequestAsync(HttpListenerContext context) { try { - var collectionResponse = await this.exporter.CollectionManager.EnterCollect().ConfigureAwait(false); + var openMetricsRequested = AcceptsOpenMetrics(context.Request); + var collectionResponse = await this.exporter.CollectionManager.EnterCollect(openMetricsRequested).ConfigureAwait(false); + try { context.Response.Headers.Add("Server", string.Empty); @@ -156,7 +170,9 @@ private async Task ProcessRequestAsync(HttpListenerContext context) { context.Response.StatusCode = 200; context.Response.Headers.Add("Last-Modified", collectionResponse.GeneratedAtUtc.ToString("R")); - context.Response.ContentType = "text/plain; charset=utf-8; version=0.0.4"; + context.Response.ContentType = openMetricsRequested + ? "application/openmetrics-text; version=1.0.0; charset=utf-8" + : "text/plain; charset=utf-8; version=0.0.4"; await context.Response.OutputStream.WriteAsync(collectionResponse.View.Array, 0, collectionResponse.View.Count).ConfigureAwait(false); } diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs index ea38fb59cf9..072b3b4511b 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs @@ -238,6 +238,24 @@ await RunPrometheusExporterMiddlewareIntegrationTest( registerMeterProvider: false); } + [Fact] + public Task PrometheusExporterMiddlewareIntegration_TextPlainResponse() + { + return RunPrometheusExporterMiddlewareIntegrationTest( + "/metrics", + app => app.UseOpenTelemetryPrometheusScrapingEndpoint(), + acceptHeader: "text/plain"); + } + + [Fact] + public Task PrometheusExporterMiddlewareIntegration_UseOpenMetricsVersionHeader() + { + return RunPrometheusExporterMiddlewareIntegrationTest( + "/metrics", + app => app.UseOpenTelemetryPrometheusScrapingEndpoint(), + acceptHeader: "application/openmetrics-text; version=1.0.0"); + } + private static async Task RunPrometheusExporterMiddlewareIntegrationTest( string path, Action configure, @@ -245,8 +263,11 @@ private static async Task RunPrometheusExporterMiddlewareIntegrationTest( Action validateResponse = null, bool registerMeterProvider = true, Action configureOptions = null, - bool skipMetrics = false) + bool skipMetrics = false, + string acceptHeader = "application/openmetrics-text") { + var requestOpenMetrics = acceptHeader.StartsWith("application/openmetrics-text"); + using var host = await new HostBuilder() .ConfigureWebHost(webBuilder => webBuilder .UseTestServer() @@ -284,7 +305,14 @@ private static async Task RunPrometheusExporterMiddlewareIntegrationTest( counter.Add(0.99D, tags); } - using var response = await host.GetTestClient().GetAsync(path); + using var client = host.GetTestClient(); + + if (!string.IsNullOrEmpty(acceptHeader)) + { + client.DefaultRequestHeaders.Add("Accept", acceptHeader); + } + + using var response = await client.GetAsync(path); var endTimestamp = DateTimeOffset.Now.ToUnixTimeMilliseconds(); @@ -292,22 +320,31 @@ private static async Task RunPrometheusExporterMiddlewareIntegrationTest( { Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.True(response.Content.Headers.Contains("Last-Modified")); - Assert.Equal("text/plain; charset=utf-8; version=0.0.4", response.Content.Headers.ContentType.ToString()); + + if (requestOpenMetrics) + { + Assert.Equal("application/openmetrics-text; version=1.0.0; charset=utf-8", response.Content.Headers.ContentType.ToString()); + } + else + { + Assert.Equal("text/plain; charset=utf-8; version=0.0.4", response.Content.Headers.ContentType.ToString()); + } string content = await response.Content.ReadAsStringAsync(); - var matches = Regex.Matches( - content, - ("^" - + "# TYPE counter_double_total counter\n" - + "counter_double_total{key1='value1',key2='value2'} 101.17 (\\d+)\n" - + "\n" - + "# EOF\n" - + "$").Replace('\'', '"')); + string expected = requestOpenMetrics + ? "# TYPE counter_double_total counter\n" + + "counter_double_total{key1='value1',key2='value2'} 101.17 (\\d+\\.\\d{3})\n" + + "# EOF\n" + : "# TYPE counter_double_total counter\n" + + "counter_double_total{key1='value1',key2='value2'} 101.17 (\\d+)\n" + + "# EOF\n"; + + var matches = Regex.Matches(content, ("^" + expected + "$").Replace('\'', '"')); Assert.Single(matches); - var timestamp = long.Parse(matches[0].Groups[1].Value); + var timestamp = long.Parse(matches[0].Groups[1].Value.Replace(".", string.Empty)); Assert.True(beginTimestamp <= timestamp && timestamp <= endTimestamp); } diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs index 70b886b5202..26111263798 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs @@ -24,11 +24,13 @@ namespace OpenTelemetry.Exporter.Prometheus.Tests; public sealed class PrometheusCollectionManagerTests { [Theory] - [InlineData(0)] // disable cache, default value for HttpListener + [InlineData(0, true)] // disable cache, default value for HttpListener + [InlineData(0, false)] // disable cache, default value for HttpListener #if PROMETHEUS_ASPNETCORE - [InlineData(300)] // default value for AspNetCore, no possibility to set on HttpListener + [InlineData(300, true)] // default value for AspNetCore, no possibility to set on HttpListener + [InlineData(300, false)] // default value for AspNetCore, no possibility to set on HttpListener #endif - public async Task EnterExitCollectTest(int scrapeResponseCacheDurationMilliseconds) + public async Task EnterExitCollectTest(int scrapeResponseCacheDurationMilliseconds, bool openMetricsRequested) { bool cacheEnabled = scrapeResponseCacheDurationMilliseconds != 0; using var meter = new Meter(Utils.GetCurrentMethodName()); @@ -65,7 +67,7 @@ public async Task EnterExitCollectTest(int scrapeResponseCacheDurationMillisecon { collectTasks[i] = Task.Run(async () => { - var response = await exporter.CollectionManager.EnterCollect(); + var response = await exporter.CollectionManager.EnterCollect(openMetricsRequested); try { return new Response @@ -98,7 +100,7 @@ public async Task EnterExitCollectTest(int scrapeResponseCacheDurationMillisecon counter.Add(100); // This should use the cache and ignore the second counter update. - var task = exporter.CollectionManager.EnterCollect(); + var task = exporter.CollectionManager.EnterCollect(openMetricsRequested); Assert.True(task.IsCompleted); var response = await task; try @@ -129,7 +131,7 @@ public async Task EnterExitCollectTest(int scrapeResponseCacheDurationMillisecon { collectTasks[i] = Task.Run(async () => { - var response = await exporter.CollectionManager.EnterCollect(); + var response = await exporter.CollectionManager.EnterCollect(openMetricsRequested); try { return new Response diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHeadersParserTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHeadersParserTests.cs new file mode 100644 index 00000000000..bc82596f3cf --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHeadersParserTests.cs @@ -0,0 +1,48 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Xunit; + +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; 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")] + public void ParseHeader_AcceptHeaders_OpenMetricsValid(string header) + { + var result = PrometheusHeadersParser.AcceptsOpenMetrics(header); + + Assert.True(result); + } + + [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")] + public void ParseHeader_AcceptHeaders_OtherHeadersInvalid(string header) + { + var result = PrometheusHeadersParser.AcceptsOpenMetrics(header); + + Assert.False(result); + } +} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs index d964432009e..e9e270dd5b4 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs @@ -90,14 +90,28 @@ public async Task PrometheusExporterHttpServerIntegration_NoMetrics() await this.RunPrometheusExporterHttpServerIntegrationTest(skipMetrics: true); } - private async Task RunPrometheusExporterHttpServerIntegrationTest(bool skipMetrics = false) + [Fact] + public async Task PrometheusExporterHttpServerIntegration_NoOpenMetrics() + { + await this.RunPrometheusExporterHttpServerIntegrationTest(acceptHeader: string.Empty); + } + + [Fact] + public async Task PrometheusExporterHttpServerIntegration_UseOpenMetricsVersionHeader() { + await this.RunPrometheusExporterHttpServerIntegrationTest(acceptHeader: "application/openmetrics-text; version=1.0.0"); + } + + private async Task RunPrometheusExporterHttpServerIntegrationTest(bool skipMetrics = false, string acceptHeader = "application/openmetrics-text") + { + var requestOpenMetrics = acceptHeader.StartsWith("application/openmetrics-text"); + Random random = new Random(); int retryAttempts = 5; int port = 0; string address = null; - MeterProvider provider; + MeterProvider provider = null; using var meter = new Meter(this.meterName); while (retryAttempts-- != 0) @@ -105,10 +119,24 @@ private async Task RunPrometheusExporterHttpServerIntegrationTest(bool skipMetri port = random.Next(2000, 5000); address = $"http://localhost:{port}/"; - provider = Sdk.CreateMeterProviderBuilder() - .AddMeter(meter.Name) - .AddPrometheusHttpListener(options => options.UriPrefixes = new string[] { address }) - .Build(); + try + { + provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddPrometheusHttpListener(options => options.UriPrefixes = new string[] { address }) + .Build(); + + break; + } + catch + { + // ignored + } + } + + if (provider == null) + { + throw new InvalidOperationException("HttpListener could not be started"); } var tags = new KeyValuePair[] @@ -125,21 +153,45 @@ private async Task RunPrometheusExporterHttpServerIntegrationTest(bool skipMetri } using HttpClient client = new HttpClient(); + + if (!string.IsNullOrEmpty(acceptHeader)) + { + client.DefaultRequestHeaders.Add("Accept", acceptHeader); + } + using var response = await client.GetAsync($"{address}metrics"); if (!skipMetrics) { Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.True(response.Content.Headers.Contains("Last-Modified")); - Assert.Equal("text/plain; charset=utf-8; version=0.0.4", response.Content.Headers.ContentType.ToString()); - Assert.Matches( - "^# TYPE counter_double_total counter\ncounter_double_total{key1='value1',key2='value2'} 101.17 \\d+\n\n# EOF\n$".Replace('\'', '"'), - await response.Content.ReadAsStringAsync()); + if (requestOpenMetrics) + { + Assert.Equal("application/openmetrics-text; version=1.0.0; charset=utf-8", response.Content.Headers.ContentType.ToString()); + } + else + { + Assert.Equal("text/plain; charset=utf-8; version=0.0.4", response.Content.Headers.ContentType.ToString()); + } + + var content = await response.Content.ReadAsStringAsync(); + + var expected = requestOpenMetrics + ? "# TYPE counter_double_total counter\n" + + "counter_double_total{key1='value1',key2='value2'} 101.17 \\d+\\.\\d{3}\n" + + "# EOF\n" + : "# TYPE counter_double_total counter\n" + + "counter_double_total{key1='value1',key2='value2'} 101.17 \\d+\n" + + "# EOF\n"; + + Assert.Matches(("^" + expected + "$").Replace('\'', '"'), content); } else { Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + + provider.Dispose(); } } diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 53617606cf0..ee837e516ca 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -512,8 +512,79 @@ public void ExponentialHistogramIsIgnoredForNow() Assert.False(PrometheusSerializer.CanWriteMetric(metrics[0])); } - private static int WriteMetric(byte[] buffer, int cursor, Metric metric) + [Fact] + public void SumWithOpenMetricsFormat() + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = new Meter(Utils.GetCurrentMethodName()); + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + var counter = meter.CreateUpDownCounter("test_updown_counter"); + counter.Add(10); + counter.Add(-11); + + provider.ForceFlush(); + + var cursor = WriteMetric(buffer, 0, metrics[0], true); + Assert.Matches( + ("^" + + "# TYPE test_updown_counter gauge\n" + + "test_updown_counter -1 \\d+\\.\\d{3}\n" + + "$").Replace('\'', '"'), + Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Fact] + public void HistogramOneDimensionWithScopeInfo() + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = new Meter(Utils.GetCurrentMethodName()); + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + var histogram = meter.CreateHistogram("test_histogram"); + histogram.Record(18, new KeyValuePair("x", "1")); + histogram.Record(100, new KeyValuePair("x", "1")); + + provider.ForceFlush(); + + var cursor = WriteMetric(buffer, 0, metrics[0], true); + Assert.Matches( + ("^" + + "# TYPE test_histogram histogram\n" + + "test_histogram_bucket{x='1',le='0'} 0 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='5'} 0 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='10'} 0 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='25'} 1 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='50'} 1 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='75'} 1 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='100'} 2 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='250'} 2 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='500'} 2 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='750'} 2 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='1000'} 2 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='2500'} 2 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='5000'} 2 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='7500'} 2 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='10000'} 2 \\d+\\.\\d{3}\n" + + "test_histogram_bucket{x='1',le='\\+Inf'} 2 \\d+\\.\\d{3}\n" + + "test_histogram_sum{x='1'} 118 \\d+\\.\\d{3}\n" + + "test_histogram_count{x='1'} 2 \\d+\\.\\d{3}\n" + + "$").Replace('\'', '"'), + Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + private static int WriteMetric(byte[] buffer, int cursor, Metric metric, bool useOpenMetrics = false) { - return PrometheusSerializer.WriteMetric(buffer, cursor, metric, PrometheusMetric.Create(metric)); + return PrometheusSerializer.WriteMetric(buffer, cursor, metric, PrometheusMetric.Create(metric), useOpenMetrics); } }