Error Handling & Logging
Implement robust error handling and structured logging
Error Handling & Logging in ASP.NET with .NET 10
Master error handling and logging in ASP.NET with free flashcards and spaced repetition practice. This lesson covers structured exception handling, middleware-based error management, logging frameworks, and production-ready diagnostic strategies—essential concepts for building robust, maintainable web applications.
Welcome 💻
Building reliable web applications means planning for failure. In ASP.NET with .NET 10, error handling and logging aren't afterthoughts—they're fundamental pillars of application architecture. Whether you're catching exceptions in your controllers, implementing global error middleware, or integrating structured logging with providers like Serilog, understanding these concepts ensures your applications fail gracefully and provide actionable diagnostic information.
This lesson will guide you through exception handling patterns, middleware-based error interception, ILogger implementation, structured logging, and integration with monitoring tools. By the end, you'll know how to build applications that recover from errors elegantly and provide comprehensive diagnostic trails.
Core Concepts 🧠
1. Exception Handling in ASP.NET Core
Exception handling in ASP.NET Core operates at multiple levels:
Controller-Level Handling:
public class ProductsController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetProduct(int id)
{
try
{
var product = _service.GetProductById(id);
return Ok(product);
}
catch (NotFoundException ex)
{
return NotFound(new { message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving product {Id}", id);
return StatusCode(500, "Internal server error");
}
}
}
Exception Filters: Exception filters intercept exceptions at the action or controller level:
public class CustomExceptionFilter : IExceptionFilter
{
private readonly ILogger<CustomExceptionFilter> _logger;
public CustomExceptionFilter(ILogger<CustomExceptionFilter> logger)
{
_logger = logger;
}
public void OnException(ExceptionContext context)
{
_logger.LogError(context.Exception, "Unhandled exception occurred");
context.Result = new ObjectResult(new
{
error = "An error occurred processing your request"
})
{
StatusCode = 500
};
context.ExceptionHandled = true;
}
}
Register in Program.cs:
builder.Services.AddControllers(options =>
{
options.Filters.Add<CustomExceptionFilter>();
});
2. Exception Handling Middleware
Middleware provides centralized, global exception handling for your entire application pipeline.
Built-in Exception Handler Middleware:
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/error");
app.UseHsts();
}
Custom Exception Handling Middleware:
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionMiddleware> _logger;
public GlobalExceptionMiddleware(RequestDelegate next,
ILogger<GlobalExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (ValidationException ex)
{
_logger.LogWarning(ex, "Validation error occurred");
await HandleValidationException(context, ex);
}
catch (UnauthorizedException ex)
{
_logger.LogWarning(ex, "Unauthorized access attempt");
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new { error = "Unauthorized" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception occurred");
await HandleGenericException(context, ex);
}
}
private async Task HandleValidationException(HttpContext context,
ValidationException ex)
{
context.Response.StatusCode = 400;
await context.Response.WriteAsJsonAsync(new
{
error = "Validation failed",
details = ex.Errors
});
}
private async Task HandleGenericException(HttpContext context, Exception ex)
{
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
var response = new
{
error = "An internal error occurred",
traceId = context.TraceIdentifier
};
await context.Response.WriteAsJsonAsync(response);
}
}
Register middleware:
app.UseMiddleware<GlobalExceptionMiddleware>();
💡 Tip: Place exception middleware early in the pipeline to catch exceptions from all subsequent middleware.
3. Logging with ILogger
ILogger
Basic Logging:
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger)
{
_logger = logger;
}
public async Task<Order> CreateOrderAsync(OrderRequest request)
{
_logger.LogInformation("Creating order for customer {CustomerId}",
request.CustomerId);
try
{
var order = await _repository.CreateAsync(request);
_logger.LogInformation("Order {OrderId} created successfully",
order.Id);
return order;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to create order for customer {CustomerId}",
request.CustomerId);
throw;
}
}
}
Log Levels:
| Level | Value | Use Case |
|---|---|---|
| Trace | 0 | Very detailed diagnostic info (rarely used in production) |
| Debug | 1 | Development diagnostics, variable values |
| Information | 2 | General application flow, significant events |
| Warning | 3 | Unexpected but recoverable situations |
| Error | 4 | Failures requiring immediate attention |
| Critical | 5 | Application crashes, data loss, critical failures |
Configuration in appsettings.json:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
}
}
4. Structured Logging
Structured logging captures log data as structured fields, not just text strings, enabling powerful querying and analysis.
Message Templates (Semantic Logging):
// ❌ String interpolation - not structured
_logger.LogInformation($"User {userId} logged in from {ipAddress}");
// ✅ Message template - structured
_logger.LogInformation("User {UserId} logged in from {IpAddress}",
userId, ipAddress);
The second approach creates structured data:
{
"Timestamp": "2024-01-15T10:30:00",
"Level": "Information",
"Message": "User 12345 logged in from 192.168.1.1",
"UserId": 12345,
"IpAddress": "192.168.1.1"
}
Scopes for Context:
public async Task ProcessPaymentAsync(int orderId, decimal amount)
{
using (_logger.BeginScope(new Dictionary<string, object>
{
["OrderId"] = orderId,
["Amount"] = amount,
["CorrelationId"] = Guid.NewGuid()
}))
{
_logger.LogInformation("Starting payment processing");
await _paymentGateway.ChargeAsync(amount);
_logger.LogInformation("Payment processed successfully");
}
}
All logs within the scope automatically include OrderId, Amount, and CorrelationId.
5. Logging Providers
.NET 10 supports multiple logging providers:
Console Provider:
builder.Logging.AddConsole();
Debug Provider:
builder.Logging.AddDebug();
Event Source Provider:
builder.Logging.AddEventSourceLogger();
Third-Party Providers:
Serilog (popular structured logging library):
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Sinks.Console
using Serilog;
var builder = WebApplication.CreateBuilder(args);
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.File("logs/app-.txt", rollingInterval: RollingInterval.Day)
.Enrich.FromLogContext()
.CreateLogger();
builder.Host.UseSerilog();
var app = builder.Build();
try
{
Log.Information("Starting web application");
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
Serilog Configuration (appsettings.json):
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "File",
"Args": {
"path": "logs/app-.txt",
"rollingInterval": "Day",
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level}] {Message}{NewLine}{Exception}"
}
}
]
}
}
6. Problem Details for HTTP APIs
RFC 7807 Problem Details provides a standardized error response format:
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler(exceptionHandlerApp =>
{
exceptionHandlerApp.Run(async context =>
{
var exceptionHandlerFeature =
context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionHandlerFeature?.Error;
var problemDetails = new ProblemDetails
{
Title = "An error occurred",
Status = StatusCodes.Status500InternalServerError,
Detail = exception?.Message,
Instance = context.Request.Path
};
problemDetails.Extensions["traceId"] = context.TraceIdentifier;
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(problemDetails);
});
});
Response:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.6.1",
"title": "An error occurred",
"status": 500,
"detail": "Database connection failed",
"instance": "/api/products/123",
"traceId": "00-abc123-def456-00"
}
🧠 Mnemonic - SLEPT for Logging Best Practices:
- Structured (use message templates)
- Levels (choose appropriate severity)
- Enrich (add context with scopes)
- Providers (multiple outputs)
- Trace (include correlation IDs)
Examples 🔍
Example 1: Complete Error Handling Strategy
Custom Exception Classes:
public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message) { }
}
public class ValidationException : Exception
{
public IDictionary<string, string[]> Errors { get; }
public ValidationException(IDictionary<string, string[]> errors)
: base("Validation failed")
{
Errors = errors;
}
}
public class BusinessRuleException : Exception
{
public string RuleCode { get; }
public BusinessRuleException(string message, string ruleCode)
: base(message)
{
RuleCode = ruleCode;
}
}
Global Exception Handler:
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
private readonly IHostEnvironment _environment;
public ExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlingMiddleware> logger,
IHostEnvironment environment)
{
_next = next;
_logger = logger;
_environment = environment;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception ex)
{
var problemDetails = ex switch
{
NotFoundException notFound => new ProblemDetails
{
Title = "Resource Not Found",
Status = StatusCodes.Status404NotFound,
Detail = notFound.Message,
Instance = context.Request.Path
},
ValidationException validation => new ValidationProblemDetails(
validation.Errors)
{
Title = "Validation Error",
Status = StatusCodes.Status400BadRequest,
Instance = context.Request.Path
},
BusinessRuleException businessRule => new ProblemDetails
{
Title = "Business Rule Violation",
Status = StatusCodes.Status422UnprocessableEntity,
Detail = businessRule.Message,
Instance = context.Request.Path,
Extensions = { ["ruleCode"] = businessRule.RuleCode }
},
_ => new ProblemDetails
{
Title = "Internal Server Error",
Status = StatusCodes.Status500InternalServerError,
Detail = _environment.IsDevelopment() ? ex.Message :
"An error occurred processing your request",
Instance = context.Request.Path
}
};
problemDetails.Extensions["traceId"] = context.TraceIdentifier;
var logLevel = problemDetails.Status >= 500 ?
LogLevel.Error : LogLevel.Warning;
_logger.Log(logLevel, ex,
"Exception occurred: {ExceptionType} - {Message}",
ex.GetType().Name, ex.Message);
context.Response.StatusCode = problemDetails.Status.Value;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problemDetails);
}
}
Example 2: Request/Response Logging Middleware
public class RequestResponseLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestResponseLoggingMiddleware> _logger;
public RequestResponseLoggingMiddleware(
RequestDelegate next,
ILogger<RequestResponseLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
// Log request
var requestBody = await ReadRequestBodyAsync(context.Request);
_logger.LogInformation(
"HTTP {Method} {Path} started. Body: {RequestBody}",
context.Request.Method,
context.Request.Path,
requestBody);
var originalBodyStream = context.Response.Body;
using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
var stopwatch = Stopwatch.StartNew();
try
{
await _next(context);
stopwatch.Stop();
// Log response
var response = await ReadResponseBodyAsync(context.Response);
_logger.LogInformation(
"HTTP {Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}. Response: {Response}",
context.Request.Method,
context.Request.Path,
stopwatch.ElapsedMilliseconds,
context.Response.StatusCode,
response);
await responseBody.CopyToAsync(originalBodyStream);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex,
"HTTP {Method} {Path} failed after {ElapsedMs}ms",
context.Request.Method,
context.Request.Path,
stopwatch.ElapsedMilliseconds);
throw;
}
finally
{
context.Response.Body = originalBodyStream;
}
}
private async Task<string> ReadRequestBodyAsync(HttpRequest request)
{
request.EnableBuffering();
using var reader = new StreamReader(
request.Body,
Encoding.UTF8,
leaveOpen: true);
var body = await reader.ReadToEndAsync();
request.Body.Position = 0;
return body;
}
private async Task<string> ReadResponseBodyAsync(HttpResponse response)
{
response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(response.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
response.Body.Seek(0, SeekOrigin.Begin);
return body;
}
}
Example 3: Health Checks with Logging
public class DatabaseHealthCheck : IHealthCheck
{
private readonly ApplicationDbContext _context;
private readonly ILogger<DatabaseHealthCheck> _logger;
public DatabaseHealthCheck(
ApplicationDbContext context,
ILogger<DatabaseHealthCheck> logger)
{
_context = context;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
await _context.Database.CanConnectAsync(cancellationToken);
_logger.LogInformation("Database health check passed");
return HealthCheckResult.Healthy("Database is accessible");
}
catch (Exception ex)
{
_logger.LogError(ex, "Database health check failed");
return HealthCheckResult.Unhealthy(
"Database is not accessible",
ex);
}
}
}
// Program.cs
builder.Services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database")
.AddUrlGroup(new Uri("https://api.example.com/health"), "external_api");
var app = builder.Build();
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = async (context, report) =>
{
context.Response.ContentType = "application/json";
var result = JsonSerializer.Serialize(new
{
status = report.Status.ToString(),
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
description = e.Value.Description,
duration = e.Value.Duration.TotalMilliseconds
}),
totalDuration = report.TotalDuration.TotalMilliseconds
});
await context.Response.WriteAsync(result);
}
});
Example 4: Correlation ID Tracking
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
private const string CorrelationIdHeader = "X-Correlation-ID";
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var correlationId = GetOrCreateCorrelationId(context);
context.Items["CorrelationId"] = correlationId;
context.Response.Headers[CorrelationIdHeader] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await _next(context);
}
}
private string GetOrCreateCorrelationId(HttpContext context)
{
if (context.Request.Headers.TryGetValue(
CorrelationIdHeader, out var correlationId))
{
return correlationId.ToString();
}
return Guid.NewGuid().ToString();
}
}
// Usage in service
public class OrderService
{
private readonly ILogger<OrderService> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
public OrderService(
ILogger<OrderService> logger,
IHttpContextAccessor httpContextAccessor)
{
_logger = logger;
_httpContextAccessor = httpContextAccessor;
}
public async Task ProcessOrderAsync(Order order)
{
var correlationId = _httpContextAccessor.HttpContext?
.Items["CorrelationId"]?.ToString();
_logger.LogInformation(
"Processing order {OrderId} with correlation {CorrelationId}",
order.Id, correlationId);
// Processing logic...
}
}
🔧 Try this: Add the correlation middleware to your application and test it with curl:
curl -H "X-Correlation-ID: test-123" http://localhost:5000/api/orders
Check your logs—you'll see test-123 associated with all log entries for that request.
Common Mistakes ⚠️
1. Swallowing Exceptions Without Logging
❌ Wrong:
try
{
await _service.ProcessDataAsync();
}
catch
{
// Silent failure - debugging nightmare!
}
✅ Right:
try
{
await _service.ProcessDataAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process data");
throw; // Re-throw to let higher layers handle
}
2. Using String Interpolation Instead of Message Templates
❌ Wrong:
_logger.LogInformation($"User {userId} performed {action}");
This creates unstructured text. You can't query by userId or action in your logging system.
✅ Right:
_logger.LogInformation("User {UserId} performed {Action}", userId, action);
Now userId and action are structured fields you can filter and aggregate.
3. Logging Sensitive Information
❌ Wrong:
_logger.LogInformation(
"User logged in with password {Password}",
request.Password);
✅ Right:
_logger.LogInformation(
"User {Username} logged in successfully",
request.Username);
// Never log passwords, credit cards, or PII
4. Not Setting Appropriate Log Levels
❌ Wrong:
_logger.LogError("User clicked the button"); // Not an error!
✅ Right:
_logger.LogDebug("User {UserId} clicked button {ButtonId}", userId, buttonId);
5. Catching Generic Exceptions Too Early
❌ Wrong:
public IActionResult Get()
{
try
{
// controller logic
}
catch (Exception ex)
{
return StatusCode(500);
}
}
Let middleware handle generic exceptions. Only catch specific exceptions you can actually handle.
✅ Right:
public IActionResult Get()
{
try
{
// controller logic
}
catch (NotFoundException ex)
{
return NotFound(new { message = ex.Message });
}
// Let middleware handle other exceptions
}
6. Over-Logging in Production
❌ Wrong:
{
"Logging": {
"LogLevel": {
"Default": "Debug"
}
}
}
Debug logs in production create massive log files and performance overhead.
✅ Right:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
🤔 Did you know? A single high-traffic API endpoint logging at Debug level can generate gigabytes of logs per day, significantly impacting storage costs and query performance in centralized logging systems.
Key Takeaways 🎯
✅ Use middleware for global exception handling—centralize your error response logic rather than duplicating try-catch blocks in every controller.
✅ Implement structured logging with message templates—this enables powerful querying and analysis in log aggregation systems.
✅ Choose appropriate log levels—Information for normal flow, Warning for unexpected but handled situations, Error for failures requiring attention.
✅ Never log sensitive data—passwords, credit cards, personal health information, and other PII should never appear in logs.
✅ Use correlation IDs—track requests across distributed services and microservices with unique identifiers.
✅ Configure different logging for different environments—Debug in development, Information in production.
✅ Implement Problem Details (RFC 7807)—provide standardized, machine-readable error responses for HTTP APIs.
✅ Add health checks with logging—monitor application and dependency health with diagnostic logging.
✅ Use log scopes for context—automatically add contextual information to all logs within a scope.
✅ Integrate with centralized logging—use Serilog, NLog, or similar to send logs to systems like Seq, Elasticsearch, or Application Insights.
📚 Further Study
- Microsoft Docs: Handle errors in ASP.NET Core
- Serilog Documentation
- RFC 7807: Problem Details for HTTP APIs
📋 Quick Reference Card
| Concept | Key Points |
|---|---|
| Exception Handling | Use middleware for global handling; catch specific exceptions in controllers; never swallow exceptions silently |
| ILogger | Inject ILogger<T>; use structured logging with message templates; choose appropriate log levels |
| Log Levels | Trace (0) → Debug (1) → Information (2) → Warning (3) → Error (4) → Critical (5) |
| Structured Logging | Use {PropertyName} in templates, not string interpolation; enables querying by specific fields |
| Middleware Order | Place exception middleware early in pipeline; use app.UseExceptionHandler() or custom middleware |
| Problem Details | RFC 7807 standard; includes type, title, status, detail, instance; add custom extensions |
| Log Scopes | Use using (_logger.BeginScope(...)) to add context to all logs in a block |
| Correlation IDs | Track requests across services; include in logs and responses; use middleware to propagate |
| Best Practices | SLEPT: Structured, Levels, Enrich, Providers, Trace |
Quick Middleware Registration Pattern:
app.UseMiddleware<CorrelationIdMiddleware>(); app.UseMiddleware<GlobalExceptionMiddleware>(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();
Common Exception Filter Pattern:
builder.Services.AddControllers(options =>
{
options.Filters.Add<CustomExceptionFilter>();
});