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 e526046cc0f..1a2b2d4db90 100644
--- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj
+++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/OpenTelemetry.Exporter.Prometheus.AspNetCore.csproj
@@ -27,6 +27,7 @@
+
diff --git a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs
index caa6938ead2..94c1570e826 100644
--- a/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs
+++ b/src/OpenTelemetry.Exporter.Prometheus.AspNetCore/PrometheusExporterMiddleware.cs
@@ -79,13 +79,13 @@ public async Task InvokeAsync(HttpContext httpContext)
var protocol = Negotiate(requestHeaders);
- var collectionResponse = await this.exporter.CollectionManager.EnterCollect(protocol.IsOpenMetrics);
+ var collectionResponse = await this.exporter.CollectionManager.EnterCollect(protocol);
try
{
linkedCts.Token.ThrowIfCancellationRequested();
- var dataView = protocol.IsOpenMetrics ? collectionResponse.OpenMetricsView : collectionResponse.PlainTextView;
+ var dataView = collectionResponse.View;
response.StatusCode = StatusCodes.Status200OK;
@@ -116,7 +116,7 @@ public async Task InvokeAsync(HttpContext httpContext)
}
finally
{
- this.exporter.CollectionManager.ExitCollect();
+ this.exporter.CollectionManager.ExitCollect(protocol);
}
}
catch (Exception ex)
@@ -272,7 +272,7 @@ private static bool TryParse(
if (version is null)
{
// Use the oldest version if no version preference was specified
- version = isOpenMetrics ? PrometheusProtocol.OpenMetricsV0 : PrometheusProtocol.PrometheusVersion0;
+ version = isOpenMetrics ? PrometheusProtocol.OpenMetricsV0 : PrometheusProtocol.PrometheusV0;
}
else if (version.Major is not > 0)
{
@@ -286,6 +286,9 @@ private static bool TryParse(
}
protocol = new(mediaType, escaping, version, isOpenMetrics);
+
+ protocol.Value.Validate();
+
return true;
}
diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs
index 1d168491514..619818b15e5 100644
--- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs
+++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/PrometheusHeadersParser.cs
@@ -119,7 +119,7 @@ internal static PrometheusProtocol Negotiate(string? contentType)
if (version is null)
{
// Use the oldest version if no version preference was specified
- version = isOpenMetrics ? PrometheusProtocol.OpenMetricsV0 : PrometheusProtocol.PrometheusVersion0;
+ version = isOpenMetrics ? PrometheusProtocol.OpenMetricsV0 : PrometheusProtocol.PrometheusV0;
}
else if (version.Major is not > 0)
{
@@ -138,6 +138,8 @@ internal static PrometheusProtocol Negotiate(string? contentType)
version,
isOpenMetrics);
+ protocol.Validate();
+
preferences.Add((protocol, quality));
}
diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusCollectionManager.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusCollectionManager.cs
index 137e31f3fc2..280ea1839ac 100644
--- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusCollectionManager.cs
+++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusCollectionManager.cs
@@ -1,6 +1,7 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
+using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using OpenTelemetry.Metrics;
@@ -11,26 +12,18 @@ internal sealed class PrometheusCollectionManager
{
private const int MaxCachedMetrics = 1024;
+ private readonly ConcurrentDictionary protocolStates = new();
+
private readonly PrometheusExporter exporter;
private readonly TimeSpan scrapeResponseCacheDuration;
private readonly long baseTimestamp = Stopwatch.GetTimestamp();
private readonly PrometheusExporter.ExportFunc onCollectRef;
private readonly Dictionary metricsCache;
private int metricsCacheCount;
- private byte[] plainTextBuffer = new byte[85000]; // encourage the object to live in LOH (large object heap)
- private byte[] openMetricsBuffer = new byte[85000]; // encourage the object to live in LOH (large object heap)
- private int plainTextTargetInfoBufferLength = -1;
- private int openMetricsTargetInfoBufferLength = -1;
- private ArraySegment previousPlainTextDataView;
- private ArraySegment previousOpenMetricsDataView;
private int globalLockState;
- private DateTime? previousPlainTextDataViewGeneratedAtUtc;
- private DateTime? previousOpenMetricsDataViewGeneratedAtUtc;
- private TimeSpan previousPlainTextDataViewGeneratedAtElapsed;
- private TimeSpan previousOpenMetricsDataViewGeneratedAtElapsed;
- private int readerCount;
- private bool collectionRunning;
- private TaskCompletionSource? collectionTcs;
+ private CollectionContext? collectionContext;
+ private CollectionContext? onCollectContext;
+ private CollectionExecutionResult collectionExecutionResult;
public PrometheusCollectionManager(PrometheusExporter exporter)
{
@@ -46,135 +39,200 @@ public PrometheusCollectionManager(PrometheusExporter exporter)
internal Func GetElapsedTime { get; set; }
#if NET
- public ValueTask EnterCollect(bool openMetricsRequested)
+ public ValueTask EnterCollect(PrometheusProtocol protocol)
#else
- public Task EnterCollect(bool openMetricsRequested)
+ public Task EnterCollect(PrometheusProtocol protocol)
#endif
{
- this.EnterGlobalLock();
+ CollectionResponse? cachedResponse = null;
+ Task? pendingCollectionTask = null;
+ CollectionContext? activeCollectionContext = null;
+ var joinedActiveCollection = false;
- DateTime? previousDataViewGeneratedAtUtc;
-
- try
+ while (true)
{
- previousDataViewGeneratedAtUtc = openMetricsRequested
- ? this.previousOpenMetricsDataViewGeneratedAtUtc
- : this.previousPlainTextDataViewGeneratedAtUtc;
+ pendingCollectionTask = null;
+ joinedActiveCollection = false;
+ var retry = false;
- var previousDataViewGeneratedAtElapsed = openMetricsRequested
- ? this.previousOpenMetricsDataViewGeneratedAtElapsed
- : this.previousPlainTextDataViewGeneratedAtElapsed;
+ this.EnterGlobalLock();
- if (previousDataViewGeneratedAtUtc.HasValue
- && this.scrapeResponseCacheDuration > TimeSpan.Zero
- && this.GetElapsedTime() - previousDataViewGeneratedAtElapsed < this.scrapeResponseCacheDuration)
+ try
{
-#if NET
- return new ValueTask(new CollectionResponse(this.previousOpenMetricsDataView, this.previousPlainTextDataView, previousDataViewGeneratedAtUtc.Value, fromCache: true));
-#else
- return Task.FromResult(new CollectionResponse(this.previousOpenMetricsDataView, this.previousPlainTextDataView, previousDataViewGeneratedAtUtc.Value, fromCache: true));
-#endif
- }
+ // If we are within {ScrapeResponseCacheDurationMilliseconds} of the
+ // last successful collect, return the previous view.
+ if (this.TryGetCachedResponse(protocol, out var response))
+ {
+ cachedResponse = response;
+ this.IncrementReaderCount(protocol);
+ break;
+ }
- // If a collection is already running, return a task to wait on the result.
- if (this.collectionRunning)
- {
- this.collectionTcs ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ if (this.collectionContext is { } currentCollectionContext)
+ {
+ pendingCollectionTask = currentCollectionContext.Task;
+ joinedActiveCollection = currentCollectionContext.TryRegisterProtocol(protocol, this.HasActiveReaders(protocol));
-#if NET
- return new ValueTask(this.collectionTcs.Task);
-#else
- return this.collectionTcs.Task;
-#endif
- }
+ if (joinedActiveCollection)
+ {
+ this.IncrementReaderCount(protocol);
+ break;
+ }
- this.WaitForReadersToComplete();
+ if (currentCollectionContext.Task.IsCompleted)
+ {
+ if (ReferenceEquals(this.collectionContext, currentCollectionContext))
+ {
+ this.collectionContext = null;
+ }
- // Start a collection on the current thread.
- this.collectionRunning = true;
+ pendingCollectionTask = null;
+ retry = true;
+ }
+ else
+ {
+ break;
+ }
+ }
+ }
+ finally
+ {
+ this.ExitGlobalLock();
+ }
- if (openMetricsRequested)
+ if (retry)
{
- this.previousOpenMetricsDataViewGeneratedAtUtc = null;
+ continue;
}
- else
+
+ if (this.WaitForReadersToComplete(protocol))
{
- this.previousPlainTextDataViewGeneratedAtUtc = null;
+ continue;
}
- }
- finally
- {
- Interlocked.Increment(ref this.readerCount);
- this.ExitGlobalLock();
- }
- CollectionResponse response;
- var result = this.ExecuteCollect(openMetricsRequested);
- if (result)
- {
- var generatedAt = this.UtcNow();
- var generatedAtElapsed = this.GetElapsedTime();
+ this.EnterGlobalLock();
- if (openMetricsRequested)
+ try
{
- this.previousOpenMetricsDataViewGeneratedAtUtc = generatedAt;
- this.previousOpenMetricsDataViewGeneratedAtElapsed = generatedAtElapsed;
+ if (this.TryGetCachedResponse(protocol, out var response))
+ {
+ cachedResponse = response;
+ this.IncrementReaderCount(protocol);
+ break;
+ }
+
+ if (this.collectionContext is { } currentCollectionContext)
+ {
+ pendingCollectionTask = currentCollectionContext.Task;
+ joinedActiveCollection = currentCollectionContext.TryRegisterProtocol(protocol, this.HasActiveReaders(protocol));
+
+ if (joinedActiveCollection)
+ {
+ this.IncrementReaderCount(protocol);
+ break;
+ }
+
+ if (currentCollectionContext.Task.IsCompleted)
+ {
+ if (ReferenceEquals(this.collectionContext, currentCollectionContext))
+ {
+ this.collectionContext = null;
+ }
+
+ continue;
+ }
+
+ break;
+ }
+
+ activeCollectionContext = new CollectionContext(protocol);
+ this.collectionContext = activeCollectionContext;
+
+ this.IncrementReaderCount(protocol);
+ break;
}
- else
+ finally
{
- this.previousPlainTextDataViewGeneratedAtUtc = generatedAt;
- this.previousPlainTextDataViewGeneratedAtElapsed = generatedAtElapsed;
+ this.ExitGlobalLock();
}
+ }
- previousDataViewGeneratedAtUtc = openMetricsRequested
- ? this.previousOpenMetricsDataViewGeneratedAtUtc
- : this.previousPlainTextDataViewGeneratedAtUtc;
-
- response = new CollectionResponse(this.previousOpenMetricsDataView, this.previousPlainTextDataView, previousDataViewGeneratedAtUtc!.Value, fromCache: false);
+ if (cachedResponse is { } collectionResponse)
+ {
+#if NET
+ return new ValueTask(collectionResponse);
+#else
+ return Task.FromResult(collectionResponse);
+#endif
}
- else
+
+ if (pendingCollectionTask is not null)
{
- response = default;
+#if NET
+ return this.WaitForCollectionResponseAsync(protocol, pendingCollectionTask, joinedActiveCollection);
+#else
+ return this.WaitForCollectionResponseAsync(protocol, pendingCollectionTask, joinedActiveCollection);
+#endif
}
+ var result = this.ExecuteCollect(activeCollectionContext!);
+
+ activeCollectionContext!.SetResult(result);
+
this.EnterGlobalLock();
try
{
- this.collectionRunning = false;
- this.collectionTcs?.SetResult(response);
- this.collectionTcs = null;
+ if (ReferenceEquals(this.collectionContext, activeCollectionContext))
+ {
+ this.collectionContext = null;
+ }
}
finally
{
this.ExitGlobalLock();
}
+ if (result.TryGetResponse(protocol, out collectionResponse))
+ {
+#if NET
+ return new ValueTask(collectionResponse);
+#else
+ return Task.FromResult(collectionResponse);
+#endif
+ }
+
#if NET
- return new ValueTask(response);
+ return new ValueTask(default(CollectionResponse));
#else
- return Task.FromResult(response);
+ return Task.FromResult(default(CollectionResponse));
#endif
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- public void ExitCollect()
- => Interlocked.Decrement(ref this.readerCount);
+ public void ExitCollect(PrometheusProtocol protocol)
+ => this.GetProtocolState(protocol).DecrementReaderCount();
- private static bool IncreaseBufferSize(ref byte[] buffer)
+#if NET
+ private async ValueTask WaitForCollectionResponseAsync(PrometheusProtocol protocol, Task pendingCollectionTask, bool protocolWasRegistered)
+#else
+ private async Task WaitForCollectionResponseAsync(PrometheusProtocol protocol, Task pendingCollectionTask, bool protocolWasRegistered)
+#endif
{
- var newBufferSize = buffer.Length * 2;
+ var collectionResult = await pendingCollectionTask.ConfigureAwait(false);
- if (newBufferSize > 100 * 1024 * 1024)
+ if (protocolWasRegistered &&
+ collectionResult.TryGetResponse(protocol, out var response))
{
- return false;
+ return response;
}
- var newBuffer = new byte[newBufferSize];
- buffer.CopyTo(newBuffer, 0);
- buffer = newBuffer;
+ if (protocolWasRegistered)
+ {
+ this.ExitCollect(protocol);
+ }
- return true;
+ return await this.EnterCollect(protocol).ConfigureAwait(false);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -183,7 +241,7 @@ private void EnterGlobalLock()
SpinWait lockWait = default;
while (true)
{
- if (Interlocked.CompareExchange(ref this.globalLockState, 1, this.globalLockState) != 0)
+ if (Interlocked.CompareExchange(ref this.globalLockState, 1, 0) != 0)
{
lockWait.SpinOnce();
continue;
@@ -198,46 +256,100 @@ private void ExitGlobalLock()
=> Interlocked.Exchange(ref this.globalLockState, 0);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- private void WaitForReadersToComplete()
+ private void IncrementReaderCount(PrometheusProtocol protocol)
+ => this.GetProtocolState(protocol).IncrementReaderCount();
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool HasActiveReaders(PrometheusProtocol protocol)
+ => this.protocolStates.TryGetValue(protocol, out var state) && state.HasActiveReaders();
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private bool WaitForReadersToComplete(PrometheusProtocol protocol)
{
+ var state = this.GetProtocolState(protocol);
+ var didSpin = false;
SpinWait readWait = default;
while (true)
{
- if (Interlocked.CompareExchange(ref this.readerCount, 0, 0) != 0)
+ if (!state.HasActiveReaders())
{
- readWait.SpinOnce();
- continue;
+ break;
}
- break;
+ this.EnterGlobalLock();
+
+ try
+ {
+ if (this.collectionContext is not null)
+ {
+ return true;
+ }
+ }
+ finally
+ {
+ this.ExitGlobalLock();
+ }
+
+ didSpin = true;
+ readWait.SpinOnce();
}
+
+ return didSpin;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- private bool ExecuteCollect(bool openMetricsRequested)
+ private CollectionResult ExecuteCollect(CollectionContext collectionContext)
{
+ this.onCollectContext = collectionContext;
+ this.collectionExecutionResult = default;
this.exporter.OnExport = this.onCollectRef;
- this.exporter.OpenMetricsRequested = openMetricsRequested;
try
{
- return this.exporter.Collect!(Timeout.Infinite);
+ var succeeded = this.exporter.Collect!(Timeout.Infinite);
+ return this.CreateCollectionResult(collectionContext, succeeded, this.collectionExecutionResult);
}
finally
{
this.exporter.OnExport = null;
+ this.onCollectContext = null;
}
}
private ExportResult OnCollect(in Batch metrics)
{
- var cursor = 0;
- ref var buffer = ref (this.exporter.OpenMetricsRequested ? ref this.openMetricsBuffer : ref this.plainTextBuffer);
- try
+ if (this.onCollectContext is not { } collectionContext)
{
- cursor = this.WriteTargetInfo(ref buffer);
+ this.collectionExecutionResult = default;
+ return ExportResult.Failure;
+ }
+
+ var protocols = collectionContext.FreezeProtocols();
+ HashSet? successfulProtocols = null;
+
+ foreach (var protocol in protocols)
+ {
+ var state = this.GetProtocolState(protocol);
+ if (this.TryWriteResponse(protocol, state, metrics))
+ {
+ successfulProtocols ??= [];
+ successfulProtocols.Add(protocol);
+ }
+ }
- var metricStates = this.GetMetricStates(metrics, this.exporter.OpenMetricsRequested);
+ this.collectionExecutionResult = new CollectionExecutionResult(protocols, successfulProtocols);
+
+ return successfulProtocols is { Count: > 0 }
+ ? ExportResult.Success
+ : ExportResult.Failure;
+ }
+
+ private bool TryWriteResponse(PrometheusProtocol protocol, PrometheusProtocolState state, in Batch metrics)
+ {
+ try
+ {
+ var cursor = this.WriteTargetInfo(protocol, state);
+ var metricStates = this.GetMetricStates(metrics, protocol.IsOpenMetrics);
foreach (var metricState in metricStates)
{
@@ -246,11 +358,11 @@ private ExportResult OnCollect(in Batch metrics)
try
{
cursor = PrometheusSerializer.WriteMetric(
- buffer,
+ state.Buffer,
cursor,
metricState.Metric,
metricState.PrometheusMetric,
- this.exporter.OpenMetricsRequested,
+ protocol.IsOpenMetrics,
metricState.WriteType,
metricState.WriteUnit,
metricState.WriteHelp,
@@ -261,7 +373,7 @@ private ExportResult OnCollect(in Batch metrics)
}
catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException)
{
- if (!IncreaseBufferSize(ref buffer))
+ if (!state.TryExpandBuffer())
{
throw;
}
@@ -273,72 +385,106 @@ private ExportResult OnCollect(in Batch metrics)
{
try
{
- cursor = PrometheusSerializer.WriteEof(buffer, cursor);
+ cursor = PrometheusSerializer.WriteEof(state.Buffer, cursor);
break;
}
catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException)
{
- if (!IncreaseBufferSize(ref buffer))
+ if (!state.TryExpandBuffer())
{
throw;
}
}
}
- if (this.exporter.OpenMetricsRequested)
- {
- this.previousOpenMetricsDataView = new ArraySegment(buffer, 0, cursor);
- }
- else
- {
- this.previousPlainTextDataView = new ArraySegment(buffer, 0, cursor);
- }
+ state.UpdateView(cursor);
- return ExportResult.Success;
+ return true;
}
catch (Exception ex)
{
- if (this.exporter.OpenMetricsRequested)
- {
- this.previousOpenMetricsDataView = new ArraySegment([], 0, 0);
- }
- else
- {
- this.previousPlainTextDataView = new ArraySegment([], 0, 0);
- }
+ state.ResetView();
PrometheusExporterEventSource.Log.FailedExport(ex);
- return ExportResult.Failure;
+ return false;
}
}
- private int WriteTargetInfo(ref byte[] buffer)
+ private CollectionResult CreateCollectionResult(CollectionContext collectionContext, bool succeeded, CollectionExecutionResult executionResult)
{
- ref var targetInfoBufferLength = ref this.exporter.OpenMetricsRequested
- ? ref this.openMetricsTargetInfoBufferLength
- : ref this.plainTextTargetInfoBufferLength;
+ var protocols = executionResult.Protocols ?? collectionContext.FreezeProtocols();
+ var responses = new Dictionary(protocols.Length);
- if (targetInfoBufferLength < 0)
+ if (succeeded)
{
- while (true)
+ var generatedAt = this.UtcNow();
+ var generatedAtElapsed = this.GetElapsedTime();
+ var successfulProtocols = executionResult.SuccessfulProtocols;
+
+ foreach (var protocol in protocols)
{
- try
+ if (successfulProtocols is not null &&
+ !successfulProtocols.Contains(protocol))
{
- targetInfoBufferLength = PrometheusSerializer.WriteTargetInfo(buffer, 0, this.exporter.Resource, this.exporter.OpenMetricsRequested);
- break;
+ continue;
}
- catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException)
+
+ ArraySegment view;
+
+ if (this.protocolStates.TryGetValue(protocol, out var state))
{
- if (!IncreaseBufferSize(ref buffer))
- {
- throw;
- }
+ state.UpdateTimestamps(generatedAt, generatedAtElapsed);
+ view = state.View;
+ }
+ else
+ {
+ view = PrometheusProtocolState.EmptyView;
}
+
+ responses[protocol] = new CollectionResponse(view, generatedAt, fromCache: false);
}
}
- return targetInfoBufferLength;
+ return new CollectionResult(responses);
+ }
+
+ private bool TryGetCachedResponse(PrometheusProtocol protocol, out CollectionResponse response)
+ {
+ if (this.protocolStates.TryGetValue(protocol, out var state) &&
+ state.GeneratedAt is { } generatedAt &&
+ state.GeneratedAtElapsed is { } generatedAtElapsed &&
+ this.scrapeResponseCacheDuration > TimeSpan.Zero &&
+ this.GetElapsedTime() - generatedAtElapsed < this.scrapeResponseCacheDuration)
+ {
+ response = new CollectionResponse(state.View, generatedAt, fromCache: true);
+ return true;
+ }
+
+ response = default;
+ return false;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private PrometheusProtocolState GetProtocolState(PrometheusProtocol protocol)
+ => this.protocolStates.GetOrAdd(protocol, static _ => new());
+
+ private int WriteTargetInfo(PrometheusProtocol protocol, PrometheusProtocolState state)
+ {
+ while (true)
+ {
+ try
+ {
+ return PrometheusSerializer.WriteTargetInfo(state.Buffer, 0, this.exporter.Resource, protocol.IsOpenMetrics);
+ }
+ catch (Exception ex) when (ex is IndexOutOfRangeException or ArgumentException)
+ {
+ if (!state.TryExpandBuffer())
+ {
+ throw;
+ }
+ }
+ }
}
private PrometheusMetric GetPrometheusMetric(Metric metric)
@@ -460,23 +606,41 @@ private List GetMetricStates(in Batch metrics, bool openMet
public readonly struct CollectionResponse
{
- public CollectionResponse(ArraySegment openMetricsView, ArraySegment plainTextView, DateTime generatedAtUtc, bool fromCache)
+ public CollectionResponse(ArraySegment view, DateTime generatedAtUtc, bool fromCache)
{
- this.OpenMetricsView = openMetricsView;
- this.PlainTextView = plainTextView;
+ this.View = view;
this.GeneratedAtUtc = generatedAtUtc;
this.FromCache = fromCache;
}
- public readonly ArraySegment OpenMetricsView { get; }
-
- public readonly ArraySegment PlainTextView { get; }
+ public readonly ArraySegment View { get; }
public readonly DateTime GeneratedAtUtc { get; }
public readonly bool FromCache { get; }
}
+ private readonly struct CollectionResult
+ {
+ private readonly IReadOnlyDictionary? responses;
+
+ public CollectionResult(IReadOnlyDictionary responses)
+ {
+ this.responses = responses;
+ }
+
+ public bool TryGetResponse(PrometheusProtocol protocol, out CollectionResponse response)
+ {
+ if (this.responses?.TryGetValue(protocol, out response) == true)
+ {
+ return true;
+ }
+
+ response = default;
+ return false;
+ }
+ }
+
private readonly struct MetricState
{
public MetricState(
@@ -543,4 +707,127 @@ public MetadataState(PrometheusType type, string? help, string? unit)
public readonly string? Unit { get; }
}
+
+ private readonly struct CollectionExecutionResult
+ {
+ public CollectionExecutionResult(PrometheusProtocol[] protocols, HashSet? successfulProtocols)
+ {
+ this.Protocols = protocols;
+ this.SuccessfulProtocols = successfulProtocols;
+ }
+
+ public PrometheusProtocol[] Protocols { get; }
+
+ public HashSet? SuccessfulProtocols { get; }
+ }
+
+ private sealed class CollectionContext
+ {
+ private readonly Lock gate = new();
+ private readonly HashSet protocols = [];
+ private readonly TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private bool frozen;
+
+ public CollectionContext(PrometheusProtocol protocol)
+ {
+ this.protocols.Add(protocol);
+ }
+
+ public Task Task => this.tcs.Task;
+
+ public PrometheusProtocol[] FreezeProtocols()
+ {
+ lock (this.gate)
+ {
+ this.frozen = true;
+ return [.. this.protocols];
+ }
+ }
+
+ public void SetResult(CollectionResult result)
+ => this.tcs.SetResult(result);
+
+ public bool TryRegisterProtocol(PrometheusProtocol protocol, bool hasActiveReaders)
+ {
+ lock (this.gate)
+ {
+ if (this.protocols.Contains(protocol))
+ {
+ return true;
+ }
+
+ if (this.frozen)
+ {
+ return false;
+ }
+
+ if (hasActiveReaders)
+ {
+ return false;
+ }
+
+ this.protocols.Add(protocol);
+ return true;
+ }
+ }
+ }
+
+ private sealed class PrometheusProtocolState
+ {
+ private const int InitialBufferSize = 85_000; // Encourage the object to live in Large Object Heap (LOH)
+ private const int MaxBufferSize = 100 * 1024 * 1024; // 100 MB
+
+ private int readerCount;
+
+ public static ArraySegment EmptyView { get; } =
+#if NET
+ ArraySegment.Empty;
+#else
+ new([]);
+#endif
+
+ public byte[] Buffer { get; private set; } = new byte[InitialBufferSize];
+
+ public ArraySegment View { get; private set; } = EmptyView;
+
+ public DateTime? GeneratedAt { get; private set; }
+
+ public TimeSpan? GeneratedAtElapsed { get; private set; }
+
+ public int DecrementReaderCount()
+ => Interlocked.Decrement(ref this.readerCount);
+
+ public bool HasActiveReaders()
+ => Interlocked.CompareExchange(ref this.readerCount, 0, 0) != 0;
+
+ public void IncrementReaderCount()
+ => Interlocked.Increment(ref this.readerCount);
+
+ public bool TryExpandBuffer()
+ {
+ var newBufferSize = this.Buffer.Length * 2;
+
+ if (newBufferSize > MaxBufferSize)
+ {
+ return false;
+ }
+
+ var expanded = new byte[newBufferSize];
+ this.Buffer.CopyTo(expanded, 0);
+ this.Buffer = expanded;
+
+ return true;
+ }
+
+ public void ResetView() => this.View = EmptyView;
+
+ public void UpdateView(int cursor)
+ => this.View = new ArraySegment(this.Buffer, 0, cursor);
+
+ public void UpdateTimestamps(DateTime timestamp, TimeSpan elapsed)
+ {
+ this.GeneratedAt = timestamp;
+ this.GeneratedAtElapsed = elapsed;
+ }
+ }
}
diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusExporter.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusExporter.cs
index 3aee061180b..1b274f761ab 100644
--- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusExporter.cs
+++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusExporter.cs
@@ -46,8 +46,6 @@ public PrometheusExporter(PrometheusExporterOptions options)
internal bool DisableTotalNameSuffixForCounters { get; }
- internal bool OpenMetricsRequested { get; set; }
-
internal Resource Resource
{
get => field ??= this.ParentProvider.GetResource();
diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusProtocol.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusProtocol.cs
index 72477a76116..8dc2252aedb 100644
--- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusProtocol.cs
+++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/Internal/Shared/PrometheusProtocol.cs
@@ -1,6 +1,8 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
using System.Text;
#if NET8_0_OR_GREATER
@@ -13,7 +15,7 @@
namespace OpenTelemetry.Exporter.Prometheus;
-internal readonly struct PrometheusProtocol
+internal readonly struct PrometheusProtocol : IEquatable
{
public const string AllowUtf8Escaping = "allow-utf-8";
public const string UnderscoresEscaping = "underscores";
@@ -21,12 +23,12 @@ internal readonly struct PrometheusProtocol
public const string OpenMetricsMediaType = "application/openmetrics-text";
public const string PrometheusTextMediaType = "text/plain";
- public static readonly Version PrometheusVersion0 = new(0, 0, 4);
- public static readonly Version PrometheusVersion1 = new(1, 0, 0);
+ public static readonly Version PrometheusV0 = new(0, 0, 4);
+ public static readonly Version PrometheusV1 = new(1, 0, 0);
public static readonly Version OpenMetricsV0 = new(0, 0, 1);
public static readonly Version OpenMetricsV1 = new(1, 0, 0);
- public static readonly PrometheusProtocol Fallback = new(PrometheusTextMediaType, null, PrometheusVersion0, false);
+ public static readonly PrometheusProtocol Fallback = new(PrometheusTextMediaType, null, PrometheusV0, false);
// TODO Support other escaping schemes, including at least "allow-utf-8".
// See https://github.com/open-telemetry/opentelemetry-dotnet/issues/7246.
@@ -43,8 +45,8 @@ internal readonly struct PrometheusProtocol
internal static readonly SupportedVersions SupportedPrometheusVersions =
[
- PrometheusVersion0,
- PrometheusVersion1,
+ PrometheusV0,
+ PrometheusV1,
];
public PrometheusProtocol(string mediaType, string? escaping, Version version, bool isOpenMetrics)
@@ -79,4 +81,41 @@ public static string GetContentType(PrometheusProtocol protocol)
return builder.ToString();
}
+
+ public bool Equals(PrometheusProtocol other)
+ => this.IsOpenMetrics == other.IsOpenMetrics &&
+ this.MediaType == other.MediaType &&
+ this.Escaping == other.Escaping &&
+ this.Version == other.Version;
+
+ public override bool Equals([NotNullWhen(true)] object? obj)
+ => obj is PrometheusProtocol other && this.Equals(other);
+
+ public override int GetHashCode()
+ {
+#if NET
+ return HashCode.Combine(this.MediaType, this.Escaping, this.IsOpenMetrics, this.Version);
+#else
+ var hashCode = this.MediaType.GetHashCode();
+
+ hashCode = (hashCode * 397) ^ (this.Escaping?.GetHashCode() ?? 0);
+ hashCode = (hashCode * 397) ^ this.IsOpenMetrics.GetHashCode();
+ hashCode = (hashCode * 397) ^ this.Version.GetHashCode();
+
+ return hashCode;
+#endif
+ }
+
+ public override string ToString() => GetContentType(this);
+
+ [Conditional("DEBUG")]
+ public void Validate()
+ {
+ // The values used to create a PrometheusProtocol should all be known and fixed values, not arbitrary values.
+ // Otherwise the number of different buffers used to write metrics to could be unbounded, which could lead to
+ // excessive memory usage when used to key the buffer dictionaries used in PrometheusCollectionManager.
+ Debug.Assert(this.MediaType is OpenMetricsMediaType or PrometheusTextMediaType, "The specified media type is not a known value.");
+ Debug.Assert(this.Escaping is null || SupportedEscapingSchemes.Contains(this.Escaping), "The specified escaping is not a known value.");
+ Debug.Assert(SupportedOpenMetricsVersions.Contains(this.Version) || SupportedPrometheusVersions.Contains(this.Version), "The specified version is not a known value.");
+ }
}
diff --git a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs
index be31dc65773..b7ededfa76c 100644
--- a/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs
+++ b/src/OpenTelemetry.Exporter.Prometheus.HttpListener/PrometheusHttpListener.cs
@@ -269,7 +269,7 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation
var protocol = Negotiate(context.Request);
- var collectionResponse = await this.exporter.CollectionManager.EnterCollect(protocol.IsOpenMetrics).ConfigureAwait(false);
+ var collectionResponse = await this.exporter.CollectionManager.EnterCollect(protocol).ConfigureAwait(false);
try
{
@@ -277,7 +277,7 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation
context.Response.Headers.Add("Server", string.Empty);
- var dataView = protocol.IsOpenMetrics ? collectionResponse.OpenMetricsView : collectionResponse.PlainTextView;
+ var dataView = collectionResponse.View;
if (dataView.Count > 0)
{
@@ -310,7 +310,7 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation
}
finally
{
- this.exporter.CollectionManager.ExitCollect();
+ this.exporter.CollectionManager.ExitCollect(protocol);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
diff --git a/src/Shared/Shims/Lock.cs b/src/Shared/Shims/Lock.cs
index c6dc7c9f880..a51d4eb4db1 100644
--- a/src/Shared/Shims/Lock.cs
+++ b/src/Shared/Shims/Lock.cs
@@ -12,7 +12,5 @@ namespace OpenTelemetry;
// sees it used. It is in OpenTelemetry namespace and not OpenTelemetry.Internal
// namespace so that code should be able to use it without the presence of a
// dedicated "using OpenTelemetry.Internal" just for the shim.
-internal sealed class Lock
-{
-}
+internal sealed class Lock;
#endif
diff --git a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj
index 8fbcdaf846e..4faae6df365 100644
--- a/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj
+++ b/test/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests/OpenTelemetry.Exporter.Prometheus.AspNetCore.Tests.csproj
@@ -34,6 +34,7 @@
+
diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs
index ea988edc648..e90a19c741b 100644
--- a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs
+++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusCollectionManagerTests.cs
@@ -78,18 +78,20 @@ async Task CollectAsync(bool advanceClock)
utcNow = utcNow.AddMilliseconds(1);
}
- var response = await exporter.CollectionManager.EnterCollect(openMetricsRequested);
+ var protocol = GetProtocol(openMetricsRequested);
+ var response = await exporter.CollectionManager.EnterCollect(protocol);
+
try
{
return new()
{
CollectionResponse = response,
- ViewPayload = openMetricsRequested ? [.. response.OpenMetricsView] : [.. response.PlainTextView],
+ ViewPayload = [.. response.View],
};
}
finally
{
- exporter.CollectionManager.ExitCollect();
+ exporter.CollectionManager.ExitCollect(protocol);
}
}
@@ -149,10 +151,12 @@ async Task[]> CollectInParallelAsync(bool advanceClock)
counter.Add(100);
+ var protocol = GetProtocol(openMetricsRequested);
try
{
// This should use the cache and ignore the second counter update.
- var task = exporter.CollectionManager.EnterCollect(openMetricsRequested);
+ var task = exporter.CollectionManager.EnterCollect(protocol);
+
Assert.True(task.IsCompleted, "Collection did not complete.");
var response = await task;
@@ -171,7 +175,7 @@ async Task[]> CollectInParallelAsync(bool advanceClock)
}
finally
{
- exporter.CollectionManager.ExitCollect();
+ exporter.CollectionManager.ExitCollect(protocol);
}
if (cacheEnabled)
@@ -235,7 +239,9 @@ public async Task EnterCollectWaitsForActiveReadersToExit()
var counter = meter.CreateCounter("counter_int");
counter.Add(100);
- var firstResponse = await exporter.CollectionManager.EnterCollect(openMetricsRequested: false);
+ var protocol = GetProtocol(openMetricsRequested: false);
+
+ var firstResponse = await exporter.CollectionManager.EnterCollect(protocol);
var firstCollectExited = false;
try
{
@@ -245,14 +251,14 @@ public async Task EnterCollectWaitsForActiveReadersToExit()
var secondCollectTask = Task.Run(async () =>
{
secondCollectStarted.SetResult(true);
- var response = await exporter.CollectionManager.EnterCollect(openMetricsRequested: false);
+ var response = await exporter.CollectionManager.EnterCollect(protocol);
try
{
return response;
}
finally
{
- exporter.CollectionManager.ExitCollect();
+ exporter.CollectionManager.ExitCollect(protocol);
}
});
@@ -269,7 +275,7 @@ public async Task EnterCollectWaitsForActiveReadersToExit()
Assert.Equal(1, collectCount);
- exporter.CollectionManager.ExitCollect();
+ exporter.CollectionManager.ExitCollect(protocol);
firstCollectExited = true;
var secondTimeout = TimeSpan.FromSeconds(5);
@@ -290,9 +296,202 @@ public async Task EnterCollectWaitsForActiveReadersToExit()
{
if (!firstCollectExited)
{
- exporter.CollectionManager.ExitCollect();
+ exporter.CollectionManager.ExitCollect(protocol);
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task EnterCollectSharesActiveCollectionAcrossProtocols(bool firstOpenMetricsRequested)
+ {
+ using var meter = CreateMeter();
+#if PROMETHEUS_HTTP_LISTENER
+ using var provider = CreateMeterProviderWithRandomPort(meter);
+#elif PROMETHEUS_ASPNETCORE
+ using var provider = Sdk.CreateMeterProviderBuilder()
+ .AddMeter(meter.Name)
+ .AddPrometheusExporter(options => options.ScrapeResponseCacheDurationMilliseconds = 0)
+ .Build();
+#endif
+
+#pragma warning disable CA2000 // MeterProvider owns exporter lifecycle
+ if (!provider.TryFindExporter(out PrometheusExporter? exporter))
+#pragma warning restore CA2000 // MeterProvider owns exporter lifecycle
+ {
+ throw new InvalidOperationException("PrometheusExporter could not be found on MeterProvider.");
+ }
+
+ var collectCount = 0;
+ var firstCollectStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var secondCollectStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var allowFirstCollectToContinue = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var originalCollect = exporter.Collect;
+ exporter.Collect = (timeout) =>
+ {
+ var currentCollectCount = Interlocked.Increment(ref collectCount);
+
+ if (currentCollectCount == 1)
+ {
+ firstCollectStarted.SetResult(true);
+
+ var completed = allowFirstCollectToContinue.Task.Wait(TimeSpan.FromSeconds(5));
+ Assert.True(completed, "First collection did not resume.");
+ }
+
+ return originalCollect!(timeout);
+ };
+
+ var counter = meter.CreateCounter("counter_int");
+ counter.Add(100);
+
+ var firstProtocol = GetProtocol(firstOpenMetricsRequested);
+ var secondProtocol = GetProtocol(!firstOpenMetricsRequested);
+
+ var firstCollectTask = Task.Run(async () => await EnterCollectAsync(exporter, firstProtocol));
+
+ await firstCollectStarted.Task;
+
+#pragma warning disable CA2025 // The test awaits the scheduled work before disposing the provider/exporter.
+ var secondCollectTask = Task.Run(async () =>
+ {
+ var collectTask = EnterCollectAsync(exporter, secondProtocol);
+ secondCollectStarted.SetResult(true);
+ return await collectTask;
+ });
+#pragma warning restore CA2025 // The test awaits the scheduled work before disposing the provider/exporter.
+
+ await secondCollectStarted.Task;
+
+ Assert.False(secondCollectTask.IsCompleted, "Second collection completed while the first protocol was still collecting.");
+
+ allowFirstCollectToContinue.SetResult(true);
+
+ var timeout = TimeSpan.FromSeconds(5);
+
+ using (var cts = new CancellationTokenSource(timeout))
+ {
+ var all = Task.WhenAll(firstCollectTask, secondCollectTask);
+ var completion = await Task.WhenAny(all, Task.Delay(timeout, cts.Token));
+ Assert.Same(all, completion);
+ }
+
+ var firstResponse = await firstCollectTask;
+ var secondResponse = await secondCollectTask;
+
+ try
+ {
+ Assert.Equal(1, collectCount);
+ Assert.Equal(firstResponse.GeneratedAtUtc, secondResponse.GeneratedAtUtc);
+
+ var firstPayload = Encoding.UTF8.GetString(firstResponse.View.Array!, firstResponse.View.Offset, firstResponse.View.Count);
+ var secondPayload = Encoding.UTF8.GetString(secondResponse.View.Array!, secondResponse.View.Offset, secondResponse.View.Count);
+
+ Assert.NotEqual(firstPayload, secondPayload);
+
+ var openMetricsPayload = firstOpenMetricsRequested ? firstPayload : secondPayload;
+ var prometheusPayload = firstOpenMetricsRequested ? secondPayload : firstPayload;
+
+ Assert.Contains("# TYPE counter_int counter", openMetricsPayload, StringComparison.Ordinal);
+ Assert.Contains("counter_int_created", openMetricsPayload, StringComparison.Ordinal);
+ Assert.Contains("# TYPE counter_int_total counter", prometheusPayload, StringComparison.Ordinal);
+ Assert.DoesNotContain("counter_int_created", prometheusPayload, StringComparison.Ordinal);
+ }
+ finally
+ {
+ exporter.CollectionManager.ExitCollect(firstProtocol);
+ exporter.CollectionManager.ExitCollect(secondProtocol);
+ }
+ }
+
+ [Fact]
+ public async Task EnterCollectRetriesAfterFailedSharedCollection()
+ {
+ using var meter = CreateMeter();
+#if PROMETHEUS_HTTP_LISTENER
+ using var provider = CreateMeterProviderWithRandomPort(meter);
+#elif PROMETHEUS_ASPNETCORE
+ using var provider = Sdk.CreateMeterProviderBuilder()
+ .AddMeter(meter.Name)
+ .AddPrometheusExporter(options => options.ScrapeResponseCacheDurationMilliseconds = 0)
+ .Build();
+#endif
+
+#pragma warning disable CA2000 // MeterProvider owns exporter lifecycle
+ if (!provider.TryFindExporter(out PrometheusExporter? exporter))
+#pragma warning restore CA2000 // MeterProvider owns exporter lifecycle
+ {
+ throw new InvalidOperationException("PrometheusExporter could not be found on MeterProvider.");
+ }
+
+ var collectCount = 0;
+ var firstCollectStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var allowFirstCollectToComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var originalCollect = exporter.Collect;
+ exporter.Collect = (timeout) =>
+ {
+ var currentCollectCount = Interlocked.Increment(ref collectCount);
+
+ if (currentCollectCount == 1)
+ {
+ firstCollectStarted.SetResult(true);
+ Assert.True(allowFirstCollectToComplete.Task.Wait(TimeSpan.FromSeconds(5)), "First collection did not resume.");
+ return false;
}
+
+ return originalCollect!(timeout);
+ };
+
+ meter.CreateCounter("counter_int").Add(100);
+
+ var protocol = GetProtocol(openMetricsRequested: false);
+
+ var firstCollectTask = Task.Run(async () =>
+ {
+ var response = await EnterCollectAsync(exporter, protocol);
+ try
+ {
+ return response;
+ }
+ finally
+ {
+ exporter.CollectionManager.ExitCollect(protocol);
+ }
+ });
+
+ await firstCollectStarted.Task;
+
+ var secondCollectTask = Task.Run(async () =>
+ {
+ var response = await EnterCollectAsync(exporter, protocol);
+ try
+ {
+ return response;
+ }
+ finally
+ {
+ exporter.CollectionManager.ExitCollect(protocol);
+ }
+ });
+
+ allowFirstCollectToComplete.SetResult(true);
+
+ var timeout = TimeSpan.FromSeconds(5);
+
+ using (var cts = new CancellationTokenSource(timeout))
+ {
+ var all = Task.WhenAll(firstCollectTask, secondCollectTask);
+ var completion = await Task.WhenAny(all, Task.Delay(timeout, cts.Token));
+ Assert.Same(all, completion);
}
+
+ var firstResponse = await firstCollectTask;
+ var secondResponse = await secondCollectTask;
+
+ Assert.Equal(2, collectCount);
+ Assert.Equal(0, firstResponse.View.Count);
+ Assert.True(secondResponse.View.Count > 0);
}
[Fact]
@@ -316,19 +515,21 @@ public async Task OpenMetricsDoesNotEmitScopeInfoMetricFamily()
meter.CreateCounter("counter_1").Add(1);
- var response = await exporter!.CollectionManager.EnterCollect(openMetricsRequested: true);
+ var protocol = GetProtocol(openMetricsRequested: true);
+ var response = await exporter!.CollectionManager.EnterCollect(protocol);
+
try
{
var output = Encoding.UTF8.GetString(
- response.OpenMetricsView.Array!,
- response.OpenMetricsView.Offset,
- response.OpenMetricsView.Count);
+ response.View.Array!,
+ response.View.Offset,
+ response.View.Count);
await Verify(output, "txt", PrometheusSerializerTests.VerifySettings);
}
finally
{
- exporter.CollectionManager.ExitCollect();
+ exporter.CollectionManager.ExitCollect(protocol);
}
}
@@ -354,19 +555,21 @@ public async Task OpenMetricsDoesNotReserveOtelScopeMetricFamilyNames()
meter.CreateObservableGauge("otel.scope", () => 1);
meter.CreateObservableGauge("otel.scope.info", () => 2);
- var response = await exporter!.CollectionManager.EnterCollect(openMetricsRequested: true);
+ var protocol = GetProtocol(openMetricsRequested: true);
+ var response = await exporter!.CollectionManager.EnterCollect(protocol);
+
try
{
var output = Encoding.UTF8.GetString(
- response.OpenMetricsView.Array!,
- response.OpenMetricsView.Offset,
- response.OpenMetricsView.Count);
+ response.View.Array!,
+ response.View.Offset,
+ response.View.Count);
await Verify(output, "txt", PrometheusSerializerTests.VerifySettings);
}
finally
{
- exporter.CollectionManager.ExitCollect();
+ exporter.CollectionManager.ExitCollect(protocol);
}
}
@@ -395,17 +598,19 @@ public async Task DuplicateMetricMetadataIsWrittenOncePerScrape()
counter1.Add(1, [new("source", "a")]);
counter2.Add(2, [new("source", "b")]);
- var response = await exporter!.CollectionManager.EnterCollect(openMetricsRequested: false);
+ var protocol = GetProtocol(openMetricsRequested: false);
+ var response = await exporter!.CollectionManager.EnterCollect(protocol);
+
try
{
- var view = response.PlainTextView;
+ var view = response.View;
var output = Encoding.UTF8.GetString(view.Array!, view.Offset, view.Count);
await Verify(output, "txt", PrometheusSerializerTests.VerifySettings);
}
finally
{
- exporter.CollectionManager.ExitCollect();
+ exporter.CollectionManager.ExitCollect(protocol);
}
}
@@ -434,17 +639,19 @@ public async Task MetricMetadataDiscoveredLaterIsWrittenBeforeSamples()
counter1.Add(1, [new("source", "a")]);
counter2.Add(2, [new("source", "b")]);
- var response = await exporter!.CollectionManager.EnterCollect(openMetricsRequested: false);
+ var protocol = GetProtocol(openMetricsRequested: false);
+ var response = await exporter!.CollectionManager.EnterCollect(protocol);
+
try
{
- var view = response.PlainTextView;
+ var view = response.View;
var output = Encoding.UTF8.GetString(view.Array!, view.Offset, view.Count);
await Verify(output, "txt", PrometheusSerializerTests.VerifySettings);
}
finally
{
- exporter.CollectionManager.ExitCollect();
+ exporter.CollectionManager.ExitCollect(protocol);
}
}
@@ -473,17 +680,19 @@ public async Task MetricUnitDiscoveredLaterIsWrittenBeforeSamples()
counter1.Add(1, [new("source", "a")]);
counter2.Add(2, [new("source", "b")]);
- var response = await exporter!.CollectionManager.EnterCollect(openMetricsRequested: false);
+ var protocol = GetProtocol(openMetricsRequested: false);
+ var response = await exporter!.CollectionManager.EnterCollect(protocol);
+
try
{
- var view = response.PlainTextView;
+ var view = response.View;
var output = Encoding.UTF8.GetString(view.Array!, view.Offset, view.Count);
await Verify(output, "txt", PrometheusSerializerTests.VerifySettings);
}
finally
{
- exporter.CollectionManager.ExitCollect();
+ exporter.CollectionManager.ExitCollect(protocol);
}
}
@@ -512,17 +721,19 @@ public async Task MetricHelpAndUnitDiscoveredTogetherLaterAreBothWrittenBeforeSa
counter1.Add(1, [new("source", "a")]);
counter2.Add(2, [new("source", "b")]);
- var response = await exporter!.CollectionManager.EnterCollect(openMetricsRequested: false);
+ var protocol = GetProtocol(openMetricsRequested: false);
+ var response = await exporter!.CollectionManager.EnterCollect(protocol);
+
try
{
- var view = response.PlainTextView;
+ var view = response.View;
var output = Encoding.UTF8.GetString(view.Array!, view.Offset, view.Count);
await Verify(output, "txt", PrometheusSerializerTests.VerifySettings);
}
finally
{
- exporter.CollectionManager.ExitCollect();
+ exporter.CollectionManager.ExitCollect(protocol);
}
}
@@ -549,17 +760,19 @@ public async Task ConflictingMetricTypesAreDroppedFromAScrape()
meter.CreateObservableGauge("test-metric", () => 1);
counter.Add(1);
- var response = await exporter!.CollectionManager.EnterCollect(openMetricsRequested: true);
+ var protocol = GetProtocol(openMetricsRequested: true);
+ var response = await exporter!.CollectionManager.EnterCollect(protocol);
+
try
{
- var view = response.OpenMetricsView;
+ var view = response.View;
var output = Encoding.UTF8.GetString(view.Array!, view.Offset, view.Count);
await Verify(output, "txt", PrometheusSerializerTests.VerifySettings);
}
finally
{
- exporter.CollectionManager.ExitCollect();
+ exporter.CollectionManager.ExitCollect(protocol);
}
}
@@ -589,20 +802,35 @@ public async Task OpenMetricsWritesMetricFamiliesContiguously()
meter1.CreateObservableGauge("other.metric", () => 3, description: "Other help");
meter2.CreateObservableGauge("test-metric", () => 2, description: "Test help");
- var response = await exporter!.CollectionManager.EnterCollect(openMetricsRequested: true);
+ var protocol = GetProtocol(openMetricsRequested: true);
+ var response = await exporter!.CollectionManager.EnterCollect(protocol);
+
try
{
- var view = response.OpenMetricsView;
+ var view = response.View;
var output = Encoding.UTF8.GetString(view.Array!, view.Offset, view.Count);
await Verify(output, "txt", PrometheusSerializerTests.VerifySettings);
}
finally
{
- exporter.CollectionManager.ExitCollect();
+ exporter.CollectionManager.ExitCollect(protocol);
}
}
+ private static PrometheusProtocol GetProtocol(bool openMetricsRequested) => new(
+ mediaType: openMetricsRequested ? PrometheusProtocol.OpenMetricsMediaType : PrometheusProtocol.PrometheusTextMediaType,
+ escaping: PrometheusProtocol.UnderscoresEscaping,
+ version: openMetricsRequested ? PrometheusProtocol.OpenMetricsV1 : PrometheusProtocol.PrometheusV1,
+ isOpenMetrics: openMetricsRequested);
+
+ private static Task EnterCollectAsync(PrometheusExporter exporter, PrometheusProtocol protocol) =>
+#if NET
+ exporter.CollectionManager.EnterCollect(protocol).AsTask();
+#else
+ exporter.CollectionManager.EnterCollect(protocol);
+#endif
+
private static Meter CreateMeter([CallerMemberName] string name = "") => new(name);
#if PROMETHEUS_HTTP_LISTENER
diff --git a/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusProtocolTests.cs b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusProtocolTests.cs
new file mode 100644
index 00000000000..2dff54d01ae
--- /dev/null
+++ b/test/OpenTelemetry.Exporter.Prometheus.HttpListener.Tests/PrometheusProtocolTests.cs
@@ -0,0 +1,714 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+namespace OpenTelemetry.Exporter.Prometheus.Tests;
+
+public class PrometheusProtocolTests
+{
+ [Fact]
+ public void Equals_ReturnsTrueForIdenticalInstances()
+ {
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ Assert.True(first.Equals(second));
+ Assert.True(second.Equals(first));
+ }
+
+ [Fact]
+ public void Equals_ReturnsTrueForSameInstance()
+ {
+ var protocol = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ null,
+ PrometheusProtocol.OpenMetricsV1,
+ true);
+
+ Assert.True(protocol.Equals(protocol));
+ }
+
+ [Fact]
+ public void Equals_ReturnsFalseForDifferentMediaType()
+ {
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ true);
+
+ Assert.False(first.Equals(second));
+ Assert.False(second.Equals(first));
+ }
+
+ [Fact]
+ public void Equals_ReturnsFalseForDifferentEscaping()
+ {
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ Assert.False(first.Equals(second));
+ Assert.False(second.Equals(first));
+ }
+
+ [Fact]
+ public void Equals_ReturnsFalseForDifferentVersion()
+ {
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV0,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ Assert.False(first.Equals(second));
+ Assert.False(second.Equals(first));
+ }
+
+ [Fact]
+ public void Equals_ReturnsFalseForDifferentIsOpenMetrics()
+ {
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ true);
+
+ Assert.False(first.Equals(second));
+ Assert.False(second.Equals(first));
+ }
+
+ [Fact]
+ public void Equals_ReturnsTrueForIdenticalInstancesWithNullEscaping()
+ {
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ null,
+ PrometheusProtocol.OpenMetricsV1,
+ true);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ null,
+ PrometheusProtocol.OpenMetricsV1,
+ true);
+
+ Assert.True(first.Equals(second));
+ Assert.True(second.Equals(first));
+ }
+
+ [Fact]
+ public void Equals_Object_ReturnsTrueForIdenticalInstances()
+ {
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ object second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ Assert.True(first.Equals(second));
+ }
+
+ [Fact]
+ public void Equals_Object_ReturnsFalseForNull()
+ {
+ var protocol = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ Assert.False(protocol.Equals(null));
+ }
+
+ [Fact]
+ public void Equals_Object_ReturnsFalseForDifferentType()
+ {
+ var protocol = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ Assert.False(protocol.Equals("not a PrometheusProtocol"));
+ Assert.False(protocol.Equals(42));
+ }
+
+ [Fact]
+ public void GetHashCode_ReturnsSameValueForEqualInstances()
+ {
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ Assert.Equal(first.GetHashCode(), second.GetHashCode());
+ }
+
+ [Fact]
+ public void GetHashCode_ReturnsSameValueForSameInstance()
+ {
+ var protocol = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ null,
+ PrometheusProtocol.OpenMetricsV1,
+ true);
+
+ var hash1 = protocol.GetHashCode();
+ var hash2 = protocol.GetHashCode();
+
+ Assert.Equal(hash1, hash2);
+ }
+
+ [Fact]
+ public void GetHashCode_ReturnsSameValueForIdenticalInstancesWithNullEscaping()
+ {
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ null,
+ PrometheusProtocol.OpenMetricsV1,
+ true);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ null,
+ PrometheusProtocol.OpenMetricsV1,
+ true);
+
+ Assert.Equal(first.GetHashCode(), second.GetHashCode());
+ }
+
+ [Fact]
+ public void GetHashCode_DifferentForDifferentMediaType()
+ {
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ null,
+ PrometheusProtocol.OpenMetricsV1,
+ true);
+
+ // Hash codes should be different (but technically could collide)
+ // We're just verifying they don't throw and are consistent
+ var hash1 = first.GetHashCode();
+ var hash2 = second.GetHashCode();
+
+ Assert.NotEqual(hash1, hash2);
+ }
+
+ [Fact]
+ public void GetHashCode_DifferentForDifferentEscaping()
+ {
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var hash1 = first.GetHashCode();
+ var hash2 = second.GetHashCode();
+
+ Assert.NotEqual(hash1, hash2);
+ }
+
+ [Fact]
+ public void GetHashCode_DifferentForDifferentVersion()
+ {
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV0,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var hash1 = first.GetHashCode();
+ var hash2 = second.GetHashCode();
+
+ Assert.NotEqual(hash1, hash2);
+ }
+
+ [Fact]
+ public void GetHashCode_DifferentForDifferentIsOpenMetrics()
+ {
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ true);
+
+ var hash1 = first.GetHashCode();
+ var hash2 = second.GetHashCode();
+
+ Assert.NotEqual(hash1, hash2);
+ }
+
+ [Fact]
+ public void Equals_WorksWithFallbackConstant()
+ {
+ var fallback1 = PrometheusProtocol.Fallback;
+ var fallback2 = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV0,
+ false);
+
+ Assert.True(fallback1.Equals(fallback2));
+ Assert.True(fallback2.Equals(fallback1));
+ Assert.Equal(fallback1.GetHashCode(), fallback2.GetHashCode());
+ }
+
+ [Theory]
+ [InlineData(PrometheusProtocol.PrometheusTextMediaType, null, false)]
+ [InlineData(PrometheusProtocol.PrometheusTextMediaType, "underscores", false)]
+ [InlineData(PrometheusProtocol.OpenMetricsMediaType, null, true)]
+ [InlineData(PrometheusProtocol.OpenMetricsMediaType, "underscores", true)]
+ public void Equals_EqualsOperator_ConsistentWithEquals(string mediaType, string? escaping, bool isOpenMetrics)
+ {
+ var version = isOpenMetrics ? PrometheusProtocol.OpenMetricsV1 : PrometheusProtocol.PrometheusV1;
+
+ var first = new PrometheusProtocol(mediaType, escaping, version, isOpenMetrics);
+ var second = new PrometheusProtocol(mediaType, escaping, version, isOpenMetrics);
+
+ // For structs, == operator needs to be implemented separately, but Equals should work
+ Assert.True(first.Equals(second));
+ }
+
+ [Fact]
+ public void CanBeUsedAsDictionaryKey()
+ {
+ var dictionary = new Dictionary();
+
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.OpenMetricsV1,
+ true);
+
+ dictionary[first] = "prometheus";
+ dictionary[second] = "openmetrics";
+
+ Assert.Equal("prometheus", dictionary[first]);
+ Assert.Equal("openmetrics", dictionary[second]);
+ Assert.Equal(2, dictionary.Count);
+ }
+
+ [Fact]
+ public void CanBeUsedInHashSet()
+ {
+ var hashSet = new HashSet();
+
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var third = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ null,
+ PrometheusProtocol.OpenMetricsV1,
+ true);
+
+ Assert.True(hashSet.Add(first));
+ Assert.False(hashSet.Add(second)); // Should be considered duplicate
+ Assert.True(hashSet.Add(third));
+
+ Assert.Equal(2, hashSet.Count);
+ Assert.Contains(first, hashSet);
+ Assert.Contains(second, hashSet); // Should find first
+ Assert.Contains(third, hashSet);
+ }
+
+ // Tests for short-circuit evaluation in Equals method
+ [Fact]
+ public void Equals_ShortCircuitsOnIsOpenMetrics()
+ {
+ // Test that if IsOpenMetrics differs, other properties don't matter
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ true);
+
+ Assert.False(first.Equals(second));
+ }
+
+ [Fact]
+ public void Equals_ShortCircuitsOnMediaType()
+ {
+ // Test that if IsOpenMetrics matches but MediaType differs, other properties don't matter
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ Assert.False(first.Equals(second));
+ }
+
+ [Fact]
+ public void Equals_ShortCircuitsOnEscaping()
+ {
+ // Test that if IsOpenMetrics and MediaType match but Escaping differs, Version doesn't matter
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ Assert.False(first.Equals(second));
+ }
+
+ [Fact]
+ public void Equals_ChecksAllPropertiesWhenPreviousMatch()
+ {
+ // Ensure all properties are checked (Version is last)
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV0,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ Assert.False(first.Equals(second));
+ }
+
+ [Fact]
+ public void Equals_ReturnsFalseForMultipleDifferentProperties()
+ {
+ // Test when multiple properties differ
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV0,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.OpenMetricsV1,
+ true);
+
+ Assert.False(first.Equals(second));
+ Assert.False(second.Equals(first));
+ }
+
+ [Fact]
+ public void Equals_HandlesEscapingNullVsEmptyString()
+ {
+ // Test null vs empty string for Escaping property
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ string.Empty,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ // null and empty string should be treated as different
+ Assert.False(first.Equals(second));
+ }
+
+ [Fact]
+ public void Equals_HandlesBothEscapingNonNull()
+ {
+ // Test both with non-null escaping but different values
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.AllowUtf8Escaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ Assert.False(first.Equals(second));
+ }
+
+ [Fact]
+ public void GetHashCode_IsConsistentAcrossMultipleCalls()
+ {
+ var protocol = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var hash1 = protocol.GetHashCode();
+ var hash2 = protocol.GetHashCode();
+ var hash3 = protocol.GetHashCode();
+
+ Assert.Equal(hash1, hash2);
+ Assert.Equal(hash2, hash3);
+ }
+
+ [Fact]
+ public void GetHashCode_HandlesNullEscaping()
+ {
+ // Ensure GetHashCode handles null escaping without throwing
+ var protocol = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var hash = protocol.GetHashCode();
+
+ // Should not throw and should be consistent
+ Assert.Equal(hash, protocol.GetHashCode());
+ }
+
+ [Fact]
+ public void GetHashCode_DifferentForNullVsEmptyStringEscaping()
+ {
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ string.Empty,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var hash1 = first.GetHashCode();
+ var hash2 = second.GetHashCode();
+
+ // Hash codes should differ for null vs empty string
+ Assert.NotEqual(hash1, hash2);
+ }
+
+ [Fact]
+ public void Equals_Symmetry()
+ {
+ // Test symmetry: if x.Equals(y), then y.Equals(x)
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.OpenMetricsV1,
+ true);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.OpenMetricsV1,
+ true);
+
+ Assert.Equal(first.Equals(second), second.Equals(first));
+ }
+
+ [Fact]
+ public void Equals_Transitivity()
+ {
+ // Test transitivity: if x.Equals(y) and y.Equals(z), then x.Equals(z)
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV0,
+ false);
+
+ var second = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV0,
+ false);
+
+ var protocol3 = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV0,
+ false);
+
+ Assert.True(first.Equals(second));
+ Assert.True(second.Equals(protocol3));
+ Assert.True(first.Equals(protocol3));
+ }
+
+ [Fact]
+ public void GetHashCode_DifferentVersionsProduceDifferentHashes()
+ {
+ // Test all version combinations
+ var prometheusV0 = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV0,
+ false);
+
+ var prometheusV1 = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ null,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ var openMetricsV0 = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ null,
+ PrometheusProtocol.OpenMetricsV0,
+ true);
+
+ var openMetricsV1 = new PrometheusProtocol(
+ PrometheusProtocol.OpenMetricsMediaType,
+ null,
+ PrometheusProtocol.OpenMetricsV1,
+ true);
+
+ var hashes = new[]
+ {
+ prometheusV0.GetHashCode(),
+ prometheusV1.GetHashCode(),
+ openMetricsV0.GetHashCode(),
+ openMetricsV1.GetHashCode(),
+ };
+
+ // All hash codes should be different
+ Assert.Equal(4, hashes.Distinct().Count());
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void Equals_HandlesIsOpenMetricsCorrectly(bool isOpenMetrics)
+ {
+ var mediaType = isOpenMetrics ? PrometheusProtocol.OpenMetricsMediaType : PrometheusProtocol.PrometheusTextMediaType;
+ var version = isOpenMetrics ? PrometheusProtocol.OpenMetricsV1 : PrometheusProtocol.PrometheusV1;
+
+ var first = new PrometheusProtocol(mediaType, null, version, isOpenMetrics);
+ var second = new PrometheusProtocol(mediaType, null, version, isOpenMetrics);
+
+ Assert.True(first.Equals(second));
+ }
+
+ [Fact]
+ public void Equals_Object_BoxingScenario()
+ {
+ // Test boxing scenario where struct is cast to object
+ var first = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ object boxed1 = first;
+ object boxed2 = new PrometheusProtocol(
+ PrometheusProtocol.PrometheusTextMediaType,
+ PrometheusProtocol.UnderscoresEscaping,
+ PrometheusProtocol.PrometheusV1,
+ false);
+
+ Assert.True(boxed1.Equals(boxed2));
+ Assert.True(boxed2.Equals(boxed1));
+ }
+}