Learnixo

Logging in .NET · Lesson 1 of 1

Structured Logging with Serilog

Structured Logging with Serilog

Structured logging captures data as key-value pairs, not just text strings. This makes logs searchable, filterable, and queryable in tools like Seq, Elastic, or Datadog.


Why Structured Logging?

# Unstructured (hard to query)
"User 42 placed order 100 for $250.00"

# Structured (queryable)
{ "userId": 42, "orderId": 100, "total": 250.00, "event": "OrderPlaced" }

Install Serilog

Bash
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Thread

Configure in Program.cs

C#
using Serilog;

Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
    .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
    .Enrich.FromLogContext()
    .Enrich.WithEnvironmentName()
    .Enrich.WithThreadId()
    .WriteTo.Console(outputTemplate:
        "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
    .WriteTo.File("logs/app-.log",
        rollingInterval: RollingInterval.Day,
        retainedFileCountLimit: 7)
    .CreateLogger();

var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog();  // replace default logger with Serilog

Log Levels

C#
// From least to most severe:
logger.LogTrace("Entering method {MethodName}", nameof(GetUser));    // very detailed
logger.LogDebug("Cache miss for key {Key}", cacheKey);               // diagnostic
logger.LogInformation("User {UserId} logged in", userId);            // normal events
logger.LogWarning("Retry {Attempt}/3 for payment {PaymentId}", n, id); // unusual
logger.LogError(ex, "Failed to process order {OrderId}", orderId);   // errors
logger.LogCritical("Database unreachable — {Service} starting down", "orders"); // fatal

Configure per-category minimum levels

JSON
// appsettings.json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "Microsoft.EntityFrameworkCore.Database.Command": "Information",
        "System": "Warning"
      }
    }
  }
}

Structured Properties

C#
// Use structured parameters — {PropertyName}, not string interpolation
logger.LogInformation(
    "Order {OrderId} placed by {CustomerId} for {Total:C}",
    order.Id, order.CustomerId, order.Total);

// Log entire objects (destructure with @)
logger.LogDebug("Processing request {@Request}", request);

// Context properties — available in all logs within the scope
using (logger.BeginScope(new { UserId = userId, CorrelationId = Guid.NewGuid() }))
{
    logger.LogInformation("Starting checkout");
    // All logs inside this scope include UserId and CorrelationId
    await ProcessCheckoutAsync();
    logger.LogInformation("Checkout complete");
}

Request Logging Middleware

C#
// Log all HTTP requests automatically
app.UseSerilogRequestLogging(options =>
{
    options.MessageTemplate =
        "{RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000}ms";

    options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
    {
        diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
        diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"]);
        if (httpContext.User.Identity?.IsAuthenticated == true)
            diagnosticContext.Set("UserId", httpContext.User.FindFirstValue("sub"));
    };
});

Logging in Services

C#
public class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger)
    {
        _logger = logger;
    }

    public async Task<Order> PlaceOrderAsync(PlaceOrderRequest request)
    {
        _logger.LogInformation("Placing order for customer {CustomerId}, {ItemCount} items",
            request.CustomerId, request.Items.Count);

        try
        {
            var order = await ProcessAsync(request);
            _logger.LogInformation("Order {OrderId} placed successfully, total {Total:C}",
                order.Id, order.Total);
            return order;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to place order for customer {CustomerId}",
                request.CustomerId);
            throw;
        }
    }
}

Production Setup — Seq

Seq is a local log server with a web UI for querying structured logs.

Bash
# Run Seq locally
docker run --name seq -d -e ACCEPT_EULA=Y -p 5341:80 datalust/seq
Bash
dotnet add package Serilog.Sinks.Seq
C#
.WriteTo.Seq("http://localhost:5341")

Open http://localhost:5341 → searchable, filterable log viewer.


Key Takeaways

  1. Use structured parameters ({UserId} not "user " + id) — this makes logs queryable
  2. Log context enriches all logs within a scope with common properties (correlation IDs, user IDs)
  3. Set minimum levels per namespace — suppress verbose Microsoft framework logs in production
  4. LogError(exception, "...") captures the full stack trace — always pass the exception as first arg
  5. Structured logs are only valuable if you can query them — set up Seq locally, Elasticsearch or Datadog in production