Fluxtail
Application logging

Serilog in ASP.NET Core: Startup Errors, JSON and Request Logging

Configure Serilog in ASP.NET Core with startup logging, JSON and request IDs. Understand middleware order and diagnose repeated-looking errors.

By Fluxtail Engineering

Serilog is a .NET logging library that stores messages with named properties, such as a request ID or HTTP status. ASP.NET Core hosts web applications and supplies its own logging and request-processing pipeline. The Serilog.AspNetCore integration connects framework messages and injected ILogger<T> instances to Serilog destinations, called sinks.

Configuring Serilog in ASP.NET Core involves two separate boundaries. During startup, an early bootstrap logger can record failures before the application host exists. The final logger later replaces it completely. Replacement does not erase events already written, but a destination omitted from the final configuration stops receiving subsequent events.

During requests, several events can describe one failure. An application message describes work attempted. An exception record explains what threw. A request-completion record reports the status and duration observed by middleware. Two error records do not establish that a payment, database write, or other business operation ran twice.

Middleware order determines which outcome Serilog sees. Middleware is a chain of request handlers: each component can call the next, then inspect its result. A logger surrounding an exception handler sees the response that handler produces. A logger inside it sees the exception before the outer handler converts it into a response. The released Serilog middleware source shows both paths.

Configure and verify Serilog in ASP.NET Core

These checks apply to applications adding Serilog or investigating missing startup output and repeated-looking failures. They establish which logger emitted an event and which request outcome it records. They do not establish how many business operations completed; that requires application records or other evidence.

Pin the SDK and integration package

This example targets .NET 10 and Serilog.AspNetCore 10.0.0. Serilog recommends matching the integration package major to the application's target framework. Microsoft's SDK download page lists SDK 10.0.403 with ASP.NET Core runtime 10.0.14.

With that SDK installed, create an isolated demonstration application:

mkdir checkout-logging
cd checkout-logging
dotnet new globaljson --sdk-version 10.0.403 --roll-forward disable
dotnet new web --framework net10.0
dotnet add package Serilog.AspNetCore --version 10.0.0

These pins make the example's dependencies explicit. Review current supported patches before using it in a deployed application.

Keep both startup logger stages complete

Replace Program.cs with the following. The failure switches and demonstration endpoints belong in this local example, not a production application.

using Microsoft.AspNetCore.Diagnostics;
using Serilog;
using Serilog.Context;
using Serilog.Events;
using Serilog.Formatting.Compact;

Log.Logger = new LoggerConfiguration()
    .Enrich.WithProperty("ServiceName", "checkout-api")
    .WriteTo.Console(new RenderedCompactJsonFormatter())
    .CreateBootstrapLogger();

try
{
    Log.Information("Checkout host initialization started");

    if (Environment.GetEnvironmentVariable("DEMO_STARTUP_FAIL") == "1")
        throw new InvalidOperationException("Synthetic startup failure");

    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddSerilog((services, logger) => logger
        .MinimumLevel.Information()
        .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
        .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
        .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
        .Enrich.FromLogContext()
        .Enrich.WithProperty("ServiceName", "checkout-api")
        .WriteTo.Console(new RenderedCompactJsonFormatter()));

    var app = builder.Build();

    app.Use(async (context, next) =>
    {
        using (LogContext.PushProperty("RequestId", context.TraceIdentifier))
            await next(context);
    });

    app.UseSerilogRequestLogging();

    app.UseExceptionHandler(new ExceptionHandlerOptions
    {
        SuppressDiagnosticsCallback = _ => false,
        ExceptionHandler = async context =>
        {
            var error = context.Features.Get<IExceptionHandlerFeature>()?.Error;
            var logger = context.RequestServices
                .GetRequiredService<ILoggerFactory>()
                .CreateLogger("Checkout.ErrorHandler");
            logger.LogError(error, "Checkout request failed");
            context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
            await context.Response.WriteAsync("Checkout temporarily unavailable");
        }
    });

    app.MapGet("/ready", (ILoggerFactory factory) =>
    {
        factory.CreateLogger("Checkout.Readiness")
            .LogInformation("Readiness probe completed");
        return Results.Ok(new { ready = true });
    });

    app.MapGet("/demo-failure", () =>
        Task.FromException<IResult>(
            new InvalidOperationException("Synthetic checkout failure")));

    await app.RunAsync();
}
catch (Exception error)
{
    Log.Fatal(error, "Checkout host stopped unexpectedly");
    Environment.ExitCode = 1;
}
finally
{
    await Log.CloseAndFlushAsync();
}

The console sink and service identity appear in both stages deliberately. Any other required destination must also be configured in both stages if it must receive both early and later events. The callback builds the final configuration; it does not append destinations to the bootstrap configuration. See Serilog's two-stage initialization guidance.

This example configures Serilog in code. It does not read a Serilog section from appsettings.json. Add ReadFrom.Configuration(builder.Configuration) deliberately if configuration files should control it, and check for destinations registered twice.

The fatal catch covers failures after the bootstrap logger has been created. A failure while creating that logger still requires process or hosting diagnostics. Flushing on normal shutdown also cannot guarantee delivery after a forced termination.

Check startup, success, and handled failure separately

The following commands use a POSIX shell. First exercise the early failure path:

DEMO_STARTUP_FAIL=1 dotnet run --no-launch-profile

Expected behavior: the initial startup message is followed by a fatal event containing the synthetic startup exception. No HTTP listener should start, and the process should exit unsuccessfully.

Then start the normal path:

ASPNETCORE_ENVIRONMENT=Production dotnet run --no-launch-profile --urls http://127.0.0.1:5187

From another terminal, call the two local GET endpoints:

curl -i http://127.0.0.1:5187/ready
curl -i http://127.0.0.1:5187/demo-failure

The expected responses are 200 and 503 respectively. These are verification expectations, not a fixed transcript. Record the actual response and emitted events for each request before changing filters.

The demonstration explicitly keeps exception-handler diagnostics enabled with SuppressDiagnosticsCallback. In .NET 10, exceptions handled by an IExceptionHandler returning true otherwise suppress those diagnostics by default. .NET 8 and 9 differ. That framework setting does not control Serilog's separate completion event. Microsoft documents the version boundary and callback.

Read JSON fields before changing their names

RenderedCompactJsonFormatter writes a rendered message in @m. Its compact format uses @t for time, @l for level, and @x for an exception. An absent @l means Information. CompactJsonFormatter instead preserves the message template in @mt; it is not interchangeable with a rendered message. See the official compact-format specification.

The following are synthetic, abbreviated log examples, not captured output. Identifiers, timestamps, elapsed times, and additional properties vary.

{"@t":"2026-11-03T10:00:00Z","@m":"Readiness probe completed","SourceContext":"Checkout.Readiness","RequestId":"demo-r1","ServiceName":"checkout-api"}
{"@t":"2026-11-03T10:00:00Z","@m":"HTTP GET /ready responded 200 in 2.0000 ms","StatusCode":200,"Elapsed":2.0,"SourceContext":"Serilog.AspNetCore.RequestLoggingMiddleware","RequestId":"demo-r1","ServiceName":"checkout-api"}

The first event says the endpoint emitted its application message. The second says the request middleware observed completion with status 200. SourceContext identifies the logger category. The shared request ID connects the records without treating them as the same event.

The example uses the server's TraceIdentifier, rather than accepting an arbitrary incoming correlation header. Do not log cookies, authorization headers, request bodies, or query strings just to correlate requests. Paths and exception messages can also contain sensitive data; review them before collection and restrict access and retention.

Compare the two middleware orders

In the example, Serilog surrounds the exception handler:

request context → Serilog request logging → exception handler → endpoint

