Files
EonaCat.Logger/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs
Jeroen Saey 6145f3a6cd Updated
2026-02-16 14:04:13 +01:00

356 lines
11 KiB
C#

using EonaCat.Logger;
using EonaCat.Logger.EonaCatCoreLogger;
using EonaCat.Logger.EonaCatCoreLogger.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
[ProviderAlias("EonaCatFileLogger")]
public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
{
private const int BufferSize = 64 * 1024;
private const int ChannelCapacity = 8192;
private const int FlushThreshold = 48 * 1024;
private static readonly UTF8Encoding Utf8 = new(false);
private readonly Channel<LogMessage> _channel;
private readonly Task _writerTask;
private readonly SemaphoreSlim _flushSemaphore = new(1, 1);
private string _filePath;
private readonly int _maxFileSize;
private readonly int _maxRolloverFiles;
private readonly bool _encryptionEnabled;
private FileStream _fileStream;
private CryptoStream? _cryptoStream;
private byte[] _buffer;
private int _position;
private long _size;
private readonly Aes? _aes;
public event Action<Exception>? OnError;
public bool IncludeCorrelationId { get; }
public bool EnableCategoryRouting { get; }
[ThreadStatic]
private static StringBuilder? _cachedStringBuilder;
private volatile bool _running = true;
public ELogType MinimumLogLevel { get; set; }
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)
{
AppDomain.CurrentDomain.ProcessExit += (s, e) => Dispose();
AppDomain.CurrentDomain.UnhandledException += (s, e) => Dispose();
var o = options.Value;
string primaryDirectory = o.LogDirectory;
string fileName = $"{o.FileNamePrefix}_{Environment.MachineName}_{DateTime.UtcNow:yyyyMMdd}.log";
_filePath = Path.Combine(primaryDirectory, fileName);
if (!TryInitializePath(primaryDirectory, fileName))
{
string tempDirectory = Path.GetTempPath();
string fallbackFileName = $"EonaCat_{DateTime.UtcNow:yyyyMMdd}.log";
if (!TryInitializePath(tempDirectory, fallbackFileName))
{
_running = false;
return;
}
}
_maxFileSize = o.FileSizeLimit;
_maxRolloverFiles = o.MaxRolloverFiles;
_encryptionEnabled = o.EncryptionKey != null && o.EncryptionIV != null;
if (_encryptionEnabled)
{
_aes = Aes.Create();
_aes.Key = o.EncryptionKey;
_aes.IV = o.EncryptionIV;
}
IncludeCorrelationId = o.IncludeCorrelationId;
EnableCategoryRouting = o.EnableCategoryRouting;
_buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
_channel = Channel.CreateBounded<LogMessage>(new BoundedChannelOptions(ChannelCapacity)
{
SingleReader = true,
SingleWriter = false,
FullMode = BoundedChannelFullMode.Wait
});
_writerTask = Task.Run(WriterLoopAsync);
}
private bool TryInitializePath(string directory, string fileName)
{
try
{
Directory.CreateDirectory(directory);
string fullPath = Path.Combine(directory, fileName);
if (!EnsureWritable(fullPath))
{
return false;
}
_filePath = fullPath;
_fileStream = new FileStream(_filePath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, 4096, FileOptions.SequentialScan | FileOptions.WriteThrough);
if (_encryptionEnabled && _aes != null)
{
_cryptoStream = new CryptoStream(_fileStream, _aes.CreateEncryptor(), CryptoStreamMode.Write);
}
_size = _fileStream.Length;
return true;
}
catch (Exception ex)
{
RaiseError(ex);
return false;
}
}
internal override Task WriteMessagesAsync(IReadOnlyList<LogMessage> messages, CancellationToken token)
{
foreach (var msg in messages)
{
if (msg.Level >= MinimumLogLevel)
{
while (!_channel.Writer.TryWrite(msg))
{
Thread.SpinWait(1);
}
}
}
return Task.CompletedTask;
}
private async Task WriterLoopAsync()
{
var reader = _channel.Reader;
List<LogMessage> batch = new();
while (_running || reader.Count > 0)
{
batch.Clear();
while (reader.TryRead(out var msg))
{
batch.Add(msg);
}
if (batch.Count > 0)
{
await WriteBatchAsync(batch);
}
await Task.Delay(50); // idle wait
}
await FlushFinalAsync();
}
private async Task WriteBatchAsync(IReadOnlyList<LogMessage> batch)
{
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)
{
return;
}
try
{
if (_cryptoStream != null)
{
await _cryptoStream.WriteAsync(_buffer, 0, _position);
await _cryptoStream.FlushAsync();
}
else
{
await _fileStream.WriteAsync(_buffer, 0, _position);
await _fileStream.FlushAsync();
}
}
catch (Exception ex) { RaiseError(ex); }
_position = 0;
}
private async Task FlushFinalAsync()
{
await FlushInternalAsync();
_cryptoStream?.FlushFinalBlock();
await _fileStream.FlushAsync();
}
private async Task RollFileAsync()
{
try
{
await _flushSemaphore.WaitAsync();
FlushInternalAsync().GetAwaiter().GetResult();
_cryptoStream?.Dispose();
_fileStream.Dispose();
string tempArchive = _filePath + ".rolling";
File.Move(_filePath, tempArchive);
// Compression in background
await Task.Run(() =>
{
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);
}
_size = 0;
}
finally { _flushSemaphore.Release(); }
}
private static StringBuilder AcquireStringBuilder()
{
var sb = _cachedStringBuilder;
if (sb == null) { sb = new StringBuilder(256); _cachedStringBuilder = sb; }
else
{
sb.Clear();
}
return sb;
}
private static void ReleaseStringBuilder(StringBuilder sb)
{
if (sb.Capacity > 8 * 1024)
{
_cachedStringBuilder = new StringBuilder(256);
}
}
private bool EnsureWritable(string path)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete);
return true;
}
catch (Exception ex) { RaiseError(ex); return false; }
}
private void RaiseError(Exception ex)
{
try { OnError?.Invoke(ex); } catch { }
}
protected override async Task OnShutdownFlushAsync()
{
_running = false;
_channel.Writer.Complete();
await _writerTask;
ArrayPool<byte>.Shared.Return(_buffer, true);
_cryptoStream?.Dispose();
_fileStream.Dispose();
_aes?.Dispose();
}
public new void Dispose() => OnShutdownFlushAsync().GetAwaiter().GetResult();
}