A Beginner’s Guide to .NET’s HostBuilder: Part 5 — Logging and Serilog
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
- Introduction and Setup
- Dependency injection
- Cancellation
- Configurations and Environments
- 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.
serilog
serilog.extensions.hosting
serilog.settings.configuration
serilog.sinks.console
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!