Back to blog
Backend Systemsbeginner

Structured Logging with Serilog in .NET

Set up structured logging in ASP.NET Core with Serilog. Learn log levels, sinks, enrichers, and how to write logs that are searchable and actionable in production.

Asma HafeezApril 17, 20263 min read
dotnetloggingserilogobservabilityaspnet-core
Share:𝕏

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

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.