Skip to content

Instantly share code, notes, and snippets.

@duncansmart
Created March 16, 2026 07:57
Show Gist options
  • Select an option

  • Save duncansmart/73edd7657d4938fa28280847010126c4 to your computer and use it in GitHub Desktop.

Select an option

Save duncansmart/73edd7657d4938fa28280847010126c4 to your computer and use it in GitHub Desktop.
Microsoft.Extensions.Logging.ILogger implementation using Channels
using System.Threading.Channels;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging.Configuration;
using Microsoft.Extensions.Options;
/// <summary>
/// Simple file logger.
/// </summary>
/// <remarks>This provider batches log entries using a channel and writes them asynchronously to the specified
/// file. Log entries are flushed in batches to optimize performance, and all pending entries are written before
/// disposal to prevent data loss. Thread safety is ensured for logger creation and log entry queuing. The log
/// file path can be configured via options or environment variables.</remarks>
[ProviderAlias("SimpleFile")]
class SimpleFileLoggerProvider : ILoggerProvider, ISupportExternalScope
{
readonly Channel<SimpleFileLogEntry> _channel = Channel.CreateUnbounded<SimpleFileLogEntry>(new UnboundedChannelOptions { SingleReader = true });
readonly Task _writerTask;
IExternalScopeProvider? _scopeProvider;
public SimpleFileLoggerProvider(IOptionsMonitor<SimpleFileLoggerOptions> optionsMonitor)
{
var filePath = Environment.ExpandEnvironmentVariables(optionsMonitor.CurrentValue.Path ?? $@"%TEMP%\{DateTime.UtcNow:yyyy-MM-dd_HHmmss}_{Environment.ProcessId}.log");
_writerTask = Task.Run(() => WriteLogsAsync(filePath, _channel.Reader));
}
public ILogger CreateLogger(string categoryName) =>
new SimpleFileLogger(categoryName, _channel.Writer) { ScopeProvider = _scopeProvider };
public void SetScopeProvider(IExternalScopeProvider scopeProvider) =>
_scopeProvider = scopeProvider;
public void Dispose()
{
// complete the channel writer (signals EOF to WaitToReadAsync)
_channel.Writer.TryComplete();
// synchronously wait for the writer task to drain, so no log entries are lost on shutdown
_writerTask.GetAwaiter().GetResult();
}
static async Task WriteLogsAsync(string filePath, ChannelReader<SimpleFileLogEntry> channelReader)
{
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory);
using var stream = new FileStream(filePath, FileMode.Append, FileAccess.Write, FileShare.Read);
using var streamWriter = new StreamWriter(stream);
// Exit when the channel is completed and all entries have been read
while (await channelReader.WaitToReadAsync())
{
// Drain all items currently available in one batch, then flush once
while (channelReader.TryRead(out var entry))
{
var logLevelShort = entry.LogLevel switch
{
LogLevel.Trace => "trce",
LogLevel.Debug => "dbug",
LogLevel.Information => "info",
LogLevel.Warning => "warn",
LogLevel.Error => "fail",
LogLevel.Critical => "crit",
_ => entry.LogLevel.ToString().ToLowerInvariant()[..4]
};
//await streamWriter.WriteLineAsync($"{entry.TimestampUtc:yyyy-MM-dd HH:mm:ss.fff} {logLevelShort}: [{entry.CategoryName}] {entry.Message}");
await streamWriter.WriteAsync(entry.TimestampUtc.ToString("yyyy-MM-dd HH:mm:ss.fff"));
await streamWriter.WriteAsync(' ');
await streamWriter.WriteAsync(logLevelShort);
await streamWriter.WriteAsync(": [");
await streamWriter.WriteAsync(entry.CategoryName);
await streamWriter.WriteAsync("] ");
await streamWriter.WriteLineAsync(entry.Message);
if (entry.Exception is not null)
await streamWriter.WriteLineAsync(entry.Exception.ToString());
}
await streamWriter.FlushAsync();
}
await streamWriter.FlushAsync();
}
}
class SimpleFileLogger : ILogger
{
readonly string _categoryName;
readonly ChannelWriter<SimpleFileLogEntry> _channelWriter;
internal IExternalScopeProvider? ScopeProvider { get; set; }
public SimpleFileLogger(string categoryName, ChannelWriter<SimpleFileLogEntry> channelWriter)
{
_categoryName = categoryName;
_channelWriter = channelWriter;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull =>
ScopeProvider?.Push(state);
public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
return;
var message = formatter(state, exception);
_channelWriter.TryWrite(new SimpleFileLogEntry(DateTime.UtcNow, logLevel, _categoryName, message, exception));
}
}
readonly record struct SimpleFileLogEntry(
DateTime TimestampUtc,
LogLevel LogLevel,
string CategoryName,
string Message,
Exception? Exception
);
public sealed class SimpleFileLoggerOptions
{
public string? Path { get; set; }
}
static class SimpleFileLoggerExtensions
{
public static ILoggingBuilder AddSimpleFile(this ILoggingBuilder builder, string? filePath = null)
{
if (!string.IsNullOrWhiteSpace(filePath))
{
builder.Services.Configure<SimpleFileLoggerOptions>(options =>
{
options.Path = filePath;
});
}
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<ILoggerProvider, SimpleFileLoggerProvider>());
LoggerProviderOptions.RegisterProviderOptions<SimpleFileLoggerOptions, SimpleFileLoggerProvider>(builder.Services);
return builder;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment