Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
4883775
[Exporter.Prometheus] Avoid struct copies
martincostello Jun 21, 2026
3cd1eba
[Exporter.Prometheus] Add dots and values escaping
martincostello Jun 21, 2026
977cac7
[Exporter.Prometheus] Fix sorting
martincostello Jun 21, 2026
8549b36
[Exporter.Prometheus] Address feedback
martincostello Jun 21, 2026
f42506e
[Exporter.Prometheus] Update test coverage
martincostello Jun 21, 2026
190a68b
[Exporter.Prometheus] Extend tests
martincostello Jun 21, 2026
c0844b9
[Exporter.Prometheus] Add allow-utf-8 escaping
martincostello Jun 21, 2026
7ff2f62
[Exporter.Prometheus] Update CHANGELOGs
martincostello Jun 21, 2026
d36104e
[Exporter.Prometheus] Extend test coverage
martincostello Jun 21, 2026
51aff69
[Exporter.Prometheus] Add new benchmark
martincostello Jun 21, 2026
0b7bd2b
[Exporter.Prometheus] Reduce allocations
martincostello Jun 21, 2026
7baa8e0
[Exporter.Prometheus] Wrap lines
martincostello Jun 21, 2026
3913d6d
[Exporter.Prometheus] Further reduce allocations
martincostello Jun 21, 2026
d434d3b
Merge branch 'main' into gh-7246
martincostello Jun 21, 2026
f93ac58
Merge branch 'main' into gh-7246-allow-utf-8
martincostello Jun 21, 2026
f79b46b
Merge branch 'main' into gh-7246
martincostello Jun 22, 2026
3a6995a
Merge branch 'main' into gh-7246-allow-utf-8
martincostello Jun 22, 2026
dbe44d4
Merge remote-tracking branch 'origin/main' into gh-7246
martincostello Jun 24, 2026
d80f412
Merge branch 'gh-7246' into gh-7246-allow-utf-8
martincostello Jun 24, 2026
8c8a028
[Exporter.Prometheus] Fix CHANGELOGs
martincostello Jun 24, 2026
e4bf711
[Exporter.Prometheus] Address feedback
martincostello Jun 24, 2026
f86a8c0
Merge branch 'main' into gh-7246
martincostello Jun 29, 2026
c7bbf26
[Exporter.Prometheus] Fix merge
martincostello Jun 29, 2026
3fb26e8
[Exporter.Prometheus] Address feedback
martincostello Jun 29, 2026
de68faf
[Exporter.Prometheus] Fix typo
martincostello Jun 29, 2026
249e431
Merge branch 'gh-7246' into gh-7246-allow-utf-8
martincostello Jun 29, 2026
7c2480e
[Exporter.Prometheus] Fix SA1202
martincostello Jun 29, 2026
7d946c4
Merge branch 'main' into gh-7246-allow-utf-8
martincostello Jun 30, 2026
3c9419f
[Exporter.Prometheus] Fix merge issues
martincostello Jun 30, 2026
5acc088
Merge branch 'main' into gh-7246-allow-utf-8
martincostello Jun 30, 2026
623180b
[Exporter.Prometheus] Address feedback
martincostello Jun 30, 2026
2be4a4a
Merge branch 'main' into gh-7246-allow-utf-8
martincostello Jun 30, 2026
eb3a8f0
[Exporter.Prometheus] Address feedback
martincostello Jul 2, 2026
9d8a9f3
[Exporter.Prometheus] Refactor test
martincostello Jul 2, 2026
5bfff05
[Exporter.Prometheus] Add UTF-8 label test
martincostello Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ Notes](../../RELEASENOTES.md).
disable the `target_info` metric in Prometheus metrics. Defaults to `true`.
([#7438](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7438))

* Added support for the `allow-utf-8` Prometheus UTF-8 name escaping scheme
when negotiated via the `Accept` header.
([#7440](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7440))

## 1.16.0-beta.1

Released 2026-Jun-10
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,24 +248,11 @@ private static bool TryParse(

if (escapedValue == null || !supportedEscapingSchemes.Contains(escapedValue))
{
// TODO Support other escaping schemes, including at least "allow-utf-8".
// For now we treat "allow-utf-8" as if it were "underscores" to avoid fallback
// to PrometheusText0.0.4 where it would previously match to OpenMetricsText1.0.0.
// See https://github.com/open-telemetry/opentelemetry-dotnet/issues/7246.
if (string.Equals(escapedValue, PrometheusProtocol.AllowUtf8Escaping, StringComparison.Ordinal))
{
escaping = PrometheusProtocol.UnderscoresEscaping;
}
else
{
// Unsupported escaping scheme
return false;
}
}
else
{
escaping = escapedValue;
// Unsupported escaping scheme
return false;
}

escaping = escapedValue;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ Notes](../../RELEASENOTES.md).
* Added the `PrometheusHttpListenerOptions.ConfigureHttpListener` option.
([#7448](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7448))

* Added support for the `allow-utf-8` Prometheus UTF-8 name escaping scheme
when negotiated via the `Accept` header.
([#7440](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7440))

## 1.16.0-beta.1

Released 2026-Jun-10
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,18 +160,7 @@ internal static PrometheusProtocol Negotiate(string? contentType)
var trimmed = TrimQuotes(value);
var escaping = trimmed.ToString();

if (PrometheusProtocol.SupportedEscapingSchemes.Contains(escaping))
{
return escaping;
}

// TODO Support other escaping schemes, including at least "allow-utf-8".
// For now we treat "allow-utf-8" as if it were "underscores" to avoid fallback
// to PrometheusText0.0.4 where it would previously match to OpenMetricsText1.0.0.
// See https://github.com/open-telemetry/opentelemetry-dotnet/issues/7246.
return string.Equals(escaping, PrometheusProtocol.AllowUtf8Escaping, StringComparison.Ordinal)
? PrometheusProtocol.UnderscoresEscaping
: null;
return PrometheusProtocol.SupportedEscapingSchemes.Contains(escaping) ? escaping : null;
}

private static ReadOnlySpan<char> SplitNext(ref ReadOnlySpan<char> span, char character)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,11 @@ internal enum EscapingScheme
/// characters are encoded as their hexadecimal Unicode code point.
/// </summary>
Values,

/// <summary>
/// Names are not escaped and are emitted as UTF-8. Names that are not valid legacy
/// names are written using the quoted exposition format (the name is moved inside
/// the label braces as a double-quoted string and non-legacy label names are quoted).
/// </summary>
AllowUtf8,
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ internal static class PrometheusEscaping

public static EscapingScheme FromString(string? escaping) => escaping switch
{
PrometheusProtocol.AllowUtf8Escaping => EscapingScheme.AllowUtf8,
PrometheusProtocol.DotsEscaping => EscapingScheme.Dots,
PrometheusProtocol.ValuesEscaping => EscapingScheme.Values,
_ => EscapingScheme.Underscores,
Expand All @@ -45,7 +46,9 @@ internal static class PrometheusEscaping
/// <returns>The escaped name. Always a valid legacy (ASCII) name for the dots and values schemes.</returns>
public static string EscapeName(string name, EscapingScheme scheme, bool isMetricName = true)
{
if (string.IsNullOrEmpty(name) || scheme == EscapingScheme.Underscores)
// The underscores scheme is handled by the OpenTelemetry sanitization, and the allow-utf-8
// scheme keeps the name unchanged, so neither is escaped here.
if (string.IsNullOrEmpty(name) || scheme is EscapingScheme.AllowUtf8 or EscapingScheme.Underscores)
{
return name;
}
Expand Down Expand Up @@ -148,15 +151,31 @@ public static int EscapeName(byte[] buffer, int cursor, string name, EscapingSch
return cursor;
}

private static bool CanWriteValuesNameUnchanged(string name, bool isMetricName)
{
// A pre-existing "U__" prefix must itself be encoded; otherwise a name such as
// "U__foo_2e_bar" collides with the values encoding of "foo.bar".
return IsValidLegacyName(name, isMetricName) && !name.StartsWith(ValuesPrefix, StringComparison.Ordinal);
}
/// <summary>
/// Returns whether the specified metric name matches the legacy metric name pattern <c>[a-zA-Z_:][a-zA-Z0-9_:]*</c>.
/// </summary>
/// <param name="name">The metric name to validate.</param>
/// <returns>
/// <see langword="true"/> if the name is a valid legacy metric name; otherwise, <see langword="false"/>.
/// </returns>
internal static bool IsValidLegacyName(string name) => IsValidLegacyName(name, isMetricName: true);

/// <summary>
/// Returns whether the specified label name matches the legacy label name pattern <c>[a-zA-Z_][a-zA-Z0-9_]*</c>.
/// </summary>
/// <param name="name">The label name to validate.</param>
/// <returns>
/// <see langword="true"/> if the name is a valid legacy label name; otherwise, <see langword="false"/>.
/// </returns>
internal static bool IsValidLegacyLabelName(string name) => IsValidLegacyName(name, isMetricName: false);

private static bool IsValidLegacyName(string name, bool isMetricName)
{
if (name.Length == 0)
{
return false;
}

var index = 0;

while (index < name.Length)
Expand Down Expand Up @@ -219,6 +238,11 @@ private static int GetCodePoint(string value, int index, out int charsConsumed,
#endif
}

// A pre-existing "U__" prefix must itself be encoded; otherwise a name such as
// "U__foo_2e_bar" collides with the values encoding of "foo.bar".
private static bool CanWriteValuesNameUnchanged(string name, bool isMetricName)
=> IsValidLegacyName(name, isMetricName) && !name.StartsWith(ValuesPrefix, StringComparison.Ordinal);

private static int WriteAscii(byte[] buffer, int cursor, string value)
{
for (var i = 0; i < value.Length; i++)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal sealed class PrometheusMetric
private readonly string rawName;
private readonly bool disableTotalNameSuffixForCounters;
private readonly NameSet underscoreNames;
private NameSet? allowUtf8Names;
private NameSet? dotsNames;
private NameSet? valuesNames;

Expand Down Expand Up @@ -235,6 +236,7 @@ UpDownCounter becomes gauge
/// <returns>The metric name set for the requested escaping scheme.</returns>
internal NameSet GetNameSet(EscapingScheme escaping) => escaping switch
{
EscapingScheme.AllowUtf8 => this.allowUtf8Names ??= BuildAllowUtf8Names(this.rawName, this.Unit, this.Type, this.disableTotalNameSuffixForCounters),
EscapingScheme.Dots => this.dotsNames ??= BuildEscapedNames(this.rawName, this.Unit, this.Type, this.disableTotalNameSuffixForCounters, EscapingScheme.Dots),
EscapingScheme.Values => this.valuesNames ??= BuildEscapedNames(this.rawName, this.Unit, this.Type, this.disableTotalNameSuffixForCounters, EscapingScheme.Values),
_ => this.underscoreNames,
Expand Down Expand Up @@ -367,6 +369,51 @@ private static NameSet BuildEscapedNames(
return new(escapedName, openMetricsName, openMetricsMetadataName);
}

private static NameSet BuildAllowUtf8Names(string name, string? sanitizedUnit, PrometheusType type, bool disableTotalNameSuffixForCounters)
{
// The allow-utf-8 scheme does not escape the name; the original UTF-8 name is kept and only
// the unit and the structural '_total' suffix are appended. The family name determines
// whether the quoted exposition format is required (the structural suffixes are legacy
// characters and so do not affect the legacy validity of the family).
var nameFamily = name;
var openMetricsFamily = type == PrometheusType.Counter
? RemoveOpenMetricsCounterNameSuffix(name)
: name;

if (sanitizedUnit != null)
{
// Each name is checked independently: openMetricsFamily has the _total counter
// suffix stripped, so it may already end with the unit even when nameFamily
// (which still carries _total) does not. For counter nameFamily, also check for
// the unit immediately before _total (e.g. "db_bytes_total" with unit "bytes").
if (!nameFamily.EndsWith(sanitizedUnit, StringComparison.Ordinal) &&
(type != PrometheusType.Counter || !nameFamily.EndsWith($"{sanitizedUnit}_total", StringComparison.Ordinal)))
{
nameFamily += $"_{sanitizedUnit}";
}

if (!openMetricsFamily.EndsWith(sanitizedUnit, StringComparison.Ordinal))
{
openMetricsFamily += $"_{sanitizedUnit}";
}
}

var metricName = nameFamily;

if (type == PrometheusType.Counter && !nameFamily.EndsWith("_total", StringComparison.Ordinal) && !disableTotalNameSuffixForCounters)
{
metricName += "_total";
}

var openMetricsName = type == PrometheusType.Counter && !openMetricsFamily.EndsWith("_total", StringComparison.Ordinal)
? openMetricsFamily + "_total"
: openMetricsFamily;

var isLegacyValid = PrometheusEscaping.IsValidLegacyName(nameFamily);

return new(metricName, openMetricsName, openMetricsFamily, isLegacyValid, encodeUtf8: true);
}

private static string RemoveOpenMetricsCounterNameSuffix(string metricName)
=> metricName.EndsWith("_total", StringComparison.Ordinal) ? metricName.Substring(0, metricName.Length - 6) : metricName;

Expand Down Expand Up @@ -482,14 +529,15 @@ private static bool TryProcessRateUnits(string updatedUnit, [NotNullWhen(true)]
/// </summary>
internal sealed class NameSet
{
public NameSet(string name, string openMetricsName, string openMetricsMetadataName)
public NameSet(string name, string openMetricsName, string openMetricsMetadataName, bool isLegacyValid = true, bool encodeUtf8 = false)
{
this.Name = name;
this.OpenMetricsName = openMetricsName;
this.OpenMetricsMetadataName = openMetricsMetadataName;
this.NameBytes = ConvertToBytes(name);
this.OpenMetricsNameBytes = ConvertToBytes(openMetricsName);
this.OpenMetricsMetadataNameBytes = ConvertToBytes(openMetricsMetadataName);
this.IsLegacyValid = isLegacyValid;
this.NameBytes = ToBytes(name, encodeUtf8);
this.OpenMetricsNameBytes = ToBytes(openMetricsName, encodeUtf8);
this.OpenMetricsMetadataNameBytes = ToBytes(openMetricsMetadataName, encodeUtf8);
}

public string Name { get; }
Expand All @@ -498,10 +546,22 @@ public NameSet(string name, string openMetricsName, string openMetricsMetadataNa

public string OpenMetricsMetadataName { get; }

/// <summary>
/// Gets a value indicating whether the metric name is a valid legacy name.
/// </summary>
/// <remarks>
/// When <see langword="false"/> (only possible for the <c>allow-utf-8 scheme</c>)
/// the quoted exposition format is required.
/// </remarks>
public bool IsLegacyValid { get; }

public byte[] NameBytes { get; }

public byte[] OpenMetricsNameBytes { get; }

public byte[] OpenMetricsMetadataNameBytes { get; }

private static byte[] ToBytes(string value, bool encodeUtf8)
=> encodeUtf8 ? Encoding.UTF8.GetBytes(value) : ConvertToBytes(value);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,9 @@ namespace OpenTelemetry.Exporter.Prometheus;

public static readonly PrometheusProtocol Fallback = new(PrometheusTextMediaType, null, PrometheusV0, false);

// TODO Support the "allow-utf-8" escaping scheme.
// See https://github.com/open-telemetry/opentelemetry-dotnet/issues/7246.
internal static readonly SupportedEscapingSchemes SupportedEscapingSchemes =
[
AllowUtf8Escaping,
DotsEscaping,
UnderscoresEscaping,
ValuesEscaping,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,7 @@ private int WriteCreatedMetric(
in TextFormatSerializerOptions options,
IReadOnlyCollection<string>? reservedOutputKeys = null)
{
cursor = this.WriteMetricNameWithSuffix(buffer, cursor, prometheusMetric, "_created");
cursor = this.WriteTags(buffer, cursor, metric, metricPoint.Tags, options, reservedOutputKeys: reservedOutputKeys);
cursor = this.WriteSeriesAndTags(buffer, cursor, metric, prometheusMetric, metricPoint.Tags, options, "_created", reservedOutputKeys);

buffer[cursor++] = unchecked((byte)' ');

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

using OpenTelemetry.Metrics;

namespace OpenTelemetry.Exporter.Prometheus.Serialization;

internal sealed class OpenMetricsV1Serializer : OpenMetricsSerializer
{
// Version-specific behaviour will be added here later
internal override int WriteMetricMetadataName(byte[] buffer, int cursor, PrometheusMetric metric) =>
this.RequiresQuotedName(metric)
? WriteQuotedMetadataName(buffer, cursor, this.GetMetricMetadataNameBytes(metric))
: base.WriteMetricMetadataName(buffer, cursor, metric);

internal override int WriteSeriesAndTags(
byte[] buffer,
int cursor,
Metric metric,
PrometheusMetric prometheusMetric,
ReadOnlyTagCollection tags,
in TextFormatSerializerOptions options,
string? suffix,
IReadOnlyCollection<string>? reservedOutputKeys) =>
this.RequiresQuotedName(prometheusMetric)
? this.WriteQuotedSeriesAndTags(buffer, cursor, metric, prometheusMetric, tags, options, suffix, reservedOutputKeys)
: base.WriteSeriesAndTags(buffer, cursor, metric, prometheusMetric, tags, options, suffix, reservedOutputKeys);

internal override int WriteHistogramBucketName(byte[] buffer, int cursor, PrometheusMetric metric) =>
this.RequiresQuotedName(metric)
? WriteQuotedBucketName(buffer, cursor, this.GetMetricMetadataNameBytes(metric))
: base.WriteHistogramBucketName(buffer, cursor, metric);

internal override int WriteSeriesNameAndSerializedTags(byte[] buffer, int cursor, PrometheusMetric metric, string suffix, ReadOnlySpan<byte> serializedTags) =>
this.RequiresQuotedName(metric)
? WriteQuotedSeriesNameAndSerializedTags(buffer, cursor, this.GetMetricMetadataNameBytes(metric), suffix, serializedTags)
: base.WriteSeriesNameAndSerializedTags(buffer, cursor, metric, suffix, serializedTags);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

using OpenTelemetry.Metrics;

namespace OpenTelemetry.Exporter.Prometheus.Serialization;

internal sealed class PrometheusTextV1Serializer : PrometheusTextSerializer
{
// Version-specific behaviour will be added here later
internal override int WriteMetricMetadataName(byte[] buffer, int cursor, PrometheusMetric metric) =>
this.RequiresQuotedName(metric)
? WriteQuotedMetadataName(buffer, cursor, this.GetMetricMetadataNameBytes(metric))
: base.WriteMetricMetadataName(buffer, cursor, metric);

internal override int WriteSeriesAndTags(
byte[] buffer,
int cursor,
Metric metric,
PrometheusMetric prometheusMetric,
ReadOnlyTagCollection tags,
in TextFormatSerializerOptions options,
string? suffix,
IReadOnlyCollection<string>? reservedOutputKeys) =>
this.RequiresQuotedName(prometheusMetric)
? this.WriteQuotedSeriesAndTags(buffer, cursor, metric, prometheusMetric, tags, options, suffix, reservedOutputKeys)
: base.WriteSeriesAndTags(buffer, cursor, metric, prometheusMetric, tags, options, suffix, reservedOutputKeys);

internal override int WriteHistogramBucketName(byte[] buffer, int cursor, PrometheusMetric metric) =>
this.RequiresQuotedName(metric)
? WriteQuotedBucketName(buffer, cursor, this.GetMetricMetadataNameBytes(metric))
: base.WriteHistogramBucketName(buffer, cursor, metric);

internal override int WriteSeriesNameAndSerializedTags(byte[] buffer, int cursor, PrometheusMetric metric, string suffix, ReadOnlySpan<byte> serializedTags) =>
this.RequiresQuotedName(metric)
? WriteQuotedSeriesNameAndSerializedTags(buffer, cursor, this.GetMetricMetadataNameBytes(metric), suffix, serializedTags)
: base.WriteSeriesNameAndSerializedTags(buffer, cursor, metric, suffix, serializedTags);
}
Loading