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 W3Ctraceparentheaders across services. Create your own with anActivitySource. - Metrics via
System.Diagnostics.Metrics.Meter(counters, histograms, up-down counters). ASP.NET Core publishes built-in meters such ashttp.server.request.durationand 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.