For /demo-failure, the handler writes a 503 response. Serilog then records that response after the handler returns. Its completion event has no attached exception unless one was separately supplied through the diagnostic context. The error handler and framework diagnostics can provide separate exception records.

Now move only app.UseSerilogRequestLogging() below the app.UseExceptionHandler(...) block and repeat the local failure request:

request context → exception handler → Serilog request logging → endpoint

The exception now escapes through Serilog first. Version 10.0.0 records a completion with status 500 and the exception, then allows the exception to continue to the handler. The handler subsequently writes 503. Serilog records the failure before the outer handler chooses the final response.

Observation Serilog outside the handler Serilog inside the handler
Request ID Connects the request's records Connects the request's records
Completion SourceContext Serilog request middleware Serilog request middleware
Completion status for this fixture 503 500
Exception attached to completion None by this example's configuration Escaping exception
Timing boundary Includes downstream error handling Ends when the exception escapes
Client response from the handler 503 503

These outcomes follow the released middleware implementation. Its default level rule marks a completion as Error when an exception reaches it or the response status exceeds 499. An Error completion therefore does not always contain an exception.

The example uses an error-handling delegate. If your application instead uses UseExceptionHandler("/Error"), account for alternate-pipeline re-execution. Downstream middleware can run again for the same request. Do not infer a second client request from that alone.

Diagnose repeated records before suppressing them

Start with request ID, SourceContext, message, exception, status, and time. A handler's exception event and a middleware completion describe different facts. Retain both when each answers a useful question.

If the same event appears twice with the same properties, inspect destinations and collection paths. Common checks include a sink registered in both code and configuration, two console collectors reading the same source, or two copies of request-logging middleware. Bootstrap and final-stage console configuration alone is not evidence that both loggers emitted every event.

Use narrow category overrides only after identifying the unwanted source. The example reduces routine Hosting, MVC, and Routing messages while keeping their warnings. Avoid a broad Microsoft category suppression that also hides exception diagnostics.

For repeated business effects, check a durable operation ID, idempotency record, or database transaction evidence. Log counts alone cannot prove repeated execution. The structured logging guide covers useful event fields; .NET performance monitoring covers wider runtime diagnosis.

Map Serilog events before collecting them in Fluxtail

Serilog's console output is newline-delimited compact JSON. A collector or adapter must parse each event and explicitly build the destination record. Fluxtail's log-parsing guidance documents source-side transformation and supported destination fields.

Serilog value Outbound field Mapping rule
@t timestamp Preserve the timestamp and timezone
@m message Require a rendered, non-empty string
@l severity Map Verbose/Debug/Information/Warning/Error/Fatal to TRACE/DEBUG/INFO/WARN/ERROR/FATAL; absent means INFO
ServiceName service_name Copy the explicit application identity

Keep the original level in a separate collector field while transforming the event. Distinguish an omitted Information level from an unknown value. Decide separately how request ID, source category, exception, status, and duration will be retained. Verify their stored representation before relying on field filters. Do not assume arbitrary Serilog properties become searchable fields.

This synthetic destination object demonstrates the core mapping without claiming automatic property support:

{
  "timestamp": "2026-11-03T10:00:00Z",
  "message": "serilog-check-demo-r1: HTTP GET /ready completed with 200",
  "severity": "INFO",
  "service_name": "checkout-api"
}

Use the HTTP JSON ingestion example for the receiver procedure. This mapping does not require or imply a native Serilog integration.

Generate a fresh timestamp and unique message marker for each delivery test. Check that marker in the intended stream's Live Tail, then find it again in the retained time window. Compare stored time, message, severity, and service identity with the outbound object before adding correlation properties. A successful HTTP response alone does not establish that the expected record is visible.

A manually delivered marker checks only that delivery path. It does not verify the application collector, retry behavior, or exactly-once delivery. Repeat with a known request from the application to verify the complete path.