.NET Logging
Logging abstractions in .NET
Abstractions
.NET supports high performance, structured logging via the
ILogger
API.
Logs can be written to different destinations. Abstraction that models this destination is a logging provider
.
There are built-in logging providers (Console, EventSource, EventLog, …) and many more third party.
Usually logs are sent to multiple destinations, often to some third party ones where we can easily search and classify them for easier troubleshooting and monitoring.
Foundation for logging is included in Microsoft.Extensions.Logging
NuGet package.
By pulling this package we automatically pull:
- Microsoft.Extensions.DependencyInjection - default DI implementation
- Microsoft.Extensions.Logging.Abstractions - abstractions for logging, such as
ILoggerFactory
,Logger<T>
,Logger<TCategoryName>
,LogLevel
- Microsoft.Extensions.Options - provides a strongly typed way of specifying and accessing settings
We will be focusing on Microsoft.Extensions.Logging.Abstractions
After adding a package and having the following program written, the output will be empty because we didn’t configure any providers (destinations) where our logs will go.
// var logger = new Logger<Program>(new LoggerFactory());
var logger = new LoggerFactory().CreateLogger<Program>();
logger.LogDebug("Error has occurred!");
One of the built-in providers is a Console provider coming with Microsoft.Extensions.Logging.Console
To create a logger we use a factory of type ILoggerFactory
.
Factory accepts a ILoggingBuilder
that enable us to configure:
minimum logging level
(filter out certain levels that we’re not interested in),logging providers
(destinations) where our logs will gofilters
- another way of filtering out irrelevant logs
Logging configuration can be provided in-line or come from an external source (a file, environment variable…).
Later is the most common as we don’t need to rebuild the application to change configuration.
Log specifics
Every log has a log level associated and a category.
There are seven log levels supported, although None
is not used for writing log messages. From lower to higher severity:
Trace
Logs that contain the most detailed messages. These messages may contain sensitive application data. These messages are disabled by default and should never be enabled in a production environment.Debug
Logs that are used for interactive investigation during development. These logs should primarily contain information useful for debugging and have no long-term value.Information
Logs that track the general flow of the application. These logs should have long-term value.Warning
Logs that highlight an abnormal or unexpected event in the application flow, but do not otherwise cause the application execution to stop.Error
Logs that highlight when the current flow of execution is stopped due to a failure. These should indicate a failure in the current activity, not an application-wide failure.Critical
Logs that describe an unrecoverable application or system crash, or a catastrophic failure that requires immediate attention.
And a special one:
None
Not used for writing log messages. Specifies that a logging category should not write any messages.
Category is a string associated with the log (it can be whatever we set, but in practice is a fully qualified name of the class associated with the logger). Ideal for grouping and filtering of the logs.
Let’s get practical
For the sake of an example, let’s have our configuration stored in a JSON file:
// logging_config.json
{
"Logging": {
"Console": {
"LogLevel": {
"Default": "Debug",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Program": "Error"
}
}
}
}
Console
is an alias for a ConsoleProvider
. If it happens that we have multiple providers this configuration will be applied only to a Console.
LogLevel
is a property that we’re configuring in a form of key-value pairs. Key is a Category
and a value is MinimumLogLevel
for that category.
Microsoft
, Microsoft.Hosting.Lifetime
and Program
are specific categories that we want to catch and filter.
Everything else falls under Default
Whatever MinimumLogLevel
we set, that one and all with the higher severity will be logged, others will be filtered out as irrelevant (i.e. not passed to a provider).
If LogLevel
is not specified, logging defaults to the Information
level.
Let’s jump into an example where we configure a logger from the configuration file and log messages with different log levels.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
var configuration =
new ConfigurationBuilder()
.AddJsonFile("logging_config.json", optional: false, reloadOnChange: true)
.Build();
var logger = LoggerFactory
.Create(builder =>
builder
.AddConsole()
.AddConfiguration(configuration.GetSection("Logging")))
.CreateLogger<Program>();
logger.LogTrace("Trace log!");
logger.LogDebug("Debug log!");
logger.LogInformation("Info log!");
logger.LogWarning("Warning log!");
logger.LogError("Error log!");
logger.LogCritical("Critical log!");
// Output:
// fail: Program[0]
// Error log!
// crit: Program[0]
// Critical log!
For a Category that starts with Program
we specified minLogLevel
to be an Error
and since our category falls there, output makes sense.
CreateLogger<Program>();
is equivalent to CreateLogger(typeof(Program).AssemblyQualifiedName)
in both ways we take class fully qualified name as a category.
If we create another logger with a different category than one specified in the configuration, it will fall under Default
category rule.
...
var otherLogger = LoggerFactory
.Create(builder =>
builder
.AddConsole()
.AddConfiguration(configuration))
.CreateLogger("OtherCategory");
otherLogger.LogTrace("Trace log!");
otherLogger.LogDebug("Debug log!");
otherLogger.LogInformation("Info log!");
otherLogger.LogWarning("Warning log!");
otherLogger.LogError("Error log!");
otherLogger.LogCritical("Critical log!");
Another way to filter out irrelevant log levels is via filters.
To add a filter we can use extension method on a builder AddFilter()
There are many overloads but in essence it enables us to filter based on the
- provider type or alias
- logger category
- log level
Let’s filter out log levels with lesser importance than Critical for a category: OtherCategory
var otherLogger = LoggerFactory
.Create(builder =>
builder
.AddConsole()
.AddFilter("OtherCategory", LogLevel.Critical))
.CreateLogger("OtherCategory");
otherLogger.LogTrace("Trace log!");
otherLogger.LogDebug("Debug log!");
otherLogger.LogInformation("Info log!");
otherLogger.LogWarning("Warning log!");
otherLogger.LogError("Error log!");
otherLogger.LogCritical("Critical log!");
Only Critical
level message will be logged.
Using filters we can override configuration values.
In the following example we also configure our logging via external configuration and filter will override configuration for a OtherCategory
category
var otherLogger = LoggerFactory
.Create(builder =>
builder
.AddConsole()
.AddConfiguration(configuration)
.AddFilter("OtherCategory", LogLevel.Critical))
.CreateLogger("OtherCategory");
For messages that go under OtherCategory
only those with Critical
severity will be logged.
Logged message specifics
So far we just logged plain text messages. To utilize a full potential of logging, especially if we’re integrating with some third party provider that does structured logging we should think about the format of our messages.
.NET supports structured logging. In the message format we can specify parameters and their values.
logger.LogDebug("Message arrived from {ExternalSystemId}", "OrderingSystem");
// Output:
// Message arrived from OrderingSystem
In this format our parameter is ExternalSystemId
with the value of OrderingSystem
If we have messages coming from other systems as well, this would make sense. We would be able to filter based on the ExternalSystemId
parameter.
Formatter
Another concept wroth mentioning. It goes hand in hand with the logging provider.
If ConsoleLoggingProvider
tells us that the destination of our logs is console, JsonConsoleFormatter
tells us in which format messages will be logged to a console.
By default when using extension method AddConsole
we have three formatters setup:
JsonConsoleFormatter
SystemdConsoleFormatter
SimpleConsoleFormatter
SimpleConsoleFormatter
is the one that’s set.
To set the other ones we can use extension methods:
- AddJsonConsole
- AddSystemdConsole
Log scopes
A way for us to group different logically connected logs and adding to them some contextual information.
I believe you already know where this might be useful. For instance if we want to connect multiple logs with the same Id (correlationRequestId, transactionId …) or more popular term for a concept tracing
.
To create a scope we just need to run BeginScope
on the ILogger
and provide it with the context that we want to be shared within the scope.
All the messages logged within the scope will have this contextual information.
To build up on top of this context we would build our own abstraction that will take control over the information shared within scope and be responsible of disposing it.
In an essence it looks like this:
var consoleLogger = LoggerFactory
.Create(builder =>
builder
.SetMinimumLevel(LogLevel.Trace)
.AddConsole(o => o.IncludeScopes = true))
.CreateLogger<Program>();
using (var scope = consoleLogger.BeginScope(correlationRequestId))
{
consoleLogger.LogDebug("Log1");
}
// Output
// dbug: Program[0]
// => 27e0cac5-f1ee-4244-9456-0dacbf10882d
// Log1
In case when we would pass something more complex we would need a proper formatter or use some already built-in ones
using (logger.BeginScope(new KeyValuePair<string, object>[]
{
new("CorrelationRequestId", correlationRequestId)
}))
{
logger.LogDebug("Log1");
}
// Output
// dbug: Program[0]
// => System.Collections.Generic.KeyValuePair`2[System.String,System.Object][]
// Log1
Then if we take built-in JsonConsoleFormatter
:
var logger = LoggerFactory
.Create(builder =>
builder
.SetMinimumLevel(LogLevel.Trace)
.AddJsonConsole(options =>
{
options.IncludeScopes = true;
options.JsonWriterOptions = new()
{
Indented = true,
};
}))
.CreateLogger<Program>();
...
// Output
{
"EventId": 0,
"LogLevel": "Debug",
"Category": "Program",
"Message": "Log1",
"State": {
"Message": "Log1",
"{OriginalFormat}": "Log1"
},
"Scopes": [
{
"Message": "System.Collections.Generic.KeyValuePair\u00602[System.String,System.Object][]",
"CorrelationRequestId": "8a9d5795-fc26-4532-9dfc-dbc41754e39c"
}
]
}
You can think of a use-case where we would send this logs to some third party sink that supports structure logging and get all these information there as well.
Once we create the scope, we can hold it’s reference inside of a variable.
i.e. var scope = logger.BeginScope(..)
and this is what we can find inside of it:
Only providers that inherit from ISupportExternalScope
support scopes, at this moment that would be ConsoleLoggerProvider
and EventLogLoggerProvider
.
If you’re interested into this concept following blog posts might give you some insights:
- https://andrewlock.net/how-to-include-scopes-when-logging-exceptions-in-asp-net-core/
- https://blog.stephencleary.com/2020/06/a-new-pattern-for-exception-logging.html
- https://blog.rsuter.com/logging-with-ilogger-recommendations-and-best-practices/
Conclusion
The abstractions that Microsoft provides are powerful and there are tons of third party ones built on top of those that can probably serve all our needs. Logging is an important concept and a small building block on which other powerful concepts such as Tracing, Monitoring, Observability are built on.
Since we now understand the foundations of Logging we can build our understanding of other concepts.
See you in the next one :)