You are viewing a preview of this lesson. Sign in to start learning
Back to ASP.NET with .NET 10

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 is the standard logging abstraction in ASP.NET Core, supporting structured logging and multiple providers.

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:

LevelValueUse Case
Trace0Very detailed diagnostic info (rarely used in production)
Debug1Development diagnostics, variable values
Information2General application flow, significant events
Warning3Unexpected but recoverable situations
Error4Failures requiring immediate attention
Critical5Application 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


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

Practice Questions

Test your understanding with these questions:

Q1: What middleware method is used to register custom global exception handling in ASP.NET Core? ```csharp var app = builder.Build(); app.{{1}}(errorPath); ```
A: UseExceptionHandler
Q2: Complete the ILogger injection: ```csharp public class OrderService { private readonly {{1}} _logger; public OrderService(ILogger<OrderService> logger) { _logger = logger; } } ```
A: ILogger<OrderService>
Q3: What log level should be used for unexpected but recoverable situations in ASP.NET Core? ```csharp _logger.{{1}}("Cache miss occurred, fetching from database"); ``` A. LogDebug B. LogInformation C. LogWarning D. LogError E. LogCritical
A: C
Q4: Fill in the structured logging message template: ```csharp _logger.LogInformation("User {{1}} performed {{2}}", userId, action); ```
A: ["UserId","Action"]
Q5: What method starts a logging scope that adds context to all subsequent log entries? ```csharp using (_logger.{{1}}(new { OrderId = orderId })) { // All logs here include OrderId } ```
A: BeginScope