DEV Community

Gael Fraiteur for PostSharp Technologies

Posted on • Originally published at blog.postsharp.net on

Adding Serilog to ASP.NET Core: a practical guide

Serilog is a logging library for .NET. It can be used on its own, but it is also compatible with Microsoft.Extensions.Logging, making it ideal for ASP.NET Core applications. In this article, we’ll explain why you should use Serilog in your ASP.NET Core application and demonstrate how to integrate it into your project. We’ll also cover ASP.NET middleware as a way to enrich your logs with contextual properties, and techniques to add high-verbosity logging without boilerplate code.

Why use Serilog in ASP.NET Core?

By default, ASP.NET Core apps have a built-in logging system thanks to the Microsoft.Extensions.Logging namespace. However, it lacks many useful features. And while it is extensible, someone has to implement and maintain those extensions. Therefore, it often makes sense to use a third-party library like Serilog, which is already feature-rich and well-maintained.

Here are the features that make Serilog a good choice for ASP.NET Core applications:

  • Contexts and enrichers: While Microsoft.Extensions.Logging supports scopes, they are quite limited and verbose. Serilog contexts make it much easier to add custom contextual information to your logs. Enrichers provide automatic ways of adding commonly used information, such as the correlation ID.
  • Sinks, formatting, and filtering: Microsoft.Extensions.Logging supports a limited number of logging providers, while Serilog offers about a hundred different sinks, including logging to a file and various cloud services and databases. Serilog also supports advanced formatting and filtering options.
  • Structured logging: Microsoft.Extensions.Logging has only very limited support for structured logging, while Serilog is built around structured logging. This makes it easier to query and analyze logs, especially when you have a large number of logs.

Using Serilog is not an all-or-nothing decision. As long as you use only Serilog sinks, you can, for example, combine Microsoft.Extensions.Logging scopes and Serilog contexts.

Adding Serilog to your ASP.NET Core project

Step 1. Add the Serilog packages

To add Serilog to your ASP.NET Core project, install the Serilog.AspNetCore NuGet package:

dotnet add package Serilog.AspNetCore

Enter fullscreen mode Exit fullscreen mode

This package includes support for registering Serilog with your application and optionally enabling request logging.

Step 2. Add the Serilog service

To configure Serilog, add code like the following to your Program.cs file:

Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateLogger();

builder.Services.AddSerilog();

Enter fullscreen mode Exit fullscreen mode

You can also delete the now unnecessary Microsoft.Extensions.Logging configuration from your appsettings.json file.

The console output from an ASP.NET Core Web API application will now look something like this:

[15:56:46 INF] Now listening on: http://localhost:5106
[15:56:46 INF] Application started. Press Ctrl+C to shut down.
[15:56:46 INF] Hosting environment: Development
[15:56:46 INF] Content root path: /mnt/c/src/TimelessDotNetEngineer/src/logging/serilog-aspnetcore/SerilogInAspNetCore
[15:56:53 INF] Request starting HTTP/1.1 GET http://localhost:5106/swagger/index.html - null null
[15:56:53 INF] Request finished HTTP/1.1 GET http://localhost:5106/swagger/index.html - 200 null text/html;charset=utf-8 71.1747ms
[15:56:53 INF] Request starting HTTP/1.1 GET http://localhost:5106/swagger/v1/swagger.json - null null
[15:56:54 INF] Request finished HTTP/1.1 GET http://localhost:5106/swagger/v1/swagger.json - 200 null application/json;charset=utf-8 70.2124ms

Enter fullscreen mode Exit fullscreen mode

Step 3. Enable HTTP request logging

Since the default ASP.NET Core logging is very noisy, it’s a good practice to reduce its verbosity (using .MinimumLevel.Override(...)) and replace it with Serilog request logging by adding a call to app.UseSerilogRequestLogging() in your Program.cs:

Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .MinimumLevel.Verbose()
    .MinimumLevel.Override( "Microsoft.AspNetCore", LogEventLevel.Warning )
    .MinimumLevel.Override( "Microsoft.Extensions.Hosting", LogEventLevel.Information )
    .MinimumLevel.Override( "Microsoft.Hosting", LogEventLevel.Information )
    .CreateLogger();

