From 7cac55d72aae5412f0cdc24afab7f3a391703aa9 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 25 Apr 2026 18:08:50 +0100 Subject: [PATCH 01/19] [Prometheus.HttpListener] Improve performance - Improve the performance of `PrometheusSerializer` for .NET 8+ by using `SearchValues`. - Add fuzz tests for `PrometheusSerializer`. --- OpenTelemetry.slnx | 1 + .../Internal/PrometheusSerializer.cs | 109 +++++++++---- ...ry.Exporter.Prometheus.HttpListener.csproj | 1 + ...r.Prometheus.HttpListener.FuzzTests.csproj | 15 ++ .../PrometheusSerializerFuzzTests.cs | 146 ++++++++++++++++++ .../PrometheusSerializerTests.cs | 51 +++++- 6 files changed, 291 insertions(+), 32 deletions(-) create mode 100644 test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests.csproj create mode 100644 test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs diff --git a/OpenTelemetry.slnx b/OpenTelemetry.slnx index 77c4d32e9f8..5a807a98bae 100644 --- a/OpenTelemetry.slnx +++ b/OpenTelemetry.slnx @@ -227,6 +227,7 @@ + diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 53034b42b64..eb917a16f1f 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -1,6 +1,9 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +#if NET +using System.Buffers; +#endif using System.Diagnostics; using System.Globalization; using System.Runtime.CompilerServices; @@ -21,6 +24,11 @@ internal static partial class PrometheusSerializer private const byte ASCII_LINEFEED = 0x0A; // `\n` #pragma warning restore SA1310 // Field name should not contain an underscore +#if NET + private static readonly SearchValues UnicodeEscapeChars = SearchValues.Create("\\\n"); + private static readonly SearchValues LabelValueEscapeChars = SearchValues.Create("\"\\\n"); +#endif + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteDouble(byte[] buffer, int cursor, double value) { @@ -32,10 +40,7 @@ public static int WriteDouble(byte[] buffer, int cursor, double value) var result = value.TryFormat(span, out var cchWritten, "G", CultureInfo.InvariantCulture); Debug.Assert(result, $"{nameof(result)} should be true."); - for (var i = 0; i < cchWritten; i++) - { - buffer[cursor++] = unchecked((byte)span[i]); - } + cursor = WriteUtf8NoEscape(buffer, cursor, span[..cchWritten]); #else cursor = WriteAsciiStringNoEscape(buffer, cursor, value.ToString(CultureInfo.InvariantCulture)); #endif @@ -66,10 +71,7 @@ public static int WriteLong(byte[] buffer, int cursor, long value) var result = value.TryFormat(span, out var cchWritten, "G", CultureInfo.InvariantCulture); Debug.Assert(result, $"{nameof(result)} should be true."); - for (var i = 0; i < cchWritten; i++) - { - buffer[cursor++] = unchecked((byte)span[i]); - } + cursor = WriteUtf8NoEscape(buffer, cursor, span[..cchWritten]); #else cursor = WriteAsciiStringNoEscape(buffer, cursor, value.ToString(CultureInfo.InvariantCulture)); #endif @@ -80,12 +82,16 @@ public static int WriteLong(byte[] buffer, int cursor, long value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteAsciiStringNoEscape(byte[] buffer, int cursor, string value) { +#if NET + return WriteUtf8NoEscape(buffer, cursor, value.AsSpan()); +#else for (var i = 0; i < value.Length; i++) { buffer[cursor++] = unchecked((byte)value[i]); } return cursor; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -114,6 +120,9 @@ public static int WriteUnicodeNoEscape(byte[] buffer, int cursor, ushort ordinal [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteUnicodeString(byte[] buffer, int cursor, string value) { +#if NET + return WriteEscapedUtf8String(buffer, cursor, value.AsSpan(), UnicodeEscapeChars); +#else for (var i = 0; i < value.Length; i++) { var ordinal = (ushort)value[i]; @@ -134,6 +143,7 @@ public static int WriteUnicodeString(byte[] buffer, int cursor, string value) } return cursor; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -170,6 +180,9 @@ ordinal is (>= 'A' and <= 'Z') or [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteLabelValue(byte[] buffer, int cursor, string value) { +#if NET + return WriteEscapedUtf8String(buffer, cursor, value.AsSpan(), LabelValueEscapeChars); +#else for (var i = 0; i < value.Length; i++) { var ordinal = (ushort)value[i]; @@ -194,6 +207,7 @@ public static int WriteLabelValue(byte[] buffer, int cursor, string value) } return cursor; +#endif } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -225,13 +239,7 @@ public static int WriteMetricName(byte[] buffer, int cursor, PrometheusMetric me Debug.Assert(!string.IsNullOrWhiteSpace(name), "name was null or whitespace"); - for (var i = 0; i < name.Length; i++) - { - var ordinal = (ushort)name[i]; - buffer[cursor++] = unchecked((byte)ordinal); - } - - return cursor; + return WriteAsciiStringNoEscape(buffer, cursor, name); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -242,13 +250,7 @@ public static int WriteMetricMetadataName(byte[] buffer, int cursor, PrometheusM Debug.Assert(!string.IsNullOrWhiteSpace(name), "name was null or whitespace"); - for (var i = 0; i < name.Length; i++) - { - var ordinal = (ushort)name[i]; - buffer[cursor++] = unchecked((byte)ordinal); - } - - return cursor; + return WriteAsciiStringNoEscape(buffer, cursor, name); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -312,14 +314,7 @@ public static int WriteUnitMetadata(byte[] buffer, int cursor, PrometheusMetric buffer[cursor++] = unchecked((byte)' '); - // Unit name has already been escaped. -#pragma warning disable IDE0370 // Remove unnecessary suppression - for (var i = 0; i < metric.Unit!.Length; i++) -#pragma warning restore IDE0370 // Remove unnecessary suppression - { - var ordinal = (ushort)metric.Unit[i]; - buffer[cursor++] = unchecked((byte)ordinal); - } + cursor = WriteAsciiStringNoEscape(buffer, cursor, metric.Unit!); buffer[cursor++] = ASCII_LINEFEED; @@ -451,6 +446,60 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource) return cursor; } +#if NET + private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan value) => + cursor + System.Text.Encoding.UTF8.GetBytes(value, buffer.AsSpan(cursor)); + + private static int WriteEscapedUtf8String(byte[] buffer, int cursor, ReadOnlySpan value, SearchValues escapedChars) + { + while (!value.IsEmpty) + { + var escapedIndex = value.IndexOfAny(escapedChars); + var nonAsciiIndex = value.IndexOfAnyExceptInRange((char)0x00, (char)0x7F); + + var specialIndex = + escapedIndex < 0 ? nonAsciiIndex + : nonAsciiIndex < 0 ? escapedIndex + : Math.Min(escapedIndex, nonAsciiIndex); + + if (specialIndex < 0) + { + return WriteUtf8NoEscape(buffer, cursor, value); + } + + if (specialIndex > 0) + { + cursor = WriteUtf8NoEscape(buffer, cursor, value[..specialIndex]); + value = value[specialIndex..]; + } + + var ordinal = (ushort)value[0]; + switch (ordinal) + { + case ASCII_QUOTATION_MARK: + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = ASCII_QUOTATION_MARK; + break; + case ASCII_REVERSE_SOLIDUS: + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + break; + case ASCII_LINEFEED: + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = unchecked((byte)'n'); + break; + default: + cursor = WriteUnicodeNoEscape(buffer, cursor, ordinal); + break; + } + + value = value[1..]; + } + + return cursor; + } +#endif + private static string MapPrometheusType(PrometheusType type) => type switch { PrometheusType.Gauge => "gauge", diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/OpenTelemetry.Exporter.Prometheus.HttpListener.csproj b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/OpenTelemetry.Exporter.Prometheus.HttpListener.csproj index f69ce360219..db5c155c77b 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/OpenTelemetry.Exporter.Prometheus.HttpListener.csproj +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/OpenTelemetry.Exporter.Prometheus.HttpListener.csproj @@ -25,6 +25,7 @@ + diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests.csproj b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests.csproj new file mode 100644 index 00000000000..296a3210cd5 --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests.csproj @@ -0,0 +1,15 @@ + + + + $(TargetFrameworksForTests) + + + + + + + + + + + diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs new file mode 100644 index 00000000000..c250dbd76bb --- /dev/null +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs @@ -0,0 +1,146 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using FsCheck; +using FsCheck.Fluent; +using FsCheck.Xunit; + +namespace OpenTelemetry.Exporter.Prometheus.FuzzTests; + +public class PrometheusSerializerFuzzTests +{ + private const int MaxTests = 200; + + [Property(MaxTest = MaxTests)] + public Property WriteAsciiStringNoEscapeMatchesReferenceImplementation() => Prop.ForAll( + Generators.AsciiStringArbitrary(), + static (value) => Serialize(value, PrometheusSerializer.WriteAsciiStringNoEscape).SequenceEqual(ReferenceWriteAsciiStringNoEscape(value))); + + [Property(MaxTest = MaxTests)] + public Property WriteLabelKeyMatchesReferenceImplementation() => Prop.ForAll( + Generators.PrometheusStringArbitrary(), + static (value) => Serialize(value, PrometheusSerializer.WriteLabelKey).SequenceEqual(ReferenceWriteLabelKey(value))); + + [Property(MaxTest = MaxTests)] + public Property WriteLabelValueMatchesReferenceImplementation() => Prop.ForAll( + Generators.PrometheusStringArbitrary(), + static (value) => Serialize(value, PrometheusSerializer.WriteLabelValue).SequenceEqual(ReferenceWriteLabelValue(value))); + + [Property(MaxTest = MaxTests)] + public Property WriteUnicodeStringMatchesReferenceImplementation() => Prop.ForAll( + Generators.PrometheusStringArbitrary(), + static (value) => Serialize(value, PrometheusSerializer.WriteUnicodeString).SequenceEqual(ReferenceWriteUnicodeString(value))); + + private static byte[] Serialize(string value, Func writer) + { + var buffer = new byte[(value.Length * 8) + 16]; + var cursor = writer(buffer, 0, value); + return buffer.AsSpan(0, cursor).ToArray(); + } + + private static byte[] ReferenceWriteAsciiStringNoEscape(string value) + { + var bytes = new byte[value.Length]; + for (var i = 0; i < value.Length; i++) + { + bytes[i] = unchecked((byte)value[i]); + } + + return bytes; + } + + private static byte[] ReferenceWriteLabelKey(string value) + { + var bytes = new List(value.Length + 1); + if (string.IsNullOrEmpty(value)) + { + bytes.Add((byte)'_'); + return [.. bytes]; + } + + if (value[0] is >= '0' and <= '9') + { + bytes.Add((byte)'_'); + } + + foreach (var c in value) + { + bytes.Add(c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') ? (byte)c : (byte)'_'); + } + + return [.. bytes]; + } + + private static byte[] ReferenceWriteLabelValue(string value) => ReferenceWriteEscapedString(value, escapeQuotationMarks: true); + + private static byte[] ReferenceWriteUnicodeString(string value) => ReferenceWriteEscapedString(value, escapeQuotationMarks: false); + + private static byte[] ReferenceWriteEscapedString(string value, bool escapeQuotationMarks) + { + var bytes = new List(value.Length * 3); + + foreach (var c in value) + { + switch ((ushort)c) + { + case '"' when escapeQuotationMarks: + bytes.Add((byte)'\\'); + bytes.Add((byte)'"'); + break; + case '\\': + bytes.Add((byte)'\\'); + bytes.Add((byte)'\\'); + break; + case '\n': + bytes.Add((byte)'\\'); + bytes.Add((byte)'n'); + break; + default: + AppendUnicodeNoEscape(bytes, c); + break; + } + } + + return [.. bytes]; + } + + private static void AppendUnicodeNoEscape(List bytes, ushort ordinal) + { + if (ordinal <= 0x7F) + { + bytes.Add(unchecked((byte)ordinal)); + } + else if (ordinal <= 0x07FF) + { + bytes.Add(unchecked((byte)(0b_1100_0000 | (ordinal >> 6)))); + bytes.Add(unchecked((byte)(0b_1000_0000 | (ordinal & 0b_0011_1111)))); + } + else + { + bytes.Add(unchecked((byte)(0b_1110_0000 | (ordinal >> 12)))); + bytes.Add(unchecked((byte)(0b_1000_0000 | ((ordinal >> 6) & 0b_0011_1111)))); + bytes.Add(unchecked((byte)(0b_1000_0000 | (ordinal & 0b_0011_1111)))); + } + } + + private static class Generators + { + public static Arbitrary AsciiStringArbitrary() + { + var asciiChar = Gen.Choose(0, 0x7F).Select(static c => (char)c); + return CreateString(asciiChar, maxLength: 256).ToArbitrary(); + } + + public static Arbitrary PrometheusStringArbitrary() + { + var charGen = Gen.Choose(0, 0xFFFF).Select(static c => (char)c); + return CreateString(charGen, maxLength: 128).ToArbitrary(); + } + + private static Gen CreateString(Gen charGen, int maxLength) => + Gen.Sized(size => + from length in Gen.Choose(0, Math.Min((size * 2) + 1, maxLength)) + from chars in Gen.ArrayOf(charGen, length) + select new string(chars)); + } +} diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 40fd23abdb3..4ef3a8c478e 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -708,8 +708,55 @@ public void HistogramOneDimensionWithScopeVersion() Encoding.UTF8.GetString(buffer, 0, cursor)); } - private static int WriteMetric(byte[] buffer, int cursor, Metric metric, bool useOpenMetrics = false, bool disableTimestamp = false) + [Fact] + public void WriteAsciiStringNoEscapeWritesAsciiBytes() { - return PrometheusSerializer.WriteMetric(buffer, cursor, metric, PrometheusMetric.Create(metric, false), useOpenMetrics, disableTimestamp); + var value = "metric_name_total"; + var buffer = new byte[64]; + + var cursor = PrometheusSerializer.WriteAsciiStringNoEscape(buffer, 0, value); + + Assert.Equal("metric_name_total", Encoding.UTF8.GetString(buffer, 0, cursor)); } + + [Fact] + public void WriteLabelValueEscapesSpecialCharacters() + { + var buffer = new byte[128]; + + var cursor = PrometheusSerializer.WriteLabelValue(buffer, 0, "\"line1\\\nline2\""); + + Assert.Equal("\\\"line1\\\\\\nline2\\\"", Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Fact] + public void WriteUnicodeStringPreservesUtf16CodeUnitEncoding() + { + const string value = "rocket:\uD83D\uDE80"; + var buffer = new byte[128]; + + var cursor = PrometheusSerializer.WriteUnicodeString(buffer, 0, value); + var actual = ToHexString(buffer, cursor); + + Assert.Equal("726F636B65743AEDA0BDEDBA80", actual); + } + + private static string ToHexString(byte[] buffer, int length) + { + var chars = new char[length * 2]; + + for (var i = 0; i < length; i++) + { + var value = buffer[i]; + chars[i * 2] = GetHexValue(value >> 4); + chars[(i * 2) + 1] = GetHexValue(value & 0xF); + } + + return new string(chars); + + static char GetHexValue(int value) => (char)(value < 10 ? '0' + value : 'A' + (value - 10)); + } + + private static int WriteMetric(byte[] buffer, int cursor, Metric metric, bool useOpenMetrics = false, bool disableTimestamp = false) + => PrometheusSerializer.WriteMetric(buffer, cursor, metric, PrometheusMetric.Create(metric, false), useOpenMetrics, disableTimestamp); } From bdb41120231609cc98c37b42670a0356cfcc8551 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 25 Apr 2026 20:54:55 +0100 Subject: [PATCH 02/19] [Prometheus.HttpListener] Fix tests Handle buffer resize. --- .../Internal/PrometheusCollectionManager.cs | 8 ++++---- .../PrometheusSerializerTests.cs | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs index 02a40a5470c..5cb6ba0c4dd 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs @@ -242,7 +242,7 @@ private ExportResult OnCollect(in Batch metrics) break; } - catch (IndexOutOfRangeException) + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) { if (!IncreaseBufferSize(ref buffer)) { @@ -281,7 +281,7 @@ private ExportResult OnCollect(in Batch metrics) break; } - catch (IndexOutOfRangeException) + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) { if (!IncreaseBufferSize(ref buffer)) { @@ -298,7 +298,7 @@ private ExportResult OnCollect(in Batch metrics) cursor = PrometheusSerializer.WriteEof(buffer, cursor); break; } - catch (IndexOutOfRangeException) + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) { if (!IncreaseBufferSize(ref buffer)) { @@ -347,7 +347,7 @@ private int WriteTargetInfo(ref byte[] buffer) break; } - catch (IndexOutOfRangeException) + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) { if (!IncreaseBufferSize(ref buffer)) { diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 4ef3a8c478e..51713db99bf 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -719,6 +719,13 @@ public void WriteAsciiStringNoEscapeWritesAsciiBytes() Assert.Equal("metric_name_total", Encoding.UTF8.GetString(buffer, 0, cursor)); } + [Fact] + public void WriteAsciiStringNoEscapeThrowsArgumentExceptionWhenBufferTooSmall() + { + var buffer = new byte[4]; + Assert.Throws(() => PrometheusSerializer.WriteAsciiStringNoEscape(buffer, 0, "metric")); + } + [Fact] public void WriteLabelValueEscapesSpecialCharacters() { From 358e2f33cfd09babfb33c5c7948499cd9bfd062f Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 25 Apr 2026 21:21:40 +0100 Subject: [PATCH 03/19] [Prometheus.HttpListener] Fix test Handle different exception type between .NET and .NET Framework. --- .../PrometheusSerializerTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 51713db99bf..0c68d8182f8 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -720,10 +720,14 @@ public void WriteAsciiStringNoEscapeWritesAsciiBytes() } [Fact] - public void WriteAsciiStringNoEscapeThrowsArgumentExceptionWhenBufferTooSmall() + public void WriteAsciiStringNoEscapeThrowsExceptionWhenBufferTooSmall() { var buffer = new byte[4]; +#if NET Assert.Throws(() => PrometheusSerializer.WriteAsciiStringNoEscape(buffer, 0, "metric")); +#else + Assert.Throws(() => PrometheusSerializer.WriteAsciiStringNoEscape(buffer, 0, "metric")); +#endif } [Fact] From 4aecfeafae4ce083c7c16c70708359cb998f29aa Mon Sep 17 00:00:00 2001 From: martincostello Date: Sat, 25 Apr 2026 22:08:01 +0100 Subject: [PATCH 04/19] [Prometheus.HttpListener] Address feedback Update comment. --- .../Internal/PrometheusCollectionManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs index 5cb6ba0c4dd..c9370a6d1ac 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusCollectionManager.cs @@ -249,7 +249,7 @@ private ExportResult OnCollect(in Batch metrics) // there are two cases we might run into the following condition: // 1. we have many metrics to be exported - in this case we probably want // to put some upper limit and allow the user to configure it. - // 2. we got an IndexOutOfRangeException which was triggered by some other + // 2. we got an ArgumentException/IndexOutOfRangeException which was triggered by some other // code instead of the buffer[cursor++] - in this case we should give up // at certain point rather than allocating like crazy. throw; From 53d6284f71886479339ba0adb29225ddb409f971 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 26 Apr 2026 16:03:57 +0100 Subject: [PATCH 05/19] [Exporter.Prometheus] Further perf improvements Improve `PrometheusSerializer` performance further by: - Writing common label value types directly instead of relying on `ToString()` - Formatting numeric values directly as UTF-8 on .NET 8+ - Caching serialized metric names, metadata names, units, and static tag prefixes - Reusing serialized tags across histogram bucket/sum/count output - Use `stackalloc` for small temporary tag buffers on .NET 8+ --- .../Internal/PrometheusMetric.cs | 49 +- .../Internal/PrometheusSerializer.cs | 485 ++++++++++++++++-- .../Internal/PrometheusSerializerExt.cs | 217 +++++--- .../PrometheusSerializerBenchmarks.cs | 27 +- .../PrometheusSerializerFuzzTests.cs | 124 +++++ .../PrometheusSerializerTests.cs | 98 ++++ 6 files changed, 879 insertions(+), 121 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs index 73665c7a75a..089686bd1f5 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs @@ -62,6 +62,22 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa this.OpenMetricsMetadataName = openMetricsMetadataName; this.Unit = sanitizedUnit; this.Type = type; + this.NameBytes = ConvertToAsciiBytes(sanitizedName); + this.OpenMetricsNameBytes = ConvertToAsciiBytes(openMetricsName); + this.OpenMetricsMetadataNameBytes = ConvertToAsciiBytes(openMetricsMetadataName); + this.UnitBytes = sanitizedUnit == null ? null : ConvertToAsciiBytes(sanitizedUnit); + + static byte[] ConvertToAsciiBytes(string value) + { + var bytes = new byte[value.Length]; + + for (var i = 0; i < value.Length; i++) + { + bytes[i] = unchecked((byte)value[i]); + } + + return bytes; + } } public string Name { get; } @@ -74,8 +90,21 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa public PrometheusType Type { get; } - public static PrometheusMetric Create(Metric metric, bool disableTotalNameSuffixForCounters) - => new(metric.Name, metric.Unit, GetPrometheusType(metric.MetricType), disableTotalNameSuffixForCounters); + internal byte[] NameBytes { get; } + + internal byte[] OpenMetricsNameBytes { get; } + + internal byte[] OpenMetricsMetadataNameBytes { get; } + + internal byte[]? UnitBytes { get; } + + internal byte[]? SerializedStaticTags { get; private set; } + + public static PrometheusMetric Create(Metric metric, bool disableTotalNameSuffixForCounters) => + new(metric.Name, metric.Unit, GetPrometheusType(metric.MetricType), disableTotalNameSuffixForCounters) + { + SerializedStaticTags = PrometheusSerializer.SerializeStaticTags(metric), + }; internal static string SanitizeMetricUnit(string metricUnit) { @@ -97,11 +126,7 @@ internal static string SanitizeMetricUnit(string metricUnit) } else { - if (sb != null) - { - sb.Append(c); - } - + sb?.Append(c); lastCharUnderscore = false; } } @@ -145,6 +170,11 @@ internal static string SanitizeMetricName(string metricName) } return sb?.ToString() ?? metricName; + + static StringBuilder CreateStringBuilder(string value) + { + return new(value.Length); + } } internal static string RemoveAnnotations(string unit) @@ -214,11 +244,6 @@ UpDownCounter becomes gauge }; } - private static StringBuilder CreateStringBuilder(string value) - { - return new(value.Length); - } - private static string SanitizeOpenMetricsName(string metricName) => metricName.EndsWith("_total", StringComparison.Ordinal) ? metricName.Substring(0, metricName.Length - 6) : metricName; diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index eb917a16f1f..7ca224a654b 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -3,6 +3,7 @@ #if NET using System.Buffers; +using System.Buffers.Text; #endif using System.Diagnostics; using System.Globalization; @@ -35,12 +36,10 @@ public static int WriteDouble(byte[] buffer, int cursor, double value) if (MathHelper.IsFinite(value)) { #if NET - Span span = stackalloc char[128]; - - var result = value.TryFormat(span, out var cchWritten, "G", CultureInfo.InvariantCulture); + var result = Utf8Formatter.TryFormat(value, buffer.AsSpan(cursor), out var bytesWritten, new StandardFormat('G')); Debug.Assert(result, $"{nameof(result)} should be true."); - cursor = WriteUtf8NoEscape(buffer, cursor, span[..cchWritten]); + cursor += bytesWritten; #else cursor = WriteAsciiStringNoEscape(buffer, cursor, value.ToString(CultureInfo.InvariantCulture)); #endif @@ -66,12 +65,25 @@ public static int WriteDouble(byte[] buffer, int cursor, double value) public static int WriteLong(byte[] buffer, int cursor, long value) { #if NET - Span span = stackalloc char[20]; + var result = Utf8Formatter.TryFormat(value, buffer.AsSpan(cursor), out var bytesWritten); + Debug.Assert(result, $"{nameof(result)} should be true."); + + cursor += bytesWritten; +#else + cursor = WriteAsciiStringNoEscape(buffer, cursor, value.ToString(CultureInfo.InvariantCulture)); +#endif + + return cursor; + } - var result = value.TryFormat(span, out var cchWritten, "G", CultureInfo.InvariantCulture); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteUnsignedLong(byte[] buffer, int cursor, ulong value) + { +#if NET + var result = Utf8Formatter.TryFormat(value, buffer.AsSpan(cursor), out var bytesWritten); Debug.Assert(result, $"{nameof(result)} should be true."); - cursor = WriteUtf8NoEscape(buffer, cursor, span[..cchWritten]); + cursor += bytesWritten; #else cursor = WriteAsciiStringNoEscape(buffer, cursor, value.ToString(CultureInfo.InvariantCulture)); #endif @@ -210,6 +222,69 @@ public static int WriteLabelValue(byte[] buffer, int cursor, string value) #endif } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteLabelValue(byte[] buffer, int cursor, object? value) + { + switch (value) + { + case null: + return cursor; + + case string stringValue: + return WriteLabelValue(buffer, cursor, stringValue); + + case bool boolValue: + return WriteAsciiStringNoEscape(buffer, cursor, boolValue ? "true" : "false"); + + case sbyte signedByteValue: + return WriteLong(buffer, cursor, signedByteValue); + + case byte byteValue: + return WriteLong(buffer, cursor, byteValue); + + case short shortValue: + return WriteLong(buffer, cursor, shortValue); + + case ushort unsignedShortValue: + return WriteLong(buffer, cursor, unsignedShortValue); + + case int intValue: + return WriteLong(buffer, cursor, intValue); + + case uint unsignedIntValue: + return WriteLong(buffer, cursor, unsignedIntValue); + + case long longValue: + return WriteLong(buffer, cursor, longValue); + + case ulong unsignedLongValue: + return WriteUnsignedLong(buffer, cursor, unsignedLongValue); + + case float floatValue: + return WriteDouble(buffer, cursor, floatValue); + + case double doubleValue: + return WriteDouble(buffer, cursor, doubleValue); + + case decimal decimalValue: +#if NET + var result = Utf8Formatter.TryFormat(decimalValue, buffer.AsSpan(cursor), out var bytesWritten); + Debug.Assert(result, $"{nameof(result)} should be true."); + return cursor + bytesWritten; +#else + return WriteLabelValue(buffer, cursor, decimalValue.ToString(CultureInfo.InvariantCulture)); +#endif + + case IFormattable formattableValue: + return WriteLabelValue(buffer, cursor, formattableValue.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty); + + // TODO: Attribute values should be written as their JSON representation. Extra logic may need to be added here to correctly convert other .NET types. + // More detail: https://github.com/open-telemetry/opentelemetry-dotnet/issues/4822#issuecomment-1707328495 + default: + return WriteLabelValue(buffer, cursor, value.ToString() ?? string.Empty); + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteLabel(byte[] buffer, int cursor, string labelKey, object? labelValue) { @@ -218,17 +293,10 @@ public static int WriteLabel(byte[] buffer, int cursor, string labelKey, object? buffer[cursor++] = unchecked((byte)'"'); // In Prometheus, a label with an empty label value is considered equivalent to a label that does not exist. - cursor = WriteLabelValue(buffer, cursor, GetLabelValueString(labelValue)); + cursor = WriteLabelValue(buffer, cursor, labelValue); buffer[cursor++] = unchecked((byte)'"'); return cursor; - - static string GetLabelValueString(object? labelValue) - { - // TODO: Attribute values should be written as their JSON representation. Extra logic may need to be added here to correctly convert other .NET types. - // More detail: https://github.com/open-telemetry/opentelemetry-dotnet/issues/4822#issuecomment-1707328495 - return labelValue is bool b ? b ? "true" : "false" : labelValue?.ToString() ?? string.Empty; - } } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -236,10 +304,11 @@ public static int WriteMetricName(byte[] buffer, int cursor, PrometheusMetric me { // Metric name has already been escaped. var name = openMetricsRequested ? metric.OpenMetricsName : metric.Name; + var nameBytes = openMetricsRequested ? metric.OpenMetricsNameBytes : metric.NameBytes; Debug.Assert(!string.IsNullOrWhiteSpace(name), "name was null or whitespace"); - return WriteAsciiStringNoEscape(buffer, cursor, name); + return WriteUtf8NoEscape(buffer, cursor, nameBytes); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -247,10 +316,11 @@ public static int WriteMetricMetadataName(byte[] buffer, int cursor, PrometheusM { // Metric name has already been escaped. var name = openMetricsRequested ? metric.OpenMetricsMetadataName : metric.Name; + var nameBytes = openMetricsRequested ? metric.OpenMetricsMetadataNameBytes : metric.NameBytes; Debug.Assert(!string.IsNullOrWhiteSpace(name), "name was null or whitespace"); - return WriteAsciiStringNoEscape(buffer, cursor, name); + return WriteUtf8NoEscape(buffer, cursor, nameBytes); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -314,7 +384,7 @@ public static int WriteUnitMetadata(byte[] buffer, int cursor, PrometheusMetric buffer[cursor++] = unchecked((byte)' '); - cursor = WriteAsciiStringNoEscape(buffer, cursor, metric.Unit!); + cursor = WriteUtf8NoEscape(buffer, cursor, metric.UnitBytes!); buffer[cursor++] = ASCII_LINEFEED; @@ -374,29 +444,19 @@ public static int WriteTimestamp(byte[] buffer, int cursor, long value, bool use [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteTags(byte[] buffer, int cursor, Metric metric, ReadOnlyTagCollection tags, bool writeEnclosingBraces = true) + => WriteTags(buffer, cursor, null, metric, tags, writeEnclosingBraces); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int WriteTags(byte[] buffer, int cursor, PrometheusMetric? prometheusMetric, Metric metric, ReadOnlyTagCollection tags, bool writeEnclosingBraces = true) { if (writeEnclosingBraces) { buffer[cursor++] = unchecked((byte)'{'); } - cursor = WriteLabel(buffer, cursor, "otel_scope_name", metric.MeterName); - buffer[cursor++] = unchecked((byte)','); - - if (!string.IsNullOrEmpty(metric.MeterVersion)) - { - cursor = WriteLabel(buffer, cursor, "otel_scope_version", metric.MeterVersion); - buffer[cursor++] = unchecked((byte)','); - } - - if (metric.MeterTags != null) - { - foreach (var tag in metric.MeterTags) - { - cursor = WriteLabel(buffer, cursor, tag.Key, tag.Value); - buffer[cursor++] = unchecked((byte)','); - } - } + cursor = prometheusMetric?.SerializedStaticTags != null + ? WriteUtf8NoEscape(buffer, cursor, prometheusMetric.SerializedStaticTags) + : WriteStaticTags(buffer, cursor, metric); foreach (var tag in tags) { @@ -446,6 +506,12 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource) return cursor; } + private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan value) + { + value.CopyTo(buffer.AsSpan(cursor)); + return cursor + value.Length; + } + #if NET private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan value) => cursor + System.Text.Encoding.UTF8.GetBytes(value, buffer.AsSpan(cursor)); @@ -500,6 +566,355 @@ private static int WriteEscapedUtf8String(byte[] buffer, int cursor, ReadOnlySpa } #endif + private static int WriteStaticTags(byte[] buffer, int cursor, Metric metric) + { + cursor = WriteLabel(buffer, cursor, "otel_scope_name", metric.MeterName); + buffer[cursor++] = unchecked((byte)','); + + if (!string.IsNullOrEmpty(metric.MeterVersion)) + { + cursor = WriteLabel(buffer, cursor, "otel_scope_version", metric.MeterVersion); + buffer[cursor++] = unchecked((byte)','); + } + + if (metric.MeterTags != null) + { + foreach (var tag in metric.MeterTags) + { + cursor = WriteLabel(buffer, cursor, tag.Key, tag.Value); + buffer[cursor++] = unchecked((byte)','); + } + } + + return cursor; + } + +#if NET + private static bool TryWriteStaticTags(Span buffer, Metric metric, out int cursor) + { + cursor = 0; + return TryWriteStaticTags(buffer, ref cursor, metric); + } + + private static bool TryWriteStaticTags(Span buffer, ref int cursor, Metric metric) + { + if (!TryWriteLabel(buffer, ref cursor, "otel_scope_name", metric.MeterName) || + !TryWriteByte(buffer, ref cursor, unchecked((byte)','))) + { + return false; + } + + if (!string.IsNullOrEmpty(metric.MeterVersion) && + (!TryWriteLabel(buffer, ref cursor, "otel_scope_version", metric.MeterVersion) || + !TryWriteByte(buffer, ref cursor, unchecked((byte)',')))) + { + return false; + } + + if (metric.MeterTags != null) + { + foreach (var tag in metric.MeterTags) + { + if (!TryWriteLabel(buffer, ref cursor, tag.Key, tag.Value) || + !TryWriteByte(buffer, ref cursor, unchecked((byte)','))) + { + return false; + } + } + } + + return true; + } + + private static bool TryWriteTags( + Span buffer, + PrometheusMetric? prometheusMetric, + Metric metric, + ReadOnlyTagCollection tags, + bool writeEnclosingBraces, + out int cursor) + { + cursor = 0; + + if (writeEnclosingBraces && !TryWriteByte(buffer, ref cursor, unchecked((byte)'{'))) + { + return false; + } + + if (prometheusMetric?.SerializedStaticTags != null) + { + if (!TryWriteBytes(buffer, ref cursor, prometheusMetric.SerializedStaticTags)) + { + return false; + } + } + else if (!TryWriteStaticTags(buffer, ref cursor, metric)) + { + return false; + } + + foreach (var tag in tags) + { + if (!TryWriteLabel(buffer, ref cursor, tag.Key, tag.Value) || + !TryWriteByte(buffer, ref cursor, unchecked((byte)','))) + { + return false; + } + } + + if (writeEnclosingBraces) + { + buffer[cursor - 1] = unchecked((byte)'}'); + } + + return true; + } + + private static bool TryWriteLabel(Span buffer, ref int cursor, string labelKey, object? labelValue) => + TryWriteLabelKey(buffer, ref cursor, labelKey) && + TryWriteByte(buffer, ref cursor, unchecked((byte)'=')) && + TryWriteByte(buffer, ref cursor, ASCII_QUOTATION_MARK) && + TryWriteLabelValue(buffer, ref cursor, labelValue) && + TryWriteByte(buffer, ref cursor, ASCII_QUOTATION_MARK); + + private static bool TryWriteLabelKey(Span buffer, ref int cursor, string value) + { + if (string.IsNullOrEmpty(value)) + { + return TryWriteByte(buffer, ref cursor, unchecked((byte)'_')); + } + + var ordinal = (ushort)value[0]; + if (ordinal is >= '0' and <= '9' && + !TryWriteByte(buffer, ref cursor, unchecked((byte)'_'))) + { + return false; + } + + for (var i = 0; i < value.Length; i++) + { + ordinal = value[i]; + var sanitizedByte = + ordinal is (>= 'A' and <= 'Z') or + (>= 'a' and <= 'z') or + (>= '0' and <= '9') + ? (byte)ordinal + : (byte)'_'; + + if (!TryWriteByte(buffer, ref cursor, sanitizedByte)) + { + return false; + } + } + + return true; + } + + private static bool TryWriteLabelValue(Span buffer, ref int cursor, object? value) + { + switch (value) + { + case null: + return true; + + case string stringValue: + return TryWriteLabelValue(buffer, ref cursor, stringValue); + + case bool boolValue: + return TryWriteAsciiStringNoEscape(buffer, ref cursor, boolValue ? "true" : "false"); + + case sbyte signedByteValue: + return TryWriteLong(buffer, ref cursor, signedByteValue); + + case byte byteValue: + return TryWriteLong(buffer, ref cursor, byteValue); + + case short shortValue: + return TryWriteLong(buffer, ref cursor, shortValue); + + case ushort unsignedShortValue: + return TryWriteLong(buffer, ref cursor, unsignedShortValue); + + case int intValue: + return TryWriteLong(buffer, ref cursor, intValue); + + case uint unsignedIntValue: + return TryWriteLong(buffer, ref cursor, unsignedIntValue); + + case long longValue: + return TryWriteLong(buffer, ref cursor, longValue); + + case ulong unsignedLongValue: + return TryWriteUnsignedLong(buffer, ref cursor, unsignedLongValue); + + case float floatValue: + return TryWriteDouble(buffer, ref cursor, floatValue); + + case double doubleValue: + return TryWriteDouble(buffer, ref cursor, doubleValue); + + case decimal decimalValue: + if (!Utf8Formatter.TryFormat(decimalValue, buffer[cursor..], out var bytesWritten)) + { + return false; + } + + cursor += bytesWritten; + return true; + + case IFormattable formattableValue: + return TryWriteLabelValue(buffer, ref cursor, formattableValue.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty); + + // TODO: Attribute values should be written as their JSON representation. Extra logic may need to be added here to correctly convert other .NET types. + // More detail: https://github.com/open-telemetry/opentelemetry-dotnet/issues/4822#issuecomment-1707328495 + default: + return TryWriteLabelValue(buffer, ref cursor, value.ToString() ?? string.Empty); + } + } + + private static bool TryWriteLabelValue(Span buffer, ref int cursor, string value) + { + for (var i = 0; i < value.Length; i++) + { + var ordinal = (ushort)value[i]; + switch (ordinal) + { + case ASCII_QUOTATION_MARK: + if (!TryWriteByte(buffer, ref cursor, ASCII_REVERSE_SOLIDUS) || + !TryWriteByte(buffer, ref cursor, ASCII_QUOTATION_MARK)) + { + return false; + } + + break; + case ASCII_REVERSE_SOLIDUS: + if (!TryWriteByte(buffer, ref cursor, ASCII_REVERSE_SOLIDUS) || + !TryWriteByte(buffer, ref cursor, ASCII_REVERSE_SOLIDUS)) + { + return false; + } + + break; + case ASCII_LINEFEED: + if (!TryWriteByte(buffer, ref cursor, ASCII_REVERSE_SOLIDUS) || + !TryWriteByte(buffer, ref cursor, unchecked((byte)'n'))) + { + return false; + } + + break; + default: + if (!TryWriteUnicodeNoEscape(buffer, ref cursor, ordinal)) + { + return false; + } + + break; + } + } + + return true; + } + + private static bool TryWriteAsciiStringNoEscape(Span buffer, ref int cursor, string value) + { + if (value.Length > buffer.Length - cursor) + { + return false; + } + + for (var i = 0; i < value.Length; i++) + { + buffer[cursor++] = unchecked((byte)value[i]); + } + + return true; + } + + private static bool TryWriteLong(Span buffer, ref int cursor, long value) + { + if (!Utf8Formatter.TryFormat(value, buffer[cursor..], out var bytesWritten)) + { + return false; + } + + cursor += bytesWritten; + return true; + } + + private static bool TryWriteUnsignedLong(Span buffer, ref int cursor, ulong value) + { + if (!Utf8Formatter.TryFormat(value, buffer[cursor..], out var bytesWritten)) + { + return false; + } + + cursor += bytesWritten; + return true; + } + + private static bool TryWriteDouble(Span buffer, ref int cursor, double value) + { + if (MathHelper.IsFinite(value)) + { + if (!Utf8Formatter.TryFormat(value, buffer[cursor..], out var bytesWritten, new StandardFormat('G'))) + { + return false; + } + + cursor += bytesWritten; + return true; + } + + return TryWriteAsciiStringNoEscape( + buffer, + ref cursor, + double.IsPositiveInfinity(value) ? "+Inf" : double.IsNegativeInfinity(value) ? "-Inf" : "Nan"); + } + + private static bool TryWriteUnicodeNoEscape(Span buffer, ref int cursor, ushort ordinal) + { + if (ordinal <= 0x7F) + { + return TryWriteByte(buffer, ref cursor, unchecked((byte)ordinal)); + } + + if (ordinal <= 0x07FF) + { + return TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1100_0000 | (ordinal >> 6)))) && + TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1000_0000 | (ordinal & 0b_0011_1111)))); + } + + return TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1110_0000 | (ordinal >> 12)))) && + TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1000_0000 | ((ordinal >> 6) & 0b_0011_1111)))) && + TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1000_0000 | (ordinal & 0b_0011_1111)))); + } + + private static bool TryWriteBytes(Span buffer, ref int cursor, ReadOnlySpan value) + { + if (value.Length > buffer.Length - cursor) + { + return false; + } + + value.CopyTo(buffer[cursor..]); + cursor += value.Length; + + return true; + } + + private static bool TryWriteByte(Span buffer, ref int cursor, byte value) + { + if ((uint)cursor >= (uint)buffer.Length) + { + return false; + } + + buffer[cursor++] = value; + return true; + } +#endif + private static string MapPrometheusType(PrometheusType type) => type switch { PrometheusType.Gauge => "gauge", diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs index 613f7acf4d7..e9949a16139 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializerExt.cs @@ -1,6 +1,7 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +using System.Buffers; using OpenTelemetry.Metrics; namespace OpenTelemetry.Exporter.Prometheus; @@ -28,42 +29,31 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe cursor = WriteUnitMetadata(buffer, cursor, prometheusMetric, openMetricsRequested); cursor = WriteHelpMetadata(buffer, cursor, prometheusMetric, metric.Description, openMetricsRequested); - if (!metric.MetricType.IsHistogram()) + var metricType = metric.MetricType; + if (!metricType.IsHistogram()) { + var isLongValue = ((int)metricType & 0b_0000_1111) == 0x0a; // I8 + var isSum = metricType.IsSum(); + foreach (ref readonly var metricPoint in metric.GetMetricPoints()) { var timestamp = metricPoint.EndTime.ToUnixTimeMilliseconds(); // Counter and Gauge cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); - cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags); + cursor = WriteTags(buffer, cursor, prometheusMetric, metric, metricPoint.Tags); buffer[cursor++] = unchecked((byte)' '); - // TODO: MetricType is same for all MetricPoints - // within a given Metric, so this check can avoided - // for each MetricPoint - if (((int)metric.MetricType & 0b_0000_1111) == 0x0a /* I8 */) + if (isLongValue) { - if (metric.MetricType.IsSum()) - { - cursor = WriteLong(buffer, cursor, metricPoint.GetSumLong()); - } - else - { - cursor = WriteLong(buffer, cursor, metricPoint.GetGaugeLastValueLong()); - } + long value = isSum ? metricPoint.GetSumLong() : metricPoint.GetGaugeLastValueLong(); + cursor = WriteLong(buffer, cursor, value); } else { - if (metric.MetricType.IsSum()) - { - cursor = WriteDouble(buffer, cursor, metricPoint.GetSumDouble()); - } - else - { - cursor = WriteDouble(buffer, cursor, metricPoint.GetGaugeLastValueDouble()); - } + double value = isSum ? metricPoint.GetSumDouble() : metricPoint.GetGaugeLastValueDouble(); + cursor = WriteDouble(buffer, cursor, value); } if (!disableTimestamp) @@ -79,78 +69,159 @@ public static int WriteMetric(byte[] buffer, int cursor, Metric metric, Promethe { foreach (ref readonly var metricPoint in metric.GetMetricPoints()) { - var tags = metricPoint.Tags; - var timestamp = metricPoint.EndTime.ToUnixTimeMilliseconds(); + cursor = WriteHistogramMetricPoint(buffer, cursor, metric, prometheusMetric, in metricPoint, openMetricsRequested, disableTimestamp); + } + } - long totalCount = 0; - foreach (var histogramMeasurement in metricPoint.GetHistogramBuckets()) - { - totalCount += histogramMeasurement.BucketCount; + return cursor; + } - cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); - cursor = WriteAsciiStringNoEscape(buffer, cursor, "_bucket{"); - cursor = WriteTags(buffer, cursor, metric, tags, writeEnclosingBraces: false); + internal static byte[] SerializeStaticTags(Metric metric) + { +#if NET + Span stackBuffer = stackalloc byte[256]; + if (TryWriteStaticTags(stackBuffer, metric, out var stackCursor)) + { + return stackBuffer[..stackCursor].ToArray(); + } +#endif - cursor = WriteAsciiStringNoEscape(buffer, cursor, "le=\""); + var buffer = new byte[128]; - if (histogramMeasurement.ExplicitBound != double.PositiveInfinity) - { - cursor = WriteDouble(buffer, cursor, histogramMeasurement.ExplicitBound); - } - else - { - cursor = WriteAsciiStringNoEscape(buffer, cursor, "+Inf"); - } + while (true) + { + try + { + var cursor = WriteStaticTags(buffer, 0, metric); + return buffer.AsSpan(0, cursor).ToArray(); + } + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) + { + buffer = new byte[checked(buffer.Length * 2)]; + } + } + } - cursor = WriteAsciiStringNoEscape(buffer, cursor, "\"} "); + private static int WriteHistogramMetricPoint( + byte[] buffer, + int cursor, + Metric metric, + PrometheusMetric prometheusMetric, + in MetricPoint metricPoint, + bool openMetricsRequested, + bool disableTimestamp) + { +#if NET + Span stackTags = stackalloc byte[256]; + if (TryWriteTags(stackTags, prometheusMetric, metric, metricPoint.Tags, writeEnclosingBraces: false, out var stackTagsLength)) + { + return WriteHistogramMetricPoint(buffer, cursor, prometheusMetric, in metricPoint, openMetricsRequested, disableTimestamp, stackTags[..stackTagsLength]); + } +#endif - cursor = WriteLong(buffer, cursor, totalCount); + var serializedTags = RentSerializedTags(prometheusMetric, metric, metricPoint.Tags, out var tagsLength); - if (!disableTimestamp) - { - buffer[cursor++] = unchecked((byte)' '); - cursor = WriteTimestamp(buffer, cursor, timestamp, openMetricsRequested); - } + try + { + return WriteHistogramMetricPoint(buffer, cursor, prometheusMetric, in metricPoint, openMetricsRequested, disableTimestamp, serializedTags.AsSpan(0, tagsLength)); + } + finally + { + ArrayPool.Shared.Return(serializedTags); + } + } - buffer[cursor++] = ASCII_LINEFEED; - } + private static int WriteHistogramMetricPoint( + byte[] buffer, + int cursor, + PrometheusMetric prometheusMetric, + in MetricPoint metricPoint, + bool openMetricsRequested, + bool disableTimestamp, + ReadOnlySpan tags) + { + var timestamp = metricPoint.EndTime.ToUnixTimeMilliseconds(); - // Histogram sum - cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); - cursor = WriteAsciiStringNoEscape(buffer, cursor, "_sum"); - cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags); + long totalCount = 0; + foreach (var histogramMeasurement in metricPoint.GetHistogramBuckets()) + { + totalCount += histogramMeasurement.BucketCount; - buffer[cursor++] = unchecked((byte)' '); + cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); + cursor = WriteAsciiStringNoEscape(buffer, cursor, "_bucket{"); + cursor = WriteUtf8NoEscape(buffer, cursor, tags); - cursor = WriteDouble(buffer, cursor, metricPoint.GetHistogramSum()); + cursor = WriteAsciiStringNoEscape(buffer, cursor, "le=\""); - if (!disableTimestamp) - { - buffer[cursor++] = unchecked((byte)' '); - cursor = WriteTimestamp(buffer, cursor, timestamp, openMetricsRequested); - } + cursor = histogramMeasurement.ExplicitBound != double.PositiveInfinity + ? WriteDouble(buffer, cursor, histogramMeasurement.ExplicitBound) + : WriteAsciiStringNoEscape(buffer, cursor, "+Inf"); - buffer[cursor++] = ASCII_LINEFEED; + cursor = WriteAsciiStringNoEscape(buffer, cursor, "\"} "); - // Histogram count - cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); - cursor = WriteAsciiStringNoEscape(buffer, cursor, "_count"); - cursor = WriteTags(buffer, cursor, metric, metricPoint.Tags); + cursor = WriteLong(buffer, cursor, totalCount); + if (!disableTimestamp) + { buffer[cursor++] = unchecked((byte)' '); + cursor = WriteTimestamp(buffer, cursor, timestamp, openMetricsRequested); + } - cursor = WriteLong(buffer, cursor, metricPoint.GetHistogramCount()); + buffer[cursor++] = ASCII_LINEFEED; + } - if (!disableTimestamp) - { - buffer[cursor++] = unchecked((byte)' '); - cursor = WriteTimestamp(buffer, cursor, timestamp, openMetricsRequested); - } + // Histogram sum + cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); + cursor = WriteAsciiStringNoEscape(buffer, cursor, "_sum{"); + cursor = WriteUtf8NoEscape(buffer, cursor, tags); + buffer[cursor - 1] = unchecked((byte)'}'); + buffer[cursor++] = unchecked((byte)' '); + cursor = WriteDouble(buffer, cursor, metricPoint.GetHistogramSum()); - buffer[cursor++] = ASCII_LINEFEED; - } + if (!disableTimestamp) + { + buffer[cursor++] = unchecked((byte)' '); + cursor = WriteTimestamp(buffer, cursor, timestamp, openMetricsRequested); } + buffer[cursor++] = ASCII_LINEFEED; + + // Histogram count + cursor = WriteMetricName(buffer, cursor, prometheusMetric, openMetricsRequested); + cursor = WriteAsciiStringNoEscape(buffer, cursor, "_count{"); + cursor = WriteUtf8NoEscape(buffer, cursor, tags); + buffer[cursor - 1] = unchecked((byte)'}'); + buffer[cursor++] = unchecked((byte)' '); + cursor = WriteLong(buffer, cursor, metricPoint.GetHistogramCount()); + + if (!disableTimestamp) + { + buffer[cursor++] = unchecked((byte)' '); + cursor = WriteTimestamp(buffer, cursor, timestamp, openMetricsRequested); + } + + buffer[cursor++] = ASCII_LINEFEED; + return cursor; } + + private static byte[] RentSerializedTags(PrometheusMetric prometheusMetric, Metric metric, ReadOnlyTagCollection tags, out int tagsLength) + { + var length = Math.Max(prometheusMetric.SerializedStaticTags?.Length ?? 0, 64) + 128; + var buffer = ArrayPool.Shared.Rent(length); + + while (true) + { + try + { + tagsLength = WriteTags(buffer, 0, prometheusMetric, metric, tags, writeEnclosingBraces: false); + return buffer; + } + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) + { + ArrayPool.Shared.Return(buffer); + buffer = ArrayPool.Shared.Rent(checked(buffer.Length * 2)); + } + } + } } diff --git a/test/Benchmarks/Exporter/PrometheusSerializerBenchmarks.cs b/test/Benchmarks/Exporter/PrometheusSerializerBenchmarks.cs index f6e6295020a..8d3816b4f07 100644 --- a/test/Benchmarks/Exporter/PrometheusSerializerBenchmarks.cs +++ b/test/Benchmarks/Exporter/PrometheusSerializerBenchmarks.cs @@ -17,6 +17,8 @@ public class PrometheusSerializerBenchmarks private readonly List metrics = []; private readonly byte[] buffer = new byte[85000]; private readonly Dictionary cache = []; + private Metric? histogramMetric; + private Metric? typedLabelsMetric; private Meter? meter; private MeterProvider? meterProvider; @@ -40,7 +42,13 @@ public void GlobalSetup() var histogram = this.meter.CreateHistogram("histogram_name_1", "long", "histogram_name_1_description"); histogram.Record(100, new("label1", "value1"), new("label2", "value2")); + var typedLabelsCounter = this.meter.CreateCounter("counter_name_2", "long", "counter_name_2_description"); + typedLabelsCounter.Add(18, new("bool_label", true), new("long_label", 9223372036854775807L), new("double_label", 1234.5)); + this.meterProvider.ForceFlush(); + + this.histogramMetric = this.metrics.Single(metric => metric.Name == "histogram_name_1"); + this.typedLabelsMetric = this.metrics.Single(metric => metric.Name == "counter_name_2"); } [GlobalCleanup] @@ -50,7 +58,6 @@ public void GlobalCleanup() this.meterProvider?.Dispose(); } - // TODO: this has a dependency on https://github.com/open-telemetry/opentelemetry-dotnet/issues/2361 [Benchmark] public void WriteMetric() { @@ -64,6 +71,24 @@ public void WriteMetric() } } + [Benchmark] + public void WriteHistogramMetric() + { + for (var i = 0; i < this.NumberOfSerializeCalls; i++) + { + _ = PrometheusSerializer.WriteMetric(this.buffer, 0, this.histogramMetric!, this.GetPrometheusMetric(this.histogramMetric!), openMetricsRequested: false, disableTimestamp: false); + } + } + + [Benchmark] + public void WriteMetricWithTypedLabels() + { + for (var i = 0; i < this.NumberOfSerializeCalls; i++) + { + _ = PrometheusSerializer.WriteMetric(this.buffer, 0, this.typedLabelsMetric!, this.GetPrometheusMetric(this.typedLabelsMetric!), openMetricsRequested: false, disableTimestamp: false); + } + } + private PrometheusMetric GetPrometheusMetric(Metric metric) { if (!this.cache.TryGetValue(metric, out var prometheusMetric)) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs index c250dbd76bb..bf9e6029e46 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs @@ -1,6 +1,7 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +using System.Globalization; using FsCheck; using FsCheck.Fluent; using FsCheck.Xunit; @@ -31,6 +32,21 @@ public Property WriteUnicodeStringMatchesReferenceImplementation() => Prop.ForAl Generators.PrometheusStringArbitrary(), static (value) => Serialize(value, PrometheusSerializer.WriteUnicodeString).SequenceEqual(ReferenceWriteUnicodeString(value))); + [Property(MaxTest = MaxTests)] + public Property WriteLabelValueObjectMatchesReferenceImplementation() => Prop.ForAll( + Generators.LabelValueArbitrary(), + static (value) => SerializeLabelValue(value).SequenceEqual(ReferenceWriteLabelValueObject(value))); + + [Property(MaxTest = MaxTests)] + public Property WriteLongMatchesReferenceImplementation() => Prop.ForAll( + Generators.LongArbitrary(), + static (value) => SerializeLong(value).SequenceEqual(ReferenceWriteLong(value))); + + [Property(MaxTest = MaxTests)] + public Property WriteDoubleMatchesReferenceImplementation() => Prop.ForAll( + Generators.DoubleArbitrary(), + static (value) => SerializeDouble(value).SequenceEqual(ReferenceWriteDouble(value))); + private static byte[] Serialize(string value, Func writer) { var buffer = new byte[(value.Length * 8) + 16]; @@ -38,6 +54,38 @@ private static byte[] Serialize(string value, Func wri return buffer.AsSpan(0, cursor).ToArray(); } + private static byte[] SerializeLabelValue(object? value) + { + var buffer = new byte[256]; + + while (true) + { + try + { + var cursor = PrometheusSerializer.WriteLabelValue(buffer, 0, value); + return buffer.AsSpan(0, cursor).ToArray(); + } + catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException) + { + buffer = new byte[checked(buffer.Length * 2)]; + } + } + } + + private static byte[] SerializeLong(long value) + { + var buffer = new byte[64]; + var cursor = PrometheusSerializer.WriteLong(buffer, 0, value); + return buffer.AsSpan(0, cursor).ToArray(); + } + + private static byte[] SerializeDouble(double value) + { + var buffer = new byte[64]; + var cursor = PrometheusSerializer.WriteDouble(buffer, 0, value); + return buffer.AsSpan(0, cursor).ToArray(); + } + private static byte[] ReferenceWriteAsciiStringNoEscape(string value) { var bytes = new byte[value.Length]; @@ -73,8 +121,37 @@ private static byte[] ReferenceWriteLabelKey(string value) private static byte[] ReferenceWriteLabelValue(string value) => ReferenceWriteEscapedString(value, escapeQuotationMarks: true); + private static byte[] ReferenceWriteLabelValueObject(object? value) + { + var stringValue = value switch + { + null => string.Empty, + bool boolValue => boolValue ? "true" : "false", + float floatValue when float.IsPositiveInfinity(floatValue) => "+Inf", + float floatValue when float.IsNegativeInfinity(floatValue) => "-Inf", + float floatValue when float.IsNaN(floatValue) => "Nan", + double doubleValue when double.IsPositiveInfinity(doubleValue) => "+Inf", + double doubleValue when double.IsNegativeInfinity(doubleValue) => "-Inf", + double doubleValue when double.IsNaN(doubleValue) => "Nan", + IFormattable formattableValue => formattableValue.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString(), + }; + + return ReferenceWriteLabelValue(stringValue ?? string.Empty); + } + private static byte[] ReferenceWriteUnicodeString(string value) => ReferenceWriteEscapedString(value, escapeQuotationMarks: false); + private static byte[] ReferenceWriteLong(long value) => System.Text.Encoding.UTF8.GetBytes(value.ToString(CultureInfo.InvariantCulture)); + + private static byte[] ReferenceWriteDouble(double value) => value switch + { + var doubleValue when double.IsPositiveInfinity(doubleValue) => System.Text.Encoding.UTF8.GetBytes("+Inf"), + var doubleValue when double.IsNegativeInfinity(doubleValue) => System.Text.Encoding.UTF8.GetBytes("-Inf"), + var doubleValue when double.IsNaN(doubleValue) => System.Text.Encoding.UTF8.GetBytes("Nan"), + _ => System.Text.Encoding.UTF8.GetBytes(value.ToString("G", CultureInfo.InvariantCulture)), + }; + private static byte[] ReferenceWriteEscapedString(string value, bool escapeQuotationMarks) { var bytes = new List(value.Length * 3); @@ -137,6 +214,53 @@ public static Arbitrary PrometheusStringArbitrary() return CreateString(charGen, maxLength: 128).ToArbitrary(); } + public static Arbitrary DoubleArbitrary() + { + var generator = + from mantissa in Gen.Choose(-1_000_000, 1_000_000) + from exponent in Gen.Choose(-12, 12) + select mantissa * Math.Pow(10, exponent); + + var finite = Gen.OneOf( + Gen.Elements(-1d, 0d, 1d, double.Epsilon, double.MinValue, double.MaxValue), + generator); + + return Gen.OneOf( + finite, + Gen.Constant(double.PositiveInfinity), + Gen.Constant(double.NegativeInfinity), + Gen.Constant(double.NaN)).ToArbitrary(); + } + + public static Arbitrary LongArbitrary() + { + var generator = from high in Gen.Choose(int.MinValue, int.MaxValue) + from low in Gen.Choose(int.MinValue, int.MaxValue) + select ((long)high << 32) | (uint)low; + + return Gen.OneOf(Gen.Elements(long.MinValue, -1L, 0L, 1L, long.MaxValue), generator).ToArbitrary(); + } + + public static Arbitrary LabelValueArbitrary() + { + var chars = Gen.Choose(0, 0xFFFF).Select(static value => (object?)(char)value); + var decimals = Gen.Choose(int.MinValue, int.MaxValue).Select(static value => (object?)(value / 10m)); + var unsignedLongs = + from high in Gen.Choose(0, int.MaxValue) + from low in Gen.Choose(int.MinValue, int.MaxValue) + select (object?)(((ulong)(uint)high << 32) | (uint)low); + + return Gen.OneOf( + Gen.Constant((object?)null), + PrometheusStringArbitrary().Generator.Select(static value => (object?)value), + Gen.Elements(true, false), + LongArbitrary().Generator.Select(static value => (object?)value), + unsignedLongs, + DoubleArbitrary().Generator.Select(static value => (object?)value), + decimals, + chars).ToArbitrary(); + } + private static Gen CreateString(Gen charGen, int maxLength) => Gen.Sized(size => from length in Gen.Choose(0, Math.Min((size * 2) + 1, maxLength)) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 0c68d8182f8..1cbaaa73fd1 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using System.Diagnostics.Metrics; +using System.Globalization; using System.Text; using OpenTelemetry.Metrics; using OpenTelemetry.Tests; @@ -740,6 +741,103 @@ public void WriteLabelValueEscapesSpecialCharacters() Assert.Equal("\\\"line1\\\\\\nline2\\\"", Encoding.UTF8.GetString(buffer, 0, cursor)); } + [Theory] + [InlineData(true, "true")] + [InlineData(false, "false")] + [InlineData(123456789, "123456789")] + [InlineData(123456787L, "123456787")] + [InlineData(123456786LU, "123456786")] + [InlineData(123456785U, "123456785")] + [InlineData(123456784f, "123456784")] + [InlineData(123456783d, "123456783")] + public void WriteLabelValueObjectFormatsCommonPrimitiveValues(object value, string expected) + { + var buffer = new byte[128]; + + var cursor = PrometheusSerializer.WriteLabelValue(buffer, 0, value); + + Assert.Equal(expected, Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Fact] + public void WriteLabelValueObjectFormatsUsingInvariantCulture() + { + var previousCulture = CultureInfo.CurrentCulture; + var previousUiCulture = CultureInfo.CurrentUICulture; + + try + { + var culture = new CultureInfo("fr-FR"); + + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + + var buffer = new byte[128]; + var doubleCursor = PrometheusSerializer.WriteLabelValue(buffer, 0, 1234.5); + Assert.Equal("1234.5", Encoding.UTF8.GetString(buffer, 0, doubleCursor)); + + Array.Clear(buffer, 0, buffer.Length); + + var decimalCursor = PrometheusSerializer.WriteLabelValue(buffer, 0, 1234.5m); + Assert.Equal("1234.5", Encoding.UTF8.GetString(buffer, 0, decimalCursor)); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + CultureInfo.CurrentUICulture = previousUiCulture; + } + } + + [Fact] + public void WriteLabelFormatsTypedValues() + { + var buffer = new byte[128]; + + var cursor = PrometheusSerializer.WriteLabel(buffer, 0, "value", 18446744073709551615UL); + + Assert.Equal("value=\"18446744073709551615\"", Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Theory] + [InlineData(long.MinValue)] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + [InlineData(long.MaxValue)] + public void WriteLongMatchesInvariantFormatting(long value) + { + var buffer = new byte[64]; + + var cursor = PrometheusSerializer.WriteLong(buffer, 0, value); + + Assert.Equal(value.ToString(CultureInfo.InvariantCulture), Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Theory] + [InlineData(double.NegativeInfinity, "-Inf")] + [InlineData(-1234.5, "-1234.5")] + [InlineData(0d, "0")] + [InlineData(1234.5, "1234.5")] + [InlineData(double.PositiveInfinity, "+Inf")] + public void WriteDoubleMatchesInvariantFormatting(double value, string expected) + { + var buffer = new byte[64]; + + var cursor = PrometheusSerializer.WriteDouble(buffer, 0, value); + + Assert.Equal(expected, Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Fact] + public void WriteDoubleFormatsNaN() + { + var buffer = new byte[64]; + + var cursor = PrometheusSerializer.WriteDouble(buffer, 0, double.NaN); + + Assert.Equal("Nan", Encoding.UTF8.GetString(buffer, 0, cursor)); + } + [Fact] public void WriteUnicodeStringPreservesUtf16CodeUnitEncoding() { From 8609727f1a87ad3d06f745d8e988dd4b6f325474 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 26 Apr 2026 17:58:53 +0100 Subject: [PATCH 06/19] [Exporter.Prometheus] Extend coverage Add missing patch coverage. --- .../PrometheusSerializerTests.cs | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 1cbaaa73fd1..3101b3c6ee6 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -12,6 +12,57 @@ namespace OpenTelemetry.Exporter.Prometheus.Tests; public sealed class PrometheusSerializerTests { + public static TheoryData LabelValueBoundaryCases => new() + { + { null, string.Empty }, + { string.Empty, string.Empty }, + { sbyte.MinValue, "-128" }, + { (sbyte)0, "0" }, + { sbyte.MaxValue, "127" }, + { byte.MinValue, "0" }, + { byte.MaxValue, "255" }, + { short.MinValue, "-32768" }, + { (short)0, "0" }, + { short.MaxValue, "32767" }, + { ushort.MinValue, "0" }, + { ushort.MaxValue, "65535" }, + { int.MinValue, "-2147483648" }, + { 0, "0" }, + { int.MaxValue, "2147483647" }, + { uint.MinValue, "0" }, + { uint.MaxValue, "4294967295" }, + { long.MinValue, "-9223372036854775808" }, + { 0L, "0" }, + { long.MaxValue, "9223372036854775807" }, + { ulong.MinValue, "0" }, + { ulong.MaxValue, "18446744073709551615" }, +#if NET + { float.MinValue, "-3.4028234663852886E+38" }, +#else + { float.MinValue, "-3.40282346638529E+38" }, +#endif + { 0f, "0" }, +#if NET + { float.MaxValue, "3.4028234663852886E+38" }, +#else + { float.MaxValue, "3.40282346638529E+38" }, +#endif +#if NET + { double.MinValue, "-1.7976931348623157E+308" }, +#else + { double.MinValue, "-1.79769313486232E+308" }, +#endif + { 0d, "0" }, +#if NET + { double.MaxValue, "1.7976931348623157E+308" }, +#else + { double.MaxValue, "1.79769313486232E+308" }, +#endif + { decimal.MinValue, "-79228162514264337593543950335" }, + { 0m, "0" }, + { decimal.MaxValue, "79228162514264337593543950335" }, + }; + [Theory] [InlineData(true)] [InlineData(false)] @@ -272,6 +323,35 @@ public void SumDoubleInfinities() Encoding.UTF8.GetString(buffer, 0, cursor)); } + [Theory] + [InlineData(0L)] + [InlineData(long.MaxValue)] + public void SumLongSerializesBoundaryValues(long value) + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = new Meter(Utils.GetCurrentMethodName()); + using (var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build()) + { + var counter = meter.CreateCounter("test_counter"); + counter.Add(value); + + provider.ForceFlush(); + } + + var cursor = WriteMetric(buffer, 0, metrics[0]); + Assert.Matches( + ("^" + + "# TYPE test_counter_total counter\n" + + $"test_counter_total{{otel_scope_name='{Utils.GetCurrentMethodName()}'}} {value.ToString(CultureInfo.InvariantCulture)} \\d+\n" + + "$").Replace('\'', '"'), + Encoding.UTF8.GetString(buffer, 0, cursor)); + } + [Fact] public void SumNonMonotonicDouble() { @@ -759,6 +839,43 @@ public void WriteLabelValueObjectFormatsCommonPrimitiveValues(object value, stri Assert.Equal(expected, Encoding.UTF8.GetString(buffer, 0, cursor)); } + [Theory] +#pragma warning disable xUnit1045 // Avoid using TheoryData type arguments that might not be serializable + [MemberData(nameof(LabelValueBoundaryCases))] +#pragma warning restore xUnit1045 // Avoid using TheoryData type arguments that might not be serializable + public void WriteLabelValueObjectFormatsBoundaryValues(object? value, string expected) + { + var buffer = new byte[128]; + + var cursor = PrometheusSerializer.WriteLabelValue(buffer, 0, value); + + Assert.Equal(expected, Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Theory] + [InlineData("café")] + [InlineData("Привет, мир")] + [InlineData("日本語")] + public void WriteLabelValueObjectFormatsNonAsciiStringsUtf8Strings(string value) + { + var buffer = new byte[128]; + + var cursor = PrometheusSerializer.WriteLabelValue(buffer, 0, (object)value); + + Assert.Equal(value, Encoding.UTF8.GetString(buffer, 0, cursor)); + } + + [Fact] + public void WriteLabelValueObjectPreservesEmojiUtf16CodeUnitEncoding() + { + const string value = "rocket:\uD83D\uDE80"; + var buffer = new byte[128]; + + var cursor = PrometheusSerializer.WriteLabelValue(buffer, 0, (object)value); + + Assert.Equal("726F636B65743AEDA0BDEDBA80", ToHexString(buffer, cursor)); + } + [Fact] public void WriteLabelValueObjectFormatsUsingInvariantCulture() { @@ -788,6 +905,41 @@ public void WriteLabelValueObjectFormatsUsingInvariantCulture() } } + [Fact] + public void WriteLabelValueObjectFormatsIFormattableUsingInvariantCulture() + { + var previousCulture = CultureInfo.CurrentCulture; + var previousUiCulture = CultureInfo.CurrentUICulture; + + try + { + var culture = new CultureInfo("fr-FR"); + + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + + var buffer = new byte[128]; + var cursor = PrometheusSerializer.WriteLabelValue(buffer, 0, new CustomFormattable(1234.5m)); + + Assert.Equal("1234.5", Encoding.UTF8.GetString(buffer, 0, cursor)); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + CultureInfo.CurrentUICulture = previousUiCulture; + } + } + + [Fact] + public void WriteLabelValueObjectFallsBackToToString() + { + var buffer = new byte[128]; + + var cursor = PrometheusSerializer.WriteLabelValue(buffer, 0, new CustomObject("fallback")); + + Assert.Equal("fallback", Encoding.UTF8.GetString(buffer, 0, cursor)); + } + [Fact] public void WriteLabelFormatsTypedValues() { @@ -868,4 +1020,18 @@ private static string ToHexString(byte[] buffer, int length) private static int WriteMetric(byte[] buffer, int cursor, Metric metric, bool useOpenMetrics = false, bool disableTimestamp = false) => PrometheusSerializer.WriteMetric(buffer, cursor, metric, PrometheusMetric.Create(metric, false), useOpenMetrics, disableTimestamp); + + private sealed class CustomFormattable(decimal value) : IFormattable + { + public string ToString(string? format, IFormatProvider? formatProvider) + => value.ToString(format, formatProvider); + + public override string ToString() + => value.ToString(CultureInfo.CurrentCulture); + } + + private sealed class CustomObject(string value) + { + public override string ToString() => value; + } } From 27a1d2ee3a08361701ed2b823da8b080eeec5a90 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 26 Apr 2026 18:04:48 +0100 Subject: [PATCH 07/19] [Exporter.Prometheus] Fix lint warnings Encode the strings. --- .../PrometheusSerializerTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 3101b3c6ee6..2c149c8b105 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -853,9 +853,9 @@ public void WriteLabelValueObjectFormatsBoundaryValues(object? value, string exp } [Theory] - [InlineData("café")] - [InlineData("Привет, мир")] - [InlineData("日本語")] + [InlineData("caf\xc3\xa9")] + [InlineData("\xd0\x9f\xd1\x80\xd0\xb8\xd0\xb2\xd0\xb5\xd1\x82, \xd0\xbc\xd0\xb8\xd1\x80")] + [InlineData("\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e")] public void WriteLabelValueObjectFormatsNonAsciiStringsUtf8Strings(string value) { var buffer = new byte[128]; From 65d6b25619d6d44e5048bc4624d59363f0f14b66 Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 26 Apr 2026 19:57:59 +0100 Subject: [PATCH 08/19] [Exporter.Prometheus] Extend coverage Add missing coverage for static labels. --- .../PrometheusSerializerTests.cs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 2c149c8b105..8236589ac27 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -14,8 +14,16 @@ public sealed class PrometheusSerializerTests { public static TheoryData LabelValueBoundaryCases => new() { + { false, "false" }, + { true, "true" }, { null, string.Empty }, { string.Empty, string.Empty }, + { "tagValue", "tagValue" }, + { "tagValueWith\"Quote", "tagValueWith\\\"Quote" }, + { "tagValueWith\\Backslash", "tagValueWith\\\\Backslash" }, + { "tagValueWith\nNewline", "tagValueWith\\nNewline" }, + { "\"line1\\\nline2\"", "\\\"line1\\\\\\nline2\\\"" }, + { "caf\u00e9", "caf\u00e9" }, { sbyte.MinValue, "-128" }, { (sbyte)0, "0" }, { sbyte.MaxValue, "127" }, @@ -42,6 +50,9 @@ public sealed class PrometheusSerializerTests { float.MinValue, "-3.40282346638529E+38" }, #endif { 0f, "0" }, + { float.NaN, "Nan" }, + { float.NegativeInfinity, "-Inf" }, + { float.PositiveInfinity, "+Inf" }, #if NET { float.MaxValue, "3.4028234663852886E+38" }, #else @@ -53,6 +64,9 @@ public sealed class PrometheusSerializerTests { double.MinValue, "-1.79769313486232E+308" }, #endif { 0d, "0" }, + { double.NegativeInfinity, "-Inf" }, + { double.PositiveInfinity, "+Inf" }, + { double.NaN, "Nan" }, #if NET { double.MaxValue, "1.7976931348623157E+308" }, #else @@ -930,6 +944,53 @@ public void WriteLabelValueObjectFormatsIFormattableUsingInvariantCulture() } } + [Theory] +#pragma warning disable xUnit1045 // Avoid using TheoryData type arguments that might not be serializable + [MemberData(nameof(LabelValueBoundaryCases))] +#pragma warning restore xUnit1045 // Avoid using TheoryData type arguments that might not be serializable + public void WriteMetricSerializesStaticMeterTagBoundaryValues(object? meterTagValue, string expectedTagValue) + { + var output = WriteGaugeMetricWithMeterTags(new KeyValuePair("meter_tag", meterTagValue)); + + Assert.Equal( + ("# TYPE test_gauge gauge\n" + + $"test_gauge{{otel_scope_name='test_meter',meter_tag='{expectedTagValue}'}} 123\n").Replace('\'', '"'), + output); + } + + [Fact] + public void WriteMetricSerializesStaticMeterTagsUsingInvariantCulture() + { + var previousCulture = CultureInfo.CurrentCulture; + var previousUiCulture = CultureInfo.CurrentUICulture; + + try + { + var culture = new CultureInfo("fr-FR"); + + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + + var output = WriteGaugeMetricWithMeterTags( + new KeyValuePair("double_value", 1234.5), + new KeyValuePair("decimal_value", 1234.5m), + new KeyValuePair("formattable_value", new CustomFormattable(1234.5m)), + new KeyValuePair("fallback_value", new CustomObject("fallback"))); + + Assert.StartsWith("# TYPE test_gauge gauge\ntest_gauge{otel_scope_name=\"test_meter\",", output, StringComparison.Ordinal); + Assert.Contains("double_value=\"1234.5\"", output, StringComparison.Ordinal); + Assert.Contains("decimal_value=\"1234.5\"", output, StringComparison.Ordinal); + Assert.Contains("formattable_value=\"1234.5\"", output, StringComparison.Ordinal); + Assert.Contains("fallback_value=\"fallback\"", output, StringComparison.Ordinal); + Assert.EndsWith("} 123\n", output, StringComparison.Ordinal); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + CultureInfo.CurrentUICulture = previousUiCulture; + } + } + [Fact] public void WriteLabelValueObjectFallsBackToToString() { @@ -1021,6 +1082,25 @@ private static string ToHexString(byte[] buffer, int length) private static int WriteMetric(byte[] buffer, int cursor, Metric metric, bool useOpenMetrics = false, bool disableTimestamp = false) => PrometheusSerializer.WriteMetric(buffer, cursor, metric, PrometheusMetric.Create(metric, false), useOpenMetrics, disableTimestamp); + private static string WriteGaugeMetricWithMeterTags(params KeyValuePair[] meterTags) + { + var buffer = new byte[85000]; + var metrics = new List(); + + using var meter = new Meter(name: "test_meter", version: null, tags: meterTags); + using (var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build()) + { + meter.CreateObservableGauge("test_gauge", () => 123); + provider.ForceFlush(); + } + + var cursor = WriteMetric(buffer, 0, metrics[0], disableTimestamp: true); + return Encoding.UTF8.GetString(buffer, 0, cursor); + } + private sealed class CustomFormattable(decimal value) : IFormattable { public string ToString(string? format, IFormatProvider? formatProvider) From fb2545dbacd7cf0f7ddc9944c7f2c3a41173c52e Mon Sep 17 00:00:00 2001 From: martincostello Date: Sun, 26 Apr 2026 20:35:17 +0100 Subject: [PATCH 09/19] [Exporter.Prometheus] Extend coverage Add remaining (easy) coverage and remove unused code. --- .../Internal/PrometheusSerializer.cs | 4 -- .../PrometheusSerializerTests.cs | 40 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 7ca224a654b..246f8419b60 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -442,10 +442,6 @@ public static int WriteTimestamp(byte[] buffer, int cursor, long value, bool use return WriteLong(buffer, cursor, value); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteTags(byte[] buffer, int cursor, Metric metric, ReadOnlyTagCollection tags, bool writeEnclosingBraces = true) - => WriteTags(buffer, cursor, null, metric, tags, writeEnclosingBraces); - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteTags(byte[] buffer, int cursor, PrometheusMetric? prometheusMetric, Metric metric, ReadOnlyTagCollection tags, bool writeEnclosingBraces = true) { diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 8236589ac27..eb90e56e21d 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -1063,6 +1063,46 @@ public void WriteUnicodeStringPreservesUtf16CodeUnitEncoding() Assert.Equal("726F636B65743AEDA0BDEDBA80", actual); } +#if NET + [Fact] + public void WriteHistogramMetricSerializesStaticTagsWithoutPreSerializedTags() + { + var buffer = new byte[85000]; + + var metric = GetSingleHistogramMetric( + meterName: "\u65e5\u672c", + meterTags: [new KeyValuePair(string.Empty, "meterTagValue")]); + + var prometheusMetric = new PrometheusMetric(metric.Name, metric.Unit, PrometheusType.Histogram, disableTotalNameSuffixForCounters: false); + + var cursor = PrometheusSerializer.WriteMetric(buffer, 0, metric, prometheusMetric, openMetricsRequested: false, disableTimestamp: true); + var output = Encoding.UTF8.GetString(buffer, 0, cursor); + + Assert.Contains("test_histogram_bucket{otel_scope_name=\"\u65e5\u672c\",_=\"meterTagValue\",le=\"0\"} 0\n", output, StringComparison.Ordinal); + Assert.Contains("test_histogram_sum{otel_scope_name=\"\u65e5\u672c\",_=\"meterTagValue\"} 18\n", output, StringComparison.Ordinal); + Assert.Contains("test_histogram_count{otel_scope_name=\"\u65e5\u672c\",_=\"meterTagValue\"} 1\n", output, StringComparison.Ordinal); + } + + private static Metric GetSingleHistogramMetric(string meterName, params KeyValuePair[] meterTags) + { + var metrics = new List(); + + using var meter = new Meter(name: meterName, version: null, tags: meterTags); + + using var provider = Sdk.CreateMeterProviderBuilder() + .AddMeter(meter.Name) + .AddInMemoryExporter(metrics) + .Build(); + + var histogram = meter.CreateHistogram("test_histogram"); + histogram.Record(18); + + provider.ForceFlush(); + + return metrics.Single(); + } +#endif + private static string ToHexString(byte[] buffer, int length) { var chars = new char[length * 2]; From d6db6309c893ddeef3c5c1bde04e46b4c7d6dd3a Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 09:26:42 +0100 Subject: [PATCH 10/19] [Exporter.Prometheus] Remove parameters Remove now-unused parameters from benchmarks. --- test/Benchmarks/Exporter/PrometheusSerializerBenchmarks.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Benchmarks/Exporter/PrometheusSerializerBenchmarks.cs b/test/Benchmarks/Exporter/PrometheusSerializerBenchmarks.cs index 8c2a9bec495..f6d8973175b 100644 --- a/test/Benchmarks/Exporter/PrometheusSerializerBenchmarks.cs +++ b/test/Benchmarks/Exporter/PrometheusSerializerBenchmarks.cs @@ -76,7 +76,7 @@ public void WriteHistogramMetric() { for (var i = 0; i < this.NumberOfSerializeCalls; i++) { - _ = PrometheusSerializer.WriteMetric(this.buffer, 0, this.histogramMetric!, this.GetPrometheusMetric(this.histogramMetric!), openMetricsRequested: false, disableTimestamp: false); + _ = PrometheusSerializer.WriteMetric(this.buffer, 0, this.histogramMetric!, this.GetPrometheusMetric(this.histogramMetric!), openMetricsRequested: false); } } @@ -85,7 +85,7 @@ public void WriteMetricWithTypedLabels() { for (var i = 0; i < this.NumberOfSerializeCalls; i++) { - _ = PrometheusSerializer.WriteMetric(this.buffer, 0, this.typedLabelsMetric!, this.GetPrometheusMetric(this.typedLabelsMetric!), openMetricsRequested: false, disableTimestamp: false); + _ = PrometheusSerializer.WriteMetric(this.buffer, 0, this.typedLabelsMetric!, this.GetPrometheusMetric(this.typedLabelsMetric!), openMetricsRequested: false); } } From b0c53f1ae6f7d9ff3f1c233f325ae948531216d8 Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 21:44:18 +0100 Subject: [PATCH 11/19] [Exporter.Prometheus] Reduce duplication Use helper for ASCII checking. --- .../Internal/PrometheusSerializer.cs | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 435714e3fab..9171ac2f558 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -180,23 +180,15 @@ public static int WriteLabelKey(byte[] buffer, int cursor, string value) return cursor; } - var ordinal = (ushort)value[0]; - - if (ordinal is >= '0' and <= '9') + if (IsAsciiDigit(value[0])) { buffer[cursor++] = unchecked((byte)'_'); } for (var i = 0; i < value.Length; i++) { - ordinal = value[i]; - - buffer[cursor++] = - ordinal is (>= 'A' and <= 'Z') or - (>= 'a' and <= 'z') or - (>= '0' and <= '9') - ? (byte)ordinal - : (byte)'_'; + var ch = value[i]; + buffer[cursor++] = IsAsciiLetterOrDigit(ch) ? (byte)ch : (byte)'_'; } return cursor; @@ -515,6 +507,22 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource) return cursor; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsAsciiDigit(char value) => +#if NET + char.IsAsciiDigit(value); +#else + value is >= '0' and <= '9'; +#endif + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsAsciiLetterOrDigit(char value) => +#if NET + char.IsAsciiLetterOrDigit(value); +#else + value is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9'); +#endif + private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan value) { value.CopyTo(buffer.AsSpan(cursor)); @@ -693,8 +701,7 @@ private static bool TryWriteLabelKey(Span buffer, ref int cursor, string v return TryWriteByte(buffer, ref cursor, unchecked((byte)'_')); } - var ordinal = (ushort)value[0]; - if (ordinal is >= '0' and <= '9' && + if (IsAsciiDigit(value[0]) && !TryWriteByte(buffer, ref cursor, unchecked((byte)'_'))) { return false; @@ -702,13 +709,8 @@ private static bool TryWriteLabelKey(Span buffer, ref int cursor, string v for (var i = 0; i < value.Length; i++) { - ordinal = value[i]; - var sanitizedByte = - ordinal is (>= 'A' and <= 'Z') or - (>= 'a' and <= 'z') or - (>= '0' and <= '9') - ? (byte)ordinal - : (byte)'_'; + var ch = value[i]; + var sanitizedByte = IsAsciiLetterOrDigit(value[i]) ? (byte)ch : (byte)'_'; if (!TryWriteByte(buffer, ref cursor, sanitizedByte)) { @@ -887,8 +889,7 @@ private static bool TryWriteUnicodeNoEscape(Span buffer, ref int cursor, u { return TryWriteByte(buffer, ref cursor, unchecked((byte)ordinal)); } - - if (ordinal <= 0x07FF) + else if (ordinal <= 0x07FF) { return TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1100_0000 | (ordinal >> 6)))) && TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1000_0000 | (ordinal & 0b_0011_1111)))); From 21b3a09132ad691be77df36c9bf38a300caf443c Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 22:01:58 +0100 Subject: [PATCH 12/19] [Exporter.Prometheus] Fix-up merge Fix some test changes that were removed during merge. --- .../PrometheusSerializerFuzzTests.cs | 36 ++++--------------- .../PrometheusSerializerTests.cs | 18 ++++++++-- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs index b779e5a3d46..9ad9038e884 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.FuzzTests/PrometheusSerializerFuzzTests.cs @@ -157,50 +157,28 @@ var doubleValue when double.IsNaN(doubleValue) => Encoding.UTF8.GetBytes("NaN"), private static byte[] ReferenceWriteEscapedString(string value, bool escapeQuotationMarks) { - var bytes = new List(value.Length * 3); + var text = new StringBuilder(value.Length); foreach (var c in value) { - switch ((ushort)c) + switch (c) { case '"' when escapeQuotationMarks: - bytes.Add((byte)'\\'); - bytes.Add((byte)'"'); + text.Append("\\\""); break; case '\\': - bytes.Add((byte)'\\'); - bytes.Add((byte)'\\'); + text.Append("\\\\"); break; case '\n': - bytes.Add((byte)'\\'); - bytes.Add((byte)'n'); + text.Append("\\n"); break; default: - AppendUnicodeNoEscape(bytes, c); + text.Append(c); break; } } - return [.. bytes]; - } - - private static void AppendUnicodeNoEscape(List bytes, ushort ordinal) - { - if (ordinal <= 0x7F) - { - bytes.Add(unchecked((byte)ordinal)); - } - else if (ordinal <= 0x07FF) - { - bytes.Add(unchecked((byte)(0b_1100_0000 | (ordinal >> 6)))); - bytes.Add(unchecked((byte)(0b_1000_0000 | (ordinal & 0b_0011_1111)))); - } - else - { - bytes.Add(unchecked((byte)(0b_1110_0000 | (ordinal >> 12)))); - bytes.Add(unchecked((byte)(0b_1000_0000 | ((ordinal >> 6) & 0b_0011_1111)))); - bytes.Add(unchecked((byte)(0b_1000_0000 | (ordinal & 0b_0011_1111)))); - } + return Encoding.UTF8.GetBytes(text.ToString()); } private static class Generators diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index f93f00dde12..4843dd2755b 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -1024,15 +1024,29 @@ public void WriteDoubleFormatsNaN() } [Fact] - public void WriteUnicodeStringPreservesUtf16CodeUnitEncoding() + public void WriteUnicodeStringEncodesSurrogatePairsAsUtf8ScalarValues() { const string value = "rocket:\uD83D\uDE80"; var buffer = new byte[128]; var cursor = PrometheusSerializer.WriteUnicodeString(buffer, 0, value); var actual = ToHexString(buffer, cursor); + var expected = ToHexString(Encoding.UTF8.GetBytes(value), Encoding.UTF8.GetByteCount(value)); - Assert.Equal("726F636B65743AEDA0BDEDBA80", actual); + Assert.Equal(expected, actual); + } + + [Fact] + public void WriteUnicodeStringReplacesInvalidSurrogates() + { + const string value = "rocket:\uD83D"; + var buffer = new byte[128]; + + var cursor = PrometheusSerializer.WriteUnicodeString(buffer, 0, value); + var actual = ToHexString(buffer, cursor); + var expected = ToHexString(Encoding.UTF8.GetBytes(value), Encoding.UTF8.GetByteCount(value)); + + Assert.Equal(expected, actual); } #if NET From 140da7a95476260b7c51be44d2d2f7df42c6b78a Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 22:42:37 +0100 Subject: [PATCH 13/19] [Exporter.Prometheus] Fix tests Fix broken UTF-8 tests. --- .../Internal/PrometheusSerializer.cs | 118 ++++++++++-------- .../PrometheusSerializerTests.cs | 4 +- 2 files changed, 69 insertions(+), 53 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 9171ac2f558..fd475f86c85 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -148,26 +148,7 @@ public static int WriteUnicodeString(byte[] buffer, int cursor, string value) #if NET return WriteEscapedUtf8String(buffer, cursor, value.AsSpan(), UnicodeEscapeChars); #else - for (var i = 0; i < value.Length; i++) - { - var ordinal = (ushort)value[i]; - switch (ordinal) - { - case ASCII_REVERSE_SOLIDUS: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - break; - case ASCII_LINEFEED: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = unchecked((byte)'n'); - break; - default: - cursor = WriteUnicodeNoEscape(buffer, cursor, ordinal); - break; - } - } - - return cursor; + return WriteEscapedUtf16String(buffer, cursor, value, escapeQuotationMarks: false); #endif } @@ -200,30 +181,7 @@ public static int WriteLabelValue(byte[] buffer, int cursor, string value) #if NET return WriteEscapedUtf8String(buffer, cursor, value.AsSpan(), LabelValueEscapeChars); #else - for (var i = 0; i < value.Length; i++) - { - var ordinal = (ushort)value[i]; - switch (ordinal) - { - case ASCII_QUOTATION_MARK: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = ASCII_QUOTATION_MARK; - break; - case ASCII_REVERSE_SOLIDUS: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - break; - case ASCII_LINEFEED: - buffer[cursor++] = ASCII_REVERSE_SOLIDUS; - buffer[cursor++] = unchecked((byte)'n'); - break; - default: - cursor = WriteUnicodeNoEscape(buffer, cursor, ordinal); - break; - } - } - - return cursor; + return WriteEscapedUtf16String(buffer, cursor, value, escapeQuotationMarks: true); #endif } @@ -529,6 +487,34 @@ private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan value, out int charsConsumed) + { + const int UnicodeReplacementCharacter = 0xFFFD; + + var character = value[0]; + + if (char.IsHighSurrogate(character)) + { + if (value.Length > 1 && char.IsLowSurrogate(value[1])) + { + charsConsumed = 2; + return char.ConvertToUtf32(character, value[1]); + } + + charsConsumed = 1; + return UnicodeReplacementCharacter; + } + + if (char.IsLowSurrogate(character)) + { + charsConsumed = 1; + return UnicodeReplacementCharacter; + } + + charsConsumed = 1; + return character; + } + #if NET private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan value) => cursor + System.Text.Encoding.UTF8.GetBytes(value, buffer.AsSpan(cursor)); @@ -556,27 +542,57 @@ private static int WriteEscapedUtf8String(byte[] buffer, int cursor, ReadOnlySpa value = value[specialIndex..]; } - var ordinal = (ushort)value[0]; - switch (ordinal) + switch (value[0]) { - case ASCII_QUOTATION_MARK: + case '"': buffer[cursor++] = ASCII_REVERSE_SOLIDUS; buffer[cursor++] = ASCII_QUOTATION_MARK; + value = value[1..]; break; - case ASCII_REVERSE_SOLIDUS: + case '\\': buffer[cursor++] = ASCII_REVERSE_SOLIDUS; buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + value = value[1..]; break; - case ASCII_LINEFEED: + case '\n': buffer[cursor++] = ASCII_REVERSE_SOLIDUS; buffer[cursor++] = unchecked((byte)'n'); + value = value[1..]; break; default: - cursor = WriteUnicodeNoEscape(buffer, cursor, ordinal); + cursor = WriteUnicodeNoEscape(buffer, cursor, GetUnicodeOrdinal(value, out var charsConsumed)); + value = value[charsConsumed..]; break; } + } - value = value[1..]; + return cursor; + } +#else + private static int WriteEscapedUtf16String(byte[] buffer, int cursor, string value, bool escapeQuotationMarks) + { + for (var i = 0; i < value.Length; i++) + { + var character = value[i]; + switch (character) + { + case '"' when escapeQuotationMarks: + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = ASCII_QUOTATION_MARK; + break; + case '\\': + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + break; + case '\n': + buffer[cursor++] = ASCII_REVERSE_SOLIDUS; + buffer[cursor++] = unchecked((byte)'n'); + break; + default: + cursor = WriteUnicodeNoEscape(buffer, cursor, GetUnicodeOrdinal(value.AsSpan(i), out var charsConsumed)); + i += charsConsumed - 1; + break; + } } return cursor; diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 4843dd2755b..60cd749b98f 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -852,14 +852,14 @@ public void WriteLabelValueObjectFormatsNonAsciiStringsUtf8Strings(string value) } [Fact] - public void WriteLabelValueObjectPreservesEmojiUtf16CodeUnitEncoding() + public void WriteLabelValueObjectEncodesEmojiAsUtf8ScalarValues() { const string value = "rocket:\uD83D\uDE80"; var buffer = new byte[128]; var cursor = PrometheusSerializer.WriteLabelValue(buffer, 0, (object)value); - Assert.Equal("726F636B65743AEDA0BDEDBA80", ToHexString(buffer, cursor)); + Assert.Equal(ToHexString(Encoding.UTF8.GetBytes(value), Encoding.UTF8.GetByteCount(value)), ToHexString(buffer, cursor)); } [Fact] From 2445d2aca6b3c8e8736dc144f807ae1bef6184a9 Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 22:56:06 +0100 Subject: [PATCH 14/19] [Exporter.Prometheus] Reduce duplication Factor away some duplicated code paths. --- .../Internal/PrometheusSerializer.cs | 59 +++++++++---------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index fd475f86c85..f7317b56bfe 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -144,13 +144,7 @@ public static int WriteUnicodeNoEscape(byte[] buffer, int cursor, int ordinal) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteUnicodeString(byte[] buffer, int cursor, string value) - { -#if NET - return WriteEscapedUtf8String(buffer, cursor, value.AsSpan(), UnicodeEscapeChars); -#else - return WriteEscapedUtf16String(buffer, cursor, value, escapeQuotationMarks: false); -#endif - } + => WriteEscapedString(buffer, cursor, value, escapeQuotationMarks: false); [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteLabelKey(byte[] buffer, int cursor, string value) @@ -177,13 +171,7 @@ public static int WriteLabelKey(byte[] buffer, int cursor, string value) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteLabelValue(byte[] buffer, int cursor, string value) - { -#if NET - return WriteEscapedUtf8String(buffer, cursor, value.AsSpan(), LabelValueEscapeChars); -#else - return WriteEscapedUtf16String(buffer, cursor, value, escapeQuotationMarks: true); -#endif - } + => WriteEscapedString(buffer, cursor, value, escapeQuotationMarks: true); [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteLabelValue(byte[] buffer, int cursor, object? value) @@ -264,27 +252,11 @@ public static int WriteLabel(byte[] buffer, int cursor, string labelKey, object? [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteMetricName(byte[] buffer, int cursor, PrometheusMetric metric, bool openMetricsRequested) - { - // Metric name has already been escaped. - var name = openMetricsRequested ? metric.OpenMetricsName : metric.Name; - var nameBytes = openMetricsRequested ? metric.OpenMetricsNameBytes : metric.NameBytes; - - Debug.Assert(!string.IsNullOrWhiteSpace(name), "name was null or whitespace"); - - return WriteUtf8NoEscape(buffer, cursor, nameBytes); - } + => WriteCachedMetricName(buffer, cursor, GetMetricName(metric, openMetricsRequested)); [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteMetricMetadataName(byte[] buffer, int cursor, PrometheusMetric metric, bool openMetricsRequested) - { - // Metric name has already been escaped. - var name = openMetricsRequested ? metric.OpenMetricsMetadataName : metric.Name; - var nameBytes = openMetricsRequested ? metric.OpenMetricsMetadataNameBytes : metric.NameBytes; - - Debug.Assert(!string.IsNullOrWhiteSpace(name), "name was null or whitespace"); - - return WriteUtf8NoEscape(buffer, cursor, nameBytes); - } + => WriteCachedMetricName(buffer, cursor, GetMetricMetadataName(metric, openMetricsRequested)); [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int WriteEof(byte[] buffer, int cursor) @@ -465,6 +437,13 @@ public static int WriteTargetInfo(byte[] buffer, int cursor, Resource resource) return cursor; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int WriteCachedMetricName(byte[] buffer, int cursor, KeyValuePair name) + { + Debug.Assert(!string.IsNullOrWhiteSpace(name.Key), "name was null or whitespace"); + return WriteUtf8NoEscape(buffer, cursor, name.Value); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool IsAsciiDigit(char value) => #if NET @@ -487,6 +466,16 @@ private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan GetMetricName(PrometheusMetric metric, bool openMetricsRequested) => + openMetricsRequested + ? new(metric.OpenMetricsName, metric.OpenMetricsNameBytes) + : new(metric.Name, metric.NameBytes); + + private static KeyValuePair GetMetricMetadataName(PrometheusMetric metric, bool openMetricsRequested) => + openMetricsRequested + ? new(metric.OpenMetricsMetadataName, metric.OpenMetricsMetadataNameBytes) + : new(metric.Name, metric.NameBytes); + private static int GetUnicodeOrdinal(ReadOnlySpan value, out int charsConsumed) { const int UnicodeReplacementCharacter = 0xFFFD; @@ -516,6 +505,9 @@ private static int GetUnicodeOrdinal(ReadOnlySpan value, out int charsCons } #if NET + private static int WriteEscapedString(byte[] buffer, int cursor, string value, bool escapeQuotationMarks) + => WriteEscapedUtf8String(buffer, cursor, value.AsSpan(), escapeQuotationMarks ? LabelValueEscapeChars : UnicodeEscapeChars); + private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan value) => cursor + System.Text.Encoding.UTF8.GetBytes(value, buffer.AsSpan(cursor)); @@ -569,6 +561,9 @@ private static int WriteEscapedUtf8String(byte[] buffer, int cursor, ReadOnlySpa return cursor; } #else + private static int WriteEscapedString(byte[] buffer, int cursor, string value, bool escapeQuotationMarks) + => WriteEscapedUtf16String(buffer, cursor, value, escapeQuotationMarks); + private static int WriteEscapedUtf16String(byte[] buffer, int cursor, string value, bool escapeQuotationMarks) { for (var i = 0; i < value.Length; i++) From fbbbc0642cb8cfcd9c3d2a6f44234089475435b1 Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 27 Apr 2026 23:01:10 +0100 Subject: [PATCH 15/19] [Exporter.Prometheus] Refactoring Tidy up some of the formatting. --- .../Internal/PrometheusSerializer.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index f7317b56bfe..2ab9c4ce610 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -378,7 +378,13 @@ public static int WriteTimestamp(byte[] buffer, int cursor, long value, bool use } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static int WriteTags(byte[] buffer, int cursor, PrometheusMetric? prometheusMetric, Metric metric, ReadOnlyTagCollection tags, bool writeEnclosingBraces = true) + public static int WriteTags( + byte[] buffer, + int cursor, + PrometheusMetric? prometheusMetric, + Metric metric, + ReadOnlyTagCollection tags, + bool writeEnclosingBraces = true) { if (writeEnclosingBraces) { @@ -508,8 +514,8 @@ private static int GetUnicodeOrdinal(ReadOnlySpan value, out int charsCons private static int WriteEscapedString(byte[] buffer, int cursor, string value, bool escapeQuotationMarks) => WriteEscapedUtf8String(buffer, cursor, value.AsSpan(), escapeQuotationMarks ? LabelValueEscapeChars : UnicodeEscapeChars); - private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan value) => - cursor + System.Text.Encoding.UTF8.GetBytes(value, buffer.AsSpan(cursor)); + private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan value) + => cursor + System.Text.Encoding.UTF8.GetBytes(value, buffer.AsSpan(cursor)); private static int WriteEscapedUtf8String(byte[] buffer, int cursor, ReadOnlySpan value, SearchValues escapedChars) { From 5fe748080a9f82830990403ef7988135800c9667 Mon Sep 17 00:00:00 2001 From: martincostello Date: Tue, 28 Apr 2026 17:44:09 +0100 Subject: [PATCH 16/19] [Exporter.Prometheus] Polyfill char methods Add polyfills for `char`'s `IsAsciiDigit`, `IsAsciiLetterOrDigit` and `IsAsciiLetterLower` methods and remove helpers. --- OpenTelemetry.slnx | 1 + .../Propagation/TraceContextPropagator.cs | 12 ++++---- .../Context/Propagation/TraceStateUtils.cs | 8 ++++-- .../OpenTelemetry.Api.csproj | 1 + ...etry.Exporter.Prometheus.AspNetCore.csproj | 1 + .../Internal/PrometheusMetric.cs | 23 ++------------- .../Internal/PrometheusSerializer.cs | 24 +++------------- ...ry.Exporter.Prometheus.HttpListener.csproj | 1 + src/Shared/CharExtensions.cs | 28 +++++++++++++++++++ 9 files changed, 50 insertions(+), 49 deletions(-) create mode 100644 src/Shared/CharExtensions.cs diff --git a/OpenTelemetry.slnx b/OpenTelemetry.slnx index 5a807a98bae..25ef6a6bf9b 100644 --- a/OpenTelemetry.slnx +++ b/OpenTelemetry.slnx @@ -146,6 +146,7 @@ + diff --git a/src/OpenTelemetry.Api/Context/Propagation/TraceContextPropagator.cs b/src/OpenTelemetry.Api/Context/Propagation/TraceContextPropagator.cs index 7c9ce6fe3c9..237e2897b8e 100644 --- a/src/OpenTelemetry.Api/Context/Propagation/TraceContextPropagator.cs +++ b/src/OpenTelemetry.Api/Context/Propagation/TraceContextPropagator.cs @@ -544,7 +544,7 @@ private static bool TryExtractSingleTracestate(string tracestate, out string tra } private static byte HexCharToByte(char c) - => c is >= '0' and <= '9' + => char.IsAsciiDigit(c) ? (byte)(c - '0') : c is >= 'a' and <= 'f' ? (byte)(c - 'a' + 10) @@ -634,7 +634,7 @@ private static bool ValidateKey(ReadOnlySpan key) // (There is an inconsistency in the expression above and the description in note. // Here is following the description in note: // "Identifiers MUST begin with a lowercase letter or a digit.") - if (!IsLowerAlphaDigit(key[0])) + if (!IsAsciiLetterOrDigitLower(key[0])) { return false; } @@ -649,7 +649,7 @@ private static bool ValidateKey(ReadOnlySpan key) break; } - if (!(IsLowerAlphaDigit(ch) + if (!(IsAsciiLetterOrDigitLower(ch) || ch == '_' || ch == '-' || ch == '*' @@ -681,7 +681,7 @@ private static bool ValidateKey(ReadOnlySpan key) for (var i = tenantLength + 1; i < key.Length; ++i) { var ch = key[i]; - if (!(IsLowerAlphaDigit(ch) + if (!(IsAsciiLetterOrDigitLower(ch) || ch == '_' || ch == '-' || ch == '*' @@ -719,8 +719,8 @@ private static bool ValidateValue(ReadOnlySpan value) } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsLowerAlphaDigit(char c) - => c is (>= '0' and <= '9') or (>= 'a' and <= 'z'); + private static bool IsAsciiLetterOrDigitLower(char c) + => char.IsAsciiDigit(c) || char.IsAsciiLetterLower(c); #if NET private static void WriteTraceParentIntoSpan(Span destination, ActivityContext context) diff --git a/src/OpenTelemetry.Api/Context/Propagation/TraceStateUtils.cs b/src/OpenTelemetry.Api/Context/Propagation/TraceStateUtils.cs index 1a590ee4f77..da139f12a17 100644 --- a/src/OpenTelemetry.Api/Context/Propagation/TraceStateUtils.cs +++ b/src/OpenTelemetry.Api/Context/Propagation/TraceStateUtils.cs @@ -204,7 +204,7 @@ private static bool ValidateKey(ReadOnlySpan key) if (key.IsEmpty || key.Length > KeyMaxSize - || (!(key[i] >= 'a' && key[i] <= 'z'))) + || !char.IsAsciiLetterLower(key[i])) { return false; } @@ -220,7 +220,8 @@ private static bool ValidateKey(ReadOnlySpan key) break; } - if (c is not (>= 'a' and <= 'z') and not (>= '0' and <= '9') and not '_' + if (!(char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c)) && + c is not '_' and not '-' and not '*' and not '/') @@ -244,7 +245,8 @@ and not '*' { var c = key[i]; - if (c is not (>= 'a' and <= 'z') and not (>= '0' and <= '9') and not '_' + if (!(char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c)) && + c is not '_' and not '-' and not '*' and not '/') diff --git a/src/OpenTelemetry.Api/OpenTelemetry.Api.csproj b/src/OpenTelemetry.Api/OpenTelemetry.Api.csproj index 9727c62ae6c..93444af663d 100644 --- a/src/OpenTelemetry.Api/OpenTelemetry.Api.csproj +++ b/src/OpenTelemetry.Api/OpenTelemetry.Api.csproj @@ -12,6 +12,7 @@ + diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj index 1979137998b..9bb0d4044bb 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj +++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj @@ -31,6 +31,7 @@ + diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs index 5d99ce842ff..54335ca0bf2 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusMetric.cs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; using System.Text; using OpenTelemetry.Metrics; @@ -116,7 +115,7 @@ internal static string SanitizeMetricUnit(string metricUnit) { var c = metricUnit[i]; - if (!IsAsciiLetterOrDigit(c) && c != ':') + if (!char.IsAsciiLetterOrDigit(c) && c != ':') { if (!lastCharUnderscore) { @@ -145,7 +144,7 @@ internal static string SanitizeMetricName(string metricName) { var c = metricName[i]; - if (i == 0 && IsAsciiDigit(c)) + if (i == 0 && char.IsAsciiDigit(c)) { sb ??= CreateStringBuilder(metricName); sb.Append('_'); @@ -153,7 +152,7 @@ internal static string SanitizeMetricName(string metricName) continue; } - if (!IsAsciiLetterOrDigit(c) && c != ':') + if (!char.IsAsciiLetterOrDigit(c) && c != ':') { if (!lastCharUnderscore) { @@ -245,22 +244,6 @@ UpDownCounter becomes gauge }; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsAsciiDigit(char value) => -#if NET - char.IsAsciiDigit(value); -#else - value is >= '0' and <= '9'; -#endif - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsAsciiLetterOrDigit(char value) => -#if NET - char.IsAsciiLetterOrDigit(value); -#else - value is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') || IsAsciiDigit(value); -#endif - private static string SanitizeOpenMetricsName(string metricName) => metricName.EndsWith("_total", StringComparison.Ordinal) ? metricName.Substring(0, metricName.Length - 6) : metricName; diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 2ab9c4ce610..fea9e16c9a1 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -155,7 +155,7 @@ public static int WriteLabelKey(byte[] buffer, int cursor, string value) return cursor; } - if (IsAsciiDigit(value[0])) + if (char.IsAsciiDigit(value[0])) { buffer[cursor++] = unchecked((byte)'_'); } @@ -163,7 +163,7 @@ public static int WriteLabelKey(byte[] buffer, int cursor, string value) for (var i = 0; i < value.Length; i++) { var ch = value[i]; - buffer[cursor++] = IsAsciiLetterOrDigit(ch) ? (byte)ch : (byte)'_'; + buffer[cursor++] = char.IsAsciiLetterOrDigit(ch) ? (byte)ch : (byte)'_'; } return cursor; @@ -450,22 +450,6 @@ private static int WriteCachedMetricName(byte[] buffer, int cursor, KeyValuePair return WriteUtf8NoEscape(buffer, cursor, name.Value); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsAsciiDigit(char value) => -#if NET - char.IsAsciiDigit(value); -#else - value is >= '0' and <= '9'; -#endif - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsAsciiLetterOrDigit(char value) => -#if NET - char.IsAsciiLetterOrDigit(value); -#else - value is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9'); -#endif - private static int WriteUtf8NoEscape(byte[] buffer, int cursor, ReadOnlySpan value) { value.CopyTo(buffer.AsSpan(cursor)); @@ -718,7 +702,7 @@ private static bool TryWriteLabelKey(Span buffer, ref int cursor, string v return TryWriteByte(buffer, ref cursor, unchecked((byte)'_')); } - if (IsAsciiDigit(value[0]) && + if (char.IsAsciiDigit(value[0]) && !TryWriteByte(buffer, ref cursor, unchecked((byte)'_'))) { return false; @@ -727,7 +711,7 @@ private static bool TryWriteLabelKey(Span buffer, ref int cursor, string v for (var i = 0; i < value.Length; i++) { var ch = value[i]; - var sanitizedByte = IsAsciiLetterOrDigit(value[i]) ? (byte)ch : (byte)'_'; + var sanitizedByte = char.IsAsciiLetterOrDigit(value[i]) ? (byte)ch : (byte)'_'; if (!TryWriteByte(buffer, ref cursor, sanitizedByte)) { diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/OpenTelemetry.Exporter.Prometheus.HttpListener.csproj b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/OpenTelemetry.Exporter.Prometheus.HttpListener.csproj index db5c155c77b..18268381e0e 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/OpenTelemetry.Exporter.Prometheus.HttpListener.csproj +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/OpenTelemetry.Exporter.Prometheus.HttpListener.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Shared/CharExtensions.cs b/src/Shared/CharExtensions.cs new file mode 100644 index 00000000000..acd9f9c18bf --- /dev/null +++ b/src/Shared/CharExtensions.cs @@ -0,0 +1,28 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if !NET + +using System.Runtime.CompilerServices; + +namespace System; + +internal static class CharExtensions +{ + extension(char) + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsAsciiDigit(char value) => + value is >= '0' and <= '9'; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsAsciiLetterOrDigit(char value) => + value is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9'); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsAsciiLetterLower(char value) => + value is >= 'a' and <= 'z'; + } +} + +#endif From 29784aeb61cacdfea02997f3f60efe7faf9531cb Mon Sep 17 00:00:00 2001 From: martincostello Date: Tue, 28 Apr 2026 21:28:06 +0100 Subject: [PATCH 17/19] [Exporter.Prometheus] Address feedback - Throw if buffer is too small. - Fix inconsistent handling of formatting and Unicode. --- .../Internal/PrometheusSerializer.cs | 44 ++++++++------- .../PrometheusSerializerTests.cs | 54 +++++++++++++++++++ 2 files changed, 78 insertions(+), 20 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index fea9e16c9a1..034ff238702 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -41,9 +41,7 @@ public static int WriteDouble(byte[] buffer, int cursor, double value) // digits are required for full precision, e.g. printf("%.17g", d). #if NET var result = Utf8Formatter.TryFormat(value, buffer.AsSpan(cursor), out var bytesWritten, new StandardFormat('G', 17)); - Debug.Assert(result, $"{nameof(result)} should be true."); - - cursor += bytesWritten; + cursor = AdvanceCursorOrThrow(result, cursor, bytesWritten); #else cursor = WriteAsciiStringNoEscape(buffer, cursor, value.ToString("G17", CultureInfo.InvariantCulture)); #endif @@ -71,9 +69,7 @@ public static int WriteLong(byte[] buffer, int cursor, long value) { #if NET var result = Utf8Formatter.TryFormat(value, buffer.AsSpan(cursor), out var bytesWritten); - Debug.Assert(result, $"{nameof(result)} should be true."); - - cursor += bytesWritten; + cursor = AdvanceCursorOrThrow(result, cursor, bytesWritten); #else cursor = WriteAsciiStringNoEscape(buffer, cursor, value.ToString(CultureInfo.InvariantCulture)); #endif @@ -86,9 +82,7 @@ public static int WriteUnsignedLong(byte[] buffer, int cursor, ulong value) { #if NET var result = Utf8Formatter.TryFormat(value, buffer.AsSpan(cursor), out var bytesWritten); - Debug.Assert(result, $"{nameof(result)} should be true."); - - cursor += bytesWritten; + cursor = AdvanceCursorOrThrow(result, cursor, bytesWritten); #else cursor = WriteAsciiStringNoEscape(buffer, cursor, value.ToString(CultureInfo.InvariantCulture)); #endif @@ -220,8 +214,7 @@ public static int WriteLabelValue(byte[] buffer, int cursor, object? value) case decimal decimalValue: #if NET var result = Utf8Formatter.TryFormat(decimalValue, buffer.AsSpan(cursor), out var bytesWritten); - Debug.Assert(result, $"{nameof(result)} should be true."); - return cursor + bytesWritten; + return AdvanceCursorOrThrow(result, cursor, bytesWritten); #else return WriteLabelValue(buffer, cursor, decimalValue.ToString(CultureInfo.InvariantCulture)); #endif @@ -788,10 +781,9 @@ private static bool TryWriteLabelValue(Span buffer, ref int cursor, string { for (var i = 0; i < value.Length; i++) { - var ordinal = (ushort)value[i]; - switch (ordinal) + switch (value[i]) { - case ASCII_QUOTATION_MARK: + case '"': if (!TryWriteByte(buffer, ref cursor, ASCII_REVERSE_SOLIDUS) || !TryWriteByte(buffer, ref cursor, ASCII_QUOTATION_MARK)) { @@ -799,7 +791,7 @@ private static bool TryWriteLabelValue(Span buffer, ref int cursor, string } break; - case ASCII_REVERSE_SOLIDUS: + case '\\': if (!TryWriteByte(buffer, ref cursor, ASCII_REVERSE_SOLIDUS) || !TryWriteByte(buffer, ref cursor, ASCII_REVERSE_SOLIDUS)) { @@ -807,7 +799,7 @@ private static bool TryWriteLabelValue(Span buffer, ref int cursor, string } break; - case ASCII_LINEFEED: + case '\n': if (!TryWriteByte(buffer, ref cursor, ASCII_REVERSE_SOLIDUS) || !TryWriteByte(buffer, ref cursor, unchecked((byte)'n'))) { @@ -816,11 +808,12 @@ private static bool TryWriteLabelValue(Span buffer, ref int cursor, string break; default: - if (!TryWriteUnicodeNoEscape(buffer, ref cursor, ordinal)) + if (!TryWriteUnicodeNoEscape(buffer, ref cursor, GetUnicodeOrdinal(value.AsSpan(i), out var charsConsumed))) { return false; } + i += charsConsumed - 1; break; } } @@ -869,7 +862,7 @@ private static bool TryWriteDouble(Span buffer, ref int cursor, double val { if (MathHelper.IsFinite(value)) { - if (!Utf8Formatter.TryFormat(value, buffer[cursor..], out var bytesWritten, new StandardFormat('G'))) + if (!Utf8Formatter.TryFormat(value, buffer[cursor..], out var bytesWritten, new StandardFormat('G', 17))) { return false; } @@ -884,7 +877,7 @@ private static bool TryWriteDouble(Span buffer, ref int cursor, double val double.IsPositiveInfinity(value) ? "+Inf" : double.IsNegativeInfinity(value) ? "-Inf" : "NaN"); } - private static bool TryWriteUnicodeNoEscape(Span buffer, ref int cursor, ushort ordinal) + private static bool TryWriteUnicodeNoEscape(Span buffer, ref int cursor, int ordinal) { if (ordinal <= 0x7F) { @@ -895,8 +888,15 @@ private static bool TryWriteUnicodeNoEscape(Span buffer, ref int cursor, u return TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1100_0000 | (ordinal >> 6)))) && TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1000_0000 | (ordinal & 0b_0011_1111)))); } + else if (ordinal <= 0xFFFF) + { + return TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1110_0000 | (ordinal >> 12)))) && + TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1000_0000 | ((ordinal >> 6) & 0b_0011_1111)))) && + TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1000_0000 | (ordinal & 0b_0011_1111)))); + } - return TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1110_0000 | (ordinal >> 12)))) && + return TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1111_0000 | (ordinal >> 18)))) && + TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1000_0000 | ((ordinal >> 12) & 0b_0011_1111)))) && TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1000_0000 | ((ordinal >> 6) & 0b_0011_1111)))) && TryWriteByte(buffer, ref cursor, unchecked((byte)(0b_1000_0000 | (ordinal & 0b_0011_1111)))); } @@ -924,6 +924,10 @@ private static bool TryWriteByte(Span buffer, ref int cursor, byte value) buffer[cursor++] = value; return true; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int AdvanceCursorOrThrow(bool result, int cursor, int bytesWritten) => + result ? cursor + bytesWritten : throw new ArgumentException("Destination buffer too small."); #endif private static string MapPrometheusType(PrometheusType type) => type switch diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs index 27f04d54d4a..e16af82055f 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusSerializerTests.cs @@ -829,6 +829,40 @@ public void WriteLabelValueEscapesSpecialCharacters() Assert.Equal("\\\"line1\\\\\\nline2\\\"", Encoding.UTF8.GetString(buffer, 0, cursor)); } +#if NET + [Fact] + public void WriteLongThrowsArgumentExceptionWhenBufferTooSmall() + { + var buffer = new byte[2]; + + Assert.Throws(() => PrometheusSerializer.WriteLong(buffer, 0, 1234)); + } + + [Fact] + public void WriteUnsignedLongThrowsArgumentExceptionWhenBufferTooSmall() + { + var buffer = new byte[2]; + + Assert.Throws(() => PrometheusSerializer.WriteUnsignedLong(buffer, 0, 1234)); + } + + [Fact] + public void WriteDoubleThrowsArgumentExceptionWhenBufferTooSmall() + { + var buffer = new byte[2]; + + Assert.Throws(() => PrometheusSerializer.WriteDouble(buffer, 0, 1234.5)); + } + + [Fact] + public void WriteLabelValueDecimalThrowsArgumentExceptionWhenBufferTooSmall() + { + var buffer = new byte[2]; + + Assert.Throws(() => PrometheusSerializer.WriteLabelValue(buffer, 0, 1234.5m)); + } +#endif + [Theory] [InlineData(true, "true")] [InlineData(false, "false")] @@ -985,6 +1019,26 @@ public void WriteMetricSerializesStaticMeterTagsUsingInvariantCulture() } } + [Fact] + public void WriteMetricSerializesStaticMeterTagDoubleUsingG17() + { + const double value = 0.84551240822557006d; + + var output = WriteGaugeMetricWithMeterTags(new KeyValuePair("double_value", value)); + + Assert.Contains($"double_value=\"{value.ToString("G17", CultureInfo.InvariantCulture)}\"", output, StringComparison.Ordinal); + } + + [Fact] + public void WriteMetricSerializesStaticMeterTagEmojiAsUtf8ScalarValues() + { + const string value = "rocket:\uD83D\uDE80"; + + var output = WriteGaugeMetricWithMeterTags(new KeyValuePair("emoji_value", value)); + + Assert.Contains($"emoji_value=\"{value}\"", output, StringComparison.Ordinal); + } + [Fact] public void WriteLabelValueObjectFallsBackToToString() { From b46cb1990c753bd57b7072bc925deb20b85afcda Mon Sep 17 00:00:00 2001 From: martincostello Date: Tue, 28 Apr 2026 21:32:04 +0100 Subject: [PATCH 18/19] [Exporter.Prometheus] Update comments Update TODO comments related to JSON. --- .../Internal/PrometheusSerializer.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs index 034ff238702..c9ba4cad31a 100644 --- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs +++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusSerializer.cs @@ -222,7 +222,8 @@ public static int WriteLabelValue(byte[] buffer, int cursor, object? value) case IFormattable formattableValue: return WriteLabelValue(buffer, cursor, formattableValue.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty); - // TODO: Attribute values should be written as their JSON representation. Extra logic may need to be added here to correctly convert other .NET types. + // Attribute values should be written as their JSON representation. + // Extra logic may need to be added here to correctly convert other .NET types. // More detail: https://github.com/open-telemetry/opentelemetry-dotnet/issues/4822#issuecomment-1707328495 default: return WriteLabelValue(buffer, cursor, value.ToString() ?? string.Empty); @@ -770,7 +771,8 @@ private static bool TryWriteLabelValue(Span buffer, ref int cursor, object case IFormattable formattableValue: return TryWriteLabelValue(buffer, ref cursor, formattableValue.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty); - // TODO: Attribute values should be written as their JSON representation. Extra logic may need to be added here to correctly convert other .NET types. + // Attribute values should be written as their JSON representation. + // Extra logic may need to be added here to correctly convert other .NET types. // More detail: https://github.com/open-telemetry/opentelemetry-dotnet/issues/4822#issuecomment-1707328495 default: return TryWriteLabelValue(buffer, ref cursor, value.ToString() ?? string.Empty); From c24c7ed0f2800b83b24ff3cf3ef10af520921224 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 1 May 2026 10:40:47 +0100 Subject: [PATCH 19/19] [Exporter.Prometheus] Simplify test Use `Task.WaitAsync()`. --- .../PrometheusCollectionManagerTests.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs index f999e7cf222..3ebad31ad17 100644 --- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs +++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs @@ -103,11 +103,7 @@ async Task[]> CollectInParallelAsync(bool advanceClock) cts.Token, async (_, _) => bag.Add(await CollectAsync(advanceClock))); - await Task.WhenAny(parallel, Task.Delay(testTimeout, cts.Token)); - - cts.Token.ThrowIfCancellationRequested(); - - await parallel; + await parallel.WaitAsync(testTimeout, cts.Token); return [.. bag.Select((r) => Task.FromResult(r))]; #else