diff --git a/.gitattributes b/.gitattributes index 1ff0c423..7b17c9b1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,9 @@ ############################################################################### * text=auto +# Linux scripts should have Linux file endings. +*.sh text eol=lf + ############################################################################### # Set default behavior for command prompt diff. # diff --git a/Benchmark.NetCore/AspNetCoreExporterBenchmarks.cs b/Benchmark.NetCore/AspNetCoreExporterBenchmarks.cs new file mode 100644 index 00000000..0fd98f6b --- /dev/null +++ b/Benchmark.NetCore/AspNetCoreExporterBenchmarks.cs @@ -0,0 +1,84 @@ +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Prometheus; + +namespace Benchmark.NetCore; + +/// +/// We start up a real(ish) ASP.NET Core web server stack to measure exporter performance end to end. +/// +[MemoryDiagnoser] +public class AspNetCoreExporterBenchmarks +{ + static AspNetCoreExporterBenchmarks() + { + // We use the global singleton metrics registry here, so just populate it with some data. + // Not too much data - we care more about the overhead and do not want to just inflate the numbers. + for (var i = 0; i < 5; i++) + Metrics.CreateGauge("dummy_metric_" + i, "For benchmark purposes", "label1", "label2", "label3").WithLabels("1", "2", "3").Inc(); + } + + [GlobalSetup] + public void Setup() + { + var builder = new WebHostBuilder().UseStartup(); + _server = new TestServer(builder); + _client = _server.CreateClient(); + + // Warmup the ASP.NET Core stack to avoid measuring the ASP.NET Core web server itself in benchmarks. + _client.GetAsync("/metrics").GetAwaiter().GetResult(); + } + + [GlobalCleanup] + public void Cleanup() + { + _client?.Dispose(); + _server?.Dispose(); + } + + private TestServer _server; + private HttpClient _client; + + private sealed class EntryPoint + { +#pragma warning disable CA1822 // Mark members as static + public void ConfigureServices(IServiceCollection services) + { + services.AddRouting(); + } + + public void Configure(IApplicationBuilder app) + { + app.UseRouting(); + + app.UseHttpMetrics(); + + app.UseEndpoints(endpoints => + { + endpoints.MapMetrics(); + + endpoints.MapGet("ok", context => + { + context.Response.StatusCode = 200; + return Task.CompletedTask; + }); + }); + } +#pragma warning restore CA1822 // Mark members as static + } + + [Benchmark] + public async Task GetMetrics() + { + await _client.GetAsync("/metrics"); + } + + [Benchmark] + public async Task Get200Ok() + { + await _client.GetAsync("/ok"); + } +} diff --git a/Benchmark.NetCore/Benchmark.NetCore.csproj b/Benchmark.NetCore/Benchmark.NetCore.csproj index 1279301f..880d2090 100644 --- a/Benchmark.NetCore/Benchmark.NetCore.csproj +++ b/Benchmark.NetCore/Benchmark.NetCore.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0 true ..\Resources\prometheus-net.snk @@ -13,6 +13,8 @@ True 1591 + true + latest 9999 @@ -24,7 +26,9 @@ - + + + diff --git a/Benchmark.NetCore/CounterBenchmarks.cs b/Benchmark.NetCore/CounterBenchmarks.cs new file mode 100644 index 00000000..505ce984 --- /dev/null +++ b/Benchmark.NetCore/CounterBenchmarks.cs @@ -0,0 +1,25 @@ +using BenchmarkDotNet.Attributes; +using Prometheus; + +namespace Benchmark.NetCore; + +public class CounterBenchmarks +{ + private readonly CollectorRegistry _registry; + private readonly MetricFactory _factory; + private readonly Counter _counter; + + public CounterBenchmarks() + { + _registry = Metrics.NewCustomRegistry(); + _factory = Metrics.WithCustomRegistry(_registry); + + _counter = _factory.CreateCounter("gauge", "help text"); + } + + [Benchmark] + public void IncToCurrentTimeUtc() + { + _counter.IncToCurrentTimeUtc(); + } +} diff --git a/Benchmark.NetCore/ExemplarBenchmarks.cs b/Benchmark.NetCore/ExemplarBenchmarks.cs new file mode 100644 index 00000000..35672fc3 --- /dev/null +++ b/Benchmark.NetCore/ExemplarBenchmarks.cs @@ -0,0 +1,79 @@ +using System.Diagnostics; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Diagnosers; +using Prometheus; + +namespace Benchmark.NetCore; + +[MemoryDiagnoser] +//[EventPipeProfiler(EventPipeProfile.CpuSampling)] +public class ExemplarBenchmarks +{ + private readonly CollectorRegistry _registry; + private readonly MetricFactory _factory; + private readonly Counter _counter; + + public ExemplarBenchmarks() + { + _registry = Metrics.NewCustomRegistry(); + _factory = Metrics.WithCustomRegistry(_registry); + + // We provide the exemplars manually, without using the default behavior. + _factory.ExemplarBehavior = ExemplarBehavior.NoExemplars(); + + _counter = _factory.CreateCounter("gauge", "help text"); + } + + // Just establish a baseline - how much time/memory do we spend if not recording an exemplar. + [Benchmark(Baseline = true)] + public void Observe_NoExemplar() + { + _counter.Inc(123); + } + + // Just as a sanity check, this should not cost us anything extra and may even be cheaper as we skip the default behavior lookup. + [Benchmark] + public void Observe_EmptyExemplar() + { + _counter.Inc(123, Exemplar.None); + } + + private static readonly Exemplar.LabelKey CustomLabelKey1 = Exemplar.Key("my_key"); + private static readonly Exemplar.LabelKey CustomLabelKey2 = Exemplar.Key("my_key2"); + + // A manually specified custom exemplar with some arbitrary value. + [Benchmark] + public void Observe_CustomExemplar() + { + _counter.Inc(123, Exemplar.From(CustomLabelKey1.WithValue("my_value"), CustomLabelKey2.WithValue("my_value2"))); + } + + // An exemplar extracted from the current trace context when there is no trace context. + [Benchmark] + public void Observe_ExemplarFromEmptyTraceContext() + { + _counter.Inc(123, Exemplar.FromTraceContext()); + } + + [GlobalSetup(Targets = new[] { nameof(Observe_ExemplarFromTraceContext) })] + public void Setup_ExemplarFromTraceContext() + { + new Activity("test activity").Start(); + + if (Activity.Current == null) + throw new Exception("Sanity check failed."); + } + + // An exemplar extracted from the current trace context when there is a trace context. + [Benchmark] + public void Observe_ExemplarFromTraceContext() + { + _counter.Inc(123, Exemplar.FromTraceContext()); + } + + [GlobalCleanup(Targets = new[] { nameof(Observe_ExemplarFromEmptyTraceContext), nameof(Observe_ExemplarFromTraceContext) })] + public void Cleanup() + { + Activity.Current = null; + } +} diff --git a/Benchmark.NetCore/GaugeBenchmarks.cs b/Benchmark.NetCore/GaugeBenchmarks.cs new file mode 100644 index 00000000..6de13456 --- /dev/null +++ b/Benchmark.NetCore/GaugeBenchmarks.cs @@ -0,0 +1,25 @@ +using BenchmarkDotNet.Attributes; +using Prometheus; + +namespace Benchmark.NetCore; + +public class GaugeBenchmarks +{ + private readonly CollectorRegistry _registry; + private readonly MetricFactory _factory; + private readonly Gauge _gauge; + + public GaugeBenchmarks() + { + _registry = Metrics.NewCustomRegistry(); + _factory = Metrics.WithCustomRegistry(_registry); + + _gauge = _factory.CreateGauge("gauge", "help text"); + } + + [Benchmark] + public void SetToCurrenTimeUtc() + { + _gauge.SetToCurrentTimeUtc(); + } +} diff --git a/Benchmark.NetCore/HttpExporterBenchmarks.cs b/Benchmark.NetCore/HttpExporterBenchmarks.cs index eacf0291..fd3729c4 100644 --- a/Benchmark.NetCore/HttpExporterBenchmarks.cs +++ b/Benchmark.NetCore/HttpExporterBenchmarks.cs @@ -3,59 +3,61 @@ using Prometheus; using Prometheus.HttpMetrics; -namespace Benchmark.NetCore +namespace Benchmark.NetCore; + +[MemoryDiagnoser] +public class HttpExporterBenchmarks { - [MemoryDiagnoser] - public class HttpExporterBenchmarks - { - private CollectorRegistry _registry; - private MetricFactory _factory; - private HttpInProgressMiddleware _inProgressMiddleware; - private HttpRequestCountMiddleware _countMiddleware; - private HttpRequestDurationMiddleware _durationMiddleware; + private CollectorRegistry _registry; + private MetricFactory _factory; + private HttpInProgressMiddleware _inProgressMiddleware; + private HttpRequestCountMiddleware _countMiddleware; + private HttpRequestDurationMiddleware _durationMiddleware; - [Params(1000, 10000)] - public int RequestCount { get; set; } + [Params(100_000)] + public int RequestCount { get; set; } + + [GlobalSetup] + public void Setup() + { + _registry = Metrics.NewCustomRegistry(); + _factory = Metrics.WithCustomRegistry(_registry); - [GlobalSetup] - public void Setup() + _inProgressMiddleware = new HttpInProgressMiddleware(next => Task.CompletedTask, new HttpInProgressOptions { - _registry = Metrics.NewCustomRegistry(); - _factory = Metrics.WithCustomRegistry(_registry); - - _inProgressMiddleware = new HttpInProgressMiddleware(next => Task.CompletedTask, new HttpInProgressOptions - { - Gauge = _factory.CreateGauge("in_progress", "help") - }); - _countMiddleware = new HttpRequestCountMiddleware(next => Task.CompletedTask, new HttpRequestCountOptions - { - Counter = _factory.CreateCounter("count", "help") - }); - _durationMiddleware = new HttpRequestDurationMiddleware(next => Task.CompletedTask, new HttpRequestDurationOptions - { - Histogram = _factory.CreateHistogram("duration", "help") - }); - } - - [Benchmark] - public async Task HttpInProgress() + Gauge = _factory.CreateGauge("in_progress", "help") + }); + _countMiddleware = new HttpRequestCountMiddleware(next => Task.CompletedTask, new HttpRequestCountOptions { - for (var i = 0; i < RequestCount; i++) - await _inProgressMiddleware.Invoke(new DefaultHttpContext()); - } - - [Benchmark] - public async Task HttpRequestCount() + Counter = _factory.CreateCounter("count", "help") + }); + _durationMiddleware = new HttpRequestDurationMiddleware(next => Task.CompletedTask, new HttpRequestDurationOptions { - for (var i = 0; i < RequestCount; i++) - await _countMiddleware.Invoke(new DefaultHttpContext()); - } + Histogram = _factory.CreateHistogram("duration", "help") + }); + } - [Benchmark] - public async Task HttpRequestDuration() - { - for (var i = 0; i < RequestCount; i++) - await _durationMiddleware.Invoke(new DefaultHttpContext()); - } + // Reuse the same HttpContext for different requests, to not count its overhead in the benchmark. + private static readonly DefaultHttpContext _httpContext = new(); + + [Benchmark] + public async Task HttpInProgress() + { + for (var i = 0; i < RequestCount; i++) + await _inProgressMiddleware.Invoke(_httpContext); + } + + [Benchmark] + public async Task HttpRequestCount() + { + for (var i = 0; i < RequestCount; i++) + await _countMiddleware.Invoke(_httpContext); + } + + [Benchmark] + public async Task HttpRequestDuration() + { + for (var i = 0; i < RequestCount; i++) + await _durationMiddleware.Invoke(_httpContext); } } \ No newline at end of file diff --git a/Benchmark.NetCore/LabelSequenceBenchmarks.cs b/Benchmark.NetCore/LabelSequenceBenchmarks.cs new file mode 100644 index 00000000..5affa893 --- /dev/null +++ b/Benchmark.NetCore/LabelSequenceBenchmarks.cs @@ -0,0 +1,19 @@ +using BenchmarkDotNet.Attributes; +using Prometheus; + +namespace Benchmark.NetCore; + +[MemoryDiagnoser] +public class LabelSequenceBenchmarks +{ + private static readonly StringSequence Names3Array = StringSequence.From(new[] { "aaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbb", "cccccccccccccc" }); + private static readonly StringSequence Values3Array = StringSequence.From(new[] { "valueaaaaaaaaaaaaaaaaa", "valuebbbbbbbbbbbbbb", "valuecccccccccccccc" }); + + [Benchmark] + public void Create_From3Array() + { + // This is too fast for the benchmark engine, so let's create some additional work by looping through it many times. + for (var i = 0; i < 10_000; i++) + LabelSequence.From(Names3Array, Values3Array); + } +} diff --git a/Benchmark.NetCore/ManualDelayer.cs b/Benchmark.NetCore/ManualDelayer.cs new file mode 100644 index 00000000..20897c7b --- /dev/null +++ b/Benchmark.NetCore/ManualDelayer.cs @@ -0,0 +1,55 @@ +using Prometheus; + +namespace Benchmark.NetCore; + +/// +/// A delayer implementation that only returns from a delay when commanded to. +/// +internal sealed class ManualDelayer : IDelayer, IDisposable +{ + public void BreakAllDelays() + { + lock (_lock) + { + _tcs.TrySetResult(); + + _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + _delayTask = _tcs.Task; + } + } + + public void Dispose() + { + lock (_lock) + { + // If anything is still waiting, it shall not wait no more. + _tcs.TrySetResult(); + + // If anything will still wait in the future, it shall not wait at all. + // Beware of creating spinning loops if you dispose the delayer when something still expects to be delayed. + _delayTask = Task.CompletedTask; + } + } + + private readonly object _lock = new(); + private Task _delayTask; + private TaskCompletionSource _tcs; + + public ManualDelayer() + { + _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + _delayTask = _tcs.Task; + } + + public Task Delay(TimeSpan duration) + { + lock (_lock) + return _delayTask; + } + + public Task Delay(TimeSpan duration, CancellationToken cancel) + { + lock (_lock) + return _delayTask.WaitAsync(cancel); + } +} diff --git a/Benchmark.NetCore/MeasurementBenchmarks.cs b/Benchmark.NetCore/MeasurementBenchmarks.cs index 89cd9f88..b24bd49b 100644 --- a/Benchmark.NetCore/MeasurementBenchmarks.cs +++ b/Benchmark.NetCore/MeasurementBenchmarks.cs @@ -6,29 +6,32 @@ namespace Benchmark.NetCore; /// /// We take a bunch of measurements of each type of metric and show the cost. /// -/// -/// Total measurements = MeasurementCount * ThreadCount -/// [MemoryDiagnoser] -[ThreadingDiagnoser] public class MeasurementBenchmarks { - public enum MetricType - { - Counter, - Gauge, - Histogram, - Summary - } - [Params(100_000)] public int MeasurementCount { get; set; } - [Params(1, 16)] - public int ThreadCount { get; set; } + [Params(ExemplarMode.Auto, ExemplarMode.None, ExemplarMode.Provided)] + public ExemplarMode Exemplars { get; set; } - [Params(MetricType.Counter, MetricType.Gauge, MetricType.Histogram, MetricType.Summary)] - public MetricType TargetMetricType { get; set; } + public enum ExemplarMode + { + /// + /// No user-supplied exemplar but the default behavior is allowed to execute (and fail to provide an exemplar). + /// + Auto, + + /// + /// Explicitly indicating that no exemplar is to be used. + /// + None, + + /// + /// Explicitly providing an exemplar. + /// + Provided + } private readonly CollectorRegistry _registry; private readonly MetricFactory _factory; @@ -37,6 +40,29 @@ public enum MetricType private readonly Gauge.Child _gauge; private readonly Summary.Child _summary; private readonly Histogram.Child _histogram; + private readonly Histogram.Child _wideHistogram; + + private readonly Exemplar.LabelKey _traceIdKey = Exemplar.Key("trace_id"); + private readonly Exemplar.LabelKey _spanIdKey = Exemplar.Key("span_id"); + + // We preallocate the exemplar values to avoid measuring the random()->string serialization as part of the benchmark. + // What we care about measuring is the overhead of processing the exemplar, not of generating/serializing it. + private readonly string _traceIdValue = "7f825eb926a90af6961ace5f9a239945"; + private readonly string _spanIdValue = "a77603af408a13ec"; + + private Exemplar.LabelPair _traceIdLabel; + private Exemplar.LabelPair _spanIdLabel; + + /// + /// The max value we observe for histograms, to give us coverage of all the histogram buckets + /// but not waste 90% of the benchmark on incrementing the +Inf bucket. + /// + private const int WideHistogramMaxValue = 32 * 1024; + + // Same but for the regular histogram. + private readonly int _regularHistogramMaxValue; + + private static readonly string[] labelNames = ["label"]; public MeasurementBenchmarks() { @@ -44,9 +70,9 @@ public MeasurementBenchmarks() _factory = Metrics.WithCustomRegistry(_registry); // We add a label to each, as labeled usage is the typical usage. - var counterTemplate = _factory.CreateCounter("counter", "test counter", new[] { "label" }); - var gaugeTemplate = _factory.CreateGauge("gauge", "test gauge", new[] { "label" }); - var summaryTemplate = _factory.CreateSummary("summary", "test summary", new[] { "label" }, new SummaryConfiguration + var counterTemplate = _factory.CreateCounter("counter", "test counter", labelNames); + var gaugeTemplate = _factory.CreateGauge("gauge", "test gauge", labelNames); + var summaryTemplate = _factory.CreateSummary("summary", "test summary", labelNames, new SummaryConfiguration { Objectives = new QuantileEpsilonPair[] { @@ -55,10 +81,21 @@ public MeasurementBenchmarks() new(0.99, 0.005) } }); - var histogramTemplate = _factory.CreateHistogram("histogram", "test histogram", new[] { "label" }, new HistogramConfiguration + + // 1 ms to 32K ms, 16 buckets. Same as used in HTTP metrics by default. + var regularHistogramBuckets = Prometheus.Histogram.ExponentialBuckets(0.001, 2, 16); + + // Last one is +inf, so take the second-to-last. + _regularHistogramMaxValue = (int)regularHistogramBuckets[^2]; + + var histogramTemplate = _factory.CreateHistogram("histogram", "test histogram", labelNames, new HistogramConfiguration + { + Buckets = regularHistogramBuckets + }); + + var wideHistogramTemplate = _factory.CreateHistogram("wide_histogram", "test histogram", labelNames, new HistogramConfiguration { - // 1 ms to 32K ms, 16 buckets. Same as used in HTTP metrics by default. - Buckets = Histogram.ExponentialBuckets(0.001, 2, 16) + Buckets = Prometheus.Histogram.LinearBuckets(1, WideHistogramMaxValue / 128, 128) }); // We cache the children, as is typical usage. @@ -66,110 +103,85 @@ public MeasurementBenchmarks() _gauge = gaugeTemplate.WithLabels("label value"); _summary = summaryTemplate.WithLabels("label value"); _histogram = histogramTemplate.WithLabels("label value"); + _wideHistogram = wideHistogramTemplate.WithLabels("label value"); + + // We take a single measurement, to warm things up and avoid any first-call impact. + _counter.Inc(); + _gauge.Set(1); + _summary.Observe(1); + _histogram.Observe(1); + _wideHistogram.Observe(1); } - [IterationSetup] - public void Setup() + [GlobalSetup] + public void GlobalSetup() { - // We reuse the same registry for each iteration, as this represents typical (warmed up) usage. - - _threadReadyToStart = new ManualResetEventSlim[ThreadCount]; - _startThreads = new ManualResetEventSlim(); - _threads = new Thread[ThreadCount]; - - for (var i = 0; i < ThreadCount; i++) - { - _threadReadyToStart[i] = new(); - _threads[i] = new Thread(GetBenchmarkThreadEntryPoint()); - _threads[i].Name = $"Measurements #{i}"; - _threads[i].Start(i); - } - - // Wait for all threads to get ready. We will give them the go signal in the actual benchmark method. - foreach (var e in _threadReadyToStart) - e.Wait(); + // There is an unavoidable string->bytes encoding overhead from this. + // As it is fixed overhead based on user data size, we pre-encode the strings here to avoid them influencing the benchmark results. + // We only preallocate the strings, however (creating the LabelPairs). We still do as much of the exemplar "processing" inline as feasible, to be realistic. + _traceIdLabel = _traceIdKey.WithValue(_traceIdValue); + _spanIdLabel = _spanIdKey.WithValue(_spanIdValue); } - private ParameterizedThreadStart GetBenchmarkThreadEntryPoint() => TargetMetricType switch - { - MetricType.Counter => MeasurementThreadCounter, - MetricType.Gauge => MeasurementThreadGauge, - MetricType.Histogram => MeasurementThreadHistogram, - MetricType.Summary => MeasurementThreadSummary, - _ => throw new NotSupportedException() - }; - - [IterationCleanup] - public void Cleanup() + [Benchmark] + public void Counter() { - _startThreads.Dispose(); + var exemplarProvider = GetExemplarProvider(); - foreach (var e in _threadReadyToStart) - e.Dispose(); + for (var i = 0; i < MeasurementCount; i++) + { + _counter.Inc(exemplarProvider()); + } } - private ManualResetEventSlim[] _threadReadyToStart; - private ManualResetEventSlim _startThreads; - private Thread[] _threads; - - private void MeasurementThreadCounter(object state) + [Benchmark] + public void Gauge() { - var threadIndex = (int)state; - - _threadReadyToStart[threadIndex].Set(); - _startThreads.Wait(); - for (var i = 0; i < MeasurementCount; i++) { - _counter.Inc(); + _gauge.Set(i); } } - private void MeasurementThreadGauge(object state) + [Benchmark] + public void Histogram() { - var threadIndex = (int)state; - - _threadReadyToStart[threadIndex].Set(); - _startThreads.Wait(); + var exemplarProvider = GetExemplarProvider(); for (var i = 0; i < MeasurementCount; i++) { - _gauge.Set(i); + var value = i % _regularHistogramMaxValue; + _histogram.Observe(value, exemplarProvider()); } } - private void MeasurementThreadHistogram(object state) + [Benchmark] + public void WideHistogram() { - var threadIndex = (int)state; - - _threadReadyToStart[threadIndex].Set(); - _startThreads.Wait(); + var exemplarProvider = GetExemplarProvider(); for (var i = 0; i < MeasurementCount; i++) { - _histogram.Observe(i); + var value = i % WideHistogramMaxValue; + _wideHistogram.Observe(value, exemplarProvider()); } } - private void MeasurementThreadSummary(object state) + // Disabled because it is slow and Summary is a legacy metric type that is not recommended for new usage. + //[Benchmark] + public void Summary() { - var threadIndex = (int)state; - - _threadReadyToStart[threadIndex].Set(); - _startThreads.Wait(); - for (var i = 0; i < MeasurementCount; i++) { _summary.Observe(i); } } - [Benchmark] - public void MeasurementPerformance() + private Func GetExemplarProvider() => Exemplars switch { - _startThreads.Set(); - - for (var i = 0; i < _threads.Length; i++) - _threads[i].Join(); - } + ExemplarMode.Auto => () => null, + ExemplarMode.None => () => Exemplar.None, + ExemplarMode.Provided => () => Exemplar.From(_traceIdLabel, _spanIdLabel), + _ => throw new NotImplementedException(), + }; } diff --git a/Benchmark.NetCore/MeterAdapterBenchmarks.cs b/Benchmark.NetCore/MeterAdapterBenchmarks.cs new file mode 100644 index 00000000..b3abf76f --- /dev/null +++ b/Benchmark.NetCore/MeterAdapterBenchmarks.cs @@ -0,0 +1,95 @@ +using BenchmarkDotNet.Attributes; +using Prometheus; +using SDM = System.Diagnostics.Metrics; + +namespace Benchmark.NetCore; + +/// +/// Equivalent of MeasurementBenchmarks, except we publish the data via .NET Meters API and convert via MeterAdapter. +/// +[MemoryDiagnoser] +//[EventPipeProfiler(BenchmarkDotNet.Diagnosers.EventPipeProfile.GcVerbose)] +public class MeterAdapterBenchmarks +{ + [Params(100_000)] + public int MeasurementCount { get; set; } + + private readonly SDM.Meter _meter = new("prometheus-net benchmark"); + private readonly SDM.Counter _intCounter; + private readonly SDM.Counter _floatCounter; + private readonly SDM.Histogram _intHistogram; + private readonly SDM.Histogram _floatHistogram; + + private readonly CollectorRegistry _registry; + + private readonly IDisposable _meterAdapter; + + private readonly KeyValuePair _label = new("label", "label value"); + + public MeterAdapterBenchmarks() + { + _intCounter = _meter.CreateCounter("int_counter", description: "This is an integer counter."); + _floatCounter = _meter.CreateCounter("float_counter", description: "This is a floating-point counter."); + _intHistogram = _meter.CreateHistogram("int_histogram", description: "This is an integer histogram."); + _floatHistogram = _meter.CreateHistogram("float_histogram", description: "This is a floating-point histogram."); + + _registry = Metrics.NewCustomRegistry(); + + _meterAdapter = MeterAdapter.StartListening(new MeterAdapterOptions + { + InstrumentFilterPredicate = instrument => instrument.Meter == _meter, + Registry = _registry, + // We resolve a custom set of buckets here, for maximum stress and to avoid easily reused default buckets. + // 1 ms to 32K ms, 16 buckets. Same as used in HTTP metrics by default. + ResolveHistogramBuckets = _ => Histogram.ExponentialBuckets(0.001, 2, 16) + }); + + // We take a single measurement, to warm things up and avoid any first-call impact. + _intCounter.Add(1, _label); + _floatCounter.Add(1, _label); + _intHistogram.Record(1, _label); + _floatHistogram.Record(1, _label); + } + + [GlobalCleanup] + public void Cleanup() + { + _meterAdapter.Dispose(); + } + + [Benchmark] + public void CounterInt() + { + for (var i = 0; i < MeasurementCount; i++) + { + _intCounter.Add(1, _label); + } + } + + [Benchmark] + public void CounterFloat() + { + for (var i = 0; i < MeasurementCount; i++) + { + _floatCounter.Add(1, _label); + } + } + + [Benchmark] + public void HistogramInt() + { + for (var i = 0; i < MeasurementCount; i++) + { + _intHistogram.Record(i, _label); + } + } + + [Benchmark] + public void HistogramFloat() + { + for (var i = 0; i < MeasurementCount; i++) + { + _floatHistogram.Record(i, _label); + } + } +} diff --git a/Benchmark.NetCore/MetricCreationBenchmarks.cs b/Benchmark.NetCore/MetricCreationBenchmarks.cs index f73d34f7..09028762 100644 --- a/Benchmark.NetCore/MetricCreationBenchmarks.cs +++ b/Benchmark.NetCore/MetricCreationBenchmarks.cs @@ -1,137 +1,197 @@ using BenchmarkDotNet.Attributes; using Prometheus; -namespace Benchmark.NetCore +namespace Benchmark.NetCore; + +/// +/// One pattern advocated by Prometheus documentation is to implement scraping of external systems by +/// creating a brand new set of metrics for each scrape. So let's benchmark this scenario. +/// +[MemoryDiagnoser] +// This seems to need a lot of warmup to stabilize. +[WarmupCount(50)] +//[EventPipeProfiler(BenchmarkDotNet.Diagnosers.EventPipeProfile.GcVerbose)] +public class MetricCreationBenchmarks { /// - /// One pattern advocated by Prometheus documentation is to implement scraping of external systems by - /// creating a brand new set of metrics for each scrape. So let's benchmark this scenario. + /// Just to ensure that a benchmark iteration has enough to do for stable and meaningful results. /// - [MemoryDiagnoser] - public class MetricCreationBenchmarks + private const int _metricCount = 10_000; + + /// + /// How many times we repeat acquiring and incrementing the same instance. + /// + [Params(1, 10)] + public int RepeatCount { get; set; } + + [Params(true, false)] + public bool IncludeStaticLabels { get; set; } + + private const string _help = "arbitrary help message for metric, not relevant for benchmarking"; + + private static readonly string[] _metricNames; + + static MetricCreationBenchmarks() { - /// - /// Just to ensure that a benchmark iteration has enough to do for stable and meaningful results. - /// - private const int _metricCount = 100; + _metricNames = new string[_metricCount]; + + for (var i = 0; i < _metricCount; i++) + _metricNames[i] = $"metric_{i:D4}"; + } - /// - /// How many times we repeat acquiring and incrementing the same instance. - /// - [Params(1, 10)] - public int RepeatCount { get; set; } + private CollectorRegistry _registry; + private IMetricFactory _factory; + private IManagedLifetimeMetricFactory _managedLifetimeFactory; - /// - /// How many times we should try to register a metric that already exists. - /// - [Params(1, 10)] - public int DuplicateCount { get; set; } + [IterationSetup] + public void Setup() + { + _registry = Metrics.NewCustomRegistry(); + _factory = Metrics.WithCustomRegistry(_registry); + + if (IncludeStaticLabels) + { + _registry.SetStaticLabels(new Dictionary + { + { "static_foo", "static_bar" }, + { "static_foo1", "static_bar" }, + { "static_foo2", "static_bar" }, + { "static_foo3", "static_bar" }, + { "static_foo4", "static_bar" } + }); + + _factory = _factory.WithLabels(new Dictionary + { + { "static_gaa", "static_bar" }, + { "static_gaa1", "static_bar" }, + { "static_gaa2", "static_bar" }, + { "static_gaa3", "static_bar" }, + { "static_gaa4", "static_bar" }, + { "static_gaa5", "static_bar" }, + }); + } - [Params(true, false)] - public bool IncludeStaticLabels { get; set; } + _managedLifetimeFactory = _factory.WithManagedLifetime(expiresAfter: TimeSpan.FromHours(1)); + } - private const string _help = "arbitrary help message for metric, not relevant for benchmarking"; + // We use the same strings both for the names and the values. + private static readonly string[] _labels = ["foo", "bar", "baz"]; - private static readonly string[] _metricNames; + private static readonly CounterConfiguration _counterConfiguration = CounterConfiguration.Default; + private static readonly GaugeConfiguration _gaugeConfiguration = GaugeConfiguration.Default; + private static readonly SummaryConfiguration _summaryConfiguration = SummaryConfiguration.Default; + private static readonly HistogramConfiguration _histogramConfiguration = HistogramConfiguration.Default; - static MetricCreationBenchmarks() + [Benchmark] + public void Counter_ArrayLabels() + { + for (var i = 0; i < _metricCount; i++) { - _metricNames = new string[_metricCount]; + var metric = _factory.CreateCounter(_metricNames[i], _help, _labels, _counterConfiguration); - for (var i = 0; i < _metricCount; i++) - _metricNames[i] = $"metric_{i:D4}"; + for (var repeat = 0; repeat < RepeatCount; repeat++) + metric.WithLabels(_labels).Inc(); } + } - private CollectorRegistry _registry; - private IMetricFactory _factory; + [Benchmark] + public void Counter_MemoryLabels() + { + // The overloads accepting string[] and ROM are functionally equivalent, though any conversion adds some overhead. + var labelsMemory = _labels.AsMemory(); - [IterationSetup] - public void Setup() + for (var i = 0; i < _metricCount; i++) { - _registry = Metrics.NewCustomRegistry(); - _factory = Metrics.WithCustomRegistry(_registry); + var metric = _factory.CreateCounter(_metricNames[i], _help, _labels, _counterConfiguration); - if (IncludeStaticLabels) - { - _registry.SetStaticLabels(new Dictionary - { - { "static_foo", "static_bar" }, - { "static_foo1", "static_bar" }, - { "static_foo2", "static_bar" }, - { "static_foo3", "static_bar" }, - { "static_foo4", "static_bar" } - }); - - _factory = _factory.WithLabels(new Dictionary - { - { "static_gaa", "static_bar" }, - { "static_gaa1", "static_bar" }, - { "static_gaa2", "static_bar" }, - { "static_gaa3", "static_bar" }, - { "static_gaa4", "static_bar" }, - { "static_gaa5", "static_bar" }, - }); - } + for (var repeat = 0; repeat < RepeatCount; repeat++) + metric.WithLabels(labelsMemory).Inc(); } + } + + [Benchmark] + public void Counter_SpanLabels() + { + var labelsSpan = _labels.AsSpan(); + + for (var i = 0; i < _metricCount; i++) + { + var metric = _factory.CreateCounter(_metricNames[i], _help, _labels, _counterConfiguration); - // We use the same strings both for the names and the values. - private static readonly string[] _labels = new[] { "foo", "bar", "baz" }; + for (var repeat = 0; repeat < RepeatCount; repeat++) + // If code is aware that it is repeating the registration, using the Span overloads offers optimal performance. + metric.WithLabels(labelsSpan).Inc(); + } + } - private CounterConfiguration _counterConfiguration = CounterConfiguration.Default; - private GaugeConfiguration _gaugeConfiguration = GaugeConfiguration.Default; - private SummaryConfiguration _summaryConfiguration = SummaryConfiguration.Default; - private HistogramConfiguration _histogramConfiguration = HistogramConfiguration.Default; + [Benchmark] + public void Counter_ManagedLifetime() + { + // Typical usage for explicitly lifetime-managed metrics is to pass the label values as span, as they may already be known. + var labelsSpan = _labels.AsSpan(); - [Benchmark] - public void Counter_Many() + for (var i = 0; i < _metricCount; i++) { - for (var dupe = 0; dupe < DuplicateCount; dupe++) - for (var i = 0; i < _metricCount; i++) - { - var metric = _factory.CreateCounter(_metricNames[i], _help, _labels, _counterConfiguration); - - for (var repeat = 0; repeat < RepeatCount; repeat++) - metric.WithLabels(_labels).Inc(); - } + var metric = _managedLifetimeFactory.CreateCounter(_metricNames[i], _help, _labels, _counterConfiguration); + + for (var repeat = 0; repeat < RepeatCount; repeat++) + metric.WithLease(static x => x.Inc(), labelsSpan); } + } + + [Benchmark] + public void Counter_10Duplicates() + { + // We try to register the same metric 10 times, which is wasteful but shows us the overhead from doing this. + + for (var dupe = 0; dupe < 10; dupe++) + for (var i = 0; i < _metricCount; i++) + { + var metric = _factory.CreateCounter(_metricNames[i], _help, _labels, _counterConfiguration); + + for (var repeat = 0; repeat < RepeatCount; repeat++) + // We do not use the Span overload here to exemplify "naive" code not aware of the repetition. + metric.WithLabels(_labels).Inc(); + } + } - [Benchmark] - public void Gauge_Many() + [Benchmark] + public void Gauge() + { + for (var i = 0; i < _metricCount; i++) { - for (var dupe = 0; dupe < DuplicateCount; dupe++) - for (var i = 0; i < _metricCount; i++) - { - var metric = _factory.CreateGauge(_metricNames[i], _help, _labels, _gaugeConfiguration); - - for (var repeat = 0; repeat < RepeatCount; repeat++) - metric.WithLabels(_labels).Inc(); - } + var metric = _factory.CreateGauge(_metricNames[i], _help, _labels, _gaugeConfiguration); + + for (var repeat = 0; repeat < RepeatCount; repeat++) + // We do not use the Span overload here to exemplify "naive" code not aware of the repetition. + metric.WithLabels(_labels).Set(repeat); } + } - [Benchmark] - public void Summary_Many() + // Disabled because it is slow and Summary is a legacy metric type that is not recommended for new usage. + //[Benchmark] + public void Summary() + { + for (var i = 0; i < _metricCount; i++) { - for (var dupe = 0; dupe < DuplicateCount; dupe++) - for (var i = 0; i < _metricCount; i++) - { - var metric = _factory.CreateSummary(_metricNames[i], _help, _labels, _summaryConfiguration); - - for (var repeat = 0; repeat < RepeatCount; repeat++) - metric.WithLabels(_labels).Observe(123); - } + var metric = _factory.CreateSummary(_metricNames[i], _help, _labels, _summaryConfiguration); + + for (var repeat = 0; repeat < RepeatCount; repeat++) + // We do not use the Span overload here to exemplify "naive" code not aware of the repetition. + metric.WithLabels(_labels).Observe(123); } + } - [Benchmark] - public void Histogram_Many() + [Benchmark] + public void Histogram() + { + for (var i = 0; i < _metricCount; i++) { - for (var dupe = 0; dupe < DuplicateCount; dupe++) - for (var i = 0; i < _metricCount; i++) - { - var metric = _factory.CreateHistogram(_metricNames[i], _help, _labels, _histogramConfiguration); - - for (var repeat = 0; repeat < RepeatCount; repeat++) - metric.WithLabels(_labels).Observe(123); - } + var metric = _factory.CreateHistogram(_metricNames[i], _help, _labels, _histogramConfiguration); + + for (var repeat = 0; repeat < RepeatCount; repeat++) + // We do not use the Span overload here to exemplify "naive" code not aware of the repetition. + metric.WithLabels(_labels).Observe(123); } } } diff --git a/Benchmark.NetCore/MetricExpirationBenchmarks.cs b/Benchmark.NetCore/MetricExpirationBenchmarks.cs index a8d5cae4..883ad131 100644 --- a/Benchmark.NetCore/MetricExpirationBenchmarks.cs +++ b/Benchmark.NetCore/MetricExpirationBenchmarks.cs @@ -7,30 +7,24 @@ namespace Benchmark.NetCore; /// Here we try to ensure that creating/using expiring metrics does not impose too heavy of a performance burden or create easily identifiable memory leaks. /// [MemoryDiagnoser] +// This seems to need a lot of warmup to stabilize. +[WarmupCount(50)] +// This seems to need a lot of iterations to stabilize. +[IterationCount(50)] +//[EventPipeProfiler(BenchmarkDotNet.Diagnosers.EventPipeProfile.GcVerbose)] public class MetricExpirationBenchmarks { /// /// Just to ensure that a benchmark iteration has enough to do for stable and meaningful results. /// - private const int _metricCount = 100; + private const int _metricCount = 25_000; /// - /// Some benchmarks try to register metrics that already exist. - /// - private const int _duplicateCount = 5; - - /// - /// How many times we repeat acquiring and incrementing the same instance. - /// - [Params(1, 10)] - public int RepeatCount { get; set; } - - /// - /// If true, we preallocate a lifetime manager for every metric, so the benchmark only measures the actual usage - /// of the metric and not the creation, as these two components of a metric lifetime can have different impact in different cases. + /// If true, we preallocate a lifetime for every metric, so the benchmark only measures the actual usage + /// of the metric and not the first-lease setup, as these two components of a metric lifetime can have different impact in different cases. /// [Params(true, false)] - public bool PreallocateLifetimeManager { get; set; } + public bool PreallocateLifetime { get; set; } private const string _help = "arbitrary help message for metric, not relevant for benchmarking"; @@ -47,6 +41,13 @@ static MetricExpirationBenchmarks() private CollectorRegistry _registry; private IManagedLifetimeMetricFactory _factory; + // We use the same strings both for the names and the values. + private static readonly string[] _labels = ["foo", "bar", "baz"]; + + private ManualDelayer _delayer; + + private readonly ManagedLifetimeMetricHandle[] _counters = new ManagedLifetimeMetricHandle[_metricCount]; + [IterationSetup] public void Setup() { @@ -55,122 +56,126 @@ public void Setup() // We enable lifetime management but set the expiration timer very high to avoid expiration in the middle of the benchmark. .WithManagedLifetime(expiresAfter: TimeSpan.FromHours(24)); - var regularFactory = Metrics.WithCustomRegistry(_registry); + _delayer = new(); - // We create non-expiring versions of the metrics to pre-warm the metrics registry. - // While not a realistic use case, it does help narrow down the benchmark workload to the actual part that is special about expiring metrics, - // which means we get more useful results from this (rather than with some metric allocation overhead mixed in). for (var i = 0; i < _metricCount; i++) { - var counter = regularFactory.CreateCounter(_metricNames[i], _help, _labels); - counter.WithLabels(_labels); + var counter = CreateCounter(_metricNames[i], _help, _labels); + _counters[i] = counter; - // If the params say so, we even preallocate the lifetime manager to ensure that we only measure the usage of the metric. - // Both the usage and the lifetime manager allocation matter but we want to bring them out separately in the benchmarks. - if (PreallocateLifetimeManager) - { - var managedLifetimeCounter = _factory.CreateCounter(_metricNames[i], _help, _labels); - - // And also take the first lease to pre-warm the lifetime manager. - managedLifetimeCounter.AcquireLease(out _, _labels).Dispose(); - } + // Both the usage and the lifetime allocation matter but we want to bring them out separately in the benchmarks. + if (PreallocateLifetime) + counter.AcquireRefLease(out _, _labels).Dispose(); } } - // We use the same strings both for the names and the values. - private static readonly string[] _labels = new[] { "foo", "bar", "baz" }; + [IterationCleanup] + public void Cleanup() + { + // Ensure that all metrics are marked as expired, so the expiration processing logic destroys them all. + // This causes some extra work during cleanup but on the other hand, it ensures good isolation between iterations, so fine. + foreach (var counter in _counters) + counter.SetAllKeepaliveTimestampsToDistantPast(); + + // Twice and with some sleep time, just for good measure. + // BenchmarkDotNet today does not support async here, so we do a sleep to let the reaper thread process things. + _delayer.BreakAllDelays(); + Thread.Sleep(millisecondsTimeout: 5); + } + + private ManagedLifetimeMetricHandle CreateCounter(string name, string help, string[] labels) + { + var counter = (ManagedLifetimeMetricHandle)_factory.CreateCounter(name, help, labels); + + // We use a breakable delayer to ensure that we can control when the metric expiration logic runs, so one iteration + // of the benchmark does not start to interfere with another iteration just because some timers are left running. + counter.Delayer = _delayer; + + return counter; + } [Benchmark] - public void CreateAndUse_AutoLease() + public void Use_AutoLease_Once() { for (var i = 0; i < _metricCount; i++) { - var metric = _factory.CreateCounter(_metricNames[i], _help, _labels).WithExtendLifetimeOnUse(); + var wrapper = _counters[i].WithExtendLifetimeOnUse(); - for (var repeat = 0; repeat < RepeatCount; repeat++) - metric.WithLabels(_labels).Inc(); + // Auto-leasing is used as a drop-in replacement in a context that is not aware the metric is lifetime-managed. + // This means the typical usage is to pass a string[] (or ROM) and not a span (which would be a hint that it already exists). + wrapper.WithLabels(_labels).Inc(); } } [Benchmark] - public void CreateAndUse_AutoLease_WithDuplicates() + public void Use_AutoLease_With10Duplicates() { - for (var dupe = 0; dupe < _duplicateCount; dupe++) + for (var dupe = 0; dupe < 10; dupe++) for (var i = 0; i < _metricCount; i++) { - var metric = _factory.CreateCounter(_metricNames[i], _help, _labels).WithExtendLifetimeOnUse(); + var wrapper = _counters[i].WithExtendLifetimeOnUse(); - for (var repeat = 0; repeat < RepeatCount; repeat++) - metric.WithLabels(_labels).Inc(); + // Auto-leasing is used as a drop-in replacement in a context that is not aware the metric is lifetime-managed. + // This means the typical usage is to pass a string[] (or ROM) and not a span (which would be a hint that it already exists). + wrapper.WithLabels(_labels).Inc(); } } - [Benchmark(Baseline = true)] - public void CreateAndUse_ManualLease() + [Benchmark] + public void Use_AutoLease_Once_With10Repeats() { for (var i = 0; i < _metricCount; i++) { - var counter = _factory.CreateCounter(_metricNames[i], _help, _labels); + var wrapper = _counters[i].WithExtendLifetimeOnUse(); - for (var repeat = 0; repeat < RepeatCount; repeat++) - { - using var lease = counter.AcquireLease(out var instance, _labels); - instance.Inc(); - } + for (var repeat = 0; repeat < 10; repeat++) + // Auto-leasing is used as a drop-in replacement in a context that is not aware the metric is lifetime-managed. + // This means the typical usage is to pass a string[] (or ROM) and not a span (which would be a hint that it already exists). + wrapper.WithLabels(_labels).Inc(); } } - [Benchmark] - public void CreateAndUse_ManualLease_WithDuplicates() + [Benchmark(Baseline = true)] + public void Use_ManualLease() { - for (var dupe = 0; dupe < _duplicateCount; dupe++) - for (var i = 0; i < _metricCount; i++) - { - var counter = _factory.CreateCounter(_metricNames[i], _help, _labels); + // Typical usage for explicitly lifetime-managed metrics is to pass the label values as span, as they may already be known. + var labelValues = _labels.AsSpan(); - for (var repeat = 0; repeat < RepeatCount; repeat++) - { - using var lease = counter.AcquireLease(out var instance, _labels); - instance.Inc(); - } - } - } - - private static void IncrementCounter(ICounter counter) - { - counter.Inc(); + for (var i = 0; i < _metricCount; i++) + { + using var lease = _counters[i].AcquireLease(out var instance, labelValues); + instance.Inc(); + } } [Benchmark] - public void CreateAndUse_WithLease() + public void Use_ManualRefLease() { - // Reuse the delegate. - Action incrementCounterAction = IncrementCounter; + // Typical usage for explicitly lifetime-managed metrics is to pass the label values as span, as they may already be known. + var labelValues = _labels.AsSpan(); for (var i = 0; i < _metricCount; i++) { - var counter = _factory.CreateCounter(_metricNames[i], _help, _labels); - - for (var repeat = 0; repeat < RepeatCount; repeat++) - { - counter.WithLease(incrementCounterAction, _labels); - } + using var lease = _counters[i].AcquireRefLease(out var instance, labelValues); + instance.Inc(); } } + private static void IncrementCounter(ICounter counter) + { + counter.Inc(); + } + [Benchmark] - public void CreateAndUse_WithLease_WithDuplicates() + public void Use_WithLease() { + // Typical usage for explicitly lifetime-managed metrics is to pass the label values as span, as they may already be known. + var labelValues = _labels.AsSpan(); + // Reuse the delegate. Action incrementCounterAction = IncrementCounter; - for (var dupe = 0; dupe < _duplicateCount; dupe++) - for (var i = 0; i < _metricCount; i++) - { - var counter = _factory.CreateCounter(_metricNames[i], _help, _labels); - - for (var repeat = 0; repeat < RepeatCount; repeat++) - counter.WithLease(incrementCounterAction, _labels); - } + for (var i = 0; i < _metricCount; i++) + _counters[i].WithLease(incrementCounterAction, labelValues); } } diff --git a/Benchmark.NetCore/MetricPusherBenchmarks.cs b/Benchmark.NetCore/MetricPusherBenchmarks.cs index 1d946831..83943efa 100644 --- a/Benchmark.NetCore/MetricPusherBenchmarks.cs +++ b/Benchmark.NetCore/MetricPusherBenchmarks.cs @@ -3,77 +3,76 @@ using Prometheus; using tester; -namespace Benchmark.NetCore +namespace Benchmark.NetCore; + +// NB! This benchmark requires the Tester project to be running and the MetricPusherTester module to be active (to receive the data). +// If there is no tester listening, the results will be overly good because the runtime is under less I/O load. +[MemoryDiagnoser] +[SimpleJob(RunStrategy.Monitoring, warmupCount: 0)] +public class MetricPusherBenchmarks { - // NB! This benchmark requires the Tester project to be running and the MetricPusherTester module to be active (to receive the data). - // If there is no tester listening, the results will be overly good because the runtime is under less I/O load. - [MemoryDiagnoser] - [SimpleJob(RunStrategy.Monitoring, warmupCount: 0)] - public class MetricPusherBenchmarks + private static string MetricPusherUrl = $"http://localhost:{TesterConstants.TesterPort}"; + + [GlobalSetup] + public async Task GlobalSetup() { - private static string MetricPusherUrl = $"http://localhost:{TesterConstants.TesterPort}"; + // Verify that there is a MetricPusher listening on Tester. - [GlobalSetup] - public async Task GlobalSetup() + using (var client = new HttpClient()) { - // Verify that there is a MetricPusher listening on Tester. - - using (var client = new HttpClient()) + try { - try - { - var result = await client.GetAsync(MetricPusherUrl); - result.EnsureSuccessStatusCode(); - } - catch - { - throw new Exception("You must start the Tester.NetCore project and configure it to use MetricPusherTester in its Program.cs before running this benchmark."); - } + var result = await client.GetAsync(MetricPusherUrl); + result.EnsureSuccessStatusCode(); + } + catch + { + throw new Exception("You must start the Tester.NetCore project and configure it to use MetricPusherTester in its Program.cs before running this benchmark."); } } + } - [Benchmark] - public async Task PushTest() - { - var registry = Metrics.NewCustomRegistry(); - var factory = Metrics.WithCustomRegistry(registry); + [Benchmark] + public async Task PushTest() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); - var pusher = new MetricPusher(new MetricPusherOptions - { - Endpoint = MetricPusherUrl, - Registry = registry, - IntervalMilliseconds = 30, - Job = "job" - }); - pusher.Start(); + var pusher = new MetricPusher(new MetricPusherOptions + { + Endpoint = MetricPusherUrl, + Registry = registry, + IntervalMilliseconds = 30, + Job = "job" + }); + pusher.Start(); - var counters = new List(); - for (int i = 0; i < 1000; i++) - { - var counter = factory.CreateCounter($"Counter{i}", String.Empty); - counters.Add(counter); - } + var counters = new List(); + for (int i = 0; i < 1000; i++) + { + var counter = factory.CreateCounter($"Counter{i}", String.Empty); + counters.Add(counter); + } - var ct = new CancellationTokenSource(); - var incTask = Task.Run(async () => + var ct = new CancellationTokenSource(); + var incTask = Task.Run(async () => + { + while (!ct.IsCancellationRequested) { - while (!ct.IsCancellationRequested) + foreach (var counter in counters) { - foreach (var counter in counters) - { - counter.Inc(); - } - - await Task.Delay(30); + counter.Inc(); } - }); - await Task.Delay(5000); - ct.Cancel(); - await incTask; + await Task.Delay(30); + } + }); - pusher.Stop(); - } + await Task.Delay(5000); + ct.Cancel(); + await incTask; + + pusher.Stop(); } } \ No newline at end of file diff --git a/Benchmark.NetCore/Program.cs b/Benchmark.NetCore/Program.cs index 9daa49ab..94822efb 100644 --- a/Benchmark.NetCore/Program.cs +++ b/Benchmark.NetCore/Program.cs @@ -1,14 +1,13 @@ using BenchmarkDotNet.Running; -namespace Benchmark.NetCore +namespace Benchmark.NetCore; + +internal class Program { - internal class Program + private static void Main(string[] args) { - private static void Main(string[] args) - { - // Give user possibility to choose which benchmark to run. - // Can be overridden from the command line with the --filter option. - new BenchmarkSwitcher(typeof(Program).Assembly).Run(args); - } + // Give user possibility to choose which benchmark to run. + // Can be overridden from the command line with the --filter option. + new BenchmarkSwitcher(typeof(Program).Assembly).Run(args); } } diff --git a/Benchmark.NetCore/SdkComparisonBenchmarks.cs b/Benchmark.NetCore/SdkComparisonBenchmarks.cs new file mode 100644 index 00000000..eb95dc2d --- /dev/null +++ b/Benchmark.NetCore/SdkComparisonBenchmarks.cs @@ -0,0 +1,285 @@ +using System.Diagnostics.Metrics; +using BenchmarkDotNet.Attributes; +using OpenTelemetry.Metrics; +using Prometheus; + +namespace Benchmark.NetCore; + +/* +BenchmarkDotNet v0.13.10, Windows 11 (10.0.22621.2715/22H2/2022Update/SunValley2) +AMD Ryzen 9 5950X, 1 CPU, 32 logical and 16 physical cores +.NET SDK 8.0.100 + [Host] : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2 + DefaultJob : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2 + Job-PPCGVJ : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2 + + +| Method | Job | MaxIterationCount | Mean | Error | StdDev | Allocated | +|------------------------------ |----------- |------------------ |------------:|---------:|---------:|----------:| +| PromNetCounter | DefaultJob | Default | 230.4 μs | 1.20 μs | 1.12 μs | - | +| PromNetHistogram | DefaultJob | Default | 956.7 μs | 3.28 μs | 3.07 μs | - | +| OTelCounter | DefaultJob | Default | 10,998.2 μs | 35.54 μs | 31.51 μs | 11 B | +| OTelHistogram | DefaultJob | Default | 12,110.3 μs | 17.08 μs | 14.26 μs | 11 B | +| PromNetHistogramForAdHocLabel | Job-PPCGVJ | 16 | 716.2 μs | 30.20 μs | 26.77 μs | 664000 B | +| OTelHistogramForAdHocLabel | Job-PPCGVJ | 16 | 350.5 μs | 1.91 μs | 1.79 μs | 96000 B | +*/ + +/// +/// We compare pure measurement (not serializing the data) with prometheus-net SDK and OpenTelemetry .NET SDK. +/// +/// +/// Design logic: +/// * Metrics are initialized once on application startup. +/// * Metrics typically measure "sessions" - there are sets of metrics that are related through shared identifiers and a shared lifetime (e.g. HTTP request), +/// with all the identifiers for the metrics created when the sesison is initialized (e.g. when the HTTP connection is established). +/// * Metrics typically are also used report SLI (Service Level Indicator); these involve emitting a lot of unique dimension values, for example: CustomerId. +/// +/// Excluded from measurement: +/// * Meter setup (because meters are created once on application setup and not impactful later). +/// * Test data generation (session numbers and identifier strings) as it is SDK-neutral. +/// +/// We have a separate benchmark to compare the setup stage (just re-runs the setup logic in measured phase). +/// +/// We also do not benchmark "observable" metrics that are only polled at time of collection. +/// Both SDKs support it as an optimization (though OpenTelemetry forces it for counters) but let's try keep the logic here simple and exclude it for now. +/// +[MemoryDiagnoser] +public class SdkComparisonBenchmarks +{ + // Unique sets of label/tag values per metric. You can think of each one as a "session" we are reporting data for. + private const int TimeseriesPerMetric = 100; + + private static readonly string[] LabelNames = new[] { "environment", "server", "session_id" }; + private const string Label1Value = "production"; + private const string Label2Value = "hkhk298599-qps010-n200"; + + // How many observations we take during a single benchmark invocation (for each timeseries). + private const int ObservationCount = 1000; + + private static readonly string[] SessionIds = new string[TimeseriesPerMetric]; + + static SdkComparisonBenchmarks() + { + for (var i = 0; i < SessionIds.Length; i++) + SessionIds[i] = Guid.NewGuid().ToString(); + } + + /// + /// Contains all the context that gets initialized at iteration setup time. + /// + /// This data set is: + /// 1) Not included in the performance measurements. + /// 2) Reused for each invocation that is part of the same iteration. + /// + private abstract class MetricsContext : IDisposable + { + /// + /// Records an observation with all the counter-type metrics for each session. + /// + public abstract void ObserveCounter(double value); + + /// + /// Records an observation with all the histogram-type metrics for each session. + /// + public abstract void ObserveHistogram(double value); + + /// + /// Records an observation with one random label value as ad-hoc using a Histogram. + /// + public abstract void ObserveHistogramWithAnAdHocLabelValue(double value); + + public virtual void Dispose() { } + } + + private sealed class PrometheusNetMetricsContext : MetricsContext + { + private readonly List _counterInstances = new(TimeseriesPerMetric); + private readonly List _histogramInstances = new(TimeseriesPerMetric); + private readonly Histogram _histogramForAdHocLabels; + + private readonly IMetricServer _server; + + public PrometheusNetMetricsContext() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); + + // Do not emit any exemplars in this benchmark, as they are not yet equally supported by the SDKs. + factory.ExemplarBehavior = ExemplarBehavior.NoExemplars(); + + var counter = factory.CreateCounter("counter", "", LabelNames); + + for (var i = 0; i < TimeseriesPerMetric; i++) + _counterInstances.Add(counter.WithLabels(Label1Value, Label2Value, SessionIds[i])); + + var histogram = factory.CreateHistogram("histogram", "", LabelNames); + + _histogramForAdHocLabels = factory.CreateHistogram("histogramForAdHocLabels", "", LabelNames); + + for (var i = 0; i < TimeseriesPerMetric; i++) + _histogramInstances.Add(histogram.WithLabels(Label1Value, Label2Value, SessionIds[i])); + + // `AddPrometheusHttpListener` of OpenTelemetry creates an HttpListener. + // Start an equivalent listener for Prometheus to ensure a fair comparison. + // We listen on 127.0.0.1 to avoid firewall prompts (randomly chosen port - we do not expect to receive any traffic). + _server = new MetricServer("127.0.0.1", port: 8051); + _server.Start(); + } + + public override void ObserveCounter(double value) + { + foreach (var counter in _counterInstances) + counter.Inc(value); + } + + public override void ObserveHistogram(double value) + { + foreach (var histogram in _histogramInstances) + histogram.Observe(value); + } + + public override void ObserveHistogramWithAnAdHocLabelValue(double value) + { + _histogramForAdHocLabels.WithLabels(Label1Value, Label2Value, Guid.NewGuid().ToString()).Observe(value); + } + + public override void Dispose() + { + base.Dispose(); + + _server.Dispose(); + } + } + + private sealed class OpenTelemetryMetricsContext : MetricsContext + { + private const string MeterBaseName = "benchmark"; + + private readonly Meter _meter; + private readonly MeterProvider _provider; + + private readonly Counter _counter; + private readonly Histogram _histogram; + private readonly Histogram _histogramForAdHocLabels; + + public OpenTelemetryMetricsContext() + { + // We use a randomized name every time because otherwise there appears to be some "shared state" between benchmark invocations, + // at least for the "setup" part which keeps getting slower every time we call it with the same metric name. + _meter = new Meter(MeterBaseName + Guid.NewGuid()); + + _counter = _meter.CreateCounter("counter"); + + _histogram = _meter.CreateHistogram("histogram"); + + _histogramForAdHocLabels = _meter.CreateHistogram("histogramForAdHocLabels"); + + _provider = OpenTelemetry.Sdk.CreateMeterProviderBuilder() + .AddView("histogram", new OpenTelemetry.Metrics.HistogramConfiguration() { RecordMinMax = false }) + .AddMeter(_meter.Name) + .AddPrometheusHttpListener() + .Build(); + } + + public override void ObserveCounter(double value) + { + for (int i = 0; i < SessionIds.Length; i++) + { + var tag1 = new KeyValuePair(LabelNames[0], Label1Value); + var tag2 = new KeyValuePair(LabelNames[1], Label2Value); + var tag3 = new KeyValuePair(LabelNames[2], SessionIds[i]); + _counter.Add(value, tag1, tag2, tag3); + } + } + + public override void ObserveHistogram(double value) + { + for (int i = 0; i < SessionIds.Length; i++) + { + var tag1 = new KeyValuePair(LabelNames[0], Label1Value); + var tag2 = new KeyValuePair(LabelNames[1], Label2Value); + var tag3 = new KeyValuePair(LabelNames[2], SessionIds[i]); + _histogram.Record(value, tag1, tag2, tag3); + } + } + + public override void ObserveHistogramWithAnAdHocLabelValue(double value) + { + var tag1 = new KeyValuePair(LabelNames[0], Label1Value); + var tag2 = new KeyValuePair(LabelNames[1], Label2Value); + var tag3 = new KeyValuePair(LabelNames[2], Guid.NewGuid().ToString()); + _histogramForAdHocLabels.Record(value, tag1, tag2, tag3); + } + + public override void Dispose() + { + base.Dispose(); + + _provider.Dispose(); + } + } + + private MetricsContext _context; + + [GlobalSetup(Targets = new string[] { nameof(OTelCounter), nameof(OTelHistogram), nameof(OTelHistogramForAdHocLabel) })] + public void OpenTelemetrySetup() + { + _context = new OpenTelemetryMetricsContext(); + } + + [GlobalSetup(Targets = new string[] { nameof(PromNetCounter), nameof(PromNetHistogram), nameof(PromNetHistogramForAdHocLabel) })] + public void PrometheusNetSetup() + { + _context = new PrometheusNetMetricsContext(); + } + + [Benchmark] + public void PromNetCounter() + { + for (var observation = 0; observation < ObservationCount; observation++) + _context.ObserveCounter(observation); + } + + [Benchmark] + public void PromNetHistogram() + { + for (var observation = 0; observation < ObservationCount; observation++) + _context.ObserveHistogram(observation); + } + + [Benchmark] + [MaxIterationCount(16)] // Need to set a lower iteration count as this benchmarks allocates a lot memory and takes too long to complete with the default number of iterations. + public void PromNetHistogramForAdHocLabel() + { + for (var observation = 0; observation < ObservationCount; observation++) + _context.ObserveHistogramWithAnAdHocLabelValue(observation); + } + + [Benchmark] + public void OTelCounter() + { + for (var observation = 0; observation < ObservationCount; observation++) + _context.ObserveCounter(observation); + } + + [Benchmark] + public void OTelHistogram() + { + for (var observation = 0; observation < ObservationCount; observation++) + _context.ObserveHistogram(observation); + } + + [Benchmark] + [MaxIterationCount(16)] // Set the same number of iteration count as the corresponding PromNet benchmark. + public void OTelHistogramForAdHocLabel() + { + for (var observation = 0; observation < ObservationCount; observation++) + _context.ObserveHistogramWithAnAdHocLabelValue(observation); + } + + [GlobalCleanup] + public void Cleanup() + { + _context.Dispose(); + } +} diff --git a/Benchmark.NetCore/SerializationBenchmarks.cs b/Benchmark.NetCore/SerializationBenchmarks.cs index 049accbd..96bf2542 100644 --- a/Benchmark.NetCore/SerializationBenchmarks.cs +++ b/Benchmark.NetCore/SerializationBenchmarks.cs @@ -1,82 +1,205 @@ -using BenchmarkDotNet.Attributes; +using System.IO.Pipes; +using BenchmarkDotNet.Attributes; using Prometheus; -namespace Benchmark.NetCore +namespace Benchmark.NetCore; + +[MemoryDiagnoser] +//[EventPipeProfiler(BenchmarkDotNet.Diagnosers.EventPipeProfile.CpuSampling)] +public class SerializationBenchmarks { - [MemoryDiagnoser] - public class SerializationBenchmarks + public enum OutputStreamType { - // Metric -> Variant -> Label values - private static readonly string[][][] _labelValueRows; + /// + /// A null stream that just does nothing and immediately returns. + /// Low overhead but also unrealistic in terms of asynchronous I/O behavior. + /// + Null, + + /// + /// A stream that does nothing except yielding the task/thread to take up nonzero time. + /// Tries to increase the overhead from async/await task management that might occur. + /// + Yield, + + /// + /// A named pipe connection. Something halfway between null and a real network connection. + /// Named pipes appear to be super slow when dealing with small amounts of data, so optimizing + /// this scenario is valuable to ensure that we perform well with real network connections that + /// may have similar limitations (depending on OS and network stack). + /// + NamedPipe + } + + [Params(OutputStreamType.Null, OutputStreamType.NamedPipe, OutputStreamType.Yield)] + public OutputStreamType StreamType { get; set; } + + // Metric -> Variant -> Label values + private static readonly string[][][] _labelValueRows; + + private const int _metricCount = 100; + private const int _variantCount = 100; + private const int _labelCount = 5; - private const int _metricCount = 100; - private const int _variantCount = 100; - private const int _labelCount = 5; + private const string _help = "arbitrary help message for metric lorem ipsum dolor golor bolem"; - private const string _help = "arbitrary help message for metric, not relevant for benchmarking"; + static SerializationBenchmarks() + { + _labelValueRows = new string[_metricCount][][]; - static SerializationBenchmarks() + for (var metricIndex = 0; metricIndex < _metricCount; metricIndex++) { - _labelValueRows = new string[_metricCount][][]; + var variants = new string[_variantCount][]; + _labelValueRows[metricIndex] = variants; - for (var metricIndex = 0; metricIndex < _metricCount; metricIndex++) + for (var variantIndex = 0; variantIndex < _variantCount; variantIndex++) { - var variants = new string[_variantCount][]; - _labelValueRows[metricIndex] = variants; - - for (var variantIndex = 0; variantIndex < _variantCount; variantIndex++) - { - var values = new string[_labelCount]; - _labelValueRows[metricIndex][variantIndex] = values; + var values = new string[_labelCount]; + _labelValueRows[metricIndex][variantIndex] = values; - for (var labelIndex = 0; labelIndex < _labelCount; labelIndex++) - values[labelIndex] = $"metric{metricIndex:D2}_label{labelIndex:D2}_variant{variantIndex:D2}"; - } + for (var labelIndex = 0; labelIndex < _labelCount; labelIndex++) + values[labelIndex] = $"metric{metricIndex:D2}_label{labelIndex:D2}_variant{variantIndex:D2}"; } } + } - private readonly CollectorRegistry _registry = Metrics.NewCustomRegistry(); - private readonly Counter[] _counters; - private readonly Gauge[] _gauges; - private readonly Summary[] _summaries; - private readonly Histogram[] _histograms; + private readonly CollectorRegistry _registry = Metrics.NewCustomRegistry(); + private readonly Counter[] _counters; + private readonly Gauge[] _gauges; + private readonly Summary[] _summaries; + private readonly Histogram[] _histograms; - public SerializationBenchmarks() - { - _counters = new Counter[_metricCount]; - _gauges = new Gauge[_metricCount]; - _summaries = new Summary[_metricCount]; - _histograms = new Histogram[_metricCount]; + public SerializationBenchmarks() + { + _counters = new Counter[_metricCount]; + _gauges = new Gauge[_metricCount]; + _summaries = new Summary[_metricCount]; + _histograms = new Histogram[_metricCount]; - var factory = Metrics.WithCustomRegistry(_registry); + var factory = Metrics.WithCustomRegistry(_registry); - // Just use 1st variant for the keys (all we care about are that there is some name-like value in there). - for (var metricIndex = 0; metricIndex < _metricCount; metricIndex++) + // Just use 1st variant for the keys (all we care about are that there is some name-like value in there). + for (var metricIndex = 0; metricIndex < _metricCount; metricIndex++) + { + _counters[metricIndex] = factory.CreateCounter($"counter{metricIndex:D2}", _help, _labelValueRows[metricIndex][0]); + _gauges[metricIndex] = factory.CreateGauge($"gauge{metricIndex:D2}", _help, _labelValueRows[metricIndex][0]); + _summaries[metricIndex] = factory.CreateSummary($"summary{metricIndex:D2}", _help, _labelValueRows[metricIndex][0]); + _histograms[metricIndex] = factory.CreateHistogram($"histogram{metricIndex:D2}", _help, _labelValueRows[metricIndex][0]); + } + + // Genmerate some sample data so the metrics are not all zero-initialized. + var exemplarLabelPair = Exemplar.Key("traceID").WithValue("bar"); + for (var metricIndex = 0; metricIndex < _metricCount; metricIndex++) + for (var variantIndex = 0; variantIndex < _variantCount; variantIndex++) { - _counters[metricIndex] = factory.CreateCounter($"counter{metricIndex:D2}", _help, _labelValueRows[metricIndex][0]); - _gauges[metricIndex] = factory.CreateGauge($"gauge{metricIndex:D2}", _help, _labelValueRows[metricIndex][0]); - _summaries[metricIndex] = factory.CreateSummary($"summary{metricIndex:D2}", _help, _labelValueRows[metricIndex][0]); - _histograms[metricIndex] = factory.CreateHistogram($"histogram{metricIndex:D2}", _help, _labelValueRows[metricIndex][0]); + _counters[metricIndex].Labels(_labelValueRows[metricIndex][variantIndex]).Inc(Exemplar.From(exemplarLabelPair)); + _gauges[metricIndex].Labels(_labelValueRows[metricIndex][variantIndex]).Inc(); + _summaries[metricIndex].Labels(_labelValueRows[metricIndex][variantIndex]).Observe(variantIndex); + _histograms[metricIndex].Labels(_labelValueRows[metricIndex][variantIndex]).Observe(variantIndex, Exemplar.From(exemplarLabelPair)); } + } + + [GlobalSetup] + public void Setup() + { + if (StreamType == OutputStreamType.Null) + { + _outputStream = Stream.Null; } + else if (StreamType == OutputStreamType.Yield) + { + _outputStream = YieldStream.Default; + } + else if (StreamType == OutputStreamType.NamedPipe) + { + var pipeName = StartStreamReader(); + + var pipeStream = new NamedPipeClientStream(".", pipeName.ToString(), PipeDirection.Out, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + pipeStream.Connect(TimeSpan.FromSeconds(1)); + + _outputStream = pipeStream; + } + else + throw new NotSupportedException(); + } - [GlobalSetup] - public void GenerateData() + // Will be reused by all the iterations - the serializer does not take ownership nor close the stream. + private Stream _outputStream; + + // When this is cancelled, the output stream reader will stop listening for new connections and reading data from existing ones. + private readonly CancellationTokenSource _outputStreamReaderCts = new(); + + // We just read data into it, we do not care about the contents. + // While we do not expect concurrent access, it is fine if it does happen because this data is never consumed + private static readonly byte[] _readBuffer = new byte[1024]; + + /// + /// Starts listening on a random port on the loopback interface and returns the name of the created pipe stream. + /// + private Guid StartStreamReader() + { + var name = Guid.NewGuid(); + var server = new NamedPipeServerStream(name.ToString(), PipeDirection.In, NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + var cancel = _outputStreamReaderCts.Token; + + _ = Task.Run(async delegate { - for (var metricIndex = 0; metricIndex < _metricCount; metricIndex++) - for (var variantIndex = 0; variantIndex < _variantCount; variantIndex++) + try + { + while (!cancel.IsCancellationRequested) { - _counters[metricIndex].Labels(_labelValueRows[metricIndex][variantIndex]).Inc(); - _gauges[metricIndex].Labels(_labelValueRows[metricIndex][variantIndex]).Inc(); - _summaries[metricIndex].Labels(_labelValueRows[metricIndex][variantIndex]).Observe(variantIndex); - _histograms[metricIndex].Labels(_labelValueRows[metricIndex][variantIndex]).Observe(variantIndex); + await server.WaitForConnectionAsync(cancel); + Console.WriteLine("Received a connection."); + + try + { + while (!cancel.IsCancellationRequested) + { + var bytesRead = await server.ReadAsync(_readBuffer, cancel); + + if (bytesRead == 0) + break; + } + } + finally + { + server.Disconnect(); + } } - } + } + catch (OperationCanceledException) when (cancel.IsCancellationRequested) + { + // Expected + } + catch (Exception ex) + { + Console.Error.WriteLine($"Unexpected exception in output stream reader: {ex}"); + } + finally + { + server.Dispose(); + } + }); - [Benchmark] - public async Task CollectAndSerialize() - { - await _registry.CollectAndSerializeAsync(new TextSerializer(Stream.Null), default); - } + return name; + } + + [Benchmark] + public async Task CollectAndSerialize() + { + await _registry.CollectAndSerializeAsync(new TextSerializer(_outputStream), default); + } + + [Benchmark] + public async Task CollectAndSerializeOpenMetrics() + { + await _registry.CollectAndSerializeAsync(new TextSerializer(_outputStream, ExpositionFormat.OpenMetricsText), default); + } + + [GlobalCleanup] + public void Cleanup() + { + _outputStreamReaderCts.Cancel(); + _outputStreamReaderCts.Dispose(); } } diff --git a/Benchmark.NetCore/StringSequenceBenchmarks.cs b/Benchmark.NetCore/StringSequenceBenchmarks.cs new file mode 100644 index 00000000..08e61c02 --- /dev/null +++ b/Benchmark.NetCore/StringSequenceBenchmarks.cs @@ -0,0 +1,76 @@ +using BenchmarkDotNet.Attributes; +using Prometheus; + +namespace Benchmark.NetCore; + +[MemoryDiagnoser] +public class StringSequenceBenchmarks +{ + private static readonly string[] Values3Array = ["aaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbb", "cccccccccccccc"]; + private static readonly ReadOnlyMemory Values3Memory = new(Values3Array); + + private static readonly string[] Values3ArrayPart1 = ["aaaaaaaaaaaaaaaaa"]; + private static readonly string[] Values3ArrayPart2 = ["bbbbbbbbbbbbbb"]; + private static readonly string[] Values3ArrayPart3 = ["cccccccccccccc"]; + + [Benchmark] + public void Create_From3Array() + { + StringSequence.From(Values3Array); + } + + [Benchmark] + public void Create_From3Memory() + { + StringSequence.From(Values3Memory); + } + + [Benchmark] + public void Create_From3ArrayConcat() + { + var part1 = StringSequence.From(Values3ArrayPart1); + var part2 = StringSequence.From(Values3ArrayPart2); + var part3 = StringSequence.From(Values3ArrayPart3); + + part1.Concat(part2).Concat(part3); + } + + private static readonly StringSequence FromValues3 = StringSequence.From(Values3Array); + private static readonly StringSequence Other = StringSequence.From(new[] { "fooooooooooooooo", "baaaaaaaaaaaaar", "baaaaaaaaaaz" }); + + [Benchmark] + public void Contains_Positive() + { + FromValues3.Contains(Values3Array[2]); + } + + [Benchmark] + public void Contains_Negative() + { + FromValues3.Contains("a string that is not in there"); + } + + [Benchmark] + public void Equals_Positive() + { + FromValues3.Equals(FromValues3); + } + + [Benchmark] + public void Equals_Negative() + { + FromValues3.Equals(Other); + } + + [Benchmark] + public void Concat_Empty() + { + FromValues3.Concat(StringSequence.Empty); + } + + [Benchmark] + public void Concat_ToEmpty() + { + StringSequence.Empty.Concat(FromValues3); + } +} diff --git a/Benchmark.NetCore/SummaryBenchmarks.cs b/Benchmark.NetCore/SummaryBenchmarks.cs index d4638ede..1a42a91a 100644 --- a/Benchmark.NetCore/SummaryBenchmarks.cs +++ b/Benchmark.NetCore/SummaryBenchmarks.cs @@ -1,77 +1,76 @@ using BenchmarkDotNet.Attributes; using Prometheus; -namespace Benchmark.NetCore +namespace Benchmark.NetCore; + +/// +/// Summary can be quite expensive to use due to its quantile measurement logic. +/// This benchmark helps get a grip on the facts. +/// +[MemoryDiagnoser] +public class SummaryBenchmarks { - /// - /// Summary can be quite expensive to use due to its quantile measurement logic. - /// This benchmark helps get a grip on the facts. - /// - [MemoryDiagnoser] - public class SummaryBenchmarks + // Arbitrary but reasonable objectices we might use for a summary. + private static readonly QuantileEpsilonPair[] Objectives = new[] { - // Arbitrary but reasonable objectices we might use for a summary. - private static readonly QuantileEpsilonPair[] Objectives = new[] - { - new QuantileEpsilonPair(0.5, 0.05), - new QuantileEpsilonPair(0.9, 0.01), - new QuantileEpsilonPair(0.95, 0.01), - new QuantileEpsilonPair(0.99, 0.005) - }; + new QuantileEpsilonPair(0.5, 0.05), + new QuantileEpsilonPair(0.9, 0.01), + new QuantileEpsilonPair(0.95, 0.01), + new QuantileEpsilonPair(0.99, 0.005) + }; - // We pre-generate some random data that we feed into the benchmark, to avoid measuring data generation. - private static readonly double[] Values = new double[1 * 1024 * 1024]; + // We pre-generate some random data that we feed into the benchmark, to avoid measuring data generation. + private static readonly double[] Values = new double[1 * 1024 * 1024]; - private static readonly TimeSpan ExportInterval = TimeSpan.FromMinutes(1); + private static readonly TimeSpan ExportInterval = TimeSpan.FromMinutes(1); - static SummaryBenchmarks() - { - var rnd = new Random(); + static SummaryBenchmarks() + { + var rnd = new Random(); - for (var i = 0; i < Values.Length; i++) - Values[i] = rnd.NextDouble(); - } + for (var i = 0; i < Values.Length; i++) + Values[i] = rnd.NextDouble(); + } - private CollectorRegistry _registry; - private MetricFactory _factory; + private CollectorRegistry _registry; + private MetricFactory _factory; - [IterationSetup] - public void Setup() - { - _registry = Metrics.NewCustomRegistry(); - _factory = Metrics.WithCustomRegistry(_registry); - } + [IterationSetup] + public void Setup() + { + _registry = Metrics.NewCustomRegistry(); + _factory = Metrics.WithCustomRegistry(_registry); + } - [Params(1, 10, 100, 1000, 10000)] - public int MeasurementsPerSecond { get; set; } + [Params(1, 10, 100, 1000, 10000)] + public int MeasurementsPerSecond { get; set; } - [Benchmark] - public async Task Summary_NPerSecond_For10Minutes() + [Benchmark] + public async Task Summary_NPerSecond_For10Minutes() + { + var summary = _factory.CreateSummary("metric_name", "help_string", new SummaryConfiguration { - var summary = _factory.CreateSummary("metric_name", "help_string", new SummaryConfiguration - { - Objectives = Objectives - }); + Objectives = Objectives + }); - var now = DateTime.UtcNow; + var now = DateTime.UtcNow; - // We start far enough back to cover the entire age range of the summary. - var t = now - Summary.DefMaxAge; - var lastExport = t; + // We start far enough back to cover the entire age range of the summary. + var t = now - Summary.DefMaxAge; + var lastExport = t; - while (t < now) - { - for (var i = 0; i < MeasurementsPerSecond; i++) - summary.Observe(Values[i % Values.Length]); + while (t < now) + { + for (var i = 0; i < MeasurementsPerSecond; i++) + summary.Observe(Values[i % Values.Length]); - t += TimeSpan.FromSeconds(1); + t += TimeSpan.FromSeconds(1); - if (lastExport + ExportInterval <= t) - { - lastExport = t; + if (lastExport + ExportInterval <= t) + { + lastExport = t; - await summary.CollectAndSerializeAsync(new TextSerializer(Stream.Null), default); - } + await summary.CollectAndSerializeAsync(new TextSerializer(Stream.Null), true, default); } } } diff --git a/Benchmark.NetCore/YieldStream.cs b/Benchmark.NetCore/YieldStream.cs new file mode 100644 index 00000000..7670c59e --- /dev/null +++ b/Benchmark.NetCore/YieldStream.cs @@ -0,0 +1,119 @@ +using System.Runtime.CompilerServices; + +namespace Benchmark.NetCore; + +/// +/// A stream that does nothing except yielding the task/thread to take up nonzero time. Modeled after NullStream. +/// +internal sealed class YieldStream : Stream +{ + public static readonly YieldStream Default = new(); + + private YieldStream() { } + + public override bool CanRead => true; + public override bool CanWrite => true; + public override bool CanSeek => true; + public override long Length => 0; + public override long Position { get => 0; set { } } + + public override void CopyTo(Stream destination, int bufferSize) + { + Thread.Yield(); + } + + public override async Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + } + + protected override void Dispose(bool disposing) + { + // Do nothing - we don't want this stream to be closable. + } + + public override void Flush() + { + Thread.Yield(); + } + + public override async Task FlushAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + } + + + public override int Read(byte[] buffer, int offset, int count) + { + Thread.Yield(); + return 0; + } + + public override int Read(Span buffer) + { + Thread.Yield(); + return 0; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + return 0; + } + + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + return 0; + } + + public override int ReadByte() + { + Thread.Yield(); + return -1; + } + + public override void Write(byte[] buffer, int offset, int count) + { + Thread.Yield(); + } + + public override void Write(ReadOnlySpan buffer) + { + Thread.Yield(); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + } + + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + } + + public override void WriteByte(byte value) + { + Thread.Yield(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + Thread.Yield(); + return 0; + } + + public override void SetLength(long length) + { + Thread.Yield(); + } +} diff --git a/Docs/MeasurementsBenchmarks.xlsx b/Docs/MeasurementsBenchmarks.xlsx new file mode 100644 index 00000000..f2b29726 Binary files /dev/null and b/Docs/MeasurementsBenchmarks.xlsx differ diff --git a/Exemplars.png b/Exemplars.png new file mode 100644 index 00000000..d199d655 Binary files /dev/null and b/Exemplars.png differ diff --git a/History b/History index 7d06e885..2c7d57cb 100644 --- a/History +++ b/History @@ -1,3 +1,33 @@ +* 8.2.1 +- Fix occasional "Collection was modified" exception when serializing metrics. #464 +* 8.2.0 +- .WithLabels() & similar now accept ReadOnlyMemory as alternative to string[]. Same behavior, just easier to use if you already have a ReadOnlyMemory. +- .WithLabels() & similar now accept ReadOnlySpan as alternative to string[]. This enables allocation-free metric instance creation if a metric instance with these labels is already known. +- Incorporated various optimizations to reduce the required CPU time and allocated memory, including #410, #443 and other contributions. +- Observation of large histograms is now 10-30% faster on machines that support AVX2 instructions. +- health checks exposed via ForwardToPrometheus() no longer create a default metric if a custom metric is provided #444 +* 8.1.1 +- Fix bug in .NET Meters API adapter for UpDownCounter, which was incorrectly transformed to Prometheus metrics. #452 and #453 +* 8.1.0 +- Add support for capturing HttpClient metrics from all registered HttpClients (`services.UseHttpClientMetrics()`). +* 8.0.1 +- Allow ObservableCounter to be reset. Previously, the MeterAdapter output got stuck on its previous maximum if the underlying Meter reset its value to a lower value. Now we transform the value 1:1. +* 8.0.0 +- Added OpenMetrics exposition format support (#388). +- Added exemplar support for Counter and Histogram (#388). +- The ':' character is no longer allowed in metric or label names. For metric names, Prometheus standard practice is to use colon only in recording rules. +- Publish symbol packages and add Source Link support for easier debugging experience. +- Fix defect where metrics with different labels could overwrite each other in specific circumstances with multiple metric factories in use (#389). +- Ensure even harder that MetricPusher flushes the final state before stopping (#383 and #384) +- Simplify DotNetStats built-in collector code for ease of readability and more best practices (#365, #364) +- Slightly improve Counter performance under concurrent load. +- Reduce memory allocations performed during ASP.NET Core HTTP request tracking. +- By default, EventCounterAdapter will only listen to a small predefined set of general-purpose useful event sources, to minimize resource consumption in the default configuration. A custom event source filter must now be provided to enable listening for additional event sources. +- EventCounterAdapter will only refresh data every 10 seconds by default, to reduce amount of garbage generated in memory (.NET event counters are very noisy and create many temporary objects). +- Added `IManagedLifetimeMetricFactory.WithLabels()` to enable simpler label enrichment in scenarios where lifetime-managed metric instances are used. +* 7.1.0 +- Added back .NET Standard 2.0 support as some customers had a hard dependency on .NET Standard 2.0 (despite not being a documented feature even earlier). +- Added (Observable)UpDownCounter support to MeterAdapter (.NET 7 specific feature). * 7.0.0 - .NET Core specific functionality now targeting .NET 6.0 or greater (all previous versions will be end of life by December 2022). - Relaxed the API restriction that forbade you to create multiple metrics with the same name but different label names. While this is a Prometheus anti-pattern, it is a standard pattern in other metrics technologies and we will allow it in the name of interoperability. diff --git a/LocalMetricsCollector/Dockerfile b/LocalMetricsCollector/Dockerfile index 5a4bce22..61ff408b 100644 --- a/LocalMetricsCollector/Dockerfile +++ b/LocalMetricsCollector/Dockerfile @@ -13,8 +13,8 @@ RUN tdnf install -y \ ################### Based on https://github.com/prometheus/prometheus/blob/main/Dockerfile -ARG PROMETHEUS_PACKAGE_NAME=prometheus-2.37.1.linux-amd64 -RUN wget --no-verbose -O prometheus.tar.gz https://github.com/prometheus/prometheus/releases/download/v2.37.1/$PROMETHEUS_PACKAGE_NAME.tar.gz +ARG PROMETHEUS_PACKAGE_NAME=prometheus-2.41.0.linux-amd64 +RUN wget --no-verbose -O prometheus.tar.gz https://github.com/prometheus/prometheus/releases/download/v2.41.0/$PROMETHEUS_PACKAGE_NAME.tar.gz RUN tar xvfz prometheus.tar.gz RUN cp /$PROMETHEUS_PACKAGE_NAME/prometheus /bin/ RUN cp /$PROMETHEUS_PACKAGE_NAME/promtool /bin/ diff --git a/LocalMetricsCollector/prometheus.yml b/LocalMetricsCollector/prometheus.yml index da1e0b4d..885d1252 100644 --- a/LocalMetricsCollector/prometheus.yml +++ b/LocalMetricsCollector/prometheus.yml @@ -7,6 +7,7 @@ global: remote_write: - url: https://prometheus-prod-01-eu-west-0.grafana.net/api/prom/push + send_exemplars: true basic_auth: username: $GRAFANA_USER password: $GRAFANA_API_KEY diff --git a/LocalMetricsCollector/run.sh b/LocalMetricsCollector/run.sh index 60fa19af..ab0c4e07 100644 --- a/LocalMetricsCollector/run.sh +++ b/LocalMetricsCollector/run.sh @@ -6,4 +6,4 @@ set -e envsubst < /app/prometheus.yml > /etc/prometheus/prometheus.yml # We must listen on 0.0.0.0 here because otherwise the liveness/readiness probes cannot reach us. -exec /bin/prometheus --web.listen-address=0.0.0.0:$PROMETHEUS_PORT --storage.tsdb.retention.time=5m --storage.tsdb.min-block-duration=2m --config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/prometheus --web.console.libraries=/usr/share/prometheus/console_libraries --web.console.templates=/usr/share/prometheus/consoles \ No newline at end of file +exec /bin/prometheus --web.listen-address=0.0.0.0:$PROMETHEUS_PORT --storage.tsdb.retention.time=5m --storage.tsdb.min-block-duration=2m --config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/prometheus --web.console.libraries=/usr/share/prometheus/console_libraries --web.console.templates=/usr/share/prometheus/consoles --enable-feature=exemplar-storage \ No newline at end of file diff --git a/Prometheus.AspNetCore.Grpc/GrpcMetricsMiddlewareExtensions.cs b/Prometheus.AspNetCore.Grpc/GrpcMetricsMiddlewareExtensions.cs index d2309b90..e9cbcfea 100644 --- a/Prometheus.AspNetCore.Grpc/GrpcMetricsMiddlewareExtensions.cs +++ b/Prometheus.AspNetCore.Grpc/GrpcMetricsMiddlewareExtensions.cs @@ -1,35 +1,34 @@ using Microsoft.AspNetCore.Builder; -namespace Prometheus +namespace Prometheus; + +public static class GrpcMetricsMiddlewareExtensions { - public static class GrpcMetricsMiddlewareExtensions + /// + /// Configures the ASP.NET Core request pipeline to collect Prometheus metrics on processed gRPC requests. + /// + public static IApplicationBuilder UseGrpcMetrics(this IApplicationBuilder app, + Action configure) { - /// - /// Configures the ASP.NET Core request pipeline to collect Prometheus metrics on processed gRPC requests. - /// - public static IApplicationBuilder UseGrpcMetrics(this IApplicationBuilder app, - Action configure) - { - var options = new GrpcMiddlewareExporterOptions(); - configure?.Invoke(options); - app.UseGrpcMetrics(options); - return app; - } - - /// - /// Configures the ASP.NET Core request pipeline to collect Prometheus metrics on processed gRPC requests. - /// - public static IApplicationBuilder UseGrpcMetrics(this IApplicationBuilder app, - GrpcMiddlewareExporterOptions? options = null) - { - options ??= new GrpcMiddlewareExporterOptions(); + var options = new GrpcMiddlewareExporterOptions(); + configure?.Invoke(options); + app.UseGrpcMetrics(options); + return app; + } - if (options.RequestCount.Enabled) - { - app.UseMiddleware(options.RequestCount); - } + /// + /// Configures the ASP.NET Core request pipeline to collect Prometheus metrics on processed gRPC requests. + /// + public static IApplicationBuilder UseGrpcMetrics(this IApplicationBuilder app, + GrpcMiddlewareExporterOptions? options = null) + { + options ??= new GrpcMiddlewareExporterOptions(); - return app; + if (options.RequestCount.Enabled) + { + app.UseMiddleware(options.RequestCount); } + + return app; } } diff --git a/Prometheus.AspNetCore.Grpc/GrpcMetricsOptionsBase.cs b/Prometheus.AspNetCore.Grpc/GrpcMetricsOptionsBase.cs index 63f6056f..034478ea 100644 --- a/Prometheus.AspNetCore.Grpc/GrpcMetricsOptionsBase.cs +++ b/Prometheus.AspNetCore.Grpc/GrpcMetricsOptionsBase.cs @@ -1,13 +1,12 @@ -namespace Prometheus +namespace Prometheus; + +public abstract class GrpcMetricsOptionsBase { - public abstract class GrpcMetricsOptionsBase - { - public bool Enabled { get; set; } = true; + public bool Enabled { get; set; } = true; - /// - /// Allows you to override the registry used to create the default metric instance. - /// Value is ignored if you specify a custom metric instance in the options. - /// - public CollectorRegistry? Registry { get; set; } - } + /// + /// Allows you to override the registry used to create the default metric instance. + /// Value is ignored if you specify a custom metric instance in the options. + /// + public CollectorRegistry? Registry { get; set; } } diff --git a/Prometheus.AspNetCore.Grpc/GrpcMiddlewareExporterOptions.cs b/Prometheus.AspNetCore.Grpc/GrpcMiddlewareExporterOptions.cs index f7d65578..8254fc19 100644 --- a/Prometheus.AspNetCore.Grpc/GrpcMiddlewareExporterOptions.cs +++ b/Prometheus.AspNetCore.Grpc/GrpcMiddlewareExporterOptions.cs @@ -1,7 +1,6 @@ -namespace Prometheus +namespace Prometheus; + +public sealed class GrpcMiddlewareExporterOptions { - public sealed class GrpcMiddlewareExporterOptions - { - public GrpcRequestCountOptions RequestCount { get; set; } = new GrpcRequestCountOptions(); - } + public GrpcRequestCountOptions RequestCount { get; set; } = new GrpcRequestCountOptions(); } diff --git a/Prometheus.AspNetCore.Grpc/GrpcRequestCountMiddleware.cs b/Prometheus.AspNetCore.Grpc/GrpcRequestCountMiddleware.cs index 2a154699..9eba9ec1 100644 --- a/Prometheus.AspNetCore.Grpc/GrpcRequestCountMiddleware.cs +++ b/Prometheus.AspNetCore.Grpc/GrpcRequestCountMiddleware.cs @@ -1,32 +1,31 @@ using Microsoft.AspNetCore.Http; -namespace Prometheus +namespace Prometheus; + +/// +/// Counts the number of requests to gRPC services. +/// +internal sealed class GrpcRequestCountMiddleware : GrpcRequestMiddlewareBase, ICounter> { - /// - /// Counts the number of requests to gRPC services. - /// - internal sealed class GrpcRequestCountMiddleware : GrpcRequestMiddlewareBase, ICounter> - { - private readonly RequestDelegate _next; + private readonly RequestDelegate _next; - public GrpcRequestCountMiddleware(RequestDelegate next, GrpcRequestCountOptions? options) - : base(options, options?.Counter) - { - _next = next ?? throw new ArgumentNullException(nameof(next)); - } + public GrpcRequestCountMiddleware(RequestDelegate next, GrpcRequestCountOptions? options) + : base(options, options?.Counter) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + } - public async Task Invoke(HttpContext context) - { - CreateChild(context)?.Inc(); + public async Task Invoke(HttpContext context) + { + CreateChild(context)?.Inc(); - await _next(context); - } + await _next(context); + } - protected override string[] DefaultLabels => GrpcRequestLabelNames.All; + protected override string[] DefaultLabels => GrpcRequestLabelNames.All; - protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateCounter( - "grpc_requests_received_total", - "Number of gRPC requests received (including those currently being processed).", - labelNames); - } + protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateCounter( + "grpc_requests_received_total", + "Number of gRPC requests received (including those currently being processed).", + labelNames); } \ No newline at end of file diff --git a/Prometheus.AspNetCore.Grpc/GrpcRequestCountOptions.cs b/Prometheus.AspNetCore.Grpc/GrpcRequestCountOptions.cs index d0fcc291..dd986ef2 100644 --- a/Prometheus.AspNetCore.Grpc/GrpcRequestCountOptions.cs +++ b/Prometheus.AspNetCore.Grpc/GrpcRequestCountOptions.cs @@ -1,10 +1,9 @@ -namespace Prometheus +namespace Prometheus; + +public sealed class GrpcRequestCountOptions : GrpcMetricsOptionsBase { - public sealed class GrpcRequestCountOptions : GrpcMetricsOptionsBase - { - /// - /// Set this to use a custom metric instead of the default. - /// - public ICollector? Counter { get; set; } - } + /// + /// Set this to use a custom metric instead of the default. + /// + public ICollector? Counter { get; set; } } diff --git a/Prometheus.AspNetCore.Grpc/GrpcRequestLabelNames.cs b/Prometheus.AspNetCore.Grpc/GrpcRequestLabelNames.cs index ac17708e..95a79886 100644 --- a/Prometheus.AspNetCore.Grpc/GrpcRequestLabelNames.cs +++ b/Prometheus.AspNetCore.Grpc/GrpcRequestLabelNames.cs @@ -1,17 +1,16 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// Reserved label names used in gRPC metrics. +/// +public static class GrpcRequestLabelNames { - /// - /// Reserved label names used in gRPC metrics. - /// - public static class GrpcRequestLabelNames - { - public const string Service = "service"; - public const string Method = "method"; + public const string Service = "service"; + public const string Method = "method"; - public static readonly string[] All = - { - Service, - Method, - }; - } + public static readonly string[] All = + { + Service, + Method, + }; } diff --git a/Prometheus.AspNetCore.Grpc/GrpcRequestMiddlewareBase.cs b/Prometheus.AspNetCore.Grpc/GrpcRequestMiddlewareBase.cs index ceae81a0..8f6c80d2 100644 --- a/Prometheus.AspNetCore.Grpc/GrpcRequestMiddlewareBase.cs +++ b/Prometheus.AspNetCore.Grpc/GrpcRequestMiddlewareBase.cs @@ -1,99 +1,98 @@ using Grpc.AspNetCore.Server; using Microsoft.AspNetCore.Http; -namespace Prometheus +namespace Prometheus; + +// Modeled after HttpRequestMiddlewareBase, just with gRPC specific functionality. +internal abstract class GrpcRequestMiddlewareBase + where TCollector : class, ICollector + where TChild : class, ICollectorChild { - // Modeled after HttpRequestMiddlewareBase, just with gRPC specific functionality. - internal abstract class GrpcRequestMiddlewareBase - where TCollector : class, ICollector - where TChild : class, ICollectorChild - { - /// - /// The set of labels from among the defaults that this metric supports. - /// - /// This set will be automatically extended with labels for additional - /// route parameters when creating the default metric instance. - /// - protected abstract string[] DefaultLabels { get; } + /// + /// The set of labels from among the defaults that this metric supports. + /// + /// This set will be automatically extended with labels for additional + /// route parameters when creating the default metric instance. + /// + protected abstract string[] DefaultLabels { get; } - /// - /// Creates the default metric instance with the specified set of labels. - /// Only used if the caller does not provide a custom metric instance in the options. - /// - protected abstract TCollector CreateMetricInstance(string[] labelNames); + /// + /// Creates the default metric instance with the specified set of labels. + /// Only used if the caller does not provide a custom metric instance in the options. + /// + protected abstract TCollector CreateMetricInstance(string[] labelNames); - /// - /// The factory to use for creating the default metric for this middleware. - /// Not used if a custom metric is alreaedy provided in options. - /// - protected MetricFactory MetricFactory { get; } + /// + /// The factory to use for creating the default metric for this middleware. + /// Not used if a custom metric is alreaedy provided in options. + /// + protected MetricFactory MetricFactory { get; } - private readonly TCollector _metric; + private readonly TCollector _metric; + + protected GrpcRequestMiddlewareBase(GrpcMetricsOptionsBase? options, TCollector? customMetric) + { + MetricFactory = Metrics.WithCustomRegistry(options?.Registry ?? Metrics.DefaultRegistry); - protected GrpcRequestMiddlewareBase(GrpcMetricsOptionsBase? options, TCollector? customMetric) + if (customMetric != null) { - MetricFactory = Metrics.WithCustomRegistry(options?.Registry ?? Metrics.DefaultRegistry); + _metric = customMetric; + ValidateNoUnexpectedLabelNames(); + } + else + { + _metric = CreateMetricInstance(DefaultLabels); + } + } - if (customMetric != null) - { - _metric = customMetric; - ValidateNoUnexpectedLabelNames(); - } - else - { - _metric = CreateMetricInstance(DefaultLabels); - } + protected TChild? CreateChild(HttpContext context) + { + var metadata = context.GetEndpoint()?.Metadata?.GetMetadata(); + if (metadata == null) + { + // Not a gRPC request + return null; } - protected TChild? CreateChild(HttpContext context) + if (!_metric.LabelNames.Any()) { - var metadata = context.GetEndpoint()?.Metadata?.GetMetadata(); - if (metadata == null) - { - // Not a gRPC request - return null; - } + return _metric.Unlabelled; + } - if (!_metric.LabelNames.Any()) - { - return _metric.Unlabelled; - } + return CreateChild(context, metadata); + } - return CreateChild(context, metadata); - } + protected TChild CreateChild(HttpContext context, GrpcMethodMetadata metadata) + { + var labelValues = new string[_metric.LabelNames.Length]; - protected TChild CreateChild(HttpContext context, GrpcMethodMetadata metadata) + for (var i = 0; i < labelValues.Length; i++) { - var labelValues = new string[_metric.LabelNames.Length]; - - for (var i = 0; i < labelValues.Length; i++) + switch (_metric.LabelNames[i]) { - switch (_metric.LabelNames[i]) - { - case GrpcRequestLabelNames.Service: - labelValues[i] = metadata.Method.ServiceName; - break; - case GrpcRequestLabelNames.Method: - labelValues[i] = metadata.Method.Name; - break; - default: - // Should never reach this point because we validate in ctor. - throw new NotSupportedException($"Unexpected label name on {_metric.Name}: {_metric.LabelNames[i]}"); - } + case GrpcRequestLabelNames.Service: + labelValues[i] = metadata.Method.ServiceName; + break; + case GrpcRequestLabelNames.Method: + labelValues[i] = metadata.Method.Name; + break; + default: + // Should never reach this point because we validate in ctor. + throw new NotSupportedException($"Unexpected label name on {_metric.Name}: {_metric.LabelNames[i]}"); } - - return _metric.WithLabels(labelValues); } - /// - /// If we use a custom metric, it should not have labels that are neither defaults nor additional route parameters. - /// - private void ValidateNoUnexpectedLabelNames() - { - var unexpected = _metric.LabelNames.Except(DefaultLabels); + return _metric.WithLabels(labelValues); + } - if (unexpected.Any()) - throw new ArgumentException($"Provided custom gRPC request metric instance for {GetType().Name} has some unexpected labels: {string.Join(", ", unexpected)}."); - } + /// + /// If we use a custom metric, it should not have labels that are neither defaults nor additional route parameters. + /// + private void ValidateNoUnexpectedLabelNames() + { + var unexpected = _metric.LabelNames.Except(DefaultLabels); + + if (unexpected.Any()) + throw new ArgumentException($"Provided custom gRPC request metric instance for {GetType().Name} has some unexpected labels: {string.Join(", ", unexpected)}."); } } \ No newline at end of file diff --git a/Prometheus.AspNetCore.Grpc/Prometheus.AspNetCore.Grpc.csproj b/Prometheus.AspNetCore.Grpc/Prometheus.AspNetCore.Grpc.csproj index 62447b54..7d1556fa 100644 --- a/Prometheus.AspNetCore.Grpc/Prometheus.AspNetCore.Grpc.csproj +++ b/Prometheus.AspNetCore.Grpc/Prometheus.AspNetCore.Grpc.csproj @@ -19,8 +19,43 @@ 9999 True + + + true + true + + + prometheus-net.AspNetCore.Grpc + sandersaares + prometheus-net + prometheus-net + ASP.NET Core gRPC integration with Prometheus + Copyright © prometheus-net developers + https://github.com/prometheus-net/prometheus-net + prometheus-net-logo.png + README.md + metrics prometheus aspnetcore + MIT + True + snupkg + + + true + + + + + True + \ + + + True + \ + + + @@ -30,4 +65,8 @@ + + + + diff --git a/Prometheus.AspNetCore.HealthChecks/HealthCheckBuilderExtensions.cs b/Prometheus.AspNetCore.HealthChecks/HealthCheckBuilderExtensions.cs index c8afbf92..187fa7aa 100644 --- a/Prometheus.AspNetCore.HealthChecks/HealthCheckBuilderExtensions.cs +++ b/Prometheus.AspNetCore.HealthChecks/HealthCheckBuilderExtensions.cs @@ -1,15 +1,14 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; -namespace Prometheus +namespace Prometheus; + +public static class HealthCheckBuilderExtensions { - public static class HealthCheckBuilderExtensions + public static IHealthChecksBuilder ForwardToPrometheus(this IHealthChecksBuilder builder, PrometheusHealthCheckPublisherOptions? options = null) { - public static IHealthChecksBuilder ForwardToPrometheus(this IHealthChecksBuilder builder, PrometheusHealthCheckPublisherOptions? options = null) - { - builder.Services.AddSingleton(provider => new PrometheusHealthCheckPublisher(options)); + builder.Services.AddSingleton(provider => new PrometheusHealthCheckPublisher(options)); - return builder; - } + return builder; } } diff --git a/Prometheus.AspNetCore.HealthChecks/Prometheus.AspNetCore.HealthChecks.csproj b/Prometheus.AspNetCore.HealthChecks/Prometheus.AspNetCore.HealthChecks.csproj index 95f49e9d..68ade9f2 100644 --- a/Prometheus.AspNetCore.HealthChecks/Prometheus.AspNetCore.HealthChecks.csproj +++ b/Prometheus.AspNetCore.HealthChecks/Prometheus.AspNetCore.HealthChecks.csproj @@ -19,18 +19,56 @@ 9999 True + + + true + true + + + prometheus-net.AspNetCore.HealthChecks + sandersaares + prometheus-net + prometheus-net + ASP.NET Core Health Checks integration with Prometheus + Copyright © prometheus-net developers + https://github.com/prometheus-net/prometheus-net + prometheus-net-logo.png + README.md + metrics prometheus aspnetcore + MIT + True + snupkg + + + + + true + + + True + \ + + + True + \ + + + + + + diff --git a/Prometheus.AspNetCore.HealthChecks/PrometheusHealthCheckPublisher.cs b/Prometheus.AspNetCore.HealthChecks/PrometheusHealthCheckPublisher.cs index 609c6483..95b08115 100644 --- a/Prometheus.AspNetCore.HealthChecks/PrometheusHealthCheckPublisher.cs +++ b/Prometheus.AspNetCore.HealthChecks/PrometheusHealthCheckPublisher.cs @@ -1,40 +1,39 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; -namespace Prometheus +namespace Prometheus; + +/// +/// Publishes ASP.NET Core Health Check statuses as Prometheus metrics. +/// +internal sealed class PrometheusHealthCheckPublisher : IHealthCheckPublisher { - /// - /// Publishes ASP.NET Core Health Check statuses as Prometheus metrics. - /// - internal sealed class PrometheusHealthCheckPublisher : IHealthCheckPublisher - { - private readonly Gauge _checkStatus; + private readonly Gauge _checkStatus; - public PrometheusHealthCheckPublisher(PrometheusHealthCheckPublisherOptions? options) - { - _checkStatus = options?.Gauge ?? new PrometheusHealthCheckPublisherOptions().Gauge; - } + public PrometheusHealthCheckPublisher(PrometheusHealthCheckPublisherOptions? options) + { + _checkStatus = options?.Gauge ?? new PrometheusHealthCheckPublisherOptions().GetDefaultGauge(); + } - public Task PublishAsync(HealthReport report, CancellationToken cancellationToken) - { - foreach (var reportEntry in report.Entries) - _checkStatus.WithLabels(reportEntry.Key).Set(HealthStatusToMetricValue(reportEntry.Value.Status)); + public Task PublishAsync(HealthReport report, CancellationToken cancellationToken) + { + foreach (var reportEntry in report.Entries) + _checkStatus.WithLabels(reportEntry.Key).Set(HealthStatusToMetricValue(reportEntry.Value.Status)); - return Task.CompletedTask; - } + return Task.CompletedTask; + } - private static double HealthStatusToMetricValue(HealthStatus status) + private static double HealthStatusToMetricValue(HealthStatus status) + { + switch (status) { - switch (status) - { - case HealthStatus.Unhealthy: - return 0; - case HealthStatus.Degraded: - return 0.5; - case HealthStatus.Healthy: - return 1; - default: - throw new NotSupportedException($"Unexpected HealthStatus value: {status}"); - } + case HealthStatus.Unhealthy: + return 0; + case HealthStatus.Degraded: + return 0.5; + case HealthStatus.Healthy: + return 1; + default: + throw new NotSupportedException($"Unexpected HealthStatus value: {status}"); } } } diff --git a/Prometheus.AspNetCore.HealthChecks/PrometheusHealthCheckPublisherOptions.cs b/Prometheus.AspNetCore.HealthChecks/PrometheusHealthCheckPublisherOptions.cs index 2157bfe2..df2c5400 100644 --- a/Prometheus.AspNetCore.HealthChecks/PrometheusHealthCheckPublisherOptions.cs +++ b/Prometheus.AspNetCore.HealthChecks/PrometheusHealthCheckPublisherOptions.cs @@ -1,11 +1,14 @@ -namespace Prometheus +namespace Prometheus; + +public sealed class PrometheusHealthCheckPublisherOptions { - public sealed class PrometheusHealthCheckPublisherOptions - { - private const string DefaultName = "aspnetcore_healthcheck_status"; - private const string DefaultHelp = "ASP.NET Core health check status (0 == Unhealthy, 0.5 == Degraded, 1 == Healthy)"; + private const string DefaultName = "aspnetcore_healthcheck_status"; + private const string DefaultHelp = "ASP.NET Core health check status (0 == Unhealthy, 0.5 == Degraded, 1 == Healthy)"; - public Gauge Gauge { get; set; } = - Metrics.CreateGauge(DefaultName, DefaultHelp, labelNames: new[] { "name" }); + public Gauge? Gauge { get; set; } + + public Gauge GetDefaultGauge() + { + return Metrics.CreateGauge(DefaultName, DefaultHelp, labelNames: new[] { "name" }); } } diff --git a/Prometheus.AspNetCore/HttpMetrics/CaptureRouteDataMiddleware.cs b/Prometheus.AspNetCore/HttpMetrics/CaptureRouteDataMiddleware.cs index 04dcecb3..af2416ff 100644 --- a/Prometheus.AspNetCore/HttpMetrics/CaptureRouteDataMiddleware.cs +++ b/Prometheus.AspNetCore/HttpMetrics/CaptureRouteDataMiddleware.cs @@ -1,46 +1,45 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +/// +/// If routing data is available before executing the inner handler, this routing data is captured +/// and can be used later by other middlewares that wish not to be affected by runtime changes to routing data. +/// +/// +/// This is intended to be executed after the .UseRouting() middleware that performs ASP.NET Core 3 endpoint routing. +/// +/// The captured route data is stored in the context via ICapturedRouteDataFeature. +/// +internal sealed class CaptureRouteDataMiddleware { - /// - /// If routing data is available before executing the inner handler, this routing data is captured - /// and can be used later by other middlewares that wish not to be affected by runtime changes to routing data. - /// - /// - /// This is intended to be executed after the .UseRouting() middleware that performs ASP.NET Core 3 endpoint routing. - /// - /// The captured route data is stored in the context via ICapturedRouteDataFeature. - /// - internal sealed class CaptureRouteDataMiddleware - { - private readonly RequestDelegate _next; + private readonly RequestDelegate _next; - public CaptureRouteDataMiddleware(RequestDelegate next) - { - _next = next; - } + public CaptureRouteDataMiddleware(RequestDelegate next) + { + _next = next; + } - public Task Invoke(HttpContext context) - { - TryCaptureRouteData(context); + public Task Invoke(HttpContext context) + { + TryCaptureRouteData(context); - return _next(context); - } + return _next(context); + } - private static void TryCaptureRouteData(HttpContext context) - { - var routeData = context.GetRouteData(); + private static void TryCaptureRouteData(HttpContext context) + { + var routeData = context.GetRouteData(); - if (routeData == null || routeData.Values.Count <= 0) - return; + if (routeData == null || routeData.Values.Count <= 0) + return; - var capturedRouteData = new CapturedRouteDataFeature(); + var capturedRouteData = new CapturedRouteDataFeature(); - foreach (var pair in routeData.Values) - capturedRouteData.Values.Add(pair.Key, pair.Value); + foreach (var pair in routeData.Values) + capturedRouteData.Values.Add(pair.Key, pair.Value); - context.Features.Set(capturedRouteData); - } + context.Features.Set(capturedRouteData); } } diff --git a/Prometheus.AspNetCore/HttpMetrics/CapturedRouteDataFeature.cs b/Prometheus.AspNetCore/HttpMetrics/CapturedRouteDataFeature.cs index 847ff518..f2562e2b 100644 --- a/Prometheus.AspNetCore/HttpMetrics/CapturedRouteDataFeature.cs +++ b/Prometheus.AspNetCore/HttpMetrics/CapturedRouteDataFeature.cs @@ -1,9 +1,8 @@ using Microsoft.AspNetCore.Routing; -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +sealed class CapturedRouteDataFeature : ICapturedRouteDataFeature { - sealed class CapturedRouteDataFeature : ICapturedRouteDataFeature - { - public RouteValueDictionary Values { get; } = new RouteValueDictionary(); - } + public RouteValueDictionary Values { get; } = new RouteValueDictionary(); } diff --git a/Prometheus.AspNetCore/HttpMetrics/HttpCustomLabel.cs b/Prometheus.AspNetCore/HttpMetrics/HttpCustomLabel.cs index cedd3809..830781c9 100644 --- a/Prometheus.AspNetCore/HttpMetrics/HttpCustomLabel.cs +++ b/Prometheus.AspNetCore/HttpMetrics/HttpCustomLabel.cs @@ -1,23 +1,22 @@ using Microsoft.AspNetCore.Http; -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +public sealed class HttpCustomLabel { - public sealed class HttpCustomLabel - { - /// - /// Name of the Prometheus label. - /// - public string LabelName { get; } + /// + /// Name of the Prometheus label. + /// + public string LabelName { get; } - /// - /// A method that extracts the label value from the HttpContext of the request being handled. - /// - public Func LabelValueProvider { get; } + /// + /// A method that extracts the label value from the HttpContext of the request being handled. + /// + public Func LabelValueProvider { get; } - public HttpCustomLabel(string labelName, Func labelValueProvider) - { - LabelName = labelName; - LabelValueProvider = labelValueProvider; - } + public HttpCustomLabel(string labelName, Func labelValueProvider) + { + LabelName = labelName; + LabelValueProvider = labelValueProvider; } } diff --git a/Prometheus.AspNetCore/HttpMetrics/HttpInProgressMiddleware.cs b/Prometheus.AspNetCore/HttpMetrics/HttpInProgressMiddleware.cs index 5b97f4b0..bff49072 100644 --- a/Prometheus.AspNetCore/HttpMetrics/HttpInProgressMiddleware.cs +++ b/Prometheus.AspNetCore/HttpMetrics/HttpInProgressMiddleware.cs @@ -1,33 +1,30 @@ using Microsoft.AspNetCore.Http; -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +internal sealed class HttpInProgressMiddleware : HttpRequestMiddlewareBase, IGauge> { - internal sealed class HttpInProgressMiddleware : HttpRequestMiddlewareBase, IGauge> - { - private readonly RequestDelegate _next; + private readonly RequestDelegate _next; - public HttpInProgressMiddleware(RequestDelegate next, HttpInProgressOptions options) - : base(options, options?.Gauge) - { - _next = next ?? throw new ArgumentNullException(nameof(next)); - } + public HttpInProgressMiddleware(RequestDelegate next, HttpInProgressOptions options) + : base(options, options?.Gauge) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + } - public async Task Invoke(HttpContext context) + public async Task Invoke(HttpContext context) + { + // CreateChild() will take care of applying the right labels, no need to think hard about it here. + using (CreateChild(context).TrackInProgress()) { - // In ASP.NET Core 2, we will not have route data, so we cannot record controller/action labels. - // In ASP.NET Core 3, we will have this data and can record the labels. - // CreateChild() will take care of applying the right labels, no need to think hard about it here. - using (CreateChild(context).TrackInProgress()) - { - await _next(context); - } + await _next(context); } + } - protected override string[] BaselineLabels => HttpRequestLabelNames.DefaultsAvailableBeforeExecutingFinalHandler; + protected override string[] BaselineLabels => HttpRequestLabelNames.DefaultsAvailableBeforeExecutingFinalHandler; - protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateGauge( - "http_requests_in_progress", - "The number of requests currently in progress in the ASP.NET Core pipeline. One series without controller/action label values counts all in-progress requests, with separate series existing for each controller-action pair.", - labelNames); - } + protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateGauge( + "http_requests_in_progress", + "The number of requests currently in progress in the ASP.NET Core pipeline. One series without controller/action label values counts all in-progress requests, with separate series existing for each controller-action pair.", + labelNames); } \ No newline at end of file diff --git a/Prometheus.AspNetCore/HttpMetrics/HttpInProgressOptions.cs b/Prometheus.AspNetCore/HttpMetrics/HttpInProgressOptions.cs index 7e3d30b2..df09ea3d 100644 --- a/Prometheus.AspNetCore/HttpMetrics/HttpInProgressOptions.cs +++ b/Prometheus.AspNetCore/HttpMetrics/HttpInProgressOptions.cs @@ -1,10 +1,9 @@ -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +public sealed class HttpInProgressOptions : HttpMetricsOptionsBase { - public sealed class HttpInProgressOptions : HttpMetricsOptionsBase - { - /// - /// Set this to use a custom metric instead of the default. - /// - public ICollector? Gauge { get; set; } - } + /// + /// Set this to use a custom metric instead of the default. + /// + public ICollector? Gauge { get; set; } } \ No newline at end of file diff --git a/Prometheus.AspNetCore/HttpMetrics/HttpMetricsOptionsBase.cs b/Prometheus.AspNetCore/HttpMetrics/HttpMetricsOptionsBase.cs index bfae5120..ab4ca5d7 100644 --- a/Prometheus.AspNetCore/HttpMetrics/HttpMetricsOptionsBase.cs +++ b/Prometheus.AspNetCore/HttpMetrics/HttpMetricsOptionsBase.cs @@ -1,48 +1,66 @@ -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +public abstract class HttpMetricsOptionsBase { - public abstract class HttpMetricsOptionsBase - { - public bool Enabled { get; set; } = true; - - /// - /// Transforms the label value from it's raw value (e.g. 200, 404) into a compressed - /// alternative (e.g. 2xx, 4xx). Setting this to true can be used to reduce the cardinality of metrics produced while still clearly communicating - /// success and error conditions (client vs server error). Defaults to false. - /// - public bool ReduceStatusCodeCardinality { get; set; } = false; - - /// - /// Additional route parameters to include beyond the defaults (controller/action). - /// This may be useful if you have, for example, a "version" parameter for API versioning. - /// - /// - /// Metric labels are automatically defined for these parameters, unless you provide your - /// own metric instance in the options (in which case you must add the required labels). - /// - public List AdditionalRouteParameters { get; set; } = new List(); - - /// - /// Additional custom labels to add to the metrics, with values extracted from the HttpContext of incoming requests. - /// - /// - /// Metric labels are automatically defined for these, unless you provide your - /// own metric instance in the options (in which case you must add the required labels). - /// - public List CustomLabels { get; set; } = new List(); - - /// - /// Allows you to override the registry used to create the default metric instance. - /// Value is ignored if you specify a custom metric instance in the options. - /// - public CollectorRegistry? Registry { get; set; } - - /// - /// If set, the "page" label will be considered one of the built-in default labels. - /// This is only enabled if Razor Pages is detected at the middleware setup stage. - /// - /// The value is ignored if a custom metric is provided (though the user may still add - /// the "page" label themselves via AdditionalRouteParameters and it will work). - /// - internal bool IncludePageLabelInDefaultsInternal { get; set; } - } + public bool Enabled { get; set; } = true; + + /// + /// Transforms the label value from it's raw value (e.g. 200, 404) into a compressed + /// alternative (e.g. 2xx, 4xx). Setting this to true can be used to reduce the cardinality of metrics produced while still clearly communicating + /// success and error conditions (client vs server error). Defaults to false. + /// + public bool ReduceStatusCodeCardinality { get; set; } = false; + + /// + /// Additional route parameters to include beyond the defaults (controller/action). + /// This may be useful if you have, for example, a "version" parameter for API versioning. + /// + /// + /// Metric labels are automatically defined for these parameters, unless you provide your + /// own metric instance in the options (in which case you must add the required labels). + /// + public List AdditionalRouteParameters { get; set; } = new List(); + + /// + /// Additional custom labels to add to the metrics, with values extracted from the HttpContext of incoming requests. + /// + /// + /// Metric labels are automatically defined for these, unless you provide your + /// own metric instance in the options (in which case you must add the required labels). + /// + public List CustomLabels { get; set; } = new List(); + + /// + /// Allows you to override the registry used to create the default metric instance. + /// + /// + /// Value is ignored if you specify a custom metric instance or metric factory in the options (instance overrides factory overrides registry). + /// + public CollectorRegistry? Registry { get; set; } + + /// + /// Allows you to override the metric factory used to create the default metric instance. + /// + /// + /// Value is ignored if you specify a custom metric instance in the options (instance overrides factory overrides registry). + /// + public IMetricFactory? MetricFactory { get; set; } + + /// + /// Enables custom logic to determine whether an exemplar should be recorded for a specific HTTP request. + /// This will be called after request processing has completed and the response has been filled by inner handlers. + /// + /// + /// By default, we always record an exemplar (if an exemplar is available for the given request/response pair). + /// + public HttpRequestExemplarPredicate ExemplarPredicate { get; set; } = _ => true; + + /// + /// If set, the "page" label will be considered one of the built-in default labels. + /// This is only enabled if Razor Pages is detected at the middleware setup stage. + /// + /// The value is ignored if a custom metric is provided (though the user may still add + /// the "page" label themselves via AdditionalRouteParameters and it will work). + /// + internal bool IncludePageLabelInDefaultsInternal { get; set; } } \ No newline at end of file diff --git a/Prometheus.AspNetCore/HttpMetrics/HttpMiddlewareExporterOptions.cs b/Prometheus.AspNetCore/HttpMetrics/HttpMiddlewareExporterOptions.cs index e20f8565..f133f8d0 100644 --- a/Prometheus.AspNetCore/HttpMetrics/HttpMiddlewareExporterOptions.cs +++ b/Prometheus.AspNetCore/HttpMetrics/HttpMiddlewareExporterOptions.cs @@ -1,65 +1,85 @@ using Microsoft.AspNetCore.Http; -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +public sealed class HttpMiddlewareExporterOptions { - public sealed class HttpMiddlewareExporterOptions + public HttpInProgressOptions InProgress { get; set; } = new HttpInProgressOptions(); + public HttpRequestCountOptions RequestCount { get; set; } = new HttpRequestCountOptions(); + public HttpRequestDurationOptions RequestDuration { get; set; } = new HttpRequestDurationOptions(); + + /// + /// Whether to capture metrics for queries to the /metrics endpoint (where metrics are exported by default). Defaults to false. + /// This matches against URLs starting with the /metrics string specifically - if you use a custom metrics endpoint, this will not match. + /// + public bool CaptureMetricsUrl { get; set; } + + /// + /// Configures all the different types of metrics to use reduced status code cardinality (using 2xx instead of 200, 201 etc). + /// + public void ReduceStatusCodeCardinality() { - public HttpInProgressOptions InProgress { get; set; } = new HttpInProgressOptions(); - public HttpRequestCountOptions RequestCount { get; set; } = new HttpRequestCountOptions(); - public HttpRequestDurationOptions RequestDuration { get; set; } = new HttpRequestDurationOptions(); + InProgress.ReduceStatusCodeCardinality = true; + RequestCount.ReduceStatusCodeCardinality = true; + RequestDuration.ReduceStatusCodeCardinality = true; + } - /// - /// Whether to capture metrics for queries to the /metrics endpoint (where metrics are exported by default). Defaults to false. - /// This matches against URLs starting with the /metrics string specifically - if you use a custom metrics endpoint, this will not match. - /// - public bool CaptureMetricsUrl { get; set; } + /// + /// Adds an additional route parameter to all the HTTP metrics. + /// + /// Helper method to avoid manually adding it to each one. + /// + public void AddRouteParameter(HttpRouteParameterMapping mapping) + { + InProgress.AdditionalRouteParameters.Add(mapping); + RequestCount.AdditionalRouteParameters.Add(mapping); + RequestDuration.AdditionalRouteParameters.Add(mapping); + } - /// - /// Configures all the different types of metrics to use reduced status code cardinality (using 2xx instead of 200, 201 etc). - /// - public void ReduceStatusCodeCardinality() - { - InProgress.ReduceStatusCodeCardinality = true; - RequestCount.ReduceStatusCodeCardinality = true; - RequestDuration.ReduceStatusCodeCardinality = true; - } + /// + /// Adds a custom label to all the HTTP metrics. + /// + /// Helper method to avoid manually adding it to each one. + /// + public void AddCustomLabel(HttpCustomLabel mapping) + { + InProgress.CustomLabels.Add(mapping); + RequestCount.CustomLabels.Add(mapping); + RequestDuration.CustomLabels.Add(mapping); + } - /// - /// Adds an additional route parameter to all the HTTP metrics. - /// - /// Helper method to avoid manually adding it to each one. - /// - public void AddRouteParameter(HttpRouteParameterMapping mapping) - { - InProgress.AdditionalRouteParameters.Add(mapping); - RequestCount.AdditionalRouteParameters.Add(mapping); - RequestDuration.AdditionalRouteParameters.Add(mapping); - } + /// + /// Adds a custom label to all the HTTP metrics. + /// + /// Helper method to avoid manually adding it to each one. + /// + public void AddCustomLabel(string labelName, Func valueProvider) + { + var mapping = new HttpCustomLabel(labelName, valueProvider); - /// - /// Adds a custom label to all the HTTP metrics. - /// - /// Helper method to avoid manually adding it to each one. - /// - public void AddCustomLabel(HttpCustomLabel mapping) - { - InProgress.CustomLabels.Add(mapping); - RequestCount.CustomLabels.Add(mapping); - RequestDuration.CustomLabels.Add(mapping); - } + InProgress.CustomLabels.Add(mapping); + RequestCount.CustomLabels.Add(mapping); + RequestDuration.CustomLabels.Add(mapping); + } - /// - /// Adds a custom label to all the HTTP metrics. - /// - /// Helper method to avoid manually adding it to each one. - /// - public void AddCustomLabel(string labelName, Func valueProvider) - { - var mapping = new HttpCustomLabel(labelName, valueProvider); + /// + /// Configures the middleware to use a custom metric factory for creating the metrics. + /// This provides an easy way to add custom static labels and configure exemplar behavior for all created metrics. + /// + public void SetMetricFactory(IMetricFactory metricFactory) + { + InProgress.MetricFactory = metricFactory; + RequestCount.MetricFactory = metricFactory; + RequestDuration.MetricFactory = metricFactory; + } - InProgress.CustomLabels.Add(mapping); - RequestCount.CustomLabels.Add(mapping); - RequestDuration.CustomLabels.Add(mapping); - } + /// + /// Configures the options that are shared between all metrics exposed by the HTTP server exporter. + /// + public void ConfigureMeasurements(Action configure) + { + configure(InProgress); + configure(RequestCount); + configure(RequestDuration); } } \ No newline at end of file diff --git a/Prometheus.AspNetCore/HttpMetrics/HttpRequestCountMiddleware.cs b/Prometheus.AspNetCore/HttpMetrics/HttpRequestCountMiddleware.cs index 3ad34cc0..faba435e 100644 --- a/Prometheus.AspNetCore/HttpMetrics/HttpRequestCountMiddleware.cs +++ b/Prometheus.AspNetCore/HttpMetrics/HttpRequestCountMiddleware.cs @@ -1,37 +1,38 @@ using Microsoft.AspNetCore.Http; -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +internal sealed class HttpRequestCountMiddleware : HttpRequestMiddlewareBase, ICounter> { - internal sealed class HttpRequestCountMiddleware : HttpRequestMiddlewareBase, ICounter> + private readonly RequestDelegate _next; + private readonly HttpRequestCountOptions _options; + + public HttpRequestCountMiddleware(RequestDelegate next, HttpRequestCountOptions options) + : base(options, options?.Counter) { - private readonly RequestDelegate _next; + _next = next ?? throw new ArgumentNullException(nameof(next)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } - public HttpRequestCountMiddleware(RequestDelegate next, HttpRequestCountOptions options) - : base(options, options?.Counter) + public async Task Invoke(HttpContext context) + { + try { - _next = next ?? throw new ArgumentNullException(nameof(next)); + await _next(context); } - - public async Task Invoke(HttpContext context) + finally { - try - { - await _next(context); - } - finally - { - // We need to record metrics after inner handler execution because routing data in - // ASP.NET Core 2 is only available *after* executing the next request delegate. - // So we would not have the right labels if we tried to create the child early on. - CreateChild(context).Inc(); - } + // We pass either null (== use default exemplar provider) or None (== do not record exemplar). + Exemplar? exemplar = _options.ExemplarPredicate(context) ? null : Exemplar.None; + + CreateChild(context).Inc(exemplar); } + } - protected override string[] BaselineLabels => HttpRequestLabelNames.Default; + protected override string[] BaselineLabels => HttpRequestLabelNames.Default; - protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateCounter( - "http_requests_received_total", - "Provides the count of HTTP requests that have been processed by the ASP.NET Core pipeline.", - labelNames); - } + protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateCounter( + "http_requests_received_total", + "Provides the count of HTTP requests that have been processed by the ASP.NET Core pipeline.", + labelNames); } \ No newline at end of file diff --git a/Prometheus.AspNetCore/HttpMetrics/HttpRequestCountOptions.cs b/Prometheus.AspNetCore/HttpMetrics/HttpRequestCountOptions.cs index d395cea2..e09e0d98 100644 --- a/Prometheus.AspNetCore/HttpMetrics/HttpRequestCountOptions.cs +++ b/Prometheus.AspNetCore/HttpMetrics/HttpRequestCountOptions.cs @@ -1,10 +1,9 @@ -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +public sealed class HttpRequestCountOptions : HttpMetricsOptionsBase { - public sealed class HttpRequestCountOptions : HttpMetricsOptionsBase - { - /// - /// Set this to use a custom metric instead of the default. - /// - public ICollector? Counter { get; set; } - } + /// + /// Set this to use a custom metric instead of the default. + /// + public ICollector? Counter { get; set; } } \ No newline at end of file diff --git a/Prometheus.AspNetCore/HttpMetrics/HttpRequestDurationMiddleware.cs b/Prometheus.AspNetCore/HttpMetrics/HttpRequestDurationMiddleware.cs index e62c8d82..820f8da4 100644 --- a/Prometheus.AspNetCore/HttpMetrics/HttpRequestDurationMiddleware.cs +++ b/Prometheus.AspNetCore/HttpMetrics/HttpRequestDurationMiddleware.cs @@ -1,44 +1,45 @@ using Microsoft.AspNetCore.Http; -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +internal sealed class HttpRequestDurationMiddleware : HttpRequestMiddlewareBase, IHistogram> { - internal sealed class HttpRequestDurationMiddleware : HttpRequestMiddlewareBase, IHistogram> + private readonly RequestDelegate _next; + private readonly HttpRequestDurationOptions _options; + + public HttpRequestDurationMiddleware(RequestDelegate next, HttpRequestDurationOptions options) + : base(options, options?.Histogram) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public async Task Invoke(HttpContext context) { - private readonly RequestDelegate _next; + var stopWatch = ValueStopwatch.StartNew(); - public HttpRequestDurationMiddleware(RequestDelegate next, HttpRequestDurationOptions options) - : base(options, options?.Histogram) + try { - _next = next ?? throw new ArgumentNullException(nameof(next)); + await _next(context); } - - public async Task Invoke(HttpContext context) + finally { - var stopWatch = ValueStopwatch.StartNew(); - - // We need to write this out in long form instead of using a timer because routing data in - // ASP.NET Core 2 is only available *after* executing the next request delegate. - // So we would not have the right labels if we tried to create the child early on. - try - { - await _next(context); - } - finally - { - CreateChild(context).Observe(stopWatch.GetElapsedTime().TotalSeconds); - } + // We pass either null (== use default exemplar provider) or None (== do not record exemplar). + Exemplar? exemplar = _options.ExemplarPredicate(context) ? null : Exemplar.None; + + CreateChild(context).Observe(stopWatch.GetElapsedTime().TotalSeconds, exemplar); } + } - protected override string[] BaselineLabels => HttpRequestLabelNames.Default; + protected override string[] BaselineLabels => HttpRequestLabelNames.Default; - protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateHistogram( - "http_request_duration_seconds", - "The duration of HTTP requests processed by an ASP.NET Core application.", - labelNames, - new HistogramConfiguration - { - // 1 ms to 32K ms buckets - Buckets = Histogram.ExponentialBuckets(0.001, 2, 16), - }); - } + protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateHistogram( + "http_request_duration_seconds", + "The duration of HTTP requests processed by an ASP.NET Core application.", + labelNames, + new HistogramConfiguration + { + // 1 ms to 32K ms buckets + Buckets = Histogram.ExponentialBuckets(0.001, 2, 16), + }); } \ No newline at end of file diff --git a/Prometheus.AspNetCore/HttpMetrics/HttpRequestDurationOptions.cs b/Prometheus.AspNetCore/HttpMetrics/HttpRequestDurationOptions.cs index ccdd1272..ddda75aa 100644 --- a/Prometheus.AspNetCore/HttpMetrics/HttpRequestDurationOptions.cs +++ b/Prometheus.AspNetCore/HttpMetrics/HttpRequestDurationOptions.cs @@ -1,10 +1,9 @@ -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +public sealed class HttpRequestDurationOptions : HttpMetricsOptionsBase { - public sealed class HttpRequestDurationOptions : HttpMetricsOptionsBase - { - /// - /// Set this to use a custom metric instead of the default. - /// - public ICollector? Histogram { get; set; } - } + /// + /// Set this to use a custom metric instead of the default. + /// + public ICollector? Histogram { get; set; } } \ No newline at end of file diff --git a/Prometheus.AspNetCore/HttpMetrics/HttpRequestExemplarPredicate.cs b/Prometheus.AspNetCore/HttpMetrics/HttpRequestExemplarPredicate.cs new file mode 100644 index 00000000..91fce1f9 --- /dev/null +++ b/Prometheus.AspNetCore/HttpMetrics/HttpRequestExemplarPredicate.cs @@ -0,0 +1,5 @@ +using Microsoft.AspNetCore.Http; + +namespace Prometheus.HttpMetrics; + +public delegate bool HttpRequestExemplarPredicate(HttpContext context); diff --git a/Prometheus.AspNetCore/HttpMetrics/HttpRequestLabelNames.cs b/Prometheus.AspNetCore/HttpMetrics/HttpRequestLabelNames.cs index dfcc6009..d2fbcfdd 100644 --- a/Prometheus.AspNetCore/HttpMetrics/HttpRequestLabelNames.cs +++ b/Prometheus.AspNetCore/HttpMetrics/HttpRequestLabelNames.cs @@ -1,55 +1,54 @@ -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +/// +/// Label names used by the HTTP request handler metrics system. +/// +public static class HttpRequestLabelNames { - /// - /// Label names used by the HTTP request handler metrics system. - /// - public static class HttpRequestLabelNames - { - public const string Code = "code"; - public const string Method = "method"; - public const string Controller = "controller"; - public const string Action = "action"; + public const string Code = "code"; + public const string Method = "method"; + public const string Controller = "controller"; + public const string Action = "action"; - // Not reserved for background-compatibility, as it used to be optional and user-supplied. - // Conditionally, it may also be automatically added to metrics. - public const string Page = "page"; + // Not reserved for background-compatibility, as it used to be optional and user-supplied. + // Conditionally, it may also be automatically added to metrics. + public const string Page = "page"; - // Not reserved for background-compatibility, as it used to be optional and user-supplied. - public const string Endpoint = "endpoint"; + // Not reserved for background-compatibility, as it used to be optional and user-supplied. + public const string Endpoint = "endpoint"; - // All labels that are supported by prometheus-net default logic. - // Useful if you want to define a custom metric that extends the default logic, without hardcoding the built-in label names. - public static readonly string[] All = - { - Code, - Method, - Controller, - Action, - Page, - Endpoint - }; + // All labels that are supported by prometheus-net default logic. + // Useful if you want to define a custom metric that extends the default logic, without hardcoding the built-in label names. + public static readonly string[] All = + { + Code, + Method, + Controller, + Action, + Page, + Endpoint + }; - // These are reserved and may only be used with the default logic. - internal static readonly string[] Default = - { - Code, - Method, - Controller, - Action - }; + // These are reserved and may only be used with the default logic. + internal static readonly string[] Default = + { + Code, + Method, + Controller, + Action + }; - internal static readonly string[] DefaultsAvailableBeforeExecutingFinalHandler = - { - Method, - Controller, - Action - }; + internal static readonly string[] DefaultsAvailableBeforeExecutingFinalHandler = + { + Method, + Controller, + Action + }; - // Labels that do not need routing information to be collected. - internal static readonly string[] NonRouteSpecific = - { - Code, - Method - }; - } + // Labels that do not need routing information to be collected. + internal static readonly string[] NonRouteSpecific = + { + Code, + Method + }; } \ No newline at end of file diff --git a/Prometheus.AspNetCore/HttpMetrics/HttpRequestMiddlewareBase.cs b/Prometheus.AspNetCore/HttpMetrics/HttpRequestMiddlewareBase.cs index 1a8e4f4d..0a5cea70 100644 --- a/Prometheus.AspNetCore/HttpMetrics/HttpRequestMiddlewareBase.cs +++ b/Prometheus.AspNetCore/HttpMetrics/HttpRequestMiddlewareBase.cs @@ -2,329 +2,328 @@ using Microsoft.AspNetCore.Routing; using System.Globalization; -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +/// +/// This base class performs the data management necessary to associate the correct labels and values +/// with HTTP request metrics, depending on the options the user has provided for the HTTP metric middleware. +/// +/// The following labels are supported: +/// 'code' (HTTP status code) +/// 'method' (HTTP request method) +/// 'controller' (The Controller used to fulfill the HTTP request) +/// 'action' (The Action used to fulfill the HTTP request) +/// Any other label - from one of: +/// * HTTP route parameter (if name/mapping specified in options; name need not match). +/// * custom logic (callback decides value for each request) +/// +/// The 'code' and 'method' data are taken from the current HTTP context. +/// 'controller', 'action' and route parameter labels will be taken from the request routing information. +/// +/// If a custom metric is provided in the options, it must not be missing any labels for explicitly defined +/// custom route parameters. However, it is permitted to lack any of the default labels (code/method/...). +/// +internal abstract class HttpRequestMiddlewareBase + where TCollector : class, ICollector + where TChild : class, ICollectorChild { /// - /// This base class performs the data management necessary to associate the correct labels and values - /// with HTTP request metrics, depending on the options the user has provided for the HTTP metric middleware. + /// The set of labels from among the defaults that this metric supports. /// - /// The following labels are supported: - /// 'code' (HTTP status code) - /// 'method' (HTTP request method) - /// 'controller' (The Controller used to fulfill the HTTP request) - /// 'action' (The Action used to fulfill the HTTP request) - /// Any other label - from one of: - /// * HTTP route parameter (if name/mapping specified in options; name need not match). - /// * custom logic (callback decides value for each request) + /// This set will be automatically extended with labels for additional + /// route parameters and custom labels when creating the default metric instance. /// - /// The 'code' and 'method' data are taken from the current HTTP context. - /// 'controller', 'action' and route parameter labels will be taken from the request routing information. - /// - /// If a custom metric is provided in the options, it must not be missing any labels for explicitly defined - /// custom route parameters. However, it is permitted to lack any of the default labels (code/method/...). + /// It will also be extended by additional built-in logic (page, endpoint). /// - internal abstract class HttpRequestMiddlewareBase - where TCollector : class, ICollector - where TChild : class, ICollectorChild - { - /// - /// The set of labels from among the defaults that this metric supports. - /// - /// This set will be automatically extended with labels for additional - /// route parameters and custom labels when creating the default metric instance. - /// - /// It will also be extended by additional built-in logic (page, endpoint). - /// - protected abstract string[] BaselineLabels { get; } - - /// - /// Creates the default metric instance with the specified set of labels. - /// Only used if the caller does not provide a custom metric instance in the options. - /// - protected abstract TCollector CreateMetricInstance(string[] labelNames); - - /// - /// The factory to use for creating the default metric for this middleware. - /// Not used if a custom metric is already provided in options. - /// - protected MetricFactory MetricFactory { get; } - - private readonly List _additionalRouteParameters; - private readonly List _customLabels; - private readonly TCollector _metric; - - // For labels that are route parameter mappings. - private readonly Dictionary _labelToRouteParameterMap; - - // For labels that use a custom value provider. - private readonly Dictionary> _labelToValueProviderMap; - - private readonly bool _labelsRequireRouteData; - private readonly bool _reduceStatusCodeCardinality; - - protected HttpRequestMiddlewareBase(HttpMetricsOptionsBase options, TCollector? customMetric) - { - MetricFactory = Metrics.WithCustomRegistry(options.Registry ?? Metrics.DefaultRegistry); + protected abstract string[] BaselineLabels { get; } - _additionalRouteParameters = options.AdditionalRouteParameters ?? new List(0); - _customLabels = options.CustomLabels ?? new List(0); + /// + /// Creates the default metric instance with the specified set of labels. + /// Only used if the caller does not provide a custom metric instance in the options. + /// + protected abstract TCollector CreateMetricInstance(string[] labelNames); - if (options.IncludePageLabelInDefaultsInternal) - AddPageLabelIfNoConflict(customMetric); + /// + /// The factory to use for creating the default metric for this middleware. + /// Not used if a custom metric is already provided in options. + /// + protected IMetricFactory MetricFactory { get; } - AddEndpointLabelIfNoConflict(customMetric); + private readonly List _additionalRouteParameters; + private readonly List _customLabels; + private readonly TCollector _metric; - ValidateMappings(); - _labelToRouteParameterMap = CreateLabelToRouteParameterMap(); - _reduceStatusCodeCardinality = options?.ReduceStatusCodeCardinality ?? false; - _labelToValueProviderMap = CreateLabelToValueProviderMap(); + // For labels that are route parameter mappings. + private readonly Dictionary _labelToRouteParameterMap; - if (customMetric != null) - { - _metric = customMetric; + // For labels that use a custom value provider. + private readonly Dictionary> _labelToValueProviderMap; - ValidateNoUnexpectedLabelNames(); - ValidateAdditionalRouteParametersPresentInMetricLabelNames(); - ValidateCustomLabelsPresentInMetricLabelNames(); - } - else - { - _metric = CreateMetricInstance(CreateDefaultLabelSet()); - } + private readonly bool _labelsRequireRouteData; + private readonly bool _reduceStatusCodeCardinality; - _labelsRequireRouteData = _metric.LabelNames.Except(HttpRequestLabelNames.NonRouteSpecific).Any(); - } + protected HttpRequestMiddlewareBase(HttpMetricsOptionsBase options, TCollector? customMetric) + { + MetricFactory = options.MetricFactory ?? Metrics.WithCustomRegistry(options.Registry ?? Metrics.DefaultRegistry); - private void AddPageLabelIfNoConflict(TCollector? customMetric) - { - // We were asked to add the "page" label because Razor Pages was detected. - // We will only do this if nothing else has already occupied the "page" label. - // If a custom metric is used, we also skip this if it has no "page" label name defined. - // - // The possible conflicts are: - // * an existing route parameter mapping (which works out the same as our logic, so fine) - // * custom logic that defines a "page" label (in which case we allow it to win, for backward compatibility). + _additionalRouteParameters = options.AdditionalRouteParameters ?? new List(0); + _customLabels = options.CustomLabels ?? new List(0); - if (_additionalRouteParameters.Any(x => x.LabelName == HttpRequestLabelNames.Page)) - return; + if (options.IncludePageLabelInDefaultsInternal) + AddPageLabelIfNoConflict(customMetric); - if (_customLabels.Any(x => x.LabelName == HttpRequestLabelNames.Page)) - return; + AddEndpointLabelIfNoConflict(customMetric); - if (customMetric != null && !customMetric.LabelNames.Contains(HttpRequestLabelNames.Page)) - return; + ValidateMappings(); + _labelToRouteParameterMap = CreateLabelToRouteParameterMap(); + _reduceStatusCodeCardinality = options?.ReduceStatusCodeCardinality ?? false; + _labelToValueProviderMap = CreateLabelToValueProviderMap(); - // If we got so far, we are good - all preconditions for adding "page" label exist. - _additionalRouteParameters.Add(new HttpRouteParameterMapping("page")); - } + if (customMetric != null) + { + _metric = customMetric; - private void AddEndpointLabelIfNoConflict(TCollector? customMetric) + ValidateNoUnexpectedLabelNames(); + ValidateAdditionalRouteParametersPresentInMetricLabelNames(); + ValidateCustomLabelsPresentInMetricLabelNames(); + } + else { - // We always try to add an "endpoint" label with the endpoint routing route pattern. - // We will only do this if nothing else has already occupied the "endpoint" label. - // If a custom metric is used, we also skip this if it has no "endpoint" label name defined. - // - // The possible conflicts are: - // * an existing route parameter mapping - // * custom logic that defines an "endpoint" label - // - // In case of conflict, we let the user-defined item win. - - if (_additionalRouteParameters.Any(x => x.LabelName == HttpRequestLabelNames.Endpoint)) - return; - - if (_customLabels.Any(x => x.LabelName == HttpRequestLabelNames.Endpoint)) - return; - - if (customMetric != null && !customMetric.LabelNames.Contains(HttpRequestLabelNames.Endpoint)) - return; - - _customLabels.Add(new HttpCustomLabel(HttpRequestLabelNames.Endpoint, context => - { - var endpoint = context.GetEndpoint() as RouteEndpoint; - return endpoint?.RoutePattern.RawText ?? ""; - })); + _metric = CreateMetricInstance(CreateDefaultLabelSet()); } - /// - /// Creates the metric child instance to use for measurements. - /// - /// - /// Internal for testing purposes. - /// - protected internal TChild CreateChild(HttpContext context) - { - if (!_metric.LabelNames.Any()) - return _metric.Unlabelled; + _labelsRequireRouteData = _metric.LabelNames.Except(HttpRequestLabelNames.NonRouteSpecific).Any(); + } - if (!_labelsRequireRouteData) - return CreateChild(context, null); + private void AddPageLabelIfNoConflict(TCollector? customMetric) + { + // We were asked to add the "page" label because Razor Pages was detected. + // We will only do this if nothing else has already occupied the "page" label. + // If a custom metric is used, we also skip this if it has no "page" label name defined. + // + // The possible conflicts are: + // * an existing route parameter mapping (which works out the same as our logic, so fine) + // * custom logic that defines a "page" label (in which case we allow it to win, for backward compatibility). - var routeData = context.Features.Get()?.Values; + if (_additionalRouteParameters.Any(x => x.LabelName == HttpRequestLabelNames.Page)) + return; - // If we have captured route data, we always prefer it. - // Otherwise, we extract new route data right now. - if (routeData == null) - routeData = context.GetRouteData()?.Values; + if (_customLabels.Any(x => x.LabelName == HttpRequestLabelNames.Page)) + return; - return CreateChild(context, routeData); - } + if (customMetric != null && !customMetric.LabelNames.Contains(HttpRequestLabelNames.Page)) + return; + + // If we got so far, we are good - all preconditions for adding "page" label exist. + _additionalRouteParameters.Add(new HttpRouteParameterMapping("page")); + } - protected TChild CreateChild(HttpContext context, RouteValueDictionary? routeData) + private void AddEndpointLabelIfNoConflict(TCollector? customMetric) + { + // We always try to add an "endpoint" label with the endpoint routing route pattern. + // We will only do this if nothing else has already occupied the "endpoint" label. + // If a custom metric is used, we also skip this if it has no "endpoint" label name defined. + // + // The possible conflicts are: + // * an existing route parameter mapping + // * custom logic that defines an "endpoint" label + // + // In case of conflict, we let the user-defined item win. + + if (_additionalRouteParameters.Any(x => x.LabelName == HttpRequestLabelNames.Endpoint)) + return; + + if (_customLabels.Any(x => x.LabelName == HttpRequestLabelNames.Endpoint)) + return; + + if (customMetric != null && !customMetric.LabelNames.Contains(HttpRequestLabelNames.Endpoint)) + return; + + _customLabels.Add(new HttpCustomLabel(HttpRequestLabelNames.Endpoint, context => { - var labelValues = new string[_metric.LabelNames.Length]; + var endpoint = context.GetEndpoint() as RouteEndpoint; + return endpoint?.RoutePattern.RawText ?? ""; + })); + } - for (var i = 0; i < labelValues.Length; i++) - { - switch (_metric.LabelNames[i]) - { - case HttpRequestLabelNames.Method: - labelValues[i] = context.Request.Method; - break; - case HttpRequestLabelNames.Code: - labelValues[i] = _reduceStatusCodeCardinality ? Math.Floor(context.Response.StatusCode / 100.0).ToString("#xx") : context.Response.StatusCode.ToString(CultureInfo.InvariantCulture); - break; - default: - // If we get to this point it must be either: - if (_labelToRouteParameterMap.TryGetValue(_metric.LabelNames[i], out var parameterName)) - { - // A mapped route parameter. - labelValues[i] = routeData?[parameterName] as string ?? string.Empty; - } - else if (_labelToValueProviderMap.TryGetValue(_metric.LabelNames[i], out var valueProvider)) - { - // A custom label value provider. - labelValues[i] = valueProvider(context) ?? string.Empty; - } - else - { - // Something we do not have data for. - // This can happen if, for example, a custom metric inherits "all" the labels without reimplementing the "when do we add which label" - // logic that prometheus-net implements (which is an entirely reasonable design). So it might just add a "page" label when we have no - // page information. Instead of rejecting such custom metrics, we just leave the label value empty and carry on. - labelValues[i] = ""; - } - break; - } - } + /// + /// Creates the metric child instance to use for measurements. + /// + /// + /// Internal for testing purposes. + /// + protected internal TChild CreateChild(HttpContext context) + { + if (!_metric.LabelNames.Any()) + return _metric.Unlabelled; - return _metric.WithLabels(labelValues); - } + if (!_labelsRequireRouteData) + return CreateChild(context, null); + var routeData = context.Features.Get()?.Values; - /// - /// Creates the set of labels defined on the automatically created metric. - /// - private string[] CreateDefaultLabelSet() - { - return BaselineLabels - .Concat(_additionalRouteParameters.Select(x => x.LabelName)) - .Concat(_customLabels.Select(x => x.LabelName)) - .ToArray(); - } + // If we have captured route data, we always prefer it. + // Otherwise, we extract new route data right now. + if (routeData == null) + routeData = context.GetRouteData()?.Values; + + return CreateChild(context, routeData); + } - /// - /// Creates the full set of labels ALLOWED for the current metric. - /// This may be a greater set than the labels automatically added to the default metric. - /// - private string[] CreateAllowedLabelSet() + protected TChild CreateChild(HttpContext context, RouteValueDictionary? routeData) + { + var labelValues = new string[_metric.LabelNames.Length]; + + for (var i = 0; i < labelValues.Length; i++) { - return HttpRequestLabelNames.All - .Concat(_additionalRouteParameters.Select(x => x.LabelName)) - .Concat(_customLabels.Select(x => x.LabelName)) - .Distinct() // Some builtins may also exist in the customs, with customs overwriting. That's fine. - .ToArray(); + switch (_metric.LabelNames[i]) + { + case HttpRequestLabelNames.Method: + labelValues[i] = context.Request.Method; + break; + case HttpRequestLabelNames.Code: + labelValues[i] = _reduceStatusCodeCardinality ? Math.Floor(context.Response.StatusCode / 100.0).ToString("#xx") : context.Response.StatusCode.ToString(CultureInfo.InvariantCulture); + break; + default: + // If we get to this point it must be either: + if (_labelToRouteParameterMap.TryGetValue(_metric.LabelNames[i], out var parameterName)) + { + // A mapped route parameter. + labelValues[i] = routeData?[parameterName] as string ?? string.Empty; + } + else if (_labelToValueProviderMap.TryGetValue(_metric.LabelNames[i], out var valueProvider)) + { + // A custom label value provider. + labelValues[i] = valueProvider(context) ?? string.Empty; + } + else + { + // Something we do not have data for. + // This can happen if, for example, a custom metric inherits "all" the labels without reimplementing the "when do we add which label" + // logic that prometheus-net implements (which is an entirely reasonable design). So it might just add a "page" label when we have no + // page information. Instead of rejecting such custom metrics, we just leave the label value empty and carry on. + labelValues[i] = ""; + } + break; + } } - private void ValidateMappings() - { - var routeParameterLabelNames = _additionalRouteParameters.Select(x => x.LabelName).ToList(); + return _metric.WithLabels(labelValues); + } - if (routeParameterLabelNames.Distinct(StringComparer.InvariantCultureIgnoreCase).Count() != routeParameterLabelNames.Count) - throw new ArgumentException("The set of additional route parameters to track contains multiple entries with the same label name.", nameof(HttpMetricsOptionsBase.AdditionalRouteParameters)); - if (HttpRequestLabelNames.Default.Except(routeParameterLabelNames, StringComparer.InvariantCultureIgnoreCase).Count() != HttpRequestLabelNames.Default.Length) - throw new ArgumentException($"The set of additional route parameters to track contains an entry with a reserved label name. Reserved label names are: {string.Join(", ", HttpRequestLabelNames.Default)}"); + /// + /// Creates the set of labels defined on the automatically created metric. + /// + private string[] CreateDefaultLabelSet() + { + return BaselineLabels + .Concat(_additionalRouteParameters.Select(x => x.LabelName)) + .Concat(_customLabels.Select(x => x.LabelName)) + .ToArray(); + } - var customLabelNames = _customLabels.Select(x => x.LabelName).ToList(); + /// + /// Creates the full set of labels ALLOWED for the current metric. + /// This may be a greater set than the labels automatically added to the default metric. + /// + private string[] CreateAllowedLabelSet() + { + return HttpRequestLabelNames.All + .Concat(_additionalRouteParameters.Select(x => x.LabelName)) + .Concat(_customLabels.Select(x => x.LabelName)) + .Distinct() // Some builtins may also exist in the customs, with customs overwriting. That's fine. + .ToArray(); + } - if (customLabelNames.Distinct(StringComparer.InvariantCultureIgnoreCase).Count() != customLabelNames.Count) - throw new ArgumentException("The set of custom labels contains multiple entries with the same label name.", nameof(HttpMetricsOptionsBase.CustomLabels)); + private void ValidateMappings() + { + var routeParameterLabelNames = _additionalRouteParameters.Select(x => x.LabelName).ToList(); - if (HttpRequestLabelNames.Default.Except(customLabelNames, StringComparer.InvariantCultureIgnoreCase).Count() != HttpRequestLabelNames.Default.Length) - throw new ArgumentException($"The set of custom labels contains an entry with a reserved label name. Reserved label names are: {string.Join(", ", HttpRequestLabelNames.Default)}"); + if (routeParameterLabelNames.Distinct(StringComparer.InvariantCultureIgnoreCase).Count() != routeParameterLabelNames.Count) + throw new ArgumentException("The set of additional route parameters to track contains multiple entries with the same label name.", nameof(HttpMetricsOptionsBase.AdditionalRouteParameters)); - if (customLabelNames.Intersect(routeParameterLabelNames).Any()) - throw new ArgumentException("The set of custom labels and the set of additional route parameters contain conflicting label names.", nameof(HttpMetricsOptionsBase.CustomLabels)); - } + if (HttpRequestLabelNames.Default.Except(routeParameterLabelNames, StringComparer.InvariantCultureIgnoreCase).Count() != HttpRequestLabelNames.Default.Length) + throw new ArgumentException($"The set of additional route parameters to track contains an entry with a reserved label name. Reserved label names are: {string.Join(", ", HttpRequestLabelNames.Default)}"); - private Dictionary CreateLabelToRouteParameterMap() - { - var map = new Dictionary(_additionalRouteParameters.Count + 2); + var customLabelNames = _customLabels.Select(x => x.LabelName).ToList(); - // Defaults are hardcoded. - map["action"] = "action"; - map["controller"] = "controller"; + if (customLabelNames.Distinct(StringComparer.InvariantCultureIgnoreCase).Count() != customLabelNames.Count) + throw new ArgumentException("The set of custom labels contains multiple entries with the same label name.", nameof(HttpMetricsOptionsBase.CustomLabels)); - // Any additional ones are merged. - foreach (var entry in _additionalRouteParameters) - map[entry.LabelName] = entry.ParameterName; + if (HttpRequestLabelNames.Default.Except(customLabelNames, StringComparer.InvariantCultureIgnoreCase).Count() != HttpRequestLabelNames.Default.Length) + throw new ArgumentException($"The set of custom labels contains an entry with a reserved label name. Reserved label names are: {string.Join(", ", HttpRequestLabelNames.Default)}"); - return map; - } + if (customLabelNames.Intersect(routeParameterLabelNames).Any()) + throw new ArgumentException("The set of custom labels and the set of additional route parameters contain conflicting label names.", nameof(HttpMetricsOptionsBase.CustomLabels)); + } - private Dictionary> CreateLabelToValueProviderMap() - { - var map = new Dictionary>(_customLabels.Count); + private Dictionary CreateLabelToRouteParameterMap() + { + var map = new Dictionary(_additionalRouteParameters.Count + 2); - foreach (var entry in _customLabels) - map[entry.LabelName] = entry.LabelValueProvider; + // Defaults are hardcoded. + map["action"] = "action"; + map["controller"] = "controller"; - return map; - } + // Any additional ones are merged. + foreach (var entry in _additionalRouteParameters) + map[entry.LabelName] = entry.ParameterName; - /// - /// Inspects the metric instance to ensure that all required labels are present. - /// - /// - /// If there are mappings to include route parameters in the labels, there must be labels defined for each such parameter. - /// We do this automatically if we use the default metric instance but if a custom one is provided, this must be done by the caller. - /// - private void ValidateAdditionalRouteParametersPresentInMetricLabelNames() - { - var labelNames = _additionalRouteParameters.Select(x => x.LabelName).ToList(); - var missing = labelNames.Except(_metric.LabelNames); + return map; + } - if (missing.Any()) - throw new ArgumentException($"Provided custom HTTP request metric instance for {GetType().Name} is missing required labels: {string.Join(", ", missing)}."); - } + private Dictionary> CreateLabelToValueProviderMap() + { + var map = new Dictionary>(_customLabels.Count); - /// - /// Inspects the metric instance to ensure that all required labels are present. - /// - /// - /// If there are mappings to include custom labels, there must be label names defined for each such parameter. - /// We do this automatically if we use the default metric instance but if a custom one is provided, this must be done by the caller. - /// - private void ValidateCustomLabelsPresentInMetricLabelNames() - { - var labelNames = _customLabels.Select(x => x.LabelName).ToList(); - var missing = labelNames.Except(_metric.LabelNames); + foreach (var entry in _customLabels) + map[entry.LabelName] = entry.LabelValueProvider; - if (missing.Any()) - throw new ArgumentException($"Provided custom HTTP request metric instance for {GetType().Name} is missing required labels: {string.Join(", ", missing)}."); - } + return map; + } - /// - /// If we use a custom metric, it should not have labels that are neither defaults nor additional route parameters. - /// - private void ValidateNoUnexpectedLabelNames() - { - var allowedLabels = CreateAllowedLabelSet(); - var unexpected = _metric.LabelNames.Except(allowedLabels); + /// + /// Inspects the metric instance to ensure that all required labels are present. + /// + /// + /// If there are mappings to include route parameters in the labels, there must be labels defined for each such parameter. + /// We do this automatically if we use the default metric instance but if a custom one is provided, this must be done by the caller. + /// + private void ValidateAdditionalRouteParametersPresentInMetricLabelNames() + { + var labelNames = _additionalRouteParameters.Select(x => x.LabelName).ToList(); + var missing = labelNames.Except(_metric.LabelNames); - if (unexpected.Any()) - throw new ArgumentException($"Provided custom HTTP request metric instance for {GetType().Name} has some unexpected labels: {string.Join(", ", unexpected)}."); - } + if (missing.Any()) + throw new ArgumentException($"Provided custom HTTP request metric instance for {GetType().Name} is missing required labels: {string.Join(", ", missing)}."); + } + + /// + /// Inspects the metric instance to ensure that all required labels are present. + /// + /// + /// If there are mappings to include custom labels, there must be label names defined for each such parameter. + /// We do this automatically if we use the default metric instance but if a custom one is provided, this must be done by the caller. + /// + private void ValidateCustomLabelsPresentInMetricLabelNames() + { + var labelNames = _customLabels.Select(x => x.LabelName).ToList(); + var missing = labelNames.Except(_metric.LabelNames); + + if (missing.Any()) + throw new ArgumentException($"Provided custom HTTP request metric instance for {GetType().Name} is missing required labels: {string.Join(", ", missing)}."); + } + + /// + /// If we use a custom metric, it should not have labels that are neither defaults nor additional route parameters. + /// + private void ValidateNoUnexpectedLabelNames() + { + var allowedLabels = CreateAllowedLabelSet(); + var unexpected = _metric.LabelNames.Except(allowedLabels); + + if (unexpected.Any()) + throw new ArgumentException($"Provided custom HTTP request metric instance for {GetType().Name} has some unexpected labels: {string.Join(", ", unexpected)}."); } } diff --git a/Prometheus.AspNetCore/HttpMetrics/HttpRouteParameterMapping.cs b/Prometheus.AspNetCore/HttpMetrics/HttpRouteParameterMapping.cs index c1977984..fafab89a 100644 --- a/Prometheus.AspNetCore/HttpMetrics/HttpRouteParameterMapping.cs +++ b/Prometheus.AspNetCore/HttpMetrics/HttpRouteParameterMapping.cs @@ -1,40 +1,39 @@ -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +/// +/// Maps an HTTP route parameter name to a Prometheus label name. +/// +/// +/// Typically, the parameter name and the label name will be equal. +/// The purpose of this is to enable capture of route parameters that conflict with built-in label names like "method" (HTTP method). +/// +public sealed class HttpRouteParameterMapping { /// - /// Maps an HTTP route parameter name to a Prometheus label name. + /// Name of the HTTP route parameter. /// - /// - /// Typically, the parameter name and the label name will be equal. - /// The purpose of this is to enable capture of route parameters that conflict with built-in label names like "method" (HTTP method). - /// - public sealed class HttpRouteParameterMapping - { - /// - /// Name of the HTTP route parameter. - /// - public string ParameterName { get; } - - /// - /// Name of the Prometheus label. - /// - public string LabelName { get; } + public string ParameterName { get; } - public HttpRouteParameterMapping(string name) - { - Collector.ValidateLabelName(name); + /// + /// Name of the Prometheus label. + /// + public string LabelName { get; } - ParameterName = name; - LabelName = name; - } + public HttpRouteParameterMapping(string name) + { + Collector.ValidateLabelName(name); - public HttpRouteParameterMapping(string parameterName, string labelName) - { - Collector.ValidateLabelName(labelName); + ParameterName = name; + LabelName = name; + } - ParameterName = parameterName; - LabelName = labelName; - } + public HttpRouteParameterMapping(string parameterName, string labelName) + { + Collector.ValidateLabelName(labelName); - public static implicit operator HttpRouteParameterMapping(string name) => new HttpRouteParameterMapping(name); + ParameterName = parameterName; + LabelName = labelName; } + + public static implicit operator HttpRouteParameterMapping(string name) => new HttpRouteParameterMapping(name); } diff --git a/Prometheus.AspNetCore/HttpMetrics/ICapturedRouteDataFeature.cs b/Prometheus.AspNetCore/HttpMetrics/ICapturedRouteDataFeature.cs index dc99e518..d712c9d3 100644 --- a/Prometheus.AspNetCore/HttpMetrics/ICapturedRouteDataFeature.cs +++ b/Prometheus.AspNetCore/HttpMetrics/ICapturedRouteDataFeature.cs @@ -1,9 +1,8 @@ using Microsoft.AspNetCore.Routing; -namespace Prometheus.HttpMetrics +namespace Prometheus.HttpMetrics; + +interface ICapturedRouteDataFeature { - interface ICapturedRouteDataFeature - { - RouteValueDictionary Values { get; } - } + RouteValueDictionary Values { get; } } diff --git a/Prometheus.AspNetCore/HttpMetricsMiddlewareExtensions.cs b/Prometheus.AspNetCore/HttpMetricsMiddlewareExtensions.cs index 1016a3b3..8f9987b2 100644 --- a/Prometheus.AspNetCore/HttpMetricsMiddlewareExtensions.cs +++ b/Prometheus.AspNetCore/HttpMetricsMiddlewareExtensions.cs @@ -3,66 +3,65 @@ using Microsoft.Extensions.DependencyInjection; using Prometheus.HttpMetrics; -namespace Prometheus +namespace Prometheus; + +public static class HttpMetricsMiddlewareExtensions { - public static class HttpMetricsMiddlewareExtensions + /// + /// Configures the ASP.NET Core request pipeline to collect Prometheus metrics on processed HTTP requests. + /// + /// Call this after .UseRouting(). + /// + public static IApplicationBuilder UseHttpMetrics(this IApplicationBuilder app, + Action configure) { - /// - /// Configures the ASP.NET Core request pipeline to collect Prometheus metrics on processed HTTP requests. - /// - /// If using ASP.NET Core 3 or newer, call this after .UseRouting(). - /// - public static IApplicationBuilder UseHttpMetrics(this IApplicationBuilder app, - Action configure) - { - var options = new HttpMiddlewareExporterOptions(); + var options = new HttpMiddlewareExporterOptions(); - configure?.Invoke(options); + configure?.Invoke(options); - app.UseHttpMetrics(options); + app.UseHttpMetrics(options); - return app; - } + return app; + } - /// - /// Configures the ASP.NET Core request pipeline to collect Prometheus metrics on processed HTTP requests. - /// - /// If using ASP.NET Core 3 or newer, call this after .UseRouting(). - /// - public static IApplicationBuilder UseHttpMetrics(this IApplicationBuilder app, - HttpMiddlewareExporterOptions? options = null) - { - options = options ?? new HttpMiddlewareExporterOptions(); + /// + /// Configures the ASP.NET Core request pipeline to collect Prometheus metrics on processed HTTP requests. + /// + /// Call this after .UseRouting(). + /// + public static IApplicationBuilder UseHttpMetrics(this IApplicationBuilder app, + HttpMiddlewareExporterOptions? options = null) + { + options = options ?? new HttpMiddlewareExporterOptions(); - if (app.ApplicationServices.GetService() != null) - { - // If Razor Pages is enabled, we will automatically add a "page" route parameter to represent it. We do this only if no custom metric is used. - // If a custom metric is used, we still allow "page" label to be present and automatically fill it with the Razor Pages route parameter - // unless there is a custom label with this name added, in which case the custom label takes priority. + if (app.ApplicationServices.GetService() != null) + { + // If Razor Pages is enabled, we will automatically add a "page" route parameter to represent it. We do this only if no custom metric is used. + // If a custom metric is used, we still allow "page" label to be present and automatically fill it with the Razor Pages route parameter + // unless there is a custom label with this name added, in which case the custom label takes priority. - options.InProgress.IncludePageLabelInDefaultsInternal = true; - options.RequestCount.IncludePageLabelInDefaultsInternal = true; - options.RequestDuration.IncludePageLabelInDefaultsInternal = true; - } + options.InProgress.IncludePageLabelInDefaultsInternal = true; + options.RequestCount.IncludePageLabelInDefaultsInternal = true; + options.RequestDuration.IncludePageLabelInDefaultsInternal = true; + } - void ApplyConfiguration(IApplicationBuilder builder) - { - builder.UseMiddleware(); + void ApplyConfiguration(IApplicationBuilder builder) + { + builder.UseMiddleware(); - if (options.InProgress.Enabled) - builder.UseMiddleware(options.InProgress); - if (options.RequestCount.Enabled) - builder.UseMiddleware(options.RequestCount); - if (options.RequestDuration.Enabled) - builder.UseMiddleware(options.RequestDuration); - } + if (options.InProgress.Enabled) + builder.UseMiddleware(options.InProgress); + if (options.RequestCount.Enabled) + builder.UseMiddleware(options.RequestCount); + if (options.RequestDuration.Enabled) + builder.UseMiddleware(options.RequestDuration); + } - if (options.CaptureMetricsUrl) - ApplyConfiguration(app); - else - app.UseWhen(context => context.Request.Path != "/metrics", ApplyConfiguration); + if (options.CaptureMetricsUrl) + ApplyConfiguration(app); + else + app.UseWhen(context => context.Request.Path != "/metrics", ApplyConfiguration); - return app; - } + return app; } } \ No newline at end of file diff --git a/Prometheus.AspNetCore/KestrelMetricServer.cs b/Prometheus.AspNetCore/KestrelMetricServer.cs index fd3c8d91..3dca354a 100644 --- a/Prometheus.AspNetCore/KestrelMetricServer.cs +++ b/Prometheus.AspNetCore/KestrelMetricServer.cs @@ -6,90 +6,109 @@ using System.Net; using System.Security.Cryptography.X509Certificates; -namespace Prometheus +namespace Prometheus; + +/// +/// A stand-alone Kestrel based metric server that saves you the effort of setting up the ASP.NET Core pipeline. +/// For all practical purposes, this is just a regular ASP.NET Core server that only serves Prometheus requests. +/// +public sealed class KestrelMetricServer : MetricHandler { - /// - /// A stand-alone Kestrel based metric server that saves you the effort of setting up the ASP.NET Core pipeline. - /// For all practical purposes, this is just a regular ASP.NET Core server that only serves Prometheus requests. - /// - public sealed class KestrelMetricServer : MetricHandler + public KestrelMetricServer(int port, string url = "/metrics", CollectorRegistry? registry = null, X509Certificate2? certificate = null) : this("+", port, url, registry, certificate) { - public KestrelMetricServer(int port, string url = "/metrics", CollectorRegistry? registry = null, X509Certificate2? certificate = null) : this("+", port, url, registry, certificate) - { - } + } + + public KestrelMetricServer(string hostname, int port, string url = "/metrics", CollectorRegistry? registry = null, X509Certificate2? certificate = null) : this(LegacyOptions(hostname, port, url, registry, certificate)) + { + } - public KestrelMetricServer(string hostname, int port, string url = "/metrics", CollectorRegistry? registry = null, X509Certificate2? certificate = null) : base(registry) + private static KestrelMetricServerOptions LegacyOptions(string hostname, int port, string url, CollectorRegistry? registry, X509Certificate2? certificate) => + new KestrelMetricServerOptions { - _hostname = hostname; - _port = port; - _url = url; + Hostname = hostname, + Port = (ushort)port, + Url = url, + Registry = registry, + TlsCertificate = certificate, + }; - _certificate = certificate; - } + public KestrelMetricServer(KestrelMetricServerOptions options) + { + _hostname = options.Hostname; + _port = options.Port; + _url = options.Url; + _certificate = options.TlsCertificate; - public KestrelMetricServer(KestrelMetricServerOptions options) : this(options.Hostname, options.Port, options.Url, options.Registry, options.TlsCertificate) + // We use one callback to apply the legacy settings, and from within this we call the real callback. + _configureExporter = settings => { - } + // Legacy setting, may be overridden by ConfigureExporter. + settings.Registry = options.Registry; - private readonly string _hostname; - private readonly int _port; - private readonly string _url; + if (options.ConfigureExporter != null) + options.ConfigureExporter(settings); + }; + } - private readonly X509Certificate2? _certificate; + private readonly string _hostname; + private readonly int _port; + private readonly string _url; - protected override Task StartServer(CancellationToken cancel) - { - var s = _certificate != null ? "s" : ""; - var hostAddress = $"http{s}://{_hostname}:{_port}"; - - // If the caller needs to customize any of this, they can just set up their own web host and inject the middleware. - var builder = new WebHostBuilder() - .UseKestrel() - .UseIISIntegration() - .Configure(app => + private readonly X509Certificate2? _certificate; + + private readonly Action _configureExporter; + + protected override Task StartServer(CancellationToken cancel) + { + var s = _certificate != null ? "s" : ""; + var hostAddress = $"http{s}://{_hostname}:{_port}"; + + // If the caller needs to customize any of this, they can just set up their own web host and inject the middleware. + var builder = new WebHostBuilder() + .UseKestrel() + .UseIISIntegration() + .Configure(app => + { + app.UseMetricServer(_configureExporter, _url); + + // If there is any URL prefix, we just redirect people going to root URL to our prefix. + if (!string.IsNullOrWhiteSpace(_url.Trim('/'))) { - // _registry will already be pre-configured by MetricHandler. - app.UseMetricServer(_url, _registry); - - // If there is any URL prefix, we just redirect people going to root URL to our prefix. - if (!string.IsNullOrWhiteSpace(_url.Trim('/'))) - { - app.MapWhen(context => context.Request.Path.Value?.Trim('/') == "", - configuration => + app.MapWhen(context => context.Request.Path.Value?.Trim('/') == "", + configuration => + { + configuration.Use((HttpContext context, RequestDelegate next) => { - configuration.Use((HttpContext context, RequestDelegate next) => - { - context.Response.Redirect(_url); - return Task.CompletedTask; - }); + context.Response.Redirect(_url); + return Task.CompletedTask; }); - } - }); + }); + } + }); - if (_certificate != null) + if (_certificate != null) + { + builder = builder.ConfigureServices(services => { - builder = builder.ConfigureServices(services => + Action configureEndpoint = options => { - Action configureEndpoint = options => - { - options.UseHttps(_certificate); - }; - - services.Configure(options => - { - options.Listen(IPAddress.Any, _port, configureEndpoint); - }); + options.UseHttps(_certificate); + }; + + services.Configure(options => + { + options.Listen(IPAddress.Any, _port, configureEndpoint); }); - } - else - { - builder = builder.UseUrls(hostAddress); - } + }); + } + else + { + builder = builder.UseUrls(hostAddress); + } - var webHost = builder.Build(); - webHost.Start(); + var webHost = builder.Build(); + webHost.Start(); - return webHost.WaitForShutdownAsync(cancel); - } + return webHost.WaitForShutdownAsync(cancel); } } diff --git a/Prometheus.AspNetCore/KestrelMetricServerExtensions.cs b/Prometheus.AspNetCore/KestrelMetricServerExtensions.cs index 757b78a4..b5215ca2 100644 --- a/Prometheus.AspNetCore/KestrelMetricServerExtensions.cs +++ b/Prometheus.AspNetCore/KestrelMetricServerExtensions.cs @@ -1,45 +1,44 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -namespace Prometheus +namespace Prometheus; + +public static class KestrelMetricServerExtensions { - public static class KestrelMetricServerExtensions + public static IServiceCollection AddMetricServer(this IServiceCollection services, Action optionsCallback) { - public static IServiceCollection AddMetricServer(this IServiceCollection services, Action optionsCallback) + return services.AddHostedService(sp => { - return services.AddHostedService(sp => - { - var options = new KestrelMetricServerOptions(); - optionsCallback(options); - return new MetricsExporterService(options); - }); + var options = new KestrelMetricServerOptions(); + optionsCallback(options); + return new MetricsExporterService(options); + }); + } + + private sealed class MetricsExporterService : BackgroundService + { + public MetricsExporterService(KestrelMetricServerOptions options) + { + _options = options; } - private sealed class MetricsExporterService : BackgroundService + private readonly KestrelMetricServerOptions _options; + + protected override async Task ExecuteAsync(CancellationToken cancel) { - public MetricsExporterService(KestrelMetricServerOptions options) + using var metricServer = new KestrelMetricServer(_options); + metricServer.Start(); + + try { - _options = options; + // Wait forever, until we are told to stop. + await Task.Delay(-1, cancel); } - - private readonly KestrelMetricServerOptions _options; - - protected override async Task ExecuteAsync(CancellationToken cancel) + catch (OperationCanceledException) when (cancel.IsCancellationRequested) { - using var metricServer = new KestrelMetricServer(_options); - metricServer.Start(); - - try - { - // Wait forever, until we are told to stop. - await Task.Delay(-1, cancel); - } - catch (OperationCanceledException) when (cancel.IsCancellationRequested) - { - // Time to stop. - } + // Time to stop. } } - } + } diff --git a/Prometheus.AspNetCore/KestrelMetricServerOptions.cs b/Prometheus.AspNetCore/KestrelMetricServerOptions.cs index 3118631e..817eebf5 100644 --- a/Prometheus.AspNetCore/KestrelMetricServerOptions.cs +++ b/Prometheus.AspNetCore/KestrelMetricServerOptions.cs @@ -1,18 +1,27 @@ -using System.Security.Cryptography.X509Certificates; +using System.ComponentModel; +using System.Security.Cryptography.X509Certificates; -namespace Prometheus +namespace Prometheus; + +public sealed class KestrelMetricServerOptions { - public sealed class KestrelMetricServerOptions - { - /// - /// Will listen for requests using this hostname. "+" indicates listen on all hostnames. - /// By setting this to "localhost", you can easily prevent access from remote systems.- - /// - public string Hostname { get; set; } = "+"; + /// + /// Will listen for requests using this hostname. "+" indicates listen on all hostnames. + /// By setting this to "localhost", you can easily prevent access from remote systems.- + /// + public string Hostname { get; set; } = "+"; + + public ushort Port { get; set; } + public string Url { get; set; } = "/metrics"; + public X509Certificate2? TlsCertificate { get; set; } + + // May be overridden by ConfigureExporter. + [EditorBrowsable(EditorBrowsableState.Never)] // It is not exactly obsolete but let's de-emphasize it and prefer ConfigureExporter. + public CollectorRegistry? Registry { get; set; } - public ushort Port { get; set; } - public string Url { get; set; } = "/metrics"; - public CollectorRegistry? Registry { get; set; } - public X509Certificate2? TlsCertificate { get; set; } - } + /// + /// Allows metric exporter options to be configured in a flexible manner. + /// The callback is called after applying any values in KestrelMetricServerOptions. + /// + public Action? ConfigureExporter { get; set; } } diff --git a/Prometheus.AspNetCore/MetricServerMiddleware.cs b/Prometheus.AspNetCore/MetricServerMiddleware.cs index ac9e12fa..e7ef2af5 100644 --- a/Prometheus.AspNetCore/MetricServerMiddleware.cs +++ b/Prometheus.AspNetCore/MetricServerMiddleware.cs @@ -1,59 +1,115 @@ using Microsoft.AspNetCore.Http; +using System.Net.Http.Headers; -namespace Prometheus +namespace Prometheus; + +/// +/// Prometheus metrics export middleware for ASP.NET Core. +/// +/// You should use IApplicationBuilder.UseMetricServer extension method instead of using this class directly. +/// +public sealed class MetricServerMiddleware { - /// - /// Prometheus metrics export middleware for ASP.NET Core. - /// - /// You should use IApplicationBuilder.UseMetricServer extension method instead of using this class directly. - /// - public sealed class MetricServerMiddleware + public MetricServerMiddleware(RequestDelegate next, Settings settings) + { + _registry = settings.Registry ?? Metrics.DefaultRegistry; + _enableOpenMetrics = settings.EnableOpenMetrics; + } + + public sealed class Settings + { + /// + /// Where do we take the metrics from. By default, we will take them from the global singleton registry. + /// + public CollectorRegistry? Registry { get; set; } + + /// + /// Whether we support the OpenMetrics exposition format. Required to publish exemplars. Defaults to enabled. + /// Use of OpenMetrics also requires that the client negotiate the OpenMetrics format via the HTTP "Accept" request header. + /// + public bool EnableOpenMetrics { get; set; } = true; + } + + private readonly CollectorRegistry _registry; + private readonly bool _enableOpenMetrics; + + private readonly record struct ProtocolNegotiationResult(ExpositionFormat ExpositionFormat, string ContentType); + + private static IEnumerable ExtractAcceptableMediaTypes(string acceptHeaderValue) { - public MetricServerMiddleware(RequestDelegate next, Settings settings) + var candidates = acceptHeaderValue.Split(','); + + foreach (var candidate in candidates) { - _registry = settings.Registry ?? Metrics.DefaultRegistry; + // It is conceivably possible that some value is invalid - we filter them out here and only return valid values. + // A common case is a missing/empty "Accept" header, in which case we just get 1 candidate of empty string (which is invalid). + if (MediaTypeWithQualityHeaderValue.TryParse(candidate, out var mediaType)) + yield return mediaType; } + } - public sealed class Settings + private ProtocolNegotiationResult NegotiateComminucationProtocol(HttpRequest request) + { + var acceptHeaderValues = request.Headers.Accept.ToString(); + + // We allow the "Accept" HTTP header to be overridden by the "accept" query string parameter. + // This is mainly for development purposes (to make it easier to request OpenMetrics format via browser URL bar). + if (request.Query.TryGetValue("accept", out var acceptValuesFromQuery)) + acceptHeaderValues = string.Join(",", acceptValuesFromQuery); + + foreach (var candidate in ExtractAcceptableMediaTypes(acceptHeaderValues) + .OrderByDescending(mt => mt.Quality.GetValueOrDefault(1))) { - public CollectorRegistry? Registry { get; set; } + if (candidate.MediaType == PrometheusConstants.TextContentType) + { + // The first preference is the text format. Fall throgh to the default case. + break; + } + else if (_enableOpenMetrics && candidate.MediaType == PrometheusConstants.OpenMetricsContentType) + { + return new ProtocolNegotiationResult(ExpositionFormat.OpenMetricsText, PrometheusConstants.OpenMetricsContentTypeWithVersionAndEncoding); + } } - private readonly CollectorRegistry _registry; + return new ProtocolNegotiationResult(ExpositionFormat.PrometheusText, PrometheusConstants.TextContentTypeWithVersionAndEncoding); + } + + public async Task Invoke(HttpContext context) + { + var response = context.Response; - public async Task Invoke(HttpContext context) + try { - var response = context.Response; + var negotiationResult = NegotiateComminucationProtocol(context.Request); - try - { - // We first touch the response.Body only in the callback because touching - // it means we can no longer send headers (the status code). - var serializer = new TextSerializer(delegate - { - response.ContentType = PrometheusConstants.ExporterContentType; - response.StatusCode = StatusCodes.Status200OK; - return response.Body; - }); - - await _registry.CollectAndSerializeAsync(serializer, context.RequestAborted); - } - catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + Stream GetResponseBodyStream() { - // The scrape was cancalled by the client. This is fine. Just swallow the exception to not generate pointless spam. + // We first touch the response.Body only in the callback here because touching it means we can no longer send headers (the status code). + // The collection logic will delay calling this method until it is reasonably confident that nothing will go wrong will the collection. + response.ContentType = negotiationResult.ContentType; + response.StatusCode = StatusCodes.Status200OK; + + return response.Body; } - catch (ScrapeFailedException ex) + + var serializer = new TextSerializer(GetResponseBodyStream, negotiationResult.ExpositionFormat); + + await _registry.CollectAndSerializeAsync(serializer, context.RequestAborted); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + // The scrape was cancalled by the client. This is fine. Just swallow the exception to not generate pointless spam. + } + catch (ScrapeFailedException ex) + { + // This can only happen before any serialization occurs, in the pre-collect callbacks. + // So it should still be safe to update the status code and write an error message. + response.StatusCode = StatusCodes.Status503ServiceUnavailable; + + if (!string.IsNullOrWhiteSpace(ex.Message)) { - // This can only happen before any serialization occurs, in the pre-collect callbacks. - // So it should still be safe to update the status code and write an error message. - response.StatusCode = StatusCodes.Status503ServiceUnavailable; - - if (!string.IsNullOrWhiteSpace(ex.Message)) - { - using (var writer = new StreamWriter(response.Body, PrometheusConstants.ExportEncoding, - bufferSize: -1, leaveOpen: true)) - await writer.WriteAsync(ex.Message); - } + using var writer = new StreamWriter(response.Body, PrometheusConstants.ExportEncoding, bufferSize: -1, leaveOpen: true); + await writer.WriteAsync(ex.Message); } } } diff --git a/Prometheus.AspNetCore/MetricServerMiddlewareExtensions.cs b/Prometheus.AspNetCore/MetricServerMiddlewareExtensions.cs index 0bdec01e..742befb7 100644 --- a/Prometheus.AspNetCore/MetricServerMiddlewareExtensions.cs +++ b/Prometheus.AspNetCore/MetricServerMiddlewareExtensions.cs @@ -1,78 +1,131 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using System.ComponentModel; -namespace Prometheus +namespace Prometheus; + +public static class MetricServerMiddlewareExtensions { - public static class MetricServerMiddlewareExtensions + private const string DefaultDisplayName = "Prometheus metrics"; + + /// + /// Starts a Prometheus metrics exporter using endpoint routing. + /// The default URL is /metrics, which is a Prometheus convention. + /// Use static methods on the class to create your metrics. + /// + public static IEndpointConventionBuilder MapMetrics( + this IEndpointRouteBuilder endpoints, + Action configure, + string pattern = "/metrics" + ) { - private const string DefaultDisplayName = "Prometheus metrics"; + var pipeline = endpoints + .CreateApplicationBuilder() + .InternalUseMiddleware(configure) + .Build(); - /// - /// Starts a Prometheus metrics exporter using endpoint routing. - /// The default URL is /metrics, which is a Prometheus convention. - /// Use static methods on the class to create your metrics. - /// - public static IEndpointConventionBuilder MapMetrics( - this IEndpointRouteBuilder endpoints, - string pattern = "/metrics", - CollectorRegistry? registry = null - ) - { - var pipeline = endpoints - .CreateApplicationBuilder() - .UseMiddleware( - new MetricServerMiddleware.Settings - { - Registry = registry - } - ) - .Build(); + return endpoints + .Map(pattern, pipeline) + .WithDisplayName(DefaultDisplayName); + } - return endpoints - .Map(pattern, pipeline) - .WithDisplayName(DefaultDisplayName); - } + /// + /// Starts a Prometheus metrics exporter, filtering to only handle requests received on a specific port. + /// The default URL is /metrics, which is a Prometheus convention. + /// Use static methods on the class to create your metrics. + /// + public static IApplicationBuilder UseMetricServer( + this IApplicationBuilder builder, + int port, + Action configure, + string? url = "/metrics") + { + // If no URL, use root URL. + url ??= "/"; + + return builder + .Map(url, b => b.MapWhen(PortMatches(), b1 => b1.InternalUseMiddleware(configure))); - /// - /// Starts a Prometheus metrics exporter, filtering to only handle requests received on a specific port. - /// The default URL is /metrics, which is a Prometheus convention. - /// Use static methods on the class to create your metrics. - /// - public static IApplicationBuilder UseMetricServer(this IApplicationBuilder builder, int port, string? url = "/metrics", CollectorRegistry? registry = null) + Func PortMatches() { - // If no URL, use root URL. - url ??= "/"; + return c => c.Connection.LocalPort == port; + } + } - return builder - .Map(url, b => b.MapWhen(PortMatches(), b1 => b1.InternalUseMiddleware(registry))); + /// + /// Starts a Prometheus metrics exporter. + /// The default URL is /metrics, which is a Prometheus convention. + /// Use static methods on the class to create your metrics. + /// + public static IApplicationBuilder UseMetricServer( + this IApplicationBuilder builder, + Action configure, + string? url = "/metrics") + { + if (url != null) + return builder.Map(url, b => b.InternalUseMiddleware(configure)); + else + return builder.InternalUseMiddleware(configure); + } - Func PortMatches() - { - return c => c.Connection.LocalPort == port; - } - } + #region Legacy methods without the configure action + /// + /// Starts a Prometheus metrics exporter using endpoint routing. + /// The default URL is /metrics, which is a Prometheus convention. + /// Use static methods on the class to create your metrics. + /// + [EditorBrowsable(EditorBrowsableState.Never)] // It is not exactly obsolete but let's de-emphasize it. + public static IEndpointConventionBuilder MapMetrics( + this IEndpointRouteBuilder endpoints, + string pattern = "/metrics", + CollectorRegistry? registry = null + ) + { + return MapMetrics(endpoints, LegacyConfigure(registry), pattern); + } - /// - /// Starts a Prometheus metrics exporter. - /// The default URL is /metrics, which is a Prometheus convention. - /// Use static methods on the class to create your metrics. - /// - public static IApplicationBuilder UseMetricServer(this IApplicationBuilder builder, string? url = "/metrics", CollectorRegistry? registry = null) - { - // If there is a URL to map, map it and re-enter without the URL. - if (url != null) - return builder.Map(url, b => b.InternalUseMiddleware(registry)); - else - return builder.InternalUseMiddleware(registry); - } + /// + /// Starts a Prometheus metrics exporter, filtering to only handle requests received on a specific port. + /// The default URL is /metrics, which is a Prometheus convention. + /// Use static methods on the class to create your metrics. + /// + [EditorBrowsable(EditorBrowsableState.Never)] // It is not exactly obsolete but let's de-emphasize it. + public static IApplicationBuilder UseMetricServer( + this IApplicationBuilder builder, + int port, + string? url = "/metrics", + CollectorRegistry? registry = null) + { + return UseMetricServer(builder, port, LegacyConfigure(registry), url); + } + + /// + /// Starts a Prometheus metrics exporter. + /// The default URL is /metrics, which is a Prometheus convention. + /// Use static methods on the class to create your metrics. + /// + [EditorBrowsable(EditorBrowsableState.Never)] // It is not exactly obsolete but let's de-emphasize it. + public static IApplicationBuilder UseMetricServer( + this IApplicationBuilder builder, + string? url = "/metrics", + CollectorRegistry? registry = null) + { + return UseMetricServer(builder, LegacyConfigure(registry), url); + } - private static IApplicationBuilder InternalUseMiddleware(this IApplicationBuilder builder, CollectorRegistry? registry = null) + private static Action LegacyConfigure(CollectorRegistry? registry) => + (MetricServerMiddleware.Settings settings) => { - return builder.UseMiddleware(new MetricServerMiddleware.Settings - { - Registry = registry - }); - } + settings.Registry = registry; + }; + #endregion + + private static IApplicationBuilder InternalUseMiddleware(this IApplicationBuilder builder, Action configure) + { + var settings = new MetricServerMiddleware.Settings(); + configure(settings); + + return builder.UseMiddleware(settings); } } diff --git a/Prometheus.AspNetCore/Prometheus.AspNetCore.csproj b/Prometheus.AspNetCore/Prometheus.AspNetCore.csproj index c0ca6a85..8a84ff63 100644 --- a/Prometheus.AspNetCore/Prometheus.AspNetCore.csproj +++ b/Prometheus.AspNetCore/Prometheus.AspNetCore.csproj @@ -19,8 +19,43 @@ 9999 True + + + true + true + + + prometheus-net.AspNetCore + andrasm,qed-,lakario,sandersaares + prometheus-net + prometheus-net + ASP.NET Core middleware and stand-alone Kestrel server for exporting metrics to Prometheus + Copyright © prometheus-net developers + https://github.com/prometheus-net/prometheus-net + prometheus-net-logo.png + README.md + metrics prometheus aspnetcore + MIT + True + snupkg + + + true + + + + + True + \ + + + True + \ + + + @@ -33,4 +68,8 @@ + + + + diff --git a/Prometheus.NetFramework.AspNet/AspNetMetricServer.cs b/Prometheus.NetFramework.AspNet/AspNetMetricServer.cs index 5cdc7804..a9d28c9d 100644 --- a/Prometheus.NetFramework.AspNet/AspNetMetricServer.cs +++ b/Prometheus.NetFramework.AspNet/AspNetMetricServer.cs @@ -1,78 +1,73 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; +using System.Net; using System.Web.Http; -namespace Prometheus +namespace Prometheus; + +public static class AspNetMetricServer { - public static class AspNetMetricServer + private const string RouteNamePrefix = "Prometheus_"; + + public sealed class Settings { - private const string RouteNamePrefix = "Prometheus_"; + public CollectorRegistry? Registry { get; set; } + } - public sealed class Settings - { - public CollectorRegistry Registry { get; set; } - } + /// + /// Registers an anonymous instance of the controller to be published on the /metrics URL. + /// + public static void RegisterRoutes(HttpConfiguration configuration, Settings? settings = null) => + MapRoute(configuration, "Default", "metrics", settings); - /// - /// Registers an anonymous instance of the controller to be published on the /metrics URL. - /// - public static void RegisterRoutes(HttpConfiguration configuration, Settings settings = null) => - MapRoute(configuration, "Default", "metrics", settings); + /// + /// Registers an anonymous instance of the controller to be published on a given URL path (e.g. "custom/metrics"). + /// + public static void RegisterRoutes(HttpConfiguration configuration, string path, Settings? settings = null) => + MapRoute(configuration, path, path, settings); - /// - /// Registers an anonymous instance of the controller to be published on a given URL path (e.g. "custom/metrics"). - /// - public static void RegisterRoutes(HttpConfiguration configuration, string path, Settings settings = null) => - MapRoute(configuration, path, path, settings); + private static void MapRoute(HttpConfiguration configuration, string routeName, string routeTemplate, Settings? settings) + { + if (configuration == null) + throw new ArgumentNullException(nameof(configuration)); - private static void MapRoute(HttpConfiguration configuration, string routeName, string routeTemplate, Settings settings) - { - if (configuration == null) - throw new ArgumentNullException(nameof(configuration)); + configuration.Routes.MapHttpRoute( + name: $"{RouteNamePrefix}{routeName}", + routeTemplate: routeTemplate, + defaults: null, + constraints: null, + handler: new Handler(settings?.Registry ?? Metrics.DefaultRegistry)); + } - configuration.Routes.MapHttpRoute( - name: $"{RouteNamePrefix}{routeName}", - routeTemplate: routeTemplate, - defaults: null, - constraints: null, - handler: new Handler(settings?.Registry ?? Metrics.DefaultRegistry)); + private sealed class Handler : HttpMessageHandler + { + public Handler(CollectorRegistry registry) + { + _registry = registry; } - private sealed class Handler : HttpMessageHandler - { - public Handler(CollectorRegistry registry) - { - _registry = registry; - } + private readonly CollectorRegistry _registry; - private readonly CollectorRegistry _registry; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // The ASP.NET PushStreamContent does not have a way to easily handle exceptions that + // occur before we write to the stream (when we can still return nice error headers). + // Maybe in a future version this could be improved, as right now exception == aborted connection. - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + var response = new HttpResponseMessage(HttpStatusCode.OK) { - // The ASP.NET PushStreamContent does not have a way to easily handle exceptions that - // occur before we write to the stream (when we can still return nice error headers). - // Maybe in a future version this could be improved, as right now exception == aborted connection. - - var response = new HttpResponseMessage(HttpStatusCode.OK) + Content = new PushStreamContent(async (stream, content, context) => { - Content = new PushStreamContent(async (stream, content, context) => + try + { + await _registry.CollectAndExportAsTextAsync(stream, ExpositionFormat.PrometheusText, cancellationToken); + } + finally { - try - { - await _registry.CollectAndExportAsTextAsync(stream, cancellationToken); - } - finally - { - stream.Close(); - } - }, PrometheusConstants.ExporterContentTypeValue) - }; + stream.Close(); + } + }, PrometheusConstants.ExporterContentTypeValue) + }; - return Task.FromResult(response); - } + return Task.FromResult(response); } } } diff --git a/Prometheus.NetFramework.AspNet/Prometheus.NetFramework.AspNet.csproj b/Prometheus.NetFramework.AspNet/Prometheus.NetFramework.AspNet.csproj index e486de41..cdd99c2e 100644 --- a/Prometheus.NetFramework.AspNet/Prometheus.NetFramework.AspNet.csproj +++ b/Prometheus.NetFramework.AspNet/Prometheus.NetFramework.AspNet.csproj @@ -1,72 +1,73 @@ - - - - - Debug - AnyCPU - {25C05E15-A8F8-4F36-A35C-4004C0948305} - Library - Properties - Prometheus - Prometheus.NetFramework.AspNet - v4.6.2 - 512 - true - true - ..\Resources\prometheus-net.snk - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - true - true - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - true - true - - - - - - ..\packages\Microsoft.AspNet.WebApi.Client.5.2.9\lib\net45\System.Net.Http.Formatting.dll - - - ..\packages\Microsoft.AspNet.WebApi.Core.5.2.9\lib\net45\System.Web.Http.dll - - - - - - - - - - - Properties\SolutionAssemblyInfo.cs - - - - - - - - - {e585417c-f7dd-4d8c-a0c5-d4b79594634a} - Prometheus - - - - \ No newline at end of file + + + + netframework462 + Prometheus + + false + + true + ..\Resources\prometheus-net.snk + + enable + enable + True + True + 1591 + + latest + 9999 + + True + + + true + true + + + prometheus-net.NetFramework.AspNet + sandersaares + prometheus-net + prometheus-net + ASP.NET Web API exporter for Prometheus + Copyright © prometheus-net developers + https://github.com/prometheus-net/prometheus-net + prometheus-net-logo.png + README.md + metrics prometheus aspnetcore + MIT + True + snupkg + + + + + true + + + + + True + \ + + + True + \ + + + + + + + + + + + + + + + + + + diff --git a/Prometheus.NetFramework.AspNet/Usings.cs b/Prometheus.NetFramework.AspNet/Usings.cs new file mode 100644 index 00000000..e6a9eb40 --- /dev/null +++ b/Prometheus.NetFramework.AspNet/Usings.cs @@ -0,0 +1 @@ +global using System.Net.Http; \ No newline at end of file diff --git a/Prometheus.NetFramework.AspNet/packages.config b/Prometheus.NetFramework.AspNet/packages.config deleted file mode 100644 index c77a9bd1..00000000 --- a/Prometheus.NetFramework.AspNet/packages.config +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/Prometheus/AutoLeasingCounter.cs b/Prometheus/AutoLeasingCounter.cs deleted file mode 100644 index 4ab852dd..00000000 --- a/Prometheus/AutoLeasingCounter.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace Prometheus -{ - /// - /// A counter that automatically extends the lifetime of a lease-extended metric whenever it is used. - /// It only supports write operations because we cannot guarantee that the metric is still alive when reading. - /// - internal sealed class AutoLeasingCounter : ICollector - { - public AutoLeasingCounter(IManagedLifetimeMetricHandle inner, ICollector root) - { - _inner = inner; - _root = root; - } - - private readonly IManagedLifetimeMetricHandle _inner; - private readonly ICollector _root; - - public string Name => _root.Name; - public string Help => _root.Help; - public string[] LabelNames => _root.LabelNames; - - public ICounter Unlabelled => new Instance(_inner, Array.Empty()); - - public ICounter WithLabels(params string[] labelValues) - { - return new Instance(_inner, labelValues); - } - - private sealed class Instance : ICounter - { - public Instance(IManagedLifetimeMetricHandle inner, string[] labelValues) - { - _inner = inner; - _labelValues = labelValues; - } - - private readonly IManagedLifetimeMetricHandle _inner; - private readonly string[] _labelValues; - - public double Value => throw new NotSupportedException("Read operations on a lifetime-extending-on-use expiring metric are not supported."); - - public void Inc(double increment = 1) - { - _inner.WithLease(x => x.Inc(increment), _labelValues); - } - - public void IncTo(double targetValue) - { - _inner.WithLease(x => x.IncTo(targetValue), _labelValues); - } - } - } -} diff --git a/Prometheus/AutoLeasingGauge.cs b/Prometheus/AutoLeasingGauge.cs deleted file mode 100644 index b2101da8..00000000 --- a/Prometheus/AutoLeasingGauge.cs +++ /dev/null @@ -1,68 +0,0 @@ -namespace Prometheus -{ - /// - /// A gauge that automatically extends the lifetime of a lease-extended metric whenever it is used. - /// It only supports write operations because we cannot guarantee that the metric is still alive when reading. - /// - internal sealed class AutoLeasingGauge : ICollector - { - public AutoLeasingGauge(IManagedLifetimeMetricHandle inner, ICollector root) - { - _inner = inner; - _root = root; - } - - private readonly IManagedLifetimeMetricHandle _inner; - private readonly ICollector _root; - - public string Name => _root.Name; - public string Help => _root.Help; - public string[] LabelNames => _root.LabelNames; - - public IGauge Unlabelled => new Instance(_inner, Array.Empty()); - - public IGauge WithLabels(params string[] labelValues) - { - return new Instance(_inner, labelValues); - } - - private sealed class Instance : IGauge - { - public Instance(IManagedLifetimeMetricHandle inner, string[] labelValues) - { - _inner = inner; - _labelValues = labelValues; - } - - private readonly IManagedLifetimeMetricHandle _inner; - private readonly string[] _labelValues; - - public double Value => throw new NotSupportedException("Read operations on a lifetime-extending-on-use expiring metric are not supported."); - - public void Inc(double increment = 1) - { - _inner.WithLease(x => x.Inc(increment), _labelValues); - } - - public void Set(double val) - { - _inner.WithLease(x => x.Set(val), _labelValues); - } - - public void Dec(double decrement = 1) - { - _inner.WithLease(x => x.Dec(decrement), _labelValues); - } - - public void IncTo(double targetValue) - { - _inner.WithLease(x => x.IncTo(targetValue), _labelValues); - } - - public void DecTo(double targetValue) - { - _inner.WithLease(x => x.DecTo(targetValue), _labelValues); - } - } - } -} diff --git a/Prometheus/AutoLeasingHistogram.cs b/Prometheus/AutoLeasingHistogram.cs deleted file mode 100644 index 22cfe682..00000000 --- a/Prometheus/AutoLeasingHistogram.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace Prometheus -{ - /// - /// A histogram that automatically extends the lifetime of a lease-extended metric whenever it is used. - /// It only supports write operations because we cannot guarantee that the metric is still alive when reading. - /// - internal sealed class AutoLeasingHistogram : ICollector - { - public AutoLeasingHistogram(IManagedLifetimeMetricHandle inner, ICollector root) - { - _inner = inner; - _root = root; - } - - private readonly IManagedLifetimeMetricHandle _inner; - private readonly ICollector _root; - - public string Name => _root.Name; - public string Help => _root.Help; - public string[] LabelNames => _root.LabelNames; - - public IHistogram Unlabelled => new Instance(_inner, Array.Empty()); - - public IHistogram WithLabels(params string[] labelValues) - { - return new Instance(_inner, labelValues); - } - - private sealed class Instance : IHistogram - { - public Instance(IManagedLifetimeMetricHandle inner, string[] labelValues) - { - _inner = inner; - _labelValues = labelValues; - } - - private readonly IManagedLifetimeMetricHandle _inner; - private readonly string[] _labelValues; - - public double Sum => throw new NotSupportedException("Read operations on a lifetime-extending-on-use expiring metric are not supported."); - public long Count => throw new NotSupportedException("Read operations on a lifetime-extending-on-use expiring metric are not supported."); - - public void Observe(double val, long count) - { - _inner.WithLease(x => x.Observe(val, count), _labelValues); - } - - public void Observe(double val) - { - _inner.WithLease(x => x.Observe(val), _labelValues); - } - } - } -} diff --git a/Prometheus/AutoLeasingSummary.cs b/Prometheus/AutoLeasingSummary.cs deleted file mode 100644 index 2ac5e056..00000000 --- a/Prometheus/AutoLeasingSummary.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace Prometheus -{ - /// - /// A Summary that automatically extends the lifetime of a lease-extended metric whenever it is used. - /// It only supports write operations because we cannot guarantee that the metric is still alive when reading. - /// - internal sealed class AutoLeasingSummary : ICollector - { - public AutoLeasingSummary(IManagedLifetimeMetricHandle inner, ICollector root) - { - _inner = inner; - _root = root; - } - - private readonly IManagedLifetimeMetricHandle _inner; - private readonly ICollector _root; - - public string Name => _root.Name; - public string Help => _root.Help; - public string[] LabelNames => _root.LabelNames; - - public ISummary Unlabelled => new Instance(_inner, Array.Empty()); - - public ISummary WithLabels(params string[] labelValues) - { - return new Instance(_inner, labelValues); - } - - private sealed class Instance : ISummary - { - public Instance(IManagedLifetimeMetricHandle inner, string[] labelValues) - { - _inner = inner; - _labelValues = labelValues; - } - - private readonly IManagedLifetimeMetricHandle _inner; - private readonly string[] _labelValues; - - public void Observe(double val) - { - _inner.WithLease(x => x.Observe(val), _labelValues); - } - } - } -} diff --git a/Prometheus/CanonicalLabel.cs b/Prometheus/CanonicalLabel.cs new file mode 100644 index 00000000..96fc4ceb --- /dev/null +++ b/Prometheus/CanonicalLabel.cs @@ -0,0 +1,13 @@ +namespace Prometheus; + +internal readonly struct CanonicalLabel(byte[] name, byte[] prometheus, byte[] openMetrics) +{ + public static readonly CanonicalLabel Empty = new([], [], []); + + public byte[] Name { get; } = name; + + public byte[] Prometheus { get; } = prometheus; + public byte[] OpenMetrics { get; } = openMetrics; + + public bool IsNotEmpty => Name.Length > 0; +} \ No newline at end of file diff --git a/Prometheus/ChildBase.cs b/Prometheus/ChildBase.cs index 33e96a6a..7e79b98b 100644 --- a/Prometheus/ChildBase.cs +++ b/Prometheus/ChildBase.cs @@ -1,111 +1,195 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// Base class for labeled instances of metrics (with all label names and label values defined). +/// +public abstract class ChildBase : ICollectorChild, IDisposable { + internal ChildBase(Collector parent, LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish, ExemplarBehavior exemplarBehavior) + { + Parent = parent; + InstanceLabels = instanceLabels; + FlattenedLabels = flattenedLabels; + _publish = publish; + _exemplarBehavior = exemplarBehavior; + } + + private readonly ExemplarBehavior _exemplarBehavior; + /// - /// Base class for labeled instances of metrics (with all label names and label values defined). + /// Marks the metric as one to be published, even if it might otherwise be suppressed. + /// + /// This is useful for publishing zero-valued metrics once you have loaded data on startup and determined + /// that there is no need to increment the value of the metric. /// - public abstract class ChildBase : ICollectorChild, IDisposable + /// + /// Subclasses must call this when their value is first set, to mark the metric as published. + /// + public void Publish() { - internal ChildBase(Collector parent, LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish) - { - _parent = parent; - InstanceLabels = instanceLabels; - FlattenedLabels = flattenedLabels; - _publish = publish; - } + Volatile.Write(ref _publish, true); + } - /// - /// Marks the metric as one to be published, even if it might otherwise be suppressed. - /// - /// This is useful for publishing zero-valued metrics once you have loaded data on startup and determined - /// that there is no need to increment the value of the metric. - /// - /// - /// Subclasses must call this when their value is first set, to mark the metric as published. - /// - public void Publish() - { - Volatile.Write(ref _publish, true); - } + /// + /// Marks the metric as one to not be published. + /// + /// The metric will be published when Publish() is called or the value is updated. + /// + public void Unpublish() + { + Volatile.Write(ref _publish, false); + } + + /// + /// Removes this labeled instance from metrics. + /// It will no longer be published and any existing measurements/buckets will be discarded. + /// + public void Remove() + { + Parent.RemoveLabelled(InstanceLabels); + } + + public void Dispose() => Remove(); + + /// + /// Labels specific to this metric instance, without any inherited static labels. + /// Internal for testing purposes only. + /// + internal LabelSequence InstanceLabels { get; } + + /// + /// All labels that materialize on this metric instance, including inherited static labels. + /// Internal for testing purposes only. + /// + internal LabelSequence FlattenedLabels { get; } + + internal byte[] FlattenedLabelsBytes => NonCapturingLazyInitializer.EnsureInitialized(ref _flattenedLabelsBytes, this, _assignFlattenedLabelsBytesFunc)!; + private byte[]? _flattenedLabelsBytes; + private static readonly Action _assignFlattenedLabelsBytesFunc = AssignFlattenedLabelsBytes; + private static void AssignFlattenedLabelsBytes(ChildBase instance) => instance._flattenedLabelsBytes = instance.FlattenedLabels.Serialize(); + + internal readonly Collector Parent; + + private bool _publish; + + /// + /// Collects all the metric data rows from this collector and serializes it using the given serializer. + /// + /// + /// Subclass must check _publish and suppress output if it is false. + /// + internal ValueTask CollectAndSerializeAsync(IMetricsSerializer serializer, CancellationToken cancel) + { + if (!Volatile.Read(ref _publish)) + return default; - /// - /// Marks the metric as one to not be published. - /// - /// The metric will be published when Publish() is called or the value is updated. - /// - public void Unpublish() + return CollectAndSerializeImplAsync(serializer, cancel); + } + + // Same as above, just only called if we really need to serialize this metric (if publish is true). + private protected abstract ValueTask CollectAndSerializeImplAsync(IMetricsSerializer serializer, CancellationToken cancel); + + /// + /// Borrows an exemplar temporarily, to be later returned via ReturnBorrowedExemplar. + /// Borrowing ensures that no other thread is modifying it (as exemplars are not thread-safe). + /// You would typically want to do this while serializing the exemplar. + /// + internal static ObservedExemplar BorrowExemplar(ref ObservedExemplar storage) + { + return Interlocked.Exchange(ref storage, ObservedExemplar.Empty); + } + + /// + /// Returns a borrowed exemplar to storage or the object pool, with correct handling for cases where it is Empty. + /// + internal static void ReturnBorrowedExemplar(ref ObservedExemplar storage, ObservedExemplar borrowed) + { + if (borrowed == ObservedExemplar.Empty) + return; + + // Return the exemplar unless a new one has arrived, in which case we discard the old one we were holding. + var foundExemplar = Interlocked.CompareExchange(ref storage, borrowed, ObservedExemplar.Empty); + + if (foundExemplar != ObservedExemplar.Empty) { - Volatile.Write(ref _publish, false); + // A new exemplar had already been written, so we could not return the borrowed one. That's perfectly fine - discard it. + ObservedExemplar.ReturnPooledIfNotEmpty(borrowed); } + } + + internal void RecordExemplar(Exemplar exemplar, ref ObservedExemplar storage, double observedValue) + { + exemplar.MarkAsConsumed(); - /// - /// Removes this labeled instance from metrics. - /// It will no longer be published and any existing measurements/buckets will be discarded. - /// - public void Remove() + // We do the "is allowed" check only if we really have an exemplar to record, to minimize the performance impact on users who do not use exemplars. + // If you are using exemplars, you are already paying for a lot of value serialization overhead, so this is insignificant. + // Whereas if you are not using exemplars, the difference from this simple check can be substantial. + if (!IsRecordingNewExemplarAllowed()) { - _parent.RemoveLabelled(InstanceLabels); + // We will not record the exemplar but must still release the resources to the pool. + exemplar.ReturnToPoolIfNotEmpty(); + return; } - public void Dispose() => Remove(); + // ObservedExemplar takes ownership of the Exemplar and will return its resources to the pool when the time is right. + var observedExemplar = ObservedExemplar.CreatePooled(exemplar, observedValue); + ObservedExemplar.ReturnPooledIfNotEmpty(Interlocked.Exchange(ref storage, observedExemplar)); + MarkNewExemplarHasBeenRecorded(); - /// - /// Labels specific to this metric instance, without any inherited static labels. - /// Internal for testing purposes only. - /// - internal LabelSequence InstanceLabels { get; } + // We cannot record an exemplar every time we record an exemplar! + Volatile.Read(ref ExemplarsRecorded)?.Inc(Exemplar.None); + } - /// - /// All labels that materialize on this metric instance, including inherited static labels. - /// Internal for testing purposes only. - /// - internal LabelSequence FlattenedLabels { get; } + protected Exemplar GetDefaultExemplar(double value) + { + if (_exemplarBehavior.DefaultExemplarProvider == null) + return Exemplar.None; - private readonly Collector _parent; + return _exemplarBehavior.DefaultExemplarProvider(Parent, value); + } - private bool _publish; + // May be replaced in test code. + internal static Func ExemplarRecordingTimestampProvider = DefaultExemplarRecordingTimestampProvider; + internal static double DefaultExemplarRecordingTimestampProvider() => LowGranularityTimeSource.GetSecondsFromUnixEpoch(); - /// - /// Collects all the metric data rows from this collector and serializes it using the given serializer. - /// - /// - /// Subclass must check _publish and suppress output if it is false. - /// - internal Task CollectAndSerializeAsync(IMetricsSerializer serializer, CancellationToken cancel) - { - if (!Volatile.Read(ref _publish)) - return Task.CompletedTask; + // Timetamp of when we last recorded an exemplar. We do not use ObservedExemplar.Timestamp because we do not want to + // read from an existing ObservedExemplar when we are writing to our metrics (to avoid the synchronization overhead). + // We start at a deep enough negative value to not cause funny behavior near zero point (only likely in tests, really). + private ThreadSafeDouble _exemplarLastRecordedTimestamp = new(-100_000_000); - return CollectAndSerializeImplAsync(serializer, cancel); - } + protected bool IsRecordingNewExemplarAllowed() + { + if (_exemplarBehavior.NewExemplarMinInterval <= TimeSpan.Zero) + return true; - // Same as above, just only called if we really need to serialize this metric (if publish is true). - private protected abstract Task CollectAndSerializeImplAsync(IMetricsSerializer serializer, CancellationToken cancel); + var elapsedSeconds = ExemplarRecordingTimestampProvider() - _exemplarLastRecordedTimestamp.Value; - /// - /// Creates a metric identifier, with an optional name postfix and an optional extra label to append to the end. - /// familyname_postfix{labelkey1="labelvalue1",labelkey2="labelvalue2"} - /// - protected byte[] CreateIdentifier(string? postfix = null, string? extraLabelName = null, string? extraLabelValue = null) - { - var fullName = postfix != null ? $"{_parent.Name}_{postfix}" : _parent.Name; + return elapsedSeconds >= _exemplarBehavior.NewExemplarMinInterval.TotalSeconds; + } - var labels = FlattenedLabels; + protected void MarkNewExemplarHasBeenRecorded() + { + if (_exemplarBehavior.NewExemplarMinInterval <= TimeSpan.Zero) + return; // No need to record the timestamp if we are not enforcing a minimum interval. - if (extraLabelName != null && extraLabelValue != null) - { - var extraLabelNames = StringSequence.From(extraLabelName); - var extraLabelValues = StringSequence.From(extraLabelValue); + _exemplarLastRecordedTimestamp.Value = ExemplarRecordingTimestampProvider(); + } - var extraLabels = LabelSequence.From(extraLabelNames, extraLabelValues); - // Extra labels go to the end (i.e. they are deepest to inherit from). - labels = labels.Concat(extraLabels); - } + // This is only set if and when debug metrics are enabled in the default registry. + private static Counter? ExemplarsRecorded; - if (labels.Length != 0) - return PrometheusConstants.ExportEncoding.GetBytes($"{fullName}{{{labels.Serialize()}}}"); - else - return PrometheusConstants.ExportEncoding.GetBytes(fullName); - } + static ChildBase() + { + Metrics.DefaultRegistry.OnStartCollectingRegistryMetrics(delegate + { + Volatile.Write(ref ExemplarsRecorded, Metrics.CreateCounter("prometheus_net_exemplars_recorded_total", "Number of exemplars that were accepted into in-memory storage in the prometheus-net SDK.")); + }); + } + + public override string ToString() + { + // Just for debugging. + return $"{Parent.Name}{{{FlattenedLabels}}}"; } } \ No newline at end of file diff --git a/Prometheus/ChildLifetimeInfo.cs b/Prometheus/ChildLifetimeInfo.cs new file mode 100644 index 00000000..fa0e4e0e --- /dev/null +++ b/Prometheus/ChildLifetimeInfo.cs @@ -0,0 +1,40 @@ +using System.Diagnostics; + +namespace Prometheus; + +/// +/// Describes a lifetime of a lifetime-managed metric instance. +/// +/// +/// Contents modified via atomic operations, not guarded by locks. +/// +internal sealed class ChildLifetimeInfo +{ + /// + /// Number of active leases. Nonzero value here indicates the lifetime extends forever. + /// + public int LeaseCount; + + /// + /// When the last lifetime related activity was performed. Expiration timer starts counting from here. + /// This is refreshed whenever a lease is released (a kept lease is a forever-keepalive, so we only care about releasing). + /// + public long KeepaliveTimestamp; + + /// + /// The lifetime has been ended, potentially while a lease was active. The next time a lease ends, + /// it will have to re-register the lifetime instead of just extending the existing one. + /// + public bool Ended; + + public override string ToString() + { + var leaseCount = Volatile.Read(ref LeaseCount); + var keepaliveTimestamp = Volatile.Read(ref KeepaliveTimestamp); + var ended = Volatile.Read(ref Ended); + + var age = PlatformCompatibilityHelpers.StopwatchGetElapsedTime(keepaliveTimestamp, Stopwatch.GetTimestamp()); + + return $"LeaseCount: {leaseCount}, KeepaliveTimestamp: {keepaliveTimestamp}, Ended: {ended}, Age: {age.TotalSeconds:F3} seconds"; + } +} \ No newline at end of file diff --git a/Prometheus/Collector.cs b/Prometheus/Collector.cs index 533237ff..45c45b6c 100644 --- a/Prometheus/Collector.cs +++ b/Prometheus/Collector.cs @@ -1,80 +1,103 @@ -using System.Collections.Concurrent; +using System.Buffers; using System.ComponentModel; +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; - -namespace Prometheus +using Microsoft.Extensions.ObjectPool; + +namespace Prometheus; + +/// +/// Base class for metrics, defining the basic informative API and the internal API. +/// +/// +/// Many of the fields are lazy-initialized to ensure we only perform the memory allocation if and when we actually use them. +/// For some, it means rarely used members are never allocated at all (e.g. if you never inspect the set of label names, they are never allocated). +/// For others, it means they are allocated at first time of use (e.g. serialization-related fields are allocated when serializing the first time). +/// +public abstract class Collector { /// - /// Base class for metrics, defining the basic informative API and the internal API. + /// The metric name, e.g. http_requests_total. /// - public abstract class Collector - { - /// - /// The metric name, e.g. http_requests_total. - /// - public string Name { get; } + public string Name { get; } + + internal byte[] NameBytes => NonCapturingLazyInitializer.EnsureInitialized(ref _nameBytes, this, _assignNameBytesFunc)!; + private byte[]? _nameBytes; + private static readonly Action _assignNameBytesFunc = AssignNameBytes; + private static void AssignNameBytes(Collector instance) => instance._nameBytes = PrometheusConstants.ExportEncoding.GetBytes(instance.Name); + + /// + /// The help text describing the metric for a human audience. + /// + public string Help { get; } - /// - /// The help text describing the metric for a human audience. - /// - public string Help { get; } + internal byte[] HelpBytes => NonCapturingLazyInitializer.EnsureInitialized(ref _helpBytes, this, _assignHelpBytesFunc)!; + private byte[]? _helpBytes; + private static readonly Action _assignHelpBytesFunc = AssignHelpBytes; + private static void AssignHelpBytes(Collector instance) => + instance._helpBytes = string.IsNullOrWhiteSpace(instance.Help) ? [] : PrometheusConstants.ExportEncoding.GetBytes(instance.Help); + + /// + /// Names of the instance-specific labels (name-value pairs) that apply to this metric. + /// When the values are added to the names, you get a instance. + /// Does not include any static label names (from metric configuration, factory or registry). + /// + public string[] LabelNames => NonCapturingLazyInitializer.EnsureInitialized(ref _labelNames, this, _assignLabelNamesFunc)!; + private string[]? _labelNames; + private static readonly Action _assignLabelNamesFunc = AssignLabelNames; + private static void AssignLabelNames(Collector instance) => instance._labelNames = instance.InstanceLabelNames.ToArray(); - /// - /// Names of the instance-specific labels (name-value pairs) that apply to this metric. - /// When the values are added to the names, you get a instance. - /// Does not include any static label names (from metric configuration, factory or registry). - /// - public string[] LabelNames => _instanceLabelNamesAsArrayLazy.Value; + internal StringSequence InstanceLabelNames; + internal StringSequence FlattenedLabelNames; - internal StringSequence InstanceLabelNames; - internal StringSequence FlattenedLabelNames; + /// + /// All static labels obtained from any hierarchy level (either defined in metric configuration or in registry). + /// These will be merged with the instance-specific labels to arrive at the final flattened label sequence for a specific child. + /// + internal LabelSequence StaticLabels; - /// - /// All static labels obtained from any hierarchy level (either defined in metric configuration or in registry). - /// These will be merged with the instance-specific labels to arrive at the final flattened label sequence for a specific child. - /// - internal LabelSequence StaticLabels; + internal abstract MetricType Type { get; } - internal abstract MetricType Type { get; } + internal byte[] TypeBytes { get; } - internal abstract int ChildCount { get; } - internal abstract int TimeseriesCount { get; } + internal abstract int ChildCount { get; } + internal abstract int TimeseriesCount { get; } - internal abstract Task CollectAndSerializeAsync(IMetricsSerializer serializer, CancellationToken cancel); + internal abstract ValueTask CollectAndSerializeAsync(IMetricsSerializer serializer, bool writeFamilyDeclaration, CancellationToken cancel); - // Used by ChildBase.Remove() - internal abstract void RemoveLabelled(LabelSequence instanceLabels); + // Used by ChildBase.Remove() + internal abstract void RemoveLabelled(LabelSequence instanceLabels); - private const string ValidMetricNameExpression = "^[a-zA-Z_:][a-zA-Z0-9_:]*$"; - private const string ValidLabelNameExpression = "^[a-zA-Z_:][a-zA-Z0-9_:]*$"; - private const string ReservedLabelNameExpression = "^__.*$"; + private const string ValidMetricNameExpression = "^[a-zA-Z_][a-zA-Z0-9_]*$"; + private const string ValidLabelNameExpression = "^[a-zA-Z_][a-zA-Z0-9_]*$"; + private const string ReservedLabelNameExpression = "^__.*$"; - private static readonly Regex MetricNameRegex = new Regex(ValidMetricNameExpression, RegexOptions.Compiled); - private static readonly Regex LabelNameRegex = new Regex(ValidLabelNameExpression, RegexOptions.Compiled); - private static readonly Regex ReservedLabelRegex = new Regex(ReservedLabelNameExpression, RegexOptions.Compiled); + private static readonly Regex MetricNameRegex = new(ValidMetricNameExpression, RegexOptions.Compiled); + private static readonly Regex LabelNameRegex = new(ValidLabelNameExpression, RegexOptions.Compiled); + private static readonly Regex ReservedLabelRegex = new(ReservedLabelNameExpression, RegexOptions.Compiled); - internal Collector(string name, string help, StringSequence instanceLabelNames, LabelSequence staticLabels) - { - if (!MetricNameRegex.IsMatch(name)) - throw new ArgumentException($"Metric name '{name}' does not match regex '{ValidMetricNameExpression}'."); + internal Collector(string name, string help, StringSequence instanceLabelNames, LabelSequence staticLabels) + { + if (!MetricNameRegex.IsMatch(name)) + throw new ArgumentException($"Metric name '{name}' does not match regex '{ValidMetricNameExpression}'."); - Name = name; - Help = help; - InstanceLabelNames = instanceLabelNames; - StaticLabels = staticLabels; + Name = name; + TypeBytes = TextSerializer.MetricTypeToBytes[Type]; + Help = help; + InstanceLabelNames = instanceLabelNames; + StaticLabels = staticLabels; - FlattenedLabelNames = instanceLabelNames.Concat(staticLabels.Names); + FlattenedLabelNames = instanceLabelNames.Concat(staticLabels.Names); - // Used to check uniqueness. - var uniqueLabelNames = new HashSet(StringComparer.Ordinal); + // Used to check uniqueness of label names, to catch any label layering mistakes early. + var uniqueLabelNames = LabelValidationHashSetPool.Get(); - var labelNameEnumerator = FlattenedLabelNames.GetEnumerator(); - while (labelNameEnumerator.MoveNext()) + try + { + foreach (var labelName in FlattenedLabelNames) { - var labelName = labelNameEnumerator.Current; - if (labelName == null) - throw new ArgumentNullException("Label name was null."); + throw new ArgumentException("One of the label names was null."); ValidateLabelName(labelName); uniqueLabelNames.Add(labelName); @@ -83,103 +106,208 @@ internal Collector(string name, string help, StringSequence instanceLabelNames, // Here we check for label name collision, ensuring that the same label name is not defined twice on any label inheritance level. if (uniqueLabelNames.Count != FlattenedLabelNames.Length) throw new InvalidOperationException("The set of label names includes duplicates: " + string.Join(", ", FlattenedLabelNames.ToArray())); - - _instanceLabelNamesAsArrayLazy = new Lazy(GetInstanceLabelNamesAsStringArray); } - - private readonly Lazy _instanceLabelNamesAsArrayLazy; - - private string[] GetInstanceLabelNamesAsStringArray() + finally { - return InstanceLabelNames.ToArray(); + LabelValidationHashSetPool.Return(uniqueLabelNames); } + } + + private static readonly ObjectPool> LabelValidationHashSetPool = ObjectPool.Create(new LabelValidationHashSetPoolPolicy()); - internal static void ValidateLabelName(string labelName) + private sealed class LabelValidationHashSetPoolPolicy : PooledObjectPolicy> + { + // If something should explode the size, we do not return it to the pool. + // This should be more than generous even for the most verbosely labeled scenarios. + private const int PooledHashSetMaxSize = 50; + +#if NET + public override HashSet Create() => new(PooledHashSetMaxSize, StringComparer.Ordinal); +#else + public override HashSet Create() => new(StringComparer.Ordinal); +#endif + + public override bool Return(HashSet obj) { - if (!LabelNameRegex.IsMatch(labelName)) - throw new ArgumentException($"Label name '{labelName}' does not match regex '{ValidLabelNameExpression}'."); + if (obj.Count > PooledHashSetMaxSize) + return false; - if (ReservedLabelRegex.IsMatch(labelName)) - throw new ArgumentException($"Label name '{labelName}' is not valid - labels starting with double underscore are reserved!"); + obj.Clear(); + return true; } } + internal static void ValidateLabelName(string labelName) + { + if (!LabelNameRegex.IsMatch(labelName)) + throw new ArgumentException($"Label name '{labelName}' does not match regex '{ValidLabelNameExpression}'."); + + if (ReservedLabelRegex.IsMatch(labelName)) + throw new ArgumentException($"Label name '{labelName}' is not valid - labels starting with double underscore are reserved!"); + } + + public override string ToString() + { + // Just for debugging. + return $"{Name}{{{FlattenedLabelNames}}}"; + } +} + +/// +/// Base class for metrics collectors, providing common labeled child management functionality. +/// +public abstract class Collector : Collector, ICollector + where TChild : ChildBase +{ + // Keyed by the instance labels (not by flattened labels!). + private readonly Dictionary _children = []; + private readonly ReaderWriterLockSlim _childrenLock = new(); + + // Lazy-initialized since not every collector will use a child with no labels. + // Lazy instance will be replaced if the unlabelled timeseries is removed. + private TChild? _lazyUnlabelled; + /// - /// Base class for metrics collectors, providing common labeled child management functionality. + /// Gets the child instance that has no labels. /// - public abstract class Collector : Collector, ICollector - where TChild : ChildBase + protected internal TChild Unlabelled => LazyInitializer.EnsureInitialized(ref _lazyUnlabelled, _createdUnlabelledFunc)!; + + private TChild CreateUnlabelled() => GetOrAddLabelled(LabelSequence.Empty); + private readonly Func _createdUnlabelledFunc; + + // We need it for the ICollector interface but using this is rarely relevant in client code, so keep it obscured. + TChild ICollector.Unlabelled => Unlabelled; + + + // Old naming, deprecated for a silly reason: by default if you start typing .La... and trigger Intellisense + // it will often for whatever reason focus on LabelNames instead of Labels, leading to tiny but persistent frustration. + // Having WithLabels() instead eliminates the other candidate and allows for a frustration-free typing experience. + // Discourage this method as it can create confusion. But it works fine, so no reason to mark it obsolete, really. + [EditorBrowsable(EditorBrowsableState.Never)] + public TChild Labels(params string[] labelValues) => WithLabels(labelValues); + + public TChild WithLabels(params string[] labelValues) { - // Keyed by the instance labels (not by flattened labels!). - private readonly ConcurrentDictionary _labelledMetrics = new(); + if (labelValues == null) + throw new ArgumentNullException(nameof(labelValues)); - // Lazy-initialized since not every collector will use a child with no labels. - // Lazy instance will be replaced if the unlabelled timeseries is unpublished. - private Lazy _unlabelledLazy; + return WithLabels(labelValues.AsMemory()); + } - /// - /// Gets the child instance that has no labels. - /// - protected internal TChild Unlabelled => _unlabelledLazy.Value; + public TChild WithLabels(ReadOnlyMemory labelValues) + { + var labels = LabelSequence.From(InstanceLabelNames, StringSequence.From(labelValues)); + return GetOrAddLabelled(labels); + } - // We need it for the ICollector interface but using this is rarely relevant in client code, so keep it obscured. - TChild ICollector.Unlabelled => Unlabelled; + public TChild WithLabels(ReadOnlySpan labelValues) + { + // We take ReadOnlySpan as a signal that the caller believes we may be able to perform the operation allocation-free because + // the label values are probably already known and a metric instance registered. There is no a guarantee, just a high probability. + // The implementation avoids allocating a long-lived string[] for the label values. We only allocate if we create a new instance. - // This servers a slightly silly but useful purpose: by default if you start typing .La... and trigger Intellisense - // it will often for whatever reason focus on LabelNames instead of Labels, leading to tiny but persistent frustration. - // Having WithLabels() instead eliminates the other candidate and allows for a frustration-free typing experience. - public TChild WithLabels(params string[] labelValues) => Labels(labelValues); + // We still need to process the label values as a reference type, so we transform the Span into a Memory using a pooled buffer. + var buffer = ArrayPool.Shared.Rent(labelValues.Length); - // Discourage it as it can create confusion. But it works fine, so no reason to mark it obsolete, really. - [EditorBrowsable(EditorBrowsableState.Never)] - public TChild Labels(params string[] labelValues) + try { - if (labelValues == null) - throw new ArgumentNullException(nameof(labelValues)); + labelValues.CopyTo(buffer); - var labels = LabelSequence.From(InstanceLabelNames, StringSequence.From(labelValues)); - return GetOrAddLabelled(labels); - } + var temporaryLabels = LabelSequence.From(InstanceLabelNames, StringSequence.From(buffer.AsMemory(0, labelValues.Length))); - public void RemoveLabelled(params string[] labelValues) + if (TryGetLabelled(temporaryLabels, out var existing)) + return existing!; + } + finally { - if (labelValues == null) - throw new ArgumentNullException(nameof(labelValues)); - - var labels = LabelSequence.From(InstanceLabelNames, StringSequence.From(labelValues)); - RemoveLabelled(labels); + ArrayPool.Shared.Return(buffer); } - internal override void RemoveLabelled(LabelSequence labels) + // If we got this far, we did not succeed in finding an existing instance. We need to allocate a long-lived string[] for the label values. + var labels = LabelSequence.From(InstanceLabelNames, StringSequence.From(labelValues.ToArray())); + return CreateLabelled(labels); + } + + public void RemoveLabelled(params string[] labelValues) + { + if (labelValues == null) + throw new ArgumentNullException(nameof(labelValues)); + + var labels = LabelSequence.From(InstanceLabelNames, StringSequence.From(labelValues)); + RemoveLabelled(labels); + } + + internal override void RemoveLabelled(LabelSequence labels) + { + _childrenLock.EnterWriteLock(); + + try { - _labelledMetrics.TryRemove(labels, out _); + _children.Remove(labels); if (labels.Length == 0) { - // If we remove the unlabeled instance (technically legitimate, to unpublish it) then + // If we remove the unlabeled instance (technically legitimate, if the caller really desires to do so) then // we need to also ensure that the special-casing used for it gets properly wired up the next time. - _unlabelledLazy = GetUnlabelledLazyInitializer(); + Volatile.Write(ref _lazyUnlabelled, null); } } + finally + { + _childrenLock.ExitWriteLock(); + } + } - private Lazy GetUnlabelledLazyInitializer() + internal override int ChildCount + { + get { - return new Lazy(() => GetOrAddLabelled(LabelSequence.Empty)); + _childrenLock.EnterReadLock(); + + try + { + return _children.Count; + } + finally + { + _childrenLock.ExitReadLock(); + } } + } - internal override int ChildCount => _labelledMetrics.Count; + /// + /// Gets the instance-specific label values of all labelled instances of the collector. + /// Values of any inherited static labels are not returned in the result. + /// + /// Note that during concurrent operation, the set of values returned here + /// may diverge from the latest set of values used by the collector. + /// + public IEnumerable GetAllLabelValues() + { + // We are yielding here so make a defensive copy so we do not hold locks for a long time. + // We reuse this buffer, so it should be relatively harmless in the long run. + LabelSequence[] buffer; + + _childrenLock.EnterReadLock(); + + var childCount = _children.Count; + buffer = ArrayPool.Shared.Rent(childCount); - /// - /// Gets the instance-specific label values of all labelled instances of the collector. - /// Values of any inherited static labels are not returned in the result. - /// - /// Note that during concurrent operation, the set of values returned here - /// may diverge from the latest set of values used by the collector. - /// - public IEnumerable GetAllLabelValues() + try { - foreach (var labels in _labelledMetrics.Keys) + try + { + _children.Keys.CopyTo(buffer, 0); + } + finally + { + _childrenLock.ExitReadLock(); + } + + for (var i = 0; i < childCount; i++) { + var labels = buffer[i]; + if (labels.Length == 0) continue; // We do not return the "unlabelled" label set. @@ -187,77 +315,165 @@ public IEnumerable GetAllLabelValues() yield return labels.Values.ToArray(); } } - - private TChild GetOrAddLabelled(LabelSequence instanceLabels) + finally { - // NOTE: We do not try to find a metric instance with the same set of label names but in a DIFFERENT order. - // Order of labels matterns in data creation, although does not matter when the exported data set is imported later. - // If we somehow end up registering the same metric with the same label names in different order, we will publish it twice, in two orders... - // That is not ideal but also not that big of a deal to do a lookup every time a metric instance is registered. + ArrayPool.Shared.Return(buffer); + } + } + + private TChild GetOrAddLabelled(LabelSequence instanceLabels) + { + // NOTE: We do not try to find a metric instance with the same set of label names but in a DIFFERENT order. + // Order of labels matters in data creation, although does not matter when the exported data set is imported later. + // If we somehow end up registering the same metric with the same label names in different order, we will publish it twice, in two orders... + // That is not ideal but also not that big of a deal to justify a lookup every time a metric instance is registered. - // Don't allocate lambda for GetOrAdd in the common case that the labeled metrics exist. - if (_labelledMetrics.TryGetValue(instanceLabels, out var metric)) - return metric; + // First try to find an existing instance. This is the fast path, if we are re-looking-up an existing one. + if (TryGetLabelled(instanceLabels, out var existing)) + return existing!; - return _labelledMetrics.GetOrAdd(instanceLabels, CreateLabelledChild); - } + // If no existing one found, grab the write lock and create a new one if needed. + return CreateLabelled(instanceLabels); + } + + private bool TryGetLabelled(LabelSequence instanceLabels, out TChild? child) + { + _childrenLock.EnterReadLock(); - private TChild CreateLabelledChild(LabelSequence instanceLabels) + try { - // Order of labels is 1) instance labels; 2) static labels. - var flattenedLabels = instanceLabels.Concat(StaticLabels); + if (_children.TryGetValue(instanceLabels, out var existing)) + { + child = existing; + return true; + } - return NewChild(instanceLabels, flattenedLabels, publish: !_suppressInitialValue); + child = null; + return false; + } + finally + { + _childrenLock.ExitReadLock(); } + } - /// - /// For tests that want to see what instance-level label values were used when metrics were created. - /// - internal LabelSequence[] GetAllInstanceLabels() => _labelledMetrics.Select(p => p.Key).ToArray(); + private TChild CreateLabelled(LabelSequence instanceLabels) + { + var newChild = _createdLabelledChildFunc(instanceLabels); + + _childrenLock.EnterWriteLock(); - internal Collector(string name, string help, StringSequence instanceLabelNames, LabelSequence staticLabels, bool suppressInitialValue) - : base(name, help, instanceLabelNames, staticLabels) + try + { +#if NET + // It could be that someone beats us to it! Probably not, though. + if (_children.TryAdd(instanceLabels, newChild)) + return newChild; + + return _children[instanceLabels]; +#else + // On .NET Fx we need to do the pessimistic case first because there is no TryAdd(). + if (_children.TryGetValue(instanceLabels, out var existing)) + return existing; + + _children.Add(instanceLabels, newChild); + return newChild; +#endif + } + finally { - _suppressInitialValue = suppressInitialValue; + _childrenLock.ExitWriteLock(); + } + } - _unlabelledLazy = GetUnlabelledLazyInitializer(); + private TChild CreateLabelledChild(LabelSequence instanceLabels) + { + // Order of labels is 1) instance labels; 2) static labels. + var flattenedLabels = instanceLabels.Concat(StaticLabels); - _familyHeaderLines = new byte[][] - { - PrometheusConstants.ExportEncoding.GetBytes($"# HELP {name} {help}"), - PrometheusConstants.ExportEncoding.GetBytes($"# TYPE {name} {Type.ToString().ToLowerInvariant()}") - }; - } + return NewChild(instanceLabels, flattenedLabels, publish: !_suppressInitialValue, _exemplarBehavior); + } - /// - /// Creates a new instance of the child collector type. - /// - private protected abstract TChild NewChild(LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish); + // Cache the delegate to avoid allocating a new one every time in GetOrAddLabelled. + private readonly Func _createdLabelledChildFunc; - private readonly byte[][] _familyHeaderLines; + /// + /// For tests that want to see what instance-level label values were used when metrics were created. + /// This is for testing only, so does not respect locks - do not use this in concurrent context. + /// + internal LabelSequence[] GetAllInstanceLabelsUnsafe() => _children.Keys.ToArray(); - internal override async Task CollectAndSerializeAsync(IMetricsSerializer serializer, CancellationToken cancel) - { - EnsureUnlabelledMetricCreatedIfNoLabels(); + internal Collector(string name, string help, StringSequence instanceLabelNames, LabelSequence staticLabels, bool suppressInitialValue, ExemplarBehavior exemplarBehavior) + : base(name, help, instanceLabelNames, staticLabels) + { + _createdUnlabelledFunc = CreateUnlabelled; + _createdLabelledChildFunc = CreateLabelledChild; - await serializer.WriteFamilyDeclarationAsync(_familyHeaderLines, cancel); + _suppressInitialValue = suppressInitialValue; + _exemplarBehavior = exemplarBehavior; + } - foreach (var child in _labelledMetrics.Values) - await child.CollectAndSerializeAsync(serializer, cancel); - } + /// + /// Creates a new instance of the child collector type. + /// + private protected abstract TChild NewChild(LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish, ExemplarBehavior exemplarBehavior); + +#if NET + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] +#endif + internal override async ValueTask CollectAndSerializeAsync(IMetricsSerializer serializer, bool writeFamilyDeclaration, CancellationToken cancel) + { + EnsureUnlabelledMetricCreatedIfNoLabels(); + + // There may be multiple Collectors emitting data for the same family. Only the first will write out the family declaration. + if (writeFamilyDeclaration) + await serializer.WriteFamilyDeclarationAsync(Name, NameBytes, HelpBytes, Type, TypeBytes, cancel); + + // This could potentially take nontrivial time, as we are serializing to a stream (potentially, a network stream). + // Therefore we operate on a defensive copy in a reused buffer. + TChild[] children; + + _childrenLock.EnterReadLock(); - private readonly bool _suppressInitialValue; + var childCount = _children.Count; + children = ArrayPool.Shared.Rent(childCount); - private void EnsureUnlabelledMetricCreatedIfNoLabels() + try { - // We want metrics to exist even with 0 values if they are supposed to be used without labels. - // Labelled metrics are created when label values are assigned. However, as unlabelled metrics are lazy-created - // (they are optional if labels are used) we might lose them for cases where they really are desired. - - // If there are no label names then clearly this metric is supposed to be used unlabelled, so create it. - // Otherwise, we allow unlabelled metrics to be used if the user explicitly does it but omit them by default. - if (!_unlabelledLazy.IsValueCreated && !LabelNames.Any()) - GetOrAddLabelled(LabelSequence.Empty); + try + { + _children.Values.CopyTo(children, 0); + } + finally + { + _childrenLock.ExitReadLock(); + } + + for (var i = 0; i < childCount; i++) + { + var child = children[i]; + await child.CollectAndSerializeAsync(serializer, cancel); + } + } + finally + { + ArrayPool.Shared.Return(children, clearArray: true); } } + + private readonly bool _suppressInitialValue; + + private void EnsureUnlabelledMetricCreatedIfNoLabels() + { + // We want metrics to exist even with 0 values if they are supposed to be used without labels. + // Labelled metrics are created when label values are assigned. However, as unlabelled metrics are lazy-created + // (they are optional if labels are used) we might lose them for cases where they really are desired. + + // If there are no label names then clearly this metric is supposed to be used unlabelled, so create it. + // Otherwise, we allow unlabelled metrics to be used if the user explicitly does it but omit them by default. + if (InstanceLabelNames.Length == 0) + LazyInitializer.EnsureInitialized(ref _lazyUnlabelled, _createdUnlabelledFunc); + } + + private readonly ExemplarBehavior _exemplarBehavior; } \ No newline at end of file diff --git a/Prometheus/CollectorFamily.cs b/Prometheus/CollectorFamily.cs new file mode 100644 index 00000000..6db89569 --- /dev/null +++ b/Prometheus/CollectorFamily.cs @@ -0,0 +1,179 @@ +using System.Buffers; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.ObjectPool; + +namespace Prometheus; + +internal sealed class CollectorFamily +{ + public Type CollectorType { get; } + + private readonly Dictionary _collectors = new(); + private readonly ReaderWriterLockSlim _lock = new(); + + public CollectorFamily(Type collectorType) + { + CollectorType = collectorType; + _collectAndSerializeFunc = CollectAndSerialize; + } + +#if NET + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] +#endif + internal async ValueTask CollectAndSerializeAsync(IMetricsSerializer serializer, CancellationToken cancel) + { + var operation = _serializeFamilyOperationPool.Get(); + operation.Serializer = serializer; + + await ForEachCollectorAsync(_collectAndSerializeFunc, operation, cancel); + + _serializeFamilyOperationPool.Return(operation); + } + + /// + /// We use these reusable operation wrappers to avoid capturing variables when serializing, to keep memory usage down while serializing. + /// + private sealed class SerializeFamilyOperation + { + // The first family member we serialize requires different serialization from the others. + public bool IsFirst; + public IMetricsSerializer? Serializer; + + public SerializeFamilyOperation() => Reset(); + + public void Reset() + { + IsFirst = true; + Serializer = null; + } + } + + // We have a bunch of families that get serialized often - no reason to churn the GC with a bunch of allocations if we can easily reuse it. + private static readonly ObjectPool _serializeFamilyOperationPool = ObjectPool.Create(new SerializeFamilyOperationPoolingPolicy()); + + private sealed class SerializeFamilyOperationPoolingPolicy : PooledObjectPolicy + { + public override SerializeFamilyOperation Create() => new(); + + public override bool Return(SerializeFamilyOperation obj) + { + obj.Reset(); + return true; + } + } + +#if NET + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] +#endif + private async ValueTask CollectAndSerialize(Collector collector, SerializeFamilyOperation operation, CancellationToken cancel) + { + await collector.CollectAndSerializeAsync(operation.Serializer!, operation.IsFirst, cancel); + operation.IsFirst = false; + } + + private readonly Func _collectAndSerializeFunc; + + internal Collector GetOrAdd( + in CollectorIdentity identity, + string name, + string help, + TConfiguration configuration, + ExemplarBehavior exemplarBehavior, + CollectorRegistry.CollectorInitializer initializer) + where TCollector : Collector + where TConfiguration : MetricConfiguration + { + // First we try just holding a read lock. This is the happy path. + _lock.EnterReadLock(); + + try + { + if (_collectors.TryGetValue(identity, out var collector)) + return collector; + } + finally + { + _lock.ExitReadLock(); + } + + // Then we grab a write lock. This is the slow path. + var newCollector = initializer(name, help, identity.InstanceLabelNames, identity.StaticLabels, configuration, exemplarBehavior); + + _lock.EnterWriteLock(); + + try + { +#if NET + // It could be that someone beats us to it! Probably not, though. + if (_collectors.TryAdd(identity, newCollector)) + return newCollector; + + return _collectors[identity]; +#else + // On .NET Fx we need to do the pessimistic case first because there is no TryAdd(). + if (_collectors.TryGetValue(identity, out var collector)) + return collector; + + _collectors.Add(identity, newCollector); + return newCollector; +#endif + } + finally + { + _lock.ExitWriteLock(); + } + } + + internal void ForEachCollector(Action action) + { + _lock.EnterReadLock(); + + try + { + foreach (var collector in _collectors.Values) + action(collector); + } + finally + { + _lock.ExitReadLock(); + } + } + +#if NET + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] +#endif + internal async ValueTask ForEachCollectorAsync(Func func, TArg arg, CancellationToken cancel) + where TArg : class + { + // This could potentially take nontrivial time, as we are serializing to a stream (potentially, a network stream). + // Therefore we operate on a defensive copy in a reused buffer. + Collector[] buffer; + + _lock.EnterReadLock(); + + var collectorCount = _collectors.Count; + buffer = ArrayPool.Shared.Rent(collectorCount); + + try + { + try + { + _collectors.Values.CopyTo(buffer, 0); + } + finally + { + _lock.ExitReadLock(); + } + + for (var i = 0; i < collectorCount; i++) + { + var collector = buffer[i]; + await func(collector, arg, cancel); + } + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + } + } +} diff --git a/Prometheus/CollectorIdentity.cs b/Prometheus/CollectorIdentity.cs index 84a829f0..c89729bf 100644 --- a/Prometheus/CollectorIdentity.cs +++ b/Prometheus/CollectorIdentity.cs @@ -1,69 +1,59 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// Uniquely identifies a specific collector within a family. Different collectors are used for different label combinations. +/// * Any difference in static labels (keys or values) means it is a different collector. +/// * Any difference in the names of instance labels means it is a different collector. +/// +internal readonly struct CollectorIdentity(StringSequence instanceLabelNames, LabelSequence staticLabels) : IEquatable { - /// - /// Represents the values that make up a single collector's unique identity, used during collector registration. - /// If these values match an existing collector, we will reuse it (or throw if metadata mismatches). - /// If these values do not match any existing collector, we will create a new collector. - /// - internal struct CollectorIdentity : IEquatable - { - public readonly string Name; - public readonly StringSequence InstanceLabelNames; - public readonly StringSequence StaticLabelNames; - - private readonly int _hashCode; - - public CollectorIdentity(string name, StringSequence instanceLabelNames, StringSequence staticLabelNames) - { - Name = name; - InstanceLabelNames = instanceLabelNames; - StaticLabelNames = staticLabelNames; + public readonly StringSequence InstanceLabelNames = instanceLabelNames; + public readonly LabelSequence StaticLabels = staticLabels; - _hashCode = CalculateHashCode(name, instanceLabelNames, staticLabelNames); - } - - public bool Equals(CollectorIdentity other) - { - if (!string.Equals(Name, other.Name, StringComparison.Ordinal)) - return false; + private readonly int _hashCode = CalculateHashCode(instanceLabelNames, staticLabels); - if (_hashCode != other._hashCode) - return false; + public bool Equals(CollectorIdentity other) + { + if (_hashCode != other._hashCode) + return false; - if (InstanceLabelNames.Length != other.InstanceLabelNames.Length) - return false; + if (InstanceLabelNames.Length != other.InstanceLabelNames.Length) + return false; - if (!InstanceLabelNames.Equals(other.InstanceLabelNames)) - return false; + if (!InstanceLabelNames.Equals(other.InstanceLabelNames)) + return false; - if (!StaticLabelNames.Equals(other.StaticLabelNames)) - return false; + if (!StaticLabels.Equals(other.StaticLabels)) + return false; - return true; - } + return true; + } - public override int GetHashCode() - { - return _hashCode; - } + public override int GetHashCode() + { + return _hashCode; + } - private static int CalculateHashCode(string name, StringSequence instanceLabelNames, StringSequence staticLabelNames) + private static int CalculateHashCode(StringSequence instanceLabelNames, LabelSequence staticLabels) + { + unchecked { - unchecked - { - int hashCode = 0; + int hashCode = 0; - hashCode ^= name.GetHashCode() * 31; - hashCode ^= instanceLabelNames.GetHashCode() * 397; - hashCode ^= staticLabelNames.GetHashCode() * 397; + hashCode ^= instanceLabelNames.GetHashCode() * 397; + hashCode ^= staticLabels.GetHashCode() * 397; - return hashCode; - } + return hashCode; } + } - public override string ToString() - { - return $"{Name}{{{InstanceLabelNames.Length + StaticLabelNames.Length}}}"; - } + public override string ToString() + { + return $"{_hashCode}{{{InstanceLabelNames.Length} + {StaticLabels.Length}}}"; + } + + public override bool Equals(object? obj) + { + return obj is CollectorIdentity identity && Equals(identity); } -} +} \ No newline at end of file diff --git a/Prometheus/CollectorRegistry.cs b/Prometheus/CollectorRegistry.cs index aa3f774e..35f1971c 100644 --- a/Prometheus/CollectorRegistry.cs +++ b/Prometheus/CollectorRegistry.cs @@ -1,322 +1,409 @@ -using System.Collections.Concurrent; +using System.Buffers; +using System.Collections.Concurrent; using System.Diagnostics; -namespace Prometheus +namespace Prometheus; + +/// +/// Maintains references to a set of collectors, from which data for metrics is collected at data export time. +/// +/// Use methods on the class to add metrics to a collector registry. +/// +/// +/// To encourage good concurrency practices, registries are append-only. You can add things to them but not remove. +/// If you wish to remove things from the registry, create a new registry with only the things you wish to keep. +/// +public sealed class CollectorRegistry : ICollectorRegistry { + #region "Before collect" callbacks /// - /// Maintains references to a set of collectors, from which data for metrics is collected at data export time. + /// Registers an action to be called before metrics are collected. + /// This enables you to do last-minute updates to metric values very near the time of collection. + /// Callbacks will delay the metric collection, so do not make them too long or it may time out. /// - /// Use methods on the class to add metrics to a collector registry. + /// The callback will be executed synchronously and should not take more than a few milliseconds. + /// To execute longer-duration callbacks, register an asynchronous callback (Func<Task>). + /// + /// If the callback throws then the entire metric collection will fail. + /// This will result in an appropriate HTTP error code or a skipped push, depending on type of exporter. + /// + /// If multiple concurrent collections occur, the callback may be called multiple times concurrently. /// - /// - /// To encourage good concurrency practices, registries are append-only. You can add things to them but not remove. - /// If you wish to remove things from the registry, create a new registry with only the things you wish to keep. - /// - public sealed class CollectorRegistry : ICollectorRegistry + public void AddBeforeCollectCallback(Action callback) { - #region "Before collect" callbacks - /// - /// Registers an action to be called before metrics are collected. - /// This enables you to do last-minute updates to metric values very near the time of collection. - /// Callbacks will delay the metric collection, so do not make them too long or it may time out. - /// - /// The callback will be executed synchronously and should not take more than a few milliseconds. - /// To execute longer-duration callbacks, register an asynchronous callback (Func<Task>). - /// - /// If the callback throws then the entire metric collection will fail. - /// This will result in an appropriate HTTP error code or a skipped push, depending on type of exporter. - /// - /// If multiple concurrent collections occur, the callback may be called multiple times concurrently. - /// - public void AddBeforeCollectCallback(Action callback) - { - if (callback == null) - throw new ArgumentNullException(nameof(callback)); + if (callback == null) + throw new ArgumentNullException(nameof(callback)); - _beforeCollectCallbacks.Add(callback); - } + _beforeCollectCallbacks.Add(callback); + } - /// - /// Registers an action to be called before metrics are collected. - /// This enables you to do last-minute updates to metric values very near the time of collection. - /// Callbacks will delay the metric collection, so do not make them too long or it may time out. - /// - /// Asynchronous callbacks will be executed concurrently and may last longer than a few milliseconds. - /// - /// If the callback throws then the entire metric collection will fail. - /// This will result in an appropriate HTTP error code or a skipped push, depending on type of exporter. - /// - /// If multiple concurrent collections occur, the callback may be called multiple times concurrently. - /// - public void AddBeforeCollectCallback(Func callback) - { - if (callback == null) - throw new ArgumentNullException(nameof(callback)); + /// + /// Registers an action to be called before metrics are collected. + /// This enables you to do last-minute updates to metric values very near the time of collection. + /// Callbacks will delay the metric collection, so do not make them too long or it may time out. + /// + /// Asynchronous callbacks will be executed concurrently and may last longer than a few milliseconds. + /// + /// If the callback throws then the entire metric collection will fail. + /// This will result in an appropriate HTTP error code or a skipped push, depending on type of exporter. + /// + /// If multiple concurrent collections occur, the callback may be called multiple times concurrently. + /// + public void AddBeforeCollectCallback(Func callback) + { + if (callback == null) + throw new ArgumentNullException(nameof(callback)); - _beforeCollectAsyncCallbacks.Add(callback); - } + _beforeCollectAsyncCallbacks.Add(callback); + } - private readonly ConcurrentBag _beforeCollectCallbacks = new ConcurrentBag(); - private readonly ConcurrentBag> _beforeCollectAsyncCallbacks = new ConcurrentBag>(); - #endregion - - #region Static labels - /// - /// The set of static labels that are applied to all metrics in this registry. - /// Enumeration of the returned collection is thread-safe. - /// - public IEnumerable> StaticLabels => _staticLabels.ToDictionary(); - - /// - /// Defines the set of static labels to apply to all metrics in this registry. - /// The static labels can only be set once on startup, before adding or publishing any metrics. - /// - public void SetStaticLabels(IDictionary labels) - { - if (labels == null) - throw new ArgumentNullException(nameof(labels)); + private readonly ConcurrentBag _beforeCollectCallbacks = []; + private readonly ConcurrentBag> _beforeCollectAsyncCallbacks = []; + #endregion - // Read lock is taken when creating metrics, so we know that no metrics can be created while we hold this lock. - _staticLabelsLock.EnterWriteLock(); + #region Static labels + /// + /// The set of static labels that are applied to all metrics in this registry. + /// Enumeration of the returned collection is thread-safe. + /// + public IEnumerable> StaticLabels => _staticLabels.ToDictionary(); - try - { - if (_staticLabels.Length != 0) - throw new InvalidOperationException("Static labels have already been defined - you can only do it once per registry."); + /// + /// Defines the set of static labels to apply to all metrics in this registry. + /// The static labels can only be set once on startup, before adding or publishing any metrics. + /// + public void SetStaticLabels(IDictionary labels) + { + if (labels == null) + throw new ArgumentNullException(nameof(labels)); - if (_collectors.Count != 0) - throw new InvalidOperationException("Metrics have already been added to the registry - cannot define static labels anymore."); + // Read lock is taken when creating metrics, so we know that no metrics can be created while we hold this lock. + _staticLabelsLock.EnterWriteLock(); - // Keep the lock for the duration of this method to make sure no publishing happens while we are setting labels. - lock (_firstCollectLock) - { - if (_hasPerformedFirstCollect) - throw new InvalidOperationException("The metrics registry has already been published - cannot define static labels anymore."); + try + { + if (_staticLabels.Length != 0) + throw new InvalidOperationException("Static labels have already been defined - you can only do it once per registry."); - foreach (var pair in labels) - { - if (pair.Key == null) - throw new ArgumentException("The name of a label cannot be null."); + if (_families.Count != 0) + throw new InvalidOperationException("Metrics have already been added to the registry - cannot define static labels anymore."); - if (pair.Value == null) - throw new ArgumentException("The value of a label cannot be null."); + // Keep the lock for the duration of this method to make sure no publishing happens while we are setting labels. + lock (_firstCollectLock) + { + if (_hasPerformedFirstCollect) + throw new InvalidOperationException("The metrics registry has already been published - cannot define static labels anymore."); + + foreach (var pair in labels) + { + if (pair.Key == null) + throw new ArgumentException("The name of a label cannot be null."); - Collector.ValidateLabelName(pair.Key); - } + if (pair.Value == null) + throw new ArgumentException("The value of a label cannot be null."); - _staticLabels = LabelSequence.From(labels); + Collector.ValidateLabelName(pair.Key); } - } - finally - { - _staticLabelsLock.ExitWriteLock(); + + _staticLabels = LabelSequence.From(labels); } } + finally + { + _staticLabelsLock.ExitWriteLock(); + } + } - private LabelSequence _staticLabels; - private readonly ReaderWriterLockSlim _staticLabelsLock = new ReaderWriterLockSlim(); + private LabelSequence _staticLabels; + private readonly ReaderWriterLockSlim _staticLabelsLock = new(); - internal LabelSequence GetStaticLabels() - { - _staticLabelsLock.EnterReadLock(); + internal LabelSequence GetStaticLabels() + { + _staticLabelsLock.EnterReadLock(); - try - { - return _staticLabels; - } - finally - { - _staticLabelsLock.ExitReadLock(); - } + try + { + return _staticLabels; } - #endregion - - /// - /// Collects all metrics and exports them in text document format to the provided stream. - /// - /// This method is designed to be used with custom output mechanisms that do not use an IMetricServer. - /// - public Task CollectAndExportAsTextAsync(Stream to, CancellationToken cancel = default) + finally { - if (to == null) - throw new ArgumentNullException(nameof(to)); - - return CollectAndSerializeAsync(new TextSerializer(to), cancel); + _staticLabelsLock.ExitReadLock(); } + } + #endregion - // We pass this thing to GetOrAdd to avoid allocating a collector or a closure. - // This reduces memory usage in situations where the collector is already registered. - internal readonly struct CollectorInitializer - where TCollector : Collector - where TConfiguration : MetricConfiguration - { - private readonly CreateInstanceDelegate _createInstance; - private readonly string _name; - private readonly string _help; - private readonly StringSequence _instanceLabelNames; - private readonly LabelSequence _staticLabels; - private readonly TConfiguration _configuration; - - public string Name => _name; - public StringSequence InstanceLabelNames => _instanceLabelNames; - public LabelSequence StaticLabels => _staticLabels; - - public CollectorInitializer(CreateInstanceDelegate createInstance, string name, string help, StringSequence instanceLabelNames, LabelSequence staticLabels, TConfiguration configuration) - { - _createInstance = createInstance; - _name = name; - _help = help; - _instanceLabelNames = instanceLabelNames; - _staticLabels = staticLabels; - _configuration = configuration; - } + /// + /// Collects all metrics and exports them in text document format to the provided stream. + /// + /// This method is designed to be used with custom output mechanisms that do not use an IMetricServer. + /// + public Task CollectAndExportAsTextAsync(Stream to, CancellationToken cancel = default) + => CollectAndExportAsTextAsync(to, ExpositionFormat.PrometheusText, cancel); + + /// + /// Collects all metrics and exports them in text document format to the provided stream. + /// + /// This method is designed to be used with custom output mechanisms that do not use an IMetricServer. + /// + public Task CollectAndExportAsTextAsync(Stream to, ExpositionFormat format, CancellationToken cancel = default) + { + if (to == null) + throw new ArgumentNullException(nameof(to)); - public TCollector CreateInstance(CollectorIdentity _) => _createInstance(_name, _help, _instanceLabelNames, _staticLabels, _configuration); + return CollectAndSerializeAsync(new TextSerializer(to, format), cancel); + } - public delegate TCollector CreateInstanceDelegate(string name, string help, StringSequence instanceLabelNames, LabelSequence staticLabels, TConfiguration configuration); - } + internal delegate TCollector CollectorInitializer(string name, string help, in StringSequence instanceLabelNames, in LabelSequence staticLabels, TConfiguration configuration, ExemplarBehavior exemplarBehavior) + where TCollector : Collector + where TConfiguration : MetricConfiguration; - /// - /// Adds a collector to the registry, returning an existing instance if one with a matching name was already registered. - /// - internal TCollector GetOrAdd(in CollectorInitializer initializer) - where TCollector : Collector - where TConfiguration : MetricConfiguration + /// + /// Adds a collector to the registry, returning an existing instance if one with a matching name was already registered. + /// + internal TCollector GetOrAdd(string name, string help, in StringSequence instanceLabelNames, in LabelSequence staticLabels, TConfiguration configuration, ExemplarBehavior exemplarBehavior, in CollectorInitializer initializer) + where TCollector : Collector + where TConfiguration : MetricConfiguration + { + var family = GetOrAddCollectorFamily(name); + + var collectorIdentity = new CollectorIdentity(instanceLabelNames, staticLabels); + + return (TCollector)family.GetOrAdd(collectorIdentity, name, help, configuration, exemplarBehavior, initializer); + } + + private CollectorFamily GetOrAddCollectorFamily(string finalName) + where TCollector : Collector + { + static CollectorFamily ValidateFamily(CollectorFamily candidate) { - var identity = new CollectorIdentity(initializer.Name, initializer.InstanceLabelNames, initializer.StaticLabels.Names); + // We either created a new collector family or found one with a matching identity. + // We do some basic validation here to avoid silly API usage mistakes. - static TCollector Validate(Collector candidate) - { - // We either created a new collector or found one with a matching identity. - // We do some basic validation here to avoid silly API usage mistakes. + if (candidate.CollectorType != typeof(TCollector)) + throw new InvalidOperationException("Collector of a different type with the same name is already registered."); - if (!(candidate is TCollector)) - throw new InvalidOperationException("Collector of a different type with the same identity is already registered."); + return candidate; + } - return (TCollector)candidate; - } + // First try to get the family with only a read lock, with the assumption that it might already exist and therefore we do not need an expensive write lock. + _familiesLock.EnterReadLock(); + + try + { + if (_families.TryGetValue(finalName, out var existing)) + return ValidateFamily(existing); + } + finally + { + _familiesLock.ExitReadLock(); + } - // Should we optimize for the case where the collector is already registered? It is unlikely to be very common. However, we still should because: - // 1) In scenarios where collector re-registration is common, it could be an expensive persistent cost in terms of allocations. - // 2) In scenarios where collector re-registration is not common, a tiny compute overhead is unlikely to be significant in the big picture, as it only happens once. - if (_collectors.TryGetValue(identity, out var existing)) - return Validate(existing); + // It does not exist. OK, just create it. + var newFamily = new CollectorFamily(typeof(TCollector)); - var collector = _collectors.GetOrAdd(identity, initializer.CreateInstance); + _familiesLock.EnterWriteLock(); - return Validate(collector); + try + { +#if NET + // It could be that someone beats us to it! Probably not, though. + if (_families.TryAdd(finalName, newFamily)) + return newFamily; + + return ValidateFamily(_families[finalName]); +#else + // On .NET Fx we need to do the pessimistic case first because there is no TryAdd(). + if (_families.TryGetValue(finalName, out var existing)) + return ValidateFamily(existing); + + _families.Add(finalName, newFamily); + return newFamily; +#endif } + finally + { + _familiesLock.ExitWriteLock(); + } + } - private readonly ConcurrentDictionary _collectors = new ConcurrentDictionary(); + // Each collector family has an identity (the base name of the metric, in Prometheus format) and any number of collectors within. + // Different collectors in the same family may have different sets of labels (static and instance) depending on how they were created. + private readonly Dictionary _families = new(StringComparer.Ordinal); + private readonly ReaderWriterLockSlim _familiesLock = new(); - internal void SetBeforeFirstCollectCallback(Action a) + internal void SetBeforeFirstCollectCallback(Action a) + { + lock (_firstCollectLock) { - lock (_firstCollectLock) - { - if (_hasPerformedFirstCollect) - return; // Avoid keeping a reference to a callback we won't ever use. + if (_hasPerformedFirstCollect) + return; // Avoid keeping a reference to a callback we won't ever use. - _beforeFirstCollectCallback = a; - } + _beforeFirstCollectCallback = a; } + } - /// - /// Allows us to initialize (or not) the registry with the default metrics before the first collection. - /// - private Action? _beforeFirstCollectCallback; - private bool _hasPerformedFirstCollect; - private readonly object _firstCollectLock = new object(); - - /// - /// Collects metrics from all the registered collectors and sends them to the specified serializer. - /// - internal async Task CollectAndSerializeAsync(IMetricsSerializer serializer, CancellationToken cancel) + /// + /// Allows us to initialize (or not) the registry with the default metrics before the first collection. + /// + private Action? _beforeFirstCollectCallback; + private bool _hasPerformedFirstCollect; + private readonly object _firstCollectLock = new(); + + /// + /// Collects metrics from all the registered collectors and sends them to the specified serializer. + /// + internal async Task CollectAndSerializeAsync(IMetricsSerializer serializer, CancellationToken cancel) + { + lock (_firstCollectLock) { - lock (_firstCollectLock) + if (!_hasPerformedFirstCollect) { - if (!_hasPerformedFirstCollect) - { - _hasPerformedFirstCollect = true; - _beforeFirstCollectCallback?.Invoke(); - _beforeFirstCollectCallback = null; - } + _hasPerformedFirstCollect = true; + _beforeFirstCollectCallback?.Invoke(); + _beforeFirstCollectCallback = null; } + } - await RunBeforeCollectCallbacksAsync(cancel); + await RunBeforeCollectCallbacksAsync(cancel); - UpdateRegistryMetrics(); + UpdateRegistryMetrics(); - foreach (var collector in _collectors.Values) - await collector.CollectAndSerializeAsync(serializer, cancel); + // This could potentially take nontrivial time, as we are serializing to a stream (potentially, a network stream). + // Therefore we operate on a defensive copy in a reused buffer. + CollectorFamily[] buffer; - await serializer.FlushAsync(cancel); - } + _familiesLock.EnterReadLock(); + + var familiesCount = _families.Count; + buffer = ArrayPool.Shared.Rent(familiesCount); - private async Task RunBeforeCollectCallbacksAsync(CancellationToken cancel) + try { - foreach (var callback in _beforeCollectCallbacks) + try { - try - { - callback(); - } - catch (Exception ex) - { - Trace.WriteLine($"Metrics before-collect callback failed: {ex}"); - } + _families.Values.CopyTo(buffer, 0); + } + finally + { + _familiesLock.ExitReadLock(); } - await Task.WhenAll(_beforeCollectAsyncCallbacks.Select(async (callback) => + for (var i = 0; i < familiesCount; i++) { - try - { - await callback(cancel); - } - catch (Exception ex) - { - Trace.WriteLine($"Metrics before-collect callback failed: {ex}"); - } - })); + var family = buffer[i]; + await family.CollectAndSerializeAsync(serializer, cancel); + } } - - /// - /// We collect some debug metrics from the registry itself to help indicate how many metrics we are publishing. - /// - internal void StartCollectingRegistryMetrics() + finally { - var factory = Metrics.WithCustomRegistry(this); + ArrayPool.Shared.Return(buffer, clearArray: true); + } - _metricFamilies = factory.CreateGauge("prometheus_net_metric_families", "Number of metric families currently registered.", labelNames: new[] { MetricTypeDebugLabel }); - _metricInstances = factory.CreateGauge("prometheus_net_metric_instances", "Number of metric instances currently registered across all metric families.", labelNames: new[] { MetricTypeDebugLabel }); - _metricTimeseries = factory.CreateGauge("prometheus_net_metric_timeseries", "Number of metric timeseries currently generated from all metric instances.", labelNames: new[] { MetricTypeDebugLabel }); + await serializer.WriteEnd(cancel); + await serializer.FlushAsync(cancel); + } - _metricFamiliesPerType = new(); - _metricInstancesPerType = new(); - _metricTimeseriesPerType = new(); + private async Task RunBeforeCollectCallbacksAsync(CancellationToken cancel) + { + foreach (var callback in _beforeCollectCallbacks) + { + try + { + callback(); + } + catch (Exception ex) + { + Trace.WriteLine($"Metrics before-collect callback failed: {ex}"); + } + } - foreach (MetricType type in Enum.GetValues(typeof(MetricType))) + await Task.WhenAll(_beforeCollectAsyncCallbacks.Select(async (callback) => + { + try + { + await callback(cancel); + } + catch (Exception ex) { - var typeName = type.ToString().ToLowerInvariant(); - _metricFamiliesPerType[type] = _metricFamilies.WithLabels(typeName); - _metricInstancesPerType[type] = _metricInstances.WithLabels(typeName); - _metricTimeseriesPerType[type] = _metricTimeseries.WithLabels(typeName); + Trace.WriteLine($"Metrics before-collect callback failed: {ex}"); } + })); + } + + /// + /// We collect some debug metrics from the registry itself to help indicate how many metrics we are publishing. + /// + internal void StartCollectingRegistryMetrics() + { + var factory = Metrics.WithCustomRegistry(this); + + _metricFamilies = factory.CreateGauge("prometheus_net_metric_families", "Number of metric families currently registered.", labelNames: [MetricTypeDebugLabel]); + _metricInstances = factory.CreateGauge("prometheus_net_metric_instances", "Number of metric instances currently registered across all metric families.", labelNames: [MetricTypeDebugLabel]); + _metricTimeseries = factory.CreateGauge("prometheus_net_metric_timeseries", "Number of metric timeseries currently generated from all metric instances.", labelNames: [MetricTypeDebugLabel]); + + _metricFamiliesPerType = []; + _metricInstancesPerType = []; + _metricTimeseriesPerType = []; + + foreach (MetricType type in Enum.GetValues(typeof(MetricType))) + { + var typeName = type.ToString().ToLowerInvariant(); + _metricFamiliesPerType[type] = _metricFamilies.WithLabels(typeName); + _metricInstancesPerType[type] = _metricInstances.WithLabels(typeName); + _metricTimeseriesPerType[type] = _metricTimeseries.WithLabels(typeName); } - private const string MetricTypeDebugLabel = "metric_type"; + _startedCollectingRegistryMetrics.SetResult(true); + } + + /// + /// Registers a callback to be called when registry debug metrics are enabled. + /// If the debug metrics have already been enabled, the callback is called immediately. + /// + internal void OnStartCollectingRegistryMetrics(Action callback) + { + _startedCollectingRegistryMetrics.Task.ContinueWith(delegate + { + callback(); + return Task.CompletedTask; + }); + } + + private readonly TaskCompletionSource _startedCollectingRegistryMetrics = new(); - private Gauge? _metricFamilies; - private Gauge? _metricInstances; - private Gauge? _metricTimeseries; + private const string MetricTypeDebugLabel = "metric_type"; - private Dictionary? _metricFamiliesPerType; - private Dictionary? _metricInstancesPerType; - private Dictionary? _metricTimeseriesPerType; + private Gauge? _metricFamilies; + private Gauge? _metricInstances; + private Gauge? _metricTimeseries; - private void UpdateRegistryMetrics() + private Dictionary? _metricFamiliesPerType; + private Dictionary? _metricInstancesPerType; + private Dictionary? _metricTimeseriesPerType; + + private void UpdateRegistryMetrics() + { + if (_metricFamiliesPerType == null || _metricInstancesPerType == null || _metricTimeseriesPerType == null) + return; // Debug metrics are not enabled. + + // We copy references to the metric families to a temporary buffer to avoid having to hold locks to keep the collection consistent. + CollectorFamily[] familiesBuffer; + + _familiesLock.EnterReadLock(); + + var familiesCount = _families.Count; + familiesBuffer = ArrayPool.Shared.Rent(familiesCount); + + try { - if (_metricFamiliesPerType == null ||_metricInstancesPerType == null || _metricTimeseriesPerType == null) - return; // Debug metrics are not enabled. + try + { + _families.Values.CopyTo(familiesBuffer, 0); + } + finally + { + _familiesLock.ExitReadLock(); + } foreach (MetricType type in Enum.GetValues(typeof(MetricType))) { @@ -324,11 +411,24 @@ private void UpdateRegistryMetrics() long instances = 0; long timeseries = 0; - foreach (var collector in _collectors.Values.Where(c => c.Type == type)) + for (var i = 0; i < familiesCount; i++) { - families++; - instances += collector.ChildCount; - timeseries += collector.TimeseriesCount; + var family = familiesBuffer[i]; + + bool hadMatchingType = false; + + family.ForEachCollector(collector => + { + if (collector.Type != type) + return; + + hadMatchingType = true; + instances += collector.ChildCount; + timeseries += collector.TimeseriesCount; + }); + + if (hadMatchingType) + families++; } _metricFamiliesPerType[type].Set(families); @@ -336,5 +436,14 @@ private void UpdateRegistryMetrics() _metricTimeseriesPerType[type].Set(timeseries); } } + finally + { + ArrayPool.Shared.Return(familiesBuffer, clearArray: true); + } } + + // We only allow integration adapters to be started once per registry with the default configuration, to prevent double-counting values. + // This is useful because we switched on adapters by default in 7.0.0 but if someone has manual .StartListening() calls from before, they would now count metrics double. + internal bool PreventMeterAdapterRegistrationWithDefaultOptions; + internal bool PreventEventCounterAdapterRegistrationWithDefaultOptions; } diff --git a/Prometheus/Counter.cs b/Prometheus/Counter.cs index e35c477a..bbcfa3ca 100644 --- a/Prometheus/Counter.cs +++ b/Prometheus/Counter.cs @@ -1,61 +1,94 @@ -namespace Prometheus +using System.Runtime.CompilerServices; + +namespace Prometheus; + +public sealed class Counter : Collector, ICounter { - public sealed class Counter : Collector, ICounter + public sealed class Child : ChildBase, ICounter { - public sealed class Child : ChildBase, ICounter + internal Child(Collector parent, LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish, ExemplarBehavior exemplarBehavior) + : base(parent, instanceLabels, flattenedLabels, publish, exemplarBehavior) { - internal Child(Collector parent, LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish) - : base(parent, instanceLabels, flattenedLabels, publish) - { - _identifier = CreateIdentifier(); - } - - private readonly byte[] _identifier; + } - private ThreadSafeDouble _value; + private ThreadSafeDouble _value; + private ObservedExemplar _observedExemplar = ObservedExemplar.Empty; - private protected override Task CollectAndSerializeImplAsync(IMetricsSerializer serializer, CancellationToken cancel) - { - return serializer.WriteMetricAsync(_identifier, Value, cancel); - } +#if NET + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] +#endif + private protected override async ValueTask CollectAndSerializeImplAsync(IMetricsSerializer serializer, CancellationToken cancel) + { + var exemplar = BorrowExemplar(ref _observedExemplar); - public void Inc(double increment = 1.0) - { - if (increment < 0.0) - throw new ArgumentOutOfRangeException(nameof(increment), "Counter value cannot decrease."); + await serializer.WriteMetricPointAsync( + Parent.NameBytes, + FlattenedLabelsBytes, + CanonicalLabel.Empty, + Value, + exemplar, + null, + cancel); - _value.Add(increment); - Publish(); - } + ReturnBorrowedExemplar(ref _observedExemplar, exemplar); + } - public void IncTo(double targetValue) - { - _value.IncrementTo(targetValue); - Publish(); - } + public void Inc(double increment = 1.0) + { + Inc(increment: increment, null); + } - public double Value => _value.Value; + public void Inc(Exemplar? exemplar) + { + Inc(increment: 1, exemplar: exemplar); } - private protected override Child NewChild(LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish) + public void Inc(double increment, Exemplar? exemplar) { - return new Child(this, instanceLabels, flattenedLabels, publish); + if (increment < 0.0) + throw new ArgumentOutOfRangeException(nameof(increment), "Counter value cannot decrease."); + + exemplar ??= GetDefaultExemplar(increment); + + if (exemplar?.Length > 0) + RecordExemplar(exemplar, ref _observedExemplar, increment); + + _value.Add(increment); + + Publish(); } - internal Counter(string name, string help, StringSequence instanceLabelNames, LabelSequence staticLabels, bool suppressInitialValue) - : base(name, help, instanceLabelNames, staticLabels, suppressInitialValue) + public void IncTo(double targetValue) { + _value.IncrementTo(targetValue); + Publish(); } - public void Inc(double increment = 1) => Unlabelled.Inc(increment); - public void IncTo(double targetValue) => Unlabelled.IncTo(targetValue); - public double Value => Unlabelled.Value; + public double Value => _value.Value; + } - public void Publish() => Unlabelled.Publish(); - public void Unpublish() => Unlabelled.Unpublish(); - internal override MetricType Type => MetricType.Counter; + private protected override Child NewChild(LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish, ExemplarBehavior exemplarBehavior) + { + return new Child(this, instanceLabels, flattenedLabels, publish, exemplarBehavior); + } - internal override int TimeseriesCount => ChildCount; + internal Counter(string name, string help, StringSequence instanceLabelNames, LabelSequence staticLabels, bool suppressInitialValue, ExemplarBehavior exemplarBehavior) + : base(name, help, instanceLabelNames, staticLabels, suppressInitialValue, exemplarBehavior) + { } + + public void Inc(double increment = 1.0) => Unlabelled.Inc(increment); + public void IncTo(double targetValue) => Unlabelled.IncTo(targetValue); + public double Value => Unlabelled.Value; + + public void Publish() => Unlabelled.Publish(); + public void Unpublish() => Unlabelled.Unpublish(); + + public void Inc(Exemplar? exemplar) => Inc(increment: 1, exemplar: exemplar); + public void Inc(double increment, Exemplar? exemplar) => Unlabelled.Inc(increment, exemplar); + + internal override MetricType Type => MetricType.Counter; + + internal override int TimeseriesCount => ChildCount; } \ No newline at end of file diff --git a/Prometheus/CounterConfiguration.cs b/Prometheus/CounterConfiguration.cs index 2cbc42b7..42f713ca 100644 --- a/Prometheus/CounterConfiguration.cs +++ b/Prometheus/CounterConfiguration.cs @@ -1,7 +1,12 @@ -namespace Prometheus +namespace Prometheus; + +public sealed class CounterConfiguration : MetricConfiguration { - public sealed class CounterConfiguration : MetricConfiguration - { - internal static readonly CounterConfiguration Default = new CounterConfiguration(); - } + internal static readonly CounterConfiguration Default = new(); + + /// + /// Allows you to configure how exemplars are applied to the published metric. + /// If null, inherits the exemplar behavior from the metric factory. + /// + public ExemplarBehavior? ExemplarBehavior { get; set; } } diff --git a/Prometheus/CounterExtensions.cs b/Prometheus/CounterExtensions.cs index ad788458..9f5bbafa 100644 --- a/Prometheus/CounterExtensions.cs +++ b/Prometheus/CounterExtensions.cs @@ -1,117 +1,116 @@ -namespace Prometheus +namespace Prometheus; + +public static class CounterExtensions { - public static class CounterExtensions + /// + /// Increments the value of the counter to the current UTC time as a Unix timestamp in seconds. + /// Value does not include any elapsed leap seconds because Unix timestamps do not include leap seconds. + /// Operation is ignored if the current value is already greater. + /// + public static void IncToCurrentTimeUtc(this ICounter counter) + { + counter.IncTo(LowGranularityTimeSource.GetSecondsFromUnixEpoch()); + } + + /// + /// Increments the value of the counter to a specific moment as the UTC Unix timestamp in seconds. + /// Value does not include any elapsed leap seconds because Unix timestamps do not include leap seconds. + /// Operation is ignored if the current value is already greater. + /// + public static void IncToTimeUtc(this ICounter counter, DateTimeOffset timestamp) { - /// - /// Increments the value of the counter to the current UTC time as a Unix timestamp in seconds. - /// Value does not include any elapsed leap seconds because Unix timestamps do not include leap seconds. - /// Operation is ignored if the current value is already greater. - /// - public static void IncToCurrentTimeUtc(this ICounter counter) + counter.IncTo(TimestampHelpers.ToUnixTimeSecondsAsDouble(timestamp)); + } + + /// + /// Executes the provided operation and increments the counter if an exception occurs. The exception is re-thrown. + /// If an exception filter is specified, only counts exceptions for which the filter returns true. + /// + public static void CountExceptions(this ICounter counter, Action wrapped, Func? exceptionFilter = null) + { + if (counter == null) + throw new ArgumentNullException(nameof(counter)); + + if (wrapped == null) + throw new ArgumentNullException(nameof(wrapped)); + + try { - counter.IncTo(TimestampHelpers.ToUnixTimeSecondsAsDouble(DateTimeOffset.UtcNow)); + wrapped(); } - - /// - /// Increments the value of the counter to a specific moment as the UTC Unix timestamp in seconds. - /// Value does not include any elapsed leap seconds because Unix timestamps do not include leap seconds. - /// Operation is ignored if the current value is already greater. - /// - public static void IncToTimeUtc(this ICounter counter, DateTimeOffset timestamp) + catch (Exception ex) when (exceptionFilter == null || exceptionFilter(ex)) { - counter.IncTo(TimestampHelpers.ToUnixTimeSecondsAsDouble(timestamp)); + counter.Inc(); + throw; } + } - /// - /// Executes the provided operation and increments the counter if an exception occurs. The exception is re-thrown. - /// If an exception filter is specified, only counts exceptions for which the filter returns true. - /// - public static void CountExceptions(this ICounter counter, Action wrapped, Func? exceptionFilter = null) - { - if (counter == null) - throw new ArgumentNullException(nameof(counter)); + /// + /// Executes the provided operation and increments the counter if an exception occurs. The exception is re-thrown. + /// If an exception filter is specified, only counts exceptions for which the filter returns true. + /// + public static TResult CountExceptions(this ICounter counter, Func wrapped, Func? exceptionFilter = null) + { + if (counter == null) + throw new ArgumentNullException(nameof(counter)); - if (wrapped == null) - throw new ArgumentNullException(nameof(wrapped)); + if (wrapped == null) + throw new ArgumentNullException(nameof(wrapped)); - try - { - wrapped(); - } - catch (Exception ex) when (exceptionFilter == null || exceptionFilter(ex)) - { - counter.Inc(); - throw; - } + try + { + return wrapped(); } - - /// - /// Executes the provided operation and increments the counter if an exception occurs. The exception is re-thrown. - /// If an exception filter is specified, only counts exceptions for which the filter returns true. - /// - public static TResult CountExceptions(this ICounter counter, Func wrapped, Func? exceptionFilter = null) + catch (Exception ex) when (exceptionFilter == null || exceptionFilter(ex)) { - if (counter == null) - throw new ArgumentNullException(nameof(counter)); - - if (wrapped == null) - throw new ArgumentNullException(nameof(wrapped)); - - try - { - return wrapped(); - } - catch (Exception ex) when (exceptionFilter == null || exceptionFilter(ex)) - { - counter.Inc(); - throw; - } + counter.Inc(); + throw; } + } - /// - /// Executes the provided async operation and increments the counter if an exception occurs. The exception is re-thrown. - /// If an exception filter is specified, only counts exceptions for which the filter returns true. - /// - public static async Task CountExceptionsAsync(this ICounter counter, Func wrapped, Func? exceptionFilter = null) - { - if (counter == null) - throw new ArgumentNullException(nameof(counter)); + /// + /// Executes the provided async operation and increments the counter if an exception occurs. The exception is re-thrown. + /// If an exception filter is specified, only counts exceptions for which the filter returns true. + /// + public static async Task CountExceptionsAsync(this ICounter counter, Func wrapped, Func? exceptionFilter = null) + { + if (counter == null) + throw new ArgumentNullException(nameof(counter)); - if (wrapped == null) - throw new ArgumentNullException(nameof(wrapped)); + if (wrapped == null) + throw new ArgumentNullException(nameof(wrapped)); - try - { - await wrapped().ConfigureAwait(false); - } - catch (Exception ex) when (exceptionFilter == null || exceptionFilter(ex)) - { - counter.Inc(); - throw; - } + try + { + await wrapped().ConfigureAwait(false); } - - /// - /// Executes the provided async operation and increments the counter if an exception occurs. The exception is re-thrown. - /// If an exception filter is specified, only counts exceptions for which the filter returns true. - /// - public static async Task CountExceptionsAsync(this ICounter counter, Func> wrapped, Func? exceptionFilter = null) + catch (Exception ex) when (exceptionFilter == null || exceptionFilter(ex)) { - if (counter == null) - throw new ArgumentNullException(nameof(counter)); + counter.Inc(); + throw; + } + } + + /// + /// Executes the provided async operation and increments the counter if an exception occurs. The exception is re-thrown. + /// If an exception filter is specified, only counts exceptions for which the filter returns true. + /// + public static async Task CountExceptionsAsync(this ICounter counter, Func> wrapped, Func? exceptionFilter = null) + { + if (counter == null) + throw new ArgumentNullException(nameof(counter)); - if (wrapped == null) - throw new ArgumentNullException(nameof(wrapped)); + if (wrapped == null) + throw new ArgumentNullException(nameof(wrapped)); - try - { - return await wrapped().ConfigureAwait(false); - } - catch (Exception ex) when (exceptionFilter == null || exceptionFilter(ex)) - { - counter.Inc(); - throw; - } + try + { + return await wrapped().ConfigureAwait(false); + } + catch (Exception ex) when (exceptionFilter == null || exceptionFilter(ex)) + { + counter.Inc(); + throw; } } } diff --git a/Prometheus/DelegatingStreamInternal.cs b/Prometheus/DelegatingStreamInternal.cs index f61759bc..1b8a6121 100644 --- a/Prometheus/DelegatingStreamInternal.cs +++ b/Prometheus/DelegatingStreamInternal.cs @@ -5,148 +5,147 @@ #nullable enable -namespace Prometheus +namespace Prometheus; + +// Forwards all calls to an inner stream except where overridden in a derived class. +internal abstract class DelegatingStreamInternal : Stream { - // Forwards all calls to an inner stream except where overridden in a derived class. - internal abstract class DelegatingStreamInternal : Stream - { - private readonly Stream _innerStream; + private readonly Stream _innerStream; - #region Properties + #region Properties - public override bool CanRead - { - get { return _innerStream.CanRead; } - } + public override bool CanRead + { + get { return _innerStream.CanRead; } + } - public override bool CanSeek - { - get { return _innerStream.CanSeek; } - } + public override bool CanSeek + { + get { return _innerStream.CanSeek; } + } - public override bool CanWrite - { - get { return _innerStream.CanWrite; } - } + public override bool CanWrite + { + get { return _innerStream.CanWrite; } + } - public override long Length - { - get { return _innerStream.Length; } - } + public override long Length + { + get { return _innerStream.Length; } + } - public override long Position - { - get { return _innerStream.Position; } - set { _innerStream.Position = value; } - } + public override long Position + { + get { return _innerStream.Position; } + set { _innerStream.Position = value; } + } - public override int ReadTimeout - { - get { return _innerStream.ReadTimeout; } - set { _innerStream.ReadTimeout = value; } - } + public override int ReadTimeout + { + get { return _innerStream.ReadTimeout; } + set { _innerStream.ReadTimeout = value; } + } - public override bool CanTimeout - { - get { return _innerStream.CanTimeout; } - } + public override bool CanTimeout + { + get { return _innerStream.CanTimeout; } + } - public override int WriteTimeout - { - get { return _innerStream.WriteTimeout; } - set { _innerStream.WriteTimeout = value; } - } + public override int WriteTimeout + { + get { return _innerStream.WriteTimeout; } + set { _innerStream.WriteTimeout = value; } + } - #endregion Properties + #endregion Properties - protected DelegatingStreamInternal(Stream innerStream) - { - _innerStream = innerStream; - } + protected DelegatingStreamInternal(Stream innerStream) + { + _innerStream = innerStream; + } - protected override void Dispose(bool disposing) + protected override void Dispose(bool disposing) + { + if (disposing) { - if (disposing) - { - _innerStream.Dispose(); - } - base.Dispose(disposing); + _innerStream.Dispose(); } + base.Dispose(disposing); + } - public override long Seek(long offset, SeekOrigin origin) - { - return _innerStream.Seek(offset, origin); - } + public override long Seek(long offset, SeekOrigin origin) + { + return _innerStream.Seek(offset, origin); + } - public override int Read(byte[] buffer, int offset, int count) - { - return _innerStream.Read(buffer, offset, count); - } + public override int Read(byte[] buffer, int offset, int count) + { + return _innerStream.Read(buffer, offset, count); + } - public override int ReadByte() - { - return _innerStream.ReadByte(); - } + public override int ReadByte() + { + return _innerStream.ReadByte(); + } - public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - return _innerStream.ReadAsync(buffer, offset, count, cancellationToken); - } - public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) - { - return _innerStream.BeginRead(buffer, offset, count, callback, state); - } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _innerStream.ReadAsync(buffer, offset, count, cancellationToken); + } + public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) + { + return _innerStream.BeginRead(buffer, offset, count, callback, state); + } - public override int EndRead(IAsyncResult asyncResult) - { - return _innerStream.EndRead(asyncResult); - } + public override int EndRead(IAsyncResult asyncResult) + { + return _innerStream.EndRead(asyncResult); + } - public override void Flush() - { - _innerStream.Flush(); - } + public override void Flush() + { + _innerStream.Flush(); + } - public override Task FlushAsync(CancellationToken cancellationToken) - { - return _innerStream.FlushAsync(cancellationToken); - } + public override Task FlushAsync(CancellationToken cancellationToken) + { + return _innerStream.FlushAsync(cancellationToken); + } - public override void SetLength(long value) - { - _innerStream.SetLength(value); - } + public override void SetLength(long value) + { + _innerStream.SetLength(value); + } - public override void Write(byte[] buffer, int offset, int count) - { - _innerStream.Write(buffer, offset, count); - } + public override void Write(byte[] buffer, int offset, int count) + { + _innerStream.Write(buffer, offset, count); + } - public override void WriteByte(byte value) - { - _innerStream.WriteByte(value); - } + public override void WriteByte(byte value) + { + _innerStream.WriteByte(value); + } - public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - return _innerStream.WriteAsync(buffer, offset, count, cancellationToken); - } + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _innerStream.WriteAsync(buffer, offset, count, cancellationToken); + } - public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) - { - return _innerStream.BeginWrite(buffer, offset, count, callback, state); - } + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) + { + return _innerStream.BeginWrite(buffer, offset, count, callback, state); + } - public override void EndWrite(IAsyncResult asyncResult) - { - _innerStream.EndWrite(asyncResult); - } + public override void EndWrite(IAsyncResult asyncResult) + { + _innerStream.EndWrite(asyncResult); + } - public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) - { - return _innerStream.CopyToAsync(destination, bufferSize, cancellationToken); - } + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + return _innerStream.CopyToAsync(destination, bufferSize, cancellationToken); } } diff --git a/Prometheus/DiagnosticSourceAdapter.cs b/Prometheus/DiagnosticSourceAdapter.cs index fcbcfb9f..483482e4 100644 --- a/Prometheus/DiagnosticSourceAdapter.cs +++ b/Prometheus/DiagnosticSourceAdapter.cs @@ -1,131 +1,118 @@ -using System.Diagnostics; - -namespace Prometheus +#if NET +using System.Diagnostics; + +namespace Prometheus; + +/// +/// Monitors all DiagnosticSource events and exposes them as Prometheus counters. +/// The event data is discarded, only the number of occurrences is measured. +/// +/// +/// This is a very coarse data set due to lacking any intelligence on the payload. +/// Users are recommended to make custom adapters with more detail for specific use cases. +/// +public sealed class DiagnosticSourceAdapter : IDisposable { /// - /// Monitors all DiagnosticSource events and exposes them as Prometheus counters. - /// The event data is discarded, only the number of occurrences is measured. + /// Starts listening for DiagnosticSource events and reporting them as Prometheus metrics. + /// Dispose of the return value to stop listening. + /// + public static IDisposable StartListening() => StartListening(DiagnosticSourceAdapterOptions.Default); + + /// + /// Starts listening for DiagnosticSource events and reporting them as Prometheus metrics. + /// Dispose of the return value to stop listening. /// - /// - /// This is a very coarse data set due to lacking any intelligence on the payload. - /// Users are recommended to make custom adapters with more detail for specific use cases. - /// - public sealed class DiagnosticSourceAdapter : IDisposable + public static IDisposable StartListening(DiagnosticSourceAdapterOptions options) => new DiagnosticSourceAdapter(options); + + private DiagnosticSourceAdapter(DiagnosticSourceAdapterOptions options) { - /// - /// Starts listening for DiagnosticSource events and reporting them as Prometheus metrics. - /// Dispose of the return value to stop listening. - /// - public static IDisposable StartListening() => StartListening(DiagnosticSourceAdapterOptions.Default); - - /// - /// Starts listening for DiagnosticSource events and reporting them as Prometheus metrics. - /// Dispose of the return value to stop listening. - /// - public static IDisposable StartListening(DiagnosticSourceAdapterOptions options) => new DiagnosticSourceAdapter(options); - - private DiagnosticSourceAdapter(DiagnosticSourceAdapterOptions options) - { - _options = options; - _metric = Metrics.WithCustomRegistry(options.Registry) - .CreateCounter("diagnostic_events_total", "Total count of events received via the DiagnosticSource infrastructure.", labelNames: new[] - { - "source", // Name of the DiagnosticSource - "event" // Name of the event - }); - - var newListenerObserver = new NewListenerObserver(OnNewListener); - _newListenerSubscription = DiagnosticListener.AllListeners.Subscribe(newListenerObserver); - } + _options = options; + _metric = Metrics.WithCustomRegistry(options.Registry) + .CreateCounter("diagnostic_events_total", "Total count of events received via the DiagnosticSource infrastructure.", labelNames: new[] + { + "source", // Name of the DiagnosticSource + "event" // Name of the event + }); + + var newListenerObserver = new NewListenerObserver(OnNewListener); + _newListenerSubscription = DiagnosticListener.AllListeners.Subscribe(newListenerObserver); + } - private readonly DiagnosticSourceAdapterOptions _options; - private readonly Counter _metric; + private readonly DiagnosticSourceAdapterOptions _options; + private readonly Counter _metric; - private readonly IDisposable _newListenerSubscription; + private readonly IDisposable _newListenerSubscription; - // listener name -> subscription - private readonly Dictionary _newEventSubscription = new Dictionary(); - private readonly object _newEventSubscriptionLock = new object(); + // listener name -> subscription + private readonly Dictionary _newEventSubscription = new Dictionary(); + private readonly object _newEventSubscriptionLock = new object(); - private void OnNewListener(DiagnosticListener listener) + private void OnNewListener(DiagnosticListener listener) + { + lock (_newEventSubscriptionLock) { - lock (_newEventSubscriptionLock) + if (_newEventSubscription.TryGetValue(listener.Name, out var oldSubscription)) { - if (_newEventSubscription.TryGetValue(listener.Name, out var oldSubscription)) - { - oldSubscription.Dispose(); - _newEventSubscription.Remove(listener.Name); - } - - if (!_options.ListenerFilterPredicate(listener)) - return; - - var listenerName = listener.Name; - var newEventObserver = new NewEventObserver(kvp => OnEvent(listenerName, kvp.Key, kvp.Value)); - _newEventSubscription[listenerName] = listener.Subscribe(newEventObserver); + oldSubscription.Dispose(); + _newEventSubscription.Remove(listener.Name); } + + if (!_options.ListenerFilterPredicate(listener)) + return; + + var listenerName = listener.Name; + var newEventObserver = new NewEventObserver(kvp => OnEvent(listenerName, kvp.Key, kvp.Value)); + _newEventSubscription[listenerName] = listener.Subscribe(newEventObserver); } + } - private void OnEvent(string listenerName, string eventName, object? payload) + private void OnEvent(string listenerName, string eventName, object? payload) + { + _metric.WithLabels(listenerName, eventName).Inc(); + } + + private sealed class NewListenerObserver(Action onNewListener) : IObserver + { + public void OnCompleted() { - _metric.WithLabels(listenerName, eventName).Inc(); } - private sealed class NewListenerObserver : IObserver + public void OnError(Exception error) { - private readonly Action _onNewListener; - - public NewListenerObserver(Action onNewListener) - { - _onNewListener = onNewListener; - } - - public void OnCompleted() - { - } - - public void OnError(Exception error) - { - } - - public void OnNext(DiagnosticListener listener) - { - _onNewListener(listener); - } } - private sealed class NewEventObserver : IObserver> + public void OnNext(DiagnosticListener listener) { - private readonly Action> _onEvent; - - public NewEventObserver(Action> onEvent) - { - _onEvent = onEvent; - } - - public void OnCompleted() - { - } + onNewListener(listener); + } + } - public void OnError(Exception error) - { - } + private sealed class NewEventObserver(Action> onEvent) : IObserver> + { + public void OnCompleted() + { + } - public void OnNext(KeyValuePair receivedEvent) - { - _onEvent(receivedEvent); - } + public void OnError(Exception error) + { } - public void Dispose() + public void OnNext(KeyValuePair receivedEvent) { - _newListenerSubscription.Dispose(); + onEvent(receivedEvent); + } + } - lock (_newEventSubscriptionLock) - { - foreach (var subscription in _newEventSubscription.Values) - subscription.Dispose(); - } + public void Dispose() + { + _newListenerSubscription.Dispose(); + + lock (_newEventSubscriptionLock) + { + foreach (var subscription in _newEventSubscription.Values) + subscription.Dispose(); } } } +#endif \ No newline at end of file diff --git a/Prometheus/DiagnosticSourceAdapterOptions.cs b/Prometheus/DiagnosticSourceAdapterOptions.cs index 089c1cc0..b86ecda8 100644 --- a/Prometheus/DiagnosticSourceAdapterOptions.cs +++ b/Prometheus/DiagnosticSourceAdapterOptions.cs @@ -1,16 +1,17 @@ -using System.Diagnostics; +#if NET +using System.Diagnostics; -namespace Prometheus +namespace Prometheus; + +public sealed class DiagnosticSourceAdapterOptions { - public sealed class DiagnosticSourceAdapterOptions - { - internal static readonly DiagnosticSourceAdapterOptions Default = new DiagnosticSourceAdapterOptions(); + internal static readonly DiagnosticSourceAdapterOptions Default = new(); - /// - /// By default we subscribe to all listeners but this allows you to filter by listener. - /// - public Func ListenerFilterPredicate = _ => true; + /// + /// By default we subscribe to all listeners but this allows you to filter by listener. + /// + public Func ListenerFilterPredicate = _ => true; - public CollectorRegistry Registry = Metrics.DefaultRegistry; - } + public CollectorRegistry Registry = Metrics.DefaultRegistry; } +#endif \ No newline at end of file diff --git a/Prometheus/DotNetStats.cs b/Prometheus/DotNetStats.cs index 8fd34167..1de75eab 100644 --- a/Prometheus/DotNetStats.cs +++ b/Prometheus/DotNetStats.cs @@ -1,95 +1,98 @@ using System.Diagnostics; -namespace Prometheus +namespace Prometheus; + +/// +/// Collects basic .NET metrics about the current process. This is not meant to be an especially serious collector, +/// more of a producer of sample data so users of the library see something when they install it. +/// +public sealed class DotNetStats { /// - /// Collects basic .NET metrics about the current process. This is not meant to be an especially serious collector, - /// more of a producer of sample data so users of the library see something when they install it. + /// Registers the .NET metrics in the specified registry. /// - public sealed class DotNetStats + public static void Register(CollectorRegistry registry) { - /// - /// Registers the .NET metrics in the specified registry. - /// - public static void Register(CollectorRegistry registry) - { - var instance = new DotNetStats(registry); - registry.AddBeforeCollectCallback(instance.UpdateMetrics); - } + var instance = new DotNetStats(Metrics.WithCustomRegistry(registry)); + registry.AddBeforeCollectCallback(instance.UpdateMetrics); + } - private readonly Process _process; - private readonly List _collectionCounts = new List(); - private Gauge _totalMemory; - private Gauge _virtualMemorySize; - private Gauge _workingSet; - private Gauge _privateMemorySize; - private Counter _cpuTotal; - private Gauge _openHandles; - private Gauge _startTime; - private Gauge _numThreads; + /// + /// Registers the .NET metrics in the default metrics factory and registry. + /// + internal static void RegisterDefault() + { + var instance = new DotNetStats(Metrics.DefaultFactory); + Metrics.DefaultRegistry.AddBeforeCollectCallback(instance.UpdateMetrics); + } - private DotNetStats(CollectorRegistry registry) - { - _process = Process.GetCurrentProcess(); - var metrics = Metrics.WithCustomRegistry(registry); + private readonly Process _process; + private readonly List _collectionCounts = new List(); + private Gauge _totalMemory; + private Gauge _virtualMemorySize; + private Gauge _workingSet; + private Gauge _privateMemorySize; + private Counter _cpuTotal; + private Gauge _openHandles; + private Gauge _startTime; + private Gauge _numThreads; - var collectionCountsParent = metrics.CreateCounter("dotnet_collection_count_total", "GC collection count", new[] { "generation" }); + private DotNetStats(IMetricFactory metricFactory) + { + _process = Process.GetCurrentProcess(); - for (var gen = 0; gen <= GC.MaxGeneration; gen++) - { - _collectionCounts.Add(collectionCountsParent.Labels(gen.ToString())); - } + var collectionCountsParent = metricFactory.CreateCounter("dotnet_collection_count_total", "GC collection count", new[] { "generation" }); + + for (var gen = 0; gen <= GC.MaxGeneration; gen++) + { + _collectionCounts.Add(collectionCountsParent.Labels(gen.ToString())); + } - // Metrics that make sense to compare between all operating systems - // Note that old versions of pushgateway errored out if different metrics had same name but different help string. - // This is fixed in newer versions but keep the help text synchronized with the Go implementation just in case. - // See https://github.com/prometheus/pushgateway/issues/194 - // and https://github.com/prometheus-net/prometheus-net/issues/89 - _startTime = metrics.CreateGauge("process_start_time_seconds", "Start time of the process since unix epoch in seconds."); - _cpuTotal = metrics.CreateCounter("process_cpu_seconds_total", "Total user and system CPU time spent in seconds."); + // Metrics that make sense to compare between all operating systems + // Note that old versions of pushgateway errored out if different metrics had same name but different help string. + // This is fixed in newer versions but keep the help text synchronized with the Go implementation just in case. + // See https://github.com/prometheus/pushgateway/issues/194 + // and https://github.com/prometheus-net/prometheus-net/issues/89 + _startTime = metricFactory.CreateGauge("process_start_time_seconds", "Start time of the process since unix epoch in seconds."); + _cpuTotal = metricFactory.CreateCounter("process_cpu_seconds_total", "Total user and system CPU time spent in seconds."); - _virtualMemorySize = metrics.CreateGauge("process_virtual_memory_bytes", "Virtual memory size in bytes."); - _workingSet = metrics.CreateGauge("process_working_set_bytes", "Process working set"); - _privateMemorySize = metrics.CreateGauge("process_private_memory_bytes", "Process private memory size"); - _openHandles = metrics.CreateGauge("process_open_handles", "Number of open handles"); - _numThreads = metrics.CreateGauge("process_num_threads", "Total number of threads"); + _virtualMemorySize = metricFactory.CreateGauge("process_virtual_memory_bytes", "Virtual memory size in bytes."); + _workingSet = metricFactory.CreateGauge("process_working_set_bytes", "Process working set"); + _privateMemorySize = metricFactory.CreateGauge("process_private_memory_bytes", "Process private memory size"); + _openHandles = metricFactory.CreateGauge("process_open_handles", "Number of open handles"); + _numThreads = metricFactory.CreateGauge("process_num_threads", "Total number of threads"); - // .net specific metrics - _totalMemory = metrics.CreateGauge("dotnet_total_memory_bytes", "Total known allocated memory"); + // .net specific metrics + _totalMemory = metricFactory.CreateGauge("dotnet_total_memory_bytes", "Total known allocated memory"); - var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - _startTime.Set((_process.StartTime.ToUniversalTime() - epoch).TotalSeconds); - } + _startTime.SetToTimeUtc(_process.StartTime); + } - // The Process class is not thread-safe so let's synchronize the updates to avoid data tearing. - private readonly object _updateLock = new object(); + // The Process class is not thread-safe so let's synchronize the updates to avoid data tearing. + private readonly object _updateLock = new object(); - private void UpdateMetrics() + private void UpdateMetrics() + { + try { - try + lock (_updateLock) { - lock (_updateLock) - { - _process.Refresh(); + _process.Refresh(); - for (var gen = 0; gen <= GC.MaxGeneration; gen++) - { - var collectionCount = _collectionCounts[gen]; - collectionCount.Inc(GC.CollectionCount(gen) - collectionCount.Value); - } + for (var gen = 0; gen <= GC.MaxGeneration; gen++) + _collectionCounts[gen].IncTo(GC.CollectionCount(gen)); - _totalMemory.Set(GC.GetTotalMemory(false)); - _virtualMemorySize.Set(_process.VirtualMemorySize64); - _workingSet.Set(_process.WorkingSet64); - _privateMemorySize.Set(_process.PrivateMemorySize64); - _cpuTotal.Inc(Math.Max(0, _process.TotalProcessorTime.TotalSeconds - _cpuTotal.Value)); - _openHandles.Set(_process.HandleCount); - _numThreads.Set(_process.Threads.Count); - } - } - catch (Exception) - { + _totalMemory.Set(GC.GetTotalMemory(false)); + _virtualMemorySize.Set(_process.VirtualMemorySize64); + _workingSet.Set(_process.WorkingSet64); + _privateMemorySize.Set(_process.PrivateMemorySize64); + _cpuTotal.IncTo(_process.TotalProcessorTime.TotalSeconds); + _openHandles.Set(_process.HandleCount); + _numThreads.Set(_process.Threads.Count); } } + catch (Exception) + { + } } } \ No newline at end of file diff --git a/Prometheus/EventCounterAdapter.cs b/Prometheus/EventCounterAdapter.cs index b734e16c..78113998 100644 --- a/Prometheus/EventCounterAdapter.cs +++ b/Prometheus/EventCounterAdapter.cs @@ -1,211 +1,238 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.Tracing; +using System.Globalization; -namespace Prometheus +namespace Prometheus; + +/// +/// Monitors .NET EventCounters and exposes them as Prometheus metrics. +/// +/// +/// All observed .NET event counters are transformed into Prometheus metrics with translated names. +/// +public sealed class EventCounterAdapter : IDisposable { - /// - /// Monitors all .NET EventCounters and exposes them as Prometheus metrics. - /// - /// - /// All .NET event counters are transformed into Prometheus metrics with translated names. - /// - /// There appear to be different types of "incrementing" counters in .NET: - /// * some "incrementing" counters publish the increment value ("+5") - /// * some "incrementing" counters are just gauges that publish a "current" value - /// - /// It is not possible for us to really determine which is which, so for incrementing event counters we publish both a gauge (with latest value) and counter (with total value). - /// Which one you use for which .NET event counter depends on how the authors of the event counter made it - one of them will be wrong! - /// - public sealed class EventCounterAdapter : IDisposable + public static IDisposable StartListening() => StartListening(EventCounterAdapterOptions.Default); + + public static IDisposable StartListening(EventCounterAdapterOptions options) { - public static IDisposable StartListening() => new EventCounterAdapter(EventCounterAdapterOptions.Default); + // If we are re-registering an adapter with the default options, just pretend and move on. + // The purpose of this code is to avoid double-counting metrics if the adapter is registered twice with the default options. + // This could happen because in 7.0.0 we added automatic registration of the adapters on startup, but the user might still + // have a manual registration active from 6.0.0 days. We do this small thing here to make upgrading less hassle. + if (options == EventCounterAdapterOptions.Default) + { + if (options.Registry.PreventEventCounterAdapterRegistrationWithDefaultOptions) + return new NoopDisposable(); - public static IDisposable StartListening(EventCounterAdapterOptions options) => new EventCounterAdapter(options); + options.Registry.PreventEventCounterAdapterRegistrationWithDefaultOptions = true; + } - private EventCounterAdapter(EventCounterAdapterOptions options) - { - _options = options; - _metricFactory = Metrics.WithCustomRegistry(_options.Registry); + return new EventCounterAdapter(options); + } - _eventSourcesConnected = _metricFactory.CreateGauge("prometheus_net_eventcounteradapter_sources_connected_total", "Number of event sources that are currently connected to the adapter."); + private EventCounterAdapter(EventCounterAdapterOptions options) + { + _options = options; + _metricFactory = _options.MetricFactory ?? Metrics.WithCustomRegistry(_options.Registry); - _listener = new Listener(OnEventSourceCreated, ConfigureEventSource, OnEventWritten); - } + _eventSourcesConnected = _metricFactory.CreateGauge("prometheus_net_eventcounteradapter_sources_connected_total", "Number of event sources that are currently connected to the adapter."); - public void Dispose() - { - // Disposal means we stop listening but we do not remove any published data just to keep things simple. - _listener.Dispose(); - } + EventCounterAdapterMemoryWarden.EnsureStarted(); - private readonly EventCounterAdapterOptions _options; - private readonly IMetricFactory _metricFactory; + _listener = new Listener(ShouldUseEventSource, ConfigureEventSource, options.UpdateInterval, OnEventWritten); + } - private readonly Listener _listener; + public void Dispose() + { + // Disposal means we stop listening but we do not remove any published data just to keep things simple. + _listener.Dispose(); + } - // We never decrease it in the current implementation but perhaps might in a future implementation, so might as well make it a counter. - private readonly Gauge _eventSourcesConnected; + private readonly EventCounterAdapterOptions _options; + private readonly IMetricFactory _metricFactory; - private bool OnEventSourceCreated(EventSource source) - { - bool connect = _options.EventSourceFilterPredicate(source.Name); + private readonly Listener _listener; - if (connect) - _eventSourcesConnected.Inc(); + // We never decrease it in the current implementation but perhaps might in a future implementation, so might as well make it a gauge. + private readonly Gauge _eventSourcesConnected; - return connect; - } + private bool ShouldUseEventSource(EventSource source) + { + bool connect = _options.EventSourceFilterPredicate(source.Name); - private EventCounterAdapterEventSourceSettings ConfigureEventSource(EventSource source) - { - return _options.EventSourceSettingsProvider(source.Name); - } + if (connect) + _eventSourcesConnected.Inc(); - private const string RateSuffix = "_rate"; + return connect; + } - private void OnEventWritten(EventWrittenEventArgs args) - { - // This deserialization here is pretty gnarly. - // We just skip anything that makes no sense. + private EventCounterAdapterEventSourceSettings ConfigureEventSource(EventSource source) + { + return _options.EventSourceSettingsProvider(source.Name); + } - try - { - if (args.EventName != "EventCounters") - return; // Do not know what it is and do not care. + private const string RateSuffix = "_rate"; - if (args.Payload == null) - return; // What? Whatever. + private void OnEventWritten(EventWrittenEventArgs args) + { + // This deserialization here is pretty gnarly. + // We just skip anything that makes no sense. - var eventSourceName = args.EventSource.Name; + try + { + if (args.EventName != "EventCounters") + return; // Do not know what it is and do not care. - foreach (var item in args.Payload) - { - if (item is not IDictionary e) - continue; + if (args.Payload == null) + return; // What? Whatever. - if (!e.TryGetValue("Name", out var nameWrapper)) - continue; + var eventSourceName = args.EventSource.Name; - var name = nameWrapper as string; + foreach (var item in args.Payload) + { + if (item is not IDictionary e) + continue; - if (name == null) - continue; // What? Whatever. + if (!e.TryGetValue("Name", out var nameWrapper)) + continue; - if (!e.TryGetValue("DisplayName", out var displayNameWrapper)) - continue; + var name = nameWrapper as string; - var displayName = displayNameWrapper as string ?? ""; + if (name == null) + continue; // What? Whatever. - // If there is a DisplayUnits, prefix it to the help text. - if (e.TryGetValue("DisplayUnits", out var displayUnitsWrapper) && !string.IsNullOrWhiteSpace(displayUnitsWrapper as string)) - displayName = $"({(string)displayUnitsWrapper}) {displayName}"; + if (!e.TryGetValue("DisplayName", out var displayNameWrapper)) + continue; - var mergedName = $"{eventSourceName}_{name}"; + var displayName = displayNameWrapper as string ?? ""; - var prometheusName = _counterPrometheusName.GetOrAdd(mergedName, PrometheusNameHelpers.TranslateNameToPrometheusName); + // If there is a DisplayUnits, prefix it to the help text. + if (e.TryGetValue("DisplayUnits", out var displayUnitsWrapper) && !string.IsNullOrWhiteSpace(displayUnitsWrapper as string)) + displayName = $"({(string)displayUnitsWrapper}) {displayName}"; - // The event counter can either be - // 1) an aggregating counter (in which case we use the mean); or - // 2) an incrementing counter (in which case we use the delta). + var mergedName = $"{eventSourceName}_{name}"; - if (e.TryGetValue("Increment", out var increment)) - { - // Looks like an incrementing counter. + var prometheusName = _counterPrometheusName.GetOrAdd(mergedName, PrometheusNameHelpers.TranslateNameToPrometheusName); - var value = increment as double?; + // The event counter can either be + // 1) an aggregating counter (in which case we use the mean); or + // 2) an incrementing counter (in which case we use the delta). - if (value == null) - continue; // What? Whatever. + if (e.TryGetValue("Increment", out var increment)) + { + // Looks like an incrementing counter. - // If the underlying metric is exposing a rate then this can result in some strange terminology like "rate_total". - // We will remove the "rate" from the name to be more understandable - you'll get the rate when you apply the Prometheus rate() function, the raw value is not the rate. - if (prometheusName.EndsWith(RateSuffix)) - prometheusName = prometheusName.Remove(prometheusName.Length - RateSuffix.Length); + var value = increment as double?; - _metricFactory.CreateCounter(prometheusName + "_total", displayName).Inc(value.Value); - } - else if (e.TryGetValue("Mean", out var mean)) - { - // Looks like an aggregating counter. + if (value == null) + continue; // What? Whatever. - var value = mean as double?; + // If the underlying metric is exposing a rate then this can result in some strange terminology like "rate_total". + // We will remove the "rate" from the name to be more understandable - you'll get the rate when you apply the Prometheus rate() function, the raw value is not the rate. + if (prometheusName.EndsWith(RateSuffix)) + prometheusName = prometheusName.Remove(prometheusName.Length - RateSuffix.Length); - if (value == null) - continue; // What? Whatever. + _metricFactory.CreateCounter(prometheusName + "_total", displayName).Inc(value.Value); + } + else if (e.TryGetValue("Mean", out var mean)) + { + // Looks like an aggregating counter. + + var value = mean as double?; - _metricFactory.CreateGauge(prometheusName, displayName).Set(value.Value); - } + if (value == null) + continue; // What? Whatever. + + _metricFactory.CreateGauge(prometheusName, displayName).Set(value.Value); } } - catch (Exception ex) - { - // We do not want to throw any exceptions if we fail to handle this event because who knows what it messes up upstream. - Trace.WriteLine($"Failed to parse EventCounter event: {ex.Message}"); - } } + catch (Exception ex) + { + // We do not want to throw any exceptions if we fail to handle this event because who knows what it messes up upstream. + Trace.WriteLine($"Failed to parse EventCounter event: {ex.Message}"); + } + } - // Source+Name -> Name - private readonly ConcurrentDictionary _counterPrometheusName = new(); + // Source+Name -> Name + private readonly ConcurrentDictionary _counterPrometheusName = new(); - private sealed class Listener : EventListener + private sealed class Listener : EventListener + { + public Listener( + Func shouldUseEventSource, + Func configureEventSosurce, + TimeSpan updateInterval, + Action onEventWritten) { - public Listener( - Func onEventSourceCreated, - Func configureEventSosurce, - Action onEventWritten) - { - _onEventSourceCreated = onEventSourceCreated; - _configureEventSosurce = configureEventSosurce; - _onEventWritten = onEventWritten; + _shouldUseEventSource = shouldUseEventSource; + _configureEventSosurce = configureEventSosurce; + _updateInterval = updateInterval; + _onEventWritten = onEventWritten; - foreach (var eventSource in _preRegisteredEventSources) - OnEventSourceCreated(eventSource); + foreach (var eventSource in _preRegisteredEventSources) + OnEventSourceCreated(eventSource); - _preRegisteredEventSources.Clear(); - } + _preRegisteredEventSources.Clear(); + } - private readonly List _preRegisteredEventSources = new List(); + private readonly List _preRegisteredEventSources = new List(); - private readonly Func _onEventSourceCreated; - private readonly Func _configureEventSosurce; - private readonly Action _onEventWritten; + private readonly Func _shouldUseEventSource; + private readonly Func _configureEventSosurce; + private readonly TimeSpan _updateInterval; + private readonly Action _onEventWritten; - protected override void OnEventSourceCreated(EventSource eventSource) + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (_shouldUseEventSource == null) { - if (_onEventSourceCreated == null) - { - // The way this EventListener thing works is rather strange. Immediately in the base class constructor, before we - // have even had time to wire up our subclass, it starts calling OnEventSourceCreated for all already-existing event sources... - // We just buffer those calls because CALM DOWN SIR! - _preRegisteredEventSources.Add(eventSource); - return; - } + // The way this EventListener thing works is rather strange. Immediately in the base class constructor, before we + // have even had time to wire up our subclass, it starts calling OnEventSourceCreated for all already-existing event sources... + // We just buffer those calls because CALM DOWN SIR! + _preRegisteredEventSources.Add(eventSource); + return; + } - if (!_onEventSourceCreated(eventSource)) - return; + if (!_shouldUseEventSource(eventSource)) + return; - try - { - var options = _configureEventSosurce(eventSource); + try + { + var options = _configureEventSosurce(eventSource); - EnableEvents(eventSource, options.MinimumLevel, options.MatchKeywords, new Dictionary() - { - ["EventCounterIntervalSec"] = "1" - }); - } - catch (Exception ex) + EnableEvents(eventSource, options.MinimumLevel, options.MatchKeywords, new Dictionary() { - // Eat exceptions here to ensure no harm comes of failed enabling. - // The EventCounter infrastructure has proven quite buggy and while it is not certain that this may throw, let's be paranoid. - Trace.WriteLine($"Failed to enable EventCounter listening for {eventSource.Name}: {ex.Message}"); - } + ["EventCounterIntervalSec"] = ((int)Math.Max(1, _updateInterval.TotalSeconds)).ToString(CultureInfo.InvariantCulture), + }); } - - protected override void OnEventWritten(EventWrittenEventArgs eventData) + catch (Exception ex) { - _onEventWritten(eventData); + // Eat exceptions here to ensure no harm comes of failed enabling. + // The EventCounter infrastructure has proven quite buggy and while it is not certain that this may throw, let's be paranoid. + Trace.WriteLine($"Failed to enable EventCounter listening for {eventSource.Name}: {ex.Message}"); } } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + _onEventWritten(eventData); + } } + + /// + /// By default we enable event sources that start with any of these strings. This is a manually curated list to try enable some useful ones + /// without just enabling everything under the sky (because .NET has no way to say "enable only the event counters", you have to enable all diagnostic events). + /// + private static readonly IReadOnlyList DefaultEventSourcePrefixes = new[] + { + "System.Runtime", + "Microsoft-AspNetCore", + "Microsoft.AspNetCore", + "System.Net" + }; + + public static readonly Func DefaultEventSourceFilterPredicate = name => DefaultEventSourcePrefixes.Any(x => name.StartsWith(x, StringComparison.Ordinal)); } diff --git a/Prometheus/EventCounterAdapterEventSourceSettings.cs b/Prometheus/EventCounterAdapterEventSourceSettings.cs index b380229b..ab08dd2f 100644 --- a/Prometheus/EventCounterAdapterEventSourceSettings.cs +++ b/Prometheus/EventCounterAdapterEventSourceSettings.cs @@ -1,20 +1,19 @@ using System.Diagnostics.Tracing; -namespace Prometheus +namespace Prometheus; + +/// +/// Defines how the EventCounterAdapter will subscribe to an event source. +/// +public sealed class EventCounterAdapterEventSourceSettings { /// - /// Defines how the EventCounterAdapter will subscribe to an event source. + /// Minimum level of events to receive. /// - public sealed class EventCounterAdapterEventSourceSettings - { - /// - /// Minimum level of events to receive. - /// - public EventLevel MinimumLevel { get; set; } = EventLevel.Informational; + public EventLevel MinimumLevel { get; set; } = EventLevel.Informational; - /// - /// Event keywords, of which at least one must match for an event to be received. - /// - public EventKeywords MatchKeywords { get; set; } = EventKeywords.None; - } + /// + /// Event keywords, of which at least one must match for an event to be received. + /// + public EventKeywords MatchKeywords { get; set; } = EventKeywords.None; } diff --git a/Prometheus/EventCounterAdapterMemoryWarden.cs b/Prometheus/EventCounterAdapterMemoryWarden.cs new file mode 100644 index 00000000..a43abff3 --- /dev/null +++ b/Prometheus/EventCounterAdapterMemoryWarden.cs @@ -0,0 +1,41 @@ +namespace Prometheus; + +/// +/// .NET EventCounters are very noisy in terms of generating a lot of garbage. At the same time, apps in development environments typically do not get loaded much, so rarely collect garbage. +/// This can mean that as soon as you plug prometheus-net into an app, its memory usage shoots up due to gen 0 garbage piling up. It will all get collected... eventually, when the GC runs. +/// This might not happen for 12+ hours! It presents a major user perception issue, as they just see the process memory usage rise and rise and rise. +/// +/// This class exists to prevent this problem. We simply force a gen 0 GC every N minutes if EventCounterAdapter is enabled and if no GC has occurred in the last N minutes already. +/// +internal static class EventCounterAdapterMemoryWarden +{ + private static readonly TimeSpan ForcedCollectionInterval = TimeSpan.FromMinutes(10); + + public static void EnsureStarted() + { + // The constructor does all the work, this is just here to signal intent. + } + + static EventCounterAdapterMemoryWarden() + { + Task.Run(Execute); + } + + private static async Task Execute() + { + while (true) + { + // Capture pre-delay state so we can check if a collection is required. + var preDelayCollectionCount = GC.CollectionCount(0); + + await Task.Delay(ForcedCollectionInterval); + + var postDelayCollectionCount = GC.CollectionCount(0); + + if (preDelayCollectionCount != postDelayCollectionCount) + continue; // GC already happened, go chill. + + GC.Collect(0); + } + } +} diff --git a/Prometheus/EventCounterAdapterOptions.cs b/Prometheus/EventCounterAdapterOptions.cs index b5a5af1c..55db8200 100644 --- a/Prometheus/EventCounterAdapterOptions.cs +++ b/Prometheus/EventCounterAdapterOptions.cs @@ -1,20 +1,33 @@ -namespace Prometheus +namespace Prometheus; + +public sealed record EventCounterAdapterOptions { - public sealed class EventCounterAdapterOptions - { - public static readonly EventCounterAdapterOptions Default = new(); + public static EventCounterAdapterOptions Default => new(); + + /// + /// By default we subscribe to a predefined set of generally useful event counters but this allows you to specify a custom filter by event source name. + /// + public Func EventSourceFilterPredicate { get; set; } = EventCounterAdapter.DefaultEventSourceFilterPredicate; + + /// + /// By default, we subscribe to event counters at Informational level from every event source. + /// You can customize these settings via this callback (with the event source name as the string given as input). + /// + public Func EventSourceSettingsProvider { get; set; } = _ => new(); - /// - /// By default we subscribe to event counters from all event sources but this allows you to filter by event source name. - /// - public Func EventSourceFilterPredicate { get; set; } = _ => true; + /// + /// How often we update event counter data. + /// + /// + /// Event counters are quite noisy in terms of generating a lot of temporary objects in memory, so we keep the default moderate. + /// All this memory is immediately GC-able but in a near-idle app it can make for a scary upward trend on the RAM usage graph because the GC might not immediately release the memory to the OS. + /// + public TimeSpan UpdateInterval { get; set; } = TimeSpan.FromSeconds(10); - /// - /// By default, we subscribe to event counters at Informational level from every event source. - /// You can customize these settings via this callback (with the event source name as the string given as input). - /// - public Func EventSourceSettingsProvider { get; set; } = _ => new(); + public CollectorRegistry Registry { get; set; } = Metrics.DefaultRegistry; - public CollectorRegistry Registry { get; set; } = Metrics.DefaultRegistry; - } + /// + /// If set, the value in Registry is ignored and this factory is instead used to create all the metrics. + /// + public IMetricFactory? MetricFactory { get; set; } = Metrics.DefaultFactory; } diff --git a/Prometheus/Exemplar.cs b/Prometheus/Exemplar.cs new file mode 100644 index 00000000..c9726d30 --- /dev/null +++ b/Prometheus/Exemplar.cs @@ -0,0 +1,304 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.Extensions.ObjectPool; + +namespace Prometheus; + +/// +/// A fully-formed exemplar, describing a set of label name-value pairs. +/// +/// One-time use only - when you pass an instance to a prometheus-net method, it will take ownership of it. +/// +/// You should preallocate and cache: +/// 1. The exemplar keys created via Exemplar.Key(). +/// 2. Exemplar key-value pairs created vvia key.WithValue() or Exemplar.Pair(). +/// +/// From the key-value pairs you can create one-use Exemplar values using Exemplar.From(). +/// You can clone Exemplar instances using Exemplar.Clone() - each clone can only be used once! +/// +public sealed class Exemplar +{ + /// + /// Indicates that no exemplar is to be recorded for a given observation. + /// + public static readonly Exemplar None = new(0); + + /// + /// An exemplar label key. For optimal performance, create it once and reuse it forever. + /// + public readonly struct LabelKey + { + internal LabelKey(byte[] key) + { + Bytes = key; + } + + // We only support ASCII here, so rune count always matches byte count. + internal int RuneCount => Bytes.Length; + + internal byte[] Bytes { get; } + + /// + /// Create a LabelPair once a value is available + /// + /// The string is expected to only contain runes in the ASCII range, runes outside the ASCII range will get replaced + /// with placeholders. This constraint may be relaxed with future versions. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public LabelPair WithValue(string value) + { + static bool IsAscii(ReadOnlySpan chars) + { + for (var i = 0; i < chars.Length; i++) + if (chars[i] > 127) + return false; + + return true; + } + + if (!IsAscii(value.AsSpan())) + { + // We believe that approximately 100% of use cases only consist of ASCII characters. + // That being said, we do not want to throw an exception here as the value may be coming from external sources + // that calling code has little control over. Therefore, we just replace such characters with placeholders. + // This matches the default behavior of Encoding.ASCII.GetBytes() - it replaces non-ASCII characters with '?'. + // As this is a highly theoretical case, we do an inefficient conversion here using the built-in encoder. + value = Encoding.ASCII.GetString(Encoding.ASCII.GetBytes(value)); + } + + return new LabelPair(Bytes, value); + } + } + + /// + /// A single exemplar label pair in a form suitable for efficient serialization. + /// If you wish to reuse the same key-value pair, you should reuse this object as much as possible. + /// + public readonly struct LabelPair + { + internal LabelPair(byte[] keyBytes, string value) + { + KeyBytes = keyBytes; + Value = value; + } + + internal int RuneCount => KeyBytes.Length + Value.Length; + internal byte[] KeyBytes { get; } + + // We keep the value as a string because it typically starts out its life as a string + // and we want to avoid paying the cost of converting it to a byte array until we serialize it. + // If we record many exemplars then we may, in fact, never serialize most of them because they get replaced. + internal string Value { get; } + } + + /// + /// Return an exemplar label key, this may be curried with a value to produce a LabelPair. + /// Reuse this for optimal performance. + /// + public static LabelKey Key(string key) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("empty key", nameof(key)); + + Collector.ValidateLabelName(key); + + var asciiBytes = Encoding.ASCII.GetBytes(key); + return new LabelKey(asciiBytes); + } + + /// + /// Pair constructs a LabelPair, it is advisable to memoize a "Key" (eg: "traceID") and then to derive "LabelPair"s + /// from these. You may (should) reuse a LabelPair for recording multiple observations that use the same exemplar. + /// + public static LabelPair Pair(string key, string value) + { + return Key(key).WithValue(value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Exemplar From(in LabelPair labelPair1, in LabelPair labelPair2, in LabelPair labelPair3, in LabelPair labelPair4, in LabelPair labelPair5, in LabelPair labelPair6) + { + var exemplar = Exemplar.AllocateFromPool(length: 6); + exemplar.LabelPair1 = labelPair1; + exemplar.LabelPair2 = labelPair2; + exemplar.LabelPair3 = labelPair3; + exemplar.LabelPair4 = labelPair4; + exemplar.LabelPair5 = labelPair5; + exemplar.LabelPair6 = labelPair6; + + return exemplar; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Exemplar From(in LabelPair labelPair1, in LabelPair labelPair2, in LabelPair labelPair3, in LabelPair labelPair4, in LabelPair labelPair5) + { + var exemplar = Exemplar.AllocateFromPool(length: 5); + exemplar.LabelPair1 = labelPair1; + exemplar.LabelPair2 = labelPair2; + exemplar.LabelPair3 = labelPair3; + exemplar.LabelPair4 = labelPair4; + exemplar.LabelPair5 = labelPair5; + + return exemplar; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Exemplar From(in LabelPair labelPair1, in LabelPair labelPair2, in LabelPair labelPair3, in LabelPair labelPair4) + { + var exemplar = Exemplar.AllocateFromPool(length: 4); + exemplar.LabelPair1 = labelPair1; + exemplar.LabelPair2 = labelPair2; + exemplar.LabelPair3 = labelPair3; + exemplar.LabelPair4 = labelPair4; + + return exemplar; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Exemplar From(in LabelPair labelPair1, in LabelPair labelPair2, in LabelPair labelPair3) + { + var exemplar = Exemplar.AllocateFromPool(length: 3); + exemplar.LabelPair1 = labelPair1; + exemplar.LabelPair2 = labelPair2; + exemplar.LabelPair3 = labelPair3; + + return exemplar; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Exemplar From(in LabelPair labelPair1, in LabelPair labelPair2) + { + var exemplar = Exemplar.AllocateFromPool(length: 2); + exemplar.LabelPair1 = labelPair1; + exemplar.LabelPair2 = labelPair2; + + return exemplar; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Exemplar From(in LabelPair labelPair1) + { + var exemplar = Exemplar.AllocateFromPool(length: 1); + exemplar.LabelPair1 = labelPair1; + + return exemplar; + } + + internal ref LabelPair this[int index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + if (index == 0) return ref LabelPair1; + if (index == 1) return ref LabelPair2; + if (index == 2) return ref LabelPair3; + if (index == 3) return ref LabelPair4; + if (index == 4) return ref LabelPair5; + if (index == 5) return ref LabelPair6; + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + // Based on https://opentelemetry.io/docs/reference/specification/compatibility/prometheus_and_openmetrics/ + private static readonly LabelKey DefaultTraceIdKey = Key("trace_id"); + private static readonly LabelKey DefaultSpanIdKey = Key("span_id"); + + public static Exemplar FromTraceContext() => FromTraceContext(DefaultTraceIdKey, DefaultSpanIdKey); + + public static Exemplar FromTraceContext(in LabelKey traceIdKey, in LabelKey spanIdKey) + { +#if NET6_0_OR_GREATER + var activity = Activity.Current; + if (activity != null) + { + // These values already exist as strings inside the Activity logic, so there is no string allocation happening here. + var traceIdLabel = traceIdKey.WithValue(activity.TraceId.ToString()); + var spanIdLabel = spanIdKey.WithValue(activity.SpanId.ToString()); + + return From(traceIdLabel, spanIdLabel); + } +#endif + + // Trace context based exemplars are only supported in .NET Core, not .NET Framework. + return None; + } + + public Exemplar() + { + } + + private Exemplar(int length) + { + Length = length; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void Update(int length) + { + Length = length; + Interlocked.Exchange(ref _consumed, IsNotConsumed); + } + + /// + /// Number of label pairs in use. + /// + internal int Length { get; private set; } + + internal LabelPair LabelPair1; + internal LabelPair LabelPair2; + internal LabelPair LabelPair3; + internal LabelPair LabelPair4; + internal LabelPair LabelPair5; + internal LabelPair LabelPair6; + + private static readonly ObjectPool ExemplarPool = ObjectPool.Create(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static Exemplar AllocateFromPool(int length) + { + var instance = ExemplarPool.Get(); + instance.Update(length); + return instance; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void ReturnToPoolIfNotEmpty() + { + if (Length == 0) + return; // Only the None instance can have a length of 0. + + Length = 0; + + ExemplarPool.Return(this); + } + + private long _consumed; + + private const long IsConsumed = 1; + private const long IsNotConsumed = 0; + + internal void MarkAsConsumed() + { + if (Interlocked.Exchange(ref _consumed, IsConsumed) == IsConsumed) + throw new InvalidOperationException($"An instance of {nameof(Exemplar)} was reused. You must obtain a new instance via Exemplar.From() or Exemplar.Clone() for each metric value observation."); + } + + /// + /// Clones the exemplar so it can be reused - each copy can only be used once! + /// + public Exemplar Clone() + { + if (Interlocked.Read(ref _consumed) == IsConsumed) + throw new InvalidOperationException($"An instance of {nameof(Exemplar)} cannot be cloned after it has already been used."); + + var clone = AllocateFromPool(Length); + clone.LabelPair1 = LabelPair1; + clone.LabelPair2 = LabelPair2; + clone.LabelPair3 = LabelPair3; + clone.LabelPair4 = LabelPair4; + clone.LabelPair5 = LabelPair5; + clone.LabelPair6 = LabelPair6; + return clone; + } +} \ No newline at end of file diff --git a/Prometheus/ExemplarBehavior.cs b/Prometheus/ExemplarBehavior.cs new file mode 100644 index 00000000..5cd584c6 --- /dev/null +++ b/Prometheus/ExemplarBehavior.cs @@ -0,0 +1,30 @@ +namespace Prometheus; + +/// +/// Defines how exemplars are obtained and published for metrics. +/// Different metrics can have their own exemplar behavior or simply inherit one from the metric factory. +/// +public sealed class ExemplarBehavior +{ + /// + /// Callback that provides the default exemplar if none is provided by the caller when providing a metric value. + /// Defaults to Exemplar.FromTraceContext(). + /// + public ExemplarProvider? DefaultExemplarProvider { get; set; } + + /// + /// A new exemplar will only be recorded for a timeseries if at least this much time has passed since the previous exemplar was recorded. + /// This can be used to limit the rate of publishing unique exemplars. By default we do not have any limit - a new exemplar always overwrites the old one. + /// + public TimeSpan NewExemplarMinInterval { get; set; } = TimeSpan.Zero; + + internal static readonly ExemplarBehavior Default = new() + { + DefaultExemplarProvider = (_, _) => Exemplar.FromTraceContext() + }; + + public static ExemplarBehavior NoExemplars() => new() + { + DefaultExemplarProvider = (_, _) => Exemplar.None + }; +} diff --git a/Prometheus/ExemplarProvider.cs b/Prometheus/ExemplarProvider.cs new file mode 100644 index 00000000..bd20af04 --- /dev/null +++ b/Prometheus/ExemplarProvider.cs @@ -0,0 +1,8 @@ +namespace Prometheus; + +/// +/// Callback to provide an exemplar for a specific observation. +/// +/// The metric instance for which an exemplar is being provided. +/// Context-dependent - for counters, the increment; for histograms, the observed value. +public delegate Exemplar ExemplarProvider(Collector metric, double value); diff --git a/Prometheus/ExpositionFormats.cs b/Prometheus/ExpositionFormats.cs new file mode 100644 index 00000000..02d811a5 --- /dev/null +++ b/Prometheus/ExpositionFormats.cs @@ -0,0 +1,13 @@ +namespace Prometheus; + +public enum ExpositionFormat +{ + /// + /// The traditional prometheus exposition format. + /// + PrometheusText, + /// + /// The OpenMetrics text exposition format + /// + OpenMetricsText +} \ No newline at end of file diff --git a/Prometheus/Gauge.cs b/Prometheus/Gauge.cs index 0bb293ed..c0ed8a12 100644 --- a/Prometheus/Gauge.cs +++ b/Prometheus/Gauge.cs @@ -1,77 +1,74 @@ -namespace Prometheus +namespace Prometheus; + +public sealed class Gauge : Collector, IGauge { - public sealed class Gauge : Collector, IGauge + public sealed class Child : ChildBase, IGauge { - public sealed class Child : ChildBase, IGauge + internal Child(Collector parent, LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish, ExemplarBehavior exemplarBehavior) + : base(parent, instanceLabels, flattenedLabels, publish, exemplarBehavior) { - internal Child(Collector parent, LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish) - : base(parent, instanceLabels, flattenedLabels, publish) - { - _identifier = CreateIdentifier(); - } - - private readonly byte[] _identifier; - - private ThreadSafeDouble _value; - - private protected override Task CollectAndSerializeImplAsync(IMetricsSerializer serializer, CancellationToken cancel) - { - return serializer.WriteMetricAsync(_identifier, Value, cancel); - } - - public void Inc(double increment = 1) - { - _value.Add(increment); - Publish(); - } + } - public void Set(double val) - { - _value.Value = val; - Publish(); - } + private ThreadSafeDouble _value; - public void Dec(double decrement = 1) - { - Inc(-decrement); - } + private protected override ValueTask CollectAndSerializeImplAsync(IMetricsSerializer serializer, CancellationToken cancel) + { + return serializer.WriteMetricPointAsync( + Parent.NameBytes, FlattenedLabelsBytes, CanonicalLabel.Empty, Value, ObservedExemplar.Empty, null, cancel); + } - public void IncTo(double targetValue) - { - _value.IncrementTo(targetValue); - Publish(); - } + public void Inc(double increment = 1) + { + _value.Add(increment); + Publish(); + } - public void DecTo(double targetValue) - { - _value.DecrementTo(targetValue); - Publish(); - } + public void Set(double val) + { + _value.Value = val; + Publish(); + } - public double Value => _value.Value; + public void Dec(double decrement = 1) + { + Inc(-decrement); } - private protected override Child NewChild(LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish) + public void IncTo(double targetValue) { - return new Child(this, instanceLabels, flattenedLabels, publish); + _value.IncrementTo(targetValue); + Publish(); } - internal Gauge(string name, string help, StringSequence instanceLabelNames, LabelSequence staticLabels, bool suppressInitialValue) - : base(name, help, instanceLabelNames, staticLabels, suppressInitialValue) + public void DecTo(double targetValue) { + _value.DecrementTo(targetValue); + Publish(); } - public void Inc(double increment = 1) => Unlabelled.Inc(increment); - public void Set(double val) => Unlabelled.Set(val); - public void Dec(double decrement = 1) => Unlabelled.Dec(decrement); - public void IncTo(double targetValue) => Unlabelled.IncTo(targetValue); - public void DecTo(double targetValue) => Unlabelled.DecTo(targetValue); - public double Value => Unlabelled.Value; - public void Publish() => Unlabelled.Publish(); - public void Unpublish() => Unlabelled.Unpublish(); + public double Value => _value.Value; + } - internal override MetricType Type => MetricType.Gauge; + private protected override Child NewChild(LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish, ExemplarBehavior exemplarBehavior) + { + return new Child(this, instanceLabels, flattenedLabels, publish, exemplarBehavior); + } - internal override int TimeseriesCount => ChildCount; + internal Gauge(string name, string help, StringSequence instanceLabelNames, LabelSequence staticLabels, bool suppressInitialValue, ExemplarBehavior exemplarBehavior) + : base(name, help, instanceLabelNames, staticLabels, suppressInitialValue, exemplarBehavior) + { } + + public void Inc(double increment = 1) => Unlabelled.Inc(increment); + public void Set(double val) => Unlabelled.Set(val); + public void Dec(double decrement = 1) => Unlabelled.Dec(decrement); + public void IncTo(double targetValue) => Unlabelled.IncTo(targetValue); + public void DecTo(double targetValue) => Unlabelled.DecTo(targetValue); + public double Value => Unlabelled.Value; + public void Publish() => Unlabelled.Publish(); + public void Unpublish() => Unlabelled.Unpublish(); + + internal override MetricType Type => MetricType.Gauge; + + internal override int TimeseriesCount => ChildCount; } \ No newline at end of file diff --git a/Prometheus/GaugeConfiguration.cs b/Prometheus/GaugeConfiguration.cs index ca732340..fea1b1f1 100644 --- a/Prometheus/GaugeConfiguration.cs +++ b/Prometheus/GaugeConfiguration.cs @@ -1,7 +1,6 @@ -namespace Prometheus +namespace Prometheus; + +public sealed class GaugeConfiguration : MetricConfiguration { - public sealed class GaugeConfiguration : MetricConfiguration - { - internal static readonly GaugeConfiguration Default = new GaugeConfiguration(); - } + internal static readonly GaugeConfiguration Default = new(); } diff --git a/Prometheus/GaugeExtensions.cs b/Prometheus/GaugeExtensions.cs index 02c3e29f..0a6062b9 100644 --- a/Prometheus/GaugeExtensions.cs +++ b/Prometheus/GaugeExtensions.cs @@ -1,76 +1,94 @@ -namespace Prometheus +using Microsoft.Extensions.ObjectPool; + +namespace Prometheus; + +public static class GaugeExtensions { - public static class GaugeExtensions + /// + /// Sets the value of the gauge to the current UTC time as a Unix timestamp in seconds. + /// Value does not include any elapsed leap seconds because Unix timestamps do not include leap seconds. + /// + public static void SetToCurrentTimeUtc(this IGauge gauge) { - /// - /// Sets the value of the gauge to the current UTC time as a Unix timestamp in seconds. - /// Value does not include any elapsed leap seconds because Unix timestamps do not include leap seconds. - /// - public static void SetToCurrentTimeUtc(this IGauge gauge) - { - gauge.Set(TimestampHelpers.ToUnixTimeSecondsAsDouble(DateTimeOffset.UtcNow)); - } + gauge.Set(LowGranularityTimeSource.GetSecondsFromUnixEpoch()); + } - /// - /// Sets the value of the gauge to a specific moment as the UTC timezone Unix timestamp in seconds. - /// Value does not include any elapsed leap seconds because Unix timestamps do not include leap seconds. - /// - public static void SetToTimeUtc(this IGauge gauge, DateTimeOffset timestamp) - { - gauge.Set(TimestampHelpers.ToUnixTimeSecondsAsDouble(timestamp)); - } + /// + /// Sets the value of the gauge to a specific moment as the UTC timezone Unix timestamp in seconds. + /// Value does not include any elapsed leap seconds because Unix timestamps do not include leap seconds. + /// + public static void SetToTimeUtc(this IGauge gauge, DateTimeOffset timestamp) + { + gauge.Set(TimestampHelpers.ToUnixTimeSecondsAsDouble(timestamp)); + } - /// - /// Increments the value of the gauge to the current UTC time as a Unix timestamp in seconds. - /// Value does not include any elapsed leap seconds because Unix timestamps do not include leap seconds. - /// Operation is ignored if the current value is already greater. - /// - public static void IncToCurrentTimeUtc(this IGauge gauge) - { - gauge.IncTo(TimestampHelpers.ToUnixTimeSecondsAsDouble(DateTimeOffset.UtcNow)); - } + /// + /// Increments the value of the gauge to the current UTC time as a Unix timestamp in seconds. + /// Value does not include any elapsed leap seconds because Unix timestamps do not include leap seconds. + /// Operation is ignored if the current value is already greater. + /// + public static void IncToCurrentTimeUtc(this IGauge gauge) + { + gauge.IncTo(LowGranularityTimeSource.GetSecondsFromUnixEpoch()); + } - /// - /// Increments the value of the gauge to a specific moment as the UTC Unix timestamp in seconds. - /// Value does not include any elapsed leap seconds because Unix timestamps do not include leap seconds. - /// Operation is ignored if the current value is already greater. - /// - public static void IncToTimeUtc(this IGauge gauge, DateTimeOffset timestamp) + /// + /// Increments the value of the gauge to a specific moment as the UTC Unix timestamp in seconds. + /// Value does not include any elapsed leap seconds because Unix timestamps do not include leap seconds. + /// Operation is ignored if the current value is already greater. + /// + public static void IncToTimeUtc(this IGauge gauge, DateTimeOffset timestamp) + { + gauge.IncTo(TimestampHelpers.ToUnixTimeSecondsAsDouble(timestamp)); + } + + private sealed class InProgressTracker : IDisposable + { + public void Dispose() { - gauge.IncTo(TimestampHelpers.ToUnixTimeSecondsAsDouble(timestamp)); + if (_gauge == null) + return; + + _gauge.Dec(); + _gauge = null; + Pool.Return(this); } - private sealed class InProgressTracker : IDisposable - { - public InProgressTracker(IGauge gauge) - { - _gauge = gauge; - } + private IGauge? _gauge; - public void Dispose() - { - _gauge.Dec(); - } + public void Update(IGauge gauge) + { + if (_gauge != null) + throw new InvalidOperationException($"{nameof(InProgressTracker)} was reused before being disposed."); - private readonly IGauge _gauge; + _gauge = gauge; } - /// - /// Tracks the number of in-progress operations taking place. - /// - /// Calling this increments the gauge. Disposing of the returned instance decrements it again. - /// - /// - /// It is safe to track the sum of multiple concurrent in-progress operations with the same gauge. - /// - public static IDisposable TrackInProgress(this IGauge gauge) + public static InProgressTracker Create(IGauge gauge) { - if (gauge == null) - throw new ArgumentNullException(nameof(gauge)); + var instance = Pool.Get(); + instance.Update(gauge); + return instance; + } - gauge.Inc(); + private static readonly ObjectPool Pool = ObjectPool.Create(); + } - return new InProgressTracker(gauge); - } + /// + /// Tracks the number of in-progress operations taking place. + /// + /// Calling this increments the gauge. Disposing of the returned instance decrements it again. + /// + /// + /// It is safe to track the sum of multiple concurrent in-progress operations with the same gauge. + /// + public static IDisposable TrackInProgress(this IGauge gauge) + { + if (gauge == null) + throw new ArgumentNullException(nameof(gauge)); + + gauge.Inc(); + + return InProgressTracker.Create(gauge); } } diff --git a/Prometheus/Histogram.cs b/Prometheus/Histogram.cs index 3eef3d2e..c2755c2e 100644 --- a/Prometheus/Histogram.cs +++ b/Prometheus/Histogram.cs @@ -1,254 +1,403 @@ -using System.Globalization; +using System.Numerics; +using System.Runtime.CompilerServices; -namespace Prometheus +#if NET7_0_OR_GREATER +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; +#endif + +namespace Prometheus; + +/// +/// The histogram is thread-safe but not atomic - the sum of values and total count of events +/// may not add up perfectly with bucket contents if new observations are made during a collection. +/// +public sealed class Histogram : Collector, IHistogram { - /// - /// The histogram is thread-safe but not atomic - the sum of values and total count of events - /// may not add up perfectly with bucket contents if new observations are made during a collection. - /// - public sealed class Histogram : Collector, IHistogram + private static readonly double[] DefaultBuckets = [.005, .01, .025, .05, .075, .1, .25, .5, .75, 1, 2.5, 5, 7.5, 10]; + + private readonly double[] _buckets; + +#if NET7_0_OR_GREATER + // For AVX, we need to align on 32 bytes and pin the memory. This is a buffer + // with extra items that we can "skip" when using the data, for alignment purposes. + private readonly double[] _bucketsAlignmentBuffer; + // How many items from the start to skip. + private readonly int _bucketsAlignmentBufferOffset; + + private const int AvxAlignBytes = 32; +#endif + + // These labels go together with the buckets, so we do not need to allocate them for every child. + private readonly CanonicalLabel[] _leLabels; + + private static readonly byte[] LeLabelName = "le"u8.ToArray(); + + internal Histogram(string name, string help, StringSequence instanceLabelNames, LabelSequence staticLabels, bool suppressInitialValue, double[]? buckets, ExemplarBehavior exemplarBehavior) + : base(name, help, instanceLabelNames, staticLabels, suppressInitialValue, exemplarBehavior) { - private static readonly double[] DefaultBuckets = { .005, .01, .025, .05, .075, .1, .25, .5, .75, 1, 2.5, 5, 7.5, 10 }; - private readonly double[] _buckets; + if (instanceLabelNames.Contains("le")) + { + throw new ArgumentException("'le' is a reserved label name"); + } - internal Histogram(string name, string help, StringSequence instanceLabelNames, LabelSequence staticLabels, bool suppressInitialValue, double[]? buckets) - : base(name, help, instanceLabelNames, staticLabels, suppressInitialValue) + _buckets = buckets ?? DefaultBuckets; + + if (_buckets.Length == 0) { - if (instanceLabelNames.Contains("le")) - { - throw new ArgumentException("'le' is a reserved label name"); - } - _buckets = buckets ?? DefaultBuckets; + throw new ArgumentException("Histogram must have at least one bucket"); + } - if (_buckets.Length == 0) - { - throw new ArgumentException("Histogram must have at least one bucket"); - } + if (!double.IsPositiveInfinity(_buckets[_buckets.Length - 1])) + { + _buckets = [.. _buckets, double.PositiveInfinity]; + } - if (!double.IsPositiveInfinity(_buckets[_buckets.Length - 1])) + for (int i = 1; i < _buckets.Length; i++) + { + if (_buckets[i] <= _buckets[i - 1]) { - _buckets = _buckets.Concat(new[] { double.PositiveInfinity }).ToArray(); + throw new ArgumentException("Bucket values must be increasing"); } + } + + _leLabels = new CanonicalLabel[_buckets.Length]; + for (var i = 0; i < _buckets.Length; i++) + { + _leLabels[i] = TextSerializer.EncodeValueAsCanonicalLabel(LeLabelName, _buckets[i]); + } + +#if NET7_0_OR_GREATER + if (Avx.IsSupported) + { + _bucketsAlignmentBuffer = GC.AllocateUninitializedArray(_buckets.Length + (AvxAlignBytes / sizeof(double)), pinned: true); - for (int i = 1; i < _buckets.Length; i++) + unsafe { - if (_buckets[i] <= _buckets[i - 1]) - { - throw new ArgumentException("Bucket values must be increasing"); - } + var pointer = (nuint)Unsafe.AsPointer(ref _bucketsAlignmentBuffer[0]); + var pointerTooFarByBytes = pointer % AvxAlignBytes; + var bytesUntilNextAlignedPosition = (AvxAlignBytes - pointerTooFarByBytes) % AvxAlignBytes; + + if (bytesUntilNextAlignedPosition % sizeof(double) != 0) + throw new Exception("Unreachable code reached - all double[] allocations are expected to be at least 8-aligned."); + + _bucketsAlignmentBufferOffset = (int)(bytesUntilNextAlignedPosition / sizeof(double)); } + + Array.Copy(_buckets, 0, _bucketsAlignmentBuffer, _bucketsAlignmentBufferOffset, _buckets.Length); + } + else + { + _bucketsAlignmentBuffer = []; } +#endif + } - private protected override Child NewChild(LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish) + private protected override Child NewChild(LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish, ExemplarBehavior exemplarBehavior) + { + return new Child(this, instanceLabels, flattenedLabels, publish, exemplarBehavior); + } + + public sealed class Child : ChildBase, IHistogram + { + internal Child(Histogram parent, LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish, ExemplarBehavior exemplarBehavior) + : base(parent, instanceLabels, flattenedLabels, publish, exemplarBehavior) { - return new Child(this, instanceLabels, flattenedLabels, publish); + Parent = parent; + + _bucketCounts = new ThreadSafeLong[Parent._buckets.Length]; + + _exemplars = new ObservedExemplar[Parent._buckets.Length]; + for (var i = 0; i < Parent._buckets.Length; i++) + { + _exemplars[i] = ObservedExemplar.Empty; + } } - public sealed class Child : ChildBase, IHistogram + internal new readonly Histogram Parent; + + private ThreadSafeDouble _sum = new(0.0D); + private readonly ThreadSafeLong[] _bucketCounts; + private static readonly byte[] SumSuffix = "sum"u8.ToArray(); + private static readonly byte[] CountSuffix = "count"u8.ToArray(); + private static readonly byte[] BucketSuffix = "bucket"u8.ToArray(); + private readonly ObservedExemplar[] _exemplars; + +#if NET + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] +#endif + private protected override async ValueTask CollectAndSerializeImplAsync(IMetricsSerializer serializer, + CancellationToken cancel) { - internal Child(Histogram parent, LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish) - : base(parent, instanceLabels, flattenedLabels, publish) + // We output sum. + // We output count. + // We output each bucket in order of increasing upper bound. + await serializer.WriteMetricPointAsync( + Parent.NameBytes, + FlattenedLabelsBytes, + CanonicalLabel.Empty, + _sum.Value, + ObservedExemplar.Empty, + SumSuffix, + cancel); + await serializer.WriteMetricPointAsync( + Parent.NameBytes, + FlattenedLabelsBytes, + CanonicalLabel.Empty, + Count, + ObservedExemplar.Empty, + CountSuffix, + cancel); + + var cumulativeCount = 0L; + + for (var i = 0; i < _bucketCounts.Length; i++) { - _parent = parent; + var exemplar = BorrowExemplar(ref _exemplars[i]); + + cumulativeCount += _bucketCounts[i].Value; + await serializer.WriteMetricPointAsync( + Parent.NameBytes, + FlattenedLabelsBytes, + Parent._leLabels[i], + cumulativeCount, + exemplar, + BucketSuffix, + cancel); + + ReturnBorrowedExemplar(ref _exemplars[i], exemplar); + } + } - _upperBounds = _parent._buckets; - _bucketCounts = new ThreadSafeLong[_upperBounds.Length]; + public double Sum => _sum.Value; - _sumIdentifier = CreateIdentifier("sum"); - _countIdentifier = CreateIdentifier("count"); + public long Count + { + get + { + long total = 0; - _bucketIdentifiers = new byte[_upperBounds.Length][]; - for (var i = 0; i < _upperBounds.Length; i++) - { - var value = double.IsPositiveInfinity(_upperBounds[i]) ? "+Inf" : _upperBounds[i].ToString(CultureInfo.InvariantCulture); + foreach (var count in _bucketCounts) + total += count.Value; - _bucketIdentifiers[i] = CreateIdentifier("bucket", "le", value); - } + return total; } + } - private readonly Histogram _parent; + public void Observe(double val, Exemplar? exemplarLabels) => ObserveInternal(val, 1, exemplarLabels); - private ThreadSafeDouble _sum = new ThreadSafeDouble(0.0D); - private readonly ThreadSafeLong[] _bucketCounts; - private readonly double[] _upperBounds; + public void Observe(double val) => Observe(val, 1); - internal readonly byte[] _sumIdentifier; - internal readonly byte[] _countIdentifier; - internal readonly byte[][] _bucketIdentifiers; + public void Observe(double val, long count) => ObserveInternal(val, count, null); - private protected override async Task CollectAndSerializeImplAsync(IMetricsSerializer serializer, CancellationToken cancel) + private void ObserveInternal(double val, long count, Exemplar? exemplar) + { + if (double.IsNaN(val)) { - // We output sum. - // We output count. - // We output each bucket in order of increasing upper bound. + return; + } - await serializer.WriteMetricAsync(_sumIdentifier, _sum.Value, cancel); - await serializer.WriteMetricAsync(_countIdentifier, _bucketCounts.Sum(b => b.Value), cancel); + exemplar ??= GetDefaultExemplar(val); - var cumulativeCount = 0L; + var bucketIndex = GetBucketIndex(val); - for (var i = 0; i < _bucketCounts.Length; i++) - { - cumulativeCount += _bucketCounts[i].Value; + _bucketCounts[bucketIndex].Add(count); - await serializer.WriteMetricAsync(_bucketIdentifiers[i], cumulativeCount, cancel); - } - } + if (exemplar?.Length > 0) + RecordExemplar(exemplar, ref _exemplars[bucketIndex], val); + + _sum.Add(val * count); - public double Sum => _sum.Value; - public long Count => _bucketCounts.Sum(b => b.Value); + Publish(); + } - public void Observe(double val) => Observe(val, 1); + private int GetBucketIndex(double val) + { +#if NET7_0_OR_GREATER + if (Avx.IsSupported) + return GetBucketIndexAvx(val); +#endif - public void Observe(double val, long count) + for (int i = 0; i < Parent._buckets.Length; i++) { - if (double.IsNaN(val)) - { - return; - } - - for (int i = 0; i < _upperBounds.Length; i++) - { - if (val <= _upperBounds[i]) - { - _bucketCounts[i].Add(count); - break; - } - } - _sum.Add(val * count); - Publish(); + if (val <= Parent._buckets[i]) + return i; } - } - internal override MetricType Type => MetricType.Histogram; - - public double Sum => Unlabelled.Sum; - public long Count => Unlabelled.Count; - public void Observe(double val) => Unlabelled.Observe(val, 1); - public void Observe(double val, long count) => Unlabelled.Observe(val, count); - public void Publish() => Unlabelled.Publish(); - public void Unpublish() => Unlabelled.Unpublish(); + throw new Exception("Unreachable code reached."); + } - // From https://github.com/prometheus/client_golang/blob/master/prometheus/histogram.go - /// - /// Creates '' buckets, where the lowest bucket has an - /// upper bound of '' and each following bucket's upper bound is '' - /// times the previous bucket's upper bound. - /// - /// The function throws if '' is 0 or negative, if '' is 0 or negative, - /// or if '' is less than or equal 1. +#if NET7_0_OR_GREATER + /// + /// AVX allows us to perform 4 comparisons at the same time when finding the right bucket to increment. + /// The total speedup is not 4x due to various overheads but it's still 10-30% (more for wider histograms). /// - /// The upper bound of the lowest bucket. Must be positive. - /// The factor to increase the upper bound of subsequent buckets. Must be greater than 1. - /// The number of buckets to create. Must be positive. - public static double[] ExponentialBuckets(double start, double factor, int count) + private unsafe int GetBucketIndexAvx(double val) { - if (count <= 0) throw new ArgumentException($"{nameof(ExponentialBuckets)} needs a positive {nameof(count)}"); - if (start <= 0) throw new ArgumentException($"{nameof(ExponentialBuckets)} needs a positive {nameof(start)}"); - if (factor <= 1) throw new ArgumentException($"{nameof(ExponentialBuckets)} needs a {nameof(factor)} greater than 1"); + // AVX operates on vectors of N buckets, so if the total is not divisible by N we need to check some of them manually. + var remaining = Parent._buckets.Length % Vector256.Count; - // The math we do can make it incur some tiny avoidable error due to floating point gremlins. - // We use decimal for the path to preserve as much accuracy as we can, before finally converting to double. - // It will not fix 100% of the cases where we end up with 0.0000000000000000000000000000001 offset but it helps a lot. + for (int i = 0; i < Parent._buckets.Length - remaining; i += Vector256.Count) + { + // The buckets are permanently pinned, no need to re-pin them here. + var boundPointer = (double*)Unsafe.AsPointer(ref Parent._bucketsAlignmentBuffer[Parent._bucketsAlignmentBufferOffset + i]); + var boundVector = Avx.LoadAlignedVector256(boundPointer); + + var valVector = Vector256.Create(val); + + var mask = Avx.CompareLessThanOrEqual(valVector, boundVector); - var next = (decimal)start; - var buckets = new double[count]; + // Condenses the mask vector into a 32-bit integer where one bit represents one vector element (so 1111000.. means "first 4 items true"). + var moveMask = Avx.MoveMask(mask); - for (var i = 0; i < buckets.Length; i++) + var indexInBlock = BitOperations.TrailingZeroCount(moveMask); + + if (indexInBlock == sizeof(int) * 8) + continue; // All bits are zero, so we did not find a match. + + return i + indexInBlock; + } + + for (int i = Parent._buckets.Length - remaining; i < Parent._buckets.Length; i++) { - buckets[i] = (double)next; - next *= (decimal)factor; + if (val <= Parent._buckets[i]) + return i; } - return buckets; + throw new Exception("Unreachable code reached."); } +#endif + } - // From https://github.com/prometheus/client_golang/blob/master/prometheus/histogram.go - /// - /// Creates '' buckets, where the lowest bucket has an - /// upper bound of '' and each following bucket's upper bound is the upper bound of the - /// previous bucket, incremented by '' - /// - /// The function throws if '' is 0 or negative. - /// - /// The upper bound of the lowest bucket. - /// The width of each bucket (distance between lower and upper bound). - /// The number of buckets to create. Must be positive. - public static double[] LinearBuckets(double start, double width, int count) + internal override MetricType Type => MetricType.Histogram; + + public double Sum => Unlabelled.Sum; + public long Count => Unlabelled.Count; + public void Observe(double val) => Unlabelled.Observe(val, 1); + public void Observe(double val, long count) => Unlabelled.Observe(val, count); + public void Observe(double val, Exemplar? exemplar) => Unlabelled.Observe(val, exemplar); + public void Publish() => Unlabelled.Publish(); + public void Unpublish() => Unlabelled.Unpublish(); + + // From https://github.com/prometheus/client_golang/blob/master/prometheus/histogram.go + /// + /// Creates '' buckets, where the lowest bucket has an + /// upper bound of '' and each following bucket's upper bound is '' + /// times the previous bucket's upper bound. + /// + /// The function throws if '' is 0 or negative, if '' is 0 or negative, + /// or if '' is less than or equal 1. + /// + /// The upper bound of the lowest bucket. Must be positive. + /// The factor to increase the upper bound of subsequent buckets. Must be greater than 1. + /// The number of buckets to create. Must be positive. + public static double[] ExponentialBuckets(double start, double factor, int count) + { + if (count <= 0) throw new ArgumentException($"{nameof(ExponentialBuckets)} needs a positive {nameof(count)}"); + if (start <= 0) throw new ArgumentException($"{nameof(ExponentialBuckets)} needs a positive {nameof(start)}"); + if (factor <= 1) throw new ArgumentException($"{nameof(ExponentialBuckets)} needs a {nameof(factor)} greater than 1"); + + // The math we do can make it incur some tiny avoidable error due to floating point gremlins. + // We use decimal for the path to preserve as much accuracy as we can, before finally converting to double. + // It will not fix 100% of the cases where we end up with 0.0000000000000000000000000000001 offset but it helps a lot. + + var next = (decimal)start; + var buckets = new double[count]; + + for (var i = 0; i < buckets.Length; i++) { - if (count <= 0) throw new ArgumentException($"{nameof(LinearBuckets)} needs a positive {nameof(count)}"); + buckets[i] = (double)next; + next *= (decimal)factor; + } - // The math we do can make it incur some tiny avoidable error due to floating point gremlins. - // We use decimal for the path to preserve as much accuracy as we can, before finally converting to double. - // It will not fix 100% of the cases where we end up with 0.0000000000000000000000000000001 offset but it helps a lot. + return buckets; + } - var next = (decimal)start; - var buckets = new double[count]; + // From https://github.com/prometheus/client_golang/blob/master/prometheus/histogram.go + /// + /// Creates '' buckets, where the lowest bucket has an + /// upper bound of '' and each following bucket's upper bound is the upper bound of the + /// previous bucket, incremented by '' + /// + /// The function throws if '' is 0 or negative. + /// + /// The upper bound of the lowest bucket. + /// The width of each bucket (distance between lower and upper bound). + /// The number of buckets to create. Must be positive. + public static double[] LinearBuckets(double start, double width, int count) + { + if (count <= 0) throw new ArgumentException($"{nameof(LinearBuckets)} needs a positive {nameof(count)}"); - for (var i = 0; i < buckets.Length; i++) - { - buckets[i] = (double)next; - next += (decimal)width; - } + // The math we do can make it incur some tiny avoidable error due to floating point gremlins. + // We use decimal for the path to preserve as much accuracy as we can, before finally converting to double. + // It will not fix 100% of the cases where we end up with 0.0000000000000000000000000000001 offset but it helps a lot. + + var next = (decimal)start; + var buckets = new double[count]; - return buckets; + for (var i = 0; i < buckets.Length; i++) + { + buckets[i] = (double)next; + next += (decimal)width; } - /// - /// Divides each power of 10 into N divisions. - /// - /// The starting range includes 10 raised to this power. - /// The ranges end with 10 raised to this power (this no longer starts a new range). - /// How many divisions to divide each range into. - /// - /// For example, with startPower=-1, endPower=2, divisions=4 we would get: - /// 10^-1 == 0.1 which defines our starting range, giving buckets: 0.25, 0.5, 0.75, 1.0 - /// 10^0 == 1 which is the next range, giving buckets: 2.5, 5, 7.5, 10 - /// 10^1 == 10 which is the next range, giving buckets: 25, 50, 75, 100 - /// 10^2 == 100 which is the end and the top level of the preceding range. - /// Giving total buckets: 0.25, 0.5, 0.75, 1.0, 2.5, 5, 7.5, 10, 25, 50, 75, 100 - /// - public static double[] PowersOfTenDividedBuckets(int startPower, int endPower, int divisions) - { - if (startPower >= endPower) - throw new ArgumentException($"{nameof(startPower)} must be less than {nameof(endPower)}.", nameof(startPower)); - - if (divisions <= 0) - throw new ArgumentOutOfRangeException($"{nameof(divisions)} must be a positive integer.", nameof(divisions)); - - var buckets = new List(); - - for (var powerOfTen = startPower; powerOfTen < endPower; powerOfTen++) + return buckets; + } + + /// + /// Divides each power of 10 into N divisions. + /// + /// The starting range includes 10 raised to this power. + /// The ranges end with 10 raised to this power (this no longer starts a new range). + /// How many divisions to divide each range into. + /// + /// For example, with startPower=-1, endPower=2, divisions=4 we would get: + /// 10^-1 == 0.1 which defines our starting range, giving buckets: 0.25, 0.5, 0.75, 1.0 + /// 10^0 == 1 which is the next range, giving buckets: 2.5, 5, 7.5, 10 + /// 10^1 == 10 which is the next range, giving buckets: 25, 50, 75, 100 + /// 10^2 == 100 which is the end and the top level of the preceding range. + /// Giving total buckets: 0.25, 0.5, 0.75, 1.0, 2.5, 5, 7.5, 10, 25, 50, 75, 100 + /// + public static double[] PowersOfTenDividedBuckets(int startPower, int endPower, int divisions) + { + if (startPower >= endPower) + throw new ArgumentException($"{nameof(startPower)} must be less than {nameof(endPower)}.", nameof(startPower)); + + if (divisions <= 0) + throw new ArgumentOutOfRangeException($"{nameof(divisions)} must be a positive integer.", nameof(divisions)); + + var buckets = new List(); + + for (var powerOfTen = startPower; powerOfTen < endPower; powerOfTen++) + { + // This gives us the upper bound (the start of the next range). + var max = (decimal)Math.Pow(10, powerOfTen + 1); + + // Then we just divide it into N divisions and we are done! + for (var division = 0; division < divisions; division++) { - // This gives us the upper bound (the start of the next range). - var max = (decimal)Math.Pow(10, powerOfTen + 1); - - // Then we just divide it into N divisions and we are done! - for (var division = 0; division < divisions; division++) - { - var bucket = max / divisions * (division + 1); - - // The math we do can make it incur some tiny avoidable error due to floating point gremlins. - // We use decimal for the path to preserve as much accuracy as we can, before finally converting to double. - // It will not fix 100% of the cases where we end up with 0.0000000000000000000000000000001 offset but it helps a lot. - var candidate = (double)bucket; - - // Depending on the number of divisions, it may be that divisions from different powers overlap. - // For example, a division into 20 would include: - // 19th value in the 0th power: 9.5 (10/20*19=9.5) - // 1st value in the 1st power: 5 (100/20*1 = 5) - // To avoid this being a problem, we simply constrain all values to be increasing. - if (buckets.Any() && buckets.Last() >= candidate) - continue; // Skip this one, it is not greater. - - buckets.Add(candidate); - } + var bucket = max / divisions * (division + 1); + + // The math we do can make it incur some tiny avoidable error due to floating point gremlins. + // We use decimal for the path to preserve as much accuracy as we can, before finally converting to double. + // It will not fix 100% of the cases where we end up with 0.0000000000000000000000000000001 offset but it helps a lot. + var candidate = (double)bucket; + + // Depending on the number of divisions, it may be that divisions from different powers overlap. + // For example, a division into 20 would include: + // 19th value in the 0th power: 9.5 (10/20*19=9.5) + // 1st value in the 1st power: 5 (100/20*1 = 5) + // To avoid this being a problem, we simply constrain all values to be increasing. + if (buckets.Any() && buckets.Last() >= candidate) + continue; // Skip this one, it is not greater. + + buckets.Add(candidate); } - - return buckets.ToArray(); } - // sum + count + buckets - internal override int TimeseriesCount => ChildCount * (2 + _buckets.Length); + return [.. buckets]; } -} + + // sum + count + buckets + internal override int TimeseriesCount => ChildCount * (2 + _buckets.Length); +} \ No newline at end of file diff --git a/Prometheus/HistogramConfiguration.cs b/Prometheus/HistogramConfiguration.cs index 893c499d..e4c449d9 100644 --- a/Prometheus/HistogramConfiguration.cs +++ b/Prometheus/HistogramConfiguration.cs @@ -1,12 +1,17 @@ -namespace Prometheus +namespace Prometheus; + +public sealed class HistogramConfiguration : MetricConfiguration { - public sealed class HistogramConfiguration : MetricConfiguration - { - internal static readonly HistogramConfiguration Default = new HistogramConfiguration(); + internal static readonly HistogramConfiguration Default = new HistogramConfiguration(); + + /// + /// Custom histogram buckets to use. If null, will use Histogram.DefaultBuckets. + /// + public double[]? Buckets { get; set; } - /// - /// Custom histogram buckets to use. If null, will use Histogram.DefaultBuckets. - /// - public double[]? Buckets { get; set; } - } + /// + /// Allows you to configure how exemplars are applied to the published metric. + /// If null, inherits the exemplar behavior from the metric factory. + /// + public ExemplarBehavior? ExemplarBehavior { get; set; } } diff --git a/Prometheus/HttpClientMetrics/HttpClientDelegatingHandlerBase.cs b/Prometheus/HttpClientMetrics/HttpClientDelegatingHandlerBase.cs index 4f4ab71d..2f80afad 100644 --- a/Prometheus/HttpClientMetrics/HttpClientDelegatingHandlerBase.cs +++ b/Prometheus/HttpClientMetrics/HttpClientDelegatingHandlerBase.cs @@ -1,107 +1,106 @@ -namespace Prometheus.HttpClientMetrics +namespace Prometheus.HttpClientMetrics; + +/// +/// This base class performs the data management necessary to associate the correct labels and values +/// with HttpClient metrics, depending on the options the user has provided for the HttpClient metric handler. +/// +/// The following labels are supported: +/// 'method' (HTTP request method) +/// 'host' (The host name of HTTP request) +/// 'client' (The name of the HttpClient) +/// 'code' (HTTP response status code) +/// +internal abstract class HttpClientDelegatingHandlerBase : DelegatingHandler + where TCollector : class, ICollector + where TChild : class, ICollectorChild { /// - /// This base class performs the data management necessary to associate the correct labels and values - /// with HttpClient metrics, depending on the options the user has provided for the HttpClient metric handler. - /// - /// The following labels are supported: - /// 'method' (HTTP request method) - /// 'host' (The host name of HTTP request) - /// 'client' (The name of the HttpClient) - /// 'code' (HTTP response status code) + /// The set of labels from among the defaults that this metric supports. /// - internal abstract class HttpClientDelegatingHandlerBase : DelegatingHandler - where TCollector : class, ICollector - where TChild : class, ICollectorChild - { - /// - /// The set of labels from among the defaults that this metric supports. - /// - protected abstract string[] DefaultLabels { get; } + protected abstract string[] DefaultLabels { get; } - /// - /// The factory to use for creating the default metric for this middleware. - /// Not used if a custom metric is already provided in options. - /// - protected MetricFactory MetricFactory { get; } + /// + /// The factory to use for creating the default metric for this middleware. + /// Not used if a custom metric is already provided in options. + /// + protected MetricFactory MetricFactory { get; } - /// - /// Creates the default metric instance with the specified set of labels. - /// Only used if the caller does not provide a custom metric instance in the options. - /// - protected abstract TCollector CreateMetricInstance(string[] labelNames); + /// + /// Creates the default metric instance with the specified set of labels. + /// Only used if the caller does not provide a custom metric instance in the options. + /// + protected abstract TCollector CreateMetricInstance(string[] labelNames); - // Internal only for tests. - internal readonly TCollector _metric; + // Internal only for tests. + internal readonly TCollector _metric; - protected HttpClientDelegatingHandlerBase(HttpClientMetricsOptionsBase? options, TCollector? customMetric, HttpClientIdentity identity) - { - _identity = identity; + protected HttpClientDelegatingHandlerBase(HttpClientMetricsOptionsBase? options, TCollector? customMetric, HttpClientIdentity identity) + { + _identity = identity; - MetricFactory = Metrics.WithCustomRegistry(options?.Registry ?? Metrics.DefaultRegistry); + MetricFactory = Metrics.WithCustomRegistry(options?.Registry ?? Metrics.DefaultRegistry); - if (customMetric != null) - { - _metric = customMetric; + if (customMetric != null) + { + _metric = customMetric; - ValidateNoUnexpectedLabelNames(); - } - else - { - _metric = CreateMetricInstance(HttpClientRequestLabelNames.All); - } + ValidateNoUnexpectedLabelNames(); + } + else + { + _metric = CreateMetricInstance(HttpClientRequestLabelNames.All); } + } - private readonly HttpClientIdentity _identity; + private readonly HttpClientIdentity _identity; - /// - /// Creates the metric child instance to use for measurements. - /// - /// - /// Internal for testing purposes. - /// - protected internal TChild CreateChild(HttpRequestMessage request, HttpResponseMessage? response) - { - if (!_metric.LabelNames.Any()) - return _metric.Unlabelled; + /// + /// Creates the metric child instance to use for measurements. + /// + /// + /// Internal for testing purposes. + /// + protected internal TChild CreateChild(HttpRequestMessage request, HttpResponseMessage? response) + { + if (!_metric.LabelNames.Any()) + return _metric.Unlabelled; - var labelValues = new string[_metric.LabelNames.Length]; + var labelValues = new string[_metric.LabelNames.Length]; - for (var i = 0; i < labelValues.Length; i++) + for (var i = 0; i < labelValues.Length; i++) + { + switch (_metric.LabelNames[i]) { - switch (_metric.LabelNames[i]) - { - case HttpClientRequestLabelNames.Method: - labelValues[i] = request.Method.Method; - break; - case HttpClientRequestLabelNames.Host: - labelValues[i] = request.RequestUri?.Host ?? ""; - break; - case HttpClientRequestLabelNames.Client: - labelValues[i] = _identity.Name; - break; - case HttpClientRequestLabelNames.Code: - labelValues[i] = response != null ? ((int)response.StatusCode).ToString() : ""; - break; - default: - // We validate the label set on initialization, so this is impossible. - throw new NotSupportedException($"Found unsupported label on metric: {_metric.LabelNames[i]}"); - } + case HttpClientRequestLabelNames.Method: + labelValues[i] = request.Method.Method; + break; + case HttpClientRequestLabelNames.Host: + labelValues[i] = request.RequestUri?.Host ?? ""; + break; + case HttpClientRequestLabelNames.Client: + labelValues[i] = _identity.Name; + break; + case HttpClientRequestLabelNames.Code: + labelValues[i] = response != null ? ((int)response.StatusCode).ToString() : ""; + break; + default: + // We validate the label set on initialization, so this is impossible. + throw new NotSupportedException($"Found unsupported label on metric: {_metric.LabelNames[i]}"); } - - return _metric.WithLabels(labelValues); } - /// - /// If we use a custom metric, it should not have labels that are not among the defaults. - /// - private void ValidateNoUnexpectedLabelNames() - { - var allowedLabels = HttpClientRequestLabelNames.All; - var unexpected = _metric.LabelNames.Except(allowedLabels); + return _metric.WithLabels(labelValues); + } - if (unexpected.Any()) - throw new ArgumentException($"Provided custom HttpClient metric instance for {GetType().Name} has some unexpected labels: {string.Join(", ", unexpected)}."); - } + /// + /// If we use a custom metric, it should not have labels that are not among the defaults. + /// + private void ValidateNoUnexpectedLabelNames() + { + var allowedLabels = HttpClientRequestLabelNames.All; + var unexpected = _metric.LabelNames.Except(allowedLabels); + + if (unexpected.Any()) + throw new ArgumentException($"Provided custom HttpClient metric instance for {GetType().Name} has some unexpected labels: {string.Join(", ", unexpected)}."); } } \ No newline at end of file diff --git a/Prometheus/HttpClientMetrics/HttpClientExporterOptions.cs b/Prometheus/HttpClientMetrics/HttpClientExporterOptions.cs index 6f58df20..c1bb529c 100644 --- a/Prometheus/HttpClientMetrics/HttpClientExporterOptions.cs +++ b/Prometheus/HttpClientMetrics/HttpClientExporterOptions.cs @@ -1,10 +1,9 @@ -namespace Prometheus.HttpClientMetrics +namespace Prometheus.HttpClientMetrics; + +public sealed class HttpClientExporterOptions { - public sealed class HttpClientExporterOptions - { - public HttpClientInProgressOptions InProgress { get; set; } = new HttpClientInProgressOptions(); - public HttpClientRequestCountOptions RequestCount { get; set; } = new HttpClientRequestCountOptions(); - public HttpClientRequestDurationOptions RequestDuration { get; set; } = new HttpClientRequestDurationOptions(); - public HttpClientResponseDurationOptions ResponseDuration { get; set; } = new HttpClientResponseDurationOptions(); - } + public HttpClientInProgressOptions InProgress { get; set; } = new HttpClientInProgressOptions(); + public HttpClientRequestCountOptions RequestCount { get; set; } = new HttpClientRequestCountOptions(); + public HttpClientRequestDurationOptions RequestDuration { get; set; } = new HttpClientRequestDurationOptions(); + public HttpClientResponseDurationOptions ResponseDuration { get; set; } = new HttpClientResponseDurationOptions(); } \ No newline at end of file diff --git a/Prometheus/HttpClientMetrics/HttpClientIdentity.cs b/Prometheus/HttpClientMetrics/HttpClientIdentity.cs index 595e1ebe..7f8cde90 100644 --- a/Prometheus/HttpClientMetrics/HttpClientIdentity.cs +++ b/Prometheus/HttpClientMetrics/HttpClientIdentity.cs @@ -1,14 +1,13 @@ -namespace Prometheus.HttpClientMetrics +namespace Prometheus.HttpClientMetrics; + +public sealed class HttpClientIdentity { - public sealed class HttpClientIdentity - { - public static readonly HttpClientIdentity Default = new HttpClientIdentity("default"); + public static readonly HttpClientIdentity Default = new HttpClientIdentity("default"); - public string Name { get; } + public string Name { get; } - public HttpClientIdentity(string name) - { - Name = name; - } + public HttpClientIdentity(string name) + { + Name = name; } } diff --git a/Prometheus/HttpClientMetrics/HttpClientInProgressHandler.cs b/Prometheus/HttpClientMetrics/HttpClientInProgressHandler.cs index a3c908ab..4624b02a 100644 --- a/Prometheus/HttpClientMetrics/HttpClientInProgressHandler.cs +++ b/Prometheus/HttpClientMetrics/HttpClientInProgressHandler.cs @@ -1,25 +1,25 @@ -namespace Prometheus.HttpClientMetrics +namespace Prometheus.HttpClientMetrics; + +internal sealed class HttpClientInProgressHandler : HttpClientDelegatingHandlerBase, IGauge> { - internal sealed class HttpClientInProgressHandler : HttpClientDelegatingHandlerBase, IGauge> + public HttpClientInProgressHandler(HttpClientInProgressOptions? options, HttpClientIdentity identity) + : base(options, options?.Gauge, identity) { - public HttpClientInProgressHandler(HttpClientInProgressOptions? options, HttpClientIdentity identity) - : base(options, options?.Gauge, identity) - { - } + } - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + using (CreateChild(request, null).TrackInProgress()) { - using (CreateChild(request, null).TrackInProgress()) - { - return await base.SendAsync(request, cancellationToken); - } + // Returns when the response HEADERS are seen. + return await base.SendAsync(request, cancellationToken); } + } - protected override string[] DefaultLabels => HttpClientRequestLabelNames.KnownInAdvance; + protected override string[] DefaultLabels => HttpClientRequestLabelNames.KnownInAdvance; - protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateGauge( - "httpclient_requests_in_progress", - "Number of requests currently being executed by an HttpClient.", - labelNames); - } + protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateGauge( + "httpclient_requests_in_progress", + "Number of requests currently being executed by an HttpClient that have not yet received response headers. Value is decremented once response headers are received.", + labelNames); } \ No newline at end of file diff --git a/Prometheus/HttpClientMetrics/HttpClientInProgressOptions.cs b/Prometheus/HttpClientMetrics/HttpClientInProgressOptions.cs index 68853edb..e701914e 100644 --- a/Prometheus/HttpClientMetrics/HttpClientInProgressOptions.cs +++ b/Prometheus/HttpClientMetrics/HttpClientInProgressOptions.cs @@ -1,10 +1,9 @@ -namespace Prometheus.HttpClientMetrics +namespace Prometheus.HttpClientMetrics; + +public sealed class HttpClientInProgressOptions : HttpClientMetricsOptionsBase { - public sealed class HttpClientInProgressOptions : HttpClientMetricsOptionsBase - { - /// - /// Set this to use a custom metric instead of the default. - /// - public ICollector? Gauge { get; set; } - } + /// + /// Set this to use a custom metric instead of the default. + /// + public ICollector? Gauge { get; set; } } \ No newline at end of file diff --git a/Prometheus/HttpClientMetrics/HttpClientMetricsOptionsBase.cs b/Prometheus/HttpClientMetrics/HttpClientMetricsOptionsBase.cs index 95192635..f810d5c9 100644 --- a/Prometheus/HttpClientMetrics/HttpClientMetricsOptionsBase.cs +++ b/Prometheus/HttpClientMetrics/HttpClientMetricsOptionsBase.cs @@ -1,13 +1,12 @@ -namespace Prometheus.HttpClientMetrics +namespace Prometheus.HttpClientMetrics; + +public abstract class HttpClientMetricsOptionsBase { - public abstract class HttpClientMetricsOptionsBase - { - public bool Enabled { get; set; } = true; + public bool Enabled { get; set; } = true; - /// - /// Allows you to override the registry used to create the default metric instance. - /// Value is ignored if you specify a custom metric instance in the options. - /// - public CollectorRegistry? Registry { get; set; } - } + /// + /// Allows you to override the registry used to create the default metric instance. + /// Value is ignored if you specify a custom metric instance in the options. + /// + public CollectorRegistry? Registry { get; set; } } \ No newline at end of file diff --git a/Prometheus/HttpClientMetrics/HttpClientRequestCountHandler.cs b/Prometheus/HttpClientMetrics/HttpClientRequestCountHandler.cs index c2555cd2..57d2ee51 100644 --- a/Prometheus/HttpClientMetrics/HttpClientRequestCountHandler.cs +++ b/Prometheus/HttpClientMetrics/HttpClientRequestCountHandler.cs @@ -1,32 +1,31 @@ -namespace Prometheus.HttpClientMetrics +namespace Prometheus.HttpClientMetrics; + +internal sealed class HttpClientRequestCountHandler : HttpClientDelegatingHandlerBase, ICounter> { - internal sealed class HttpClientRequestCountHandler : HttpClientDelegatingHandlerBase, ICounter> + public HttpClientRequestCountHandler(HttpClientRequestCountOptions? options, HttpClientIdentity identity) + : base(options, options?.Counter, identity) { - public HttpClientRequestCountHandler(HttpClientRequestCountOptions? options, HttpClientIdentity identity) - : base(options, options?.Counter, identity) + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + HttpResponseMessage? response = null; + + try { + response = await base.SendAsync(request, cancellationToken); + return response; } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + finally { - HttpResponseMessage? response = null; - - try - { - response = await base.SendAsync(request, cancellationToken); - return response; - } - finally - { - CreateChild(request, response).Inc(); - } + CreateChild(request, response).Inc(); } + } - protected override string[] DefaultLabels => HttpClientRequestLabelNames.All; + protected override string[] DefaultLabels => HttpClientRequestLabelNames.All; - protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateCounter( - "httpclient_requests_sent_total", - "Count of HTTP requests that have been completed by an HttpClient.", - labelNames); - } + protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateCounter( + "httpclient_requests_sent_total", + "Count of HTTP requests that have been completed by an HttpClient.", + labelNames); } \ No newline at end of file diff --git a/Prometheus/HttpClientMetrics/HttpClientRequestCountOptions.cs b/Prometheus/HttpClientMetrics/HttpClientRequestCountOptions.cs index 5985c2fb..c94bfd2d 100644 --- a/Prometheus/HttpClientMetrics/HttpClientRequestCountOptions.cs +++ b/Prometheus/HttpClientMetrics/HttpClientRequestCountOptions.cs @@ -1,10 +1,9 @@ -namespace Prometheus.HttpClientMetrics +namespace Prometheus.HttpClientMetrics; + +public sealed class HttpClientRequestCountOptions : HttpClientMetricsOptionsBase { - public sealed class HttpClientRequestCountOptions : HttpClientMetricsOptionsBase - { - /// - /// Set this to use a custom metric instead of the default. - /// - public ICollector? Counter { get; set; } - } + /// + /// Set this to use a custom metric instead of the default. + /// + public ICollector? Counter { get; set; } } \ No newline at end of file diff --git a/Prometheus/HttpClientMetrics/HttpClientRequestDurationHandler.cs b/Prometheus/HttpClientMetrics/HttpClientRequestDurationHandler.cs index 4fcc7dab..45b9c732 100644 --- a/Prometheus/HttpClientMetrics/HttpClientRequestDurationHandler.cs +++ b/Prometheus/HttpClientMetrics/HttpClientRequestDurationHandler.cs @@ -1,41 +1,40 @@ -namespace Prometheus.HttpClientMetrics +namespace Prometheus.HttpClientMetrics; + +internal sealed class HttpClientRequestDurationHandler : HttpClientDelegatingHandlerBase, IHistogram> { - internal sealed class HttpClientRequestDurationHandler : HttpClientDelegatingHandlerBase, IHistogram> + public HttpClientRequestDurationHandler(HttpClientRequestDurationOptions? options, HttpClientIdentity identity) + : base(options, options?.Histogram, identity) { - public HttpClientRequestDurationHandler(HttpClientRequestDurationOptions? options, HttpClientIdentity identity) - : base(options, options?.Histogram, identity) - { - } + } - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var stopWatch = ValueStopwatch.StartNew(); + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var stopWatch = ValueStopwatch.StartNew(); - HttpResponseMessage? response = null; + HttpResponseMessage? response = null; - try - { - // We measure until SendAsync returns - which is when the response HEADERS are seen. - // The response body may continue streaming for a long time afterwards, which this does not measure. - response = await base.SendAsync(request, cancellationToken); - return response; - } - finally - { - CreateChild(request, response).Observe(stopWatch.GetElapsedTime().TotalSeconds); - } + try + { + // We measure until SendAsync returns - which is when the response HEADERS are seen. + // The response body may continue streaming for a long time afterwards, which this does not measure. + response = await base.SendAsync(request, cancellationToken); + return response; + } + finally + { + CreateChild(request, response).Observe(stopWatch.GetElapsedTime().TotalSeconds); } + } - protected override string[] DefaultLabels => HttpClientRequestLabelNames.All; + protected override string[] DefaultLabels => HttpClientRequestLabelNames.All; - protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateHistogram( - "httpclient_request_duration_seconds", - "Duration histogram of HTTP requests performed by an HttpClient.", - labelNames, - new HistogramConfiguration - { - // 1 ms to 32K ms buckets - Buckets = Histogram.ExponentialBuckets(0.001, 2, 16), - }); - } + protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateHistogram( + "httpclient_request_duration_seconds", + "Duration histogram of HTTP requests performed by an HttpClient.", + labelNames, + new HistogramConfiguration + { + // 1 ms to 32K ms buckets + Buckets = Histogram.ExponentialBuckets(0.001, 2, 16), + }); } \ No newline at end of file diff --git a/Prometheus/HttpClientMetrics/HttpClientRequestDurationOptions.cs b/Prometheus/HttpClientMetrics/HttpClientRequestDurationOptions.cs index 8177e968..84c91252 100644 --- a/Prometheus/HttpClientMetrics/HttpClientRequestDurationOptions.cs +++ b/Prometheus/HttpClientMetrics/HttpClientRequestDurationOptions.cs @@ -1,10 +1,9 @@ -namespace Prometheus.HttpClientMetrics +namespace Prometheus.HttpClientMetrics; + +public sealed class HttpClientRequestDurationOptions : HttpClientMetricsOptionsBase { - public sealed class HttpClientRequestDurationOptions : HttpClientMetricsOptionsBase - { - /// - /// Set this to use a custom metric instead of the default. - /// - public ICollector? Histogram { get; set; } - } + /// + /// Set this to use a custom metric instead of the default. + /// + public ICollector? Histogram { get; set; } } \ No newline at end of file diff --git a/Prometheus/HttpClientMetrics/HttpClientRequestLabelNames.cs b/Prometheus/HttpClientMetrics/HttpClientRequestLabelNames.cs index a06a119d..2684fd77 100644 --- a/Prometheus/HttpClientMetrics/HttpClientRequestLabelNames.cs +++ b/Prometheus/HttpClientMetrics/HttpClientRequestLabelNames.cs @@ -1,30 +1,29 @@ -namespace Prometheus.HttpClientMetrics +namespace Prometheus.HttpClientMetrics; + +/// +/// Label names reserved for the use by the HttpClient metrics. +/// +public static class HttpClientRequestLabelNames { - /// - /// Label names reserved for the use by the HttpClient metrics. - /// - public static class HttpClientRequestLabelNames - { - public const string Method = "method"; - public const string Host = "host"; - public const string Client = "client"; - public const string Code = "code"; + public const string Method = "method"; + public const string Host = "host"; + public const string Client = "client"; + public const string Code = "code"; - public static readonly string[] All = - { - Method, - Host, - Client, - Code - }; + public static readonly string[] All = + { + Method, + Host, + Client, + Code + }; - // The labels known before receiving the response. - // Everything except the response status code, basically. - public static readonly string[] KnownInAdvance = - { - Method, - Host, - Client - }; - } + // The labels known before receiving the response. + // Everything except the response status code, basically. + public static readonly string[] KnownInAdvance = + { + Method, + Host, + Client + }; } \ No newline at end of file diff --git a/Prometheus/HttpClientMetrics/HttpClientResponseDurationHandler.cs b/Prometheus/HttpClientMetrics/HttpClientResponseDurationHandler.cs index a0b42a7a..69ff3529 100644 --- a/Prometheus/HttpClientMetrics/HttpClientResponseDurationHandler.cs +++ b/Prometheus/HttpClientMetrics/HttpClientResponseDurationHandler.cs @@ -1,142 +1,141 @@ using System.Net.Http.Headers; -namespace Prometheus.HttpClientMetrics +namespace Prometheus.HttpClientMetrics; + +internal sealed class HttpClientResponseDurationHandler : HttpClientDelegatingHandlerBase, IHistogram> { - internal sealed class HttpClientResponseDurationHandler : HttpClientDelegatingHandlerBase, IHistogram> + public HttpClientResponseDurationHandler(HttpClientResponseDurationOptions? options, HttpClientIdentity identity) + : base(options, options?.Histogram, identity) { - public HttpClientResponseDurationHandler(HttpClientResponseDurationOptions? options, HttpClientIdentity identity) - : base(options, options?.Histogram, identity) - { - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var stopWatch = ValueStopwatch.StartNew(); + } - var response = await base.SendAsync(request, cancellationToken); + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var stopWatch = ValueStopwatch.StartNew(); - Stream oldStream = await response.Content.ReadAsStreamAsync(); + var response = await base.SendAsync(request, cancellationToken); - Wrap(response, oldStream, delegate - { - CreateChild(request, response).Observe(stopWatch.GetElapsedTime().TotalSeconds); - }); + Stream oldStream = await response.Content.ReadAsStreamAsync(); - return response; - } + Wrap(response, oldStream, delegate + { + CreateChild(request, response).Observe(stopWatch.GetElapsedTime().TotalSeconds); + }); - protected override string[] DefaultLabels => HttpClientRequestLabelNames.All; + return response; + } - protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateHistogram( - "httpclient_response_duration_seconds", - "Duration histogram of HTTP requests performed by an HttpClient, measuring the duration until the HTTP response finished being processed.", - labelNames, - new HistogramConfiguration - { - // 1 ms to 32K ms buckets - Buckets = Histogram.ExponentialBuckets(0.001, 2, 16), - }); + protected override string[] DefaultLabels => HttpClientRequestLabelNames.All; - private void Wrap(HttpResponseMessage response, Stream oldStream, Action onEndOfStream) + protected override ICollector CreateMetricInstance(string[] labelNames) => MetricFactory.CreateHistogram( + "httpclient_response_duration_seconds", + "Duration histogram of HTTP requests performed by an HttpClient, measuring the duration until the HTTP response finished being processed.", + labelNames, + new HistogramConfiguration { - var newContent = new StreamContent(new EndOfStreamDetectingStream(oldStream, onEndOfStream)); + // 1 ms to 32K ms buckets + Buckets = Histogram.ExponentialBuckets(0.001, 2, 16), + }); + + private void Wrap(HttpResponseMessage response, Stream oldStream, Action onEndOfStream) + { + var newContent = new StreamContent(new EndOfStreamDetectingStream(oldStream, onEndOfStream)); - var oldHeaders = response.Content.Headers; - var newHeaders = newContent.Headers; + var oldHeaders = response.Content.Headers; + var newHeaders = newContent.Headers; #if NET6_0_OR_GREATER - foreach (KeyValuePair header in oldHeaders.NonValidated) + foreach (KeyValuePair header in oldHeaders.NonValidated) + { + if (header.Value.Count > 1) { - if (header.Value.Count > 1) - { - newHeaders.TryAddWithoutValidation(header.Key, header.Value); - } - else - { - newHeaders.TryAddWithoutValidation(header.Key, header.Value.ToString()); - } + newHeaders.TryAddWithoutValidation(header.Key, header.Value); } -#else - foreach (var header in oldHeaders) + else { - newHeaders.TryAddWithoutValidation(header.Key, header.Value); + newHeaders.TryAddWithoutValidation(header.Key, header.Value.ToString()); } + } +#else + foreach (var header in oldHeaders) + { + newHeaders.TryAddWithoutValidation(header.Key, header.Value); + } #endif - response.Content = newContent; + response.Content = newContent; + } + + private sealed class EndOfStreamDetectingStream : Stream + { + public EndOfStreamDetectingStream(Stream inner, Action onEndOfStream) + { + _inner = inner; + _onEndOfStream = onEndOfStream; } - private sealed class EndOfStreamDetectingStream : Stream + private readonly Stream _inner; + private readonly Action _onEndOfStream; + private int _sawEndOfStream = 0; + + public override void Flush() => _inner.Flush(); + + public override int Read(byte[] buffer, int offset, int count) { - public EndOfStreamDetectingStream(Stream inner, Action onEndOfStream) + var read = _inner.Read(buffer, offset, count); + + if (read == 0 && buffer.Length != 0) { - _inner = inner; - _onEndOfStream = onEndOfStream; + SignalCompletion(); } - private readonly Stream _inner; - private readonly Action _onEndOfStream; - private int _sawEndOfStream = 0; + return read; + } - public override void Flush() => _inner.Flush(); + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return buffer.Length == 0 + ? _inner.ReadAsync(buffer, offset, count, cancellationToken) + : ReadAsyncCore(this, _inner.ReadAsync(buffer, offset, count, cancellationToken)); - public override int Read(byte[] buffer, int offset, int count) + static async Task ReadAsyncCore(EndOfStreamDetectingStream stream, Task readTask) { - var read = _inner.Read(buffer, offset, count); + int read = await readTask; - if (read == 0 && buffer.Length != 0) + if (read == 0) { - SignalCompletion(); + stream.SignalCompletion(); } return read; } + } - public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - return buffer.Length == 0 - ? _inner.ReadAsync(buffer, offset, count, cancellationToken) - : ReadAsyncCore(this, _inner.ReadAsync(buffer, offset, count, cancellationToken)); - - static async Task ReadAsyncCore(EndOfStreamDetectingStream stream, Task readTask) - { - int read = await readTask; - - if (read == 0) - { - stream.SignalCompletion(); - } - - return read; - } - } - - protected override void Dispose(bool disposing) + protected override void Dispose(bool disposing) + { + if (disposing) { - if (disposing) - { - SignalCompletion(); + SignalCompletion(); - _inner.Dispose(); - } + _inner.Dispose(); } + } - private void SignalCompletion() + private void SignalCompletion() + { + if (Interlocked.Exchange(ref _sawEndOfStream, 1) == 0) { - if (Interlocked.Exchange(ref _sawEndOfStream, 1) == 0) - { - _onEndOfStream(); - } + _onEndOfStream(); } - - public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin); - public override void SetLength(long value) => _inner.SetLength(value); - public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count); - public override bool CanRead => _inner.CanRead; - public override bool CanSeek => _inner.CanSeek; - public override bool CanWrite => _inner.CanWrite; - public override long Length => _inner.Length; - public override long Position { get => _inner.Position; set => _inner.Position = value; } } + + public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin); + public override void SetLength(long value) => _inner.SetLength(value); + public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count); + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => _inner.CanSeek; + public override bool CanWrite => _inner.CanWrite; + public override long Length => _inner.Length; + public override long Position { get => _inner.Position; set => _inner.Position = value; } } } \ No newline at end of file diff --git a/Prometheus/HttpClientMetrics/HttpClientResponseDurationOptions.cs b/Prometheus/HttpClientMetrics/HttpClientResponseDurationOptions.cs index 0bd2c68f..39f53bb5 100644 --- a/Prometheus/HttpClientMetrics/HttpClientResponseDurationOptions.cs +++ b/Prometheus/HttpClientMetrics/HttpClientResponseDurationOptions.cs @@ -1,10 +1,9 @@ -namespace Prometheus.HttpClientMetrics +namespace Prometheus.HttpClientMetrics; + +public sealed class HttpClientResponseDurationOptions : HttpClientMetricsOptionsBase { - public sealed class HttpClientResponseDurationOptions : HttpClientMetricsOptionsBase - { - /// - /// Set this to use a custom metric instead of the default. - /// - public ICollector? Histogram { get; set; } - } + /// + /// Set this to use a custom metric instead of the default. + /// + public ICollector? Histogram { get; set; } } \ No newline at end of file diff --git a/Prometheus/HttpClientMetricsExtensions.cs b/Prometheus/HttpClientMetricsExtensions.cs index 8ca20250..ab529d0f 100644 --- a/Prometheus/HttpClientMetricsExtensions.cs +++ b/Prometheus/HttpClientMetricsExtensions.cs @@ -1,54 +1,100 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; using Prometheus.HttpClientMetrics; -namespace Prometheus +namespace Prometheus; + +public static class HttpClientMetricsExtensions { - public static class HttpClientMetricsExtensions + /// + /// Configures the HttpClient pipeline to collect Prometheus metrics. + /// + public static IHttpClientBuilder UseHttpClientMetrics(this IHttpClientBuilder builder, Action configure) { - /// - /// Configures the HttpClient pipeline to collect Prometheus metrics. - /// - public static IHttpClientBuilder UseHttpClientMetrics(this IHttpClientBuilder builder, Action configure) - { - var options = new HttpClientExporterOptions(); + var options = new HttpClientExporterOptions(); - configure?.Invoke(options); + configure?.Invoke(options); - builder.UseHttpClientMetrics(options); + builder.UseHttpClientMetrics(options); - return builder; + return builder; + } + + /// + /// Configures the HttpClient pipeline to collect Prometheus metrics. + /// + public static IHttpClientBuilder UseHttpClientMetrics(this IHttpClientBuilder builder, HttpClientExporterOptions? options = null) + { + options ??= new HttpClientExporterOptions(); + + var identity = new HttpClientIdentity(builder.Name); + + if (options.InProgress.Enabled) + { + builder = builder.AddHttpMessageHandler(x => new HttpClientInProgressHandler(options.InProgress, identity)); } - /// - /// Configures the HttpClient pipeline to collect Prometheus metrics. - /// - public static IHttpClientBuilder UseHttpClientMetrics(this IHttpClientBuilder builder, HttpClientExporterOptions? options = null) + if (options.RequestCount.Enabled) { - options ??= new HttpClientExporterOptions(); + builder = builder.AddHttpMessageHandler(x => new HttpClientRequestCountHandler(options.RequestCount, identity)); + } - var identity = new HttpClientIdentity(builder.Name); + if (options.RequestDuration.Enabled) + { + builder = builder.AddHttpMessageHandler(x => new HttpClientRequestDurationHandler(options.RequestDuration, identity)); + } - if (options.InProgress.Enabled) - { - builder = builder.AddHttpMessageHandler(x => new HttpClientInProgressHandler(options.InProgress, identity)); - } + if (options.ResponseDuration.Enabled) + { + builder = builder.AddHttpMessageHandler(x => new HttpClientResponseDurationHandler(options.ResponseDuration, identity)); + } - if (options.RequestCount.Enabled) - { - builder = builder.AddHttpMessageHandler(x => new HttpClientRequestCountHandler(options.RequestCount, identity)); - } + return builder; + } - if (options.RequestDuration.Enabled) - { - builder = builder.AddHttpMessageHandler(x => new HttpClientRequestDurationHandler(options.RequestDuration, identity)); - } + /// + /// Configures the HttpMessageHandler pipeline to collect Prometheus metrics. + /// + public static HttpMessageHandlerBuilder UseHttpClientMetrics(this HttpMessageHandlerBuilder builder, HttpClientExporterOptions? options = null) + { + options ??= new HttpClientExporterOptions(); - if (options.ResponseDuration.Enabled) - { - builder = builder.AddHttpMessageHandler(x => new HttpClientResponseDurationHandler(options.ResponseDuration, identity)); - } + var identity = new HttpClientIdentity(builder.Name); - return builder; + if (options.InProgress.Enabled) + { + builder.AdditionalHandlers.Add(new HttpClientInProgressHandler(options.InProgress, identity)); + } + + if (options.RequestCount.Enabled) + { + builder.AdditionalHandlers.Add(new HttpClientRequestCountHandler(options.RequestCount, identity)); } + + if (options.RequestDuration.Enabled) + { + builder.AdditionalHandlers.Add(new HttpClientRequestDurationHandler(options.RequestDuration, identity)); + } + + if (options.ResponseDuration.Enabled) + { + builder.AdditionalHandlers.Add(new HttpClientResponseDurationHandler(options.ResponseDuration, identity)); + } + + return builder; + } + + /// + /// Configures the service container to collect Prometheus metrics from all registered HttpClients. + /// + public static IServiceCollection UseHttpClientMetrics(this IServiceCollection services, HttpClientExporterOptions? options = null) + { + return services.ConfigureAll((HttpClientFactoryOptions optionsToConfigure) => + { + optionsToConfigure.HttpMessageHandlerBuilderActions.Add(builder => + { + builder.UseHttpClientMetrics(options); + }); + }); } } \ No newline at end of file diff --git a/Prometheus/ICollector.cs b/Prometheus/ICollector.cs index ffc1a8e5..6b162fbc 100644 --- a/Prometheus/ICollector.cs +++ b/Prometheus/ICollector.cs @@ -1,22 +1,23 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// Child-type-specific interface implemented by all collectors, used to enable substitution in test code. +/// +public interface ICollector : ICollector + where TChild : ICollectorChild { - /// - /// Child-type-specific interface implemented by all collectors, used to enable substitution in test code. - /// - public interface ICollector : ICollector - where TChild : ICollectorChild - { - TChild Unlabelled { get; } - TChild WithLabels(params string[] labelValues); - } + TChild Unlabelled { get; } + TChild WithLabels(params string[] labelValues); + TChild WithLabels(ReadOnlyMemory labelValues); + TChild WithLabels(ReadOnlySpan labelValues); +} - /// - /// Interface implemented by all collectors, used to enable substitution in test code. - /// - public interface ICollector - { - string Name { get; } - string Help { get; } - string[] LabelNames { get; } - } +/// +/// Interface implemented by all collectors, used to enable substitution in test code. +/// +public interface ICollector +{ + string Name { get; } + string Help { get; } + string[] LabelNames { get; } } diff --git a/Prometheus/ICollectorChild.cs b/Prometheus/ICollectorChild.cs index 422ea653..c7dc32e6 100644 --- a/Prometheus/ICollectorChild.cs +++ b/Prometheus/ICollectorChild.cs @@ -1,9 +1,8 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// Interface shared by all labelled collector children. +/// +public interface ICollectorChild { - /// - /// Interface shared by all labelled collector children. - /// - public interface ICollectorChild - { - } } diff --git a/Prometheus/ICollectorRegistry.cs b/Prometheus/ICollectorRegistry.cs index d6ec5c94..bca0bf06 100644 --- a/Prometheus/ICollectorRegistry.cs +++ b/Prometheus/ICollectorRegistry.cs @@ -1,17 +1,16 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// Allows for substitution of CollectorRegistry in tests. +/// Not used by prometheus-net itself - you cannot provide your own implementation to prometheus-net code, only to your own code. +/// +public interface ICollectorRegistry { - /// - /// Allows for substitution of CollectorRegistry in tests. - /// Not used by prometheus-net itself - you cannot provide your own implementation to prometheus-net code, only to your own code. - /// - public interface ICollectorRegistry - { - void AddBeforeCollectCallback(Action callback); - void AddBeforeCollectCallback(Func callback); + void AddBeforeCollectCallback(Action callback); + void AddBeforeCollectCallback(Func callback); - IEnumerable> StaticLabels { get; } - void SetStaticLabels(IDictionary labels); + IEnumerable> StaticLabels { get; } + void SetStaticLabels(IDictionary labels); - Task CollectAndExportAsTextAsync(Stream to, CancellationToken cancel = default); - } + Task CollectAndExportAsTextAsync(Stream to, ExpositionFormat format = ExpositionFormat.PrometheusText, CancellationToken cancel = default); } diff --git a/Prometheus/ICounter.cs b/Prometheus/ICounter.cs index 8803d4be..658e0033 100644 --- a/Prometheus/ICounter.cs +++ b/Prometheus/ICounter.cs @@ -1,9 +1,34 @@ -namespace Prometheus +namespace Prometheus; + +public interface ICounter : ICollectorChild { - public interface ICounter : ICollectorChild - { - void Inc(double increment = 1); - void IncTo(double targetValue); - double Value { get; } - } + /// + /// Increment a counter by 1. + /// + void Inc(double increment = 1.0); + + /// + /// Increment a counter by 1. + /// + /// + /// A set of labels representing an exemplar, created using Exemplar.From(). + /// If null, the default exemplar provider associated with the metric is asked to provide an exemplar. + /// Pass Exemplar.None to explicitly record an observation without an exemplar. + /// + void Inc(Exemplar? exemplar); + + /// + /// Increment a counter. + /// + /// The increment. + /// + /// A set of labels representing an exemplar, created using Exemplar.From(). + /// If null, the default exemplar provider associated with the metric is asked to provide an exemplar. + /// Pass Exemplar.None to explicitly record an observation without an exemplar. + /// + void Inc(double increment, Exemplar? exemplar); + + void IncTo(double targetValue); + + double Value { get; } } diff --git a/Prometheus/IDelayer.cs b/Prometheus/IDelayer.cs index c25a5bf9..7c121534 100644 --- a/Prometheus/IDelayer.cs +++ b/Prometheus/IDelayer.cs @@ -1,11 +1,10 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// Abstraction over Task.Delay() to allow custom delay logic to be injected in tests. +/// +internal interface IDelayer { - /// - /// Abstraction over Task.Delay() to allow custom delay logic to be injected in tests. - /// - internal interface IDelayer - { - Task Delay(TimeSpan duration); - Task Delay(TimeSpan duration, CancellationToken cancel); - } + Task Delay(TimeSpan duration); + Task Delay(TimeSpan duration, CancellationToken cancel); } diff --git a/Prometheus/IGauge.cs b/Prometheus/IGauge.cs index ac5c50fd..2e7ebda7 100644 --- a/Prometheus/IGauge.cs +++ b/Prometheus/IGauge.cs @@ -1,12 +1,11 @@ -namespace Prometheus +namespace Prometheus; + +public interface IGauge : ICollectorChild { - public interface IGauge : ICollectorChild - { - void Inc(double increment = 1); - void Set(double val); - void Dec(double decrement = 1); - void IncTo(double targetValue); - void DecTo(double targetValue); - double Value { get; } - } + void Inc(double increment = 1); + void Set(double val); + void Dec(double decrement = 1); + void IncTo(double targetValue); + void DecTo(double targetValue); + double Value { get; } } diff --git a/Prometheus/IHistogram.cs b/Prometheus/IHistogram.cs index 83c88dc9..72508f39 100644 --- a/Prometheus/IHistogram.cs +++ b/Prometheus/IHistogram.cs @@ -1,24 +1,34 @@ -namespace Prometheus +namespace Prometheus; + +public interface IHistogram : IObserver { - public interface IHistogram : IObserver - { - /// - /// Observe multiple events with a given value. - /// - /// Intended to support high frequency or batch processing use cases utilizing pre-aggregation. - /// - /// Measured value. - /// Number of observations with this value. - void Observe(double val, long count); + /// + /// Observe multiple events with a given value. + /// + /// Intended to support high frequency or batch processing use cases utilizing pre-aggregation. + /// + /// Measured value. + /// Number of observations with this value. + void Observe(double val, long count); - /// - /// Gets the sum of all observed events. - /// - double Sum { get; } + /// + /// Observe an event with an exemplar + /// + /// Measured value. + /// + /// A set of labels representing an exemplar, created using Exemplar.From(). + /// If null, the default exemplar provider associated with the metric is asked to provide an exemplar. + /// Pass Exemplar.None to explicitly record an observation without an exemplar. + /// + void Observe(double val, Exemplar? exemplar); + + /// + /// Gets the sum of all observed events. + /// + double Sum { get; } - /// - /// Gets the count of all observed events. - /// - long Count { get; } - } + /// + /// Gets the count of all observed events. + /// + long Count { get; } } diff --git a/Prometheus/IManagedLifetimeMetricFactory.cs b/Prometheus/IManagedLifetimeMetricFactory.cs index 2c856f17..ee9f9c4e 100644 --- a/Prometheus/IManagedLifetimeMetricFactory.cs +++ b/Prometheus/IManagedLifetimeMetricFactory.cs @@ -1,37 +1,42 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// A metric factory for creating metrics that use a managed lifetime, whereby the metric may +/// be deleted based on logic other than disposal or similar explicit deletion. +/// +/// +/// The lifetime management logic is associated with a metric handle. Calling CreateXyz() with equivalent identity parameters will return +/// the same handle. However, using multiple factories will create independent handles (which will delete the same metric independently). +/// +public interface IManagedLifetimeMetricFactory { /// - /// A metric factory for creating metrics that use a managed lifetime, whereby the metric may - /// be unpublished based on logic other than disposal or similar explicit unpublishing. + /// Creates a metric with a lease-extended lifetime. + /// A timeseries will expire N seconds after the last lease is released, with N determined at factory create-time. /// - /// - /// The lifetime management logic is associated with a metric handle. Calling CreateXyz() with equivalent identity parameters will return - /// the same handle. However, using multiple factories will create independent handles (which will unpublish the same metric independently). - /// - public interface IManagedLifetimeMetricFactory - { - /// - /// Creates a metric with a lease-extended lifetime. - /// A timeseries will expire N seconds after the last lease is released, with N determined at factory create-time. - /// - IManagedLifetimeMetricHandle CreateCounter(string name, string help, string[] labelNames, CounterConfiguration? configuration = null); + IManagedLifetimeMetricHandle CreateCounter(string name, string help, string[]? labelNames = null, CounterConfiguration? configuration = null); - /// - /// Creates a metric with a lease-extended lifetime. - /// A timeseries will expire N seconds after the last lease is released, with N determined at factory create-time. - /// - IManagedLifetimeMetricHandle CreateGauge(string name, string help, string[] labelNames, GaugeConfiguration? configuration = null); + /// + /// Creates a metric with a lease-extended lifetime. + /// A timeseries will expire N seconds after the last lease is released, with N determined at factory create-time. + /// + IManagedLifetimeMetricHandle CreateGauge(string name, string help, string[]? labelNames = null, GaugeConfiguration? configuration = null); + + /// + /// Creates a metric with a lease-extended lifetime. + /// A timeseries will expire N seconds after the last lease is released, with N determined at factory create-time. + /// + IManagedLifetimeMetricHandle CreateHistogram(string name, string help, string[]? labelNames = null, HistogramConfiguration? configuration = null); - /// - /// Creates a metric with a lease-extended lifetime. - /// A timeseries will expire N seconds after the last lease is released, with N determined at factory create-time. - /// - IManagedLifetimeMetricHandle CreateHistogram(string name, string help, string[] labelNames, HistogramConfiguration? configuration = null); + /// + /// Creates a metric with a lease-extended lifetime. + /// A timeseries will expire N seconds after the last lease is released, with N determined at factory create-time. + /// + IManagedLifetimeMetricHandle CreateSummary(string name, string help, string[]? labelNames = null, SummaryConfiguration? configuration = null); - /// - /// Creates a metric with a lease-extended lifetime. - /// A timeseries will expire N seconds after the last lease is released, with N determined at factory create-time. - /// - IManagedLifetimeMetricHandle CreateSummary(string name, string help, string[] labelNames, SummaryConfiguration? configuration = null); - } + /// + /// Returns a new metric factory that will add the specified labels to any metrics created using it. + /// Different instances returned for the same labels are equivalent and any metrics created via them share their lifetimes. + /// + IManagedLifetimeMetricFactory WithLabels(IDictionary labels); } diff --git a/Prometheus/IManagedLifetimeMetricHandle.cs b/Prometheus/IManagedLifetimeMetricHandle.cs index ddf254be..0112589e 100644 --- a/Prometheus/IManagedLifetimeMetricHandle.cs +++ b/Prometheus/IManagedLifetimeMetricHandle.cs @@ -2,11 +2,16 @@ /// /// Handle to a metric with a lease-extended lifetime, enabling the metric to be accessed and its lifetime to be controlled. -/// Each label combination is automatically unpublished N seconds after the last lease on that label combination expires. +/// Each label combination is automatically deleted N seconds after the last lease on that label combination expires. /// +/// +/// When creating leases, prefer the overload that takes a ReadOnlySpan because it avoids +/// allocating a string array if the metric instance you are leasing is already alive. +/// public interface IManagedLifetimeMetricHandle where TMetricInterface : ICollectorChild { + #region Lease(string[]) /// /// Takes a lifetime-extending lease on the metric, scoped to a specific combination of label values. /// @@ -19,6 +24,19 @@ public interface IManagedLifetimeMetricHandle /// IDisposable AcquireLease(out TMetricInterface metric, params string[] labelValues); + /// + /// Takes a lifetime-extending lease on the metric, scoped to a specific combination of label values. + /// The lease is returned as a stack-only struct, which is faster than the IDisposable version. + /// + /// The typical pattern is that the metric value is only modified when the caller is holding a lease on the metric. + /// Automatic removal of the metric will not occur until all leases on the metric are disposed and the expiration duration elapses. + /// + /// + /// Acquiring a new lease after the metric has been removed will re-publish the metric without preserving the old value. + /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). + /// + RefLease AcquireRefLease(out TMetricInterface metric, params string[] labelValues); + /// /// While executing an action, holds a lifetime-extending lease on the metric, scoped to a specific combination of label values. /// @@ -31,6 +49,19 @@ public interface IManagedLifetimeMetricHandle /// void WithLease(Action action, params string[] labelValues); + /// + /// While executing an action, holds a lifetime-extending lease on the metric, scoped to a specific combination of label values. + /// Passes a given argument to the callback. + /// + /// The typical pattern is that the metric value is only modified when the caller is holding a lease on the metric. + /// Automatic removal of the metric will not occur until all leases on the metric are disposed and the expiration duration elapses. + /// + /// + /// Acquiring a new lease after the metric has been removed will re-publish the metric without preserving the old value. + /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). + /// + void WithLease(Action action, TArg arg, params string[] labelValues); + /// /// While executing an action, holds a lifetime-extending lease on the metric, scoped to a specific combination of label values. /// @@ -66,14 +97,167 @@ public interface IManagedLifetimeMetricHandle /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). /// Task WithLeaseAsync(Func> action, params string[] labelValues); + #endregion + + #region Lease(ReadOnlyMemory) + /// + /// Takes a lifetime-extending lease on the metric, scoped to a specific combination of label values. + /// + /// The typical pattern is that the metric value is only modified when the caller is holding a lease on the metric. + /// Automatic removal of the metric will not occur until all leases on the metric are disposed and the expiration duration elapses. + /// + /// + /// Acquiring a new lease after the metric has been removed will re-publish the metric without preserving the old value. + /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). + /// + IDisposable AcquireLease(out TMetricInterface metric, ReadOnlyMemory labelValues); + + /// + /// Takes a lifetime-extending lease on the metric, scoped to a specific combination of label values. + /// The lease is returned as a stack-only struct, which is faster than the IDisposable version. + /// + /// The typical pattern is that the metric value is only modified when the caller is holding a lease on the metric. + /// Automatic removal of the metric will not occur until all leases on the metric are disposed and the expiration duration elapses. + /// + /// + /// Acquiring a new lease after the metric has been removed will re-publish the metric without preserving the old value. + /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). + /// + RefLease AcquireRefLease(out TMetricInterface metric, ReadOnlyMemory labelValues); + + /// + /// While executing an action, holds a lifetime-extending lease on the metric, scoped to a specific combination of label values. + /// + /// The typical pattern is that the metric value is only modified when the caller is holding a lease on the metric. + /// Automatic removal of the metric will not occur until all leases on the metric are disposed and the expiration duration elapses. + /// + /// + /// Acquiring a new lease after the metric has been removed will re-publish the metric without preserving the old value. + /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). + /// + void WithLease(Action action, ReadOnlyMemory labelValues); + + /// + /// While executing an action, holds a lifetime-extending lease on the metric, scoped to a specific combination of label values. + /// Passes a given argument to the callback. + /// + /// The typical pattern is that the metric value is only modified when the caller is holding a lease on the metric. + /// Automatic removal of the metric will not occur until all leases on the metric are disposed and the expiration duration elapses. + /// + /// + /// Acquiring a new lease after the metric has been removed will re-publish the metric without preserving the old value. + /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). + /// + void WithLease(Action action, TArg arg, ReadOnlyMemory labelValues); + + /// + /// While executing an action, holds a lifetime-extending lease on the metric, scoped to a specific combination of label values. + /// + /// The typical pattern is that the metric value is only modified when the caller is holding a lease on the metric. + /// Automatic removal of the metric will not occur until all leases on the metric are disposed and the expiration duration elapses. + /// + /// + /// Acquiring a new lease after the metric has been removed will re-publish the metric without preserving the old value. + /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). + /// + Task WithLeaseAsync(Func func, ReadOnlyMemory labelValues); + + + /// + /// While executing a function, holds a lifetime-extending lease on the metric, scoped to a specific combination of label values. + /// + /// The typical pattern is that the metric value is only modified when the caller is holding a lease on the metric. + /// Automatic removal of the metric will not occur until all leases on the metric are disposed and the expiration duration elapses. + /// + /// + /// Acquiring a new lease after the metric has been removed will re-publish the metric without preserving the old value. + /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). + /// + TResult WithLease(Func func, ReadOnlyMemory labelValues); + + /// + /// While executing a function, holds a lifetime-extending lease on the metric, scoped to a specific combination of label values. + /// + /// The typical pattern is that the metric value is only modified when the caller is holding a lease on the metric. + /// Automatic removal of the metric will not occur until all leases on the metric are disposed and the expiration duration elapses. + /// + /// + /// Acquiring a new lease after the metric has been removed will re-publish the metric without preserving the old value. + /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). + /// + Task WithLeaseAsync(Func> action, ReadOnlyMemory labelValues); + #endregion + + #region Lease(ReadOnlySpan) + /// + /// Takes a lifetime-extending lease on the metric, scoped to a specific combination of label values. + /// + /// The typical pattern is that the metric value is only modified when the caller is holding a lease on the metric. + /// Automatic removal of the metric will not occur until all leases on the metric are disposed and the expiration duration elapses. + /// + /// + /// Acquiring a new lease after the metric has been removed will re-publish the metric without preserving the old value. + /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). + /// + IDisposable AcquireLease(out TMetricInterface metric, ReadOnlySpan labelValues); + + /// + /// Takes a lifetime-extending lease on the metric, scoped to a specific combination of label values. + /// The lease is returned as a stack-only struct, which is faster than the IDisposable version. + /// + /// The typical pattern is that the metric value is only modified when the caller is holding a lease on the metric. + /// Automatic removal of the metric will not occur until all leases on the metric are disposed and the expiration duration elapses. + /// + /// + /// Acquiring a new lease after the metric has been removed will re-publish the metric without preserving the old value. + /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). + /// + RefLease AcquireRefLease(out TMetricInterface metric, ReadOnlySpan labelValues); + + /// + /// While executing an action, holds a lifetime-extending lease on the metric, scoped to a specific combination of label values. + /// + /// The typical pattern is that the metric value is only modified when the caller is holding a lease on the metric. + /// Automatic removal of the metric will not occur until all leases on the metric are disposed and the expiration duration elapses. + /// + /// + /// Acquiring a new lease after the metric has been removed will re-publish the metric without preserving the old value. + /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). + /// + void WithLease(Action action, ReadOnlySpan labelValues); + + /// + /// While executing an action, holds a lifetime-extending lease on the metric, scoped to a specific combination of label values. + /// Passes a given argument to the callback. + /// + /// The typical pattern is that the metric value is only modified when the caller is holding a lease on the metric. + /// Automatic removal of the metric will not occur until all leases on the metric are disposed and the expiration duration elapses. + /// + /// + /// Acquiring a new lease after the metric has been removed will re-publish the metric without preserving the old value. + /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). + /// + void WithLease(Action action, TArg arg, ReadOnlySpan labelValues); + + /// + /// While executing a function, holds a lifetime-extending lease on the metric, scoped to a specific combination of label values. + /// + /// The typical pattern is that the metric value is only modified when the caller is holding a lease on the metric. + /// Automatic removal of the metric will not occur until all leases on the metric are disposed and the expiration duration elapses. + /// + /// + /// Acquiring a new lease after the metric has been removed will re-publish the metric without preserving the old value. + /// Re-publishing may return a new instance of the metric (data collected via expired instances will not be published). + /// + TResult WithLease(Func func, ReadOnlySpan labelValues); + #endregion /// /// Returns a metric instance that automatically extends the lifetime of the timeseries whenever the value is changed. /// This is equivalent to taking a lease for every update to the value, and immediately releasing the lease. /// - /// This is useful if: - /// 1) the caller does not perform any long-running operations that would require keeping a lease for more than 1 update; - /// 2) or if the caller is lifetime-management-agnostic code that is not aware of the possibility to extend metric lifetime via leases. + /// This is useful if the caller is lifetime-management-agnostic code that is not aware of the possibility to extend metric lifetime via leases. + /// Do not use this if you can use explicit leases instead, as this is considerably less efficient. /// ICollector WithExtendLifetimeOnUse(); } diff --git a/Prometheus/IMetricFactory.cs b/Prometheus/IMetricFactory.cs index 691e7d27..b58fde35 100644 --- a/Prometheus/IMetricFactory.cs +++ b/Prometheus/IMetricFactory.cs @@ -1,43 +1,48 @@ using System.ComponentModel; -namespace Prometheus +namespace Prometheus; + +/// +/// Allows for substitution of MetricFactory in tests. +/// You cannot provide your own implementation to prometheus-net code, only to your own code. +/// +public interface IMetricFactory { + // These require you to allocate a Configuration for each instance, which can be wasteful because often the only thing that differs is the label names. + // We will mark them as non-browsable to discourage their use. They still work, so they are not obsolete or anything like that. Just discouraged. + [EditorBrowsable(EditorBrowsableState.Never)] + Counter CreateCounter(string name, string help, CounterConfiguration? configuration = null); + [EditorBrowsable(EditorBrowsableState.Never)] + Gauge CreateGauge(string name, string help, GaugeConfiguration? configuration = null); + [EditorBrowsable(EditorBrowsableState.Never)] + Histogram CreateHistogram(string name, string help, HistogramConfiguration? configuration = null); + [EditorBrowsable(EditorBrowsableState.Never)] + Summary CreateSummary(string name, string help, SummaryConfiguration? configuration = null); + + // These allow you to reuse a Configuration and only provide the label names. The reduced memory allocations can make a difference in high performance scenarios. + // If label names are provided in both, they must match. Otherwise, label names in the Configuration object may be null. + Counter CreateCounter(string name, string help, string[] labelNames, CounterConfiguration? configuration = null); + Gauge CreateGauge(string name, string help, string[] labelNames, GaugeConfiguration? configuration = null); + Histogram CreateHistogram(string name, string help, string[] labelNames, HistogramConfiguration? configuration = null); + Summary CreateSummary(string name, string help, string[] labelNames, SummaryConfiguration? configuration = null); + /// - /// Allows for substitution of MetricFactory in tests. - /// You cannot provide your own implementation to prometheus-net code, only to your own code. + /// Returns a new metric factory that will add the specified labels to any metrics created using it. /// - public interface IMetricFactory - { - // These require you to allocate a Configuration for each instance, which can be wasteful because often the only thing that differs is the label names. - // We will mark them as non-browsable to discourage their use. They still work, so they are not obsolete or anything like that. Just discouraged. - [EditorBrowsable(EditorBrowsableState.Never)] - Counter CreateCounter(string name, string help, CounterConfiguration? configuration = null); - [EditorBrowsable(EditorBrowsableState.Never)] - Gauge CreateGauge(string name, string help, GaugeConfiguration? configuration = null); - [EditorBrowsable(EditorBrowsableState.Never)] - Histogram CreateHistogram(string name, string help, HistogramConfiguration? configuration = null); - [EditorBrowsable(EditorBrowsableState.Never)] - Summary CreateSummary(string name, string help, SummaryConfiguration? configuration = null); - - // These allow you to reuse a Configuration and only provide the label names. The reduced memory allocations can make a difference in high performance scenarios. - // If label names are provided in both, they must match. Otherwise, label names in the Configuration object may be null. - Counter CreateCounter(string name, string help, string[] labelNames, CounterConfiguration? configuration = null); - Gauge CreateGauge(string name, string help, string[] labelNames, GaugeConfiguration? configuration = null); - Histogram CreateHistogram(string name, string help, string[] labelNames, HistogramConfiguration? configuration = null); - Summary CreateSummary(string name, string help, string[] labelNames, SummaryConfiguration? configuration = null); + IMetricFactory WithLabels(IDictionary labels); - /// - /// Returns a new metric factory that will add the specified labels to any metrics created using it. - /// - IMetricFactory WithLabels(IDictionary labels); + /// + /// Returns a factory that creates metrics with a managed lifetime. + /// + /// + /// Metrics created from this factory will expire after this time span elapses, enabling automatic deletion of unused metrics. + /// The expiration timer is reset to zero for the duration of any active lifetime-extension lease that is taken on a specific metric. + /// + IManagedLifetimeMetricFactory WithManagedLifetime(TimeSpan expiresAfter); - /// - /// Returns a factory that creates metrics with a managed lifetime. - /// - /// - /// Metrics created from this factory will expire after this time span elapses, enabling automatic unpublishing of unused metrics. - /// The expiration timer is reset to zero for the duration of any active lifetime-extension lease that is taken on a specific metric. - /// - IManagedLifetimeMetricFactory WithManagedLifetime(TimeSpan expiresAfter); - } + /// + /// Allows you to configure how exemplars are applied to published metrics. If null, uses default behavior (see ). + /// This is inherited by all metrics by default, although may be overridden in the configuration of an individual metric. + /// + ExemplarBehavior? ExemplarBehavior { get; set; } } diff --git a/Prometheus/IMetricServer.cs b/Prometheus/IMetricServer.cs index be038fc3..34652a49 100644 --- a/Prometheus/IMetricServer.cs +++ b/Prometheus/IMetricServer.cs @@ -1,26 +1,25 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// A metric server exposes a Prometheus metric exporter endpoint in the background, +/// operating independently and serving metrics until it is instructed to stop. +/// +public interface IMetricServer : IDisposable { /// - /// A metric server exposes a Prometheus metric exporter endpoint in the background, - /// operating independently and serving metrics until it is instructed to stop. + /// Starts serving metrics. + /// + /// Returns the same instance that was called (for fluent-API-style chaining). /// - public interface IMetricServer : IDisposable - { - /// - /// Starts serving metrics. - /// - /// Returns the same instance that was called (for fluent-API-style chaining). - /// - IMetricServer Start(); + IMetricServer Start(); - /// - /// Instructs the metric server to stop and returns a task you can await for it to stop. - /// - Task StopAsync(); + /// + /// Instructs the metric server to stop and returns a task you can await for it to stop. + /// + Task StopAsync(); - /// - /// Instructs the metric server to stop and waits for it to stop. - /// - void Stop(); - } + /// + /// Instructs the metric server to stop and waits for it to stop. + /// + void Stop(); } diff --git a/Prometheus/IMetricsSerializer.cs b/Prometheus/IMetricsSerializer.cs index 789a99ef..c1701503 100644 --- a/Prometheus/IMetricsSerializer.cs +++ b/Prometheus/IMetricsSerializer.cs @@ -1,25 +1,37 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// The only purpose this serves is to warn the developer when he might be accidentally introducing +/// new serialization-time relationships. The serialization code is very tied to the text format and +/// not intended to be a generic serialization mechanism. +/// +internal interface IMetricsSerializer { /// - /// The only purpose this serves is to warn the developer when he might be accidentally introducing - /// new serialization-time relationships. The serialization code is very tied to the text format and - /// not intended to be a generic serialization mechanism. + /// Writes the lines that declare the metric family. + /// + ValueTask WriteFamilyDeclarationAsync(string name, byte[] nameBytes, byte[] helpBytes, MetricType type, + byte[] typeBytes, CancellationToken cancel); + + /// + /// Writes out a single metric point with a floating point value. /// - internal interface IMetricsSerializer - { - /// - /// Writes the lines that declare the metric family. - /// - Task WriteFamilyDeclarationAsync(byte[][] headerLines, CancellationToken cancel); + ValueTask WriteMetricPointAsync(byte[] name, byte[] flattenedLabels, CanonicalLabel extraLabel, + double value, ObservedExemplar exemplar, byte[]? suffix, CancellationToken cancel); - /// - /// Writes a single metric in a metric family. - /// - Task WriteMetricAsync(byte[] identifier, double value, CancellationToken cancel); + /// + /// Writes out a single metric point with an integer value. + /// + ValueTask WriteMetricPointAsync(byte[] name, byte[] flattenedLabels, CanonicalLabel extraLabel, + long value, ObservedExemplar exemplar, byte[]? suffix, CancellationToken cancel); - /// - /// Flushes any pending buffers. Always call this after all your write calls. - /// - Task FlushAsync(CancellationToken cancel); - } -} + /// + /// Writes out terminal lines + /// + ValueTask WriteEnd(CancellationToken cancel); + + /// + /// Flushes any pending buffers. Always call this after all your write calls. + /// + Task FlushAsync(CancellationToken cancel); +} \ No newline at end of file diff --git a/Prometheus/INotifyLeaseEnded.cs b/Prometheus/INotifyLeaseEnded.cs new file mode 100644 index 00000000..d402a518 --- /dev/null +++ b/Prometheus/INotifyLeaseEnded.cs @@ -0,0 +1,6 @@ +namespace Prometheus; + +internal interface INotifyLeaseEnded +{ + void OnLeaseEnded(object child, ChildLifetimeInfo lifetime); +} diff --git a/Prometheus/IObserver.cs b/Prometheus/IObserver.cs index 70b70393..a5ffd912 100644 --- a/Prometheus/IObserver.cs +++ b/Prometheus/IObserver.cs @@ -1,13 +1,12 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// Implemented by metric types that observe individual events with specific values. +/// +public interface IObserver : ICollectorChild { /// - /// Implemented by metric types that observe individual events with specific values. + /// Observes a single event with the given value. /// - public interface IObserver : ICollectorChild - { - /// - /// Observes a single event with the given value. - /// - void Observe(double val); - } + void Observe(double val); } \ No newline at end of file diff --git a/Prometheus/ISummary.cs b/Prometheus/ISummary.cs index aeed5840..a775d53c 100644 --- a/Prometheus/ISummary.cs +++ b/Prometheus/ISummary.cs @@ -1,6 +1,5 @@ -namespace Prometheus +namespace Prometheus; + +public interface ISummary : IObserver { - public interface ISummary : IObserver - { - } } diff --git a/Prometheus/ITimer.cs b/Prometheus/ITimer.cs index 922d343a..3334e836 100644 --- a/Prometheus/ITimer.cs +++ b/Prometheus/ITimer.cs @@ -1,15 +1,14 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// A timer that can be used to observe a duration of elapsed time. +/// +/// The observation is made either when ObserveDuration is called or when the instance is disposed of. +/// +public interface ITimer : IDisposable { /// - /// A timer that can be used to observe a duration of elapsed time. - /// - /// The observation is made either when ObserveDuration is called or when the instance is disposed of. + /// Observes the duration (in seconds) and returns the observed value. /// - public interface ITimer : IDisposable - { - /// - /// Observes the duration (in seconds) and returns the observed value. - /// - TimeSpan ObserveDuration(); - } + TimeSpan ObserveDuration(); } diff --git a/Prometheus/LabelEnrichingAutoLeasingMetric.cs b/Prometheus/LabelEnrichingAutoLeasingMetric.cs new file mode 100644 index 00000000..b7ad7f50 --- /dev/null +++ b/Prometheus/LabelEnrichingAutoLeasingMetric.cs @@ -0,0 +1,82 @@ +using System.Buffers; + +namespace Prometheus; + +internal sealed class LabelEnrichingAutoLeasingMetric : ICollector + where TMetric : ICollectorChild +{ + public LabelEnrichingAutoLeasingMetric(ICollector inner, string[] enrichWithLabelValues) + { + _inner = inner; + _enrichWithLabelValues = enrichWithLabelValues; + } + + private readonly ICollector _inner; + private readonly string[] _enrichWithLabelValues; + + public TMetric Unlabelled + { + get + { + // If we are not provided any custom label values, we can be pretty sure the label values are not going to change + // between calls, so reuse a buffer to avoid allocations when passing the data to the inner instance. + var buffer = ArrayPool.Shared.Rent(_enrichWithLabelValues.Length); + + try + { + _enrichWithLabelValues.CopyTo(buffer, 0); + var finalLabelValues = buffer.AsSpan(0, _enrichWithLabelValues.Length); + + return _inner.WithLabels(finalLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + + public string Name => _inner.Name; + public string Help => _inner.Help; + + // We do not display the enriched labels, they are transparent - this is only the instance-specific label names. + public string[] LabelNames => _inner.LabelNames; + + public TMetric WithLabels(params string[] labelValues) + { + // The caller passing us string[] does not signal that the allocation is not needed - in all likelihood it is. + // However, we do not want to allocate two arrays here (because we need to concatenate as well) so instead we + // use the reusable-buffer overload to avoid at least one of the allocations. + + return WithLabels(labelValues.AsSpan()); + } + + public TMetric WithLabels(ReadOnlyMemory labelValues) + { + // The caller passing us ReadOnlyMemory does not signal that the allocation is not needed - in all likelihood it is. + // However, we do not want to allocate two arrays here (because we need to concatenate as well) so instead we + // use the reusable-buffer overload to avoid at least one of the allocations. + + return WithLabels(labelValues.Span); + } + + public TMetric WithLabels(ReadOnlySpan labelValues) + { + // The ReadOnlySpan overload suggests that the label values may already be known to the metric, + // so we should strongly avoid allocating memory here. Thus we copy everything to a reusable buffer. + var buffer = ArrayPool.Shared.Rent(_enrichWithLabelValues.Length + labelValues.Length); + + try + { + _enrichWithLabelValues.CopyTo(buffer, 0); + labelValues.CopyTo(buffer.AsSpan(_enrichWithLabelValues.Length)); + var finalLabelValues = buffer.AsSpan(0, _enrichWithLabelValues.Length + labelValues.Length); + + return _inner.WithLabels(finalLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} diff --git a/Prometheus/LabelEnrichingManagedLifetimeCounter.cs b/Prometheus/LabelEnrichingManagedLifetimeCounter.cs new file mode 100644 index 00000000..e3ccad5e --- /dev/null +++ b/Prometheus/LabelEnrichingManagedLifetimeCounter.cs @@ -0,0 +1,201 @@ +using System.Buffers; + +namespace Prometheus; + +internal sealed class LabelEnrichingManagedLifetimeCounter : IManagedLifetimeMetricHandle +{ + public LabelEnrichingManagedLifetimeCounter(IManagedLifetimeMetricHandle inner, string[] enrichWithLabelValues) + { + _inner = inner; + _enrichWithLabelValues = enrichWithLabelValues; + } + + // Internal for manipulation during testing. + internal readonly IManagedLifetimeMetricHandle _inner; + private readonly string[] _enrichWithLabelValues; + + public ICollector WithExtendLifetimeOnUse() + { + return new LabelEnrichingAutoLeasingMetric(_inner.WithExtendLifetimeOnUse(), _enrichWithLabelValues); + } + + #region Lease(string[]) + public IDisposable AcquireLease(out ICounter metric, params string[] labelValues) + { + return _inner.AcquireLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public RefLease AcquireRefLease(out ICounter metric, params string[] labelValues) + { + return _inner.AcquireRefLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, params string[] labelValues) + { + _inner.WithLease(action, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, TArg arg, params string[] labelValues) + { + _inner.WithLease(action, arg, WithEnrichedLabelValues(labelValues)); + } + + public TResult WithLease(Func func, params string[] labelValues) + { + return _inner.WithLease(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func func, params string[] labelValues) + { + return _inner.WithLeaseAsync(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func> action, params string[] labelValues) + { + return _inner.WithLeaseAsync(action, WithEnrichedLabelValues(labelValues)); + } + #endregion + + #region Lease(ReadOnlyMemory) + public IDisposable AcquireLease(out ICounter metric, ReadOnlyMemory labelValues) + { + return _inner.AcquireLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public RefLease AcquireRefLease(out ICounter metric, ReadOnlyMemory labelValues) + { + return _inner.AcquireRefLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, ReadOnlyMemory labelValues) + { + _inner.WithLease(action, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, TArg arg, ReadOnlyMemory labelValues) + { + _inner.WithLease(action, arg, WithEnrichedLabelValues(labelValues)); + } + + public TResult WithLease(Func func, ReadOnlyMemory labelValues) + { + return _inner.WithLease(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func func, ReadOnlyMemory labelValues) + { + return _inner.WithLeaseAsync(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func> action, ReadOnlyMemory labelValues) + { + return _inner.WithLeaseAsync(action, WithEnrichedLabelValues(labelValues)); + } + #endregion + + private string[] WithEnrichedLabelValues(string[] instanceLabelValues) + { + var enriched = new string[_enrichWithLabelValues.Length + instanceLabelValues.Length]; + _enrichWithLabelValues.CopyTo(enriched, 0); + instanceLabelValues.CopyTo(enriched, _enrichWithLabelValues.Length); + + return enriched; + } + + private string[] WithEnrichedLabelValues(ReadOnlyMemory instanceLabelValues) + { + var enriched = new string[_enrichWithLabelValues.Length + instanceLabelValues.Length]; + _enrichWithLabelValues.CopyTo(enriched, 0); + instanceLabelValues.Span.CopyTo(enriched.AsSpan(_enrichWithLabelValues.Length)); + + return enriched; + } + + #region Lease(ReadOnlySpan) + public IDisposable AcquireLease(out ICounter metric, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + return _inner.AcquireLease(out metric, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public RefLease AcquireRefLease(out ICounter metric, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + return _inner.AcquireRefLease(out metric, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public void WithLease(Action action, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + _inner.WithLease(action, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public void WithLease(Action action, TArg arg, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + _inner.WithLease(action, arg, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public TResult WithLease(Func func, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + return _inner.WithLease(func, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + #endregion + + private string[] RentBufferForEnrichedLabelValues(ReadOnlySpan instanceLabelValues) + => ArrayPool.Shared.Rent(instanceLabelValues.Length + _enrichWithLabelValues.Length); + + private ReadOnlySpan AssembleEnrichedLabelValues(ReadOnlySpan instanceLabelValues, string[] buffer) + { + _enrichWithLabelValues.CopyTo(buffer, 0); + instanceLabelValues.CopyTo(buffer.AsSpan(_enrichWithLabelValues.Length)); + + return buffer.AsSpan(0, _enrichWithLabelValues.Length + instanceLabelValues.Length); + } +} diff --git a/Prometheus/LabelEnrichingManagedLifetimeGauge.cs b/Prometheus/LabelEnrichingManagedLifetimeGauge.cs new file mode 100644 index 00000000..2f272b65 --- /dev/null +++ b/Prometheus/LabelEnrichingManagedLifetimeGauge.cs @@ -0,0 +1,200 @@ +using System.Buffers; + +namespace Prometheus; + +internal sealed class LabelEnrichingManagedLifetimeGauge : IManagedLifetimeMetricHandle +{ + public LabelEnrichingManagedLifetimeGauge(IManagedLifetimeMetricHandle inner, string[] enrichWithLabelValues) + { + _inner = inner; + _enrichWithLabelValues = enrichWithLabelValues; + } + + private readonly IManagedLifetimeMetricHandle _inner; + private readonly string[] _enrichWithLabelValues; + + public ICollector WithExtendLifetimeOnUse() + { + return new LabelEnrichingAutoLeasingMetric(_inner.WithExtendLifetimeOnUse(), _enrichWithLabelValues); + } + + #region Lease(string[]) + public IDisposable AcquireLease(out IGauge metric, params string[] labelValues) + { + return _inner.AcquireLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public RefLease AcquireRefLease(out IGauge metric, params string[] labelValues) + { + return _inner.AcquireRefLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, params string[] labelValues) + { + _inner.WithLease(action, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, TArg arg, params string[] labelValues) + { + _inner.WithLease(action, arg, WithEnrichedLabelValues(labelValues)); + } + + public TResult WithLease(Func func, params string[] labelValues) + { + return _inner.WithLease(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func func, params string[] labelValues) + { + return _inner.WithLeaseAsync(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func> action, params string[] labelValues) + { + return _inner.WithLeaseAsync(action, WithEnrichedLabelValues(labelValues)); + } + #endregion + + #region Lease(ReadOnlyMemory) + public IDisposable AcquireLease(out IGauge metric, ReadOnlyMemory labelValues) + { + return _inner.AcquireLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public RefLease AcquireRefLease(out IGauge metric, ReadOnlyMemory labelValues) + { + return _inner.AcquireRefLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, ReadOnlyMemory labelValues) + { + _inner.WithLease(action, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, TArg arg, ReadOnlyMemory labelValues) + { + _inner.WithLease(action, arg, WithEnrichedLabelValues(labelValues)); + } + + public TResult WithLease(Func func, ReadOnlyMemory labelValues) + { + return _inner.WithLease(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func func, ReadOnlyMemory labelValues) + { + return _inner.WithLeaseAsync(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func> action, ReadOnlyMemory labelValues) + { + return _inner.WithLeaseAsync(action, WithEnrichedLabelValues(labelValues)); + } + #endregion + + private string[] WithEnrichedLabelValues(string[] instanceLabelValues) + { + var enriched = new string[_enrichWithLabelValues.Length + instanceLabelValues.Length]; + _enrichWithLabelValues.CopyTo(enriched, 0); + instanceLabelValues.CopyTo(enriched, _enrichWithLabelValues.Length); + + return enriched; + } + + private string[] WithEnrichedLabelValues(ReadOnlyMemory instanceLabelValues) + { + var enriched = new string[_enrichWithLabelValues.Length + instanceLabelValues.Length]; + _enrichWithLabelValues.CopyTo(enriched, 0); + instanceLabelValues.Span.CopyTo(enriched.AsSpan(_enrichWithLabelValues.Length)); + + return enriched; + } + + #region Lease(ReadOnlySpan) + public IDisposable AcquireLease(out IGauge metric, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + return _inner.AcquireLease(out metric, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public RefLease AcquireRefLease(out IGauge metric, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + return _inner.AcquireRefLease(out metric, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public void WithLease(Action action, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + _inner.WithLease(action, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public void WithLease(Action action, TArg arg, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + _inner.WithLease(action, arg, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public TResult WithLease(Func func, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + return _inner.WithLease(func, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + #endregion + + private string[] RentBufferForEnrichedLabelValues(ReadOnlySpan instanceLabelValues) + => ArrayPool.Shared.Rent(instanceLabelValues.Length + _enrichWithLabelValues.Length); + + private ReadOnlySpan AssembleEnrichedLabelValues(ReadOnlySpan instanceLabelValues, string[] buffer) + { + _enrichWithLabelValues.CopyTo(buffer, 0); + instanceLabelValues.CopyTo(buffer.AsSpan(_enrichWithLabelValues.Length)); + + return buffer.AsSpan(0, _enrichWithLabelValues.Length + instanceLabelValues.Length); + } +} diff --git a/Prometheus/LabelEnrichingManagedLifetimeHistogram.cs b/Prometheus/LabelEnrichingManagedLifetimeHistogram.cs new file mode 100644 index 00000000..6a645cd5 --- /dev/null +++ b/Prometheus/LabelEnrichingManagedLifetimeHistogram.cs @@ -0,0 +1,200 @@ +using System.Buffers; + +namespace Prometheus; + +internal sealed class LabelEnrichingManagedLifetimeHistogram : IManagedLifetimeMetricHandle +{ + public LabelEnrichingManagedLifetimeHistogram(IManagedLifetimeMetricHandle inner, string[] enrichWithLabelValues) + { + _inner = inner; + _enrichWithLabelValues = enrichWithLabelValues; + } + + private readonly IManagedLifetimeMetricHandle _inner; + private readonly string[] _enrichWithLabelValues; + + public ICollector WithExtendLifetimeOnUse() + { + return new LabelEnrichingAutoLeasingMetric(_inner.WithExtendLifetimeOnUse(), _enrichWithLabelValues); + } + + #region Lease(string[]) + public IDisposable AcquireLease(out IHistogram metric, params string[] labelValues) + { + return _inner.AcquireLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public RefLease AcquireRefLease(out IHistogram metric, params string[] labelValues) + { + return _inner.AcquireRefLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, params string[] labelValues) + { + _inner.WithLease(action, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, TArg arg, params string[] labelValues) + { + _inner.WithLease(action, arg, WithEnrichedLabelValues(labelValues)); + } + + public TResult WithLease(Func func, params string[] labelValues) + { + return _inner.WithLease(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func func, params string[] labelValues) + { + return _inner.WithLeaseAsync(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func> action, params string[] labelValues) + { + return _inner.WithLeaseAsync(action, WithEnrichedLabelValues(labelValues)); + } + #endregion + + #region Lease(ReadOnlyMemory) + public IDisposable AcquireLease(out IHistogram metric, ReadOnlyMemory labelValues) + { + return _inner.AcquireLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public RefLease AcquireRefLease(out IHistogram metric, ReadOnlyMemory labelValues) + { + return _inner.AcquireRefLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, ReadOnlyMemory labelValues) + { + _inner.WithLease(action, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, TArg arg, ReadOnlyMemory labelValues) + { + _inner.WithLease(action, arg, WithEnrichedLabelValues(labelValues)); + } + + public TResult WithLease(Func func, ReadOnlyMemory labelValues) + { + return _inner.WithLease(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func func, ReadOnlyMemory labelValues) + { + return _inner.WithLeaseAsync(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func> action, ReadOnlyMemory labelValues) + { + return _inner.WithLeaseAsync(action, WithEnrichedLabelValues(labelValues)); + } + #endregion + + private string[] WithEnrichedLabelValues(string[] instanceLabelValues) + { + var enriched = new string[_enrichWithLabelValues.Length + instanceLabelValues.Length]; + _enrichWithLabelValues.CopyTo(enriched, 0); + instanceLabelValues.CopyTo(enriched, _enrichWithLabelValues.Length); + + return enriched; + } + + private string[] WithEnrichedLabelValues(ReadOnlyMemory instanceLabelValues) + { + var enriched = new string[_enrichWithLabelValues.Length + instanceLabelValues.Length]; + _enrichWithLabelValues.CopyTo(enriched, 0); + instanceLabelValues.Span.CopyTo(enriched.AsSpan(_enrichWithLabelValues.Length)); + + return enriched; + } + + #region Lease(ReadOnlySpan) + public IDisposable AcquireLease(out IHistogram metric, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + return _inner.AcquireLease(out metric, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public RefLease AcquireRefLease(out IHistogram metric, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + return _inner.AcquireRefLease(out metric, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public void WithLease(Action action, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + _inner.WithLease(action, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public void WithLease(Action action, TArg arg, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + _inner.WithLease(action, arg, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public TResult WithLease(Func func, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + return _inner.WithLease(func, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + #endregion + + private string[] RentBufferForEnrichedLabelValues(ReadOnlySpan instanceLabelValues) + => ArrayPool.Shared.Rent(instanceLabelValues.Length + _enrichWithLabelValues.Length); + + private ReadOnlySpan AssembleEnrichedLabelValues(ReadOnlySpan instanceLabelValues, string[] buffer) + { + _enrichWithLabelValues.CopyTo(buffer, 0); + instanceLabelValues.CopyTo(buffer.AsSpan(_enrichWithLabelValues.Length)); + + return buffer.AsSpan(0, _enrichWithLabelValues.Length + instanceLabelValues.Length); + } +} diff --git a/Prometheus/LabelEnrichingManagedLifetimeMetricFactory.cs b/Prometheus/LabelEnrichingManagedLifetimeMetricFactory.cs new file mode 100644 index 00000000..11d6d7a7 --- /dev/null +++ b/Prometheus/LabelEnrichingManagedLifetimeMetricFactory.cs @@ -0,0 +1,247 @@ +namespace Prometheus; + +/// +/// Applies a set of static labels to lifetime-managed metrics. Multiple instances are functionally equivalent for the same label set. +/// +internal sealed class LabelEnrichingManagedLifetimeMetricFactory : IManagedLifetimeMetricFactory +{ + public LabelEnrichingManagedLifetimeMetricFactory(ManagedLifetimeMetricFactory inner, IDictionary enrichWithLabels) + { + _inner = inner; + + // We just need the items to be consistently ordered between equivalent instances but it does not actually matter what the order is. + _labels = enrichWithLabels.OrderBy(x => x.Key, StringComparer.Ordinal).ToList(); + + _enrichWithLabelNames = enrichWithLabels.Select(x => x.Key).ToArray(); + _enrichWithLabelValues = enrichWithLabels.Select(x => x.Value).ToArray(); + } + + private readonly ManagedLifetimeMetricFactory _inner; + + // This is an ordered list because labels have specific order. + private readonly IReadOnlyList> _labels; + + // Cache the names/values to enrich with, for reuse. + // We could perhaps improve even further via StringSequence but that requires creating separate internal APIs so can be a future optimization. + private readonly string[] _enrichWithLabelNames; + private readonly string[] _enrichWithLabelValues; + + public IManagedLifetimeMetricHandle CreateCounter(string name, string help, string[]? instanceLabelNames, CounterConfiguration? configuration) + { + var combinedLabelNames = WithEnrichedLabelNames(instanceLabelNames ?? Array.Empty()); + var innerHandle = _inner.CreateCounter(name, help, combinedLabelNames, configuration); + + // 1-1 relationship between instance of inner handle and our labeling handle. + // We expect lifetime of each to match the lifetime of the respective factory, so no need to cleanup anything. + + _countersLock.EnterReadLock(); + + try + { + if (_counters.TryGetValue(innerHandle, out var existing)) + return existing; + } + finally + { + _countersLock.ExitReadLock(); + } + + var instance = CreateCounterCore(innerHandle); + + _countersLock.EnterWriteLock(); + + try + { +#if NET + // It could be that someone beats us to it! Probably not, though. + if (_counters.TryAdd(innerHandle, instance)) + return instance; + + return _counters[innerHandle]; +#else + // On .NET Fx we need to do the pessimistic case first because there is no TryAdd(). + if (_counters.TryGetValue(innerHandle, out var existing)) + return existing; + + _counters.Add(innerHandle, instance); + return instance; +#endif + } + finally + { + _countersLock.ExitWriteLock(); + } + } + + private LabelEnrichingManagedLifetimeCounter CreateCounterCore(IManagedLifetimeMetricHandle inner) => new LabelEnrichingManagedLifetimeCounter(inner, _enrichWithLabelValues); + + private readonly Dictionary, LabelEnrichingManagedLifetimeCounter> _counters = new(); + private readonly ReaderWriterLockSlim _countersLock = new(); + + public IManagedLifetimeMetricHandle CreateGauge(string name, string help, string[]? instanceLabelNames, GaugeConfiguration? configuration) + { + var combinedLabelNames = WithEnrichedLabelNames(instanceLabelNames ?? Array.Empty()); + var innerHandle = _inner.CreateGauge(name, help, combinedLabelNames, configuration); + + // 1-1 relationship between instance of inner handle and our labeling handle. + // We expect lifetime of each to match the lifetime of the respective factory, so no need to cleanup anything. + + _gaugesLock.EnterReadLock(); + + try + { + if (_gauges.TryGetValue(innerHandle, out var existing)) + return existing; + } + finally + { + _gaugesLock.ExitReadLock(); + } + + var instance = CreateGaugeCore(innerHandle); + + _gaugesLock.EnterWriteLock(); + + try + { +#if NET + // It could be that someone beats us to it! Probably not, though. + if (_gauges.TryAdd(innerHandle, instance)) + return instance; + + return _gauges[innerHandle]; +#else + // On .NET Fx we need to do the pessimistic case first because there is no TryAdd(). + if (_gauges.TryGetValue(innerHandle, out var existing)) + return existing; + + _gauges.Add(innerHandle, instance); + return instance; +#endif + } + finally + { + _gaugesLock.ExitWriteLock(); + } + } + + private LabelEnrichingManagedLifetimeGauge CreateGaugeCore(IManagedLifetimeMetricHandle inner) => new LabelEnrichingManagedLifetimeGauge(inner, _enrichWithLabelValues); + private readonly Dictionary, LabelEnrichingManagedLifetimeGauge> _gauges = new(); + private readonly ReaderWriterLockSlim _gaugesLock = new(); + + public IManagedLifetimeMetricHandle CreateHistogram(string name, string help, string[]? instanceLabelNames, HistogramConfiguration? configuration) + { + var combinedLabelNames = WithEnrichedLabelNames(instanceLabelNames ?? Array.Empty()); + var innerHandle = _inner.CreateHistogram(name, help, combinedLabelNames, configuration); + + // 1-1 relationship between instance of inner handle and our labeling handle. + // We expect lifetime of each to match the lifetime of the respective factory, so no need to cleanup anything. + + _histogramsLock.EnterReadLock(); + + try + { + if (_histograms.TryGetValue(innerHandle, out var existing)) + return existing; + } + finally + { + _histogramsLock.ExitReadLock(); + } + + var instance = CreateHistogramCore(innerHandle); + + _histogramsLock.EnterWriteLock(); + + try + { +#if NET + // It could be that someone beats us to it! Probably not, though. + if (_histograms.TryAdd(innerHandle, instance)) + return instance; + + return _histograms[innerHandle]; +#else + // On .NET Fx we need to do the pessimistic case first because there is no TryAdd(). + if (_histograms.TryGetValue(innerHandle, out var existing)) + return existing; + + _histograms.Add(innerHandle, instance); + return instance; +#endif + } + finally + { + _histogramsLock.ExitWriteLock(); + } + } + + private LabelEnrichingManagedLifetimeHistogram CreateHistogramCore(IManagedLifetimeMetricHandle inner) => new LabelEnrichingManagedLifetimeHistogram(inner, _enrichWithLabelValues); + private readonly Dictionary, LabelEnrichingManagedLifetimeHistogram> _histograms = new(); + private readonly ReaderWriterLockSlim _histogramsLock = new(); + + public IManagedLifetimeMetricHandle CreateSummary(string name, string help, string[]? instanceLabelNames, SummaryConfiguration? configuration) + { + var combinedLabelNames = WithEnrichedLabelNames(instanceLabelNames ?? Array.Empty()); + var innerHandle = _inner.CreateSummary(name, help, combinedLabelNames, configuration); + + // 1-1 relationship between instance of inner handle and our labeling handle. + // We expect lifetime of each to match the lifetime of the respective factory, so no need to cleanup anything. + + _summariesLock.EnterReadLock(); + + try + { + if (_summaries.TryGetValue(innerHandle, out var existing)) + return existing; + } + finally + { + _summariesLock.ExitReadLock(); + } + + var instance = CreateSummaryCore(innerHandle); + + _summariesLock.EnterWriteLock(); + + try + { +#if NET + // It could be that someone beats us to it! Probably not, though. + if (_summaries.TryAdd(innerHandle, instance)) + return instance; + + return _summaries[innerHandle]; +#else + // On .NET Fx we need to do the pessimistic case first because there is no TryAdd(). + if (_summaries.TryGetValue(innerHandle, out var existing)) + return existing; + + _summaries.Add(innerHandle, instance); + return instance; +#endif + } + finally + { + _summariesLock.ExitWriteLock(); + } + } + + private LabelEnrichingManagedLifetimeSummary CreateSummaryCore(IManagedLifetimeMetricHandle inner) => new LabelEnrichingManagedLifetimeSummary(inner, _enrichWithLabelValues); + private readonly Dictionary, LabelEnrichingManagedLifetimeSummary> _summaries = new(); + private readonly ReaderWriterLockSlim _summariesLock = new(); + + public IManagedLifetimeMetricFactory WithLabels(IDictionary labels) + { + var combinedLabels = _labels.Concat(labels).ToDictionary(x => x.Key, x => x.Value); + + // Inner factory takes care of applying the correct ordering for labels. + return _inner.WithLabels(combinedLabels); + } + + private string[] WithEnrichedLabelNames(string[] instanceLabelNames) + { + // Enrichment labels always go first when we are communicating with the inner factory. + return _enrichWithLabelNames.Concat(instanceLabelNames).ToArray(); + } +} diff --git a/Prometheus/LabelEnrichingManagedLifetimeSummary.cs b/Prometheus/LabelEnrichingManagedLifetimeSummary.cs new file mode 100644 index 00000000..796045fb --- /dev/null +++ b/Prometheus/LabelEnrichingManagedLifetimeSummary.cs @@ -0,0 +1,200 @@ +using System.Buffers; + +namespace Prometheus; + +internal sealed class LabelEnrichingManagedLifetimeSummary : IManagedLifetimeMetricHandle +{ + public LabelEnrichingManagedLifetimeSummary(IManagedLifetimeMetricHandle inner, string[] enrichWithLabelValues) + { + _inner = inner; + _enrichWithLabelValues = enrichWithLabelValues; + } + + private readonly IManagedLifetimeMetricHandle _inner; + private readonly string[] _enrichWithLabelValues; + + public ICollector WithExtendLifetimeOnUse() + { + return new LabelEnrichingAutoLeasingMetric(_inner.WithExtendLifetimeOnUse(), _enrichWithLabelValues); + } + + #region Lease(string[]) + public IDisposable AcquireLease(out ISummary metric, params string[] labelValues) + { + return _inner.AcquireLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public RefLease AcquireRefLease(out ISummary metric, params string[] labelValues) + { + return _inner.AcquireRefLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, params string[] labelValues) + { + _inner.WithLease(action, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, TArg arg, params string[] labelValues) + { + _inner.WithLease(action, arg, WithEnrichedLabelValues(labelValues)); + } + + public TResult WithLease(Func func, params string[] labelValues) + { + return _inner.WithLease(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func func, params string[] labelValues) + { + return _inner.WithLeaseAsync(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func> action, params string[] labelValues) + { + return _inner.WithLeaseAsync(action, WithEnrichedLabelValues(labelValues)); + } + #endregion + + #region Lease(ReadOnlyMemory) + public IDisposable AcquireLease(out ISummary metric, ReadOnlyMemory labelValues) + { + return _inner.AcquireLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public RefLease AcquireRefLease(out ISummary metric, ReadOnlyMemory labelValues) + { + return _inner.AcquireRefLease(out metric, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, ReadOnlyMemory labelValues) + { + _inner.WithLease(action, WithEnrichedLabelValues(labelValues)); + } + + public void WithLease(Action action, TArg arg, ReadOnlyMemory labelValues) + { + _inner.WithLease(action, arg, WithEnrichedLabelValues(labelValues)); + } + + public TResult WithLease(Func func, ReadOnlyMemory labelValues) + { + return _inner.WithLease(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func func, ReadOnlyMemory labelValues) + { + return _inner.WithLeaseAsync(func, WithEnrichedLabelValues(labelValues)); + } + + public Task WithLeaseAsync(Func> action, ReadOnlyMemory labelValues) + { + return _inner.WithLeaseAsync(action, WithEnrichedLabelValues(labelValues)); + } + #endregion + + private string[] WithEnrichedLabelValues(string[] instanceLabelValues) + { + var enriched = new string[_enrichWithLabelValues.Length + instanceLabelValues.Length]; + _enrichWithLabelValues.CopyTo(enriched, 0); + instanceLabelValues.CopyTo(enriched, _enrichWithLabelValues.Length); + + return enriched; + } + + private string[] WithEnrichedLabelValues(ReadOnlyMemory instanceLabelValues) + { + var enriched = new string[_enrichWithLabelValues.Length + instanceLabelValues.Length]; + _enrichWithLabelValues.CopyTo(enriched, 0); + instanceLabelValues.Span.CopyTo(enriched.AsSpan(_enrichWithLabelValues.Length)); + + return enriched; + } + + #region Lease(ReadOnlySpan) + public IDisposable AcquireLease(out ISummary metric, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + return _inner.AcquireLease(out metric, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public RefLease AcquireRefLease(out ISummary metric, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + return _inner.AcquireRefLease(out metric, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public void WithLease(Action action, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + _inner.WithLease(action, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public void WithLease(Action action, TArg arg, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + _inner.WithLease(action, arg, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public TResult WithLease(Func func, ReadOnlySpan labelValues) + { + var buffer = RentBufferForEnrichedLabelValues(labelValues); + + try + { + var enrichedLabelValues = AssembleEnrichedLabelValues(labelValues, buffer); + return _inner.WithLease(func, enrichedLabelValues); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + #endregion + + private string[] RentBufferForEnrichedLabelValues(ReadOnlySpan instanceLabelValues) + => ArrayPool.Shared.Rent(instanceLabelValues.Length + _enrichWithLabelValues.Length); + + private ReadOnlySpan AssembleEnrichedLabelValues(ReadOnlySpan instanceLabelValues, string[] buffer) + { + _enrichWithLabelValues.CopyTo(buffer, 0); + instanceLabelValues.CopyTo(buffer.AsSpan(_enrichWithLabelValues.Length)); + + return buffer.AsSpan(0, _enrichWithLabelValues.Length + instanceLabelValues.Length); + } +} diff --git a/Prometheus/LabelSequence.cs b/Prometheus/LabelSequence.cs index 4f6b1e30..c3266e9f 100644 --- a/Prometheus/LabelSequence.cs +++ b/Prometheus/LabelSequence.cs @@ -1,18 +1,16 @@ -using System.Text; - -namespace Prometheus; +namespace Prometheus; /// /// A sequence of metric label-name pairs. /// -internal struct LabelSequence +internal readonly struct LabelSequence : IEquatable { public static readonly LabelSequence Empty = new(); public readonly StringSequence Names; public readonly StringSequence Values; - public int Length { get; } + public int Length => Names.Length; private LabelSequence(StringSequence names, StringSequence values) { @@ -22,8 +20,6 @@ private LabelSequence(StringSequence names, StringSequence values) Names = names; Values = values; - Length = names.Length; - _hashCode = CalculateHashCode(); } @@ -86,34 +82,105 @@ private static string EscapeLabelValue(string value) .Replace("\"", @"\"""); } + private static int GetEscapedLabelValueByteCount(string value) + { + var byteCount = PrometheusConstants.ExportEncoding.GetByteCount(value); + + foreach (var c in value) + { + if (c == '\\' || c == '\n' || c == '"') + byteCount++; + } + + return byteCount; + } + /// - /// Serializes to the labelkey1="labelvalue1",labelkey2="labelvalue2" label string. + /// Serializes to the labelkey1="labelvalue1",labelkey2="labelvalue2" label string as bytes. /// - public string Serialize() + public byte[] Serialize() { - // Result is cached in child collector - no need to worry about efficiency here. - - var sb = new StringBuilder(); + // Result is cached in child collector, though we still might be making many of these child collectors if they are not reused. + // Let's try to be efficient to avoid allocations if this gets called in a hot path. + // First pass - calculate how many bytes we need to allocate. var nameEnumerator = Names.GetEnumerator(); var valueEnumerator = Values.GetEnumerator(); + var byteCount = 0; + for (var i = 0; i < Names.Length; i++) { if (!nameEnumerator.MoveNext()) throw new Exception("API contract violation."); if (!valueEnumerator.MoveNext()) throw new Exception("API contract violation."); if (i != 0) - sb.Append(','); + byteCount += TextSerializer.Comma.Length; - sb.Append(nameEnumerator.Current); - sb.Append('='); - sb.Append('"'); - sb.Append(EscapeLabelValue(valueEnumerator.Current)); - sb.Append('"'); + byteCount += PrometheusConstants.ExportEncoding.GetByteCount(nameEnumerator.Current); + byteCount += TextSerializer.Equal.Length; + byteCount += TextSerializer.Quote.Length; + byteCount += GetEscapedLabelValueByteCount(valueEnumerator.Current); + byteCount += TextSerializer.Quote.Length; } - return sb.ToString(); + var bytes = new byte[byteCount]; + var index = 0; + + nameEnumerator = Names.GetEnumerator(); + valueEnumerator = Values.GetEnumerator(); + + for (var i = 0; i < Names.Length; i++) + { + if (!nameEnumerator.MoveNext()) throw new Exception("API contract violation."); + if (!valueEnumerator.MoveNext()) throw new Exception("API contract violation."); + +#if NET + if (i != 0) + { + TextSerializer.Comma.CopyTo(bytes.AsSpan(index)); + index += TextSerializer.Comma.Length; + } + + index += PrometheusConstants.ExportEncoding.GetBytes(nameEnumerator.Current, 0, nameEnumerator.Current.Length, bytes, index); + + TextSerializer.Equal.CopyTo(bytes.AsSpan(index)); + index += TextSerializer.Equal.Length; + + TextSerializer.Quote.CopyTo(bytes.AsSpan(index)); + index += TextSerializer.Quote.Length; + + var escapedLabelValue = EscapeLabelValue(valueEnumerator.Current); + index += PrometheusConstants.ExportEncoding.GetBytes(escapedLabelValue, 0, escapedLabelValue.Length, bytes, index); + + TextSerializer.Quote.CopyTo(bytes.AsSpan(index)); + index += TextSerializer.Quote.Length; +#else + if (i != 0) + { + Array.Copy(TextSerializer.Comma, 0, bytes, index, TextSerializer.Comma.Length); + index += TextSerializer.Comma.Length; + } + + index += PrometheusConstants.ExportEncoding.GetBytes(nameEnumerator.Current, 0, nameEnumerator.Current.Length, bytes, index); + + Array.Copy(TextSerializer.Equal, 0, bytes, index, TextSerializer.Equal.Length); + index += TextSerializer.Equal.Length; + + Array.Copy(TextSerializer.Quote, 0, bytes, index, TextSerializer.Quote.Length); + index += TextSerializer.Quote.Length; + + var escapedLabelValue = EscapeLabelValue(valueEnumerator.Current); + index += PrometheusConstants.ExportEncoding.GetBytes(escapedLabelValue, 0, escapedLabelValue.Length, bytes, index); + + Array.Copy(TextSerializer.Quote, 0, bytes, index, TextSerializer.Quote.Length); + index += TextSerializer.Quote.Length; +#endif + } + + if (index != byteCount) throw new Exception("API contract violation - we counted the same bytes twice but got different numbers."); + + return bytes; } public bool Equals(LabelSequence other) @@ -169,4 +236,10 @@ public IDictionary ToDictionary() return result; } + + public override string ToString() + { + // Just for debugging. + return $"({Length})" + string.Join("; ", ToDictionary().Select(pair => $"{pair.Key} = {pair.Value}")); + } } diff --git a/Prometheus/LowGranularityTimeSource.cs b/Prometheus/LowGranularityTimeSource.cs new file mode 100644 index 00000000..92989936 --- /dev/null +++ b/Prometheus/LowGranularityTimeSource.cs @@ -0,0 +1,45 @@ +using System.Diagnostics; + +namespace Prometheus; + +/// +/// We occasionally need timestamps to attach to metrics metadata. In high-performance code, calling the standard get-timestamp functions can be a nontrivial cost. +/// This class does some caching to avoid calling the expensive timestamp functions too often, giving an accurate but slightly lower granularity clock as one might otherwise get. +/// +internal static class LowGranularityTimeSource +{ + [ThreadStatic] + private static double LastUnixSeconds; + + [ThreadStatic] + private static long LastStopwatchTimestamp; + + [ThreadStatic] + private static int LastTickCount; + + public static double GetSecondsFromUnixEpoch() + { + UpdateIfRequired(); + + return LastUnixSeconds; + } + + public static long GetStopwatchTimestamp() + { + UpdateIfRequired(); + + return LastStopwatchTimestamp; + } + + private static void UpdateIfRequired() + { + var currentTickCount = Environment.TickCount; + + if (LastTickCount != currentTickCount) + { + LastUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0; + LastStopwatchTimestamp = Stopwatch.GetTimestamp(); + LastTickCount = currentTickCount; + } + } +} diff --git a/Prometheus/ManagedLifetimeCounter.cs b/Prometheus/ManagedLifetimeCounter.cs index 51f11f51..6e55b1c0 100644 --- a/Prometheus/ManagedLifetimeCounter.cs +++ b/Prometheus/ManagedLifetimeCounter.cs @@ -1,11 +1,103 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// This class implements two sets of functionality: +/// 1. A lifetime-managed metric handle that can be used to take leases on the metric. +/// 2. An automatically-lifetime-extending-on-use metric that creates leases automatically. +/// +/// While conceptually separate, we merge the two sets into one class to avoid allocating a bunch of small objects +/// every time you want to obtain a lifetime-extending-on-use metric (which tends to be on a relatively hot path). +/// +/// The lifetime-extending feature only supports write operations because we cannot guarantee that the metric is still alive when reading. +/// +internal sealed class ManagedLifetimeCounter : ManagedLifetimeMetricHandle, ICollector { - internal sealed class ManagedLifetimeCounter : ManagedLifetimeMetricHandle + static ManagedLifetimeCounter() + { + _assignUnlabelledFunc = AssignUnlabelled; + } + + public ManagedLifetimeCounter(Collector metric, TimeSpan expiresAfter) : base(metric, expiresAfter) + { + } + + public override ICollector WithExtendLifetimeOnUse() => this; + + #region ICollector implementation (for WithExtendLifetimeOnUse) + public string Name => _metric.Name; + public string Help => _metric.Help; + public string[] LabelNames => _metric.LabelNames; + + public ICounter Unlabelled => NonCapturingLazyInitializer.EnsureInitialized(ref _unlabelled, this, _assignUnlabelledFunc); + private AutoLeasingInstance? _unlabelled; + private static readonly Action _assignUnlabelledFunc; + private static void AssignUnlabelled(ManagedLifetimeCounter instance) => instance._unlabelled = new AutoLeasingInstance(instance, Array.Empty()); + + // These do not get cached, so are potentially expensive - user code should try avoiding re-allocating these when possible, + // though admittedly this may not be so easy as often these are on the hot path and the very reason that lifetime-managed + // metrics are used is that we do not have a meaningful way to reuse metrics or identify their lifetime. + public ICounter WithLabels(params string[] labelValues) => WithLabels(labelValues.AsMemory()); + + public ICounter WithLabels(ReadOnlyMemory labelValues) + { + return new AutoLeasingInstance(this, labelValues); + } + + public ICounter WithLabels(ReadOnlySpan labelValues) { - public ManagedLifetimeCounter(Collector metric, TimeSpan expiresAfter) : base(metric, expiresAfter) + // We are allocating a long-lived auto-leasing wrapper here, so there is no way we can just use the span directly. + // We must copy it to a long-lived array. Another reason to avoid re-allocating these as much as possible. + return new AutoLeasingInstance(this, labelValues.ToArray()); + } + #endregion + + private sealed class AutoLeasingInstance : ICounter + { + public AutoLeasingInstance(IManagedLifetimeMetricHandle inner, ReadOnlyMemory labelValues) + { + _inner = inner; + _labelValues = labelValues; + } + + private readonly IManagedLifetimeMetricHandle _inner; + private readonly ReadOnlyMemory _labelValues; + + public double Value => throw new NotSupportedException("Read operations on a lifetime-extending-on-use expiring metric are not supported."); + + public void Inc(double increment) => Inc(increment, null); + public void Inc(Exemplar? exemplar) => Inc(increment: 1, exemplar: exemplar); + + public void Inc(double increment, Exemplar? exemplar) + { + var args = new IncArgs(increment, exemplar); + + // We use the Span overload to signal that we expect the label values to be known already. + _inner.WithLease(_incCoreFunc, args, _labelValues.Span); + } + + private readonly struct IncArgs(double increment, Exemplar? exemplar) + { + public readonly double Increment = increment; + public readonly Exemplar? Exemplar = exemplar; + } + + private static void IncCore(IncArgs args, ICounter counter) => counter.Inc(args.Increment, args.Exemplar); + private static readonly Action _incCoreFunc = IncCore; + + public void IncTo(double targetValue) + { + var args = new IncToArgs(targetValue); + + // We use the Span overload to signal that we expect the label values to be known already. + _inner.WithLease(_incToCoreFunc, args, _labelValues.Span); + } + + private readonly struct IncToArgs(double targetValue) { + public readonly double TargetValue = targetValue; } - public override ICollector WithExtendLifetimeOnUse() => new AutoLeasingCounter(this, _metric); + private static void IncToCore(IncToArgs args, ICounter counter) => counter.IncTo(args.TargetValue); + private static readonly Action _incToCoreFunc = IncToCore; } } diff --git a/Prometheus/ManagedLifetimeGauge.cs b/Prometheus/ManagedLifetimeGauge.cs index 70ef50d0..c0efd7b6 100644 --- a/Prometheus/ManagedLifetimeGauge.cs +++ b/Prometheus/ManagedLifetimeGauge.cs @@ -1,11 +1,147 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// This class implements two sets of functionality: +/// 1. A lifetime-managed metric handle that can be used to take leases on the metric. +/// 2. An automatically-lifetime-extending-on-use metric that creates leases automatically. +/// +/// While conceptually separate, we merge the two sets into one class to avoid allocating a bunch of small objects +/// every time you want to obtain a lifetime-extending-on-use metric (which tends to be on a relatively hot path). +/// +/// The lifetime-extending feature only supports write operations because we cannot guarantee that the metric is still alive when reading. +/// +internal sealed class ManagedLifetimeGauge : ManagedLifetimeMetricHandle, ICollector { - internal sealed class ManagedLifetimeGauge : ManagedLifetimeMetricHandle + static ManagedLifetimeGauge() + { + _assignUnlabelledFunc = AssignUnlabelled; + } + + public ManagedLifetimeGauge(Collector metric, TimeSpan expiresAfter) : base(metric, expiresAfter) { - public ManagedLifetimeGauge(Collector metric, TimeSpan expiresAfter) : base(metric, expiresAfter) + } + + public override ICollector WithExtendLifetimeOnUse() => this; + + #region ICollector implementation (for WithExtendLifetimeOnUse) + public string Name => _metric.Name; + public string Help => _metric.Help; + public string[] LabelNames => _metric.LabelNames; + + public IGauge Unlabelled => NonCapturingLazyInitializer.EnsureInitialized(ref _unlabelled, this, _assignUnlabelledFunc); + private AutoLeasingInstance? _unlabelled; + private static readonly Action _assignUnlabelledFunc; + private static void AssignUnlabelled(ManagedLifetimeGauge instance) => instance._unlabelled = new AutoLeasingInstance(instance, Array.Empty()); + + // These do not get cached, so are potentially expensive - user code should try avoiding re-allocating these when possible, + // though admittedly this may not be so easy as often these are on the hot path and the very reason that lifetime-managed + // metrics are used is that we do not have a meaningful way to reuse metrics or identify their lifetime. + public IGauge WithLabels(params string[] labelValues) => WithLabels(labelValues.AsMemory()); + + public IGauge WithLabels(ReadOnlyMemory labelValues) + { + return new AutoLeasingInstance(this, labelValues); + } + + public IGauge WithLabels(ReadOnlySpan labelValues) + { + // We are allocating a long-lived auto-leasing wrapper here, so there is no way we can just use the span directly. + // We must copy it to a long-lived array. Another reason to avoid re-allocating these as much as possible. + return new AutoLeasingInstance(this, labelValues.ToArray()); + } + #endregion + + private sealed class AutoLeasingInstance : IGauge + { + public AutoLeasingInstance(IManagedLifetimeMetricHandle inner, ReadOnlyMemory labelValues) + { + _inner = inner; + _labelValues = labelValues; + } + + private readonly IManagedLifetimeMetricHandle _inner; + private readonly ReadOnlyMemory _labelValues; + + public double Value => throw new NotSupportedException("Read operations on a lifetime-extending-on-use expiring metric are not supported."); + + public void Inc(double increment = 1) + { + var args = new IncArgs(increment); + + // We use the Span overload to signal that we expect the label values to be known already. + _inner.WithLease(_incCoreFunc, args, _labelValues.Span); + } + + private readonly struct IncArgs(double increment) + { + public readonly double Increment = increment; + } + + private static void IncCore(IncArgs args, IGauge gauge) => gauge.Inc(args.Increment); + private static readonly Action _incCoreFunc = IncCore; + + public void Set(double val) + { + var args = new SetArgs(val); + + // We use the Span overload to signal that we expect the label values to be known already. + _inner.WithLease(_setCoreFunc, args, _labelValues.Span); + } + + private readonly struct SetArgs(double val) + { + public readonly double Val = val; + } + + private static void SetCore(SetArgs args, IGauge gauge) => gauge.Set(args.Val); + private static readonly Action _setCoreFunc = SetCore; + + public void Dec(double decrement = 1) + { + var args = new DecArgs(decrement); + + // We use the Span overload to signal that we expect the label values to be known already. + _inner.WithLease(_decCoreFunc, args, _labelValues.Span); + } + + private readonly struct DecArgs(double decrement) + { + public readonly double Decrement = decrement; + } + + private static void DecCore(DecArgs args, IGauge gauge) => gauge.Dec(args.Decrement); + private static readonly Action _decCoreFunc = DecCore; + + public void IncTo(double targetValue) + { + var args = new IncToArgs(targetValue); + + // We use the Span overload to signal that we expect the label values to be known already. + _inner.WithLease(_incToCoreFunc, args, _labelValues.Span); + } + + private readonly struct IncToArgs(double targetValue) + { + public readonly double TargetValue = targetValue; + } + + private static void IncToCore(IncToArgs args, IGauge gauge) => gauge.IncTo(args.TargetValue); + private static readonly Action _incToCoreFunc = IncToCore; + + public void DecTo(double targetValue) + { + var args = new DecToArgs(targetValue); + + // We use the Span overload to signal that we expect the label values to be known already. + _inner.WithLease(_decToCoreFunc, args, _labelValues.Span); + } + + private readonly struct DecToArgs(double targetValue) { + public readonly double TargetValue = targetValue; } - public override ICollector WithExtendLifetimeOnUse() => new AutoLeasingGauge(this, _metric); + private static void DecToCore(DecToArgs args, IGauge gauge) => gauge.DecTo(args.TargetValue); + private static readonly Action _decToCoreFunc = DecToCore; } } diff --git a/Prometheus/ManagedLifetimeHistogram.cs b/Prometheus/ManagedLifetimeHistogram.cs index d28e18fb..6971fe46 100644 --- a/Prometheus/ManagedLifetimeHistogram.cs +++ b/Prometheus/ManagedLifetimeHistogram.cs @@ -1,11 +1,107 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// This class implements two sets of functionality: +/// 1. A lifetime-managed metric handle that can be used to take leases on the metric. +/// 2. An automatically-lifetime-extending-on-use metric that creates leases automatically. +/// +/// While conceptually separate, we merge the two sets into one class to avoid allocating a bunch of small objects +/// every time you want to obtain a lifetime-extending-on-use metric (which tends to be on a relatively hot path). +/// +/// The lifetime-extending feature only supports write operations because we cannot guarantee that the metric is still alive when reading. +/// +internal sealed class ManagedLifetimeHistogram : ManagedLifetimeMetricHandle, ICollector { - internal sealed class ManagedLifetimeHistogram : ManagedLifetimeMetricHandle + static ManagedLifetimeHistogram() { - public ManagedLifetimeHistogram(Collector metric, TimeSpan expiresAfter) : base(metric, expiresAfter) + _assignUnlabelledFunc = AssignUnlabelled; + } + + public ManagedLifetimeHistogram(Collector metric, TimeSpan expiresAfter) : base(metric, expiresAfter) + { + } + + public override ICollector WithExtendLifetimeOnUse() => this; + + #region ICollector implementation (for WithExtendLifetimeOnUse) + public string Name => _metric.Name; + public string Help => _metric.Help; + public string[] LabelNames => _metric.LabelNames; + + public IHistogram Unlabelled => NonCapturingLazyInitializer.EnsureInitialized(ref _unlabelled, this, _assignUnlabelledFunc); + private AutoLeasingInstance? _unlabelled; + private static readonly Action _assignUnlabelledFunc; + private static void AssignUnlabelled(ManagedLifetimeHistogram instance) => instance._unlabelled = new AutoLeasingInstance(instance, Array.Empty()); + + // These do not get cached, so are potentially expensive - user code should try avoiding re-allocating these when possible, + // though admittedly this may not be so easy as often these are on the hot path and the very reason that lifetime-managed + // metrics are used is that we do not have a meaningful way to reuse metrics or identify their lifetime. + public IHistogram WithLabels(params string[] labelValues) => WithLabels(labelValues.AsMemory()); + + public IHistogram WithLabels(ReadOnlyMemory labelValues) + { + return new AutoLeasingInstance(this, labelValues); + } + + public IHistogram WithLabels(ReadOnlySpan labelValues) + { + // We are allocating a long-lived auto-leasing wrapper here, so there is no way we can just use the span directly. + // We must copy it to a long-lived array. Another reason to avoid re-allocating these as much as possible. + return new AutoLeasingInstance(this, labelValues.ToArray()); + } + #endregion + + private sealed class AutoLeasingInstance : IHistogram + { + public AutoLeasingInstance(IManagedLifetimeMetricHandle inner, ReadOnlyMemory labelValues) { + _inner = inner; + _labelValues = labelValues; } - public override ICollector WithExtendLifetimeOnUse() => new AutoLeasingHistogram(this, _metric); + private readonly IManagedLifetimeMetricHandle _inner; + private readonly ReadOnlyMemory _labelValues; + + public double Sum => throw new NotSupportedException("Read operations on a lifetime-extending-on-use expiring metric are not supported."); + public long Count => throw new NotSupportedException("Read operations on a lifetime-extending-on-use expiring metric are not supported."); + + public void Observe(double val, long count) + { + var args = new ObserveValCountArgs(val, count); + + // We use the Span overload to signal that we expect the label values to be known already. + _inner.WithLease(_observeValCountCoreFunc, args, _labelValues.Span); + } + + private readonly struct ObserveValCountArgs(double val, long count) + { + public readonly double Val = val; + public readonly long Count = count; + } + + private static void ObserveValCountCore(ObserveValCountArgs args, IHistogram histogram) => histogram.Observe(args.Val, args.Count); + private static readonly Action _observeValCountCoreFunc = ObserveValCountCore; + + public void Observe(double val, Exemplar? exemplar) + { + var args = new ObserveValExemplarArgs(val, exemplar); + + // We use the Span overload to signal that we expect the label values to be known already. + _inner.WithLease(_observeValExemplarCoreFunc, args, _labelValues.Span); + } + + private readonly struct ObserveValExemplarArgs(double val, Exemplar? exemplar) + { + public readonly double Val = val; + public readonly Exemplar? Exemplar = exemplar; + } + + private static void ObserveValExemplarCore(ObserveValExemplarArgs args, IHistogram histogram) => histogram.Observe(args.Val, args.Exemplar); + private static readonly Action _observeValExemplarCoreFunc = ObserveValExemplarCore; + + public void Observe(double val) + { + Observe(val, null); + } } } diff --git a/Prometheus/ManagedLifetimeMetricFactory.cs b/Prometheus/ManagedLifetimeMetricFactory.cs index 647841fa..22d67178 100644 --- a/Prometheus/ManagedLifetimeMetricFactory.cs +++ b/Prometheus/ManagedLifetimeMetricFactory.cs @@ -1,170 +1,223 @@ -using System.Collections.Concurrent; +namespace Prometheus; -namespace Prometheus +internal sealed class ManagedLifetimeMetricFactory : IManagedLifetimeMetricFactory { - internal sealed class ManagedLifetimeMetricFactory : IManagedLifetimeMetricFactory + public ManagedLifetimeMetricFactory(MetricFactory inner, TimeSpan expiresAfter) { - public ManagedLifetimeMetricFactory(MetricFactory inner, TimeSpan expiresAfter) - { - // .NET Framework requires the timer to fit in int.MaxValue and we will have hidden failures to expire if it does not. - // For simplicity, let's just limit it to 1 day, which should be enough for anyone. - if (expiresAfter > TimeSpan.FromDays(1)) - throw new ArgumentOutOfRangeException(nameof(expiresAfter), "Automatic metric expiration time must be no greater than 1 day."); + // .NET Framework requires the timer to fit in int.MaxValue and we will have hidden failures to expire if it does not. + // For simplicity, let's just limit it to 1 day, which should be enough for anyone. + if (expiresAfter > TimeSpan.FromDays(1)) + throw new ArgumentOutOfRangeException(nameof(expiresAfter), "Automatic metric expiration time must be no greater than 1 day."); - _inner = inner; - _expiresAfter = expiresAfter; - } + _inner = inner; + _expiresAfter = expiresAfter; + } - private readonly MetricFactory _inner; - private readonly TimeSpan _expiresAfter; + private readonly MetricFactory _inner; + private readonly TimeSpan _expiresAfter; - public IManagedLifetimeMetricHandle CreateCounter(string name, string help, string[] instanceLabelNames, CounterConfiguration? configuration = null) - { - var identity = new CollectorIdentity(name, StringSequence.From(instanceLabelNames), StringSequence.Empty); + public IManagedLifetimeMetricFactory WithLabels(IDictionary labels) + { + return new LabelEnrichingManagedLifetimeMetricFactory(this, labels); + } + + public IManagedLifetimeMetricHandle CreateCounter(string name, string help, string[]? instanceLabelNames, CounterConfiguration? configuration) + { + var identity = new ManagedLifetimeMetricIdentity(name, StringSequence.From(instanceLabelNames ?? Array.Empty())); + + _countersLock.EnterReadLock(); + try + { // Let's be optimistic and assume that in the typical case, the metric will already exist. if (_counters.TryGetValue(identity, out var existing)) return existing; - - var initializer = new CounterInitializer(_inner, _expiresAfter, help, configuration); - return _counters.GetOrAdd(identity, initializer.CreateInstance); } + finally + { + _countersLock.ExitReadLock(); + } + + var metric = _inner.CreateCounter(identity.MetricFamilyName, help, identity.InstanceLabelNames, configuration); + var instance = new ManagedLifetimeCounter(metric, _expiresAfter); - public IManagedLifetimeMetricHandle CreateGauge(string name, string help, string[] instanceLabelNames, GaugeConfiguration? configuration = null) + _countersLock.EnterWriteLock(); + + try { - var identity = new CollectorIdentity(name, StringSequence.From(instanceLabelNames), StringSequence.Empty); +#if NET + // It could be that someone beats us to it! Probably not, though. + if (_counters.TryAdd(identity, instance)) + return instance; + + return _counters[identity]; +#else + // On .NET Fx we need to do the pessimistic case first because there is no TryAdd(). + if (_counters.TryGetValue(identity, out var existing)) + return existing; + + _counters.Add(identity, instance); + return instance; +#endif + } + finally + { + _countersLock.ExitWriteLock(); + } + } + + public IManagedLifetimeMetricHandle CreateGauge(string name, string help, string[]? instanceLabelNames, GaugeConfiguration? configuration) + { + var identity = new ManagedLifetimeMetricIdentity(name, StringSequence.From(instanceLabelNames ?? Array.Empty())); + _gaugesLock.EnterReadLock(); + + try + { // Let's be optimistic and assume that in the typical case, the metric will already exist. if (_gauges.TryGetValue(identity, out var existing)) return existing; - - var initializer = new GaugeInitializer(_inner, _expiresAfter, help, configuration); - return _gauges.GetOrAdd(identity, initializer.CreateInstance); } + finally + { + _gaugesLock.ExitReadLock(); + } + + var metric = _inner.CreateGauge(identity.MetricFamilyName, help, identity.InstanceLabelNames, configuration); + var instance = new ManagedLifetimeGauge(metric, _expiresAfter); - public IManagedLifetimeMetricHandle CreateHistogram(string name, string help, string[] instanceLabelNames, HistogramConfiguration? configuration = null) + _gaugesLock.EnterWriteLock(); + + try + { +#if NET + // It could be that someone beats us to it! Probably not, though. + if (_gauges.TryAdd(identity, instance)) + return instance; + + return _gauges[identity]; +#else + // On .NET Fx we need to do the pessimistic case first because there is no TryAdd(). + if (_gauges.TryGetValue(identity, out var existing)) + return existing; + + _gauges.Add(identity, instance); + return instance; +#endif + } + finally { - var identity = new CollectorIdentity(name, StringSequence.From(instanceLabelNames), StringSequence.Empty); + _gaugesLock.ExitWriteLock(); + } + } + + public IManagedLifetimeMetricHandle CreateHistogram(string name, string help, string[]? instanceLabelNames, HistogramConfiguration? configuration) + { + var identity = new ManagedLifetimeMetricIdentity(name, StringSequence.From(instanceLabelNames ?? Array.Empty())); + _histogramsLock.EnterReadLock(); + + try + { // Let's be optimistic and assume that in the typical case, the metric will already exist. if (_histograms.TryGetValue(identity, out var existing)) return existing; - - var initializer = new HistogramInitializer(_inner, _expiresAfter, help, configuration); - return _histograms.GetOrAdd(identity, initializer.CreateInstance); } + finally + { + _histogramsLock.ExitReadLock(); + } + + var metric = _inner.CreateHistogram(identity.MetricFamilyName, help, identity.InstanceLabelNames, configuration); + var instance = new ManagedLifetimeHistogram(metric, _expiresAfter); - public IManagedLifetimeMetricHandle CreateSummary(string name, string help, string[] instanceLabelNames, SummaryConfiguration? configuration = null) + _histogramsLock.EnterWriteLock(); + + try + { +#if NET + // It could be that someone beats us to it! Probably not, though. + if (_histograms.TryAdd(identity, instance)) + return instance; + + return _histograms[identity]; +#else + // On .NET Fx we need to do the pessimistic case first because there is no TryAdd(). + if (_histograms.TryGetValue(identity, out var existing)) + return existing; + + _histograms.Add(identity, instance); + return instance; +#endif + } + finally { - var identity = new CollectorIdentity(name, StringSequence.From(instanceLabelNames), StringSequence.Empty); + _histogramsLock.ExitWriteLock(); + } + } + + public IManagedLifetimeMetricHandle CreateSummary(string name, string help, string[]? instanceLabelNames, SummaryConfiguration? configuration) + { + var identity = new ManagedLifetimeMetricIdentity(name, StringSequence.From(instanceLabelNames ?? Array.Empty())); + _summariesLock.EnterReadLock(); + + try + { // Let's be optimistic and assume that in the typical case, the metric will already exist. if (_summaries.TryGetValue(identity, out var existing)) return existing; + } + finally + { + _summariesLock.ExitReadLock(); + } - var initializer = new SummaryInitializer(_inner, _expiresAfter, help, configuration); - return _summaries.GetOrAdd(identity, initializer.CreateInstance); - } - - /// - /// Gets all the existing label names predefined either in the factory or in the registry. - /// - internal StringSequence GetAllStaticLabelNames() => _inner.GetAllStaticLabelNames(); - - // We need to reuse existing instances of lifetime-managed metrics because the user might not want to cache it. - // This somewhat duplicates the metric identity tracking logic in CollectorRegistry but this is intentional, as we really do need to do this work on two layers. - // We never remove collectors from here as long as the factory is alive. The expectation is that there is not an unbounded set of label names, so this set is non-gigantic. - private readonly ConcurrentDictionary _counters = new(); - private readonly ConcurrentDictionary _gauges = new(); - private readonly ConcurrentDictionary _histograms = new(); - private readonly ConcurrentDictionary _summaries = new(); - - private readonly struct CounterInitializer - { - public readonly MetricFactory Inner; - public readonly TimeSpan ExpiresAfter; - public readonly string Help; - public readonly CounterConfiguration? Configuration; - - public CounterInitializer(MetricFactory inner, TimeSpan expiresAfter, string help, CounterConfiguration? configuration) - { - Inner = inner; - ExpiresAfter = expiresAfter; - Help = help; - Configuration = configuration; - } - - public ManagedLifetimeCounter CreateInstance(CollectorIdentity identity) - { - var metric = Inner.CreateCounter(identity.Name, Help, identity.InstanceLabelNames, Configuration); - return new ManagedLifetimeCounter(metric, ExpiresAfter); - } - } - - private readonly struct GaugeInitializer - { - public readonly MetricFactory Inner; - public readonly TimeSpan ExpiresAfter; - public readonly string Help; - public readonly GaugeConfiguration? Configuration; - - public GaugeInitializer(MetricFactory inner, TimeSpan expiresAfter, string help, GaugeConfiguration? configuration) - { - Inner = inner; - ExpiresAfter = expiresAfter; - Help = help; - Configuration = configuration; - } - - public ManagedLifetimeGauge CreateInstance(CollectorIdentity identity) - { - var metric = Inner.CreateGauge(identity.Name, Help, identity.InstanceLabelNames, Configuration); - return new ManagedLifetimeGauge(metric, ExpiresAfter); - } - } - - private readonly struct HistogramInitializer - { - public readonly MetricFactory Inner; - public readonly TimeSpan ExpiresAfter; - public readonly string Help; - public readonly HistogramConfiguration? Configuration; - - public HistogramInitializer(MetricFactory inner, TimeSpan expiresAfter, string help, HistogramConfiguration? configuration) - { - Inner = inner; - ExpiresAfter = expiresAfter; - Help = help; - Configuration = configuration; - } - - public ManagedLifetimeHistogram CreateInstance(CollectorIdentity identity) - { - var metric = Inner.CreateHistogram(identity.Name, Help, identity.InstanceLabelNames, Configuration); - return new ManagedLifetimeHistogram(metric, ExpiresAfter); - } - } - - private readonly struct SummaryInitializer - { - public readonly MetricFactory Inner; - public readonly TimeSpan ExpiresAfter; - public readonly string Help; - public readonly SummaryConfiguration? Configuration; - - public SummaryInitializer(MetricFactory inner, TimeSpan expiresAfter, string help, SummaryConfiguration? configuration) - { - Inner = inner; - ExpiresAfter = expiresAfter; - Help = help; - Configuration = configuration; - } - - public ManagedLifetimeSummary CreateInstance(CollectorIdentity identity) - { - var metric = Inner.CreateSummary(identity.Name, Help, identity.InstanceLabelNames, Configuration); - return new ManagedLifetimeSummary(metric, ExpiresAfter); - } + var metric = _inner.CreateSummary(identity.MetricFamilyName, help, identity.InstanceLabelNames, configuration); + var instance = new ManagedLifetimeSummary(metric, _expiresAfter); + + _summariesLock.EnterWriteLock(); + + try + { +#if NET + // It could be that someone beats us to it! Probably not, though. + if (_summaries.TryAdd(identity, instance)) + return instance; + + return _summaries[identity]; +#else + // On .NET Fx we need to do the pessimistic case first because there is no TryAdd(). + if (_summaries.TryGetValue(identity, out var existing)) + return existing; + + _summaries.Add(identity, instance); + return instance; +#endif + } + finally + { + _summariesLock.ExitWriteLock(); } } + + /// + /// Gets all the existing label names predefined either in the factory or in the registry. + /// + internal StringSequence GetAllStaticLabelNames() => _inner.GetAllStaticLabelNames(); + + // We need to reuse existing instances of lifetime-managed metrics because the user might not want to cache it. + // This somewhat duplicates the metric identity tracking logic in CollectorRegistry but this is intentional, as we really do need to do this work on two layers. + // We never remove collectors from here as long as the factory is alive. The expectation is that there is not an unbounded set of label names, so this set is non-gigantic. + private readonly Dictionary _counters = new(); + private readonly ReaderWriterLockSlim _countersLock = new(); + + private readonly Dictionary _gauges = new(); + private readonly ReaderWriterLockSlim _gaugesLock = new(); + + private readonly Dictionary _histograms = new(); + private readonly ReaderWriterLockSlim _histogramsLock = new(); + + private readonly Dictionary _summaries = new(); + private readonly ReaderWriterLockSlim _summariesLock = new(); } diff --git a/Prometheus/ManagedLifetimeMetricHandle.cs b/Prometheus/ManagedLifetimeMetricHandle.cs index 53d71563..3045338a 100644 --- a/Prometheus/ManagedLifetimeMetricHandle.cs +++ b/Prometheus/ManagedLifetimeMetricHandle.cs @@ -1,13 +1,24 @@ -using System.Collections.Concurrent; +using System.Buffers; namespace Prometheus; -internal abstract class ManagedLifetimeMetricHandle : IManagedLifetimeMetricHandle +/// +/// Represents a metric whose lifetime is managed by the caller, either via explicit leases or via extend-on-use behavior (implicit leases). +/// +/// +/// Each metric handle maintains a reaper task that occasionally removes metrics that have expired. The reaper is started +/// when the first lifetime-managed metric is created and terminates when the last lifetime-managed metric expires. +/// This does mean that the metric handle may keep objects alive until expiration, even if the handle itself is no longer used. +/// +internal abstract class ManagedLifetimeMetricHandle + : IManagedLifetimeMetricHandle, INotifyLeaseEnded where TChild : ChildBase, TMetricInterface where TMetricInterface : ICollectorChild { internal ManagedLifetimeMetricHandle(Collector metric, TimeSpan expiresAfter) { + _reaperFunc = Reaper; + _metric = metric; _expiresAfter = expiresAfter; } @@ -15,6 +26,7 @@ internal ManagedLifetimeMetricHandle(Collector metric, TimeSpan expiresA protected readonly Collector _metric; protected readonly TimeSpan _expiresAfter; + #region Lease(string[]) public IDisposable AcquireLease(out TMetricInterface metric, params string[] labelValues) { var child = _metric.WithLabels(labelValues); @@ -23,19 +35,28 @@ public IDisposable AcquireLease(out TMetricInterface metric, params string[] lab return TakeLease(child); } + public RefLease AcquireRefLease(out TMetricInterface metric, params string[] labelValues) + { + var child = _metric.WithLabels(labelValues); + metric = child; + + return TakeRefLease(child); + } + public void WithLease(Action action, params string[] labelValues) { var child = _metric.WithLabels(labelValues); - var lease = TakeLeaseFast(child); + using var lease = TakeRefLease(child); - try - { - action(child); - } - finally - { - lease.Dispose(); - } + action(child); + } + + public void WithLease(Action action, TArg arg, params string[] labelValues) + { + var child = _metric.WithLabels(labelValues); + using var lease = TakeRefLease(child); + + action(arg, child); } public async Task WithLeaseAsync(Func action, params string[] labelValues) @@ -55,270 +76,422 @@ public async Task WithLeaseAsync(Func WithExtendLifetimeOnUse(); + #region Lease(ReadOnlyMemory) + public IDisposable AcquireLease(out TMetricInterface metric, ReadOnlyMemory labelValues) + { + var child = _metric.WithLabels(labelValues); + metric = child; - /// - /// Internal to allow the delay logic to be replaced in test code, enabling (non-)expiration on demand. - /// - internal IDelayer Delayer = RealDelayer.Instance; + return TakeLease(child); + } - /// - /// An instance of LifetimeManager takes care of the lifetime of a single child metric: - /// * It maintains the count of active leases. - /// * It schedules removal for a suitable moment after the last lease is released. - /// - /// Once the lifetime manager decides to remove the metric, it can no longer be used and a new lifetime manager must be allocated. - /// Taking new leases after removal will have no effect without recycling the lifetime manager (because it will be a lease on - /// a metric instance that has already been removed from its parent metric family - even if you update the value, it is no longer exported). - /// - /// - /// Expiration is managed on a loosely accurate method - when the first lease is taken, an expiration timer is started. - /// This timer will tick at a regular interval and, upon each tick, check whether the metric needs to expire. That's it. - /// The metric expiration is guaranteed to be no less than [expiresAfter] has elapsed, but may be more as the timer ticks on its own clock. - /// - private sealed class LifetimeManager - { - public LifetimeManager(TChild child, TimeSpan expiresAfter, IDelayer delayer, Action remove) - { - _child = child; - _expiresAfter = expiresAfter; - _delayer = delayer; - _remove = remove; + public RefLease AcquireRefLease(out TMetricInterface metric, ReadOnlyMemory labelValues) + { + var child = _metric.WithLabels(labelValues); + metric = child; - // NB! There may be optimistic copies made by the ConcurrentDictionary - this may be such a copy! - _reusableLease = new ReusableLease(ReleaseLease); - } + return TakeRefLease(child); + } - private readonly TChild _child; - private readonly TimeSpan _expiresAfter; - private readonly IDelayer _delayer; - private readonly Action _remove; + public void WithLease(Action action, ReadOnlyMemory labelValues) + { + var child = _metric.WithLabels(labelValues); + using var lease = TakeRefLease(child); - private readonly object _lock = new(); - private int _leaseCount = 0; + action(child); + } - // Taking or releasing a lease will always start a new epoch. The expiration timer simply checks whether the epoch changes between two ticks. - // If the epoch changes, it must mean there was some lease-related activity and it will do nothing. If the epoch remains the same and the lease - // count is 0, the metric has expired and will be removed. - private int _epoch = 0; + public void WithLease(Action action, TArg arg, ReadOnlyMemory labelValues) + { + var child = _metric.WithLabels(labelValues); + using var lease = TakeRefLease(child); - // We start the expiration timer the first time a lease is taken. - private bool _timerStarted; + action(arg, child); + } - private readonly ReusableLease _reusableLease; + public async Task WithLeaseAsync(Func action, ReadOnlyMemory labelValues) + { + using var lease = AcquireLease(out var metric, labelValues); + await action(metric); + } - public IDisposable TakeLease() - { - TakeLeaseCore(); + public TResult WithLease(Func func, ReadOnlyMemory labelValues) + { + using var lease = AcquireLease(out var metric, labelValues); + return func(metric); + } - return new Lease(ReleaseLease); - } + public async Task WithLeaseAsync(Func> func, ReadOnlyMemory labelValues) + { + using var lease = AcquireLease(out var metric, labelValues); + return await func(metric); + } + #endregion - /// - /// Returns a reusable lease-releaser object. Only for internal use - to avoid allocating on every lease. - /// - internal IDisposable TakeLeaseFast() - { - TakeLeaseCore(); + #region Lease(ReadOnlySpan) + public IDisposable AcquireLease(out TMetricInterface metric, ReadOnlySpan labelValues) + { + var child = _metric.WithLabels(labelValues); + metric = child; - return _reusableLease; - } + return TakeLease(child); + } - private void TakeLeaseCore() - { - lock (_lock) - { - EnsureExpirationTimerStarted(); + public RefLease AcquireRefLease(out TMetricInterface metric, ReadOnlySpan labelValues) + { + var child = _metric.WithLabels(labelValues); + metric = child; - _leaseCount++; - unchecked { _epoch++; } - } - } + return TakeRefLease(child); + } - private void ReleaseLease() - { - lock (_lock) - { - _leaseCount--; - unchecked { _epoch++; } - } - } + public void WithLease(Action action, ReadOnlySpan labelValues) + { + var child = _metric.WithLabels(labelValues); + using var lease = TakeRefLease(child); - private void EnsureExpirationTimerStarted() - { - if (_timerStarted) - return; + action(child); + } - _timerStarted = true; + public void WithLease(Action action, TArg arg, ReadOnlySpan labelValues) + { + var child = _metric.WithLabels(labelValues); + using var lease = TakeRefLease(child); - _ = Task.Run(ExecuteExpirationTimer); - } + action(arg, child); + } - private async Task ExecuteExpirationTimer() - { - while (true) - { - int epochBeforeDelay; - - lock (_lock) - epochBeforeDelay = _epoch; + public TResult WithLease(Func func, ReadOnlySpan labelValues) + { + using var lease = AcquireLease(out var metric, labelValues); + return func(metric); + } + #endregion - // We iterate on the expiration interval. This means that the real lifetime of a metric may be up to 2x the expiration interval. - // This is fine - we are intentionally loose here, to avoid the timer logic being scheduled too aggressively. Approximate is good enough. - await _delayer.Delay(_expiresAfter); + public abstract ICollector WithExtendLifetimeOnUse(); - lock (_lock) - { - if (_leaseCount != 0) - continue; // Will not expire if there are active leases. + /// + /// Internal to allow the delay logic to be replaced in test code, enabling (non-)expiration on demand. + /// + internal IDelayer Delayer = RealDelayer.Instance; - if (_epoch != epochBeforeDelay) - continue; // Will not expire if some leasing activity happened during this interval. - } + #region Lease tracking + private readonly Dictionary _lifetimes = new(); - // Expired! - // - // It is possible that a new lease still gets taken before this call completes, because we are not yet holding the lifetime manager write lock that - // guards against new leases being taken. In that case, the new lease will be a dud - it will fail to extend the lifetime because the removal happens - // already now, even if the new lease is taken. This is intentional, to keep the code simple. - _remove(_child); - break; - } - } + // Guards the collection but not the contents. + private readonly ReaderWriterLockSlim _lifetimesLock = new(); - private sealed class Lease : IDisposable + private bool HasAnyTrackedLifetimes() + { + _lifetimesLock.EnterReadLock(); + + try { - public Lease(Action releaseLease) - { - _releaseLease = releaseLease; - } + return _lifetimes.Count != 0; + } + finally + { + _lifetimesLock.ExitReadLock(); + } + } - ~Lease() - { - // Anomalous but we'll do the best we can. - Dispose(); - } + /// + /// For testing only. Sets all keepalive timestamps to a time in the disstant past, + /// which will cause all lifetimes to expire (if they have no leases). + /// + internal void SetAllKeepaliveTimestampsToDistantPast() + { + // We cannot just zero this because zero is the machine start timestamp, so zero is not necessarily + // far in the past (especially if the machine is a build agent that just started up). 1 year negative should work, though. + var distantPast = -PlatformCompatibilityHelpers.ElapsedToTimeStopwatchTicks(TimeSpan.FromDays(365)); - private readonly Action _releaseLease; + _lifetimesLock.EnterReadLock(); - private bool _disposed; - private readonly object _lock = new(); + try + { + foreach (var lifetime in _lifetimes.Values) + Volatile.Write(ref lifetime.KeepaliveTimestamp, distantPast); + } + finally + { + _lifetimesLock.ExitReadLock(); + } + } - public void Dispose() - { - lock (_lock) - { - if (_disposed) - return; + /// + /// For anomaly analysis during testing only. + /// + internal void DebugDumpLifetimes() + { + _lifetimesLock.EnterReadLock(); - _disposed = true; - } + try + { + Console.WriteLine($"Dumping {_lifetimes.Count} lifetimes of {_metric}. Reaper status: {Volatile.Read(ref _reaperActiveBool)}."); - _releaseLease(); - GC.SuppressFinalize(this); + foreach (var pair in _lifetimes) + { + Console.WriteLine($"{pair.Key} -> {pair.Value}"); } } - - public sealed class ReusableLease : IDisposable + finally { - public ReusableLease(Action releaseLease) - { - _releaseLease = releaseLease; - } + _lifetimesLock.ExitReadLock(); + } + } - private readonly Action _releaseLease; + private IDisposable TakeLease(TChild child) + { + var lifetime = GetOrCreateLifetimeAndIncrementLeaseCount(child); + EnsureReaperActive(); - public void Dispose() - { - _releaseLease(); - } - } + return new Lease(this, child, lifetime); } - /// - /// The lifetime manager of each child is stored here. We optimistically allocate them to avoid synchronization on the hot path. - /// We only synchronize when disposing of children whose lifetime has expired, to avoid racing between concurrent removal and re-publishing. - /// - /// Avoiding races during lifetime manager allocation: - /// * Creating a new instance of LifetimeManager is harmless in duplicate. - /// - An instance of LifetimeManager will only "start" once its methods are called, not in its ctor. - /// - ConcurrentDictionary will throw away an optimistically created duplicate. - /// * Creating a new instance takes a reader lock to allow allocation to be blocked by removal logic. - /// * Removal will take a writer lock to prevent concurrent allocataions (which also implies preventing concurrent new leases that might "renew" a lifetime). - /// - It can be that between "unpublishing needed" event and write lock being taken, the state of the lifetime manager changes because of - /// actions done by holders of the read lock (e.g. new lease added). For code simplicity, we accept this as a gap where we may lose data (such a lease fails to renew/start a lifetime). - /// - private readonly ConcurrentDictionary _lifetimeManagers = new(); + private RefLease TakeRefLease(TChild child) + { + var lifetime = GetOrCreateLifetimeAndIncrementLeaseCount(child); + EnsureReaperActive(); - private readonly ReaderWriterLockSlim _lifetimeManagersLock = new(); + return new RefLease(this, child, lifetime); + } - /// - /// Takes a new lease on a child, allocating a new lifetime manager if necessary. - /// Any number of leases may be held concurrently on the same child. - /// As soon as the last lease is released, the child is eligible for removal, though new leases may still be taken to extend the lifetime. - /// - private IDisposable TakeLease(TChild child) + private ChildLifetimeInfo GetOrCreateLifetimeAndIncrementLeaseCount(TChild child) { - // We synchronize here to ensure that we do not get a LifetimeManager that has already ended the lifetime. - _lifetimeManagersLock.EnterReadLock(); + _lifetimesLock.EnterReadLock(); try { - return GetOrAddLifetimeManagerCore(child).TakeLease(); + // Ideally, there already exists a registered lifetime for this metric instance. + if (_lifetimes.TryGetValue(child, out var existing)) + { + // Immediately increment it, to reduce the risk of any concurrent activities ending the lifetime. + Interlocked.Increment(ref existing.LeaseCount); + return existing; + } } finally { - _lifetimeManagersLock.ExitReadLock(); + _lifetimesLock.ExitReadLock(); } - } - // Non-allocating variant, for internal use via WithLease(). - private IDisposable TakeLeaseFast(TChild child) - { - // We synchronize here to ensure that we do not get a LifetimeManager that has already ended the lifetime. - _lifetimeManagersLock.EnterReadLock(); + // No lifetime registered yet - we need to take a write lock and register it. + var newLifetime = new ChildLifetimeInfo + { + LeaseCount = 1 + }; + + _lifetimesLock.EnterWriteLock(); try { - return GetOrAddLifetimeManagerCore(child).TakeLeaseFast(); +#if NET + // It could be that someone beats us to it! Probably not, though. + if (_lifetimes.TryAdd(child, newLifetime)) + return newLifetime; + + var existing = _lifetimes[child]; + + // Immediately increment it, to reduce the risk of any concurrent activities ending the lifetime. + // Even if something does, it is not the end of the world - the reaper will create a new lifetime when it realizes this happened. + Interlocked.Increment(ref existing.LeaseCount); + return existing; +#else + // On .NET Fx we need to do the pessimistic case first because there is no TryAdd(). + if (_lifetimes.TryGetValue(child, out var existing)) + { + // Immediately increment it, to reduce the risk of any concurrent activities ending the lifetime. + // Even if something does, it is not the end of the world - the reaper will create a new lifetime when it realizes this happened. + Interlocked.Increment(ref existing.LeaseCount); + return existing; + } + + _lifetimes.Add(child, newLifetime); + return newLifetime; +#endif } finally { - _lifetimeManagersLock.ExitReadLock(); + _lifetimesLock.ExitWriteLock(); } } - private LifetimeManager GetOrAddLifetimeManagerCore(TChild child) + internal void OnLeaseEnded(TChild child, ChildLifetimeInfo lifetime) { - // Let's assume optimistically that in the typical case, there already is a lifetime manager for it. - if (_lifetimeManagers.TryGetValue(child, out var existing)) - return existing; + // Update keepalive timestamp before anything else, to avoid racing. + Volatile.Write(ref lifetime.KeepaliveTimestamp, LowGranularityTimeSource.GetStopwatchTimestamp()); + + // If the lifetime has been ended while we still held a lease, it means there was a race that we lost. + // The metric instance may or may not be still alive. To ensure proper cleanup, we re-register a lifetime + // for the metric instance, which will ensure it gets cleaned up when it expires. + if (Volatile.Read(ref lifetime.Ended)) + { + // We just take a new lease and immediately dispose it. We are guaranteed not to loop here because the + // reaper removes lifetimes from the dictionary once ended, so we can never run into the same lifetime again. + TakeRefLease(child).Dispose(); + } - return _lifetimeManagers.GetOrAdd(child, CreateLifetimeManager); + // Finally, decrement the lease count to relinquish any claim on extending the lifetime. + Interlocked.Decrement(ref lifetime.LeaseCount); } - private LifetimeManager CreateLifetimeManager(TChild child) + void INotifyLeaseEnded.OnLeaseEnded(object child, ChildLifetimeInfo lifetime) { - return new LifetimeManager(child, _expiresAfter, Delayer, UnpublishOuter); + OnLeaseEnded((TChild)child, lifetime); } + private sealed class Lease(ManagedLifetimeMetricHandle parent, TChild child, ChildLifetimeInfo lifetime) : IDisposable + { + public void Dispose() => parent.OnLeaseEnded(child, lifetime); + } +#endregion + + #region Reaper + // Whether the reaper is currently active. This is set to true when a metric instance is created and + // reset when the last metric instance expires (after which it may be set again). + // We use atomic operations without locking. + private int _reaperActiveBool = ReaperInactive; + + private const int ReaperActive = 1; + private const int ReaperInactive = 0; + /// - /// Performs the locking necessary to ensure that a LifetimeManager that ends the lifetime does not get reused. + /// Call this immediately after creating a metric instance that will eventually expire. /// - private void UnpublishOuter(TChild child) + private void EnsureReaperActive() { - _lifetimeManagersLock.EnterWriteLock(); - - try + if (Interlocked.CompareExchange(ref _reaperActiveBool, ReaperActive, ReaperInactive) == ReaperActive) { - // We assume here that LifetimeManagers are not so buggy to call this method twice (when another LifetimeManager has replaced the old one). - _ = _lifetimeManagers.TryRemove(child, out _); - child.Remove(); + // It was already active - nothing for us to do. + return; } - finally + + _ = Task.Run(_reaperFunc); + } + + private async Task Reaper() + { + while (true) { - _lifetimeManagersLock.ExitWriteLock(); + var now = LowGranularityTimeSource.GetStopwatchTimestamp(); + + // Will contains the results of pass 1. + TChild[] expiredInstancesBuffer = null!; + int expiredInstanceCount = 0; + + // Pass 1: holding only a read lock, make a list of metric instances that have expired. + _lifetimesLock.EnterReadLock(); + + try + { + try + { + expiredInstancesBuffer = ArrayPool.Shared.Rent(_lifetimes.Count); + + foreach (var pair in _lifetimes) + { + if (Volatile.Read(ref pair.Value.LeaseCount) != 0) + continue; // Not expired. + + if (PlatformCompatibilityHelpers.StopwatchGetElapsedTime(Volatile.Read(ref pair.Value.KeepaliveTimestamp), now) < _expiresAfter) + continue; // Not expired. + + // No leases and keepalive has expired - it is an expired instance! + expiredInstancesBuffer[expiredInstanceCount++] = pair.Key; + } + } + finally + { + _lifetimesLock.ExitReadLock(); + } + + // Pass 2: if we have any work to do, take a write lock and remove the expired metric instances, + // assuming our judgement about their expiration remains valid. We process and lock one by one, + // to avoid holding locks for a long duration if many items expire at once - we are not in any rush. + for (var i = 0; i < expiredInstanceCount; i++) + { + var expiredInstance = expiredInstancesBuffer[i]; + + _lifetimesLock.EnterWriteLock(); + + try + { + if (!_lifetimes.TryGetValue(expiredInstance, out var lifetime)) + continue; // Already gone, nothing for us to do. + + // We need to check again whether the metric instance is still expired, because it may have been + // renewed by a new lease in the meantime. If it is still expired, we can remove it. + if (Volatile.Read(ref lifetime.LeaseCount) != 0) + continue; // Not expired. + + if (PlatformCompatibilityHelpers.StopwatchGetElapsedTime(Volatile.Read(ref lifetime.KeepaliveTimestamp), now) < _expiresAfter) + continue; // Not expired. + + // No leases and keepalive has expired - it is an expired instance! + + // We mark the old lifetime as ended - if it happened that it got associated with a new lease + // (which is possible because we do not prevent lease-taking while in this loop), the new lease + // upon being ended will re-register the lifetime instead of just extending the existing one. + // We can be certain that any concurrent lifetime-affecting logic is using the same LifetimeInfo + // instance because the lifetime dictionary remains locked until we are done (by which time this flag is set). + Volatile.Write(ref lifetime.Ended, true); + + _lifetimes.Remove(expiredInstance); + + // If we did encounter a race, removing the metric instance here means that some metric value updates + // may go missing (until the next lease creates a new instance). This is acceptable behavior, to keep the code simple. + expiredInstance.Remove(); + } + finally + { + _lifetimesLock.ExitWriteLock(); + } + } + } + finally + { + ArrayPool.Shared.Return(expiredInstancesBuffer); + } + + // Check if we need to shut down the reaper or keep going. + _lifetimesLock.EnterReadLock(); + + try + { + if (_lifetimes.Count != 0) + goto has_more_work; + } + finally + { + _lifetimesLock.ExitReadLock(); + } + + CleanupReaper(); + return; + + has_more_work: + // Work done! Go sleep a bit and come back when something may have expired. + // We do not need to be too aggressive here, as expiration is not a hard schedule guarantee. + await Delayer.Delay(_expiresAfter); } } + + /// + /// Called when the reaper has noticed that all metric instances have expired and it has no more work to do. + /// + private void CleanupReaper() + { + Volatile.Write(ref _reaperActiveBool, ReaperInactive); + + // The reaper is now gone. However, as we do not use locking here it is possible that someone already + // added metric instances (which saw "oh reaper is still running") before we got here. Let's check - if + // there appear to be metric instances registered, we may need to start the reaper again. + if (HasAnyTrackedLifetimes()) + EnsureReaperActive(); + } + + private readonly Func _reaperFunc; + #endregion } \ No newline at end of file diff --git a/Prometheus/ManagedLifetimeMetricIdentity.cs b/Prometheus/ManagedLifetimeMetricIdentity.cs new file mode 100644 index 00000000..2b4babab --- /dev/null +++ b/Prometheus/ManagedLifetimeMetricIdentity.cs @@ -0,0 +1,66 @@ +namespace Prometheus; + +/// +/// For managed lifetime metrics, we just want to uniquely identify metric instances so we can cache them. +/// We differentiate by the family name + the set of unique instance label names applied to the instance. +/// +/// Managed lifetime metrics are not differentiated by static labels because the static labels are applied +/// in a lower layer (the underlying MetricFactory) and cannot differ within a single ManagedLifetimeMetricFactory. +/// +internal readonly struct ManagedLifetimeMetricIdentity : IEquatable +{ + public readonly string MetricFamilyName; + public readonly StringSequence InstanceLabelNames; + + private readonly int _hashCode; + + public ManagedLifetimeMetricIdentity(string metricFamilyName, StringSequence instanceLabelNames) + { + MetricFamilyName = metricFamilyName; + InstanceLabelNames = instanceLabelNames; + + _hashCode = CalculateHashCode(metricFamilyName, instanceLabelNames); + } + + public bool Equals(ManagedLifetimeMetricIdentity other) + { + if (_hashCode != other._hashCode) + return false; + + if (!string.Equals(MetricFamilyName, other.MetricFamilyName, StringComparison.Ordinal)) + return false; + + if (!InstanceLabelNames.Equals(other.InstanceLabelNames)) + return false; + + return true; + } + + public override int GetHashCode() + { + return _hashCode; + } + + private static int CalculateHashCode(string metricFamilyName, StringSequence instanceLabelNames) + { + unchecked + { + int hashCode = 0; + + hashCode ^= metricFamilyName.GetHashCode() * 997; + hashCode ^= instanceLabelNames.GetHashCode() * 397; + + return hashCode; + } + } + + public override string ToString() + { + return $"{MetricFamilyName}{InstanceLabelNames}"; + } + + public override bool Equals(object? obj) + { + return obj is ManagedLifetimeMetricIdentity identity && Equals(identity); + } +} diff --git a/Prometheus/ManagedLifetimeSummary.cs b/Prometheus/ManagedLifetimeSummary.cs index 6433aacf..5cb1f8d0 100644 --- a/Prometheus/ManagedLifetimeSummary.cs +++ b/Prometheus/ManagedLifetimeSummary.cs @@ -1,11 +1,81 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// This class implements two sets of functionality: +/// 1. A lifetime-managed metric handle that can be used to take leases on the metric. +/// 2. An automatically-lifetime-extending-on-use metric that creates leases automatically. +/// +/// While conceptually separate, we merge the two sets into one class to avoid allocating a bunch of small objects +/// every time you want to obtain a lifetime-extending-on-use metric (which tends to be on a relatively hot path). +/// +/// The lifetime-extending feature only supports write operations because we cannot guarantee that the metric is still alive when reading. +/// +internal sealed class ManagedLifetimeSummary : ManagedLifetimeMetricHandle, ICollector { - internal sealed class ManagedLifetimeSummary : ManagedLifetimeMetricHandle + static ManagedLifetimeSummary() + { + _assignUnlabelledFunc = AssignUnlabelled; + } + + public ManagedLifetimeSummary(Collector metric, TimeSpan expiresAfter) : base(metric, expiresAfter) { - public ManagedLifetimeSummary(Collector metric, TimeSpan expiresAfter) : base(metric, expiresAfter) + } + + public override ICollector WithExtendLifetimeOnUse() => this; + + #region ICollector implementation (for WithExtendLifetimeOnUse) + public string Name => _metric.Name; + public string Help => _metric.Help; + public string[] LabelNames => _metric.LabelNames; + + public ISummary Unlabelled => NonCapturingLazyInitializer.EnsureInitialized(ref _unlabelled, this, _assignUnlabelledFunc); + private AutoLeasingInstance? _unlabelled; + private static readonly Action _assignUnlabelledFunc; + private static void AssignUnlabelled(ManagedLifetimeSummary instance) => instance._unlabelled = new AutoLeasingInstance(instance, Array.Empty()); + + // These do not get cached, so are potentially expensive - user code should try avoiding re-allocating these when possible, + // though admittedly this may not be so easy as often these are on the hot path and the very reason that lifetime-managed + // metrics are used is that we do not have a meaningful way to reuse metrics or identify their lifetime. + public ISummary WithLabels(params string[] labelValues) => WithLabels(labelValues.AsMemory()); + + public ISummary WithLabels(ReadOnlyMemory labelValues) + { + return new AutoLeasingInstance(this, labelValues); + } + + public ISummary WithLabels(ReadOnlySpan labelValues) + { + // We are allocating a long-lived auto-leasing wrapper here, so there is no way we can just use the span directly. + // We must copy it to a long-lived array. Another reason to avoid re-allocating these as much as possible. + return new AutoLeasingInstance(this, labelValues.ToArray()); + } + #endregion + + private sealed class AutoLeasingInstance : ISummary + { + public AutoLeasingInstance(IManagedLifetimeMetricHandle inner, ReadOnlyMemory labelValues) + { + _inner = inner; + _labelValues = labelValues; + } + + private readonly IManagedLifetimeMetricHandle _inner; + private readonly ReadOnlyMemory _labelValues; + + public void Observe(double val) + { + var args = new ObserveArgs(val); + + // We use the Span overload to signal that we expect the label values to be known already. + _inner.WithLease(_observeCoreFunc, args, _labelValues.Span); + } + + private readonly struct ObserveArgs(double val) { + public readonly double Val = val; } - public override ICollector WithExtendLifetimeOnUse() => new AutoLeasingSummary(this, _metric); + private static void ObserveCore(ObserveArgs args, ISummary summary) => summary.Observe(args.Val); + private static readonly Action _observeCoreFunc = ObserveCore; } } diff --git a/Prometheus/MeterAdapter.cs b/Prometheus/MeterAdapter.cs index 62dfe5fe..ed41a76f 100644 --- a/Prometheus/MeterAdapter.cs +++ b/Prometheus/MeterAdapter.cs @@ -1,7 +1,9 @@ -using System.Collections.Concurrent; +#if NET6_0_OR_GREATER +using System.Buffers; +using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.Metrics; -using System.Diagnostics.Tracing; +using System.Runtime.InteropServices; using System.Text; namespace Prometheus; @@ -11,17 +13,36 @@ namespace Prometheus; /// public sealed class MeterAdapter : IDisposable { - public static IDisposable StartListening() => new MeterAdapter(MeterAdapterOptions.Default); + public static IDisposable StartListening() => StartListening(MeterAdapterOptions.Default); - public static IDisposable StartListening(MeterAdapterOptions options) => new MeterAdapter(options); + public static IDisposable StartListening(MeterAdapterOptions options) + { + // If we are re-registering an adapter with the default options, just pretend and move on. + // The purpose of this code is to avoid double-counting metrics if the adapter is registered twice with the default options. + // This could happen because in 7.0.0 we added automatic registration of the adapters on startup, but the user might still + // have a manual registration active from 6.0.0 days. We do this small thing here to make upgrading less hassle. + if (options == MeterAdapterOptions.Default) + { + if (options.Registry.PreventMeterAdapterRegistrationWithDefaultOptions) + return new NoopDisposable(); + + options.Registry.PreventMeterAdapterRegistrationWithDefaultOptions = true; + } + + return new MeterAdapter(options); + } private MeterAdapter(MeterAdapterOptions options) { + _createGaugeFunc = CreateGauge; + _createHistogramFunc = CreateHistogram; + _options = options; _registry = options.Registry; - _factory = (ManagedLifetimeMetricFactory)Metrics.WithCustomRegistry(_options.Registry) - .WithManagedLifetime(expiresAfter: options.MetricsExpireAfter); + + var baseFactory = options.MetricFactory ?? Metrics.WithCustomRegistry(_options.Registry); + _factory = (ManagedLifetimeMetricFactory)baseFactory.WithManagedLifetime(expiresAfter: options.MetricsExpireAfter); _inheritedStaticLabelNames = _factory.GetAllStaticLabelNames().ToArray(); @@ -36,7 +57,7 @@ private MeterAdapter(MeterAdapterOptions options) _listener.SetMeasurementEventCallback(OnMeasurementRecorded); var regularFactory = Metrics.WithCustomRegistry(_registry); - _instrumentsConnected = regularFactory.CreateGauge("prometheus_net_meteradapter_instruments_connected_total", "Number of instruments that are currently connected to the adapter."); + _instrumentsConnected = regularFactory.CreateGauge("prometheus_net_meteradapter_instruments_connected", "Number of instruments that are currently connected to the adapter."); _listener.Start(); @@ -44,8 +65,9 @@ private MeterAdapter(MeterAdapterOptions options) { // ICollectorRegistry does not support unregistering the callback, so we just no-op when disposed. // The expected pattern is that any disposal of the pipeline also throws away the ICollectorRegistry. - if (_disposed) - return; + lock (_disposedLock) + if (_disposed) + return; // Seems OK to call even when _listener has been disposed. _listener.RecordObservableInstruments(); @@ -62,12 +84,12 @@ private MeterAdapter(MeterAdapterOptions options) private readonly MeterListener _listener = new MeterListener(); - private volatile bool _disposed; - private readonly object _lock = new(); + private bool _disposed; + private readonly object _disposedLock = new(); public void Dispose() { - lock (_lock) + lock (_disposedLock) { if (_disposed) return; @@ -101,72 +123,81 @@ private void OnInstrumentPublished(Instrument instrument, MeterListener listener } } - private void OnMeasurementRecorded(Instrument instrument, T measurement, ReadOnlySpan> tags, object? state) - where T : struct + private void OnMeasurementRecorded( + Instrument instrument, + TMeasurement measurement, + ReadOnlySpan> tags, + object? state) + where TMeasurement : struct { // NOTE: If we throw an exception from this, it can lead to the instrument becoming inoperable (no longer measured). Let's not do that. + // We assemble and sort the label values in a temporary buffer. If the metric instance is already known + // to prometheus-net, this means no further memory allocation for the label values is required below. + var labelValuesBuffer = ArrayPool.Shared.Rent(tags.Length); + try { - // NB! Order of labels matters in the prometheus-net API. However, in .NET Meters the data is unordered. - // Therefore, we need to sort the labels to ensure that we always create metrics with the same order. - var sortedTags = tags.ToArray().OrderBy(x => x.Key, StringComparer.Ordinal).ToList(); - var labelNameCandidates = TagsToLabelNames(sortedTags); - var labelValueCandidates = TagsToLabelValues(sortedTags); - - // NOTE: As we accept random input from external code here, there is no guarantee that the labels in this code do not conflict with existing static labels. - // We must therefore take explicit action here to prevent conflict (as prometheus-net will detect and fault on such conflicts). We do this by inspecting - // the internals of the factory to identify conflicts with any static labels, and remove those lables from the Meters API data point (static overrides dynamic). - FilterLabelsToAvoidConflicts(labelNameCandidates, labelValueCandidates, _inheritedStaticLabelNames, out var labelNames, out var labelValues); - - var value = Convert.ToDouble(measurement); - - // We do not represent any of the "counter" style .NET meter types as counters because they may be re-created on the .NET Meters side at any time, decrementing the value! - - if (instrument is Counter) + double value = unchecked(measurement switch + { + byte x => x, + short x => x, + int x => x, + long x => x, + float x => (double)x, + double x => x, + decimal x => (double)x, + _ => throw new NotSupportedException($"Measurement type {typeof(TMeasurement).Name} is not supported.") + }); + + // We do not represent any of the "counter" style .NET meter types as counters because + // they may be re-created on the .NET Meters side at any time, decrementing the value! + + if (instrument is Counter) { - var handle = _factory.CreateGauge(_instrumentPrometheusNames[instrument], _instrumentPrometheusHelp[instrument], labelNames); + var context = GetOrCreateGaugeContext(instrument, tags); + var labelValues = CopyTagValuesToLabelValues(context.PrometheusLabelValueIndexes, tags, labelValuesBuffer.AsSpan()); // A measurement is the increment. - handle.WithLease(c => c.Inc(value), labelValues); + context.MetricInstanceHandle.WithLease(_incrementGaugeFunc, value, labelValues); } - else if (instrument is ObservableCounter) + else if (instrument is ObservableCounter) { - var handle = _factory.CreateGauge(_instrumentPrometheusNames[instrument], _instrumentPrometheusHelp[instrument], labelNames); + var context = GetOrCreateGaugeContext(instrument, tags); + var labelValues = CopyTagValuesToLabelValues(context.PrometheusLabelValueIndexes, tags, labelValuesBuffer.AsSpan()); - // A measurement is the current value. - handle.WithLease(c => c.IncTo(value), labelValues); + // A measurement is the current value. We transform it into a Set() to allow the counter to reset itself (unusual but who are we to say no). + context.MetricInstanceHandle.WithLease(_setGaugeFunc, value, labelValues); } - /* .NET 7: else if (instrument is UpDownCounter) +#if NET7_0_OR_GREATER + else if (instrument is UpDownCounter) { - var handle = _factory.CreateGauge(_instrumentPrometheusNames[instrument], _instrumentPrometheusHelp[instrument], new GaugeConfiguration - { - LabelNames = labelNames - }); - - using (handle.AcquireLease(out var gauge, labelValues)) - { - // A measurement is the increment. - gauge.Inc(value); - } - }*/ - else if (instrument is ObservableGauge /* .NET 7: or ObservableUpDownCounter*/) + var context = GetOrCreateGaugeContext(instrument, tags); + var labelValues = CopyTagValuesToLabelValues(context.PrometheusLabelValueIndexes, tags, labelValuesBuffer.AsSpan()); + + // A measurement is the increment. + context.MetricInstanceHandle.WithLease(_incrementGaugeFunc, value, labelValues); + } +#endif + else if (instrument is ObservableGauge +#if NET7_0_OR_GREATER + or ObservableUpDownCounter +#endif + ) { - var handle = _factory.CreateGauge(_instrumentPrometheusNames[instrument], _instrumentPrometheusHelp[instrument], labelNames); + var context = GetOrCreateGaugeContext(instrument, tags); + var labelValues = CopyTagValuesToLabelValues(context.PrometheusLabelValueIndexes, tags, labelValuesBuffer.AsSpan()); // A measurement is the current value. - handle.WithLease(g => g.Set(value), labelValues); + context.MetricInstanceHandle.WithLease(_setGaugeFunc, value, labelValues); } - else if (instrument is Histogram) + else if (instrument is Histogram) { - var handle = _factory.CreateHistogram(_instrumentPrometheusNames[instrument], _instrumentPrometheusHelp[instrument], labelNames, new HistogramConfiguration - { - // We oursource the bucket definition to the callback in options, as it might need to be different for different instruments. - Buckets = _options.ResolveHistogramBuckets(instrument) - }); + var context = GetOrCreateHistogramContext(instrument, tags); + var labelValues = CopyTagValuesToLabelValues(context.PrometheusLabelValueIndexes, tags, labelValuesBuffer.AsSpan()); // A measurement is the observed value. - handle.WithLease(h => h.Observe(value), labelValues); + context.MetricInstanceHandle.WithLease(_observeHistogramFunc, value, labelValues); } else { @@ -177,64 +208,257 @@ private void OnMeasurementRecorded(Instrument instrument, T measurement, Read { Trace.WriteLine($"{instrument.Name} collection failed: {ex.Message}"); } + finally + { + ArrayPool.Shared.Return(labelValuesBuffer); + } } - private static void FilterLabelsToAvoidConflicts(string[] nameCandidates, string[] valueCandidates, string[] namesToSkip, out string[] names, out string[] values) + private static void IncrementGauge(double value, IGauge gauge) => gauge.Inc(value); + private static readonly Action _incrementGaugeFunc = IncrementGauge; + + private static void SetGauge(double value, IGauge gauge) => gauge.Set(value); + private static readonly Action _setGaugeFunc = SetGauge; + + private static void ObserveHistogram(double value, IHistogram histogram) => histogram.Observe(value); + private static readonly Action _observeHistogramFunc = ObserveHistogram; + + // Cache key: Instrument + user-ordered list of label names. + // NB! The same Instrument may be cached multiple times, with the same label names in a different order! + private readonly struct CacheKey(Instrument instrument, StringSequence meterLabelNames) : IEquatable { - var acceptedNames = new List(nameCandidates.Length); - var acceptedValues = new List(valueCandidates.Length); + public Instrument Instrument { get; } = instrument; - for (int i = 0; i < nameCandidates.Length; i++) + // Order is whatever was provided by the caller of the .NET Meters API. + public StringSequence MeterLabelNames { get; } = meterLabelNames; + + public override readonly bool Equals(object? obj) => obj is CacheKey other && Equals(other); + + public override readonly int GetHashCode() => _hashCode; + private readonly int _hashCode = HashCode.Combine(instrument, meterLabelNames); + + public readonly bool Equals(CacheKey other) => Instrument == other.Instrument && MeterLabelNames.Equals(other.MeterLabelNames); + } + + // Cache value: Prometheus metric handle + Prometheus-ordered indexes into original Meters tags list. + // Not all Meter tags may be preserved, as some may have conflicted with static labels and been filtered out. + private sealed class MetricContext( + IManagedLifetimeMetricHandle metricInstanceHandle, + int[] prometheusLabelValueIndexes) + where TMetricInterface : ICollectorChild + { + public IManagedLifetimeMetricHandle MetricInstanceHandle { get; } = metricInstanceHandle; + + // Index into the .NET Meters API labels list, indicating which original label to take the value from. + public int[] PrometheusLabelValueIndexes { get; } = prometheusLabelValueIndexes; + } + + private readonly Dictionary> _gaugeCache = new(); + private readonly ReaderWriterLockSlim _gaugeCacheLock = new(); + + private readonly Dictionary> _histogramCache = new(); + private readonly ReaderWriterLockSlim _histogramCacheLock = new(); + + private MetricContext GetOrCreateGaugeContext(Instrument instrument, in ReadOnlySpan> tags) + => GetOrCreateMetricContext(instrument, tags, _createGaugeFunc, _gaugeCacheLock, _gaugeCache); + + private MetricContext GetOrCreateHistogramContext(Instrument instrument, in ReadOnlySpan> tags) + => GetOrCreateMetricContext(instrument, tags, _createHistogramFunc, _histogramCacheLock, _histogramCache); + + private IManagedLifetimeMetricHandle CreateGauge(Instrument instrument, string name, string help, string[] labelNames) + => _factory.CreateGauge(name, help, labelNames, null); + private readonly Func> _createGaugeFunc; + + private IManagedLifetimeMetricHandle CreateHistogram(Instrument instrument, string name, string help, string[] labelNames) + => _factory.CreateHistogram(name, help, labelNames, new HistogramConfiguration + { + // We outsource the bucket definition to the callback in options, as it might need to be different for different instruments. + Buckets = _options.ResolveHistogramBuckets(instrument) + }); + private readonly Func> _createHistogramFunc; + + private MetricContext GetOrCreateMetricContext( + Instrument instrument, + in ReadOnlySpan> tags, + Func> metricFactory, + ReaderWriterLockSlim cacheLock, + Dictionary> cache) + where TMetricInstance : ICollectorChild + { + // Use a pooled array for the cache key if we are performing a lookup. + // This avoids allocating a new array if the context is already cached. + var meterLabelNamesBuffer = ArrayPool.Shared.Rent(tags.Length); + var meterLabelNamesCount = tags.Length; + + try { - if (namesToSkip.Contains(nameCandidates[i])) - continue; + for (var i = 0; i < tags.Length; i++) + meterLabelNamesBuffer[i] = tags[i].Key; - acceptedNames.Add(nameCandidates[i]); - acceptedValues.Add(valueCandidates[i]); + var meterLabelNames = StringSequence.From(meterLabelNamesBuffer.AsMemory(0, meterLabelNamesCount)); + var cacheKey = new CacheKey(instrument, meterLabelNames); + + cacheLock.EnterReadLock(); + + try + { + // In the common case, we will find the context in the cache and can return it here without any memory allocation. + if (cache.TryGetValue(cacheKey, out var context)) + return context; + } + finally + { + cacheLock.ExitReadLock(); + } + } + finally + { + ArrayPool.Shared.Return(meterLabelNamesBuffer); } - names = acceptedNames.ToArray(); - values = acceptedValues.ToArray(); + // If we got here, we did not find the context in the cache. Make a new one. + return CreateMetricContext(instrument, tags, metricFactory, cacheLock, cache); } - private void OnMeasurementsCompleted(Instrument instrument, object? state) + private MetricContext CreateMetricContext( + Instrument instrument, + in ReadOnlySpan> tags, + Func> metricFactory, + ReaderWriterLockSlim cacheLock, + Dictionary> cache) + where TMetricInstance : ICollectorChild { - // Called when no more data is coming for an instrument. We do not do anything with the already published metrics because: - // 1) We operate on a pull model - just because the instrument goes away does not mean that the latest data from it has been pulled. - // 2) We already have a perfectly satisfactory expiration based lifetime control model, no need to complicate with a second logic alongside. - // 3) There is no 1:1 mapping between instrument and metric due to allowing flexible label name combinations, which may cause undesirable complexity. + var meterLabelNamesBuffer = new string[tags.Length]; - // We know we will not need this data anymore, though, so we can throw it out. - _instrumentPrometheusNames.TryRemove(instrument, out _); - _instrumentPrometheusHelp.TryRemove(instrument, out _); + for (var i = 0; i < tags.Length; i++) + meterLabelNamesBuffer[i] = tags[i].Key; + + var meterLabelNames = StringSequence.From(meterLabelNamesBuffer); + var cacheKey = new CacheKey(instrument, meterLabelNames); + + // Create the context before taking any locks, to avoid holding the cache too long. + DeterminePrometheusLabels(tags, out var prometheusLabelNames, out var prometheusLabelValueIndexes); + var metricHandle = metricFactory(instrument, _instrumentPrometheusNames[instrument], _instrumentPrometheusHelp[instrument], prometheusLabelNames); + var newContext = new MetricContext(metricHandle, prometheusLabelValueIndexes); + + cacheLock.EnterWriteLock(); + + try + { +#if NET + // It could be that someone beats us to it! Probably not, though. + if (cache.TryAdd(cacheKey, newContext)) + return newContext; + + return cache[cacheKey]; +#else + // On .NET Fx we need to do the pessimistic case first because there is no TryAdd(). + if (cache.TryGetValue(cacheKey, out var context)) + return context; + + cache.Add(cacheKey, newContext); + return newContext; +#endif + } + finally + { + cacheLock.ExitWriteLock(); + } } - private string[] TagsToLabelNames(List> tags) + private void DeterminePrometheusLabels( + in ReadOnlySpan> tags, + out string[] prometheusLabelNames, + out int[] prometheusLabelValueIndexes) { - var labelNames = new string[tags.Count]; + var originalsCount = tags.Length; - for (var i = 0; i < tags.Count; i++) + // Prometheus name of the label. + var namesBuffer = ArrayPool.Shared.Rent(originalsCount); + // Index into the original label list. + var indexesBuffer = ArrayPool.Shared.Rent(originalsCount); + // Whether the label should be skipped entirely (because it conflicts with a static label). + var skipFlagsBuffer = ArrayPool.Shared.Rent(originalsCount); + + try { - var prometheusLabelName = _tagPrometheusNames.GetOrAdd(tags[i].Key, TranslateTagNameToPrometheusName); - labelNames[i] = prometheusLabelName; - } + for (var i = 0; i < tags.Length; i++) + { + var prometheusName = _tagPrometheusNames.GetOrAdd(tags[i].Key, _translateTagNameToPrometheusNameFunc); + + namesBuffer[i] = prometheusName; + indexesBuffer[i] = i; + } + + // The order of labels matters in the prometheus-net API. However, in .NET Meters the tags are unordered. + // Therefore, we need to sort the labels to ensure that we always create metrics with the same order. + Array.Sort(keys: namesBuffer, items: indexesBuffer, index: 0, length: originalsCount, StringComparer.Ordinal); + + // NOTE: As we accept random input from external code here, there is no guarantee that the labels in this code + // do not conflict with existing static labels. We must therefore take explicit action here to prevent conflict + // (as prometheus-net will detect and fault on such conflicts). We do this by inspecting the internals of the + // factory to identify conflicts with any static labels, and remove those lables from the Meters API data point + // (static overrides dynamic) if there is a match (by just skipping them in our output index set). + var preservedLabelCount = 0; - return labelNames; + for (var i = 0; i < tags.Length; i++) + { + skipFlagsBuffer[i] = _inheritedStaticLabelNames.Contains(namesBuffer[i], StringComparer.Ordinal); + + if (skipFlagsBuffer[i] == false) + preservedLabelCount++; + } + + prometheusLabelNames = new string[preservedLabelCount]; + prometheusLabelValueIndexes = new int[preservedLabelCount]; + + var nextIndex = 0; + + for (var i = 0; i < tags.Length; i++) + { + if (skipFlagsBuffer[i]) + continue; + + prometheusLabelNames[nextIndex] = namesBuffer[i]; + prometheusLabelValueIndexes[nextIndex] = indexesBuffer[i]; + nextIndex++; + } + } + finally + { + ArrayPool.Shared.Return(skipFlagsBuffer); + ArrayPool.Shared.Return(indexesBuffer); + ArrayPool.Shared.Return(namesBuffer); + } } - private string[] TagsToLabelValues(List> tags) + private void OnMeasurementsCompleted(Instrument instrument, object? state) { - var labelValues = new string[tags.Count]; + // Called when no more data is coming for an instrument. We do not do anything with the already published metrics because: + // 1) We operate on a pull model - just because the instrument goes away does not mean that the latest data from it has been pulled. + // 2) We already have a perfectly satisfactory expiration based lifetime control model, no need to complicate with a second logic alongside. + // 3) There is no 1:1 mapping between instrument and metric due to allowing flexible label name combinations, which may cause undesirable complexity. + + // We also cannot clear our mapping collections yet because it is possible that some measurement observations are still in progress! + // In other words, this may be called before the last OnMeasurementRecorded() call for the instrument has completed (perhaps even started?). + // The entire adapter data set will be collected when the Prometheus registry itself is garbage collected. + } - for (var i = 0; i < tags.Count; i++) + private static ReadOnlySpan CopyTagValuesToLabelValues( + int[] prometheusLabelValueIndexes, + ReadOnlySpan> tags, + Span labelValues) + { + for (var i = 0; i < prometheusLabelValueIndexes.Length; i++) { - labelValues[i] = tags[i].Value?.ToString() ?? ""; + var index = prometheusLabelValueIndexes[i]; + labelValues[i] = tags[index].Value?.ToString() ?? ""; } - return labelValues; + return labelValues[..prometheusLabelValueIndexes.Length]; } - // We use these dictionaries to register Prometheus metrics on-demand for different tag sets. + // We use these dictionaries to register Prometheus metrics on-demand for different instruments. private static readonly ConcurrentDictionary _instrumentPrometheusNames = new(); private static readonly ConcurrentDictionary _instrumentPrometheusHelp = new(); @@ -258,18 +482,35 @@ private static string TranslateTagNameToPrometheusName(string tagName) return PrometheusNameHelpers.TranslateNameToPrometheusName(tagName); } + private static readonly Func _translateTagNameToPrometheusNameFunc = TranslateTagNameToPrometheusName; + + [ThreadStatic] + private static StringBuilder? _prometheusHelpBuilder; + + // If the string builder grows over this, we throw it away and use a new one next time to avoid keeping a large buffer around. + private const int PrometheusHelpBuilderReusableCapacity = 1 * 1024; + private static string TranslateInstrumentDescriptionToPrometheusHelp(Instrument instrument) { - var sb = new StringBuilder(); + _prometheusHelpBuilder ??= new(PrometheusHelpBuilderReusableCapacity); if (!string.IsNullOrWhiteSpace(instrument.Unit)) - sb.AppendFormat($"({instrument.Unit}) "); + _prometheusHelpBuilder.Append($"({instrument.Unit}) "); - sb.Append(instrument.Description); + _prometheusHelpBuilder.Append(instrument.Description); // Append the base type name, so we see what type of metric it is. - sb.Append($" ({instrument.GetType().Name})"); + _prometheusHelpBuilder.Append($" ({instrument.GetType().Name})"); + + var result = _prometheusHelpBuilder.ToString(); + + // If it grew too big, throw it away. + if (_prometheusHelpBuilder.Capacity > PrometheusHelpBuilderReusableCapacity) + _prometheusHelpBuilder = null; + else + _prometheusHelpBuilder.Clear(); - return sb.ToString(); + return result; } -} \ No newline at end of file +} +#endif diff --git a/Prometheus/MeterAdapterOptions.cs b/Prometheus/MeterAdapterOptions.cs index 4b82dd1e..b66e7e1a 100644 --- a/Prometheus/MeterAdapterOptions.cs +++ b/Prometheus/MeterAdapterOptions.cs @@ -1,10 +1,11 @@ -using System.Diagnostics.Metrics; +#if NET6_0_OR_GREATER +using System.Diagnostics.Metrics; namespace Prometheus; -public sealed class MeterAdapterOptions +public sealed record MeterAdapterOptions { - public static readonly MeterAdapterOptions Default = new(); + public static MeterAdapterOptions Default => new(); // This is unlikely to be suitable for all cases, so you will want to customize it per-instrument. public static readonly double[] DefaultHistogramBuckets = Histogram.ExponentialBuckets(0.01, 2, 25); @@ -16,7 +17,7 @@ public sealed class MeterAdapterOptions /// /// The .NET Meters API does not tell us (or even know itself) when a metric with a certain label combination is no longer going to receive new data. - /// To avoid building an ever-increasing store of in-memory metrics states, we unpublish metrics once they have not been updated in a while. + /// To avoid building an ever-increasing store of in-memory metrics states, we delete metrics once they have not been updated in a while. /// The idea being that metrics are useful when they are changing regularly - if a value stays the same for N minutes, it probably is not a valuable data point anymore. /// public TimeSpan MetricsExpireAfter { get; set; } = TimeSpan.FromMinutes(5); @@ -26,8 +27,14 @@ public sealed class MeterAdapterOptions /// public CollectorRegistry Registry { get; set; } = Metrics.DefaultRegistry; + /// + /// If set, the value in Registry is ignored and this factory is instead used to create all the metrics. + /// + public IMetricFactory? MetricFactory { get; set; } = Metrics.DefaultFactory; + /// /// Enables you to define custom buckets for histogram-typed metrics. /// public Func ResolveHistogramBuckets { get; set; } = _ => DefaultHistogramBuckets; } +#endif \ No newline at end of file diff --git a/Prometheus/MetricConfiguration.cs b/Prometheus/MetricConfiguration.cs index 3f66e2d0..07cc7fbc 100644 --- a/Prometheus/MetricConfiguration.cs +++ b/Prometheus/MetricConfiguration.cs @@ -1,28 +1,27 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// This class packages the options for creating metrics into a single class (with subclasses per metric type) +/// for easy extensibility of the API without adding numerous method overloads whenever new options are added. +/// +public abstract class MetricConfiguration { /// - /// This class packages the options for creating metrics into a single class (with subclasses per metric type) - /// for easy extensibility of the API without adding numerous method overloads whenever new options are added. + /// NOTE: Only used by APIs that do not take an explicit labelNames value as input. + /// + /// Names of all the label fields that are defined for each instance of the metric. + /// If null, the metric will be created without any instance-specific labels. + /// + /// Before using a metric that uses instance-specific labels, .WithLabels() must be called to provide values for the labels. /// - public abstract class MetricConfiguration - { - /// - /// NOTE: Only used by APIs that do not take an explicit labelNames value as input. - /// - /// Names of all the label fields that are defined for each instance of the metric. - /// If null, the metric will be created without any instance-specific labels. - /// - /// Before using a metric that uses instance-specific labels, .WithLabels() must be called to provide values for the labels. - /// - public string[]? LabelNames { get; set; } + public string[]? LabelNames { get; set; } - /// - /// If true, the metric will not be published until its value is first modified (regardless of the specific value). - /// This is useful to delay publishing gauges that get their initial values delay-loaded. - /// - /// By default, metrics are published as soon as possible - if they do not use labels then they are published on - /// creation and if they use labels then as soon as the label values are assigned. - /// - public bool SuppressInitialValue { get; set; } - } + /// + /// If true, the metric will not be published until its value is first modified (regardless of the specific value). + /// This is useful to delay publishing gauges that get their initial values delay-loaded. + /// + /// By default, metrics are published as soon as possible - if they do not use labels then they are published on + /// creation and if they use labels then as soon as the label values are assigned. + /// + public bool SuppressInitialValue { get; set; } } diff --git a/Prometheus/MetricFactory.cs b/Prometheus/MetricFactory.cs index 7fbf5f1f..db330d63 100644 --- a/Prometheus/MetricFactory.cs +++ b/Prometheus/MetricFactory.cs @@ -1,175 +1,177 @@ -namespace Prometheus +using static Prometheus.CollectorRegistry; + +namespace Prometheus; + +/// +/// Adds metrics to a registry. +/// +public sealed class MetricFactory : IMetricFactory { + private readonly CollectorRegistry _registry; + + // If set, these labels will be applied to all created metrics, acting as additional static labels scoped to this factory. + // These are appended to the metric-specific static labels set at metric creation time. + private readonly LabelSequence _factoryLabels; + + // Both the factory-defined and the registry-defined static labels. + // TODO: We should validate that registry labels cannot be defined any more once we have already resolved this. + private readonly Lazy _staticLabelsLazy; + + internal MetricFactory(CollectorRegistry registry) : this(registry, LabelSequence.Empty) + { + } + + internal MetricFactory(CollectorRegistry registry, in LabelSequence withLabels) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _factoryLabels = withLabels; + + _staticLabelsLazy = new Lazy(ResolveStaticLabels); + } + + private LabelSequence ResolveStaticLabels() + { + if (_factoryLabels.Length != 0) + return _factoryLabels.Concat(_registry.GetStaticLabels()); + else + return _registry.GetStaticLabels(); + } + + /// + /// Counters only increase in value and reset to zero when the process restarts. + /// + public Counter CreateCounter(string name, string help, CounterConfiguration? configuration = null) + => CreateCounter(name, help, configuration?.LabelNames ?? Array.Empty(), configuration); + + /// + /// Gauges can have any numeric value and change arbitrarily. + /// + public Gauge CreateGauge(string name, string help, GaugeConfiguration? configuration = null) + => CreateGauge(name, help, configuration?.LabelNames ?? Array.Empty(), configuration); + + /// + /// Summaries track the trends in events over time (10 minutes by default). + /// + public Summary CreateSummary(string name, string help, SummaryConfiguration? configuration = null) + => CreateSummary(name, help, configuration?.LabelNames ?? Array.Empty(), configuration); + + /// + /// Histograms track the size and number of events in buckets. + /// + public Histogram CreateHistogram(string name, string help, HistogramConfiguration? configuration = null) + => CreateHistogram(name, help, configuration?.LabelNames ?? Array.Empty(), configuration); + + /// + /// Counters only increase in value and reset to zero when the process restarts. + /// + public Counter CreateCounter(string name, string help, string[] labelNames, CounterConfiguration? configuration = null) + => CreateCounter(name, help, StringSequence.From(labelNames), configuration); + /// - /// Adds metrics to a registry. + /// Gauges can have any numeric value and change arbitrarily. /// - public sealed class MetricFactory : IMetricFactory + public Gauge CreateGauge(string name, string help, string[] labelNames, GaugeConfiguration? configuration = null) + => CreateGauge(name, help, StringSequence.From(labelNames), configuration); + + /// + /// Summaries track the trends in events over time (10 minutes by default). + /// + public Histogram CreateHistogram(string name, string help, string[] labelNames, HistogramConfiguration? configuration = null) + => CreateHistogram(name, help, StringSequence.From(labelNames), configuration); + + /// + /// Histograms track the size and number of events in buckets. + /// + public Summary CreateSummary(string name, string help, string[] labelNames, SummaryConfiguration? configuration = null) + => CreateSummary(name, help, StringSequence.From(labelNames), configuration); + + internal Counter CreateCounter(string name, string help, StringSequence instanceLabelNames, CounterConfiguration? configuration) { - private readonly CollectorRegistry _registry; - - // If set, these labels will be applied to all created metrics, acting as additional static labels scoped to this factory. - // These are appended to the metric-specific static labels set at metric creation time. - private readonly LabelSequence _factoryLabels; - - // Both the factory-defined and the registry-defined static labels. - // TODO: We should validate that registry labels cannot be defined any more once we have already resolved this. - private readonly Lazy _staticLabelsLazy; - - internal MetricFactory(CollectorRegistry registry) : this(registry, LabelSequence.Empty) - { - } - - internal MetricFactory(CollectorRegistry registry, LabelSequence withLabels) - { - _registry = registry ?? throw new ArgumentNullException(nameof(registry)); - _factoryLabels = withLabels; - - _staticLabelsLazy = new Lazy(ResolveStaticLabels); - } - - private LabelSequence ResolveStaticLabels() - { - if (_factoryLabels.Length != 0) - return _factoryLabels.Concat(_registry.GetStaticLabels()); - else - return _registry.GetStaticLabels(); - } - - /// - /// Counters only increase in value and reset to zero when the process restarts. - /// - public Counter CreateCounter(string name, string help, CounterConfiguration? configuration = null) - => CreateCounter(name, help, configuration?.LabelNames ?? Array.Empty(), configuration); - - /// - /// Gauges can have any numeric value and change arbitrarily. - /// - public Gauge CreateGauge(string name, string help, GaugeConfiguration? configuration = null) - => CreateGauge(name, help, configuration?.LabelNames ?? Array.Empty(), configuration); - - /// - /// Summaries track the trends in events over time (10 minutes by default). - /// - public Summary CreateSummary(string name, string help, SummaryConfiguration? configuration = null) - => CreateSummary(name, help, configuration?.LabelNames ?? Array.Empty(), configuration); - - /// - /// Histograms track the size and number of events in buckets. - /// - public Histogram CreateHistogram(string name, string help, HistogramConfiguration? configuration = null) - => CreateHistogram(name, help, configuration?.LabelNames ?? Array.Empty(), configuration); - - /// - /// Counters only increase in value and reset to zero when the process restarts. - /// - public Counter CreateCounter(string name, string help, string[] labelNames, CounterConfiguration? configuration = null) - => CreateCounter(name, help, StringSequence.From(labelNames), configuration); - - /// - /// Gauges can have any numeric value and change arbitrarily. - /// - public Gauge CreateGauge(string name, string help, string[] labelNames, GaugeConfiguration? configuration = null) - => CreateGauge(name, help, StringSequence.From(labelNames), configuration); - - /// - /// Summaries track the trends in events over time (10 minutes by default). - /// - public Histogram CreateHistogram(string name, string help, string[] labelNames, HistogramConfiguration? configuration = null) - => CreateHistogram(name, help, StringSequence.From(labelNames), configuration); - - /// - /// Histograms track the size and number of events in buckets. - /// - public Summary CreateSummary(string name, string help, string[] labelNames, SummaryConfiguration? configuration = null) - => CreateSummary(name, help, StringSequence.From(labelNames), configuration); - - internal Counter CreateCounter(string name, string help, StringSequence instanceLabelNames, CounterConfiguration? configuration) - { - static Counter CreateInstance(string finalName, string finalHelp, StringSequence finalInstanceLabelNames, LabelSequence finalStaticLabels, CounterConfiguration finalConfiguration) - { - return new Counter(finalName, finalHelp, finalInstanceLabelNames, finalStaticLabels, finalConfiguration.SuppressInitialValue); - } - - var initializer = new CollectorRegistry.CollectorInitializer(CreateInstance, name, help, instanceLabelNames, _staticLabelsLazy.Value, configuration ?? CounterConfiguration.Default); - return _registry.GetOrAdd(initializer); - } - - internal Gauge CreateGauge(string name, string help, StringSequence instanceLabelNames, GaugeConfiguration? configuration) - { - static Gauge CreateInstance(string finalName, string finalHelp, StringSequence finalInstanceLabelNames, LabelSequence finalStaticLabels, GaugeConfiguration finalConfiguration) - { - return new Gauge(finalName, finalHelp, finalInstanceLabelNames, finalStaticLabels, finalConfiguration.SuppressInitialValue); - } - - var initializer = new CollectorRegistry.CollectorInitializer(CreateInstance, name, help, instanceLabelNames, _staticLabelsLazy.Value, configuration ?? GaugeConfiguration.Default); - return _registry.GetOrAdd(initializer); - } - - internal Histogram CreateHistogram(string name, string help, StringSequence instanceLabelNames, HistogramConfiguration? configuration) - { - static Histogram CreateInstance(string finalName, string finalHelp, StringSequence finalInstanceLabelNames, LabelSequence finalStaticLabels, HistogramConfiguration finalConfiguration) - { - return new Histogram(finalName, finalHelp, finalInstanceLabelNames, finalStaticLabels, finalConfiguration.SuppressInitialValue, finalConfiguration.Buckets); - } - - var initializer = new CollectorRegistry.CollectorInitializer(CreateInstance, name, help, instanceLabelNames, _staticLabelsLazy.Value, configuration ?? HistogramConfiguration.Default); - return _registry.GetOrAdd(initializer); - } - - internal Summary CreateSummary(string name, string help, StringSequence instanceLabelNames, SummaryConfiguration? configuration) - { - static Summary CreateInstance(string finalName, string finalHelp, StringSequence finalInstanceLabelNames, LabelSequence finalStaticLabels, SummaryConfiguration finalConfiguration) - { - return new Summary(finalName, finalHelp, finalInstanceLabelNames, finalStaticLabels, finalConfiguration.SuppressInitialValue, - finalConfiguration.Objectives, finalConfiguration.MaxAge, finalConfiguration.AgeBuckets, finalConfiguration.BufferSize); - } - - var initializer = new CollectorRegistry.CollectorInitializer(CreateInstance, name, help, instanceLabelNames, _staticLabelsLazy.Value, configuration ?? SummaryConfiguration.Default); - return _registry.GetOrAdd(initializer); - } - - /// - /// Counters only increase in value and reset to zero when the process restarts. - /// - public Counter CreateCounter(string name, string help, params string[] labelNames) => CreateCounter(name, help, labelNames, null); - - /// - /// Gauges can have any numeric value and change arbitrarily. - /// - public Gauge CreateGauge(string name, string help, params string[] labelNames) => CreateGauge(name, help, labelNames, null); - - /// - /// Summaries track the trends in events over time (10 minutes by default). - /// - public Summary CreateSummary(string name, string help, params string[] labelNames) => CreateSummary(name, help, labelNames, null); - - /// - /// Histograms track the size and number of events in buckets. - /// - public Histogram CreateHistogram(string name, string help, params string[] labelNames) => CreateHistogram(name, help, labelNames, null); - - public IMetricFactory WithLabels(IDictionary labels) - { - if (labels.Count == 0) - return this; - - var newLabels = LabelSequence.From(labels); - - // Add any already-inherited labels to the end (rule is that lower levels go first, higher levels last). - var newFactoryLabels = newLabels.Concat(_factoryLabels); - - return new MetricFactory(_registry, newFactoryLabels); - } - - /// - /// Gets all the existing label names predefined either in the factory or in the registry. - /// - internal StringSequence GetAllStaticLabelNames() - { - return _factoryLabels.Names.Concat(_registry.GetStaticLabels().Names); - } - - public IManagedLifetimeMetricFactory WithManagedLifetime(TimeSpan expiresAfter) => - new ManagedLifetimeMetricFactory(this, expiresAfter); + var exemplarBehavior = configuration?.ExemplarBehavior ?? ExemplarBehavior ?? ExemplarBehavior.Default; + + return _registry.GetOrAdd(name, help, instanceLabelNames, _staticLabelsLazy.Value, configuration ?? CounterConfiguration.Default, exemplarBehavior, _createCounterInstanceFunc); } + + internal Gauge CreateGauge(string name, string help, StringSequence instanceLabelNames, GaugeConfiguration? configuration) + { + // Note: exemplars are not supported for gauges. We just pass it along here to avoid forked APIs downstream. + var exemplarBehavior = ExemplarBehavior ?? ExemplarBehavior.Default; + + return _registry.GetOrAdd(name, help, instanceLabelNames, _staticLabelsLazy.Value, configuration ?? GaugeConfiguration.Default, exemplarBehavior, _createGaugeInstanceFunc); + } + + internal Histogram CreateHistogram(string name, string help, StringSequence instanceLabelNames, HistogramConfiguration? configuration) + { + var exemplarBehavior = configuration?.ExemplarBehavior ?? ExemplarBehavior ?? ExemplarBehavior.Default; + + return _registry.GetOrAdd(name, help, instanceLabelNames, _staticLabelsLazy.Value, configuration ?? HistogramConfiguration.Default, exemplarBehavior, _createHistogramInstanceFunc); + } + + internal Summary CreateSummary(string name, string help, StringSequence instanceLabelNames, SummaryConfiguration? configuration) + { + // Note: exemplars are not supported for summaries. We just pass it along here to avoid forked APIs downstream. + var exemplarBehavior = ExemplarBehavior ?? ExemplarBehavior.Default; + + return _registry.GetOrAdd(name, help, instanceLabelNames, _staticLabelsLazy.Value, configuration ?? SummaryConfiguration.Default, exemplarBehavior, _createSummaryInstanceFunc); + } + + private static Counter CreateCounterInstance(string Name, string Help, in StringSequence InstanceLabelNames, in LabelSequence StaticLabels, CounterConfiguration Configuration, ExemplarBehavior ExemplarBehavior) => new(Name, Help, InstanceLabelNames, StaticLabels, Configuration.SuppressInitialValue, ExemplarBehavior); + + private static Gauge CreateGaugeInstance(string Name, string Help, in StringSequence InstanceLabelNames, in LabelSequence StaticLabels, GaugeConfiguration Configuration, ExemplarBehavior ExemplarBehavior) => new(Name, Help, InstanceLabelNames, StaticLabels, Configuration.SuppressInitialValue, ExemplarBehavior); + + private static Histogram CreateHistogramInstance(string Name, string Help, in StringSequence InstanceLabelNames, in LabelSequence StaticLabels, HistogramConfiguration Configuration, ExemplarBehavior ExemplarBehavior) => new(Name, Help, InstanceLabelNames, StaticLabels, Configuration.SuppressInitialValue, Configuration.Buckets, ExemplarBehavior); + + private static Summary CreateSummaryInstance(string name, string help, in StringSequence instanceLabelNames, in LabelSequence staticLabels, SummaryConfiguration configuration, ExemplarBehavior exemplarBehavior) => new(name, help, instanceLabelNames, staticLabels, exemplarBehavior, configuration.SuppressInitialValue, + configuration.Objectives, configuration.MaxAge, configuration.AgeBuckets, configuration.BufferSize); + + private static readonly CollectorInitializer _createCounterInstanceFunc = CreateCounterInstance; + private static readonly CollectorInitializer _createGaugeInstanceFunc = CreateGaugeInstance; + private static readonly CollectorInitializer _createHistogramInstanceFunc = CreateHistogramInstance; + private static readonly CollectorInitializer _createSummaryInstanceFunc = CreateSummaryInstance; + + /// + /// Counters only increase in value and reset to zero when the process restarts. + /// + public Counter CreateCounter(string name, string help, params string[] labelNames) => CreateCounter(name, help, labelNames, null); + + /// + /// Gauges can have any numeric value and change arbitrarily. + /// + public Gauge CreateGauge(string name, string help, params string[] labelNames) => CreateGauge(name, help, labelNames, null); + + /// + /// Summaries track the trends in events over time (10 minutes by default). + /// + public Summary CreateSummary(string name, string help, params string[] labelNames) => CreateSummary(name, help, labelNames, null); + + /// + /// Histograms track the size and number of events in buckets. + /// + public Histogram CreateHistogram(string name, string help, params string[] labelNames) => CreateHistogram(name, help, labelNames, null); + + public IMetricFactory WithLabels(IDictionary labels) + { + if (labels.Count == 0) + return this; + + var newLabels = LabelSequence.From(labels); + + // Add any already-inherited labels to the end (rule is that lower levels go first, higher levels last). + var newFactoryLabels = newLabels.Concat(_factoryLabels); + + return new MetricFactory(_registry, newFactoryLabels); + } + + /// + /// Gets all the existing label names predefined either in the factory or in the registry. + /// + internal StringSequence GetAllStaticLabelNames() + { + return _factoryLabels.Names.Concat(_registry.GetStaticLabels().Names); + } + + public IManagedLifetimeMetricFactory WithManagedLifetime(TimeSpan expiresAfter) => + new ManagedLifetimeMetricFactory(this, expiresAfter); + + public ExemplarBehavior? ExemplarBehavior { get; set; } } \ No newline at end of file diff --git a/Prometheus/MetricHandler.cs b/Prometheus/MetricHandler.cs index 9d382307..8286bb2c 100644 --- a/Prometheus/MetricHandler.cs +++ b/Prometheus/MetricHandler.cs @@ -1,75 +1,69 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// Base class for various metric server implementations that start an independent exporter in the background. +/// The expoters may either be pull-based (exposing the Prometheus API) or push-based (actively pushing to PushGateway). +/// +public abstract class MetricHandler : IMetricServer, IDisposable { - /// - /// Base class for various metric server implementations that start an independent exporter in the background. - /// The expoters may either be pull-based (exposing the Prometheus API) or push-based (actively pushing to PushGateway). - /// - public abstract class MetricHandler : IMetricServer, IDisposable - { - // The registry that contains the collectors to export metrics from. - // Subclasses are expected to use this variable to obtain the correct registry. - protected readonly CollectorRegistry _registry; + // The token is cancelled when the handler is instructed to stop. + private CancellationTokenSource? _cts = new CancellationTokenSource(); - // The token is cancelled when the handler is instructed to stop. - private CancellationTokenSource? _cts = new CancellationTokenSource(); + // This is the task started for the purpose of exporting metrics. + private Task? _task; - // This is the task started for the purpose of exporting metrics. - private Task? _task; + protected MetricHandler() + { + } - protected MetricHandler(CollectorRegistry? registry = null) - { - _registry = registry ?? Metrics.DefaultRegistry; - } + public IMetricServer Start() + { + if (_task != null) + throw new InvalidOperationException("The metric server has already been started."); - public IMetricServer Start() - { - if (_task != null) - throw new InvalidOperationException("The metric server has already been started."); + if (_cts == null) + throw new InvalidOperationException("The metric server has already been started and stopped. Create a new server if you want to start it again."); - if (_cts == null) - throw new InvalidOperationException("The metric server has already been started and stopped. Create a new server if you want to start it again."); + _task = StartServer(_cts.Token); + return this; + } - _task = StartServer(_cts.Token); - return this; - } + public async Task StopAsync() + { + // Signal the CTS to give a hint to the server thread that it is time to close up shop. + _cts?.Cancel(); - public async Task StopAsync() + try { - // Signal the CTS to give a hint to the server thread that it is time to close up shop. - _cts?.Cancel(); - - try - { - if (_task == null) - return; // Never started. + if (_task == null) + return; // Never started. - // This will re-throw any exception that was caught on the StartServerAsync thread. - // Perhaps not ideal behavior but hey, if the implementation does not want this to happen - // it should have caught it itself in the background processing thread. - await _task.ConfigureAwait(false); // Issue #308 - } - catch (OperationCanceledException) - { - // We'll eat this one, though, since it can easily get thrown by whatever checks the CancellationToken. - } - finally - { - _cts?.Dispose(); - _cts = null; - } + // This will re-throw any exception that was caught on the StartServerAsync thread. + // Perhaps not ideal behavior but hey, if the implementation does not want this to happen + // it should have caught it itself in the background processing thread. + await _task.ConfigureAwait(false); // Issue #308 } - - public void Stop() + catch (OperationCanceledException) { - // This method mainly exists for API compatiblity with prometheus-net v1. But it works, so that's fine. - StopAsync().GetAwaiter().GetResult(); + // We'll eat this one, though, since it can easily get thrown by whatever checks the CancellationToken. } - - void IDisposable.Dispose() + finally { - Stop(); + _cts?.Dispose(); + _cts = null; } + } - protected abstract Task StartServer(CancellationToken cancel); + public void Stop() + { + // This method mainly exists for API compatiblity with prometheus-net v1. But it works, so that's fine. + StopAsync().GetAwaiter().GetResult(); } + + public void Dispose() + { + Stop(); + } + + protected abstract Task StartServer(CancellationToken cancel); } diff --git a/Prometheus/MetricPusher.cs b/Prometheus/MetricPusher.cs index 7eeded35..da9d0a1c 100644 --- a/Prometheus/MetricPusher.cs +++ b/Prometheus/MetricPusher.cs @@ -1,162 +1,179 @@ using System.Diagnostics; using System.Text; -namespace Prometheus +namespace Prometheus; + +/// +/// A metric server that regularly pushes metrics to a Prometheus PushGateway. +/// +public class MetricPusher : MetricHandler { - /// - /// A metric server that regularly pushes metrics to a Prometheus PushGateway. - /// - public class MetricPusher : MetricHandler + private readonly TimeSpan _pushInterval; + private readonly HttpMethod _method; + private readonly Uri _targetUrl; + private readonly Func _httpClientProvider; + + public MetricPusher(string endpoint, string job, string? instance = null, long intervalMilliseconds = 1000, IEnumerable>? additionalLabels = null, CollectorRegistry? registry = null, bool pushReplace = false) : this(new MetricPusherOptions { - private readonly TimeSpan _pushInterval; - private readonly HttpMethod _method; - private readonly Uri _targetUrl; - private readonly Func _httpClientProvider; + Endpoint = endpoint, + Job = job, + Instance = instance, + IntervalMilliseconds = intervalMilliseconds, + AdditionalLabels = additionalLabels, + Registry = registry, + ReplaceOnPush = pushReplace, + }) + { + } - public MetricPusher(string endpoint, string job, string? instance = null, long intervalMilliseconds = 1000, IEnumerable>? additionalLabels = null, CollectorRegistry? registry = null, bool pushReplace = false) : this(new MetricPusherOptions - { - Endpoint = endpoint, - Job = job, - Instance = instance, - IntervalMilliseconds = intervalMilliseconds, - AdditionalLabels = additionalLabels, - Registry = registry, - ReplaceOnPush = pushReplace, - }) - { - } + public MetricPusher(MetricPusherOptions options) + { + if (string.IsNullOrEmpty(options.Endpoint)) + throw new ArgumentNullException(nameof(options.Endpoint)); - public MetricPusher(MetricPusherOptions options) : base(options.Registry) - { - if (string.IsNullOrEmpty(options.Endpoint)) - throw new ArgumentNullException(nameof(options.Endpoint)); + if (string.IsNullOrEmpty(options.Job)) + throw new ArgumentNullException(nameof(options.Job)); - if (string.IsNullOrEmpty(options.Job)) - throw new ArgumentNullException(nameof(options.Job)); + if (options.IntervalMilliseconds <= 0) + throw new ArgumentException("Interval must be greater than zero", nameof(options.IntervalMilliseconds)); - if (options.IntervalMilliseconds <= 0) - throw new ArgumentException("Interval must be greater than zero", nameof(options.IntervalMilliseconds)); + _registry = options.Registry ?? Metrics.DefaultRegistry; - _httpClientProvider = options.HttpClientProvider ?? (() => _singletonHttpClient); + _httpClientProvider = options.HttpClientProvider ?? (() => _singletonHttpClient); - StringBuilder sb = new StringBuilder(string.Format("{0}/job/{1}", options.Endpoint!.TrimEnd('/'), options.Job)); - if (!string.IsNullOrEmpty(options.Instance)) - sb.AppendFormat("/instance/{0}", options.Instance); + StringBuilder sb = new StringBuilder(string.Format("{0}/job/{1}", options.Endpoint!.TrimEnd('/'), options.Job)); + if (!string.IsNullOrEmpty(options.Instance)) + sb.AppendFormat("/instance/{0}", options.Instance); - if (options.AdditionalLabels != null) + if (options.AdditionalLabels != null) + { + foreach (var pair in options.AdditionalLabels) { - foreach (var pair in options.AdditionalLabels) - { - if (pair == null || string.IsNullOrEmpty(pair.Item1) || string.IsNullOrEmpty(pair.Item2)) - throw new NotSupportedException($"Invalid {nameof(MetricPusher)} additional label: ({pair?.Item1}):({pair?.Item2})"); + if (pair == null || string.IsNullOrEmpty(pair.Item1) || string.IsNullOrEmpty(pair.Item2)) + throw new NotSupportedException($"Invalid {nameof(MetricPusher)} additional label: ({pair?.Item1}):({pair?.Item2})"); - sb.AppendFormat("/{0}/{1}", pair.Item1, pair.Item2); - } + sb.AppendFormat("/{0}/{1}", pair.Item1, pair.Item2); } + } - Uri? targetUrl; - if (!Uri.TryCreate(sb.ToString(), UriKind.Absolute, out targetUrl) || targetUrl == null) - { - throw new ArgumentException("Endpoint must be a valid url", "endpoint"); - } + if (!Uri.TryCreate(sb.ToString(), UriKind.Absolute, out var targetUrl) || targetUrl == null) + { + throw new ArgumentException("Endpoint must be a valid url", nameof(options.Endpoint)); + } - _targetUrl = targetUrl; + _targetUrl = targetUrl; - _pushInterval = TimeSpan.FromMilliseconds(options.IntervalMilliseconds); - _onError = options.OnError; + _pushInterval = TimeSpan.FromMilliseconds(options.IntervalMilliseconds); + _onError = options.OnError; - _method = options.ReplaceOnPush ? HttpMethod.Put : HttpMethod.Post; - } + _method = options.ReplaceOnPush ? HttpMethod.Put : HttpMethod.Post; + } - private static readonly HttpClient _singletonHttpClient = new HttpClient(); + private static readonly HttpClient _singletonHttpClient = new(); - private readonly Action? _onError; + private readonly CollectorRegistry _registry; + private readonly Action? _onError; - protected override Task StartServer(CancellationToken cancel) + protected override Task StartServer(CancellationToken cancel) + { + // Start the server processing loop asynchronously in the background. + return Task.Run(async delegate { - // Start the server processing loop asynchronously in the background. - return Task.Run(async delegate + // We do 1 final push after we get cancelled, to ensure that we publish the final state. + var pushingFinalState = false; + + while (true) { - while (true) + // We schedule approximately at the configured interval. There may be some small accumulation for the + // part of the loop we do not measure but it is close enough to be acceptable for all practical scenarios. + var duration = ValueStopwatch.StartNew(); + + try { - // We schedule approximately at the configured interval. There may be some small accumulation for the - // part of the loop we do not measure but it is close enough to be acceptable for all practical scenarios. - var duration = ValueStopwatch.StartNew(); + var httpClient = _httpClientProvider(); - try + var request = new HttpRequestMessage { - var httpClient = _httpClientProvider(); - - var request = new HttpRequestMessage + Method = _method, + RequestUri = _targetUrl, + // We use a copy-pasted implementation of PushStreamContent here to avoid taking a dependency on the old ASP.NET Web API where it lives. + Content = new PushStreamContentInternal(async (stream, content, context) => { - Method = _method, - RequestUri = _targetUrl, - // We use a copy-pasted implementation of PushStreamContent here to avoid taking a dependency on the old ASP.NET Web API where it lives. - Content = new PushStreamContentInternal(async (stream, content, context) => + try { - try - { - // Do not pass CT because we only want to cancel after pushing, so a flush is always performed. - await _registry.CollectAndExportAsTextAsync(stream, default); - } - finally - { - stream.Close(); - } - }, PrometheusConstants.ExporterContentTypeValue), - }; - - var response = await httpClient.SendAsync(request); - - // If anything goes wrong, we want to get at least an entry in the trace log. - response.EnsureSuccessStatusCode(); - } - catch (ScrapeFailedException ex) + // Do not pass CT because we only want to cancel after pushing, so a flush is always performed. + await _registry.CollectAndExportAsTextAsync(stream, default); + } + finally + { + stream.Close(); + } + }, PrometheusConstants.ExporterContentTypeValue), + }; + + var response = await httpClient.SendAsync(request); + + // If anything goes wrong, we want to get at least an entry in the trace log. + response.EnsureSuccessStatusCode(); + } + catch (ScrapeFailedException ex) + { + // We do not consider failed scrapes a reportable error since the user code that raises the failure should be the one logging it. + Trace.WriteLine($"Skipping metrics push due to failed scrape: {ex.Message}"); + } + catch (Exception ex) + { + HandleFailedPush(ex); + } + + if (cancel.IsCancellationRequested) + { + if (!pushingFinalState) { - // We do not consider failed scrapes a reportable error since the user code that raises the failure should be the one logging it. - Trace.WriteLine($"Skipping metrics push due to failed scrape: {ex.Message}"); + // Continue for one more loop to push the final state. + // We do this because it might be that we were stopped while in the middle of a push. + pushingFinalState = true; + continue; } - catch (Exception ex) + else { - HandleFailedPush(ex); - } - - // We stop only after pushing metrics, to ensure that the latest state is flushed when told to stop. - if (cancel.IsCancellationRequested) + // Final push completed, time to pack up our things and go home. break; + } + } - var sleepTime = _pushInterval - duration.GetElapsedTime(); + var sleepTime = _pushInterval - duration.GetElapsedTime(); - // Sleep until the interval elapses or the pusher is asked to shut down. - if (sleepTime > TimeSpan.Zero) + // Sleep until the interval elapses or the pusher is asked to shut down. + if (sleepTime > TimeSpan.Zero) + { + try { - try - { - await Task.Delay(sleepTime, cancel); - } - catch (OperationCanceledException) - { - // The task was cancelled. - // We continue the loop here to ensure final state gets pushed. - continue; - } + await Task.Delay(sleepTime, cancel); + } + catch (OperationCanceledException) when (cancel.IsCancellationRequested) + { + // The task was cancelled. + // We continue the loop here to ensure final state gets pushed. + pushingFinalState = true; + continue; } } - }); - } + } + }); + } - private void HandleFailedPush(Exception ex) + private void HandleFailedPush(Exception ex) + { + if (_onError != null) { - if (_onError != null) - { - // Asynchronous because we don't trust the callee to be fast. - Task.Run(() => _onError(ex)); - } - else - { - // If there is no error handler registered, we write to trace to at least hopefully get some attention to the problem. - Trace.WriteLine(string.Format("Error in MetricPusher: {0}", ex)); - } + // Asynchronous because we don't trust the callee to be fast. + Task.Run(() => _onError(ex)); + } + else + { + // If there is no error handler registered, we write to trace to at least hopefully get some attention to the problem. + Trace.WriteLine(string.Format("Error in MetricPusher: {0}", ex)); } } } diff --git a/Prometheus/MetricPusherOptions.cs b/Prometheus/MetricPusherOptions.cs index 72794436..c9214af2 100644 --- a/Prometheus/MetricPusherOptions.cs +++ b/Prometheus/MetricPusherOptions.cs @@ -1,33 +1,32 @@ -namespace Prometheus +namespace Prometheus; + +public sealed class MetricPusherOptions { - public sealed class MetricPusherOptions - { - internal static readonly MetricPusherOptions Default = new MetricPusherOptions(); + internal static readonly MetricPusherOptions Default = new(); - public string? Endpoint { get; set; } - public string? Job { get; set; } - public string? Instance { get; set; } - public long IntervalMilliseconds { get; set; } = 1000; - public IEnumerable>? AdditionalLabels { get; set; } - public CollectorRegistry? Registry { get; set; } + public string? Endpoint { get; set; } + public string? Job { get; set; } + public string? Instance { get; set; } + public long IntervalMilliseconds { get; set; } = 1000; + public IEnumerable>? AdditionalLabels { get; set; } + public CollectorRegistry? Registry { get; set; } - /// - /// Callback for when a metric push fails. - /// - public Action? OnError { get; set; } + /// + /// Callback for when a metric push fails. + /// + public Action? OnError { get; set; } - /// - /// If null, a singleton HttpClient will be used. - /// - public Func? HttpClientProvider { get; set; } + /// + /// If null, a singleton HttpClient will be used. + /// + public Func? HttpClientProvider { get; set; } - /// - /// If true, replace the metrics in the group (identified by Job, Instance, AdditionalLabels). - /// - /// Replace means a HTTP PUT request will be made, otherwise a HTTP POST request will be made (which means add metrics to the group, if it already exists). - /// - /// Note: Other implementations of the pushgateway client default to replace, however to preserve backwards compatibility this implementation defaults to add. - /// - public bool ReplaceOnPush { get; set; } = false; - } + /// + /// If true, replace the metrics in the group (identified by Job, Instance, AdditionalLabels). + /// + /// Replace means a HTTP PUT request will be made, otherwise a HTTP POST request will be made (which means add metrics to the group, if it already exists). + /// + /// Note: Other implementations of the pushgateway client default to replace, however to preserve backwards compatibility this implementation defaults to add. + /// + public bool ReplaceOnPush { get; set; } = false; } diff --git a/Prometheus/MetricServer.cs b/Prometheus/MetricServer.cs index 199bf9ed..7b28eb4d 100644 --- a/Prometheus/MetricServer.cs +++ b/Prometheus/MetricServer.cs @@ -1,125 +1,128 @@ using System.Diagnostics; using System.Net; -namespace Prometheus +namespace Prometheus; + +/// +/// Implementation of a Prometheus exporter that serves metrics using HttpListener. +/// This is a stand-alone exporter for apps that do not already have an HTTP server included. +/// +public class MetricServer : MetricHandler { + private readonly HttpListener _httpListener = new(); + /// - /// Implementation of a Prometheus exporter that serves metrics using HttpListener. - /// This is a stand-alone exporter for apps that do not already have an HTTP server included. + /// Only requests that match this predicate will be served by the metric server. This allows you to add authorization checks. + /// By default (if null), all requests are served. /// - public class MetricServer : MetricHandler + public Func? RequestPredicate { get; set; } + + public MetricServer(int port, string url = "metrics/", CollectorRegistry? registry = null, bool useHttps = false) : this("+", port, url, registry, useHttps) { - private readonly HttpListener _httpListener = new HttpListener(); + } - /// - /// Only requests that match this predicate will be served by the metric server. This allows you to add authorization checks. - /// By default (if null), all requests are served. - /// - public Func? RequestPredicate { get; set; } + public MetricServer(string hostname, int port, string url = "metrics/", CollectorRegistry? registry = null, bool useHttps = false) + { + var s = useHttps ? "s" : ""; + _httpListener.Prefixes.Add($"http{s}://{hostname}:{port}/{url}"); - public MetricServer(int port, string url = "metrics/", CollectorRegistry? registry = null, bool useHttps = false) : this("+", port, url, registry, useHttps) - { - } + _registry = registry ?? Metrics.DefaultRegistry; + } - public MetricServer(string hostname, int port, string url = "metrics/", CollectorRegistry? registry = null, bool useHttps = false) : base(registry) - { - var s = useHttps ? "s" : ""; - _httpListener.Prefixes.Add($"http{s}://{hostname}:{port}/{url}"); - } + private readonly CollectorRegistry _registry; - protected override Task StartServer(CancellationToken cancel) - { - // This will ensure that any failures to start are nicely thrown from StartServerAsync. - _httpListener.Start(); + protected override Task StartServer(CancellationToken cancel) + { + // This will ensure that any failures to start are nicely thrown from StartServerAsync. + _httpListener.Start(); - // Kick off the actual processing to a new thread and return a Task for the processing thread. - return Task.Factory.StartNew(delegate + // Kick off the actual processing to a new thread and return a Task for the processing thread. + return Task.Factory.StartNew(delegate + { + try { - try + Thread.CurrentThread.Name = "Metric Server"; //Max length 16 chars (Linux limitation) + + while (!cancel.IsCancellationRequested) { - Thread.CurrentThread.Name = "Metric Server"; //Max length 16 chars (Linux limitation) + // There is no way to give a CancellationToken to GCA() so, we need to hack around it a bit. + var getContext = _httpListener.GetContextAsync(); + getContext.Wait(cancel); + var context = getContext.Result; - while (!cancel.IsCancellationRequested) + // Asynchronously process the request. + _ = Task.Factory.StartNew(async delegate { - // There is no way to give a CancellationToken to GCA() so, we need to hack around it a bit. - var getContext = _httpListener.GetContextAsync(); - getContext.Wait(cancel); - var context = getContext.Result; + var request = context.Request; + var response = context.Response; - // Asynchronously process the request. - _ = Task.Factory.StartNew(async delegate + try { - var request = context.Request; - var response = context.Response; + var predicate = RequestPredicate; - try + if (predicate != null && !predicate(request)) { - var predicate = RequestPredicate; + // Request rejected by predicate. + response.StatusCode = (int)HttpStatusCode.Forbidden; + return; + } - if (predicate != null && !predicate(request)) + try + { + // We first touch the response.OutputStream only in the callback because touching + // it means we can no longer send headers (the status code). + var serializer = new TextSerializer(delegate { - // Request rejected by predicate. - response.StatusCode = (int)HttpStatusCode.Forbidden; - return; - } + response.ContentType = PrometheusConstants.TextContentTypeWithVersionAndEncoding; + response.StatusCode = 200; + return response.OutputStream; + }); - try - { - // We first touch the response.OutputStream only in the callback because touching - // it means we can no longer send headers (the status code). - var serializer = new TextSerializer(delegate - { - response.ContentType = PrometheusConstants.ExporterContentType; - response.StatusCode = 200; - return response.OutputStream; - }); - - await _registry.CollectAndSerializeAsync(serializer, cancel); - response.OutputStream.Dispose(); - } - catch (ScrapeFailedException ex) - { - // This can only happen before anything is written to the stream, so it - // should still be safe to update the status code and report an error. - response.StatusCode = 503; - - if (!string.IsNullOrWhiteSpace(ex.Message)) - { - using (var writer = new StreamWriter(response.OutputStream)) - writer.Write(ex.Message); - } - } + await _registry.CollectAndSerializeAsync(serializer, cancel); + response.OutputStream.Dispose(); } - catch (Exception ex) when (!(ex is OperationCanceledException)) + catch (ScrapeFailedException ex) { - if (!_httpListener.IsListening) - return; // We were shut down. - - Trace.WriteLine(string.Format("Error in {0}: {1}", nameof(MetricServer), ex)); + // This can only happen before anything is written to the stream, so it + // should still be safe to update the status code and report an error. + response.StatusCode = 503; - try + if (!string.IsNullOrWhiteSpace(ex.Message)) { - response.StatusCode = 500; - } - catch - { - // Might be too late in request processing to set response code, so just ignore. + using (var writer = new StreamWriter(response.OutputStream)) + writer.Write(ex.Message); } } - finally + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + if (!_httpListener.IsListening) + return; // We were shut down. + + Trace.WriteLine(string.Format("Error in {0}: {1}", nameof(MetricServer), ex)); + + try { - response.Close(); + response.StatusCode = 500; } - }); - } - } - finally - { - _httpListener.Stop(); - // This should prevent any currently processed requests from finishing. - _httpListener.Close(); + catch + { + // Might be too late in request processing to set response code, so just ignore. + } + } + finally + { + response.Close(); + } + }); } - }, TaskCreationOptions.LongRunning); - } + } + finally + { + _httpListener.Stop(); + // This should prevent any currently processed requests from finishing. + _httpListener.Close(); + } + }, TaskCreationOptions.LongRunning); } } diff --git a/Prometheus/MetricType.cs b/Prometheus/MetricType.cs index 75ca46a9..aa844296 100644 --- a/Prometheus/MetricType.cs +++ b/Prometheus/MetricType.cs @@ -1,10 +1,9 @@ -namespace Prometheus +namespace Prometheus; + +internal enum MetricType { - internal enum MetricType - { - Counter, - Gauge, - Summary, - Histogram - } + Counter, + Gauge, + Summary, + Histogram } diff --git a/Prometheus/Metrics.cs b/Prometheus/Metrics.cs index d994aa4a..41851cf4 100644 --- a/Prometheus/Metrics.cs +++ b/Prometheus/Metrics.cs @@ -1,172 +1,178 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// Static class for easy creation of metrics. Acts as the entry point to the prometheus-net metrics recording API. +/// +/// Some built-in metrics are registered by default in the default collector registry. If these default metrics are +/// not desired, call to remove them before registering your own. +/// +public static class Metrics { /// - /// Static class for easy creation of metrics. Acts as the entry point to the prometheus-net metrics recording API. - /// - /// Some built-in metrics are registered by default in the default collector registry. If these default metrics are - /// not desired, call to remove them before registering your own. + /// The default registry where all metrics are registered by default. /// - public static class Metrics + public static CollectorRegistry DefaultRegistry { get; private set; } + + /// + /// The default metric factory used to create collectors in the default registry. + /// + public static MetricFactory DefaultFactory { get; private set; } + + /// + /// Creates a new registry. You may want to use multiple registries if you want to + /// export different sets of metrics via different exporters (e.g. on different URLs). + /// + public static CollectorRegistry NewCustomRegistry() => new(); + + /// + /// Returns an instance of that you can use to register metrics in a custom registry. + /// + public static MetricFactory WithCustomRegistry(CollectorRegistry registry) => new(registry); + + /// + /// Adds the specified static labels to all metrics created using the returned factory. + /// + public static IMetricFactory WithLabels(IDictionary labels) => + new MetricFactory(DefaultRegistry, LabelSequence.From(labels)); + + /// + /// Returns a factory that creates metrics with a managed lifetime. + /// + /// + /// Metrics created from this factory will expire after this time span elapses, enabling automatic deletion of unused metrics. + /// The expiration timer is reset to zero for the duration of any active lifetime-extension lease that is taken on a specific metric. + /// + public static IManagedLifetimeMetricFactory WithManagedLifetime(TimeSpan expiresAfter) => + DefaultFactory.WithManagedLifetime(expiresAfter); + + /// + /// Counters only increase in value and reset to zero when the process restarts. + /// + public static Counter CreateCounter(string name, string help, CounterConfiguration? configuration = null) => + DefaultFactory.CreateCounter(name, help, configuration); + + /// + /// Gauges can have any numeric value and change arbitrarily. + /// + public static Gauge CreateGauge(string name, string help, GaugeConfiguration? configuration = null) => + DefaultFactory.CreateGauge(name, help, configuration); + + /// + /// Summaries track the trends in events over time (10 minutes by default). + /// + public static Summary CreateSummary(string name, string help, SummaryConfiguration? configuration = null) => + DefaultFactory.CreateSummary(name, help, configuration); + + /// + /// Histograms track the size and number of events in buckets. + /// + public static Histogram CreateHistogram(string name, string help, HistogramConfiguration? configuration = null) => + DefaultFactory.CreateHistogram(name, help, configuration); + + /// + /// Counters only increase in value and reset to zero when the process restarts. + /// + public static Counter CreateCounter(string name, string help, string[] labelNames, CounterConfiguration? configuration = null) => + DefaultFactory.CreateCounter(name, help, labelNames, configuration); + + /// + /// Gauges can have any numeric value and change arbitrarily. + /// + public static Gauge CreateGauge(string name, string help, string[] labelNames, GaugeConfiguration? configuration = null) => + DefaultFactory.CreateGauge(name, help, labelNames, configuration); + + /// + /// Summaries track the trends in events over time (10 minutes by default). + /// + public static Summary CreateSummary(string name, string help, string[] labelNames, SummaryConfiguration? configuration = null) => + DefaultFactory.CreateSummary(name, help, labelNames, configuration); + + /// + /// Histograms track the size and number of events in buckets. + /// + public static Histogram CreateHistogram(string name, string help, string[] labelNames, HistogramConfiguration? configuration = null) => + DefaultFactory.CreateHistogram(name, help, labelNames, configuration); + + /// + /// Counters only increase in value and reset to zero when the process restarts. + /// + public static Counter CreateCounter(string name, string help, params string[] labelNames) => + DefaultFactory.CreateCounter(name, help, labelNames); + + /// + /// Gauges can have any numeric value and change arbitrarily. + /// + public static Gauge CreateGauge(string name, string help, params string[] labelNames) => + DefaultFactory.CreateGauge(name, help, labelNames); + + /// + /// Summaries track the trends in events over time (10 minutes by default). + /// + public static Summary CreateSummary(string name, string help, params string[] labelNames) => + DefaultFactory.CreateSummary(name, help, labelNames); + + /// + /// Histograms track the size and number of events in buckets. + /// + public static Histogram CreateHistogram(string name, string help, params string[] labelNames) => + DefaultFactory.CreateHistogram(name, help, labelNames); + + static Metrics() { - /// - /// The default registry where all metrics are registered by default. - /// - public static CollectorRegistry DefaultRegistry { get; private set; } - - private static MetricFactory _defaultFactory; - - /// - /// Creates a new registry. You may want to use multiple registries if you want to - /// export different sets of metrics via different exporters (e.g. on different URLs). - /// - public static CollectorRegistry NewCustomRegistry() => new CollectorRegistry(); - - /// - /// Returns an instance of that you can use to register metrics in a custom registry. - /// - public static MetricFactory WithCustomRegistry(CollectorRegistry registry) => - new MetricFactory(registry); - - /// - /// Adds the specified static labels to all metrics created using the returned factory. - /// - public static IMetricFactory WithLabels(IDictionary labels) => - new MetricFactory(DefaultRegistry, LabelSequence.From(labels)); - - /// - /// Returns a factory that creates metrics with a managed lifetime. - /// - /// - /// Metrics created from this factory will expire after this time span elapses, enabling automatic unpublishing of unused metrics. - /// The expiration timer is reset to zero for the duration of any active lifetime-extension lease that is taken on a specific metric. - /// - public static IManagedLifetimeMetricFactory WithManagedLifetime(TimeSpan expiresAfter) => - _defaultFactory.WithManagedLifetime(expiresAfter); - - /// - /// Counters only increase in value and reset to zero when the process restarts. - /// - public static Counter CreateCounter(string name, string help, CounterConfiguration? configuration = null) => - _defaultFactory.CreateCounter(name, help, configuration); - - /// - /// Gauges can have any numeric value and change arbitrarily. - /// - public static Gauge CreateGauge(string name, string help, GaugeConfiguration? configuration = null) => - _defaultFactory.CreateGauge(name, help, configuration); - - /// - /// Summaries track the trends in events over time (10 minutes by default). - /// - public static Summary CreateSummary(string name, string help, SummaryConfiguration? configuration = null) => - _defaultFactory.CreateSummary(name, help, configuration); - - /// - /// Histograms track the size and number of events in buckets. - /// - public static Histogram CreateHistogram(string name, string help, HistogramConfiguration? configuration = null) => - _defaultFactory.CreateHistogram(name, help, configuration); - - /// - /// Counters only increase in value and reset to zero when the process restarts. - /// - public static Counter CreateCounter(string name, string help, string[] labelNames, CounterConfiguration? configuration = null) => - _defaultFactory.CreateCounter(name, help, labelNames, configuration); - - /// - /// Gauges can have any numeric value and change arbitrarily. - /// - public static Gauge CreateGauge(string name, string help, string[] labelNames, GaugeConfiguration? configuration = null) => - _defaultFactory.CreateGauge(name, help, labelNames, configuration); - - /// - /// Summaries track the trends in events over time (10 minutes by default). - /// - public static Summary CreateSummary(string name, string help, string[] labelNames, SummaryConfiguration? configuration = null) => - _defaultFactory.CreateSummary(name, help, labelNames, configuration); - - /// - /// Histograms track the size and number of events in buckets. - /// - public static Histogram CreateHistogram(string name, string help, string[] labelNames, HistogramConfiguration? configuration = null) => - _defaultFactory.CreateHistogram(name, help, labelNames, configuration); - - /// - /// Counters only increase in value and reset to zero when the process restarts. - /// - public static Counter CreateCounter(string name, string help, params string[] labelNames) => - _defaultFactory.CreateCounter(name, help, labelNames); - - /// - /// Gauges can have any numeric value and change arbitrarily. - /// - public static Gauge CreateGauge(string name, string help, params string[] labelNames) => - _defaultFactory.CreateGauge(name, help, labelNames); - - /// - /// Summaries track the trends in events over time (10 minutes by default). - /// - public static Summary CreateSummary(string name, string help, params string[] labelNames) => - _defaultFactory.CreateSummary(name, help, labelNames); - - /// - /// Histograms track the size and number of events in buckets. - /// - public static Histogram CreateHistogram(string name, string help, params string[] labelNames) => - _defaultFactory.CreateHistogram(name, help, labelNames); - - static Metrics() - { - DefaultRegistry = new CollectorRegistry(); + DefaultRegistry = new CollectorRegistry(); - // Configures defaults to their default behaviors, can be overridden by user if they desire (before first collection). - SuppressDefaultMetrics(SuppressDefaultMetricOptions.SuppressNone); + // Configures defaults to their default behaviors, can be overridden by user if they desire (before first collection). + SuppressDefaultMetrics(SuppressDefaultMetricOptions.SuppressNone); - _defaultFactory = new MetricFactory(DefaultRegistry); - } + DefaultFactory = new MetricFactory(DefaultRegistry); + } - /// - /// Suppresses the registration of the default sample metrics from the default registry. - /// Has no effect if not called on startup (it will not remove metrics from a registry already in use). - /// - public static void SuppressDefaultMetrics() => SuppressDefaultMetrics(SuppressDefaultMetricOptions.SuppressAll); + /// + /// Suppresses the registration of the default sample metrics from the default registry. + /// Has no effect if not called on startup (it will not remove metrics from a registry already in use). + /// + public static void SuppressDefaultMetrics() => SuppressDefaultMetrics(SuppressDefaultMetricOptions.SuppressAll); - /// - /// Suppresses the registration of the default sample metrics from the default registry. - /// Has no effect if not called on startup (it will not remove metrics from a registry already in use). - /// - public static void SuppressDefaultMetrics(SuppressDefaultMetricOptions options) - { - options ??= SuppressDefaultMetricOptions.SuppressAll; + /// + /// Suppresses the registration of the default sample metrics from the default registry. + /// Has no effect if not called on startup (it will not remove metrics from a registry already in use). + /// + public static void SuppressDefaultMetrics(SuppressDefaultMetricOptions options) + { + options ??= SuppressDefaultMetricOptions.SuppressAll; - // Only has effect if called before the registry is collected from. Otherwise a no-op. - DefaultRegistry.SetBeforeFirstCollectCallback(delegate + // Only has effect if called before the registry is collected from. Otherwise a no-op. + DefaultRegistry.SetBeforeFirstCollectCallback(delegate + { + var configureCallbacks = new SuppressDefaultMetricOptions.ConfigurationCallbacks() { - var configureCallbacks = new SuppressDefaultMetricOptions.ConfigurationCallbacks() - { +#if NET + ConfigureEventCounterAdapter = _configureEventCounterAdapterCallback, +#endif #if NET6_0_OR_GREATER - ConfigureEventCounterAdapter = _configureEventCounterAdapterCallback, - ConfigureMeterAdapter = _configureMeterAdapterOptions + ConfigureMeterAdapter = _configureMeterAdapterOptions #endif - }; + }; + + options.ApplyToDefaultRegistry(configureCallbacks); + }); + } - options.Configure(DefaultRegistry, configureCallbacks); - }); - } +#if NET + private static Action _configureEventCounterAdapterCallback = delegate { }; + + /// + /// Configures the event counter adapter that is enabled by default on startup. + /// + public static void ConfigureEventCounterAdapter(Action callback) => _configureEventCounterAdapterCallback = callback; +#endif #if NET6_0_OR_GREATER - private static Action _configureEventCounterAdapterCallback = delegate { }; - private static Action _configureMeterAdapterOptions = delegate { }; - - /// - /// Configures the event counter adapter that is enabled by default on startup. - /// - public static void ConfigureEventCounterAdapter(Action callback) => _configureEventCounterAdapterCallback = callback; - - /// - /// Configures the meter adapter that is enabled by default on startup. - /// - public static void ConfigureMeterAdapter(Action callback) => _configureMeterAdapterOptions = callback; + private static Action _configureMeterAdapterOptions = delegate { }; + + /// + /// Configures the meter adapter that is enabled by default on startup. + /// + public static void ConfigureMeterAdapter(Action callback) => _configureMeterAdapterOptions = callback; #endif - } } \ No newline at end of file diff --git a/Prometheus/NonCapturingLazyInitializer.cs b/Prometheus/NonCapturingLazyInitializer.cs new file mode 100644 index 00000000..67b23d40 --- /dev/null +++ b/Prometheus/NonCapturingLazyInitializer.cs @@ -0,0 +1,140 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Prometheus; + +// Copy-pasted from https://github.com/dotnet/efcore/blob/main/src/Shared/NonCapturingLazyInitializer.cs +// Crudely modified to inline dependencies and reduce functionality down to .NET Fx compatible level. +internal static class NonCapturingLazyInitializer +{ + public static TValue EnsureInitialized( + ref TValue? target, + TParam param, + Func valueFactory) + where TValue : class + { + var tmp = Volatile.Read(ref target); + if (tmp != null) + { + DebugAssert(target != null, $"target was null in {nameof(EnsureInitialized)} after check"); + return tmp; + } + + Interlocked.CompareExchange(ref target, valueFactory(param), null); + + return target; + } + + public static TValue EnsureInitialized( + ref TValue? target, + TParam1 param1, + TParam2 param2, + Func valueFactory) + where TValue : class + { + var tmp = Volatile.Read(ref target); + if (tmp != null) + { + DebugAssert(target != null, $"target was null in {nameof(EnsureInitialized)} after check"); + return tmp; + } + + Interlocked.CompareExchange(ref target, valueFactory(param1, param2), null); + + return target; + } + + public static TValue EnsureInitialized( + ref TValue? target, + TParam1 param1, + TParam2 param2, + TParam3 param3, + Func valueFactory) + where TValue : class + { + var tmp = Volatile.Read(ref target); + if (tmp != null) + { + DebugAssert(target != null, $"target was null in {nameof(EnsureInitialized)} after check"); + return tmp; + } + + Interlocked.CompareExchange(ref target, valueFactory(param1, param2, param3), null); + + return target; + } + + public static TValue EnsureInitialized( + ref TValue target, + ref bool initialized, + TParam param, + Func valueFactory) + where TValue : class? + { + var alreadyInitialized = Volatile.Read(ref initialized); + if (alreadyInitialized) + { + var value = Volatile.Read(ref target); + DebugAssert(target != null, $"target was null in {nameof(EnsureInitialized)} after check"); + DebugAssert(value != null, $"value was null in {nameof(EnsureInitialized)} after check"); + return value; + } + + Volatile.Write(ref target, valueFactory(param)); + Volatile.Write(ref initialized, true); + + return target; + } + + public static TValue EnsureInitialized( + ref TValue? target, + TValue value) + where TValue : class + { + var tmp = Volatile.Read(ref target); + if (tmp != null) + { + DebugAssert(target != null, $"target was null in {nameof(EnsureInitialized)} after check"); + return tmp; + } + + Interlocked.CompareExchange(ref target, value, null); + + return target; + } + + public static TValue EnsureInitialized( + ref TValue? target, + TParam param, + Action valueFactory) + where TValue : class + { + var tmp = Volatile.Read(ref target); + if (tmp != null) + { + DebugAssert(target != null, $"target was null in {nameof(EnsureInitialized)} after check"); + return tmp; + } + + valueFactory(param); + + var tmp2 = Volatile.Read(ref target); + DebugAssert( + target != null && tmp2 != null, + $"{nameof(valueFactory)} did not initialize {nameof(target)} in {nameof(EnsureInitialized)}"); +#pragma warning disable CS8603 // Possible null reference return. + return tmp2; +#pragma warning restore CS8603 // Possible null reference return. + } + + [Conditional("DEBUG")] + private static void DebugAssert(bool condition, string message) + { + if (!condition) + { + throw new Exception($"Check.DebugAssert failed: {message}"); + } + } +} diff --git a/Prometheus/NoopDisposable.cs b/Prometheus/NoopDisposable.cs new file mode 100644 index 00000000..04364cb6 --- /dev/null +++ b/Prometheus/NoopDisposable.cs @@ -0,0 +1,8 @@ +namespace Prometheus; + +internal sealed class NoopDisposable : IDisposable +{ + public void Dispose() + { + } +} diff --git a/Prometheus/ObservedExemplar.cs b/Prometheus/ObservedExemplar.cs new file mode 100644 index 00000000..3d82ef8d --- /dev/null +++ b/Prometheus/ObservedExemplar.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.ObjectPool; +using System.Diagnostics; + +namespace Prometheus; + +/// +/// Internal representation of an Exemplar ready to be serialized. +/// +internal sealed class ObservedExemplar +{ + /// + /// OpenMetrics places a length limit of 128 runes on the exemplar (sum of all key value pairs). + /// + private const int MaxRunes = 128; + + /// + /// We have a pool of unused instances that we can reuse, to avoid constantly allocating memory. Once the set of metrics stabilizes, + /// all allocations should generally be coming from the pool. We expect the default pool configuratiopn to be suitable for this. + /// + private static readonly ObjectPool Pool = ObjectPool.Create(); + + public static readonly ObservedExemplar Empty = new(); + + internal static Func NowProvider = DefaultNowProvider; + internal static double DefaultNowProvider() => LowGranularityTimeSource.GetSecondsFromUnixEpoch(); + + public Exemplar? Labels { get; private set; } + public double Value { get; private set; } + public double Timestamp { get; private set; } + + public ObservedExemplar() + { + Labels = null; + Value = 0; + Timestamp = 0; + } + + public bool IsValid => Labels != null; + + private void Update(Exemplar labels, double value) + { + Debug.Assert(this != Empty, "Do not mutate the sentinel"); + + var totalRuneCount = 0; + + for (var i = 0; i < labels.Length; i++) + { + totalRuneCount += labels[i].RuneCount; + for (var j = 0; j < labels.Length; j++) + { + if (i == j) continue; + if (ByteArraysEqual(labels[i].KeyBytes, labels[j].KeyBytes)) + throw new ArgumentException("Exemplar contains duplicate keys."); + } + } + + if (totalRuneCount > MaxRunes) + throw new ArgumentException($"Exemplar consists of {totalRuneCount} runes, exceeding the OpenMetrics limit of {MaxRunes}."); + + Labels = labels; + Value = value; + Timestamp = NowProvider(); + } + + private static bool ByteArraysEqual(byte[] a, byte[] b) + { + if (a.Length != b.Length) return false; + + for (var i = 0; i < a.Length; i++) + if (a[i] != b[i]) return false; + + return true; + } + + /// + /// Takes ownership of the labels and will destroy them when the instance is returned to the pool. + /// + public static ObservedExemplar CreatePooled(Exemplar labels, double value) + { + var instance = Pool.Get(); + instance.Update(labels, value); + return instance; + } + + public static void ReturnPooledIfNotEmpty(ObservedExemplar instance) + { + if (object.ReferenceEquals(instance, Empty)) + return; // We never put the "Empty" instance into the pool. Do the check here to avoid repeating it any time we return instances to the pool. + + instance.Labels?.ReturnToPoolIfNotEmpty(); + Pool.Return(instance); + } +} \ No newline at end of file diff --git a/Prometheus/PlatformCompatibilityHelpers.cs b/Prometheus/PlatformCompatibilityHelpers.cs new file mode 100644 index 00000000..6069e4e7 --- /dev/null +++ b/Prometheus/PlatformCompatibilityHelpers.cs @@ -0,0 +1,13 @@ +using System.Diagnostics; + +namespace Prometheus; + +internal class PlatformCompatibilityHelpers +{ + // Reimplementation of Stopwatch.GetElapsedTime (only available on .NET 7 or newer). + public static TimeSpan StopwatchGetElapsedTime(long start, long end) + => new((long)((end - start) * ((double)10_000_000 / Stopwatch.Frequency))); + + public static long ElapsedToTimeStopwatchTicks(TimeSpan elapsedTime) + => (long)(elapsedTime.Ticks * (Stopwatch.Frequency / (double)10_000_000)); +} diff --git a/Prometheus/Prometheus.csproj b/Prometheus/Prometheus.csproj index 1edb9783..addc6192 100644 --- a/Prometheus/Prometheus.csproj +++ b/Prometheus/Prometheus.csproj @@ -1,15 +1,15 @@  - net6.0 + net6.0;net7.0;netstandard2.0 - net462;net6.0 + net462;net6.0;net7.0;netstandard2.0 @@ -27,11 +27,37 @@ True 1591 - + true + + preview 9999 True + + + true + true + + + prometheus-net + andrasm,qed-,lakario,sandersaares + prometheus-net + prometheus-net + .NET client library for the Prometheus monitoring and alerting system + Copyright © prometheus-net developers + https://github.com/prometheus-net/prometheus-net + prometheus-net-logo.png + README.md + metrics prometheus + MIT + True + snupkg + + + + + true @@ -44,7 +70,20 @@ - + + True + \ + + + True + \ + + + + + + + diff --git a/Prometheus/PrometheusConstants.cs b/Prometheus/PrometheusConstants.cs index 6fa05eda..bcff4471 100644 --- a/Prometheus/PrometheusConstants.cs +++ b/Prometheus/PrometheusConstants.cs @@ -1,17 +1,22 @@ using System.Net.Http.Headers; using System.Text; -namespace Prometheus +namespace Prometheus; + +public static class PrometheusConstants { - public static class PrometheusConstants - { - public const string ExporterContentType = "text/plain; version=0.0.4; charset=utf-8"; + public const string TextContentType = "text/plain"; + public const string OpenMetricsContentType = "application/openmetrics-text"; + + public const string TextContentTypeWithVersionAndEncoding = TextContentType + "; version=0.0.4; charset=utf-8"; + public const string OpenMetricsContentTypeWithVersionAndEncoding = OpenMetricsContentType + "; version=1.0.0; charset=utf-8"; + + // ASP.NET requires a MediaTypeHeaderValue object + public static readonly MediaTypeHeaderValue ExporterContentTypeValue = MediaTypeHeaderValue.Parse(TextContentTypeWithVersionAndEncoding); + public static readonly MediaTypeHeaderValue ExporterOpenMetricsContentTypeValue = MediaTypeHeaderValue.Parse(OpenMetricsContentTypeWithVersionAndEncoding); - // ASP.NET requires a MediaTypeHeaderValue object - public static readonly MediaTypeHeaderValue ExporterContentTypeValue = MediaTypeHeaderValue.Parse(ExporterContentType); + // Use UTF-8 encoding, but provide the flag to ensure the Unicode Byte Order Mark is never prepended to the output stream. + public static readonly Encoding ExportEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - // Use UTF-8 encoding, but provide the flag to ensure the Unicode Byte Order Mark is never - // pre-pended to the output stream. - public static readonly Encoding ExportEncoding = new UTF8Encoding(false); - } -} + internal static readonly Encoding ExemplarEncoding = new ASCIIEncoding(); +} \ No newline at end of file diff --git a/Prometheus/PrometheusNameHelpers.cs b/Prometheus/PrometheusNameHelpers.cs index 92d91451..704ca57b 100644 --- a/Prometheus/PrometheusNameHelpers.cs +++ b/Prometheus/PrometheusNameHelpers.cs @@ -1,58 +1,57 @@ using System.Text; using System.Text.RegularExpressions; -namespace Prometheus +namespace Prometheus; + +/// +/// Transforms external names in different character sets into Prometheus (metric or label) names. +/// +internal static class PrometheusNameHelpers { - /// - /// Transforms external names in different character sets into Prometheus (metric or label) names. - /// - internal static class PrometheusNameHelpers + private static readonly Regex NameRegex = new("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled); + private const string FirstCharacterCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"; + private const string NonFirstCharacterCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789"; + + public static string TranslateNameToPrometheusName(string inputName) { - private static readonly Regex NameRegex = new Regex("^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.Compiled); - private const string FirstCharacterCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"; - private const string NonFirstCharacterCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789"; + // Transformations done: + // * all lowercase + // * special characters to underscore + // * must match: [a-zA-Z_][a-zA-Z0-9_]* + // * colon is "permitted" by spec but reserved for recording rules - public static string TranslateNameToPrometheusName(string inputName) - { - // Transformations done: - // * all lowercase - // * special characters to underscore - // * must match: [a-zA-Z_][a-zA-Z0-9_]* - // * colon is "permitted" by spec but reserved for recording rules + var sb = new StringBuilder(); - var sb = new StringBuilder(); + foreach (char inputCharacter in inputName) + { + // All lowercase. + var c = Char.ToLowerInvariant(inputCharacter); - foreach (char inputCharacter in inputName) + if (sb.Length == 0) { - // All lowercase. - var c = Char.ToLowerInvariant(inputCharacter); - - if (sb.Length == 0) - { - // If first character is not from allowed charset, prefix it with underscore to minimize first character data loss. - if (!FirstCharacterCharset.Contains(c)) - sb.Append('_'); + // If first character is not from allowed charset, prefix it with underscore to minimize first character data loss. + if (!FirstCharacterCharset.Contains(c)) + sb.Append('_'); - sb.Append(c); - } + sb.Append(c); + } + else + { + // Standard rules. + // If character is not permitted, replace with underscore. Simple as that! + if (!NonFirstCharacterCharset.Contains(c)) + sb.Append('_'); else - { - // Standard rules. - // If character is not permitted, replace with underscore. Simple as that! - if (!NonFirstCharacterCharset.Contains(c)) - sb.Append('_'); - else - sb.Append(c); - } + sb.Append(c); } + } - var name = sb.ToString(); + var name = sb.ToString(); - // Sanity check. - if (!NameRegex.IsMatch(name)) - throw new Exception("Self-check failed: generated name did not match our own naming rules."); + // Sanity check. + if (!NameRegex.IsMatch(name)) + throw new Exception("Self-check failed: generated name did not match our own naming rules."); - return name; - } + return name; } } diff --git a/Prometheus/PushStreamContentInternal.cs b/Prometheus/PushStreamContentInternal.cs index 31f96070..eeb1da03 100644 --- a/Prometheus/PushStreamContentInternal.cs +++ b/Prometheus/PushStreamContentInternal.cs @@ -6,80 +6,79 @@ using System.Net; using System.Net.Http.Headers; -namespace Prometheus +namespace Prometheus; + +/// +/// Provides an implementation that exposes an output +/// which can be written to directly. The ability to push data to the output stream differs from the +/// where data is pulled and not pushed. +/// +sealed class PushStreamContentInternal : HttpContent { + private readonly Func _onStreamAvailable; + + private static readonly MediaTypeHeaderValue OctetStreamHeaderValue = MediaTypeHeaderValue.Parse("application/octet-stream"); + /// - /// Provides an implementation that exposes an output - /// which can be written to directly. The ability to push data to the output stream differs from the - /// where data is pulled and not pushed. + /// Initializes a new instance of the class with the given . /// - sealed class PushStreamContentInternal : HttpContent + public PushStreamContentInternal(Func onStreamAvailable, MediaTypeHeaderValue mediaType) { - private readonly Func _onStreamAvailable; + _onStreamAvailable = onStreamAvailable; + Headers.ContentType = mediaType ?? OctetStreamHeaderValue; + } - private static readonly MediaTypeHeaderValue OctetStreamHeaderValue = MediaTypeHeaderValue.Parse("application/octet-stream"); + /// + /// When this method is called, it calls the action provided in the constructor with the output + /// stream to write to. Once the action has completed its work it closes the stream which will + /// close this content instance and complete the HTTP request or response. + /// + /// The to which to write. + /// The associated . + /// A instance that is asynchronously serializing the object's content. + [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exception is passed as task result.")] + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + TaskCompletionSource serializeToStreamTask = new TaskCompletionSource(); - /// - /// Initializes a new instance of the class with the given . - /// - public PushStreamContentInternal(Func onStreamAvailable, MediaTypeHeaderValue mediaType) - { - _onStreamAvailable = onStreamAvailable; - Headers.ContentType = mediaType ?? OctetStreamHeaderValue; - } + Stream wrappedStream = new CompleteTaskOnCloseStream(stream, serializeToStreamTask); + await _onStreamAvailable(wrappedStream, this, context); - /// - /// When this method is called, it calls the action provided in the constructor with the output - /// stream to write to. Once the action has completed its work it closes the stream which will - /// close this content instance and complete the HTTP request or response. - /// - /// The to which to write. - /// The associated . - /// A instance that is asynchronously serializing the object's content. - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exception is passed as task result.")] - protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) - { - TaskCompletionSource serializeToStreamTask = new TaskCompletionSource(); + // wait for wrappedStream.Close/Dispose to get called. + await serializeToStreamTask.Task; + } - Stream wrappedStream = new CompleteTaskOnCloseStream(stream, serializeToStreamTask); - await _onStreamAvailable(wrappedStream, this, context); + /// + /// Computes the length of the stream if possible. + /// + /// The computed length of the stream. + /// true if the length has been computed; otherwise false. + protected override bool TryComputeLength(out long length) + { + // We can't know the length of the content being pushed to the output stream. + length = -1; + return false; + } - // wait for wrappedStream.Close/Dispose to get called. - await serializeToStreamTask.Task; - } + internal class CompleteTaskOnCloseStream : DelegatingStreamInternal + { + private TaskCompletionSource _serializeToStreamTask; - /// - /// Computes the length of the stream if possible. - /// - /// The computed length of the stream. - /// true if the length has been computed; otherwise false. - protected override bool TryComputeLength(out long length) + public CompleteTaskOnCloseStream(Stream innerStream, TaskCompletionSource serializeToStreamTask) + : base(innerStream) { - // We can't know the length of the content being pushed to the output stream. - length = -1; - return false; + _serializeToStreamTask = serializeToStreamTask; } - internal class CompleteTaskOnCloseStream : DelegatingStreamInternal + [SuppressMessage( + "Microsoft.Usage", + "CA2215:Dispose methods should call base class dispose", + Justification = "See comments, this is intentional.")] + protected override void Dispose(bool disposing) { - private TaskCompletionSource _serializeToStreamTask; - - public CompleteTaskOnCloseStream(Stream innerStream, TaskCompletionSource serializeToStreamTask) - : base(innerStream) - { - _serializeToStreamTask = serializeToStreamTask; - } - - [SuppressMessage( - "Microsoft.Usage", - "CA2215:Dispose methods should call base class dispose", - Justification = "See comments, this is intentional.")] - protected override void Dispose(bool disposing) - { - // We don't dispose the underlying stream because we don't own it. Dispose in this case just signifies - // that the user's action is finished. - _serializeToStreamTask.TrySetResult(true); - } + // We don't dispose the underlying stream because we don't own it. Dispose in this case just signifies + // that the user's action is finished. + _serializeToStreamTask.TrySetResult(true); } } } diff --git a/Prometheus/QuantileEpsilonPair.cs b/Prometheus/QuantileEpsilonPair.cs index 1ccf40a7..52b2eb5e 100644 --- a/Prometheus/QuantileEpsilonPair.cs +++ b/Prometheus/QuantileEpsilonPair.cs @@ -1,14 +1,7 @@ -namespace Prometheus -{ - public readonly struct QuantileEpsilonPair - { - public QuantileEpsilonPair(double quantile, double epsilon) - { - Quantile = quantile; - Epsilon = epsilon; - } +namespace Prometheus; - public double Quantile { get; } - public double Epsilon { get; } - } +public readonly struct QuantileEpsilonPair(double quantile, double epsilon) +{ + public double Quantile { get; } = quantile; + public double Epsilon { get; } = epsilon; } diff --git a/Prometheus/RealDelayer.cs b/Prometheus/RealDelayer.cs index d8e94007..d31b9755 100644 --- a/Prometheus/RealDelayer.cs +++ b/Prometheus/RealDelayer.cs @@ -1,18 +1,17 @@ using System.Diagnostics; -namespace Prometheus +namespace Prometheus; + +/// +/// An implementation that uses Task.Delay(), for use at runtime. +/// +internal sealed class RealDelayer : IDelayer { - /// - /// An implementation that uses Task.Delay(), for use at runtime. - /// - internal sealed class RealDelayer : IDelayer - { - public static readonly RealDelayer Instance = new(); + public static readonly RealDelayer Instance = new(); - [DebuggerStepThrough] - public Task Delay(TimeSpan duration) => Task.Delay(duration); + [DebuggerStepThrough] + public Task Delay(TimeSpan duration) => Task.Delay(duration); - [DebuggerStepThrough] - public Task Delay(TimeSpan duration, CancellationToken cancel) => Task.Delay(duration, cancel); - } + [DebuggerStepThrough] + public Task Delay(TimeSpan duration, CancellationToken cancel) => Task.Delay(duration, cancel); } diff --git a/Prometheus/RefLease.cs b/Prometheus/RefLease.cs new file mode 100644 index 00000000..17feabf1 --- /dev/null +++ b/Prometheus/RefLease.cs @@ -0,0 +1,21 @@ +namespace Prometheus; + +/// +/// A stack-only struct for holding a lease on a lifetime-managed metric. +/// Helps avoid allocation when you need to take a lease in a synchronous context where stack-only structs are allowed. +/// +public readonly ref struct RefLease +{ + internal RefLease(INotifyLeaseEnded notifyLeaseEnded, object child, ChildLifetimeInfo lifetime) + { + _notifyLeaseEnded = notifyLeaseEnded; + _child = child; + _lifetime = lifetime; + } + + private readonly INotifyLeaseEnded _notifyLeaseEnded; + private readonly object _child; + private readonly ChildLifetimeInfo _lifetime; + + public void Dispose() => _notifyLeaseEnded.OnLeaseEnded(_child, _lifetime); +} \ No newline at end of file diff --git a/Prometheus/ScrapeFailedException.cs b/Prometheus/ScrapeFailedException.cs index fb803c2d..833adc48 100644 --- a/Prometheus/ScrapeFailedException.cs +++ b/Prometheus/ScrapeFailedException.cs @@ -1,19 +1,15 @@ -namespace Prometheus +namespace Prometheus; + +/// +/// Signals to the metrics server that metrics are currently unavailable. Thrown from "before collect" callbacks. +/// This causes the entire export operation to fail - even if some metrics are available, they will not be exported. +/// +/// The exception message will be delivered as the HTTP response body by the exporter. +/// +[Serializable] +public class ScrapeFailedException : Exception { - /// - /// Signals to the metrics server that metrics are currently unavailable. Thrown from "before collect" callbacks. - /// This causes the entire export operation to fail - even if some metrics are available, they will not be exported. - /// - /// The exception message will be delivered as the HTTP response body by the exporter. - /// - [Serializable] - public class ScrapeFailedException : Exception - { - public ScrapeFailedException() { } - public ScrapeFailedException(string message) : base(message) { } - public ScrapeFailedException(string message, Exception inner) : base(message, inner) { } - protected ScrapeFailedException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - } + public ScrapeFailedException() { } + public ScrapeFailedException(string message) : base(message) { } + public ScrapeFailedException(string message, Exception inner) : base(message, inner) { } } diff --git a/Prometheus/StringSequence.cs b/Prometheus/StringSequence.cs index de79f5d9..74334106 100644 --- a/Prometheus/StringSequence.cs +++ b/Prometheus/StringSequence.cs @@ -1,4 +1,4 @@ -using System.Collections; +using System.Runtime.CompilerServices; namespace Prometheus; @@ -16,10 +16,7 @@ namespace Prometheus; { public static readonly StringSequence Empty = new(); - public Enumerator GetEnumerator() - { - return new Enumerator(_values, _inheritedValues); - } + public Enumerator GetEnumerator() => new(_values.Span, _inheritedValueArrays ?? []); public ref struct Enumerator { @@ -27,36 +24,47 @@ public ref struct Enumerator private int _completedInheritedArrays; private int _completedItemsInCurrentArray; - private readonly string[]? _values; - private readonly string[][]? _inheritedValues; + private readonly ReadOnlySpan _values; + private readonly ReadOnlyMemory[] _inheritedValues; - public string Current { get; private set; } + private ReadOnlySpan _currentArray; + private string _current; - public Enumerator(string[]? values, string[][]? inheritedValues) + public readonly string Current + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _current; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Enumerator(ReadOnlySpan values, ReadOnlyMemory[] inheritedValues) { _values = values; _inheritedValues = inheritedValues; - Current = string.Empty; + _current = string.Empty; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool MoveNext() { // Do we have an item to get from the primary values array? - if (_values != null && _values.Length > _completedItemsInValues) + if (_values.Length > _completedItemsInValues) { - Current = _values[_completedItemsInValues]; + _current = _values[_completedItemsInValues]; _completedItemsInValues++; return true; } // Do we have an item to get from an inherited array? - else if (_inheritedValues != null && _inheritedValues.Length > _completedInheritedArrays) + else if (_inheritedValues.Length > _completedInheritedArrays) { - var array = _inheritedValues[_completedInheritedArrays]; - Current = array[_completedItemsInCurrentArray++]; + if (_completedItemsInCurrentArray == 0) + _currentArray = _inheritedValues[_completedInheritedArrays].Span; + + _current = _currentArray[_completedItemsInCurrentArray++]; // Did we complete this array? - if (array.Length == _completedItemsInCurrentArray) + if (_currentArray.Length == _completedItemsInCurrentArray) { _completedItemsInCurrentArray = 0; _completedInheritedArrays++; @@ -74,6 +82,8 @@ public bool MoveNext() public int Length { get; } + public bool IsEmpty => Length == 0; + public bool Equals(StringSequence other) { if (_hashCode != other._hashCode) return false; @@ -106,89 +116,90 @@ public override bool Equals(object? obj) // There are various ways we can make a StringSequence, comining one or two parents and maybe adding some extra to the start. // This ctor tries to account for all these options. - private StringSequence(StringSequence? inheritFrom, StringSequence? thenFrom, string[]? andFinallyPrepend) + private StringSequence(StringSequence inheritFrom, StringSequence thenFrom, in ReadOnlyMemory andFinallyPrepend) { - // Simplify construction if we are given empty inputs. - if (inheritFrom.HasValue && inheritFrom.Value.Length == 0) - inheritFrom = null; - - if (thenFrom.HasValue && thenFrom.Value.Length == 0) - thenFrom = null; - - if (andFinallyPrepend != null && andFinallyPrepend.Length == 0) - andFinallyPrepend = null; - - // Simplify construction if we have nothing at all. - if (!inheritFrom.HasValue && !thenFrom.HasValue && andFinallyPrepend == null) - return; - - // Simplify construction if we just need to match one of the cloneable inputs. - if (inheritFrom.HasValue && !thenFrom.HasValue && andFinallyPrepend == null) + // Anything inherited is already validated. Perform a sanity check on anything new. + if (andFinallyPrepend.Length != 0) { - this = inheritFrom.Value; - return; - } - else if (thenFrom.HasValue && !inheritFrom.HasValue && andFinallyPrepend == null) - { - this = thenFrom.Value; - return; - } + var span = andFinallyPrepend.Span; - // Anything inherited is already validated. - if (andFinallyPrepend != null) - { - foreach (var ownValue in andFinallyPrepend) + for (var i = 0; i < span.Length; i++) { - if (ownValue == null) + if (span[i] == null) throw new NotSupportedException("Null values are not supported for metric label names and values."); } + + _values = andFinallyPrepend; } - _values = andFinallyPrepend; - _inheritedValues = InheritFrom(inheritFrom, thenFrom); + if (!inheritFrom.IsEmpty || !thenFrom.IsEmpty) + _inheritedValueArrays = InheritFrom(inheritFrom, thenFrom); - Length = (_values?.Length ?? 0) - + (inheritFrom.HasValue ? inheritFrom.Value.Length : 0) - + (thenFrom.HasValue ? thenFrom.Value.Length : 0); + Length = _values.Length + inheritFrom.Length + thenFrom.Length; _hashCode = CalculateHashCode(); } public static StringSequence From(params string[] values) { - return new StringSequence(null, null, values); + if (values.Length == 0) + return Empty; + + return new StringSequence(Empty, Empty, values); + } + + public static StringSequence From(ReadOnlyMemory values) + { + if (values.Length == 0) + return Empty; + + return new StringSequence(Empty, Empty, values); } // Creates a new sequence, inheriting all current values and optionally adding more. New values are prepended to the sequence, inherited values come last. public StringSequence InheritAndPrepend(params string[] prependValues) { - return new StringSequence(this, null, prependValues); + if (prependValues.Length == 0) + return this; + + return new StringSequence(this, Empty, prependValues); } // Creates a new sequence, inheriting all current values and optionally adding more. New values are prepended to the sequence, inherited values come last. public StringSequence InheritAndPrepend(StringSequence prependValues) { + if (prependValues.IsEmpty) + return this; + + if (IsEmpty) + return prependValues; + return new StringSequence(this, prependValues, null); } // Creates a new sequence, concatenating another string sequence (by inheriting from it). public StringSequence Concat(StringSequence concatenatedValues) { + if (concatenatedValues.IsEmpty) + return this; + + if (IsEmpty) + return concatenatedValues; + return new StringSequence(concatenatedValues, this, null); } - // Values added by this instance. - // It may be null because structs have a default ctor and let's be paranoid. - private readonly string[]? _values; + // Values added by this instance. It may be empty. + private readonly ReadOnlyMemory _values; // Inherited values from one or more parent instances. - // It may be null because structs have a default ctor and let's be paranoid. - private readonly string[][]? _inheritedValues; + // It may be null because structs have a default ctor that zero-initializes them, so watch out. + private readonly ReadOnlyMemory[]? _inheritedValueArrays; private readonly int _hashCode; // We can inherit from one or two parent sequences. Order is "first at the end, second prefixed to it" as is typical (ancestors at the end). - private static string[][]? InheritFrom(StringSequence? first, StringSequence? second) + private static ReadOnlyMemory[] InheritFrom(StringSequence first, StringSequence second) { // Expected output: second._values, second._inheritedValues, first._values, first._inheritedValues @@ -197,47 +208,46 @@ public StringSequence Concat(StringSequence concatenatedValues) int secondOwnArrayCount = 0; int secondInheritedArrayCount = 0; - if (first.HasValue) + if (!first.IsEmpty) { - firstOwnArrayCount = first.Value._values?.Length > 0 ? 1 : 0; - firstInheritedArrayCount = first.Value._inheritedValues?.Length ?? 0; + firstOwnArrayCount = first._values.Length > 0 ? 1 : 0; + firstInheritedArrayCount = first._inheritedValueArrays?.Length ?? 0; } - if (second.HasValue) + if (!second.IsEmpty) { - secondOwnArrayCount = second.Value._values?.Length > 0 ? 1 : 0; - secondInheritedArrayCount = second.Value._inheritedValues?.Length ?? 0; + secondOwnArrayCount = second._values.Length > 0 ? 1 : 0; + secondInheritedArrayCount = second._inheritedValueArrays?.Length ?? 0; } var totalSegmentCount = firstOwnArrayCount + firstInheritedArrayCount + secondOwnArrayCount + secondInheritedArrayCount; if (totalSegmentCount == 0) - return null; + throw new Exception("Unreachable code reached: InheritFrom() should not even be called if there is nothing to inherit."); - var result = new string[totalSegmentCount][]; + var result = new ReadOnlyMemory[totalSegmentCount]; var targetIndex = 0; if (secondOwnArrayCount != 0) { - result[targetIndex++] = second!.Value._values!; + result[targetIndex++] = second._values; } if (secondInheritedArrayCount != 0) { - Array.Copy(second!.Value._inheritedValues!, 0, result, targetIndex, secondInheritedArrayCount); + Array.Copy(second._inheritedValueArrays!, 0, result, targetIndex, secondInheritedArrayCount); targetIndex += secondInheritedArrayCount; } if (firstOwnArrayCount != 0) { - result[targetIndex++] = first!.Value._values!; + result[targetIndex++] = first._values; } if (firstInheritedArrayCount != 0) { - Array.Copy(first!.Value._inheritedValues!, 0, result, targetIndex, firstInheritedArrayCount); - targetIndex += firstInheritedArrayCount; + Array.Copy(first._inheritedValueArrays!, 0, result, targetIndex, firstInheritedArrayCount); } return result; @@ -247,12 +257,11 @@ private int CalculateHashCode() { int hashCode = 0; - var enumerator = GetEnumerator(); - while (enumerator.MoveNext()) + foreach (var item in this) { unchecked { - hashCode ^= (enumerator.Current.GetHashCode() * 397); + hashCode ^= (item.GetHashCode() * 397); } } @@ -261,10 +270,9 @@ private int CalculateHashCode() public bool Contains(string value) { - var enumerator = GetEnumerator(); - while (enumerator.MoveNext()) + foreach (var item in this) { - if (enumerator.Current.Equals(value, StringComparison.Ordinal)) + if (item.Equals(value, StringComparison.Ordinal)) return true; } @@ -278,13 +286,10 @@ public string[] ToArray() { var result = new string[Length]; - var enumerator = GetEnumerator(); var index = 0; - while (enumerator.MoveNext()) - { - result[index++] = enumerator.Current; - } + foreach (var item in this) + result[index++] = item; return result; } diff --git a/Prometheus/Summary.cs b/Prometheus/Summary.cs index 23ead339..1b027c72 100644 --- a/Prometheus/Summary.cs +++ b/Prometheus/Summary.cs @@ -1,136 +1,169 @@ -using Prometheus.SummaryImpl; -using System.Globalization; +using System.Buffers; +using System.Runtime.CompilerServices; +using Prometheus.SummaryImpl; -namespace Prometheus +namespace Prometheus; + +public sealed class Summary : Collector, ISummary { - public sealed class Summary : Collector, ISummary + // Label that defines the quantile in a summary. + private const string QuantileLabel = "quantile"; + + /// + /// Client library guidelines say that the summary should default to not measuring quantiles. + /// https://prometheus.io/docs/instrumenting/writing_clientlibs/#summary + /// + internal static readonly QuantileEpsilonPair[] DefObjectivesArray = new QuantileEpsilonPair[0]; + + // Default duration for which observations stay relevant + public static readonly TimeSpan DefMaxAge = TimeSpan.FromMinutes(10); + + // Default number of buckets used to calculate the age of observations + public static readonly int DefAgeBuckets = 5; + + // Standard buffer size for collecting Summary observations + public static readonly int DefBufCap = 500; + + // Objectives defines the quantile rank estimates with their respective + // absolute error. If Objectives[q] = e, then the value reported + // for q will be the φ-quantile value for some φ between q-e and q+e. + // The default value is DefObjectives. + private readonly IReadOnlyList _objectives; + + // MaxAge defines the duration for which an observation stays relevant + // for the summary. Must be positive. The default value is DefMaxAge. + private readonly TimeSpan _maxAge; + + // AgeBuckets is the number of buckets used to exclude observations that + // are older than MaxAge from the summary. A higher number has a + // resource penalty, so only increase it if the higher resolution is + // really required. For very high observation rates, you might want to + // reduce the number of age buckets. With only one age bucket, you will + // effectively see a complete reset of the summary each time MaxAge has + // passed. The default value is DefAgeBuckets. + private readonly int _ageBuckets; + + // BufCap defines the default sample stream buffer size. The default + // value of DefBufCap should suffice for most uses. If there is a need + // to increase the value, a multiple of 500 is recommended (because that + // is the internal buffer size of the underlying package + // "github.com/bmizerany/perks/quantile"). + private readonly int _bufCap; + + private readonly double[] _sortedObjectives; + + // These labels go together with the objectives, so we do not need to allocate them for every child. + private readonly CanonicalLabel[] _quantileLabels; + + private static readonly byte[] QuantileLabelName = "quantile"u8.ToArray(); + + internal Summary( + string name, + string help, + StringSequence instanceLabelNames, + LabelSequence staticLabels, + ExemplarBehavior exemplarBehavior, + bool suppressInitialValue = false, + IReadOnlyList? objectives = null, + TimeSpan? maxAge = null, + int? ageBuckets = null, + int? bufCap = null) + : base(name, help, instanceLabelNames, staticLabels, suppressInitialValue, exemplarBehavior) { - // Label that defines the quantile in a summary. - private const string QuantileLabel = "quantile"; - - /// - /// Client library guidelines say that the summary should default to not measuring quantiles. - /// https://prometheus.io/docs/instrumenting/writing_clientlibs/#summary - /// - internal static readonly QuantileEpsilonPair[] DefObjectivesArray = new QuantileEpsilonPair[0]; - - // Default Summary quantile values. - public static readonly IList DefObjectives = new List(DefObjectivesArray); - - // Default duration for which observations stay relevant - public static readonly TimeSpan DefMaxAge = TimeSpan.FromMinutes(10); - - // Default number of buckets used to calculate the age of observations - public static readonly int DefAgeBuckets = 5; - - // Standard buffer size for collecting Summary observations - public static readonly int DefBufCap = 500; - - private readonly IReadOnlyList _objectives; - private readonly TimeSpan _maxAge; - private readonly int _ageBuckets; - private readonly int _bufCap; - - internal Summary( - string name, - string help, - StringSequence instanceLabelNames, - LabelSequence staticLabels, - bool suppressInitialValue = false, - IReadOnlyList? objectives = null, - TimeSpan? maxAge = null, - int? ageBuckets = null, - int? bufCap = null) - : base(name, help, instanceLabelNames, staticLabels, suppressInitialValue) - { - _objectives = objectives ?? DefObjectivesArray; - _maxAge = maxAge ?? DefMaxAge; - _ageBuckets = ageBuckets ?? DefAgeBuckets; - _bufCap = bufCap ?? DefBufCap; + _objectives = objectives ?? DefObjectivesArray; + _maxAge = maxAge ?? DefMaxAge; + _ageBuckets = ageBuckets ?? DefAgeBuckets; + _bufCap = bufCap ?? DefBufCap; - if (_objectives.Count == 0) - _objectives = DefObjectivesArray; + if (_objectives.Count == 0) + _objectives = DefObjectivesArray; - if (_maxAge < TimeSpan.Zero) - throw new ArgumentException($"Illegal max age {_maxAge}"); + if (_maxAge < TimeSpan.Zero) + throw new ArgumentException($"Illegal max age {_maxAge}"); - if (_ageBuckets == 0) - _ageBuckets = DefAgeBuckets; + if (_ageBuckets == 0) + _ageBuckets = DefAgeBuckets; - if (_bufCap == 0) - _bufCap = DefBufCap; + if (_bufCap == 0) + _bufCap = DefBufCap; - if (instanceLabelNames.Contains(QuantileLabel)) - throw new ArgumentException($"{QuantileLabel} is a reserved label name"); - } + if (instanceLabelNames.Contains(QuantileLabel)) + throw new ArgumentException($"{QuantileLabel} is a reserved label name"); - private protected override Child NewChild(LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish) + if (_objectives.Count == 0) { - return new Child(this, instanceLabels, flattenedLabels, publish); + _sortedObjectives = []; + _quantileLabels = []; } - - internal override MetricType Type => MetricType.Summary; - - public sealed class Child : ChildBase, ISummary + else { - internal Child(Summary parent, LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish) - : base(parent, instanceLabels, flattenedLabels, publish) + _sortedObjectives = new double[_objectives.Count]; + _quantileLabels = new CanonicalLabel[_objectives.Count]; + + for (var i = 0; i < _objectives.Count; i++) { - _objectives = parent._objectives; - _maxAge = parent._maxAge; - _ageBuckets = parent._ageBuckets; - _bufCap = parent._bufCap; - - _sortedObjectives = new double[_objectives.Count]; - _hotBuf = new SampleBuffer(_bufCap); - _coldBuf = new SampleBuffer(_bufCap); - _streamDuration = new TimeSpan(_maxAge.Ticks / _ageBuckets); - _headStreamExpTime = DateTime.UtcNow.Add(_streamDuration); - _hotBufExpTime = _headStreamExpTime; - - _streams = new QuantileStream[_ageBuckets]; - for (var i = 0; i < _ageBuckets; i++) - { - _streams[i] = QuantileStream.NewTargeted(_objectives); - } + _sortedObjectives[i] = _objectives[i].Quantile; + _quantileLabels[i] = TextSerializer.EncodeValueAsCanonicalLabel(QuantileLabelName, _objectives[i].Quantile); + } - _headStream = _streams[0]; + Array.Sort(_sortedObjectives); + } + } - for (var i = 0; i < _objectives.Count; i++) - { - _sortedObjectives[i] = _objectives[i].Quantile; - } + private protected override Child NewChild(LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish, ExemplarBehavior exemplarBehavior) + { + return new Child(this, instanceLabels, flattenedLabels, publish, exemplarBehavior); + } - Array.Sort(_sortedObjectives); + internal override MetricType Type => MetricType.Summary; - _sumIdentifier = CreateIdentifier("sum"); - _countIdentifier = CreateIdentifier("count"); + public sealed class Child : ChildBase, ISummary + { + internal Child(Summary parent, LabelSequence instanceLabels, LabelSequence flattenedLabels, bool publish, ExemplarBehavior exemplarBehavior) + : base(parent, instanceLabels, flattenedLabels, publish, exemplarBehavior) + { + _parent = parent; - _quantileIdentifiers = new byte[_objectives.Count][]; - for (var i = 0; i < _objectives.Count; i++) - { - var value = double.IsPositiveInfinity(_objectives[i].Quantile) ? "+Inf" : _objectives[i].Quantile.ToString(CultureInfo.InvariantCulture); + _hotBuf = new SampleBuffer(_parent._bufCap); + _coldBuf = new SampleBuffer(_parent._bufCap); + _streamDuration = new TimeSpan(_parent._maxAge.Ticks / _parent._ageBuckets); + _headStreamExpUnixtimeSeconds = LowGranularityTimeSource.GetSecondsFromUnixEpoch() + _streamDuration.TotalSeconds; + _hotBufExpUnixtimeSeconds = _headStreamExpUnixtimeSeconds; - _quantileIdentifiers[i] = CreateIdentifier(null, "quantile", value); - } + _streams = new QuantileStream[_parent._ageBuckets]; + for (var i = 0; i < _parent._ageBuckets; i++) + { + _streams[i] = QuantileStream.NewTargeted(_parent._objectives); } - private readonly byte[] _sumIdentifier; - private readonly byte[] _countIdentifier; - private readonly byte[][] _quantileIdentifiers; + _headStream = _streams[0]; + } - private protected override async Task CollectAndSerializeImplAsync(IMetricsSerializer serializer, CancellationToken cancel) - { - // We output sum. - // We output count. - // We output quantiles. + private readonly Summary _parent; - var now = DateTime.UtcNow; + private static readonly byte[] SumSuffix = "sum"u8.ToArray(); + private static readonly byte[] CountSuffix = "count"u8.ToArray(); - double count; - double sum; - var values = new List<(double quantile, double value)>(_objectives.Count); +#if NET + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] +#endif + private protected override async ValueTask CollectAndSerializeImplAsync(IMetricsSerializer serializer, + CancellationToken cancel) + { + // We output sum. + // We output count. + // We output quantiles. + + var now = LowGranularityTimeSource.GetSecondsFromUnixEpoch(); + + long count; + double sum; + var values = ArrayPool<(double quantile, double value)>.Shared.Rent(_parent._objectives.Count); + var valuesIndex = 0; + + try + { lock (_bufLock) { lock (_lock) @@ -142,170 +175,169 @@ private protected override async Task CollectAndSerializeImplAsync(IMetricsSeria count = _count; sum = _sum; - for (var i = 0; i < _sortedObjectives.Length; i++) + for (var i = 0; i < _parent._sortedObjectives.Length; i++) { - var quantile = _sortedObjectives[i]; + var quantile = _parent._sortedObjectives[i]; var value = _headStream.Count == 0 ? double.NaN : _headStream.Query(quantile); - values.Add((quantile, value)); + values[valuesIndex++] = (quantile, value); } } } - await serializer.WriteMetricAsync(_sumIdentifier, sum, cancel); - await serializer.WriteMetricAsync(_countIdentifier, count, cancel); - - for (var i = 0; i < values.Count; i++) - await serializer.WriteMetricAsync(_quantileIdentifiers[i], values[i].value, cancel); + await serializer.WriteMetricPointAsync( + Parent.NameBytes, + FlattenedLabelsBytes, + CanonicalLabel.Empty, + sum, + ObservedExemplar.Empty, + SumSuffix, + cancel); + await serializer.WriteMetricPointAsync( + Parent.NameBytes, + FlattenedLabelsBytes, + CanonicalLabel.Empty, + count, + ObservedExemplar.Empty, + CountSuffix, + cancel); + + for (var i = 0; i < _parent._objectives.Count; i++) + { + await serializer.WriteMetricPointAsync( + Parent.NameBytes, + FlattenedLabelsBytes, + _parent._quantileLabels[i], + values[i].value, + ObservedExemplar.Empty, + null, + cancel); + } } - - // Objectives defines the quantile rank estimates with their respective - // absolute error. If Objectives[q] = e, then the value reported - // for q will be the φ-quantile value for some φ between q-e and q+e. - // The default value is DefObjectives. - private IReadOnlyList _objectives = new List(); - private double[] _sortedObjectives; - private double _sum; - private ulong _count; - private SampleBuffer _hotBuf; - private SampleBuffer _coldBuf; - private QuantileStream[] _streams; - private TimeSpan _streamDuration; - private QuantileStream _headStream; - private int _headStreamIdx; - private DateTime _headStreamExpTime; - private DateTime _hotBufExpTime; - - // Protects hotBuf and hotBufExpTime. - private readonly object _bufLock = new object(); - - // Protects every other moving part. - // Lock bufMtx before mtx if both are needed. - private readonly object _lock = new object(); - - // MaxAge defines the duration for which an observation stays relevant - // for the summary. Must be positive. The default value is DefMaxAge. - private TimeSpan _maxAge; - - // AgeBuckets is the number of buckets used to exclude observations that - // are older than MaxAge from the summary. A higher number has a - // resource penalty, so only increase it if the higher resolution is - // really required. For very high observation rates, you might want to - // reduce the number of age buckets. With only one age bucket, you will - // effectively see a complete reset of the summary each time MaxAge has - // passed. The default value is DefAgeBuckets. - private int _ageBuckets; - - // BufCap defines the default sample stream buffer size. The default - // value of DefBufCap should suffice for most uses. If there is a need - // to increase the value, a multiple of 500 is recommended (because that - // is the internal buffer size of the underlying package - // "github.com/bmizerany/perks/quantile"). - private int _bufCap; - - public void Observe(double val) + finally { - Observe(val, DateTime.UtcNow); + ArrayPool<(double quantile, double value)>.Shared.Return(values); } + } - /// - /// For unit tests only - /// - internal void Observe(double val, DateTime now) - { - if (double.IsNaN(val)) - return; + private double _sum; + private long _count; + private SampleBuffer _hotBuf; + private SampleBuffer _coldBuf; + private readonly QuantileStream[] _streams; + private readonly TimeSpan _streamDuration; + private QuantileStream _headStream; + private int _headStreamIdx; + private double _headStreamExpUnixtimeSeconds; + private double _hotBufExpUnixtimeSeconds; - lock (_bufLock) - { - if (now > _hotBufExpTime) - Flush(now); + // Protects hotBuf and hotBufExpTime. + private readonly object _bufLock = new(); - _hotBuf.Append(val); + // Protects every other moving part. + // Lock bufMtx before mtx if both are needed. + private readonly object _lock = new(); - if (_hotBuf.IsFull) - Flush(now); - } + public void Observe(double val) + { + Observe(val, LowGranularityTimeSource.GetSecondsFromUnixEpoch()); + } - Publish(); - } + /// + /// For unit tests only + /// + internal void Observe(double val, double nowUnixtimeSeconds) + { + if (double.IsNaN(val)) + return; - // Flush needs bufMtx locked. - private void Flush(DateTime now) + lock (_bufLock) { - lock (_lock) - { - SwapBufs(now); + if (nowUnixtimeSeconds > _hotBufExpUnixtimeSeconds) + Flush(nowUnixtimeSeconds); - // Go version flushes on a separate goroutine, but doing this on another - // thread actually makes the benchmark tests slower in .net - FlushColdBuf(); - } + _hotBuf.Append(val); + + if (_hotBuf.IsFull) + Flush(nowUnixtimeSeconds); } - // SwapBufs needs mtx AND bufMtx locked, coldBuf must be empty. - private void SwapBufs(DateTime now) - { - if (!_coldBuf.IsEmpty) - throw new InvalidOperationException("coldBuf is not empty"); + Publish(); + } - var temp = _hotBuf; - _hotBuf = _coldBuf; - _coldBuf = temp; + // Flush needs bufMtx locked. + private void Flush(double nowUnixtimeSeconds) + { + lock (_lock) + { + SwapBufs(nowUnixtimeSeconds); - // hotBuf is now empty and gets new expiration set. - while (now > _hotBufExpTime) - { - _hotBufExpTime = _hotBufExpTime.Add(_streamDuration); - } + // Go version flushes on a separate goroutine, but doing this on another + // thread actually makes the benchmark tests slower in .net + FlushColdBuf(); } + } + + // SwapBufs needs mtx AND bufMtx locked, coldBuf must be empty. + private void SwapBufs(double nowUnixtimeSeconds) + { + if (!_coldBuf.IsEmpty) + throw new InvalidOperationException("coldBuf is not empty"); - // FlushColdBuf needs mtx locked. - private void FlushColdBuf() + (_coldBuf, _hotBuf) = (_hotBuf, _coldBuf); + + // hotBuf is now empty and gets new expiration set. + while (nowUnixtimeSeconds > _hotBufExpUnixtimeSeconds) { - for (var bufIdx = 0; bufIdx < _coldBuf.Position; bufIdx++) - { - var value = _coldBuf[bufIdx]; + _hotBufExpUnixtimeSeconds += _streamDuration.TotalSeconds; + } + } - for (var streamIdx = 0; streamIdx < _streams.Length; streamIdx++) - { - _streams[streamIdx].Insert(value); - } + // FlushColdBuf needs mtx locked. + private void FlushColdBuf() + { + for (var bufIdx = 0; bufIdx < _coldBuf.Position; bufIdx++) + { + var value = _coldBuf[bufIdx]; - _count++; - _sum += value; + for (var streamIdx = 0; streamIdx < _streams.Length; streamIdx++) + { + _streams[streamIdx].Insert(value); } - _coldBuf.Reset(); - MaybeRotateStreams(); + _count++; + _sum += value; } - // MaybeRotateStreams needs mtx AND bufMtx locked. - private void MaybeRotateStreams() + _coldBuf.Reset(); + MaybeRotateStreams(); + } + + // MaybeRotateStreams needs mtx AND bufMtx locked. + private void MaybeRotateStreams() + { + while (!_hotBufExpUnixtimeSeconds.Equals(_headStreamExpUnixtimeSeconds)) { - while (!_hotBufExpTime.Equals(_headStreamExpTime)) - { - _headStream.Reset(); - _headStreamIdx++; + _headStream.Reset(); + _headStreamIdx++; - if (_headStreamIdx >= _streams.Length) - _headStreamIdx = 0; + if (_headStreamIdx >= _streams.Length) + _headStreamIdx = 0; - _headStream = _streams[_headStreamIdx]; - _headStreamExpTime = _headStreamExpTime.Add(_streamDuration); - } + _headStream = _streams[_headStreamIdx]; + _headStreamExpUnixtimeSeconds += _streamDuration.TotalSeconds; } } + } - public void Observe(double val) - { - Unlabelled.Observe(val); - } + public void Observe(double val) + { + Unlabelled.Observe(val); + } - public void Publish() => Unlabelled.Publish(); - public void Unpublish() => Unlabelled.Unpublish(); + public void Publish() => Unlabelled.Publish(); + public void Unpublish() => Unlabelled.Unpublish(); - // count + sum + objectives - internal override int TimeseriesCount => ChildCount * (2 + _objectives.Count); - } + // count + sum + objectives + internal override int TimeseriesCount => ChildCount * (2 + _objectives.Count); } \ No newline at end of file diff --git a/Prometheus/SummaryConfiguration.cs b/Prometheus/SummaryConfiguration.cs index 40baa923..e6162e97 100644 --- a/Prometheus/SummaryConfiguration.cs +++ b/Prometheus/SummaryConfiguration.cs @@ -1,32 +1,31 @@ -namespace Prometheus +namespace Prometheus; + +public sealed class SummaryConfiguration : MetricConfiguration { - public sealed class SummaryConfiguration : MetricConfiguration - { - internal static readonly SummaryConfiguration Default = new SummaryConfiguration(); + internal static readonly SummaryConfiguration Default = new SummaryConfiguration(); - /// - /// Pairs of quantiles and allowed error values (epsilon). - /// - /// For example, a quantile of 0.95 with an epsilon of 0.01 means the calculated value - /// will be between the 94th and 96th quantile. - /// - /// If null, no quantiles will be calculated! - /// - public IReadOnlyList Objectives { get; set; } = Summary.DefObjectivesArray; + /// + /// Pairs of quantiles and allowed error values (epsilon). + /// + /// For example, a quantile of 0.95 with an epsilon of 0.01 means the calculated value + /// will be between the 94th and 96th quantile. + /// + /// If null, no quantiles will be calculated! + /// + public IReadOnlyList Objectives { get; set; } = Summary.DefObjectivesArray; - /// - /// Time span over which to calculate the summary. - /// - public TimeSpan MaxAge { get; set; } = Summary.DefMaxAge; + /// + /// Time span over which to calculate the summary. + /// + public TimeSpan MaxAge { get; set; } = Summary.DefMaxAge; - /// - /// Number of buckets used to control measurement expiration. - /// - public int AgeBuckets { get; set; } = Summary.DefAgeBuckets; + /// + /// Number of buckets used to control measurement expiration. + /// + public int AgeBuckets { get; set; } = Summary.DefAgeBuckets; - /// - /// Buffer size limit. Use multiples of 500 to avoid waste, as internal buffers use that size. - /// - public int BufferSize { get; set; } = Summary.DefBufCap; - } + /// + /// Buffer size limit. Use multiples of 500 to avoid waste, as internal buffers use that size. + /// + public int BufferSize { get; set; } = Summary.DefBufCap; } diff --git a/Prometheus/SummaryImpl/QuantileStream.cs b/Prometheus/SummaryImpl/QuantileStream.cs index 0f34637e..bb34f7bf 100644 --- a/Prometheus/SummaryImpl/QuantileStream.cs +++ b/Prometheus/SummaryImpl/QuantileStream.cs @@ -1,175 +1,144 @@ -namespace Prometheus.SummaryImpl +namespace Prometheus.SummaryImpl; + +// Ported from https://github.com/beorn7/perks/blob/master/quantile/stream.go + +// Package quantile computes approximate quantiles over an unbounded data +// stream within low memory and CPU bounds. +// +// A small amount of accuracy is traded to achieve the above properties. +// +// Multiple streams can be merged before calling Query to generate a single set +// of results. This is meaningful when the streams represent the same type of +// data. See Merge and Samples. +// +// For more detailed information about the algorithm used, see: +// +// Effective Computation of Biased Quantiles over Data Streams +// +// http://www.cs.rutgers.edu/~muthu/bquant.pdf + +internal delegate double Invariant(SampleStream stream, double r); + +internal sealed class QuantileStream { - // Ported from https://github.com/beorn7/perks/blob/master/quantile/stream.go + private readonly SampleStream _sampleStream; + private readonly List _samples; + private bool _sorted; - // Package quantile computes approximate quantiles over an unbounded data - // stream within low memory and CPU bounds. - // - // A small amount of accuracy is traded to achieve the above properties. - // - // Multiple streams can be merged before calling Query to generate a single set - // of results. This is meaningful when the streams represent the same type of - // data. See Merge and Samples. - // - // For more detailed information about the algorithm used, see: - // - // Effective Computation of Biased Quantiles over Data Streams - // - // http://www.cs.rutgers.edu/~muthu/bquant.pdf - - internal delegate double Invariant(SampleStream stream, double r); - - internal class QuantileStream + private QuantileStream(SampleStream sampleStream, List samples, bool sorted) { - private readonly SampleStream _sampleStream; - private readonly List _samples; - private bool _sorted; - - private QuantileStream(SampleStream sampleStream, List samples, bool sorted) - { - _sampleStream = sampleStream; - _samples = samples; - _sorted = sorted; - } - - public static QuantileStream NewStream(Invariant invariant) - { - return new QuantileStream(new SampleStream(invariant), new List { Capacity = 500 }, true); - } + _sampleStream = sampleStream; + _samples = samples; + _sorted = sorted; + } - // NewLowBiased returns an initialized Stream for low-biased quantiles - // (e.g. 0.01, 0.1, 0.5) where the needed quantiles are not known a priori, but - // error guarantees can still be given even for the lower ranks of the data - // distribution. - // - // The provided epsilon is a relative error, i.e. the true quantile of a value - // returned by a query is guaranteed to be within (1±Epsilon)*Quantile. - // - // See http://www.cs.rutgers.edu/~muthu/bquant.pdf for time, space, and error - // properties. - public static QuantileStream NewLowBiased(double epsilon) - { - return NewStream((stream, r) => 2 * epsilon * r); - } + public static QuantileStream NewStream(Invariant invariant) + { + return new QuantileStream(new SampleStream(invariant), new List { Capacity = 500 }, true); + } - // NewHighBiased returns an initialized Stream for high-biased quantiles - // (e.g. 0.01, 0.1, 0.5) where the needed quantiles are not known a priori, but - // error guarantees can still be given even for the higher ranks of the data - // distribution. - // - // The provided epsilon is a relative error, i.e. the true quantile of a value - // returned by a query is guaranteed to be within 1-(1±Epsilon)*(1-Quantile). - // - // See http://www.cs.rutgers.edu/~muthu/bquant.pdf for time, space, and error - // properties. - public static QuantileStream NewHighBiased(double epsilon) + // NewTargeted returns an initialized Stream concerned with a particular set of + // quantile values that are supplied a priori. Knowing these a priori reduces + // space and computation time. The targets map maps the desired quantiles to + // their absolute errors, i.e. the true quantile of a value returned by a query + // is guaranteed to be within (Quantile±Epsilon). + // + // See http://www.cs.rutgers.edu/~muthu/bquant.pdf for time, space, and error properties. + public static QuantileStream NewTargeted(IReadOnlyList targets) + { + return NewStream((stream, r) => { - return NewStream((stream, r) => 2 * epsilon * (stream.N - r)); - } + var m = double.MaxValue; - // NewTargeted returns an initialized Stream concerned with a particular set of - // quantile values that are supplied a priori. Knowing these a priori reduces - // space and computation time. The targets map maps the desired quantiles to - // their absolute errors, i.e. the true quantile of a value returned by a query - // is guaranteed to be within (Quantile±Epsilon). - // - // See http://www.cs.rutgers.edu/~muthu/bquant.pdf for time, space, and error properties. - public static QuantileStream NewTargeted(IReadOnlyList targets) - { - return NewStream((stream, r) => + for (var i = 0; i < targets.Count; i++) { - var m = double.MaxValue; - - for (var i = 0; i < targets.Count; i++) - { - var target = targets[i]; + var target = targets[i]; - double f; - if (target.Quantile * stream.N <= r) - f = (2 * target.Epsilon * r) / target.Quantile; - else - f = (2 * target.Epsilon * (stream.N - r)) / (1 - target.Quantile); + double f; + if (target.Quantile * stream.N <= r) + f = (2 * target.Epsilon * r) / target.Quantile; + else + f = (2 * target.Epsilon * (stream.N - r)) / (1 - target.Quantile); - if (f < m) - m = f; - } + if (f < m) + m = f; + } - return m; - }); - } + return m; + }); + } - public void Insert(double value) - { - Insert(new Sample { Value = value, Width = 1 }); - } + public void Insert(double value) + { + Insert(new Sample { Value = value, Width = 1 }); + } - private void Insert(Sample sample) - { - _samples.Add(sample); - _sorted = false; - if (_samples.Count == _samples.Capacity) - Flush(); - } + private void Insert(Sample sample) + { + _samples.Add(sample); + _sorted = false; + if (_samples.Count == _samples.Capacity) + Flush(); + } - private void Flush() - { - MaybeSort(); - _sampleStream.Merge(_samples); - _samples.Clear(); - } + private void Flush() + { + MaybeSort(); + _sampleStream.Merge(_samples); + _samples.Clear(); + } - private void MaybeSort() + private void MaybeSort() + { + if (!_sorted) { - if (!_sorted) - { - _sorted = true; - _samples.Sort(SampleComparison); - } + _sorted = true; + _samples.Sort(SampleComparison); } + } - private static int SampleComparison(Sample lhs, Sample rhs) - { - return lhs.Value.CompareTo(rhs.Value); - } + private static int SampleComparison(Sample lhs, Sample rhs) + { + return lhs.Value.CompareTo(rhs.Value); + } - public void Reset() - { - _sampleStream.Reset(); - _samples.Clear(); - } + public void Reset() + { + _sampleStream.Reset(); + _samples.Clear(); + } - // Count returns the total number of samples observed in the stream since initialization. - public int Count => _samples.Count + _sampleStream.Count; + // Count returns the total number of samples observed in the stream since initialization. + public int Count => _samples.Count + _sampleStream.Count; - public int SamplesCount => _samples.Count; + public int SamplesCount => _samples.Count; - public bool Flushed => _sampleStream.SampleCount > 0; + public bool Flushed => _sampleStream.SampleCount > 0; - // Query returns the computed qth percentiles value. If s was created with - // NewTargeted, and q is not in the set of quantiles provided a priori, Query - // will return an unspecified result. - public double Query(double q) + // Query returns the computed qth percentiles value. If s was created with + // NewTargeted, and q is not in the set of quantiles provided a priori, Query + // will return an unspecified result. + public double Query(double q) + { + if (!Flushed) { - if (!Flushed) - { - // Fast path when there hasn't been enough data for a flush; - // this also yields better accuracy for small sets of data. + // Fast path when there hasn't been enough data for a flush; + // this also yields better accuracy for small sets of data. - var l = _samples.Count; + var l = _samples.Count; - if (l == 0) - return 0; + if (l == 0) + return 0; - var i = (int)(l * q); - if (i > 0) - i -= 1; - - MaybeSort(); - return _samples[i].Value; - } + var i = (int)(l * q); + if (i > 0) + i -= 1; - Flush(); - return _sampleStream.Query(q); + MaybeSort(); + return _samples[i].Value; } + + Flush(); + return _sampleStream.Query(q); } } diff --git a/Prometheus/SummaryImpl/SampleBuffer.cs b/Prometheus/SummaryImpl/SampleBuffer.cs index c1afda5a..61fc531b 100644 --- a/Prometheus/SummaryImpl/SampleBuffer.cs +++ b/Prometheus/SummaryImpl/SampleBuffer.cs @@ -1,46 +1,45 @@ -namespace Prometheus.SummaryImpl +namespace Prometheus.SummaryImpl; + +internal sealed class SampleBuffer { - internal class SampleBuffer - { - private readonly double[] _buffer; + private readonly double[] _buffer; - public SampleBuffer(int capacity) - { - if (capacity <= 0) - throw new ArgumentOutOfRangeException(nameof(capacity), "Must be > 0"); + public SampleBuffer(int capacity) + { + if (capacity <= 0) + throw new ArgumentOutOfRangeException(nameof(capacity), "Must be > 0"); - _buffer = new double[capacity]; - Position = 0; - } + _buffer = new double[capacity]; + Position = 0; + } - public void Append(double value) - { - if (Position >= Capacity) - throw new InvalidOperationException("Buffer is full"); + public void Append(double value) + { + if (Position >= Capacity) + throw new InvalidOperationException("Buffer is full"); - _buffer[Position++] = value; - } + _buffer[Position++] = value; + } - public double this[int index] + public double this[int index] + { + get { - get - { - if (index > Position) - throw new ArgumentOutOfRangeException(nameof(index), "Index is greater than position"); + if (index > Position) + throw new ArgumentOutOfRangeException(nameof(index), "Index is greater than position"); - return _buffer[index]; - } + return _buffer[index]; } + } - public void Reset() - { - Position = 0; - } + public void Reset() + { + Position = 0; + } - public int Position { get; private set; } + public int Position { get; private set; } - public int Capacity => _buffer.Length; - public bool IsFull => Position == Capacity; - public bool IsEmpty => Position == 0; - } + public int Capacity => _buffer.Length; + public bool IsFull => Position == Capacity; + public bool IsEmpty => Position == 0; } diff --git a/Prometheus/SummaryImpl/SampleStream.cs b/Prometheus/SummaryImpl/SampleStream.cs index 98e6377f..08ff2a84 100644 --- a/Prometheus/SummaryImpl/SampleStream.cs +++ b/Prometheus/SummaryImpl/SampleStream.cs @@ -1,113 +1,112 @@ -namespace Prometheus.SummaryImpl +namespace Prometheus.SummaryImpl; + +internal sealed class SampleStream { - internal class SampleStream + public double N; + private readonly List _samples = new List(); + private readonly Invariant _invariant; + + public SampleStream(Invariant invariant) { - public double N; - private readonly List _samples = new List(); - private readonly Invariant _invariant; + _invariant = invariant; + } - public SampleStream(Invariant invariant) - { - _invariant = invariant; - } + public void Merge(List samples) + { + // TODO(beorn7): This tries to merge not only individual samples, but + // whole summaries. The paper doesn't mention merging summaries at + // all. Unittests show that the merging is inaccurate. Find out how to + // do merges properly. - public void Merge(List samples) - { - // TODO(beorn7): This tries to merge not only individual samples, but - // whole summaries. The paper doesn't mention merging summaries at - // all. Unittests show that the merging is inaccurate. Find out how to - // do merges properly. + double r = 0; + var i = 0; - double r = 0; - var i = 0; + for (var sampleIdx = 0; sampleIdx < samples.Count; sampleIdx++) + { + var sample = samples[sampleIdx]; - for (var sampleIdx = 0; sampleIdx < samples.Count; sampleIdx++) + for (; i < _samples.Count; i++) { - var sample = samples[sampleIdx]; + var c = _samples[i]; - for (; i < _samples.Count; i++) + if (c.Value > sample.Value) { - var c = _samples[i]; - - if (c.Value > sample.Value) - { - // Insert at position i - _samples.Insert(i, new Sample { Value = sample.Value, Width = sample.Width, Delta = Math.Max(sample.Delta, Math.Floor(_invariant(this, r)) - 1) }); - i++; - goto inserted; - } - r += c.Width; + // Insert at position i + _samples.Insert(i, new Sample { Value = sample.Value, Width = sample.Width, Delta = Math.Max(sample.Delta, Math.Floor(_invariant(this, r)) - 1) }); + i++; + goto inserted; } - _samples.Add(new Sample { Value = sample.Value, Width = sample.Width, Delta = 0 }); - i++; - - inserted: - N += sample.Width; - r += sample.Width; + r += c.Width; } + _samples.Add(new Sample { Value = sample.Value, Width = sample.Width, Delta = 0 }); + i++; - Compress(); + inserted: + N += sample.Width; + r += sample.Width; } - private void Compress() - { - if (_samples.Count < 2) - return; + Compress(); + } - var x = _samples[_samples.Count - 1]; - var xi = _samples.Count - 1; - var r = N - 1 - x.Width; + private void Compress() + { + if (_samples.Count < 2) + return; - for (var i = _samples.Count - 2; i >= 0; i--) - { - var c = _samples[i]; + var x = _samples[_samples.Count - 1]; + var xi = _samples.Count - 1; + var r = N - 1 - x.Width; - if (c.Width + x.Width + x.Delta <= _invariant(this, r)) - { - x.Width += c.Width; - _samples[xi] = x; - _samples.RemoveAt(i); - xi -= 1; - } - else - { - x = c; - xi = i; - } + for (var i = _samples.Count - 2; i >= 0; i--) + { + var c = _samples[i]; - r -= c.Width; + if (c.Width + x.Width + x.Delta <= _invariant(this, r)) + { + x.Width += c.Width; + _samples[xi] = x; + _samples.RemoveAt(i); + xi -= 1; + } + else + { + x = c; + xi = i; } - } - public void Reset() - { - _samples.Clear(); - N = 0; + r -= c.Width; } + } - public int Count => (int)N; + public void Reset() + { + _samples.Clear(); + N = 0; + } - public double Query(double q) - { - var t = Math.Ceiling(q * N); - t += Math.Ceiling(_invariant(this, t) / 2); - var p = _samples[0]; - double r = 0; + public int Count => (int)N; - for (var i = 1; i < _samples.Count; i++) - { - var c = _samples[i]; - r += p.Width; + public double Query(double q) + { + var t = Math.Ceiling(q * N); + t += Math.Ceiling(_invariant(this, t) / 2); + var p = _samples[0]; + double r = 0; - if (r + c.Width + c.Delta > t) - return p.Value; + for (var i = 1; i < _samples.Count; i++) + { + var c = _samples[i]; + r += p.Width; - p = c; - } + if (r + c.Width + c.Delta > t) + return p.Value; - return p.Value; + p = c; } - public int SampleCount => _samples.Count; + return p.Value; } + + public int SampleCount => _samples.Count; } \ No newline at end of file diff --git a/Prometheus/SuppressDefaultMetricOptions.cs b/Prometheus/SuppressDefaultMetricOptions.cs index e03c5922..a78af281 100644 --- a/Prometheus/SuppressDefaultMetricOptions.cs +++ b/Prometheus/SuppressDefaultMetricOptions.cs @@ -1,85 +1,95 @@ -namespace Prometheus +namespace Prometheus; + +public sealed class SuppressDefaultMetricOptions { - public sealed class SuppressDefaultMetricOptions + internal static readonly SuppressDefaultMetricOptions SuppressAll = new() { - internal static readonly SuppressDefaultMetricOptions SuppressAll = new() - { - SuppressProcessMetrics = true, - SuppressDebugMetrics = true, + SuppressProcessMetrics = true, + SuppressDebugMetrics = true, +#if NET + SuppressEventCounters = true, +#endif + #if NET6_0_OR_GREATER - SuppressEventCounters = true, - SuppressMeters = true + SuppressMeters = true +#endif + }; + + internal static readonly SuppressDefaultMetricOptions SuppressNone = new() + { + SuppressProcessMetrics = false, + SuppressDebugMetrics = false, +#if NET + SuppressEventCounters = false, #endif - }; - internal static readonly SuppressDefaultMetricOptions SuppressNone = new() - { - SuppressProcessMetrics = false, - SuppressDebugMetrics = false, #if NET6_0_OR_GREATER - SuppressEventCounters = false, - SuppressMeters = false + SuppressMeters = false #endif - }; + }; + + /// + /// Suppress the current-process-inspecting metrics (uptime, resource use, ...). + /// + public bool SuppressProcessMetrics { get; set; } - /// - /// Suppress the current-process-inspecting metrics (uptime, resource use, ...). - /// - public bool SuppressProcessMetrics { get; set; } + /// + /// Suppress metrics that prometheus-net uses to report debug information about itself (e.g. number of metrics exported). + /// + public bool SuppressDebugMetrics { get; set; } - /// - /// Suppress metrics that prometheus-net uses to report debug information about itself (e.g. number of metrics exported). - /// - public bool SuppressDebugMetrics { get; set; } +#if NET + /// + /// Suppress the default .NET Event Counter integration. + /// + public bool SuppressEventCounters { get; set; } +#endif #if NET6_0_OR_GREATER - /// - /// Suppress the default .NET Event Counter integration. - /// - public bool SuppressEventCounters { get; set; } + /// + /// Suppress the .NET Meter API integration. + /// + public bool SuppressMeters { get; set; } +#endif - /// - /// Suppress the .NET Meter API integration. - /// - public bool SuppressMeters { get; set; } + internal sealed class ConfigurationCallbacks + { +#if NET + public Action ConfigureEventCounterAdapter = delegate { }; #endif - internal sealed class ConfigurationCallbacks - { #if NET6_0_OR_GREATER - public Action ConfigureEventCounterAdapter = delegate { }; - public Action ConfigureMeterAdapter = delegate { }; + public Action ConfigureMeterAdapter = delegate { }; #endif - } - - /// - /// Configures the target registry based on the requested defaults behavior. - /// - internal void Configure(CollectorRegistry registry, ConfigurationCallbacks configurationCallbacks) - { - // We include some metrics by default, just to give some output when a user first uses the library. - // These are not designed to be super meaningful/useful metrics. - if (!SuppressProcessMetrics) - DotNetStats.Register(registry); + } - if (!SuppressDebugMetrics) - registry.StartCollectingRegistryMetrics(); + /// + /// Configures the default metrics registry based on the requested defaults behavior. + /// + internal void ApplyToDefaultRegistry(ConfigurationCallbacks configurationCallbacks) + { + if (!SuppressProcessMetrics) + DotNetStats.RegisterDefault(); -#if NET6_0_OR_GREATER - if (!SuppressEventCounters) - { - var options = new EventCounterAdapterOptions(); - configurationCallbacks.ConfigureEventCounterAdapter(options); - EventCounterAdapter.StartListening(options); - } + if (!SuppressDebugMetrics) + Metrics.DefaultRegistry.StartCollectingRegistryMetrics(); - if (!SuppressMeters) - { - var options = new MeterAdapterOptions(); - configurationCallbacks.ConfigureMeterAdapter(options); - MeterAdapter.StartListening(options); - } +#if NET + if (!SuppressEventCounters) + { + var options = new EventCounterAdapterOptions(); + configurationCallbacks.ConfigureEventCounterAdapter(options); + EventCounterAdapter.StartListening(options); + } #endif + +#if NET6_0_OR_GREATER + if (!SuppressMeters) + { + var options = new MeterAdapterOptions(); + configurationCallbacks.ConfigureMeterAdapter(options); + MeterAdapter.StartListening(options); } +#endif } } diff --git a/Prometheus/TextSerializer.Net.cs b/Prometheus/TextSerializer.Net.cs new file mode 100644 index 00000000..f93fee80 --- /dev/null +++ b/Prometheus/TextSerializer.Net.cs @@ -0,0 +1,619 @@ +#if NET +using System; +using System.Buffers; +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Prometheus; + +/// +/// Does NOT take ownership of the stream - caller remains the boss. +/// +internal sealed class TextSerializer : IMetricsSerializer +{ + internal static ReadOnlySpan NewLine => [(byte)'\n']; + internal static ReadOnlySpan Quote => [(byte)'"']; + internal static ReadOnlySpan Equal => [(byte)'=']; + internal static ReadOnlySpan Comma => [(byte)',']; + internal static ReadOnlySpan Underscore => [(byte)'_']; + internal static ReadOnlySpan LeftBrace => [(byte)'{']; + internal static ReadOnlySpan RightBraceSpace => [(byte)'}', (byte)' ']; + internal static ReadOnlySpan Space => [(byte)' ']; + internal static ReadOnlySpan SpaceHashSpaceLeftBrace => [(byte)' ', (byte)'#', (byte)' ', (byte)'{']; + internal static ReadOnlySpan PositiveInfinity => [(byte)'+', (byte)'I', (byte)'n', (byte)'f']; + internal static ReadOnlySpan NegativeInfinity => [(byte)'-', (byte)'I', (byte)'n', (byte)'f']; + internal static ReadOnlySpan NotANumber => [(byte)'N', (byte)'a', (byte)'N']; + internal static ReadOnlySpan DotZero => [(byte)'.', (byte)'0']; + internal static ReadOnlySpan FloatPositiveOne => [(byte)'1', (byte)'.', (byte)'0']; + internal static ReadOnlySpan FloatZero => [(byte)'0', (byte)'.', (byte)'0']; + internal static ReadOnlySpan FloatNegativeOne => [(byte)'-', (byte)'1', (byte)'.', (byte)'0']; + internal static ReadOnlySpan IntPositiveOne => [(byte)'1']; + internal static ReadOnlySpan IntZero => [(byte)'0']; + internal static ReadOnlySpan IntNegativeOne => [(byte)'-', (byte)'1']; + internal static ReadOnlySpan HashHelpSpace => [(byte)'#', (byte)' ', (byte)'H', (byte)'E', (byte)'L', (byte)'P', (byte)' ']; + internal static ReadOnlySpan NewlineHashTypeSpace => [(byte)'\n', (byte)'#', (byte)' ', (byte)'T', (byte)'Y', (byte)'P', (byte)'E', (byte)' ']; + + internal static readonly byte[] UnknownBytes = "unknown"u8.ToArray(); + internal static readonly byte[] EofNewLineBytes = [(byte)'#', (byte)' ', (byte)'E', (byte)'O', (byte)'F', (byte)'\n']; + internal static readonly byte[] PositiveInfinityBytes = [(byte)'+', (byte)'I', (byte)'n', (byte)'f']; + + internal static readonly Dictionary MetricTypeToBytes = new() + { + { MetricType.Gauge, "gauge"u8.ToArray() }, + { MetricType.Counter, "counter"u8.ToArray() }, + { MetricType.Histogram, "histogram"u8.ToArray() }, + { MetricType.Summary, "summary"u8.ToArray() }, + }; + + private static readonly char[] DotEChar = ['.', 'e']; + + public TextSerializer(Stream stream, ExpositionFormat fmt = ExpositionFormat.PrometheusText) + { + _expositionFormat = fmt; + _stream = new Lazy(() => AddStreamBuffering(stream)); + } + + // Enables delay-loading of the stream, because touching stream in HTTP handler triggers some behavior. + public TextSerializer(Func streamFactory, + ExpositionFormat fmt = ExpositionFormat.PrometheusText) + { + _expositionFormat = fmt; + _stream = new Lazy(() => AddStreamBuffering(streamFactory())); + } + + /// + /// Ensures that writes to the stream are buffered, meaning we do not emit individual "write 1 byte" calls to the stream. + /// This has been rumored by some users to be relevant in their scenarios (though never with solid evidence or repro steps). + /// However, we can easily simulate this via the serialization benchmark through named pipes - they are super slow if writing + /// individual characters. It is a reasonable assumption that this limitation is also true elsewhere, at least on some OS/platform. + /// + private static Stream AddStreamBuffering(Stream inner) + { + return new BufferedStream(inner, bufferSize: 16 * 1024); + } + + public async Task FlushAsync(CancellationToken cancel) + { + // If we never opened the stream, we don't touch it on flush. + if (!_stream.IsValueCreated) + return; + + await _stream.Value.FlushAsync(cancel); + } + + private readonly Lazy _stream; + + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] + public async ValueTask WriteFamilyDeclarationAsync(string name, byte[] nameBytes, byte[] helpBytes, MetricType type, + byte[] typeBytes, CancellationToken cancel) + { + var bufferLength = MeasureFamilyDeclarationLength(name, nameBytes, helpBytes, type, typeBytes); + var buffer = ArrayPool.Shared.Rent(bufferLength); + + try + { + var nameLen = nameBytes.Length; + if (_expositionFormat == ExpositionFormat.OpenMetricsText && type == MetricType.Counter) + { + if (name.EndsWith("_total")) + { + nameLen -= 6; // in OpenMetrics the counter name does not include the _total prefix. + } + else + { + typeBytes = UnknownBytes; // if the total prefix is missing the _total prefix it is out of spec + } + } + + var position = 0; + AppendToBufferAndIncrementPosition(HashHelpSpace, buffer, ref position); + AppendToBufferAndIncrementPosition(nameBytes.AsSpan(0, nameLen), buffer, ref position); + // The space after the name in "HELP" is mandatory as per ABNF, even if there is no help text. + AppendToBufferAndIncrementPosition(Space, buffer, ref position); + if (helpBytes.Length > 0) + { + AppendToBufferAndIncrementPosition(helpBytes, buffer, ref position); + } + AppendToBufferAndIncrementPosition(NewlineHashTypeSpace, buffer, ref position); + AppendToBufferAndIncrementPosition(nameBytes.AsSpan(0, nameLen), buffer, ref position); + AppendToBufferAndIncrementPosition(Space, buffer, ref position); + AppendToBufferAndIncrementPosition(typeBytes, buffer, ref position); + AppendToBufferAndIncrementPosition(NewLine, buffer, ref position); + + await _stream.Value.WriteAsync(buffer.AsMemory(0, position), cancel); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public int MeasureFamilyDeclarationLength(string name, byte[] nameBytes, byte[] helpBytes, MetricType type, byte[] typeBytes) + { + // We mirror the logic in the Write() call but just measure how many bytes of buffer we need. + var length = 0; + + var nameLen = nameBytes.Length; + + if (_expositionFormat == ExpositionFormat.OpenMetricsText && type == MetricType.Counter) + { + if (name.EndsWith("_total")) + { + nameLen -= 6; // in OpenMetrics the counter name does not include the _total prefix. + } + else + { + typeBytes = UnknownBytes; // if the total prefix is missing the _total prefix it is out of spec + } + } + + length += HashHelpSpace.Length; + length += nameLen; + // The space after the name in "HELP" is mandatory as per ABNF, even if there is no help text. + length += Space.Length; + length += helpBytes.Length; + length += NewlineHashTypeSpace.Length; + length += nameLen; + length += Space.Length; + length += typeBytes.Length; + length += NewLine.Length; + + return length; + } + + public async ValueTask WriteEnd(CancellationToken cancel) + { + if (_expositionFormat == ExpositionFormat.OpenMetricsText) + await _stream.Value.WriteAsync(EofNewLineBytes, cancel); + } + + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] + public async ValueTask WriteMetricPointAsync(byte[] name, byte[] flattenedLabels, CanonicalLabel canonicalLabel, + double value, ObservedExemplar exemplar, byte[]? suffix, CancellationToken cancel) + { + // This is a max length because we do not know ahead of time how many bytes the actual value will consume. + var bufferMaxLength = MeasureIdentifierPartLength(name, flattenedLabels, canonicalLabel, suffix) + MeasureValueMaxLength(value) + NewLine.Length; + + if (_expositionFormat == ExpositionFormat.OpenMetricsText && exemplar.IsValid) + bufferMaxLength += MeasureExemplarMaxLength(exemplar); + + var buffer = ArrayPool.Shared.Rent(bufferMaxLength); + + try + { + var position = WriteIdentifierPart(buffer, name, flattenedLabels, canonicalLabel, suffix); + + position += WriteValue(buffer.AsSpan(position..), value); + + if (_expositionFormat == ExpositionFormat.OpenMetricsText && exemplar.IsValid) + { + position += WriteExemplar(buffer.AsSpan(position..), exemplar); + } + + AppendToBufferAndIncrementPosition(NewLine, buffer, ref position); + + ValidateBufferMaxLengthAndPosition(bufferMaxLength, position); + + await _stream.Value.WriteAsync(buffer.AsMemory(0, position), cancel); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] + public async ValueTask WriteMetricPointAsync(byte[] name, byte[] flattenedLabels, CanonicalLabel canonicalLabel, + long value, ObservedExemplar exemplar, byte[]? suffix, CancellationToken cancel) + { + // This is a max length because we do not know ahead of time how many bytes the actual value will consume. + var bufferMaxLength = MeasureIdentifierPartLength(name, flattenedLabels, canonicalLabel, suffix) + MeasureValueMaxLength(value) + NewLine.Length; + + if (_expositionFormat == ExpositionFormat.OpenMetricsText && exemplar.IsValid) + bufferMaxLength += MeasureExemplarMaxLength(exemplar); + + var buffer = ArrayPool.Shared.Rent(bufferMaxLength); + + try + { + var position = WriteIdentifierPart(buffer, name, flattenedLabels, canonicalLabel, suffix); + + position += WriteValue(buffer.AsSpan(position..), value); + + if (_expositionFormat == ExpositionFormat.OpenMetricsText && exemplar.IsValid) + { + position += WriteExemplar(buffer.AsSpan(position..), exemplar); + } + + AppendToBufferAndIncrementPosition(NewLine, buffer, ref position); + + ValidateBufferMaxLengthAndPosition(bufferMaxLength, position); + + await _stream.Value.WriteAsync(buffer.AsMemory(0, position), cancel); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private int WriteExemplar(Span buffer, ObservedExemplar exemplar) + { + var position = 0; + + AppendToBufferAndIncrementPosition(SpaceHashSpaceLeftBrace, buffer, ref position); + + for (var i = 0; i < exemplar.Labels!.Length; i++) + { + if (i > 0) + AppendToBufferAndIncrementPosition(Comma, buffer, ref position); + + ref var labelPair = ref exemplar.Labels[i]; + position += WriteExemplarLabel(buffer[position..], labelPair.KeyBytes, labelPair.Value); + } + + AppendToBufferAndIncrementPosition(RightBraceSpace, buffer, ref position); + position += WriteValue(buffer[position..], exemplar.Value); + AppendToBufferAndIncrementPosition(Space, buffer, ref position); + position += WriteValue(buffer[position..], exemplar.Timestamp); + + return position; + } + + private int MeasureExemplarMaxLength(ObservedExemplar exemplar) + { + // We mirror the logic in the Write() call but just measure how many bytes of buffer we need. + var length = 0; + + length += SpaceHashSpaceLeftBrace.Length; + + for (var i = 0; i < exemplar.Labels!.Length; i++) + { + if (i > 0) + length += Comma.Length; + + ref var labelPair = ref exemplar.Labels[i]; + length += MeasureExemplarLabelMaxLength(labelPair.KeyBytes, labelPair.Value); + } + + length += RightBraceSpace.Length; + length += MeasureValueMaxLength(exemplar.Value); + length += Space.Length; + length += MeasureValueMaxLength(exemplar.Timestamp); + + return length; + } + + private static int WriteExemplarLabel(Span buffer, byte[] label, string value) + { + var position = 0; + + AppendToBufferAndIncrementPosition(label, buffer, ref position); + AppendToBufferAndIncrementPosition(Equal, buffer, ref position); + AppendToBufferAndIncrementPosition(Quote, buffer, ref position); + position += PrometheusConstants.ExemplarEncoding.GetBytes(value.AsSpan(), buffer[position..]); + AppendToBufferAndIncrementPosition(Quote, buffer, ref position); + + return position; + } + + private static int MeasureExemplarLabelMaxLength(byte[] label, string value) + { + // We mirror the logic in the Write() call but just measure how many bytes of buffer we need. + var length = 0; + + length += label.Length; + length += Equal.Length; + length += Quote.Length; + length += PrometheusConstants.ExemplarEncoding.GetMaxByteCount(value.Length); + length += Quote.Length; + + return length; + } + + private int WriteValue(Span buffer, double value) + { + var position = 0; + + if (_expositionFormat == ExpositionFormat.OpenMetricsText) + { + switch (value) + { + case 0: + AppendToBufferAndIncrementPosition(FloatZero, buffer, ref position); + return position; + case 1: + AppendToBufferAndIncrementPosition(FloatPositiveOne, buffer, ref position); + return position; + case -1: + AppendToBufferAndIncrementPosition(FloatNegativeOne, buffer, ref position); + return position; + case double.PositiveInfinity: + AppendToBufferAndIncrementPosition(PositiveInfinity, buffer, ref position); + return position; + case double.NegativeInfinity: + AppendToBufferAndIncrementPosition(NegativeInfinity, buffer, ref position); + return position; + case double.NaN: + AppendToBufferAndIncrementPosition(NotANumber, buffer, ref position); + return position; + } + } + + // Size limit guided by https://stackoverflow.com/questions/21146544/what-is-the-maximum-length-of-double-tostringd + if (!value.TryFormat(_stringCharsBuffer, out var charsWritten, "g", CultureInfo.InvariantCulture)) + throw new Exception("Failed to encode floating point value as string."); + + var encodedBytes = PrometheusConstants.ExportEncoding.GetBytes(_stringCharsBuffer, 0, charsWritten, _stringBytesBuffer, 0); + AppendToBufferAndIncrementPosition(_stringBytesBuffer.AsSpan(0, encodedBytes), buffer, ref position); + + // In certain places (e.g. "le" label) we need floating point values to actually have the decimal point in them for OpenMetrics. + if (_expositionFormat == ExpositionFormat.OpenMetricsText && RequiresOpenMetricsDotZero(_stringCharsBuffer, charsWritten)) + AppendToBufferAndIncrementPosition(DotZero, buffer, ref position); + + return position; + } + + static bool RequiresOpenMetricsDotZero(char[] buffer, int length) + { + return buffer.AsSpan(0..length).IndexOfAny(DotEChar) == -1; /* did not contain .|e, so needs a .0 to turn it into a floating-point value */ + } + + private int MeasureValueMaxLength(double value) + { + // We mirror the logic in the Write() call but just measure how many bytes of buffer we need. + if (_expositionFormat == ExpositionFormat.OpenMetricsText) + { + switch (value) + { + case 0: + return FloatZero.Length; + case 1: + return FloatPositiveOne.Length; + case -1: + return FloatNegativeOne.Length; + case double.PositiveInfinity: + return PositiveInfinity.Length; + case double.NegativeInfinity: + return NegativeInfinity.Length; + case double.NaN: + return NotANumber.Length; + } + } + + // We do not want to spend time formatting the value just to measure the length and throw away the result. + // Therefore we just consider the max length and return it. The max length is just the length of the value-encoding buffer. + return _stringBytesBuffer.Length; + } + + private int WriteValue(Span buffer, long value) + { + var position = 0; + + if (_expositionFormat == ExpositionFormat.OpenMetricsText) + { + switch (value) + { + case 0: + AppendToBufferAndIncrementPosition(IntZero, buffer, ref position); + return position; + case 1: + AppendToBufferAndIncrementPosition(IntPositiveOne, buffer, ref position); + return position; + case -1: + AppendToBufferAndIncrementPosition(IntNegativeOne, buffer, ref position); + return position; + } + } + + if (!value.TryFormat(_stringCharsBuffer, out var charsWritten, "D", CultureInfo.InvariantCulture)) + throw new Exception("Failed to encode integer value as string."); + + var encodedBytes = PrometheusConstants.ExportEncoding.GetBytes(_stringCharsBuffer, 0, charsWritten, _stringBytesBuffer, 0); + AppendToBufferAndIncrementPosition(_stringBytesBuffer.AsSpan(0, encodedBytes), buffer, ref position); + + return position; + } + + private int MeasureValueMaxLength(long value) + { + // We mirror the logic in the Write() call but just measure how many bytes of buffer we need. + if (_expositionFormat == ExpositionFormat.OpenMetricsText) + { + switch (value) + { + case 0: + return IntZero.Length; + case 1: + return IntPositiveOne.Length; + case -1: + return IntNegativeOne.Length; + } + } + + // We do not want to spend time formatting the value just to measure the length and throw away the result. + // Therefore we just consider the max length and return it. The max length is just the length of the value-encoding buffer. + return _stringBytesBuffer.Length; + } + + // Reuse a buffer to do the serialization and UTF-8 encoding. + // Size limit guided by https://stackoverflow.com/questions/21146544/what-is-the-maximum-length-of-double-tostringd + private readonly char[] _stringCharsBuffer = new char[32]; + private readonly byte[] _stringBytesBuffer = new byte[32]; + + private readonly ExpositionFormat _expositionFormat; + + private static void AppendToBufferAndIncrementPosition(ReadOnlySpan from, Span to, ref int position) + { + from.CopyTo(to[position..]); + position += from.Length; + } + + private static void ValidateBufferLengthAndPosition(int bufferLength, int position) + { + if (position != bufferLength) + throw new Exception("Internal error: counting the same bytes twice got us a different value."); + } + + private static void ValidateBufferMaxLengthAndPosition(int bufferMaxLength, int position) + { + if (position > bufferMaxLength) + throw new Exception("Internal error: counting the same bytes twice got us a different value."); + } + + /// + /// Creates a metric identifier, with an optional name postfix and an optional extra label to append to the end. + /// familyname_postfix{labelkey1="labelvalue1",labelkey2="labelvalue2"} + /// Note: Terminates with a SPACE + /// + private int WriteIdentifierPart(Span buffer, byte[] name, byte[] flattenedLabels, CanonicalLabel extraLabel, byte[]? suffix = null) + { + var position = 0; + + AppendToBufferAndIncrementPosition(name, buffer, ref position); + + if (suffix != null && suffix.Length > 0) + { + AppendToBufferAndIncrementPosition(Underscore, buffer, ref position); + AppendToBufferAndIncrementPosition(suffix, buffer, ref position); + } + + if (flattenedLabels.Length > 0 || extraLabel.IsNotEmpty) + { + AppendToBufferAndIncrementPosition(LeftBrace, buffer, ref position); + if (flattenedLabels.Length > 0) + { + AppendToBufferAndIncrementPosition(flattenedLabels, buffer, ref position); + } + + // Extra labels go to the end (i.e. they are deepest to inherit from). + if (extraLabel.IsNotEmpty) + { + if (flattenedLabels.Length > 0) + { + AppendToBufferAndIncrementPosition(Comma, buffer, ref position); + } + + AppendToBufferAndIncrementPosition(extraLabel.Name, buffer, ref position); + AppendToBufferAndIncrementPosition(Equal, buffer, ref position); + AppendToBufferAndIncrementPosition(Quote, buffer, ref position); + + if (_expositionFormat == ExpositionFormat.OpenMetricsText) + AppendToBufferAndIncrementPosition(extraLabel.OpenMetrics, buffer, ref position); + else + AppendToBufferAndIncrementPosition(extraLabel.Prometheus, buffer, ref position); + + AppendToBufferAndIncrementPosition(Quote, buffer, ref position); + } + + AppendToBufferAndIncrementPosition(RightBraceSpace, buffer, ref position); + } + else + { + AppendToBufferAndIncrementPosition(Space, buffer, ref position); + } + + return position; + } + + private int MeasureIdentifierPartLength(byte[] name, byte[] flattenedLabels, CanonicalLabel extraLabel, byte[]? suffix = null) + { + // We mirror the logic in the Write() call but just measure how many bytes of buffer we need. + var length = 0; + + length += name.Length; + + if (suffix != null && suffix.Length > 0) + { + length += Underscore.Length; + length += suffix.Length; + } + + if (flattenedLabels.Length > 0 || extraLabel.IsNotEmpty) + { + length += LeftBrace.Length; + if (flattenedLabels.Length > 0) + { + length += flattenedLabels.Length; + } + + // Extra labels go to the end (i.e. they are deepest to inherit from). + if (extraLabel.IsNotEmpty) + { + if (flattenedLabels.Length > 0) + { + length += Comma.Length; + } + + length += extraLabel.Name.Length; + length += Equal.Length; + length += Quote.Length; + + if (_expositionFormat == ExpositionFormat.OpenMetricsText) + length += extraLabel.OpenMetrics.Length; + else + length += extraLabel.Prometheus.Length; + + length += Quote.Length; + } + + length += RightBraceSpace.Length; + } + else + { + length += Space.Length; + } + + return length; + } + + /// + /// Encode the special variable in regular Prometheus form and also return a OpenMetrics variant, these can be + /// the same. + /// see: https://github.com/OpenObservability/OpenMetrics/blob/main/specification/OpenMetrics.md#considerations-canonical-numbers + /// + internal static CanonicalLabel EncodeValueAsCanonicalLabel(byte[] name, double value) + { + if (double.IsPositiveInfinity(value)) + return new CanonicalLabel(name, PositiveInfinityBytes, PositiveInfinityBytes); + + // Size limit guided by https://stackoverflow.com/questions/21146544/what-is-the-maximum-length-of-double-tostringd + Span buffer = stackalloc char[32]; + + if (!value.TryFormat(buffer, out var charsWritten, "g", CultureInfo.InvariantCulture)) + throw new Exception("Failed to encode floating point value as string."); + + var prometheusChars = buffer[0..charsWritten]; + + var prometheusByteCount = PrometheusConstants.ExportEncoding.GetByteCount(prometheusChars); + var prometheusBytes = new byte[prometheusByteCount]; + + if (PrometheusConstants.ExportEncoding.GetBytes(prometheusChars, prometheusBytes) != prometheusByteCount) + throw new Exception("Internal error: counting the same bytes twice got us a different value."); + + var openMetricsByteCount = prometheusByteCount; + byte[] openMetricsBytes; + + // Identify whether the written characters are expressed as floating-point, by checking for presence of the 'e' or '.' characters. + if (prometheusChars.IndexOfAny(DotEChar) == -1) + { + // Prometheus defaults to integer-formatting without a decimal point, if possible. + // OpenMetrics requires labels containing numeric values to be expressed in floating point format. + // If all we find is an integer, we add a ".0" to the end to make it a floating point value. + openMetricsByteCount += 2; + + openMetricsBytes = new byte[openMetricsByteCount]; + Array.Copy(prometheusBytes, openMetricsBytes, prometheusByteCount); + + DotZero.CopyTo(openMetricsBytes.AsSpan(prometheusByteCount)); + } + else + { + // It is already a floating-point value in Prometheus representation - reuse same bytes for OpenMetrics. + openMetricsBytes = prometheusBytes; + } + + return new CanonicalLabel(name, prometheusBytes, openMetricsBytes); + } +} +#endif \ No newline at end of file diff --git a/Prometheus/TextSerializer.NetStandardFx.cs b/Prometheus/TextSerializer.NetStandardFx.cs new file mode 100644 index 00000000..9b012c21 --- /dev/null +++ b/Prometheus/TextSerializer.NetStandardFx.cs @@ -0,0 +1,317 @@ +#if !NET +using System.Globalization; + +namespace Prometheus; + +/// +/// Does NOT take ownership of the stream - caller remains the boss. +/// +internal sealed class TextSerializer : IMetricsSerializer +{ + internal static readonly byte[] NewLine = [(byte)'\n']; + internal static readonly byte[] Quote = [(byte)'"']; + internal static readonly byte[] Equal = [(byte)'=']; + internal static readonly byte[] Comma = [(byte)',']; + internal static readonly byte[] Underscore = [(byte)'_']; + internal static readonly byte[] LeftBrace = [(byte)'{']; + internal static readonly byte[] RightBraceSpace = [(byte)'}', (byte)' ']; + internal static readonly byte[] Space = [(byte)' ']; + internal static readonly byte[] SpaceHashSpaceLeftBrace = [(byte)' ', (byte)'#', (byte)' ', (byte)'{']; + internal static readonly byte[] PositiveInfinity = "+Inf"u8.ToArray(); + internal static readonly byte[] NegativeInfinity = "-Inf"u8.ToArray(); + internal static readonly byte[] NotANumber = "NaN"u8.ToArray(); + internal static readonly byte[] DotZero = ".0"u8.ToArray(); + internal static readonly byte[] FloatPositiveOne = "1.0"u8.ToArray(); + internal static readonly byte[] FloatZero = "0.0"u8.ToArray(); + internal static readonly byte[] FloatNegativeOne = "-1.0"u8.ToArray(); + internal static readonly byte[] IntPositiveOne = "1"u8.ToArray(); + internal static readonly byte[] IntZero = "0"u8.ToArray(); + internal static readonly byte[] IntNegativeOne = "-1"u8.ToArray(); + internal static readonly byte[] EofNewLine = "# EOF\n"u8.ToArray(); + internal static readonly byte[] HashHelpSpace = "# HELP "u8.ToArray(); + internal static readonly byte[] NewlineHashTypeSpace = "\n# TYPE "u8.ToArray(); + + internal static readonly byte[] Unknown = "unknown"u8.ToArray(); + + internal static readonly Dictionary MetricTypeToBytes = new() + { + { MetricType.Gauge, "gauge"u8.ToArray() }, + { MetricType.Counter, "counter"u8.ToArray() }, + { MetricType.Histogram, "histogram"u8.ToArray() }, + { MetricType.Summary, "summary"u8.ToArray() }, + }; + + private static readonly char[] DotEChar = ['.', 'e']; + + public TextSerializer(Stream stream, ExpositionFormat fmt = ExpositionFormat.PrometheusText) + { + _expositionFormat = fmt; + _stream = new Lazy(() => AddStreamBuffering(stream)); + } + + // Enables delay-loading of the stream, because touching stream in HTTP handler triggers some behavior. + public TextSerializer(Func streamFactory, + ExpositionFormat fmt = ExpositionFormat.PrometheusText) + { + _expositionFormat = fmt; + _stream = new Lazy(() => AddStreamBuffering(streamFactory())); + } + + /// + /// Ensures that writes to the stream are buffered, meaning we do not emit individual "write 1 byte" calls to the stream. + /// This has been rumored by some users to be relevant in their scenarios (though never with solid evidence or repro steps). + /// However, we can easily simulate this via the serialization benchmark through named pipes - they are super slow if writing + /// individual characters. It is a reasonable assumption that this limitation is also true elsewhere, at least on some OS/platform. + /// + private static Stream AddStreamBuffering(Stream inner) + { + return new BufferedStream(inner); + } + + public async Task FlushAsync(CancellationToken cancel) + { + // If we never opened the stream, we don't touch it on flush. + if (!_stream.IsValueCreated) + return; + + await _stream.Value.FlushAsync(cancel); + } + + private readonly Lazy _stream; + + public async ValueTask WriteFamilyDeclarationAsync(string name, byte[] nameBytes, byte[] helpBytes, MetricType type, + byte[] typeBytes, CancellationToken cancel) + { + var nameLen = nameBytes.Length; + if (_expositionFormat == ExpositionFormat.OpenMetricsText && type == MetricType.Counter) + { + if (name.EndsWith("_total")) + { + nameLen -= 6; // in OpenMetrics the counter name does not include the _total prefix. + } + else + { + typeBytes = Unknown; // if the total prefix is missing the _total prefix it is out of spec + } + } + + await _stream.Value.WriteAsync(HashHelpSpace, 0, HashHelpSpace.Length, cancel); + await _stream.Value.WriteAsync(nameBytes, 0, nameLen, cancel); + // The space after the name in "HELP" is mandatory as per ABNF, even if there is no help text. + await _stream.Value.WriteAsync(Space, 0, Space.Length, cancel); + if (helpBytes.Length > 0) + { + await _stream.Value.WriteAsync(helpBytes, 0, helpBytes.Length, cancel); + } + await _stream.Value.WriteAsync(NewlineHashTypeSpace, 0, NewlineHashTypeSpace.Length, cancel); + await _stream.Value.WriteAsync(nameBytes, 0, nameLen, cancel); + await _stream.Value.WriteAsync(Space, 0, Space.Length, cancel); + await _stream.Value.WriteAsync(typeBytes, 0, typeBytes.Length, cancel); + await _stream.Value.WriteAsync(NewLine, 0, NewLine.Length, cancel); + } + + public async ValueTask WriteEnd(CancellationToken cancel) + { + if (_expositionFormat == ExpositionFormat.OpenMetricsText) + await _stream.Value.WriteAsync(EofNewLine, 0, EofNewLine.Length, cancel); + } + + public async ValueTask WriteMetricPointAsync(byte[] name, byte[] flattenedLabels, CanonicalLabel canonicalLabel, + double value, ObservedExemplar exemplar, byte[]? suffix, CancellationToken cancel) + { + await WriteIdentifierPartAsync(name, flattenedLabels, canonicalLabel, suffix, cancel); + + await WriteValue(value, cancel); + if (_expositionFormat == ExpositionFormat.OpenMetricsText && exemplar.IsValid) + { + await WriteExemplarAsync(cancel, exemplar); + } + + await _stream.Value.WriteAsync(NewLine, 0, NewLine.Length, cancel); + } + + public async ValueTask WriteMetricPointAsync(byte[] name, byte[] flattenedLabels, CanonicalLabel canonicalLabel, + long value, ObservedExemplar exemplar, byte[]? suffix, CancellationToken cancel) + { + await WriteIdentifierPartAsync(name, flattenedLabels, canonicalLabel, suffix, cancel); + + await WriteValue(value, cancel); + if (_expositionFormat == ExpositionFormat.OpenMetricsText && exemplar.IsValid) + { + await WriteExemplarAsync(cancel, exemplar); + } + + await _stream.Value.WriteAsync(NewLine, 0, NewLine.Length, cancel); + } + + private async Task WriteExemplarAsync(CancellationToken cancel, ObservedExemplar exemplar) + { + await _stream.Value.WriteAsync(SpaceHashSpaceLeftBrace, 0, SpaceHashSpaceLeftBrace.Length, cancel); + for (var i = 0; i < exemplar.Labels!.Length; i++) + { + if (i > 0) + await _stream.Value.WriteAsync(Comma, 0, Comma.Length, cancel); + + await WriteLabel(exemplar.Labels![i].KeyBytes, PrometheusConstants.ExemplarEncoding.GetBytes(exemplar.Labels![i].Value), cancel); + } + + await _stream.Value.WriteAsync(RightBraceSpace, 0, RightBraceSpace.Length, cancel); + await WriteValue(exemplar.Value, cancel); + await _stream.Value.WriteAsync(Space, 0, Space.Length, cancel); + await WriteValue(exemplar.Timestamp, cancel); + } + + private async Task WriteLabel(byte[] label, byte[] value, CancellationToken cancel) + { + await _stream.Value.WriteAsync(label, 0, label.Length, cancel); + await _stream.Value.WriteAsync(Equal, 0, Equal.Length, cancel); + await _stream.Value.WriteAsync(Quote, 0, Quote.Length, cancel); + await _stream.Value.WriteAsync(value, 0, value.Length, cancel); + await _stream.Value.WriteAsync(Quote, 0, Quote.Length, cancel); + } + + private async Task WriteValue(double value, CancellationToken cancel) + { + if (_expositionFormat == ExpositionFormat.OpenMetricsText) + { + switch (value) + { + case 0: + await _stream.Value.WriteAsync(FloatZero, 0, FloatZero.Length, cancel); + return; + case 1: + await _stream.Value.WriteAsync(FloatPositiveOne, 0, FloatPositiveOne.Length, cancel); + return; + case -1: + await _stream.Value.WriteAsync(FloatNegativeOne, 0, FloatNegativeOne.Length, cancel); + return; + case double.PositiveInfinity: + await _stream.Value.WriteAsync(PositiveInfinity, 0, PositiveInfinity.Length, cancel); + return; + case double.NegativeInfinity: + await _stream.Value.WriteAsync(NegativeInfinity, 0, NegativeInfinity.Length, cancel); + return; + case double.NaN: + await _stream.Value.WriteAsync(NotANumber, 0, NotANumber.Length, cancel); + return; + } + } + + var valueAsString = value.ToString("g", CultureInfo.InvariantCulture); + + var numBytes = PrometheusConstants.ExportEncoding.GetBytes(valueAsString, 0, valueAsString.Length, _stringBytesBuffer, 0); + await _stream.Value.WriteAsync(_stringBytesBuffer, 0, numBytes, cancel); + + // In certain places (e.g. "le" label) we need floating point values to actually have the decimal point in them for OpenMetrics. + if (_expositionFormat == ExpositionFormat.OpenMetricsText && valueAsString.IndexOfAny(DotEChar) == -1 /* did not contain .|e */) + await _stream.Value.WriteAsync(DotZero, 0, DotZero.Length, cancel); + } + + private async Task WriteValue(long value, CancellationToken cancel) + { + if (_expositionFormat == ExpositionFormat.OpenMetricsText) + { + switch (value) + { + case 0: + await _stream.Value.WriteAsync(IntZero, 0, IntZero.Length, cancel); + return; + case 1: + await _stream.Value.WriteAsync(IntPositiveOne, 0, IntPositiveOne.Length, cancel); + return; + case -1: + await _stream.Value.WriteAsync(IntNegativeOne, 0, IntNegativeOne.Length, cancel); + return; + } + } + + var valueAsString = value.ToString("D", CultureInfo.InvariantCulture); + var numBytes = PrometheusConstants.ExportEncoding.GetBytes(valueAsString, 0, valueAsString.Length, _stringBytesBuffer, 0); + await _stream.Value.WriteAsync(_stringBytesBuffer, 0, numBytes, cancel); + } + + // Reuse a buffer to do the serialization and UTF-8 encoding. + // Size limit guided by https://stackoverflow.com/questions/21146544/what-is-the-maximum-length-of-double-tostringd + private readonly char[] _stringCharsBuffer = new char[32]; + private readonly byte[] _stringBytesBuffer = new byte[32]; + + private readonly ExpositionFormat _expositionFormat; + + /// + /// Creates a metric identifier, with an optional name postfix and an optional extra label to append to the end. + /// familyname_postfix{labelkey1="labelvalue1",labelkey2="labelvalue2"} + /// Note: Terminates with a SPACE + /// + private async Task WriteIdentifierPartAsync(byte[] name, byte[] flattenedLabels, + CanonicalLabel canonicalLabel, byte[]? suffix, CancellationToken cancel) + { + await _stream.Value.WriteAsync(name, 0, name.Length, cancel); + if (suffix != null && suffix.Length > 0) + { + await _stream.Value.WriteAsync(Underscore, 0, Underscore.Length, cancel); + await _stream.Value.WriteAsync(suffix, 0, suffix.Length, cancel); + } + + if (flattenedLabels.Length > 0 || canonicalLabel.IsNotEmpty) + { + await _stream.Value.WriteAsync(LeftBrace, 0, LeftBrace.Length, cancel); + if (flattenedLabels.Length > 0) + { + await _stream.Value.WriteAsync(flattenedLabels, 0, flattenedLabels.Length, cancel); + } + + // Extra labels go to the end (i.e. they are deepest to inherit from). + if (canonicalLabel.IsNotEmpty) + { + if (flattenedLabels.Length > 0) + { + await _stream.Value.WriteAsync(Comma, 0, Comma.Length, cancel); + } + + await _stream.Value.WriteAsync(canonicalLabel.Name, 0, canonicalLabel.Name.Length, cancel); + await _stream.Value.WriteAsync(Equal, 0, Equal.Length, cancel); + await _stream.Value.WriteAsync(Quote, 0, Quote.Length, cancel); + if (_expositionFormat == ExpositionFormat.OpenMetricsText) + await _stream.Value.WriteAsync( + canonicalLabel.OpenMetrics, 0, canonicalLabel.OpenMetrics.Length, cancel); + else + await _stream.Value.WriteAsync( + canonicalLabel.Prometheus, 0, canonicalLabel.Prometheus.Length, cancel); + await _stream.Value.WriteAsync(Quote, 0, Quote.Length, cancel); + } + + await _stream.Value.WriteAsync(RightBraceSpace, 0, RightBraceSpace.Length, cancel); + } + else + { + await _stream.Value.WriteAsync(Space, 0, Space.Length, cancel); + } + } + + /// + /// Encode the special variable in regular Prometheus form and also return a OpenMetrics variant, these can be + /// the same. + /// see: https://github.com/OpenObservability/OpenMetrics/blob/main/specification/OpenMetrics.md#considerations-canonical-numbers + /// + internal static CanonicalLabel EncodeValueAsCanonicalLabel(byte[] name, double value) + { + if (double.IsPositiveInfinity(value)) + return new CanonicalLabel(name, PositiveInfinity, PositiveInfinity); + + var valueAsString = value.ToString("g", CultureInfo.InvariantCulture); + var prometheusBytes = PrometheusConstants.ExportEncoding.GetBytes(valueAsString); + + var openMetricsBytes = prometheusBytes; + + // Identify whether the original value is floating-point, by checking for presence of the 'e' or '.' characters. + if (valueAsString.IndexOfAny(DotEChar) == -1) + { + // OpenMetrics requires labels containing numeric values to be expressed in floating point format. + // If all we find is an integer, we add a ".0" to the end to make it a floating point value. + openMetricsBytes = PrometheusConstants.ExportEncoding.GetBytes(valueAsString + ".0"); + } + + return new CanonicalLabel(name, prometheusBytes, openMetricsBytes); + } +} +#endif \ No newline at end of file diff --git a/Prometheus/TextSerializer.cs b/Prometheus/TextSerializer.cs deleted file mode 100644 index 584ba762..00000000 --- a/Prometheus/TextSerializer.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Globalization; - -namespace Prometheus -{ - /// - /// Does NOT take ownership of the stream - caller remains the boss. - /// - internal sealed class TextSerializer : IMetricsSerializer - { - private static readonly byte[] NewLine = new[] { (byte)'\n' }; - private static readonly byte[] Space = new[] { (byte)' ' }; - - public TextSerializer(Stream stream) - { - _stream = new Lazy(() => stream); - } - - // Enables delay-loading of the stream, because touching stream in HTTP handler triggers some behavior. - public TextSerializer(Func streamFactory) - { - _stream = new Lazy(streamFactory); - } - - public async Task FlushAsync(CancellationToken cancel) - { - // If we never opened the stream, we don't touch it on flush. - if (!_stream.IsValueCreated) - return; - - await _stream.Value.FlushAsync(cancel); - } - - private readonly Lazy _stream; - - // # HELP name help - // # TYPE name type - public async Task WriteFamilyDeclarationAsync(byte[][] headerLines, CancellationToken cancel) - { - foreach (var line in headerLines) - { - await _stream.Value.WriteAsync(line, 0, line.Length, cancel); - await _stream.Value.WriteAsync(NewLine, 0, NewLine.Length, cancel); - } - } - - // Reuse a buffer to do the UTF-8 encoding. - // Maybe one day also ValueStringBuilder but that would be .NET Core only. - // https://github.com/dotnet/corefx/issues/28379 - // Size limit guided by https://stackoverflow.com/questions/21146544/what-is-the-maximum-length-of-double-tostringd - private readonly byte[] _stringBytesBuffer = new byte[32]; - - // name{labelkey1="labelvalue1",labelkey2="labelvalue2"} 123.456 - public async Task WriteMetricAsync(byte[] identifier, double value, CancellationToken cancel) - { - await _stream.Value.WriteAsync(identifier, 0, identifier.Length, cancel); - await _stream.Value.WriteAsync(Space, 0, Space.Length, cancel); - - var valueAsString = value.ToString(CultureInfo.InvariantCulture); - - var numBytes = PrometheusConstants.ExportEncoding - .GetBytes(valueAsString, 0, valueAsString.Length, _stringBytesBuffer, 0); - - await _stream.Value.WriteAsync(_stringBytesBuffer, 0, numBytes, cancel); - await _stream.Value.WriteAsync(NewLine, 0, NewLine.Length, cancel); - } - } -} diff --git a/Prometheus/ThreadSafeDouble.cs b/Prometheus/ThreadSafeDouble.cs index fb3679a8..5f24e99b 100644 --- a/Prometheus/ThreadSafeDouble.cs +++ b/Prometheus/ThreadSafeDouble.cs @@ -1,92 +1,91 @@ using System.Globalization; -namespace Prometheus +namespace Prometheus; + +internal struct ThreadSafeDouble { - internal struct ThreadSafeDouble + private long _value; + + public ThreadSafeDouble(double value) { - private long _value; + _value = BitConverter.DoubleToInt64Bits(value); + } - public ThreadSafeDouble(double value) + public double Value + { + get { - _value = BitConverter.DoubleToInt64Bits(value); + return BitConverter.Int64BitsToDouble(Interlocked.Read(ref _value)); } - - public double Value + set { - get - { - return BitConverter.Int64BitsToDouble(Interlocked.Read(ref _value)); - } - set - { - Interlocked.Exchange(ref _value, BitConverter.DoubleToInt64Bits(value)); - } + Interlocked.Exchange(ref _value, BitConverter.DoubleToInt64Bits(value)); } + } - public void Add(double increment) + public void Add(double increment) + { + while (true) { - while (true) - { - long initialValue = _value; - double computedValue = BitConverter.Int64BitsToDouble(initialValue) + increment; - - if (initialValue == Interlocked.CompareExchange(ref _value, BitConverter.DoubleToInt64Bits(computedValue), initialValue)) - return; - } + long initialValue = Volatile.Read(ref _value); + double computedValue = BitConverter.Int64BitsToDouble(initialValue) + increment; + + if (initialValue == Interlocked.CompareExchange(ref _value, BitConverter.DoubleToInt64Bits(computedValue), initialValue)) + return; } + } - /// - /// Sets the value to this, unless the existing value is already greater. - /// - public void IncrementTo(double to) + /// + /// Sets the value to this, unless the existing value is already greater. + /// + public void IncrementTo(double to) + { + while (true) { - while (true) - { - long initialRaw = _value; - double initialValue = BitConverter.Int64BitsToDouble(initialRaw); + long initialRaw = Volatile.Read(ref _value); + double initialValue = BitConverter.Int64BitsToDouble(initialRaw); - if (initialValue >= to) - return; // Already greater. + if (initialValue >= to) + return; // Already greater. - if (initialRaw == Interlocked.CompareExchange(ref _value, BitConverter.DoubleToInt64Bits(to), initialRaw)) - return; - } + if (initialRaw == Interlocked.CompareExchange(ref _value, BitConverter.DoubleToInt64Bits(to), initialRaw)) + return; } + } - /// - /// Sets the value to this, unless the existing value is already smaller. - /// - public void DecrementTo(double to) + /// + /// Sets the value to this, unless the existing value is already smaller. + /// + public void DecrementTo(double to) + { + while (true) { - while (true) - { - long initialRaw = _value; - double initialValue = BitConverter.Int64BitsToDouble(initialRaw); + long initialRaw = Volatile.Read(ref _value); + double initialValue = BitConverter.Int64BitsToDouble(initialRaw); - if (initialValue <= to) - return; // Already greater. + if (initialValue <= to) + return; // Already smaller. - if (initialRaw == Interlocked.CompareExchange(ref _value, BitConverter.DoubleToInt64Bits(to), initialRaw)) - return; - } + if (initialRaw == Interlocked.CompareExchange(ref _value, BitConverter.DoubleToInt64Bits(to), initialRaw)) + return; } + } - public override string ToString() - { - return Value.ToString(CultureInfo.InvariantCulture); - } + public override string ToString() + { + return Value.ToString(CultureInfo.InvariantCulture); + } - public override bool Equals(object? obj) - { - if (obj is ThreadSafeDouble) - return Value.Equals(((ThreadSafeDouble)obj).Value); + public override bool Equals(object? obj) + { + if (obj is ThreadSafeDouble other) + return Value.Equals(other.Value); - return Value.Equals(obj); - } + return Value.Equals(obj); + } - public override int GetHashCode() - { - return Value.GetHashCode(); - } + public override int GetHashCode() + { + return Value.GetHashCode(); } } diff --git a/Prometheus/ThreadSafeLong.cs b/Prometheus/ThreadSafeLong.cs index dabbad3d..a81f7aab 100644 --- a/Prometheus/ThreadSafeLong.cs +++ b/Prometheus/ThreadSafeLong.cs @@ -1,49 +1,48 @@ using System.Globalization; -namespace Prometheus +namespace Prometheus; + +internal struct ThreadSafeLong { - internal struct ThreadSafeLong + private long _value; + + public ThreadSafeLong(long value) { - private long _value; + _value = value; + } - public ThreadSafeLong(long value) + public long Value + { + get { - _value = value; + return Interlocked.Read(ref _value); } - - public long Value + set { - get - { - return Interlocked.Read(ref _value); - } - set - { - Interlocked.Exchange(ref _value, value); - } + Interlocked.Exchange(ref _value, value); } + } - public void Add(long increment) - { - Interlocked.Add(ref _value, increment); - } + public void Add(long increment) + { + Interlocked.Add(ref _value, increment); + } - public override string ToString() - { - return Value.ToString(CultureInfo.InvariantCulture); - } + public override string ToString() + { + return Value.ToString(CultureInfo.InvariantCulture); + } - public override bool Equals(object? obj) - { - if (obj is ThreadSafeLong) - return Value.Equals(((ThreadSafeLong)obj).Value); + public override bool Equals(object? obj) + { + if (obj is ThreadSafeLong other) + return Value.Equals(other.Value); - return Value.Equals(obj); - } + return Value.Equals(obj); + } - public override int GetHashCode() - { - return Value.GetHashCode(); - } + public override int GetHashCode() + { + return Value.GetHashCode(); } } diff --git a/Prometheus/TimerExtensions.cs b/Prometheus/TimerExtensions.cs index dabb6569..b1f3bcf3 100644 --- a/Prometheus/TimerExtensions.cs +++ b/Prometheus/TimerExtensions.cs @@ -1,67 +1,66 @@ -namespace Prometheus +namespace Prometheus; + +public static class TimerExtensions { - public static class TimerExtensions + private sealed class Timer : ITimer { - private sealed class Timer : ITimer - { - private readonly ValueStopwatch _stopwatch = ValueStopwatch.StartNew(); - private readonly Action _observeDurationAction; - - public Timer(IObserver observer) - { - _observeDurationAction = duration => observer.Observe(duration); - } - - public Timer(IGauge gauge) - { - _observeDurationAction = duration => gauge.Set(duration); - } - - public Timer(ICounter counter) - { - _observeDurationAction = duration => counter.Inc(duration); - } + private readonly ValueStopwatch _stopwatch = ValueStopwatch.StartNew(); + private readonly Action _observeDurationAction; - public TimeSpan ObserveDuration() - { - var duration = _stopwatch.GetElapsedTime(); - _observeDurationAction.Invoke(duration.TotalSeconds); - - return duration; - } + public Timer(IObserver observer) + { + _observeDurationAction = observer.Observe; + } - public void Dispose() - { - ObserveDuration(); - } + public Timer(IGauge gauge) + { + _observeDurationAction = gauge.Set; } - /// - /// Enables you to easily report elapsed seconds in the value of an observer. - /// Dispose of the returned instance to report the elapsed duration. - /// - public static ITimer NewTimer(this IObserver observer) + public Timer(ICounter counter) { - return new Timer(observer); + _observeDurationAction = counter.Inc; } - /// - /// Enables you to easily report elapsed seconds in the value of a gauge. - /// Dispose of the returned instance to report the elapsed duration. - /// - public static ITimer NewTimer(this IGauge gauge) + public TimeSpan ObserveDuration() { - return new Timer(gauge); + var duration = _stopwatch.GetElapsedTime(); + _observeDurationAction(duration.TotalSeconds); + + return duration; } - /// - /// Enables you to easily report elapsed seconds in the value of a counter. - /// The duration (in seconds) will be added to the value of the counter. - /// Dispose of the returned instance to report the elapsed duration. - /// - public static ITimer NewTimer(this ICounter counter) + public void Dispose() { - return new Timer(counter); + ObserveDuration(); } } + + /// + /// Enables you to easily report elapsed seconds in the value of an observer. + /// Dispose of the returned instance to report the elapsed duration. + /// + public static ITimer NewTimer(this IObserver observer) + { + return new Timer(observer); + } + + /// + /// Enables you to easily report elapsed seconds in the value of a gauge. + /// Dispose of the returned instance to report the elapsed duration. + /// + public static ITimer NewTimer(this IGauge gauge) + { + return new Timer(gauge); + } + + /// + /// Enables you to easily report elapsed seconds in the value of a counter. + /// The duration (in seconds) will be added to the value of the counter. + /// Dispose of the returned instance to report the elapsed duration. + /// + public static ITimer NewTimer(this ICounter counter) + { + return new Timer(counter); + } } diff --git a/Prometheus/TimestampHelpers.cs b/Prometheus/TimestampHelpers.cs index baca4c10..c47ce850 100644 --- a/Prometheus/TimestampHelpers.cs +++ b/Prometheus/TimestampHelpers.cs @@ -1,26 +1,25 @@ -namespace Prometheus +namespace Prometheus; + +static class TimestampHelpers { - static class TimestampHelpers - { - // Math copypasted from DateTimeOffset.cs in .NET Framework. + // Math copypasted from DateTimeOffset.cs in .NET Framework. - // Number of days in a non-leap year - private const int DaysPerYear = 365; - // Number of days in 4 years - private const int DaysPer4Years = DaysPerYear * 4 + 1; // 1461 - // Number of days in 100 years - private const int DaysPer100Years = DaysPer4Years * 25 - 1; // 36524 - // Number of days in 400 years - private const int DaysPer400Years = DaysPer100Years * 4 + 1; // 146097 - private const int DaysTo1970 = DaysPer400Years * 4 + DaysPer100Years * 3 + DaysPer4Years * 17 + DaysPerYear; // 719,162 - private const long UnixEpochTicks = TimeSpan.TicksPerDay * DaysTo1970; // 621,355,968,000,000,000 - private const long UnixEpochSeconds = UnixEpochTicks / TimeSpan.TicksPerSecond; // 62,135,596,800 + // Number of days in a non-leap year + private const int DaysPerYear = 365; + // Number of days in 4 years + private const int DaysPer4Years = DaysPerYear * 4 + 1; // 1461 + // Number of days in 100 years + private const int DaysPer100Years = DaysPer4Years * 25 - 1; // 36524 + // Number of days in 400 years + private const int DaysPer400Years = DaysPer100Years * 4 + 1; // 146097 + private const int DaysTo1970 = DaysPer400Years * 4 + DaysPer100Years * 3 + DaysPer4Years * 17 + DaysPerYear; // 719,162 + private const long UnixEpochTicks = TimeSpan.TicksPerDay * DaysTo1970; // 621,355,968,000,000,000 + private const long UnixEpochSeconds = UnixEpochTicks / TimeSpan.TicksPerSecond; // 62,135,596,800 - public static double ToUnixTimeSecondsAsDouble(DateTimeOffset timestamp) - { - // This gets us sub-millisecond precision, which is better than ToUnixTimeMilliseconds(). - var ticksSinceUnixEpoch = timestamp.ToUniversalTime().Ticks - UnixEpochSeconds * TimeSpan.TicksPerSecond; - return ticksSinceUnixEpoch / (double)TimeSpan.TicksPerSecond; - } + public static double ToUnixTimeSecondsAsDouble(DateTimeOffset timestamp) + { + // This gets us sub-millisecond precision, which is better than ToUnixTimeMilliseconds(). + var ticksSinceUnixEpoch = timestamp.ToUniversalTime().Ticks - UnixEpochSeconds * TimeSpan.TicksPerSecond; + return ticksSinceUnixEpoch / (double)TimeSpan.TicksPerSecond; } } diff --git a/Prometheus/Usings.cs b/Prometheus/Usings.cs new file mode 100644 index 00000000..e6a9eb40 --- /dev/null +++ b/Prometheus/Usings.cs @@ -0,0 +1 @@ +global using System.Net.Http; \ No newline at end of file diff --git a/Prometheus/ValueStopwatch.cs b/Prometheus/ValueStopwatch.cs index 2dee1891..87aa33d0 100644 --- a/Prometheus/ValueStopwatch.cs +++ b/Prometheus/ValueStopwatch.cs @@ -1,36 +1,32 @@ using System.Diagnostics; // Copied from: https://github.com/dotnet/extensions/blob/master/src/Shared/src/ValueStopwatch/ValueStopwatch.cs -namespace Prometheus -{ - internal struct ValueStopwatch - { - private static readonly double TimestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency; +namespace Prometheus; - private long _startTimestamp; +internal readonly struct ValueStopwatch +{ + private static readonly double TimestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency; - public bool IsActive => _startTimestamp != 0; + private readonly long _startTimestamp; - private ValueStopwatch(long startTimestamp) - { - _startTimestamp = startTimestamp; - } + private ValueStopwatch(long startTimestamp) + { + _startTimestamp = startTimestamp; + } - public static ValueStopwatch StartNew() => new ValueStopwatch(Stopwatch.GetTimestamp()); + public static ValueStopwatch StartNew() => new(Stopwatch.GetTimestamp()); - public TimeSpan GetElapsedTime() - { - // Start timestamp can't be zero in an initialized ValueStopwatch. It would have to be literally the first thing executed when the machine boots to be 0. - // So it being 0 is a clear indication of default(ValueStopwatch) - if (!IsActive) - { - throw new InvalidOperationException("An uninitialized, or 'default', ValueStopwatch cannot be used to get elapsed time."); - } + public TimeSpan GetElapsedTime() + { + // Start timestamp can't be zero in an initialized ValueStopwatch. + // It would have to be literally the first thing executed when the machine boots to be 0. + // So it being 0 is a clear indication of default(ValueStopwatch) + if (_startTimestamp == 0) + throw new InvalidOperationException("An uninitialized, or 'default', ValueStopwatch cannot be used to get elapsed time."); - var end = Stopwatch.GetTimestamp(); - var timestampDelta = end - _startTimestamp; - var ticks = (long)(TimestampToTicks * timestampDelta); - return new TimeSpan(ticks); - } + var end = Stopwatch.GetTimestamp(); + var timestampDelta = end - _startTimestamp; + var ticks = (long)(TimestampToTicks * timestampDelta); + return new TimeSpan(ticks); } } diff --git a/README.md b/README.md index 03a328ac..9ddfd113 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,10 @@ The library targets the following runtimes (and newer): * [Counting exceptions](#counting-exceptions) * [Labels](#labels) * [Static labels](#static-labels) +* [Exemplars](#exemplars) +* [Limiting exemplar volume](#limiting-exemplar-volume) * [When are metrics published?](#when-are-metrics-published) -* [Unpublishing metrics](#unpublishing-metrics) +* [Deleting metrics](#deleting-metrics) * [ASP.NET Core exporter middleware](#aspnet-core-exporter-middleware) * [ASP.NET Core HTTP request metrics](#aspnet-core-http-request-metrics) * [ASP.NET Core gRPC request metrics](#aspnet-core-grpc-request-metrics) @@ -85,12 +87,14 @@ Refer to the sample projects for quick start instructions: | [Sample.Web](Sample.Web/Program.cs) | ASP.NET Core application that produces custom metrics and uses multiple integrations to publish built-in metrics | | [Sample.Console](Sample.Console/Program.cs) | .NET console application that exports custom metrics | | [Sample.Console.DotNetMeters](Sample.Console.DotNetMeters/Program.cs) | Demonstrates how to [publish custom metrics via the .NET Meters API](#net-meters-integration) | +| [Sample.Console.Exemplars](Sample.Console.Exemplars/Program.cs) | .NET console application that attaches exemplars to some metrics | | [Sample.Console.NetFramework](Sample.Console.NetFramework/Program.cs) | Same as above but targeting .NET Framework | | [Sample.Console.NoAspNetCore](Sample.Console.NoAspNetCore/Program.cs) | .NET console application that exports custom metrics without requiring the ASP.NET Core runtime to be installed | | [Sample.Grpc](Sample.Grpc/Program.cs) | ASP.NET Core application that publishes a gRPC service | | [Sample.Grpc.Client](Sample.Grpc.Client/Program.cs) | Client app for the above | +| [Sample.NetStandard](Sample.NetStandard/ImportantProcess.cs) | Demonstrates how to reference prometheus-net in a .NET Standard class library | | [Sample.Web.DifferentPort](Sample.Web.DifferentPort/Program.cs) | Demonstrates how to set up the metric exporter on a different port from the main web API (e.g. for security purposes) | -| [Sample.Web.MetricExpiration](Sample.Web.MetricExpiration/Program.cs) | Demonstrates how to use [automatic metric unpublishing](#unpublishing-metrics) | +| [Sample.Web.MetricExpiration](Sample.Web.MetricExpiration/Program.cs) | Demonstrates how to use [automatic metric deletion](#deleting-metrics) | | [Sample.Web.NetFramework](Sample.Web.NetFramework/Global.asax.cs) | .NET Framework web app that publishes custom metrics | The rest of this document describes how to use individual features of the library. @@ -317,6 +321,112 @@ requestsHandled.WithLabels("404").Inc(); requestsHandled.WithLabels("200").Inc(); ``` +# Exemplars + +Exemplars facilitate [distributed tracing](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/distributed-tracing-concepts), by attaching related trace IDs to metrics. This enables a metrics visualization app to cross-reference [traces](https://opentelemetry.io/docs/concepts/signals/traces/) that explain how the metric got the value it has. + +![](Exemplars.png) + +See also, [Grafana fundamentals - introduction to exemplars](https://grafana.com/docs/grafana/latest/fundamentals/exemplars/). + +By default, prometheus-net will create an exemplar with the `trace_id` and `span_id` labels based on the current distributed tracing context (`Activity.Current`). If using OpenTelemetry tracing with ASP.NET Core, the `traceparent` HTTP request header will be used to automatically assign `Activity.Current`. + +```csharp +private static readonly Counter TotalSleepTime = Metrics + .CreateCounter("sample_sleep_seconds_total", "Total amount of time spent sleeping."); +... + +// You only need to create the Activity if one is not automatically assigned (e.g. by ASP.NET Core). +using (var activity = new Activity("Pausing before record processing").Start()) +{ + var sleepStopwatch = Stopwatch.StartNew(); + await Task.Delay(TimeSpan.FromSeconds(1)); + + // The trace_id and span_id from the current Activity are exposed as the exemplar. + TotalSleepTime.Inc(sleepStopwatch.Elapsed.TotalSeconds); +} +``` + +This will be published as the following metric point: + +``` +sample_sleep_seconds_total 251.03833569999986 # {trace_id="08ad1c8cec52bf5284538abae7e6d26a",span_id="4761a4918922879b"} 1.0010688 1672634812.125 +``` + +You can override any default exemplar logic by providing your own exemplar when updating the value of the metric: + +```csharp +private static readonly Counter RecordsProcessed = Metrics + .CreateCounter("sample_records_processed_total", "Total number of records processed."); + +// The key from an exemplar key-value pair should be created once and reused to minimize memory allocations. +private static readonly Exemplar.LabelKey RecordIdKey = Exemplar.Key("record_id"); +... + +foreach (var record in recordsToProcess) +{ + var exemplar = Exemplar.From(RecordIdKey.WithValue(record.Id.ToString())); + RecordsProcessed.Inc(exemplar); +} +``` + +> **Warning** +> Exemplars are limited to 128 ASCII characters (counting both keys and values) - they are meant to contain IDs for cross-referencing with trace databases, not as a replacement for trace databases. + +Exemplars are only published if the metrics are being scraped by an OpenMetrics-capable client. For development purposes, you can force the library to use the OpenMetrics exposition format by adding `?accept=application/openmetrics-text` to the `/metrics` URL. + +> **Note** +> The Prometheus database automatically negotiates OpenMetrics support when scraping metrics - you do not need to apply any special scraping configuration in production scenarios. You may need to [enable exemplar storage](https://prometheus.io/docs/prometheus/latest/feature_flags/#exemplars-storage), though. + +See also, [Sample.Console.Exemplars](Sample.Console.Exemplars/Program.cs). + +# Limiting exemplar volume + +Exemplars can be expensive to store in the metrics database. For this reason, it can be useful to only record exemplars for "interesting" metric values. + +You can use `ExemplarBehavior.NewExemplarMinInterval` to define a minimum interval between exemplars - a new exemplar will only be recorded if this much time has passed. This can be useful to limit the rate of publishing unique exemplars. + +You can customize the default exemplar provider via `IMetricFactory.ExemplarBehavior` or `CounterConfiguration.ExemplarBehavior` and `HistogramConfiguration.ExemplarBehavior`, which allows you to provide your own method to generate exemplars and to filter which values/metrics exemplars are recorded for: + +Example of a custom exemplar provider used together with exemplar rate limiting: + +```csharp +// For the next histogram we only want to record exemplars for values larger than 0.1 (i.e. when record processing goes slowly). +static Exemplar RecordExemplarForSlowRecordProcessingDuration(Collector metric, double value) +{ + if (value < 0.1) + return Exemplar.None; + + return Exemplar.FromTraceContext(); +} + +var recordProcessingDuration = Metrics + .CreateHistogram("sample_record_processing_duration_seconds", "How long it took to process a record, in seconds.", + new HistogramConfiguration + { + Buckets = Histogram.PowersOfTenDividedBuckets(-4, 1, 5), + ExemplarBehavior = new() + { + DefaultExemplarProvider = RecordExemplarForSlowRecordProcessingDuration, + // Even if we have interesting data more often, do not record it to conserve exemplar storage. + NewExemplarMinInterval = TimeSpan.FromMinutes(5) + } + }); +``` + +For the ASP.NET Core HTTP server metrics, you can further fine-tune exemplar recording by inspecting the HTTP request and response: + +```csharp +app.UseHttpMetrics(options => +{ + options.ConfigureMeasurements(measurementOptions => + { + // Only measure exemplar if the HTTP response status code is not "OK". + measurementOptions.ExemplarPredicate = context => context.Response.StatusCode != HttpStatusCode.Ok; + }); +}); +``` + # When are metrics published? Metrics without labels are published immediately after the `Metrics.CreateX()` call. Metrics that use labels are published when you provide the label values for the first time. @@ -337,13 +447,13 @@ private static readonly Gauge UsersLoggedIn = Metrics UsersLoggedIn.Set(LoadSessions().Count); ``` -You can also use `.Publish()` on a metric to mark it as ready to be published without modifying the initial value (e.g. to publish a zero). +You can also use `.Publish()` on a metric to mark it as ready to be published without modifying the initial value (e.g. to publish a zero). Conversely, you can use `.Unpublish()` to hide a metric temporarily. Note that the metric remains in memory and retains its value. -# Unpublishing metrics +# Deleting metrics -You can use `.Dispose()` or `.RemoveLabelled()` methods on the metric classes to manually unpublish metrics at any time. +You can use `.Dispose()` or `.RemoveLabelled()` methods on the metric classes to manually delete metrics at any time. -In some situations, it can be hard to determine when a metric with a specific set of labels becomes irrelevant and needs to be unpublished. The library provides some assistance here by enabling automatic expiration of metrics when they are no longer used. +In some situations, it can be hard to determine when a metric with a specific set of labels becomes irrelevant and needs to be removed. The library provides some assistance here by enabling automatic expiration of metrics when they are no longer used. To enable automatic expiration, create the metrics via the metric factory returned by `Metrics.WithManagedLifetime()`. All such metrics will have a fixed expiration time, with the expiration restarting based on certain conditions that indicate the metric is in use. @@ -355,7 +465,7 @@ var factory = Metrics.WithManagedLifetime(expiresAfter: TimeSpan.FromMinutes(5)) // With expiring metrics, we get back handles to the metric, not the metric directly. var inProgressHandle = expiringMetricFactory .CreateGauge("documents_in_progress", "Number of documents currently being processed.", - // Automatic unpublishing only makes sense if we have a high/unknown cardinality label set, + // Automatic metric deletion only makes sense if we have a high/unknown cardinality label set, // so here is a sample label for each "document provider", whoever that may be. labelNames: new[] { "document_provider" }); @@ -363,7 +473,7 @@ var inProgressHandle = expiringMetricFactory public void ProcessDocument(string documentProvider) { - // Automatic unpublishing will not occur while this lease is held. + // Automatic metric deletion will not occur while this lease is held. // This will also reset any existing expiration timer for this document provider. inProgressHandle.WithLease(metric => { @@ -383,7 +493,7 @@ var factory = Metrics.WithManagedLifetime(expiresAfter: TimeSpan.FromMinutes(5)) // With expiring metrics, we get back handles to the metric, not the metric directly. var processingStartedHandle = expiringMetricFactory .CreateGauge("documents_started_processing_total", "Number of documents for which processing has started.", - // Automatic unpublishing only makes sense if we have a high/unknown cardinality label set, + // Automatic metric deletion only makes sense if we have a high/unknown cardinality label set, // so here is a sample label for each "document provider", whoever that may be. labelNames: new[] { "document_provider" }); @@ -463,7 +573,7 @@ public void Configure(IApplicationBuilder app, ...) By default, metrics are collected separately for each response status code (200, 201, 202, 203, ...). You can considerably reduce the size of the data set by only preserving information about the first digit of the status code: -``` +```csharp app.UseHttpMetrics(options => { // This will preserve only the first digit of the status code. @@ -533,20 +643,22 @@ The exposed metrics include: * Duration of HTTP client requests (from start of request to end of reading response headers). * Duration of HTTP client responses (from start of request to end of reading response body). -Example `Startup.cs` modification to enable these metrics: +Example `Startup.cs` modification to enable these metrics for all HttpClients registered in the service collection: ```csharp public void ConfigureServices(IServiceCollection services) { // ... - services.AddHttpClient(Options.DefaultName) - .UseHttpClientMetrics(); + services.UseHttpClientMetrics(); // ... } ``` +> **Note** +> You can also register HTTP client metrics only for a specific HttpClient by calling `services.AddHttpClient(...).UseHttpClientMetrics()`. + See also, [Sample.Web](Sample.Web/Program.cs). # ASP.NET Core health check status metrics @@ -742,6 +854,8 @@ The level of detail obtained from this is rather low - only the total count for You can configure the integration using `Metrics.ConfigureEventCounterAdapter()`. +By default, prometheus-net will only publish [the well-known .NET EventCounters](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/available-counters) to minimize resource consumption in the default configuration. A custom event source filter must be provided in the configuration to enable publishing of additional event counters. + See also, [Sample.Console](Sample.Console/Program.cs). # .NET Meters integration @@ -757,46 +871,27 @@ See also, [Sample.Console.DotNetMeters](Sample.Console.DotNetMeters/Program.cs). # Benchmarks -A suite of benchmarks is included if you wish to explore the performance characteristics of the app. Simply build and run the `Benchmarks.NetCore` project in Release mode. - -As an example of the performance of measuring data using prometheus-net, we have the results of the MeasurementBenchmarks here: - -``` -BenchmarkDotNet=v0.13.2, OS=Windows 10 (10.0.19044.2006/21H2/November2021Update) -AMD Ryzen 9 5950X, 1 CPU, 32 logical and 16 physical cores -.NET SDK=7.0.100-rc.1.22431.12 - [Host] : .NET 6.0.9 (6.0.922.41905), X64 RyuJIT AVX2 - -| MeasurementCount | ThreadCount | TargetMetricType | Mean | Lock Contentions | Allocated | -|------------------|-------------|------------------|---------------:|-----------------:|----------:| -| 100000 | 1 | Counter | 406.4 us | - | 480 B | -| 100000 | 1 | Gauge | 207.8 us | - | 480 B | -| 100000 | 1 | Histogram | 1,416.5 us | - | 480 B | -| 100000 | 1 | Summary | 42,601.8 us | - | 480 B | -| 100000 | 16 | Counter | 176,601.2 us | 13.0000 | 480 B | -| 100000 | 16 | Gauge | 31,241.0 us | 14.0000 | 480 B | -| 100000 | 16 | Histogram | 179,327.9 us | 14.0000 | 480 B | -| 100000 | 16 | Summary | 1,017,871.1 us | 10332.0000 | 480 B | -``` +A suite of benchmarks is included if you wish to explore the performance characteristics of the library. Simply build and run the `Benchmarks.NetCore` project in Release mode. -> **Note** -> The 480 byte allocation is benchmark harness overhead. Metric measurements do not allocate memory. +As an example of the performance of measuring data using prometheus-net, we have the results of the MeasurementBenchmarks here, converted into measurements per second: -Converting this to more everyday units: +| Metric type | Measurements per second | +|-------------------------|------------------------:| +| Counter | 261 million | +| Gauge | 591 million | +| Histogram (16 buckets) | 105 million | +| Histogram (128 buckets) | 65 million | -| Metric type | Concurrency | Measurements per second | -|-------------|------------:|------------------------:| -| Counter | 1 thread | 246 million | -| Gauge | 1 thread | 481 million | -| Histogram | 1 thread | 71 million | -| Summary | 1 thread | 2 million | -| Counter | 16 threads | 9 million | -| Gauge | 16 threads | 51 million | -| Histogram | 16 threads | 9 million | -| Summary | 16 threads | 2 million | +Another popular .NET SDK with Prometheus support is the OpenTelemetry SDK. To help you choose, we have [SdkComparisonBenchmarks.cs](Benchmark.NetCore/SdkComparisonBenchmarks.cs) to compare the two SDKs and give some idea of how they differer in the performance tradeoffs made. Both SDKs are evaluated in single-threaded mode under a comparable workload and enabled feature set. A representative result is here: -> **Note** -> All measurements on all threads are recorded by the same metric instance, for maximum stress and concurrent load. If you have more than 1 metric in your app, multithreaded performance will likely be similar to single-threaded performance. +| SDK | Benchmark scenario | CPU time | Memory | +|----------------|---------------------------------------|---------:|-------:| +| prometheus-net | Counter (existing timeseries) x100K | 230 µs | None | +| OpenTelemetry | Counter (existing timeseries) x100K | 10998 µs | None | +| prometheus-net | Histogram (existing timeseries) x100K | 957 µs | None | +| OpenTelemetry | Histogram (existing timeseries) x100K | 12110 µs | None | +| prometheus-net | Histogram (new timeseries) x1K | 716 µs | 664 KB | +| OpenTelemetry | Histogram (new timeseries) x1K | 350 µs | 96 KB | # Community projects diff --git a/Resources/Nuspec/Merge-ReleaseNotes.ps1 b/Resources/Nuspec/Merge-ReleaseNotes.ps1 deleted file mode 100644 index 53de7c84..00000000 --- a/Resources/Nuspec/Merge-ReleaseNotes.ps1 +++ /dev/null @@ -1,14 +0,0 @@ -$ErrorActionPreference = "Stop" - -# This script merges the History file into the NuSpec (using release agent paths). -# It just exists to enable maintaining the history as a plain text file, not hidden away somewhere. - -# Path as it exists on the release agent. -$historyPath = Join-Path $PSScriptRoot "..\History\History" -$releaseNotes = [IO.File]::ReadAllText($historyPath) - -foreach ($nuspec in Get-ChildItem -Path $PSScriptRoot -Filter *.nuspec) { - $content = [IO.File]::ReadAllText($nuspec.FullName) - $content = $content.Replace("", "$releaseNotes") - [IO.File]::WriteAllText($nuspec.FullName, $content) -} \ No newline at end of file diff --git a/Resources/Nuspec/prometheus-net.AspNetCore.Grpc.nuspec b/Resources/Nuspec/prometheus-net.AspNetCore.Grpc.nuspec deleted file mode 100644 index bb60c364..00000000 --- a/Resources/Nuspec/prometheus-net.AspNetCore.Grpc.nuspec +++ /dev/null @@ -1,30 +0,0 @@ - - - - prometheus-net.AspNetCore.Grpc - sandersaares - ASP.NET Core gRPC integration with Prometheus - https://github.com/prometheus-net/prometheus-net - MIT - metrics prometheus aspnetcore grpc - Copyright © prometheus-net developers - images/prometheus-net-logo.png - - - __NUGETPACKAGEVERSION__ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Resources/Nuspec/prometheus-net.AspNetCore.HealthChecks.nuspec b/Resources/Nuspec/prometheus-net.AspNetCore.HealthChecks.nuspec deleted file mode 100644 index 0b3b94ba..00000000 --- a/Resources/Nuspec/prometheus-net.AspNetCore.HealthChecks.nuspec +++ /dev/null @@ -1,29 +0,0 @@ - - - - prometheus-net.AspNetCore.HealthChecks - sandersaares - ASP.NET Core Health Checks integration with Prometheus - https://github.com/prometheus-net/prometheus-net - MIT - metrics prometheus aspnetcore healthchecks - Copyright © prometheus-net developers - images/prometheus-net-logo.png - - - __NUGETPACKAGEVERSION__ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Resources/Nuspec/prometheus-net.AspNetCore.nuspec b/Resources/Nuspec/prometheus-net.AspNetCore.nuspec deleted file mode 100644 index 5180625f..00000000 --- a/Resources/Nuspec/prometheus-net.AspNetCore.nuspec +++ /dev/null @@ -1,36 +0,0 @@ - - - - prometheus-net.AspNetCore - andrasm,qed-,lakario,sandersaares - ASP.NET Core middleware and stand-alone Kestrel server for exporting metrics to Prometheus - https://github.com/prometheus-net/prometheus-net - MIT - metrics prometheus aspnetcore - Copyright © prometheus-net developers - images/prometheus-net-logo.png - - - __NUGETPACKAGEVERSION__ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Resources/Nuspec/prometheus-net.NetFramework.AspNet.nuspec b/Resources/Nuspec/prometheus-net.NetFramework.AspNet.nuspec deleted file mode 100644 index cab98a17..00000000 --- a/Resources/Nuspec/prometheus-net.NetFramework.AspNet.nuspec +++ /dev/null @@ -1,32 +0,0 @@ - - - - prometheus-net.NetFramework.AspNet - sandersaares - ASP.NET Web API exporter for Prometheus - https://github.com/prometheus-net/prometheus-net - MIT - metrics prometheus aspnet webapi - Copyright © prometheus-net developers - images/prometheus-net-logo.png - - - __NUGETPACKAGEVERSION__ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Resources/Nuspec/prometheus-net.nuspec b/Resources/Nuspec/prometheus-net.nuspec deleted file mode 100644 index 257eb7ef..00000000 --- a/Resources/Nuspec/prometheus-net.nuspec +++ /dev/null @@ -1,35 +0,0 @@ - - - - prometheus-net - andrasm,qed-,lakario,sandersaares - .NET client library for the Prometheus monitoring and alerting system - https://github.com/prometheus-net/prometheus-net - MIT - metrics prometheus - Copyright © prometheus-net developers - images/prometheus-net-logo.png - - - __NUGETPACKAGEVERSION__ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Resources/SolutionAssemblyInfo.cs b/Resources/SolutionAssemblyInfo.cs index 5e93fced..1f300ba8 100644 --- a/Resources/SolutionAssemblyInfo.cs +++ b/Resources/SolutionAssemblyInfo.cs @@ -2,10 +2,10 @@ using System.Runtime.CompilerServices; // This is the real version number, used in NuGet packages and for display purposes. -[assembly: AssemblyFileVersion("7.0.0")] +[assembly: AssemblyFileVersion("8.2.1")] // Only use major version here, with others kept at zero, for correct assembly binding logic. -[assembly: AssemblyVersion("7.0.0")] +[assembly: AssemblyVersion("8.0.0")] [assembly: InternalsVisibleTo("Tests.NetFramework, PublicKey=002400000480000014010000060200000024000052534131000800000100010049b30b6bccc8311c8d5f9c006a5968b0592eca8b5a228e9e0a2ac0292e2a162ea3314b0f9941ffad9fe40a4071de2a0b6e4f50b70292d26081054f96df6a05e5a89a71538d50decaf8322f0cdd008e8e14d5e227b46c8c10a6cc850a5d7febf9ad5e0ffb8371e840744d3dd0cb88012ee61490a09d007fab29fc13fb0b4c2fb4d72692232546712b3e9e25a201e309bec907a9a241059d26f1826a337faf6e7a16902fc35e8dafeceff35a48622a9716af86138a1a064c879b7239a9495b8416abf63f8763a613e5be2e6b13403eb952c36008a281502bc2c89ca3367624b0791712f50674760fcbab2e7795fb6c53b0675f940d152ef449ad10463bce59a7d5")] [assembly: InternalsVisibleTo("Tests.NetCore, PublicKey=002400000480000014010000060200000024000052534131000800000100010049b30b6bccc8311c8d5f9c006a5968b0592eca8b5a228e9e0a2ac0292e2a162ea3314b0f9941ffad9fe40a4071de2a0b6e4f50b70292d26081054f96df6a05e5a89a71538d50decaf8322f0cdd008e8e14d5e227b46c8c10a6cc850a5d7febf9ad5e0ffb8371e840744d3dd0cb88012ee61490a09d007fab29fc13fb0b4c2fb4d72692232546712b3e9e25a201e309bec907a9a241059d26f1826a337faf6e7a16902fc35e8dafeceff35a48622a9716af86138a1a064c879b7239a9495b8416abf63f8763a613e5be2e6b13403eb952c36008a281502bc2c89ca3367624b0791712f50674760fcbab2e7795fb6c53b0675f940d152ef449ad10463bce59a7d5")] diff --git a/Resources/Nuspec/prometheus-net-logo.png b/Resources/prometheus-net-logo.png similarity index 100% rename from Resources/Nuspec/prometheus-net-logo.png rename to Resources/prometheus-net-logo.png diff --git a/Sample.Console.DotNetMeters/CustomDotNetMeters.cs b/Sample.Console.DotNetMeters/CustomDotNetMeters.cs index 86b5e00e..296c9d36 100644 --- a/Sample.Console.DotNetMeters/CustomDotNetMeters.cs +++ b/Sample.Console.DotNetMeters/CustomDotNetMeters.cs @@ -32,7 +32,7 @@ IEnumerable> ObserveGrossNestsAll() var histogram1 = meter1.CreateHistogram("bytes-considered", "bytes", "Informs about all the bytes considered."); // .NET 7: Example metric: an up/down counter. - /*var upDown1 = meter1.CreateUpDownCounter("water-level", "brick-heights", "Current water level in the tank (measured in visible bricks from the midpoint)."); + var upDown1 = meter1.CreateUpDownCounter("water-level", "brick-heights", "Current water level in the tank (measured in visible bricks from the midpoint)."); // Example metric: an observable up/down counter. int sandLevel = 0; @@ -43,7 +43,7 @@ int MeasureSandLevel() return sandLevel; } - var upDown2 = meter1.CreateObservableUpDownCounter("sand-level", MeasureSandLevel, "chainlinks", "Current sand level in the tank (measured in visible chain links from the midpoint).");*/ + var upDown2 = meter1.CreateObservableUpDownCounter("sand-level", MeasureSandLevel, "chainlinks", "Current sand level in the tank (measured in visible chain links from the midpoint)."); // Example high cardinality metric: bytes sent per connection. var highCardinalityCounter1 = meter1.CreateCounter("bytes-sent", "bytes", "Bytes sent per connection."); @@ -65,15 +65,18 @@ int MeasureSandLevel() if (Random.Shared.Next(10) == 0) counter1.Add(1, new KeyValuePair("wing-type", "SlaxxWing 1.0"), new KeyValuePair("wing-version", "beta")); - histogram1.Record((byte)(Random.Shared.Next(256)), new KeyValuePair("is-faulted", true)); + // is-faulted here conflicts with the static label of the same name and gets overwritten by the static label. + histogram1.Record((byte)(Random.Shared.Next(256)), new KeyValuePair("is-faulted", true), new KeyValuePair("canbus_ver", "1.0")); - // .NET 7: upDown1.Add(Random.Shared.Next(-1, 2)); + // .NET 7 + upDown1.Add(Random.Shared.Next(-1, 2)); // Add some bytes for every active connection. foreach (var connection in activeConnections) highCardinalityCounter1.Add(Random.Shared.Next(10_000_000), new KeyValuePair("connection-id", connection)); // Maybe some connection went away, maybe some was added. + // Timeseries that stop receiving updates will disappear from prometheus-net output after a short delay (up to 10 minutes by default). if (Random.Shared.Next(100) == 0) { activeConnections.RemoveAt(Random.Shared.Next(activeConnections.Count)); diff --git a/Sample.Console.DotNetMeters/Program.cs b/Sample.Console.DotNetMeters/Program.cs index 67ed74e6..1137ca6c 100644 --- a/Sample.Console.DotNetMeters/Program.cs +++ b/Sample.Console.DotNetMeters/Program.cs @@ -9,7 +9,8 @@ Metrics.SuppressDefaultMetrics(new SuppressDefaultMetricOptions { SuppressProcessMetrics = true, - SuppressEventCounters = true + SuppressEventCounters = true, + SuppressDebugMetrics = true }); // Example of static labels that conflict with .NET Meters API labels ("Bytes considered" histogram). @@ -23,7 +24,7 @@ using var server = new KestrelMetricServer(port: 1234); server.Start(); -// Start publishing sample data via .NET Meters API. Data from this API is published by default by prometheus-net. +// Start publishing sample data via .NET Meters API. All data from the .NET Meters API is published by default. CustomDotNetMeters.PublishSampleData(); // Metrics published in this sample: diff --git a/Sample.Console.DotNetMeters/Sample.Console.DotNetMeters.csproj b/Sample.Console.DotNetMeters/Sample.Console.DotNetMeters.csproj index 4fb63234..0ae9263f 100644 --- a/Sample.Console.DotNetMeters/Sample.Console.DotNetMeters.csproj +++ b/Sample.Console.DotNetMeters/Sample.Console.DotNetMeters.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net7.0 enable enable diff --git a/Sample.Console.Exemplars/Program.cs b/Sample.Console.Exemplars/Program.cs new file mode 100644 index 00000000..3a9e6f6b --- /dev/null +++ b/Sample.Console.Exemplars/Program.cs @@ -0,0 +1,90 @@ +using Prometheus; +using System.Diagnostics; + +// This sample demonstrates how to attach exemplars to metrics exposed by a .NET console app. +// +// NuGet packages required: +// * prometheus-net.AspNetCore + +// Suppress some default metrics to make the output cleaner, so the exemplars are easier to see. +Metrics.SuppressDefaultMetrics(new SuppressDefaultMetricOptions +{ + SuppressEventCounters = true, + SuppressMeters = true, + SuppressProcessMetrics = true +}); + +// Start the metrics server on your preferred port number. +using var server = new KestrelMetricServer(port: 1234); +server.Start(); + +// Generate some sample data from fake business logic. +var recordsProcessed = Metrics.CreateCounter("sample_records_processed_total", "Total number of records processed."); +var recordSizeInPages = Metrics.CreateHistogram("sample_record_size_pages", "Size of a record, in pages.", new HistogramConfiguration +{ + Buckets = Histogram.PowersOfTenDividedBuckets(0, 2, 10) +}); + +// SAMPLED EXEMPLAR: For the next histogram we only want to record exemplars for values larger than 0.1 (i.e. when record processing goes slowly). +static Exemplar RecordExemplarForSlowRecordProcessingDuration(Collector metric, double value) +{ + if (value < 0.1) + return Exemplar.None; + + return Exemplar.FromTraceContext(); +} + +var recordProcessingDuration = Metrics.CreateHistogram("sample_record_processing_duration_seconds", "How long it took to process a record, in seconds.", new HistogramConfiguration +{ + Buckets = Histogram.PowersOfTenDividedBuckets(-4, 1, 5), + ExemplarBehavior = new() + { + DefaultExemplarProvider = RecordExemplarForSlowRecordProcessingDuration + } +}); + +var totalSleepTime = Metrics.CreateCounter("sample_sleep_seconds_total", "Total amount of time spent sleeping."); + +// CUSTOM EXEMPLAR: The key from an exemplar key-value pair should be created once and reused to minimize memory allocations. +var recordIdKey = Exemplar.Key("record_id"); + +_ = Task.Run(async delegate +{ + while (true) + { + // DEFAULT EXEMPLAR: We expose the trace_id and span_id for distributed tracing, based on Activity.Current. + // Activity.Current is often automatically inherited from incoming HTTP requests if using OpenTelemetry tracing with ASP.NET Core. + // Here, we manually create and start an activity for sample purposes, without relying on the platform managing the activity context. + // See https://learn.microsoft.com/en-us/dotnet/core/diagnostics/distributed-tracing-concepts + using (var activity = new Activity("Pausing before record processing").Start()) + { + var sleepStopwatch = Stopwatch.StartNew(); + await Task.Delay(TimeSpan.FromSeconds(1)); + + // The trace_id and span_id from the current Activity are exposed as the exemplar by default. + totalSleepTime.Inc(sleepStopwatch.Elapsed.TotalSeconds); + } + + using var processingDurationTimer = recordProcessingDuration.NewTimer(); + + // Pretend to process a record approximately every second, just for changing sample data. + var recordId = Guid.NewGuid(); + var recordPageCount = Random.Shared.Next(minValue: 5, maxValue: 100); + + // CUSTOM EXEMPLAR: We pass the record ID key-value pair when we increment the metric. + // When the metric data is published to Prometheus, the most recent record ID will be attached to it. + var exemplar = Exemplar.From(recordIdKey.WithValue(recordId.ToString())); + + // Note that one Exemplar object can only be used once. You must clone it to reuse it. + recordsProcessed.Inc(exemplar.Clone()); + recordSizeInPages.Observe(recordPageCount, exemplar); + } +}); + +// Metrics published in this sample: +// * the custom sample metrics defined above, with exemplars +// * internal debug metrics from prometheus-net, without exemplars +// Note that the OpenMetrics exposition format must be selected via HTTP header or query string parameter to see exemplars. +Console.WriteLine("Open http://localhost:1234/metrics?accept=application/openmetrics-text in a web browser."); +Console.WriteLine("Press enter to exit."); +Console.ReadLine(); \ No newline at end of file diff --git a/Sample.Console.Exemplars/Sample.Console.Exemplars.csproj b/Sample.Console.Exemplars/Sample.Console.Exemplars.csproj new file mode 100644 index 00000000..62836eb9 --- /dev/null +++ b/Sample.Console.Exemplars/Sample.Console.Exemplars.csproj @@ -0,0 +1,21 @@ + + + + Exe + net6.0 + + enable + enable + True + True + 1591 + + latest + 9999 + + + + + + + diff --git a/Sample.Console.NoAspNetCore/Program.cs b/Sample.Console.NoAspNetCore/Program.cs index ce21bbbc..cd2ae775 100644 --- a/Sample.Console.NoAspNetCore/Program.cs +++ b/Sample.Console.NoAspNetCore/Program.cs @@ -38,7 +38,7 @@ // Metrics published in this sample: // * built-in process metrics giving basic information about the .NET runtime (enabled by default) -// * metrics from .NET Event Counters (enabled by default) +// * metrics from .NET Event Counters (enabled by default, updated every 10 seconds) // * metrics from .NET Meters (enabled by default) // * the custom sample counter defined above Console.WriteLine("Open http://localhost:1234/metrics in a web browser."); diff --git a/Sample.Console/Program.cs b/Sample.Console/Program.cs index 3c0d6ffe..cf30871a 100644 --- a/Sample.Console/Program.cs +++ b/Sample.Console/Program.cs @@ -25,8 +25,9 @@ // Metrics published in this sample: // * built-in process metrics giving basic information about the .NET runtime (enabled by default) -// * metrics from .NET Event Counters (enabled by default) +// * metrics from .NET Event Counters (enabled by default, updated every 10 seconds) // * metrics from .NET Meters (enabled by default) +// * prometheus-net self-inspection metrics that indicate number of registered metrics/timeseries (enabled by default) // * the custom sample counter defined above Console.WriteLine("Open http://localhost:1234/metrics in a web browser."); Console.WriteLine("Press enter to exit."); diff --git a/Sample.Grpc/Program.cs b/Sample.Grpc/Program.cs index a093dee6..d78de82f 100644 --- a/Sample.Grpc/Program.cs +++ b/Sample.Grpc/Program.cs @@ -28,8 +28,9 @@ // // Metrics published in this sample: // * built-in process metrics giving basic information about the .NET runtime (enabled by default) - // * metrics from .NET Event Counters (enabled by default) + // * metrics from .NET Event Counters (enabled by default, updated every 10 seconds) // * metrics from .NET Meters (enabled by default) + // * prometheus-net self-inspection metrics that indicate number of registered metrics/timeseries (enabled by default) // * metrics about HTTP requests handled by the web app (configured above) // * metrics about gRPC requests handled by the web app (configured above) app.MapMetrics(); diff --git a/Sample.NetStandard/ImportantProcess.cs b/Sample.NetStandard/ImportantProcess.cs new file mode 100644 index 00000000..9f96d77c --- /dev/null +++ b/Sample.NetStandard/ImportantProcess.cs @@ -0,0 +1,21 @@ +using Prometheus; + +namespace Sample.NetStandard; + +public static class ImportantProcess +{ + public static void Start() + { + _ = Task.Run(async delegate + { + while (true) + { + ImportantCounter.Inc(); + + await Task.Delay(TimeSpan.FromSeconds(0.1)); + } + }); + } + + private static readonly Counter ImportantCounter = Metrics.CreateCounter("sample_important_counter", "Counts up and up and up!"); +} diff --git a/Sample.NetStandard/Sample.NetStandard.csproj b/Sample.NetStandard/Sample.NetStandard.csproj new file mode 100644 index 00000000..fb1cf430 --- /dev/null +++ b/Sample.NetStandard/Sample.NetStandard.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + + enable + enable + True + True + 1591 + + latest + 9999 + + + + + + + diff --git a/Sample.Web.DifferentPort/Program.cs b/Sample.Web.DifferentPort/Program.cs index ebe86f8c..794fee35 100644 --- a/Sample.Web.DifferentPort/Program.cs +++ b/Sample.Web.DifferentPort/Program.cs @@ -16,7 +16,7 @@ // // Metrics published: // * built-in process metrics giving basic information about the .NET runtime (enabled by default) -// * metrics from .NET Event Counters (enabled by default) +// * metrics from .NET Event Counters (enabled by default, updated every 10 seconds) // * metrics from .NET Meters (enabled by default) // * metrics about requests handled by the web app (configured below) builder.Services.AddMetricServer(options => diff --git a/Sample.Web.MetricExpiration/Program.cs b/Sample.Web.MetricExpiration/Program.cs index fd31256f..c8459e53 100644 --- a/Sample.Web.MetricExpiration/Program.cs +++ b/Sample.Web.MetricExpiration/Program.cs @@ -24,7 +24,7 @@ app.UseRouting(); -// Use an auto-expiring variant for all the demo metrics here - they get automatically unpublished if not used in the last 60 seconds. +// Use an auto-expiring variant for all the demo metrics here - they get automatically deleted if not used in the last 60 seconds. var expiringMetricFactory = Metrics.WithManagedLifetime(expiresAfter: TimeSpan.FromSeconds(60)); // OPTION 1: metric lifetime can be managed by leases, to ensure they do not go away during potentially @@ -33,7 +33,7 @@ { var inProgress = expiringMetricFactory.CreateGauge("long_running_operations_in_progress", "Number of long running operations in progress.", labelNames: new[] { "operation_type" }); - // The metric will not be unpublished as long as this lease is kept. + // The metric will not be deleted as long as this lease is kept. await inProgress.WithLeaseAsync(async inProgressInstance => { // Long-running operation, which we track via the "in progress" gauge. diff --git a/Sample.Web.NetFramework/Web.config b/Sample.Web.NetFramework/Web.config index 5ad0b860..1a4f9c74 100644 --- a/Sample.Web.NetFramework/Web.config +++ b/Sample.Web.NetFramework/Web.config @@ -1,67 +1,75 @@ - + - - - - + + + + - - + + - - - - + + + + + + + + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - \ No newline at end of file + diff --git a/Sample.Web/Program.cs b/Sample.Web/Program.cs index 451322cc..9eff1f3c 100644 --- a/Sample.Web/Program.cs +++ b/Sample.Web/Program.cs @@ -12,7 +12,10 @@ builder.Services.AddRazorPages(); // Define an HTTP client that reports metrics about its usage, to be used by a sample background service. -builder.Services.AddHttpClient(SampleService.HttpClientName).UseHttpClientMetrics(); +builder.Services.AddHttpClient(SampleService.HttpClientName); + +// Export metrics from all HTTP clients registered in services +builder.Services.UseHttpClientMetrics(); // A sample service that uses the above HTTP client. builder.Services.AddHostedService(); @@ -47,7 +50,7 @@ // // Metrics published in this sample: // * built-in process metrics giving basic information about the .NET runtime (enabled by default) - // * metrics from .NET Event Counters (enabled by default) + // * metrics from .NET Event Counters (enabled by default, updated every 10 seconds) // * metrics from .NET Meters (enabled by default) // * metrics about requests made by registered HTTP clients used in SampleService (configured above) // * metrics about requests handled by the web app (configured above) diff --git a/Sample.Web/SampleService.cs b/Sample.Web/SampleService.cs index 4f5ce248..14249097 100644 --- a/Sample.Web/SampleService.cs +++ b/Sample.Web/SampleService.cs @@ -56,28 +56,30 @@ private async Task ReadySetGoAsync(CancellationToken cancel) var googleTask = Task.Run(async delegate { - await httpClient.GetAsync(googleUrl, cancel); + using var response = await httpClient.GetAsync(googleUrl, cancel); googleStopwatch.Stop(); }, cancel); var microsoftTask = Task.Run(async delegate { - await httpClient.GetAsync(microsoftUrl, cancel); + using var response = await httpClient.GetAsync(microsoftUrl, cancel); microsoftStopwatch.Stop(); }, cancel); await Task.WhenAll(googleTask, microsoftTask); + var exemplar = Exemplar.From(Exemplar.Pair("traceID", "1234")); + // Determine the winner and report the change in score. if (googleStopwatch.Elapsed < microsoftStopwatch.Elapsed) { - WinsByEndpoint.WithLabels(googleUrl).Inc(); - LossesByEndpoint.WithLabels(microsoftUrl).Inc(); + WinsByEndpoint.WithLabels(googleUrl).Inc(exemplar); + LossesByEndpoint.WithLabels(microsoftUrl).Inc(exemplar); } - else if (googleStopwatch.Elapsed < microsoftStopwatch.Elapsed) + else if (googleStopwatch.Elapsed > microsoftStopwatch.Elapsed) { - WinsByEndpoint.WithLabels(microsoftUrl).Inc(); - LossesByEndpoint.WithLabels(googleUrl).Inc(); + WinsByEndpoint.WithLabels(microsoftUrl).Inc(exemplar); + LossesByEndpoint.WithLabels(googleUrl).Inc(exemplar); } else { @@ -86,7 +88,7 @@ private async Task ReadySetGoAsync(CancellationToken cancel) // Report the difference. var difference = Math.Abs(googleStopwatch.Elapsed.TotalSeconds - microsoftStopwatch.Elapsed.TotalSeconds); - Difference.Observe(difference); + Difference.Observe(difference, exemplar: exemplar); // We finished one iteration of the service's work. IterationCount.Inc(); diff --git a/Sample.Web/run_prometheus_scrape.sh b/Sample.Web/run_prometheus_scrape.sh new file mode 100644 index 00000000..a499e60b --- /dev/null +++ b/Sample.Web/run_prometheus_scrape.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Runs a prometheus scrape on an endpoint with exemplar storage enabled. localhost:9090 will take you to the prometheus +# ui for verification. + +cat < prometheus.yml +scrape_configs: + - job_name: 'prometheus-net' + scrape_interval: 5s + static_configs: + - targets: ['$1'] +EOF + +docker run --rm -it --name prometheus -p 9090:9090 \ + -v $PWD/prometheus.yml:/etc/prometheus/prometheus.yml \ + prom/prometheus \ + --config.file=/etc/prometheus/prometheus.yml \ + --log.level=debug \ + --enable-feature=exemplar-storage \ No newline at end of file diff --git a/Tester.NetFramework.AspNet/Web.config b/Tester.NetFramework.AspNet/Web.config index 41fd9284..bee5b6c5 100644 --- a/Tester.NetFramework.AspNet/Web.config +++ b/Tester.NetFramework.AspNet/Web.config @@ -32,6 +32,10 @@ + + + + diff --git a/Tests.NetCore/BreakableDelayer.cs b/Tests.NetCore/BreakableDelayer.cs index 701525b4..04e2cc99 100644 --- a/Tests.NetCore/BreakableDelayer.cs +++ b/Tests.NetCore/BreakableDelayer.cs @@ -2,77 +2,76 @@ using System.Threading; using System.Threading.Tasks; -namespace Prometheus.Tests +namespace Prometheus.Tests; + +/// +/// A delayer that seems to work as usual except it can be instructed to end all waits immediately. +/// +/// +/// Thread-safe. +/// +public sealed class BreakableDelayer : IDelayer { /// - /// A delayer that seems to work as usual except it can be instructed to end all waits immediately. + /// Ends all delays and pretends the timers all elapsed. /// - /// - /// Thread-safe. - /// - public sealed class BreakableDelayer : IDelayer + public void BreakAllDelays() { - /// - /// Ends all delays and pretends the timers all elapsed. - /// - public void BreakAllDelays() + CancellationTokenSource old; + + lock (_lock) { - CancellationTokenSource old; + // Have to replace CTS first to ensure that any new calls get new CTS. + // Very important because canceling the CTS actually executes code until the next await. + old = _cts; + _cts = new CancellationTokenSource(); + } - lock (_lock) - { - // Have to replace CTS first to ensure that any new calls get new CTS. - // Very important because canceling the CTS actually executes code until the next await. - old = _cts; - _cts = new CancellationTokenSource(); - } + old.Cancel(); + old.Dispose(); + } - old.Cancel(); - old.Dispose(); - } + private CancellationTokenSource _cts = new CancellationTokenSource(); + private readonly object _lock = new object(); - private CancellationTokenSource _cts = new CancellationTokenSource(); - private readonly object _lock = new object(); + public async Task Delay(TimeSpan duration) + { + CancellationToken cancel; + + lock (_lock) + cancel = _cts.Token; - public async Task Delay(TimeSpan duration) + try { - CancellationToken cancel; + await Task.Delay(duration, cancel); + } + catch (TaskCanceledException) + { + } + } - lock (_lock) - cancel = _cts.Token; + public async Task Delay(TimeSpan duration, CancellationToken requestedCancel) + { + CancellationTokenSource callCts; + CancellationToken cancel; - try - { - await Task.Delay(duration, cancel); - } - catch (TaskCanceledException) - { - } + lock (_lock) + { + callCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, requestedCancel); + cancel = callCts.Token; } - public async Task Delay(TimeSpan duration, CancellationToken requestedCancel) + try { - CancellationTokenSource callCts; - CancellationToken cancel; - - lock (_lock) - { - callCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, requestedCancel); - cancel = callCts.Token; - } - - try - { - await Task.Delay(duration, cancel); - } - catch (TaskCanceledException) - { - requestedCancel.ThrowIfCancellationRequested(); - } - finally - { - callCts.Dispose(); - } + await Task.Delay(duration, cancel); + } + catch (TaskCanceledException) + { + requestedCancel.ThrowIfCancellationRequested(); + } + finally + { + callCts.Dispose(); } } } diff --git a/Tests.NetCore/CounterTests.cs b/Tests.NetCore/CounterTests.cs index f3a7fbb3..a6eb79c9 100644 --- a/Tests.NetCore/CounterTests.cs +++ b/Tests.NetCore/CounterTests.cs @@ -1,32 +1,118 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Prometheus.Tests +namespace Prometheus.Tests; + +[TestClass] +public class CounterTests { - [TestClass] - public class CounterTests + private CollectorRegistry _registry; + private MetricFactory _metrics; + + public CounterTests() + { + _registry = Metrics.NewCustomRegistry(); + _metrics = Metrics.WithCustomRegistry(_registry); + } + + [TestMethod] + public void IncTo_IncrementsButDoesNotDecrement() { - private CollectorRegistry _registry; - private MetricFactory _metrics; + var counter = _metrics.CreateCounter("xxx", "xxx"); + + counter.IncTo(100); + Assert.AreEqual(100, counter.Value); + + counter.IncTo(100); + Assert.AreEqual(100, counter.Value); + + counter.IncTo(10); + Assert.AreEqual(100, counter.Value); + } - public CounterTests() + [TestMethod] + public async Task ObserveExemplar_WithDefaultExemplarProvider_UsesDefaultOnlyWhenNoExplicitExemplarProvided() + { + const string defaultExemplarData = "this_is_the_default_exemplar"; + const string explicitExemplarData = "this_is_the_explicit_exemplar"; + + var counter = _metrics.CreateCounter("xxx", "", new CounterConfiguration { - _registry = Metrics.NewCustomRegistry(); - _metrics = Metrics.WithCustomRegistry(_registry); - } + ExemplarBehavior = new ExemplarBehavior + { + DefaultExemplarProvider = (_, _) => Exemplar.From(Exemplar.Pair(defaultExemplarData, defaultExemplarData)) + } + }); + + // No exemplar provided, expect to see default. + counter.Inc(); + + var serialized = await _registry.CollectAndSerializeToStringAsync(ExpositionFormat.OpenMetricsText); + StringAssert.Contains(serialized, defaultExemplarData); + + counter.Inc(Exemplar.From(Exemplar.Pair(explicitExemplarData, explicitExemplarData))); + + serialized = await _registry.CollectAndSerializeToStringAsync(ExpositionFormat.OpenMetricsText); + StringAssert.Contains(serialized, explicitExemplarData); + } - [TestMethod] - public void IncTo_IncrementsButDoesNotDecrement() + [TestMethod] + public async Task ObserveExemplar_WithLimitedRecordingInterval_RecordsOnlyAfterIntervalElapses() + { + const string firstData = "this_is_the_first_exemplar"; + const string secondData = "this_is_the_second_exemplar"; + const string thirdData = "this_is_the_third_exemplar"; + + var interval = TimeSpan.FromMinutes(5); + + var counter = _metrics.CreateCounter("xxx", "", new CounterConfiguration + { + ExemplarBehavior = new ExemplarBehavior + { + NewExemplarMinInterval = interval + } + }); + + double timestampSeconds = 0; + ChildBase.ExemplarRecordingTimestampProvider = () => timestampSeconds; + + try { - var counter = _metrics.CreateCounter("xxx", "xxx"); + counter.Inc(Exemplar.From(Exemplar.Pair(firstData, firstData))); + + var serialized = await _registry.CollectAndSerializeToStringAsync(ExpositionFormat.OpenMetricsText); + StringAssert.Contains(serialized, firstData); + + // Attempt to record a new exemplar immediately - should fail because interval has not elapsed. + counter.Inc(Exemplar.From(Exemplar.Pair(secondData, secondData))); + + serialized = await _registry.CollectAndSerializeToStringAsync(ExpositionFormat.OpenMetricsText); + StringAssert.Contains(serialized, firstData); - counter.IncTo(100); - Assert.AreEqual(100, counter.Value); + // Wait for enough time to elapse - now it should work. + timestampSeconds = interval.TotalSeconds; - counter.IncTo(100); - Assert.AreEqual(100, counter.Value); + counter.Inc(Exemplar.From(Exemplar.Pair(thirdData, thirdData))); - counter.IncTo(10); - Assert.AreEqual(100, counter.Value); + serialized = await _registry.CollectAndSerializeToStringAsync(ExpositionFormat.OpenMetricsText); + StringAssert.Contains(serialized, thirdData); } + finally + { + ChildBase.ExemplarRecordingTimestampProvider = ChildBase.DefaultExemplarRecordingTimestampProvider; + } + } + + [TestMethod] + public void ObserveExemplar_ReusingInstance_Throws() + { + var counter = _metrics.CreateCounter("xxx", ""); + + var exemplar = Exemplar.From(Exemplar.Pair("foo", "bar")); + + counter.Inc(exemplar); + + Assert.ThrowsException(() => counter.Inc(exemplar)); } } diff --git a/Tests.NetCore/FactoryLabelTests.cs b/Tests.NetCore/FactoryLabelTests.cs index 94baf170..bf6da23a 100644 --- a/Tests.NetCore/FactoryLabelTests.cs +++ b/Tests.NetCore/FactoryLabelTests.cs @@ -1,6 +1,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; namespace Prometheus.Tests @@ -91,5 +92,36 @@ public void WithLabels_WithEmptyLabelSet_IsNoop() Assert.AreEqual(2, counter1.Value); } + + // https://github.com/prometheus-net/prometheus-net/issues/389 + [TestMethod] + public async Task Issue389() + { + _registry.SetStaticLabels(new Dictionary { { "registry", "registry-label-value" } }); + + var factory1 = _metrics.WithLabels(new Dictionary { { "factory", "factory1" } }); + var factory2 = _metrics.WithLabels(new Dictionary { { "factory", "factory2" } }); + var factory3 = _metrics.WithLabels(new Dictionary { { "factory", "factory3" } }); + + var metric1 = factory1.CreateCounter("counter", ""); + var metric2 = factory2.CreateCounter("counter", ""); + var metric3 = factory3.CreateCounter("counter", ""); + + metric1.Inc(); + metric2.Inc(); + metric3.Inc(); + + Assert.AreEqual(1, metric1.Value); + Assert.AreEqual(1, metric2.Value); + Assert.AreEqual(1, metric3.Value); + + var serialized = await _registry.CollectAndSerializeToStringAsync(); + + // It should serialize them all as a single family, not multiple families. + var lines = serialized.Split('\n'); + var familyDeclarationLineCount = lines.Count(x => x.StartsWith("# TYPE ")); + + Assert.AreEqual(1, familyDeclarationLineCount); + } } } diff --git a/Tests.NetCore/GrpcExporter/RequestCountMiddlewareTests.cs b/Tests.NetCore/GrpcExporter/RequestCountMiddlewareTests.cs index 24a0bc69..b20a7242 100644 --- a/Tests.NetCore/GrpcExporter/RequestCountMiddlewareTests.cs +++ b/Tests.NetCore/GrpcExporter/RequestCountMiddlewareTests.cs @@ -78,7 +78,7 @@ public async Task Given_request_populates_labels_correctly() await _sut.Invoke(_httpContext); - var labels = counter.GetAllInstanceLabels().Single(); + var labels = counter.GetAllInstanceLabelsUnsafe().Single(); Assert.AreEqual( expectedService, GetLabelValueOrDefault(labels, GrpcRequestLabelNames.Service) diff --git a/Tests.NetCore/HistogramTests.cs b/Tests.NetCore/HistogramTests.cs index 6edce2c1..36c75583 100644 --- a/Tests.NetCore/HistogramTests.cs +++ b/Tests.NetCore/HistogramTests.cs @@ -1,4 +1,6 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Threading; +using Microsoft.VisualStudio.TestTools.UnitTesting; using NSubstitute; using System.Threading.Tasks; @@ -7,6 +9,68 @@ namespace Prometheus.Tests [TestClass] public sealed class HistogramTests { + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void ObserveExemplarDuplicateKeys() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); + + var histogram = factory.CreateHistogram("xxx", ""); + histogram.Observe(1, Exemplar.From(Exemplar.Pair("traceID", "123"), Exemplar.Pair("traceID", "1"))); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void ObserveExemplarTooManyRunes() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); + + var key1 = "0123456789" + "0123456789" + "0123456789" + "0123456789" + "0123456789"; // 50 + var key2 = "0123456789" + "0123456789" + "0123456789" + "0123456789" + "0123456780"; // 50 + var val1 = "01234567890123"; // 14 + var val2 = "012345678901234"; // 15 (= 129) + + var histogram = factory.CreateHistogram("xxx", ""); + histogram.Observe(1, Exemplar.From(Exemplar.Pair(key1, val1), Exemplar.Pair(key2, val2))); + } + + [TestMethod] + public async Task ObserveExemplar_OnlyAddsExemplarToSingleBucket() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); + + var histogram = factory.CreateHistogram("xxx", "", new HistogramConfiguration + { + Buckets = new[] { 1.0, 2.0, 3.0 } + }); + + var canary = "my_value_354867398"; + var exemplar = Exemplar.From(Exemplar.Pair("my_key", canary)); + + // We expect the exemplar to be added to the specific bucket that the value falls into, not every bucket that gets incremented. + // In this case, it would be the 2.0 bucket that the exemplar belongs to (the lowest-valued bucket that gets incremented). + // OpenMetrics says "Exemplars SHOULD be put into the bucket with the highest value." but that seems backwards - it would mean + // that every exemplar goes into the +Inf bucket, as that is always the highest value of an incremented bucket. + histogram.Observe(1.9, exemplar); + + var serialized = await registry.CollectAndSerializeToStringAsync(ExpositionFormat.OpenMetricsText); + + // We expect to see it there. + StringAssert.Contains(serialized, canary); + + // And we expect to see it only once. + var firstIndex = serialized.IndexOf(canary); + var lastIndex = serialized.LastIndexOf(canary); + Assert.AreEqual(firstIndex, lastIndex); + + // And we expect to see it on the correct line. + var expectedLine = $@"xxx_bucket{{le=""2.0""}} 1 # {{my_key=""{canary}""}}"; + StringAssert.Contains(serialized, expectedLine); + } + [TestMethod] public async Task Observe_IncrementsCorrectBucketsAndCountAndSum() { @@ -22,20 +86,21 @@ public async Task Observe_IncrementsCorrectBucketsAndCountAndSum() histogram.Observe(3.0); var serializer = Substitute.For(); - await histogram.CollectAndSerializeAsync(serializer, default); + await histogram.CollectAndSerializeAsync(serializer, true, default); // Sum + await serializer.Received().WriteMetricPointAsync(Arg.Any(), Arg.Any(), Arg.Any(), 5.0, Arg.Any(), Arg.Any(), Arg.Any()); + + // 1.0 bucket + await serializer.Received().WriteMetricPointAsync(Arg.Any(), Arg.Any(), Arg.Any(), 0, Arg.Any(), Arg.Any(), Arg.Any()); + + // 2.0 bucket + await serializer.Received().WriteMetricPointAsync(Arg.Any(), Arg.Any(), Arg.Any(), 1, Arg.Any(), Arg.Any(), Arg.Any()); + // Count - // 1.0 - // 2.0 - // 3.0 - // +inf - await serializer.Received().WriteMetricAsync(histogram.Unlabelled._sumIdentifier, 5.0, default); - await serializer.Received().WriteMetricAsync(histogram.Unlabelled._countIdentifier, 2.0, default); - await serializer.Received().WriteMetricAsync(histogram.Unlabelled._bucketIdentifiers[0], 0, default); - await serializer.Received().WriteMetricAsync(histogram.Unlabelled._bucketIdentifiers[1], 1, default); - await serializer.Received().WriteMetricAsync(histogram.Unlabelled._bucketIdentifiers[2], 2, default); - await serializer.Received().WriteMetricAsync(histogram.Unlabelled._bucketIdentifiers[3], 2, default); + // 3.0 bucket + // +inf bucket + await serializer.Received(requiredNumberOfCalls: 3).WriteMetricPointAsync(Arg.Any(), Arg.Any(), Arg.Any(), 2, Arg.Any(), Arg.Any(), Arg.Any()); } [TestMethod] diff --git a/Tests.NetCore/HttpExporter/RouteParameterMappingTests.cs b/Tests.NetCore/HttpExporter/RouteParameterMappingTests.cs index f54ce074..c911647c 100644 --- a/Tests.NetCore/HttpExporter/RouteParameterMappingTests.cs +++ b/Tests.NetCore/HttpExporter/RouteParameterMappingTests.cs @@ -3,6 +3,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Prometheus.HttpMetrics; using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -63,6 +64,51 @@ public void DefaultMetric_AppliesStandardLabels() }, child.InstanceLabels.Values.ToArray()); } + [TestMethod] + public void DefaultMetric_WithCustomFactory_AppliesStandardLabelsAndFactoryLabels() + { + SetupHttpContext(_context, TestStatusCode, TestMethod, TestAction, TestController); + + var labelName = "static_label_1"; + var labelValue = "static_label_value_1"; + + var factory = Metrics.WithCustomRegistry(_registry) + .WithLabels(new Dictionary + { + { labelName, labelValue } + }); + + var middleware = new HttpRequestCountMiddleware(_next, new HttpRequestCountOptions + { + Registry = _registry, + MetricFactory = factory + }); + var child = (ChildBase)middleware.CreateChild(_context); + + CollectionAssert.AreEquivalent(DefaultLabelNamesPlusEndpoint, child.InstanceLabels.Names.ToArray()); + CollectionAssert.AreEquivalent(new[] + { + TestStatusCode.ToString(), + TestMethod, + TestAction, + TestController, + TestEndpoint + }, child.InstanceLabels.Values.ToArray()); + + var expectedFlattenedLabelNames = new[] { labelName }.Concat(DefaultLabelNamesPlusEndpoint).ToArray(); + var expectedFlattenedLabelValues = new[] { labelValue }.Concat(new[] + { + TestStatusCode.ToString(), + TestMethod, + TestAction, + TestController, + TestEndpoint + }).ToArray(); + + CollectionAssert.AreEquivalent(expectedFlattenedLabelNames, child.FlattenedLabels.Names.ToArray()); + CollectionAssert.AreEquivalent(expectedFlattenedLabelValues, child.FlattenedLabels.Values.ToArray()); + } + [TestMethod] public void CustomMetric_WithNoLabels_AppliesNoLabels() { diff --git a/Tests.NetCore/MeterAdapterTests.cs b/Tests.NetCore/MeterAdapterTests.cs new file mode 100644 index 00000000..a3a022a4 --- /dev/null +++ b/Tests.NetCore/MeterAdapterTests.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SDM = System.Diagnostics.Metrics; + +namespace Prometheus.Tests; + +[TestClass] +public sealed class MeterAdapterTests : IDisposable +{ + private readonly CollectorRegistry _registry; + private readonly MetricFactory _metrics; + private readonly SDM.Meter _meter = new("test"); + private readonly SDM.Counter _intCounter; + private readonly SDM.Counter _floatCounter; + private readonly IDisposable _adapter; + + public MeterAdapterTests() + { + _registry = Metrics.NewCustomRegistry(); + _metrics = Metrics.WithCustomRegistry(_registry); + + _intCounter = _meter.CreateCounter("int_counter"); + _floatCounter = _meter.CreateCounter("float_counter"); + + _registry = Metrics.NewCustomRegistry(); + _metrics = Metrics.WithCustomRegistry(_registry); + + _adapter = MeterAdapter.StartListening(new MeterAdapterOptions + { + InstrumentFilterPredicate = instrument => + { + return instrument.Meter == _meter; + }, + Registry = _registry, + MetricFactory = _metrics, + ResolveHistogramBuckets = instrument => new double[] { 1, 2, 3, 4 }, + }); + } + + private static FakeSerializer SerializeMetrics(CollectorRegistry registry) + { + var serializer = new FakeSerializer(); + registry.CollectAndSerializeAsync(serializer, default).Wait(); + return serializer; + } + + private double GetValue(string meterName, params (string name, string value)[] labels) => + GetValue(_registry, meterName, labels); + private double GetValue(CollectorRegistry registry, string meterName, params (string name, string value)[] labels) + { + var serializer = SerializeMetrics(registry); + if (serializer.Data.Count == 0) + throw new Exception("No metrics found"); + var labelsString = string.Join(",", labels.Select(l => $"{l.name}=\"{l.value}\"")); + foreach (var d in serializer.Data) + { + Console.WriteLine($"{d.name} {d.labels} {d.canonicalLabel} {d.value}"); + + if (d.name == meterName && d.labels == labelsString) + { + return d.value; + } + } + if (serializer.Data.Any(d => d.name == meterName)) + throw new Exception($"Metric {meterName}{{{labelsString}}} not found, only these labels were found: {string.Join(" / ", serializer.Data.Where(d => d.name == meterName).Select(d => d.labels))}"); + + throw new Exception($"Metric {meterName} not found, only these metrics were found: {string.Join(" / ", serializer.Data.Select(d => d.name).Distinct())}"); + } + + [TestMethod] + public void CounterInt() + { + _intCounter.Add(1); + Assert.AreEqual(1, GetValue("test_int_counter")); + _intCounter.Add(2); + Assert.AreEqual(3, GetValue("test_int_counter")); + } + + [TestMethod] + public void CounterFloat() + { + _floatCounter.Add(1); + Assert.AreEqual(1, GetValue("test_float_counter")); + _floatCounter.Add(0.002); + Assert.AreEqual(1.002, GetValue("test_float_counter")); + } + + [TestMethod] + public void CounterLabels() + { + _intCounter.Add(1, new("l1", "value"), new("l2", 111)); + Assert.AreEqual(1, GetValue("test_int_counter", ("l1", "value"), ("l2", "111"))); + _intCounter.Add(1000); + _intCounter.Add(1000, new("l1", "value"), new("l2", 0)); + _intCounter.Add(1000, new KeyValuePair("l1", "value")); + _intCounter.Add(1, new("l2", 111), new("l1", "value")); + Assert.AreEqual(2, GetValue("test_int_counter", ("l1", "value"), ("l2", "111"))); + Assert.AreEqual(1000, GetValue("test_int_counter", ("l1", "value"), ("l2", "0"))); + Assert.AreEqual(1000, GetValue("test_int_counter", ("l1", "value"))); + Assert.AreEqual(1000, GetValue("test_int_counter")); + } + + [TestMethod] + public void LabelRenaming() + { + _intCounter.Add(1, new("my-label", 1), new("Another.Label", 1)); + Assert.AreEqual(1, GetValue("test_int_counter", ("another_label", "1"), ("my_label", "1"))); + } + + + [TestMethod] + public void MultipleInstances() + { + _intCounter.Add(1000); + + var registry2 = Metrics.NewCustomRegistry(); + var metrics2 = Metrics.WithCustomRegistry(registry2); + + var adapter2 = MeterAdapter.StartListening(new MeterAdapterOptions + { + InstrumentFilterPredicate = instrument => + { + return instrument.Meter == _meter; + }, + Registry = registry2, + MetricFactory = metrics2, + ResolveHistogramBuckets = instrument => new double[] { 1, 2, 3, 4 }, + }); + + _intCounter.Add(1); + Assert.AreEqual(1001, GetValue("test_int_counter")); + Assert.AreEqual(1, GetValue(registry2, "test_int_counter")); + + adapter2.Dispose(); + + _intCounter.Add(1); + Assert.AreEqual(1002, GetValue("test_int_counter")); + Assert.AreEqual(1, GetValue(registry2, "test_int_counter")); + } + + public void Dispose() + { + _adapter.Dispose(); + } + + class FakeSerializer : IMetricsSerializer + { + public List<(string name, string labels, string canonicalLabel, double value, ObservedExemplar exemplar)> Data = new(); + public Task FlushAsync(CancellationToken cancel) => Task.CompletedTask; + public ValueTask WriteEnd(CancellationToken cancel) => default; + + public ValueTask WriteFamilyDeclarationAsync(string name, byte[] nameBytes, byte[] helpBytes, MetricType type, byte[] typeBytes, CancellationToken cancel) => default; + + public ValueTask WriteMetricPointAsync(byte[] name, byte[] flattenedLabels, CanonicalLabel canonicalLabel, double value, ObservedExemplar exemplar, byte[] suffix, CancellationToken cancel) + { + Data.Add(( + name: Encoding.UTF8.GetString(name), + labels: Encoding.UTF8.GetString(flattenedLabels), + canonicalLabel: canonicalLabel.ToString(), + value: value, + exemplar: exemplar + )); + return default; + } + + public ValueTask WriteMetricPointAsync(byte[] name, byte[] flattenedLabels, CanonicalLabel canonicalLabel, long value, ObservedExemplar exemplar, byte[] suffix, CancellationToken cancel) => + WriteMetricPointAsync(name, flattenedLabels, canonicalLabel, (double)value, exemplar, suffix, cancel); + } +} diff --git a/Tests.NetCore/MetricExpirationTests.cs b/Tests.NetCore/MetricExpirationTests.cs index 363b2c5c..86cec7b2 100644 --- a/Tests.NetCore/MetricExpirationTests.cs +++ b/Tests.NetCore/MetricExpirationTests.cs @@ -1,7 +1,7 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; -using System.Threading; +using System; +using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Prometheus.Tests { @@ -27,13 +27,11 @@ public MetricExpirationTests() // This has a slight dependency on the performance of the PC executing the tests - maybe not ideal long term strategy but what can you do. private static readonly TimeSpan WaitForAsyncActionSleepTime = TimeSpan.FromSeconds(0.1); - private static readonly string[] _labels = Array.Empty(); - [TestMethod] public void ManagedLifetimeMetric_IsSameMetricAsNormalMetric() { - var counter1 = _metrics.CreateCounter(MetricName, "", _labels); - var counterHandle = _expiringMetrics.CreateCounter(MetricName, "", _labels); + var counter1 = _metrics.CreateCounter(MetricName, ""); + var counterHandle = _expiringMetrics.CreateCounter(MetricName, "", Array.Empty()); counter1.Inc(); @@ -56,8 +54,8 @@ public void ManagedLifetimeMetric_IsSameMetricAsNormalMetric() [TestMethod] public void ManagedLifetimeMetric_MultipleHandlesFromSameFactory_AreSameHandle() { - var handle1 = _expiringMetrics.CreateCounter(MetricName, "", _labels); - var handle2 = _expiringMetrics.CreateCounter(MetricName, "", _labels); + var handle1 = _expiringMetrics.CreateCounter(MetricName, "", Array.Empty()); + var handle2 = _expiringMetrics.CreateCounter(MetricName, "", Array.Empty()); Assert.AreSame(handle1, handle2); } @@ -65,10 +63,10 @@ public void ManagedLifetimeMetric_MultipleHandlesFromSameFactory_AreSameHandle() [TestMethod] public void ManagedLifetimeMetric_ViaDifferentFactories_IsSameMetric() { - var handle1 = _expiringMetrics.CreateCounter(MetricName, "", _labels); + var handle1 = _expiringMetrics.CreateCounter(MetricName, "", Array.Empty()); var expiringMetrics2 = _metrics.WithManagedLifetime(expiresAfter: TimeSpan.FromHours(24)); - var handle2 = expiringMetrics2.CreateCounter(MetricName, "", _labels); + var handle2 = expiringMetrics2.CreateCounter(MetricName, "", Array.Empty()); using (var lease = handle1.AcquireLease(out var instance)) instance.Inc(); @@ -87,14 +85,13 @@ public void ManagedLifetimeMetric_ViaDifferentFactories_IsSameMetric() [TestMethod] public async Task ManagedLifetimeMetric_ExpiresOnlyAfterAllLeasesReleased() { - var handle = _expiringMetrics.CreateCounter(MetricName, "", _labels); + var handle = (ManagedLifetimeCounter)_expiringMetrics.CreateCounter(MetricName, "", Array.Empty()); // We break delays on demand to force any expiring-eligible metrics to expire. var delayer = new BreakableDelayer(); - ((ManagedLifetimeCounter)handle).Delayer = delayer; + handle.Delayer = delayer; // We detect expiration by the value having been reset when we try allocate the counter again. - // We break 2 delays on every use, to ensure that the expiration logic has enough iterations to make up its mind. using (handle.AcquireLease(out var instance1)) { @@ -104,37 +101,120 @@ public async Task ManagedLifetimeMetric_ExpiresOnlyAfterAllLeasesReleased() { instance2.Inc(); - await Task.Delay(WaitForAsyncActionSleepTime); // Give it a moment to wake up and start expiring. - + handle.SetAllKeepaliveTimestampsToDistantPast(); delayer.BreakAllDelays(); await Task.Delay(WaitForAsyncActionSleepTime); // Give it a moment to wake up and finish expiring. + + // 2 leases remain - should not have expired yet. Check with a fresh copy from the root registry. + Assert.AreEqual(2, _metrics.CreateCounter(MetricName, "").Value); + } + + handle.SetAllKeepaliveTimestampsToDistantPast(); + delayer.BreakAllDelays(); + await Task.Delay(WaitForAsyncActionSleepTime); // Give it a moment to wake up and finish expiring. + + // 1 lease remains - should not have expired yet. Check with a fresh copy from the root registry. + Assert.AreEqual(2, _metrics.CreateCounter(MetricName, "").Value); + } + + handle.SetAllKeepaliveTimestampsToDistantPast(); + delayer.BreakAllDelays(); + await Task.Delay(WaitForAsyncActionSleepTime); // Give it a moment to wake up and finish expiring. + + handle.DebugDumpLifetimes(); + + // 0 leases remains - should have expired. Check with a fresh copy from the root registry. + Assert.AreEqual(0, _metrics.CreateCounter(MetricName, "").Value); + } + + [TestMethod] + public async Task ManagedLifetimeMetric_WithMultipleLabelingPaths_SharedSameLifetime() + { + // Two calls with the same X, Y to ManagedLifetimeMetricFactory.WithLabels(X).CreateCounter().AcquireLease(Y) + // will impact the same metric lifetime (and, implicitly, share the same metric instance data). + // A call with matching ManagedLifetimeMetricFactory.CreateCounter(X).AcquireLease(Y) will do the same. + + var label1Key = "test_label_1"; + var label2Key = "test_label_2"; + var label1Value = "some value 1"; + var label2Value = "some value 2"; + var labels = new Dictionary + { + { label1Key, label1Value }, + { label2Key, label2Value }, + }; + + // Must be ordinal-sorted to match the WithLabels() sorting. + var labelNames = new[] { label1Key, label2Key }; + var labelValues = new[] { label1Value, label2Value }; + + var labelingFactory1 = _expiringMetrics.WithLabels(labels); + var labelingFactory2 = _expiringMetrics.WithLabels(labels); + + var factory1Handle = (LabelEnrichingManagedLifetimeCounter)labelingFactory1.CreateCounter(MetricName, "", Array.Empty()); + var factory2Handle = (LabelEnrichingManagedLifetimeCounter)labelingFactory2.CreateCounter(MetricName, "", Array.Empty()); + + var rawHandle = (ManagedLifetimeCounter)_expiringMetrics.CreateCounter(MetricName, "", labelNames); + + // We break delays on demand to force any expiring-eligible metrics to expire. + var delayer = new BreakableDelayer(); + ((ManagedLifetimeCounter)factory1Handle._inner).Delayer = delayer; + ((ManagedLifetimeCounter)factory2Handle._inner).Delayer = delayer; + rawHandle.Delayer = delayer; + + // We detect expiration by the value having been reset when we try allocate the counter again. + + using (factory1Handle.AcquireLease(out var instance1)) + { + instance1.Inc(); + + using (factory2Handle.AcquireLease(out var instance2)) + { + instance2.Inc(); + + rawHandle.SetAllKeepaliveTimestampsToDistantPast(); delayer.BreakAllDelays(); await Task.Delay(WaitForAsyncActionSleepTime); // Give it a moment to wake up and finish expiring. // 2 leases remain - should not have expired yet. Check with a fresh copy from the root registry. - Assert.AreEqual(2, _metrics.CreateCounter(MetricName, "", _labels).Value); + Assert.AreEqual(2, _metrics.CreateCounter(MetricName, "", labelNames).WithLabels(labelValues).Value); } - await Task.Delay(WaitForAsyncActionSleepTime); // Give it a moment to wake up and start expiring. - + rawHandle.SetAllKeepaliveTimestampsToDistantPast(); delayer.BreakAllDelays(); await Task.Delay(WaitForAsyncActionSleepTime); // Give it a moment to wake up and finish expiring. + + // 1 lease remains - should not have expired yet. Check with a fresh copy from the root registry. + Assert.AreEqual(2, _metrics.CreateCounter(MetricName, "", labelNames).WithLabels(labelValues).Value); + + using (rawHandle.AcquireLease(out var instance3, labelValues)) + { + instance3.Inc(); + + rawHandle.SetAllKeepaliveTimestampsToDistantPast(); + delayer.BreakAllDelays(); + await Task.Delay(WaitForAsyncActionSleepTime); // Give it a moment to wake up and finish expiring. + + // 2 leases remain - should not have expired yet. Check with a fresh copy from the root registry. + Assert.AreEqual(3, _metrics.CreateCounter(MetricName, "", labelNames).WithLabels(labelValues).Value); + } + + rawHandle.SetAllKeepaliveTimestampsToDistantPast(); delayer.BreakAllDelays(); await Task.Delay(WaitForAsyncActionSleepTime); // Give it a moment to wake up and finish expiring. // 1 lease remains - should not have expired yet. Check with a fresh copy from the root registry. - Assert.AreEqual(2, _metrics.CreateCounter(MetricName, "", _labels).Value); + Assert.AreEqual(3, _metrics.CreateCounter(MetricName, "", labelNames).WithLabels(labelValues).Value); } - await Task.Delay(WaitForAsyncActionSleepTime); // Give it a moment to wake up and start expiring. - - delayer.BreakAllDelays(); - await Task.Delay(WaitForAsyncActionSleepTime); // Give it a moment to wake up and finish expiring. + rawHandle.SetAllKeepaliveTimestampsToDistantPast(); delayer.BreakAllDelays(); await Task.Delay(WaitForAsyncActionSleepTime); // Give it a moment to wake up and finish expiring. + rawHandle.DebugDumpLifetimes(); + // 0 leases remains - should have expired. Check with a fresh copy from the root registry. - Assert.AreEqual(0, _metrics.CreateCounter(MetricName, "", _labels).Value); + Assert.AreEqual(0, _metrics.CreateCounter(MetricName, "", labelNames).WithLabels(labelValues).Value); } } } diff --git a/Tests.NetCore/MetricInitializationTests.cs b/Tests.NetCore/MetricInitializationTests.cs index aa8ae491..04ceac5c 100644 --- a/Tests.NetCore/MetricInitializationTests.cs +++ b/Tests.NetCore/MetricInitializationTests.cs @@ -2,396 +2,421 @@ using NSubstitute; using System.Threading.Tasks; -namespace Prometheus.Tests +namespace Prometheus.Tests; + +[TestClass] +public sealed class MetricInitializationTests { - [TestClass] - public sealed class MetricInitializationTests + private static HistogramConfiguration NewHistogramConfiguration() => new() + { + // This results in 4 metrics - sum, count, 1.0, +Inf + Buckets = new[] { 1.0 } + }; + + private static SummaryConfiguration NewSummaryConfiguration() => new() { - private static HistogramConfiguration NewHistogramConfiguration() => new HistogramConfiguration + // This results in 3 metrics - sum, count, 0.1 + Objectives = new[] { - // This results in 4 metrics - sum, count, 1.0, +Inf - Buckets = new[] { 1.0 } - }; + new QuantileEpsilonPair(0.1, 0.05) + } + }; - private static SummaryConfiguration NewSummaryConfiguration() => new SummaryConfiguration + #region Unlabelled logic + [TestMethod] + public async Task CreatingUnlabelledMetric_WithoutObservingAnyData_ExportsImmediately() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); + + var summaryConfig = NewSummaryConfiguration(); + var histogramConfig = NewHistogramConfiguration(); + + var gauge = factory.CreateGauge("gauge", "", new GaugeConfiguration { - // This results in 3 metrics - sum, count, 0.1 - Objectives = new[] - { - new QuantileEpsilonPair(0.1, 0.05) - } - }; - - #region Unlabelled logic - [TestMethod] - public async Task CreatingUnlabelledMetric_WithoutObservingAnyData_ExportsImmediately() + }); + var counter = factory.CreateCounter("counter", "", new CounterConfiguration { - var registry = Metrics.NewCustomRegistry(); - var factory = Metrics.WithCustomRegistry(registry); + }); + var summary = factory.CreateSummary("summary", "", summaryConfig); + var histogram = factory.CreateHistogram("histogram", "", histogramConfig); + // 4 families with 9 metrics total. - var summaryConfig = NewSummaryConfiguration(); - var histogramConfig = NewHistogramConfiguration(); + var serializer = Substitute.For(); + await registry.CollectAndSerializeAsync(serializer, default); - var gauge = factory.CreateGauge("gauge", "", new GaugeConfiguration - { - }); - var counter = factory.CreateCounter("counter", "", new CounterConfiguration - { - }); - var summary = factory.CreateSummary("summary", "", summaryConfig); - var histogram = factory.CreateHistogram("histogram", "", histogramConfig); - // 4 families with 9 metrics total. + // Without touching any metrics, there should be output for all because default config publishes immediately. - var serializer = Substitute.For(); - await registry.CollectAndSerializeAsync(serializer, default); + await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + // gauge + counter + summary.sum + histogram.sum + summary quantile + await serializer.ReceivedWithAnyArgs(5).WriteMetricPointAsync(default, default, default, default(double), default, default, default); + // summary.count + 2x histogram bucket + histogram count + await serializer.ReceivedWithAnyArgs(4).WriteMetricPointAsync(default, default, default, default, default, default, default); + } - // Without touching any metrics, there should be output for all because default config publishes immediately. + [TestMethod] + public async Task CreatingUnlabelledMetric_WithInitialValueSuppression_ExportsNothingByDefault() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); - await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default); - await serializer.ReceivedWithAnyArgs(9).WriteMetricAsync(default, default, default); - } + var sumamryConfig = NewSummaryConfiguration(); + sumamryConfig.SuppressInitialValue = true; - [TestMethod] - public async Task CreatingUnlabelledMetric_WithInitialValueSuppression_ExportsNothingByDefault() - { - var registry = Metrics.NewCustomRegistry(); - var factory = Metrics.WithCustomRegistry(registry); - - var sumamryConfig = NewSummaryConfiguration(); - sumamryConfig.SuppressInitialValue = true; - - var histogramConfig = NewHistogramConfiguration(); - histogramConfig.SuppressInitialValue = true; - - var gauge = factory.CreateGauge("gauge", "", new GaugeConfiguration - { - SuppressInitialValue = true - }); - var counter = factory.CreateCounter("counter", "", new CounterConfiguration - { - SuppressInitialValue = true - }); - var summary = factory.CreateSummary("summary", "", sumamryConfig); - var histogram = factory.CreateHistogram("histogram", "", histogramConfig); - // 4 families with 9 metrics total. - - var serializer = Substitute.For(); - await registry.CollectAndSerializeAsync(serializer, default); - - // There is a family for each of the above, in each family we expect to see 0 metrics. - await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default); - await serializer.DidNotReceiveWithAnyArgs().WriteMetricAsync(default, default, default); - } + var histogramConfig = NewHistogramConfiguration(); + histogramConfig.SuppressInitialValue = true; - [TestMethod] - public async Task CreatingUnlabelledMetric_WithInitialValueSuppression_ExportsAfterValueChange() + var gauge = factory.CreateGauge("gauge", "", new GaugeConfiguration { - var registry = Metrics.NewCustomRegistry(); - var factory = Metrics.WithCustomRegistry(registry); - - var sumamryConfig = NewSummaryConfiguration(); - sumamryConfig.SuppressInitialValue = true; - - var histogramConfig = NewHistogramConfiguration(); - histogramConfig.SuppressInitialValue = true; - - var gauge = factory.CreateGauge("gauge", "", new GaugeConfiguration - { - SuppressInitialValue = true - }); - var counter = factory.CreateCounter("counter", "", new CounterConfiguration - { - SuppressInitialValue = true - }); - var summary = factory.CreateSummary("summary", "", sumamryConfig); - var histogram = factory.CreateHistogram("histogram", "", histogramConfig); - // 4 families with 9 metrics total. - - gauge.Set(123); - counter.Inc(); - summary.Observe(123); - histogram.Observe(31); - - var serializer = Substitute.For(); - await registry.CollectAndSerializeAsync(serializer, default); - - // Even though suppressed, they all now have values so should all be published. - await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default); - await serializer.ReceivedWithAnyArgs(9).WriteMetricAsync(default, default, default); - } + SuppressInitialValue = true + }); + var counter = factory.CreateCounter("counter", "", new CounterConfiguration + { + SuppressInitialValue = true + }); + var summary = factory.CreateSummary("summary", "", sumamryConfig); + var histogram = factory.CreateHistogram("histogram", "", histogramConfig); + // 4 families with 9 metrics total. + + var serializer = Substitute.For(); + await registry.CollectAndSerializeAsync(serializer, default); + + // There is a family for each of the above, in each family we expect to see 0 metrics. + await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default(double), default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default, default, default, default); + } + + [TestMethod] + public async Task CreatingUnlabelledMetric_WithInitialValueSuppression_ExportsAfterValueChange() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); - [TestMethod] - public async Task CreatingUnlabelledMetric_WithInitialValueSuppression_ExportsAfterPublish() + var sumamryConfig = NewSummaryConfiguration(); + sumamryConfig.SuppressInitialValue = true; + + var histogramConfig = NewHistogramConfiguration(); + histogramConfig.SuppressInitialValue = true; + + var gauge = factory.CreateGauge("gauge", "", new GaugeConfiguration { - var registry = Metrics.NewCustomRegistry(); - var factory = Metrics.WithCustomRegistry(registry); - - var sumamryConfig = NewSummaryConfiguration(); - sumamryConfig.SuppressInitialValue = true; - - var histogramConfig = NewHistogramConfiguration(); - histogramConfig.SuppressInitialValue = true; - - var gauge = factory.CreateGauge("gauge", "", new GaugeConfiguration - { - SuppressInitialValue = true - }); - var counter = factory.CreateCounter("counter", "", new CounterConfiguration - { - SuppressInitialValue = true - }); - var summary = factory.CreateSummary("summary", "", sumamryConfig); - var histogram = factory.CreateHistogram("histogram", "", histogramConfig); - // 4 families with 9 metrics total. - - gauge.Publish(); - counter.Publish(); - summary.Publish(); - histogram.Publish(); - - var serializer = Substitute.For(); - await registry.CollectAndSerializeAsync(serializer, default); - - // Even though suppressed, they were all explicitly published. - await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default); - await serializer.ReceivedWithAnyArgs(9).WriteMetricAsync(default, default, default); - } - #endregion + SuppressInitialValue = true + }); + var counter = factory.CreateCounter("counter", "", new CounterConfiguration + { + SuppressInitialValue = true + }); + var summary = factory.CreateSummary("summary", "", sumamryConfig); + var histogram = factory.CreateHistogram("histogram", "", histogramConfig); + // 4 families with 9 metrics total. + + gauge.Set(123); + counter.Inc(); + summary.Observe(123); + histogram.Observe(31); + + var serializer = Substitute.For(); + await registry.CollectAndSerializeAsync(serializer, default); + + // Even though suppressed, they all now have values so should all be published. + await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + // gauge + counter + summary.sum + histogram.sum + summary quantile + await serializer.ReceivedWithAnyArgs(5).WriteMetricPointAsync(default, default, default, default(double), default, default, default); + // summary.count + 2x histogram bucket + histogram count + await serializer.ReceivedWithAnyArgs(4).WriteMetricPointAsync(default, default, default, default, default, default, default); + } + + [TestMethod] + public async Task CreatingUnlabelledMetric_WithInitialValueSuppression_ExportsAfterPublish() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); + + var sumamryConfig = NewSummaryConfiguration(); + sumamryConfig.SuppressInitialValue = true; + + var histogramConfig = NewHistogramConfiguration(); + histogramConfig.SuppressInitialValue = true; - #region Labelled logic - [TestMethod] - public async Task CreatingLabelledMetric_WithoutObservingAnyData_ExportsImmediately() + var gauge = factory.CreateGauge("gauge", "", new GaugeConfiguration { - var registry = Metrics.NewCustomRegistry(); - var factory = Metrics.WithCustomRegistry(registry); + SuppressInitialValue = true + }); + var counter = factory.CreateCounter("counter", "", new CounterConfiguration + { + SuppressInitialValue = true + }); + var summary = factory.CreateSummary("summary", "", sumamryConfig); + var histogram = factory.CreateHistogram("histogram", "", histogramConfig); + // 4 families with 9 metrics total. + + gauge.Publish(); + counter.Publish(); + summary.Publish(); + histogram.Publish(); + + var serializer = Substitute.For(); + await registry.CollectAndSerializeAsync(serializer, default); + + // Even though suppressed, they were all explicitly published. + await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + + // gauge + counter + summary.sum + histogram.sum + summary quantile + await serializer.ReceivedWithAnyArgs(5).WriteMetricPointAsync(default, default, default, default(double), default, default, default); + // summary.count + 2x histogram bucket + histogram count + await serializer.ReceivedWithAnyArgs(4).WriteMetricPointAsync(default, default, default, default(long), default, default, default); + } + #endregion - var sumamryConfig = NewSummaryConfiguration(); + #region Labelled logic + [TestMethod] + public async Task CreatingLabelledMetric_WithoutObservingAnyData_ExportsImmediately() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); - var histogramConfig = NewHistogramConfiguration(); + var sumamryConfig = NewSummaryConfiguration(); - var gauge = factory.CreateGauge("gauge", "", new[] { "foo" }).WithLabels("bar"); - var counter = factory.CreateCounter("counter", "", new[] { "foo" }).WithLabels("bar"); - var summary = factory.CreateSummary("summary", "", new[] { "foo" }, sumamryConfig).WithLabels("bar"); - var histogram = factory.CreateHistogram("histogram", "", new[] { "foo" }, histogramConfig).WithLabels("bar"); - // 4 families with 9 metrics total. + var histogramConfig = NewHistogramConfiguration(); - var serializer = Substitute.For(); - await registry.CollectAndSerializeAsync(serializer, default); + var gauge = factory.CreateGauge("gauge", "", new[] { "foo" }).WithLabels("bar"); + var counter = factory.CreateCounter("counter", "", new[] { "foo" }).WithLabels("bar"); + var summary = factory.CreateSummary("summary", "", new[] { "foo" }, sumamryConfig).WithLabels("bar"); + var histogram = factory.CreateHistogram("histogram", "", new[] { "foo" }, histogramConfig).WithLabels("bar"); + // 4 families with 9 metrics total. - // Metrics are published as soon as label values are defined. - await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default); - await serializer.ReceivedWithAnyArgs(9).WriteMetricAsync(default, default, default); - } + var serializer = Substitute.For(); + await registry.CollectAndSerializeAsync(serializer, default); - [TestMethod] - public async Task CreatingLabelledMetric_WithInitialValueSuppression_ExportsNothingByDefault() - { - var registry = Metrics.NewCustomRegistry(); - var factory = Metrics.WithCustomRegistry(registry); - - var sumamryConfig = NewSummaryConfiguration(); - sumamryConfig.SuppressInitialValue = true; - - var histogramConfig = NewHistogramConfiguration(); - histogramConfig.SuppressInitialValue = true; - - var gauge = factory.CreateGauge("gauge", "", new[] { "foo" }, new GaugeConfiguration - { - SuppressInitialValue = true, - }).WithLabels("bar"); - var counter = factory.CreateCounter("counter", "", new[] { "foo" }, new CounterConfiguration - { - SuppressInitialValue = true, - }).WithLabels("bar"); - var summary = factory.CreateSummary("summary", "", new[] { "foo" }, sumamryConfig).WithLabels("bar"); - var histogram = factory.CreateHistogram("histogram", "", new[] { "foo" }, histogramConfig).WithLabels("bar"); - // 4 families with 9 metrics total. - - var serializer = Substitute.For(); - await registry.CollectAndSerializeAsync(serializer, default); - - // Publishing was suppressed. - await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default); - await serializer.DidNotReceiveWithAnyArgs().WriteMetricAsync(default, default, default); - } + // Metrics are published as soon as label values are defined. + await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + // gauge + counter + summary.sum + histogram.sum + summary quantile + await serializer.ReceivedWithAnyArgs(5).WriteMetricPointAsync(default, default, default, default(double), default, default, default); + // summary.count + 2x histogram bucket + histogram count + await serializer.ReceivedWithAnyArgs(4).WriteMetricPointAsync(default, default, default, default(long), default, default, default); + } + + [TestMethod] + public async Task CreatingLabelledMetric_WithInitialValueSuppression_ExportsNothingByDefault() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); + + var sumamryConfig = NewSummaryConfiguration(); + sumamryConfig.SuppressInitialValue = true; + + var histogramConfig = NewHistogramConfiguration(); + histogramConfig.SuppressInitialValue = true; - [TestMethod] - public async Task CreatingLabelledMetric_WithInitialValueSuppression_ExportsAfterValueChange() + var gauge = factory.CreateGauge("gauge", "", new[] { "foo" }, new GaugeConfiguration { - var registry = Metrics.NewCustomRegistry(); - var factory = Metrics.WithCustomRegistry(registry); - - var sumamryConfig = NewSummaryConfiguration(); - sumamryConfig.SuppressInitialValue = true; - - var histogramConfig = NewHistogramConfiguration(); - histogramConfig.SuppressInitialValue = true; - - var gauge = factory.CreateGauge("gauge", "", new[] { "foo" }, new GaugeConfiguration - { - SuppressInitialValue = true, - }).WithLabels("bar"); - var counter = factory.CreateCounter("counter", "", new[] { "foo" }, new CounterConfiguration - { - SuppressInitialValue = true, - }).WithLabels("bar"); - var summary = factory.CreateSummary("summary", "", new[] { "foo" }, sumamryConfig).WithLabels("bar"); - var histogram = factory.CreateHistogram("histogram", "", new[] { "foo" }, histogramConfig).WithLabels("bar"); - // 4 families with 9 metrics total. - - gauge.Set(123); - counter.Inc(); - summary.Observe(123); - histogram.Observe(31); - - var serializer = Substitute.For(); - await registry.CollectAndSerializeAsync(serializer, default); - - // Metrics are published because value was set. - await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default); - await serializer.ReceivedWithAnyArgs(9).WriteMetricAsync(default, default, default); - } + SuppressInitialValue = true, + }).WithLabels("bar"); + var counter = factory.CreateCounter("counter", "", new[] { "foo" }, new CounterConfiguration + { + SuppressInitialValue = true, + }).WithLabels("bar"); + var summary = factory.CreateSummary("summary", "", new[] { "foo" }, sumamryConfig).WithLabels("bar"); + var histogram = factory.CreateHistogram("histogram", "", new[] { "foo" }, histogramConfig).WithLabels("bar"); + // 4 families with 9 metrics total. + + var serializer = Substitute.For(); + await registry.CollectAndSerializeAsync(serializer, default); + + // Publishing was suppressed. + await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default(double), default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default(long), default, default, default); + } + + [TestMethod] + public async Task CreatingLabelledMetric_WithInitialValueSuppression_ExportsAfterValueChange() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); + + var sumamryConfig = NewSummaryConfiguration(); + sumamryConfig.SuppressInitialValue = true; - [TestMethod] - public async Task CreatingLabelledMetric_WithInitialValueSuppression_ExportsAfterPublish() + var histogramConfig = NewHistogramConfiguration(); + histogramConfig.SuppressInitialValue = true; + + var gauge = factory.CreateGauge("gauge", "", new[] { "foo" }, new GaugeConfiguration { - var registry = Metrics.NewCustomRegistry(); - var factory = Metrics.WithCustomRegistry(registry); - - var sumamryConfig = NewSummaryConfiguration(); - sumamryConfig.SuppressInitialValue = true; - - var histogramConfig = NewHistogramConfiguration(); - histogramConfig.SuppressInitialValue = true; - - var gauge = factory.CreateGauge("gauge", "", new[] { "foo" }, new GaugeConfiguration - { - SuppressInitialValue = true, - }).WithLabels("bar"); - var counter = factory.CreateCounter("counter", "", new[] { "foo" }, new CounterConfiguration - { - SuppressInitialValue = true, - }).WithLabels("bar"); - var summary = factory.CreateSummary("summary", "", new[] { "foo" }, sumamryConfig).WithLabels("bar"); - var histogram = factory.CreateHistogram("histogram", "", new[] { "foo" }, histogramConfig).WithLabels("bar"); - // 4 families with 9 metrics total. - - gauge.Publish(); - counter.Publish(); - summary.Publish(); - histogram.Publish(); - - var serializer = Substitute.For(); - await registry.CollectAndSerializeAsync(serializer, default); - - // Metrics are published because of explicit publish. - await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default); - await serializer.ReceivedWithAnyArgs(9).WriteMetricAsync(default, default, default); - } + SuppressInitialValue = true, + }).WithLabels("bar"); + var counter = factory.CreateCounter("counter", "", new[] { "foo" }, new CounterConfiguration + { + SuppressInitialValue = true, + }).WithLabels("bar"); + var summary = factory.CreateSummary("summary", "", new[] { "foo" }, sumamryConfig).WithLabels("bar"); + var histogram = factory.CreateHistogram("histogram", "", new[] { "foo" }, histogramConfig).WithLabels("bar"); + // 4 families with 9 metrics total. + + gauge.Set(123); + counter.Inc(); + summary.Observe(123); + histogram.Observe(31); + + var serializer = Substitute.For(); + await registry.CollectAndSerializeAsync(serializer, default); + + // Metrics are published because value was set. + await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + // gauge + counter + summary.sum + histogram.sum + summary quantile + await serializer.ReceivedWithAnyArgs(5).WriteMetricPointAsync(default, default, default, default(double), default, default, default); + // summary.count + 2x histogram bucket + histogram count + await serializer.ReceivedWithAnyArgs(4).WriteMetricPointAsync(default, default, default, default, default, default, default); + } + + [TestMethod] + public async Task CreatingLabelledMetric_WithInitialValueSuppression_ExportsAfterPublish() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); + + var sumamryConfig = NewSummaryConfiguration(); + sumamryConfig.SuppressInitialValue = true; + + var histogramConfig = NewHistogramConfiguration(); + histogramConfig.SuppressInitialValue = true; - [TestMethod] - public async Task CreatingLabelledMetric_AndUnpublishingAfterObservingData_DoesNotExport() + var gauge = factory.CreateGauge("gauge", "", new[] { "foo" }, new GaugeConfiguration { - var registry = Metrics.NewCustomRegistry(); - var factory = Metrics.WithCustomRegistry(registry); + SuppressInitialValue = true, + }).WithLabels("bar"); + var counter = factory.CreateCounter("counter", "", new[] { "foo" }, new CounterConfiguration + { + SuppressInitialValue = true, + }).WithLabels("bar"); + var summary = factory.CreateSummary("summary", "", new[] { "foo" }, sumamryConfig).WithLabels("bar"); + var histogram = factory.CreateHistogram("histogram", "", new[] { "foo" }, histogramConfig).WithLabels("bar"); + // 4 families with 9 metrics total. + + gauge.Publish(); + counter.Publish(); + summary.Publish(); + histogram.Publish(); + + var serializer = Substitute.For(); + await registry.CollectAndSerializeAsync(serializer, default); + + // Metrics are published because of explicit publish. + await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + + // gauge + counter + summary.sum + histogram.sum + summary quantile + await serializer.ReceivedWithAnyArgs(5).WriteMetricPointAsync(default, default, default, default(double), default, default, default); + // summary.count + 2x histogram bucket + histogram count + await serializer.ReceivedWithAnyArgs(4).WriteMetricPointAsync(default, default, default, default(long), default, default, default); + } - var counter = factory.CreateCounter("counter", "", new[] { "foo" }).WithLabels("bar"); + [TestMethod] + public async Task CreatingLabelledMetric_AndUnpublishingAfterObservingData_DoesNotExport() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); - counter.Inc(); - counter.Unpublish(); + var counter = factory.CreateCounter("counter", "", new[] { "foo" }).WithLabels("bar"); - var serializer = Substitute.For(); - await registry.CollectAndSerializeAsync(serializer, default); + counter.Inc(); + counter.Unpublish(); - await serializer.ReceivedWithAnyArgs(1).WriteFamilyDeclarationAsync(default, default); - await serializer.ReceivedWithAnyArgs(0).WriteMetricAsync(default, default, default); - } - #endregion + var serializer = Substitute.For(); + await registry.CollectAndSerializeAsync(serializer, default); - #region Relation between labelled and unlabelled - [TestMethod] - public async Task CreatingLabelledMetric_WithoutObservingAnyData_DoesNotExportUnlabelled() - { - var registry = Metrics.NewCustomRegistry(); - var factory = Metrics.WithCustomRegistry(registry); + await serializer.ReceivedWithAnyArgs(1).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default, default, default, default); + } + #endregion - var summaryConfig = NewSummaryConfiguration(); - var histogramConfig = NewHistogramConfiguration(); + #region Relation between labelled and unlabelled + [TestMethod] + public async Task CreatingLabelledMetric_WithoutObservingAnyData_DoesNotExportUnlabelled() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); - var gauge = factory.CreateGauge("gauge", "", new[] { "labelname" }); - var counter = factory.CreateCounter("counter", "", new[] { "labelname" }); - var summary = factory.CreateSummary("summary", "", new[] { "labelname" }, summaryConfig); - var histogram = factory.CreateHistogram("histogram", "", new[] { "labelname" }, histogramConfig); - // 4 families with 9 metrics total. + var summaryConfig = NewSummaryConfiguration(); + var histogramConfig = NewHistogramConfiguration(); - var serializer = Substitute.For(); - await registry.CollectAndSerializeAsync(serializer, default); + var gauge = factory.CreateGauge("gauge", "", new[] { "labelname" }); + var counter = factory.CreateCounter("counter", "", new[] { "labelname" }); + var summary = factory.CreateSummary("summary", "", new[] { "labelname" }, summaryConfig); + var histogram = factory.CreateHistogram("histogram", "", new[] { "labelname" }, histogramConfig); + // 4 families with 9 metrics total. - // Family for each of the above, in each is 0 metrics. - await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default); - await serializer.DidNotReceiveWithAnyArgs().WriteMetricAsync(default, default, default); - } + var serializer = Substitute.For(); + await registry.CollectAndSerializeAsync(serializer, default); - [TestMethod] - public async Task CreatingLabelledMetric_AfterObservingLabelledData_DoesNotExportUnlabelled() - { - var registry = Metrics.NewCustomRegistry(); - var factory = Metrics.WithCustomRegistry(registry); - - var summaryConfig = NewSummaryConfiguration(); - var histogramConfig = NewHistogramConfiguration(); - - var gauge = factory.CreateGauge("gauge", "", new[] { "labelname" }); - var counter = factory.CreateCounter("counter", "", new[] { "labelname" }); - var summary = factory.CreateSummary("summary", "", new[] { "labelname" }, summaryConfig); - var histogram = factory.CreateHistogram("histogram", "", new[] { "labelname" }, histogramConfig); - // 4 families with 9 metrics total. - - // Touch some labelled metrics. - gauge.WithLabels("labelvalue").Inc(); - counter.WithLabels("labelvalue").Inc(); - summary.WithLabels("labelvalue").Observe(123); - histogram.WithLabels("labelvalue").Observe(123); - - var serializer = Substitute.For(); - await registry.CollectAndSerializeAsync(serializer, default); - - // Family for each of the above, in each is 4 metrics (labelled only). - await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default); - await serializer.ReceivedWithAnyArgs(9).WriteMetricAsync(default, default, default); - - // Only after touching unlabelled do they get published. - gauge.Inc(); - counter.Inc(); - summary.Observe(123); - histogram.Observe(123); - - serializer.ClearReceivedCalls(); - await registry.CollectAndSerializeAsync(serializer, default); - - // Family for each of the above, in each is 8 metrics (unlabelled+labelled). - await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default); - await serializer.ReceivedWithAnyArgs(9 * 2).WriteMetricAsync(default, default, default); - } - #endregion + // Family for each of the above, in each is 0 metrics. + await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default, default, default, default); + } - [TestMethod] - public void RemovingLabeledInstance_ThenRecreatingIt_CreatesIndependentInstance() - { - var registry = Metrics.NewCustomRegistry(); - var factory = Metrics.WithCustomRegistry(registry); + [TestMethod] + public async Task CreatingLabelledMetric_AfterObservingLabelledData_DoesNotExportUnlabelled() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); + + var summaryConfig = NewSummaryConfiguration(); + var histogramConfig = NewHistogramConfiguration(); + + var gauge = factory.CreateGauge("gauge", "", new[] { "labelname" }); + var counter = factory.CreateCounter("counter", "", new[] { "labelname" }); + var summary = factory.CreateSummary("summary", "", new[] { "labelname" }, summaryConfig); + var histogram = factory.CreateHistogram("histogram", "", new[] { "labelname" }, histogramConfig); + // 4 families with 9 metrics total. + + // Touch some labelled metrics. + gauge.WithLabels("labelvalue").Inc(); + counter.WithLabels("labelvalue").Inc(); + summary.WithLabels("labelvalue").Observe(123); + histogram.WithLabels("labelvalue").Observe(123); + + var serializer = Substitute.For(); + await registry.CollectAndSerializeAsync(serializer, default); + + // Family for each of the above, in each is 4 metrics (labelled only). + await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + // gauge + counter + summary.sum + histogram.sum + summary quantile + await serializer.ReceivedWithAnyArgs(5).WriteMetricPointAsync(default, default, default, default(double), default, default, default); + // summary.count + 2x histogram bucket + histogram count + await serializer.ReceivedWithAnyArgs(4).WriteMetricPointAsync(default, default, default, default, default, default, default); + + // Only after touching unlabelled do they get published. + gauge.Inc(); + counter.Inc(); + summary.Observe(123); + histogram.Observe(123); + + serializer.ClearReceivedCalls(); + await registry.CollectAndSerializeAsync(serializer, default); + + // Family for each of the above, in each family the instance count now doubled as unlabelled instances are published. + await serializer.ReceivedWithAnyArgs(4).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + await serializer.ReceivedWithAnyArgs(10).WriteMetricPointAsync(default, default, default, default(double), default, default, default); + await serializer.ReceivedWithAnyArgs(8).WriteMetricPointAsync(default, default, default, default, default, default, default); + } + #endregion + + [TestMethod] + public void RemovingLabeledInstance_ThenRecreatingIt_CreatesIndependentInstance() + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); - var counter = factory.CreateCounter("counter", "", new[] { "foo" }); + var counter = factory.CreateCounter("counter", "", new[] { "foo" }); - var bar1 = counter.WithLabels("bar"); - bar1.Inc(); + var bar1 = counter.WithLabels("bar"); + bar1.Inc(); - Assert.AreEqual(1, bar1.Value); - bar1.Remove(); + Assert.AreEqual(1, bar1.Value); + bar1.Remove(); - // The new instance after the old one was removed must be independent. - var bar2 = counter.WithLabels("bar"); - Assert.AreEqual(0, bar2.Value); - } + // The new instance after the old one was removed must be independent. + var bar2 = counter.WithLabels("bar"); + Assert.AreEqual(0, bar2.Value); } } diff --git a/Tests.NetCore/MetricPusherTests.cs b/Tests.NetCore/MetricPusherTests.cs index 6669bb78..772952d6 100644 --- a/Tests.NetCore/MetricPusherTests.cs +++ b/Tests.NetCore/MetricPusherTests.cs @@ -27,13 +27,13 @@ void OnError(Exception ex) // Small interval to ensure that we exit fast. IntervalMilliseconds = 100, // Nothing listening there, should throw error right away. - Endpoint = "https://127.0.0.1:1", + Endpoint = "https://127.0.0.1:0", OnError = OnError }); pusher.Start(); - var onErrorWasCalled = onErrorCalled.Wait(TimeSpan.FromSeconds(5)); + var onErrorWasCalled = onErrorCalled.Wait(TimeSpan.FromSeconds(10)); Assert.IsTrue(onErrorWasCalled, "OnError was not called even though at least one failed push should have happened already."); Assert.IsNotNull(lastError); diff --git a/Tests.NetCore/MetricsTests.cs b/Tests.NetCore/MetricsTests.cs index 22669383..b8d1f9f0 100644 --- a/Tests.NetCore/MetricsTests.cs +++ b/Tests.NetCore/MetricsTests.cs @@ -4,409 +4,421 @@ using System.Linq; using System.Threading.Tasks; -namespace Prometheus.Tests +namespace Prometheus.Tests; + +[TestClass] +public sealed class MetricsTests { - [TestClass] - public sealed class MetricsTests - { - private CollectorRegistry _registry; - private MetricFactory _metrics; + private CollectorRegistry _registry; + private MetricFactory _metrics; - public MetricsTests() - { - _registry = Metrics.NewCustomRegistry(); - _metrics = Metrics.WithCustomRegistry(_registry); - } + public MetricsTests() + { + _registry = Metrics.NewCustomRegistry(); + _metrics = Metrics.WithCustomRegistry(_registry); + } - [TestMethod] - public void api_usage() - { - var gauge = _metrics.CreateGauge("name1", "help1"); - gauge.Inc(); - Assert.AreEqual(1, gauge.Value); - gauge.Inc(3.2); - Assert.AreEqual(4.2, gauge.Value); - gauge.Set(4); - Assert.AreEqual(4, gauge.Value); - gauge.Dec(0.2); - Assert.AreEqual(3.8, gauge.Value); - - Assert.ThrowsException(() => gauge.Labels("1")); - - var counter = _metrics.CreateCounter("name2", "help2", "label1"); - counter.Inc(); - counter.Inc(3.2); - counter.Inc(0); - Assert.ThrowsException(() => counter.Inc(-1)); - Assert.AreEqual(4.2, counter.Value); - - Assert.AreEqual(0, counter.Labels("a").Value); - counter.Labels("a").Inc(3.3); - counter.Labels("a").Inc(1.1); - Assert.AreEqual(4.4, counter.Labels("a").Value); - } + [TestMethod] + public void api_usage() + { + var gauge = _metrics.CreateGauge("name1", "help1"); + gauge.Inc(); + Assert.AreEqual(1, gauge.Value); + gauge.Inc(3.2); + Assert.AreEqual(4.2, gauge.Value); + gauge.Set(4); + Assert.AreEqual(4, gauge.Value); + gauge.Dec(0.2); + Assert.AreEqual(3.8, gauge.Value); + + Assert.ThrowsException(() => gauge.Labels("1")); + + var counter = _metrics.CreateCounter("name2", "help2", "label1"); + counter.Inc(); + counter.Inc(3.2); + counter.Inc(0); + Assert.ThrowsException(() => counter.Inc(-1)); + Assert.AreEqual(4.2, counter.Value); + + Assert.AreEqual(0, counter.Labels("a").Value); + counter.Labels("a").Inc(3.3); + counter.Labels("a").Inc(1.1); + Assert.AreEqual(4.4, counter.Labels("a").Value); + } - [TestMethod] - public async Task CreateCounter_WithDifferentRegistry_CreatesIndependentCounters() - { - var registry1 = Metrics.NewCustomRegistry(); - var registry2 = Metrics.NewCustomRegistry(); - var counter1 = Metrics.WithCustomRegistry(registry1) - .CreateCounter("counter", ""); - var counter2 = Metrics.WithCustomRegistry(registry2) - .CreateCounter("counter", ""); + [TestMethod] + public async Task CreateCounter_WithDifferentRegistry_CreatesIndependentCounters() + { + var registry1 = Metrics.NewCustomRegistry(); + var registry2 = Metrics.NewCustomRegistry(); + var counter1 = Metrics.WithCustomRegistry(registry1) + .CreateCounter("counter", ""); + var counter2 = Metrics.WithCustomRegistry(registry2) + .CreateCounter("counter", ""); - Assert.AreNotSame(counter1, counter2); + Assert.AreNotSame(counter1, counter2); - counter1.Inc(); - counter2.Inc(); + counter1.Inc(); + counter2.Inc(); - Assert.AreEqual(1, counter1.Value); - Assert.AreEqual(1, counter2.Value); + Assert.AreEqual(1, counter1.Value); + Assert.AreEqual(1, counter2.Value); - var serializer1 = Substitute.For(); - await registry1.CollectAndSerializeAsync(serializer1, default); + var serializer1 = Substitute.For(); + await registry1.CollectAndSerializeAsync(serializer1, default); - var serializer2 = Substitute.For(); - await registry2.CollectAndSerializeAsync(serializer2, default); + var serializer2 = Substitute.For(); + await registry2.CollectAndSerializeAsync(serializer2, default); - await serializer1.ReceivedWithAnyArgs().WriteFamilyDeclarationAsync(default, default); - await serializer1.ReceivedWithAnyArgs().WriteMetricAsync(default, default, default); + await serializer1.ReceivedWithAnyArgs().WriteFamilyDeclarationAsync(default, default, default, default, default, default); + await serializer1.ReceivedWithAnyArgs().WriteMetricPointAsync(default, default, default, default(double), default, default, default); - await serializer2.ReceivedWithAnyArgs().WriteFamilyDeclarationAsync(default, default); - await serializer2.ReceivedWithAnyArgs().WriteMetricAsync(default, default, default); - } + await serializer2.ReceivedWithAnyArgs().WriteFamilyDeclarationAsync(default, default, default, default, default, default); + await serializer2.ReceivedWithAnyArgs().WriteMetricPointAsync(default, default, default, default(double), default, default, default); + } - [TestMethod] - public async Task Export_FamilyWithOnlyNonpublishedUnlabeledMetrics_ExportsFamilyDeclaration() + [TestMethod] + public async Task Export_FamilyWithOnlyNonpublishedUnlabeledMetrics_ExportsFamilyDeclaration() + { + // See https://github.com/prometheus-net/prometheus-net/issues/196 + var metric = _metrics.CreateCounter("my_family", "", new CounterConfiguration { - // See https://github.com/prometheus-net/prometheus-net/issues/196 - var metric = _metrics.CreateCounter("my_family", "", new CounterConfiguration - { - SuppressInitialValue = true - }); + SuppressInitialValue = true + }); - var serializer = Substitute.For(); - await _registry.CollectAndSerializeAsync(serializer, default); + var serializer = Substitute.For(); + await _registry.CollectAndSerializeAsync(serializer, default); - await serializer.ReceivedWithAnyArgs(1).WriteFamilyDeclarationAsync(default, default); - await serializer.DidNotReceiveWithAnyArgs().WriteMetricAsync(default, default, default); - serializer.ClearReceivedCalls(); + await serializer.ReceivedWithAnyArgs(1).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default(double), default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default, default, default, default); + serializer.ClearReceivedCalls(); - metric.Inc(); - metric.Unpublish(); + metric.Inc(); + metric.Unpublish(); - await _registry.CollectAndSerializeAsync(serializer, default); + await _registry.CollectAndSerializeAsync(serializer, default); - await serializer.ReceivedWithAnyArgs(1).WriteFamilyDeclarationAsync(default, default); - await serializer.DidNotReceiveWithAnyArgs().WriteMetricAsync(default, default, default); - } + await serializer.ReceivedWithAnyArgs(1).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default(double), default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default, default, default, default); + } - [TestMethod] - public async Task Export_FamilyWithOnlyNonpublishedLabeledMetrics_ExportsFamilyDeclaration() + [TestMethod] + public async Task Export_FamilyWithOnlyNonpublishedLabeledMetrics_ExportsFamilyDeclaration() + { + // See https://github.com/prometheus-net/prometheus-net/issues/196 + var metric = _metrics.CreateCounter("my_family", "", new[] { "labelname" }, new CounterConfiguration { - // See https://github.com/prometheus-net/prometheus-net/issues/196 - var metric = _metrics.CreateCounter("my_family", "", new[] { "labelname" }, new CounterConfiguration - { - SuppressInitialValue = true, - }); + SuppressInitialValue = true, + }); - var instance = metric.WithLabels("labelvalue"); + var instance = metric.WithLabels("labelvalue"); - var serializer = Substitute.For(); - await _registry.CollectAndSerializeAsync(serializer, default); + var serializer = Substitute.For(); + await _registry.CollectAndSerializeAsync(serializer, default); - await serializer.ReceivedWithAnyArgs(1).WriteFamilyDeclarationAsync(default, default); - await serializer.DidNotReceiveWithAnyArgs().WriteMetricAsync(default, default, default); - serializer.ClearReceivedCalls(); + await serializer.ReceivedWithAnyArgs(1).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default(double), default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default, default, default, default); + serializer.ClearReceivedCalls(); - instance.Inc(); - instance.Unpublish(); + instance.Inc(); + instance.Unpublish(); - await _registry.CollectAndSerializeAsync(serializer, default); + await _registry.CollectAndSerializeAsync(serializer, default); - await serializer.ReceivedWithAnyArgs(1).WriteFamilyDeclarationAsync(default, default); - await serializer.DidNotReceiveWithAnyArgs().WriteMetricAsync(default, default, default); - } + await serializer.ReceivedWithAnyArgs(1).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default(double), default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default, default, default, default); + } - [TestMethod] - public async Task DisposeChild_RemovesMetric() + [TestMethod] + public async Task DisposeChild_RemovesMetric() + { + var metric = _metrics.CreateCounter("my_family", "", new[] { "labelname" }, new CounterConfiguration { - var metric = _metrics.CreateCounter("my_family", "", new[] { "labelname" }, new CounterConfiguration - { - SuppressInitialValue = true, - }); + SuppressInitialValue = true, + }); - var instance = metric.WithLabels("labelvalue"); - instance.Inc(); + var instance = metric.WithLabels("labelvalue"); + instance.Inc(); - var serializer = Substitute.For(); - await _registry.CollectAndSerializeAsync(serializer, default); + var serializer = Substitute.For(); + await _registry.CollectAndSerializeAsync(serializer, default); - await serializer.ReceivedWithAnyArgs(1).WriteFamilyDeclarationAsync(default, default); - await serializer.ReceivedWithAnyArgs(1).WriteMetricAsync(default, default, default); - serializer.ClearReceivedCalls(); + await serializer.ReceivedWithAnyArgs(1).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + await serializer.ReceivedWithAnyArgs(1).WriteMetricPointAsync(default, default, default, default(double), default, default, default); + serializer.ClearReceivedCalls(); - instance.Dispose(); + instance.Dispose(); - await _registry.CollectAndSerializeAsync(serializer, default); + await _registry.CollectAndSerializeAsync(serializer, default); - await serializer.ReceivedWithAnyArgs(1).WriteFamilyDeclarationAsync(default, default); - await serializer.DidNotReceiveWithAnyArgs().WriteMetricAsync(default, default, default); - } + await serializer.ReceivedWithAnyArgs(1).WriteFamilyDeclarationAsync(default, default, default, default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default(double), default, default, default); + await serializer.DidNotReceiveWithAnyArgs().WriteMetricPointAsync(default, default, default, default, default, default, default); + } - [TestMethod] - public void histogram_no_buckets() + [TestMethod] + public void histogram_no_buckets() + { + try { - try - { - _metrics.CreateHistogram("hist", "help", new HistogramConfiguration - { - Buckets = new double[0] - }); - - Assert.Fail("Expected an exception"); - } - catch (ArgumentException ex) + _metrics.CreateHistogram("hist", "help", new HistogramConfiguration { - Assert.AreEqual("Histogram must have at least one bucket", ex.Message); - } + Buckets = new double[0] + }); + + Assert.Fail("Expected an exception"); + } + catch (ArgumentException ex) + { + Assert.AreEqual("Histogram must have at least one bucket", ex.Message); } + } - [TestMethod] - public void histogram_buckets_do_not_increase() + [TestMethod] + public void histogram_buckets_do_not_increase() + { + try { - try + _metrics.CreateHistogram("hist", "help", new HistogramConfiguration { - _metrics.CreateHistogram("hist", "help", new HistogramConfiguration - { - Buckets = new double[] { 0.5, 0.1 } - }); - - Assert.Fail("Expected an exception"); - } - catch (ArgumentException ex) - { - Assert.AreEqual("Bucket values must be increasing", ex.Message); - } - } + Buckets = new double[] { 0.5, 0.1 } + }); - [TestMethod] - public void histogram_exponential_buckets_are_correct() + Assert.Fail("Expected an exception"); + } + catch (ArgumentException ex) { - var bucketsStart = 1.1; - var bucketsFactor = 2.4; - var bucketsCount = 4; + Assert.AreEqual("Bucket values must be increasing", ex.Message); + } + } - var buckets = Histogram.ExponentialBuckets(bucketsStart, bucketsFactor, bucketsCount); + [TestMethod] + public void histogram_exponential_buckets_are_correct() + { + var bucketsStart = 1.1; + var bucketsFactor = 2.4; + var bucketsCount = 4; - Assert.AreEqual(bucketsCount, buckets.Length); - Assert.AreEqual(1.1, buckets[0]); - Assert.AreEqual(2.64, buckets[1]); - Assert.AreEqual(6.336, buckets[2]); - Assert.AreEqual(15.2064, buckets[3]); - } + var buckets = Histogram.ExponentialBuckets(bucketsStart, bucketsFactor, bucketsCount); - [TestMethod] - public void histogram_exponential_buckets_with_non_positive_count_throws() - { - var bucketsStart = 1; - var bucketsFactor = 2; + Assert.AreEqual(bucketsCount, buckets.Length); + Assert.AreEqual(1.1, buckets[0]); + Assert.AreEqual(2.64, buckets[1]); + Assert.AreEqual(6.336, buckets[2]); + Assert.AreEqual(15.2064, buckets[3]); + } - Assert.ThrowsException(() => Histogram.ExponentialBuckets(bucketsStart, bucketsFactor, -1)); - Assert.ThrowsException(() => Histogram.ExponentialBuckets(bucketsStart, bucketsFactor, 0)); - } + [TestMethod] + public void histogram_exponential_buckets_with_non_positive_count_throws() + { + var bucketsStart = 1; + var bucketsFactor = 2; - [TestMethod] - public void histogram_exponential_buckets_with_non_positive_start_throws() - { - var bucketsFactor = 2; - var bucketsCount = 5; + Assert.ThrowsException(() => Histogram.ExponentialBuckets(bucketsStart, bucketsFactor, -1)); + Assert.ThrowsException(() => Histogram.ExponentialBuckets(bucketsStart, bucketsFactor, 0)); + } - Assert.ThrowsException(() => Histogram.ExponentialBuckets(-1, bucketsFactor, bucketsCount)); - Assert.ThrowsException(() => Histogram.ExponentialBuckets(0, bucketsFactor, bucketsCount)); - } + [TestMethod] + public void histogram_exponential_buckets_with_non_positive_start_throws() + { + var bucketsFactor = 2; + var bucketsCount = 5; - [TestMethod] - public void histogram_exponential_buckets_with__factor_less_than_one_throws() - { - var bucketsStart = 1; - var bucketsCount = 5; + Assert.ThrowsException(() => Histogram.ExponentialBuckets(-1, bucketsFactor, bucketsCount)); + Assert.ThrowsException(() => Histogram.ExponentialBuckets(0, bucketsFactor, bucketsCount)); + } - Assert.ThrowsException(() => Histogram.ExponentialBuckets(bucketsStart, 0.9, bucketsCount)); - Assert.ThrowsException(() => Histogram.ExponentialBuckets(bucketsStart, 0, bucketsCount)); - Assert.ThrowsException(() => Histogram.ExponentialBuckets(bucketsStart, -1, bucketsCount)); - } + [TestMethod] + public void histogram_exponential_buckets_with__factor_less_than_one_throws() + { + var bucketsStart = 1; + var bucketsCount = 5; - [TestMethod] - public void histogram_linear_buckets_are_correct() - { - var bucketsStart = 1.1; - var bucketsWidth = 2.4; - var bucketsCount = 4; + Assert.ThrowsException(() => Histogram.ExponentialBuckets(bucketsStart, 0.9, bucketsCount)); + Assert.ThrowsException(() => Histogram.ExponentialBuckets(bucketsStart, 0, bucketsCount)); + Assert.ThrowsException(() => Histogram.ExponentialBuckets(bucketsStart, -1, bucketsCount)); + } - var buckets = Histogram.LinearBuckets(bucketsStart, bucketsWidth, bucketsCount); + [TestMethod] + public void histogram_linear_buckets_are_correct() + { + var bucketsStart = 1.1; + var bucketsWidth = 2.4; + var bucketsCount = 4; - Assert.AreEqual(bucketsCount, buckets.Length); - Assert.AreEqual(1.1, buckets[0]); - Assert.AreEqual(3.5, buckets[1]); - Assert.AreEqual(5.9, buckets[2]); - Assert.AreEqual(8.3, buckets[3]); - } + var buckets = Histogram.LinearBuckets(bucketsStart, bucketsWidth, bucketsCount); - [TestMethod] - public void histogram_linear_buckets_with_non_positive_count_throws() - { - var bucketsStart = 1; - var bucketsWidth = 2; + Assert.AreEqual(bucketsCount, buckets.Length); + Assert.AreEqual(1.1, buckets[0]); + Assert.AreEqual(3.5, buckets[1]); + Assert.AreEqual(5.9, buckets[2]); + Assert.AreEqual(8.3, buckets[3]); + } - Assert.ThrowsException(() => Histogram.LinearBuckets(bucketsStart, bucketsWidth, -1)); - Assert.ThrowsException(() => Histogram.LinearBuckets(bucketsStart, bucketsWidth, 0)); - } + [TestMethod] + public void histogram_linear_buckets_with_non_positive_count_throws() + { + var bucketsStart = 1; + var bucketsWidth = 2; - [TestMethod] - public void same_labels_return_same_instance() - { - var gauge = _metrics.CreateGauge("name1", "help1", "label1"); + Assert.ThrowsException(() => Histogram.LinearBuckets(bucketsStart, bucketsWidth, -1)); + Assert.ThrowsException(() => Histogram.LinearBuckets(bucketsStart, bucketsWidth, 0)); + } - var labelled1 = gauge.Labels("1"); + [TestMethod] + public void same_labels_return_same_instance() + { + var gauge = _metrics.CreateGauge("name1", "help1", "label1"); - var labelled2 = gauge.Labels("1"); + var labelled1 = gauge.Labels("1"); - Assert.AreSame(labelled2, labelled1); - } + var labelled2 = gauge.Labels("1"); - [TestMethod] - public async Task CreateMetric_WithSameMetadataButDifferentLabels_CreatesMetric() - { - // This is a deviation from standard Prometheus practices, where you can only use a metric name with a single set of label names. - // Instead, this library allows the same metric name to be used with different sets of label names, as long as all other metadata exists. - // The reason for this is that we want to support using prometheus-net as a bridge to report data originating from metrics systems - // that do not have such limitations (such as the .NET 6 Meters API). Such scenarios can create any combinations of label names. - // This is permissible by OpenMetrics, though violates the Prometheus client authoring requirements (which is OK - what can you do). - - var gauge1 = _metrics.CreateGauge("name1", "h"); - var gauge2 = _metrics.CreateGauge("name1", "h", "label1"); - var gauge3 = _metrics.CreateGauge("name1", "h", "label2"); - var gauge4 = _metrics.CreateGauge("name1", "h", "label1", "label3"); - - // We expect all the metrics registered to be unique instances. - Assert.AreNotSame(gauge1, gauge2); - Assert.AreNotSame(gauge2, gauge3); - Assert.AreNotSame(gauge3, gauge4); - - var gauge1Again = _metrics.CreateGauge("name1", "h"); - var gauge2Again = _metrics.CreateGauge("name1", "h", "label1"); - var gauge3Again = _metrics.CreateGauge("name1", "h", "label2"); - var gauge4Again = _metrics.CreateGauge("name1", "h", "label1", "label3"); - - // We expect the instances to be sticky to the specific set of label names. - Assert.AreSame(gauge1, gauge1Again); - Assert.AreSame(gauge2, gauge2Again); - Assert.AreSame(gauge3, gauge3Again); - Assert.AreSame(gauge4, gauge4Again); - - var canary1 = 543289; - var canary2 = 735467; - var canary3 = 627864; - var canary4 = 837855; - - gauge1.Set(canary1); - gauge2.Set(canary2); - gauge3.Set(canary3); - gauge4.Set(canary4); - - var serialized = await _registry.CollectAndSerializeToStringAsync(); - - // We expect all of them to work (to publish data) and to work independently. - StringAssert.Contains(serialized, canary1.ToString()); - StringAssert.Contains(serialized, canary2.ToString()); - StringAssert.Contains(serialized, canary3.ToString()); - StringAssert.Contains(serialized, canary4.ToString()); - } + Assert.AreSame(labelled2, labelled1); + } - [TestMethod] - public void cannot_create_metrics_with_the_same_name_and_labels_but_different_type() - { - _metrics.CreateGauge("name1", "h", "label1"); - try - { - _metrics.CreateCounter("name1", "h", "label1"); - Assert.Fail("should have thrown"); - } - catch (InvalidOperationException e) - { - Assert.AreEqual("Collector of a different type with the same identity is already registered.", e.Message); - } - } + [TestMethod] + public async Task CreateMetric_WithSameMetadataButDifferentLabels_CreatesMetric() + { + // This is a deviation from standard Prometheus practices, where you can only use a metric name with a single set of label names. + // Instead, this library allows the same metric name to be used with different sets of label names, as long as all other metadata exists. + // The reason for this is that we want to support using prometheus-net as a bridge to report data originating from metrics systems + // that do not have such limitations (such as the .NET 6 Meters API). Such scenarios can create any combinations of label names. + // This is permissible by OpenMetrics, though violates the Prometheus client authoring requirements (which is OK - what can you do). + + var gauge1 = _metrics.CreateGauge("name1", "h"); + var gauge2 = _metrics.CreateGauge("name1", "h", "label1"); + var gauge3 = _metrics.CreateGauge("name1", "h", "label2"); + var gauge4 = _metrics.CreateGauge("name1", "h", "label1", "label3"); + + // We expect all the metrics registered to be unique instances. + Assert.AreNotSame(gauge1, gauge2); + Assert.AreNotSame(gauge2, gauge3); + Assert.AreNotSame(gauge3, gauge4); + + var gauge1Again = _metrics.CreateGauge("name1", "h"); + var gauge2Again = _metrics.CreateGauge("name1", "h", "label1"); + var gauge3Again = _metrics.CreateGauge("name1", "h", "label2"); + var gauge4Again = _metrics.CreateGauge("name1", "h", "label1", "label3"); + + // We expect the instances to be sticky to the specific set of label names. + Assert.AreSame(gauge1, gauge1Again); + Assert.AreSame(gauge2, gauge2Again); + Assert.AreSame(gauge3, gauge3Again); + Assert.AreSame(gauge4, gauge4Again); + + var canary1 = 543289; + var canary2 = 735467; + var canary3 = 627864; + var canary4 = 837855; + + gauge1.Set(canary1); + gauge2.Set(canary2); + gauge3.Set(canary3); + gauge4.Set(canary4); + + var serialized = await _registry.CollectAndSerializeToStringAsync(); + + // We expect all of them to work (to publish data) and to work independently. + StringAssert.Contains(serialized, canary1.ToString()); + StringAssert.Contains(serialized, canary2.ToString()); + StringAssert.Contains(serialized, canary3.ToString()); + StringAssert.Contains(serialized, canary4.ToString()); + + // We expect them all to be serialized as different metric instances in the same metric family. + var familyDeclaration = "# TYPE name1 gauge"; + StringAssert.Contains(serialized, familyDeclaration); + var firstIndex = serialized.IndexOf(familyDeclaration); + var lastIndex = serialized.LastIndexOf(familyDeclaration); + + Assert.AreEqual(firstIndex, lastIndex); + } - [TestMethod] - public void metric_names() + [TestMethod] + public void cannot_create_metrics_with_the_same_name_and_labels_but_different_type() + { + _metrics.CreateGauge("name1", "h", "label1"); + try { - Assert.ThrowsException(() => _metrics.CreateGauge("my-metric", "help")); - Assert.ThrowsException(() => _metrics.CreateGauge("my!metric", "help")); - Assert.ThrowsException(() => _metrics.CreateGauge("%", "help")); - Assert.ThrowsException(() => _metrics.CreateGauge("5a", "help")); - - _metrics.CreateGauge("abc", "help"); - _metrics.CreateGauge("myMetric2", "help"); - _metrics.CreateGauge("a:3", "help"); + _metrics.CreateCounter("name1", "h", "label1"); + Assert.Fail("should have thrown"); } - - [TestMethod] - public void label_names() + catch (InvalidOperationException e) { - Assert.ThrowsException(() => _metrics.CreateGauge("a", "help1", "my-metric")); - Assert.ThrowsException(() => _metrics.CreateGauge("a", "help1", "my!metric")); - Assert.ThrowsException(() => _metrics.CreateGauge("a", "help1", "my%metric")); - Assert.ThrowsException(() => _metrics.CreateHistogram("a", "help1", "le")); - _metrics.CreateGauge("a", "help1", "my:metric"); - _metrics.CreateGauge("b", "help1", "good_name"); - - Assert.ThrowsException(() => _metrics.CreateGauge("c", "help1", "__reserved")); + Assert.AreEqual("Collector of a different type with the same name is already registered.", e.Message); } + } - [TestMethod] - public void label_values() - { - var metric = _metrics.CreateGauge("a", "help1", "mylabelname"); + [TestMethod] + public void metric_names() + { + Assert.ThrowsException(() => _metrics.CreateGauge("my-metric", "help")); + Assert.ThrowsException(() => _metrics.CreateGauge("my!metric", "help")); + Assert.ThrowsException(() => _metrics.CreateGauge("%", "help")); + Assert.ThrowsException(() => _metrics.CreateGauge("5a", "help")); + Assert.ThrowsException(() => _metrics.CreateGauge("a:3", "help")); + + _metrics.CreateGauge("abc", "help"); + _metrics.CreateGauge("myMetric2", "help"); + } - metric.Labels(""); - metric.Labels("mylabelvalue"); - Assert.ThrowsException(() => metric.Labels(new string[] { null })); - } + [TestMethod] + public void label_names() + { + Assert.ThrowsException(() => _metrics.CreateGauge("a", "help1", "my-label")); + Assert.ThrowsException(() => _metrics.CreateGauge("b", "help1", "my!label")); + Assert.ThrowsException(() => _metrics.CreateGauge("c", "help1", "my%label")); + Assert.ThrowsException(() => _metrics.CreateHistogram("d", "help1", "le")); + Assert.ThrowsException(() => _metrics.CreateHistogram("e", "help1", "my:label")); + _metrics.CreateGauge("f", "help1", "good_name"); + + Assert.ThrowsException(() => _metrics.CreateGauge("g", "help1", "__reserved")); + } - [TestMethod] - public void GetAllLabelValues_GetsThemAll() - { - var metric = _metrics.CreateGauge("ahdgfln", "ahegrtijpm", "a", "b", "c"); - metric.Labels("1", "2", "3"); - metric.Labels("4", "5", "6"); + [TestMethod] + public void label_values() + { + var metric = _metrics.CreateGauge("a", "help1", "mylabelname"); - var values = metric.GetAllLabelValues().OrderBy(v => v[0]).ToArray(); + metric.Labels(""); + metric.Labels("mylabelvalue"); + Assert.ThrowsException(() => metric.Labels(new string[] { null })); + } - Assert.AreEqual(2, values.Length); + [TestMethod] + public void GetAllLabelValues_GetsThemAll() + { + var metric = _metrics.CreateGauge("ahdgfln", "ahegrtijpm", "a", "b", "c"); + metric.Labels("1", "2", "3"); + metric.Labels("4", "5", "6"); - Assert.AreEqual(3, values[0].Length); - Assert.AreEqual("1", values[0][0]); - Assert.AreEqual("2", values[0][1]); - Assert.AreEqual("3", values[0][2]); + var values = metric.GetAllLabelValues().OrderBy(v => v[0]).ToArray(); - Assert.AreEqual(3, values[1].Length); - Assert.AreEqual("4", values[1][0]); - Assert.AreEqual("5", values[1][1]); - Assert.AreEqual("6", values[1][2]); - } + Assert.AreEqual(2, values.Length); - [TestMethod] - public void GetAllLabelValues_DoesNotGetUnlabelled() - { - var metric = _metrics.CreateGauge("ahdggfagfln", "ahegrgftijpm"); - metric.Inc(); + Assert.AreEqual(3, values[0].Length); + Assert.AreEqual("1", values[0][0]); + Assert.AreEqual("2", values[0][1]); + Assert.AreEqual("3", values[0][2]); - var values = metric.GetAllLabelValues().ToArray(); + Assert.AreEqual(3, values[1].Length); + Assert.AreEqual("4", values[1][0]); + Assert.AreEqual("5", values[1][1]); + Assert.AreEqual("6", values[1][2]); + } - Assert.AreEqual(0, values.Length); - } + [TestMethod] + public void GetAllLabelValues_DoesNotGetUnlabelled() + { + var metric = _metrics.CreateGauge("ahdggfagfln", "ahegrgftijpm"); + metric.Inc(); + + var values = metric.GetAllLabelValues().ToArray(); + + Assert.AreEqual(0, values.Length); } } diff --git a/Tests.NetCore/QuantileStreamTests.cs b/Tests.NetCore/QuantileStreamTests.cs index 42690abe..f28d0144 100644 --- a/Tests.NetCore/QuantileStreamTests.cs +++ b/Tests.NetCore/QuantileStreamTests.cs @@ -32,24 +32,6 @@ public void TestTargetedQuery() VerifyPercsWithAbsoluteEpsilon(a, s); } - [TestMethod] - public void TestLowBiasedQuery() - { - var random = new Random(42); - var s = QuantileStream.NewLowBiased(RelativeEpsilon); - var a = PopulateStream(s, random); - VerifyLowPercsWithRelativeEpsilon(a, s); - } - - [TestMethod] - public void TestHighBiasedQuery() - { - var random = new Random(42); - var s = QuantileStream.NewHighBiased(RelativeEpsilon); - var a = PopulateStream(s, random); - VerifyHighPercsWithRelativeEpsilon(a, s); - } - [TestMethod] public void TestUncompressed() { diff --git a/Tests.NetCore/TestExtensions.cs b/Tests.NetCore/TestExtensions.cs index 2442c8e6..34ab4c04 100644 --- a/Tests.NetCore/TestExtensions.cs +++ b/Tests.NetCore/TestExtensions.cs @@ -2,15 +2,14 @@ using System.Text; using System.Threading.Tasks; -namespace Prometheus.Tests +namespace Prometheus.Tests; + +internal static class TestExtensions { - internal static class TestExtensions + public static async Task CollectAndSerializeToStringAsync(this CollectorRegistry registry, ExpositionFormat expositionFormat = ExpositionFormat.PrometheusText) { - public static async Task CollectAndSerializeToStringAsync(this CollectorRegistry registry) - { - var buffer = new MemoryStream(); - await registry.CollectAndExportAsTextAsync(buffer); - return Encoding.UTF8.GetString(buffer.ToArray()); - } + var buffer = new MemoryStream(); + await registry.CollectAndExportAsTextAsync(buffer, expositionFormat); + return Encoding.UTF8.GetString(buffer.ToArray()); } } diff --git a/Tests.NetCore/Tests.NetCore.csproj b/Tests.NetCore/Tests.NetCore.csproj index 71d24f2d..adbfcd4f 100644 --- a/Tests.NetCore/Tests.NetCore.csproj +++ b/Tests.NetCore/Tests.NetCore.csproj @@ -1,7 +1,7 @@  - net6.0 + net7.0 false @@ -12,9 +12,9 @@ - - - + + + diff --git a/Tests.NetCore/TextSerializerTests.cs b/Tests.NetCore/TextSerializerTests.cs new file mode 100644 index 00000000..922d8797 --- /dev/null +++ b/Tests.NetCore/TextSerializerTests.cs @@ -0,0 +1,331 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Prometheus.Tests; + +[TestClass] +public class TextSerializerTests +{ + [ClassInitialize] + public static void BeforeClass(TestContext testContext) + { + ObservedExemplar.NowProvider = () => TestNow; + } + + [ClassCleanup] + public static void AfterClass() + { + ObservedExemplar.NowProvider = ObservedExemplar.DefaultNowProvider; + } + + [TestMethod] + public async Task ValidateTextFmtSummaryExposition_Labels() + { + var result = await TestCase.Run(factory => + { + var summary = factory.CreateSummary("boom_bam", "", new SummaryConfiguration + { + LabelNames = new[] { "blah" }, + Objectives = new[] + { + new QuantileEpsilonPair(0.5, 0.05), + } + }); + + summary.WithLabels("foo").Observe(3); + }); + + result.ShouldBe(@"# HELP boom_bam +# TYPE boom_bam summary +boom_bam_sum{blah=""foo""} 3 +boom_bam_count{blah=""foo""} 1 +boom_bam{blah=""foo"",quantile=""0.5""} 3 +"); + } + + [TestMethod] + public async Task ValidateTextFmtSummaryExposition_NoLabels() + { + var result = await TestCase.Run(factory => + { + var summary = factory.CreateSummary("boom_bam", "something", new SummaryConfiguration + { + Objectives = new[] + { + new QuantileEpsilonPair(0.5, 0.05), + } + }); + summary.Observe(3); + }); + + result.ShouldBe(@"# HELP boom_bam something +# TYPE boom_bam summary +boom_bam_sum 3 +boom_bam_count 1 +boom_bam{quantile=""0.5""} 3 +"); + } + + + [TestMethod] + public async Task ValidateTextFmtGaugeExposition_Labels() + { + var result = await TestCase.Run(factory => + { + var gauge = factory.CreateGauge("boom_bam", "", new GaugeConfiguration + { + LabelNames = new[] { "blah" } + }); + + gauge.WithLabels("foo").IncTo(10); + }); + + result.ShouldBe(@"# HELP boom_bam +# TYPE boom_bam gauge +boom_bam{blah=""foo""} 10 +"); + } + + [TestMethod] + public async Task ValidateTextFmtCounterExposition_Labels() + { + var result = await TestCase.Run(factory => + { + var counter = factory.CreateCounter("boom_bam", "", new CounterConfiguration + { + LabelNames = new[] { "blah" } + }); + + counter.WithLabels("foo").IncTo(10); + }); + + result.ShouldBe("# HELP boom_bam \n" + + "# TYPE boom_bam counter\n" + + "boom_bam{blah=\"foo\"} 10\n"); + } + + [TestMethod] + public async Task ValidateTextFmtCounterExposition_TotalSuffixInName() + { + var result = await TestCase.Run(factory => + { + var counter = factory.CreateCounter("boom_bam_total", "", new CounterConfiguration + { + LabelNames = new[] { "blah" } + }); + + counter.WithLabels("foo").IncTo(10); + }); + + // This tests that the counter exposition format isn't influenced by openmetrics codepaths when it comes to the + // _total suffix + result.ShouldBe("# HELP boom_bam_total \n" + + "# TYPE boom_bam_total counter\n" + + "boom_bam_total{blah=\"foo\"} 10\n"); + } + + [TestMethod] + public async Task ValidateTextFmtHistogramExposition_Labels() + { + var result = await TestCase.Run(factory => + { + var counter = factory.CreateHistogram("boom_bam", "", new HistogramConfiguration + { + LabelNames = new[] { "blah" }, + Buckets = new[] { 1.0 } + }); + + counter.WithLabels("foo").Observe(0.5); + }); + + result.ShouldBe(@"# HELP boom_bam +# TYPE boom_bam histogram +boom_bam_sum{blah=""foo""} 0.5 +boom_bam_count{blah=""foo""} 1 +boom_bam_bucket{blah=""foo"",le=""1""} 1 +boom_bam_bucket{blah=""foo"",le=""+Inf""} 1 +"); + } + + [TestMethod] + public async Task ValidateTextFmtHistogramExposition_NoLabels() + { + var result = await TestCase.Run(factory => + { + var counter = factory.CreateHistogram("boom_bam", "something", new HistogramConfiguration + { + Buckets = new[] { 1.0, Math.Pow(10, 45) } + }); + + counter.Observe(0.5); + }); + + result.ShouldBe(@"# HELP boom_bam something +# TYPE boom_bam histogram +boom_bam_sum 0.5 +boom_bam_count 1 +boom_bam_bucket{le=""1""} 1 +boom_bam_bucket{le=""1e+45""} 1 +boom_bam_bucket{le=""+Inf""} 1 +"); + } + + [TestMethod] + public async Task ValidateOpenMetricsFmtHistogram_Basic() + { + var result = await TestCase.RunOpenMetrics(factory => + { + var counter = factory.CreateHistogram("boom_bam", "something", new HistogramConfiguration + { + Buckets = new[] { 1, 2.5 } + }); + + counter.Observe(1.5); + counter.Observe(1); + }); + + // This asserts that the le label has been modified and that we have a EOF + result.ShouldBe(@"# HELP boom_bam something +# TYPE boom_bam histogram +boom_bam_sum 2.5 +boom_bam_count 2 +boom_bam_bucket{le=""1.0""} 1 +boom_bam_bucket{le=""2.5""} 2 +boom_bam_bucket{le=""+Inf""} 2 +# EOF +"); + } + + [TestMethod] + public async Task ValidateOpenMetricsFmtHistogram_WithExemplar() + { + var result = await TestCase.RunOpenMetrics(factory => + { + var counter = factory.CreateHistogram("boom_bam", "something", new HistogramConfiguration + { + Buckets = new[] { 1, 2.5, 3, Math.Pow(10, 45) } + }); + + counter.Observe(1, Exemplar.From(Exemplar.Pair("traceID", "1"))); + counter.Observe(1.5, Exemplar.From(Exemplar.Pair("traceID", "2"))); + counter.Observe(4, Exemplar.From(Exemplar.Pair("traceID", "3"))); + counter.Observe(Math.Pow(10, 44), Exemplar.From(Exemplar.Pair("traceID", "4"))); + }); + + // This asserts histogram OpenMetrics form with exemplars and also using numbers which are large enough for + // scientific notation + result.ShouldBe(@"# HELP boom_bam something +# TYPE boom_bam histogram +boom_bam_sum 1e+44 +boom_bam_count 4 +boom_bam_bucket{le=""1.0""} 1 # {traceID=""1""} 1.0 1668779954.714 +boom_bam_bucket{le=""2.5""} 2 # {traceID=""2""} 1.5 1668779954.714 +boom_bam_bucket{le=""3.0""} 2 +boom_bam_bucket{le=""1e+45""} 4 # {traceID=""4""} 1e+44 1668779954.714 +boom_bam_bucket{le=""+Inf""} 4 +# EOF +"); + } + + [TestMethod] + public async Task ValidateOpenMetricsFmtCounter_MultiItemExemplar() + { + var result = await TestCase.RunOpenMetrics(factory => + { + var counter = factory.CreateCounter("boom_bam", "", new CounterConfiguration + { + LabelNames = new[] { "blah" } + }); + + counter.WithLabels("foo").Inc(1, + Exemplar.From(Exemplar.Pair("traceID", "1234"), Exemplar.Pair("yaay", "4321"))); + }); + // This asserts that multi-labeled exemplars work as well not supplying a _total suffix in the counter name. + result.ShouldBe(@"# HELP boom_bam +# TYPE boom_bam unknown +boom_bam{blah=""foo""} 1.0 # {traceID=""1234"",yaay=""4321""} 1.0 1668779954.714 +# EOF +"); + } + + [TestMethod] + public async Task ValidateOpenMetricsFmtCounter_TotalInNameSuffix() + { + var result = await TestCase.RunOpenMetrics(factory => + { + var counter = factory.CreateCounter("boom_bam_total", "", new CounterConfiguration + { + LabelNames = new[] { "blah" } + }); + + counter.WithLabels("foo").Inc(1, + Exemplar.From(Exemplar.Pair("traceID", "1234"), Exemplar.Pair("yaay", "4321"))); + }); + // This tests the shape of OpenMetrics when _total suffix is supplied + result.ShouldBe(@"# HELP boom_bam +# TYPE boom_bam counter +boom_bam_total{blah=""foo""} 1.0 # {traceID=""1234"",yaay=""4321""} 1.0 1668779954.714 +# EOF +"); + } + + private const double TestNow = 1668779954.714; + + private class TestCase + { + private readonly String raw; + private readonly List lines; + + private TestCase(List lines, string raw) + { + this.lines = lines; + this.raw = raw; + } + + public static async Task RunOpenMetrics(Action register) + { + return await Run(register, ExpositionFormat.OpenMetricsText); + } + + public static async Task Run(Action register, ExpositionFormat format = ExpositionFormat.PrometheusText) + { + var registry = Metrics.NewCustomRegistry(); + var factory = Metrics.WithCustomRegistry(registry); + + register(factory); + + using var stream = new MemoryStream(); + await registry.CollectAndExportAsTextAsync(stream, format); + + var lines = new List(); + stream.Position = 0; + var raw = new StreamReader(stream).ReadToEnd(); + stream.Position = 0; + + using StreamReader reader = new StreamReader(stream); + while (!reader.EndOfStream) + { + lines.Add(reader.ReadLine()); + } + + return new TestCase(lines, raw); + } + + public void DumpExposition() + { + foreach (var line in lines) + { + Console.WriteLine(line); + } + } + + public void ShouldBe(string expected) + { + expected = expected.Replace("\r", ""); + Assert.AreEqual(expected, raw); + } + } +} \ No newline at end of file diff --git a/Tests.NetFramework/app.config b/Tests.NetFramework/app.config index da5fff44..0ae1d6b2 100644 --- a/Tests.NetFramework/app.config +++ b/Tests.NetFramework/app.config @@ -1,23 +1,34 @@  - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests.NetFramework/packages.config b/Tests.NetFramework/packages.config index 2f70dd54..5644b6c2 100644 --- a/Tests.NetFramework/packages.config +++ b/Tests.NetFramework/packages.config @@ -1,9 +1,12 @@  - - + + + + + diff --git a/Tests.NetFramework/tests.netframework.csproj b/Tests.NetFramework/tests.netframework.csproj index 78c0cc0f..98ce9ba4 100644 --- a/Tests.NetFramework/tests.netframework.csproj +++ b/Tests.NetFramework/tests.netframework.csproj @@ -1,6 +1,6 @@  - + Debug AnyCPU @@ -56,18 +56,28 @@ - ..\packages\MSTest.TestFramework.2.2.10\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.dll + ..\packages\MSTest.TestFramework.3.0.2\lib\net462\Microsoft.VisualStudio.TestPlatform.TestFramework.dll - ..\packages\MSTest.TestFramework.2.2.10\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll + ..\packages\MSTest.TestFramework.3.0.2\lib\net462\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll ..\packages\NSubstitute.4.4.0\lib\net46\NSubstitute.dll + + ..\packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll + + + ..\packages\System.Memory.4.5.5\lib\net461\System.Memory.dll + + + + ..\packages\System.Numerics.Vectors.4.5.0\lib\net46\System.Numerics.Vectors.dll + ..\packages\System.Runtime.CompilerServices.Unsafe.6.0.0\lib\net461\System.Runtime.CompilerServices.Unsafe.dll @@ -156,6 +166,9 @@ TestExtensions.cs + + TextSerializerTests.cs + ThreadSafeDoubleTests.cs @@ -175,8 +188,8 @@ This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - + + - + \ No newline at end of file diff --git a/prometheus-net.sln b/prometheus-net.sln index 26c01936..33799d9e 100644 --- a/prometheus-net.sln +++ b/prometheus-net.sln @@ -55,6 +55,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.Web.NetFramework", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sample.Console.NoAspNetCore", "Sample.Console.NoAspNetCore\Sample.Console.NoAspNetCore.csproj", "{673B5DEC-8530-4226-B923-9D97A6AC29DB}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sample.NetStandard", "Sample.NetStandard\Sample.NetStandard.csproj", "{4F91DAA7-9DD3-418F-A276-84ABFED388A5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.Console.Exemplars", "Sample.Console.Exemplars\Sample.Console.Exemplars.csproj", "{7F4947F1-C9DD-42F9-867D-5AC931478205}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -94,7 +98,6 @@ Global {1C13A89A-563E-4C4C-83F9-0DCCCFFDFA86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1C13A89A-563E-4C4C-83F9-0DCCCFFDFA86}.Debug|Any CPU.Build.0 = Debug|Any CPU {1C13A89A-563E-4C4C-83F9-0DCCCFFDFA86}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1C13A89A-563E-4C4C-83F9-0DCCCFFDFA86}.Release|Any CPU.Build.0 = Release|Any CPU {4B630AF4-71AD-4CF1-AD4E-D0608B426109}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4B630AF4-71AD-4CF1-AD4E-D0608B426109}.Debug|Any CPU.Build.0 = Debug|Any CPU {4B630AF4-71AD-4CF1-AD4E-D0608B426109}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -146,11 +149,18 @@ Global {35C90741-497A-465C-A018-633FAD414D0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {35C90741-497A-465C-A018-633FAD414D0D}.Debug|Any CPU.Build.0 = Debug|Any CPU {35C90741-497A-465C-A018-633FAD414D0D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {35C90741-497A-465C-A018-633FAD414D0D}.Release|Any CPU.Build.0 = Release|Any CPU {673B5DEC-8530-4226-B923-9D97A6AC29DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {673B5DEC-8530-4226-B923-9D97A6AC29DB}.Debug|Any CPU.Build.0 = Debug|Any CPU {673B5DEC-8530-4226-B923-9D97A6AC29DB}.Release|Any CPU.ActiveCfg = Release|Any CPU {673B5DEC-8530-4226-B923-9D97A6AC29DB}.Release|Any CPU.Build.0 = Release|Any CPU + {4F91DAA7-9DD3-418F-A276-84ABFED388A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4F91DAA7-9DD3-418F-A276-84ABFED388A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F91DAA7-9DD3-418F-A276-84ABFED388A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4F91DAA7-9DD3-418F-A276-84ABFED388A5}.Release|Any CPU.Build.0 = Release|Any CPU + {7F4947F1-C9DD-42F9-867D-5AC931478205}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F4947F1-C9DD-42F9-867D-5AC931478205}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F4947F1-C9DD-42F9-867D-5AC931478205}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F4947F1-C9DD-42F9-867D-5AC931478205}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE