If you’re working with .NET Core, you’re likely using an ILogger
-based logging system. In this guide, we’ll explore some recommendations and best practices for using ILogger
effectively.
The NuGet package Microsoft.Extensions.Logging.Abstractions
offers logging interfaces with various backends and sinks. A backend, in the context of logging, is where your logs go, such as files, Application Insights, Seq, or Kibana. If you’re familiar with Serilog, these are known to “sinks.”
Interfaces
ILogger
ILogger
is your go-to interface for writing log messages of different levels and creating logging scopes. It exposes generic log methods, which are later used by “external” extension methods like LogInformation
or LogError
.
ILoggerProvider
An ILoggerProvider
represents an actual logging sink, like the Console, Application Insights, or Serilog. Its sole responsibility is to create ILogger
instances that log to a specific sink.
ILoggerFactory
ILoggerFactory
is the logging system’s initiator. It attaches logger providers and creates logger instances, both typed (ILogger<T>
) and untyped (ILogger
), which log to all registered logger providers.
Use Structured Logs
I strongly recommend using structured logs. This approach separates the log message string from its values, allowing the logging backend to replace them on demand. This preserves associated properties and a template hash, which is invaluable for advanced filtering and searching.
Example:
logger.LogWarning("The person {PersonId} could not be found.", personId);
Advantages of structured logs:
- Properties are stored as custom properties for filtering.
- A message template/message hash allows easy querying of log statement types.
- Serialization of properties only occurs when the log is written.
Disadvantages:
- Correct parameter order in the log statement is crucial.
Pass Exceptions as the First Parameter
When logging exceptions, always pass the exception object as the first argument to ensure proper formatting and storage.
Example:
logger.LogWarning(exception, "An exception occurred");
Always Use Placeholders and Avoid String Interpolation
To ensure correct logging of message templates and properties, use placeholders in the correct order.
Example:
logger.LogWarning("The person {PersonId} could not be found.", personId);
Avoid string interpolation, as it can lead to unnecessary object serialization and may not work with log level filters.
// Avoid this
logger.LogWarning($"The person {personId} could not be found.");
Do Not Use Dots in Property Names
Avoid using dots in placeholder property names, as some ILogger
implementations, like Serilog, do not support them.
Scopes
Use Scopes to Add Custom Properties to Multiple Log Entries
Scopes are handy for adding custom properties to all logs within a specific execution context. They work well even in parallel code due to their use of async contexts.
Example:
using (logger.BeginScope(new Dictionary<string, object> { {"PersonId", 5 } })) {
logger.LogInformation("Hello");
logger.LogInformation("World");
}
Consider creating scopes for logical contexts, such as per HTTP request, per event queue message, or per database transaction. Always include properties like Correlation ID for proper log organization.
Add Scope Identifiers
To filter logs by scope, you can add a scope identifier using a custom extension method.
public static IDisposable BeginNamedScope(this ILogger logger, string name, params ValueTuple<string, object>[] properties) {
// Implementation here
}
This allows you to isolate logs of a particular scope in your logging backend.
Add Custom Properties to a Single Entry
If you need to add properties to a log statement without including them in the message template, create a “short-lived” scope.
using (logger.BeginPropertyScope(("UserId", currentUserId))) {
logger.LogTrace("The message has been processed.");
}
Use a Consistent List of Property Names
Build a list of constant log entry property names (usually set with scopes) for your domain to ensure uniformity in your logs. This simplifies filtering across all log entries.
Previously I have set up some constants for scopes, ensuring that whenever we used UserId
or AccountId
we would always use the same name, allowing for better grouping and filtering.
Log Levels
Use the Right Log Levels
Choose log levels carefully to enable automated alerts, reports, and issue tracking.
When thinking about log levels, consider what it is your logging and the purpose. Here are the levels .NET currently has, and what you should use them for:
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.
You can find these all defined in the Microsoft documentation.
Log exceptions with full details
Exceptions should always be logged as exceptions (i.e. not only the exception message) so that the stacktrace and all other information is retained. Use the correct log level to distinguish critical, error and informational exceptions.
Logger Implementation
Use Concrete Implementations Sparingly
Reserve concrete implementations (e.g. Serilog types) for the application’s root, like the Startup.cs
of your ASP.NET Core app. Let services and logger consumers rely on the ILogger
, ILogger<T>
, or ILoggerFactory
interfaces via constructor dependency injection.
Consider Using Serilog
Consider using Serilog as an intermediary layer between ILogger
and the logging sink (e.g. Application Insights). This approach maintains consistency across sinks, ensuring consistent behavior and feature sets regardless of the selected sink.
Testing
Use the Null Logger for Testing
In testing, employ NullLogger.Instance
as a null object to avoid excessive null checks.
Local Development
Use Seq for Local Logging
For local development, Seq is a great choice as a logging backend. You can run it easily with Docker, making log access and analysis more convenient.
Here’s how to set it up:
Start a local Seq image:
docker run -d --restart unless-stopped --name seq -e ACCEPT_EULA=Y -p 5341:80 datalust/seq:latest
Note this command will host Seq on port 5341.
Add Seq as a Serilog logging sink:
var serilogLogger = new LoggerConfiguration() .WriteTo.Seq("http://localhost:5341") .CreateLogger(); var loggerFactory = (ILoggerFactory)new LoggerFactory(); loggerFactory.AddSerilog(serilogLogger); var logger = loggerFactory.CreateLogger();
You can now access the local logging UI for Seq at http://localhost:5341.
The benefits of doing this is that you now have an easy visible way to see what logs your software is outputting, allowing you to ensure you are receiving what you require in production.
Conclusion
Incorporating these best practices into your .NET Core and .NET Standard projects can greatly enhance the efficiency and effectiveness of your logging system. Whether you’re working with various logging backends or need to maintain a structured and consistent approach, following these guidelines will help you navigate the world of logging with confidence.
Keep these recommendations in mind to create a robust, organized, and easy-to-maintain logging solution for your applications.