.NET Observability
This guide covers implementing all four observability pillars in .NET applications with ASP.NET Core.
Logging
Use Serilog for structured JSON logging to stdout.
Installation
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Formatting.Compact
Configuration
// Program.cs
using Serilog;
using Serilog.Formatting.Compact;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "MyService")
.WriteTo.Console(new CompactJsonFormatter())
.CreateLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog();
var app = builder.Build();
app.UseSerilogRequestLogging();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
Usage
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger)
{
_logger = logger;
}
public async Task ProcessOrder(Order order)
{
_logger.LogInformation("Processing order {OrderId} for customer {CustomerId}",
order.Id, order.CustomerId);
try
{
await ProcessPayment(order);
_logger.LogInformation("Order {OrderId} completed successfully", order.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process order {OrderId}", order.Id);
throw;
}
}
}
Request Context
// Middleware to add request context
app.Use(async (context, next) =>
{
var requestId = context.Request.Headers["X-Request-Id"].FirstOrDefault()
?? Guid.NewGuid().ToString();
using (LogContext.PushProperty("RequestId", requestId))
using (LogContext.PushProperty("Path", context.Request.Path))
{
context.Response.Headers["X-Request-Id"] = requestId;
await next();
}
});
Metrics
Use prometheus-net or OpenTelemetry.NET for Prometheus metrics.
prometheus-net
dotnet add package prometheus-net.AspNetCore
// Program.cs
using Prometheus;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Enable metrics endpoint
app.UseMetricServer(); // Exposes /metrics
app.UseHttpMetrics(); // Records HTTP request metrics
app.Run();
Custom Metrics
using Prometheus;
public class OrderMetrics
{
private static readonly Counter OrdersTotal = Metrics.CreateCounter(
"orders_total",
"Total orders processed",
new CounterConfiguration
{
LabelNames = new[] { "status" }
});
private static readonly Histogram OrderDuration = Metrics.CreateHistogram(
"order_processing_duration_seconds",
"Order processing duration",
new HistogramConfiguration
{
Buckets = Histogram.ExponentialBuckets(0.01, 2, 10)
});
private static readonly Gauge ActiveOrders = Metrics.CreateGauge(
"active_orders",
"Number of orders being processed");
public void RecordOrder(string status, double durationSeconds)
{
OrdersTotal.WithLabels(status).Inc();
OrderDuration.Observe(durationSeconds);
}
public IDisposable TrackActiveOrder()
{
ActiveOrders.Inc();
return new DisposableAction(() => ActiveOrders.Dec());
}
}
OpenTelemetry Metrics
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.Prometheus.AspNetCore
// Program.cs
using OpenTelemetry.Metrics;
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddPrometheusExporter();
});
var app = builder.Build();
app.UseOpenTelemetryPrometheusScrapingEndpoint(); // /metrics
Tracing
Use OpenTelemetry.NET for distributed tracing.
Installation
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Instrumentation.SqlClient
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
Configuration
// Program.cs
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource =>
{
resource.AddService(serviceName: "my-service", serviceVersion: "1.0.0");
})
.WithTracing(tracing =>
{
tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSqlClientInstrumentation()
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri("http://otel-collector:4317");
});
});
Manual Instrumentation
using System.Diagnostics;
public class OrderService
{
private static readonly ActivitySource ActivitySource = new("MyService.Orders");
public async Task ProcessOrder(Order order)
{
using var activity = ActivitySource.StartActivity("ProcessOrder");
activity?.SetTag("order.id", order.Id);
activity?.SetTag("customer.id", order.CustomerId);
try
{
using (ActivitySource.StartActivity("ValidateOrder"))
{
await ValidateOrder(order);
}
using (ActivitySource.StartActivity("ProcessPayment"))
{
await ProcessPayment(order);
}
activity?.SetStatus(ActivityStatusCode.Ok);
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);
throw;
}
}
}
Register the ActivitySource:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing =>
{
tracing.AddSource("MyService.Orders");
// ... other configuration
});
Connecting Logs and Traces
// Serilog automatically includes trace context with this enricher
dotnet add package Serilog.Enrichers.Span
Log.Logger = new LoggerConfiguration()
.Enrich.WithSpan() // Adds TraceId, SpanId
.WriteTo.Console(new CompactJsonFormatter())
.CreateLogger();
Health Checks
Use ASP.NET Core's built-in health checks.
Installation
dotnet add package Microsoft.Extensions.Diagnostics.HealthChecks
dotnet add package AspNetCore.HealthChecks.NpgSql # PostgreSQL
dotnet add package AspNetCore.HealthChecks.Redis # Redis
Configuration
// Program.cs
builder.Services.AddHealthChecks()
.AddNpgSql(
connectionString: builder.Configuration.GetConnectionString("Database"),
name: "database",
tags: new[] { "ready" })
.AddRedis(
redisConnectionString: builder.Configuration.GetConnectionString("Redis"),
name: "redis",
tags: new[] { "ready" });
var app = builder.Build();
// Liveness - simple check that process is running
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false // Run no checks, just return healthy
});
// Readiness - check dependencies
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
// Comprehensive
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = WriteHealthResponse
});
Custom Response Format
using System.Text.Json;
using Microsoft.Extensions.Diagnostics.HealthChecks;
async Task WriteHealthResponse(HttpContext context, HealthReport report)
{
context.Response.ContentType = "application/json";
var result = new
{
status = report.Status.ToString().ToLower(),
duration = report.TotalDuration.TotalMilliseconds,
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString().ToLower(),
duration_ms = e.Value.Duration.TotalMilliseconds,
error = e.Value.Exception?.Message
})
};
await context.Response.WriteAsync(JsonSerializer.Serialize(result));
}
Custom Health Check
public class ExternalApiHealthCheck : IHealthCheck
{
private readonly HttpClient _httpClient;
public ExternalApiHealthCheck(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetAsync("/health", cancellationToken);
if (response.IsSuccessStatusCode)
{
return HealthCheckResult.Healthy("External API is accessible");
}
return HealthCheckResult.Degraded($"External API returned {response.StatusCode}");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("External API is not accessible", ex);
}
}
}
// Register
builder.Services.AddHealthChecks()
.AddCheck<ExternalApiHealthCheck>("external_api", tags: new[] { "ready" });
Complete Example
using Serilog;
using Serilog.Formatting.Compact;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using OpenTelemetry.Metrics;
using Prometheus;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.Enrich.FromLogContext()
.Enrich.WithSpan()
.WriteTo.Console(new CompactJsonFormatter())
.CreateLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog();
// Health checks
builder.Services.AddHealthChecks()
.AddNpgSql(builder.Configuration.GetConnectionString("Database"), tags: new[] { "ready" });
// OpenTelemetry
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("my-service"))
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter())
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddPrometheusExporter());
var app = builder.Build();
app.UseSerilogRequestLogging();
app.UseOpenTelemetryPrometheusScrapingEndpoint();
app.MapHealthChecks("/health/live", new() { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new() { Predicate = c => c.Tags.Contains("ready") });
app.MapHealthChecks("/health");
app.Run();
}
finally
{
Log.CloseAndFlush();
}