
Structured Logging with Serilog in ASP.NET WEB API
Author - Abdul Rahman (Bhai)
Web API
23 Articles
Table of Contents
What we gonna do?
In this article, let's learn about how to do Structured Logging using Serilog in Web API in ASP.NET Core.
Note: If you have not done so already, I recommend you read the article on Global Exception Handling in ASP.NET WEB API.
Logging is needed in all applications. This will help to find root cause or track user activity in any environment. By default logging is baked into ASP.NET Core. It is very easy to use and logs are stored in plain text format. This will be difficult to read and analyse.
Serilog is a popular structured logging library for .NET applications. It is very easy to use and configure. It has many sinks available to store the logs in different places. It has many enrichers available to enrich the logs with additional information. It also supports scoping logs events to enrich logs for a particular scope.
This is the second thing that I do when I create new Web API Projects or when I work on existing code base. Let's focus on how to implement structured logging in ASP.NET Web API.
Why we gonna do?
Traditional plain-text logs can be hard to read and analyze. Structured logging is a practice where you apply the same message format (JSON Structure) to all of your application logs. The end result is that all your logs will have a similar structure, allowing them to be easily searched and analyzed.
How we gonna do?
Write to Sink
A Sink in Serilog is a destination where your log events are sent. There are sinks for various outputs like the console, files, databases, and even other logging platforms like Seq, which we'll explore in more detail.
The most commonly used Sinks are,
- Console - To log to console
- File - To log to file
- Seq - To log to Seq (useful in docker based development)
- Application Insights - To log to Azure Application Insights
- Database - To log to database
- Log Group - To log to AWS cloud watch log groups
Implementing Logging with Serilog
Let's see how to implement structured logging with Serilog in ASP.NET Web API.
Install the required NuGet packages:
dotnet add package Serilog.AspNetCore dotnet add package Serilog.Sinks.Console dotnet add package Serilog.Sinks.File dotnet add package Serilog.Enrichers.Environment # WithMachineName, WithEnvironmentName dotnet add package Serilog.Sinks.ApplicationInsights # WriteTo.ApplicationInsights (production)The next step is to pass Configuration to and use it to create Serilog Logger. These can be done programmatically or using appsettings.json. I prefer appsettings.json as it gives more flexibility.
{ "Serilog": { "MinimumLevel": { "Default": "Information", "Override": { "Microsoft": "Warning", "System": "Warning" } } } }The above configuration controls minimum log levels. Sinks are configured in code in Step 3 so they can vary per environment — do not add a WriteTo section in appsettings.json alongside programmatic sink registration or you will get duplicate log output.
The next step is to create log configuration using above configuration and use it to create Serilog logger as shown below.
// 1. Read configuration before CreateBuilder so the bootstrap logger // picks up minimum-level overrides and other settings from appsettings.json var config = new ConfigurationBuilder() .AddJsonFile("appsettings.json") .AddJsonFile( $"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")}.json", optional: true) .Build(); // 2. Reusable helper — only add enrichers you actually query; // every enricher writes data to every log event and consumes resources. LoggerConfiguration WithCommonEnrichers(LoggerConfiguration loggerConfig) => loggerConfig .ReadFrom.Configuration(config) .Enrich.FromLogContext() .Enrich.WithMachineName() .Enrich.WithEnvironmentName() .Enrich.WithProperty("ApplicationName", "ILoveDotNet.Api"); // 3. Bootstrap logger — active from this point until AddSerilog() takes over Log.Logger = WithCommonEnrichers(new LoggerConfiguration()) .CreateBootstrapLogger(); // Using Warning (not Information) ensures this message is visible even when // the configured minimum level is Warning or above. Log.Warning("Starting {FullName}", typeof(Program).Assembly.FullName);Finally, continuing in Program.cs directly after the bootstrap logger setup above, register Serilog via AddSerilog() — the recommended approach over UseSerilog(). Unlike UseSerilog(), AddSerilog() receives the DI serviceProvider so sinks and enrichers can consume registered services via ReadFrom.Services(). Wrap the entire host block in a try/catch/finally so the bootstrap logger captures any startup failure via Log.Fatal and Log.CloseAndFlush() always flushes before the process exits:
try { var builder = WebApplication.CreateBuilder(args); // Remove built-in providers; Serilog becomes the sole logging backend builder.Logging.ClearProviders(); // AddSerilog is preferred over UseSerilog() — ReadFrom.Services(serviceProvider) // lets sinks and enrichers consume DI-registered services. builder.Services.AddSerilog((serviceProvider, loggerConfig) => { // LoggerConfiguration mutates in place and returns 'this', so the // chained result can be discarded — loggerConfig is fully configured. WithCommonEnrichers(loggerConfig) .ReadFrom.Services(serviceProvider); if (builder.Environment.IsDevelopment()) { loggerConfig .WriteTo.Async(sink => sink.Console( outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} <s:{SourceContext}>{NewLine}{Exception}")) .WriteTo.Async(sink => sink.File( path: Path.Combine("logs", "ilovedotnet-api_.log"), rollingInterval: RollingInterval.Day, shared: true, outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} <s:{SourceContext}>{NewLine}{Exception}")); } else { // Production: ship structured telemetry to Application Insights loggerConfig .WriteTo.Async(sink => sink.ApplicationInsights( new Microsoft.ApplicationInsights.Extensibility.TelemetryConfiguration { ConnectionString = builder.Configuration["ConnectionStrings:ApplicationInsights"] }, TelemetryConverter.Traces)); } }); var app = builder.Build(); app.Run(); } catch (Exception ex) { Log.Fatal(ex, "Host terminated unexpectedly"); } finally { Log.CloseAndFlush(); }
Enriching Logs with additional information
Serilog enrichers are packages that add properties to log events — things like the current machine name, environment name, or any custom value. The WithCommonEnrichers helper built in Step 2 wires them up in one place:
LoggerConfiguration WithCommonEnrichers(LoggerConfiguration loggerConfig) =>
loggerConfig
.ReadFrom.Configuration(config) // reads MinimumLevel overrides from appsettings.json (not an enricher)
.Enrich.FromLogContext() // enables log scope support
.Enrich.WithMachineName() // useful for multi-node deployments
.Enrich.WithEnvironmentName() // tags every event with dev / staging / production
.Enrich.WithProperty("ApplicationName", "ILoveDotNet.Api");
Only add enrichers you actually query. Every enricher writes additional properties to every single log event and consumes CPU and memory. Resist the temptation to add WithProcessId, WithThreadId, or others "just in case" — if you never filter or alert on a property, it is pure overhead.
Every log entry now carries MachineName, EnvironmentName, and ApplicationName automatically — enabling cross-environment and cross-machine log correlation from day one.
Log Scope using Serilog
Now what if we want to add some properties to all the logs events in a request in a single place? Serilog supports logging scopes. Scopes are a way to add properties to log events that are in effect for a limited period of time. Scopes provide a global way to add properties to all log events in a particular request. For example, you can add a scope for a particular request with User ID or a Tenant ID. All log events within that scope will have the User ID or Tenant ID property in log output.
The FromLogContext() call inside WithCommonEnrichers (see Step 2) is what enables scope support — it propagates any properties pushed into the current log context into every log event produced within that scope. No additional configuration is needed.
Note: If you have not done so already, I recommend you read the article on Introducing Middleware in ASP.NET.
Now we need to create a Middleware to do the actual work of adding the scope. Let's create a middleware called LogScopeMiddleware and use it in request pipeline. This will add the scope to all the log events in a request.
The example below uses a Dictionary<string, object> as the scope payload so each
property appears as a named field in the log output rather than a positional string.
ITenantService is a placeholder — replace GetCurrentUser()
with your own user resolution logic. Be mindful of the
known limitation called out in the code comment: scope properties are lost
for exceptions because the using block is disposed before the
Global Exception Handler runs.
public sealed class LogScopeMiddleware(
ILogger<LogScopeMiddleware> logger,
ITenantService tenantService) : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (context.User.Identity is { IsAuthenticated: true })
{
var user = context.User;
// Only include claims you actively filter or alert on —
// every scope property is attached to every log event in the request.
var claimsToInclude = new List<string> { "name", "username", "role" };
var scopeProperties = new Dictionary<string, object>();
foreach (var claimType in claimsToInclude)
{
scopeProperties[claimType] = string.Join(", ",
user.Claims
.Where(c => c.Type == claimType)
.Select(c => c.Value));
}
scopeProperties["UserId"] = tenantService.GetCurrentUser();
// ⚠ Known limitation: scope properties are lost when an exception is thrown
// because the using block is disposed before the Global Exception Handler runs.
// See https://github.com/serilog/serilog/issues/895
using (logger.BeginScope(scopeProperties))
{
await next(context);
}
}
else
{
await next(context);
}
}
}
// Extension member syntax requires C# 14 (.NET 10+).
// On earlier versions use: public static IApplicationBuilder UseLogScope(this IApplicationBuilder builder) => ...
public static class LogScopeMiddlewareExtensions
{
extension(IApplicationBuilder builder)
{
public IApplicationBuilder UseLogScope()
{
return builder.UseMiddleware<LogScopeMiddleware>();
}
}
}
// Program.cs
app.UseLogScope();
That's it now all you need to do is to inject ILogger<T> in your apps and start logging.
Advantages
The advantages of using Serilog are,
- Structured Logging - Simple JSON structure for all log events
- Multiple Sinks - Can write / output to multiple sinks
- Enrichers - Can enrich logs with additional information
- Scopes - Can enrich logs with additional information for a particular scope
- Easy to use - Very easy to use and configure
- Improved Searching and readability - This is more powerful when linked with Seq or Elastic Search
- Improved Analytics - Because of the structured nature easy to get analytics
Go Deeper with Logging
This article covers Serilog in the context of ASP.NET Web API. The dedicated Logging learning path goes deeper on every concept:
- Introduction to Logging in .NET — From Basics to Best Practices — log levels, categories, providers, and your first structured logs.
- Mastering Modern .NET Logging — Structured Logging and Advanced Concepts — message templates, event IDs, and why string interpolation breaks observability.
- Understanding .NET Logging Providers — From Console to Custom Implementations — built-in providers, Application Insights integration, and building a custom provider.
- Getting Started with Serilog in .NET — From Installation to Production-Ready Logging — sinks, enrichers, destructuring, operation timing, and Application Insights.
- High Performance Logging in .NET
—
LoggerMessage.Defineand the[LoggerMessage]source generator to eliminate allocations on hot paths.
Summary
In this article, we learnt how to implement structured logging using Serilog in ASP.NET Core. We saw how to install the required packages, read configuration before CreateBuilder() with a bootstrap logger using CreateBootstrapLogger(), and consolidate enrichers into a reusable WithCommonEnrichers helper — including only the enrichers you actually query, since every enricher adds data to every log event and consumes resources. We learnt how to clear built-in providers with ClearProviders() and register Serilog via the recommended AddSerilog() (preferred over UseSerilog()), leveraging ReadFrom.Services() for DI-aware sinks and enrichers, with environment-specific sinks — console and file in development, Application Insights in production — and wrap the host block in a try/catch/finally with Log.Fatal and Log.CloseAndFlush() so the bootstrap logger captures any startup failure and always flushes on exit. We also explored Sinks, Enrichers, and Scopes to add rich context to every log event.