Health checks and observability

ASP.NET Core · Expert · 14 min

You can't operate what you can't see: orchestrators need health signals and engineers need traces and metrics.

Health checks tell load balancers and orchestrators whether to send traffic. Register checks with builder.Services.AddHealthChecks().AddCheck<DbCheck>("db", tags: ["ready"]) and expose them with app.MapHealthChecks("/healthz"). Separate liveness (is the process responsive? keep it trivial) from readiness (can it serve traffic: database, dependencies) using tag predicates, so a database blip doesn't make Kubernetes restart every pod.

Observability in .NET is built on standard APIs:

  • Tracing via System.Diagnostics.Activity. ASP.NET Core, HttpClient and many libraries create activities for incoming and outgoing requests, propagating W3C traceparent headers across services. Create your own with an ActivitySource.
  • Metrics via System.Diagnostics.Metrics.Meter (counters, histograms, up-down counters). ASP.NET Core publishes built-in meters such as http.server.request.duration and Kestrel connection metrics.
  • Logs via ILogger, correlated with the current trace id.

The OpenTelemetry .NET SDK (OpenTelemetry.Extensions.Hosting) collects all three and exports them over OTLP to backends like Prometheus/Grafana, Jaeger, Azure Monitor or Datadog: AddOpenTelemetry().WithTracing(...).WithMetrics(...).UseOtlpExporter(). .NET Aspire wires this up by default.

For live diagnostics without redeploying, use dotnet-counters monitor, dotnet-trace collect and dotnet-dump. Keep metric tag cardinality low: never tag with user ids or raw URLs.

Example

using System.Diagnostics;
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Diagnostics.HealthChecks;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks()
    .AddCheck("self", () => HealthCheckResult.Healthy())
    .AddCheck("db", () => HealthCheckResult.Degraded("slow"), tags: ["ready"]);
// + OpenTelemetry packages: builder.Services.AddOpenTelemetry().WithTracing(t => t
//   .AddAspNetCoreInstrumentation().AddSource("Shop")).WithMetrics(m => m.AddMeter("Shop")).UseOtlpExporter();
var app = builder.Build();
app.MapHealthChecks("/healthz/live", new() { Predicate = _ => false });   // process is up
app.MapHealthChecks("/healthz/ready", new() { Predicate = c => c.Tags.Contains("ready") });

var source = new ActivitySource("Shop");
var orders = new Meter("Shop").CreateCounter<long>("shop.orders.placed");
app.MapPost("/orders", () =>
{
    using var activity = source.StartActivity("PlaceOrder");   // span in the current trace
    activity?.SetTag("order.items", 3);
    orders.Add(1, new KeyValuePair<string, object?>("channel", "web"));
    return Results.Accepted();
});
app.Run();
// GET /healthz/ready -> 200 Degraded (Unhealthy would be 503)

Remember

  • Separate liveness from readiness health checks
  • Traces use Activity/ActivitySource, metrics use Meter
  • OpenTelemetry collects and exports traces, metrics and logs over OTLP
  • Keep metric tag cardinality low

Watch out for

  • Making the liveness probe depend on the database, so a DB outage restarts every instance.

Check yourself

  1. Why should a liveness probe usually not check the database?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: learn.microsoft.com/en-us/aspnet/core/host-and-deploy/health-checks

Builds on

Unlocks

Nothing builds on this yet.