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);
}
}