Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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 @@ -13,6 +13,10 @@ Notes](../../RELEASENOTES.md).
* The library is now marked as trim and AOT compatible.
([#7441](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7441))

* Added support for the `dots` and `values` Prometheus UTF-8 name escaping
schemes when negotiated via the `Accept` header.
([#7439](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7439))

* Fix double unit suffixes in metric names when using OpenMetrics.
([#7454](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7454))

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

* Added support for the `dots` and `values` Prometheus UTF-8 name escaping
schemes when negotiated via the `Accept` header.
([#7439](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7439))

* Fix double unit suffixes in metric names when using OpenMetrics.
([#7454](https://github.com/open-telemetry/opentelemetry-dotnet/pull/7454))

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

namespace OpenTelemetry.Exporter.Prometheus;

/// <summary>
/// The Prometheus UTF-8 name escaping scheme used when rendering metric and label names.
/// See https://prometheus.io/docs/instrumenting/escaping_schemes/.
/// </summary>
internal enum EscapingScheme
{
/// <summary>
/// Discouraged characters are replaced with the <c>_</c> character. This is the
/// OpenTelemetry default and also covers the legacy (pre-1.0.0) text formats which
/// have no negotiated escaping scheme.
/// </summary>
Underscores = 0,

/// <summary>
/// Dots are replaced with <c>_dot_</c> and underscores are doubled, allowing the
/// original name to be recovered by a Prometheus client.
/// </summary>
Dots,

/// <summary>
/// Names that are not valid legacy names are prefixed with <c>U__</c> and discouraged
/// characters are encoded as their hexadecimal Unicode code point.
/// </summary>
Values,
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ public PrometheusCollectionManager(PrometheusExporter exporter)
internal Func<TimeSpan> GetElapsedTime { get; set; }

#if NET
public ValueTask<CollectionResponse> EnterCollect(PrometheusProtocol protocol)
public ValueTask<CollectionResponse> EnterCollect(in PrometheusProtocol protocol)
#else
public Task<CollectionResponse> EnterCollect(PrometheusProtocol protocol)
public Task<CollectionResponse> EnterCollect(in PrometheusProtocol protocol)
#endif
{
CollectionResponse? cachedResponse = null;
Expand Down Expand Up @@ -211,7 +211,7 @@ public Task<CollectionResponse> EnterCollect(PrometheusProtocol protocol)
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void ExitCollect(PrometheusProtocol protocol)
public void ExitCollect(in PrometheusProtocol protocol)
=> this.GetProtocolState(protocol).DecrementReaderCount();

#if NET
Expand Down Expand Up @@ -257,15 +257,15 @@ private void ExitGlobalLock()
=> Interlocked.Exchange(ref this.globalLockState, 0);

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void IncrementReaderCount(PrometheusProtocol protocol)
private void IncrementReaderCount(in PrometheusProtocol protocol)
=> this.GetProtocolState(protocol).IncrementReaderCount();

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool HasActiveReaders(PrometheusProtocol protocol)
private bool HasActiveReaders(in PrometheusProtocol protocol)
=> this.protocolStates.TryGetValue(protocol, out var state) && state.HasActiveReaders();

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool WaitForReadersToComplete(PrometheusProtocol protocol)
private bool WaitForReadersToComplete(in PrometheusProtocol protocol)
{
var state = this.GetProtocolState(protocol);
var didSpin = false;
Expand Down Expand Up @@ -345,7 +345,7 @@ private ExportResult OnCollect(in Batch<Metric> metrics)
: ExportResult.Failure;
}

private bool TryWriteResponse(PrometheusProtocol protocol, PrometheusProtocolState state, in Batch<Metric> metrics)
private bool TryWriteResponse(in PrometheusProtocol protocol, PrometheusProtocolState state, in Batch<Metric> metrics)
{
try
{
Expand Down Expand Up @@ -451,7 +451,7 @@ private CollectionResult CreateCollectionResult(CollectionContext collectionCont
return new CollectionResult(responses);
}

private bool TryGetCachedResponse(PrometheusProtocol protocol, out CollectionResponse response)
private bool TryGetCachedResponse(in PrometheusProtocol protocol, out CollectionResponse response)
{
if (this.protocolStates.TryGetValue(protocol, out var state) &&
state.GeneratedAt is { } generatedAt &&
Expand All @@ -468,7 +468,7 @@ state.GeneratedAtElapsed is { } generatedAtElapsed &&
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private PrometheusProtocolState GetProtocolState(PrometheusProtocol protocol)
private PrometheusProtocolState GetProtocolState(in PrometheusProtocol protocol)
=> this.protocolStates.GetOrAdd(protocol, static _ => new());

private int WriteTargetInfo(TextFormatSerializer serializer, PrometheusProtocolState state)
Expand Down Expand Up @@ -633,7 +633,7 @@ public CollectionResult(IReadOnlyDictionary<PrometheusProtocol, CollectionRespon
this.responses = responses;
}

public bool TryGetResponse(PrometheusProtocol protocol, out CollectionResponse response)
public bool TryGetResponse(in PrometheusProtocol protocol, out CollectionResponse response)
{
if (this.responses?.TryGetValue(protocol, out response) == true)
{
Expand Down Expand Up @@ -732,7 +732,7 @@ private sealed class CollectionContext
private readonly TaskCompletionSource<CollectionResult> tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
private bool frozen;

public CollectionContext(PrometheusProtocol protocol)
public CollectionContext(in PrometheusProtocol protocol)
{
this.protocols.Add(protocol);
}
Expand All @@ -751,7 +751,7 @@ public PrometheusProtocol[] FreezeProtocols()
public void SetResult(CollectionResult result)
=> this.tcs.SetResult(result);

public bool TryRegisterProtocol(PrometheusProtocol protocol, bool hasActiveReaders)
public bool TryRegisterProtocol(in PrometheusProtocol protocol, bool hasActiveReaders)
{
lock (this.gate)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

using System.Buffers;
using System.Diagnostics;
using System.Text;

namespace OpenTelemetry.Exporter.Prometheus;

/// <summary>
/// Implements the Prometheus name escaping schemes.
/// </summary>
/// <remarks>
/// The transformations are faithful to <c>EscapeName</c> in
/// https://github.com/prometheus/common/blob/43de10cc658055c6b5e4d619edc917d5af493409/model/metric.go
/// so that a Prometheus client can reverse them.
/// </remarks>
internal static class PrometheusEscaping
{
// Upper bound on the number of bytes a single UTF-16 character can expand to. "_dot_" is five
// bytes for the dots scheme and a value-encoded basic-multilingual-plane character ("_FFFD_"
// or "_xxxx_") is at most six bytes; a surrogate pair expands to at most eight bytes across
// two characters, so six per character remains a safe bound.
private const int MaxBytesPerCharacter = 6;

public static EscapingScheme FromString(string? escaping) => escaping switch
{
PrometheusProtocol.DotsEscaping => EscapingScheme.Dots,
PrometheusProtocol.ValuesEscaping => EscapingScheme.Values,
_ => EscapingScheme.Underscores,
};

/// <summary>
/// Escapes a fully-constructed metric or label name (including any unit and type-specific
/// suffixes) according to the supplied scheme. The name MUST be escaped as a single unit
/// so that the structural underscores introduced when building the name are doubled and
/// can be reversed by a client.
/// </summary>
/// <param name="name">The name to escape.</param>
/// <param name="scheme">The escaping scheme to apply.</param>
/// <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)
{
if (string.IsNullOrEmpty(name) || scheme == EscapingScheme.Underscores)
{
return name;
}

if (scheme == EscapingScheme.Values && IsValidLegacyName(name))
{
return name;
}

var rented = ArrayPool<byte>.Shared.Rent((scheme == EscapingScheme.Values ? 3 : 0) + (name.Length * MaxBytesPerCharacter));

try
{
var length = EscapeName(rented, 0, name, scheme);
#if NET
return Encoding.ASCII.GetString(rented.AsSpan(0, length));
#else
return Encoding.ASCII.GetString(rented, 0, length);
#endif
}
finally
{
ArrayPool<byte>.Shared.Return(rented);
}
}

/// <summary>
/// Escapes a fully-constructed metric or label name directly into <paramref name="buffer"/>
/// according to the supplied scheme, avoiding an intermediate string allocation. The scheme
/// MUST be <see cref="EscapingScheme.Dots"/> or <see cref="EscapingScheme.Values"/>; the
Comment thread
martincostello marked this conversation as resolved.
/// underscores scheme is handled using the pre-computed names.
/// </summary>
/// <param name="buffer">The buffer to write to.</param>
/// <param name="cursor">The current position in the buffer.</param>
/// <param name="name">The name to escape.</param>
/// <param name="scheme">The escaping scheme to apply.</param>
/// <returns>The new cursor position after writing the (ASCII) escaped name.</returns>
public static int EscapeName(byte[] buffer, int cursor, string name, EscapingScheme scheme)
{
Debug.Assert(scheme is EscapingScheme.Dots or EscapingScheme.Values, $"Unexpected escaping scheme: {scheme}");

if (string.IsNullOrEmpty(name))
{
return cursor;
}

if (scheme == EscapingScheme.Values && IsValidLegacyName(name))
{
return WriteAscii(buffer, cursor, name);
}

if (scheme == EscapingScheme.Values)
{
cursor = WriteAscii(buffer, cursor, "U__");
}

var index = 0;

while (index < name.Length)
{
var codePoint = GetCodePoint(name, index, out var charsConsumed, out var isValidRune);

if (codePoint == '_')
{
cursor = WriteAscii(buffer, cursor, "__");
}
else if (scheme == EscapingScheme.Dots && codePoint == '.')
{
cursor = WriteAscii(buffer, cursor, "_dot_");
}
else if (IsValidLegacyRune(codePoint, index == 0))
{
buffer[cursor++] = unchecked((byte)codePoint);
}
else if (scheme == EscapingScheme.Dots)
{
cursor = WriteAscii(buffer, cursor, "__");
}
else if (!isValidRune)
{
cursor = WriteAscii(buffer, cursor, "_FFFD_");
}
else
{
cursor = WriteHexCodePoint(buffer, cursor, codePoint);
}

index += charsConsumed;
}

return cursor;
}

private static bool IsValidLegacyName(string name)
{
var index = 0;

while (index < name.Length)
{
var codePoint = GetCodePoint(name, index, out var charsConsumed, out _);

if (!IsValidLegacyRune(codePoint, index == 0))
{
return false;
}

index += charsConsumed;
}

return true;
}

private static bool IsValidLegacyRune(int codePoint, bool isFirst) =>
codePoint is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '_' or ':' ||
(!isFirst && codePoint is >= '0' and <= '9');

private static int GetCodePoint(string value, int index, out int charsConsumed, out bool isValidRune)
{
#if NET
var status = Rune.DecodeFromUtf16(value.AsSpan(index), out var rune, out charsConsumed);

isValidRune = status == OperationStatus.Done;

return rune.Value;
#else
const int UnicodeReplacementCharacter = 0xFFFD;

var character = value[index];

if (char.IsHighSurrogate(character) &&
index + 1 < value.Length &&
char.IsLowSurrogate(value[index + 1]))
{
charsConsumed = 2;
isValidRune = true;
return char.ConvertToUtf32(character, value[index + 1]);
}

if (char.IsSurrogate(character))
{
// An unpaired surrogate is not a valid Unicode scalar value.
charsConsumed = 1;
isValidRune = false;
return UnicodeReplacementCharacter;
}

charsConsumed = 1;
isValidRune = true;

return character;
#endif
}

private static int WriteAscii(byte[] buffer, int cursor, string value)
{
for (var i = 0; i < value.Length; i++)
{
buffer[cursor++] = unchecked((byte)value[i]);
}

return cursor;
}

private static int WriteHexCodePoint(byte[] buffer, int cursor, int codePoint)
{
// Writes "_<lowercase-hex>_" with no leading zeros, matching the reference implementation's
// use of strconv.FormatInt(value, 16). Code points are at most 0x10FFFF (six hex digits).
buffer[cursor++] = unchecked((byte)'_');

var started = false;

for (var shift = 20; shift >= 0; shift -= 4)
{
var nibble = (codePoint >> shift) & 0xF;

if (nibble != 0 || started || shift == 0)
{
buffer[cursor++] = unchecked((byte)(nibble < 10 ? '0' + nibble : 'a' + (nibble - 10)));
started = true;
}
}

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

return cursor;
}
}
Loading