.NET & C# Development · Lesson 84 of 92

Centralized Logs With Seq — Search 10,000 Lines in Seconds

Why File Logs Don't Scale

Flat log files work fine for a single service on one machine. The moment you have:

  • Multiple service instances behind a load balancer
  • More than one microservice involved in a request
  • Logs rolling over daily across dozens of pods

...you're grep-ing across SSH sessions and losing your mind. Centralised structured logging solves this.

Seq — Dev-Friendly Log Server

Seq is free for a single user, has a slick search UI, and understands Serilog's structured events natively. Run it with Docker:

Bash
docker run -d --name seq -e ACCEPT_EULA=Y -p 5341:5341 -p 8080:80 datalust/seq

Packages

Bash
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Seq
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Thread

Configuration

C#
// Program.cs
using Serilog;

Log.Logger = new LoggerConfiguration()
    .ReadFrom.Configuration(builder.Configuration)
    .Enrich.FromLogContext()
    .Enrich.WithMachineName()
    .Enrich.WithThreadId()
    .WriteTo.Console()
    .WriteTo.Seq("http://localhost:5341")
    .CreateLogger();

builder.Host.UseSerilog();
JSON
// appsettings.json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft.AspNetCore": "Warning",
        "System": "Warning"
      }
    }
  }
}

Structured Log Events

The key difference: log objects, not interpolated strings.

C#
// Bad — you can't filter on OrderId in Seq
_logger.LogInformation($"Order {orderId} created for customer {customerId}");

// Good — OrderId and CustomerId become searchable properties
_logger.LogInformation("Order {OrderId} created for {CustomerId}", orderId, customerId);

In Seq UI you can now query: OrderId = 'abc-123' and get every log event touching that order across all services.

Correlation IDs

Add a correlation ID middleware so every log event for a single HTTP request shares the same ID:

C#
public class CorrelationIdMiddleware(RequestDelegate next)
{
    private const string Header = "X-Correlation-ID";

    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId = context.Request.Headers[Header].FirstOrDefault()
            ?? Guid.NewGuid().ToString("N");

        context.Response.Headers[Header] = correlationId;

        using (LogContext.PushProperty("CorrelationId", correlationId))
        {
            await next(context);
        }
    }
}

// Program.cs
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseSerilogRequestLogging(); // logs method, path, status, elapsed

Now every log line has CorrelationId — search CorrelationId = 'abc' in Seq to see the full request trace.

ELK Stack — Production-Scale Logging

ELK = Elasticsearch (store + search) + Logstash (ingest pipeline) + Kibana (UI). For .NET, you often skip Logstash and push directly to Elasticsearch.

Bash
dotnet add package Serilog.Sinks.Elasticsearch
C#
.WriteTo.Elasticsearch(new ElasticsearchSinkOptions(
    new Uri(builder.Configuration["Elasticsearch:Uri"]!))
{
    AutoRegisterTemplate = true,
    AutoRegisterTemplateVersion = AutoRegisterTemplateVersion.ESv7,
    IndexFormat = $"dotnet-logs-{DateTime.UtcNow:yyyy-MM}",
    ModifyConnectionSettings = c =>
        c.BasicAuthentication(
            builder.Configuration["Elasticsearch:User"],
            builder.Configuration["Elasticsearch:Password"])
})

Index Template

Register this in Elasticsearch once to ensure correct field mappings:

JSON
PUT _index_template/dotnet-logs
{
  "index_patterns": ["dotnet-logs-*"],
  "template": {
    "mappings": {
      "properties": {
        "@timestamp":     { "type": "date" },
        "level":          { "type": "keyword" },
        "message":        { "type": "text" },
        "CorrelationId":  { "type": "keyword" },
        "RequestPath":    { "type": "keyword" },
        "StatusCode":     { "type": "integer" },
        "Elapsed":        { "type": "float" },
        "MachineName":    { "type": "keyword" },
        "service":        { "type": "keyword" }
      }
    }
  }
}

keyword fields are exact-match filterable. text fields are full-text searched. Getting this right means fast queries at scale.

Kibana Search Across Services

In Kibana Discover, with the index pattern dotnet-logs-*:

CorrelationId : "abc123" AND level : "Error"
service : "order-api" AND StatusCode >= 500
RequestPath : "/api/orders*" AND Elapsed > 1000

Choosing Seq vs ELK

| | Seq | ELK | |---|---|---| | Setup | 1 Docker command | docker-compose with 3 services | | Cost | Free (1 user) | Free (self-hosted) | | Best for | Local dev, small teams | Multi-service production | | Query language | Seq SQL-like | Lucene / KQL |

Run Seq locally, push to ELK in production. Use the same Serilog configuration with environment-based sink selection:

C#
var logConfig = new LoggerConfiguration()
    .ReadFrom.Configuration(configuration)
    .Enrich.FromLogContext();

if (environment.IsDevelopment())
    logConfig.WriteTo.Seq("http://localhost:5341");
else
    logConfig.WriteTo.Elasticsearch(esOptions);

Log.Logger = logConfig.CreateLogger();

Summary

  • Structured logging ({Property} tokens) is the foundation — without it, centralised logging is just fancy grep
  • Seq is the fastest path to searchable logs in dev; zero config beyond a Docker container
  • ELK scales to terabytes across hundreds of services; index templates ensure field types are correct
  • Correlation IDs thread a single request across every service it touches — essential for microservices debugging