In this article, we’ll take a look at some best practices when it comes to logging in .NET with Serilog.

Logging is essential to any application we build and Serilog is one of the major logging providers. For this reason, we’ve prepared some Serilog best practices to help you improve your logs. Through the article, we’ll use the boilerplate weather API to showcase any of the practices mentioned here.

To download the source code for this article, you can visit our GitHub repository.

Let’s start!

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

Avoid the Static Logger Class

Serilog comes with its static Log class that we can use through our application to log events and information. We can use it to access its Logger property and write any sort of logs we wish. But by doing this we break the dependency inversion principle

We can easily integrate Serilog with Microsoft’s built-in logging interface, which is one of the most useful practices to follow. By employing this approach, we not only stick to the dependency inversion principle but also make testing our application much easier.

However, one of the use cases for the static Log class is in the Program class:

Log.Logger = new LoggerConfiguration()
    .WriteTo.File(
        "logs/log.txt",
        retainedFileCountLimit: 7,
        rollingInterval: RollingInterval.Day)
    .MinimumLevel.Information()
    .CreateLogger();

try
{
    var builder = WebApplication.CreateBuilder(args);

    // code ommited for brevity

    app.Run();
}
catch (Exception ex)
{
    Log.Error(
        "The following {Exception} was thrown during application startup",
        ex);
}
finally
{
    Log.CloseAndFlush();
}

We use the Logger property of the static Log class to configure it to write the logs to a file. Then we wrap our application’s configuration in a trycatch block – this way we’ll log any errors that happen during the start process of our application. The finally block just takes care of closing and flushing the remaining logs.

We can also use the static Log class in any other places where dependency injection is not possible.

Configure Serilog From appsettings.json

First and foremost, we need to configure Serilog corresponding to our needs. We have two options: Fluent API or the configurations system. While the Fluent API is very intuitive and easy to read, there is one big downside – every time we change something in our configuration, we need to publish a new build of our application.

This is why it’s better to use the configuration system to set up Serilog in our applications:

Install-Package Serilog.Settings.Configuration

We start by installing the Serilog.Settings.Configuration NuGet package.

Now that we have the package, let’s add our basic configuration:

"Serilog": {
  "Using": [
    "Serilog.Sinks.Console"
  ],
  "MinimumLevel": {
    "Default": "Information"
  },
  "WriteTo": [
    {
      "Name": "Console",
      "Args": {
        "OutputTemplate": "[{Timestamp:HH:mm:ss} {Level:u11}] {Message:lj}{NewLine}"
      }
    }
  ],
  "Properties": {
    "ApplicationName": "Weather API"
  }
}

First, in the appsettings.json file, we add a new section called Serilog. Inside it, we start by creating the Using sub-section in which we state the sink that we want to use.

Next, we configure the default log level. Then we move on to the WriteTo sub-section, where we configure the different sinks, which can even include things like the message template.

Then, we need to apply the configuration: 

builder.Host.UseSerilog((context, config) =>
    config.ReadFrom.Configuration(context.Configuration));

In our Program class, we use the UseSerilog() extension method to specify that our application should use the appsettings.json file to configure Serilog. With this, we remove the need to republish our application when our logging configuration changes.

Forget About Serilog’s Console and File Sinks in Production

When we develop an application it’s great to see event logs as they happen. Logging to the console is a great way to achieve this. However, logging everything to the console can make it very difficult to track down events as it quickly gets clogged with information. Moreover, we want to stay away from this in our production environment as it may cause performance issues.

The Serlilog’s file sink is even better than the console one when it comes to development. We can easily filter and sort the logs making searching for certain events very easy. However, as with console logging, this becomes very burdensome to deal with in production.

You can find out more about Serilog’s file sink in our article How to Configure Rolling File Logging With Serilog.

For production purposes, we can use Seq, Elasticsearch, or any other Serilog sink that is more suited for production environments. This way we can gain better scalability and reliability compared to file or console logs.

It’s worth mentioning that depending on our situation, there might be scenarios where file and console logging can have their uses in Production. For example, our log provider might have a problem receiving logs so having file or console logs might prove useful.

Always Use Structured Logging

When possible, we should always avoid simple strings when logging:

logger.LogInformation(
    $"The weather today will be {forecast[0].Summary} and {forecast[0].TemperatureC} degrees.");

This will produce a simple log message that may not be very useful. Moreover, even though the first parameter of ILogger<T>‘s LogInformation() method has the name of message, this is not correct and this is the message template.

Let’s use it properly:

logger.LogInformation(
    "The weather today will be {Summary} and {Temperature} degrees.",
    forecast[0].Summary,
    forecast[0].TemperatureC);

