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:

  1. 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.

  1. 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.