Bootstrap logging with Serilog + ASP.NET Core
Errors during application start-up are some of the nastiest problems to hit in production. Deployment issues like broken manifests or missing assemblies, incorrect settings, exceptions thrown during IoC container configuration or in the constructors of important components - these can bring start-up to a screeching halt and cause a process exit, without leaving even so much as an error page.
Ideally, though, your logging infrastructure will have your back, and collect the information you need to diagnose the issue. This is why Serilog is — ideally — set up on the very first line of Main()
:
public static class Program
{
public static void Main(string[] args)
{
// Initialize early, without access to configuration or services
Log.Logger = new LoggerConfiguration()
.WriteTo.Console() // + file or centralized logging
.CreateLogger();
try
{
CreateHostBuilder(args).Build().Run();
}
catch (Exception ex)
{
// Any unhandled exception during start-up will be caught and flushed to
// our log file or centralized log server
Log.Fatal(ex, "An unhandled exception occured during bootstrapping");
}
finally
{
Log.CloseAndFlush();
}
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.UseSerilog()
.ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); });
}
There’s a catch, though: in this case, important parts of the hosting infrastructure like appSettings.json
configuration, and services provided by ASP.NET, won’t be available when logging is initialized.
If you want to use JSON configuration to configure your logger, enrich events with information from a service like IHttpContextAccessor
, or use sinks that rely on components from your IoC container, you need to configure logging inline:
public static class Program
{
public static void Main(string[] args)
{
// Exceptions thrown here won't always be logged
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.UseSerilog((context, services, configuration) => configuration
.WriteTo.Console()
// But, we have access to configuration and services from the host
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services))
.ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); });
}
Fewer lines of code, and we have access to services and JSON configuration….. but we’re between a rock and a hard place: if start-up fails early, in Startup.ConfigureServices()
for instance, logging won’t yet be initialized, and we’ll be left in the dark.
Why can’t we just do both?
We very nearly can, with two separate logging pipelines, by combining code from both examples.
But, a) we don’t want any code to use the original “bootstrap” logger once the full logging pipeline is initialized. This is tricky because components may have stored contextual ILogger
instances tied to the bootstrap logger.
And, b) some Serilog sinks — the file sink in particular — take locks or consume resources that mean they should be set up only once.
For this to be production-worthy, we need some way to “upgrade” the bootstrap logger with the new configuration, and that’s what the latest hosting integration finally achieves.
Introducing CreateBootstrapLogger()
The latest 4.0.0-*
pre-release of Serilog.Extensions.Hosting adds a new extension method, and some infrastructure, to make this all work:
// dotnet add package serilog.extensions.hosting -v 4.0.0-*
public static class Program
{
public static void Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger(); // <-- 😎
try
{
CreateHostBuilder(args).Build().Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "An unhandled exception occured during bootstrapping");
}
finally
{
Log.CloseAndFlush();
}
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.UseSerilog((context, services, configuration) => configuration
.WriteTo.Console()
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services))
.ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); });
}
Spot the difference? CreateBootstrapLogger()
returns a new, mutable logger type, ReloadableLogger
, that can be reconfigured on-the-fly.
The callback overload of UseSerilog()
is aware of this: if Log.Logger
is a ReloadableLogger
, it will be reconfigured through the callback, and then “frozen” into the efficient, immutable logging pipeline that the application will use for the remainder of its execution time.
You might notice that the console sink is specified in both logger configurations. This is because the bootstrap logger is wholly reconfigured: its initial set of sinks and other pipeline components are completely shut down, and a new pipeline is set up.
When should I use CreateBootstrapLogger()
The CreateBootstrapLogger()
feature is only useful if you are unable to fully configure your logging pipeline at program start-up. It’s deeply integrated with the overload of UseSerilog()
that accepts a logger configuration callback.
Using CreateBootstrapLogger()
in any other scenario won’t receive special treatment. You might find your own uses for the underlying ReloadableLogger
type, but out of the box, unless you later call UseSerilog()
and pass a configuration callback, nothing interesting will happen.
Do I have to use CreateBootstrapLogger()
?
No; the CreateBootstrapLogger()
/ReloadableLogger
mechanism has been carefully integrated so that there are no breaking changes or behavioral gotchas if you ignore it.
Are there any downsides?
Finally, I think I can say “no”. CreateBootstrapLogger()
sorts out the whole order-of-initialization problem, and at runtime, once fully initialized, doesn’t add any overhead at all.
If you’re able to try the 4.0.0-*
pre-release of Serilog.Extensions.Hosting, feedback would be appreciated, and help us move this feature forward into a final version of the package.