using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.Threading.Channels; namespace LoggerExtensions; public sealed class FileLogger : IDisposable { private readonly Channel channel; private readonly Task consumerTask; private readonly CancellationTokenSource cts = new(); private readonly string logDirectory; private StreamWriter? writer; private string currentFilePath = string.Empty; private DateTime currentDate = DateTime.MinValue; public FileLogger(string logDirectory = "Logs") { this.logDirectory = logDirectory; Directory.CreateDirectory(logDirectory); channel = Channel.CreateBounded( new BoundedChannelOptions(10000) { SingleReader = true, SingleWriter = false, FullMode = BoundedChannelFullMode.Wait }); consumerTask = Task.Run(() => ConsumeAsync(cts.Token)); } internal void WriteMessage(string message) { channel.Writer.TryWrite(message); } private async Task ConsumeAsync(CancellationToken ct) { const int batchSize = 200; const int maxIdleMs = 4000; var batch = new List(batchSize); Task? readTask = null; try { while (!ct.IsCancellationRequested) { readTask ??= channel.Reader.ReadAsync(ct).AsTask(); using var delayCts = CancellationTokenSource.CreateLinkedTokenSource(ct); var delayTask = Task.Delay(maxIdleMs, delayCts.Token); var completed = await Task.WhenAny(readTask, delayTask); if (completed == delayTask) { if (batch.Count > 0) { await WriteBatchAsync(batch, ct); batch.Clear(); } continue; } delayCts.Cancel(); var message = await readTask; readTask = null; batch.Add(message); if (batch.Count >= batchSize) { await WriteBatchAsync(batch, ct); batch.Clear(); } } } catch (OperationCanceledException) { if (batch.Count > 0) { try { await WriteBatchAsync(batch, ct); } catch { } } } } private async Task WriteBatchAsync(List messages, CancellationToken ct) { const int maxRetries = 3; for (int attempt = 0; attempt < maxRetries; attempt++) { try { await WriteBatchInternalAsync(messages, ct); return; } catch (IOException) when (attempt < maxRetries - 1) { await Task.Delay(100 << attempt, ct); } } } private async Task WriteBatchInternalAsync(List messages, CancellationToken ct) { var today = DateTime.Today; if (writer == null || today != currentDate) { await CloseWriterAsync(ct); currentDate = today; currentFilePath = Path.Combine(logDirectory, $"log-{today:yyyyMMdd}.txt"); FileStream stream = new( currentFilePath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite, bufferSize: 65536, useAsync: true); writer = new StreamWriter(stream) { AutoFlush = false }; } foreach (var msg in messages) { await writer.WriteAsync(msg.AsMemory(), ct); } await writer.FlushAsync(ct); } private async Task CloseWriterAsync(CancellationToken ct) { if (writer == null) return; try { await writer.FlushAsync(ct); await writer.DisposeAsync(); } catch { } finally { writer = null; } } public void Dispose() { channel.Writer.Complete(); cts.CancelAfter(TimeSpan.FromSeconds(8)); try { consumerTask.Wait(TimeSpan.FromSeconds(10)); } catch { } CloseWriterAsync(cts.Token).GetAwaiter().GetResult(); cts.Dispose(); } } internal sealed class CategoryLogger(FileLogger innerLogger, string categoryName) : ILogger { public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { if (!IsEnabled(logLevel)) return; var message = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{logLevel,-11}] {categoryName}: {formatter(state, exception)}"; if (exception != null) message += $"{Environment.NewLine}{exception}"; message += Environment.NewLine; innerLogger.WriteMessage(message); } public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; } public sealed class FileLoggerProvider : ILoggerProvider { private readonly FileLogger sharedLogger; public FileLoggerProvider(string logDirectory = "Logs") { sharedLogger = new FileLogger(logDirectory); } public ILogger CreateLogger(string categoryName) { return new CategoryLogger(sharedLogger, categoryName); } public void Dispose() => sharedLogger.Dispose(); } public static class LoggingBuilderExtensions { public static ILoggingBuilder AddFileLogger( this ILoggingBuilder builder, string logDirectory = "Logs") { builder.Services.AddSingleton(sp => new FileLoggerProvider(logDirectory)); return builder; } }