builder.Services.AddSerilog();
var app = builder.Build();
app.UseSerilogRequestLogging();

Enter fullscreen mode Exit fullscreen mode

Using that, the output will be nicer:

[16:18:03 INF] Now listening on: http://localhost:5106
[16:18:03 INF] Application started. Press Ctrl+C to shut down.
[16:18:03 INF] Hosting environment: Development
[16:18:03 INF] Content root path: /mnt/c/src/TimelessDotNetEngineer/src/logging/serilog-aspnetcore/SerilogInAspNetCore
[16:18:07 INF] HTTP GET /swagger/index.html responded 200 in 57.3368 ms
[16:18:07 INF] HTTP GET /swagger/v1/swagger.json responded 200 in 68.1877 ms

Enter fullscreen mode Exit fullscreen mode

Adding logging to your code

Now that Serilog is properly configured, let’s talk about how and when to add logging to your code.

Step 1. Pull the ILogger service

The first step is to pull the logging interface from the dependency injection container. It’s recommended to use the regular Microsoft.Extensions.Logging.ILogger interface instead of the product-specific Serilog.ILogger one because it will make your code more standard and portable. However, there are no significant differences between these interfaces.

Instead of pulling the non-generic ILogger interface, pull the generic ILogger<T> interface where T is the current class. This trick will allow you to adjust the verbosity of the logging for this specific class and easily filter or search messages reported by this specific class.

[ApiController]
[Route( "[controller]" )]
public class WeatherForecastController( ILogger<WeatherForecastController> logger ) : ControllerBase

Enter fullscreen mode Exit fullscreen mode

Step 2. Add logging instructions

The general rules are not specific to Serilog: you should log whenever something happens that might be useful to troubleshoot a problem in production. Use the LogError, LogWarning, LogInformation, and LogVerbose methods of the ILogger interface to write messages of different severities.

By default, Serilog will use the ToString() method to format parameters to text. If you want Serilog to include the JSON representation of the log parameter instead, you can use the @ destructuring operator. This will work even if you use the regular Microsoft.Extensions.Logging.ILogger interface.

logger.LogDebug(
    "Returning weather forecast for the {days} days after today: {@forecast}",
    days,
    forecast );

Enter fullscreen mode Exit fullscreen mode

This will produce a log message like this:

[19:09:56 DBG] Returning weather forecast for the 5 days after today: [{"Date": "2024-07-10", "Temperature": 31, "$type": "WeatherForecast"}, {"Date": "2024-07-11", "Temperature": 26, "$type": "WeatherForecast"}, {"Date": "2024-07-12", "Temperature": 31, "$type": "WeatherForecast"}, {"Date": "2024-07-13", "Temperature": 19, "$type": "WeatherForecast"}, {"Date": "2024-07-14", "Temperature": 25, "$type": "WeatherForecast"}]

Enter fullscreen mode Exit fullscreen mode

Step 3. Optionally, add scope information

In the examples above, we wrote the logs as text to the console. Serilog supports several semantic backends like Elasticsearch or the .NET Aspire dashboard, where Serilog messages are stored as JSON objects. This allows you to easily search and filter messages based on object queries rather than text. This is what is meant by semantic logging.

Serilog supports several mechanisms to add properties to the JSON payload. Often, you’ll want to search according to the execution context (the current username, client IP, operation, etc.), so it’s a good idea to enrich messages with these properties.

To enable this feature, the first thing to do is add a call to .Enrich.FromLogContext() in your Serilog configuration code.

If you want to visualize properties in the console or text output, you also need to override the console message template.

These two steps are achieved by the following snippet:

Log.Logger = new LoggerConfiguration()
    .Enrich.FromLogContext()
    .WriteTo.Console( outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties}{NewLine}{Exception}" )

Enter fullscreen mode Exit fullscreen mode

We can now add contextual information to our logging.

The first and standard way is to use the BeginScope method of the Microsoft.Extensions.Logging.ILogger interface:

using (logger.BeginScope( "Getting weather forecast for {ScopeDays} days", days ))

Enter fullscreen mode Exit fullscreen mode

This approach defines a text property named Scope, which is not very semantically searchable.

