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,