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)); + } +}