Updated on

The practices that matter most with Serilog are small and structural: configure it from appsettings.json rather than in code, log message templates rather than interpolated strings, inject ILogger<T> rather than reaching for the static Log class, and enrich once at startup rather than at every call site.

Each of those is a decision made once, at setup, that determines whether logs can be queried later. Get them wrong and we still get logs; we just get logs nothing can search.

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

What Is Serilog?

Serilog is a structured logging library for .NET. Where a traditional logger writes a formatted string, Serilog writes a message template plus the values that fill it, and keeps those values as named properties on the log event.

That distinction is the whole library. A log line that arrives as "Order 4417 shipped to Berlin" can only be grepped. The same line recorded as "Order {OrderId} shipped to {City}" with two properties attached can be filtered, grouped, and counted by any of them.

Sinks decide where events go: console, file, Seq, Elasticsearch, and many others. Each one is a separate package we opt into.

Enrichers add properties to every event automatically, so machine name or thread ID appears on all of them without touching a single call site.

Serilog plugs into the ILogger<T> interface the rest of .NET already uses, so adopting it does not mean rewriting call sites.

Serilog’s own README frames that as the point of the library: “Serilog’s support for structured logging shines when instrumenting complex, distributed, and asynchronous applications.” (serilog/serilog)

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.

That last point is not theoretical. An injected interface is what makes unit testing code that logs possible without a real logger behind it.

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

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

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

    // code omitted for brevity

    app.Run();
}
catch (Exception ex)
{
    Log.Error(ex, "The exception was thrown during application startup");
}
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.

The MinimumLevel call sits above the sinks purely for readability, so the configuration reads as levels-then-destinations. Both are consumed inside CreateLogger(), so the order of the chain does not change what the logger does.

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

That is the Package Manager Console form. Outside Visual Studio, the .NET CLI does the same job:

dotnet add 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:u3}] {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 set the default minimum level. Our separate guide covers the full set of Serilog log levels and how to set a minimum. Then we move on to the WriteTo sub-section, where we configure the different sinks, which can even include things like the message template.

In that template, {Level:u3} is the conventional form. The number is a maximum width rather than a field width, so a larger value such as u11 does not pad anything: it simply prints each level in full, giving us INFORMATION and WARNING where u3 gives the aligned INF and WRN.

Then, we need to apply the configuration:

builder.Services.AddSerilog((services, config) =>
    config.ReadFrom.Configuration(builder.Configuration));

In our Program class, we use the AddSerilog() 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.

AddSerilog() is what Serilog.AspNetCore documents today. The older builder.Host.UseSerilog() still works, but it is not a straight rename: UseSerilog() hands the lambda a HostBuilderContext while AddSerilog() hands it an IServiceProvider, so the configuration has to come from builder.Configuration instead of context.Configuration.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

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 Serilog’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.

A further option is shipping logs as OpenTelemetry data, which sends them to any backend that speaks the OTLP protocol rather than to one vendor’s sink.

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, the first parameter of ILogger<T>‘s LogInformation() method is named message, but it is a message template, not a message.

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.

The placeholder syntax has rules of its own, and we cover message templates in more depth separately.

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

An enricher attaches a property to every log event, configured once at startup instead of passed at every call site.

Serilog ships a small core and puts most enrichers in separate packages, which is where the confusion starts: the Enrich value in configuration and the package that supplies it have different names.

WithMachineName and WithEnvironmentName both come from Serilog.Enrichers.Environment. WithThreadId comes from Serilog.Enrichers.Thread, and WithProcessId from Serilog.Enrichers.Process. Naming an enricher without installing its package is a silent no-op: the property simply never appears.

FromLogContext is the exception worth knowing. It needs no extra package, and it is what lets us push a property such as a correlation ID onto every event raised inside a block of code.

Enrichers are also cheap in a way call-site properties are not. Configured once at startup, they cost nothing per log statement and cannot be forgotten by whoever writes the next one.

The table below maps each enricher to the package that supplies it.

One note on that silent no-op: Serilog does write a message about it to Serilog.Debugging.SelfLog, which is off unless we turn it on.

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

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

The same three packages with the .NET CLI:

dotnet add package Serilog.Enrichers.Thread
dotnet add package Serilog.Enrichers.Process
dotnet add package Serilog.Enrichers.Environment

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.

EnricherEnrich value in appsettings.jsonNuGet packageProperty added
Thread IDWithThreadIdSerilog.Enrichers.ThreadThreadId
Thread nameWithThreadNameSerilog.Enrichers.ThreadThreadName
Process IDWithProcessIdSerilog.Enrichers.ProcessProcessId
Process nameWithProcessNameSerilog.Enrichers.ProcessProcessName
Machine nameWithMachineNameSerilog.Enrichers.EnvironmentMachineName
Environment nameWithEnvironmentNameSerilog.Enrichers.EnvironmentEnvironmentName
Environment userWithEnvironmentUserNameSerilog.Enrichers.EnvironmentEnvironmentUserName
Ambient propertiesFromLogContextSerilog core, no extra packageWhatever LogContext.PushProperty() pushed

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.Services.AddSerilog((services, config) =>
    config.ReadFrom.Configuration(builder.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.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

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

The same mechanism is behind logging class and method names automatically, which saves writing them into every message by hand.

Request Logging With Serilog

Requests are a vital part of any 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.

How Do We Configure Serilog in ASP.NET Core?

Configuration happens in two places, and the split is deliberate. The host wires Serilog into dependency injection; appsettings.json decides what it actually does.

The wiring goes in the Program class and reads the configuration section, so sinks, minimum levels, and enrichers all change without a rebuild.

A bootstrap logger is the piece most setups miss. Configuration is not available until the host is built, so anything that fails before that point is logged nowhere. Creating a minimal logger first, then replacing it once configuration loads, closes that window.

Log.CloseAndFlush() belongs in a finally block for the same reason. Sinks that batch (Seq, Elasticsearch, most network sinks) hold events in memory briefly, and a process that exits without flushing loses exactly the events written just before it died.

Everything after startup goes through ILogger<T> as usual. The static Log class earns its place only in the two spots dependency injection cannot reach: before the container exists, and after it has been disposed.

The Serilog.AspNetCore README calls that two-stage initialization: an initial “bootstrap” logger is configured immediately when the program starts, “and this is replaced by the fully-configured logger once the host has loaded” (serilog/serilog-aspnetcore).

Creating that first logger takes one call:

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

CreateBootstrapLogger() replaces CreateLogger() in the snippet we saw earlier. Everything else stays as it was, including the trycatchfinally around the host and the Log.CloseAndFlush() inside the finally.

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.

Tested with .NET 10.0.10, Serilog 4.4.0, and Serilog.AspNetCore 10.0.0.