diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md index 1f9079f99fb..c975dde0869 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/CHANGELOG.md @@ -31,6 +31,10 @@ Notes](../../RELEASENOTES.md). disable the `target_info` metric in Prometheus metrics. Defaults to `true`. ([#7438](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7438)) +* Added support for the `allow-utf-8` Prometheus UTF-8 name escaping scheme + when negotiated via the `Accept` header. + ([#7440](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7440)) + ## 1.16.0-beta.1 Released 2026-Jun-10 diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs index 008e6febdad..c620db5499a 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs @@ -248,24 +248,11 @@ private static bool TryParse( if (escapedValue == null || !supportedEscapingSchemes.Contains(escapedValue)) { - // TODO Support other escaping schemes, including at least "allow-utf-8". - // For now we treat "allow-utf-8" as if it were "underscores" to avoid fallback - // to PrometheusText0.0.4 where it would previously match to OpenMetricsText1.0.0. - // See https://github.com/open-telemetry/opentelemetry-dotnet/issues/7246. - if (string.Equals(escapedValue, PrometheusProtocol.AllowUtf8Escaping, StringComparison.Ordinal)) - { - escaping = PrometheusProtocol.UnderscoresEscaping; - } - else - { - // Unsupported escaping scheme - return false; - } - } - else - { - escaping = escapedValue; + // Unsupported escaping scheme + return false; } + + escaping = escapedValue; } } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md index f679d769109..5aad537d7df 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/CHANGELOG.md @@ -37,6 +37,10 @@ Notes](../../RELEASENOTES.md). * Added the `PrometheusHttpListenerOptions.ConfigureHttpListener` option. ([#7448](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7448)) +* Added support for the `allow-utf-8` Prometheus UTF-8 name escaping scheme + when negotiated via the `Accept` header. + ([#7440](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7440)) + ## 1.16.0-beta.1 Released 2026-Jun-10 diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs index 95b79390ffb..17970c7e59f 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs @@ -160,18 +160,7 @@ internal static PrometheusProtocol Negotiate(string? contentType) var trimmed = TrimQuotes(value); var escaping = trimmed.ToString(); - if (PrometheusProtocol.SupportedEscapingSchemes.Contains(escaping)) - { - return escaping; - } - - // TODO Support other escaping schemes, including at least "allow-utf-8". - // For now we treat "allow-utf-8" as if it were "underscores" to avoid fallback - // to PrometheusText0.0.4 where it would previously match to OpenMetricsText1.0.0. - // See https://github.com/open-telemetry/opentelemetry-dotnet/issues/7246. - return string.Equals(escaping, PrometheusProtocol.AllowUtf8Escaping, StringComparison.Ordinal) - ? PrometheusProtocol.UnderscoresEscaping - : null; + return PrometheusProtocol.SupportedEscapingSchemes.Contains(escaping) ? escaping : null; } private static ReadOnlySpan SplitNext(ref ReadOnlySpan span, char character) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/EscapingScheme.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/EscapingScheme.cs index 37691cc207d..4115d2a5fda 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/EscapingScheme.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/EscapingScheme.cs @@ -27,4 +27,11 @@ internal enum EscapingScheme /// characters are encoded as their hexadecimal Unicode code point. /// Values, + + /// + /// Names are not escaped and are emitted as UTF-8. Names that are not valid legacy + /// names are written using the quoted exposition format (the name is moved inside + /// the label braces as a double-quoted string and non-legacy label names are quoted). + /// + AllowUtf8, } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusEscaping.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusEscaping.cs index 21509cd1fd0..b7bdfdbce24 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusEscaping.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusEscaping.cs @@ -25,6 +25,7 @@ internal static class PrometheusEscaping public static EscapingScheme FromString(string? escaping) => escaping switch { + PrometheusProtocol.AllowUtf8Escaping => EscapingScheme.AllowUtf8, PrometheusProtocol.DotsEscaping => EscapingScheme.Dots, PrometheusProtocol.ValuesEscaping => EscapingScheme.Values, _ => EscapingScheme.Underscores, @@ -45,7 +46,9 @@ internal static class PrometheusEscaping /// The escaped name. Always a valid legacy (ASCII) name for the dots and values schemes. public static string EscapeName(string name, EscapingScheme scheme, bool isMetricName = true) { - if (string.IsNullOrEmpty(name) || scheme == EscapingScheme.Underscores) + // The underscores scheme is handled by the OpenTelemetry sanitization, and the allow-utf-8 + // scheme keeps the name unchanged, so neither is escaped here. + if (string.IsNullOrEmpty(name) || scheme is EscapingScheme.AllowUtf8 or EscapingScheme.Underscores) { return name; } @@ -148,15 +151,31 @@ public static int EscapeName(byte[] buffer, int cursor, string name, EscapingSch return cursor; } - private static bool CanWriteValuesNameUnchanged(string name, bool isMetricName) - { - // A pre-existing "U__" prefix must itself be encoded; otherwise a name such as - // "U__foo_2e_bar" collides with the values encoding of "foo.bar". - return IsValidLegacyName(name, isMetricName) && !name.StartsWith(ValuesPrefix, StringComparison.Ordinal); - } + /// + /// Returns whether the specified metric name matches the legacy metric name pattern [a-zA-Z_:][a-zA-Z0-9_:]*. + /// + /// The metric name to validate. + /// + /// if the name is a valid legacy metric name; otherwise, . + /// + internal static bool IsValidLegacyName(string name) => IsValidLegacyName(name, isMetricName: true); + + /// + /// Returns whether the specified label name matches the legacy label name pattern [a-zA-Z_][a-zA-Z0-9_]*. + /// + /// The label name to validate. + /// + /// if the name is a valid legacy label name; otherwise, . + /// + internal static bool IsValidLegacyLabelName(string name) => IsValidLegacyName(name, isMetricName: false); private static bool IsValidLegacyName(string name, bool isMetricName) { + if (name.Length == 0) + { + return false; + } + var index = 0; while (index < name.Length) @@ -219,6 +238,11 @@ private static int GetCodePoint(string value, int index, out int charsConsumed, #endif } + // A pre-existing "U__" prefix must itself be encoded; otherwise a name such as + // "U__foo_2e_bar" collides with the values encoding of "foo.bar". + private static bool CanWriteValuesNameUnchanged(string name, bool isMetricName) + => IsValidLegacyName(name, isMetricName) && !name.StartsWith(ValuesPrefix, StringComparison.Ordinal); + private static int WriteAscii(byte[] buffer, int cursor, string value) { for (var i = 0; i < value.Length; i++) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusMetric.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusMetric.cs index f812e0d4015..69c49289b6b 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusMetric.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusMetric.cs @@ -12,6 +12,7 @@ internal sealed class PrometheusMetric private readonly string rawName; private readonly bool disableTotalNameSuffixForCounters; private readonly NameSet underscoreNames; + private NameSet? allowUtf8Names; private NameSet? dotsNames; private NameSet? valuesNames; @@ -235,6 +236,7 @@ UpDownCounter becomes gauge /// The metric name set for the requested escaping scheme. internal NameSet GetNameSet(EscapingScheme escaping) => escaping switch { + EscapingScheme.AllowUtf8 => this.allowUtf8Names ??= BuildAllowUtf8Names(this.rawName, this.Unit, this.Type, this.disableTotalNameSuffixForCounters), EscapingScheme.Dots => this.dotsNames ??= BuildEscapedNames(this.rawName, this.Unit, this.Type, this.disableTotalNameSuffixForCounters, EscapingScheme.Dots), EscapingScheme.Values => this.valuesNames ??= BuildEscapedNames(this.rawName, this.Unit, this.Type, this.disableTotalNameSuffixForCounters, EscapingScheme.Values), _ => this.underscoreNames, @@ -367,6 +369,51 @@ private static NameSet BuildEscapedNames( return new(escapedName, openMetricsName, openMetricsMetadataName); } + private static NameSet BuildAllowUtf8Names(string name, string? sanitizedUnit, PrometheusType type, bool disableTotalNameSuffixForCounters) + { + // The allow-utf-8 scheme does not escape the name; the original UTF-8 name is kept and only + // the unit and the structural '_total' suffix are appended. The family name determines + // whether the quoted exposition format is required (the structural suffixes are legacy + // characters and so do not affect the legacy validity of the family). + var nameFamily = name; + var openMetricsFamily = type == PrometheusType.Counter + ? RemoveOpenMetricsCounterNameSuffix(name) + : name; + + if (sanitizedUnit != null) + { + // Each name is checked independently: openMetricsFamily has the _total counter + // suffix stripped, so it may already end with the unit even when nameFamily + // (which still carries _total) does not. For counter nameFamily, also check for + // the unit immediately before _total (e.g. "db_bytes_total" with unit "bytes"). + if (!nameFamily.EndsWith(sanitizedUnit, StringComparison.Ordinal) && + (type != PrometheusType.Counter || !nameFamily.EndsWith($"{sanitizedUnit}_total", StringComparison.Ordinal))) + { + nameFamily += $"_{sanitizedUnit}"; + } + + if (!openMetricsFamily.EndsWith(sanitizedUnit, StringComparison.Ordinal)) + { + openMetricsFamily += $"_{sanitizedUnit}"; + } + } + + var metricName = nameFamily; + + if (type == PrometheusType.Counter && !nameFamily.EndsWith("_total", StringComparison.Ordinal) && !disableTotalNameSuffixForCounters) + { + metricName += "_total"; + } + + var openMetricsName = type == PrometheusType.Counter && !openMetricsFamily.EndsWith("_total", StringComparison.Ordinal) + ? openMetricsFamily + "_total" + : openMetricsFamily; + + var isLegacyValid = PrometheusEscaping.IsValidLegacyName(nameFamily); + + return new(metricName, openMetricsName, openMetricsFamily, isLegacyValid, encodeUtf8: true); + } + private static string RemoveOpenMetricsCounterNameSuffix(string metricName) => metricName.EndsWith("_total", StringComparison.Ordinal) ? metricName.Substring(0, metricName.Length - 6) : metricName; @@ -482,14 +529,15 @@ private static bool TryProcessRateUnits(string updatedUnit, [NotNullWhen(true)] /// internal sealed class NameSet { - public NameSet(string name, string openMetricsName, string openMetricsMetadataName) + public NameSet(string name, string openMetricsName, string openMetricsMetadataName, bool isLegacyValid = true, bool encodeUtf8 = false) { this.Name = name; this.OpenMetricsName = openMetricsName; this.OpenMetricsMetadataName = openMetricsMetadataName; - this.NameBytes = ConvertToBytes(name); - this.OpenMetricsNameBytes = ConvertToBytes(openMetricsName); - this.OpenMetricsMetadataNameBytes = ConvertToBytes(openMetricsMetadataName); + this.IsLegacyValid = isLegacyValid; + this.NameBytes = ToBytes(name, encodeUtf8); + this.OpenMetricsNameBytes = ToBytes(openMetricsName, encodeUtf8); + this.OpenMetricsMetadataNameBytes = ToBytes(openMetricsMetadataName, encodeUtf8); } public string Name { get; } @@ -498,10 +546,22 @@ public NameSet(string name, string openMetricsName, string openMetricsMetadataNa public string OpenMetricsMetadataName { get; } + /// + /// Gets a value indicating whether the metric name is a valid legacy name. + /// + /// + /// When (only possible for the allow-utf-8 scheme) + /// the quoted exposition format is required. + /// + public bool IsLegacyValid { get; } + public byte[] NameBytes { get; } public byte[] OpenMetricsNameBytes { get; } public byte[] OpenMetricsMetadataNameBytes { get; } + + private static byte[] ToBytes(string value, bool encodeUtf8) + => encodeUtf8 ? Encoding.UTF8.GetBytes(value) : ConvertToBytes(value); } } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusProtocol.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusProtocol.cs index a57ee151434..473efa97bde 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusProtocol.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusProtocol.cs @@ -32,10 +32,9 @@ namespace OpenTelemetry.Exporter.Prometheus; public static readonly PrometheusProtocol Fallback = new(PrometheusTextMediaType, null, PrometheusV0, false); - // TODO Support the "allow-utf-8" escaping scheme. - // See https://github.com/open-telemetry/opentelemetry-dotnet/issues/7246. internal static readonly SupportedEscapingSchemes SupportedEscapingSchemes = [ + AllowUtf8Escaping, DotsEscaping, UnderscoresEscaping, ValuesEscaping, diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/OpenMetricsSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/OpenMetricsSerializer.cs index 44f364460d8..b213487ca6c 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/OpenMetricsSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/OpenMetricsSerializer.cs @@ -164,8 +164,7 @@ private int WriteCreatedMetric( in TextFormatSerializerOptions options, IReadOnlyCollection? reservedOutputKeys = null) { - cursor = this.WriteMetricNameWithSuffix(buffer, cursor, prometheusMetric, "_created"); - cursor = this.WriteTags(buffer, cursor, metric, metricPoint.Tags, options, reservedOutputKeys: reservedOutputKeys); + cursor = this.WriteSeriesAndTags(buffer, cursor, metric, prometheusMetric, metricPoint.Tags, options, "_created", reservedOutputKeys); buffer[cursor++] = unchecked((byte)' '); diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/OpenMetricsV1Serializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/OpenMetricsV1Serializer.cs index 78b253e2bed..909d2132e81 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/OpenMetricsV1Serializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/OpenMetricsV1Serializer.cs @@ -1,9 +1,37 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +using OpenTelemetry.Metrics; + namespace OpenTelemetry.Exporter.Prometheus.Serialization; internal sealed class OpenMetricsV1Serializer : OpenMetricsSerializer { - // Version-specific behaviour will be added here later + internal override int WriteMetricMetadataName(byte[] buffer, int cursor, PrometheusMetric metric) => + this.RequiresQuotedName(metric) + ? WriteQuotedMetadataName(buffer, cursor, this.GetMetricMetadataNameBytes(metric)) + : base.WriteMetricMetadataName(buffer, cursor, metric); + + internal override int WriteSeriesAndTags( + byte[] buffer, + int cursor, + Metric metric, + PrometheusMetric prometheusMetric, + ReadOnlyTagCollection tags, + in TextFormatSerializerOptions options, + string? suffix, + IReadOnlyCollection? reservedOutputKeys) => + this.RequiresQuotedName(prometheusMetric) + ? this.WriteQuotedSeriesAndTags(buffer, cursor, metric, prometheusMetric, tags, options, suffix, reservedOutputKeys) + : base.WriteSeriesAndTags(buffer, cursor, metric, prometheusMetric, tags, options, suffix, reservedOutputKeys); + + internal override int WriteHistogramBucketName(byte[] buffer, int cursor, PrometheusMetric metric) => + this.RequiresQuotedName(metric) + ? WriteQuotedBucketName(buffer, cursor, this.GetMetricMetadataNameBytes(metric)) + : base.WriteHistogramBucketName(buffer, cursor, metric); + + internal override int WriteSeriesNameAndSerializedTags(byte[] buffer, int cursor, PrometheusMetric metric, string suffix, ReadOnlySpan serializedTags) => + this.RequiresQuotedName(metric) + ? WriteQuotedSeriesNameAndSerializedTags(buffer, cursor, this.GetMetricMetadataNameBytes(metric), suffix, serializedTags) + : base.WriteSeriesNameAndSerializedTags(buffer, cursor, metric, suffix, serializedTags); } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/PrometheusTextV1Serializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/PrometheusTextV1Serializer.cs index e0735647459..9c2d73e4c84 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/PrometheusTextV1Serializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/PrometheusTextV1Serializer.cs @@ -1,9 +1,37 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +using OpenTelemetry.Metrics; + namespace OpenTelemetry.Exporter.Prometheus.Serialization; internal sealed class PrometheusTextV1Serializer : PrometheusTextSerializer { - // Version-specific behaviour will be added here later + internal override int WriteMetricMetadataName(byte[] buffer, int cursor, PrometheusMetric metric) => + this.RequiresQuotedName(metric) + ? WriteQuotedMetadataName(buffer, cursor, this.GetMetricMetadataNameBytes(metric)) + : base.WriteMetricMetadataName(buffer, cursor, metric); + + internal override int WriteSeriesAndTags( + byte[] buffer, + int cursor, + Metric metric, + PrometheusMetric prometheusMetric, + ReadOnlyTagCollection tags, + in TextFormatSerializerOptions options, + string? suffix, + IReadOnlyCollection? reservedOutputKeys) => + this.RequiresQuotedName(prometheusMetric) + ? this.WriteQuotedSeriesAndTags(buffer, cursor, metric, prometheusMetric, tags, options, suffix, reservedOutputKeys) + : base.WriteSeriesAndTags(buffer, cursor, metric, prometheusMetric, tags, options, suffix, reservedOutputKeys); + + internal override int WriteHistogramBucketName(byte[] buffer, int cursor, PrometheusMetric metric) => + this.RequiresQuotedName(metric) + ? WriteQuotedBucketName(buffer, cursor, this.GetMetricMetadataNameBytes(metric)) + : base.WriteHistogramBucketName(buffer, cursor, metric); + + internal override int WriteSeriesNameAndSerializedTags(byte[] buffer, int cursor, PrometheusMetric metric, string suffix, ReadOnlySpan serializedTags) => + this.RequiresQuotedName(metric) + ? WriteQuotedSeriesNameAndSerializedTags(buffer, cursor, this.GetMetricMetadataNameBytes(metric), suffix, serializedTags) + : base.WriteSeriesNameAndSerializedTags(buffer, cursor, metric, suffix, serializedTags); } diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/TextFormatSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/TextFormatSerializer.cs index 3080567995a..17d42b1abf1 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/TextFormatSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/Serialization/TextFormatSerializer.cs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 #if NET -using System.Buffers; using System.Buffers.Text; #if NET9_0_OR_GREATER using System.Collections.Frozen; @@ -10,6 +9,7 @@ using System.Collections.Immutable; #endif #endif +using System.Buffers; using System.Diagnostics; using System.Globalization; using System.Runtime.CompilerServices; @@ -179,9 +179,15 @@ public int WriteMetric( foreach (ref readonly var metricPoint in metric.GetMetricPoints()) { - // Counter and Gauge - cursor = this.WriteMetricName(buffer, cursor, prometheusMetric); - cursor = this.WriteTags(buffer, cursor, metric, metricPoint.Tags, options); + cursor = this.WriteSeriesAndTags( + buffer, + cursor, + metric, + prometheusMetric, + metricPoint.Tags, + options, + suffix: null, + reservedOutputKeys: null); buffer[cursor++] = unchecked((byte)' '); @@ -221,8 +227,8 @@ public int WriteMetric( totalCount += histogramMeasurement.BucketCount; - cursor = this.WriteMetricNameWithSuffix(buffer, cursor, prometheusMetric, "_bucket"); - buffer[cursor++] = unchecked((byte)'{'); + cursor = this.WriteHistogramBucketName(buffer, cursor, prometheusMetric); + cursor = WriteSerializedTagValues(buffer, cursor, serializedTags, appendTrailingComma: true); cursor = WriteAsciiStringNoEscape(buffer, cursor, "le=\""); @@ -251,8 +257,7 @@ public int WriteMetric( // OpenMetrics histograms with negative bucket thresholds MUST NOT expose // _sum and therefore MUST NOT expose _count. // See https://prometheus.io/docs/specs/om/open_metrics_spec/#histogram-1 - cursor = this.WriteMetricNameWithSuffix(buffer, cursor, prometheusMetric, "_sum"); - cursor = WriteSerializedTags(buffer, cursor, serializedTags); + cursor = this.WriteSeriesNameAndSerializedTags(buffer, cursor, prometheusMetric, "_sum", serializedTags); buffer[cursor++] = unchecked((byte)' '); @@ -261,8 +266,7 @@ public int WriteMetric( buffer[cursor++] = AsciiLineFeed; // Histogram count - cursor = this.WriteMetricNameWithSuffix(buffer, cursor, prometheusMetric, "_count"); - cursor = WriteSerializedTags(buffer, cursor, serializedTags); + cursor = this.WriteSeriesNameAndSerializedTags(buffer, cursor, prometheusMetric, "_count", serializedTags); buffer[cursor++] = unchecked((byte)' '); @@ -314,7 +318,7 @@ public int WriteTargetInfo(byte[] buffer, int cursor, Resource resource) } while (attributes.MoveNext()); - cursor = WriteLabels(buffer, cursor, labels, writeEnclosingBraces: true); + cursor = WriteLabels(buffer, cursor, labels, writeEnclosingBraces: true, default, null); buffer[cursor++] = unchecked((byte)' '); buffer[cursor++] = unchecked((byte)'1'); buffer[cursor++] = AsciiLineFeed; @@ -581,12 +585,123 @@ internal static int WriteSerializedTags( return cursor; } + internal static int WriteQuotedName(byte[] buffer, int cursor, ReadOnlySpan nameBytes, string? suffix) + { + // Writes a metric name as a double-quoted string ("name") with the backslash, quote + // and line-feed characters escaped. The (legacy) suffix, if any, is written inside the quotes. + buffer[cursor++] = AsciiQuotationMark; + + foreach (var value in nameBytes) + { + switch (value) + { + case AsciiQuotationMark: + buffer[cursor++] = AsciiReverseSolidus; + buffer[cursor++] = AsciiQuotationMark; + break; + + case AsciiReverseSolidus: + buffer[cursor++] = AsciiReverseSolidus; + buffer[cursor++] = AsciiReverseSolidus; + break; + + case AsciiLineFeed: + buffer[cursor++] = AsciiReverseSolidus; + buffer[cursor++] = unchecked((byte)'n'); + break; + + default: + buffer[cursor++] = value; + break; + } + } + + if (suffix is { Length: > 0 }) + { + cursor = WriteAsciiStringNoEscape(buffer, cursor, suffix); + } + + buffer[cursor++] = AsciiQuotationMark; + + return cursor; + } + + /// + /// Writes a label output key, quoting it (as a double-quoted UTF-8 string) when it is not a + /// valid legacy label name. Only the allow-utf-8 scheme can produce a non-legacy output key; the + /// underscores, dots and values schemes (and all v0 formats) always produce legacy ASCII names, + /// which are written verbatim. The quoting therefore needs no knowledge of the negotiated scheme. + /// + /// The buffer to write to. + /// The current position in the buffer. + /// The label output key to write. + /// The new cursor position. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int WriteLabelName(byte[] buffer, int cursor, string outputKey) + { + if (PrometheusEscaping.IsValidLegacyLabelName(outputKey)) + { + return WriteAsciiStringNoEscape(buffer, cursor, outputKey); + } + + buffer[cursor++] = AsciiQuotationMark; + cursor = WriteLabelValue(buffer, cursor, outputKey); + buffer[cursor++] = AsciiQuotationMark; + + return cursor; + } + + internal static int WriteQuotedMetadataName(byte[] buffer, int cursor, ReadOnlySpan nameBytes) + { + // Writes a metadata-line (# TYPE/# HELP/# UNIT) metric family name in the quoted exposition + // form, e.g. '# TYPE "my.metric" gauge'. Unlike a sample line the name is not wrapped in + // braces. Shared by the v1.0.0 serializers; the base flow never emits the quoted form. + cursor = WriteQuotedName(buffer, cursor, nameBytes, suffix: null); + + return cursor; + } + + internal static int WriteQuotedBucketName(byte[] buffer, int cursor, ReadOnlySpan nameBytes) + { + // Writes a histogram '_bucket' series name in the quoted exposition form and opens the label + // set ('{"name_bucket",'), leaving the cursor positioned for the first tag. + buffer[cursor++] = unchecked((byte)'{'); + cursor = WriteQuotedName(buffer, cursor, nameBytes, "_bucket"); + buffer[cursor++] = unchecked((byte)','); + + return cursor; + } + + internal static int WriteQuotedSeriesNameAndSerializedTags( + byte[] buffer, + int cursor, + ReadOnlySpan nameBytes, + string suffix, + ReadOnlySpan serializedTags) + { + // Writes a histogram '_sum'/'_count' series name and its pre-serialized tags in the quoted + // exposition form ('{"name",tags}'). + buffer[cursor++] = unchecked((byte)'{'); + cursor = WriteQuotedName(buffer, cursor, nameBytes, suffix); + + if (!serializedTags.IsEmpty) + { + buffer[cursor++] = unchecked((byte)','); + cursor = WriteSerializedTagValues(buffer, cursor, serializedTags); + } + + buffer[cursor++] = unchecked((byte)'}'); + + return cursor; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal int WriteExemplar(byte[] buffer, int cursor, in Exemplar exemplar, bool isLongValue) { buffer[cursor++] = unchecked((byte)' '); buffer[cursor++] = unchecked((byte)'#'); buffer[cursor++] = unchecked((byte)' '); + List? labels = null; if (exemplar.TraceId != default) @@ -611,6 +726,8 @@ internal int WriteExemplar(byte[] buffer, int cursor, in Exemplar exemplar, bool cursor, labels, writeEnclosingBraces: true, + default, + null, maxLabelSetCharacters: MaxExemplarLabelSetCharacters); buffer[cursor++] = unchecked((byte)' '); @@ -637,9 +754,28 @@ internal int WriteTags( in TextFormatSerializerOptions options, bool writeEnclosingBraces = true, IReadOnlyCollection? reservedOutputKeys = null) + => this.WriteTags(buffer, cursor, metric, tags, options, default, null, writeEnclosingBraces, reservedOutputKeys); + + internal int WriteTags( + byte[] buffer, + int cursor, + Metric metric, + ReadOnlyTagCollection tags, + in TextFormatSerializerOptions options, + ReadOnlySpan quotedNameBytes, + string? quotedNameSuffix, + bool writeEnclosingBraces, + IReadOnlyCollection? reservedOutputKeys) { + // When quotedNameBytes is non-empty the metric name is embedded as a double-quoted string + // as the first element inside the braces, e.g. {"my.metric",label="value"}. This is the + // allow-utf-8 exposition format used when the metric name is not a valid legacy name. var startCursor = cursor; - List? writtenOutputKeys = null; + + // The fast path writes each output label name directly into the buffer and detects reserved + // names and collisions by comparing the written bytes, so no per-label key string is + // allocated. Each entry is the (start, length) of an already-written key within the buffer. + List<(int Start, int Length)>? writtenKeyRanges = null; var wroteLabel = false; if (writeEnclosingBraces) @@ -647,6 +783,12 @@ internal int WriteTags( buffer[cursor++] = unchecked((byte)'{'); } + if (!quotedNameBytes.IsEmpty) + { + cursor = WriteQuotedName(buffer, cursor, quotedNameBytes, quotedNameSuffix); + wroteLabel = true; + } + if (!options.SuppressScopeInfo) { WriteScopeLabels(); @@ -673,7 +815,10 @@ internal int WriteTags( { foreach (var scopeLabel in CreateScopeLabelData(metric)) { - AddLabel(scopeLabel.OriginalKey, this.GetScopeOutputKey(scopeLabel.OutputKey), scopeLabel.Value, ref labels, reservedOutputKeys); + // Scope labels (otel_scope_*) are already in their target Prometheus form, so they + // are written verbatim and not re-escaped by the negotiated scheme, exactly as the + // fast path does. + AddLabel(scopeLabel.OriginalKey, scopeLabel.OutputKey, scopeLabel.Value, ref labels, reservedOutputKeys); } } @@ -682,7 +827,7 @@ internal int WriteTags( this.AddLabel(tag.Key, tag.Value, ref labels, reservedOutputKeys); } - return WriteLabels(buffer, cursor, labels, writeEnclosingBraces); + return WriteLabels(buffer, cursor, labels, writeEnclosingBraces, quotedNameBytes, quotedNameSuffix); void WriteScopeLabels() { @@ -713,31 +858,48 @@ bool TryWritePointTags() bool TryWriteLabel(string key, object? value, bool isScopeLabel = false) { - // Scope labels arrive in their underscore-normalized (otel_scope_*) form and are then - // escaped using the negotiated scheme, exactly as point tags are. The resulting output - // key is always a legacy-valid ASCII name, so it is both used for de-duplication and - // written verbatim. - var outputKey = isScopeLabel ? this.GetScopeOutputKey(key) : this.GetOutputLabelKey(key); + // The output label name is written directly into the buffer (scope labels arrive already + // in their final form and are written verbatim; point tags are escaped using the + // negotiated scheme). The written bytes are then used to detect reserved names and + // collisions, so no per-label key string is allocated. On a reserved name or a collision + // the write is rolled back by rewinding the cursor; a collision additionally aborts the + // fast path so the slow path can merge. + var rewindCursor = cursor; - if (reservedOutputKeys?.Contains(outputKey) == true) + if (wroteLabel) { - return true; + buffer[cursor++] = unchecked((byte)','); } - if (writtenOutputKeys?.Contains(outputKey) == true) + var keyStart = cursor; + + cursor = isScopeLabel + ? WriteLabelName(buffer, cursor, key) + : this.WriteOutputLabelKey(buffer, cursor, key); + + var writtenKey = new ReadOnlySpan(buffer, keyStart, cursor - keyStart); + + if (IsReservedOutputKey(reservedOutputKeys, writtenKey)) { - return false; + cursor = rewindCursor; + return true; } - writtenOutputKeys ??= []; - writtenOutputKeys.Add(outputKey); - - if (wroteLabel) + if (writtenKeyRanges != null) { - buffer[cursor++] = unchecked((byte)','); + foreach (var (start, length) in writtenKeyRanges) + { + if (new ReadOnlySpan(buffer, start, length).SequenceEqual(writtenKey)) + { + cursor = rewindCursor; + return false; + } + } } - cursor = WriteAsciiStringNoEscape(buffer, cursor, outputKey); + writtenKeyRanges ??= []; + writtenKeyRanges.Add((keyStart, writtenKey.Length)); + cursor = WriteSanitizedLabel(buffer, cursor, value); wroteLabel = true; @@ -784,14 +946,28 @@ internal byte[] SerializeTags( internal int WriteMetricName(byte[] buffer, int cursor, PrometheusMetric metric) => WriteUtf8NoEscape(buffer, cursor, this.GetMetricNameBytes(metric)); + // Writes a metric family name for a metadata (# TYPE/# HELP/# UNIT) line. The base writes the + // legacy name verbatim; the v1.0.0 formats override this to write a non-legacy allow-utf-8 name + // as a quoted string, e.g. '# TYPE "my.metric" gauge'. [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal int WriteMetricMetadataName(byte[] buffer, int cursor, PrometheusMetric metric) + internal virtual int WriteMetricMetadataName(byte[] buffer, int cursor, PrometheusMetric metric) => WriteUtf8NoEscape(buffer, cursor, this.GetMetricMetadataNameBytes(metric)); /// - /// Writes the (already escaped) metric metadata/family name followed by a structural suffix - /// (e.g. "_bucket", "_sum", "_count", "_created"). The suffix is appended literally for all - /// escaping schemes so Prometheus can recognize and strip it when determining the family name. + /// Indicates whether the metric name must be written using the quoted exposition format. + /// + /// The metric to check. + /// if the metric name requires quoting; otherwise, . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal bool RequiresQuotedName(PrometheusMetric metric) + => !metric.GetNameSet(this.Escaping).IsLegacyValid; + + /// + /// Writes a metric family name followed by a serialization-time suffix (e.g. "_bucket", + /// "_sum", "_count", "_created"). For the underscores scheme the pre-computed metadata name + /// bytes are written verbatim followed by the literal suffix. For the dots and values + /// schemes the suffix is part of the (unescaped) intended name, so the intended name and + /// suffix are escaped together as a single unit to keep the structural underscores reversible. /// /// The buffer to write to. /// The current position in the buffer. @@ -808,6 +984,82 @@ internal int WriteMetricNameWithSuffix(byte[] buffer, int cursor, PrometheusMetr return WriteAsciiStringNoEscape(buffer, cursor, suffix); } + internal virtual int WriteSeriesAndTags( + byte[] buffer, + int cursor, + Metric metric, + PrometheusMetric prometheusMetric, + ReadOnlyTagCollection tags, + in TextFormatSerializerOptions options, + string? suffix, + IReadOnlyCollection? reservedOutputKeys) + { + // Writes a sample series name (optionally with a structural suffix) followed by its live tags. + // The base writes the legacy form 'name{tags}'; the v1.0.0 formats override this to + // emit the quoted form '{"name",tags}' for a non-legacy allow-utf-8 name. + cursor = suffix is null + ? this.WriteMetricName(buffer, cursor, prometheusMetric) + : this.WriteMetricNameWithSuffix(buffer, cursor, prometheusMetric, suffix); + + return this.WriteTags(buffer, cursor, metric, tags, options, writeEnclosingBraces: true, reservedOutputKeys: reservedOutputKeys); + } + + internal int WriteQuotedSeriesAndTags( + byte[] buffer, + int cursor, + Metric metric, + PrometheusMetric prometheusMetric, + ReadOnlyTagCollection tags, + in TextFormatSerializerOptions options, + string? suffix, + IReadOnlyCollection? reservedOutputKeys) + { + // Emits a sample series in the quoted exposition form, embedding the (non-legacy) metric name + // as the first quoted element inside the label braces. + + // Counter and gauge samples embed the full name (with any '_total' baked in); a suffixed + // series (e.g. '_created') embeds the metadata family name plus the literal suffix. + var nameBytes = suffix is null + ? this.GetMetricNameBytes(prometheusMetric) + : this.GetMetricMetadataNameBytes(prometheusMetric); + + return this.WriteTags( + buffer, + cursor, + metric, + tags, + options, + nameBytes, + suffix, + writeEnclosingBraces: true, + reservedOutputKeys: reservedOutputKeys); + } + + internal virtual int WriteHistogramBucketName(byte[] buffer, int cursor, PrometheusMetric metric) + { + // Writes a histogram '_bucket' series name and opens the label set, leaving the cursor + // positioned for the first tag. The base writes the legacy form 'name_bucket{'; the v1.0.0 + // formats override this to emit the quoted form '{"name_bucket",' for a non-legacy name. + cursor = this.WriteMetricNameWithSuffix(buffer, cursor, metric, "_bucket"); + buffer[cursor++] = unchecked((byte)'{'); + + return cursor; + } + + // Writes a histogram '_sum'/'_count' series name followed by its (pre-serialized) tags. The + // base writes the legacy form 'name{tags}'; the v1.0.0 formats override this to emit + // the quoted form '{"name",tags}' for a non-legacy allow-utf-8 name. + internal virtual int WriteSeriesNameAndSerializedTags( + byte[] buffer, + int cursor, + PrometheusMetric metric, + string suffix, + ReadOnlySpan serializedTags) + { + cursor = this.WriteMetricNameWithSuffix(buffer, cursor, metric, suffix); + return WriteSerializedTags(buffer, cursor, serializedTags); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal int WriteHelpMetadata(byte[] buffer, int cursor, PrometheusMetric metric, string metricDescription) { @@ -1308,51 +1560,165 @@ private static int WriteSanitizedLabel(byte[] buffer, int cursor, object? labelV return cursor; } - private static void AppendSanitizedLabelKey(StringBuilder builder, string value) + private static void AddLabel( + string originalKey, + string outputKey, + object? value, + ref List? labels, + IReadOnlyCollection? reservedOutputKeys = null) { - if (string.IsNullOrEmpty(value)) + if (reservedOutputKeys?.Contains(outputKey) == true) { - builder.Append('_'); return; } - var lastCharUnderscore = false; + labels ??= []; + labels.Add(new LabelData(originalKey, outputKey, GetLabelValueString(value))); + } + private static bool IsValidLabelKey(string value) + { if (char.IsAsciiDigit(value[0])) { - builder.Append('_'); - lastCharUnderscore = true; + return false; } - for (var i = 0; i < value.Length; i++) + foreach (var character in value) { - var ch = value[i]; + if (!IsAllowedMetricsLabelCharacter(character)) + { + return false; + } + } - if (!IsAllowedMetricsLabelCharacter(ch)) + return true; + } + + private static string SanitizeLabelKey(string value) + { + // The only growth is a single leading underscore added before a leading digit + var buffer = ArrayPool.Shared.Rent(value.Length + 1); + + try + { + var length = 0; + var lastCharUnderscore = false; + + if (char.IsAsciiDigit(value[0])) { - if (!lastCharUnderscore) + buffer[length++] = '_'; + lastCharUnderscore = true; + } + + foreach (var character in value) + { + if (!IsAllowedMetricsLabelCharacter(character)) { - builder.Append('_'); - lastCharUnderscore = true; + if (!lastCharUnderscore) + { + buffer[length++] = '_'; + lastCharUnderscore = true; + } + + continue; } - continue; + buffer[length++] = character; + lastCharUnderscore = character == '_'; } - builder.Append(ch); - lastCharUnderscore = ch == '_'; + return new string(buffer, 0, length); + } + finally + { + ArrayPool.Shared.Return(buffer); } } - private static void AddLabel(string originalKey, string outputKey, object? value, ref List? labels, IReadOnlyCollection? reservedOutputKeys = null) + private static int WriteEscapedLabelKey(byte[] buffer, int cursor, string key, EscapingScheme escaping) { - if (reservedOutputKeys?.Contains(outputKey) == true) + // Writes a dots or values escaped label name directly to the buffer. The escaped form is a valid + // legacy metric name but may contain a colon, which is not permitted in a legacy label name, so + // it is quoted in that case exactly as WriteLabelName would quote the equivalent string. + var scratch = ArrayPool.Shared.Rent((key.Length * 8) + 16); + + try { - return; + var length = PrometheusEscaping.EscapeName(scratch, 0, key, escaping, isMetricName: false); + var escaped = new ReadOnlySpan(scratch, 0, length); + + if (IsValidLegacyLabelName(escaped)) + { + escaped.CopyTo(new Span(buffer, cursor, length)); + return cursor + length; + } + + return WriteQuotedName(buffer, cursor, escaped, suffix: null); + } + finally + { + ArrayPool.Shared.Return(scratch); } + } - labels ??= []; - labels.Add(new LabelData(originalKey, outputKey, GetLabelValueString(value))); + private static bool IsReservedOutputKey(IReadOnlyCollection? reservedOutputKeys, ReadOnlySpan writtenKey) + { + if (reservedOutputKeys == null) + { + return false; + } + + foreach (var reserved in reservedOutputKeys) + { + if (SpanEqualsAscii(writtenKey, reserved)) + { + return true; + } + } + + return false; + } + + private static bool SpanEqualsAscii(ReadOnlySpan span, string value) + { + if (span.Length != value.Length) + { + return false; + } + + for (var i = 0; i < span.Length; i++) + { + if (span[i] != unchecked((byte)value[i])) + { + return false; + } + } + + return true; + } + + private static bool IsValidLegacyLabelName(ReadOnlySpan name) + { + if (name.IsEmpty) + { + return false; + } + + for (var i = 0; i < name.Length; i++) + { + var ch = name[i]; + + var valid = + ch is (>= (byte)'a' and <= (byte)'z') or (>= (byte)'A' and <= (byte)'Z') or (byte)'_' || + (i > 0 && ch is >= (byte)'0' and <= (byte)'9'); + + if (!valid) + { + return false; + } + } + + return true; } private static int WriteLabels( @@ -1360,6 +1726,8 @@ private static int WriteLabels( int cursor, IReadOnlyList? labels, bool writeEnclosingBraces, + ReadOnlySpan quotedNameBytes, + string? quotedNameSuffix, int? maxLabelSetCharacters = null) { if (writeEnclosingBraces) @@ -1370,6 +1738,14 @@ private static int WriteLabels( var wroteLabel = false; var labelSetCharacters = 0; + if (!quotedNameBytes.IsEmpty) + { + // Embed the metric name as the first (quoted) element inside the braces. + cursor = WriteQuotedName(buffer, cursor, quotedNameBytes, quotedNameSuffix); + buffer[cursor++] = unchecked((byte)','); + wroteLabel = true; + } + if (labels != null && labels.Count > 0) { List? orderedKeys = null; @@ -1413,9 +1789,9 @@ private static int WriteLabels( labelSetCharacters += labelCharacters; } - // The grouped key is already the final (escaped) output key, so it is written - // verbatim rather than being escaped again. - cursor = WriteAsciiStringNoEscape(buffer, cursor, key); + // The grouped key is already the final output key; it is written verbatim, or + // quoted when the allow-utf-8 scheme produced a non-legacy label name. + cursor = WriteLabelName(buffer, cursor, key); cursor = WriteSanitizedLabel(buffer, cursor, value); buffer[cursor++] = unchecked((byte)','); wroteLabel = true; @@ -1690,11 +2066,6 @@ private string[] GetReservedExemplarOutputKeys() // the negotiated scheme reverses it correctly; for example "otel_scope_dot_name" would // otherwise be incorrectly decoded under the dots scheme into "otel_scope.name", losing // the required "otel_scope_" prefix. The underscores scheme leaves the key unchanged. - private string GetScopeOutputKey(string outputKey) => - this.Escaping == EscapingScheme.Underscores - ? outputKey - : PrometheusEscaping.EscapeName(outputKey, this.Escaping, isMetricName: false); - private string GetOutputLabelKey(string value) { // The dots and values schemes produce a reversible, legacy-valid ASCII label name. The @@ -1704,9 +2075,38 @@ private string GetOutputLabelKey(string value) return string.IsNullOrEmpty(value) ? "_" : PrometheusEscaping.EscapeName(value, this.Escaping, isMetricName: false); } - var builder = new StringBuilder(value.Length + 1); - AppendSanitizedLabelKey(builder, value); - return builder.ToString(); + if (string.IsNullOrEmpty(value)) + { + return "_"; + } + + // Check for validity first, since the vast majority of label + // keys are already valid, to avoid allocating to sanitize it. + return IsValidLabelKey(value) ? value : SanitizeLabelKey(value); + } + + private int WriteOutputLabelKey(byte[] buffer, int cursor, string key) + { + // Writes a point tag's output label name directly to the buffer using the negotiated escaping + // scheme, without allocating an intermediate string. This is the buffer-writing counterpart of + // GetOutputLabelKey followed by WriteLabelName, used by the fast label-writing path. + if (string.IsNullOrEmpty(key)) + { + buffer[cursor++] = unchecked((byte)'_'); + return cursor; + } + + return this.Escaping switch + { + // The underscores scheme always produces a valid legacy label name (no quoting needed). + EscapingScheme.Underscores => WriteNormalizedLabelKey(buffer, cursor, key), + + // The allow-utf-8 scheme keeps the name as-is, quoting it when it is not a legacy name. + EscapingScheme.AllowUtf8 => WriteLabelName(buffer, cursor, key), + + // The dots and values schemes escape the name and quote it only if a colon survives. + _ => WriteEscapedLabelKey(buffer, cursor, key, this.Escaping), + }; } private void AddLabel(string originalKey, object? value, ref List? labels, IReadOnlyCollection? reservedOutputKeys = null) diff --git a/test/Benchmarks/Exporter/PrometheusSerializerEscapingBenchmarks.cs b/test/Benchmarks/Exporter/PrometheusSerializerEscapingBenchmarks.cs new file mode 100644 index 00000000000..88e5dd09aeb --- /dev/null +++ b/test/Benchmarks/Exporter/PrometheusSerializerEscapingBenchmarks.cs @@ -0,0 +1,134 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics.Metrics; +using BenchmarkDotNet.Attributes; +using OpenTelemetry; +using OpenTelemetry.Exporter.Prometheus; +using OpenTelemetry.Exporter.Prometheus.Serialization; +using OpenTelemetry.Metrics; +using OpenTelemetry.Tests; + +namespace Benchmarks.Exporter; + +/// +/// Compares the serialization throughput and allocation of the Prometheus serializer across every +/// combination of the text formats and escaping schemes. +/// +#pragma warning disable CA1001 // Types that own disposable fields should be disposable - handled by GlobalCleanup +public class PrometheusSerializerEscapingBenchmarks +#pragma warning restore CA1001 // Types that own disposable fields should be disposable - handled by GlobalCleanup +{ + private readonly List metrics = []; + private readonly byte[] buffer = new byte[85000]; + private readonly Dictionary cache = []; + private Meter? meter; + private MeterProvider? meterProvider; + private TextFormatSerializer? serializer; + + /// + /// The Prometheus exposition text format and version to serialize with. + /// + public enum TextFormat + { + /// The Prometheus text format version 0.0.4. + PrometheusTextV0, + + /// The Prometheus text format version 1.0.0. + PrometheusTextV1, + + /// The OpenMetrics text format version 0.0.1. + OpenMetricsV0, + + /// The OpenMetrics text format version 1.0.0. + OpenMetricsV1, + } + + [Params(TextFormat.PrometheusTextV0, TextFormat.PrometheusTextV1, TextFormat.OpenMetricsV0, TextFormat.OpenMetricsV1)] + public TextFormat Format { get; set; } + + [Params( + PrometheusProtocol.UnderscoresEscaping, + PrometheusProtocol.DotsEscaping, + PrometheusProtocol.ValuesEscaping, + PrometheusProtocol.AllowUtf8Escaping)] + public string Escaping { get; set; } = PrometheusProtocol.UnderscoresEscaping; + + [GlobalSetup] + public void GlobalSetup() + { + this.meter = new Meter(Utils.GetCurrentMethodName()); + this.meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter(this.meter.Name) + .AddInMemoryExporter(this.metrics) + .Build(); + + var counter = this.meter.CreateCounter("http.server.request.count", "By", "Number of requests."); + counter.Add(18, new("http.method", "GET"), new("http.route", "/api/values")); + + _ = this.meter.CreateObservableGauge("system.cpu.utilization", () => 0.42D, description: "CPU utilization."); + + var histogram = this.meter.CreateHistogram("http.server.request.duration", "s", "Request duration."); + histogram.Record(0.25, new("http.method", "GET"), new("http.route", "/api/values")); + + var typedLabels = this.meter.CreateCounter("rpc.client.duration", "ms", "RPC duration."); + typedLabels.Add(18, new("rpc.success", true), new("rpc.attempt", 3L), new("rpc.cost", 12.5)); + + this.meterProvider.ForceFlush(); + + var (mediaType, version, isOpenMetrics) = this.Format switch + { + TextFormat.PrometheusTextV0 => (PrometheusProtocol.PrometheusTextMediaType, PrometheusProtocol.PrometheusV0, false), + TextFormat.PrometheusTextV1 => (PrometheusProtocol.PrometheusTextMediaType, PrometheusProtocol.PrometheusV1, false), + TextFormat.OpenMetricsV0 => (PrometheusProtocol.OpenMetricsMediaType, PrometheusProtocol.OpenMetricsV0, true), + TextFormat.OpenMetricsV1 => (PrometheusProtocol.OpenMetricsMediaType, PrometheusProtocol.OpenMetricsV1, true), + _ => throw new NotSupportedException(), + }; + + var protocol = new PrometheusProtocol(mediaType, this.Escaping, version, isOpenMetrics); + + this.serializer = TextFormatSerializer.GetSerializer(protocol); + + // Warm the per-scheme name cache so the benchmark measures steady-state serialization, which + // is what a long-running scrape endpoint experiences (the escaped name sets are computed + // lazily once per scheme and then reused). + this.WriteMetrics(); + } + + [GlobalCleanup] + public void GlobalCleanup() + { + this.meter?.Dispose(); + this.meterProvider?.Dispose(); + } + + [Benchmark] + public void WriteMetrics() + { + var cursor = 0; + foreach (var metric in this.metrics) + { + cursor = this.serializer!.WriteMetric( + this.buffer, + cursor, + metric, + this.GetPrometheusMetric(metric), + writeType: true, + writeUnit: true, + writeHelp: true, + unitOverride: null, + helpOverride: null); + } + } + + private PrometheusMetric GetPrometheusMetric(Metric metric) + { + if (!this.cache.TryGetValue(metric, out var prometheusMetric)) + { + prometheusMetric = PrometheusMetric.Create(metric, false); + this.cache[metric] = prometheusMetric; + } + + return prometheusMetric; + } +} diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs index d810ba3c9ad..913be505e46 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/PrometheusIntegrationTests.cs @@ -208,23 +208,48 @@ public async Task Prometheus_Can_Scrape_Metrics(string scrapeProtocol) => await await WaitForServiceDiscoveryAsync(prometheusBaseAddress, outputHelper, cts.Token); - HashSet expectedSeries = - [ + // Prometheus negotiates the allow-utf-8 escaping scheme for the v1.0.0 text formats, so + // the OpenTelemetry instrument names (which contain '.') are exposed and stored verbatim + // using the quoted exposition format rather than being sanitized to '_'. When no scrape + // protocol is pinned Prometheus defaults to the v1.0.0 formats, so UTF-8 names are used + // unless a v0 protocol is explicitly negotiated. + var usesUtf8Names = !scrapeProtocol.Contains("0.0.", StringComparison.Ordinal); + + HashSet expectedSeries = usesUtf8Names + ? + [ +#if NET10_0_OR_GREATER + "aspnetcore.memory_pool.allocated_bytes_total", +#endif + "http.server.active_requests", + "http.server.request.duration_seconds_bucket", + "http.server.request.duration_seconds_count", + "http.server.request.duration_seconds_sum", + "kestrel.active_connections", + "kestrel.connection.duration_seconds_bucket", + "kestrel.connection.duration_seconds_count", + "kestrel.connection.duration_seconds_sum", + "processed_bytes_total", + "queue_balance", + "temperature_celsius", + ] + : + [ #if NET10_0_OR_GREATER - "aspnetcore_memory_pool_allocated_bytes_total", + "aspnetcore_memory_pool_allocated_bytes_total", #endif - "http_server_active_requests", - "http_server_request_duration_seconds_bucket", - "http_server_request_duration_seconds_count", - "http_server_request_duration_seconds_sum", - "kestrel_active_connections", - "kestrel_connection_duration_seconds_bucket", - "kestrel_connection_duration_seconds_count", - "kestrel_connection_duration_seconds_sum", - "processed_bytes_total", - "queue_balance", - "temperature_celsius", - ]; + "http_server_active_requests", + "http_server_request_duration_seconds_bucket", + "http_server_request_duration_seconds_count", + "http_server_request_duration_seconds_sum", + "kestrel_active_connections", + "kestrel_connection_duration_seconds_bucket", + "kestrel_connection_duration_seconds_count", + "kestrel_connection_duration_seconds_sum", + "processed_bytes_total", + "queue_balance", + "temperature_celsius", + ]; HashSet actualSeries = []; @@ -373,6 +398,7 @@ static async Task WaitForServiceDiscoveryAsync( [InlineData("text/plain")] [InlineData("text/plain;version=0.0.4")] [InlineData("text/plain;version=1.0.0")] + [InlineData("text/plain;version=1.0.0;escaping=allow-utf-8", Skip = "https://github.com/prometheus/prometheus/issues/8932")] [InlineData("text/plain;version=1.0.0;escaping=dots")] [InlineData("text/plain;version=1.0.0;escaping=values")] [InlineData("application/openmetrics-text", Skip = "https://github.com/prometheus/prometheus/issues/8932")] diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs index 1dd0c29ae04..3399eb8a925 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs @@ -66,6 +66,21 @@ public Property EscapeNameToBufferMatchesStringOverload() => Prop.ForAll( SerializeEscapeName(value, EscapingScheme.Dots).SequenceEqual(Encoding.ASCII.GetBytes(PrometheusEscaping.EscapeName(value, EscapingScheme.Dots))) && SerializeEscapeName(value, EscapingScheme.Values).SequenceEqual(Encoding.ASCII.GetBytes(PrometheusEscaping.EscapeName(value, EscapingScheme.Values)))); + [Property(MaxTest = MaxTests)] + public Property IsValidLegacyNameMatchesReferenceImplementation() => Prop.ForAll( + Generators.PrometheusStringArbitrary(), + static (value) => PrometheusEscaping.IsValidLegacyName(value) == ReferenceIsValidLegacyName(value)); + + [Property(MaxTest = MaxTests)] + public Property IsValidLegacyLabelNameMatchesReferenceImplementation() => Prop.ForAll( + Generators.PrometheusStringArbitrary(), + static (value) => PrometheusEscaping.IsValidLegacyLabelName(value) == ReferenceIsValidLegacyLabelName(value)); + + [Property(MaxTest = MaxTests)] + public Property WriteLabelNameMatchesReferenceImplementation() => Prop.ForAll( + Generators.PrometheusStringArbitrary(), + static (value) => Serialize(value, static (buffer, cursor, text) => TextFormatSerializer.WriteLabelName(buffer, cursor, text)).SequenceEqual(ReferenceWriteLabelName(value))); + private static byte[] Serialize(string value, Func writer) { var buffer = new byte[(value.Length * 8) + 16]; @@ -213,7 +228,7 @@ private static string ReferenceEscapeName(string value, EscapingScheme scheme) { text.Append('_').Append(codePoint.ToString("x", CultureInfo.InvariantCulture)).Append('_'); } - else if (ReferenceIsValidLegacyRune(codePoint, index == 0)) + else if (ReferenceIsValidLegacyRune(codePoint, index == 0, isMetricName: true)) { text.Append((char)codePoint); } @@ -236,15 +251,24 @@ private static string ReferenceEscapeName(string value, EscapingScheme scheme) return text.ToString(); } - private static bool ReferenceIsValidLegacyName(string value) + private static bool ReferenceIsValidLegacyName(string value) => ReferenceIsValidLegacyName(value, isMetricName: true); + + private static bool ReferenceIsValidLegacyLabelName(string value) => ReferenceIsValidLegacyName(value, isMetricName: false); + + private static bool ReferenceIsValidLegacyName(string value, bool isMetricName) { + if (value.Length == 0) + { + return false; + } + var index = 0; while (index < value.Length) { var codePoint = ReferenceGetCodePoint(value, index, out var charsConsumed, out _); - if (!ReferenceIsValidLegacyRune(codePoint, index == 0)) + if (!ReferenceIsValidLegacyRune(codePoint, index == 0, isMetricName)) { return false; } @@ -255,8 +279,24 @@ private static bool ReferenceIsValidLegacyName(string value) return true; } - private static bool ReferenceIsValidLegacyRune(int codePoint, bool isFirst) => - codePoint is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '_' or ':' || + // An independent re-implementation of TextFormatSerializer.WriteLabelName: a non-legacy label + // name is emitted as a double-quoted UTF-8 string (escaping '"', '\' and '\n'); a legacy name is + // written verbatim as ASCII bytes. + private static byte[] ReferenceWriteLabelName(string value) + { + if (ReferenceIsValidLegacyLabelName(value)) + { + return ReferenceWriteAsciiStringNoEscape(value); + } + + var escaped = ReferenceWriteEscapedString(value, escapeQuotationMarks: true); + + return [(byte)'"', .. escaped, (byte)'"']; + } + + private static bool ReferenceIsValidLegacyRune(int codePoint, bool isFirst, bool isMetricName) => + codePoint is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '_' || + (isMetricName && codePoint == ':') || (!isFirst && codePoint is >= '0' and <= '9'); private static int ReferenceGetCodePoint(string value, int index, out int charsConsumed, out bool isValidRune) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusAcceptHeaders.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusAcceptHeaders.cs index a3962cdfb90..f164a294f28 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusAcceptHeaders.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusAcceptHeaders.cs @@ -31,16 +31,6 @@ public static class PrometheusAcceptHeaders testCases.Add(accept, "text/plain", false, "0.0.4", null); } - string[] prometheusV1 = - [ - "text/plain;version=1.0.0;escaping=allow-utf-8;q=0.6,*/*;q=0.5", - ]; - - foreach (var accept in prometheusV1) - { - testCases.Add(accept, "text/plain", false, "1.0.0", "underscores"); - } - string[] openMetricsV0 = [ "application/openmetrics-text", @@ -61,17 +51,13 @@ public static class PrometheusAcceptHeaders [ "application/openmetrics-text; version=1.0.0", "application/openmetrics-text; version=\"1.0.0\"", - "application/openmetrics-text; version=1.0.0; escaping=allow-utf-8", "application/openmetrics-text; version=1.0.0; escaping=underscores", "application/openmetrics-text; version=\"1.0.0\"; escaping=\"underscores\"", "application/openmetrics-text; version=1.0.0; charset=utf-8", "Application/OpenMetrics-Text; version=1.0.0", "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores", - "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=allow-utf-8", "text/plain; q=0.3, application/openmetrics-text; version=1.0.0; q=0.9", "TEXT/PLAIN; q=0.3, Application/OpenMetrics-Text; version=1.0.0; q=0.9", - "application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.6,*/*;q=0.5", - "application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.6,application/openmetrics-text;version=0.0.1;q=0.5,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.4,text/plain;version=0.0.4;q=0.3,*/*;q=0.2", "application/openmetrics-text;version=1.0.0;escaping=underscores;q=0.6,*/*;q=0.5", "application/openmetrics-text;version=1.0.0;escaping=underscores;q=0.6,application/openmetrics-text;version=0.0.1;q=0.5,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.4,text/plain;version=0.0.4;q=0.3,*/*;q=0.2", ]; @@ -81,7 +67,12 @@ public static class PrometheusAcceptHeaders testCases.Add(accept, "application/openmetrics-text", true, "1.0.0", "underscores"); } - // The `dots` and `values` escaping schemes are only negotiated for the v1 formats + // The allow-utf-8, dots and values escaping schemes are negotiated for only the v1 formats. + testCases.Add("text/plain;version=1.0.0;escaping=allow-utf-8;q=0.6,*/*;q=0.5", "text/plain", false, "1.0.0", "allow-utf-8"); + testCases.Add("application/openmetrics-text; version=1.0.0; escaping=allow-utf-8", "application/openmetrics-text", true, "1.0.0", "allow-utf-8"); + testCases.Add("application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=allow-utf-8", "application/openmetrics-text", true, "1.0.0", "allow-utf-8"); + testCases.Add("application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.6,*/*;q=0.5", "application/openmetrics-text", true, "1.0.0", "allow-utf-8"); + testCases.Add("application/openmetrics-text;version=1.0.0;escaping=allow-utf-8;q=0.6,application/openmetrics-text;version=0.0.1;q=0.5,text/plain;version=1.0.0;escaping=allow-utf-8;q=0.4,text/plain;version=0.0.4;q=0.3,*/*;q=0.2", "application/openmetrics-text", true, "1.0.0", "allow-utf-8"); testCases.Add("application/openmetrics-text; version=1.0.0; escaping=dots", "application/openmetrics-text", true, "1.0.0", "dots"); testCases.Add("application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=dots", "application/openmetrics-text", true, "1.0.0", "dots"); testCases.Add("application/openmetrics-text; version=1.0.0; escaping=values", "application/openmetrics-text", true, "1.0.0", "values"); diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusEscapingTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusEscapingTests.cs index 042b70faf6e..b2a2ad6ddab 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusEscapingTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusEscapingTests.cs @@ -1,6 +1,8 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +using System.Text; + namespace OpenTelemetry.Exporter.Prometheus.Tests; public static class PrometheusEscapingTests @@ -66,12 +68,42 @@ public static void EscapeName_Values_EncodesUnpairedSurrogateAsReplacementCharac PrometheusEscaping.EscapeName(name, EscapingScheme.Values)); } + [Fact] + public static void EscapeName_ToBuffer_Values_WithLegacyValidName_WritesNameUnchanged() + { + var buffer = new byte[32]; + + var cursor = PrometheusEscaping.EscapeName(buffer, 0, "no:escaping_required", EscapingScheme.Values); + + Assert.Equal("no:escaping_required", Encoding.ASCII.GetString(buffer, 0, cursor)); + } + [Theory] - [InlineData("metric.name", "metric.name")] - [InlineData("a_b", "a_b")] - [InlineData("", "")] - public static void EscapeName_Underscores_IsHandledElsewhere(string name, string expected) - => Assert.Equal(expected, PrometheusEscaping.EscapeName(name, EscapingScheme.Underscores)); + [InlineData("metric_name", true)] + [InlineData("Avalid_23name", true)] + [InlineData("colon:in:the:middle", true)] + [InlineData("a\u00C5z", false)] + [InlineData("MetricName", true)] + [InlineData("_metric", true)] + [InlineData(":metric", true)] + [InlineData("http.server.requests", false)] + [InlineData("0metric", false)] + [InlineData("metric.name", false)] + [InlineData("metric name", false)] + [InlineData("", false)] + public static void IsValidLegacyName_ValidatesMetricNames(string name, bool expected) + => Assert.Equal(expected, PrometheusEscaping.IsValidLegacyName(name)); + + [Theory] + [InlineData("label_name", true)] + [InlineData("LabelName", true)] + [InlineData("_label", true)] + [InlineData("la:bel", false)] + [InlineData("http.method", false)] + [InlineData("0label", false)] + [InlineData("", false)] + public static void IsValidLegacyLabelName_DisallowsColons(string name, bool expected) + => Assert.Equal(expected, PrometheusEscaping.IsValidLegacyLabelName(name)); [Theory] [InlineData("http:method", "http_method")] @@ -85,6 +117,16 @@ public static void EscapeName_Dots_LabelNameEncodesColon(string name, string exp public static void EscapeName_Values_LabelNameEncodesColon(string name, string expected) => Assert.Equal(expected, PrometheusEscaping.EscapeName(name, EscapingScheme.Values, isMetricName: false)); + [Theory] + [InlineData(EscapingScheme.AllowUtf8, "metric.name", "metric.name")] + [InlineData(EscapingScheme.AllowUtf8, "a_b", "a_b")] + [InlineData(EscapingScheme.AllowUtf8, "", "")] + [InlineData(EscapingScheme.Underscores, "metric.name", "metric.name")] + [InlineData(EscapingScheme.Underscores, "a_b", "a_b")] + [InlineData(EscapingScheme.Underscores, "", "")] + internal static void EscapeName_LeavesNameUnchanged(EscapingScheme scheme, string name, string expected) + => Assert.Equal(expected, PrometheusEscaping.EscapeName(name, scheme)); + [Theory] [InlineData(EscapingScheme.Dots, "http:method", "http:method")] [InlineData(EscapingScheme.Values, "http:method", "http:method")] @@ -105,11 +147,12 @@ internal static void EscapeName_ToBuffer_WithEmptyName_LeavesCursorUnchanged(Esc [Theory] [InlineData(null, EscapingScheme.Underscores)] - [InlineData("underscores", EscapingScheme.Underscores)] + [InlineData("", EscapingScheme.Underscores)] + [InlineData("allow-utf-8", EscapingScheme.AllowUtf8)] + [InlineData("anything-else", EscapingScheme.Underscores)] [InlineData("dots", EscapingScheme.Dots)] + [InlineData("underscores", EscapingScheme.Underscores)] [InlineData("values", EscapingScheme.Values)] - [InlineData("allow-utf-8", EscapingScheme.Underscores)] - [InlineData("anything-else", EscapingScheme.Underscores)] internal static void FromString_MapsEscapingScheme(string? escaping, EscapingScheme expected) => Assert.Equal(expected, PrometheusEscaping.FromString(escaping)); } diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs index da926980a71..0cb00e5900c 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusIntegrationTests.cs @@ -17,6 +17,7 @@ public class PrometheusIntegrationTests(PromToolFixture fixture, ITestOutputHelp [InlineData("text/plain")] [InlineData("text/plain;version=0.0.4")] [InlineData("text/plain;version=1.0.0")] + [InlineData("text/plain;version=1.0.0;escaping=allow-utf-8", Skip = "https://github.com/prometheus/prometheus/issues/8932")] [InlineData("text/plain;version=1.0.0;escaping=dots")] [InlineData("text/plain;version=1.0.0;escaping=values")] [InlineData("application/openmetrics-text", Skip = "https://github.com/prometheus/prometheus/issues/8932")] diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs index 5cd82d59dd8..7ce587afdbb 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusMetricTests.cs @@ -492,6 +492,39 @@ public void GetNames_Underscores_MatchesDefaultProperties() Assert.Equal(metric.Name, names.Name); } + [Fact] + public void GetNames_AllowUtf8_CounterWithUnitBeforeTotalSuffix_DoesNotDuplicateUnit() + { + var metric = new PrometheusMetric("db_bytes_total", "By", PrometheusType.Counter, false); + var names = metric.GetNameSet(EscapingScheme.AllowUtf8); + + Assert.Equal("db_bytes_total", names.Name); + Assert.Equal("db_bytes_total", names.OpenMetricsName); + Assert.Equal("db_bytes", names.OpenMetricsMetadataName); + } + + [Fact] + public void GetNames_AllowUtf8_GaugeNamedWithTotalSuffix_KeepsSuffix() + { + var metric = new PrometheusMetric("requests_total", string.Empty, PrometheusType.Gauge, false); + var names = metric.GetNameSet(EscapingScheme.AllowUtf8); + + Assert.Equal("requests_total", names.Name); + Assert.Equal("requests_total", names.OpenMetricsName); + Assert.Equal("requests_total", names.OpenMetricsMetadataName); + } + + [Fact] + public void GetNames_AllowUtf8_GaugeNamedExactlyTotal_IsNotEmptied() + { + var metric = new PrometheusMetric("_total", string.Empty, PrometheusType.Gauge, false); + var names = metric.GetNameSet(EscapingScheme.AllowUtf8); + + Assert.Equal("_total", names.Name); + Assert.Equal("_total", names.OpenMetricsName); + Assert.Equal("_total", names.OpenMetricsMetadataName); + } + [Theory] [InlineData(PrometheusType.Counter)] [InlineData(PrometheusType.Gauge)] diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 430520487fd..c2d377425ec 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -18,6 +18,38 @@ public sealed partial class PrometheusSerializerTests { internal static readonly VerifySettings VerifySettings = CreateVerifySettings(); +#pragma warning disable CA1825 // Workaround for https://github.com/dotnet/sdk/issues/54275 + public static TheoryData EscapingSchemes => + [ + PrometheusProtocol.AllowUtf8Escaping, + PrometheusProtocol.DotsEscaping, + PrometheusProtocol.UnderscoresEscaping, + PrometheusProtocol.ValuesEscaping, + ]; +#pragma warning restore CA1825 + + public static TheoryData EscapingSchemesAndFormats + { + get + { + var data = new TheoryData(); + + foreach (var escaping in new[] + { + PrometheusProtocol.AllowUtf8Escaping, + PrometheusProtocol.DotsEscaping, + PrometheusProtocol.UnderscoresEscaping, + PrometheusProtocol.ValuesEscaping, + }) + { + data.Add(escaping, false); + data.Add(escaping, true); + } + + return data; + } + } + public static TheoryData LabelValueBoundaryCases => new() { { false, "false" }, @@ -1755,9 +1787,7 @@ public void ExemplarLabelWithSurrogatePairCountsUnicodeCodePoints() } [Theory] - [InlineData(PrometheusProtocol.DotsEscaping)] - [InlineData(PrometheusProtocol.UnderscoresEscaping)] - [InlineData(PrometheusProtocol.ValuesEscaping)] + [MemberData(nameof(EscapingSchemes))] public async Task WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme(string escaping) { var buffer = new byte[85000]; @@ -1799,6 +1829,30 @@ public async Task WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme(st await Verify(output, "txt", VerifySettings).UseParameters(escaping); } + [Theory] + [MemberData(nameof(EscapingSchemes))] + public async Task WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules(string escaping) + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = CreateMeter(); + var counter = meter.CreateCounter("test_counter"); + + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + counter.Add(1, new KeyValuePair("http:method", "GET")); + + provider.ForceFlush(); + + var output = WriteMetricWithEscaping(buffer, metrics[0], escaping); + + await Verify(output, "txt", VerifySettings).UseParameters(escaping); + } + [Fact] public async Task WriteMetricEscapesScopeLabelKeysUsingNegotiatedDotsScheme() { @@ -1825,6 +1879,38 @@ public async Task WriteMetricEscapesScopeLabelKeysUsingNegotiatedDotsScheme() await Verify(output, "txt", VerifySettings); } + [Theory] + [InlineData(PrometheusProtocol.AllowUtf8Escaping, "otel_scope_name")] + [InlineData(PrometheusProtocol.DotsEscaping, "otel/scope/name")] + [InlineData(PrometheusProtocol.UnderscoresEscaping, "otel_scope_name")] + [InlineData(PrometheusProtocol.ValuesEscaping, "otel_scope_name")] + public async Task WriteMetricWritesScopeLabelsVerbatim(string escaping, string collidingPointTagKey) + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = new Meter( + nameof(this.WriteMetricWritesScopeLabelsVerbatim), + "1.0.0", + tags: null, + scope: null); + + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + meter.CreateCounter("test_counter").Add( + 1, + new KeyValuePair(collidingPointTagKey, "point")); + + provider.ForceFlush(); + + var output = WriteMetricWithEscaping(buffer, metrics[0], escaping); + + await Verify(output, "txt", VerifySettings).UseParameters(escaping); + } + [Fact] public async Task WriteExemplarEscapesReservedTraceIdKeyToPreventCollisionWithFilteredTag() { @@ -1863,6 +1949,146 @@ public async Task WriteExemplarEscapesReservedTraceIdKeyToPreventCollisionWithFi await Verify(output, "txt", VerifySettings); } + [Fact] + public async Task WriteMetric_AllowUtf8_EscapesQuoteBackslashAndLineFeedInQuotedName() + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = CreateMeter(); + var counter = meter.CreateCounter("original_name"); + + // The Meter API rejects instrument names containing characters such as '"', '\' and '\n', + // but the OpenTelemetry specification does not subject a View-provided stream name to the + // instrument name syntax, so a renamed metric can contain them. Such a name is not a valid + // legacy name, so the allow-utf-8 format writes it as a quoted string in which those + // characters must themselves be escaped. + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddView("original_name", "a\"b\\c\nd") + .AddInMemoryExporter(metrics) + .Build(); + + counter.Add(1); + + provider.ForceFlush(); + + var protocol = new PrometheusProtocol( + PrometheusProtocol.OpenMetricsMediaType, + PrometheusProtocol.AllowUtf8Escaping, + PrometheusProtocol.OpenMetricsV1, + isOpenMetrics: true); + + var serializer = TextFormatSerializer.GetSerializer(protocol); + + var cursor = serializer.WriteMetric( + buffer, + 0, + metrics[0], + PrometheusMetric.Create(metrics[0], false), + writeType: true, + writeUnit: false, + writeHelp: false, + unitOverride: null, + helpOverride: null); + + var output = Encoding.UTF8.GetString(buffer, 0, cursor); + + await Verify(output, "txt", VerifySettings); + } + + [Fact] + public async Task WriteMetric_AllowUtf8_EmbedsQuotedNameWhenLabelKeyCollidesWithScopeLabel() + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = CreateMeter(); + var counter = meter.CreateCounter("http.server.requests"); + + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + // A point tag whose output key collides with the emitted otel_scope_name scope label forces + // the slow label-writing path, which must still embed the quoted metric name as the first + // element inside the braces. + counter.Add(1, new KeyValuePair("otel_scope_name", "collision")); + + provider.ForceFlush(); + + var protocol = new PrometheusProtocol( + PrometheusProtocol.OpenMetricsMediaType, + PrometheusProtocol.AllowUtf8Escaping, + PrometheusProtocol.OpenMetricsV1, + isOpenMetrics: true); + + var serializer = TextFormatSerializer.GetSerializer(protocol); + + var cursor = serializer.WriteMetric( + buffer, + 0, + metrics[0], + PrometheusMetric.Create(metrics[0], false), + writeType: false, + writeUnit: false, + writeHelp: false, + unitOverride: null, + helpOverride: null); + + var output = Encoding.UTF8.GetString(buffer, 0, cursor); + + await Verify(output, "txt", VerifySettings); + } + + [Theory] + [MemberData(nameof(EscapingSchemesAndFormats))] + public async Task WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView(string escaping, bool useOpenMetrics) + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = CreateMeter(); + var histogram = meter.CreateHistogram( + "original_name", + unit: "s", + description: "Duration of HTTP server requests."); + + // A View-provided metric stream name is not subject to the OpenTelemetry instrument name + // syntax (https://opentelemetry.io/docs/specs/otel/metrics/sdk/#stream-configuration), so it + // may contain multibyte UTF-8 characters that the Meter API would otherwise reject. Renaming + // to a name containing the multibyte characters U+82B1 U+706B (CJK) and U+1F680 (a rocket, + // outside the Basic Multilingual Plane) exercises BuildAllowUtf8Names, the UTF-8 byte encoding + // in NameSet, and the quoted/escaped TYPE, UNIT and HELP metadata together with the _bucket, + // _sum, _count and _created series end-to-end. It is parameterized over all four escaping + // schemes and both v1 serializers, and each response remains UTF-8: + // * allow-utf-8 preserves the multibyte characters using the quoted exposition form, + // * underscores replaces the unsupported characters with underscores, + // * dots encodes '.' and replaces the unsupported characters, and + // * values reversibly encodes the Unicode code points. + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddView( + "original_name", + new ExplicitBucketHistogramConfiguration + { + Name = "http.\u82B1\u706B.\U0001F680.requests", + Boundaries = [5, 10], + }) + .AddInMemoryExporter(metrics) + .Build(); + + histogram.Record(4, new KeyValuePair("http.method", "GET")); + histogram.Record(8, new KeyValuePair("http.method", "GET")); + + provider.ForceFlush(); + + var output = WriteMetricWithEscaping(buffer, metrics[0], escaping, useOpenMetrics); + + await Verify(output, "txt", VerifySettings).UseParameters(escaping, useOpenMetrics); + } + #if NET [Fact] public async Task WriteHistogramMetricSerializesStaticTagsWithoutPreSerializedTags() @@ -1950,12 +2176,18 @@ private static int WriteMetric(byte[] buffer, int cursor, Metric metric, bool us } private static string WriteMetricWithDotsEscaping(byte[] buffer, Metric metric) + => WriteMetricWithEscaping(buffer, metric, PrometheusProtocol.DotsEscaping); + + private static string WriteMetricWithEscaping(byte[] buffer, Metric metric, string escaping) + => WriteMetricWithEscaping(buffer, metric, escaping, useOpenMetrics: true); + + private static string WriteMetricWithEscaping(byte[] buffer, Metric metric, string escaping, bool useOpenMetrics) { var protocol = new PrometheusProtocol( - PrometheusProtocol.OpenMetricsMediaType, - PrometheusProtocol.DotsEscaping, - PrometheusProtocol.OpenMetricsV1, - isOpenMetrics: true); + useOpenMetrics ? PrometheusProtocol.OpenMetricsMediaType : PrometheusProtocol.PrometheusTextMediaType, + escaping, + useOpenMetrics ? PrometheusProtocol.OpenMetricsV1 : PrometheusProtocol.PrometheusV1, + isOpenMetrics: useOpenMetrics); var serializer = TextFormatSerializer.GetSerializer(protocol); @@ -2029,7 +2261,7 @@ private static VerifySettings CreateVerifySettings() } #if NET - [GeneratedRegex("(?m)^([^\\s]*_created(?:\\{[^}]*\\})?\\s+)\\S+$")] + [GeneratedRegex("(?m)^((?:[^\\s{]*_created(?:\\{[^}]*\\})?|\\{[^}]*_created[^}]*\\})\\s+)\\S+$")] private static partial Regex CreatedMetric(); [GeneratedRegex("(?m)^(.+?\\s#\\s\\{[^}]*\\}\\s+\\S+\\s+)\\S+$")] @@ -2041,7 +2273,7 @@ private static VerifySettings CreateVerifySettings() [GeneratedRegex("telemetry_sdk_version=\"[^\"]*\"")] private static partial Regex SdkVersion(); #else - private static Regex CreatedMetric() => new("(?m)^([^\\s]*_created(?:\\{[^}]*\\})?\\s+)\\S+$", RegexOptions.Compiled); + private static Regex CreatedMetric() => new("(?m)^((?:[^\\s{]*_created(?:\\{[^}]*\\})?|\\{[^}]*_created[^}]*\\})\\s+)\\S+$", RegexOptions.Compiled); private static Regex ExemplarTimestamp() => new("(?m)^(.+?\\s#\\s\\{[^}]*\\}\\s+\\S+\\s+)\\S+$", RegexOptions.Compiled); diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteExemplarEscapesReservedTraceIdKeyToPreventCollisionWithFilteredTag.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteExemplarEscapesReservedTraceIdKeyToPreventCollisionWithFilteredTag.verified.txt index f0099af1c9a..5a1b17f7483 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteExemplarEscapesReservedTraceIdKeyToPreventCollisionWithFilteredTag.verified.txt +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteExemplarEscapesReservedTraceIdKeyToPreventCollisionWithFilteredTag.verified.txt @@ -1,3 +1,3 @@ # TYPE test__counter counter -test__counter_total{otel__scope__name="WriteExemplarEscapesReservedTraceIdKeyToPreventCollisionWithFilteredTag",keep="value"} 2 # {trace__id="",span__id=""} 2 -test__counter_created{otel__scope__name="WriteExemplarEscapesReservedTraceIdKeyToPreventCollisionWithFilteredTag",keep="value"} +test__counter_total{otel_scope_name="WriteExemplarEscapesReservedTraceIdKeyToPreventCollisionWithFilteredTag",keep="value"} 2 # {trace__id="",span__id=""} 2 +test__counter_created{otel_scope_name="WriteExemplarEscapesReservedTraceIdKeyToPreventCollisionWithFilteredTag",keep="value"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricEscapesScopeLabelKeysUsingNegotiatedDotsScheme.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricEscapesScopeLabelKeysUsingNegotiatedDotsScheme.verified.txt index 34e3efda0da..2d7c2390928 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricEscapesScopeLabelKeysUsingNegotiatedDotsScheme.verified.txt +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricEscapesScopeLabelKeysUsingNegotiatedDotsScheme.verified.txt @@ -1,3 +1,3 @@ # TYPE test__counter counter -test__counter_total{otel__scope__name="WriteMetricEscapesScopeLabelKeysUsingNegotiatedDotsScheme",otel__scope__version="1.0.0",otel__scope__dot__name="value"} 1 -test__counter_created{otel__scope__name="WriteMetricEscapesScopeLabelKeysUsingNegotiatedDotsScheme",otel__scope__version="1.0.0",otel__scope__dot__name="value"} +test__counter_total{otel_scope_name="WriteMetricEscapesScopeLabelKeysUsingNegotiatedDotsScheme",otel_scope_version="1.0.0",otel_scope_dot_name="value"} 1 +test__counter_created{otel_scope_name="WriteMetricEscapesScopeLabelKeysUsingNegotiatedDotsScheme",otel_scope_version="1.0.0",otel_scope_dot_name="value"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricWritesScopeLabelsVerbatim_escaping=allow-utf-8.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricWritesScopeLabelsVerbatim_escaping=allow-utf-8.verified.txt new file mode 100644 index 00000000000..3075cdd92ec --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricWritesScopeLabelsVerbatim_escaping=allow-utf-8.verified.txt @@ -0,0 +1,3 @@ +# TYPE test_counter counter +test_counter_total{otel_scope_name="WriteMetricWritesScopeLabelsVerbatim;point",otel_scope_version="1.0.0"} 1 +test_counter_created{otel_scope_name="WriteMetricWritesScopeLabelsVerbatim;point",otel_scope_version="1.0.0"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricWritesScopeLabelsVerbatim_escaping=dots.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricWritesScopeLabelsVerbatim_escaping=dots.verified.txt new file mode 100644 index 00000000000..2767d84800a --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricWritesScopeLabelsVerbatim_escaping=dots.verified.txt @@ -0,0 +1,3 @@ +# TYPE test__counter counter +test__counter_total{otel_scope_name="point;WriteMetricWritesScopeLabelsVerbatim",otel_scope_version="1.0.0"} 1 +test__counter_created{otel_scope_name="point;WriteMetricWritesScopeLabelsVerbatim",otel_scope_version="1.0.0"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricWritesScopeLabelsVerbatim_escaping=underscores.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricWritesScopeLabelsVerbatim_escaping=underscores.verified.txt new file mode 100644 index 00000000000..3075cdd92ec --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricWritesScopeLabelsVerbatim_escaping=underscores.verified.txt @@ -0,0 +1,3 @@ +# TYPE test_counter counter +test_counter_total{otel_scope_name="WriteMetricWritesScopeLabelsVerbatim;point",otel_scope_version="1.0.0"} 1 +test_counter_created{otel_scope_name="WriteMetricWritesScopeLabelsVerbatim;point",otel_scope_version="1.0.0"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricWritesScopeLabelsVerbatim_escaping=values.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricWritesScopeLabelsVerbatim_escaping=values.verified.txt new file mode 100644 index 00000000000..3075cdd92ec --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetricWritesScopeLabelsVerbatim_escaping=values.verified.txt @@ -0,0 +1,3 @@ +# TYPE test_counter counter +test_counter_total{otel_scope_name="WriteMetricWritesScopeLabelsVerbatim;point",otel_scope_version="1.0.0"} 1 +test_counter_created{otel_scope_name="WriteMetricWritesScopeLabelsVerbatim;point",otel_scope_version="1.0.0"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_AllowUtf8_EmbedsQuotedNameWhenLabelKeyCollidesWithScopeLabel.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_AllowUtf8_EmbedsQuotedNameWhenLabelKeyCollidesWithScopeLabel.verified.txt new file mode 100644 index 00000000000..a1aa7167890 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_AllowUtf8_EmbedsQuotedNameWhenLabelKeyCollidesWithScopeLabel.verified.txt @@ -0,0 +1,2 @@ +{"http.server.requests_total",otel_scope_name="WriteMetric_AllowUtf8_EmbedsQuotedNameWhenLabelKeyCollidesWithScopeLabel;collision"} 1 +{"http.server.requests_created",otel_scope_name="WriteMetric_AllowUtf8_EmbedsQuotedNameWhenLabelKeyCollidesWithScopeLabel;collision"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_AllowUtf8_EscapesQuoteBackslashAndLineFeedInQuotedName.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_AllowUtf8_EscapesQuoteBackslashAndLineFeedInQuotedName.verified.txt new file mode 100644 index 00000000000..6cda737d597 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_AllowUtf8_EscapesQuoteBackslashAndLineFeedInQuotedName.verified.txt @@ -0,0 +1,3 @@ +# TYPE "a\"b\\c\nd" counter +{"a\"b\\c\nd_total",otel_scope_name="WriteMetric_AllowUtf8_EscapesQuoteBackslashAndLineFeedInQuotedName"} 1 +{"a\"b\\c\nd_created",otel_scope_name="WriteMetric_AllowUtf8_EscapesQuoteBackslashAndLineFeedInQuotedName"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules_escaping=allow-utf-8.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules_escaping=allow-utf-8.verified.txt new file mode 100644 index 00000000000..a381dd8c624 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules_escaping=allow-utf-8.verified.txt @@ -0,0 +1,3 @@ +# TYPE test_counter counter +test_counter_total{otel_scope_name="WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules","http:method"="GET"} 1 +test_counter_created{otel_scope_name="WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules","http:method"="GET"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules_escaping=dots.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules_escaping=dots.verified.txt new file mode 100644 index 00000000000..ff0a9badfbd --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules_escaping=dots.verified.txt @@ -0,0 +1,3 @@ +# TYPE test__counter counter +test__counter_total{otel_scope_name="WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules",http_method="GET"} 1 +test__counter_created{otel_scope_name="WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules",http_method="GET"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules_escaping=underscores.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules_escaping=underscores.verified.txt new file mode 100644 index 00000000000..59c5f1c2981 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules_escaping=underscores.verified.txt @@ -0,0 +1,3 @@ +# TYPE test_counter counter +test_counter_total{otel_scope_name="WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules",http_method="GET"} 1 +test_counter_created{otel_scope_name="WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules",http_method="GET"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules_escaping=values.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules_escaping=values.verified.txt new file mode 100644 index 00000000000..afed8012269 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules_escaping=values.verified.txt @@ -0,0 +1,3 @@ +# TYPE test_counter counter +test_counter_total{otel_scope_name="WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules",U__http_3a_method="GET"} 1 +test_counter_created{otel_scope_name="WriteMetric_EscapesColonInPointTagKeyUsingLabelNameRules",U__http_3a_method="GET"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme_escaping=allow-utf-8.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme_escaping=allow-utf-8.verified.txt new file mode 100644 index 00000000000..6a43aae64b9 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme_escaping=allow-utf-8.verified.txt @@ -0,0 +1,3 @@ +# TYPE "http.server.requests" counter +{"http.server.requests_total",otel_scope_name="WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme","http.method"="GET"} 1 +{"http.server.requests_created",otel_scope_name="WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme","http.method"="GET"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme_escaping=dots.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme_escaping=dots.verified.txt index 0daa858f7fa..992f5a8c377 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme_escaping=dots.verified.txt +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme_escaping=dots.verified.txt @@ -1,3 +1,3 @@ # TYPE http_dot_server_dot_requests counter -http_dot_server_dot_requests_total{otel__scope__name="WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme",http_dot_method="GET"} 1 -http_dot_server_dot_requests_created{otel__scope__name="WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme",http_dot_method="GET"} +http_dot_server_dot_requests_total{otel_scope_name="WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme",http_dot_method="GET"} 1 +http_dot_server_dot_requests_created{otel_scope_name="WriteMetric_EscapesMetricAndLabelNamesUsingNegotiatedScheme",http_dot_method="GET"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=allow-utf-8_useOpenMetrics=False.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=allow-utf-8_useOpenMetrics=False.verified.txt new file mode 100644 index 00000000000..4e9f89bdbff --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=allow-utf-8_useOpenMetrics=False.verified.txt @@ -0,0 +1,8 @@ +# TYPE "http.花火.🚀.requests_seconds" histogram +# UNIT "http.花火.🚀.requests_seconds" seconds +# HELP "http.花火.🚀.requests_seconds" Duration of HTTP server requests. +{"http.花火.🚀.requests_seconds_bucket",otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView","http.method"="GET",le="5"} 1 +{"http.花火.🚀.requests_seconds_bucket",otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView","http.method"="GET",le="10"} 2 +{"http.花火.🚀.requests_seconds_bucket",otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView","http.method"="GET",le="+Inf"} 2 +{"http.花火.🚀.requests_seconds_sum",otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView","http.method"="GET"} 12 +{"http.花火.🚀.requests_seconds_count",otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView","http.method"="GET"} 2 diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=allow-utf-8_useOpenMetrics=True.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=allow-utf-8_useOpenMetrics=True.verified.txt new file mode 100644 index 00000000000..31a693576cd --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=allow-utf-8_useOpenMetrics=True.verified.txt @@ -0,0 +1,9 @@ +# TYPE "http.花火.🚀.requests_seconds" histogram +# UNIT "http.花火.🚀.requests_seconds" seconds +# HELP "http.花火.🚀.requests_seconds" Duration of HTTP server requests. +{"http.花火.🚀.requests_seconds_bucket",otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView","http.method"="GET",le="5.0"} 1 +{"http.花火.🚀.requests_seconds_bucket",otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView","http.method"="GET",le="10.0"} 2 +{"http.花火.🚀.requests_seconds_bucket",otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView","http.method"="GET",le="+Inf"} 2 +{"http.花火.🚀.requests_seconds_sum",otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView","http.method"="GET"} 12 +{"http.花火.🚀.requests_seconds_count",otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView","http.method"="GET"} 2 +{"http.花火.🚀.requests_seconds_created",otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView","http.method"="GET"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=dots_useOpenMetrics=False.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=dots_useOpenMetrics=False.verified.txt new file mode 100644 index 00000000000..9de2f7fd15e --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=dots_useOpenMetrics=False.verified.txt @@ -0,0 +1,8 @@ +# TYPE http_dot____dot___dot_requests__seconds histogram +# UNIT http_dot____dot___dot_requests__seconds seconds +# HELP http_dot____dot___dot_requests__seconds Duration of HTTP server requests. +http_dot____dot___dot_requests__seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_dot_method="GET",le="5"} 1 +http_dot____dot___dot_requests__seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_dot_method="GET",le="10"} 2 +http_dot____dot___dot_requests__seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_dot_method="GET",le="+Inf"} 2 +http_dot____dot___dot_requests__seconds_sum{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_dot_method="GET"} 12 +http_dot____dot___dot_requests__seconds_count{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_dot_method="GET"} 2 diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=dots_useOpenMetrics=True.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=dots_useOpenMetrics=True.verified.txt new file mode 100644 index 00000000000..80c340c1c4a --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=dots_useOpenMetrics=True.verified.txt @@ -0,0 +1,9 @@ +# TYPE http_dot____dot___dot_requests__seconds histogram +# UNIT http_dot____dot___dot_requests__seconds seconds +# HELP http_dot____dot___dot_requests__seconds Duration of HTTP server requests. +http_dot____dot___dot_requests__seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_dot_method="GET",le="5.0"} 1 +http_dot____dot___dot_requests__seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_dot_method="GET",le="10.0"} 2 +http_dot____dot___dot_requests__seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_dot_method="GET",le="+Inf"} 2 +http_dot____dot___dot_requests__seconds_sum{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_dot_method="GET"} 12 +http_dot____dot___dot_requests__seconds_count{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_dot_method="GET"} 2 +http_dot____dot___dot_requests__seconds_created{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_dot_method="GET"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=underscores_useOpenMetrics=False.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=underscores_useOpenMetrics=False.verified.txt new file mode 100644 index 00000000000..94233b71e5a --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=underscores_useOpenMetrics=False.verified.txt @@ -0,0 +1,8 @@ +# TYPE http_requests_seconds histogram +# UNIT http_requests_seconds seconds +# HELP http_requests_seconds Duration of HTTP server requests. +http_requests_seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_method="GET",le="5"} 1 +http_requests_seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_method="GET",le="10"} 2 +http_requests_seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_method="GET",le="+Inf"} 2 +http_requests_seconds_sum{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_method="GET"} 12 +http_requests_seconds_count{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_method="GET"} 2 diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=underscores_useOpenMetrics=True.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=underscores_useOpenMetrics=True.verified.txt new file mode 100644 index 00000000000..9b3a5bcd47e --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=underscores_useOpenMetrics=True.verified.txt @@ -0,0 +1,9 @@ +# TYPE http_requests_seconds histogram +# UNIT http_requests_seconds seconds +# HELP http_requests_seconds Duration of HTTP server requests. +http_requests_seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_method="GET",le="5.0"} 1 +http_requests_seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_method="GET",le="10.0"} 2 +http_requests_seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_method="GET",le="+Inf"} 2 +http_requests_seconds_sum{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_method="GET"} 12 +http_requests_seconds_count{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_method="GET"} 2 +http_requests_seconds_created{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",http_method="GET"} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=values_useOpenMetrics=False.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=values_useOpenMetrics=False.verified.txt new file mode 100644 index 00000000000..2a50cf85105 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=values_useOpenMetrics=False.verified.txt @@ -0,0 +1,8 @@ +# TYPE U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds histogram +# UNIT U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds seconds +# HELP U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds Duration of HTTP server requests. +U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",U__http_2e_method="GET",le="5"} 1 +U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",U__http_2e_method="GET",le="10"} 2 +U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",U__http_2e_method="GET",le="+Inf"} 2 +U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds_sum{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",U__http_2e_method="GET"} 12 +U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds_count{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",U__http_2e_method="GET"} 2 diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=values_useOpenMetrics=True.verified.txt b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=values_useOpenMetrics=True.verified.txt new file mode 100644 index 00000000000..2941fe795af --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/snapshots/PrometheusSerializer.WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView_escaping=values_useOpenMetrics=True.verified.txt @@ -0,0 +1,9 @@ +# TYPE U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds histogram +# UNIT U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds seconds +# HELP U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds Duration of HTTP server requests. +U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",U__http_2e_method="GET",le="5.0"} 1 +U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",U__http_2e_method="GET",le="10.0"} 2 +U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds_bucket{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",U__http_2e_method="GET",le="+Inf"} 2 +U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds_sum{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",U__http_2e_method="GET"} 12 +U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds_count{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",U__http_2e_method="GET"} 2 +U__http_2e__82b1__706b__2e__1f680__2e_requests__seconds_created{otel_scope_name="WriteMetric_SerializesMultibyteUtf8MetricNameProvidedByView",U__http_2e_method="GET"}