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