Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ Notes](../../RELEASENOTES.md).
* Fix incorrect handling of untyped metrics when using OpenMetrics format.
([#7219](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7219))

* Fix Prometheus/OpenMetrics serialization to emit metric and label names
containing `:` and `_` instead of dropping them and prefixing leading digits.
Invalid characters are replaced with `_` instead of being dropped.
([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209))

* Add `escaping=underscores` to the `Accept` header handling for content
negotiation so OpenMetrics are handled correctly.
([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209))

## 1.15.3-beta.1

Released 2026-Apr-21
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ namespace OpenTelemetry.Exporter;
/// </summary>
internal sealed class PrometheusExporterMiddleware
{
private const string OpenMetricsEscapingScheme = "underscores";
private const string OpenMetricsMediaType = "application/openmetrics-text";
private const string OpenMetricsVersion = "1.0.0";
private const string OpenMetricsContentType = $"application/openmetrics-text; version={OpenMetricsVersion}; charset=utf-8";
private const string OpenMetricsContentType = $"application/openmetrics-text; version={OpenMetricsVersion}; charset=utf-8; escaping={OpenMetricsEscapingScheme}";
Comment thread
Kielek marked this conversation as resolved.

private const string PrometheusTextMediaType = "text/plain";

Expand Down Expand Up @@ -123,7 +124,7 @@ internal static bool AcceptsOpenMetrics(HttpRequest request)
}

if (string.Equals(mediaType.MediaType.Value, OpenMetricsMediaType, StringComparison.OrdinalIgnoreCase) &&
HasSupportedOpenMetricsVersion(mediaType))
HasSupportedOpenMetricsParameters(mediaType))
{
bestOpenMetricsQuality =
bestOpenMetricsQuality is not { } comparison || quality > comparison ?
Expand All @@ -143,16 +144,23 @@ bestPrometheusQuality is not { } comparison || quality > comparison ?
(bestPrometheusQuality is not { } prometheusQuality || openMetricsQuality >= prometheusQuality);
}

private static bool HasSupportedOpenMetricsVersion(MediaTypeHeaderValue value)
private static bool HasSupportedOpenMetricsParameters(MediaTypeHeaderValue value)
{
var hasSupportedOpenMetricsEscaping = true;
var hasSupportedOpenMetricsVersion = true;

foreach (var parameter in value.Parameters)
{
if (string.Equals(parameter.Name.Value, "version", StringComparison.OrdinalIgnoreCase))
{
return string.Equals(parameter.Value.Value?.Trim('"'), OpenMetricsVersion, StringComparison.Ordinal);
hasSupportedOpenMetricsVersion = string.Equals(parameter.Value.Value?.Trim('"'), OpenMetricsVersion, StringComparison.Ordinal);
}
else if (string.Equals(parameter.Name.Value, "escaping", StringComparison.OrdinalIgnoreCase))
{
hasSupportedOpenMetricsEscaping = string.Equals(parameter.Value.Value?.Trim('"'), OpenMetricsEscapingScheme, StringComparison.Ordinal);
}
}

return true;
return hasSupportedOpenMetricsVersion && hasSupportedOpenMetricsEscaping;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ Notes](../../RELEASENOTES.md).
environment variables.
([#7167](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7167))

* Fix Prometheus/OpenMetrics serialization to emit metric and label names
containing `:` and `_` instead of dropping them and prefixing leading digits.
Invalid characters are replaced with `_` instead of being dropped.
([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209))

* Add `escaping=underscores` to the `Accept` header handling for content
negotiation so OpenMetrics are handled correctly.
([#7209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7209))

## 1.15.3-beta.1

Released 2026-Apr-21
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ private ExportResult OnCollect(in Batch<Metric> metrics)
{
try
{
cursor = PrometheusSerializer.WriteScopeInfo(buffer, cursor, metric.MeterName);
cursor = PrometheusSerializer.WriteScopeInfo(buffer, cursor, metric.MeterName, openMetricsRequested: true);

break;
}
Expand Down Expand Up @@ -342,7 +342,7 @@ private int WriteTargetInfo(ref byte[] buffer)
{
try
{
this.targetInfoBufferLength = PrometheusSerializer.WriteTargetInfo(buffer, 0, this.exporter.Resource);
this.targetInfoBufferLength = PrometheusSerializer.WriteTargetInfo(buffer, 0, this.exporter.Resource, openMetricsRequested: true);

break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace OpenTelemetry.Exporter.Prometheus;

internal static class PrometheusHeadersParser
{
private const string OpenMetricsEscapingScheme = "underscores";
private const string OpenMetricsMediaType = "application/openmetrics-text";
private const string OpenMetricsVersion = "1.0.0";
private const string PrometheusTextMediaType = "text/plain";
Expand All @@ -23,6 +24,7 @@ internal static bool AcceptsOpenMetrics(string? contentType)
var mediaType = TrimWhitespace(SplitNext(ref headerValue, ';'));
var quality = 1.0;
var hasValidQuality = true;
var hasSupportedOpenMetricsEscaping = true;
var hasSupportedOpenMetricsVersion = true;

while (headerValue.Length > 0)
Expand All @@ -35,6 +37,10 @@ internal static bool AcceptsOpenMetrics(string? contentType)
{
hasSupportedOpenMetricsVersion = IsSupportedOpenMetricsVersion(parameter.Slice("version=".Length));
}
else if (parameter.StartsWith("escaping=".AsSpan(), StringComparison.OrdinalIgnoreCase))
{
hasSupportedOpenMetricsEscaping = IsSupportedOpenMetricsEscaping(parameter.Slice("escaping=".Length));
}

continue;
}
Expand All @@ -59,7 +65,9 @@ internal static bool AcceptsOpenMetrics(string? contentType)
continue;
}

if (mediaType.Equals(OpenMetricsMediaType.AsSpan(), StringComparison.OrdinalIgnoreCase) && hasSupportedOpenMetricsVersion)
if (mediaType.Equals(OpenMetricsMediaType.AsSpan(), StringComparison.OrdinalIgnoreCase) &&
hasSupportedOpenMetricsVersion &&
hasSupportedOpenMetricsEscaping)
{
bestOpenMetricsQuality =
bestOpenMetricsQuality is not { } comparison || quality > comparison ?
Expand All @@ -82,6 +90,9 @@ bestPrometheusQuality is not { } comparison || quality > comparison ?
private static bool IsSupportedOpenMetricsVersion(ReadOnlySpan<char> value)
=> TrimQuotes(value).Equals(OpenMetricsVersion.AsSpan(), StringComparison.Ordinal);

private static bool IsSupportedOpenMetricsEscaping(ReadOnlySpan<char> value)
=> TrimQuotes(value).Equals(OpenMetricsEscapingScheme.AsSpan(), StringComparison.Ordinal);

private static ReadOnlySpan<char> SplitNext(ref ReadOnlySpan<char> span, char character)
{
var index = span.IndexOf(character);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa
// consecutive `_` characters MUST be replaced with a single `_` character.
// https://github.com/open-telemetry/opentelemetry-specification/blob/b2f923fb1650dde1f061507908b834035506a796/specification/compatibility/prometheus_and_openmetrics.md#L230-L233
var sanitizedName = SanitizeMetricName(name);
var openMetricsName = SanitizeOpenMetricsName(sanitizedName);
var openMetricsName = RemoveOpenMetricsCounterNameSuffix(name);

string? sanitizedUnit = null;
if (!string.IsNullOrEmpty(unit))
Expand All @@ -35,6 +35,8 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa
}
}

openMetricsName = EscapeOpenMetricsName(openMetricsName);

// If the metric name for monotonic Sum metric points does not end in a suffix of `_total` a suffix of `_total` MUST be added by default, otherwise the name MUST remain unchanged.
// Exporters SHOULD provide a configuration option to disable the addition of `_total` suffixes.
// https://github.com/open-telemetry/opentelemetry-specification/blob/b2f923fb1650dde1f061507908b834035506a796/specification/compatibility/prometheus_and_openmetrics.md#L286
Expand All @@ -54,8 +56,8 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa
// In OpenMetrics format, the UNIT, TYPE and HELP metadata must be suffixed with the unit (handled above), and not the '_total' suffix, as in the case for counters.
// https://github.com/prometheus/OpenMetrics/blob/v1.0.0/specification/OpenMetrics.md#unit
var openMetricsMetadataName = type == PrometheusType.Counter
? SanitizeOpenMetricsName(openMetricsName)
: sanitizedName;
? RemoveOpenMetricsCounterNameSuffix(openMetricsName)
: openMetricsName;

this.Name = sanitizedName;
this.OpenMetricsName = openMetricsName;
Expand Down Expand Up @@ -148,6 +150,47 @@ static StringBuilder CreateStringBuilder(string value)
}
}

internal static string EscapeOpenMetricsName(string metricName)
{
StringBuilder? sb = null;
var lastCharUnderscore = false;

for (var i = 0; i < metricName.Length; i++)
{
var c = metricName[i];

if (i == 0 && char.IsAsciiDigit(c))
{
sb ??= CreateStringBuilder(metricName);
sb.Append('_');
lastCharUnderscore = true;
}

if (!char.IsAsciiLetterOrDigit(c) && c != ':')
{
if (!lastCharUnderscore)
{
sb ??= CreateStringBuilder(metricName);
sb.Append('_');
lastCharUnderscore = true;
}
}
else
{
sb ??= CreateStringBuilder(metricName);
sb.Append(c);
lastCharUnderscore = c == '_';
}
}

return sb?.ToString() ?? metricName;

static StringBuilder CreateStringBuilder(string value)
{
return new(value.Length + 1);
}
}

internal static string RemoveAnnotations(string unit)
{
// UCUM standard says the curly braces shouldn't be nested:
Expand Down Expand Up @@ -215,7 +258,7 @@ UpDownCounter becomes gauge
};
}

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

private static string GetUnit(string unit)
Expand Down
Loading
Loading