Metriche in .NET con OpenTelemetry: contare, misurare, vedere

· 10 min di lettura

Scritto per .NET 10 · ASP.NET Core 10

#tech#dotnet

Nell’articolo sul CancellationToken c’era una riga: canceled.Add(1). Contava le richieste annullate, ma restavano due domande: chi crea quel contatore, e dove finiscono i numeri?

La risposta passa dalle metriche: numeri raccolti nel tempo che dicono come sta andando il sistema. Quante richieste arrivano, quanto durano, quante falliscono o vengono annullate.

Log, metriche e trace

La telemetria di un’applicazione si divide in tre segnali, e ognuno risponde a una domanda diversa.

Segnale Cosa racconta La domanda tipica
Log Un evento alla volta, con tutti i dettagli. Cosa è successo a quella richiesta?
Metriche Numeri aggregati nel tempo. Quante sono, quanto durano, sta peggiorando?
Trace Il percorso di una richiesta tra i servizi. Dove si è perso il tempo?

La differenza tra log e metriche non è solo di forma. Un log costa in proporzione al traffico: mille richieste, mille righe da scrivere, spedire e conservare. Una metrica invece viene aggregata in memoria: l’applicazione somma i valori e ogni tanto (con OpenTelemetry, di default ogni 60 secondi) invia il totale. Mille o un milione di richieste producono lo stesso numero di punti. In cambio si perde il dettaglio della singola richiesta, che resta compito di log e trace.

Come funziona: chi misura e chi raccoglie

In .NET le metriche si scrivono con System.Diagnostics.Metrics, che fa parte del runtime. Il codice crea un Meter, un gruppo di strumenti con un nome, e dal Meter gli strumenti: Counter per contare, Histogram per misurare, e altri.

Chi raccoglie i dati (l’SDK di OpenTelemetry, dotnet-counters) si iscrive ai Meter per nome. Finché nessuno ascolta, registrare una misura costa pochi nanosecondi e non succede niente: per questo le metriche si possono mettere anche nel codice che gira spesso. E per questo, più avanti, il nome del Meter andrà ripetuto nella configurazione.

Creazione di metricheDocumentazione ufficiale · learn.microsoft.com

1. Il Meter, in una classe dedicata

Gli strumenti stanno bene in una classe sola, registrata nella dependency injection. Il Meter non si crea con new: si chiede a IMeterFactory, che ASP.NET Core registra già (lo fa l’host, da .NET 8).

OrderMetrics.cs
public sealed class OrderMetrics
{
    static readonly InstrumentAdvice<double> Seconds = new()
    {
        HistogramBucketBoundaries = [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5]
    };

    readonly Counter<long> canceled;
    readonly Histogram<double> duration;

    public OrderMetrics(IMeterFactory factory)
    {
        var meter = factory.Create("Shop.Orders");

        canceled = meter.CreateCounter<long>("shop.orders.canceled");
        duration = meter.CreateHistogram<double>(
            "shop.orders.search.duration", unit: "s", advice: Seconds);
    }
}

IMeterFactory lega i Meter al container: vengono rilasciati con lui, e due container (per esempio due test in parallelo) non si mescolano le misure anche se i nomi sono uguali.

I nomi seguono le convenzioni di OpenTelemetry: minuscolo, parti separate da punti. Le durate si registrano in secondi, come double, con l’unità s.

IMeterFactoryDocumentazione ufficiale · .NET 10 · learn.microsoft.com

Istogrammi e bucket

Un istogramma non conserva ogni valore. Divide l’intervallo in bucket e conta quante misure cadono in ognuno: fino a 10 ms, fino a 50 ms, e così via. I percentili (il 95° per esempio: il 95% delle ricerche dura meno di così) si stimano poi da quei conteggi.

Per le durate i percentili contano più della media. Se 90 ricerche durano 40 ms e 10 durano 3 secondi, la media dice 336 ms e non descrive nessuno; il 95° percentile dice 3 secondi e mostra che qualcuno aspetta davvero.

Il dettaglio che sfugge: secondi e bucket di default

Il punto delicato sono i confini dei bucket, e nel carosello non c’era spazio per mostrarlo: lì l’istogramma era creato senza advice. Funziona, compila e registra i valori, ma con OpenTelemetry i numeri che arrivano a Grafana sono sbagliati.

Il motivo: se il codice non dice nulla, l’SDK usa i suoi bucket di default,

0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000

pensati per valori in millisecondi: con misure in ms, 1000, 2500 e 5000 sono proprio 1, 2,5 e 5 secondi. Ma i confini sono solo numeri. L’unità "s" è un’etichetta per chi legge i dati: l’SDK non sa che registriamo secondi e confronta il valore così com’è.

E noi registriamo secondi: una ricerca da 40 ms vale 0.04, una da 3 secondi vale 3, e 3 viene confrontato con 5, non con 5000. Finiscono entrambe nel bucket “tra 0 e 5”, insieme a tutte le altre. Per questi dati il confine 1000 vuol dire mille secondi. L’istogramma sa solo che tutte le ricerche durano meno di 5 secondi.

