diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md
index c7e15fca4c4..6584ea2d604 100644
--- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md
+++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md
@@ -26,45 +26,74 @@ Notes](../../RELEASENOTES.md).
* Fix non-ASCII characters in metric names and unit strings not being sanitized
correctly during Prometheus serialization.
- ([#7184](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7184))
+ ([#7184](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7184))
* Fix case where reader tracking could be reset while readers were still active.
([#7190](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7190))
* Improve `Accept` header handling for format negotiation so OpenMetrics is
selected correctly by considering whitespace and `q` weights.
- ([#7208](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7208))
+ ([#7208](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7208))
* Emit OpenMetrics exemplars for counters and histogram buckets.
- ([#7222](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7222))
+ ([#7222](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7222))
* Fix incorrect handling of untyped metrics when using OpenMetrics format.
- ([#7219](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7219))
+ ([#7219](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7219))
* Fix Prometheus/OpenMetrics serialization to emit metric and label names
- containing `_` instead of dropping them and prefixing leading digits.
+ 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))
+ ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/pull/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))
+ ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7209))
+
+* Use the canonical representation for histogram "le" label values when using
+ OpenMetrics.
+ ([#7218](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7218))
* Omit histogram `_sum` and `_count` in OpenMetrics when negative bucket
thresholds are present.
- ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221))
+ ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7221))
* Fix `ArgumentException` if `OTEL_SDK_DISABLED=true`.
- ([#7273](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7273))
+ ([#7273](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7273))
* Update `Accept` header parsing to more closely follow the Prometheus
[Scrape protocol content negotiation](https://prometheus.io/docs/instrumenting/content_negotiation/)
specification.
- ([#7266](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7266))
+ ([#7266](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7266))
* Abort scrape request processing if request exceeds the value specified by the
`X-Prometheus-Scrape-Timeout-Seconds` HTTP request header.
- ([#7252](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7252))
+ ([#7252](https://github.com/open-telemetry/opentelemetry-dotnet/pull/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/pull/7274))
+
+* 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))
+
+* 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/pull/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/pull/7237))
+
+* Drop conflicting scope attributes named `name`, `version`, and `schema_url`.
+ ([#7237](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7237))
+
+* Add Prometheus text fallback `target_info` output as a gauge.
+ ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7238))
+
+* Merge colliding sanitized label keys.
+ ([#7239](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7239))
## 1.15.3-beta.1
@@ -72,7 +101,7 @@ Released 2026-Apr-21
* Fixed metric unit strings containing invalid Prometheus characters (e.g. `# RU`)
not being sanitized, resulting in malformed metric names.
- ([#6187](https://github.com/open-telemetry/opentelemetry-dotnet/issues/6187))
+ ([#6187](https://github.com/open-telemetry/opentelemetry-dotnet/pull/6187))
* Fixed Prometheus metric serialization to handle empty label names without
throwing during scrape rendering.
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 d3a3eb3e763..482a4dc2dbd 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 6c24aff6e91..4a03d6d334a 100644
--- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs
+++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs
@@ -5,7 +5,9 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
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;
@@ -73,9 +75,11 @@ public async Task InvokeAsync(HttpContext httpContext)
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(requestCancelled.Token, httpContext.RequestAborted);
- var protocol = Negotiate(httpContext.Request);
+ var requestHeaders = httpContext.Request.GetTypedHeaders();
- var collectionResponse = await this.exporter.CollectionManager.EnterCollect(protocol.IsOpenMetrics).ConfigureAwait(false);
+ var protocol = Negotiate(requestHeaders);
+
+ var collectionResponse = await this.exporter.CollectionManager.EnterCollect(protocol.IsOpenMetrics);
try
{
@@ -90,7 +94,7 @@ public async Task InvokeAsync(HttpContext httpContext)
response.Headers.Append("Last-Modified", collectionResponse.GeneratedAtUtc.ToString("R"));
response.ContentType = PrometheusProtocol.GetContentType(protocol);
- 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
{
@@ -144,9 +148,9 @@ static bool TryGetScrapeTimeout(
}
}
- internal static PrometheusProtocol Negotiate(HttpRequest request)
+ internal static PrometheusProtocol Negotiate(RequestHeaders headers)
{
- var acceptHeader = request.GetTypedHeaders().Accept;
+ var acceptHeader = headers.Accept;
if (acceptHeader is not { Count: > 0 })
{
@@ -284,4 +288,44 @@ private static bool TryParse(
protocol = new(mediaType, escaping, version, isOpenMetrics);
return true;
}
+
+ 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/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md
index b603fcdb3a2..be6bfb1ab06 100644
--- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md
+++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md
@@ -32,7 +32,7 @@ Notes](../../RELEASENOTES.md).
* Fix non-ASCII characters in metric names and unit strings not being sanitized
correctly during Prometheus serialization.
- ([#7184](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7184))
+ ([#7184](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7184))
* Add support for caching the scrape endpoint HTTP responses using the
`PrometheusHttpListenerOptions.ScrapeResponseCacheDurationMilliseconds` option.
@@ -45,13 +45,13 @@ Notes](../../RELEASENOTES.md).
* Improve `Accept` header handling for format negotiation so OpenMetrics is
selected correctly by considering whitespace and `q` weights.
- ([#7208](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7208))
+ ([#7208](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7208))
* Emit OpenMetrics exemplars for counters and histogram buckets.
- ([#7222](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7222))
+ ([#7222](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7222))
* Fix incorrect handling of untyped metrics when using OpenMetrics format.
- ([#7219](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7219))
+ ([#7219](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7219))
* Add support for configuring the HTTP listener endpoint host and port using
the `OTEL_EXPORTER_PROMETHEUS_HOST` and `OTEL_EXPORTER_PROMETHEUS_PORT`
@@ -60,26 +60,59 @@ Notes](../../RELEASENOTES.md).
[#7255](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7255))
* Fix Prometheus/OpenMetrics serialization to emit metric and label names
- containing and `_` instead of dropping them and prefixing leading digits.
+ containing `_` 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))
+ ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/pull/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))
+ ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7209))
* Omit histogram `_sum` and `_count` in OpenMetrics when negative bucket
thresholds are present.
- ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221))
+ ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7221))
* Update `Accept` header parsing to more closely follow the Prometheus
[Scrape protocol content negotiation](https://prometheus.io/docs/instrumenting/content_negotiation/)
specification.
- ([#7266](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7266))
+ ([#7266](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7266))
* Abort scrape request processing if request exceeds the value specified by the
`X-Prometheus-Scrape-Timeout-Seconds` HTTP request header.
- ([#7252](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7252))
+ ([#7252](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7252))
+
+* Add `escaping=underscores` to the `Accept` header handling for content
+ negotiation so OpenMetrics are handled correctly.
+ ([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7209))
+
+* Use the canonical representation for histogram "le" label values when using
+ OpenMetrics.
+ ([#7218](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7218))
+
+* Omit histogram `_sum` and `_count` in OpenMetrics when negative bucket
+ thresholds are present.
+ ([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/pull/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))
+
+* 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/pull/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/pull/7237))
+
+* Drop conflicting scope attributes named `name`, `version`, and `schema_url`.
+ ([#7237](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7237))
+
+* Add Prometheus text fallback `target_info` output as a gauge.
+ ([#7238](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7238))
+
+* Merge colliding sanitized label keys.
+ ([#7239](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7239))
## 1.15.3-beta.1
@@ -87,7 +120,7 @@ Released 2026-Apr-21
* Fixed metric unit strings containing invalid Prometheus characters (e.g. `# RU`)
not being sanitized, resulting in malformed metric names.
- ([#6187](https://github.com/open-telemetry/opentelemetry-dotnet/issues/6187))
+ ([#7033](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7033))
* Fixed Prometheus metric serialization to handle empty label names without
throwing during scrape rendering.
@@ -383,7 +416,7 @@ Released 2022-Feb-02
Released 2021-Nov-29
* Bug fix for handling Histogram with empty buckets.
- ([#2651](https://github.com/open-telemetry/opentelemetry-dotnet/issues/2651))
+ ([#2651](https://github.com/open-telemetry/opentelemetry-dotnet/pull/2651))
## 1.2.0-beta2
diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs
index 33d0c6b5d0f..c28f8dfd577 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,11 +238,12 @@ 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;
foreach (var metric in metrics)
{
@@ -250,13 +252,19 @@ private ExportResult OnCollect(in Batch metrics)
continue;
}
- if (this.scopes.Add(metric.MeterName))
+ if (this.scopes.Add(PrometheusSerializer.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;
}
@@ -292,11 +300,11 @@ private ExportResult OnCollect(in Batch metrics)
metricState.Metric,
metricState.PrometheusMetric,
this.exporter.OpenMetricsRequested,
- metricState.WriteType,
- metricState.WriteUnit,
- metricState.WriteHelp,
- metricState.Unit,
- metricState.Help);
+ writeType: metricState.WriteType,
+ writeUnit: metricState.WriteUnit,
+ writeHelp: metricState.WriteHelp,
+ unitOverride: metricState.Unit,
+ helpOverride: metricState.Help);
break;
}
@@ -356,13 +364,18 @@ 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);
+ targetInfoBufferLength = PrometheusSerializer.WriteTargetInfo(buffer, 0, this.exporter.Resource, this.exporter.OpenMetricsRequested);
break;
}
@@ -376,7 +389,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/PrometheusMetric.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs
index 93438271258..f4086aeb1f7 100644
--- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs
+++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs
@@ -23,6 +23,7 @@ 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)
@@ -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}";
}
}
diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs
index 1f9f3c1c90b..af086f05df8 100644
--- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs
+++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs
@@ -1,9 +1,14 @@
// 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;
+using System.Text;
using OpenTelemetry.Internal;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
@@ -21,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 !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)
{
@@ -31,55 +41,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)]
@@ -151,17 +153,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)
@@ -204,52 +196,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)]
@@ -422,23 +368,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;
@@ -455,41 +412,15 @@ public static int WriteTags(
bool openMetricsRequested,
bool writeEnclosingBraces = true)
{
- if (writeEnclosingBraces)
- {
- buffer[cursor++] = unchecked((byte)'{');
- }
-
- 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, openMetricsRequested);
- buffer[cursor++] = unchecked((byte)',');
- }
-
- if (metric.MeterTags != null)
- {
- foreach (var tag in metric.MeterTags)
- {
- cursor = WriteLabel(buffer, cursor, tag.Key, tag.Value, openMetricsRequested);
- buffer[cursor++] = unchecked((byte)',');
- }
- }
+ List? labels = null;
+ AddScopeLabels(metric, openMetricsRequested, ref labels);
foreach (var tag in tags)
{
- cursor = WriteLabel(buffer, cursor, tag.Key, tag.Value, openMetricsRequested);
- buffer[cursor++] = unchecked((byte)',');
+ AddLabel(tag.Key, tag.Value, openMetricsRequested, ref labels);
}
- if (writeEnclosingBraces)
- {
- buffer[cursor - 1] = unchecked((byte)'}'); // Note: We write the '}' over the last written comma, which is extra.
- }
-
- return cursor;
+ return WriteLabels(buffer, cursor, labels, openMetricsRequested, writeEnclosingBraces);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -500,25 +431,29 @@ 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");
- 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;
@@ -526,13 +461,40 @@ 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 label in scopeLabels)
+ {
+ builder.Append('\0')
+ .Append(label.Key)
+ .Append('\0')
+ .Append(label.Value);
+ }
+
+ return builder.ToString();
+ }
+
private static int WritePrometheusLabelKey(byte[] buffer, int cursor, string value)
- => WriteNormalizedLabelKey(buffer, cursor, value);
+ => WriteNormalizedLabelKey(buffer, cursor, value, isOpenMetrics: false);
private static int WriteOpenMetricsLabelKey(byte[] buffer, int cursor, string value)
- => WriteNormalizedLabelKey(buffer, cursor, value);
+ => WriteNormalizedLabelKey(buffer, cursor, value, isOpenMetrics: true);
- private static int WriteNormalizedLabelKey(byte[] buffer, int cursor, string value)
+ private static int WriteNormalizedLabelKey(byte[] buffer, int cursor, string value, bool isOpenMetrics)
{
var lastCharUnderscore = false;
@@ -549,7 +511,7 @@ private static int WriteNormalizedLabelKey(byte[] buffer, int cursor, string val
}
}
- if (!IsAllowedMetricsLabelCharacter(ch))
+ if (!IsAllowedMetricsLabelCharacter(ch, isOpenMetrics))
{
if (!lastCharUnderscore)
{
@@ -567,9 +529,253 @@ private static int WriteNormalizedLabelKey(byte[] buffer, int cursor, string val
return cursor;
}
+ private static int WriteScopeLabels(byte[] buffer, int cursor, Metric metric, bool openMetricsRequested)
+ {
+ List? labels = null;
+ AddScopeLabels(metric, openMetricsRequested, ref labels);
+ return WriteLabels(buffer, cursor, labels, openMetricsRequested, writeEnclosingBraces: true);
+ }
+
+ private static void AddScopeLabels(Metric metric, bool openMetricsRequested, ref List? labels)
+ {
+ AddLabel("otel_scope_name", "otel_scope_name", metric.MeterName, openMetricsRequested, ref labels);
+
+ if (!string.IsNullOrEmpty(metric.MeterVersion))
+ {
+ AddLabel("otel_scope_version", "otel_scope_version", metric.MeterVersion, openMetricsRequested, ref labels);
+ }
+
+ if (!string.IsNullOrEmpty(metric.MeterSchemaUrl))
+ {
+ 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, openMetricsRequested, out var scopeLabel))
+ {
+ labels ??= [];
+ labels.Add(scopeLabel);
+ }
+ }
+ }
+ }
+
+ 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 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 AppendSanitizedLabelKeyCharacter(buffer, cursor, builder, '_');
+ }
+
+ var lastCharUnderscore = false;
+
+ if (char.IsAsciiDigit(value[0]))
+ {
+ cursor = AppendSanitizedLabelKeyCharacter(buffer, cursor, builder, '_');
+ lastCharUnderscore = true;
+ }
+
+ for (var i = 0; i < value.Length; i++)
+ {
+ var ch = value[i];
+
+ if (!IsAllowedMetricsLabelCharacter(ch, openMetricsRequested))
+ {
+ if (!lastCharUnderscore)
+ {
+ cursor = AppendSanitizedLabelKeyCharacter(buffer, cursor, builder, '_');
+ lastCharUnderscore = true;
+ }
+
+ continue;
+ }
+
+ cursor = AppendSanitizedLabelKeyCharacter(buffer, cursor, builder, ch);
+ lastCharUnderscore = ch == '_';
+ }
+
+ return cursor;
+ }
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static bool IsAllowedMetricsLabelCharacter(char value) =>
- char.IsAsciiLetterOrDigit(value) || value is '_';
+ 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)
+ => AddLabel(originalKey, GetSanitizedLabelKey(originalKey, openMetricsRequested), value, openMetricsRequested, ref labels);
+
+ private static void AddLabel(string originalKey, string outputKey, object? value, bool openMetricsRequested, ref List? labels)
+ {
+ labels ??= [];
+ labels.Add(new LabelData(originalKey, GetSanitizedLabelKey(outputKey, openMetricsRequested), GetLabelValueString(value)));
+ }
+
+ private static List> MergeLabels(IReadOnlyList? labels)
+ {
+ if (labels == null || labels.Count == 0)
+ {
+ return [];
+ }
+
+ 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 WriteLabels(byte[] buffer, int cursor, IReadOnlyList? labels, bool openMetricsRequested, bool writeEnclosingBraces)
+ {
+ 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)',');
+ }
+
+ if (writeEnclosingBraces)
+ {
+ if (labels != null && labels.Count > 0)
+ {
+ buffer[cursor - 1] = unchecked((byte)'}');
+ }
+ else
+ {
+ buffer[cursor++] = unchecked((byte)'}');
+ }
+ }
+ else if (labels != null && labels.Count > 0)
+ {
+ cursor--;
+ }
+
+ 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 == ':');
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int WriteUnicodeScalar(byte[] buffer, int cursor, string value, ref int index)
@@ -593,8 +799,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
{
@@ -607,4 +817,201 @@ private static int WriteUnixTimeSeconds(byte[] buffer, int cursor, DateTimeOffse
// See https://prometheus.io/docs/specs/om/open_metrics_spec/#unknown-1
PrometheusType.Untyped or _ => openMetricsRequested ? "unknown" : "untyped",
};
+
+#if NET
+ private static int FormatCanonicalLabelValue(Span destination, double value)
+ {
+ if (double.IsPositiveInfinity(value))
+ {
+ return GetBytesWrittenOrThrow("+Inf"u8.TryCopyTo(destination), 4);
+ }
+ else if (double.IsNegativeInfinity(value))
+ {
+ return GetBytesWrittenOrThrow("-Inf"u8.TryCopyTo(destination), 4);
+ }
+ else if (double.IsNaN(value))
+ {
+ return GetBytesWrittenOrThrow("NaN"u8.TryCopyTo(destination), 3);
+ }
+ else if (value == 0)
+ {
+ return GetBytesWrittenOrThrow("0.0"u8.TryCopyTo(destination), 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)
+ {
+ if (destination.Length < (isNegative ? 6 : 5))
+ {
+ throw new ArgumentException("Destination buffer too small.");
+ }
+
+ 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)
+ {
+ if (destination.Length < 4)
+ {
+ throw new ArgumentException("Destination buffer too small.");
+ }
+
+ destination[0] = (byte)'e';
+ destination[1] = exponent >= 0 ? (byte)'+' : (byte)'-';
+
+ var absoluteExponent = Math.Abs(exponent);
+ (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);
+ }
+ else 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;
+ Debug.Assert(absoluteValue > 0, $"{nameof(absoluteValue)} should be positive.");
+
+ 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;
+ }
+
+ 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/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs
index 38a2943a8b0..a9402a09eef 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,
- bool writeUnit,
- bool writeHelp,
- string? unitOverride,
- string? helpOverride)
+ bool writeType = true,
+ bool writeUnit = true,
+ bool writeHelp = true,
+ string? unitOverride = null,
+ string? helpOverride = null)
{
if (writeType)
{
@@ -51,7 +51,7 @@ public static int WriteMetric(
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())
{
@@ -61,7 +61,7 @@ public static int WriteMetric(
buffer[cursor++] = unchecked((byte)' ');
- if (isLongValue)
+ if (isLong)
{
cursor = metric.MetricType.IsSum()
? WriteLong(buffer, cursor, metricPoint.GetSumLong())
@@ -78,10 +78,15 @@ public static int WriteMetric(
prometheusMetric.Type == PrometheusType.Counter &&
TryGetLatestExemplar(metricPoint, out var exemplar))
{
- cursor = WriteExemplar(buffer, cursor, in exemplar, isLongValue, openMetricsRequested);
+ cursor = WriteExemplar(buffer, cursor, in exemplar, isLong, openMetricsRequested);
}
buffer[cursor++] = ASCII_LINEFEED;
+
+ if (openMetricsRequested && prometheusMetric.Type == PrometheusType.Counter)
+ {
+ cursor = WriteCreatedMetric(buffer, cursor, metric, prometheusMetric, metricPoint);
+ }
}
}
else
@@ -105,12 +110,20 @@ public static int WriteMetric(
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=\"");
- 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, "\"} ");
@@ -152,6 +165,11 @@ public static int WriteMetric(
buffer[cursor++] = ASCII_LINEFEED;
}
+
+ if (openMetricsRequested)
+ {
+ cursor = WriteCreatedMetric(buffer, cursor, metric, prometheusMetric, metricPoint);
+ }
}
}
@@ -236,4 +254,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 fe5150c8a08..c0bd1a821ca 100644
--- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs
+++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusExporterMiddlewareTests.cs
@@ -236,7 +236,7 @@ public void PrometheusExporterMiddlewareNegotiate_UsesTypedAcceptHeaders(
var context = new DefaultHttpContext();
context.Request.Headers.Accept = accept;
- var actual = PrometheusExporterMiddleware.Negotiate(context.Request);
+ var actual = PrometheusExporterMiddleware.Negotiate(context.Request.GetTypedHeaders());
Assert.Equal(mediaType, actual.MediaType);
Assert.Equal(isOpenMetrics, actual.IsOpenMetrics);
@@ -251,7 +251,7 @@ public void PrometheusExporterMiddlewareNegotiate_UsesFallbackForInvalidHeader(s
var context = new DefaultHttpContext();
context.Request.Headers.Accept = accept;
- var actual = PrometheusExporterMiddleware.Negotiate(context.Request);
+ var actual = PrometheusExporterMiddleware.Negotiate(context.Request.GetTypedHeaders());
Assert.Equivalent(PrometheusProtocol.Fallback, actual);
}
@@ -553,8 +553,13 @@ private static async Task VerifyAsync(
Assert.Equal(contentType, response.Content.Headers.ContentType!.ToString());
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();
@@ -563,16 +568,19 @@ private static async Task VerifyAsync(
# 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
+ counter_double_bytes_total{otel_scope_name="{{MeterName}}",otel_scope_version="{{MeterVersion}}",{{additionalTags}}key1="value1",key2="value2"} 101.17{{createdMetricSample}}
# EOF
""".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..dec79f37a46 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.IO.Compression;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
@@ -68,13 +69,62 @@ public async Task Scrape_Endpoint_Returns_No_Content_If_Sdk_Disabled()
Assert.Empty(content);
}
+ [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; version=0.0.4; charset=utf-8", 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("# EOF\n", content, 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
diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs
index 40a244dcbed..16909d77699 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(ReferenceWriteLabelKey(value)));
+ static (value) => SerializeOpenMetricsLabelKey(value).SequenceEqual(ReferenceWriteOpenMetricsLabelKey(value)));
[Property(MaxTest = MaxTests)]
public Property WriteLabelValueMatchesReferenceImplementation() => Prop.ForAll(
@@ -88,6 +88,12 @@ 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))
@@ -102,10 +108,11 @@ private static byte[] ReferenceWriteLabelKey(string value)
{
var c = value[i];
var isAllowed =
- c is (>= 'A' and <= 'Z') or
- (>= 'a' and <= 'z') or
- (>= '0' and <= '9') or
- '_';
+ (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')
{
diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs
index 270efaa251b..9f61e1863d0 100644
--- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs
+++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs
@@ -293,6 +293,96 @@ 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", "dotnetbot")], 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=\"dotnetbot\"} 1", output, StringComparison.Ordinal);
+ }
+ finally
+ {
+ exporter.CollectionManager.ExitCollect();
+ }
+ }
+
+ [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();
+ }
+ }
+
[Fact]
public async Task DuplicateMetricMetadataIsWrittenOncePerScrape()
{
diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs
index 313f3e8f504..7f8ed8394b4 100644
--- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs
+++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusHttpListenerTests.cs
@@ -612,8 +612,13 @@ private static async Task RunPrometheusExporterHttpServerIntegrationTest(
Assert.Equal(contentType, response.Content.Headers.ContentType.ToString());
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();
@@ -621,14 +626,18 @@ private static async Task RunPrometheusExporterHttpServerIntegrationTest(
? "# 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"
+ + 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";
diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs
index 5bf28e7e0a3..4f419910058 100644
--- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs
+++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs
@@ -293,13 +293,14 @@ public void WriteLabelKeyPrefixesLeadingDigits(bool openMetricsRequested)
}
[Fact]
- public void WriteLabelKeyNormalizesOpenMetricsLabelNames()
+ 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));
+ Assert.Equal("a_b:c", Encoding.UTF8.GetString(buffer, 0, cursor));
+ Assert.Equal("a_b:c", Encoding.UTF8.GetString(buffer, 0, cursor));
}
[Fact]
@@ -757,7 +758,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 +798,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);
@@ -813,6 +814,40 @@ 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]);
+ 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]
public void HistogramOneDimensionWithOpenMetricsFormat()
{
@@ -836,24 +871,25 @@ 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"
+ + $"test_histogram_created{{otel_scope_name='{Utils.GetCurrentMethodName()}',x='1'}} [0-9]+(?:\\.[0-9]+)?\n"
+ "$").Replace('\'', '"');
Assert.Matches(expected, output);
}
@@ -896,7 +932,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(
@@ -918,18 +954,211 @@ 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", "dotnetbot")], 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='dotnetbot'} 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", "dotnetbot"),
+ 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", "dotnetbot"),
+ 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=\"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);
+#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);
+ }
+
+#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);
+ }
+
+ [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]
public void SumWithScopeVersion()
{
@@ -971,28 +1200,29 @@ 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"
+ + $"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));
}
@@ -1018,14 +1248,83 @@ 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=\"-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);
}
+ [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()
{
@@ -1087,7 +1386,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);
}
@@ -1141,6 +1440,141 @@ public void WriteDoubleFormatsNaN()
Assert.Equal("NaN", Encoding.UTF8.GetString(buffer, 0, cursor));
}
+ [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")]
+ [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));
+ }
+
+#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")]
+ [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));
+ }
+
+ [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()
{
@@ -1193,9 +1627,9 @@ public void WriteHistogramMetricSerializesStaticTagsWithoutPreSerializedTags()
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)
@@ -1237,7 +1671,7 @@ static char GetHexValue(int value)
}
}
- 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,