👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Structured Logging with Serilog in ASP.NET WEB API

Structured Logging with Serilog in ASP.NET WEB API

Author - Abdul Rahman (Bhai)

Web API

23 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

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.

  1. 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)
                        
  2. 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);
                        
  3. 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:

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.

👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • Web API
  • Logging
  • Serilog
  • Structured Logging