Serilog’s proprietary API offers a better solution: adding properties to the current execution context using the LogContext.PushProperty static method. You can use it from anywhere. If you want to enrich the logs with information about the current HTTP request, it’s best to create an ASP.NET Middleware. A middleware is a C# class with a single method InvokeAsync that gets called in the request pipeline, before any controller or API is called. Here is an example that pushes two properties: Client and RequestId.

public sealed class PushPropertiesMiddleware : IMiddleware
{
    public async Task InvokeAsync( HttpContext context, RequestDelegate next )
    {
        var requestId = Guid.NewGuid().ToString();

        using (LogContext.PushProperty( "Host", context.Request.Host ))
        using (LogContext.PushProperty( "RequestId", requestId ))
        {
            await next( context );
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

To add the middleware to the pipeline, you must first add it to the service collection:

builder.Services.AddSingleton<PushPropertiesMiddleware>();

Enter fullscreen mode Exit fullscreen mode

Then, call the UseMiddleware method.

app.UseMiddleware<PushPropertiesMiddleware>();

Enter fullscreen mode Exit fullscreen mode

Keep in mind that using a middleware is just one of the options. You can call LogContext.PushProperty from anywhere.

Automatically adding verbose logging to your code

Adding logging to your code by hand makes sense for low-volume, high-relevance pieces of information such as errors, warnings, and important messages. However, it would be a severe waste of time for more noisy messages. If all you want is better request tracing, you can write messages from your custom middleware. If you want logging deep inside your application and still avoid boilerplate, you can use a free code generation tool like Metalama.

With Metalama, you can create a template (called an aspect) that teaches the compiler how to implement logging, then apply the templates to all required methods.

First, install the Metalama.Extensions.DependencyInjection. Most of the magic is done by the Metalama.Framework package, which is implicitly added.

Then, create an aspect to fit your logging needs. Here’s a simple example:

public class LogAttribute : OverrideMethodAspect
{
    [IntroduceDependency]
    private readonly ILogger _logger;

    public override dynamic? OverrideMethod()
    {
        var formatString = BuildFormatString();

        try
        {
            _logger.LogDebug( formatString + " started.", (object[])meta.Target.Parameters.ToValueArray() );

            return meta.Proceed();
        }
        finally
        {
            _logger.LogDebug( formatString + " finished.", (object[])meta.Target.Parameters.ToValueArray() );
        }
    }

    [CompileTime]
    private static string BuildFormatString()
    {
        var parameters = meta.Target.Parameters
            .Where( x => x.RefKind != RefKind.Out )
            .Select( p => $"{p.Name}: {{{p.Name}}}" );

        return $"{meta.Target.Type}.{meta.Target.Method.Name}({string.Join( ", ", parameters )})";
    }
}

Enter fullscreen mode Exit fullscreen mode

You can now add the aspect to all methods you want to log. You can mark methods one at a time using the [Log] attribute. Another option is to use a Metalama fabric (a commercial feature) to add logging more broadly using a single piece of code.

internal class Fabric : ProjectFabric
{
    public override void AmendProject( IProjectAmender amender )
    {
        amender.Outbound
            .Select( c => c.GlobalNamespace.GetDescendant( "SerilogInAspNetCore.Controllers" )! )
            .SelectMany( c => c.Types )
            .Where( t => t.Accessibility == Accessibility.Public )
            .SelectMany( c => c.Methods )
            .Where( m => m.Accessibility == Accessibility.Public )
            .AddAspectIfEligible<LogAttribute>();
    }
}

Enter fullscreen mode Exit fullscreen mode

When applied to a WeatherForecastController.Get(int days) method (whether using an attribute or a fabric), the aspect generates code equivalent to the following at the start of the method:

this._logger.LogTrace( "WeatherForecastController.Get(days: {days}) started.", days );

Enter fullscreen mode Exit fullscreen mode

Summary

Serilog is an excellent semantic logging provider and it’s fully compatible with ASP.NET Core. In this article, we discussed how to set up Serilog, and how to use it in your code in different scenarios, including the use of an ASP.NET middleware. We mentioned Metalama as a possible tool to add verbose logging to your code without boilerplate.

This article was first published on a https://blog.postsharp.net under the title Adding Serilog to ASP.NET Core: a practical guide.

Top comments (0)