From 5d43be86575908bb8a4d568412e53b51ea868df2 Mon Sep 17 00:00:00 2001 From: EonaCat Date: Tue, 21 Jul 2026 11:23:19 +0200 Subject: [PATCH] Updated --- .../Flows/AdvancedFilterFlow.cs | 178 ++++++++++++ .../Flows/AggregationFlow.cs | 189 +++++++++++++ .../EonaCatLoggerCore/Flows/BufferedFlow.cs | 158 +++++++++++ .../Flows/DuplicateDetectionFlow.cs | 182 +++++++++++++ .../EonaCatLoggerCore/Flows/FileFlow.cs | 181 ++++++++++--- .../Flows/MetricsAggregatorFlow.cs | 229 ++++++++++++++++ .../EonaCatLoggerCore/Flows/PipelineFlow.cs | 253 ++++++++++++++++++ .../Flows/RotatingFileFlow.cs | 247 +++++++++++++++++ .../EonaCatLoggerCore/Flows/TransformFlow.cs | 107 ++++++++ .../Helpers/DirectoryPermissionHelper.cs | 221 +++++++++++++++ EonaCat.LogStack/LogBuilder.cs | 116 ++++++++ EonaCat.LogStack/Server.cs | 145 ++++++++-- 12 files changed, 2154 insertions(+), 52 deletions(-) create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Flows/AdvancedFilterFlow.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Flows/AggregationFlow.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Flows/BufferedFlow.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Flows/DuplicateDetectionFlow.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Flows/MetricsAggregatorFlow.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Flows/PipelineFlow.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Flows/RotatingFileFlow.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Flows/TransformFlow.cs create mode 100644 EonaCat.LogStack/Helpers/DirectoryPermissionHelper.cs diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/AdvancedFilterFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/AdvancedFilterFlow.cs new file mode 100644 index 0000000..1037806 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/AdvancedFilterFlow.cs @@ -0,0 +1,178 @@ +using EonaCat.LogStack.Core; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace EonaCat.LogStack.Flows +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/License for full license details. + + /// + /// A flow that filters log events based on custom predicates before passing to an inner flow. + /// Useful for applying complex filtering logic beyond simple log level filtering. + /// + public sealed class AdvancedFilterFlow : FlowBase + { + private readonly IFlow _innerFlow; + private readonly List> _filters; + + public AdvancedFilterFlow( + IFlow innerFlow, + LogLevel minimumLevel = LogLevel.Trace) + : base(innerFlow?.Name + "_Filter" ?? "AdvancedFilterFlow", minimumLevel) + { + _innerFlow = innerFlow ?? throw new ArgumentNullException(nameof(innerFlow)); + _filters = new List>(); + } + + /// + /// Adds a filter predicate. All predicates must return true for the log event to pass through. + /// + public void AddFilter(Predicate filter) + { + if (filter == null) + { + throw new ArgumentNullException(nameof(filter)); + } + + lock (_filters) + { + _filters.Add(filter); + } + } + + /// + /// Adds a filter that matches messages containing specific text (case-insensitive). + /// + public void AddMessageContainsFilter(string text) + { + if (string.IsNullOrEmpty(text)) + { + throw new ArgumentNullException(nameof(text)); + } + + var lowerText = text.ToLowerInvariant(); + AddFilter(logEvent => logEvent.Message.ToString().ToLowerInvariant().Contains(lowerText)); + } + + /// + /// Adds a filter that matches specific log categories. + /// + public void AddCategoryFilter(params string[] categories) + { + if (categories == null || categories.Length == 0) + { + throw new ArgumentException("At least one category must be specified", nameof(categories)); + } + + var categorySet = new HashSet(categories, StringComparer.OrdinalIgnoreCase); + AddFilter(logEvent => categorySet.Contains(logEvent.Category)); + } + + /// + /// Adds a filter that excludes specific log categories. + /// + public void AddExcludeCategoryFilter(params string[] categories) + { + if (categories == null || categories.Length == 0) + { + throw new ArgumentException("At least one category must be specified", nameof(categories)); + } + + var categorySet = new HashSet(categories, StringComparer.OrdinalIgnoreCase); + AddFilter(logEvent => !categorySet.Contains(logEvent.Category)); + } + + /// + /// Adds a filter for exceptions. + /// + public void AddExceptionFilter(bool onlyWithExceptions = true) + { + AddFilter(logEvent => onlyWithExceptions ? logEvent.Exception != null : logEvent.Exception == null); + } + + private bool PassesAllFilters(LogEvent logEvent) + { + lock (_filters) + { + foreach (var filter in _filters) + { + if (!filter(logEvent)) + { + return false; + } + } + } + + return true; + } + + public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) + { + if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { + return WriteResult.LevelFiltered; + } + + if (!PassesAllFilters(logEvent)) + { + Interlocked.Increment(ref DroppedCount); + return WriteResult.Success; + } + + var result = await _innerFlow.BlastAsync(logEvent, cancellationToken).ConfigureAwait(false); + if (result == WriteResult.Success) + { + Interlocked.Increment(ref BlastedCount); + } + else + { + Interlocked.Increment(ref DroppedCount); + } + + return result; + } + + public override async Task BlastBatchAsync(ReadOnlyMemory logEvents, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return WriteResult.FlowDisabled; + } + + var filteredEvents = new List(); + foreach (var logEvent in logEvents.Span) + { + if (IsLogLevelEnabled(logEvent) && PassesAllFilters(logEvent)) + { + filteredEvents.Add(logEvent); + Interlocked.Increment(ref BlastedCount); + } + else + { + Interlocked.Increment(ref DroppedCount); + } + } + + if (filteredEvents.Count > 0) + { + return await _innerFlow.BlastBatchAsync(filteredEvents.ToArray(), cancellationToken).ConfigureAwait(false); + } + + return WriteResult.Success; + } + + public override async Task FlushAsync(CancellationToken cancellationToken = default) + { + await _innerFlow.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + public override async ValueTask DisposeAsync() + { + await _innerFlow.DisposeAsync().ConfigureAwait(false); + await base.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/AggregationFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/AggregationFlow.cs new file mode 100644 index 0000000..9448363 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/AggregationFlow.cs @@ -0,0 +1,189 @@ +using EonaCat.LogStack.Core; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace EonaCat.LogStack.Flows +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/License for full license details. + + /// + /// A flow that aggregates log events and periodically emits summary logs. + /// Groups logs by category or level and provides statistics. + /// + public sealed class AggregationFlow : FlowBase + { + private readonly IFlow _innerFlow; + private readonly TimeSpan _aggregationInterval; + private readonly Dictionary _stats; + private readonly Timer _flushTimer; + private readonly object _statsLock = new(); + private bool _disposed; + + public AggregationFlow( + IFlow innerFlow, + int aggregationIntervalMs = 60000, + LogLevel minimumLevel = LogLevel.Trace) + : base(innerFlow?.Name + "_Aggregated" ?? "AggregationFlow", minimumLevel) + { + _innerFlow = innerFlow ?? throw new ArgumentNullException(nameof(innerFlow)); + _aggregationInterval = TimeSpan.FromMilliseconds(aggregationIntervalMs > 0 ? aggregationIntervalMs : 60000); + _stats = new Dictionary(); + _flushTimer = new Timer(FlushAggregationCallback, null, _aggregationInterval, _aggregationInterval); + } + + private class AggregationStats + { + public int Count { get; set; } + public int ErrorCount { get; set; } + public int WarningCount { get; set; } + public DateTime FirstOccurrence { get; set; } + public DateTime LastOccurrence { get; set; } + public HashSet Messages { get; } = new HashSet(); + } + + public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) + { + if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { + return WriteResult.LevelFiltered; + } + + lock (_statsLock) + { + var key = $"{logEvent.Category}_{logEvent.Level}"; + + if (!_stats.TryGetValue(key, out var stats)) + { + stats = new AggregationStats { FirstOccurrence = DateTime.UtcNow }; + _stats[key] = stats; + } + + stats.Count++; + stats.LastOccurrence = DateTime.UtcNow; + + if (logEvent.Level >= LogLevel.Error) + { + stats.ErrorCount++; + } + else if (logEvent.Level == LogLevel.Warning) + { + stats.WarningCount++; + } + + var messageStr = logEvent.Message.ToString(); + if (!string.IsNullOrEmpty(messageStr) && stats.Messages.Count < 10) + { + stats.Messages.Add(messageStr); + } + } + + Interlocked.Increment(ref BlastedCount); + return WriteResult.Success; + } + + public override async Task BlastBatchAsync(ReadOnlyMemory logEvents, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return WriteResult.FlowDisabled; + } + + var eventsArray = logEvents.ToArray(); + foreach (var logEvent in eventsArray) + { + if (IsLogLevelEnabled(logEvent)) + { + await BlastAsync(logEvent, cancellationToken).ConfigureAwait(false); + } + } + + return WriteResult.Success; + } + + public override async Task FlushAsync(CancellationToken cancellationToken = default) + { + await EmitAggregationSummaryAsync(cancellationToken).ConfigureAwait(false); + } + + private async void FlushAggregationCallback(object? state) + { + if (!_disposed) + { + await EmitAggregationSummaryAsync(CancellationToken.None).ConfigureAwait(false); + } + } + + private async Task EmitAggregationSummaryAsync(CancellationToken cancellationToken) + { + Dictionary currentStats; + lock (_statsLock) + { + if (_stats.Count == 0) + { + return; + } + + currentStats = new Dictionary(_stats); + _stats.Clear(); + } + + var summaryMessages = new List(); + summaryMessages.Add($"=== Log Aggregation Summary (Period: {_aggregationInterval.TotalSeconds:F0}s) ==="); + + foreach (var kvp in currentStats.OrderBy(x => x.Key)) + { + var key = kvp.Key; + var stats = kvp.Value; + var duration = (stats.LastOccurrence - stats.FirstOccurrence).TotalSeconds; + + summaryMessages.Add( + $"Category-Level: {key} | Count: {stats.Count} | " + + $"Errors: {stats.ErrorCount} | Warnings: {stats.WarningCount} | " + + $"Duration: {duration:F2}s"); + + if (stats.Messages.Count > 0) + { + summaryMessages.Add($" Sample messages: {string.Join("; ", stats.Messages.Take(3))}"); + } + } + + var summaryMessage = string.Join(Environment.NewLine, summaryMessages); + + var summaryEvent = new LogEvent + { + Message = summaryMessage.AsMemory(), + Category = "AggregationFlow", + Level = LogLevel.Information, + Timestamp = LogEvent.CreateTimestamp(DateTime.UtcNow), + Exception = null, + Properties = new Dictionary + { + { "AggregationType", "Summary" }, + { "StatsCount", currentStats.Count } + } + }; + + try + { + await _innerFlow.BlastAsync(summaryEvent, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"AggregationFlow summary emit error: {ex.Message}"); + } + } + + public override async ValueTask DisposeAsync() + { + _disposed = true; + _flushTimer?.Dispose(); + await EmitAggregationSummaryAsync(default).ConfigureAwait(false); + await _innerFlow.DisposeAsync().ConfigureAwait(false); + await base.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/BufferedFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/BufferedFlow.cs new file mode 100644 index 0000000..e2f9a58 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/BufferedFlow.cs @@ -0,0 +1,158 @@ +using EonaCat.LogStack.Core; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace EonaCat.LogStack.Flows +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/License for full license details. + + /// + /// A flow that buffers log events and flushes them on a schedule or when a size threshold is reached. + /// Useful for improving performance by batching writes to an underlying flow. + /// + public sealed class BufferedFlow : FlowBase + { + private readonly IFlow _innerFlow; + private readonly int _bufferSize; + private readonly TimeSpan _flushInterval; + private readonly List _buffer; + private readonly SemaphoreSlim _semaphore; + private readonly Timer _flushTimer; + private readonly object _bufferLock = new(); + private bool _disposed; + + public BufferedFlow( + IFlow innerFlow, + int bufferSize = 1000, + int flushIntervalMs = 5000, + LogLevel minimumLevel = LogLevel.Trace) + : base(innerFlow?.Name + "_Buffered" ?? "BufferedFlow", minimumLevel) + { + _innerFlow = innerFlow ?? throw new ArgumentNullException(nameof(innerFlow)); + _bufferSize = bufferSize > 0 ? bufferSize : throw new ArgumentException("Buffer size must be greater than 0", nameof(bufferSize)); + _flushInterval = TimeSpan.FromMilliseconds(flushIntervalMs > 0 ? flushIntervalMs : 5000); + _buffer = new List(_bufferSize); + _semaphore = new SemaphoreSlim(1, 1); + _flushTimer = new Timer(FlushTimerCallback, null, _flushInterval, _flushInterval); + } + + public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) + { + if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { + return WriteResult.LevelFiltered; + } + + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + lock (_bufferLock) + { + _buffer.Add(logEvent); + Interlocked.Increment(ref BlastedCount); + + if (_buffer.Count >= _bufferSize) + { + // Return a fire-and-forget task to flush + _ = FlushInternalAsync(cancellationToken); + } + } + + return WriteResult.Success; + } + finally + { + _semaphore.Release(); + } + } + + public override async Task BlastBatchAsync(ReadOnlyMemory logEvents, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return WriteResult.FlowDisabled; + } + + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + lock (_bufferLock) + { + foreach (var logEvent in logEvents.Span) + { + if (IsLogLevelEnabled(logEvent)) + { + _buffer.Add(logEvent); + Interlocked.Increment(ref BlastedCount); + } + } + + if (_buffer.Count >= _bufferSize) + { + _ = FlushInternalAsync(cancellationToken); + } + } + + return WriteResult.Success; + } + finally + { + _semaphore.Release(); + } + } + + public override async Task FlushAsync(CancellationToken cancellationToken = default) + { + await FlushInternalAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task FlushInternalAsync(CancellationToken cancellationToken) + { + List eventsToFlush; + lock (_bufferLock) + { + if (_buffer.Count == 0) + { + return; + } + + eventsToFlush = new List(_buffer); + _buffer.Clear(); + } + + if (eventsToFlush.Count > 0) + { + try + { + await _innerFlow.BlastBatchAsync(eventsToFlush.ToArray(), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + Interlocked.Add(ref DroppedCount, eventsToFlush.Count); + System.Diagnostics.Debug.WriteLine($"BufferedFlow flush error: {ex.Message}"); + } + } + } + + private void FlushTimerCallback(object? state) + { + if (!_disposed) + { + _ = FlushInternalAsync(CancellationToken.None); + } + } + + public override async ValueTask DisposeAsync() + { + _disposed = true; + _flushTimer?.Dispose(); + await FlushAsync(default).ConfigureAwait(false); + _semaphore?.Dispose(); + await _innerFlow.DisposeAsync().ConfigureAwait(false); + await base.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/DuplicateDetectionFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/DuplicateDetectionFlow.cs new file mode 100644 index 0000000..895aa1c --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/DuplicateDetectionFlow.cs @@ -0,0 +1,182 @@ +using EonaCat.LogStack.Core; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace EonaCat.LogStack.Flows +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/License for full license details. + + /// + /// A flow that detects and suppresses duplicate log events. + /// Uses content-based hashing to identify duplicates within a rolling time window. + /// + public sealed class DuplicateDetectionFlow : FlowBase + { + private readonly IFlow _innerFlow; + private readonly int _windowSizeMs; + private readonly Dictionary _seenHashes; + private readonly Timer _pruneTimer; + private readonly object _hashLock = new(); + private bool _disposed; + + private class DuplicateEntry + { + public DateTime FirstSeen { get; set; } + public int Count { get; set; } + } + + public DuplicateDetectionFlow( + IFlow innerFlow, + int windowSizeMs = 300000, + int pruneIntervalMs = 60000, + LogLevel minimumLevel = LogLevel.Trace) + : base(innerFlow?.Name + "_DuplicateDetection" ?? "DuplicateDetectionFlow", minimumLevel) + { + _innerFlow = innerFlow ?? throw new ArgumentNullException(nameof(innerFlow)); + _windowSizeMs = windowSizeMs > 0 ? windowSizeMs : 300000; + _seenHashes = new Dictionary(); + _pruneTimer = new Timer(PruneOldEntriesCallback, null, pruneIntervalMs > 0 ? pruneIntervalMs : 60000, pruneIntervalMs > 0 ? pruneIntervalMs : 60000); + } + + private int ComputeHash(LogEvent logEvent) + { + unchecked + { + int hash = 17; + hash = hash * 31 + logEvent.Message.ToString().GetHashCode(); + hash = hash * 31 + (logEvent.Category?.GetHashCode() ?? 0); + hash = hash * 31 + logEvent.Level.GetHashCode(); + hash = hash * 31 + (logEvent.Exception?.GetType().Name.GetHashCode() ?? 0); + return hash; + } + } + + private bool IsDuplicate(LogEvent logEvent, out bool isNewException) + { + isNewException = false; + var hash = ComputeHash(logEvent); + + lock (_hashLock) + { + var now = DateTime.UtcNow; + + if (_seenHashes.TryGetValue(hash, out var entry)) + { + var age = (now - entry.FirstSeen).TotalMilliseconds; + if (age < _windowSizeMs) + { + entry.Count++; + return true; + } + else + { + _seenHashes[hash] = new DuplicateEntry { FirstSeen = now, Count = 1 }; + return false; + } + } + else + { + _seenHashes[hash] = new DuplicateEntry { FirstSeen = now, Count = 1 }; + return false; + } + } + } + + private void PruneOldEntriesCallback(object? state) + { + if (!_disposed) + { + lock (_hashLock) + { + var now = DateTime.UtcNow; + var keysToRemove = new List(); + + foreach (var kvp in _seenHashes) + { + var age = (now - kvp.Value.FirstSeen).TotalMilliseconds; + if (age >= _windowSizeMs) + { + keysToRemove.Add(kvp.Key); + } + } + + foreach (var key in keysToRemove) + { + _seenHashes.Remove(key); + } + } + } + } + + public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) + { + if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { + return WriteResult.LevelFiltered; + } + + if (IsDuplicate(logEvent, out _)) + { + Interlocked.Increment(ref DroppedCount); + return WriteResult.Success; + } + + var result = await _innerFlow.BlastAsync(logEvent, cancellationToken).ConfigureAwait(false); + if (result == WriteResult.Success) + { + Interlocked.Increment(ref BlastedCount); + } + else + { + Interlocked.Increment(ref DroppedCount); + } + + return result; + } + + public override async Task BlastBatchAsync(ReadOnlyMemory logEvents, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return WriteResult.FlowDisabled; + } + + var uniqueEvents = new List(); + foreach (var logEvent in logEvents.Span) + { + if (IsLogLevelEnabled(logEvent) && !IsDuplicate(logEvent, out _)) + { + uniqueEvents.Add(logEvent); + Interlocked.Increment(ref BlastedCount); + } + else + { + Interlocked.Increment(ref DroppedCount); + } + } + + if (uniqueEvents.Count > 0) + { + return await _innerFlow.BlastBatchAsync(uniqueEvents.ToArray(), cancellationToken).ConfigureAwait(false); + } + + return WriteResult.Success; + } + + public override async Task FlushAsync(CancellationToken cancellationToken = default) + { + await _innerFlow.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + public override async ValueTask DisposeAsync() + { + _disposed = true; + _pruneTimer?.Dispose(); + await _innerFlow.DisposeAsync().ConfigureAwait(false); + await base.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/FileFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/FileFlow.cs index 10ac1fa..fe0e59e 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/FileFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/FileFlow.cs @@ -51,6 +51,7 @@ namespace EonaCat.LogStack.Flows private readonly BlockingCollection _queue; private readonly ConcurrentQueue _compressionQueue = new ConcurrentQueue(); private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + private volatile bool _isDisposing; // Track disposal state to prevent accessing disposed objects private readonly Thread _writerThread; private readonly Thread _compressionThread; private readonly Task _flushTask; @@ -217,25 +218,36 @@ namespace EonaCat.LogStack.Flows // Fall back to a per-user temp directory. try { - Directory.CreateDirectory(_directory); + // Use DirectoryPermissionHelper to ensure directory with proper permissions + if (!Helpers.DirectoryPermissionHelper.EnsureDirectoryHierarchy(_directory)) + { + throw new UnauthorizedAccessException($"Cannot write to directory: {Helpers.DirectoryPermissionHelper.GetAccessIssueDiagnosis(_directory)}"); + } } - catch + catch (Exception ex) { try { var processId = Process.GetCurrentProcess().Id; var newDirectory = Path.Combine(Path.GetTempPath(), "EonaCat.LogStack", processId.ToString()); - Directory.CreateDirectory(newDirectory); - OnDirectoryException?.Invoke(this, $"FileFlow: Could not create directory: '{_directory}', using directory '{newDirectory}' instead"); - _directory = newDirectory; + + if (Helpers.DirectoryPermissionHelper.EnsureDirectoryHierarchy(newDirectory)) + { + OnDirectoryException?.Invoke(this, $"FileFlow: Could not create directory: '{_directory}' ({ex.Message}), using directory '{newDirectory}' instead"); + _directory = newDirectory; + } + else + { + throw new InvalidOperationException($"Failed to create fallback directory: {newDirectory}"); + } } catch { var newDirectory = Path.GetTempPath(); - OnDirectoryException?.Invoke(this, $"FileFlow: Could not create directory: '{_directory}', using directory '{newDirectory}' instead"); + OnDirectoryException?.Invoke(this, $"FileFlow: Could not create any writable directory, falling back to temp: '{newDirectory}'. Original error: {ex.Message}"); _directory = newDirectory; - // Last resort: disable file output by pointing to a safe-ish temp path. + // Last resort: disable file output by pointing to temp path. // The writer thread still runs and swallows failures. } } @@ -815,13 +827,15 @@ namespace EonaCat.LogStack.Flows } IsEnabled = false; + _isDisposing = true; // Signal all threads that disposal is in progress _queue.CompleteAdding(); _cts.Cancel(); - _writerThread.Join(2000); + // Give threads more time to gracefully exit (increased from 2000ms to 5000ms) + _writerThread.Join(5000); _compressionSignal.Release(); - _compressionThread.Join(2000); + _compressionThread.Join(5000); lock (_fileLock) { @@ -842,7 +856,16 @@ namespace EonaCat.LogStack.Flows _openFiles.Clear(); } - _cts.Dispose(); + // Only dispose the CTS after all threads have been signaled and given time to exit + try + { + _cts.Dispose(); + } + catch (ObjectDisposedException) + { + // Already disposed, ignore + } + _compressionSignal.Dispose(); _queue.Dispose(); @@ -857,8 +880,18 @@ namespace EonaCat.LogStack.Flows LogEvent e; try { + // Check if CTS is disposed before using it + if (_isDisposing) + { + break; + } e = _queue.Take(_cts.Token); } + catch (ObjectDisposedException) + { + // CancellationTokenSource was disposed; gracefully exit + break; + } catch (OperationCanceledException) { break; } catch (InvalidOperationException) { break; } @@ -1013,7 +1046,16 @@ namespace EonaCat.LogStack.Flows Interlocked.Exchange(ref _lastErrorTimestamp, DateTime.UtcNow.Ticks); try { - WriteToConsoleError("[FileFlow] Write error for '" + path + "': " + ex.Message); + var diagnosis = ""; + if (ex is UnauthorizedAccessException || ex is System.IO.IOException) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + { + diagnosis = " | " + Helpers.DirectoryPermissionHelper.GetAccessIssueDiagnosis(dir); + } + } + WriteToConsoleError("[FileFlow] Write error for '" + path + "': " + ex.Message + diagnosis); } catch { /* Do nothing */ } } @@ -1511,7 +1553,11 @@ namespace EonaCat.LogStack.Flows string dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) { - Directory.CreateDirectory(dir); + // Use DirectoryPermissionHelper to ensure directory with permissions + if (!Helpers.DirectoryPermissionHelper.EnsureDirectoryHierarchy(dir)) + { + throw new UnauthorizedAccessException($"Cannot create directory: {Helpers.DirectoryPermissionHelper.GetAccessIssueDiagnosis(dir)}"); + } } fs = new FileStream( @@ -1584,7 +1630,16 @@ namespace EonaCat.LogStack.Flows _openFiles.Remove(path); } - WriteToConsoleError("[FileFlow] Failed to open '" + path + "': " + ex.Message); + var diagnosis = ""; + if (ex is UnauthorizedAccessException || ex is System.IO.IOException) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + { + diagnosis = " Diagnosis: " + Helpers.DirectoryPermissionHelper.GetAccessIssueDiagnosis(dir); + } + } + WriteToConsoleError("[FileFlow] Failed to open '" + path + "': " + ex.Message + diagnosis); } } catch (Exception ex) @@ -1710,8 +1765,21 @@ namespace EonaCat.LogStack.Flows { try { + // Check for disposal before attempting to use the token + if (_isDisposing) + { + DrainCompressionQueue(); + return; + } + _compressionSignal.Wait(_cts.Token); } + catch (ObjectDisposedException) + { + // Handle the case where CTS is disposed while waiting + DrainCompressionQueue(); + return; + } catch (OperationCanceledException) { DrainCompressionQueue(); @@ -1802,46 +1870,85 @@ namespace EonaCat.LogStack.Flows private void PeriodicFlushLoop() { - while (!_cts.Token.IsCancellationRequested) + try { - try + while (!_isDisposing && !_cts.Token.IsCancellationRequested) { - long mem = Interlocked.Read(ref _currentMemoryBytes); - int delay = mem > (long)(_maxMemoryBytes * 0.8) - ? 250 - : (int)_flushInterval.TotalMilliseconds; - - if (_cts.Token.WaitHandle.WaitOne(delay)) + try { - break; - } + long mem = Interlocked.Read(ref _currentMemoryBytes); + int delay = mem > (long)(_maxMemoryBytes * 0.8) + ? 250 + : (int)_flushInterval.TotalMilliseconds; - lock (_fileLock) - { - foreach (OpenFile of in _openFiles.Values) + // Take precaution before using token + if (_isDisposing) { - try { of.Writer.Flush(); } catch { /* ignore */ } + break; + } + + if (_cts.Token.WaitHandle.WaitOne(delay)) + { + break; + } + + lock (_fileLock) + { + foreach (OpenFile of in _openFiles.Values) + { + try { of.Writer.Flush(); } catch { /* ignore */ } + } } } + catch (ObjectDisposedException) + { + // Token source was disposed; exit gracefully + break; + } + catch (ThreadInterruptedException) { break; } + catch (Exception ex) + { + WriteToConsoleError("[FileFlow] Flush error: " + ex.Message); + } } - catch (ThreadInterruptedException) { break; } - catch (Exception ex) - { - WriteToConsoleError("[FileFlow] Flush error: " + ex.Message); - } + } + catch (Exception ex) + { + WriteToConsoleError("[FileFlow] PeriodicFlushLoop error: " + ex.Message); } } private void RetentionLoop() { - while (!_cts.Token.IsCancellationRequested) + try { - if (_cts.Token.WaitHandle.WaitOne(TimeSpan.FromMinutes(15))) + while (!_isDisposing && !_cts.Token.IsCancellationRequested) { - break; - } + try + { + // Check disposal state before accessing token + if (_isDisposing) + { + break; + } - ApplyRetention(); + if (_cts.Token.WaitHandle.WaitOne(TimeSpan.FromMinutes(15))) + { + break; + } + + ApplyRetention(); + } + catch (ObjectDisposedException) + { + // Token source was disposed; exit gracefully + break; + } + } + } + catch (Exception ex) + { + WriteToConsoleError("[FileFlow] RetentionLoop error: " + ex.Message); } } diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/MetricsAggregatorFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/MetricsAggregatorFlow.cs new file mode 100644 index 0000000..5ec3f3b --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/MetricsAggregatorFlow.cs @@ -0,0 +1,229 @@ +using EonaCat.LogStack.Core; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace EonaCat.LogStack.Flows +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/License for full license details. + + /// + /// A flow that collects performance metrics about log processing. + /// Tracks throughput, latency, and other performance indicators. + /// + public sealed class MetricsAggregatorFlow : FlowBase + { + private readonly object _metricsLock = new(); + private long _totalEventsProcessed; + private long _totalEventsDropped; + private long _totalLatencyMs; + private long _minLatencyMs = long.MaxValue; + private long _maxLatencyMs = 0; + private DateTime _metricsStartTime; + private readonly Queue _recentLatencies; + private readonly int _maxRecentLatencies; + + public MetricsAggregatorFlow( + int maxRecentLatencies = 100, + LogLevel minimumLevel = LogLevel.Trace) + : base("MetricsAggregatorFlow", minimumLevel) + { + _maxRecentLatencies = maxRecentLatencies > 0 ? maxRecentLatencies : 100; + _recentLatencies = new Queue(_maxRecentLatencies); + _metricsStartTime = DateTime.UtcNow; + } + + public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) + { + if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { + return WriteResult.LevelFiltered; + } + + var sw = Stopwatch.StartNew(); + try + { + sw.Stop(); + RecordMetric(success: true, latencyMs: sw.ElapsedMilliseconds); + Interlocked.Increment(ref BlastedCount); + return WriteResult.Success; + } + catch (Exception) + { + sw.Stop(); + RecordMetric(success: false, latencyMs: sw.ElapsedMilliseconds); + Interlocked.Increment(ref DroppedCount); + return WriteResult.Failed; + } + } + + public override async Task BlastBatchAsync(ReadOnlyMemory logEvents, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return WriteResult.FlowDisabled; + } + + var sw = Stopwatch.StartNew(); + try + { + int processedCount = 0; + foreach (var logEvent in logEvents.Span) + { + if (IsLogLevelEnabled(logEvent)) + { + processedCount++; + } + } + + sw.Stop(); + RecordMetric(success: true, latencyMs: sw.ElapsedMilliseconds); + Interlocked.Add(ref BlastedCount, processedCount); + Interlocked.Add(ref DroppedCount, logEvents.Length - processedCount); + return WriteResult.Success; + } + catch (Exception) + { + sw.Stop(); + RecordMetric(success: false, latencyMs: sw.ElapsedMilliseconds); + Interlocked.Add(ref DroppedCount, logEvents.Length); + return WriteResult.Failed; + } + } + + private void RecordMetric(bool success, long latencyMs) + { + lock (_metricsLock) + { + if (success) + { + _totalEventsProcessed++; + _totalLatencyMs += latencyMs; + _minLatencyMs = Math.Min(_minLatencyMs, latencyMs); + _maxLatencyMs = Math.Max(_maxLatencyMs, latencyMs); + + if (_recentLatencies.Count >= _maxRecentLatencies) + { + _recentLatencies.Dequeue(); + } + _recentLatencies.Enqueue(latencyMs); + } + else + { + _totalEventsDropped++; + } + } + } + + /// + /// Gets current performance metrics snapshot. + /// + public MetricsSnapshot GetMetricsSnapshot() + { + lock (_metricsLock) + { + var totalProcessed = _totalEventsProcessed; + var totalDropped = _totalEventsDropped; + var totalLatency = _totalLatencyMs; + var minLatency = _minLatencyMs == long.MaxValue ? 0 : _minLatencyMs; + var maxLatency = _maxLatencyMs; + var recentLatencies = _recentLatencies.ToArray(); + + var avgLatency = totalProcessed > 0 ? totalLatency / totalProcessed : 0; + var medianLatency = recentLatencies.Length > 0 + ? recentLatencies[recentLatencies.Length / 2] + : 0; + + var elapsed = DateTime.UtcNow - _metricsStartTime; + var eventsPerSecond = elapsed.TotalSeconds > 0 + ? totalProcessed / elapsed.TotalSeconds + : 0; + + return new MetricsSnapshot + { + TotalProcessed = totalProcessed, + TotalDropped = totalDropped, + AverageLatencyMs = avgLatency, + MinLatencyMs = minLatency, + MaxLatencyMs = maxLatency, + MedianLatencyMs = medianLatency, + EventsPerSecond = eventsPerSecond, + ElapsedTime = elapsed, + CollectedAt = DateTime.UtcNow + }; + } + } + + /// + /// Resets metrics counters. + /// + public void ResetMetrics() + { + lock (_metricsLock) + { + _totalEventsProcessed = 0; + _totalEventsDropped = 0; + _totalLatencyMs = 0; + _minLatencyMs = long.MaxValue; + _maxLatencyMs = 0; + _recentLatencies.Clear(); + _metricsStartTime = DateTime.UtcNow; + } + } + + public override async Task FlushAsync(CancellationToken cancellationToken = default) + { + // Metrics flow doesn't buffer events, nothing to flush + return; + } + + public override ValueTask DisposeAsync() + { + return base.DisposeAsync(); + } + } + + /// + /// A snapshot of performance metrics at a point in time. + /// + public sealed class MetricsSnapshot + { + /// Total number of log events processed + public long TotalProcessed { get; set; } + + /// Total number of log events dropped + public long TotalDropped { get; set; } + + /// Average latency in milliseconds + public long AverageLatencyMs { get; set; } + + /// Minimum latency in milliseconds + public long MinLatencyMs { get; set; } + + /// Maximum latency in milliseconds + public long MaxLatencyMs { get; set; } + + /// Median latency in milliseconds + public long MedianLatencyMs { get; set; } + + /// Number of events processed per second + public double EventsPerSecond { get; set; } + + /// Total elapsed time since metrics started + public TimeSpan ElapsedTime { get; set; } + + /// When this snapshot was collected + public DateTime CollectedAt { get; set; } + + public override string ToString() + { + return $"Metrics[Processed={TotalProcessed}, Dropped={TotalDropped}, " + + $"AvgLat={AverageLatencyMs}ms, MinLat={MinLatencyMs}ms, MaxLat={MaxLatencyMs}ms, " + + $"Median={MedianLatencyMs}ms, Rate={EventsPerSecond:F2} eps, Elapsed={ElapsedTime.TotalSeconds:F2}s]"; + } + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/PipelineFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/PipelineFlow.cs new file mode 100644 index 0000000..adc832c --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/PipelineFlow.cs @@ -0,0 +1,253 @@ +using EonaCat.LogStack.Core; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace EonaCat.LogStack.Flows +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/License for full license details. + + /// + /// A flow that chains multiple flows together, passing log events through them in sequence. + /// Useful for composing complex logging behavior from simple components. + /// + public sealed class PipelineFlow : FlowBase + { + private readonly List _flows; + private readonly StopOnError _stopOnError; + + public enum StopOnError + { + /// Continue to next flow even if current flow fails + Continue = 0, + + /// Stop pipeline if any flow fails + StopImmediate = 1, + + /// Stop pipeline if core flows fail, continue for non-critical flows + StopOnCritical = 2 + } + + public PipelineFlow( + LogLevel minimumLevel = LogLevel.Trace, + StopOnError stopOnError = StopOnError.Continue) + : base("PipelineFlow", minimumLevel) + { + _flows = new List(); + _stopOnError = stopOnError; + } + + /// + /// Adds a flow to the pipeline. Flows are executed in the order they are added. + /// + public PipelineFlow AddFlow(IFlow flow) + { + if (flow == null) + { + throw new ArgumentNullException(nameof(flow)); + } + + lock (_flows) + { + _flows.Add(flow); + } + + return this; + } + + /// + /// Adds multiple flows to the pipeline. + /// + public PipelineFlow AddFlows(params IFlow[] flows) + { + if (flows == null) + { + throw new ArgumentNullException(nameof(flows)); + } + + foreach (var flow in flows) + { + AddFlow(flow); + } + + return this; + } + + public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) + { + if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { + return WriteResult.LevelFiltered; + } + + List flowsToExecute; + lock (_flows) + { + flowsToExecute = new List(_flows); + } + + if (flowsToExecute.Count == 0) + { + Interlocked.Increment(ref DroppedCount); + return WriteResult.Success; + } + + var overallResult = WriteResult.Success; + + foreach (var flow in flowsToExecute) + { + try + { + if (flow.IsEnabled && logEvent.Level >= flow.MinimumLevel) + { + var result = await flow.BlastAsync(logEvent, cancellationToken).ConfigureAwait(false); + + if (result != WriteResult.Success) + { + overallResult = result; + + if (_stopOnError == StopOnError.StopImmediate) + { + break; + } + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"PipelineFlow error in {flow.Name}: {ex.Message}"); + overallResult = WriteResult.Failed; + + if (_stopOnError == StopOnError.StopImmediate) + { + break; + } + } + } + + if (overallResult == WriteResult.Success) + { + Interlocked.Increment(ref BlastedCount); + } + else + { + Interlocked.Increment(ref DroppedCount); + } + + return overallResult; + } + + public override async Task BlastBatchAsync(ReadOnlyMemory logEvents, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return WriteResult.FlowDisabled; + } + + List flowsToExecute; + lock (_flows) + { + flowsToExecute = new List(_flows); + } + + if (flowsToExecute.Count == 0) + { + Interlocked.Add(ref DroppedCount, logEvents.Length); + return WriteResult.Success; + } + + var overallResult = WriteResult.Success; + var logsProcessed = 0; + + foreach (var flow in flowsToExecute) + { + try + { + if (flow.IsEnabled) + { + var result = await flow.BlastBatchAsync(logEvents, cancellationToken).ConfigureAwait(false); + + if (result != WriteResult.Success) + { + overallResult = result; + + if (_stopOnError == StopOnError.StopImmediate) + { + break; + } + } + + logsProcessed += logEvents.Length; + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"PipelineFlow batch error in {flow.Name}: {ex.Message}"); + overallResult = WriteResult.Failed; + + if (_stopOnError == StopOnError.StopImmediate) + { + break; + } + } + } + + if (logsProcessed > 0) + { + Interlocked.Add(ref BlastedCount, logsProcessed); + } + else + { + Interlocked.Add(ref DroppedCount, logEvents.Length); + } + + return overallResult; + } + + public override async Task FlushAsync(CancellationToken cancellationToken = default) + { + List flowsToFlush; + lock (_flows) + { + flowsToFlush = new List(_flows); + } + + foreach (var flow in flowsToFlush) + { + try + { + await flow.FlushAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"PipelineFlow flush error in {flow.Name}: {ex.Message}"); + } + } + } + + public override async ValueTask DisposeAsync() + { + List flowsToDispose; + lock (_flows) + { + flowsToDispose = new List(_flows); + } + + foreach (var flow in flowsToDispose) + { + try + { + await flow.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"PipelineFlow dispose error in {flow.Name}: {ex.Message}"); + } + } + + await base.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/RotatingFileFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/RotatingFileFlow.cs new file mode 100644 index 0000000..f89d750 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/RotatingFileFlow.cs @@ -0,0 +1,247 @@ +using EonaCat.LogStack.Core; +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace EonaCat.LogStack.Flows +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/License for full license details. + + /// + /// A flow that writes logs to files with automatic date-based rotation. + /// Creates new log files daily, hourly, or at custom intervals without external dependencies. + /// + public sealed class RotatingFileFlow : FlowBase + { + public enum RotationFrequency + { + Hourly = 0, + Daily = 1, + Monthly = 2 + } + + private readonly string _baseDirectory; + private readonly string _fileNamePattern; + private readonly RotationFrequency _frequency; + private readonly Encoding _encoding; + private readonly object _fileLock = new(); + private StreamWriter? _currentWriter; + private string _currentFileName = string.Empty; + private DateTime _lastRotationCheck = DateTime.MinValue; + + public RotatingFileFlow( + string baseDirectory, + string fileNamePattern = "logs-{date}.txt", + RotationFrequency frequency = RotationFrequency.Daily, + Encoding? encoding = null, + LogLevel minimumLevel = LogLevel.Trace) + : base("RotatingFileFlow", minimumLevel) + { + _baseDirectory = baseDirectory ?? throw new ArgumentNullException(nameof(baseDirectory)); + _fileNamePattern = fileNamePattern ?? throw new ArgumentNullException(nameof(fileNamePattern)); + _frequency = frequency; + _encoding = encoding ?? Encoding.UTF8; + + try + { + if (!Directory.Exists(_baseDirectory)) + { + Directory.CreateDirectory(_baseDirectory); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"RotatingFileFlow directory creation error: {ex.Message}"); + } + } + + private string GetCurrentFileName() + { + var now = DateTime.Now; + string datePart = _frequency switch + { + RotationFrequency.Hourly => now.ToString("yyyy-MM-dd_HH"), + RotationFrequency.Monthly => now.ToString("yyyy-MM"), + RotationFrequency.Daily => now.ToString("yyyy-MM-dd"), + _ => now.ToString("yyyy-MM-dd") + }; + + var fileName = _fileNamePattern.Replace("{date}", datePart); + return Path.Combine(_baseDirectory, fileName); + } + + private bool ShouldRotate() + { + var now = DateTime.Now; + var timeSinceLastCheck = now - _lastRotationCheck; + + return _frequency switch + { + RotationFrequency.Hourly => timeSinceLastCheck.TotalHours >= 1, + RotationFrequency.Monthly => timeSinceLastCheck.TotalDays >= 30, + RotationFrequency.Daily => timeSinceLastCheck.TotalDays >= 1, + _ => false + }; + } + + private async Task EnsureWriterAsync() + { + var targetFileName = GetCurrentFileName(); + + if (_currentWriter != null && _currentFileName == targetFileName && !ShouldRotate()) + { + return; + } + + _lastRotationCheck = DateTime.Now; + + try + { + _currentWriter?.Dispose(); + + _currentFileName = targetFileName; + var fileStream = new FileStream( + _currentFileName, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 4096, + FileOptions.SequentialScan); + + _currentWriter = new StreamWriter(fileStream, _encoding, 4096, false); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"RotatingFileFlow writer creation error: {ex.Message}"); + throw; + } + } + + public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) + { + if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { + return WriteResult.LevelFiltered; + } + + lock (_fileLock) + { + try + { + EnsureWriterAsync().Wait(cancellationToken); + + if (_currentWriter == null) + { + Interlocked.Increment(ref DroppedCount); + return WriteResult.Failed; + } + + var formattedMessage = FormatLogEvent(logEvent); + _currentWriter.WriteLine(formattedMessage); + _currentWriter.Flush(); + + Interlocked.Increment(ref BlastedCount); + return WriteResult.Success; + } + catch (Exception ex) + { + Interlocked.Increment(ref DroppedCount); + System.Diagnostics.Debug.WriteLine($"RotatingFileFlow write error: {ex.Message}"); + return WriteResult.Failed; + } + } + } + + public override async Task BlastBatchAsync(ReadOnlyMemory logEvents, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return WriteResult.FlowDisabled; + } + + lock (_fileLock) + { + try + { + EnsureWriterAsync().Wait(cancellationToken); + + if (_currentWriter == null) + { + Interlocked.Add(ref DroppedCount, logEvents.Length); + return WriteResult.Failed; + } + + foreach (var logEvent in logEvents.Span) + { + if (IsLogLevelEnabled(logEvent)) + { + var formattedMessage = FormatLogEvent(logEvent); + _currentWriter.WriteLine(formattedMessage); + Interlocked.Increment(ref BlastedCount); + } + else + { + Interlocked.Increment(ref DroppedCount); + } + } + + _currentWriter.Flush(); + return WriteResult.Success; + } + catch (Exception ex) + { + Interlocked.Add(ref DroppedCount, logEvents.Length); + System.Diagnostics.Debug.WriteLine($"RotatingFileFlow batch write error: {ex.Message}"); + return WriteResult.Failed; + } + } + } + + private string FormatLogEvent(LogEvent logEvent) + { + var sb = new StringBuilder(); + var dateTime = LogEvent.GetDateTime(logEvent.Timestamp); + sb.Append($"[{dateTime:yyyy-MM-dd HH:mm:ss.fff}] "); + sb.Append($"[{logEvent.Level}] "); + if (!string.IsNullOrEmpty(logEvent.Category)) + { + sb.Append($"[{logEvent.Category}] "); + } + sb.Append(logEvent.Message); + + if (logEvent.Exception != null) + { + sb.AppendLine(); + sb.Append("Exception: "); + sb.Append(logEvent.Exception.GetType().Name); + sb.Append(": "); + sb.Append(logEvent.Exception.Message); + } + + return sb.ToString(); + } + + public override async Task FlushAsync(CancellationToken cancellationToken = default) + { + lock (_fileLock) + { + _currentWriter?.Flush(); + } + } + + public override async ValueTask DisposeAsync() + { + lock (_fileLock) + { + _currentWriter?.Flush(); + _currentWriter?.Dispose(); + _currentWriter = null; + } + + await base.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/TransformFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/TransformFlow.cs new file mode 100644 index 0000000..50ccbc1 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/TransformFlow.cs @@ -0,0 +1,107 @@ +using EonaCat.LogStack.Core; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace EonaCat.LogStack.Flows +{ + // This file is part of the EonaCat project(s) which is released under the Apache License. + // See the LICENSE file or go to https://EonaCat.com/License for full license details. + + /// + /// A flow that transforms log events before passing them to an inner flow. + /// Allows modification of messages, properties, and other attributes. + /// + public sealed class TransformFlow : FlowBase + { + private readonly IFlow _innerFlow; + private readonly Func _transformer; + + public TransformFlow( + IFlow innerFlow, + Func transformer, + LogLevel minimumLevel = LogLevel.Trace) + : base(innerFlow?.Name + "_Transform" ?? "TransformFlow", minimumLevel) + { + _innerFlow = innerFlow ?? throw new ArgumentNullException(nameof(innerFlow)); + _transformer = transformer ?? throw new ArgumentNullException(nameof(transformer)); + } + + public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) + { + if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { + return WriteResult.LevelFiltered; + } + + try + { + var transformedEvent = _transformer(logEvent); + var result = await _innerFlow.BlastAsync(transformedEvent, cancellationToken).ConfigureAwait(false); + if (result == WriteResult.Success) + { + Interlocked.Increment(ref BlastedCount); + } + else + { + Interlocked.Increment(ref DroppedCount); + } + + return result; + } + catch (Exception ex) + { + Interlocked.Increment(ref DroppedCount); + System.Diagnostics.Debug.WriteLine($"TransformFlow error: {ex.Message}"); + return WriteResult.Failed; + } + } + + public override async Task BlastBatchAsync(ReadOnlyMemory logEvents, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return WriteResult.FlowDisabled; + } + + var eventsArray = logEvents.ToArray(); + var transformedEvents = new List(); + foreach (var logEvent in eventsArray) + { + if (IsLogLevelEnabled(logEvent)) + { + try + { + var transformed = _transformer(logEvent); + transformedEvents.Add(transformed); + Interlocked.Increment(ref BlastedCount); + } + catch (Exception ex) + { + Interlocked.Increment(ref DroppedCount); + System.Diagnostics.Debug.WriteLine($"TransformFlow batch error: {ex.Message}"); + } + } + } + + if (transformedEvents.Count > 0) + { + return await _innerFlow.BlastBatchAsync(transformedEvents.ToArray(), cancellationToken).ConfigureAwait(false); + } + + return WriteResult.Success; + } + + public override async Task FlushAsync(CancellationToken cancellationToken = default) + { + await _innerFlow.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + public override async ValueTask DisposeAsync() + { + await _innerFlow.DisposeAsync().ConfigureAwait(false); + await base.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/EonaCat.LogStack/Helpers/DirectoryPermissionHelper.cs b/EonaCat.LogStack/Helpers/DirectoryPermissionHelper.cs new file mode 100644 index 0000000..0bccec8 --- /dev/null +++ b/EonaCat.LogStack/Helpers/DirectoryPermissionHelper.cs @@ -0,0 +1,221 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace EonaCat.LogStack.Helpers +{ + /// + /// Utility class for managing directory permissions and ensuring write access. + /// Handles both Windows and Unix-like systems gracefully. + /// + public static class DirectoryPermissionHelper + { + /// + /// Checks if the current process has write access to the specified directory. + /// Returns true if writable, false otherwise. Never throws. + /// + public static bool CanWrite(string dirPath) + { + if (string.IsNullOrEmpty(dirPath)) + { + return false; + } + + try + { + if (!Directory.Exists(dirPath)) + { + // Check if we can create it + var parentPath = Path.GetDirectoryName(dirPath); + if (string.IsNullOrEmpty(parentPath) || parentPath == dirPath) + { + return false; + } + return CanWrite(parentPath); + } + + // Try to create a temporary file to verify write access + var testFile = Path.Combine(dirPath, ".permission_test_" + Guid.NewGuid().ToString(".")); + try + { + using (var fs = File.Create(testFile, 1, FileOptions.DeleteOnClose)) + { + fs.WriteByte(0); + } + return true; + } + catch + { + return false; + } + } + catch + { + return false; + } + } + + /// + /// Attempts to create a directory and fix permissions if needed. + /// Never throws - logs errors and returns false if it cannot fix permissions. + /// + public static bool EnsureDirectory(string dirPath) + { + if (string.IsNullOrEmpty(dirPath)) + { + return false; + } + + try + { + // Create directory if it doesn't exist + if (!Directory.Exists(dirPath)) + { + Directory.CreateDirectory(dirPath); + } + + // Verify we have write access + if (CanWrite(dirPath)) + { + return true; + } + + // Try to fix permissions (Windows-specific via attribute clearing) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + try + { + TryFixWindowsPermissions(dirPath); + return CanWrite(dirPath); + } + catch + { + // Permission fixing failed, but that's okay - we tried + } + } + + // For Unix-like systems, we can't easily fix permissions without external tools + // Just return whether we can write + return CanWrite(dirPath); + } + catch + { + return false; + } + } + + /// + /// Attempts to ensure all parent directories have write access. + /// Creates missing directories. + /// + public static bool EnsureDirectoryHierarchy(string dirPath) + { + if (string.IsNullOrEmpty(dirPath)) + { + return false; + } + + try + { + var current = Path.GetDirectoryName(dirPath); + var stack = new System.Collections.Generic.Stack(); + + // Build directory hierarchy + while (!string.IsNullOrEmpty(current) && current != Path.GetPathRoot(current)) + { + if (Directory.Exists(current)) + { + break; + } + stack.Push(current); + current = Path.GetDirectoryName(current); + } + + // Create missing directories from root to target + while (stack.Count > 0) + { + var dir = stack.Pop(); + if (!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + } + + // Ensure final target directory + return EnsureDirectory(dirPath); + } + catch + { + return false; + } + } + + /// + /// Attempts to grant write permissions on Windows by clearing the read-only attribute. + /// This is a best-effort operation and may fail on restricted systems. + /// + private static void TryFixWindowsPermissions(string dirPath) + { + try + { + var dirInfo = new DirectoryInfo(dirPath); + + // Clear read-only attribute if set + if ((dirInfo.Attributes & FileAttributes.ReadOnly) != 0) + { + dirInfo.Attributes &= ~FileAttributes.ReadOnly; + } + + // Note: Full ACL manipulation would require System.Security.AccessControl + // which is a separate NuGet package. We're using the basic attribute approach + // which covers most common permission issues. + } + catch + { + // Silently fail - we tried our best + throw; + } + } + + /// + /// Gets a safe description of why directory access failed. + /// + public static string GetAccessIssueDiagnosis(string dirPath) + { + try + { + if (string.IsNullOrEmpty(dirPath)) + { + return "Directory path is null or empty"; + } + + if (!Directory.Exists(dirPath)) + { + var parent = Path.GetDirectoryName(dirPath); + if (string.IsNullOrEmpty(parent)) + { + return $"Cannot determine parent directory: {dirPath}"; + } + if (!Directory.Exists(parent)) + { + return $"Parent directory does not exist: {parent}"; + } + return $"Directory does not exist and parent is not writable: {dirPath}"; + } + + var dirInfo = new DirectoryInfo(dirPath); + + if ((dirInfo.Attributes & FileAttributes.ReadOnly) != 0) + { + return $"Directory is read-only: {dirPath}"; + } + + return $"Directory exists but no write permission: {dirPath}. Check file system permissions."; + } + catch (Exception ex) + { + return $"Error diagnosing directory: {ex.Message}"; + } + } + } +} diff --git a/EonaCat.LogStack/LogBuilder.cs b/EonaCat.LogStack/LogBuilder.cs index eb6ac33..a8fa3f2 100644 --- a/EonaCat.LogStack/LogBuilder.cs +++ b/EonaCat.LogStack/LogBuilder.cs @@ -12,6 +12,7 @@ using System.Linq; using System.Net.Http; using System.Net.Security; using System.Security.Cryptography.X509Certificates; +using System.Text; namespace EonaCat.LogStack.Configuration; @@ -1274,6 +1275,121 @@ public sealed class LogBuilder return this; } + /// + /// Writes log events to files with automatic date-based rotation. + /// Creates new log files daily, hourly, or at custom intervals. + /// + public LogBuilder WriteToRotatingFileFlow( + string baseDirectory, + string fileNamePattern = "logs-{date}.txt", + RotatingFileFlow.RotationFrequency frequency = RotatingFileFlow.RotationFrequency.Daily, + Encoding? encoding = null, + LogLevel minimumLevel = LogLevel.Trace) + { + _flows.Add(new RotatingFileFlow( + baseDirectory, + fileNamePattern, + frequency, + encoding, + minimumLevel)); + return this; + } + + /// + /// Chains multiple flows together, passing log events through them in sequence. + /// Useful for composing complex logging behavior from simple components. + /// + public LogBuilder WriteToPipelineFlow( + LogLevel minimumLevel = LogLevel.Trace, + PipelineFlow.StopOnError stopOnError = PipelineFlow.StopOnError.Continue) + { + _flows.Add(new PipelineFlow(minimumLevel, stopOnError)); + return this; + } + + /// + /// Adds a metrics aggregator flow that collects and reports logging metrics. + /// + public LogBuilder WriteToMetricsAggregatorFlow( + int maxRecentLatencies = 100, + LogLevel minimumLevel = LogLevel.Trace) + { + _flows.Add(new MetricsAggregatorFlow(maxRecentLatencies, minimumLevel)); + return this; + } + + /// + /// Adds duplicate detection to prevent repeated identical log messages within a time window. + /// + public LogBuilder WriteToDuplicateDetectionFlow( + IFlow innerFlow, + int windowSizeMs = 300000, + int pruneIntervalMs = 60000, + LogLevel minimumLevel = LogLevel.Trace) + { + _flows.Add(new DuplicateDetectionFlow( + innerFlow, + windowSizeMs, + pruneIntervalMs, + minimumLevel)); + return this; + } + + /// + /// Transforms log events using a custom function before forwarding to an inner flow. + /// + public LogBuilder WriteToTransformFlow( + IFlow innerFlow, + Func transformer, + LogLevel minimumLevel = LogLevel.Trace) + { + _flows.Add(new TransformFlow(innerFlow, transformer, minimumLevel)); + return this; + } + + /// + /// Aggregates log events by category and reports statistics periodically. + /// + public LogBuilder WriteToAggregationFlow( + IFlow innerFlow, + int aggregationIntervalMs = 60000, + LogLevel minimumLevel = LogLevel.Trace) + { + _flows.Add(new AggregationFlow( + innerFlow, + aggregationIntervalMs, + minimumLevel)); + return this; + } + + /// + /// Adds advanced filtering capabilities using predicates to selectively process log events. + /// + public LogBuilder WriteToAdvancedFilterFlow( + IFlow innerFlow, + LogLevel minimumLevel = LogLevel.Trace) + { + _flows.Add(new AdvancedFilterFlow(innerFlow, minimumLevel)); + return this; + } + + /// + /// Buffers log events and flushes them in batches to reduce I/O operations. + /// + public LogBuilder WriteToBufferedFlow( + IFlow innerFlow, + int bufferSize = 1000, + int flushIntervalMs = 5000, + LogLevel minimumLevel = LogLevel.Trace) + { + _flows.Add(new BufferedFlow( + innerFlow, + bufferSize, + flushIntervalMs, + minimumLevel)); + return this; + } + /// /// Adds the which captures caller member/file/line. /// diff --git a/EonaCat.LogStack/Server.cs b/EonaCat.LogStack/Server.cs index 43e77bc..5041c9d 100644 --- a/EonaCat.LogStack/Server.cs +++ b/EonaCat.LogStack/Server.cs @@ -147,6 +147,12 @@ namespace EonaCat.LogStack.Server _isRunning = true; Metrics.StartedAt = DateTime.UtcNow; + // Initialize log directory with permission checking + if (!InitializeLogDirectory()) + { + Console.WriteLine("[EonaCat Server] WARNING: Could not initialize log directory. Logs will be dropped if write fails."); + } + var bind = ipAddress ?? _options.BindAddress ?? IPAddress.Any; var tasks = new List(); @@ -184,6 +190,40 @@ namespace EonaCat.LogStack.Server await Task.WhenAll(tasks).ConfigureAwait(false); } + /// + /// Initializes the log directory during startup, ensuring write permissions. + /// Returns true if successful, false if there were issues (but doesn't prevent startup). + /// + private bool InitializeLogDirectory() + { + try + { + var logDir = _options.LogsRootDirectory; + + if (!Helpers.DirectoryPermissionHelper.EnsureDirectoryHierarchy(logDir)) + { + Console.WriteLine($"[EonaCat Server] WARNING: Could not fully initialize log directory: {logDir}"); + Console.WriteLine($"[EonaCat Server] Diagnosis: {Helpers.DirectoryPermissionHelper.GetAccessIssueDiagnosis(logDir)}"); + return false; + } + + if (!Helpers.DirectoryPermissionHelper.CanWrite(logDir)) + { + Console.WriteLine($"[EonaCat Server] WARNING: Log directory exists but is not writable: {logDir}"); + Console.WriteLine($"[EonaCat Server] Please check file system permissions."); + return false; + } + + Console.WriteLine($"[EonaCat Server] Log directory ready: {Path.GetFullPath(logDir)}"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"[EonaCat Server] Error initializing log directory: {ex.GetType().Name}: {ex.Message}"); + return false; + } + } + /// Gracefully stop all transports. public void Stop() { @@ -515,26 +555,101 @@ namespace EonaCat.LogStack.Server protected virtual async Task ProcessLogAsync(string logData) { - var root = _options.LogsRootDirectory; - Directory.CreateDirectory(root); + const int maxRetries = 3; + const int retryDelayMs = 100; - var daily = Path.Combine(root, DateTime.Now.ToString("yyyyMMdd")); - Directory.CreateDirectory(daily); - - var basePath = Path.Combine(daily, "EonaCatLogs"); - var filePath = basePath + ".log"; - - int idx = 1; - while (File.Exists(filePath) && new FileInfo(filePath).Length > _options.MaxLogFileSize) + for (int attempt = 0; attempt < maxRetries; attempt++) { - filePath = $"{basePath}_{idx++}.log"; + try + { + var root = _options.LogsRootDirectory; + + // Ensure directory exists with permission handling + if (!EnsureLogDirectory(root)) + { + if (attempt < maxRetries - 1) + { + await Task.Delay(retryDelayMs).ConfigureAwait(false); + continue; + } + // Last attempt failed - log that we couldn't write + Console.WriteLine($"[EonaCat Server] WARNING: Cannot write to log directory. Reason: {Helpers.DirectoryPermissionHelper.GetAccessIssueDiagnosis(root)}"); + Interlocked.Increment(ref Metrics.TotalDropped); + return; + } + + var daily = Path.Combine(root, DateTime.Now.ToString("yyyyMMdd")); + + if (!EnsureLogDirectory(daily)) + { + if (attempt < maxRetries - 1) + { + await Task.Delay(retryDelayMs).ConfigureAwait(false); + continue; + } + Console.WriteLine($"[EonaCat Server] WARNING: Cannot create daily log directory: {daily}"); + Interlocked.Increment(ref Metrics.TotalDropped); + return; + } + + var basePath = Path.Combine(daily, "EonaCatLogs"); + var filePath = basePath + ".log"; + + int idx = 1; + while (File.Exists(filePath) && new FileInfo(filePath).Length > _options.MaxLogFileSize) + { + filePath = $"{basePath}_{idx++}.log"; + } + + File.AppendAllText(filePath, logData + Environment.NewLine); + Interlocked.Increment(ref Metrics.TotalWritten); + LogWritten?.Invoke(logData); + + // Cleanup happens asynchronously to avoid blocking + _ = Task.Run(() => CleanUpOldLogs()); + return; + } + catch (UnauthorizedAccessException ex) when (attempt < maxRetries - 1) + { + Console.WriteLine($"[EonaCat Server] Access denied to log directory (attempt {attempt + 1}/{maxRetries}): {ex.Message}"); + await Task.Delay(retryDelayMs * (attempt + 1)).ConfigureAwait(false); + } + catch (IOException ex) when (attempt < maxRetries - 1) + { + Console.WriteLine($"[EonaCat Server] IO error writing to log (attempt {attempt + 1}/{maxRetries}): {ex.Message}"); + await Task.Delay(retryDelayMs * (attempt + 1)).ConfigureAwait(false); + } + catch (Exception ex) + { + // Any other exception - log and drop the message to prevent crash + Console.WriteLine($"[EonaCat Server] Error writing log: {ex.GetType().Name}: {ex.Message}"); + Interlocked.Increment(ref Metrics.TotalDropped); + LogDropped?.Invoke(logData); + return; + } } - File.AppendAllText(filePath, logData + Environment.NewLine); - Interlocked.Increment(ref Metrics.TotalWritten); - LogWritten?.Invoke(logData); + // All retries exhausted + Console.WriteLine("[EonaCat Server] Max retries exhausted, dropping log message"); + Interlocked.Increment(ref Metrics.TotalDropped); + LogDropped?.Invoke(logData); + } - CleanUpOldLogs(); + /// + /// Ensures a log directory exists and is writable, with permission fixing if needed. + /// Returns true if successful, false otherwise. + /// + private bool EnsureLogDirectory(string dirPath) + { + try + { + return Helpers.DirectoryPermissionHelper.EnsureDirectoryHierarchy(dirPath); + } + catch (Exception ex) + { + Console.WriteLine($"[EonaCat Server] Failed to ensure log directory: {ex.Message}"); + return false; + } } private void CleanUpOldLogs()