using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using EonaCat.Logger.EonaCatCoreLogger.Internal; using EonaCat.Logger.Managers; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace EonaCat.Logger.EonaCatCoreLogger; [ProviderAlias("EonaCatFileLogger")] public sealed class FileLoggerProvider : BatchingLoggerProvider { private readonly string _path; private readonly string _fileNamePrefix; private readonly int _maxFileSize; private readonly int _maxRetainedFiles; private readonly int _maxRolloverFiles; private string _logFile; private long _currentFileSize; private readonly ConcurrentDictionary _buffers = new(); private readonly ConcurrentDictionary _fileSizes = new(); private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly SemaphoreSlim _rolloverLock = new(1, 1); private readonly LoggerScopedContext _context = new(); public string LogFile => _logFile ?? string.Empty; public event EventHandler OnError; public FileLoggerProvider(IOptions options) : base(options) { var o = options.Value; _path = o.LogDirectory; _fileNamePrefix = o.FileNamePrefix; _maxFileSize = o.FileSizeLimit; _maxRetainedFiles = o.RetainedFileCountLimit; _maxRolloverFiles = o.MaxRolloverFiles; IncludeCorrelationId = o.IncludeCorrelationId; Directory.CreateDirectory(_path); InitializeCurrentFile(); } public bool IncludeCorrelationId { get; } internal override async Task WriteMessagesAsync( IReadOnlyList messages, CancellationToken token) { if (messages.Count == 0) { return; } Directory.CreateDirectory(_path); // Group messages by date foreach (var group in messages.GroupBy(m => (m.Timestamp.Year, m.Timestamp.Month, m.Timestamp.Day))) { var file = GetFullName(group.Key); InitializeFile(file); var stringBuilder = _buffers.GetOrAdd(file, _ => new StringBuilder(4096)); lock (stringBuilder) { foreach (var message in group) { AppendMessage(stringBuilder, message); } } await FlushAsync(file, stringBuilder, token).ConfigureAwait(false); DeleteOldLogFiles(); } } private void AppendMessage(StringBuilder sb, LogMessage msg) { // Ensure correlation id exists (once per async context) if (IncludeCorrelationId) { var cid = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); _context.Set("CorrelationId", cid); } sb.Append(msg.Message); var ctx = _context.GetAll(); if (ctx.Count > 0) { sb.Append(" ["); bool first = true; foreach (var kv in ctx) { if (!first) { sb.Append(' '); } sb.Append(kv.Key).Append('=').Append(kv.Value); first = false; } sb.Append(']'); } sb.AppendLine(); } public void InitializeCurrentFile() { if (!string.IsNullOrEmpty(_logFile)) { return; } _logFile = GetFullName((DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day)); _currentFileSize = File.Exists(_logFile) ? new FileInfo(_logFile).Length : 0; } private async Task FlushAsync(string file, StringBuilder stringBuilder, CancellationToken token) { if (stringBuilder.Length == 0) { return; } await _writeLock.WaitAsync(token).ConfigureAwait(false); try { var text = stringBuilder.ToString(); byte[] bytesToWrite = Encoding.UTF8.GetBytes(text); using (var fileStream = new FileStream( file, FileMode.Append, FileAccess.Write, FileShare.Read, 64 * 1024, useAsync: true)) { await fileStream.WriteAsync(bytesToWrite, 0, bytesToWrite.Length, token); } // Update current file size correctly _fileSizes.AddOrUpdate(file, bytesToWrite.Length, (_, old) => old + bytesToWrite.Length); _currentFileSize = _fileSizes[file]; stringBuilder.Clear(); if (_fileSizes[file] >= _maxFileSize) { await RollOverAsync(file).ConfigureAwait(false); } } finally { _writeLock.Release(); } } private async Task RollOverAsync(string file) { await _rolloverLock.WaitAsync().ConfigureAwait(false); try { var dir = Path.GetDirectoryName(file); var name = Path.GetFileNameWithoutExtension(file); var ext = Path.GetExtension(file); for (int i = _maxRolloverFiles - 1; i >= 1; i--) { var src = Path.Combine(dir, $"{name}.{i}{ext}"); var dst = Path.Combine(dir, $"{name}.{i + 1}{ext}"); if (File.Exists(dst)) { File.Delete(dst); } if (File.Exists(src)) { File.Move(src, dst); } } var firstRoll = Path.Combine(dir, $"{name}.1{ext}"); if (File.Exists(file)) { File.Move(file, firstRoll); } _fileSizes[file] = 0; } finally { _rolloverLock.Release(); } } private void InitializeFile(string file) { _fileSizes.TryAdd(file, File.Exists(file) ? new FileInfo(file).Length : 0); _buffers.TryAdd(file, new StringBuilder(4096)); } private (int Year, int Month, int Day) GetGrouping(LogMessage m) => (m.Timestamp.Year, m.Timestamp.Month, m.Timestamp.Day); private string GetFullName((int Year, int Month, int Day) g) => string.IsNullOrWhiteSpace(_fileNamePrefix) ? Path.Combine(_path, $"{g.Year:0000}{g.Month:00}{g.Day:00}.log") : Path.Combine(_path, $"{_fileNamePrefix}_{g.Year:0000}{g.Month:00}{g.Day:00}.log"); private void DeleteOldLogFiles() { if (_maxRetainedFiles <= 0) { return; } var files = new DirectoryInfo(_path) .GetFiles($"{_fileNamePrefix}*") .OrderByDescending(f => { // Parse date from filename instead of CreationTimeUtc var name = Path.GetFileNameWithoutExtension(f.Name); var parts = name.Split('_'); var datePart = parts.Length > 1 ? parts[1] : parts[0]; if (DateTime.TryParseExact(datePart, "yyyyMMdd", null, System.Globalization.DateTimeStyles.None, out var dt)) { return dt; } return DateTime.MinValue; }) .Skip(_maxRetainedFiles); foreach (var f in files) { try { f.Delete(); } catch { } } } }