Metrics

Record business and technical metrics with Meter, counters and histograms, read the built-in ASP.NET Core metrics and export them.

Syntaxvar meter = meterFactory.Create("Shop.Orders"); var placed = meter.CreateCounter<long>("shop.orders.placed", "{order}"); placed.Add(1, new KeyValuePair<string, object?>("payment.method", "card"));

Logs describe one request; metrics describe the whole service as numbers aggregated in memory and exported on an interval, cheap enough to keep for months. The API is System.Diagnostics.Metrics in the runtime. A Meter is a named group of instruments; an instrument records measurements with optional tags; a listener (OpenTelemetry, dotnet-counters, a MeterListener in a test) aggregates them. With no listener, Counter.Add is close to free.

Instruments

InstrumentMeaningExample
Counter<T>Monotonic sum; backends compute ratesOrders placed, payment declines
UpDownCounter<T>Sum that goes both waysIn-flight requests, open WebSocket connections
Histogram<T>Distribution; gives percentilesCheckout duration, order value
ObservableGauge<T>Callback read at collection timeCache entries, oldest unsent outbox message

Create meters through IMeterFactory, injected from DI, rather than new Meter(...): the factory tags meters with the container as their scope, so a test can collect only its own host's measurements (a MetricCollector<T> created from that host's IMeterFactory), even though a MeterListener is process-wide and sees every meter. Follow OpenTelemetry naming (shop.orders.placed), UCUM units (s, By, {order}), and record durations in seconds as double.

Built-in metrics

Meter Microsoft.AspNetCore.Hosting publishes http.server.request.duration (a histogram in seconds tagged with method, route template and status code) and http.server.active_requests. Microsoft.AspNetCore.Server.Kestrel publishes kestrel.active_connections and kestrel.queued_connections; System.Net.Http publishes http.client.request.duration; others cover rate limiting, exceptions seen by the diagnostics middleware, and the runtime's GC, thread pool and memory. dotnet-counters monitor -n Orders.Api --counters Microsoft.AspNetCore.Hosting shows them live on a box with no exporter. Request rate, error ratio and p99 latency per route all come from that one histogram.

Exporting

WithMetrics(m => m.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation().AddMeter("Shop.Orders").AddOtlpExporter()) exports every 60 seconds by default (OTEL_METRIC_EXPORT_INTERVAL). Your own meters must be listed in AddMeter or nothing leaves the process. For Prometheus the usual production route is OTLP to an OpenTelemetry Collector that exposes a scrape endpoint or remote-writes; the in-process Prometheus exporter package for ASP.NET Core is still pre-release. Histogram bucket boundaries matter: a histogram whose buckets do not fit its values produces useless percentiles, so give your own duration histograms boundaries, either with InstrumentAdvice<double> at creation or with a view in the exporter configuration.

Cardinality is the production failure

Each unique combination of tag values is a separate time series held in memory by the SDK and billed by the backend. Tagging with customer.id or order.id turns one series into millions. The OpenTelemetry SDK caps each metric at 2000 tag combinations by default and folds measurements beyond the cap into an overflow series, so totals stay right but per-tag numbers quietly stop being meaningful. Tag with bounded values: payment method, region, status code, route template. Identifiers belong in traces and logs.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Alert when the 5xx ratio of POST /orders exceeds 2% over five minutes using http.server.request.duration tagged by route and status.
  • Chart orders placed per minute split by payment method to spot a broken PayPal integration within one scrape interval.
  • Track the depth of an in-process payment queue with an observable gauge and scale out before it backs up.

More examples

Exporting with OpenTelemetry and fixing histogram buckets

The OpenTelemetry SDK's default histogram boundaries run from 0 to 10000, sized for milliseconds, so a duration recorded in seconds lands almost entirely in the lowest bucket and p95 becomes meaningless. ASP.NET Core's own histograms carry bucket advice that the SDK honours. Give your own histograms boundaries, either with <code>advice: new InstrumentAdvice&lt;double&gt; { HistogramBucketBoundaries = [...] }</code> at creation or with a view in the exporter configuration as here. <code>System.Runtime</code> is the runtime's built-in meter for GC, thread pool and memory metrics.

Example · csharp
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService("orders-api"))
    .WithMetrics(metrics => metrics
        .AddAspNetCoreInstrumentation()       // Microsoft.AspNetCore.Hosting and Kestrel meters
        .AddHttpClientInstrumentation()       // http.client.request.duration
        .AddMeter("Shop.Orders", "System.Runtime")
        .AddView("shop.checkout.duration", new ExplicitBucketHistogramConfiguration
        {
            // Seconds: the SDK's default buckets assume milliseconds and would put every checkout in the first one.
            Boundaries = [0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10],
        })
        .AddView("shop.orders.value", new ExplicitBucketHistogramConfiguration
        {
            Boundaries = [10, 25, 50, 100, 250, 500, 1000, 5000],
        })
        .AddOtlpExporter());

var app = builder.Build();
app.MapGet("/", () => "orders-api");
app.Run();

Collector config: OTLP in, Prometheus scrape endpoint out

The API only speaks OTLP; the Collector translates. Prometheus scrapes <code>:8889/metrics</code>, where <code>http.server.request.duration</code> appears as <code>http_server_request_duration_seconds_bucket</code>. <code>resource_to_telemetry_conversion</code> copies resource attributes such as <code>service.name</code> onto every series so dashboards can filter per service. Swapping Prometheus for a vendor later is a Collector change, not a redeploy of every API.

Example · yaml
# otel-collector-config.yaml for the contrib distribution (otelcol-contrib)
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318
processors:
  batch: {}
  memory_limiter:
    check_interval: 1s
    limit_mib: 400
exporters:
  prometheus:
    endpoint: 0.0.0.0:8889
    resource_to_telemetry_conversion:
      enabled: true
service:
  pipelines:
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheus]

Pitfall: a customer id tag, and an observable gauge

With 50,000 customers the commented-out line creates up to 50,000 series for one counter; the SDK folds everything past its per-metric limit into an overflow series and the backend charges for what it does receive. Decline reasons and regions are a few dozen values. The observable gauge reads the bounded <code>Channel</code> count only at collection time, which is cheaper and more accurate than incrementing and decrementing an <code>UpDownCounter</code> around every read and write.

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

public sealed record PaymentJob(string PaymentId, string CustomerId, string Region);

public sealed class PaymentMetrics
{
    private readonly Counter<long> _declined;

    public PaymentMetrics(IMeterFactory meterFactory, Channel<PaymentJob> queue)
    {
        var meter = meterFactory.Create("Shop.Payments");
        _declined = meter.CreateCounter<long>("shop.payments.declined", unit: "{payment}");

        // Read only when the exporter collects, so there is no per-item bookkeeping.
        meter.CreateObservableGauge("shop.payments.queue.depth",
            () => queue.Reader.CanCount ? queue.Reader.Count : 0,
            unit: "{job}", description: "Payments waiting in the in-process queue");
    }

    public void Declined(PaymentJob job, string reason)
    {
        // Wrong: one time series per customer; hits the cardinality cap within hours.
        // _declined.Add(1, new("customer.id", job.CustomerId));

        // Right: bounded tags only. The customer id goes in the log line and the span.
        _declined.Add(1,
            new KeyValuePair<string, object?>("payment.decline_reason", reason),
            new KeyValuePair<string, object?>("cloud.region", job.Region));
    }
}

Discussion

  • Be the first to comment on this lesson.