Metrics

Record counters, gauges and histograms with System.Diagnostics.Metrics, and choose the few numbers worth alerting on.

SyntaxCounter<long> counter = meter.CreateCounter<long>("shop.orders.placed", unit: "{order}", description: "Orders placed");

A metric is a number measured repeatedly over time. Logs tell you what happened in one request. Metrics tell you what is happening across all of them, cheaply enough to keep running all the time.

Meters and instruments

A meter is a named group of instruments, usually one per component. An instrument is the thing you record into. There are three kinds worth knowing:

  • A counter only goes up. Orders placed, exceptions thrown.
  • A gauge is a value that goes up and down and is read as-is. Queue depth, connections open. Gauge<T> arrived in .NET 9; before that you used CreateObservableGauge, which calls a function of yours when the value is collected.
  • A histogram records a spread of values so you can ask for the median or the 99th percentile. Request duration belongs here.

Tags

A tag is a key and value attached to a measurement, such as the channel an order came from. Each distinct combination of tag values becomes its own series in the monitoring system, so tags with few possible values are useful and tags with unbounded values are a bill.

What ASP.NET Core already gives you

MeterInstrument
Microsoft.AspNetCore.Hostinghttp.server.request.duration, http.server.active_requests
Microsoft.AspNetCore.Server.Kestrelkestrel.active_connections, kestrel.rejected_connections
Microsoft.AspNetCore.Diagnosticsaspnetcore.diagnostics.exceptions
System.Net.Httphttp.client.request.duration

Four numbers carry most alerts: request duration at the 99th percentile, the share of 5xx responses, active requests, and one number from your own domain, such as queue depth. Alert on those before inventing more.

Example

Example · csharp
using System.Diagnostics.Metrics;
using System.Globalization;

// A meter is a named group of instruments, normally one per component.
var meter = new Meter("Shop.Orders", "1.0.0");

Counter<long> ordersPlaced = meter.CreateCounter<long>(
    "shop.orders.placed", unit: "{order}");
Histogram<double> checkoutSeconds = meter.CreateHistogram<double>(
    "shop.checkout.duration", unit: "s");

// A listener is normally the monitoring library. This one prints instead.
var listener = new MeterListener
{
    InstrumentPublished = (instrument, activeListener) =>
    {
        if (instrument.Meter.Name == "Shop.Orders")
        {
            activeListener.EnableMeasurementEvents(instrument);
        }
    }
};
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
    Console.WriteLine($"{instrument.Name} += {measurement}"));
// InvariantCulture so the decimal point does not follow the machine's locale.
listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
    Console.WriteLine($"{instrument.Name} = {measurement.ToString(CultureInfo.InvariantCulture)}"));
listener.Start();

ordersPlaced.Add(1, new KeyValuePair<string, object?>("shop.channel", "web"));
ordersPlaced.Add(2, new KeyValuePair<string, object?>("shop.channel", "app"));
checkoutSeconds.Record(0.42);

// Output:
// shop.orders.placed += 1
// shop.orders.placed += 2
// shop.checkout.duration = 0.42

When to use it

  • Alert when the 99th percentile of request duration crosses the number in your service level objective.
  • Count orders per channel without writing a log line for every order.
  • Watch queue depth so you can scale out before a backlog becomes visible to users.
  • Confirm after a deploy that the 5xx rate did not move.

More examples

Custom metrics in a web app, through IMeterFactory

ASP.NET Core registers IMeterFactory in dependency injection already. Creating meters through it instead of calling new Meter(...) lets one test collect only its own measurements, because each factory is scoped to the host under test. Wrap your instruments in a small class like this so the recording calls read as domain language rather than as telemetry plumbing.

Example · csharp
using System.Diagnostics.Metrics;

public sealed class ShopMetrics
{
    private readonly Counter<int> _productSold;

    public ShopMetrics(IMeterFactory meterFactory)
    {
        Meter meter = meterFactory.Create("Shop.Web");
        _productSold = meter.CreateCounter<int>("shop.product.sold");
    }

    // Expression-bodied member. Identical to:
    //     public void ProductSold(string name, int quantity)
    //     {
    //         _productSold.Add(quantity, ...);
    //     }
    public void ProductSold(string name, int quantity) =>
        _productSold.Add(quantity,
            new KeyValuePair<string, object?>("shop.product.name", name));
}

// In Program.cs:
// builder.Services.AddSingleton<ShopMetrics>();
//
// app.MapPost("/sales", (Sale sale, ShopMetrics metrics) =>
// {
//     metrics.ProductSold(sale.ProductName, sale.Quantity);
//     return Results.NoContent();
// });

A gauge for a value you read, not count

An observable gauge is the right shape for anything you can read on demand, such as queue depth or cache size, because the collector decides when to sample and you never store a stale copy. The synchronous Gauge<T>, new in .NET 9, suits values that only change when you change them. Do not use a counter for either: counters are summed over time and a sum of queue depths means nothing.

Example · csharp
using System.Diagnostics.Metrics;
using System.Threading.Channels;

var meter = new Meter("Shop.Jobs");
Channel<string> queue = Channel.CreateUnbounded<string>();

// Called by the collector, not by you. Report the value at that moment.
meter.CreateObservableGauge(
    "shop.jobs.queued",
    () => queue.Reader.Count,
    unit: "{job}");

// Since .NET 9 there is also a synchronous gauge you push into:
Gauge<int> workers = meter.CreateGauge<int>("shop.jobs.workers");
workers.Record(4);

Asserting a metric in a test

MetricCollector<T> subscribes to one instrument on one meter and keeps the measurements it sees. Use it when a metric is part of the contract, for example when an alert or a dashboard depends on a tag name: a renamed tag is a silent break that no other test will catch.

Example · csharp
// Package: Microsoft.Extensions.Diagnostics.Testing
using Microsoft.Extensions.Diagnostics.Metrics.Testing;

[Fact]
public async Task Get_records_request_duration()
{
    HttpClient client = _factory.CreateClient();
    IMeterFactory meterFactory =
        _factory.Services.GetRequiredService<IMeterFactory>();

    var collector = new MetricCollector<double>(
        meterFactory, "Microsoft.AspNetCore.Hosting", "http.server.request.duration");

    await client.GetAsync("/");

    await collector.WaitForMeasurementsAsync(minCount: 1)
        .WaitAsync(TimeSpan.FromSeconds(5));
    Assert.Equal("GET",
        collector.GetMeasurementSnapshot()[0].Tags["http.request.method"]);
}

Discussion

  • Be the first to comment on this lesson.