Updated
This commit is contained in:
@@ -13,8 +13,8 @@
|
||||
<Copyright>EonaCat (Jeroen Saey)</Copyright>
|
||||
<PackageTags>EonaCat;Logger;EonaCatLogger;Log;Writer;Jeroen;Saey</PackageTags>
|
||||
<PackageIconUrl />
|
||||
<Version>1.7.4</Version>
|
||||
<FileVersion>1.7.4</FileVersion>
|
||||
<Version>1.7.5</Version>
|
||||
<FileVersion>1.7.5</FileVersion>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<GenerateDocumentationFile>True</GenerateDocumentationFile>
|
||||
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||
@@ -25,7 +25,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<EVRevisionFormat>1.7.4+{chash:10}.{c:ymd}</EVRevisionFormat>
|
||||
<EVRevisionFormat>1.7.5+{chash:10}.{c:ymd}</EVRevisionFormat>
|
||||
<EVDefault>true</EVDefault>
|
||||
<EVInfo>true</EVInfo>
|
||||
<EVTagMatch>v[0-9]*</EVTagMatch>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
using EonaCat.Logger;
|
||||
using EonaCat.Logger.EonaCatCoreLogger;
|
||||
using EonaCat.Logger.EonaCatCoreLogger.Internal;
|
||||
using EonaCat.Logger.Managers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
@@ -20,349 +18,149 @@ using System.Threading.Tasks;
|
||||
[ProviderAlias("EonaCatFileLogger")]
|
||||
public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly string _fileNamePrefix;
|
||||
private readonly int _maxFileSize;
|
||||
private readonly int _maxRetainedFiles;
|
||||
private readonly int _maxRolloverFiles;
|
||||
private const int BufferSize = 64 * 1024;
|
||||
private const int ChannelCapacity = 8192;
|
||||
private const int FlushThreshold = 48 * 1024;
|
||||
|
||||
private readonly byte[] _encryptionKey;
|
||||
private readonly byte[] _encryptionIV;
|
||||
private readonly bool _isEncryptionEnabled;
|
||||
|
||||
private const int MaxQueueSize = 500;
|
||||
private const int BufferSize = 16 * 1024;
|
||||
private const int MaxStringBuilderPool = 4;
|
||||
private const int MaxStringBuilderCapacity = 512;
|
||||
private const int InitialStringBuilderCapacity = 256;
|
||||
private const int MaxSanitizedCacheSize = 256;
|
||||
|
||||
private static readonly Encoding Utf8 = new UTF8Encoding(false);
|
||||
|
||||
private readonly LoggerScopedContext _context = new();
|
||||
|
||||
private readonly ConcurrentDictionary<string, FileState> _files = new(1, 4, StringComparer.Ordinal);
|
||||
private readonly ConcurrentQueue<string> _compressionQueue = new();
|
||||
private readonly SemaphoreSlim _compressionSemaphore = new(1, 1);
|
||||
private readonly CancellationTokenSource _compressionCts = new();
|
||||
private Task _compressionWorker;
|
||||
|
||||
private static readonly object _sanitizedCacheLock = new object();
|
||||
private static readonly Dictionary<string, string> _sanitizedCache = new(StringComparer.Ordinal);
|
||||
private static readonly Queue<string> _sanitizedCacheOrder = new();
|
||||
private static readonly UTF8Encoding Utf8 = new(false);
|
||||
|
||||
private readonly Channel<LogMessage> _channel;
|
||||
private readonly Task _backgroundWorker;
|
||||
private readonly Thread _writerThread;
|
||||
|
||||
private readonly ConcurrentBag<StringBuilder> _sbPool = new();
|
||||
private int _sbPoolCount;
|
||||
private readonly string _filePath;
|
||||
private readonly int _maxFileSize;
|
||||
private readonly bool _encryptionEnabled;
|
||||
|
||||
private Timer _flushTimer;
|
||||
private readonly TimeSpan _flushInterval = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private int _disposed;
|
||||
private Aes _aes;
|
||||
|
||||
private readonly ThreadLocal<ICryptoTransform> _encryptor = new ThreadLocal<ICryptoTransform>(() => null, trackAllValues: true);
|
||||
private readonly object _aesLock = new object();
|
||||
private readonly Aes _aes;
|
||||
private readonly ICryptoTransform _encryptor;
|
||||
|
||||
public bool IncludeCorrelationId { get; }
|
||||
public bool EnableCategoryRouting { get; }
|
||||
|
||||
private FileStream _stream;
|
||||
private byte[] _buffer;
|
||||
private int _position;
|
||||
private long _size;
|
||||
|
||||
public string LogFile => _filePath;
|
||||
private volatile bool _running = true;
|
||||
private readonly int _maxRolloverFiles;
|
||||
|
||||
public ELogType MinimumLogLevel { get; set; }
|
||||
|
||||
public event EventHandler<ErrorMessage> OnError;
|
||||
public event EventHandler<string> OnRollOver;
|
||||
|
||||
public string LogFile => _files.TryGetValue(string.Empty, out var state) ? state.FilePath : null;
|
||||
private readonly LoggerScopedContext _context = new();
|
||||
|
||||
public FileLoggerProvider(IOptions<FileLoggerOptions> options) : base(options)
|
||||
{
|
||||
var o = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
var o = options.Value;
|
||||
|
||||
_filePath = Path.Combine(o.LogDirectory,
|
||||
$"{o.FileNamePrefix}_{Environment.MachineName}_{DateTime.UtcNow:yyyyMMdd}.log");
|
||||
|
||||
Directory.CreateDirectory(o.LogDirectory);
|
||||
|
||||
_path = EnsureWritableDirectory(o.LogDirectory);
|
||||
_fileNamePrefix = o.FileNamePrefix;
|
||||
_maxFileSize = o.FileSizeLimit;
|
||||
_maxRetainedFiles = o.RetainedFileCountLimit;
|
||||
_maxRolloverFiles = o.MaxRolloverFiles;
|
||||
IncludeCorrelationId = o.IncludeCorrelationId;
|
||||
EnableCategoryRouting = o.EnableCategoryRouting;
|
||||
MinimumLogLevel = o.MinimumLogLevel;
|
||||
|
||||
_encryptionKey = o.EncryptionKey;
|
||||
_encryptionIV = o.EncryptionIV;
|
||||
_isEncryptionEnabled = _encryptionKey != null && _encryptionIV != null;
|
||||
_encryptionEnabled = o.EncryptionKey != null && o.EncryptionIV != null;
|
||||
|
||||
if (_isEncryptionEnabled)
|
||||
if (_encryptionEnabled)
|
||||
{
|
||||
_aes = Aes.Create();
|
||||
_aes.Key = _encryptionKey;
|
||||
_aes.IV = _encryptionIV;
|
||||
_aes.Key = o.EncryptionKey;
|
||||
_aes.IV = o.EncryptionIV;
|
||||
_encryptor = _aes.CreateEncryptor();
|
||||
}
|
||||
|
||||
var defaultState = CreateFileState(DateTime.UtcNow.Date, string.Empty);
|
||||
_files[string.Empty] = defaultState;
|
||||
IncludeCorrelationId = o.IncludeCorrelationId;
|
||||
EnableCategoryRouting = o.EnableCategoryRouting;
|
||||
|
||||
var channelOptions = new BoundedChannelOptions(MaxQueueSize)
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
AllowSynchronousContinuations = false
|
||||
};
|
||||
_channel = Channel.CreateBounded<LogMessage>(channelOptions);
|
||||
_stream = new FileStream(_filePath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, 1, FileOptions.WriteThrough | FileOptions.SequentialScan);
|
||||
_size = _stream.Length;
|
||||
_buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
|
||||
|
||||
_backgroundWorker = Task.Run(ProcessQueueAsync);
|
||||
_flushTimer = new Timer(FlushTimerCallback, null, _flushInterval, _flushInterval);
|
||||
_compressionWorker = Task.Run(() => CompressionWorkerAsync(_compressionCts.Token));
|
||||
}
|
||||
|
||||
private string EnsureWritableDirectory(string path)
|
||||
{
|
||||
string fallback = Path.Combine(Path.GetTempPath(), "EonaCatFallbackLogs");
|
||||
foreach (var dir in new[] { path, fallback })
|
||||
{
|
||||
try
|
||||
_channel = Channel.CreateBounded<LogMessage>(
|
||||
new BoundedChannelOptions(ChannelCapacity)
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
string testFile = Path.Combine(dir, $"write_test_{Guid.NewGuid()}.tmp");
|
||||
File.WriteAllText(testFile, "test");
|
||||
File.Delete(testFile);
|
||||
return dir;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
Directory.CreateDirectory(fallback);
|
||||
return fallback;
|
||||
}
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
FullMode = BoundedChannelFullMode.Wait
|
||||
});
|
||||
|
||||
private void FlushTimerCallback(object state)
|
||||
{
|
||||
if (_disposed == 1)
|
||||
_writerThread = new Thread(WriterLoop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
IsBackground = true,
|
||||
Priority = ThreadPriority.AboveNormal
|
||||
};
|
||||
|
||||
// Synchronous flush on timer to avoid task buildup
|
||||
foreach (var f in _files.Values)
|
||||
{
|
||||
FlushBufferSync(f);
|
||||
}
|
||||
_writerThread.Start();
|
||||
}
|
||||
|
||||
internal override Task WriteMessagesAsync(IReadOnlyList<LogMessage> messages, CancellationToken token)
|
||||
{
|
||||
for (int i = 0; i < messages.Count; i++)
|
||||
{
|
||||
var msg = messages[i];
|
||||
if (msg.Level >= MinimumLogLevel)
|
||||
var message = messages[i];
|
||||
if (message.Level >= MinimumLogLevel)
|
||||
{
|
||||
// TryWrite waits until it can write
|
||||
_channel.Writer.TryWrite(msg);
|
||||
while (!_channel.Writer.TryWrite(message))
|
||||
{
|
||||
Thread.SpinWait(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task ProcessQueueAsync()
|
||||
private void WriterLoop()
|
||||
{
|
||||
try
|
||||
var reader = _channel.Reader;
|
||||
var spin = new SpinWait();
|
||||
|
||||
while (_running || reader.Count > 0)
|
||||
{
|
||||
await foreach (var msg in _channel.Reader.ReadAllAsync())
|
||||
while (reader.TryRead(out var message))
|
||||
{
|
||||
await ProcessSingleMessageAsync(msg);
|
||||
WriteMessage(message);
|
||||
}
|
||||
|
||||
FlushIfNeeded();
|
||||
|
||||
spin.SpinOnce();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on shutdown
|
||||
}
|
||||
|
||||
FlushFinal();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private async Task ProcessSingleMessageAsync(LogMessage msg)
|
||||
private void FlushIfNeeded()
|
||||
{
|
||||
var key = EnableCategoryRouting ? GetOrCreateSanitizedCategory(msg.Category) : string.Empty;
|
||||
|
||||
if (!_files.TryGetValue(key, out var state))
|
||||
if (_position >= FlushThreshold)
|
||||
{
|
||||
state = CreateFileState(DateTime.UtcNow.Date, key);
|
||||
_files[key] = state;
|
||||
FlushInternal();
|
||||
}
|
||||
}
|
||||
|
||||
if (!TryRecover(state))
|
||||
private void FlushInternal()
|
||||
{
|
||||
if (_position == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await state.WriteLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await WriteMessageAsync(state, msg);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.WriteLock.Release();
|
||||
}
|
||||
_stream.Write(_buffer, 0, _position);
|
||||
_position = 0;
|
||||
}
|
||||
|
||||
private async Task WriteMessageAsync(FileState state, LogMessage msg)
|
||||
private void FlushFinal()
|
||||
{
|
||||
var sb = RentStringBuilder();
|
||||
|
||||
try
|
||||
{
|
||||
BuildMessageInto(sb, msg);
|
||||
var text = sb.ToString();
|
||||
|
||||
// Size the array precisely
|
||||
int maxByteCount = Utf8.GetMaxByteCount(text.Length);
|
||||
byte[] rentedBytes = ArrayPool<byte>.Shared.Rent(maxByteCount);
|
||||
|
||||
try
|
||||
{
|
||||
int actualByteCount = Utf8.GetBytes(text, 0, text.Length, rentedBytes, 0);
|
||||
|
||||
byte[] finalBytes = rentedBytes;
|
||||
int finalLength = actualByteCount;
|
||||
|
||||
if (_isEncryptionEnabled)
|
||||
{
|
||||
byte[] encrypted = EncryptFast(rentedBytes, actualByteCount);
|
||||
finalBytes = encrypted;
|
||||
finalLength = encrypted.Length;
|
||||
ArrayPool<byte>.Shared.Return(rentedBytes, clearArray: true);
|
||||
rentedBytes = null;
|
||||
}
|
||||
|
||||
if (state.Buffer == null)
|
||||
{
|
||||
state.Buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
|
||||
}
|
||||
|
||||
// Flush if needed
|
||||
if (state.BufferPosition + finalLength > BufferSize)
|
||||
{
|
||||
await FlushBufferAsync(state).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Handle file size limit
|
||||
if (_maxFileSize > 0 && state.Size + finalLength > _maxFileSize)
|
||||
{
|
||||
await FlushBufferAsync(state).ConfigureAwait(false);
|
||||
RollOverAndCompressOldest(state, string.Empty);
|
||||
}
|
||||
|
||||
// Write to buffer
|
||||
Buffer.BlockCopy(finalBytes, 0, state.Buffer, state.BufferPosition, finalLength);
|
||||
state.BufferPosition += finalLength;
|
||||
state.Size += finalLength;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (rentedBytes != null)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(rentedBytes, clearArray: true);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReturnStringBuilder(sb);
|
||||
}
|
||||
FlushInternal();
|
||||
_stream.Flush(true);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private byte[] EncryptFast(byte[] plainBytes, int length)
|
||||
private void WriteMessage(LogMessage msg)
|
||||
{
|
||||
var encryptor = _encryptor.Value;
|
||||
if (encryptor == null)
|
||||
{
|
||||
lock (_aesLock)
|
||||
{
|
||||
encryptor = _aes?.CreateEncryptor();
|
||||
_encryptor.Value = encryptor;
|
||||
}
|
||||
}
|
||||
return encryptor.TransformFinalBlock(plainBytes, 0, length);
|
||||
}
|
||||
|
||||
private void FlushBufferSync(FileState state)
|
||||
{
|
||||
if (state.BufferPosition == 0 || state.Stream == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
state.Stream.Write(state.Buffer, 0, state.BufferPosition);
|
||||
state.Stream.Flush();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
HandleWriteFailure(state, ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.BufferPosition = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FlushBufferAsync(FileState state)
|
||||
{
|
||||
if (state.BufferPosition == 0 || state.Stream == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await state.Stream.WriteAsync(state.Buffer, 0, state.BufferPosition).ConfigureAwait(false);
|
||||
await state.Stream.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
HandleWriteFailure(state, ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.BufferPosition = 0;
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private StringBuilder RentStringBuilder()
|
||||
{
|
||||
if (_sbPool.TryTake(out var sb))
|
||||
{
|
||||
sb.Clear();
|
||||
Interlocked.Decrement(ref _sbPoolCount);
|
||||
return sb;
|
||||
}
|
||||
return new StringBuilder(InitialStringBuilderCapacity);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void ReturnStringBuilder(StringBuilder sb)
|
||||
{
|
||||
if (sb.Capacity > MaxStringBuilderCapacity)
|
||||
{
|
||||
return; // Don't pool oversized builders
|
||||
}
|
||||
|
||||
if (_sbPoolCount < MaxStringBuilderPool)
|
||||
{
|
||||
_sbPool.Add(sb);
|
||||
Interlocked.Increment(ref _sbPoolCount);
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void BuildMessageInto(StringBuilder sb, LogMessage msg)
|
||||
{
|
||||
sb.Clear();
|
||||
sb.Append(msg.Message);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
if (IncludeCorrelationId)
|
||||
{
|
||||
@@ -398,332 +196,140 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(Environment.NewLine);
|
||||
}
|
||||
|
||||
private void HandleWriteFailure(FileState state, Exception ex)
|
||||
{
|
||||
state.IsFaulted = true;
|
||||
state.LastFailureUtc = DateTime.UtcNow;
|
||||
|
||||
state.Stream?.Dispose();
|
||||
state.Stream = null;
|
||||
|
||||
try
|
||||
string correlationId = null;
|
||||
if (IncludeCorrelationId)
|
||||
{
|
||||
string fallbackFile = Path.Combine(_path, Path.GetFileName(state.FilePath));
|
||||
state.Stream = new FileStream(
|
||||
fallbackFile,
|
||||
FileMode.Append,
|
||||
FileAccess.Write,
|
||||
FileShare.ReadWrite | FileShare.Delete,
|
||||
4096,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
state.FilePath = fallbackFile;
|
||||
state.Size = GetFileSize(fallbackFile);
|
||||
state.IsFaulted = false;
|
||||
|
||||
OnError?.Invoke(this, new ErrorMessage { Exception = ex, Message = $"Switched to fallback path: {fallbackFile}" });
|
||||
correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString();
|
||||
_context.Set("CorrelationId", correlationId);
|
||||
}
|
||||
catch (Exception fallbackEx)
|
||||
|
||||
string text = sb.ToString() + ' ' + msg.Message + Environment.NewLine;
|
||||
|
||||
int max = Utf8.GetMaxByteCount(text.Length);
|
||||
byte[] temp = ArrayPool<byte>.Shared.Rent(max);
|
||||
|
||||
int bytes = Utf8.GetBytes(text, 0, text.Length, temp, 0);
|
||||
|
||||
byte[] final = temp;
|
||||
int length = bytes;
|
||||
|
||||
if (_encryptionEnabled)
|
||||
{
|
||||
OnError?.Invoke(this, new ErrorMessage { Exception = fallbackEx, Message = "Failed to recover logging using fallback path" });
|
||||
final = _encryptor.TransformFinalBlock(temp, 0, bytes);
|
||||
length = final.Length;
|
||||
ArrayPool<byte>.Shared.Return(temp, true);
|
||||
temp = null;
|
||||
}
|
||||
|
||||
if (_position + length > BufferSize)
|
||||
{
|
||||
FlushInternal();
|
||||
}
|
||||
|
||||
Buffer.BlockCopy(final, 0, _buffer, _position, length);
|
||||
_position += length;
|
||||
_size += length;
|
||||
|
||||
if (_maxFileSize > 0 && _size >= _maxFileSize)
|
||||
{
|
||||
RollFile();
|
||||
}
|
||||
|
||||
if (temp != null)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(temp, true);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryRecover(FileState state)
|
||||
private readonly object _rollLock = new();
|
||||
|
||||
private void RollFile()
|
||||
{
|
||||
if (!state.IsFaulted)
|
||||
lock (_rollLock)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
FlushInternal();
|
||||
|
||||
if (DateTime.UtcNow - state.LastFailureUtc < TimeSpan.FromSeconds(60))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_stream?.Dispose();
|
||||
_stream = null;
|
||||
|
||||
try
|
||||
{
|
||||
state.Stream = new FileStream(
|
||||
state.FilePath,
|
||||
FileMode.Append,
|
||||
FileAccess.Write,
|
||||
FileShare.ReadWrite | FileShare.Delete,
|
||||
4096,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
state.Size = GetFileSize(state.FilePath);
|
||||
state.IsFaulted = false;
|
||||
return true;
|
||||
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 { return false; }
|
||||
}
|
||||
|
||||
private FileState CreateFileState(DateTime date, string category)
|
||||
private void RotateCompressedFiles()
|
||||
{
|
||||
var path = GetFullName(date, category);
|
||||
try
|
||||
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--)
|
||||
{
|
||||
return new FileState
|
||||
string current = Path.Combine(directory, $"{nameWithoutExt}_{i}{extension}.gz");
|
||||
|
||||
if (!File.Exists(current))
|
||||
{
|
||||
FilePath = path,
|
||||
Date = date,
|
||||
Size = GetFileSize(path),
|
||||
Buffer = ArrayPool<byte>.Shared.Rent(BufferSize),
|
||||
Stream = new FileStream(
|
||||
path,
|
||||
FileMode.Append,
|
||||
FileAccess.Write,
|
||||
FileShare.ReadWrite | FileShare.Delete,
|
||||
4096,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan),
|
||||
WriteLock = new SemaphoreSlim(1, 1)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnError?.Invoke(this, new ErrorMessage { Exception = ex, Message = $"Failed to create log file: {path}" });
|
||||
return new FileState
|
||||
{
|
||||
FilePath = path,
|
||||
Date = date,
|
||||
Buffer = ArrayPool<byte>.Shared.Rent(BufferSize),
|
||||
IsFaulted = true,
|
||||
WriteLock = new SemaphoreSlim(1, 1)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private string GetFullName(DateTime date, string category)
|
||||
{
|
||||
var datePart = date.ToString("yyyyMMdd");
|
||||
var machine = Environment.MachineName;
|
||||
|
||||
if (!EnableCategoryRouting || string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
return Path.Combine(_path, $"{_fileNamePrefix}_{machine}_{datePart}.log");
|
||||
}
|
||||
|
||||
var safeCategory = GetOrCreateSanitizedCategory(category);
|
||||
return Path.Combine(_path, $"{_fileNamePrefix}_{machine}_{safeCategory}_{datePart}.log");
|
||||
}
|
||||
|
||||
private static string GetOrCreateSanitizedCategory(string category)
|
||||
{
|
||||
if (string.IsNullOrEmpty(category))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
lock (_sanitizedCacheLock)
|
||||
{
|
||||
if (_sanitizedCache.TryGetValue(category, out var cached))
|
||||
{
|
||||
return cached;
|
||||
continue;
|
||||
}
|
||||
|
||||
var sanitized = SanitizeCategory(category);
|
||||
|
||||
// Simple LRU: evict oldest if cache exceeds max size
|
||||
if (_sanitizedCache.Count >= MaxSanitizedCacheSize)
|
||||
if (i == maxFiles)
|
||||
{
|
||||
var oldestKey = _sanitizedCacheOrder.Dequeue();
|
||||
_sanitizedCache.Remove(oldestKey);
|
||||
File.Delete(current);
|
||||
}
|
||||
|
||||
_sanitizedCache[category] = sanitized;
|
||||
_sanitizedCacheOrder.Enqueue(category);
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeCategory(string category)
|
||||
{
|
||||
if (string.IsNullOrEmpty(category))
|
||||
{
|
||||
return category;
|
||||
}
|
||||
|
||||
var chars = category.ToCharArray();
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
bool modified = false;
|
||||
|
||||
for (int i = 0; i < chars.Length; i++)
|
||||
{
|
||||
if (Array.IndexOf(invalid, chars[i]) >= 0 || chars[i] == '.')
|
||||
else
|
||||
{
|
||||
chars[i] = '_';
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
string next = Path.Combine(directory, $"{nameWithoutExt}_{i + 1}{extension}.gz");
|
||||
|
||||
return modified ? new string(chars) : category;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static long GetFileSize(string path) => File.Exists(path) ? new FileInfo(path).Length : 0;
|
||||
|
||||
private void RollOverAndCompressOldest(FileState state, string category)
|
||||
{
|
||||
if (state.Stream != null)
|
||||
{
|
||||
state.Stream.Flush();
|
||||
state.Stream.Dispose();
|
||||
state.Stream = null;
|
||||
}
|
||||
|
||||
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}");
|
||||
try
|
||||
{
|
||||
if (File.Exists(dst))
|
||||
if (File.Exists(next))
|
||||
{
|
||||
File.Delete(dst);
|
||||
File.Delete(next);
|
||||
}
|
||||
|
||||
if (File.Exists(src))
|
||||
{
|
||||
File.Move(src, dst);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore rollover failures
|
||||
}
|
||||
}
|
||||
|
||||
var rolledFile = Path.Combine(dir, $"{name}_1{ext}");
|
||||
try
|
||||
{
|
||||
if (File.Exists(state.FilePath))
|
||||
{
|
||||
File.Move(state.FilePath, rolledFile);
|
||||
}
|
||||
|
||||
OnRollOver?.Invoke(this, rolledFile);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore rollover failures
|
||||
}
|
||||
|
||||
state.Size = 0;
|
||||
state.Stream = new FileStream(
|
||||
state.FilePath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.ReadWrite | FileShare.Delete,
|
||||
4096,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
var oldestFile = Path.Combine(dir, $"{name}_{_maxRolloverFiles}{ext}");
|
||||
if (File.Exists(oldestFile))
|
||||
{
|
||||
_compressionQueue.Enqueue(oldestFile);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CompressionWorkerAsync(CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(1000, token).ConfigureAwait(false);
|
||||
|
||||
while (_compressionQueue.TryDequeue(out var file))
|
||||
{
|
||||
await _compressionSemaphore.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
await CompressOldLogFileAsync(file);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently fail compression
|
||||
}
|
||||
finally
|
||||
{
|
||||
_compressionSemaphore.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
File.Move(current, next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CompressOldLogFileAsync(string filePath)
|
||||
|
||||
private void CompressToIndex(string sourceFile, int index)
|
||||
{
|
||||
if (!File.Exists(filePath) || filePath.EndsWith(".gz"))
|
||||
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))
|
||||
{
|
||||
return;
|
||||
input.CopyTo(gzip);
|
||||
}
|
||||
|
||||
var compressedFile = filePath + ".gz";
|
||||
|
||||
try
|
||||
{
|
||||
using (var original = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 8192, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
using (var compressed = new FileStream(compressedFile, FileMode.Create, FileAccess.Write, FileShare.None, 8192, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
using (var gzip = new GZipStream(compressed, CompressionLevel.Fastest)) // Fastest compression for lower memory usage
|
||||
{
|
||||
await original.CopyToAsync(gzip, 8192).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
File.Delete(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Do nothing
|
||||
}
|
||||
File.Delete(sourceFile);
|
||||
}
|
||||
|
||||
protected override async Task OnShutdownFlushAsync()
|
||||
|
||||
protected override Task OnShutdownFlushAsync()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_flushTimer?.Dispose();
|
||||
_flushTimer = null;
|
||||
|
||||
try { _compressionCts.Cancel(); } catch { }
|
||||
_running = false;
|
||||
_channel.Writer.Complete();
|
||||
_writerThread.Join();
|
||||
|
||||
try
|
||||
{
|
||||
await _backgroundWorker.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
|
||||
foreach (var state in _files.Values)
|
||||
{
|
||||
try { await FlushBufferAsync(state).ConfigureAwait(false); } catch { }
|
||||
try { state.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
_files.Clear();
|
||||
|
||||
while (_sbPool.TryTake(out _)) { }
|
||||
Interlocked.Exchange(ref _sbPoolCount, 0);
|
||||
|
||||
_encryptor.Dispose();
|
||||
ArrayPool<byte>.Shared.Return(_buffer, true);
|
||||
_stream.Dispose();
|
||||
_aes?.Dispose();
|
||||
_compressionCts.Dispose();
|
||||
_compressionSemaphore.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public new void Dispose()
|
||||
@@ -731,32 +337,4 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider, IDisposable
|
||||
OnShutdownFlushAsync().GetAwaiter().GetResult();
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
private sealed class FileState : IDisposable
|
||||
{
|
||||
public string FilePath;
|
||||
public long Size;
|
||||
public DateTime Date;
|
||||
public byte[] Buffer;
|
||||
public int BufferPosition;
|
||||
public FileStream Stream;
|
||||
public SemaphoreSlim WriteLock;
|
||||
public bool IsFaulted;
|
||||
public DateTime LastFailureUtc;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stream?.Dispose();
|
||||
Stream = null;
|
||||
|
||||
if (Buffer != null)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(Buffer, clearArray: true);
|
||||
Buffer = null;
|
||||
}
|
||||
|
||||
WriteLock?.Dispose();
|
||||
WriteLock = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
using System.IO.Compression;
|
||||
using EonaCat.Logger.EonaCatCoreLogger;
|
||||
using EonaCat.Logger.Extensions;
|
||||
using EonaCat.Logger.Managers;
|
||||
|
||||
namespace EonaCat.Logger.Test.Web;
|
||||
|
||||
public class Logger
|
||||
{
|
||||
private LogManager _logManager;
|
||||
public LoggerSettings LoggerSettings { get; }
|
||||
public bool UseLocalTime { get; set; }
|
||||
public string LogFolder { get; set; } = Path.Combine(FileLoggerOptions.DefaultPath, "logs");
|
||||
public string CurrentLogFile => _logManager.CurrentLogFile;
|
||||
public bool IsDisabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Logger
|
||||
/// </summary>
|
||||
/// <param name="name"></param>
|
||||
/// <param name="typesToLog"></param>
|
||||
/// <param name="useLocalTime"></param>
|
||||
/// <param name="maxFileSize"></param>
|
||||
public Logger(string name = "EonaCatTestLogger", List<ELogType> typesToLog = null, bool useLocalTime = false, int maxFileSize = 20_000_000)
|
||||
{
|
||||
UseLocalTime = useLocalTime;
|
||||
|
||||
LoggerSettings = new LoggerSettings
|
||||
{
|
||||
Id = name,
|
||||
TypesToLog = typesToLog,
|
||||
UseLocalTime = UseLocalTime,
|
||||
FileLoggerOptions =
|
||||
{
|
||||
LogDirectory = LogFolder,
|
||||
FileSizeLimit = maxFileSize,
|
||||
UseLocalTime = UseLocalTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public void DeleteCurrentLogFile()
|
||||
{
|
||||
if (IsDisabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logManager.DeleteCurrentLogFile();
|
||||
}
|
||||
|
||||
private string ConvertToAbsolutePath(string path)
|
||||
{
|
||||
return Path.IsPathRooted(path) ? path : Path.Combine(LogFolder, path);
|
||||
}
|
||||
|
||||
public async Task DownloadLogAsync(HttpContext context, string logName, long limit)
|
||||
{
|
||||
var logFileName = logName + ".log";
|
||||
|
||||
await using var fileStream = new FileStream(Path.Combine(ConvertToAbsolutePath(LogFolder), logFileName), FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 64 * 1024, true);
|
||||
|
||||
var response = context.Response;
|
||||
|
||||
response.ContentType = "text/plain";
|
||||
response.Headers.ContentDisposition = "attachment;filename=" + logFileName;
|
||||
|
||||
if (limit > fileStream.Length || limit < 1)
|
||||
{
|
||||
limit = fileStream.Length;
|
||||
}
|
||||
|
||||
var oFS = new OffsetStream(fileStream, 0, limit);
|
||||
var request = context.Request;
|
||||
Stream stream;
|
||||
|
||||
string acceptEncoding = request.Headers["Accept-Encoding"];
|
||||
if (string.IsNullOrEmpty(acceptEncoding))
|
||||
{
|
||||
stream = response.Body;
|
||||
}
|
||||
else
|
||||
{
|
||||
var acceptEncodingParts = acceptEncoding.Split(new[] { ',' },
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (acceptEncodingParts.Contains("br"))
|
||||
{
|
||||
response.Headers.ContentEncoding = "br";
|
||||
stream = new BrotliStream(response.Body, CompressionMode.Compress);
|
||||
}
|
||||
else if (acceptEncodingParts.Contains("gzip"))
|
||||
{
|
||||
response.Headers.ContentEncoding = "gzip";
|
||||
stream = new GZipStream(response.Body, CompressionMode.Compress);
|
||||
}
|
||||
else if (acceptEncodingParts.Contains("deflate"))
|
||||
{
|
||||
response.Headers.ContentEncoding = "deflate";
|
||||
stream = new DeflateStream(response.Body, CompressionMode.Compress);
|
||||
}
|
||||
else
|
||||
{
|
||||
stream = response.Body;
|
||||
}
|
||||
}
|
||||
|
||||
await using (stream)
|
||||
{
|
||||
await oFS.CopyToAsync(stream).ConfigureAwait(false);
|
||||
|
||||
if (fileStream.Length > limit)
|
||||
{
|
||||
await stream.WriteAsync("\r\n####___TRUNCATED___####"u8.ToArray()).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogAsync(string message, ELogType logType = ELogType.INFO, bool writeToConsole = true)
|
||||
{
|
||||
if (IsDisabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InitLogger();
|
||||
await _logManager.WriteAsync(message, logType, writeToConsole).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void InitLogger()
|
||||
{
|
||||
if (_logManager == null)
|
||||
{
|
||||
// Initialize LogManager
|
||||
_logManager = new LogManager(LoggerSettings);
|
||||
_logManager.Settings.TypesToLog.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogAsync(Exception exception, string message = "", bool writeToConsole = true)
|
||||
{
|
||||
if (IsDisabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InitLogger();
|
||||
if (LoggerSettings.TypesToLog.Contains(ELogType.ERROR))
|
||||
{
|
||||
await _logManager.WriteAsync(exception, message, writeToConsole: writeToConsole).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user