My software development journey

Implement a custom logger

March 09, 2022

In this final part about logging, we will take a look at how to write a custom logging provider. But before we do that, let’s first take a look at how to add file logging. Since Microsoft at the time of writing doesn’t provide file logging, the fastest option I found was to use Serilog file extension. This extension builds on top of Serilog logging library to bind part of it into default .Net logging. As usual, we need to add the required NuGet package Serilog.Extensions.Logging.File. After that we create and configure the logging factory:

using var loggerFactory = LoggerFactory.Create(lb =>
{
  var loggingConfig = configurationRoot.GetSection("Logging");
  lb.AddConfiguration(loggingConfig);
  lb.AddFile(loggingConfig);
});

var logger = loggerFactory.CreateLogger<Program>();

logger.LogError("Hello world!");

AddFile extension method is used to configure file logging. Configuration for file logging is located in appsettings Logging section.

{
  "PathFormat": "Logs/log-{Date}.txt"
}

As the last thing on our list, let’s see how to implement a custom logging provider. The goal for building this provider is to show-case how in a nutshell logging infrastructure works. Our sample will be more barebones and lack things like thread safety, performance improvements, support for scopes, and formatting. So with this big disclaimer out of the way let’s get started. 🙂

 public class WriteActionLogger : ILogger
{
  private readonly string _name;
  private readonly Action<string> _writeTarget;

  public WriteActionLogger(
    string name,
    Action<string> writeTarget)
  {
    _name = name ??
      throw new ArgumentNullException(nameof(name));
    _writeTarget = writeTarget ??
      throw new ArgumentNullException(nameof(writeTarget));
  }

  public void Log<TState>(
    LogLevel logLevel,
    EventId eventId,
    TState state,
    Exception exception,
    Func<TState, Exception, string> formatter)
  {
    if (!IsEnabled(logLevel))
    {
      return;
    }

    if (formatter == null)
    {
      throw new ArgumentNullException(nameof(formatter));
    }

    var message = formatter(state, exception);

    if (message != null)
    {
      if (exception != null)
      {
        message += Environment.NewLine + "Exception message: "
        + exception.Message + Environment.NewLine
        + exception.StackTrace;
      }

      message = $"[{_name}] {message}";
      _writeTarget(message);
    }
  }

  public bool IsEnabled(LogLevel logLevel)
  {
      return logLevel != LogLevel.None;
  }

  public IDisposable BeginScope<TState>(TState state)
    => NullScope.Instance;
}

public class NullScope : IDisposable
{
  public static NullScope Instance { get; } = new NullScope();

  private NullScope() {}

  public void Dispose() {}
}

The key point of our logger implementation is that we take a write action and a logging category name as inputs. Name is used with a formatter action to create a message that is then passed to write action. Since scopes are not supported, a disposable null object is returned on scope begin.

We are still missing an ILoggerProvider implementation to register and use our logger. The provider is responsible for creating logger instances and managing their shared resources.

public class WriteActionLoggerProvider : ILoggerProvider
{
  private readonly Action<string> _writeTarget;

  public WriteActionLoggerProvider(Action<string> writeTarget)
  {
    _writeTarget = writeTarget;
  }

  public ILogger CreateLogger(string categoryName)
  {
    return new WriteActionLogger(categoryName, _writeTarget);
  }

  public void Dispose() {}
}

And finally, let’s use our logger:

var sb = new StringBuilder();
using var loggerFactory = LoggerFactory.Create(lb =>
{
  var provider = new WriteActionLoggerProvider(
    m => sb.AppendLine(m));
  lb.AddProvider(provider);
});

var logger = loggerFactory.CreateLogger<Program>();

logger.LogError("Hello world!");

Console.Write(sb.ToString());

Here we pass to WriteActionLoggerProvider an action that writes to StringBuilder and then add the provider to the logging builder. In the end, we write the contents of the StringBuilder to console, which produces the following output:

[MSLoggingInConsoleApp.Program] Hello world!

So while this example is a bit contrived, I hope it does give a bit of insight in how logging infrastructure works in the background.

Resources: