.NET & C# Development · Lesson 13 of 92

Add Serilog in 10 Minutes — Logs Your Team Can Search

Why ILogger Alone Isn't Enough for Production

ASP.NET Core's built-in ILogger is fine for local output. In production it falls short:

  • Console output is plain text — you can't query where OrderId = 42
  • No structured properties on log events
  • No correlation IDs or user context automatically enriched
  • No centralized log storage without extra plumbing

Serilog emits structured log events. Instead of a flat string, each event is a document with typed properties you can filter, aggregate, and alert on.


Install Packages

Bash
# Core + ASP.NET Core integration
dotnet add package Serilog.AspNetCore

# Sinks  where logs go
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Sinks.Seq                 # local dev structured log UI

# Enrichers  properties added to every event
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Thread
dotnet add package Serilog.Enrichers.Process

Minimal Setup in Program.cs

C#
using Serilog;

// Bootstrap logger — catches startup errors before full config loads
Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateBootstrapLogger();

try
{
    var builder = WebApplication.CreateBuilder(args);

    // Replace ASP.NET Core's logger with Serilog
    builder.Host.UseSerilog((context, services, config) =>
    {
        config
            .ReadFrom.Configuration(context.Configuration)  // appsettings.json
            .ReadFrom.Services(services)                     // inject registered services
            .Enrich.FromLogContext()                         // ILogger.BeginScope() values
            .WriteTo.Console();
    });

    // ... rest of Program.cs
    var app = builder.Build();

    // Log every HTTP request automatically
    app.UseSerilogRequestLogging();

    app.MapControllers();
    app.Run();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Application failed to start");
}
finally
{
    await Log.CloseAndFlushAsync();
}

Full appsettings.json Configuration

Move Serilog config out of code and into appsettings.json so you can change log levels without redeploying:

JSON
{
  "Serilog": {
    "Using": [
      "Serilog.Sinks.Console",
      "Serilog.Sinks.File",
      "Serilog.Sinks.Seq"
    ],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "Microsoft.Hosting.Lifetime": "Information",
        "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
        "System": "Warning"
      }
    },
    "WriteTo": [
      {
        "Name": "Console",
        "Args": {
          "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} — {Message:lj}{NewLine}{Exception}"
        }
      },
      {
        "Name": "File",
        "Args": {
          "path": "logs/orderflow-.log",
          "rollingInterval": "Day",
          "retainedFileCountLimit": 30,
          "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} — {Message:lj}{NewLine}{Exception}"
        }
      },
      {
        "Name": "Seq",
        "Args": {
          "serverUrl": "http://localhost:5341"
        }
      }
    ],
    "Enrich": [
      "FromLogContext",
      "WithMachineName",
      "WithThreadId",
      "WithProcessId"
    ],
    "Properties": {
      "Application": "OrderFlow.Api",
      "Environment": "Development"
    }
  }
}
JSON
// appsettings.Production.json — no Seq, no debug output
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Warning",
      "Override": {
        "OrderFlow": "Information"
      }
    },
    "WriteTo": [
      {
        "Name": "Console"
      }
    ]
  }
}

Structured Logging — The Core Benefit

Never concatenate strings into log messages. Use message templates with named properties.

C#
// BAD — flat string, can't query by OrderId
_logger.LogInformation($"Order {orderId} created for customer {customerId}");
// Stored as: "Order 42 created for customer 18"

// GOOD — structured event, properties are queryable
_logger.LogInformation(
    "Order {OrderId} created for customer {CustomerId} with total {Total:C}",
    orderId, customerId, total);
// Stored as: { OrderId: 42, CustomerId: 18, Total: 199.99 }
// Seq query: SELECT * WHERE OrderId = 42
C#
public class OrderService : IOrderService
{
    private readonly ILogger<OrderService> _logger;
    private readonly IOrderRepository _repo;

    public OrderService(ILogger<OrderService> logger, IOrderRepository repo)
    {
        _logger = logger;
        _repo   = repo;
    }

    public async Task<OrderDto> CreateAsync(CreateOrderRequest req, CancellationToken ct)
    {
        _logger.LogInformation(
            "Creating order for Customer {CustomerId} with {ItemCount} items",
            req.CustomerId, req.Items.Count);

        var order = Order.Create(req);

        try
        {
            await _repo.AddAsync(order, ct);

            _logger.LogInformation(
                "Order {OrderId} persisted. Total: {Total:C}",
                order.Id, order.Total);

            return order.ToDto();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex,
                "Failed to create order for Customer {CustomerId}",
                req.CustomerId);
            throw;
        }
    }
}

Log Levels

C#
_logger.LogTrace("Entering method. Parameter value: {Param}", param);      // dev only
_logger.LogDebug("Cache miss for key {CacheKey}", key);                     // dev + staging
_logger.LogInformation("Order {OrderId} created", order.Id);               // normal flow
_logger.LogWarning("Payment retry #{Attempt} for Order {OrderId}", n, id); // needs attention
_logger.LogError(ex, "Payment failed for Order {OrderId}", order.Id);      // something broke
_logger.LogCritical(ex, "Database unreachable — service is degraded");     // wake someone up

Production rule of thumb:

  • Default minimum: Warning
  • Your own namespaces: Information
  • EF Core queries: Warning (unless debugging slow queries)

Enrichers — Properties on Every Log Event

C#
// Program.cs — code-based enrichers
builder.Host.UseSerilog((ctx, svc, config) =>
{
    config
        .ReadFrom.Configuration(ctx.Configuration)
        .ReadFrom.Services(svc)
        .Enrich.FromLogContext()
        .Enrich.WithMachineName()
        .Enrich.WithThreadId()
        .Enrich.WithProperty("Application", "OrderFlow.Api")
        .Enrich.WithProperty("Environment", ctx.HostingEnvironment.EnvironmentName)
        .WriteTo.Console();
});

Every log event now contains MachineName, ThreadId, Application, and Environment automatically. No per-call code needed.


Log Context — Per-Request Enrichment

Push values that apply to a block of code without passing them to every method:

C#
public class OrdersController : ControllerBase
{
    private readonly IOrderService _orders;
    private readonly ILogger<OrdersController> _logger;

    public OrdersController(IOrderService orders, ILogger<OrdersController> logger)
    {
        _orders = orders;
        _logger = logger;
    }

    [HttpPost]
    public async Task<IActionResult> Create(
        [FromBody] CreateOrderRequest req,
        CancellationToken ct)
    {
        // All logs within this scope include CustomerId and CorrelationId
        using var scope = _logger.BeginScope(new Dictionary<string, object>
        {
            ["CustomerId"]    = req.CustomerId,
            ["CorrelationId"] = HttpContext.TraceIdentifier
        });

        _logger.LogInformation("Received order request");

        var order = await _orders.CreateAsync(req, ct);

        _logger.LogInformation("Order created successfully");
        // Both of these include CustomerId and CorrelationId

        return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
    }
}

Serilog Request Logging Middleware

Replace the noisy default ASP.NET Core request logs with one structured event per request:

C#
// Program.cs
app.UseSerilogRequestLogging(options =>
{
    // Customize the log level by status code
    options.GetLevel = (ctx, elapsed, ex) =>
        ex is not null || ctx.Response.StatusCode >= 500 ? LogLevel.Error
        : ctx.Response.StatusCode >= 400                 ? LogLevel.Warning
        : LogLevel.Information;

    // Add extra properties to every request log
    options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
    {
        diagnosticContext.Set("RequestHost",   httpContext.Request.Host.Value);
        diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
        diagnosticContext.Set("UserAgent",     httpContext.Request.Headers["User-Agent"].ToString());
        diagnosticContext.Set("UserId",        httpContext.User.Identity?.Name ?? "anonymous");

        var correlationId = httpContext.Request.Headers["X-Correlation-Id"].FirstOrDefault();
        if (correlationId is not null)
            diagnosticContext.Set("CorrelationId", correlationId);
    };
});

This replaces six default framework log lines per request with one clean line:

[10:23:15 INF] HTTP GET /api/orders/42 responded 200 in 14.3ms | UserId: john@example.com

Avoiding Logging Sensitive Data

C#
// BAD — logs the actual card number
_logger.LogInformation("Processing payment with card {CardNumber}", req.CardNumber);

// BAD — logs the full request body including password
_logger.LogDebug("Request body: {Body}", JsonSerializer.Serialize(req));

// GOOD — log only non-sensitive identifiers
_logger.LogInformation("Processing payment for Order {OrderId} via {Provider}",
    req.OrderId, req.PaymentProvider);

// GOOD — mask sensitive fields in a DTO before logging
_logger.LogDebug("Payment request: {PaymentRequest}",
    new { req.OrderId, req.Amount, CardLastFour = req.CardNumber[^4..] });

// GOOD — destructuring with selective properties
_logger.LogInformation("User logged in: {@UserSummary}",
    new { user.Id, user.Email, user.Role });
// DO NOT log: user.PasswordHash, user.SecurityStamp, user.TwoFactorKey

What to Learn Next

  • Middleware Pipeline: Build the correlation ID and request logging middleware that feeds your Serilog output
  • Observability: Distributed tracing with OpenTelemetry to go beyond logs
  • Health Checks: Add structured logging for health probe failures