This commit is contained in:
2026-02-12 20:54:24 +01:00
parent 126b9aa203
commit 80d79af9ce
6 changed files with 518 additions and 189 deletions

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using EonaCat.Logger.EonaCatCoreLogger.Internal; using EonaCat.Logger.EonaCatCoreLogger.Internal;
using EonaCat.Logger.Managers; using EonaCat.Logger.Managers;
using Microsoft.Extensions.Logging;
namespace EonaCat.Logger.EonaCatCoreLogger; namespace EonaCat.Logger.EonaCatCoreLogger;
// This file is part of the EonaCat project(s) which is released under the Apache License. // This file is part of the EonaCat project(s) which is released under the Apache License.
@@ -17,7 +18,12 @@ public class FileLoggerOptions : BatchingLoggerOptions
private int _maxRolloverFiles = 10; private int _maxRolloverFiles = 10;
private int _retainedFileCountLimit = 50; private int _retainedFileCountLimit = 50;
public bool EnableCategoryRouting { get; set; } public bool EnableCategoryRouting { get; set; }
public ELogType MinimumLogLevel { get; set; } = ELogType.INFO;
public string Category { get; set; } public string Category { get; set; }
public byte[] EncryptionKey { get; set; }
public byte[] EncryptionIV { get; set; }
public bool IsEncryptionEnabled => EncryptionKey != null && EncryptionIV != null;
public static string DefaultPath => public static string DefaultPath =>
AppDomain.CurrentDomain.RelativeSearchPath ?? AppDomain.CurrentDomain.BaseDirectory; AppDomain.CurrentDomain.RelativeSearchPath ?? AppDomain.CurrentDomain.BaseDirectory;

View File

