Skip to main content

.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();
}