Metriche in .NET con OpenTelemetry: contare, misurare, vedere
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.
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).
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.
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.
.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>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:
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:
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.
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.
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.
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.
Dove vederle
Dal terminale. dotnet-counters si collega a un processo in esecuzione e mostra
i valori in tempo reale, senza configurare niente:
dotnet-counters monitor -n Shop.Api --counters Shop.Orders
dotnet-counters
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:
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.
In produzione. Prometheus conserva le serie nel tempo, Grafana le disegna e manda gli allarmi. In alternativa, un servizio gestito che accetta OTLP.
Raccogliere metricheCome 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:
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
- Un
MeterdaIMeterFactory, in una classe dedicata. Counterconta,Histogrammisura: durate in secondi, con i bucket giusti.- Tag con pochi valori possibili.
AddMetercon lo stesso nome e un exporter OTLP.- 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