Il problema non è il percentile in sé, ma quello che ha a disposizione per calcolarlo. Prometheus non conosce i valori veri: sa solo quante misure stanno in ogni bucket, e dentro un bucket le immagina distribuite in modo uniforme. Se tutto sta tra 0 e 5, il 95° percentile cade al 95% di quel tratto: 4,75 secondi. Sempre, che le ricerche durino 40 ms o 3 secondi. Il grafico è una linea piatta: nessun errore, nessun avviso, solo un numero che sembra vero.

La soluzione è dare all’istogramma confini adatti ai secondi: sono quelli del campo Seconds in OrderMetrics.cs, nel primo blocco di codice. Con le stesse ricerche dell’esempio, 90 da 40 ms e 10 da 3 secondi:

Bucket di default Bucket in secondi
Dove finiscono le ricerche da 40 ms tra 0 e 5 tra 0,01 e 0,05
Dove finiscono quelle da 3 secondi tra 0 e 5 tra 2,5 e 5
50° percentile stimato 2,5 s circa 32 ms
95° percentile stimato 4,75 s circa 3,75 s

Con i bucket giusti le ricerche veloci e quelle lente finiscono in bucket diversi, e Prometheus ha di nuovo qualcosa da distinguere. Il percentile resta una stima, tanto più precisa quanto più stretti sono i bucket intorno ai valori, ma ora racconta quello che succede davvero: quasi tutte le ricerche sono veloci, qualcuna aspetta secondi.

InstrumentAdvice è il modo per dire all’SDK quali confini usare: si scrive accanto all’istogramma, e l’SDK li usa al posto di quelli di default. È disponibile da .NET 9 (pacchetto System.Diagnostics.DiagnosticSource 9.0) e l’SDK di OpenTelemetry lo legge dalla versione 1.10.

Lo stesso si ottiene dalla parte di chi raccoglie, con una View: serve per gli istogrammi di una libreria che non controlliamo, o per cambiare i confini senza toccare il codice che misura.

Program.cs
.WithMetrics(m => m
    .AddMeter("Shop.Orders")
    .AddView("shop.orders.search.duration",
        new ExplicitBucketHistogramConfiguration
        {
            Boundaries = [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5]
        })
    .AddOtlpExporter());

Non tutti gli strumenti hanno il problema. dotnet-counters calcola i percentili con una sua aggregazione, molto più fine, e mostra numeri corretti anche senza advice: per questo in locale può sembrare tutto a posto. E le metriche di ASP.NET Core, come http.server.request.duration, hanno già i loro confini in secondi.

Come scegliere i confini: più fitti dove cadono i valori normali, qualcuno più largo per la coda. Ogni bucket in più costa memoria per ogni serie, quindi una decina basta quasi sempre.

InstrumentAdvice<T>Documentazione ufficiale · .NET 10 · learn.microsoft.com

2. Le misure: metodi con un nome chiaro

Il resto del codice non deve sapere cosa sia un contatore o un istogramma. La classe espone metodi che dicono cosa è successo:

OrderMetrics.cs
public void Canceled() => canceled.Add(1);

public void Searched(TimeSpan elapsed, string result) =>
    duration.Record(elapsed.TotalSeconds, new TagList { { "result", result } });

Il tag result divide le misure in gruppi: in Grafana diventerà una linea per ogni valore. TagList è una struct che evita allocazioni anche quando i tag sono più di tre.

3. Nel servizio

La ricerca degli ordini, la stessa dell’articolo precedente, ora misura il tempo e conta gli annullamenti:

OrderService.cs
var start = Stopwatch.GetTimestamp();
try
{
    var list = await repo.SearchAsync(f, ct);

    var result = list.Count > 0 ? "found" : "empty";
    metrics.Searched(Stopwatch.GetElapsedTime(start), result);

    return [.. list.Select(o => o.ToDto())];
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    metrics.Canceled();
    throw;
}

Stopwatch.GetTimestamp legge solo un numero, senza creare oggetti. La durata viene registrata quando la ricerca arriva in fondo: le ricerche annullate finiscono nel contatore, non nell’istogramma, così non abbassano i tempi.

Stopwatch.GetElapsedTimeDocumentazione ufficiale · .NET 10 · learn.microsoft.com

4. Collegarle a OpenTelemetry

Fin qui le misure vengono registrate, ma nessuno le raccoglie. Servono tre pacchetti: OpenTelemetry.Extensions.Hosting, OpenTelemetry.Instrumentation.AspNetCore e OpenTelemetry.Exporter.OpenTelemetryProtocol.

Program.cs
builder.Services.AddSingleton<OrderMetrics>();

builder.Services.AddOpenTelemetry()
    .WithMetrics(m => m
        .AddMeter("Shop.Orders")
        .AddAspNetCoreInstrumentation()
        .AddOtlpExporter());

