A Beginner’s Guide to .NET’s HostBuilder: Part 5 — Logging and Serilog

Sawyer Watts
4 min readOct 28, 2023

--

This part covers the ILogger<T> built into .NET’s HostBuilder, as well as detailing an alternative logger: Serilog. Here is the console app’s current working structure:

$ tree -I 'obj|bin'  # This will print the tree structure, ignore obj/ and bin/
.
├── appsettings.Development.json
├── appsettings.json
├── Driver.cs
├── HostBuilderDemo.csproj
├── Program.cs
└── Properties
└── launchSettings.json

1 directory, 6 file

The series

  1. Introduction and Setup
  2. Dependency injection
  3. Cancellation
  4. Configurations and Environments
  5. Logging and Serilog

Table of contents

Readers may need to refresh after clicking a section

The built-in Logger

Out of the box, ILogger<T> is a registered service. Update Driver.cs‘s constructor to request that service:

namespace HostBuilderDemo;

public class Driver
{
private readonly ILogger<Driver> _logger;

public Driver(
ILogger<Driver> logger)
{
_logger = logger;
// The class name is included in the log, so don't need to
// log the class name anymore.
_logger.LogInformation("Instantiating");
}

public Task RunAsync(
CancellationToken cancellationToken)
{
_logger.LogInformation("Hello, internet!");
return Task.CompletedTask;
}
}

Update Program.cs to sleep for two seconds when closing:

using HostBuilderDemo;

IHost host = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services.AddTransient<Driver>();
})
.Build();

try
{
await host.Services.GetRequiredService<Driver>().RunAsync(default);
}
finally
{
// Let the logger finish flushing. There's probably a better way to do
// this, but logger packages (like Serilog) have their own unique ways to
// explicitly flush the log, so just do that.
Thread.Sleep(2000);
}

Run the app for the following output:

$ dotnet run
Building...
info: HostBuilderDemo.Driver[0]
Instantiating
info: HostBuilderDemo.Driver[0]
Hello, internet!

Ilogger<T> will write its logs to standard out (STDOUT). There are a couple of levels to the log, like Information, Debug, and Error, and the level can be specified in style _logger.Log{Level}("Message template");. The minimum level to log is defined in the Appsettings*.json(s). Here is appsettings.Development.json:

{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

For more on each level and when to use it, see the docs.

Serilog

However, the default logger does not write to a file, and for more control over the logger, logging packages like Serilog can be imported.

To use Serilog, start by installing the following packages. This will allow the app to write its logs to STDOUT and a file, as well as read from IConfiguration and to play nice with the host builder.

  1. serilog
  2. serilog.extensions.hosting
  3. serilog.settings.configuration
  4. serilog.sinks.console
  5. serilog.sinks.file

Next, update Program.cs:

using HostBuilderDemo;
using Serilog;

IHost host = Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
// Add this paragraph:
IConfiguration config = hostContext.Configuration;
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(config)
.Enrich.FromLogContext()
.CreateLogger();

services.AddTransient<Driver>();
})
.UseSerilog() // Add this line
.Build();

try
{
await host.Services.GetRequiredService<Driver>().RunAsync(default);
}
finally
{
// Replaced Thread.Sleep(2000) with this line:
Log.CloseAndFlush();
}

After that, replace the logging section of appsettings.Development.json with:

{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.DbLoggerCategory.Database.Command": "Information"
}
},
"Using": [ "Serilog.Sinks.File", "Serilog.Sinks.Console" ],
"WriteTo": [
{
"Name": "File",
"Args":
{
"path": "Logs/On-.log",
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level}] {SourceContext} {Message:lj}{NewLine}{Exception}",
"rollingInterval": "Day"
}
},
{
"Name": "Console",
"Args":
{
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level}] {SourceContext} {Message:lj}{NewLine}{Exception}"
}
}
],
"Properties": {
"Application": "HostBuilderDemo"
}
}
}

Serilog is now integrated into the app. Note that Driver did not need to change. This is because Serilog’s logger implements .NET’s built-in ILogger<T> interface. Next, run the app to see the Serilog logs printed to STDOUT and to file Logs/On-20231027.log:

$ dotnet run
Building...
2023-10-27 17:26:26.660 [Information] HostBuilderDemo.Driver Instantiating
2023-10-27 17:26:26.716 [Information] HostBuilderDemo.Driver Hello, internet!
$ cat Logs/On-20231027.log
2023-10-27 17:29:38.802 [Information] HostBuilderDemo.Driver Instantiating
2023-10-27 17:29:38.840 [Information] HostBuilderDemo.Driver Hello, internet!

Using Serilog’s logger directly

Instead of using Serilog via the built-in ILogger<T>, it is possible to use Serilog’s ILogger. Here are the main advantageous of this choice:

  • Serilog-specific functionality would be made available to service classes
  • Serilog’s ILogger makes fewer heap allocations so it is more efficient

That said, I tend to keep using the built-in ILogger<T> because it vastly limits coupling my apps to this third-party package and the performance enhancements have not yet been compelling enough to overcome that coupling cost.

To use Serilog’s ILogger, update Program.cs:

using HostBuilderDemo;
using Serilog;

IHost host = Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
IConfiguration config = hostContext.Configuration;
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(config)
.Enrich.FromLogContext()
.CreateLogger();
// Register the singleton:
services.AddSingleton(Log.Logger);

services.AddTransient<Driver>();
})
// .UseSerilog() // This is not necessary anymore.
.Build();

try
{
await host.Services.GetRequiredService<Driver>().RunAsync(default);
}
finally
{
Log.CloseAndFlush();
}

Next, update Driver.cs to use that logger:

namespace HostBuilderDemo;

public class Driver
{
private readonly Serilog.ILogger _logger;

public Driver(
Serilog.ILogger logger)
{
_logger = logger;
_logger.Information("Instantiating");
}

public Task RunAsync(
CancellationToken cancellationToken)
{
_logger.Information("Hello, internet!");
return Task.CompletedTask;
}
}

When running the application, here are the logs (note that the source class, the category, is not included by default):

$ dotnet run
Building...
2023-10-27 17:37:19.190 [Information] Instantiating Driver
2023-10-27 17:37:19.246 [Information] Hello, internet!

The flattened Serilog file path

In some situations, it can be helpful to configure Serilog’s file path via a CLI argument (or via a YAML variable). Recall that this is the current appsettings.Development.json:

{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.DbLoggerCategory.Database.Command": "Information"
}
},
"Using": [ "Serilog.Sinks.File", "Serilog.Sinks.Console" ],
"WriteTo": [
{
"Name": "File",
"Args":
{
"path": "Logs/On-.log",
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level}] {SourceContext} {Message:lj}{NewLine}{Exception}",
"rollingInterval": "Day"
}
},
{
"Name": "Console",
"Args":
{
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level}] {SourceContext} {Message:lj}{NewLine}{Exception}"
}
}
],
"Properties": {
"Application": "HostBuilderDemo"
}
}
}

The flattened config path to the file path is Serilog:WriteTo:0:Args:path, so the app can have its log path set to Logs/Yay-.log with the following:

$ dotnet run --Serilog:WriteTo:0:Args:path Logs/Yay-.log
Building...
2023-10-28 13:21:04.598 [Information] HostBuilderDemo.Driver Instantiating
2023-10-28 13:21:04.631 [Information] HostBuilderDemo.Driver Hello, internet!
$ cat Logs/Yay-20231028.log
2023-10-28 13:21:04.598 [Information] HostBuilderDemo.Driver Instantiating
2023-10-28 13:21:04.631 [Information] HostBuilderDemo.Driver Hello, internet!

--

--