Quando si parla di osservabilità in ASP.NET Core, il rischio è quasi sempre lo stesso: ci si concentra su un solo pezzo del puzzle, magari i log o le trace, e ci si ritrova con una configurazione incompleta, utile solo in parte. Nella pratica, invece, una base solida dovrebbe coprire almeno quattro aree: tracing, metrics, log strutturati e health checks.
È l’impostazione che consigliamo anche nei progetti reali: OpenTelemetry per tracce e metriche, Serilog per i log, endpoint distinti per liveness e readiness, insieme ad alcuni accorgimenti che vengono saltati spesso, come la correlazione tra TraceId e log applicativi, il sampling e un minimo di telemetria custom sulle operazioni che contano davvero.
Il vantaggio è concreto e si vede presto. Si ottiene una piattaforma di osservabilità moderna e standardizzata, facile da esportare via OTLP verso collector, Grafana, Tempo o altri backend; allo stesso tempo si evita la classica situazione in cui un’app “ha i log”, ma quando le cose iniziano a degradare non restituisce informazioni davvero utili.
Una baseline sensata: tracing, metriche, log e probe HTTP
Il modello di partenza è semplice e, a mio avviso, dovrebbe diventare quasi universale nei progetti ASP.NET Core 10:
- OpenTelemetry tracing per seguire le richieste end-to-end;
- OpenTelemetry metrics per misurare throughput, errori, latenza e segnali runtime;
- Serilog per log strutturati, interrogabili e coerenti con il contesto applicativo;
- Health Checks con endpoint distinti
/health/livee/health/ready.
Questa architettura ha un pregio molto concreto: separa i segnali in modo pulito, senza costringere i log a fare il lavoro delle metriche o le health probe a sostituire il tracing. Ogni componente resta nel proprio perimetro, e il risultato finale è molto più leggibile sia in locale sia in produzione.
OpenTelemetry con OTLP exporter in ASP.NET Core 10
In .NET l’integrazione con OpenTelemetry è piuttosto naturale, anche perché la piattaforma espone già le primitive su cui si appoggia questo modello: Activity e ActivitySource per il tracing, Meter per le metriche e ILogger per il logging. OpenTelemetry si occupa di raccogliere questi segnali e inviarli a un backend tramite exporter, con OTLP come scelta più flessibile e portabile.
Una configurazione tipica in Program.cs può assomigliare a questa:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using Serilog; using Serilog.Context; using System.Diagnostics; using System.Diagnostics.Metrics; var builder = WebApplication.CreateBuilder(args); builder.Host.UseSerilog((context, services, logger) => logger .ReadFrom.Configuration(context.Configuration) .Enrich.FromLogContext() .Enrich.WithProperty("Application", "MyApi") .WriteTo.Console()); builder.Services.AddOpenTelemetry() .ConfigureResource(resource => resource .AddService( serviceName: "MyApi", serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString())) .WithTracing(tracing => { tracing .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddSqlClientInstrumentation() .AddSource("MyApi") .SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(0.25))) .AddOtlpExporter(); }) .WithMetrics(metrics => { metrics .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddRuntimeInstrumentation() .AddMeter("MyApi") .AddOtlpExporter(); }); builder.Services.AddHealthChecks() .AddCheck("self", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy(), tags: new[] { "live" }) .AddCheck<SqlServerReadinessHealthCheck>("sql", tags: new[] { "ready" }) .AddCheck<RedisReadinessHealthCheck>("redis", tags: new[] { "ready" }); var app = builder.Build(); app.Use(async (context, next) => { var traceId = Activity.Current?.TraceId.ToString(); var spanId = Activity.Current?.SpanId.ToString(); using (LogContext.PushProperty("TraceId", traceId)) using (LogContext.PushProperty("SpanId", spanId)) { await next(); } }); app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions { Predicate = check => check.Tags.Contains("live") }); app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions { Predicate = check => check.Tags.Contains("ready") }); app.MapGet("/", () => "OK"); app.Run(); |
La parte interessante è che, con una configurazione del genere, si ottiene già una base osservabile seria: richieste in ingresso tracciate, chiamate HTTP in uscita osservabili, query SQL intercettate, metriche runtime e applicative esportate via OTLP, log strutturati su console o su sink esterni, più due endpoint chiari per orchestratori e load balancer.
OTLP exporter: conviene sempre? (Spoiler: SI)
OTLP è, oggi, la scelta più razionale se non vuoi legarti troppo presto a un vendor o a uno stack specifico. Ti consente di inviare traces e metrics a un collector OpenTelemetry, a un’Aspire Dashboard in locale, oppure verso piattaforme come Grafana tramite pipeline dedicate. In sviluppo puoi anche tenere l’instrumentation attiva senza avere per forza un collector sempre acceso: il codice continua a produrre Activity e metriche locali, che restano comunque utili per diagnosi e test.
In pratica, l’OTLP exporter non è soltanto una scelta “enterprise”: è anche il modo più pulito per non dover ripensare tutta la telemetria ogni volta che cambi backend o aggiungi un ambiente.
Tracing: attivare davvero quello che serve
Uno degli errori più comuni è pensare di “avere il tracing” solo perché hai aggiunto OpenTelemetry. In realtà, la qualità del risultato dipende dalle instrumentations abilitate e dalle sorgenti custom che aggiungi per il tuo dominio applicativo.
In una Web API ASP.NET Core, le tre attivazioni minime che considero davvero importanti sono queste:
- ASP.NET Core / Kestrel per tracciare le richieste HTTP in ingresso;
- HttpClient per osservare le dipendenze esterne e le chiamate outbound;
- EF Core o SqlClient per capire dove il tempo si perde sul database.
Qui conviene essere chiari: se la tua applicazione gira su ASP.NET Core, l’instrumentation lato server copre le richieste in ingresso del web stack, quindi di fatto anche il traffico gestito da Kestrel; per la parte dati, invece, puoi fermarti a SqlClient se ti basta osservare le query SQL, oppure aggiungere l’instrumentation dedicata a EF Core quando vuoi una visibilità più aderente al livello ORM.
Quando poi il progetto ha operazioni critiche, conviene quasi sempre affiancare anche una ActivitySource custom. È lì che inizi a vedere davvero il dominio, non solo l’infrastruttura.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public static class Observability { public const string ActivitySourceName = "MyApi"; public static readonly ActivitySource ActivitySource = new(ActivitySourceName); } app.MapPost("/orders", async (OrderRequest request, ILogger<Program> logger) => { using var activity = Observability.ActivitySource.StartActivity("orders.create"); activity?.SetTag("order.customer_id", request.CustomerId); activity?.SetTag("order.items_count", request.Items.Count); logger.LogInformation("Creating order for customer {CustomerId}", request.CustomerId); // business logic... return Results.Accepted(); }); |
Questo è uno di quei passaggi che fanno davvero la differenza in Grafana Tempo o in qualunque trace backend serio: vedere una trace HTTP è utile, ma vedere anche lo span applicativo con tag di dominio lo è molto di più.
Metriche custom con Meter e Counter<T>
Le metriche built-in di ASP.NET Core, HttpClient e runtime sono utilissime, ma quasi mai sufficienti. Prima o poi serve misurare qualcosa di specifico: ordini elaborati, job completati, webhook falliti, documenti scartati, login negati, retry effettuati. È qui che entrano in gioco Meter e Counter<T>.
Il pattern è semplice: definisci un meter applicativo e uno o più strumenti metrici, poi li incrementi nei punti del flusso che hanno valore diagnostico o operativo.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
using System.Diagnostics.Metrics; public static class AppMetrics { public const string MeterName = "MyApi"; public static readonly Meter Meter = new(MeterName); public static readonly Counter<int> OrdersCreated = Meter.CreateCounter<int>( "orders.created", unit: "{order}", description: "Number of successfully created orders"); public static readonly Counter<int> OrdersFailed = Meter.CreateCounter<int>( "orders.failed", unit: "{order}", description: "Number of failed order creations"); } |
Uso:
|
1 2 3 4 5 6 7 |
AppMetrics.OrdersCreated.Add(1, new KeyValuePair<string, object?>("channel", "api"), new KeyValuePair<string, object?>("tenant_id", tenantId)); AppMetrics.OrdersFailed.Add(1, new KeyValuePair<string, object?>("reason", "validation"), new KeyValuePair<string, object?>("tenant_id", tenantId)); |
Questa è telemetria che, in produzione, vale molto più di tanti log verbosi. Un contatore con tag ben scelti ti permette di vedere subito un’anomalia, costruire pannelli Grafana sensati e impostare alert su trend o spike reali. L’importante è non esagerare con la cardinalità dei tag: identificativi univoci, email, GUID o valori troppo variabili sono quasi sempre una cattiva idea.
Serilog: log strutturati, leggibili e interrogabili
OpenTelemetry non rende inutili i log. Li rimette semplicemente al loro posto. I log servono ancora, e parecchio, ma devono essere strutturati, non scritti come testo libero indistinto. In un progetto ASP.NET Core 10, Serilog continua a essere una scelta eccellente proprio per questo.
La configurazione minima che consiglio è molto semplice:
ReadFrom.Configuration(...)per centralizzare livelli e sink;Enrich.FromLogContext()per raccogliere proprietà contestuali;- output JSON o comunque strutturato verso console, file, Seq, Loki o altri sink;
- riduzione del rumore su namespace framework come
MicrosofteSystem.
Il vero salto di qualità, però, arriva quando i log parlano la stessa lingua delle trace: stessi identificativi di business, stessi tag concettuali, stessi nomi coerenti per le proprietà. A quel punto la correlazione smette di essere teorica e diventa davvero utilizzabile.
Correlazione TraceId - Serilog tramite LogContext
Questo è uno dei dettagli che molti saltano all’inizio e si ritrovano poi a inseguire in produzione. Se vuoi passare rapidamente da una trace a una riga di log, oppure risalire dai log alla trace completa, devi portare nel logging almeno il TraceId, e possibilmente anche lo SpanId.
Serilog può includere queste informazioni nel rendering dei log, ma nella pratica conviene anche spingerle esplicitamente nel LogContext all’interno della pipeline HTTP, così da averle sempre disponibili nei sink strutturati e nelle query su Loki o strumenti analoghi.
|
1 2 3 4 5 6 7 8 9 10 11 |
app.Use(async (context, next) => { var activity = Activity.Current; using (LogContext.PushProperty("TraceId", activity?.TraceId.ToString())) using (LogContext.PushProperty("SpanId", activity?.SpanId.ToString())) using (LogContext.PushProperty("RequestPath", context.Request.Path.Value)) { await next(); } }); |
Poi, nei punti importanti del codice:
|
1 2 3 4 |
logger.LogInformation( "Order {OrderId} created for tenant {TenantId}", orderId, tenantId); |
In questo modo i log restano puliti, ma portano con sé il contesto necessario per passare da Grafana Loki a Tempo e viceversa. È una piccola aggiunta, ma di quelle che ripagano subito quando bisogna ricostruire un incidente reale.
Sampling: conviene tracciare tutto? (Spoiler: NO)
All’inizio si è tentati di raccogliere il 100% delle trace. In locale può avere senso. In produzione, quasi mai. Il volume cresce in fretta, i costi aumentano e il rapporto segnale/rumore peggiora. Per questo il sampling va pensato prima, non dopo.
La combinazione più sensata, nella maggior parte dei casi, è parent-based + ratio-based. In pratica:
- se una trace padre è già campionata, i figli la seguono per mantenere coerenza end-to-end;
- per le nuove trace si applica una percentuale, ad esempio 10%, 25% o 50% a seconda del carico e del valore diagnostico.
Un esempio ragionevole:
|
1 2 3 |
tracing.SetSampler( new ParentBasedSampler( new TraceIdRatioBasedSampler(0.10))); |
Questa impostazione evita un errore abbastanza comune: usare solo un sampler ratio-based e ritrovarsi con trace distribuite spezzate o incoerenti tra servizi. In un sistema con più hop, preservare la decisione del parent è quasi sempre la scelta corretta.
Health Checks: /health/live e /health/ready
Gli health checks vengono spesso trattati come una formalità, ma non lo sono. Separare /health/live da /health/ready chiarisce una distinzione fondamentale:
- liveness dice se il processo è vivo;
- readiness dice se l’applicazione è davvero pronta a ricevere traffico.
Sembrano simili, ma in pratica rispondono a domande molto diverse. Un’app può essere viva ma non pronta, per esempio perché non ha ancora completato l’inizializzazione, non riesce a connettersi al database o ha una dipendenza critica degradata.
La struttura che consiglio è questa:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
builder.Services.AddHealthChecks() .AddCheck("self", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy(), tags: new[] { "live" }) .AddCheck<SqlServerReadinessHealthCheck>("sql", tags: new[] { "ready" }) .AddCheck<RedisReadinessHealthCheck>("redis", tags: new[] { "ready" }) .AddCheck<RabbitMqReadinessHealthCheck>("rabbitmq", tags: new[] { "ready" }); app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions { Predicate = check => check.Tags.Contains("live") }); app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions { Predicate = check => check.Tags.Contains("ready") }); |
Questa impostazione funziona bene in Kubernetes, dietro load balancer, nei container e più in generale ovunque tu voglia evitare che un’istanza venga considerata disponibile solo perché sta ancora rispondendo a livello di processo. Tradotto in termini pratici: non basta essere accesi, bisogna anche essere pronti.
Dashboard Grafana e tracing in Tempo
Una volta esportati tracing e metrics via OTLP, il passaggio successivo è visualizzarli in modo sensato. Qui Grafana e Tempo funzionano molto bene, soprattutto quando la telemetria è stata pensata con un minimo di disciplina.
Alcuni pannelli che considero davvero utili:
1. HTTP overview
- request rate per endpoint;
- latenza p50, p95 e p99;
- tasso errori 4xx/5xx;
- top endpoint per durata media.
2. Dipendenze esterne
- latenza chiamate
HttpClientper host remoto; - error rate per provider esterno;
- trace lente filtrate per dipendenza.
3. Database
- durata query SQL o operazioni EF Core;
- numero di errori verso il database;
- tracce con maggiore impatto lato data access.
4. Metriche custom di dominio
orders.createdeorders.failedper tenant o canale;- job completati/falliti per tipo;
- trend operativi su finestre temporali brevi e lunghe.
5. Health e disponibilità
- stato readiness nel tempo;
- conteggio fallimenti dei check critici;
- correlazione tra readiness flapping, errori applicativi e spike di latenza.
Su Tempo, invece, la parte più utile resta quasi sempre la navigazione per trace lente o anomale, unita alla correlazione verso i log. Se hai configurato bene TraceId nei log Serilog e hai collegato Tempo a Loki in Grafana, puoi passare da una span ai log relativi con uno o due click. È lì che il sistema smette di essere “bello da vedere” e diventa davvero utile per fare troubleshooting.
Naming, tag e cardinalità
Vale la pena chiudere con un’osservazione molto concreta: gran parte della qualità dell’osservabilità si gioca sui dettagli. Non basta emettere telemetria; bisogna emetterla bene.
Quindi:
- dai nomi chiari a meter, activity source e strumenti metrici;
- usa tag coerenti tra trace, metriche e log;
- evita cardinalità troppo alta nei tag metrici;
- non loggare tutto: logga quello che aiuta davvero a capire il sistema;
- non usare le health probe come sostituto di metriche e tracing.
Sono regole semplici, ma fanno una differenza enorme nel lungo periodo. E, come spesso accade, la parte difficile non è aggiungere una libreria: è mantenere disciplina quando il progetto cresce.
Conclusioni
Il quadro che emerge è piuttosto chiaro: OpenTelemetry per traces e metrics, Serilog per i log strutturati, health checks distinti per liveness e readiness, più una manciata di metriche custom e una correlazione seria tra segnali.
In ASP.NET Core 10 è una combinazione matura, concreta e adatta sia ai progetti nuovi sia a quelli esistenti che vogliono fare un salto di qualità sul fronte osservabilità. La parte interessante è che non serve costruire una piattaforma enorme per ottenere valore: basta partire bene, scegliere pochi componenti giusti e farli lavorare insieme nel modo corretto.
Quando ti troverai davanti una trace lenta su Tempo, il relativo TraceId nei log Serilog, una metrica custom che segnala un’anomalia e un endpoint /health/ready che inizia a degradare, ti accorgerai che non stai più solo “raccogliendo dati”: stai osservando davvero il comportamento reale della tua applicazione.