AddMeter usa lo stesso nome passato a factory.Create: è così che l’SDK sa quali Meter ascoltare. AddAspNetCoreInstrumentation aggiunge quelli di ASP.NET Core.

OTLP è il protocollo standard di OpenTelemetry, e quasi tutti gli strumenti lo accettano. L’applicazione invia le metriche a un indirizzo, che si imposta con la variabile OTEL_EXPORTER_OTLP_ENDPOINT: il codice resta uguale tra sviluppo e produzione, cambia solo dove arrivano i dati.

Osservabilità di .NET con OpenTelemetryDocumentazione ufficiale · learn.microsoft.com

I tag e la cardinalità

Ogni combinazione di valori dei tag diventa una serie a sé, da conservare e aggiornare. Le combinazioni si moltiplicano: 10 endpoint per 4 metodi per 8 status code fanno 320 serie, e sono ancora poche. Basta aggiungere l’id dell’utente per arrivare a milioni.

Tag Va bene?
Esito, endpoint, metodo HTTP, status code Sì: pochi valori possibili, noti in anticipo.
Id utente, id ordine, email, testo cercato No: valori infiniti. Vanno nei log o nei trace.

Gli istogrammi pesano di più: ogni serie si porta dietro tutti i suoi bucket. Il nostro tag result ha due valori; è il genere di tag da cercare.

Alcune le hai già

Prima di scrivere una metrica conviene vedere se esiste. ASP.NET Core ne produce diverse da solo, e AddAspNetCoreInstrumentation le rende visibili.

La più utile è http.server.request.duration: un istogramma in secondi, con route, metodo, status code e tipo di errore, e con bucket già adatti alle richieste web. Da sola dice quante richieste arrivano a ogni endpoint, quanto durano e quante falliscono.

Qui ritornano le richieste annullate. Quando il client si disconnette, ASP.NET Core registra la richiesta con status 499 (Client Closed Request), non come errore 500. Conta però cosa arriva in fondo: se è la OperationCanceledException così com’è, la richiesta è un annullamento e basta. Se un catch generico l’ha incapsulata in un’altra eccezione, la metrica riporta anche error.type con il tipo di quell’eccezione, e nei grafici degli errori l’annullamento diventa un fallimento. È lo stesso motivo del filtro when visto nell’articolo precedente.

ASP.NET Core metriche HTTP predefiniteDocumentazione ufficiale · ASP.NET Core 10 · learn.microsoft.com

Dove vederle

Dal terminale. dotnet-counters si collega a un processo in esecuzione e mostra i valori in tempo reale, senza configurare niente:

Terminale
dotnet-counters monitor -n Shop.Api --counters Shop.Orders
dotnet-countersDocumentazione ufficiale · learn.microsoft.com

In sviluppo. La dashboard di Aspire riceve OTLP e mostra metriche, log e trace insieme. Si avvia anche da sola, senza usare Aspire nel progetto:

Terminale
docker run --rm -it -d -p 18888:18888 -p 4317:18889 \
  --name aspire-dashboard mcr.microsoft.com/dotnet/aspire-dashboard

L’interfaccia è su http://localhost:18888, l’applicazione invia a http://localhost:4317.

Dashboard di Aspire autonomaDocumentazione ufficiale · aspire.dev

In produzione. Prometheus conserva le serie nel tempo, Grafana le disegna e manda gli allarmi. In alternativa, un servizio gestito che accetta OTLP.

Raccogliere metricheDocumentazione ufficiale · learn.microsoft.com

Come si leggono in Grafana

Passando da OpenTelemetry a Prometheus i nomi cambiano: i punti diventano trattini bassi e l’unità finisce nel nome. shop.orders.search.duration diventa shop_orders_search_duration_seconds, e i bucket stanno in …_bucket.

Il 95° percentile della ricerca, una linea per ogni valore di result:

PromQL
histogram_quantile(0.95,
  sum by (le, result) (rate(shop_orders_search_duration_seconds_bucket[5m])))

rate calcola, per ogni bucket, quante misure al secondo sono arrivate negli ultimi 5 minuti; sum by somma le serie per confine del bucket (le) e per result; histogram_quantile stima il percentile da quei conteggi.

Se la linea empty sta stabilmente sopra found, le ricerche che non trovano nulla sono più lente. Un caso tipico è la ricerca paginata senza un indice adatto: quando ci sono risultati, il database si ferma appena ha riempito la pagina; quando non ce ne sono, per esserne sicuro deve leggere tutta la tabella. È il genere di cosa che nei log non si vede, e in un grafico salta all’occhio.

In breve

  1. Un Meter da IMeterFactory, in una classe dedicata.
  2. Counter conta, Histogram misura: durate in secondi, con i bucket giusti.
  3. Tag con pochi valori possibili.
  4. AddMeter con lo stesso nome e un exporter OTLP.
  5. Prima di scrivere una metrica, guarda quelle che ASP.NET Core ti dà già.

Codice semplificato a scopo di esempio: nomi e struttura servono a dare l'idea, non sono da copiare così.

Guarda il carosello su Instagram @alessandroaiello.dev