Here, we first pass the message template and then follow up with the two parameters required by it. This will produce a structured log, where both the Summary and Temperature will be stored as parameters associated with the log message. This makes querying our logs much easier, saving us time and effort.

You can find out more about Structured Logging in our article Structured Logging in ASP.NET Core with Serilog.

Use Built-in Event Log Enrichers

Adding additional information to our logs can prove beneficial.

For this purpose, Serilog comes with various built-in log event enriches:

Install-Package Serilog.Enrichers.Thread
Install-Package Serilog.Enrichers.Process
Install-Package Serilog.Enrichers.Environment

We start by installing Serilog’s Thread, Process, and Environment enrichment packages.

Next, we update our configuration:

"Enrich": [
  "WithThreadId", 
  "WithProcessId",
  "WithMachineName",
  "WithEnvironmentName"
]

In the appsettings.json file, we add a new sub-section called Enrich. Inside it, we add properties specifying that Serilog should enrich our logs with ThreadId, ProcessId, MachineName as well as EnvironmentName.

We can send a request to our API and explore the output in Seq:

Serilog Best Practices: Showing EnvironmentName, MachineName, ProcessId and ThreadId as part of the log properties in Seq.

We can see that the log includes all the additional properties we specified in the Enrich sub-section of the appsettings.json file.

Create Custom Log Event Enricher for Serilog

It comes as no surprise that we can create custom log event enrichers:

public class ThreadPriorityEnricher : ILogEventEnricher
{
    public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
    {
        logEvent.AddPropertyIfAbsent(
            propertyFactory.CreateProperty(
                "ThreadPriority",
                Thread.CurrentThread.Priority.ToString()));
    }
}

We start by creating the ThreadPriorityEnricher class and implementing the ILogEventEnricher interface. The interface forces us to implement the Enrich() method. Using the LogEvent class’ AddPropertyIfAbsent() method, we try to add a new property to the logs if it’s not already present. Our additional property will add the thread priority to the log events. To get the priority itself we use the Thread class and its properties. The property is attached to the logs via the CreateProperty() method of the ILogEventPropertyFactory interface.

Next, we register the enricher:

builder.Host.UseSerilog((context, config) =>
    config.ReadFrom.Configuration(context.Configuration)
        .Enrich.With(new ThreadPriorityEnricher()));

In the Program class, we use the With() method on the Enrich property of the LoggerConfiguration class. To the method, we pass a new instance of our ThreadPriorityEnricher class.

With this, all logs of our application will have the ThreadPriority as a property.

Request Logging With Serilog

Requests are a vital part of one application so detailed logging is a must:

app.UseSerilogRequestLogging();

In our Program class, we call the UseSerilogRequestLogging() extension method on our WebApplication instance. With this, our request logs will now have information about the HTTP method, path, status code, and how long it took for our application to respond.

We can go a step further and create a custom request log enricher:

public static class RequestEnricher
{
    public static void LogAdditionalInfo(
        IDiagnosticContext diagnosticContext, 
        HttpContext httpContext)
    {
        diagnosticContext.Set(
            "ClientIP", 
            httpContext.Connection.RemoteIpAddress?.ToString());
    }
}

We start by creating a new RequestEnricher class.

Next, we create the LogAdditionalInfo() method. It takes two parameters: Serilog’s IDiagnosticContext and HttpContext instances. Then, we use the diagnostic context’s Set() method to create the ClientIP property and assign it the RemoteIpAddress property of the HttpContext that is passed to the method.

Note that this is different from implementing the ILogEventEnricher interface and has to be registered differently.

Next, we add our custom enricher:

app.UseSerilogRequestLogging(options
  => options.EnrichDiagnosticContext = RequestEnricher.LogAdditionalInfo);

To do this, we set to EnrichDiagnosticContext property inside the UseSerilogRequestLogging() method to be equal to the LogAdditionalInfo() method we just wrote.

Finally, we can send a request and check the log:

Serilog Best Practices: Showing enriched request logs with Client IP address.

We can see that our log now has information about the HTTP method, path, and status code. We also get a property called ClientIP with a value of ::1 which means we send the request from the same machine on which our application is running.

Conclusion

In conclusion, mastering logging practices with Serilog in .NET is essential for optimizing our application’s performance and troubleshooting. By configuring Serilog through the application settings we gain the flexibility to change logging settings without requiring constant republishing.

We ensure scalability and reliability by steering clear of console and file sinks in favor of specialized alternatives when it comes to production.

When we enrich logs with additional information and adopt detailed request-logging practices we further enhance our application’s diagnostic capabilities. By adhering to the practices mentioned here, we can harness Serilog’s power to create robust, insightful logging solutions for our applications. We hope you enjoyed exploring some of the Serilog best practices, please let us know in the comments of any others you think are worthy contenders.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!