This commit is contained in:
Jeroen Saey
2026-02-16 14:04:13 +01:00
parent d9ef0f1723
commit 6145f3a6cd
2 changed files with 172 additions and 417 deletions

View File

@@ -13,8 +13,8 @@
<Copyright>EonaCat (Jeroen Saey)</Copyright> <Copyright>EonaCat (Jeroen Saey)</Copyright>
<PackageTags>EonaCat;Logger;EonaCatLogger;Log;Writer;Jeroen;Saey</PackageTags> <PackageTags>EonaCat;Logger;EonaCatLogger;Log;Writer;Jeroen;Saey</PackageTags>
<PackageIconUrl /> <PackageIconUrl />
<Version>1.7.8</Version> <Version>1.7.9</Version>
<FileVersion>1.7.8</FileVersion> <FileVersion>1.7.9</FileVersion>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<GenerateDocumentationFile>True</GenerateDocumentationFile> <GenerateDocumentationFile>True</GenerateDocumentationFile>
<PackageLicenseFile>LICENSE</PackageLicenseFile> <PackageLicenseFile>LICENSE</PackageLicenseFile>

View File

@@ -6,10 +6,8 @@ using Microsoft.Extensions.Options;
using System; using System;
using System.Buffers; using System.Buffers;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Runtime.CompilerServices;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
@@ -22,58 +20,53 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
private const int BufferSize = 64 * 1024; private const int BufferSize = 64 * 1024;
private const int ChannelCapacity = 8192; private const int ChannelCapacity = 8192;
private const int FlushThreshold = 48 * 1024; private const int FlushThreshold = 48 * 1024;
private static readonly UTF8Encoding Utf8 = new(false); private static readonly UTF8Encoding Utf8 = new(false);
private readonly Channel<LogMessage> _channel; private readonly Channel<LogMessage> _channel;
private readonly Thread _writerThread; private readonly Task _writerTask;
private readonly SemaphoreSlim _flushSemaphore = new(1, 1);
private static readonly TimeSpan FlushInterval = TimeSpan.FromMilliseconds(500);
private long _lastFlushTicks = Stopwatch.GetTimestamp();
private string _filePath; private string _filePath;
private readonly int _maxFileSize; private readonly int _maxFileSize;
private readonly int _maxRolloverFiles;
private readonly bool _encryptionEnabled; private readonly bool _encryptionEnabled;
private readonly Aes _aes; private FileStream _fileStream;
private readonly ICryptoTransform _encryptor; private CryptoStream? _cryptoStream;
private byte[] _buffer;
private int _position;
private long _size;
private readonly Aes? _aes;
public event Action<Exception>? OnError; public event Action<Exception>? OnError;
public bool IncludeCorrelationId { get; } public bool IncludeCorrelationId { get; }
public bool EnableCategoryRouting { get; } public bool EnableCategoryRouting { get; }
private FileStream _stream;
private byte[] _buffer;
private int _position;
private long _size;
[ThreadStatic] [ThreadStatic]
private static StringBuilder? _cachedStringBuilder; private static StringBuilder? _cachedStringBuilder;
public bool IsHealthy => _running && _stream != null;
public string LogFile => _filePath;
private volatile bool _running = true; private volatile bool _running = true;
private readonly int _maxRolloverFiles;
public ELogType MinimumLogLevel { get; set; } public ELogType MinimumLogLevel { get; set; }
private readonly LoggerScopedContext _context = new(); private readonly LoggerScopedContext _context = new();
private static readonly TimeSpan FlushInterval = TimeSpan.FromMilliseconds(500);
private long _lastFlushTicks = DateTime.UtcNow.Ticks;
public FileLoggerProvider(IOptions<FileLoggerOptions> options) : base(options) public FileLoggerProvider(IOptions<FileLoggerOptions> options) : base(options)
{ {
var o = options.Value; AppDomain.CurrentDomain.ProcessExit += (s, e) => Dispose();
AppDomain.CurrentDomain.UnhandledException += (s, e) => Dispose();
var o = options.Value;
string primaryDirectory = o.LogDirectory; string primaryDirectory = o.LogDirectory;
string fileName = $"{o.FileNamePrefix}_{Environment.MachineName}_{DateTime.UtcNow:yyyyMMdd}.log"; string fileName = $"{o.FileNamePrefix}_{Environment.MachineName}_{DateTime.UtcNow:yyyyMMdd}.log";
_filePath = Path.Combine(primaryDirectory, fileName); _filePath = Path.Combine(primaryDirectory, fileName);
if (!TryInitializePath(primaryDirectory, fileName)) if (!TryInitializePath(primaryDirectory, fileName))
{ {
// Fallback to temp path with required format: EonaCat_date.log
string tempDirectory = Path.GetTempPath(); string tempDirectory = Path.GetTempPath();
string fallbackFileName = $"EonaCat_{DateTime.UtcNow:yyyyMMdd}.log"; string fallbackFileName = $"EonaCat_{DateTime.UtcNow:yyyyMMdd}.log";
if (!TryInitializePath(tempDirectory, fallbackFileName)) if (!TryInitializePath(tempDirectory, fallbackFileName))
{ {
_running = false; _running = false;
@@ -83,7 +76,6 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
_maxFileSize = o.FileSizeLimit; _maxFileSize = o.FileSizeLimit;
_maxRolloverFiles = o.MaxRolloverFiles; _maxRolloverFiles = o.MaxRolloverFiles;
_encryptionEnabled = o.EncryptionKey != null && o.EncryptionIV != null; _encryptionEnabled = o.EncryptionKey != null && o.EncryptionIV != null;
if (_encryptionEnabled) if (_encryptionEnabled)
@@ -91,41 +83,21 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
_aes = Aes.Create(); _aes = Aes.Create();
_aes.Key = o.EncryptionKey; _aes.Key = o.EncryptionKey;
_aes.IV = o.EncryptionIV; _aes.IV = o.EncryptionIV;
_encryptor = _aes.CreateEncryptor();
} }
IncludeCorrelationId = o.IncludeCorrelationId; IncludeCorrelationId = o.IncludeCorrelationId;
EnableCategoryRouting = o.EnableCategoryRouting; EnableCategoryRouting = o.EnableCategoryRouting;
try
{
_stream = new FileStream(_filePath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, 1, FileOptions.WriteThrough | FileOptions.SequentialScan);
_size = _stream.Length;
}
catch (Exception ex)
{
RaiseError(ex);
_running = false;
return;
}
_buffer = ArrayPool<byte>.Shared.Rent(BufferSize); _buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
_channel = Channel.CreateBounded<LogMessage>( _channel = Channel.CreateBounded<LogMessage>(new BoundedChannelOptions(ChannelCapacity)
new BoundedChannelOptions(ChannelCapacity)
{
SingleReader = true,
SingleWriter = false,
FullMode = BoundedChannelFullMode.Wait
});
_writerThread = new Thread(WriterLoop)
{ {
IsBackground = true, SingleReader = true,
Priority = ThreadPriority.AboveNormal SingleWriter = false,
}; FullMode = BoundedChannelFullMode.Wait
});
_writerThread.Start(); _writerTask = Task.Run(WriterLoopAsync);
} }
private bool TryInitializePath(string directory, string fileName) private bool TryInitializePath(string directory, string fileName)
@@ -133,19 +105,21 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
try try
{ {
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
string fullPath = Path.Combine(directory, fileName); string fullPath = Path.Combine(directory, fileName);
if (!EnsureWritable(fullPath)) if (!EnsureWritable(fullPath))
{ {
return false; return false;
} }
_filePath = fullPath; _filePath = fullPath;
_fileStream = new FileStream(_filePath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, 4096, FileOptions.SequentialScan | FileOptions.WriteThrough);
_stream = new FileStream(_filePath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, 1, FileOptions.WriteThrough | FileOptions.SequentialScan); if (_encryptionEnabled && _aes != null)
_size = _stream.Length; {
_cryptoStream = new CryptoStream(_fileStream, _aes.CreateEncryptor(), CryptoStreamMode.Write);
}
_size = _fileStream.Length;
return true; return true;
} }
catch (Exception ex) catch (Exception ex)
@@ -155,15 +129,13 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
} }
} }
internal override Task WriteMessagesAsync(IReadOnlyList<LogMessage> messages, CancellationToken token) internal override Task WriteMessagesAsync(IReadOnlyList<LogMessage> messages, CancellationToken token)
{ {
for (int i = 0; i < messages.Count; i++) foreach (var msg in messages)
{ {
var message = messages[i]; if (msg.Level >= MinimumLogLevel)
if (message.Level >= MinimumLogLevel)
{ {
while (!_channel.Writer.TryWrite(message)) while (!_channel.Writer.TryWrite(msg))
{ {
Thread.SpinWait(1); Thread.SpinWait(1);
} }
@@ -172,172 +144,169 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
return Task.CompletedTask; return Task.CompletedTask;
} }
private void WriterLoop() private async Task WriterLoopAsync()
{ {
if (_stream == null)
{
return;
}
var reader = _channel.Reader; var reader = _channel.Reader;
var spin = new SpinWait(); List<LogMessage> batch = new();
while (_running || reader.Count > 0) while (_running || reader.Count > 0)
{ {
while (reader.TryRead(out var message)) batch.Clear();
while (reader.TryRead(out var msg))
{ {
WriteMessage(message); batch.Add(msg);
} }
FlushIfNeededTimed(); if (batch.Count > 0)
{
await WriteBatchAsync(batch);
}
spin.SpinOnce(); await Task.Delay(50); // idle wait
} }
FlushFinal(); await FlushFinalAsync();
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)] private async Task WriteBatchAsync(IReadOnlyList<LogMessage> batch)
private void FlushIfNeededTimed() {
var sb = AcquireStringBuilder();
foreach (var msg in batch)
{
if (IncludeCorrelationId)
{
var ctx = _context.GetAll();
if (ctx.Count > 0)
{
sb.Append(" [");
foreach (var kv in ctx)
{
sb.Append(kv.Key).Append('=').Append(kv.Value).Append(' ');
}
sb.Length--; // trim
sb.Append(']');
}
}
sb.Append(' ').Append(msg.Message).AppendLine();
}
int maxBytes = Utf8.GetMaxByteCount(sb.Length);
byte[] rented = ArrayPool<byte>.Shared.Rent(maxBytes);
int byteCount = Utf8.GetBytes(sb.ToString(), 0, sb.Length, rented, 0);
WriteToBuffer(rented, byteCount);
ArrayPool<byte>.Shared.Return(rented, true);
ReleaseStringBuilder(sb);
await FlushIfNeededAsync();
}
private async Task FlushIfNeededAsync()
{
long now = DateTime.UtcNow.Ticks;
if (_position >= FlushThreshold || now - _lastFlushTicks >= FlushInterval.Ticks)
{
await _flushSemaphore.WaitAsync();
try
{
await FlushInternalAsync();
_lastFlushTicks = now;
}
finally { _flushSemaphore.Release(); }
}
}
private void WriteToBuffer(byte[] data, int length)
{
if (_position + length > _buffer.Length)
{
FlushInternalAsync().GetAwaiter().GetResult();
}
Buffer.BlockCopy(data, 0, _buffer, _position, length);
_position += length;
_size += length;
if (_maxFileSize > 0 && _size >= _maxFileSize)
{
Task.Run(RollFileAsync);
}
}
private async Task FlushInternalAsync()
{ {
if (_position == 0) if (_position == 0)
{ {
return; return;
} }
var nowTicks = DateTime.UtcNow.Ticks; try
// Size-based flush (existing behavior)
if (_position >= FlushThreshold)
{ {
FlushInternal(); if (_cryptoStream != null)
_lastFlushTicks = nowTicks; {
return; await _cryptoStream.WriteAsync(_buffer, 0, _position);
await _cryptoStream.FlushAsync();
}
else
{
await _fileStream.WriteAsync(_buffer, 0, _position);
await _fileStream.FlushAsync();
}
} }
catch (Exception ex) { RaiseError(ex); }
// Time-based flush (new behavior) _position = 0;
if (nowTicks - _lastFlushTicks >= FlushInterval.Ticks)
{
FlushInternal();
_lastFlushTicks = nowTicks;
}
} }
private async Task FlushFinalAsync()
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void WriteMessage(LogMessage msg)
{ {
var sb = AcquireStringBuilder(); await FlushInternalAsync();
if (IncludeCorrelationId) _cryptoStream?.FlushFinalBlock();
{
var ctx = _context.GetAll();
var tags = msg.Tags;
if (ctx.Count > 0 || (tags?.Length ?? 0) > 0) await _fileStream.FlushAsync();
{
sb.Append(" [");
foreach (var kv in ctx)
{
sb.Append(kv.Key).Append('=').Append(kv.Value).Append(' ');
}
if (tags != null)
{
for (int i = 0; i < tags.Length; i++)
{
sb.Append("tag=").Append(tags[i]).Append(' ');
}
}
// Trim trailing space
if (sb[sb.Length - 1] == ' ')
{
sb.Length--;
}
sb.Append(']');
}
// Ensure correlation id exists
var correlationId = _context.Get("CorrelationId");
if (correlationId == null)
{
correlationId = Guid.NewGuid().ToString();
_context.Set("CorrelationId", correlationId);
}
}
sb.Append(' ')
.Append(msg.Message)
.AppendLine();
int charCount = sb.Length;
int maxBytes = Utf8.GetMaxByteCount(charCount);
byte[] rented = ArrayPool<byte>.Shared.Rent(maxBytes);
int byteCount = Utf8.GetBytes(sb.ToString(), 0, charCount, rented, 0);
byte[] data = rented;
int length = byteCount;
if (_encryptionEnabled)
{
data = _encryptor.TransformFinalBlock(rented, 0, byteCount);
length = data.Length;
ArrayPool<byte>.Shared.Return(rented, true);
rented = null;
}
WriteToBuffer(data, length);
if (_maxFileSize > 0 && _size >= _maxFileSize)
{
RollFile();
}
if (rented != null)
{
ArrayPool<byte>.Shared.Return(rented, true);
}
ReleaseStringBuilder(sb);
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void WriteToBuffer(byte[] data, int length) private async Task RollFileAsync()
{ {
if (length > BufferSize) try
{ {
if (_position > 0) await _flushSemaphore.WaitAsync();
FlushInternalAsync().GetAwaiter().GetResult();
_cryptoStream?.Dispose();
_fileStream.Dispose();
string tempArchive = _filePath + ".rolling";
File.Move(_filePath, tempArchive);
// Compression in background
await Task.Run(() =>
{ {
FlushInternal(); string dest = _filePath.Replace(".log", "_1.log.gz");
using var input = File.OpenRead(tempArchive);
using var output = File.Create(dest);
using var gzip = new GZipStream(output, CompressionLevel.Fastest);
input.CopyTo(gzip);
File.Delete(tempArchive);
});
_fileStream = new FileStream(_filePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, 4096, FileOptions.SequentialScan);
if (_encryptionEnabled && _aes != null)
{
_cryptoStream = new CryptoStream(_fileStream, _aes.CreateEncryptor(), CryptoStreamMode.Write);
} }
WriteDirect(data, length); _size = 0;
return;
} }
finally { _flushSemaphore.Release(); }
if (_position + length > BufferSize)
{
FlushInternal();
}
Buffer.BlockCopy(data, 0, _buffer, _position, length);
_position += length;
_size += length;
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static StringBuilder AcquireStringBuilder() private static StringBuilder AcquireStringBuilder()
{ {
var sb = _cachedStringBuilder; var sb = _cachedStringBuilder;
if (sb == null) if (sb == null) { sb = new StringBuilder(256); _cachedStringBuilder = sb; }
{
sb = new StringBuilder(256);
_cachedStringBuilder = sb;
}
else else
{ {
sb.Clear(); sb.Clear();
@@ -346,255 +315,41 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
return sb; return sb;
} }
private const int MaxBuilderCapacity = 8 * 1024;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void ReleaseStringBuilder(StringBuilder sb) private static void ReleaseStringBuilder(StringBuilder sb)
{ {
if (sb.Capacity > MaxBuilderCapacity) if (sb.Capacity > 8 * 1024)
{ {
_cachedStringBuilder = new StringBuilder(256); _cachedStringBuilder = new StringBuilder(256);
} }
} }
private void WriteDirect(byte[] data, int length)
{
try
{
if (_stream == null || !File.Exists(_filePath))
{
RecreateStream();
}
_stream.Write(data, 0, length);
}
catch (IOException ioEx) when (!File.Exists(_filePath))
{
// File was removed while writing, recreate and retry once
RecreateStream();
_stream.Write(data, 0, length);
}
catch (Exception ex)
{
RaiseError(ex);
_running = false;
return;
}
_size += length;
if (_maxFileSize > 0 && _size >= _maxFileSize)
{
RollFile();
}
}
private void RecreateStream()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
_stream?.Dispose();
_stream = new FileStream(
_filePath,
FileMode.Append,
FileAccess.Write,
FileShare.ReadWrite | FileShare.Delete,
4096,
FileOptions.WriteThrough | FileOptions.SequentialScan);
_size = _stream.Length;
}
catch (Exception ex)
{
RaiseError(ex);
_running = false;
}
}
private void FlushInternal()
{
if (_position == 0)
{
return;
}
try
{
if (_stream == null || !File.Exists(_filePath))
{
RecreateStream();
}
_stream.Write(_buffer, 0, _position);
}
catch (IOException ioEx) when (!File.Exists(_filePath))
{
RecreateStream();
_stream.Write(_buffer, 0, _position);
}
catch (Exception ex)
{
RaiseError(ex);
_running = false;
}
_position = 0;
}
private void FlushFinal()
{
FlushInternal();
_stream.Flush(true);
}
private readonly object _rollLock = new();
private void RollFile()
{
try
{
lock (_rollLock)
{
FlushInternal();
_stream?.Dispose();
_stream = null;
RotateCompressedFiles();
string tempArchive = _filePath + ".rolling";
File.Move(_filePath, tempArchive);
CompressToIndex(tempArchive, 1);
_stream = new FileStream(_filePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, 4096, FileOptions.SequentialScan);
_size = 0;
}
}
catch (Exception ex)
{
RaiseError(ex);
_running = false;
}
}
private void RotateCompressedFiles()
{
int maxFiles = _maxRolloverFiles;
string directory = Path.GetDirectoryName(_filePath)!;
string nameWithoutExt = Path.GetFileNameWithoutExtension(_filePath);
string extension = Path.GetExtension(_filePath); // .log
for (int i = maxFiles; i >= 1; i--)
{
string current = Path.Combine(directory, $"{nameWithoutExt}_{i}{extension}.gz");
if (!File.Exists(current))
{
continue;
}
if (i == maxFiles)
{
File.Delete(current);
}
else
{
string next = Path.Combine(directory, $"{nameWithoutExt}_{i + 1}{extension}.gz");
if (File.Exists(next))
{
File.Delete(next);
}
File.Move(current, next);
}
}
}
private bool EnsureWritable(string path) private bool EnsureWritable(string path)
{ {
try try
{ {
string? directory = Path.GetDirectoryName(path); Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (string.IsNullOrWhiteSpace(directory)) using var fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
{
return false;
}
Directory.CreateDirectory(directory);
// Test write access
using (var fs = new FileStream(
path,
FileMode.Append,
FileAccess.Write,
FileShare.ReadWrite | FileShare.Delete))
{
// Just opening is enough to validate permission
}
return true; return true;
} }
catch (Exception ex) catch (Exception ex) { RaiseError(ex); return false; }
{
RaiseError(ex);
return false;
}
}
private void CompressToIndex(string sourceFile, int index)
{
string directory = Path.GetDirectoryName(_filePath)!;
string nameWithoutExtension = Path.GetFileNameWithoutExtension(_filePath);
string extension = Path.GetExtension(_filePath); // .log
// destination uses the same "_index" before .log + add .gz
string destination = Path.Combine(directory, $"{nameWithoutExtension}_{index}{extension}.gz");
using (var input = new FileStream(sourceFile, FileMode.Open, FileAccess.Read, FileShare.Read))
using (var output = new FileStream(destination, FileMode.Create, FileAccess.Write, FileShare.None))
using (var gzip = new GZipStream(output, CompressionLevel.Fastest))
{
input.CopyTo(gzip);
}
File.Delete(sourceFile);
}
protected override Task OnShutdownFlushAsync()
{
_running = false;
_channel.Writer.Complete();
_writerThread.Join();
ArrayPool<byte>.Shared.Return(_buffer, true);
_stream.Dispose();
_aes?.Dispose();
return Task.CompletedTask;
} }
private void RaiseError(Exception ex) private void RaiseError(Exception ex)
{ {
try try { OnError?.Invoke(ex); } catch { }
{
OnError?.Invoke(ex);
}
catch
{
// Never allow logging failure to crash app
}
} }
public new void Dispose() protected override async Task OnShutdownFlushAsync()
{ {
OnShutdownFlushAsync().GetAwaiter().GetResult(); _running = false;
base.Dispose(); _channel.Writer.Complete();
await _writerTask;
ArrayPool<byte>.Shared.Return(_buffer, true);
_cryptoStream?.Dispose();
_fileStream.Dispose();
_aes?.Dispose();
} }
public new void Dispose() => OnShutdownFlushAsync().GetAwaiter().GetResult();
} }