.NET Programming With Me

Logging and Structured Diagnostics in .NET 8 with Serilog and PostgreSQL Sink

When something breaks in production at 2 AM, plain-text log lines like "Error processing order" are nearly useless. You cannot filter them, you cannot correlate them across a request, and you cannot query them. Structured logging fixes this by treating every log entry as data with named properties, not a sentence. Serilog is the de facto standard for it in .NET.

By the end of this post you will have a .NET 8 API that logs structured events through Serilog, enriches every entry with request context, writes them to PostgreSQL where you can query them with SQL, and correlates all logs from a single request together.


1 Prerequisites

Before starting, make sure you have:

  • .NET 8 SDK and an existing ASP.NET Core Web API project
  • A running PostgreSQL instance to act as the log sink
  • The Serilog.AspNetCore and Serilog.Sinks.PostgreSQL NuGet packages
Note: A database is a convenient and queryable sink for moderate volumes, but high-throughput systems usually ship logs to a dedicated platform such as Seq, Elasticsearch, or a cloud log service. The structured-logging principles here apply identically; only the sink changes.

2 Wire up Serilog

The goal is to replace the default logging provider with Serilog as early as possible, so that even startup errors are captured. .NET 8's minimal hosting model makes this clean with a two-stage bootstrap.

Bootstrap logger plus full configuration

The first logger catches failures during host build; the second, configured from appsettings.json, takes over once the app is running.

// Program.cs
Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateBootstrapLogger();

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

    builder.Host.UseSerilog((context, services, config) => config
        .ReadFrom.Configuration(context.Configuration)
        .ReadFrom.Services(services)
        .Enrich.FromLogContext());

    var app = builder.Build();
    app.UseSerilogRequestLogging(); // one tidy log line per HTTP request
    app.Run();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Application terminated unexpectedly.");
}
finally
{
    Log.CloseAndFlush();
}

UseSerilogRequestLogging() is a quiet hero: it collapses the framework's noisy per-request log spam into a single structured line with method, path, status code, and elapsed time. CloseAndFlush() in the finally block guarantees buffered logs reach the sink before the process exits, which matters most when the app is crashing, exactly when you need those logs.


3 Write structured events

This is the habit shift that makes everything else pay off. Use message templates with named placeholders rather than string interpolation. Serilog captures each value as a queryable property instead of baking it into an opaque string.

// Do this: the values become structured properties
_logger.LogInformation(
    "Order {OrderId} placed by user {UserId} for {Amount:C}",
    order.Id, userId, order.Total);

// Not this: the values are lost inside one flat string
_logger.LogInformation(
    $"Order {order.Id} placed by user {userId} for {order.Total:C}");

With the first form, you can later query "all orders over $500" or "every event for OrderId 4827" directly, because OrderId, UserId, and Amount are real fields. The second form throws that structure away the moment it is written. The difference looks cosmetic; operationally it is enormous.


4 Sink to PostgreSQL

Now we route those structured events into a PostgreSQL table where each property can map to its own column. Defining the column map explicitly keeps the schema predictable and queryable.

// Program.cs — configure the PostgreSQL sink
var columns = new Dictionary<string, ColumnWriterBase>
{
    ["message"]      = new RenderedMessageColumnWriter(),
    ["level"]        = new LevelColumnWriter(),
    ["timestamp"]    = new TimestampColumnWriter(),
    ["exception"]    = new ExceptionColumnWriter(),
    ["properties"]   = new LogEventSerializedColumnWriter(),
    ["request_id"]   = new SinglePropertyColumnWriter("RequestId")
};

builder.Host.UseSerilog((context, services, config) => config
    .ReadFrom.Configuration(context.Configuration)
    .Enrich.FromLogContext()
    .WriteTo.PostgreSQL(
        connectionString: context.Configuration.GetConnectionString("Logs")!,
        tableName: "app_logs",
        columnOptions: columns,
        needAutoCreateTable: true));

With needAutoCreateTable set, the sink creates app_logs on first run. The properties column stores the full structured payload as JSON, while common fields get dedicated columns. That hybrid gives you fast filtering on the columns plus the full detail in JSON when you need to dig deeper.

-- Querying logs becomes ordinary SQL
SELECT timestamp, level, message, request_id
FROM app_logs
WHERE level = 'Error'
  AND timestamp > now() - interval '1 hour'
ORDER BY timestamp DESC;
Performance note: writing each log line synchronously to a database will throttle a busy API. Use the sink's batching options (period and batch size) so writes are buffered and flushed in groups, and never log at Debug level in production unless you are actively investigating.

5 Correlate logs per request

The final piece is correlation. When a single request produces ten log lines across services, you need to tie them together. Enriching every log within a request with a shared identifier lets you reconstruct the full story of any one call.

// Middleware that pushes a correlation id into the log context
public sealed class CorrelationMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId = context.Request.Headers["X-Correlation-ID"]
            .FirstOrDefault() ?? Guid.NewGuid().ToString();

        context.Response.Headers["X-Correlation-ID"] = correlationId;

        using (LogContext.PushProperty("CorrelationId", correlationId))
        {
            await next(context);
        }
    }
}
// Program.cs — register before the endpoints run
app.UseMiddleware<CorrelationMiddleware>();

Because Enrich.FromLogContext() is already configured, every log written inside that using block automatically carries the CorrelationId property, with no extra code at the call sites. Returning the id in the response header also lets clients quote it in support tickets, so you can jump straight to their exact request: WHERE properties ->> 'CorrelationId' = '...'.


Wrapping up

You now have an API that logs structured, queryable events through Serilog, enriches them with per-request correlation ids, and persists them to PostgreSQL where ordinary SQL answers your operational questions. That is the difference between guessing what happened in production and knowing.

This closes out Series 5 on real-world .NET 8 API features. Across the five posts you have built authentication, authorization, background processing, caching, and now diagnostics, the cross-cutting concerns that turn a working API into a production-ready one.

Got a question or ran into a problem? Drop a comment below and I will reply.

No comments: