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