How to create and implement a custom logging provider in C# Blazor

In this blog post, we’ll go through how we implement a custom logging provider in C#. I will be using a Blazor server project for the application. However, this example is not Blazor specific and can be applied across many .NET project types.

There are many third-party logging frameworks that exist already, some of which are even endorsed by Microsoft themselves. But having said this, there still may be many reasons why you would want to implement your own custom logging provider.

Perhaps you only need a simple solution that does not warrant learning a new framework entirely. My reason for having to implement a custom logging provider is to act as a catch-all for all unhandled exceptions in Blazor; having all errors in one place makes for much easier debugging.

This post will outline how to create a custom logging provider that writes to text files. We will also be implementing our own configuration (using appsettings.json) whereby each log level writes its logs to a different directory. This means that all Information logs are in one directory, all Warning logs in another, Error logs in a different directory and so on. Finally, we’ll register and use our custom logging provider.

Each log level will output to a different directory using the following configuration type:

public class MyCustomLoggerConfiguration
    {
        public Dictionary<LogLevel, string> LogLevels { get; set; } = new();
    }

Next we will create the custom logger:

public sealed class MyCustomLogger : ILogger
    {
        private readonly string _name;
        private readonly Func<MyCustomLoggerConfiguration> _getCurrentConfig;

        public MyCustomLogger(
            string name,
            Func<MyCustomLoggerConfiguration> getCurrentConfig) =>
            (_name, _getCurrentConfig) = (name, getCurrentConfig);

        public IDisposable BeginScope<TState>(TState state) => default!;

        public bool IsEnabled(LogLevel logLevel) =>
            _getCurrentConfig().LogLevels.ContainsKey(logLevel);

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

            MyCustomLoggerConfiguration config = _getCurrentConfig();

            var logLevelPath = config.LogLevels[logLevel];
            Directory.CreateDirectory(logLevelPath);
            var fullPath = Path.Combine(logLevelPath, $"{logLevel}.txt");
            using StreamWriter file = File.AppendText(fullPath);
            file.WriteLine($"Log whatever you want in here!");
        }
    }

In this code, a logging instance will be created per category name. Although not necessary, this would allow for different outputs per log category. As well as being instantiated with the category name, there is a Func<MyCustomLoggerConfiguration> which handles any configuration changes. These changes are monitored using IOptionsMonitor<MyCustomLoggerConfiguration> in the logging provider. The configuration is also checked to ensure that the current log level is included. Next is to create the logging provider:

[ProviderAlias("CustomLogger")]
    public sealed class MyCustomLoggerProvider : ILoggerProvider
    {
        private readonly IDisposable _onChangeToken;
        private MyCustomLoggerConfiguration _currentConfig;
        private readonly ConcurrentDictionary<string, MyCustomLogger> _loggers =
            new(StringComparer.OrdinalIgnoreCase);

        public MyCustomLoggerProvider(
            IOptionsMonitor<MyCustomLoggerConfiguration> config)
        {
            _currentConfig = config.CurrentValue;
            _onChangeToken = config.OnChange(updatedConfig => _currentConfig = updatedConfig);
        }

        public ILogger CreateLogger(string categoryName) =>
            _loggers.GetOrAdd(categoryName, name => new MyCustomLogger(name, GetCurrentConfig));

        private MyCustomLoggerConfiguration GetCurrentConfig() => _currentConfig;

        public void Dispose()
        {
            _loggers.Clear();
            _onChangeToken.Dispose();
        }
    }

In the CreateLogger method, an instance of MyCustomLogger is created per cateogyr name and stored in the ConcurrentDictionary<string, MyCustomLogger>. The IOptionsMonitor<MyCustomLoggerConfiguration> is required to update any changes made to the configuration.

By adding [ProviderAlias(“CustomLogger”)] we can define configuration sections in appsettings using the CustomLogger key, like in the following example:

"Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    },
    "CustomLogger": {
      "LogLevels": {
        "Information": "C:\\MyCustomLogs\\InformationLogs",
        "Warning": "C:\\MyCustomLogs\\WarningLogs",
        "Error": "C:\\MyCustomLogs\\ErrorLogs"
      }
    }
  }

Notice how the original Logging section has been extended to include a CustomLogger section (as that is the alias we have used). You will now see how this configuration is used in the IsEnabled method of our custom logger and how it will only log if that log level is included here.

Finally is to register the custom logger for use. First we’ll create an extension class that has two methods in order to expose the custom logger.

public static class MyCustomLoggerExtensions
    {
        public static ILoggingBuilder AddMyCustomLogger(
            this ILoggingBuilder builder)
        {
            builder.AddConfiguration();

            builder.Services.TryAddEnumerable(
                ServiceDescriptor.Singleton<ILoggerProvider, MyCustomLoggerProvider>());

            LoggerProviderOptions.RegisterProviderOptions
                <MyCustomLoggerConfiguration, MyCustomLoggerProvider>(builder.Services);

            return builder;
        }

        public static ILoggingBuilder AddMyCustomLogger(
            this ILoggingBuilder builder,
            Action<MyCustomLoggerConfiguration> configure)
        {
            builder.AddMyCustomLogger();
            builder.Services.Configure(configure);

            return builder;
        }
    }

In program.cs, modify the CreateHostBuilder method to expose our custom logger as shown below:

public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                })
                .ConfigureLogging((hostBuilderContext, logging) =>
                {
                    logging.AddMyCustomLogger();
                });

Our extension class contained two methods. Above, we have used the method where no configuration values are passed. Using the other method however, we can override any appsettings configuration that may be present:

public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                })
                .ConfigureLogging((hostBuilderContext, logging) =>
                {
                    logging.AddMyCustomLogger(configuration =>
                    {
                        configuration.LogLevels[LogLevel.Warning] = "C:\\Somewhere_else";
                    });
                });

And that concludes the tutorial on how to create a custom logging provider in C# and Blazor. As said earlier, it is not necessary to create an instance of the logger per category name and that example is present just to demonstrate the capability of different outputs per category. Instead, a new instance of the logger could simply be returned as opposed to adding to the dictionary. Also, any suggestions or improvements on this code are very welcome.

Scroll to Top