@@ -1,4 +1,5 @@
using EonaCat.Logger.EonaCatCoreLogger; using EonaCat.Logger;
using EonaCat.Logger.EonaCatCoreLogger;
using EonaCat.Logger.EonaCatCoreLogger.Internal; using EonaCat.Logger.EonaCatCoreLogger.Internal;
using EonaCat.Logger.Managers; using EonaCat.Logger.Managers;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -21,69 +22,79 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
private readonly int _maxFileSize; private readonly int _maxFileSize;
private readonly int _maxRetainedFiles; private readonly int _maxRetainedFiles;
private readonly int _maxRolloverFiles; private readonly int _maxRolloverFiles;
private bool _disposed; private readonly List<Task> _compressionTasks = new();
private readonly LoggerScopedContext _context = new LoggerScopedContext(); private readonly byte[] _encryptionKey;
private readonly byte[] _encryptionIV;
public bool IsEncryptionEnabled => _encryptionKey != null && _encryptionIV != null;
private bool _disposed;
private int _isFlushing;
public static TimeSpan FaultCooldown = TimeSpan.FromSeconds(60);
private readonly LoggerScopedContext _context = new();
private readonly ConcurrentDictionary<string, FileState> _files = new(); private readonly ConcurrentDictionary<string, FileState> _files = new();
private readonly ConcurrentDictionary<string, ConcurrentQueue<LogMessage>> _messageQueues = new(); private readonly ConcurrentDictionary<string, ConcurrentQueue<LogMessage>> _messageQueues = new();
private const int BufferSize = 1024 * 1024; // 1 MB buffer for large JSON logs private const int BufferSize = 1024 * 1024;
private static readonly Encoding Utf8 = new UTF8Encoding(false); private static readonly Encoding Utf8 = new UTF8Encoding(false);
public bool IncludeCorrelationId { get; } public bool IncludeCorrelationId { get; }
public bool EnableCategoryRouting { get; } public bool EnableCategoryRouting { get; }
public string LogFile => _files.TryGetValue(string.Empty, out var state) ? state.FilePath : null; public string LogFile => _files.TryGetValue(string.Empty, out var s) ? s.FilePath : null;
public ELogType MinimumLogLevel { get; set; }
public event EventHandler<ErrorMessage> OnError; public event EventHandler<ErrorMessage> OnError;
public event EventHandler<string> OnRollOver; public event EventHandler<string> OnRollOver;
private readonly Timer _flushTimer; private readonly Timer _flushTimer;
private readonly TimeSpan _flushInterval = TimeSpan.FromMilliseconds(500); private readonly TimeSpan _flushInterval = TimeSpan.FromMilliseconds(500);
private readonly string _fallbackPath;
private sealed class FileState : IDisposable private sealed class FileState : IDisposable
{ {
public string FilePath; public string FilePath;
public long Size; public long Size;
public DateTime Date; public DateTime Date;
public byte[] Buffer = ArrayPool<byte>.Shared.Rent(BufferSize); public byte[] Buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
public int BufferPosition; public int BufferPosition;
public FileStream Stream; public FileStream Stream;
public SemaphoreSlim WriteLock = new(1, 1); public SemaphoreSlim WriteLock = new(1, 1);
public bool IsFaulted;
public DateTime LastFailureUtc;
public void Dispose() public void Dispose()
{ {
try try
{ {
if (Buffer != null) if (Buffer != null)
{ {
ArrayPool<byte>.Shared.Return(Buffer); Array.Clear(Buffer, 0, BufferPosition);
ArrayPool<byte>.Shared.Return(Buffer, clearArray: true);
Buffer = null; Buffer = null;
} }
Stream?.Dispose(); Stream?.Dispose();
}
catch
{
// Do nothing
}
try
{
WriteLock?.Dispose(); WriteLock?.Dispose();
} }
catch catch
{ {
// Do nothing // Do nothing
} }
} }
} }
public FileLoggerProvider(IOptions<FileLoggerOptions> options) : base(options) public FileLoggerProvider(IOptions<FileLoggerOptions> options) : base(options)
{ {
var o = options.Value ?? throw new ArgumentNullException(nameof(options)); var o = options.Value ?? throw new ArgumentNullException(nameof(options));
_path = o.LogDirectory; _path = o.LogDirectory;
_fileNamePrefix = o.FileNamePrefix; _fileNamePrefix = o.FileNamePrefix;
_maxFileSize = o.FileSizeLimit; _maxFileSize = o.FileSizeLimit;
@@ -91,48 +102,75 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
_maxRolloverFiles = o.MaxRolloverFiles; _maxRolloverFiles = o.MaxRolloverFiles;
IncludeCorrelationId = o.IncludeCorrelationId; IncludeCorrelationId = o.IncludeCorrelationId;
EnableCategoryRouting = o.EnableCategoryRouting; EnableCategoryRouting = o.EnableCategoryRouting;
MinimumLogLevel = o.MinimumLogLevel;
_encryptionKey = o.EncryptionKey;
_encryptionIV = o.EncryptionIV;
Directory.CreateDirectory(_path); _path = EnsureWritableDirectory(o.LogDirectory);
_fallbackPath = EnsureWritableDirectory(Path.Combine(Path.GetTempPath(), "EonaCatFallbackLogs"));
var defaultState = CreateFileState(DateTime.UtcNow.Date, o.Category); var defaultState = CreateFileState(DateTime.UtcNow.Date, o.Category);
_files[string.Empty] = defaultState; _files[string.Empty] = defaultState;
// Periodic flush
_flushTimer = new Timer(FlushTimerCallback, null, _flushInterval, _flushInterval); _flushTimer = new Timer(FlushTimerCallback, null, _flushInterval, _flushInterval);
} }
private void FlushTimerCallback(object state) private static string EnsureWritableDirectory(string path)
{ {
string fallback = Path.Combine(Path.GetTempPath(), "EonaCatFallbackLogs");
foreach (var dir in new[] { path, fallback })
{
try
{
Directory.CreateDirectory(dir);
// Test write permission
string testFile = Path.Combine(dir, $"write_test_{Guid.NewGuid()}.tmp");
File.WriteAllText(testFile, "test");
File.Delete(testFile);
return dir;
}
catch
{
// Do nothing
}
}
try try
{ {
PeriodicFlushAsync().GetAwaiter().GetResult(); Directory.CreateDirectory(fallback);
} }
catch catch
{ {
// swallow - avoid timer thread crash // Do nothing
} }
return fallback;
} }
private void CleanupUnusedCategories()
private void FlushTimerCallback(object state)
{ {
var now = DateTime.UtcNow; if (_disposed)
foreach (var kv in _files.ToArray())
{ {
var state = kv.Value; return;
}
// Remove file states older than 2 days and empty queues if (Interlocked.Exchange(ref _isFlushing, 1) == 1)
if ((now - state.Date).TotalDays > 2 && {
_messageQueues.TryGetValue(kv.Key, out var queue) && return;
queue.IsEmpty) }
{
if (_files.TryRemove(kv.Key, out var removed))
{
removed.Dispose();
}
_messageQueues.TryRemove(kv.Key, out _); try
} {
PeriodicFlushAsync().ConfigureAwait(false);
}
catch { }
finally
{
Interlocked.Exchange(ref _isFlushing, 0);
} }
} }
@@ -140,13 +178,15 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
{ {
try try
{ {
var filtered = messages.Where(m => m.Level >= MinimumLogLevel).ToList();
if (EnableCategoryRouting) if (EnableCategoryRouting)
{ {
var grouped = messages.GroupBy(m => SanitizeCategory(m.Category)); // Group messages by sanitized category
var grouped = filtered.GroupBy(m => SanitizeCategory(m.Category));
foreach (var group in grouped) foreach (var group in grouped)
{ {
var key = group.Key; var queue = _messageQueues.GetOrAdd(group.Key, _ => new ConcurrentQueue<LogMessage>());
var queue = _messageQueues.GetOrAdd(key, _ => new ConcurrentQueue<LogMessage>());
foreach (var msg in group) foreach (var msg in group)
{ {
queue.Enqueue(msg); queue.Enqueue(msg);
@@ -156,7 +196,7 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
else else
{ {
var queue = _messageQueues.GetOrAdd(string.Empty, _ => new ConcurrentQueue<LogMessage>()); var queue = _messageQueues.GetOrAdd(string.Empty, _ => new ConcurrentQueue<LogMessage>());
foreach (var msg in messages) foreach (var msg in filtered)
{ {
queue.Enqueue(msg); queue.Enqueue(msg);
} }
@@ -164,7 +204,11 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
OnError?.Invoke(this, new ErrorMessage { Exception = ex, Message = ex.Message }); OnError?.Invoke(this, new ErrorMessage
{
Exception = ex,
Message = $"Failed to enqueue messages: {ex.Message}"
});
} }
return Task.CompletedTask; return Task.CompletedTask;
@@ -172,6 +216,11 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
private async Task PeriodicFlushAsync() private async Task PeriodicFlushAsync()
{ {
if (_disposed)
{
return;
}
foreach (var kv in _messageQueues) foreach (var kv in _messageQueues)
{ {
var key = kv.Key; var key = kv.Key;
@@ -183,170 +232,202 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
_files[key] = state; _files[key] = state;
} }
if (!queue.IsEmpty) if (!TryRecover(state))
{ {
await state.WriteLock.WaitAsync(); // drop to prevent memory leak
try while (queue.TryDequeue(out _)) { }
continue;
}
if (queue.IsEmpty)
{
continue;
}
state.WriteLock.Wait();
try
{
var batch = new List<LogMessage>(256);
while (queue.TryDequeue(out var msg))
{ {
while (queue.TryDequeue(out var msg)) batch.Add(msg);
if (batch.Count >= 256)
{ {
WriteBatch(state, new[] { msg }, key); break;
} }
} }
finally
if (batch.Count > 0)
{ {
state.WriteLock.Release(); await WriteBatchAsync(state, batch, key).ConfigureAwait(false);
} }
} }
finally
{
state.WriteLock.Release();
}
} }
DeleteOldLogFiles(); CompressOldLogFiles();
CleanupUnusedCategories(); CompressOldFilesByAge(7);
} }
private void WriteBatch(FileState state, IEnumerable<LogMessage> messages, string categoryKey) private void CompressOldLogFiles()
{ {
if (!File.Exists(state.FilePath)) if (_maxRetainedFiles <= 0)
{ {
RecreateFile(state, categoryKey); return;
} }
var files = new DirectoryInfo(_path).GetFiles($"{_fileNamePrefix}*").OrderByDescending(f => f.LastWriteTimeUtc).Skip(_maxRetainedFiles);
foreach (var currentFile in files)
{
try
{
Task.Run(() => CompressOldLogFile(currentFile.FullName));
}
catch
{
// Do nothing
}
}
}
private void CompressOldFilesByAge(int daysThreshold)
{
var cutoff = DateTime.UtcNow.AddDays(-daysThreshold);
var files = new DirectoryInfo(_path)
.GetFiles($"{_fileNamePrefix}*")
.Where(f => f.LastWriteTimeUtc < cutoff && !f.Name.EndsWith(".gz"));
foreach (var file in files)
{
var task = Task.Run(() => CompressOldLogFile(file.FullName));
_compressionTasks.Add(task);
}
}
private async Task WriteBatchAsync(FileState state, List<LogMessage> messages, string categoryKey)
{
foreach (var msg in messages) foreach (var msg in messages)
{ {
var date = msg.Timestamp.UtcDateTime.Date; var date = msg.Timestamp.UtcDateTime.Date;
if (state.Date != date) if (state.Date != date)
{ {
FlushBufferAsync(state).GetAwaiter().GetResult(); await FlushBufferAsync(state).ConfigureAwait(false);
RotateByDate(state, date, categoryKey); RotateByDate(state, date, categoryKey);
} }
WriteMessageToBuffer(state, msg); await WriteMessageToBufferAsync(state, msg).ConfigureAwait(false);
if (state.BufferPosition >= BufferSize - 1024 || state.Size >= _maxFileSize) if (state.BufferPosition >= BufferSize - 1024 || state.Size >= _maxFileSize)
{ {
FlushBufferAsync(state).GetAwaiter().GetResult(); await FlushBufferAsync(state).ConfigureAwait(false);
if (state.Size >= _maxFileSize) if (state.Size >= _maxFileSize)
{ {
RollOver(state, categoryKey); RollOverAndCompressOldest(state, categoryKey);
} }
} }
} }
FlushBufferAsync(state).GetAwaiter().GetResult(); await FlushBufferAsync(state).ConfigureAwait(false);
} }
private FileState CreateFileState(DateTime date, string category) private async Task WriteMessageToBufferAsync(FileState state, LogMessage msg)
{ {
var path = GetFullName(date, category); try
return new FileState
{
FilePath = path,
Date = date,
Size = GetFileSize(path),
Stream = OpenFileWithRetryAsync(path).GetAwaiter().GetResult()
};
}
private static async Task<FileStream> OpenFileWithRetryAsync(string path)
{
const int retries = 3;
for (int i = 0; i < retries; i++)
{ {
string text;
try try
{ {
return new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan); text = BuildMessage(msg);
} }
catch catch (Exception ex)
{ {
await Task.Delay(5); OnError?.Invoke(this, new ErrorMessage
{
Exception = ex,
Message = $"Failed to build log message: {msg.Message}"
});
return;
} }
}
throw new IOException("Unable to open log file."); var data = Utf8.GetBytes(text);
}
private void RecreateFile(FileState state, string category) if (IsEncryptionEnabled)
{
FlushBufferAsync(state).GetAwaiter().GetResult();
state.Stream?.Dispose();
state.FilePath = GetFullName(DateTime.UtcNow.Date, category);
state.Size = 0;
state.BufferPosition = 0;
state.Stream = OpenFileWithRetryAsync(state.FilePath).GetAwaiter().GetResult();
}
private void RotateByDate(FileState state, DateTime newDate, string category)
{
state.Stream?.Dispose();
state.Date = newDate;
state.FilePath = GetFullName(newDate, category);
state.Size = GetFileSize(state.FilePath);
state.BufferPosition = 0;
state.Stream = OpenFileWithRetryAsync(state.FilePath).GetAwaiter().GetResult();
}
private void RollOver(FileState state, string category)
{
FlushBufferAsync(state).GetAwaiter().GetResult();
state.Stream?.Dispose();
var dir = Path.GetDirectoryName(state.FilePath);
var name = Path.GetFileNameWithoutExtension(state.FilePath);
var ext = Path.GetExtension(state.FilePath);
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); data = Encrypt(data);
} }
if (File.Exists(src)) // Flush buffer if not enough space
if (state.BufferPosition + data.Length > BufferSize)
{ {
File.Move(src, dst); await FlushBufferAsync(state).ConfigureAwait(false);
} }
}
var first = Path.Combine(dir, $"{name}.1{ext}"); // Copy to buffer safely
if (File.Exists(state.FilePath)) if (data.Length <= BufferSize)
{
Array.Copy(data, 0, state.Buffer, state.BufferPosition, data.Length);
state.BufferPosition += data.Length;
state.Size += data.Length;
}
// Clear temporary data
Array.Clear(data, 0, data.Length);
}
catch (Exception ex)
{ {
File.Move(state.FilePath, first); HandleWriteFailure(state, ex);
} }
OnRollOver?.Invoke(this, state.FilePath);
state.Size = 0;
state.BufferPosition = 0;
state.Stream = OpenFileWithRetryAsync(state.FilePath).GetAwaiter().GetResult();
} }
private void WriteMessageToBuffer(FileState state, LogMessage msg) private byte[] Encrypt(byte[] plainBytes)
{ {
var text = BuildMessage(msg); if (plainBytes == null || plainBytes.Length == 0) return plainBytes;
var byteCount = Utf8.GetByteCount(text);
if (state.BufferPosition + byteCount > BufferSize) using var aes = System.Security.Cryptography.Aes.Create();
{ aes.Key = _encryptionKey;
FlushBufferAsync(state).GetAwaiter().GetResult(); aes.IV = _encryptionIV;
}
var written = Utf8.GetBytes(text, 0, text.Length, state.Buffer, state.BufferPosition); using var encryptor = aes.CreateEncryptor();
state.BufferPosition += written; var encrypted = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
state.Size += written;
// Clear plaintext bytes
Array.Clear(plainBytes, 0, plainBytes.Length);
return encrypted;
}
public byte[] Decrypt(byte[] encryptedData)
{
if (!IsEncryptionEnabled || encryptedData == null || encryptedData.Length == 0)
return encryptedData;
using var aes = System.Security.Cryptography.Aes.Create();
aes.Key = _encryptionKey;
aes.IV = _encryptionIV;
using var decryptor = aes.CreateDecryptor();
using var ms = new MemoryStream(encryptedData);
using var cryptoStream = new System.Security.Cryptography.CryptoStream(ms, decryptor, System.Security.Cryptography.CryptoStreamMode.Read);
using var resultStream = new MemoryStream();
cryptoStream.CopyTo(resultStream);
var result = resultStream.ToArray();
// Clear sensitive memory
Array.Clear(encryptedData, 0, encryptedData.Length);
return result;
} }
private string BuildMessage(LogMessage msg) private string BuildMessage(LogMessage msg)
{ {
var settings = msg.Settings ?? LoggerSettings;
if (!IncludeCorrelationId) if (!IncludeCorrelationId)
{ {
return msg.Message + Environment.NewLine; return msg.Message + Environment.NewLine;
@@ -358,55 +439,275 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
return msg.Message + Environment.NewLine; return msg.Message + Environment.NewLine;
} }
var sb = new StringBuilder(256); var sb = new StringBuilder(msg.Message.Length + 64);
sb.Append(msg.Message).Append(" ["); sb.Append(msg.Message).Append(" [");
bool first = true; foreach (var (key, value) in ctx.Select(kv => (kv.Key, kv.Value)))
foreach (var kv in ctx)
{ {
if (!first) sb.Append(key).Append('=').Append(value).Append(' ');
{ }
sb.Append(' ');
}
sb.Append(kv.Key).Append('=').Append(kv.Value); if (msg.Tags != null)
first = false; {
foreach (var tag in msg.Tags)
{
sb.Append("tag=").Append(tag).Append(' ');
}
}
if (sb[sb.Length - 1] == ' ')
{
sb.Length--; // remove trailing space
} }
sb.Append(']').AppendLine(); sb.Append(']').AppendLine();
return sb.ToString(); return sb.ToString();
} }
private async Task FlushBufferAsync(FileState state)
{
if (state.BufferPosition == 0 || state.Stream == null)
{
return;
}
await state.Stream.WriteAsync(state.Buffer, 0, state.BufferPosition); private async Task FlushBufferAsync(FileState state, CancellationToken token = default)
await state.Stream.FlushAsync(); {
state.BufferPosition = 0; if (state.IsFaulted || state.BufferPosition == 0 || state.Stream == null)
return;
try
{
await state.Stream.WriteAsync(state.Buffer, 0, state.BufferPosition, token).ConfigureAwait(false);
await state.Stream.FlushAsync(token).ConfigureAwait(false);
}
catch (Exception ex)
{
HandleWriteFailure(state, ex);
}
finally
{
// Clear buffer to prevent leaking sensitive info
Array.Clear(state.Buffer, 0, state.BufferPosition);
state.BufferPosition = 0;
}
} }
private static long GetFileSize(string path) => File.Exists(path) ? new FileInfo(path).Length : 0;
private void DeleteOldLogFiles()
private void HandleWriteFailure(FileState state, Exception ex)
{ {
if (_maxRetainedFiles <= 0) state.IsFaulted = true;
state.LastFailureUtc = DateTime.UtcNow;
// Dispose current stream
state.Stream?.Dispose();
state.Stream = null;
// Determine a fallback path
string originalDir = Path.GetDirectoryName(state.FilePath);
string fallbackDir = EnsureWritableDirectory(originalDir);
string fileName = Path.GetFileName(state.FilePath);
string fallbackFile = Path.Combine(fallbackDir, fileName);
try
{ {
// Try to reopen the stream in the fallback directory
state.FilePath = fallbackFile;
state.Stream = new FileStream(
fallbackFile,
FileMode.Append,
FileAccess.Write,
FileShare.ReadWrite | FileShare.Delete
);
state.Size = GetFileSize(fallbackFile);
state.IsFaulted = false;
OnError?.Invoke(this, new ErrorMessage
{
Exception = ex,
Message = $"Logging failed for original path. Switching to fallback path: {fallbackFile}"
});
}
catch (Exception fallbackEx)
{
OnError?.Invoke(this, new ErrorMessage
{
Exception = fallbackEx,
Message = $"Failed to recover logging using fallback path: {fallbackFile}"
});
}
}
private bool TryRecover(FileState state)
{
if (!state.IsFaulted)
{
return true;
}
if (DateTime.UtcNow - state.LastFailureUtc < FaultCooldown)
{
return false;
}
try
{
state.Stream = new FileStream(state.FilePath, FileMode.Append,
FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
state.Size = GetFileSize(state.FilePath);
state.IsFaulted = false;
return true;
}
catch
{
state.LastFailureUtc = DateTime.UtcNow;
return false;
}
}
private FileState CreateFileState(DateTime date, string category)
{
// Get the intended log file path
var intendedPath = GetFullName(date, category);
// Ensure directory is writable (falls back automatically if needed)
var writableDir = EnsureWritableDirectory(Path.GetDirectoryName(intendedPath));
var path = Path.Combine(writableDir, Path.GetFileName(intendedPath));
try
{
return new FileState
{
FilePath = path,
Date = date,
Size = GetFileSize(path),
Stream = new FileStream(
path,
FileMode.Append,
FileAccess.Write,
FileShare.ReadWrite | FileShare.Delete
)
};
}
catch (Exception ex)
{
OnError?.Invoke(this, new ErrorMessage
{
Exception = ex,
Message = $"Failed to create log file: {path}"
});
return new FileState
{
FilePath = path,
Date = date,
IsFaulted = true
};
}
}
private void RotateByDate(FileState state, DateTime newDate, string category)
{
state.Stream?.Dispose();
state.Date = newDate;
state.FilePath = GetFullName(newDate, category);
state.Size = GetFileSize(state.FilePath);
state.Stream = new FileStream(state.FilePath, FileMode.Append,
FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
}
private void RollOverAndCompressOldest(FileState state, string category)
{
state.Stream?.Dispose();
var dir = Path.GetDirectoryName(state.FilePath);
var name = Path.GetFileNameWithoutExtension(state.FilePath);
var ext = Path.GetExtension(state.FilePath);
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 rolledFile = Path.Combine(dir, $"{name}.1{ext}");
if (File.Exists(state.FilePath))
{
File.Move(state.FilePath, rolledFile);
}
OnRollOver?.Invoke(this, rolledFile);
state.Size = 0;
state.Stream = new FileStream(state.FilePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
// Compress the oldest rolled file safely
var oldestFile = Path.Combine(dir, $"{name}.{_maxRolloverFiles}{ext}");
if (File.Exists(oldestFile))
{
Task.Run(() => CompressOldLogFile(oldestFile));
}
}
private static long GetFileSize(string path)
=> File.Exists(path) ? new FileInfo(path).Length : 0;
private void CompressOldLogFile(string filePath, int retryCount = 3)
{
if (filePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase))
return; return;
}
var files = new DirectoryInfo(_path) Task.Run(async () =>
.GetFiles($"{_fileNamePrefix}*")
.OrderByDescending(f => f.LastWriteTimeUtc)
.Skip(_maxRetainedFiles);
foreach (var f in files)
{ {
try { f.Delete(); } catch { } for (int attemptRetry = 1; attemptRetry <= retryCount; attemptRetry++)
} {
try
{
string compressedFile;
int suffix = 0;
do
{
string suffixText = suffix == 0 ? "" : $"_{suffix}";
compressedFile = filePath + suffixText + ".gz";
suffix++;
} while (File.Exists(compressedFile));
using var originalFileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var compressedFileStream = new FileStream(compressedFile, FileMode.CreateNew, FileAccess.Write);
using (var compressionStream = new System.IO.Compression.GZipStream(compressedFileStream, System.IO.Compression.CompressionLevel.Optimal))
{
await originalFileStream.CopyToAsync(compressionStream).ConfigureAwait(false);
await compressionStream.FlushAsync().ConfigureAwait(false);
}
File.Delete(filePath);
break;
}
catch (IOException)
{
await Task.Delay(100).ConfigureAwait(false);
}
catch (Exception ex)
{
OnError?.Invoke(this, new ErrorMessage
{
Exception = ex,
Message = $"Failed to compress log file: {filePath}"
});
break;
}
}
});
} }
private string GetFullName(DateTime date, string category) private string GetFullName(DateTime date, string category)
@@ -429,18 +730,22 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
{ {
category = category.Replace(c, '_'); category = category.Replace(c, '_');
} }
return category.Replace('.', '_'); return category.Replace('.', '_');
} }
protected override void OnShutdownFlush() protected override async Task OnShutdownFlushAsync()
{ {
_disposed = true;
_flushTimer?.Dispose(); _flushTimer?.Dispose();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try try
{ {
PeriodicFlushAsync().GetAwaiter().GetResult(); await PeriodicFlushAsync().ConfigureAwait(false);
} }
catch catch
{ {
// Do nothing // Do nothing
} }
@@ -449,9 +754,9 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
{ {
try try
{ {
FlushBufferAsync(state).GetAwaiter().GetResult(); await FlushBufferAsync(state, cts.Token).ConfigureAwait(false);
} }
catch catch
{ {
// Do nothing // Do nothing
} }
@@ -462,6 +767,16 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
_files.Clear(); _files.Clear();
_messageQueues.Clear(); _messageQueues.Clear();
base.OnShutdownFlush(); try
{
if (_compressionTasks.Count > 0)
{
await Task.WhenAny(Task.WhenAll(_compressionTasks), Task.Delay(TimeSpan.FromSeconds(5)));
}
}
catch
{
// Do nothing
}
} }
} }

View File

@@ -71,7 +71,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger.Internal
timestamp.DateTime, timestamp.DateTime,
category); category);
var writtenMessage = _provider.AddMessage(timestamp, formatted, category); var writtenMessage = _provider.AddMessage(timestamp, formatted, category, logLevel.FromLogLevel());
effectiveSettings?.RaiseOnLog(new EonaCatLogMessage effectiveSettings?.RaiseOnLog(new EonaCatLogMessage
{ {

View File

@@ -1,4 +1,5 @@
using EonaCat.Logger.EonaCatCoreLogger; using EonaCat.Logger;
using EonaCat.Logger.EonaCatCoreLogger;
using EonaCat.Logger.EonaCatCoreLogger.Internal; using EonaCat.Logger.EonaCatCoreLogger.Internal;
using EonaCat.Logger.Extensions; using EonaCat.Logger.Extensions;
using EonaCat.Logger.Managers; using EonaCat.Logger.Managers;
@@ -67,9 +68,9 @@ public abstract class BatchingLoggerProvider : ILoggerProvider, IDisposable
IReadOnlyList<LogMessage> messages, IReadOnlyList<LogMessage> messages,
CancellationToken token); CancellationToken token);
internal string AddMessage(DateTimeOffset timestamp, string message, string category) internal string AddMessage(DateTimeOffset timestamp, string message, string category, ELogType logLevel, string[] tags = null)
{ {
var log = CreateLogMessage(message, timestamp, category); var log = CreateLogMessage(message, timestamp, category, logLevel, tags);
var size = log.EstimatedSize; var size = log.EstimatedSize;
var newSize = Interlocked.Add(ref _currentQueueBytes, size); var newSize = Interlocked.Add(ref _currentQueueBytes, size);
@@ -88,7 +89,7 @@ public abstract class BatchingLoggerProvider : ILoggerProvider, IDisposable
} }
private LogMessage CreateLogMessage(string message, DateTimeOffset ts, string category, LoggerSettings? settings = null) private LogMessage CreateLogMessage(string message, DateTimeOffset ts, string category, ELogType logLevel, string[] tags = null, LoggerSettings? settings = null)
{ {
var effectiveSettings = settings ?? LoggerSettings; var effectiveSettings = settings ?? LoggerSettings;
@@ -103,7 +104,9 @@ public abstract class BatchingLoggerProvider : ILoggerProvider, IDisposable
Message = message, Message = message,
Timestamp = ts, Timestamp = ts,
Category = category, Category = category,
Settings = effectiveSettings Settings = effectiveSettings,
Level = logLevel,
Tags = tags,
}; };
} }
@@ -171,12 +174,12 @@ public abstract class BatchingLoggerProvider : ILoggerProvider, IDisposable
_cts.Cancel(); _cts.Cancel();
_worker.Join(); _worker.Join();
OnShutdownFlush(); OnShutdownFlushAsync().ConfigureAwait(false);
_cts.Dispose(); _cts.Dispose();
} }
protected virtual void OnShutdownFlush() protected virtual async Task OnShutdownFlushAsync()
{ {
// default: Do nothing // default: Do nothing
} }

View File

@@ -1,4 +1,5 @@
using EonaCat.Logger.Managers; using EonaCat.Logger.Managers;
using Microsoft.Extensions.Logging;
using System; using System;
namespace EonaCat.Logger.EonaCatCoreLogger.Internal; namespace EonaCat.Logger.EonaCatCoreLogger.Internal;
@@ -12,4 +13,6 @@ public struct LogMessage
public string Category { get; set; } public string Category { get; set; }
public int EstimatedSize { get; set; } public int EstimatedSize { get; set; }
public LoggerSettings? Settings { get; set; } public LoggerSettings? Settings { get; set; }
public ELogType Level { get; set; }
public string[] Tags { get; set; }
} }

View File

@@ -177,6 +177,8 @@ public class LoggerSettings
if (_fileLoggerOptions == null) if (_fileLoggerOptions == null)
{ {
_fileLoggerOptions = CreateDefaultFileLoggerOptions(); _fileLoggerOptions = CreateDefaultFileLoggerOptions();
_fileLoggerOptions.LoggerSettings = this;
_fileLoggerOptions.MinimumLogLevel = ELogType.INFO;
} }
return _fileLoggerOptions; return _fileLoggerOptions;