Serilog in Depth: Bootstrap Logger, appsettings Configuration, Enrichers and Request Logging
A production-ready Serilog setup has two stages: a bootstrap logger that catches startup failures, then the real logger fed by appsettings and DI. ReadFrom.Configuration, a custom enricher, PushProperty versus BeginScope, UseSerilogRequestLogging writing one summary event per request, and the logs lost when CloseAndFlush is forgotten.
Version note: The code in this post was built and run with .NET 10, Serilog 4.4, Serilog.AspNetCore 10.0, Serilog.Settings.Configuration 10.0 and Serilog.Sinks.Seq 9.1.
Guess First
There's a small export app that runs at night. It sets up Serilog with the Seq sink, writes a single line when it finishes its work, and exits:
using Serilog;
Log.Logger = new LoggerConfiguration()
.WriteTo.Seq("http://localhost:17481")
.CreateLogger();
string runId = args[0];
Log.Information("Nightly export {RunId} finished: {OrderCount} orders", runId, 1250);
if (args is [_, "flush"])
{
await Log.CloseAndFlushAsync();
}
You run the app once without the flush argument and once with it. How many events do you see in Seq? I give the answer in the pitfalls section at the end of the post, with numbers taken from Seq's API (Application Programming Interface).
Why Two-Stage Initialization?
In the ASP.NET Core Web API series post "Logging in ASP.NET Core: ILogger, Log Levels, Structured Logging and Serilog" we made the first move to Serilog and wrote to the console with AddSerilog. This post is the in-depth version of that move: a Serilog setup that will actually serve you in production.
The first problem is this: the logger's real configuration comes from appsettings.json, but the code that reads appsettings.json can fail too. If the configuration file is broken, an environment variable is missing, or a service blows up while the DI (Dependency Injection) container is being built, the app dies before the logger is ready, and all you're left with is a stack trace on stderr that nobody collects. Serilog's answer is two-stage initialization: first a simple bootstrap logger that works everywhere, then the real logger that replaces it once configuration and services are ready.
using Bookstore.Api;
using Serilog;
using Serilog.Context;
using Serilog.Core;
using Serilog.Events;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
Log.Information("Starting Bookstore.Api");
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ILogEventEnricher, AppVersionEnricher>();
builder.Services.AddSerilog((services, lc) => lc
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services));
WebApplication app = builder.Build();
// ... middleware and endpoints
app.Run();
return 0;
}
catch (Exception ex) when (ex is not HostAbortedException)
{
Log.Fatal(ex, "Bookstore.Api terminated unexpectedly");
return 1;
}
finally
{
await Log.CloseAndFlushAsync();
}
CreateBootstrapLogger() doesn't return an ordinary logger, it returns a ReloadableLogger. The AddSerilog overload that takes (services, lc) notices that Log.Logger is of this type, reconfigures it with the new configuration and freezes it. That way, loggers created during startup keep writing with the real configuration. An important note from the Serilog.AspNetCore README: the real logger completely replaces the bootstrap logger, so you need to define sinks in both places.
The when (ex is not HostAbortedException) filter is deliberate too: tools such as the Entity Framework Core (EF Core) migration tooling stop the host on purpose with this exception, and you don't want it logged as Fatal every time.
To try it, I left appsettings.Staging.json with a JSON (JavaScript Object Notation) object that's deliberately not closed and started the app with ASPNETCORE_ENVIRONMENT=Staging. The beginning of the real output (I shortened the file path):
[05:15:06 INF] Starting Bookstore.Api
[05:15:06 FTL] Bookstore.Api terminated unexpectedly
System.IO.InvalidDataException: Failed to load configuration from file '/app/appsettings.Staging.json'.
---> System.FormatException: Could not parse the JSON file.
---> System.Text.Json.JsonReaderException: Expected depth to be zero at the end of the JSON payload. There is an open JSON object or array that should be closed. LineNumber: 4 | BytePositionInLine: 0.
The failure happened on the WebApplication.CreateBuilder(args) line, before the real logger was configured. What caught it and wrote it at Fatal level is the bootstrap logger. The app exited with code 1; when the container orchestrator gets into a restart loop, you can see why in the log. If you add more than the console to the bootstrap logger, Seq for example, this line also reaches your central log system.
Configuration from appsettings
ReadFrom.Configuration reads the Serilog section and sets up the minimum level, sinks, enrichers and constant properties. Bookstore.Api's section:
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Enrichers.Environment" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} {Message:lj} {Properties:j}{NewLine}{Exception}"
}
}
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithEnvironmentName" ],
"Properties": {
"Application": "Bookstore.Api"
}
},
"AllowedHosts": "*"
}
A few things to watch. Serilog doesn't read the Logging:LogLevel section; the level rules now live under Serilog:MinimumLevel. Override keys are category prefixes and the longest match wins: while Microsoft.AspNetCore is Warning, Microsoft.Hosting.Lifetime stays at Information, so we keep seeing the "Now listening on" line. The Using list says which assemblies the sinks and enrichers are in; on .NET 10 assemblies are mostly found automatically, but in trimming or single-file publish this list is a lifesaver. {Properties:j} in the console sink's outputTemplate prints every property not used in the message as JSON at the end of the line; we'll use it to see the effect of the enrichers in the outputs below.
The configuration follows the provider order from the Web API series post "Configuration and the Options Pattern": by passing --Serilog:MinimumLevel:Override:Microsoft.AspNetCore=Information on the command line, you can turn on framework logs for a single run. We'll use that shortly to measure the benefit of request logging.
Enrichers and Your Own Enricher
An enricher is a small class that adds properties to every log event. FromLogContext adds properties pushed to the LogContext (below). WithMachineName and WithEnvironmentName come from the Serilog.Enrichers.Environment package; the second reads its value from the ASPNETCORE_ENVIRONMENT or DOTNET_ENVIRONMENT environment variable. Our own enricher adds the application version:
using System.Reflection;
using Serilog.Core;
using Serilog.Events;
namespace Bookstore.Api;
public sealed class AppVersionEnricher : ILogEventEnricher
{
// the value never changes, so the property is created once
private readonly LogEventProperty _property = new("AppVersion", new ScalarValue(
typeof(AppVersionEnricher).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion.Split('+')[0] ?? "unknown"));
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) =>
logEvent.AddPropertyIfAbsent(_property);
}
We registered the enricher in the DI container as ILogEventEnricher; ReadFrom.Services(services) finds these and adds them to the pipeline. That way an enricher can take other services through its constructor, for example a service that knows the active tenant. Split('+') drops the commit hash that the SDK (Software Development Kit) appends to the informational version. With <Version>1.4.2</Version> in the project, the startup line is:
[05:14:36 INF] Microsoft.Hosting.Lifetime Now listening on: http://localhost:17415 {"EventId": {"Id": 14, "Name": "ListeningOnAddress"}, "MachineName": "Mac", "EnvironmentName": "Production", "Application": "Bookstore.Api", "AppVersion": "1.4.2"}
AppVersion looks small, but after a deploy it's the answer, one query away, to "did the error start with the new version?". Likewise, Application is the simplest way to separate logs in an environment where several services write to the same Seq.
LogContext.PushProperty or BeginScope?
There are two ways to add the same property to every log of a piece of work. I used both side by side in the endpoint:
app.MapGet("/books/{id:int}", (int id, ILogger<Program> logger, IDiagnosticContext diagnosticContext) =>
{
diagnosticContext.Set("BookId", id);
using (logger.BeginScope(new Dictionary<string, object> { ["CustomerId"] = 42 }))
using (logger.BeginScope("Catalog lookup {LookupId}", 9001))
using (LogContext.PushProperty("CartId", 7))
{
logger.LogInformation("Looking up book {BookId}", id);
}
return id <= 100 ? Results.Ok(new { id, title = "The Pragmatic Programmer" }) : Results.NotFound();
});
[05:15:38 INF] Program Looking up book 12 {"LookupId": 9001, "CustomerId": 42, "RequestId": "0HNPT7ON6GKOT:00000001", "RequestPath": "/books/12", "ConnectionId": "0HNPT7ON6GKOT", "Scope": ["Catalog lookup 9001"], "CartId": 7, "MachineName": "Mac", "EnvironmentName": "Production", "Application": "Bookstore.Api", "AppVersion": "1.4.2"}
All three became properties, but by different routes. The scope opened with a dictionary became the CustomerId property directly. The scope with a message template became both a LookupId property and, with its rendered text, an element of the Scope array. LogContext.PushProperty added CartId. We didn't write RequestId, RequestPath and ConnectionId: ASP.NET Core opens a scope for every request, and Serilog turns that into properties too.
Which one to choose? BeginScope is part of the Microsoft.Extensions.Logging abstraction: your code doesn't depend on Serilog, scopes keep working if you switch providers tomorrow, and if you're writing library code it's the only right choice. LogContext.PushProperty is Serilog-specific; it shows up only in the Serilog pipeline and only when Enrich.FromLogContext() is on, but it also covers static calls like Log.Information and processes the value with Serilog's destructuring rules. My default in application code is BeginScope with a dictionary; I use the Serilog-specific PushProperty in infrastructure code such as middleware. Both flow through AsyncLocal, so they stay valid after an await.
UseSerilogRequestLogging: One Event per Request
ASP.NET Core's own logs narrate a request step by step at Information level. When I raised Microsoft.AspNetCore to Information from the command line and sent a single request to /books/12, these are the lines I got (I trimmed the properties for readability):
[05:14:56 INF] Microsoft.AspNetCore.Hosting.Diagnostics Request starting HTTP/1.1 GET http://localhost:17415/books/12 - null null
[05:14:56 INF] Microsoft.AspNetCore.Routing.EndpointMiddleware Executing endpoint 'HTTP: GET /books/{id:int}'
[05:14:56 INF] Program Looking up book 12
[05:14:56 INF] Microsoft.AspNetCore.Http.Result.OkObjectResult Setting HTTP status code 200. [EventId 1 WritingResultAsStatusCode]
[05:14:56 INF] Microsoft.AspNetCore.Http.Result.OkObjectResult Writing value of type '<>f__AnonymousType0`2' as Json. [EventId 3 WritingResultAsJson]
[05:14:56 INF] Microsoft.AspNetCore.Routing.EndpointMiddleware Executed endpoint 'HTTP: GET /books/{id:int}' [EventId 1 ExecutedEndpoint]
[05:14:56 INF] Serilog.AspNetCore.RequestLoggingMiddleware HTTP GET /books/12 responded 200 in 8.7307 ms
[05:14:56 INF] Microsoft.AspNetCore.Hosting.Diagnostics Request finished HTTP/1.1 GET http://localhost:17415/books/12 - 200 null application/json; charset=utf-8 33.7686ms
Seven framework lines against the one line we wrote. Serilog's request logging middleware writes a single summary event instead, when the request completes; with the framework at Warning as in appsettings.json, two lines remain for the same request:
[05:15:38 INF] Program Looking up book 12 {"LookupId": 9001, "CustomerId": 42, "RequestId": "0HNPT7ON6GKOT:00000001", "RequestPath": "/books/12", "ConnectionId": "0HNPT7ON6GKOT", "Scope": ["Catalog lookup 9001"], "CartId": 7, "MachineName": "Mac", "EnvironmentName": "Production", "Application": "Bookstore.Api", "AppVersion": "1.4.2"}
[05:15:38 INF] Serilog.AspNetCore.RequestLoggingMiddleware HTTP GET /books/12 responded 200 in 7.8500 ms {"BookId": 12, "ClientIp": "::1", "UserAgent": "curl/8.7.1", "RequestId": "0HNPT7ON6GKOT:00000001", "ConnectionId": "0HNPT7ON6GKOT", "MachineName": "Mac", "EnvironmentName": "Production", "Application": "Bookstore.Api", "AppVersion": "1.4.2"}
The summary event's default template is HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms; so the method, path, status code and duration are already queryable properties. We add more from two places:
app.UseSerilogRequestLogging(options =>
{
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("ClientIp", httpContext.Connection.RemoteIpAddress?.ToString());
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
};
});
EnrichDiagnosticContext defines the fields taken from the HTTP (Hypertext Transfer Protocol) context for every request. From inside the endpoint you can also inject IDiagnosticContext and call diagnosticContext.Set("BookId", id); the value isn't written to the log at that moment, it's added to the summary event when the request completes. "Which book's page is the slowest?" is now a single query grouped by BookId.
There are a few more options: with GetLevel you can choose the level per situation (the default is Error for status 500 and above or an exception, Information otherwise); dropping /health requests to Verbose is the classic use. IncludeQueryInRequestPath defaults to false; if you have endpoints that carry a token or an email in the query string, keep it that way. Middleware order matters too: middleware added before UseSerilogRequestLogging isn't timed or logged, so put it close to the start of the pipeline; after UseStaticFiles if you don't want static file requests logged.
MEL or Serilog?
Not every project needs Serilog. MEL (Microsoft.Extensions.Logging) does structured logging on its own; it has a JSON console formatter, it has scopes, and when connected to an OTLP (OpenTelemetry Protocol) exporter with builder.Logging.AddOpenTelemetry(...), it sends its logs to an OpenTelemetry Collector or any backend that accepts OTLP. If your app runs in a container, logs are collected from stdout or via OTLP, and you don't need targets like files or a database, MEL with OTLP is enough; that's one dependency less.
Where Serilog stands out: a broad sink ecosystem (rolling files, Seq, Elasticsearch and dozens more), enrichers, destructuring with {@Order} and destructuring policies, per-sink filtering with Serilog.Expressions, and ready-made pieces such as the bootstrap logger and request logging. NLog is a mature alternative in the same league; with NLog.Extensions.Logging it plugs in under MEL as a provider and takes its configuration from its own nlog.config file or from appsettings. Whichever you choose, keep application code writing to ILogger<T>; a provider change should touch only Program.cs.
In Production
Think about the bootstrap logger's sinks for production too. If it has only the console and your containers' logs are collected, that's enough; if they aren't, startup failures are lost again. Putting the real configuration's sinks into the bootstrap logger as well is usually the safest choice.
The request logging summary event looks like a good metric source on its own, but it isn't. Computing elapsed values from logs means scanning millions of events on every query; for percentiles use ASP.NET Core's built-in http.server.request.duration metric and keep the log for drilling into individual requests.
Fields such as ClientIp and UserAgent may count as personal data. Know what you collect and how long you keep it; we'll open that topic in this series' "Sensitive Data in Logs" post.
Stay alert to configuration mistakes too. If you misspell the sink name in Serilog:WriteTo, Serilog doesn't throw an exception, it silently skips that sink. When I passed --Serilog:WriteTo:0:Name=Consol on the command line, the app started without errors, but not a single line reached the console after the bootstrap logger's "Starting" line. The way to see the reason is calling SelfLog.Enable(Console.Error) before ReadFrom.Configuration; the line I got with the same configuration in a small console app:
2026-11-08T02:18:39.5484640Z Unable to find a method called Consol. Candidate methods are:
The list of candidate methods follows. We'll cover SelfLog in detail in the next post, together with the errors sinks run into at runtime.
Common Pitfalls
Back to the opening question. I ran the export app twice, then counted the events by RunId through Seq's /api/events endpoint:
run-no-flush: 0
run-with-flush: 1
The event of the run that exited without calling CloseAndFlush never arrived. The Seq sink collects events in memory and sends them in batches; when the process ends before that batch is sent, everything in memory is gone. In short-lived console apps, Azure Functions or background jobs, this is the most common cause of "the app ran but there are no logs". In the web app, the CloseAndFlushAsync inside finally does the same job.
Second, seeing the same exception twice. When the /boom endpoint throws, the request logging middleware writes the summary event at Error level together with the exception, and then Kestrel logs the same exception once more:
[05:15:50 ERR] Serilog.AspNetCore.RequestLoggingMiddleware HTTP GET /boom responded 500 in 0.3427 ms {"ClientIp": "::1", "UserAgent": "curl/8.7.1", "RequestId": "0HNPT7OQR6B6T:00000001", "ConnectionId": "0HNPT7OQR6B6T", "MachineName": "Mac", "EnvironmentName": "Production", "Application": "Bookstore.Api", "AppVersion": "1.4.2"}
System.InvalidOperationException: Inventory service is not reachable
at Program.<>c.<<Main>$>b__0_3() in /src/Bookstore.Api/Program.cs:line 51
at lambda_method2(Closure, Object, HttpContext)
at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
[05:15:50 ERR] Microsoft.AspNetCore.Server.Kestrel Connection id "0HNPT7OQR6B6T", Request id "0HNPT7OQR6B6T:00000001": An unhandled exception was thrown by the application. {"EventId": {"Id": 13, "Name": "ApplicationError"}, "RequestId": "0HNPT7OQR6B6T:00000001", "RequestPath": "/boom", "MachineName": "Mac", "EnvironmentName": "Production", "Application": "Bookstore.Api", "AppVersion": "1.4.2"}
The "log an exception once" rule from the first post of the series applies here too. When you set up the exception handler from the Web API series post "Global Error Handling", the exception is handled there and doesn't reach Kestrel; then you have to choose between the handler's log and request logging's Error event. I prefer leaving the request logging event at Information with GetLevel and letting the handler log the exception; that way the error count comes from a single place.
Third, forgetting Enrich.FromLogContext(). PushProperty doesn't fail, the property just never shows up anywhere. Make sure it's in both the bootstrap and the real configuration.
Fourth, changing the Logging:LogLevel settings and expecting Serilog's level to change. After AddSerilog, the level rules are in Serilog:MinimumLevel; deleting the old section prevents the "I changed this, why didn't it take effect?" confusion in the team.
A Small Experiment
★☆☆ Add "Program": "Warning" under Override in appsettings.json and send a request to /books/12. Does the "Looking up book" line disappear, does the request logging summary event stay? Why?
★★☆ Write a GetLevel for UseSerilogRequestLogging: make /health requests Verbose, requests taking longer than 1 second Warning, and requests with an exception Error. Try it by adding a /health endpoint and an endpoint slowed down with Task.Delay.
★★★ Write an enricher that reads the current user's id from HttpContext.User and adds it to every log event as UserId. Have it take IHttpContextAccessor through the constructor and join the pipeline via ReadFrom.Services. Look at what happens in logs outside a request (such as the startup lines).
We've set up Serilog; for now every event goes to the console. In the next post we move to sinks: why stdout and compact JSON in the container world, rolling and retention in the File sink, the batching that moved into the core with Serilog 4, the async wrapper and how logs get lost when a sink slows down, SelfLog, and going out to OTLP with the OpenTelemetry sink.