From 5c435fb2f5c5b3788996a32be7eb05b78cfa5452 Mon Sep 17 00:00:00 2001 From: EonaCat Date: Tue, 4 Aug 2026 21:47:43 +0200 Subject: [PATCH] Updated encryption file Added braces --- EonaCat.LogStack/EonaCat.LogStack.csproj | 13 +- .../Flows/CorrelatedEventFlow.cs | 35 +- .../Flows/LocalStorageQueryFlow.cs | 6 + .../Flows/LogFilterPresetFlow.cs | 5 + .../Flows/PerformanceAnomalyDetectorFlow.cs | 21 + .../Extensions/LoggerFeatureExtensions.cs | 2 + .../Features/AlertingEngine.cs | 19 + .../Features/ConfigurationValidator.cs | 10 + .../Features/CorrelationDashboardHelper.cs | 12 + .../Features/LogReplayEngine.cs | 20 + .../Features/LogSearchEngine.cs | 14 + .../EonaCat.LogStack/LogHeatmapAnalyzer.cs | 69 +- .../AdaptiveSamplingEngine.cs | 6 + .../EonaCatLoggerCore/AnomalyDetector.cs | 22 + .../ContextSnapshotCollector.cs | 6 + .../EonaCatLoggerCore/DeadLetterQueue.cs | 5 + .../Flows/DelegatingLoggerFlow.cs | 6 + .../Flows/DiagnosticsFlow.cs | 3 +- .../EonaCatLoggerCore/Flows/DlqFlow.cs | 18 +- .../Flows/EncryptedFileFlow.cs | 254 +- .../EonaCatLoggerCore/Flows/FileFlow.cs | 4761 +++++++++-------- .../EonaCatLoggerCore/Flows/RedisFlow.cs | 3 +- .../EonaCatLoggerCore/Flows/ThrottledFlow.cs | 12 +- .../EonaCatLoggerCore/IntelligentRouter.cs | 19 +- .../EonaCatLoggerCore/LoggerChain.cs | 22 + .../EonaCatLoggerCore/MessageTemplate.cs | 3 +- .../PerformanceInsightsCollector.cs | 5 +- .../PerformanceInsights/PerformanceTracker.cs | 23 +- .../EonaCatLoggerCore/StringBuilderPool.cs | 3 +- .../Templates/MessageTemplateEngine.cs | 3 +- .../EonaCatLoggerCore/Tracing/SpanFactory.cs | 6 +- .../Tracing/TraceContextManager.cs | 6 +- .../EonaCatLoggerCore/Tracing/TracingSpan.cs | 22 +- .../Extensions/ServiceCollectionExtensions.cs | 10 + EonaCat.LogStack/LogBuilder.cs | 2 + EonaCat.LogStack/SuperiorFeaturesStats.cs | 10 + .../Telemetry/AdaptiveTelemetryEngine.cs | 5 + EonaCat.LogStack/Telemetry/Histogram.cs | 8 +- .../Telemetry/TelemetryAggregator.cs | 24 +- EonaCat.LogStack/Telemetry/TelemetrySignal.cs | 8 +- Testers/EonaCat.LogStack.Test.Web/Program.cs | 3 +- 41 files changed, 3079 insertions(+), 2425 deletions(-) diff --git a/EonaCat.LogStack/EonaCat.LogStack.csproj b/EonaCat.LogStack/EonaCat.LogStack.csproj index d4a1243..3bc7426 100644 --- a/EonaCat.LogStack/EonaCat.LogStack.csproj +++ b/EonaCat.LogStack/EonaCat.LogStack.csproj @@ -14,7 +14,7 @@ It features a rich fluent API for routing log events to dozens of destinations f EonaCat (Jeroen Saey) EonaCat;Logger;EonaCatLogStack;Log;Writer;Flows;LogStack;Memory;Speed;Jeroen;Saey - 0.2.0 + 0.2.1 README.md True LICENSE @@ -25,7 +25,7 @@ It features a rich fluent API for routing log events to dozens of destinations f - 0.2.0+{chash:10}.{c:ymd} + 0.2.1+{chash:10}.{c:ymd} true true v[0-9]* @@ -36,7 +36,7 @@ It features a rich fluent API for routing log events to dozens of destinations f - 0.2.0 + 0.2.1 EonaCat.LogStack EonaCat.LogStack https://git.saey.me/EonaCat/EonaCat.LogStack @@ -52,16 +52,19 @@ It features a rich fluent API for routing log events to dozens of destinations f + + + @@ -110,8 +113,4 @@ It features a rich fluent API for routing log events to dozens of destinations f \ - - - - \ No newline at end of file diff --git a/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/CorrelatedEventFlow.cs b/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/CorrelatedEventFlow.cs index 505e730..1b77f12 100644 --- a/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/CorrelatedEventFlow.cs +++ b/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/CorrelatedEventFlow.cs @@ -41,13 +41,17 @@ namespace EonaCat.LogStack.Flows public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) { if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { return WriteResult.Success; + } try { var correlationId = ExtractCorrelationId(logEvent); if (string.IsNullOrEmpty(correlationId)) + { return WriteResult.Success; + } // Get or create correlation group var group = _correlationGroups.AddOrUpdate( @@ -119,7 +123,9 @@ namespace EonaCat.LogStack.Flows public IEnumerable GetCorrelatedEvents(string correlationId) { if (_correlationGroups.TryGetValue(correlationId, out var group)) + { return group.Events.ToList(); + } return new List(); } @@ -200,11 +206,19 @@ namespace EonaCat.LogStack.Flows if (logEvent.Properties != null) { if (logEvent.Properties.TryGetValue("CorrelationId", out var corrId) && corrId is string str) + { return str; + } + if (logEvent.Properties.TryGetValue("RequestId", out var reqId) && reqId is string str2) + { return str2; + } + if (logEvent.Properties.TryGetValue("TraceId", out var traceId) && traceId is string str3) + { return str3; + } } // Try thread-based correlation @@ -230,10 +244,17 @@ namespace EonaCat.LogStack.Flows private double GetMedian(List values) { - if (values.Count == 0) return 0; + if (values.Count == 0) + { + return 0; + } + var sorted = values.OrderBy(x => x).ToList(); if (sorted.Count % 2 == 0) + { return (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2.0; + } + return sorted[sorted.Count / 2]; } @@ -258,7 +279,9 @@ namespace EonaCat.LogStack.Flows get { lock (_eventLock) + { return _events.AsReadOnly(); + } } } public DateTime FirstEventTime { get; private set; } @@ -287,7 +310,11 @@ namespace EonaCat.LogStack.Flows { lock (_eventLock) { - if (_events.Count == 0) return TimeSpan.Zero; + if (_events.Count == 0) + { + return TimeSpan.Zero; + } + return new TimeSpan(_events[_events.Count - 1].Timestamp - _events[0].Timestamp); } } @@ -298,7 +325,9 @@ namespace EonaCat.LogStack.Flows public IEnumerable GetEventsByLevel(LogLevel level) { lock (_eventLock) + { return _events.Where(e => e.Level == level).ToList(); + } } /// @@ -309,7 +338,9 @@ namespace EonaCat.LogStack.Flows get { lock (_eventLock) + { return _events.Any(e => e.Exception != null); + } } } diff --git a/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/LocalStorageQueryFlow.cs b/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/LocalStorageQueryFlow.cs index 2031036..96217ba 100644 --- a/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/LocalStorageQueryFlow.cs +++ b/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/LocalStorageQueryFlow.cs @@ -44,7 +44,9 @@ namespace EonaCat.LogStack.Flows : base("LocalStorageQuery", minimumLevel) { if (maxCapacity <= 0) + { throw new ArgumentException("Capacity must be greater than 0", nameof(maxCapacity)); + } _maxCapacity = maxCapacity; } @@ -52,7 +54,9 @@ namespace EonaCat.LogStack.Flows public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) { if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { return WriteResult.Success; + } try { @@ -185,7 +189,9 @@ namespace EonaCat.LogStack.Flows { var events = _logBuffer.ToList(); if (events.Count == 0) + { return new LogStorageStatistics(); + } var stats = new LogStorageStatistics { diff --git a/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/LogFilterPresetFlow.cs b/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/LogFilterPresetFlow.cs index 7d65b76..9bcaf63 100644 --- a/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/LogFilterPresetFlow.cs +++ b/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/LogFilterPresetFlow.cs @@ -72,7 +72,9 @@ namespace EonaCat.LogStack.Flows public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) { if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { return WriteResult.Success; + } try { @@ -114,7 +116,10 @@ namespace EonaCat.LogStack.Flows { _channel.Writer.TryComplete(); if (_processingTask != null) + { await _processingTask; + } + await _targetFlow.DisposeAsync(); await base.DisposeAsync(); } diff --git a/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/PerformanceAnomalyDetectorFlow.cs b/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/PerformanceAnomalyDetectorFlow.cs index e59804b..af1f400 100644 --- a/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/PerformanceAnomalyDetectorFlow.cs +++ b/EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/Flows/PerformanceAnomalyDetectorFlow.cs @@ -44,13 +44,17 @@ namespace EonaCat.LogStack.Flows public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) { if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { return WriteResult.Success; + } try { var metrics = ExtractPerformanceMetrics(logEvent); if (metrics.Count == 0) + { return WriteResult.Success; + } lock (_trackersLock) { @@ -102,7 +106,9 @@ namespace EonaCat.LogStack.Flows { var result = await BlastAsync(logEvent, cancellationToken); if (result != WriteResult.Success) + { return result; + } } return WriteResult.Success; } @@ -110,7 +116,10 @@ namespace EonaCat.LogStack.Flows public override async Task FlushAsync(CancellationToken cancellationToken = default) { if (_alertTarget != null) + { await _alertTarget.FlushAsync(cancellationToken); + } + await Task.CompletedTask; } @@ -165,7 +174,10 @@ namespace EonaCat.LogStack.Flows lock (_trackersLock) { if (_trackers.TryGetValue(metricName, out var tracker)) + { return tracker.RecentAnomalies.ToList(); + } + return new List(); } } @@ -199,7 +211,9 @@ namespace EonaCat.LogStack.Flows var metrics = new Dictionary(); if (logEvent.Properties == null) + { return metrics; + } // Look for common performance metric property names var metricNames = new[] { "duration", "latency", "elapsed", "time_ms", "response_time", "memory", "cpu" }; @@ -235,7 +249,10 @@ namespace EonaCat.LogStack.Flows { Clear(); if (_alertTarget != null) + { await _alertTarget.DisposeAsync(); + } + await base.DisposeAsync(); } } @@ -310,7 +327,9 @@ namespace EonaCat.LogStack.Flows _recentAnomalies.Add(anomaly); if (_recentAnomalies.Count > 100) + { _recentAnomalies.RemoveAt(0); + } AnomalyCount++; return anomaly; @@ -324,7 +343,9 @@ namespace EonaCat.LogStack.Flows private double CalculateStandardDeviation() { if (_measurements.Count < 2) + { return 0; + } var avg = Average; var sumSquaredDiff = _measurements.Sum(x => Math.Pow(x - avg, 2)); diff --git a/EonaCat.LogStack/EonaCat.LogStack/Extensions/LoggerFeatureExtensions.cs b/EonaCat.LogStack/EonaCat.LogStack/Extensions/LoggerFeatureExtensions.cs index d3f2f06..77e5986 100644 --- a/EonaCat.LogStack/EonaCat.LogStack/Extensions/LoggerFeatureExtensions.cs +++ b/EonaCat.LogStack/EonaCat.LogStack/Extensions/LoggerFeatureExtensions.cs @@ -207,7 +207,9 @@ namespace EonaCat.LogStack.Extensions { var engine = provider.GetService(); if (engine == null) + { return Enumerable.Empty(); + } var query = new LogSearchQuery { diff --git a/EonaCat.LogStack/EonaCat.LogStack/Features/AlertingEngine.cs b/EonaCat.LogStack/EonaCat.LogStack/Features/AlertingEngine.cs index 69be2bd..81489fb 100644 --- a/EonaCat.LogStack/EonaCat.LogStack/Features/AlertingEngine.cs +++ b/EonaCat.LogStack/EonaCat.LogStack/Features/AlertingEngine.cs @@ -35,7 +35,9 @@ namespace EonaCat.LogStack.Features public AlertRule AddRule(string ruleName, Action configureAction) { if (string.IsNullOrWhiteSpace(ruleName)) + { throw new ArgumentException("Rule name cannot be empty", nameof(ruleName)); + } var builder = new AlertRuleBuilder(ruleName); configureAction(builder); @@ -72,7 +74,9 @@ namespace EonaCat.LogStack.Features public void EvaluateLog(LogEvent logEvent, string formattedMessage) { if (!_isEnabled) + { return; + } lock (_lockObject) { @@ -107,31 +111,41 @@ namespace EonaCat.LogStack.Features { // Level check if (rule.MinimumLevel.HasValue && logEvent.Level < rule.MinimumLevel.Value) + { return false; + } // Keyword check if (!string.IsNullOrEmpty(rule.KeywordPattern)) { if (!formattedMessage.Contains(rule.KeywordPattern, StringComparison.OrdinalIgnoreCase)) + { return false; + } } // Logger check if (!string.IsNullOrEmpty(rule.LoggerNamePattern)) { if (!(logEvent.Category?? "").Contains(rule.LoggerNamePattern, StringComparison.OrdinalIgnoreCase)) + { return false; + } } // Exception check if (rule.OnlyWithExceptions && logEvent.Exception == null) + { return false; + } // Custom predicate if (rule.CustomPredicate != null) { if (!rule.CustomPredicate(logEvent, formattedMessage)) + { return false; + } } // Check throttling @@ -142,7 +156,9 @@ namespace EonaCat.LogStack.Features { var timeSinceLastTrigger = (now - rule.LastTriggeredAt.Value).TotalSeconds; if (timeSinceLastTrigger < rule.ThrottleIntervalSeconds) + { return false; + } } rule.LastTriggeredAt = now; } @@ -263,7 +279,10 @@ namespace EonaCat.LogStack.Features public AlertRuleBuilder WithThrottling(int intervalSeconds) { if (intervalSeconds < 0) + { throw new ArgumentException("Throttle interval must be >= 0", nameof(intervalSeconds)); + } + _rule.ThrottleIntervalSeconds = intervalSeconds; return this; } diff --git a/EonaCat.LogStack/EonaCat.LogStack/Features/ConfigurationValidator.cs b/EonaCat.LogStack/EonaCat.LogStack/Features/ConfigurationValidator.cs index 004aa15..101dc67 100644 --- a/EonaCat.LogStack/EonaCat.LogStack/Features/ConfigurationValidator.cs +++ b/EonaCat.LogStack/EonaCat.LogStack/Features/ConfigurationValidator.cs @@ -30,10 +30,14 @@ namespace EonaCat.LogStack.Features public void AddRule(string ruleName, Func validator) { if (string.IsNullOrWhiteSpace(ruleName)) + { throw new ArgumentException("Rule name cannot be empty", nameof(ruleName)); + } if (validator == null) + { throw new ArgumentNullException(nameof(validator)); + } _rules.Add(new ConfigurationRule { @@ -48,7 +52,9 @@ namespace EonaCat.LogStack.Features public ConfigurationValidationReport Validate(ILogger logger) { if (logger == null) + { throw new ArgumentNullException(nameof(logger)); + } var context = new ConfigurationValidationContext { Logger = logger }; var results = new List(); @@ -247,7 +253,9 @@ namespace EonaCat.LogStack.Features public void EnableHotReload(Action onConfigChanged) { if (onConfigChanged == null) + { throw new ArgumentNullException(nameof(onConfigChanged)); + } lock (_lockObject) { @@ -274,7 +282,9 @@ namespace EonaCat.LogStack.Features public void NotifyConfigurationChange(string configKey, object oldValue, object newValue, string reason = null) { if (!_isEnabled || _changeHandler == null) + { return; + } var notification = new ConfigurationChangeNotification { diff --git a/EonaCat.LogStack/EonaCat.LogStack/Features/CorrelationDashboardHelper.cs b/EonaCat.LogStack/EonaCat.LogStack/Features/CorrelationDashboardHelper.cs index 5370b11..8c23732 100644 --- a/EonaCat.LogStack/EonaCat.LogStack/Features/CorrelationDashboardHelper.cs +++ b/EonaCat.LogStack/EonaCat.LogStack/Features/CorrelationDashboardHelper.cs @@ -21,7 +21,9 @@ namespace EonaCat.LogStack.Features string serviceName, ActivityStatus status, double durationMs, Exception exception = null) { if (string.IsNullOrWhiteSpace(correlationId)) + { throw new ArgumentNullException(nameof(correlationId)); + } lock (_lockObject) { @@ -59,7 +61,9 @@ namespace EonaCat.LogStack.Features public CorrelatedActivityTrace GetTrace(string correlationId) { if (string.IsNullOrWhiteSpace(correlationId)) + { return null; + } lock (_lockObject) { @@ -85,7 +89,9 @@ namespace EonaCat.LogStack.Features public IEnumerable GetTracesByService(string serviceName) { if (string.IsNullOrWhiteSpace(serviceName)) + { return Enumerable.Empty(); + } lock (_lockObject) { @@ -129,7 +135,9 @@ namespace EonaCat.LogStack.Features lock (_lockObject) { if (_traces.Count == 0) + { return new DashboardStatistics(); + } var allActivities = _traces.SelectMany(t => t.Activities).ToList(); var byStatus = allActivities.GroupBy(a => a.Status) @@ -174,7 +182,9 @@ namespace EonaCat.LogStack.Features var to = services[i + 1]; if (!dependencies.ContainsKey(from)) + { dependencies[from] = new HashSet(); + } dependencies[from].Add(to); } @@ -242,7 +252,9 @@ namespace EonaCat.LogStack.Features { var trace = GetTrace(correlationId); if (trace == null) + { return null; + } var startTime = trace.Activities.Min(a => a.Timestamp); var activities = trace.Activities diff --git a/EonaCat.LogStack/EonaCat.LogStack/Features/LogReplayEngine.cs b/EonaCat.LogStack/EonaCat.LogStack/Features/LogReplayEngine.cs index f79ca0f..e6538fa 100644 --- a/EonaCat.LogStack/EonaCat.LogStack/Features/LogReplayEngine.cs +++ b/EonaCat.LogStack/EonaCat.LogStack/Features/LogReplayEngine.cs @@ -50,7 +50,9 @@ namespace EonaCat.LogStack.Features public void CaptureLog(LogEvent logEvent, string formattedMessage) { if (!_isCapturing) + { return; + } lock (_lockObject) { @@ -95,17 +97,25 @@ namespace EonaCat.LogStack.Features var results = _capturedLogs.AsEnumerable(); if (minLevel.HasValue) + { results = results.Where(l => l.Level >= minLevel.Value); + } if (!string.IsNullOrWhiteSpace(loggerFilter)) + { results = results.Where(l => l.Logger.Contains(loggerFilter, StringComparison.OrdinalIgnoreCase)); + } if (fromUtc.HasValue) + { results = results.Where(l => l.Timestamp >= fromUtc.Value); + } if (toUtc.HasValue) + { results = results.Where(l => l.Timestamp <= toUtc.Value); + } return results.ToList(); } @@ -134,7 +144,9 @@ namespace EonaCat.LogStack.Features public void ImportFromJson(string filePath) { if (!File.Exists(filePath)) + { throw new FileNotFoundException($"File not found: {filePath}"); + } var json = File.ReadAllText(filePath); var logs = JsonSerializer.Deserialize>(json); @@ -151,7 +163,9 @@ namespace EonaCat.LogStack.Features public void Replay(Action onLogReplayed, bool respectTimings = false) { if (onLogReplayed == null) + { throw new ArgumentNullException(nameof(onLogReplayed)); + } List logsToReplay; @@ -161,7 +175,9 @@ namespace EonaCat.LogStack.Features } if (logsToReplay.Count == 0) + { return; + } DateTime? previousTimestamp = null; @@ -189,7 +205,9 @@ namespace EonaCat.LogStack.Features lock (_lockObject) { if (_capturedLogs.Count == 0) + { return new LogReplayStatistics(); + } var byLevel = _capturedLogs.GroupBy(l => l.Level) .ToDictionary(g => g.Key, g => g.Count()); @@ -317,7 +335,9 @@ namespace EonaCat.LogStack.Features public static LogReplayScenario Load(string filePath) { if (!File.Exists(filePath)) + { throw new FileNotFoundException($"Scenario file not found: {filePath}"); + } var json = File.ReadAllText(filePath); return JsonSerializer.Deserialize(json); diff --git a/EonaCat.LogStack/EonaCat.LogStack/Features/LogSearchEngine.cs b/EonaCat.LogStack/EonaCat.LogStack/Features/LogSearchEngine.cs index ce037ae..035ff6a 100644 --- a/EonaCat.LogStack/EonaCat.LogStack/Features/LogSearchEngine.cs +++ b/EonaCat.LogStack/EonaCat.LogStack/Features/LogSearchEngine.cs @@ -88,7 +88,9 @@ namespace EonaCat.LogStack.Features public IEnumerable SearchByKeyword(string keyword) { if (string.IsNullOrWhiteSpace(keyword)) + { return Enumerable.Empty(); + } var lower = keyword.ToLowerInvariant(); lock (_lockObject) @@ -106,7 +108,9 @@ namespace EonaCat.LogStack.Features public IEnumerable SearchByLogger(string loggerName) { if (string.IsNullOrWhiteSpace(loggerName)) + { return Enumerable.Empty(); + } lock (_lockObject) { @@ -122,7 +126,9 @@ namespace EonaCat.LogStack.Features public IEnumerable SearchByCorrelationId(string correlationId) { if (string.IsNullOrWhiteSpace(correlationId)) + { return Enumerable.Empty(); + } lock (_lockObject) { @@ -151,7 +157,9 @@ namespace EonaCat.LogStack.Features public IEnumerable SearchByTimeRange(DateTime fromUtc, DateTime toUtc) { if (fromUtc > toUtc) + { throw new ArgumentException("fromUtc must be less than or equal to toUtc"); + } lock (_lockObject) { @@ -167,7 +175,9 @@ namespace EonaCat.LogStack.Features public IEnumerable Search(LogSearchQuery query) { if (query == null) + { return Enumerable.Empty(); + } lock (_lockObject) { @@ -241,7 +251,9 @@ namespace EonaCat.LogStack.Features public IEnumerable GetRecent(int count = 100) { if (count <= 0) + { count = 100; + } lock (_lockObject) { @@ -261,7 +273,9 @@ namespace EonaCat.LogStack.Features lock (_lockObject) { if (_logIndex.Count == 0) + { return new LogSearchStatistics(); + } return new LogSearchStatistics { diff --git a/EonaCat.LogStack/EonaCat.LogStack/LogHeatmapAnalyzer.cs b/EonaCat.LogStack/EonaCat.LogStack/LogHeatmapAnalyzer.cs index 444e46a..bc2f1bf 100644 --- a/EonaCat.LogStack/EonaCat.LogStack/LogHeatmapAnalyzer.cs +++ b/EonaCat.LogStack/EonaCat.LogStack/LogHeatmapAnalyzer.cs @@ -36,19 +36,27 @@ namespace EonaCat.LogStack int height = 10) { if (timeResolutionMinutes <= 0) + { throw new ArgumentException("Time resolution must be positive", nameof(timeResolutionMinutes)); + } var eventList = events?.ToList() ?? new List(); if (eventList.Count == 0) + { return "No events to visualize"; + } var timeBuckets = CreateTimeBuckets(eventList, timeResolutionMinutes); if (timeBuckets.Count == 0) + { return "No valid time data"; + } var maxCount = timeBuckets.Values.Max(); if (maxCount == 0) + { return "All buckets empty"; + } return RenderHeatmap(timeBuckets, width, height, maxCount); } @@ -60,7 +68,9 @@ namespace EonaCat.LogStack { var eventList = events?.ToList() ?? new List(); if (eventList.Count == 0) + { return "No events to visualize"; + } var levels = new[] { LogLevel.Trace, LogLevel.Debug, LogLevel.Information, LogLevel.Warning, LogLevel.Error, LogLevel.Critical }; var levelCounts = levels.ToDictionary(l => l, l => eventList.Count(e => e.Level == l)); @@ -71,12 +81,17 @@ namespace EonaCat.LogStack var maxCount = levelCounts.Values.Max(); if (maxCount == 0) + { return "No events"; + } foreach (var level in levels) { var count = levelCounts[level]; - if (count == 0 && level != LogLevel.Information) continue; + if (count == 0 && level != LogLevel.Information) + { + continue; + } var percentage = (double)count / eventList.Count * 100; var barLength = (int)((double)count / maxCount * (width - 20)); @@ -95,7 +110,9 @@ namespace EonaCat.LogStack { var eventList = events?.ToList() ?? new List(); if (eventList.Count == 0) + { return "No events to visualize"; + } var categoryCounts = eventList .GroupBy(e => e.Category ?? "Unknown") @@ -109,7 +126,9 @@ namespace EonaCat.LogStack var maxCount = categoryCounts.Values.Max(); if (maxCount == 0) + { return "No categories"; + } foreach (var kvp in categoryCounts) { @@ -132,12 +151,16 @@ namespace EonaCat.LogStack { var eventList = events?.ToList() ?? new List(); if (eventList.Count == 0) + { return string.Empty; + } var timeBuckets = CreateTimeBuckets(eventList, (int)Math.Ceiling((double)GetTimeSpanMinutes(eventList) / buckets)); var maxCount = timeBuckets.Values.Max(); if (maxCount == 0) + { return string.Empty; + } var sb = new StringBuilder(); foreach (var bucket in timeBuckets.OrderBy(kvp => kvp.Key)) @@ -155,7 +178,9 @@ namespace EonaCat.LogStack { var eventList = events?.ToList() ?? new List(); if (eventList.Count == 0) + { return "No events"; + } // Create a 24x7 matrix (hours x days) var hourlyMatrix = new int[24, 7]; @@ -178,11 +203,17 @@ namespace EonaCat.LogStack var maxValue = 0; for (int h = 0; h < 24; h++) + { for (int d = 0; d < 7; d++) + { maxValue = Math.Max(maxValue, hourlyMatrix[h, d]); + } + } if (maxValue == 0) + { return "No hourly data"; + } for (int h = 0; h < 24; h++) { @@ -214,9 +245,13 @@ namespace EonaCat.LogStack .AddMinutes(-(timestamp.Minute % bucketMinutes)); if (buckets.ContainsKey(bucketTime)) + { buckets[bucketTime]++; + } else + { buckets[bucketTime] = 1; + } } return buckets; @@ -259,23 +294,45 @@ namespace EonaCat.LogStack sb.AppendLine(new string('=', width + 4)); if (sortedBuckets.Count > 0) + { sb.AppendLine($"From: {sortedBuckets.First().Key:yyyy-MM-dd HH:mm}, To: {sortedBuckets.Last().Key:yyyy-MM-dd HH:mm}"); + } return sb.ToString(); } private static char GetHeatCharacter(double intensity) { - if (intensity < 0.01) return EmptyCharacter; - if (intensity < 0.35) return ColdCharacter; - if (intensity < 0.65) return CoolCharacter; - if (intensity < 0.85) return WarmCharacter; + if (intensity < 0.01) + { + return EmptyCharacter; + } + + if (intensity < 0.35) + { + return ColdCharacter; + } + + if (intensity < 0.65) + { + return CoolCharacter; + } + + if (intensity < 0.85) + { + return WarmCharacter; + } + return HotCharacter; } private static int GetTimeSpanMinutes(List events) { - if (events.Count == 0) return 1; + if (events.Count == 0) + { + return 1; + } + var maxTicks = events.Max(e => e.Timestamp); var minTicks = events.Min(e => e.Timestamp); var span = new TimeSpan(maxTicks - minTicks); diff --git a/EonaCat.LogStack/EonaCatLoggerCore/AdaptiveSamplingEngine.cs b/EonaCat.LogStack/EonaCatLoggerCore/AdaptiveSamplingEngine.cs index 6e7dc99..dbb3fd0 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/AdaptiveSamplingEngine.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/AdaptiveSamplingEngine.cs @@ -65,10 +65,14 @@ public class AdaptiveSamplingEngine // Use random sampling based on current rate if (_samplingRate >= 1.0) + { return true; + } if (_samplingRate <= 0.0) + { return false; + } return ThreadSafeRandom.NextDouble() < _samplingRate; } @@ -164,7 +168,9 @@ public class AdaptiveSamplingEngine { var now = DateTime.UtcNow; if ((now - _lastCpuCheck).TotalMilliseconds < 1000) + { return; // Update at most once per second to avoid overhead + } _lastCpuCheck = now; diff --git a/EonaCat.LogStack/EonaCatLoggerCore/AnomalyDetector.cs b/EonaCat.LogStack/EonaCatLoggerCore/AnomalyDetector.cs index 83ed16c..b8641f2 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/AnomalyDetector.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/AnomalyDetector.cs @@ -113,7 +113,9 @@ public class AnomalyDetector { _recentAlerts.Add(alert); if (_recentAlerts.Count > 1000) + { _recentAlerts.RemoveAt(0); // Keep last 1000 alerts + } } AnomalyDetected?.Invoke(this, alert); @@ -180,10 +182,14 @@ public class AnomalyDetector private AnomalySeverity DetermineAnomalySeverity(List anomalies) { if (anomalies.Any(a => a.Contains("error rate"))) + { return AnomalySeverity.Critical; + } if (anomalies.Any(a => a.Contains("Exception") || a.Contains("infinite loop"))) + { return AnomalySeverity.High; + } return AnomalySeverity.Medium; } @@ -222,10 +228,14 @@ internal class CategoryStatistics EventCount++; _recentEventTimes.Enqueue(DateTime.UtcNow); if (_recentEventTimes.Count > 1000) + { _recentEventTimes.Dequeue(); + } if (logEvent.Level == LogLevel.Error || logEvent.Level == LogLevel.Critical) + { _errorCount++; + } } } @@ -244,7 +254,9 @@ internal class CategoryStatistics lock (_lock) { if (_recentEventTimes.Count < 10) + { return false; + } var now = DateTime.UtcNow; var times = _recentEventTimes.ToList(); @@ -254,11 +266,15 @@ internal class CategoryStatistics { var interval = (now - times[i]).TotalSeconds; if (interval > 0) + { recentIntervals.Add(interval); + } } if (recentIntervals.Count < 2) + { return false; + } var mean = recentIntervals.Average(); var variance = recentIntervals.Average(x => Math.Pow(x - mean, 2)); @@ -286,7 +302,9 @@ internal class LevelStatistics _errorCount++; _eventTimes.Enqueue(DateTime.UtcNow); if (_eventTimes.Count > 10000) + { _eventTimes.Dequeue(); + } } } @@ -295,13 +313,17 @@ internal class LevelStatistics lock (_lock) { if (_eventTimes.Count < 2) + { return 0; + } var now = DateTime.UtcNow; var span = (now - _eventTimes.Peek()).TotalSeconds; if (span < 1) + { return _eventTimes.Count; + } return _eventTimes.Count / span; } diff --git a/EonaCat.LogStack/EonaCatLoggerCore/ContextSnapshotCollector.cs b/EonaCat.LogStack/EonaCatLoggerCore/ContextSnapshotCollector.cs index 9ae3765..5fe30a5 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/ContextSnapshotCollector.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/ContextSnapshotCollector.cs @@ -38,7 +38,9 @@ public class ContextSnapshotCollector public ContextSnapshot CaptureIfNeeded(LogEvent logEvent) { if (logEvent.Level < _triggerLevel) + { return null; + } return CaptureSnapshot(logEvent); } @@ -64,7 +66,9 @@ public class ContextSnapshotCollector { _snapshots.Add(snapshot); if (_snapshots.Count > _maxSnapshots) + { _snapshots.RemoveAt(0); + } _totalSnapshotsCaptured++; } @@ -257,7 +261,9 @@ public class ContextSnapshotCollector { var varValue = Environment.GetEnvironmentVariable(varName); if (varValue != null) + { env[varName] = varValue; + } } // Add runtime info diff --git a/EonaCat.LogStack/EonaCatLoggerCore/DeadLetterQueue.cs b/EonaCat.LogStack/EonaCatLoggerCore/DeadLetterQueue.cs index b46f5b7..b19fd1f 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/DeadLetterQueue.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/DeadLetterQueue.cs @@ -111,7 +111,9 @@ public class DeadLetterQueue public async Task ReplayAsync(DeadLetterEvent dlEvent, IEnumerable targetFlows) { if (dlEvent == null) + { throw new ArgumentNullException(nameof(dlEvent)); + } dlEvent.RetryCount++; dlEvent.LastRetryAt = DateTime.UtcNow; @@ -207,7 +209,10 @@ public class DeadLetterQueue { var reason = evt.FailureReason ?? "Unknown"; if (!stats.ContainsKey(reason)) + { stats[reason] = 0; + } + stats[reason]++; } return stats.OrderByDescending(x => x.Value).ToDictionary(x => x.Key, x => x.Value); diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/DelegatingLoggerFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/DelegatingLoggerFlow.cs index 79a7590..8e08fff 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/DelegatingLoggerFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/DelegatingLoggerFlow.cs @@ -135,7 +135,9 @@ public sealed class DelegatingLoggerFlow : FlowBase lock (_queueLock) { if (_delegationQueue.Count == 0) + { return; + } pendingEvents = new Queue(_delegationQueue); _delegationQueue.Clear(); @@ -170,7 +172,9 @@ public sealed class DelegatingLoggerFlow : FlowBase private void DelegateLogEvent(LogEvent logEvent) { if (_disposed != 0) + { return; + } try { @@ -199,7 +203,9 @@ public sealed class DelegatingLoggerFlow : FlowBase public override async ValueTask DisposeAsync() { if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) + { return; + } await FlushAsync().ConfigureAwait(false); diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/DiagnosticsFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/DiagnosticsFlow.cs index 6f36364..37319e6 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/DiagnosticsFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/DiagnosticsFlow.cs @@ -124,8 +124,7 @@ namespace EonaCat.LogStack.Flows /// Current value of a named counter (0 if not yet created). public long ReadCounter(string name) { - Counter c; - return _counters.TryGetValue(name, out c) ? c.Value : 0; + return _counters.TryGetValue(name, out Counter c) ? c.Value : 0; } public override Task BlastAsync( diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/DlqFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/DlqFlow.cs index 24d50e3..f237040 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/DlqFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/DlqFlow.cs @@ -40,7 +40,11 @@ public class DlqFlow : FlowBase /// public DlqFlow AddBackupFlow(IFlow flow) { - if (flow == null) throw new ArgumentNullException(nameof(flow)); + if (flow == null) + { + throw new ArgumentNullException(nameof(flow)); + } + lock (_flowsLock) { _backupFlows.Add(flow); @@ -54,10 +58,14 @@ public class DlqFlow : FlowBase public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) { if (_disposed != 0) + { return WriteResult.Dropped; + } if (!IsLogLevelEnabled(logEvent)) + { return WriteResult.Success; + } // Try backup flows first before enqueueing to DLQ List backupFlows; @@ -96,7 +104,9 @@ public class DlqFlow : FlowBase // All backups failed, enqueue to DLQ var failureReason = string.Join("; ", failureReasons); if (failureReason.Length > 500) + { failureReason = failureReason.Substring(0, 500) + "..."; + } var enqueued = _dlq.Enqueue(logEvent, failureReason ?? "Unknown failure"); @@ -116,7 +126,9 @@ public class DlqFlow : FlowBase public override async Task BlastBatchAsync(ReadOnlyMemory logEvents, CancellationToken cancellationToken = default) { if (_disposed != 0) + { return WriteResult.Dropped; + } var events = logEvents.ToArray(); int successCount = 0; @@ -125,7 +137,9 @@ public class DlqFlow : FlowBase { var result = await BlastAsync(logEvent, cancellationToken).ConfigureAwait(false); if (result == WriteResult.Success) + { successCount++; + } } return successCount > 0 ? WriteResult.Success : WriteResult.Dropped; @@ -180,7 +194,9 @@ public class DlqFlow : FlowBase public override async ValueTask DisposeAsync() { if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) + { return; + } List flowsToDispose; lock (_flowsLock) diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/EncryptedFileFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/EncryptedFileFlow.cs index dc09ad7..91feb42 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/EncryptedFileFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/EncryptedFileFlow.cs @@ -2,6 +2,7 @@ using EonaCat.LogStack.Core; using EonaCat.LogStack.EonaCatLogStackCore; using EonaCat.LogStack.EonaCatLogStackCore.Policies; using System; +using System.Buffers; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; @@ -116,6 +117,7 @@ namespace EonaCat.LogStack.Flows // Auto-flush on error private volatile bool _autoFlushOnError; + private volatile bool _durableWrites; // Scoped properties (AsyncLocal for ambient context) private static readonly AsyncLocal> _scopeProperties @@ -136,10 +138,9 @@ namespace EonaCat.LogStack.Flows private static readonly int CachedPid = Process.GetCurrentProcess().Id; private List> _compiledTemplate; - private readonly Dictionary> _customTokens - = new Dictionary>(StringComparer.OrdinalIgnoreCase); - + private readonly Dictionary> _customTokens = new Dictionary>(StringComparer.OrdinalIgnoreCase); private int _correlationSeed; + public string EncryptedFileExtension { get; set; } = ".eona"; public EncryptedFileFlow( string directory, @@ -148,7 +149,7 @@ namespace EonaCat.LogStack.Flows long maxFileSize = 50L * 1024 * 1024, long maxDirectorySize = 2L * 1024 * 1024 * 1024, FileRetentionPolicy retention = null, - int flushIntervalMs = 3000, + int flushIntervalMs = 250, int batchSize = 1, LogLevel minimumLevel = LogLevel.Trace, bool useCategoryRouting = false, @@ -299,6 +300,28 @@ namespace EonaCat.LogStack.Flows return this; } + /// Registers this flow to flush and dispose automatically on process exit + /// (ProcessExit) and Ctrl+C (CancelKeyPress). Opt-in only — call this explicitly if + /// your application doesn't already have its own shutdown/dispose sequence for this flow. + /// Not recommended if you're hosting inside ASP.NET Core, a Windows Service, or any + /// framework with its own graceful-shutdown lifecycle — hook into that instead. + public EncryptedFileFlow WithAutoShutdownHooks() + { + AppDomain.CurrentDomain.ProcessExit += (_, _) => + { + try { DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); } catch { } + }; + + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + try { DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); } catch { } + Environment.Exit(0); + }; + + return this; + } + /// Callback invoked with the archived path after each file rotation. public EncryptedFileFlow OnFileRotated(Action callback) { @@ -365,6 +388,18 @@ namespace EonaCat.LogStack.Flows return this; } + /// When enabled, every encrypted line is flushed to the physical disk + /// (StreamWriter-equivalent FileStream buffer + OS write cache) before the write call + /// returns, instead of relying on the periodic FlushLoop. Eliminates data loss on abrupt + /// process termination at the cost of significantly reduced throughput — each line becomes + /// a synchronous disk I/O. Recommended for audit-trail / compliance use cases, which is + /// arguably the common case for an encrypted log sink. + public EncryptedFileFlow WithDurableWrites(bool enabled = true) + { + _durableWrites = enabled; + return this; + } + /// Enable deduplication: suppress identical messages within the given time window. public EncryptedFileFlow WithDeduplication(TimeSpan window) { @@ -563,6 +598,11 @@ namespace EonaCat.LogStack.Flows aes.Key = key; aes.IV = iv; + if (!Directory.Exists(Path.GetDirectoryName(outputPath))) + { + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + } + using (ICryptoTransform dec = aes.CreateDecryptor()) using (StreamWriter out_ = new StreamWriter(outputPath, false, Encoding.UTF8)) { @@ -585,8 +625,8 @@ namespace EonaCat.LogStack.Flows ReadExact(source, cipher, blockLength); byte[] plain = dec.TransformFinalBlock(cipher, 0, cipher.Length); out_.WriteLine(Encoding.UTF8.GetString(plain)); - return true; } + return true; } } } @@ -729,10 +769,11 @@ namespace EonaCat.LogStack.Flows IsEnabled = false; _queue.CompleteAdding(); - _cts.Cancel(); _writerThread.Join(TimeSpan.FromSeconds(5)); _flushThread.Join(TimeSpan.FromSeconds(2)); _retentionThread.Join(TimeSpan.FromSeconds(2)); + _cts.Cancel(); + lock (_lock) { CloseCurrentFile(); } _cts.Dispose(); _queue.Dispose(); @@ -752,9 +793,8 @@ namespace EonaCat.LogStack.Flows WriteEncryptedLine(entry); - QueueEntry extra; int batch = 0; - while (batch < _batchSize && _queue.TryTake(out extra)) + while (batch < _batchSize && _queue.TryTake(out QueueEntry extra)) { WriteEncryptedLine(extra); batch++; @@ -767,8 +807,7 @@ namespace EonaCat.LogStack.Flows } finally { - QueueEntry remaining; - while (_queue.TryTake(out remaining)) + while (_queue.TryTake(out QueueEntry remaining)) { WriteEncryptedLine(remaining); } @@ -781,8 +820,10 @@ namespace EonaCat.LogStack.Flows { while (!_cts.Token.IsCancellationRequested) { - try { Thread.Sleep(_flushIntervalMs); } - catch (ThreadInterruptedException) { break; } + if (_cts.Token.WaitHandle.WaitOne(_flushIntervalMs)) + { + break; + } lock (_lock) { @@ -827,18 +868,36 @@ namespace EonaCat.LogStack.Flows { byte[] plain = Encoding.UTF8.GetBytes(entry.Line); byte[] cipher = _encryptor.TransformFinalBlock(plain, 0, plain.Length); - byte[] lenBuf = BitConverter.GetBytes(cipher.Length); + byte[] frame = ArrayPool.Shared.Rent(4 + cipher.Length); + + try + { + frame[0] = (byte)cipher.Length; + frame[1] = (byte)(cipher.Length >> 8); + frame[2] = (byte)(cipher.Length >> 16); + frame[3] = (byte)(cipher.Length >> 24); + Buffer.BlockCopy(cipher, 0, frame, 4, cipher.Length); + + _currentStream.Write(frame, 0, 4 + cipher.Length); + } + finally + { + ArrayPool.Shared.Return(frame); + } - _currentStream.Write(lenBuf, 0, 4); - _currentStream.Write(cipher, 0, cipher.Length); _currentSize += 4 + cipher.Length; + if (_durableWrites) + { + try { _currentStream.Flush(true); } catch { /* surfaced via outer catch below on failure */ } + } + Interlocked.Increment(ref _totalWritten); Interlocked.Increment(ref BlastedCount); Interlocked.Add(ref _totalBytesWritten, 4 + cipher.Length); // Auto-flush on error/critical - if (_autoFlushOnError && entry.Level >= LogLevel.Error) + if (!_durableWrites && _autoFlushOnError && entry.Level >= LogLevel.Error) { try { _currentStream.Flush(true); } catch { /* ignore */ } } @@ -848,6 +907,21 @@ namespace EonaCat.LogStack.Flows _lastError = ex; Interlocked.Increment(ref _totalErrors); Interlocked.Exchange(ref _lastErrorTimestamp, DateTime.UtcNow.Ticks); + + // A write failure means this event is lost — make that visible via the + // same drop-reporting path used for backpressure/rate-limit drops, instead + // of only surfacing it through GetTotalErrors()/GetLastError(). + Interlocked.Increment(ref DroppedCount); + Action drop = _onDrop; + if (drop != null) + { + try + { + drop(new LogEvent { Level = entry.Level, Message = default }); + } + catch { /* Do nothing */ } + } + OnException?.Invoke(null, "[EncryptedFileFlow] Write error: " + ex.Message); } } @@ -856,29 +930,28 @@ namespace EonaCat.LogStack.Flows private void OpenNewFile(DateTime date) { _currentDate = date; - _currentPath = Path.Combine( - _directory, - _filePrefix + "_" + Environment.MachineName + "_" + date.ToString(_dateFormat) + ".eona"); + _currentPath = Path.Combine(_directory, _filePrefix + "_" + Environment.MachineName + "_" + date.ToString(_dateFormat) + EncryptedFileExtension); bool isNew = !File.Exists(_currentPath) || new FileInfo(_currentPath).Length == 0; - _currentStream = new FileStream( - _currentPath, FileMode.Append, FileAccess.Write, FileShare.Read, 65536); + _currentStream = new FileStream(_currentPath, FileMode.Append, FileAccess.Write, FileShare.Read, 65536, _durableWrites ? FileOptions.WriteThrough : FileOptions.None); byte[] salt = new byte[SaltSize]; byte[] iv = new byte[IvSize]; if (isNew) { - using (RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider()) - { - rng.GetBytes(salt); - rng.GetBytes(iv); - } + FillRandom(salt); + FillRandom(iv); _currentStream.Write(Magic, 0, 4); _currentStream.Write(salt, 0, SaltSize); _currentStream.Write(iv, 0, IvSize); _currentSize = 4 + SaltSize + IvSize; + + if (_durableWrites) + { + try { _currentStream.Flush(true); } catch { /* ignore */ } + } } else { @@ -894,13 +967,45 @@ namespace EonaCat.LogStack.Flows byte[] key = DeriveKey(_password, salt); - Aes aes = Aes.Create(); - aes.KeySize = 256; - aes.Mode = CipherMode.CBC; - aes.Padding = PaddingMode.PKCS7; - aes.Key = key; - aes.IV = iv; - _encryptor = aes.CreateEncryptor(); + // The Aes instance is only needed to produce the transform below; it must be + // disposed here rather than kept alive, otherwise every file open/rotation + // (daily, or on maxFileSize) leaks an Aes instance and its key material for + // the lifetime of the process. + using (Aes aes = Aes.Create()) + { + aes.KeySize = 256; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + aes.Key = key; + aes.IV = iv; + _encryptor = aes.CreateEncryptor(); + } + } + + private static void ClearSecret(byte[] bytes) + { + if (bytes == null) + { + return; + } + +#if NET6_0_OR_GREATER + CryptographicOperations.ZeroMemory(bytes); +#else + Array.Clear(bytes, 0, bytes.Length); +#endif + } + + private static void FillRandom(byte[] buffer) + { +#if NET6_0_OR_GREATER + RandomNumberGenerator.Fill(buffer); +#else + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(buffer); + } +#endif } private void CloseCurrentFile() @@ -937,15 +1042,22 @@ namespace EonaCat.LogStack.Flows string line = Format(logEvent); var entry = new QueueEntry { Line = line, Level = logEvent.Level }; - if (!_queue.TryAdd(entry)) + try { - Interlocked.Increment(ref DroppedCount); - Action drop = _onDrop; - if (drop != null) + if (!_queue.TryAdd(entry)) { - drop(logEvent); - } + Interlocked.Increment(ref DroppedCount); + Action drop = _onDrop; + if (drop != null) + { + drop(logEvent); + } + return WriteResult.Dropped; + } + } + catch (Exception) + { return WriteResult.Dropped; } @@ -1022,8 +1134,7 @@ namespace EonaCat.LogStack.Flows long nowTicks = DateTime.UtcNow.Ticks; long windowTicks = _deduplicationWindow.Ticks; - long existing; - if (_deduplicationCache.TryGetValue(key, out existing)) + if (_deduplicationCache.TryGetValue(key, out long existing)) { if (nowTicks - existing < windowTicks) { @@ -1047,20 +1158,26 @@ namespace EonaCat.LogStack.Flows { if (nowTicks - kvp.Value >= windowTicks) { - long removed; - _deduplicationCache.TryRemove(kvp.Key, out removed); + _deduplicationCache.TryRemove(kvp.Key, out long removed); } } } private string Format(LogEvent log) { - var sb = new StringBuilder(256); - foreach (Action action in _compiledTemplate) + StringBuilder sb = StringBuilderPool.Rent(); + try { - action(log, sb); + foreach (Action action in _compiledTemplate) + { + action(log, sb); + } + return sb.ToString(); + } + finally + { + StringBuilderPool.Return(sb); } - return sb.ToString(); } private void AppendProperties(LogEvent log, StringBuilder sb) @@ -1174,8 +1291,7 @@ namespace EonaCat.LogStack.Flows case "logtype": return (log, sb) => { - string s; - sb.Append(LevelStrings.TryGetValue(log.Level, out s) ? s : log.Level.ToString()); + sb.Append(LevelStrings.TryGetValue(log.Level, out string s) ? s : log.Level.ToString()); }; case "message": return (log, sb) => @@ -1225,8 +1341,7 @@ namespace EonaCat.LogStack.Flows string name = token; return (log, sb) => { - Action custom; - if (_customTokens.TryGetValue(name, out custom)) + if (_customTokens.TryGetValue(name, out Action custom)) { custom(log, sb); } @@ -1267,10 +1382,13 @@ namespace EonaCat.LogStack.Flows return; } - FileInfo[] files = dir.GetFiles("*.eona") - .OrderByDescending(f => f.LastWriteTimeUtc) - .ToArray(); + // failsafe + if (string.IsNullOrWhiteSpace(EncryptedFileExtension)) + { + EncryptedFileExtension = ".eona"; + } + FileInfo[] files = dir.GetFiles($"*.{EncryptedFileExtension}").OrderByDescending(f => f.LastWriteTimeUtc).ToArray(); long totalBytes = 0; int kept = 0; @@ -1315,23 +1433,21 @@ namespace EonaCat.LogStack.Flows return s; } - private static string LevelString(LogLevel level) - { - switch (level) - { - case LogLevel.Trace: return "TRACE"; - case LogLevel.Debug: return "DEBUG"; - case LogLevel.Information: return "INFO"; - case LogLevel.Warning: return "WARN"; - case LogLevel.Error: return "ERROR"; - case LogLevel.Critical: return "CRITICAL"; - default: return level.ToString().ToUpperInvariant(); - } - } - private static byte[] DeriveKey(string password, byte[] salt) { - using (Rfc2898DeriveBytes kdf = new Rfc2898DeriveBytes(password, salt, Pbkdf2Iter)) +#if NET6_0_OR_GREATER + using (var kdf = new Rfc2898DeriveBytes( + password, + salt, + Pbkdf2Iter, + HashAlgorithmName.SHA256)) +#else + // SHA1 is the only option on older .NET Framework + using (var kdf = new Rfc2898DeriveBytes( + password, + salt, + Pbkdf2Iter)) +#endif { return kdf.GetBytes(KeySize); } diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/FileFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/FileFlow.cs index a1c07e5..473107b 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/FileFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/FileFlow.cs @@ -1,2304 +1,2465 @@ -using EonaCat.LogStack.Core; -using EonaCat.LogStack.EonaCatLogStackCore; -using EonaCat.LogStack.EonaCatLogStackCore.Policies; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -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. - - /// - /// High-performance file sink with batching, rotation, compression, sampling, - /// enrichment, retention policies, and pluggable secondary writers. - /// - public sealed class FileFlow : FlowBase - { - public event EventHandler OnDirectoryException; - public event EventHandler OnException; - private const int FileBufferSize = 131072; // 128 KB - private const int WriterBufferSize = 131072; // 128 KB - private readonly int _batchSize; - private const int QueueCapacity = 8192; - - private static readonly Dictionary LevelStrings = - new Dictionary - { - { LogLevel.Trace, "TRACE" }, - { LogLevel.Debug, "DEBUG" }, - { LogLevel.Information, "INFO" }, - { LogLevel.Warning, "WARN" }, - { LogLevel.Error, "ERROR" }, - { LogLevel.Critical, "CRITICAL" }, - }; - - private static readonly char[] CsvSpecialChars = { ',', '"', '\n', '\r' }; - private static readonly string CachedMachineName = Environment.MachineName; - private static readonly int CachedPid = Process.GetCurrentProcess().Id; - - private const string CsvHeader = "timestamp,level,category,message,exception,properties\r\n"; - - 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; - private readonly Task _retentionTask; - private readonly Stopwatch _uptime = Stopwatch.StartNew(); - private readonly SemaphoreSlim _compressionSignal = new SemaphoreSlim(0, int.MaxValue); - - private readonly string _directory; - private readonly string _filePrefix; - private readonly long _maxFileSize; - private readonly long _maxDirectorySize; - private readonly FileRetentionPolicy _retention; - private readonly TimestampMode _timestampMode; - private readonly TimeSpan _flushInterval; - private readonly FileOutputFormat _outputFormat; - private readonly CompressionFormat _compressionFormat; - private readonly string _template; - private readonly bool _useCategoryRouting; - private readonly HashSet _logLevelsForSeparateFiles; - private readonly long _maxMemoryBytes; - - private volatile SamplingPolicy _samplingPolicy; - private volatile Action _onDrop; - private volatile Action _onRotate; - private List> _secondaryWriters; - private readonly object _secondaryWritersLock = new object(); - private int _correlationSeed; - - private readonly List> _filters = new List>(); - private readonly object _filtersLock = new object(); - - private readonly ConcurrentDictionary _deduplicationCache - = new ConcurrentDictionary(StringComparer.Ordinal); - private TimeSpan _deduplicationWindow = TimeSpan.Zero; - private volatile bool _deduplicationEnabled; - - private string _dateFormat = "yyyyMMdd"; - private volatile Exception _lastError; - private long _lastErrorTimestamp; - private long _totalErrors; - - private readonly List>> _enrichers - = new List>>(); - - private long _currentMemoryBytes; - - // Rate limiting - private int _maxEventsPerSecond; - private bool _rateLimitEnabled => _maxEventsPerSecond > 0; - private long _rateLimitWindowStart; - private int _rateLimitCounter; - private readonly object _rateLimitLock = new object(); - - // Auto-flush on error - private volatile bool _autoFlushOnError; - - // Scoped properties (AsyncLocal for ambient context) - private static readonly AsyncLocal> _scopeProperties - = new AsyncLocal>(); - - private long _totalBytesWritten; - private long _totalRotations; - - private readonly Dictionary _openFiles - = new Dictionary(StringComparer.OrdinalIgnoreCase); - private readonly object _fileLock = new object(); - - private string _fileExtension = ".log"; - - private List> _compiledTemplate; - private readonly Dictionary> _customTokens - = new Dictionary>(StringComparer.OrdinalIgnoreCase); - - private sealed class OpenFile : IDisposable - { - public readonly FileStream Stream; - public readonly StreamWriter Writer; - public readonly DateTime Date; - public long Size; - public bool HasCsvHeader; - public bool HasXmlHeader; - - public OpenFile(FileStream fs, StreamWriter sw, DateTime date) - { - Stream = fs; - Writer = sw; - Date = date; - Size = fs.Length; - } - - public void Dispose() - { - try { Writer.Flush(); } catch { } - try { Writer.Dispose(); } catch { } - try { Stream.Dispose(); } catch { } - } - } - - public FileFlow( - string directory, - string filePrefix = "log", - long maxFileSize = 200 * 1024 * 1024, - long maxDirectorySize = 2L * 1024 * 1024 * 1024, - FileRetentionPolicy retention = null, - int flushIntervalMs = 2000, - int batchSize = 1, - LogLevel minimumLevel = LogLevel.Trace, - bool useCategoryRouting = false, - LogLevel[] logLevelsForSeparateFiles = null, - TimestampMode timestampMode = TimestampMode.Utc, - BackpressureStrategy backpressure = BackpressureStrategy.DropOldest, - FileOutputFormat outputFormat = FileOutputFormat.Text, - CompressionFormat compression = CompressionFormat.GZip, - string template = "[{ts}] [Host: {host}] [Category: {category}] [Thread: {thread}] [{logtype}] {message}{props}", - long maxMemoryBytes = 20 * 1024 * 1024) - : base("File:" + Path.Combine(directory, filePrefix), minimumLevel) - { - if (directory == null) - { - throw new ArgumentNullException("directory"); - } - - if (filePrefix == null) - { - throw new ArgumentNullException("filePrefix"); - } - - if (template == null) - { - throw new ArgumentNullException("template"); - } - - _batchSize = batchSize <= 0 ? 1 : batchSize; - _directory = directory; - _filePrefix = filePrefix; - _template = template; - _maxFileSize = maxFileSize; - _maxDirectorySize = maxDirectorySize > 0 ? maxDirectorySize : 10L * maxFileSize; - _retention = retention ?? new FileRetentionPolicy(); - _timestampMode = timestampMode; - _useCategoryRouting = useCategoryRouting; - _maxMemoryBytes = maxMemoryBytes; - _flushInterval = TimeSpan.FromMilliseconds(flushIntervalMs); - _outputFormat = outputFormat; +using EonaCat.LogStack.Core; +using EonaCat.LogStack.EonaCatLogStackCore; +using EonaCat.LogStack.EonaCatLogStackCore.Policies; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +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. + + /// + /// High-performance file sink with batching, rotation, compression, sampling, + /// enrichment, retention policies, and pluggable secondary writers. + /// + public sealed class FileFlow : FlowBase + { + public event EventHandler OnDirectoryException; + public event EventHandler OnException; + + private const int FileBufferSize = 131072; // 128 KB + private const int WriterBufferSize = 131072; // 128 KB + private readonly int _batchSize; + private const int QueueCapacity = 8192; + private int _writerBusy; + + // How many writes to trust an open file handle before re-checking File.Exists. + private const int ExistenceCheckInterval = 200; + + // How long an open file handle can sit unused before the retention pass closes it. + private static readonly TimeSpan IdleFileEvictionThreshold = TimeSpan.FromMinutes(10); + + private volatile string _cachedPath; + private DateTime _cachedPathDate; + private volatile bool _durableWrites; + + private static readonly Dictionary LevelStrings = + new Dictionary + { + { LogLevel.Trace, "TRACE" }, + { LogLevel.Debug, "DEBUG" }, + { LogLevel.Information, "INFO" }, + { LogLevel.Warning, "WARN" }, + { LogLevel.Error, "ERROR" }, + { LogLevel.Critical, "CRITICAL" }, + }; + + private static readonly char[] CsvSpecialChars = { ',', '"', '\n', '\r' }; + private static readonly string CachedMachineName = Environment.MachineName; + private static readonly int CachedPid = Process.GetCurrentProcess().Id; + + private const string CsvHeader = "timestamp,level,category,message,exception,properties\r\n"; + + 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; + private readonly Task _retentionTask; + private readonly Stopwatch _uptime = Stopwatch.StartNew(); + private readonly SemaphoreSlim _compressionSignal = new SemaphoreSlim(0, int.MaxValue); + + private readonly string _directory; + private readonly string _filePrefix; + private readonly long _maxFileSize; + private readonly long _maxDirectorySize; + private readonly FileRetentionPolicy _retention; + private readonly TimestampMode _timestampMode; + private readonly TimeSpan _flushInterval; + private readonly FileOutputFormat _outputFormat; + private readonly CompressionFormat _compressionFormat; + private readonly string _template; + private readonly bool _useCategoryRouting; + private readonly HashSet _logLevelsForSeparateFiles; + private readonly long _maxMemoryBytes; + + private volatile SamplingPolicy _samplingPolicy; + private volatile Action _onDrop; + private volatile Action _onRotate; + private List> _secondaryWriters; + private readonly object _secondaryWritersLock = new object(); + private int _correlationSeed; + + private readonly List> _filters = new List>(); + private readonly object _filtersLock = new object(); + + private readonly ConcurrentDictionary _deduplicationCache + = new ConcurrentDictionary(StringComparer.Ordinal); + private TimeSpan _deduplicationWindow = TimeSpan.Zero; + private volatile bool _deduplicationEnabled; + + private string _dateFormat = "yyyyMMdd"; + private volatile Exception _lastError; + private long _lastErrorTimestamp; + private long _totalErrors; + + private readonly List>> _enrichers + = new List>>(); + + private long _currentMemoryBytes; + + // Rate limiting + private int _maxEventsPerSecond; + private bool _rateLimitEnabled => _maxEventsPerSecond > 0; + private long _rateLimitWindowStart; + private int _rateLimitCounter; + private readonly object _rateLimitLock = new object(); + + // Auto-flush on error + private volatile bool _autoFlushOnError; + + // Scoped properties (AsyncLocal for ambient context) + private static readonly AsyncLocal> _scopeProperties + = new AsyncLocal>(); + + private long _totalBytesWritten; + private long _totalRotations; + + private readonly Dictionary _openFiles + = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly object _fileLock = new object(); + + private string _fileExtension = ".log"; + + private List> _compiledTemplate; + private readonly Dictionary> _customTokens + = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + private sealed class OpenFile : IDisposable + { + public readonly FileStream Stream; + public readonly StreamWriter Writer; + public readonly DateTime Date; + public long Size; + public bool HasCsvHeader; + public bool HasXmlHeader; + public int WritesSinceExistenceCheck; + public long LastWriteTicksUtc; + + public OpenFile(FileStream fs, StreamWriter sw, DateTime date) + { + Stream = fs; + Writer = sw; + Date = date; + Size = fs.Length; + LastWriteTicksUtc = DateTime.UtcNow.Ticks; + } + + public void Dispose() + { + try { Writer.Flush(); } catch { } + try { Writer.Dispose(); } catch { } + try { Stream.Dispose(); } catch { } + } + } + + public FileFlow( + string directory, + string filePrefix = "log", + long maxFileSize = 200 * 1024 * 1024, + long maxDirectorySize = 2L * 1024 * 1024 * 1024, + FileRetentionPolicy retention = null, + int flushIntervalMs = 250, + int batchSize = 1, + LogLevel minimumLevel = LogLevel.Trace, + bool useCategoryRouting = false, + LogLevel[] logLevelsForSeparateFiles = null, + TimestampMode timestampMode = TimestampMode.Utc, + BackpressureStrategy backpressure = BackpressureStrategy.DropOldest, + FileOutputFormat outputFormat = FileOutputFormat.Text, + CompressionFormat compression = CompressionFormat.GZip, + string template = "[{ts}] [Host: {host}] [Category: {category}] [Thread: {thread}] [{logtype}] {message}{props}", + long maxMemoryBytes = 20 * 1024 * 1024) + : base("File:" + Path.Combine(directory, filePrefix), minimumLevel) + { + if (directory == null) + { + throw new ArgumentNullException("directory"); + } + + if (filePrefix == null) + { + throw new ArgumentNullException("filePrefix"); + } + + if (template == null) + { + throw new ArgumentNullException("template"); + } + + _batchSize = batchSize <= 0 ? 1 : batchSize; + _directory = directory; + _filePrefix = filePrefix; + _template = template; + _maxFileSize = maxFileSize; + _maxDirectorySize = maxDirectorySize > 0 ? maxDirectorySize : 10L * maxFileSize; + _retention = retention ?? new FileRetentionPolicy(); + _timestampMode = timestampMode; + _useCategoryRouting = useCategoryRouting; + _maxMemoryBytes = maxMemoryBytes; + _flushInterval = TimeSpan.FromMilliseconds(flushIntervalMs); + _outputFormat = outputFormat; _compressionFormat = compression; // Use the cascading fallback strategy to resolve the final logging directory - try - { - _directory = ResolveLoggingDirectory(_directory); - } - catch (Exception ex) - { - _directory = Path.GetTempPath(); - OnDirectoryException?.Invoke(this, $"FileFlow: Critical failure in directory resolution: {ex.Message}. Falling back to temp: '{_directory}'"); - } - - _logLevelsForSeparateFiles = logLevelsForSeparateFiles != null - ? new HashSet(logLevelsForSeparateFiles) - : new HashSet(); - - SetFileExtension(outputFormat); - CompileTemplate(template); - - // BlockingCollection with bounded capacity - _queue = new BlockingCollection(new ConcurrentQueue(), QueueCapacity); - - // Dedicated writer thread - _writerThread = new Thread(WriterThreadBody) - { - IsBackground = true, - Name = "FileFlow.Writer[" + filePrefix + "]", - Priority = ThreadPriority.AboveNormal, - }; - _writerThread.Start(); - - // Dedicated compression thread - _compressionThread = new Thread(CompressionThreadBody) - { - IsBackground = true, - Name = "FileFlow.Compress[" + filePrefix + "]", - Priority = ThreadPriority.BelowNormal, - }; - _compressionThread.Start(); - - _flushTask = flushIntervalMs > 0 - ? Task.Factory.StartNew(PeriodicFlushLoop, TaskCreationOptions.LongRunning) - : Task.FromResult(0); - - _retentionTask = Task.Factory.StartNew(RetentionLoop, TaskCreationOptions.LongRunning); - } - - /// Add an ambient property enricher applied to every event. - public FileFlow EnrichWith(string key, Func valueFactory) - { - if (key == null) - { - throw new ArgumentNullException("key"); - } - - if (valueFactory == null) - { - throw new ArgumentNullException("valueFactory"); - } - - _enrichers.Add(new KeyValuePair>(key, valueFactory)); - return this; - } - - /// Add a static ambient property. - public FileFlow EnrichWith(string key, object value) - { - return EnrichWith(key, _ => value); - } - - /// Configure sampling: only log 1 in events - /// that match . - public FileFlow WithSampling(int rate, Func predicate = null) - { - _samplingPolicy = new SamplingPolicy { Rate = rate, Predicate = predicate }; - return this; - } - - /// Callback invoked when an event is dropped due to backpressure. - public FileFlow OnEventDropped(Action callback) - { - _onDrop = callback; - return this; - } - - /// Callback invoked with the archived path after each file rotation. - public FileFlow OnFileRotated(Action callback) - { - _onRotate = callback; - return this; - } - - /// Fan-out: also invoke for every formatted log line. - public FileFlow AddSecondaryWriter(Action writer) - { - if (writer == null) - { - throw new ArgumentNullException("writer"); - } - - lock (_secondaryWritersLock) - { - if (_secondaryWriters == null) - { - _secondaryWriters = new List>(); - } - - _secondaryWriters.Add(writer); - } - return this; - } - - /// Register a custom template token (e.g. {mytoken}). - public FileFlow RegisterToken(string name, Action formatter) - { - if (formatter == null) - { - throw new ArgumentNullException("formatter"); - } - - _customTokens[name] = formatter; - return this; - } - - /// Change the minimum log level at runtime (thread-safe). - public void SetMinimumLevel(LogLevel level) - { - MinimumLevel = level; - } - - /// Add a custom filter predicate. Events are logged only if ALL filters return true. - public FileFlow WithFilter(Func predicate) - { - if (predicate == null) - { - throw new ArgumentNullException("predicate"); - } - - lock (_filtersLock) - { - _filters.Add(predicate); - } - return this; - } - - /// Enable deduplication: suppress identical messages within the given time window. - public FileFlow WithDeduplication(TimeSpan window) - { - if (window <= TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException("window", "Deduplication window must be positive."); - } - - _deduplicationWindow = window; - _deduplicationEnabled = true; - return this; - } - - /// Configure a custom date format for log file names (default: yyyyMMdd). - public FileFlow WithDateFormat(string dateFormat) - { - if (string.IsNullOrWhiteSpace(dateFormat)) - { - throw new ArgumentNullException("dateFormat"); - } - - _dateFormat = dateFormat; - return this; - } - - /// Limit the flow to a maximum number of events per second. Events exceeding the limit are dropped. - public FileFlow WithRateLimit(int maxEventsPerSecond) - { - _maxEventsPerSecond = maxEventsPerSecond; - return this; - } - - /// When enabled, the file stream is flushed immediately after writing Error or Critical level events. - public FileFlow WithAutoFlushOnError(bool enabled = true) - { - _autoFlushOnError = enabled; - return this; - } - - /// Push scoped properties that will be included in all log events written on the current async context. - public IDisposable BeginScope(params KeyValuePair[] properties) - { - var previous = _scopeProperties.Value; - var merged = previous != null - ? new Dictionary(previous) - : new Dictionary(); - - foreach (var kv in properties) - { - merged[kv.Key] = kv.Value; - } - - _scopeProperties.Value = merged; - return new ScopeDisposable(previous); - } - - /// Push a single scoped property. - public IDisposable BeginScope(string key, object value) - { - return BeginScope(new KeyValuePair(key, value)); - } - - /// Returns the current queue depth (number of pending events). - public int GetQueueDepth() - { - return _queue.Count; - } - - /// Returns the current estimated memory usage of the queue in bytes. - public long GetMemoryPressureBytes() - { - return Interlocked.Read(ref _currentMemoryBytes); - } - - /// Generates a fingerprint hash for an exception to assist with grouping. - public static string GetExceptionFingerprint(Exception ex) - { - if (ex == null) - { - return null; - } - - string source = string.Concat( - ex.GetType().FullName, "|", - ex.TargetSite?.Name ?? string.Empty, "|", - ex.StackTrace != null && ex.StackTrace.Length > 0 - ? ex.StackTrace.Substring(0, Math.Min(200, ex.StackTrace.Length)) - : string.Empty); - - // Simple FNV-1a hash - unchecked - { - uint hash = 2166136261; - foreach (char c in source) - { - hash ^= c; - hash *= 16777619; - } - return hash.ToString("x8"); - } - } - - private sealed class ScopeDisposable : IDisposable - { - private readonly Dictionary _previous; - - public ScopeDisposable(Dictionary previous) - { - _previous = previous; - } - - public void Dispose() - { - _scopeProperties.Value = _previous; - } - } - - /// Returns true if the flow is healthy (no recent errors and writer thread alive). - public bool IsHealthy() - { - if (!IsEnabled) - { - return false; - } - - if (!_writerThread.IsAlive) - { - return false; - } - - long lastErr = Interlocked.Read(ref _lastErrorTimestamp); - if (lastErr > 0) - { - TimeSpan since = TimeSpan.FromTicks(DateTime.UtcNow.Ticks - lastErr); - if (since < TimeSpan.FromMinutes(1)) - { - return false; - } - } - - return true; - } - - /// Returns the last error encountered by the writer, or null if none. - public Exception GetLastError() - { - return _lastError; - } - - /// Returns the total number of write errors encountered. - public long GetTotalErrors() - { - return Interlocked.Read(ref _totalErrors); - } - - /// Returns live throughput and health metrics. - public LogStats GetStats() - { - long written = Interlocked.Read(ref BlastedCount); - long dropped = Interlocked.Read(ref DroppedCount); - long bytes = Interlocked.Read(ref _totalBytesWritten); - long rots = Interlocked.Read(ref _totalRotations); - double elapsed = _uptime.Elapsed.TotalSeconds; - double wps = elapsed > 0 ? written / elapsed : 0; - return new LogStats(written, dropped, rots, bytes, wps); - } - - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public override Task BlastAsync( - LogEvent logEvent, - CancellationToken cancellationToken = default(CancellationToken)) - { - if (!IsEnabled || !IsLogLevelEnabled(logEvent)) - { - return Task.FromResult(WriteResult.LevelFiltered); - } - - SamplingPolicy sp = _samplingPolicy; - if (sp != null && !sp.ShouldLog(logEvent)) - { - return Task.FromResult(WriteResult.LevelFiltered); - } - - if (!PassesFilters(logEvent)) - { - return Task.FromResult(WriteResult.LevelFiltered); - } - - if (_deduplicationEnabled && IsDuplicate(logEvent)) - { - return Task.FromResult(WriteResult.LevelFiltered); - } - - if (_rateLimitEnabled && !TryPassRateLimit()) - { - Interlocked.Increment(ref DroppedCount); - Action drop = _onDrop; - if (drop != null) - { - drop(logEvent); - } - - return Task.FromResult(WriteResult.Dropped); - } - - return Task.FromResult(TryEnqueue(logEvent)); - } - - public override Task BlastBatchAsync( - ReadOnlyMemory logEvents, - CancellationToken cancellationToken = default(CancellationToken)) - { - if (!IsEnabled) - { - return Task.FromResult(WriteResult.FlowDisabled); - } - - WriteResult result = WriteResult.Success; - SamplingPolicy sp = _samplingPolicy; - ReadOnlySpan span = logEvents.Span; - - for (int i = 0; i < span.Length; i++) - { - LogEvent e = span[i]; - if (e.Level < MinimumLevel) - { - continue; - } - - if (sp != null && !sp.ShouldLog(e)) - { - continue; - } - - if (!PassesFilters(e)) - { - continue; - } - - if (_deduplicationEnabled && IsDuplicate(e)) - { - continue; - } - - if (_rateLimitEnabled && !TryPassRateLimit()) - { - Interlocked.Increment(ref DroppedCount); - Action drop = _onDrop; - if (drop != null) - { - drop(e); - } - - result = WriteResult.Dropped; - continue; - } - - if (TryEnqueue(e) == WriteResult.Dropped) - { - result = WriteResult.Dropped; - } - } - - return Task.FromResult(result); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private WriteResult TryEnqueue(LogEvent log) - { - long size = EstimateSize(log); - long current = Interlocked.Read(ref _currentMemoryBytes); - - if (current + size > _maxMemoryBytes) - { - Interlocked.Increment(ref DroppedCount); - Action drop = _onDrop; - if (drop != null) - { - drop(log); - } - - return WriteResult.Dropped; - } - - if (!_queue.TryAdd(log)) - { - Interlocked.Increment(ref DroppedCount); - Action drop = _onDrop; - if (drop != null) - { - drop(log); - } - - return WriteResult.Dropped; - } - - Interlocked.Add(ref _currentMemoryBytes, size); - Interlocked.Increment(ref BlastedCount); - return WriteResult.Success; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool TryPassRateLimit() - { - long now = DateTime.UtcNow.Ticks; - lock (_rateLimitLock) - { - long elapsed = now - _rateLimitWindowStart; - if (elapsed >= TimeSpan.TicksPerSecond) - { - _rateLimitWindowStart = now; - _rateLimitCounter = 1; - return true; - } - - if (_rateLimitEnabled && _rateLimitCounter >= _maxEventsPerSecond) - { - return false; - } - - _rateLimitCounter++; - return true; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool PassesFilters(LogEvent log) - { - lock (_filtersLock) - { - for (int i = 0; i < _filters.Count; i++) - { - if (!_filters[i](log)) - { - return false; - } - } - } - return true; - } - - private bool IsDuplicate(LogEvent log) - { - string key = string.Concat( - log.Level.ToString(), "|", - log.Category ?? string.Empty, "|", - log.Message.Length > 0 ? log.Message.ToString() : string.Empty); - - long nowTicks = DateTime.UtcNow.Ticks; - long windowTicks = _deduplicationWindow.Ticks; - - long existing; - if (_deduplicationCache.TryGetValue(key, out existing)) - { - if (nowTicks - existing < windowTicks) - { - return true; - } - } - - _deduplicationCache[key] = nowTicks; - - // Periodic cleanup: remove expired entries when cache grows large - if (_deduplicationCache.Count > 10000) - { - CleanDeduplicationCache(nowTicks, windowTicks); - } - - return false; - } - - private void CleanDeduplicationCache(long nowTicks, long windowTicks) - { - foreach (var kvp in _deduplicationCache) - { - if (nowTicks - kvp.Value >= windowTicks) - { - long removed; - _deduplicationCache.TryRemove(kvp.Key, out removed); - } - } - } - - - public override Task FlushAsync(CancellationToken cancellationToken = default(CancellationToken)) - { - try - { - Stopwatch sw = Stopwatch.StartNew(); - while (_queue.Count > 0 - && !cancellationToken.IsCancellationRequested - && sw.Elapsed < TimeSpan.FromSeconds(10)) - { - Thread.Sleep(5); - } - - lock (_fileLock) - { - foreach (OpenFile of in _openFiles.Values) - { - try { of.Writer.Flush(); } catch { /* ignore */ } - } - } - - return Task.FromResult(0); - } - catch - { - return Task.FromResult(0); - } - } - - - public override async ValueTask DisposeAsync() - { - if (!IsEnabled) - { - return; - } - - IsEnabled = false; - _isDisposing = true; // Signal all threads that disposal is in progress - - _queue.CompleteAdding(); - - // Give the writer thread time to drain the queue before canceling - Stopwatch sw = Stopwatch.StartNew(); - while (_queue.Count > 0 && sw.Elapsed < TimeSpan.FromSeconds(5)) - { - Thread.Sleep(10); - } - - _cts.Cancel(); - - // Give threads more time to gracefully exit (increased from 2000ms to 5000ms) - _writerThread.Join(5000); - _compressionSignal.Release(); - _compressionThread.Join(5000); - - lock (_fileLock) - { - foreach (var outputFile in _openFiles.Values) - { - if (_outputFormat == FileOutputFormat.Xml && outputFile.HasXmlHeader) - { - try - { - outputFile.Writer.WriteLine(""); - } - catch { } - } - - outputFile.Dispose(); - } - - _openFiles.Clear(); - } - - // 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(); - - await base.DisposeAsync().ConfigureAwait(false); - } - private void WriterThreadBody() - { - try - { - while (!_queue.IsCompleted) - { - 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; } - - WriteLogEvent(e); - - // Drain additional items - int extra = 0; - LogEvent next; - while (extra < _batchSize && _queue.TryTake(out next)) - { - WriteLogEvent(next); - extra++; - } - } - } - catch (Exception ex) - { - OnException?.Invoke(this, $"[FileFlow] Writer thread encountered an error: {ex.Message}"); - } - finally - { - // Drain remaining events before the thread exits - LogEvent remaining; - while (_queue.TryTake(out remaining)) - { - WriteLogEvent(remaining); - } - - // Final flush - lock (_fileLock) - { - foreach (OpenFile of in _openFiles.Values) - { - try { of.Writer.Flush(); } catch { /* ignore */ } - } - } - } - } - private void WriteLogEvent(LogEvent log) - { - long size =0; - try - { - size = EstimateSize(log); - } - catch - { - size = 512; - } - - Interlocked.Add(ref _currentMemoryBytes, -size); - - string line; - try - { - StringBuilder sb = StringBuilderPool.Rent(); - try - { - switch (_outputFormat) - { - case FileOutputFormat.Json: - FormatJson(log, sb, false); - break; - case FileOutputFormat.StructuredJson: - FormatJson(log, sb, true); - break; - case FileOutputFormat.Xml: - FormatXml(log, sb); - break; - case FileOutputFormat.Csv: - FormatCsv(log, sb); - break; - default: - FormatText(log, sb); - break; - } - line = sb.ToString(); - } - finally - { - StringBuilderPool.Return(sb); - } - } - catch (Exception ex) - { - try - { - line = "[FileFlow] Format error: " + ex.Message - + " | Original level=" + log.Level - + " message=" + (log.Message.Length > 0 ? log.Message.ToString() : "(empty)"); - } - catch - { - line = "[FileFlow] Format error (unrecoverable)"; - } - } - - try - { - string path = GenerateFilePath(GetCurrentDate(), log); - - lock (_fileLock) - { - try - { - if (!EnsureFileOpen(path, log)) - { - // File open failed; skip write operation but continue with other processing - } - else - { - if (ShouldRotate(path, line.Length)) - { - string archived = RotateFile(path); - if (!EnsureFileOpen(path, log)) - { - // File reopen after rotation failed; skip this write - } - else - { - OpenFile of; - if (_openFiles.TryGetValue(path, out of)) - { - of.Writer.WriteLine(line); - of.Size += line.Length + Environment.NewLine.Length; - } - } - if (archived != null) - { - Action onRotate = _onRotate; - if (onRotate != null) - { - try { onRotate(archived); } - catch { /* Do nothing */ } - } - } - } - else - { - OpenFile of; - if (_openFiles.TryGetValue(path, out of)) - { - of.Writer.WriteLine(line); - of.Size += line.Length + Environment.NewLine.Length; - } - } - } - } - catch (Exception ex) - { - _lastError = ex; - Interlocked.Increment(ref _totalErrors); - Interlocked.Exchange(ref _lastErrorTimestamp, DateTime.UtcNow.Ticks); - try - { - 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); - } - } - OnException?.Invoke(this, "[FileFlow] Write error for '" + path + "': " + ex.Message + diagnosis); - } - catch { /* Do nothing */ } - } - } - - Interlocked.Add(ref _totalBytesWritten, line.Length + 1); - - // Auto-flush on error/critical - if (_autoFlushOnError && (log.Level >= LogLevel.Error)) - { - lock (_fileLock) - { - OpenFile autoFlushOf; - if (_openFiles.TryGetValue(path, out autoFlushOf)) - { - try { autoFlushOf.Writer.Flush(); } catch { /* ignore */ } - } - } - } - } - catch (Exception ex) - { - try - { - OnException?.Invoke(this, "[FileFlow] WriteLogEvent error: " + ex.Message); - } - catch { /* Do nothing */ } - } - - // Fan-out to secondary writers - try - { - if (_secondaryWriters != null) - { - lock (_secondaryWritersLock) - { - foreach (Action writer in _secondaryWriters) - { - try { writer(log, line); } - catch - { - // Do nothing - } - } - } - } - } - catch - { - // Do nothing - } - } - - - private void FormatText(LogEvent log, StringBuilder sb) - { - foreach (Action action in _compiledTemplate) - { - action(log, sb); - } - } - - - private void FormatJson(LogEvent log, StringBuilder sb, bool structured) - { - sb.Append('{'); - - // Timestamp - sb.Append("\"timestamp\":\""); - AppendJsonEscaped(LogEvent.GetDateTime(log.Timestamp).ToString("O"), sb); - sb.Append("\","); - - // Level - sb.Append("\"level\":\""); - string lvlStr; - sb.Append(LevelStrings.TryGetValue(log.Level, out lvlStr) ? lvlStr : log.Level.ToString()); - sb.Append("\","); - - if (structured) - { - // Monotonic hex correlation ID - int cid = Interlocked.Increment(ref _correlationSeed); - sb.Append("\"correlationId\":\""); - sb.Append(cid.ToString("x8")); - sb.Append("\","); - - sb.Append("\"host\":\""); - AppendJsonEscaped(CachedMachineName, sb); - sb.Append("\","); - - sb.Append("\"pid\":"); - sb.Append(CachedPid); - sb.Append(','); - - // Distributed tracing - if (log.TraceId != default(ActivityTraceId)) - { - sb.Append("\"traceId\":\""); - sb.Append(log.TraceId.ToHexString()); - sb.Append("\","); - } - - if (log.SpanId != default(ActivitySpanId)) - { - sb.Append("\"spanId\":\""); - sb.Append(log.SpanId.ToHexString()); - sb.Append("\","); - } - - sb.Append("\"threadId\":"); - sb.Append(log.ThreadId); - sb.Append(','); - } - - // Category - sb.Append("\"category\":\""); - if (!string.IsNullOrEmpty(log.Category)) - { - AppendJsonEscaped(log.Category, sb); - } - - sb.Append("\","); - - // Message - sb.Append("\"message\":\""); - if (log.Message.Length > 0) - { - AppendJsonEscaped(log.Message.ToString(), sb); - } - - sb.Append('"'); - - // Exception - if (log.Exception != null) - { - sb.Append(",\"exception\":\""); - AppendJsonEscaped(log.Exception.ToString(), sb); - sb.Append('"'); - } - - // Properties (enrichers + event properties) - bool hasEnrichers = _enrichers.Count > 0; - bool hasProps = log.Properties.Count > 0; - if (hasEnrichers || hasProps) - { - sb.Append(",\"properties\":{"); - bool first = true; - - foreach (KeyValuePair> kv in _enrichers) - { - if (!first) - { - sb.Append(','); - } - - first = false; - sb.Append('"'); - AppendJsonEscaped(kv.Key, sb); - sb.Append("\":\""); - object val = kv.Value(log); - AppendJsonEscaped(val != null ? val.ToString() : "null", sb); - sb.Append('"'); - } - - foreach (var property in log.Properties) - { - if (!first) - { - sb.Append(','); - } - - first = false; - sb.Append('"'); - AppendJsonEscaped(property.Key, sb); - sb.Append("\":"); - if (property.Value == null) - { - sb.Append("null"); - } - else - { - sb.Append('"'); - AppendJsonEscaped(property.Value.ToString(), sb); - sb.Append('"'); - } - } - - sb.Append('}'); - } - - sb.Append('}'); - } - - private static void AppendJsonEscaped(string value, StringBuilder sb) - { - if (value == null) - { - return; - } - - foreach (char c in value) - { - switch (c) - { - case '"': sb.Append("\\\""); break; - case '\\': sb.Append("\\\\"); break; - case '\b': sb.Append("\\b"); break; - case '\f': sb.Append("\\f"); break; - case '\n': sb.Append("\\n"); break; - case '\r': sb.Append("\\r"); break; - case '\t': sb.Append("\\t"); break; - default: - if (char.IsControl(c)) - { - sb.Append("\\u"); - sb.Append(((int)c).ToString("x4")); - } - else - { - sb.Append(c); - } - - break; - } - } - } - - private void RepairXmlIfNeeded(string path) - { - try - { - if (!File.Exists(path)) - { - return; - } - - const string footer = ""; - const int tailSize = 512; - - using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) - { - if (fs.Length < footer.Length) - { - return; - } - - int readSize = (int)Math.Min(tailSize, fs.Length); - fs.Seek(-readSize, SeekOrigin.End); - - byte[] buffer = new byte[readSize]; - fs.Read(buffer, 0, readSize); - - string tail = Encoding.UTF8.GetString(buffer); - - if (!tail.Contains(footer)) - { - fs.Seek(0, SeekOrigin.End); - using (var sw = new StreamWriter(fs, Encoding.UTF8, 1024, true)) - { - sw.WriteLine(); - sw.WriteLine(footer); - sw.Flush(); - } - } - } - } - catch - { - // Never throw during logging - } - } - - private void FormatXml(LogEvent log, StringBuilder sb) - { - sb.Append(""); - - string lvlStr; - AppendXmlElement("timestamp", LogEvent.GetDateTime(log.Timestamp).ToString("O"), sb); - AppendXmlElement("level", LevelStrings.TryGetValue(log.Level, out lvlStr) ? lvlStr : log.Level.ToString(), sb); - - if (!string.IsNullOrEmpty(log.Category)) - { - AppendXmlElement("category", log.Category, sb); - } - - sb.Append(""); - if (log.Message.Length > 0) - { - AppendXmlEscaped(log.Message.ToString(), sb); - } - - sb.Append(""); - - if (log.Exception != null) - { - AppendXmlElement("exception", log.Exception.ToString(), sb); - } - - bool hasEnrichers = _enrichers.Count > 0; - bool hasProps = log.Properties.Count > 0; - if (hasEnrichers || hasProps) - { - sb.Append(""); - - foreach (KeyValuePair> kv in _enrichers) - { - sb.Append(""); - object val = kv.Value(log); - AppendXmlEscaped(val != null ? val.ToString() : "null", sb); - sb.Append(""); - } - - foreach (var property in log.Properties) - { - if (string.IsNullOrEmpty(property.Key)) - { - continue; - } - - sb.Append(""); - if (property.Value != null) - { - AppendXmlEscaped(property.Value.ToString(), sb); - } - else - { - sb.Append("null"); - } - - sb.Append(""); - } - - sb.Append(""); - } - - sb.Append(""); - } - - private static void AppendXmlElement(string tag, string content, StringBuilder sb) - { - sb.Append('<').Append(tag).Append('>'); - AppendXmlEscaped(content, sb); - sb.Append("'); - } - - private static void AppendXmlEscaped(string value, StringBuilder sb) - { - if (value == null) - { - return; - } - - foreach (char c in value) - { - switch (c) - { - case '&': sb.Append("&"); break; - case '<': sb.Append("<"); break; - case '>': sb.Append(">"); break; - case '"': sb.Append("""); break; - case '\'': sb.Append("'"); break; - default: - if (char.IsControl(c) && c != '\r' && c != '\n' && c != '\t') - { - sb.Append('?'); - } - else - { - sb.Append(c); - } - - break; - } - } - } - - - private void FormatCsv(LogEvent log, StringBuilder sb) - { - string lvlStr; - - AppendCsvField(LogEvent.GetDateTime(log.Timestamp).ToString("O"), sb); - sb.Append(','); - AppendCsvField(LevelStrings.TryGetValue(log.Level, out lvlStr) ? lvlStr : log.Level.ToString(), sb); - sb.Append(','); - AppendCsvField(log.Category ?? string.Empty, sb); - sb.Append(','); - AppendCsvField(log.Message.Length > 0 ? log.Message.ToString() : string.Empty, sb); - sb.Append(','); - AppendCsvField(log.Exception != null ? log.Exception.ToString() : string.Empty, sb); - sb.Append(','); - - // Properties column: key=value; key=value - sb.Append('"'); - bool first = true; - foreach (KeyValuePair> kv in _enrichers) - { - if (!first) - { - sb.Append("; "); - } - - first = false; - AppendCsvInner(kv.Key, sb); - sb.Append('='); - object val = kv.Value(log); - AppendCsvInner(val != null ? val.ToString() : "null", sb); - } - foreach (var property in log.Properties) - { - if (!first) - { - sb.Append("; "); - } - - first = false; - AppendCsvInner(property.Key ?? string.Empty, sb); - sb.Append('='); - AppendCsvInner(property.Value != null ? property.Value.ToString() : "null", sb); - } - sb.Append('"'); - } - - private static void AppendCsvField(string value, StringBuilder sb) - { - if (value == null) - { - value = string.Empty; - } - - bool needsQuote = value.IndexOfAny(CsvSpecialChars) >= 0; - if (needsQuote) - { - sb.Append('"'); - } - - AppendCsvInner(value, sb); - if (needsQuote) - { - sb.Append('"'); - } - } - - private static void AppendCsvInner(string value, StringBuilder sb) - { - if (value == null) - { - return; - } - - foreach (char c in value) - { - if (c == '"') - { - // RFC 4180: escape quote by doubling - sb.Append('"'); - } - - sb.Append(c); - } - } - - private bool EnsureFileOpen(string path, LogEvent logEvent) - { - try - { - OpenFile existing; - if (_openFiles.TryGetValue(path, out existing)) - { - try - { - if (File.Exists(path)) - { - return true; - } - } - catch - { - // File.Exists failed (permissions, etc.) – assume file is gone - } - - // File was deleted or check failed; dispose stale handle - try { existing.Dispose(); } catch { /* ignore */ } - _openFiles.Remove(path); - } - - FileStream fs = null; - StreamWriter sw = null; - try - { - string dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(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( - path, - FileMode.Append, - FileAccess.Write, - FileShare.ReadWrite | FileShare.Delete, - FileBufferSize, - FileOptions.SequentialScan); - - sw = new StreamWriter(fs, Encoding.UTF8, WriterBufferSize); - sw.AutoFlush = false; - - OpenFile of = new OpenFile(fs, sw, GetCurrentDate()); - _openFiles[path] = of; - - // From here on, resources are owned by _openFiles; clear locals - // so the finally block does not double-dispose. - fs = null; - sw = null; - - if (_outputFormat == FileOutputFormat.Csv && of.Stream.Length == 0) - { - try - { - of.Writer.Write(CsvHeader); - of.Writer.Flush(); - of.HasCsvHeader = true; - } - catch (Exception ex) - { - OnException?.Invoke(this, "[FileFlow] CSV header write error for '" + path + "': " + ex.Message); - } - } - - if (_outputFormat == FileOutputFormat.Xml) - { - try - { - if (of.Stream.Length == 0) - { - of.Writer.WriteLine(""); - of.Writer.WriteLine(""); - of.Writer.Flush(); - of.HasXmlHeader = true; - } - else - { - of.Writer.Flush(); - RepairXmlIfNeeded(path); - of.HasXmlHeader = true; - } - } - catch (Exception ex) - { - OnException?.Invoke(this, "[FileFlow] XML header write error for '" + path + "': " + ex.Message); - } - } - - return true; - } - catch (Exception ex) - { - // Clean up partially-allocated resources that were never - // handed off to an OpenFile entry. - try { sw?.Dispose(); } catch { /* ignore */ } - try { fs?.Dispose(); } catch { /* ignore */ } - - // Remove any entry that may be in a broken state - OpenFile broken; - if (_openFiles.TryGetValue(path, out broken)) - { - try { broken.Dispose(); } catch { /* ignore */ } - _openFiles.Remove(path); - } - - 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); - } - } - OnException?.Invoke(this, "[FileFlow] Failed to open '" + path + "': " + ex.Message + diagnosis); - return false; - } - } - catch (Exception ex) - { - // Outermost safety net – never let this method take down the process - try - { - OnException?.Invoke(this, "[FileFlow] EnsureFileOpen unhandled error: " + ex.Message); - } - catch { /* ignore */ } - return false; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool ShouldRotate(string path, long additionalBytes) - { - OpenFile of; - if (!_openFiles.TryGetValue(path, out of)) - { - return false; - } - - return of.Size + additionalBytes > _maxFileSize - || of.Date.Date != GetCurrentDate() - || Interlocked.Read(ref _currentMemoryBytes) > (long)(_maxMemoryBytes * 0.9); - } - - private string RotateFile(string path) - { - OpenFile of; - if (_openFiles.TryGetValue(path, out of)) - { - if (_outputFormat == FileOutputFormat.Xml && of.HasXmlHeader) - { - try - { - of.Writer.WriteLine(""); - of.Writer.Flush(); - } - catch { } - } - - of.Dispose(); - _openFiles.Remove(path); - } - - if (!File.Exists(path)) - { - return null; - } - - string archived = ArchiveFile(path); - Interlocked.Increment(ref _totalRotations); - return archived; - } - - private string ArchiveFile(string filePath) - { - try - { - string dir = Path.GetDirectoryName(filePath); - string fileName = Path.GetFileName(filePath); - int extIdx = fileName.LastIndexOf(_fileExtension, StringComparison.OrdinalIgnoreCase); - string baseName = extIdx >= 0 ? fileName.Substring(0, extIdx) : fileName; - int maxFiles = _retention.MaxRolledFiles > 0 ? _retention.MaxRolledFiles : 999; - - for (int i = maxFiles - 1; i >= 1; i--) - { - string src = Path.Combine(dir, baseName + "_" + i + _fileExtension); - string srcGz = src + ".gz"; - string dst = Path.Combine(dir, baseName + "_" + (i + 1) + _fileExtension); - string dstGz = dst + ".gz"; - - // Move .gz variant if it exists - if (File.Exists(srcGz)) - { - if (File.Exists(dstGz)) - { - File.Delete(dstGz); - } - File.Move(srcGz, dstGz); - } - - if (!File.Exists(src)) - { - continue; - } - - if (File.Exists(dst)) - { - File.Delete(dst); - } - File.Move(src, dst); - } - - string archive = Path.Combine(dir, baseName + "_1" + _fileExtension); - if (File.Exists(archive)) - { - File.Delete(archive); - } - - File.Move(filePath, archive); - - // Queue the archived file for compression - if (_compressionFormat != CompressionFormat.None) - { - _compressionQueue.Enqueue(archive); - _compressionSignal.Release(1); - } - - return archive; - } - catch (Exception ex) - { - OnException?.Invoke(this, "[FileFlow] Archive error '" + filePath + "': " + ex.Message); - return null; - } - } - - private void CompressionThreadBody() - { - while (true) - { - 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(); - return; - } - - DrainCompressionQueue(); - } - } - - private void DrainCompressionQueue() - { - string path; - while (_compressionQueue.TryDequeue(out path)) - { - try { CompressFile(path); } - catch (Exception ex) - { - OnException?.Invoke(this, "[FileFlow] Compress error '" + path + "': " + ex.Message); - } - } - } - - private void CompressFile(string path) - { - if (_compressionFormat == CompressionFormat.None || !File.Exists(path)) - { - return; - } - - string outPath = path + ".gz"; - - if (File.Exists(outPath)) - { - string ts = DateTime.UtcNow.ToString("yyMMdd_HHmmss_fff"); - File.Move(outPath, path + "_" + ts + ".gz"); - } - - const int bufSize = 65536; - byte[] buffer = new byte[bufSize]; - - using (FileStream src = File.OpenRead(path)) - using (FileStream dst = File.Create(outPath)) - using (GZipStream gz = new GZipStream(dst, CompressionLevel.Optimal)) - { - int read; - while ((read = src.Read(buffer, 0, bufSize)) > 0) - { - gz.Write(buffer, 0, read); - } - } - - // Delete the original uncompressed file after successful compression - try { File.Delete(path); } catch { /* ignore */ } - - EnforceCompressedFileLimit(); - } - - private void EnforceCompressedFileLimit() - { - if (_retention.MaxCompressedFiles <= 0) - { - return; - } - - try - { - DirectoryInfo dir = new DirectoryInfo(_directory); - if (!dir.Exists) - { - return; - } - - FileInfo[] compressedFiles = dir.GetFiles("*" + _fileExtension + ".gz") - .OrderByDescending(f => f.LastWriteTimeUtc) - .ToArray(); - - for (int i = _retention.MaxCompressedFiles; i < compressedFiles.Length; i++) - { - try { compressedFiles[i].Delete(); } catch { /* ignore */ } - } - } - catch (Exception ex) - { - OnException?.Invoke(this, "[FileFlow] Compressed retention error: " + ex.Message); - } - } - - private void PeriodicFlushLoop() - { - try - { - while (!_isDisposing && !_cts.Token.IsCancellationRequested) - { - try - { - long mem = Interlocked.Read(ref _currentMemoryBytes); - int delay = mem > (long)(_maxMemoryBytes * 0.8) - ? 250 - : (int)_flushInterval.TotalMilliseconds; - - // Take precaution before using token - if (_isDisposing) - { - 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) - { - OnException?.Invoke(this, "[FileFlow] Flush error: " + ex.Message); - } - } - } - catch (Exception ex) - { - OnException?.Invoke(this, "[FileFlow] PeriodicFlushLoop error: " + ex.Message); - } - } - - private void RetentionLoop() - { - try - { - while (!_isDisposing && !_cts.Token.IsCancellationRequested) - { - try - { - // Check disposal state before accessing token - if (_isDisposing) - { - break; - } - - if (_cts.Token.WaitHandle.WaitOne(TimeSpan.FromMinutes(15))) - { - break; - } - - ApplyRetention(); - } - catch (ObjectDisposedException) - { - // Token source was disposed; exit gracefully - break; - } - } - } - catch (Exception ex) - { - OnException?.Invoke(this, "[FileFlow] RetentionLoop error: " + ex.Message); - } - } - - private void ApplyRetention() - { - try - { - DirectoryInfo dir = new DirectoryInfo(_directory); - if (!dir.Exists) - { - return; - } - - FileInfo[] files = dir.GetFiles("*" + _fileExtension) - .Concat(dir.GetFiles("*" + _fileExtension + ".gz")) - .OrderByDescending(f => f.LastWriteTimeUtc) - .ToArray(); - - long totalBytes = 0; - int kept = 0; - int keptCompressed = 0; - - foreach (FileInfo f in files) - { - bool isCompressed = f.Extension.Equals(".gz", StringComparison.OrdinalIgnoreCase); - bool tooOld = _retention.MaxAgeDays > 0 - && (DateTime.UtcNow - f.LastWriteTimeUtc).TotalDays > _retention.MaxAgeDays; - bool tooMany = _retention.MaxRolledFiles > 0 && kept >= _retention.MaxRolledFiles; - bool tooManyCompressed = isCompressed - && _retention.MaxCompressedFiles > 0 - && keptCompressed >= _retention.MaxCompressedFiles; - bool tooLarge = _retention.MaxTotalArchiveBytes > 0 - && totalBytes + f.Length > _retention.MaxTotalArchiveBytes; - bool directoryTooLarge = totalBytes + f.Length > _maxDirectorySize; - - if (tooOld || tooMany || tooManyCompressed || tooLarge || directoryTooLarge) - { - try { f.Delete(); } catch { /* ignore */ } - } - else - { - totalBytes += f.Length; - kept++; - if (isCompressed) - { - keptCompressed++; - } - } - } - } - catch (Exception ex) - { - OnException?.Invoke(this, "[FileFlow] Retention error: " + ex.Message); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private DateTime GetCurrentDate() - { - return _timestampMode == TimestampMode.Local ? DateTime.Now.Date : DateTime.UtcNow.Date; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private string GenerateFilePath(DateTime date, LogEvent log) - { - string prefix = _filePrefix; - - if (_useCategoryRouting && !string.IsNullOrEmpty(log.Category)) - { - prefix += "_" + log.Category; - } - - if (_logLevelsForSeparateFiles.Count > 0 && _logLevelsForSeparateFiles.Contains(log.Level)) - { - string lvlStr; - prefix += "_" + (LevelStrings.TryGetValue(log.Level, out lvlStr) ? lvlStr : log.Level.ToString()); - } - - return Path.Combine( - _directory, - string.Concat(prefix, "_", CachedMachineName, "_", date.ToString(_dateFormat), _fileExtension)); - } - - /// - /// Resolves the logging directory with a cascading fallback strategy: - /// 1. Try the requested directory - /// 2. Try to fix permissions on the requested directory - /// 3. Fall back to %appdata%/EonaCat.LogStack/ - /// 4. Fall back to TEMP directory - /// - private string ResolveLoggingDirectory(string requestedDirectory) - { - // Resolve relative paths first - string workingDirectory = requestedDirectory; - if (workingDirectory.StartsWith("./", StringComparison.Ordinal)) - { - workingDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, workingDirectory.Substring(2)); - } - - if (Helpers.DirectoryPermissionHelper.EnsureDirectoryHierarchy(workingDirectory)) - { - return workingDirectory; - } - - if (TryFixDirectoryPermissions(workingDirectory) && Helpers.DirectoryPermissionHelper.EnsureDirectoryHierarchy(workingDirectory)) - { - return workingDirectory; - } - - string appDataDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "EonaCat.LogStack"); - - if (Helpers.DirectoryPermissionHelper.EnsureDirectoryHierarchy(appDataDirectory)) - { - OnDirectoryException?.Invoke(this, $"FileFlow: Could not use requested directory '{requestedDirectory}', fell back to '{appDataDirectory}'"); - return appDataDirectory; - } - - string tempDirectory = Path.GetTempPath(); - - try - { - if (Helpers.DirectoryPermissionHelper.CanWrite(tempDirectory)) - { - OnDirectoryException?.Invoke(this, $"FileFlow: Could not use requested directory '{requestedDirectory}' or AppData, fell back to TEMP directory '{tempDirectory}'"); - return tempDirectory; - } - } - catch - { - // Temp directory check failed - } - - // Final fallback: return the temp path anyway, even if it might not work - OnDirectoryException?.Invoke(this, $"FileFlow: Critical - could not find any writable directory. Attempting to use TEMP: '{tempDirectory}'"); - return tempDirectory; - } - - /// - /// Attempts to fix directory permissions across all platforms (Windows, Linux, Mac). - /// On Windows: Clears read-only attributes. - /// On Linux/Mac: Uses chmod to set full permissions. - /// Returns true if fix was successful or attempted, false if it failed or is not applicable. - /// - private bool TryFixDirectoryPermissions(string dirPath) - { - try - { - if (!Directory.Exists(dirPath)) - { - // Try to create it first - try - { - Directory.CreateDirectory(dirPath); - } - catch - { - return false; - } - } - - // Attempt to set everyone permissions (handles all platforms) - if (Helpers.DirectoryPermissionHelper.TrySetEveryonePermissions(dirPath)) - { - return true; - } - - // Fallback: On Windows, try clearing the read-only attribute directly - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - try - { - var dirInfo = new DirectoryInfo(dirPath); - if ((dirInfo.Attributes & FileAttributes.ReadOnly) != 0) - { - dirInfo.Attributes &= ~FileAttributes.ReadOnly; - } - return true; - } - catch (Exception ex) - { - OnException?.Invoke(this, $"[FileFlow] Failed to fix permissions: {ex.Message}"); - return false; - } - } - - // On Unix-like systems, we've already attempted chmod via TrySetEveryonePermissions - return false; - } - catch - { - return false; - } - } - - private void SetFileExtension(FileOutputFormat fmt) - { - switch (fmt) - { - case FileOutputFormat.Json: - case FileOutputFormat.StructuredJson: - _fileExtension = ".json"; break; - case FileOutputFormat.Xml: - _fileExtension = ".xml"; break; - case FileOutputFormat.Csv: - _fileExtension = ".csv"; break; - default: - _fileExtension = ".log"; break; - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static long EstimateSize(LogEvent log) - { - long s = 200L - + log.Message.Length * 2 - + (log.Category != null ? log.Category.Length * 2 : 0) - + log.Properties.Count * 40; - if (log.Exception != null) - { - s += 2048; // Avoid calling Exception.ToString() just for size estimation - } - - return s; - } - - private void CompileTemplate(string template) - { - _compiledTemplate = new List>(); - int pos = 0; - - while (pos < template.Length) - { - int open = template.IndexOf('{', pos); - if (open < 0) - { - string lit = template.Substring(pos); - _compiledTemplate.Add((_, sb) => sb.Append(lit)); - break; - } - - if (open > pos) - { - string lit = template.Substring(pos, open - pos); - _compiledTemplate.Add((_, sb) => sb.Append(lit)); - } - - int close = template.IndexOf('}', open); - if (close < 0) - { - string lit = template.Substring(open); - _compiledTemplate.Add((_, sb) => sb.Append(lit)); - break; - } - - string token = template.Substring(open + 1, close - (open + 1)); - _compiledTemplate.Add(ResolveToken(token)); - pos = close + 1; - } - } - - private Action ResolveToken(string token) - { - switch (token.ToLowerInvariant()) - { - case "ts": - return (log, sb) => - sb.Append(LogEvent.GetDateTime(log.Timestamp).ToString("yyyy-MM-dd HH:mm:ss.fff")); - case "tz": - return (log, sb) => - sb.Append(_timestampMode == TimestampMode.Local - ? TimeZoneInfo.Local.StandardName : "UTC"); - case "host": - return (log, sb) => sb.Append(CachedMachineName); - case "category": - return (log, sb) => { if (log.Category != null) { sb.Append(log.Category); } }; - case "thread": - return (log, sb) => sb.Append(Thread.CurrentThread.ManagedThreadId); - case "logtype": - return (log, sb) => - { - string s; - sb.Append(LevelStrings.TryGetValue(log.Level, out s) ? s : log.Level.ToString()); - }; - case "message": - return (log, sb) => - { - if (log.Message.Length > 0) - { - sb.Append(log.Message.ToString()); - } - }; - case "exception": - return (log, sb) => - { - if (log.Exception != null) - { - sb.Append(log.Exception.ToString()); - } - }; - case "props": - return (log, sb) => AppendProperties(log, sb); - case "newline": - return (log, sb) => sb.AppendLine(); - case "pid": - return (log, sb) => sb.Append(CachedPid); - case "traceid": - return (log, sb) => - { - if (log.TraceId != default(ActivityTraceId)) - { - sb.Append(log.TraceId.ToHexString()); - } - }; - case "spanid": - return (log, sb) => - { - if (log.SpanId != default(ActivitySpanId)) - { - sb.Append(log.SpanId.ToHexString()); - } - }; - default: - return BuildCustomOrLiteralToken(token); - } - } - - private Action BuildCustomOrLiteralToken(string token) - { - string name = token; - return (log, sb) => - { - Action custom; - if (_customTokens.TryGetValue(name, out custom)) - { - custom(log, sb); - } - else - { - sb.Append('{').Append(name).Append('}'); - } - }; - } - - private void AppendProperties(LogEvent log, StringBuilder sb) - { - var scopeProps = _scopeProperties.Value; - bool hasEnrichers = _enrichers.Count > 0; - bool hasProps = log.Properties.Count > 0; - bool hasScope = scopeProps != null && scopeProps.Count > 0; - if (!hasEnrichers && !hasProps && !hasScope) - { - return; - } - - sb.Append(" {"); - bool first = true; - - foreach (KeyValuePair> kv in _enrichers) - { - if (!first) - { - sb.Append(", "); - } - - first = false; - object val = kv.Value(log); - sb.Append(kv.Key).Append('=').Append(val != null ? val.ToString() : "null"); - } - - foreach (var property in log.Properties) - { - if (!first) - { - sb.Append(", "); - } - - first = false; - sb.Append(property.Key).Append('=') - .Append(property.Value != null ? property.Value.ToString() : "null"); - } - - if (hasScope) - { - foreach (var kv in scopeProps) - { - if (!first) - { - sb.Append(", "); - } - - first = false; - sb.Append(kv.Key).Append('=') - .Append(kv.Value != null ? kv.Value.ToString() : "null"); - } - } - - sb.Append('}'); - } - } + try + { + _directory = ResolveLoggingDirectory(_directory); + } + catch (Exception ex) + { + _directory = Path.GetTempPath(); + OnDirectoryException?.Invoke(this, $"FileFlow: Critical failure in directory resolution: {ex.Message}. Falling back to temp: '{_directory}'"); + } + + _logLevelsForSeparateFiles = logLevelsForSeparateFiles != null + ? new HashSet(logLevelsForSeparateFiles) + : new HashSet(); + + SetFileExtension(outputFormat); + CompileTemplate(template); + + // BlockingCollection with bounded capacity + _queue = new BlockingCollection(new ConcurrentQueue(), QueueCapacity); + + // Dedicated writer thread + _writerThread = new Thread(WriterThreadBody) + { + IsBackground = true, + Name = "FileFlow.Writer[" + filePrefix + "]", + Priority = ThreadPriority.AboveNormal, + }; + _writerThread.Start(); + + // Dedicated compression thread + _compressionThread = new Thread(CompressionThreadBody) + { + IsBackground = true, + Name = "FileFlow.Compress[" + filePrefix + "]", + Priority = ThreadPriority.BelowNormal, + }; + _compressionThread.Start(); + + _flushTask = flushIntervalMs > 0 + ? Task.Factory.StartNew(PeriodicFlushLoop, TaskCreationOptions.LongRunning) + : Task.FromResult(0); + + _retentionTask = Task.Factory.StartNew(RetentionLoop, TaskCreationOptions.LongRunning); + } + + /// Add an ambient property enricher applied to every event. + public FileFlow EnrichWith(string key, Func valueFactory) + { + if (key == null) + { + throw new ArgumentNullException("key"); + } + + if (valueFactory == null) + { + throw new ArgumentNullException("valueFactory"); + } + + _enrichers.Add(new KeyValuePair>(key, valueFactory)); + return this; + } + + /// Add a static ambient property. + public FileFlow EnrichWith(string key, object value) + { + return EnrichWith(key, _ => value); + } + + /// + /// Remove an ambient property enricher by key. + /// + /// + /// + /// + public FileFlow RemoveEnricher(string key) + { + if (key == null) + { + throw new ArgumentNullException("key"); + } + + lock (_enrichers) + { + var index = _enrichers.FindIndex(kv => string.Equals(kv.Key, key, StringComparison.OrdinalIgnoreCase)); + if (index >= 0) + { + _enrichers.RemoveAt(index); + return this; + } + } + return this; + } + + /// Configure sampling: only log 1 in events + /// that match . + public FileFlow WithSampling(int rate, Func predicate = null) + { + _samplingPolicy = new SamplingPolicy { Rate = rate, Predicate = predicate }; + return this; + } + + /// Callback invoked when an event is dropped due to backpressure. + public FileFlow OnEventDropped(Action callback) + { + _onDrop = callback; + return this; + } + + /// Registers this flow to flush and dispose automatically on process exit + /// (ProcessExit) and Ctrl+C (CancelKeyPress). Opt-in only — call this explicitly if + /// your application doesn't already have its own shutdown/dispose sequence for this flow. + /// Not recommended if you're hosting inside ASP.NET Core, a Windows Service, or any + /// framework with its own graceful-shutdown lifecycle — hook into that instead. + public FileFlow WithAutoShutdownHooks() + { + AppDomain.CurrentDomain.ProcessExit += (_, _) => + { + try { DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); } catch { } + }; + + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + try { DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); } catch { } + Environment.Exit(0); + }; + + return this; + } + + /// Callback invoked with the archived path after each file rotation. + public FileFlow OnFileRotated(Action callback) + { + _onRotate = callback; + return this; + } + + /// Fan-out: also invoke for every formatted log line. + public FileFlow AddSecondaryWriter(Action writer) + { + if (writer == null) + { + throw new ArgumentNullException("writer"); + } + + lock (_secondaryWritersLock) + { + if (_secondaryWriters == null) + { + _secondaryWriters = new List>(); + } + + _secondaryWriters.Add(writer); + } + return this; + } + + public bool RemoveSecondaryWriter(Action writer) + { + if (writer == null) + { + throw new ArgumentNullException("writer"); + } + + lock (_secondaryWritersLock) + { + return _secondaryWriters?.Remove(writer) ?? false; + } + } + + /// Register a custom template token (e.g. {mytoken}). + public FileFlow RegisterToken(string name, Action formatter) + { + if (formatter == null) + { + throw new ArgumentNullException("formatter"); + } + + _customTokens[name] = formatter; + return this; + } + + /// Change the minimum log level at runtime (thread-safe). + public void SetMinimumLevel(LogLevel level) + { + MinimumLevel = level; + } + + /// Add a custom filter predicate. Events are logged only if ALL filters return true. + public FileFlow WithFilter(Func predicate) + { + if (predicate == null) + { + throw new ArgumentNullException("predicate"); + } + + lock (_filtersLock) + { + _filters.Add(predicate); + } + return this; + } + + public bool RemoveFilter(Func predicate) + { + if (predicate == null) + { + throw new ArgumentNullException("predicate"); + } + + lock (_filtersLock) + { + return _filters.Remove(predicate); + } + } + + /// When enabled, every write is flushed to the physical disk (StreamWriter buffer + /// + OS write cache) before the write call returns, instead of relying on periodic flush. + /// This eliminates data loss on abrupt process termination (crash, kill -9, debugger stop) + /// at the cost of significantly reduced throughput — every log line becomes a synchronous + /// disk I/O instead of a buffered memory write. Use for audit logs or compliance-sensitive + /// output; leave off for high-throughput application logging. + public FileFlow WithDurableWrites(bool enabled = true) + { + _durableWrites = enabled; + return this; + } + + /// Enable deduplication: suppress identical messages within the given time window. + public FileFlow WithDeduplication(TimeSpan window) + { + if (window <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException("window", "Deduplication window must be positive."); + } + + _deduplicationWindow = window; + _deduplicationEnabled = true; + return this; + } + + /// Configure a custom date format for log file names (default: yyyyMMdd). + public FileFlow WithDateFormat(string dateFormat) + { + if (string.IsNullOrWhiteSpace(dateFormat)) + { + throw new ArgumentNullException("dateFormat"); + } + + _dateFormat = dateFormat; + return this; + } + + /// Limit the flow to a maximum number of events per second. Events exceeding the limit are dropped. + public FileFlow WithRateLimit(int maxEventsPerSecond) + { + _maxEventsPerSecond = maxEventsPerSecond; + return this; + } + + /// When enabled, the file stream is flushed immediately after writing Error or Critical level events. + public FileFlow WithAutoFlushOnError(bool enabled = true) + { + _autoFlushOnError = enabled; + return this; + } + + /// Push scoped properties that will be included in all log events written on the current async context. + public IDisposable BeginScope(params KeyValuePair[] properties) + { + var previous = _scopeProperties.Value; + var merged = previous != null + ? new Dictionary(previous) + : new Dictionary(); + + foreach (var kv in properties) + { + merged[kv.Key] = kv.Value; + } + + _scopeProperties.Value = merged; + return new ScopeDisposable(previous); + } + + /// Push a single scoped property. + public IDisposable BeginScope(string key, object value) + { + return BeginScope(new KeyValuePair(key, value)); + } + + /// Returns the current queue depth (number of pending events). + public int GetQueueDepth() + { + return _queue.Count; + } + + /// Returns the current estimated memory usage of the queue in bytes. + public long GetMemoryPressureBytes() + { + return Interlocked.Read(ref _currentMemoryBytes); + } + + /// Generates a fingerprint hash for an exception to assist with grouping. + public static string GetExceptionFingerprint(Exception ex) + { + if (ex == null) + { + return null; + } + + string source = string.Concat( + ex.GetType().FullName, "|", + ex.TargetSite?.Name ?? string.Empty, "|", + ex.StackTrace != null && ex.StackTrace.Length > 0 + ? ex.StackTrace.Substring(0, Math.Min(200, ex.StackTrace.Length)) + : string.Empty); + + // Simple FNV-1a hash + unchecked + { + uint hash = 2166136261; + foreach (char c in source) + { + hash ^= c; + hash *= 16777619; + } + return hash.ToString("x8"); + } + } + + private sealed class ScopeDisposable : IDisposable + { + private readonly Dictionary _previous; + + public ScopeDisposable(Dictionary previous) + { + _previous = previous; + } + + public void Dispose() + { + _scopeProperties.Value = _previous; + } + } + + /// Returns true if the flow is healthy (no recent errors and writer thread alive). + public bool IsHealthy() + { + if (!IsEnabled) + { + return false; + } + + if (!_writerThread.IsAlive) + { + return false; + } + + long lastErr = Interlocked.Read(ref _lastErrorTimestamp); + if (lastErr > 0) + { + TimeSpan since = TimeSpan.FromTicks(DateTime.UtcNow.Ticks - lastErr); + if (since < TimeSpan.FromMinutes(1)) + { + return false; + } + } + + return true; + } + + /// Returns the last error encountered by the writer, or null if none. + public Exception GetLastError() + { + return _lastError; + } + + /// Returns the total number of write errors encountered. + public long GetTotalErrors() + { + return Interlocked.Read(ref _totalErrors); + } + + /// Returns live throughput and health metrics. + public LogStats GetStats() + { + long written = Interlocked.Read(ref BlastedCount); + long dropped = Interlocked.Read(ref DroppedCount); + long bytes = Interlocked.Read(ref _totalBytesWritten); + long rots = Interlocked.Read(ref _totalRotations); + double elapsed = _uptime.Elapsed.TotalSeconds; + double wps = elapsed > 0 ? written / elapsed : 0; + return new LogStats(written, dropped, rots, bytes, wps); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override Task BlastAsync( + LogEvent logEvent, + CancellationToken cancellationToken = default(CancellationToken)) + { + if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { + return Task.FromResult(WriteResult.LevelFiltered); + } + + SamplingPolicy sp = _samplingPolicy; + if (sp != null && !sp.ShouldLog(logEvent)) + { + return Task.FromResult(WriteResult.LevelFiltered); + } + + if (!PassesFilters(logEvent)) + { + return Task.FromResult(WriteResult.LevelFiltered); + } + + if (_deduplicationEnabled && IsDuplicate(logEvent)) + { + return Task.FromResult(WriteResult.LevelFiltered); + } + + if (_rateLimitEnabled && !TryPassRateLimit()) + { + Interlocked.Increment(ref DroppedCount); + Action drop = _onDrop; + if (drop != null) + { + drop(logEvent); + } + + return Task.FromResult(WriteResult.Dropped); + } + + return Task.FromResult(TryEnqueue(logEvent)); + } + + public override Task BlastBatchAsync( + ReadOnlyMemory logEvents, + CancellationToken cancellationToken = default(CancellationToken)) + { + if (!IsEnabled) + { + return Task.FromResult(WriteResult.FlowDisabled); + } + + WriteResult result = WriteResult.Success; + SamplingPolicy sp = _samplingPolicy; + ReadOnlySpan span = logEvents.Span; + + for (int i = 0; i < span.Length; i++) + { + LogEvent e = span[i]; + if (e.Level < MinimumLevel) + { + continue; + } + + if (sp != null && !sp.ShouldLog(e)) + { + continue; + } + + if (!PassesFilters(e)) + { + continue; + } + + if (_deduplicationEnabled && IsDuplicate(e)) + { + continue; + } + + if (_rateLimitEnabled && !TryPassRateLimit()) + { + Interlocked.Increment(ref DroppedCount); + Action drop = _onDrop; + if (drop != null) + { + drop(e); + } + + result = WriteResult.Dropped; + continue; + } + + if (TryEnqueue(e) == WriteResult.Dropped) + { + result = WriteResult.Dropped; + } + } + + return Task.FromResult(result); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private WriteResult TryEnqueue(LogEvent log) + { + long size = EstimateSize(log); + long current = Interlocked.Read(ref _currentMemoryBytes); + + if (current + size > _maxMemoryBytes) + { + Interlocked.Increment(ref DroppedCount); + Action drop = _onDrop; + if (drop != null) + { + drop(log); + } + + return WriteResult.Dropped; + } + + try + { + if (!_queue.TryAdd(log)) + { + Interlocked.Increment(ref DroppedCount); + Action drop = _onDrop; + if (drop != null) + { + drop(log); + } + return WriteResult.Dropped; + } + } + catch (Exception) + { + return WriteResult.Dropped; + } + + Interlocked.Add(ref _currentMemoryBytes, size); + Interlocked.Increment(ref BlastedCount); + return WriteResult.Success; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryPassRateLimit() + { + long now = DateTime.UtcNow.Ticks; + lock (_rateLimitLock) + { + long elapsed = now - _rateLimitWindowStart; + if (elapsed >= TimeSpan.TicksPerSecond) + { + _rateLimitWindowStart = now; + _rateLimitCounter = 1; + return true; + } + + if (_rateLimitEnabled && _rateLimitCounter >= _maxEventsPerSecond) + { + return false; + } + + _rateLimitCounter++; + return true; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool PassesFilters(LogEvent log) + { + lock (_filtersLock) + { + for (int i = 0; i < _filters.Count; i++) + { + if (!_filters[i](log)) + { + return false; + } + } + } + return true; + } + + private bool IsDuplicate(LogEvent log) + { + string key = string.Concat( + log.Level.ToString(), "|", + log.Category ?? string.Empty, "|", + log.Message.Length > 0 ? log.Message.ToString() : string.Empty); + + long nowTicks = DateTime.UtcNow.Ticks; + + if (_deduplicationCache.TryGetValue(key, out long existing)) + { + if (nowTicks - existing < _deduplicationWindow.Ticks) + { + return true; + } + } + + _deduplicationCache[key] = nowTicks; + return false; + } + + private void RetentionLoop() + { + try + { + while (!_isDisposing && !_cts.Token.IsCancellationRequested) + { + try + { + if (_cts.Token.WaitHandle.WaitOne(TimeSpan.FromMinutes(5))) + { + break; + } + + ApplyRetention(); + EvictIdleFileHandles(); + CleanupDeduplicationCache(); + } + catch (OperationCanceledException) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + catch (Exception ex) + { + OnException?.Invoke(this, "[FileFlow] RetentionLoop error: " + ex.Message); + } + } + } + catch (Exception ex) + { + OnException?.Invoke(this, "[FileFlow] RetentionLoop fatal error: " + ex.Message); + } + } + + private void CleanupDeduplicationCache() + { + if (!_deduplicationEnabled) + { + return; + } + + long nowTicks = DateTime.UtcNow.Ticks; + long windowTicks = _deduplicationWindow.Ticks; + + foreach (var kvp in _deduplicationCache) + { + if (nowTicks - kvp.Value >= windowTicks) + { + _deduplicationCache.TryRemove(kvp.Key, out _); + } + } + } + + public override Task FlushAsync(CancellationToken cancellationToken = default(CancellationToken)) + { + try + { + while ((_queue.Count > 0 || + Volatile.Read(ref _writerBusy) != 0) && + !cancellationToken.IsCancellationRequested) + { + Thread.Sleep(1); + } + + lock (_fileLock) + { + foreach (OpenFile of in _openFiles.Values) + { + try { of.Writer.Flush(); } catch { /* ignore */ } + } + } + + return Task.FromResult(0); + } + catch + { + return Task.FromResult(0); + } + } + + public override async ValueTask DisposeAsync() + { + if (!IsEnabled) + { + return; + } + + IsEnabled = false; + _isDisposing = true; // Signal all threads that disposal is in progress + + _queue.CompleteAdding(); + + // Give threads more time to gracefully exit + _writerThread.Join(5000); + _compressionSignal.Release(); + _compressionThread.Join(5000); + _cts.Cancel(); + + lock (_fileLock) + { + foreach (var outputFile in _openFiles.Values) + { + if (_outputFormat == FileOutputFormat.Xml && outputFile.HasXmlHeader) + { + try + { + outputFile.Writer.WriteLine(""); + } + catch { } + } + + outputFile.Dispose(); + } + + _openFiles.Clear(); + } + + // 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(); + + await base.DisposeAsync().ConfigureAwait(false); + } + + private void WriterThreadBody() + { + try + { + foreach (var logEvent in _queue.GetConsumingEnumerable()) + { + Interlocked.Increment(ref _writerBusy); + try + { + WriteLogEvent(logEvent); + } + finally + { + Interlocked.Decrement(ref _writerBusy); + } + + int extra = 0; + while (extra < _batchSize && _queue.TryTake(out var next)) + { + Interlocked.Increment(ref _writerBusy); + try + { + WriteLogEvent(next); + } + finally + { + Interlocked.Decrement(ref _writerBusy); + } + extra++; + } + } + } + catch (Exception ex) + { + OnException?.Invoke(this, + $"[FileFlow] Writer thread encountered an error: {ex}"); + } + finally + { + lock (_fileLock) + { + foreach (var file in _openFiles.Values) + { + try + { + file.Writer.Flush(); + + if (_durableWrites) + { + file.Stream.Flush(true); + } + } + catch + { + } + } + } + } + } + + private void WriteLogEvent(LogEvent log) + { + long size = 0; + try + { + size = EstimateSize(log); + } + catch + { + size = 512; + } + + Interlocked.Add(ref _currentMemoryBytes, -size); + + string line; + try + { + StringBuilder sb = StringBuilderPool.Rent(); + try + { + switch (_outputFormat) + { + case FileOutputFormat.Json: + FormatJson(log, sb, false); + break; + case FileOutputFormat.StructuredJson: + FormatJson(log, sb, true); + break; + case FileOutputFormat.Xml: + FormatXml(log, sb); + break; + case FileOutputFormat.Csv: + FormatCsv(log, sb); + break; + default: + FormatText(log, sb); + break; + } + line = sb.ToString(); + } + finally + { + StringBuilderPool.Return(sb); + } + } + catch (Exception ex) + { + try + { + line = "[FileFlow] Format error: " + ex.Message + + " | Original level=" + log.Level + + " message=" + (log.Message.Length > 0 ? log.Message.ToString() : "(empty)"); + } + catch + { + line = "[FileFlow] Format error (unrecoverable)"; + } + } + + try + { + string path = GenerateFilePath(GetCurrentDate(), log); + + lock (_fileLock) + { + try + { + if (!EnsureFileOpen(path, log)) + { + // File open failed; the event is lost. Make that visible instead + // of silently continuing, so callers relying on DroppedCount/_onDrop + // for observability actually see it. + Interlocked.Increment(ref DroppedCount); + Action drop = _onDrop; + if (drop != null) + { + try { drop(log); } catch { /* Do nothing */ } + } + } + else + { + if (ShouldRotate(path, line.Length)) + { + string archived = RotateFile(path); + if (!EnsureFileOpen(path, log)) + { + Interlocked.Increment(ref DroppedCount); + Action drop = _onDrop; + if (drop != null) + { + try { drop(log); } catch { /* Do nothing */ } + } + } + else + { + if (_openFiles.TryGetValue(path, out OpenFile of)) + { + of.Writer.WriteLine(line); + of.Size += line.Length + Environment.NewLine.Length; + of.LastWriteTicksUtc = DateTime.UtcNow.Ticks; + + if (_durableWrites) + { + try { of.Writer.Flush(); of.Stream.Flush(true); } catch { /* surfaced via outer catch */ } + } + } + } + if (archived != null) + { + Action onRotate = _onRotate; + if (onRotate != null) + { + try { onRotate(archived); } + catch { /* Do nothing */ } + } + } + } + else + { + if (_openFiles.TryGetValue(path, out OpenFile of)) + { + of.Writer.WriteLine(line); + of.Size += line.Length + Environment.NewLine.Length; + of.LastWriteTicksUtc = DateTime.UtcNow.Ticks; + + if (_durableWrites) + { + try { of.Writer.Flush(); of.Stream.Flush(true); } catch { /* surfaced via outer catch */ } + } + } + } + } + } + catch (Exception ex) + { + _lastError = ex; + Interlocked.Increment(ref _totalErrors); + Interlocked.Exchange(ref _lastErrorTimestamp, DateTime.UtcNow.Ticks); + try + { + 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); + } + } + OnException?.Invoke(this, "[FileFlow] Write error for '" + path + "': " + ex.Message + diagnosis); + } + catch { /* Do nothing */ } + } + } + + Interlocked.Add(ref _totalBytesWritten, line.Length + 1); + + // Auto-flush on error/critical + if (!_durableWrites && _autoFlushOnError && (log.Level >= LogLevel.Error)) + { + lock (_fileLock) + { + if (_openFiles.TryGetValue(path, out OpenFile autoFlushOf)) + { + try { autoFlushOf.Writer.Flush(); } catch { /* ignore */ } + } + } + } + } + catch (Exception ex) + { + try + { + OnException?.Invoke(this, "[FileFlow] WriteLogEvent error: " + ex.Message); + } + catch { /* Do nothing */ } + } + + // Fan-out to secondary writers + try + { + if (_secondaryWriters != null) + { + lock (_secondaryWritersLock) + { + foreach (Action writer in _secondaryWriters) + { + try { writer(log, line); } + catch + { + // Do nothing + } + } + } + } + } + catch + { + // Do nothing + } + } + + private void FormatText(LogEvent log, StringBuilder sb) + { + foreach (Action action in _compiledTemplate) + { + action(log, sb); + } + } + + private void FormatJson(LogEvent log, StringBuilder sb, bool structured) + { + sb.Append('{'); + + sb.Append("\"timestamp\":\""); + AppendJsonEscaped(LogEvent.GetDateTime(log.Timestamp).ToString("O"), sb); + sb.Append("\","); + + sb.Append("\"level\":\""); + sb.Append(LevelStrings.TryGetValue(log.Level, out string lvlStr) ? lvlStr : log.Level.ToString()); + sb.Append("\","); + + if (structured) + { + int cid = Interlocked.Increment(ref _correlationSeed); + sb.Append("\"correlationId\":\""); + sb.Append(cid.ToString("x8")); + sb.Append("\","); + + sb.Append("\"host\":\""); + AppendJsonEscaped(CachedMachineName, sb); + sb.Append("\","); + + sb.Append("\"pid\":"); + sb.Append(CachedPid); + sb.Append(','); + + if (log.TraceId != default(ActivityTraceId)) + { + sb.Append("\"traceId\":\""); + sb.Append(log.TraceId.ToHexString()); + sb.Append("\","); + } + + if (log.SpanId != default(ActivitySpanId)) + { + sb.Append("\"spanId\":\""); + sb.Append(log.SpanId.ToHexString()); + sb.Append("\","); + } + + sb.Append("\"threadId\":"); + sb.Append(log.ThreadId); + sb.Append(','); + } + + sb.Append("\"category\":\""); + if (!string.IsNullOrEmpty(log.Category)) + { + AppendJsonEscaped(log.Category, sb); + } + + sb.Append("\","); + + sb.Append("\"message\":\""); + if (log.Message.Length > 0) + { + AppendJsonEscaped(log.Message.ToString(), sb); + } + + sb.Append('"'); + + if (log.Exception != null) + { + sb.Append(",\"exception\":\""); + AppendJsonEscaped(log.Exception.ToString(), sb); + sb.Append('"'); + } + + bool hasEnrichers = _enrichers.Count > 0; + bool hasProps = log.Properties.Count > 0; + if (hasEnrichers || hasProps) + { + sb.Append(",\"properties\":{"); + bool first = true; + + foreach (KeyValuePair> kv in _enrichers) + { + if (!first) + { + sb.Append(','); + } + + first = false; + sb.Append('"'); + AppendJsonEscaped(kv.Key, sb); + sb.Append("\":\""); + object val = kv.Value(log); + AppendJsonEscaped(val != null ? val.ToString() : "null", sb); + sb.Append('"'); + } + + foreach (var property in log.Properties) + { + if (!first) + { + sb.Append(','); + } + + first = false; + sb.Append('"'); + AppendJsonEscaped(property.Key, sb); + sb.Append("\":"); + if (property.Value == null) + { + sb.Append("null"); + } + else + { + sb.Append('"'); + AppendJsonEscaped(property.Value.ToString(), sb); + sb.Append('"'); + } + } + + sb.Append('}'); + } + + sb.Append('}'); + } + + private static void AppendJsonEscaped(string value, StringBuilder sb) + { + if (value == null) + { + return; + } + + foreach (char c in value) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (char.IsControl(c)) + { + sb.Append("\\u"); + sb.Append(((int)c).ToString("x4")); + } + else + { + sb.Append(c); + } + + break; + } + } + } + + private void RepairXmlIfNeeded(string path) + { + try + { + if (!File.Exists(path)) + { + return; + } + + const string footer = ""; + const int tailSize = 512; + + using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) + { + if (fs.Length < footer.Length) + { + return; + } + + int readSize = (int)Math.Min(tailSize, fs.Length); + fs.Seek(-readSize, SeekOrigin.End); + + byte[] buffer = new byte[readSize]; + fs.Read(buffer, 0, readSize); + + string tail = Encoding.UTF8.GetString(buffer); + + if (!tail.Contains(footer)) + { + fs.Seek(0, SeekOrigin.End); + using (var sw = new StreamWriter(fs, Encoding.UTF8, 1024, true)) + { + sw.WriteLine(); + sw.WriteLine(footer); + sw.Flush(); + } + } + } + } + catch + { + // Never throw during logging + } + } + + private void FormatXml(LogEvent log, StringBuilder sb) + { + sb.Append(""); + + AppendXmlElement("timestamp", LogEvent.GetDateTime(log.Timestamp).ToString("O"), sb); + AppendXmlElement("level", LevelStrings.TryGetValue(log.Level, out string lvlStr) ? lvlStr : log.Level.ToString(), sb); + + if (!string.IsNullOrEmpty(log.Category)) + { + AppendXmlElement("category", log.Category, sb); + } + + sb.Append(""); + if (log.Message.Length > 0) + { + AppendXmlEscaped(log.Message.ToString(), sb); + } + + sb.Append(""); + + if (log.Exception != null) + { + AppendXmlElement("exception", log.Exception.ToString(), sb); + } + + bool hasEnrichers = _enrichers.Count > 0; + bool hasProps = log.Properties.Count > 0; + if (hasEnrichers || hasProps) + { + sb.Append(""); + + foreach (KeyValuePair> kv in _enrichers) + { + sb.Append(""); + object val = kv.Value(log); + AppendXmlEscaped(val != null ? val.ToString() : "null", sb); + sb.Append(""); + } + + foreach (var property in log.Properties) + { + if (string.IsNullOrEmpty(property.Key)) + { + continue; + } + + sb.Append(""); + if (property.Value != null) + { + AppendXmlEscaped(property.Value.ToString(), sb); + } + else + { + sb.Append("null"); + } + + sb.Append(""); + } + + sb.Append(""); + } + + sb.Append(""); + } + + private static void AppendXmlElement(string tag, string content, StringBuilder sb) + { + sb.Append('<').Append(tag).Append('>'); + AppendXmlEscaped(content, sb); + sb.Append("'); + } + + private static void AppendXmlEscaped(string value, StringBuilder sb) + { + if (value == null) + { + return; + } + + foreach (char c in value) + { + switch (c) + { + case '&': sb.Append("&"); break; + case '<': sb.Append("<"); break; + case '>': sb.Append(">"); break; + case '"': sb.Append("""); break; + case '\'': sb.Append("'"); break; + default: + if (char.IsControl(c) && c != '\r' && c != '\n' && c != '\t') + { + sb.Append('?'); + } + else + { + sb.Append(c); + } + + break; + } + } + } + + private void FormatCsv(LogEvent log, StringBuilder sb) + { + AppendCsvField(LogEvent.GetDateTime(log.Timestamp).ToString("O"), sb); + sb.Append(','); + AppendCsvField(LevelStrings.TryGetValue(log.Level, out string lvlStr) ? lvlStr : log.Level.ToString(), sb); + sb.Append(','); + AppendCsvField(log.Category ?? string.Empty, sb); + sb.Append(','); + AppendCsvField(log.Message.Length > 0 ? log.Message.ToString() : string.Empty, sb); + sb.Append(','); + AppendCsvField(log.Exception != null ? log.Exception.ToString() : string.Empty, sb); + sb.Append(','); + + sb.Append('"'); + bool first = true; + foreach (KeyValuePair> kv in _enrichers) + { + if (!first) + { + sb.Append("; "); + } + + first = false; + AppendCsvInner(kv.Key, sb); + sb.Append('='); + object val = kv.Value(log); + AppendCsvInner(val != null ? val.ToString() : "null", sb); + } + foreach (var property in log.Properties) + { + if (!first) + { + sb.Append("; "); + } + + first = false; + AppendCsvInner(property.Key ?? string.Empty, sb); + sb.Append('='); + AppendCsvInner(property.Value != null ? property.Value.ToString() : "null", sb); + } + sb.Append('"'); + } + + private static void AppendCsvField(string value, StringBuilder sb) + { + if (value == null) + { + value = string.Empty; + } + + bool needsQuote = value.IndexOfAny(CsvSpecialChars) >= 0; + if (needsQuote) + { + sb.Append('"'); + } + + AppendCsvInner(value, sb); + if (needsQuote) + { + sb.Append('"'); + } + } + + private static void AppendCsvInner(string value, StringBuilder sb) + { + if (value == null) + { + return; + } + + foreach (char c in value) + { + if (c == '"') + { + sb.Append('"'); + } + + sb.Append(c); + } + } + + private bool EnsureFileOpen(string path, LogEvent logEvent) + { + try + { + if (_openFiles.TryGetValue(path, out OpenFile existing)) + { + if (++existing.WritesSinceExistenceCheck < ExistenceCheckInterval) + { + return true; + } + + existing.WritesSinceExistenceCheck = 0; + + try + { + if (File.Exists(path)) + { + return true; + } + } + catch + { + // assume file is gone, and try to reopen it below + } + + // File was deleted or check failed + try { existing.Dispose(); } catch { /* Do nothing */ } + _openFiles.Remove(path); + } + + return OpenNewFileHandle(path); + } + catch (Exception ex) + { + try + { + OnException?.Invoke(this, "[FileFlow] EnsureFileOpen unhandled error: " + ex.Message); + } + catch { /* ignore */ } + return false; + } + } + + /// Opens a fresh FileStream/StreamWriter for and + /// registers it in . Split out from + /// so the "do we already have a handle" logic stays separate from the "open a new one" logic. + private bool OpenNewFileHandle(string path) + { + FileStream fs = null; + StreamWriter sw = null; + try + { + string dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + if (!Helpers.DirectoryPermissionHelper.EnsureDirectoryHierarchy(dir)) + { + throw new UnauthorizedAccessException($"Cannot create directory: {Helpers.DirectoryPermissionHelper.GetAccessIssueDiagnosis(dir)}"); + } + } + + fs = new FileStream( + path, + FileMode.Append, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete, + FileBufferSize, + _durableWrites + ? FileOptions.SequentialScan | FileOptions.WriteThrough + : FileOptions.SequentialScan); + + sw = new StreamWriter(fs, Encoding.UTF8, WriterBufferSize); + sw.AutoFlush = false; + + OpenFile of = new OpenFile(fs, sw, GetCurrentDate()); + _openFiles[path] = of; + + fs = null; + sw = null; + + WriteFileHeaderIfNeeded(of, path); + + return true; + } + catch (Exception ex) + { + try { sw?.Dispose(); } catch { /* ignore */ } + try { fs?.Dispose(); } catch { /* ignore */ } + + if (_openFiles.TryGetValue(path, out OpenFile broken)) + { + try { broken.Dispose(); } catch { /* ignore */ } + _openFiles.Remove(path); + } + + 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); + } + } + OnException?.Invoke(this, "[FileFlow] Failed to open '" + path + "': " + ex.Message + diagnosis); + return false; + } + } + + /// Writes the CSV or XML file header if this is a fresh (or existing-but-headerless) file. + /// Split out of the open-path so header concerns don't clutter file-open error handling. + private void WriteFileHeaderIfNeeded(OpenFile of, string path) + { + if (_outputFormat == FileOutputFormat.Csv && of.Stream.Length == 0) + { + try + { + of.Writer.Write(CsvHeader); + of.Writer.Flush(); + of.HasCsvHeader = true; + } + catch (Exception ex) + { + OnException?.Invoke(this, "[FileFlow] CSV header write error for '" + path + "': " + ex.Message); + } + } + + if (_outputFormat == FileOutputFormat.Xml) + { + try + { + if (of.Stream.Length == 0) + { + of.Writer.WriteLine(""); + of.Writer.WriteLine(""); + of.Writer.Flush(); + of.HasXmlHeader = true; + } + else + { + of.Writer.Flush(); + RepairXmlIfNeeded(path); + of.HasXmlHeader = true; + } + } + catch (Exception ex) + { + OnException?.Invoke(this, "[FileFlow] XML header write error for '" + path + "': " + ex.Message); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool ShouldRotate(string path, long additionalBytes) + { + if (!_openFiles.TryGetValue(path, out OpenFile of)) + { + return false; + } + + return of.Size + additionalBytes > _maxFileSize + || of.Date.Date != GetCurrentDate() + || Interlocked.Read(ref _currentMemoryBytes) > (long)(_maxMemoryBytes * 0.9); + } + + private string RotateFile(string path) + { + if (_openFiles.TryGetValue(path, out OpenFile of)) + { + if (_outputFormat == FileOutputFormat.Xml && of.HasXmlHeader) + { + try + { + of.Writer.WriteLine(""); + of.Writer.Flush(); + } + catch { } + } + + of.Dispose(); + _openFiles.Remove(path); + } + + if (!File.Exists(path)) + { + return null; + } + + string archived = ArchiveFile(path); + Interlocked.Increment(ref _totalRotations); + return archived; + } + + private string ArchiveFile(string filePath) + { + try + { + string dir = Path.GetDirectoryName(filePath); + string fileName = Path.GetFileName(filePath); + int extIdx = fileName.LastIndexOf(_fileExtension, StringComparison.OrdinalIgnoreCase); + string baseName = extIdx >= 0 ? fileName.Substring(0, extIdx) : fileName; + int maxFiles = _retention.MaxRolledFiles > 0 ? _retention.MaxRolledFiles : 999; + + for (int i = maxFiles - 1; i >= 1; i--) + { + string src = Path.Combine(dir, baseName + "_" + i + _fileExtension); + string srcGz = src + ".gz"; + string dst = Path.Combine(dir, baseName + "_" + (i + 1) + _fileExtension); + string dstGz = dst + ".gz"; + + if (File.Exists(srcGz)) + { + if (File.Exists(dstGz)) + { + File.Delete(dstGz); + } + File.Move(srcGz, dstGz); + } + + if (!File.Exists(src)) + { + continue; + } + + if (File.Exists(dst)) + { + File.Delete(dst); + } + File.Move(src, dst); + } + + string archive = Path.Combine(dir, baseName + "_1" + _fileExtension); + if (File.Exists(archive)) + { + File.Delete(archive); + } + + File.Move(filePath, archive); + + if (_compressionFormat != CompressionFormat.None) + { + _compressionQueue.Enqueue(archive); + _compressionSignal.Release(1); + } + + return archive; + } + catch (Exception ex) + { + OnException?.Invoke(this, "[FileFlow] Archive error '" + filePath + "': " + ex.Message); + return null; + } + } + + private void CompressionThreadBody() + { + while (true) + { + try + { + if (_isDisposing) + { + DrainCompressionQueue(); + return; + } + + _compressionSignal.Wait(_cts.Token); + } + catch (ObjectDisposedException) + { + DrainCompressionQueue(); + return; + } + catch (OperationCanceledException) + { + DrainCompressionQueue(); + return; + } + + DrainCompressionQueue(); + } + } + + private void DrainCompressionQueue() + { + while (_compressionQueue.TryDequeue(out string path)) + { + try { CompressFile(path); } + catch (Exception ex) + { + OnException?.Invoke(this, "[FileFlow] Compress error '" + path + "': " + ex.Message); + } + } + } + + private void CompressFile(string path) + { + if (_compressionFormat == CompressionFormat.None || !File.Exists(path)) + { + return; + } + + string outPath = path + ".gz"; + + if (File.Exists(outPath)) + { + string ts = DateTime.UtcNow.ToString("yyMMdd_HHmmss_fff"); + File.Move(outPath, path + "_" + ts + ".gz"); + } + + const int bufSize = 65536; + byte[] buffer = new byte[bufSize]; + + using (FileStream src = File.OpenRead(path)) + using (FileStream dst = File.Create(outPath)) + using (GZipStream gz = new GZipStream(dst, CompressionLevel.Optimal)) + { + int read; + while ((read = src.Read(buffer, 0, bufSize)) > 0) + { + gz.Write(buffer, 0, read); + } + } + + try { File.Delete(path); } catch { /* ignore */ } + + EnforceCompressedFileLimit(); + } + + private void EnforceCompressedFileLimit() + { + if (_retention.MaxCompressedFiles <= 0) + { + return; + } + + try + { + DirectoryInfo dir = new DirectoryInfo(_directory); + if (!dir.Exists) + { + return; + } + + FileInfo[] compressedFiles = dir.GetFiles("*" + _fileExtension + ".gz") + .OrderByDescending(f => f.LastWriteTimeUtc) + .ToArray(); + + for (int i = _retention.MaxCompressedFiles; i < compressedFiles.Length; i++) + { + try { compressedFiles[i].Delete(); } catch { /* ignore */ } + } + } + catch (Exception ex) + { + OnException?.Invoke(this, "[FileFlow] Compressed retention error: " + ex.Message); + } + } + + private void PeriodicFlushLoop() + { + try + { + while (!_isDisposing && !_cts.Token.IsCancellationRequested) + { + try + { + long mem = Interlocked.Read(ref _currentMemoryBytes); + int delay = mem > (long)(_maxMemoryBytes * 0.8) + ? 250 + : (int)_flushInterval.TotalMilliseconds; + + if (_cts.Token.WaitHandle.WaitOne(delay)) + { + break; + } + + lock (_fileLock) + { + foreach (OpenFile of in _openFiles.Values) + { + try { of.Writer.Flush(); } catch { /* ignore */ } + } + } + } + catch (ObjectDisposedException) + { + break; + } + catch (ThreadInterruptedException) { break; } + catch (Exception ex) + { + OnException?.Invoke(this, "[FileFlow] Flush error: " + ex.Message); + } + } + } + catch (Exception ex) + { + OnException?.Invoke(this, "[FileFlow] PeriodicFlushLoop error: " + ex.Message); + } + } + + /// Closes open file handles that haven't been written to in a while. + /// Guards against unbounded growth of when category + /// or per-level routing produces high-cardinality file names over time. + private void EvictIdleFileHandles() + { + try + { + long cutoff = DateTime.UtcNow.Ticks - IdleFileEvictionThreshold.Ticks; + List idle = null; + + lock (_fileLock) + { + foreach (var kv in _openFiles) + { + if (kv.Value.LastWriteTicksUtc < cutoff) + { + if (idle == null) + { + idle = new List(); + } + idle.Add(kv.Key); + } + } + + if (idle != null) + { + foreach (string path in idle) + { + if (_openFiles.TryGetValue(path, out OpenFile of)) + { + if (_outputFormat == FileOutputFormat.Xml && of.HasXmlHeader) + { + try + { + of.Writer.WriteLine(""); + of.Writer.Flush(); + } + catch { } + } + + of.Dispose(); + _openFiles.Remove(path); + } + } + } + } + } + catch (Exception ex) + { + OnException?.Invoke(this, "[FileFlow] Idle file eviction error: " + ex.Message); + } + } + + private void ApplyRetention() + { + try + { + DirectoryInfo dir = new DirectoryInfo(_directory); + if (!dir.Exists) + { + return; + } + + FileInfo[] files = dir.GetFiles("*" + _fileExtension) + .Concat(dir.GetFiles("*" + _fileExtension + ".gz")) + .OrderByDescending(f => f.LastWriteTimeUtc) + .ToArray(); + + long totalBytes = 0; + int kept = 0; + int keptCompressed = 0; + + foreach (FileInfo f in files) + { + bool isCompressed = f.Extension.Equals(".gz", StringComparison.OrdinalIgnoreCase); + bool tooOld = _retention.MaxAgeDays > 0 + && (DateTime.UtcNow - f.LastWriteTimeUtc).TotalDays > _retention.MaxAgeDays; + bool tooMany = _retention.MaxRolledFiles > 0 && kept >= _retention.MaxRolledFiles; + bool tooManyCompressed = isCompressed + && _retention.MaxCompressedFiles > 0 + && keptCompressed >= _retention.MaxCompressedFiles; + bool tooLarge = _retention.MaxTotalArchiveBytes > 0 + && totalBytes + f.Length > _retention.MaxTotalArchiveBytes; + bool directoryTooLarge = totalBytes + f.Length > _maxDirectorySize; + + if (tooOld || tooMany || tooManyCompressed || tooLarge || directoryTooLarge) + { + try { f.Delete(); } catch { /* ignore */ } + } + else + { + totalBytes += f.Length; + kept++; + if (isCompressed) + { + keptCompressed++; + } + } + } + } + catch (Exception ex) + { + OnException?.Invoke(this, "[FileFlow] Retention error: " + ex.Message); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private DateTime GetCurrentDate() + { + return _timestampMode == TimestampMode.Local ? DateTime.Now.Date : DateTime.UtcNow.Date; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private string GenerateFilePath(DateTime date, LogEvent log) + { + bool isSimple = !_useCategoryRouting && _logLevelsForSeparateFiles.Count == 0; + + if (isSimple) + { + string cached = _cachedPath; + if (cached != null && _cachedPathDate == date) + { + return cached; + } + } + + string prefix = _filePrefix; + + if (_useCategoryRouting && !string.IsNullOrEmpty(log.Category)) + { + prefix += "_" + log.Category; + } + + if (_logLevelsForSeparateFiles.Count > 0 && _logLevelsForSeparateFiles.Contains(log.Level)) + { + prefix += "_" + (LevelStrings.TryGetValue(log.Level, out string lvlStr) ? lvlStr : log.Level.ToString()); + } + + string path = Path.Combine(_directory, string.Concat(prefix, "_", CachedMachineName, "_", date.ToString(_dateFormat), _fileExtension)); + + if (isSimple) + { + _cachedPathDate = date; + _cachedPath = path; + } + + return path; + } + + /// + /// Resolves the logging directory with a cascading fallback strategy: + /// 1. Try the requested directory + /// 2. Try to fix permissions on the requested directory + /// 3. Fall back to %appdata%/EonaCat.LogStack/ + /// 4. Fall back to TEMP directory + /// + private string ResolveLoggingDirectory(string requestedDirectory) + { + string workingDirectory = requestedDirectory; + if (workingDirectory.StartsWith("./", StringComparison.Ordinal)) + { + workingDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, workingDirectory.Substring(2)); + } + + if (Helpers.DirectoryPermissionHelper.EnsureDirectoryHierarchy(workingDirectory)) + { + return workingDirectory; + } + + if (TryFixDirectoryPermissions(workingDirectory) && Helpers.DirectoryPermissionHelper.EnsureDirectoryHierarchy(workingDirectory)) + { + return workingDirectory; + } + + string appDataDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "EonaCat.LogStack"); + + if (Helpers.DirectoryPermissionHelper.EnsureDirectoryHierarchy(appDataDirectory)) + { + OnDirectoryException?.Invoke(this, $"FileFlow: Could not use requested directory '{requestedDirectory}', fell back to '{appDataDirectory}'"); + return appDataDirectory; + } + + string tempDirectory = Path.GetTempPath(); + + try + { + if (Helpers.DirectoryPermissionHelper.CanWrite(tempDirectory)) + { + OnDirectoryException?.Invoke(this, $"FileFlow: Could not use requested directory '{requestedDirectory}' or AppData, fell back to TEMP directory '{tempDirectory}'"); + return tempDirectory; + } + } + catch + { + // Temp directory check failed + } + + OnDirectoryException?.Invoke(this, $"FileFlow: Critical - could not find any writable directory. Attempting to use TEMP: '{tempDirectory}'"); + return tempDirectory; + } + + /// + /// Attempts to fix directory permissions across all platforms (Windows, Linux, Mac). + /// + private bool TryFixDirectoryPermissions(string dirPath) + { + try + { + if (!Directory.Exists(dirPath)) + { + try + { + Directory.CreateDirectory(dirPath); + } + catch + { + return false; + } + } + + if (Helpers.DirectoryPermissionHelper.TrySetEveryonePermissions(dirPath)) + { + return true; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + try + { + var dirInfo = new DirectoryInfo(dirPath); + if ((dirInfo.Attributes & FileAttributes.ReadOnly) != 0) + { + dirInfo.Attributes &= ~FileAttributes.ReadOnly; + } + return true; + } + catch (Exception ex) + { + OnException?.Invoke(this, $"[FileFlow] Failed to fix permissions: {ex.Message}"); + return false; + } + } + + return false; + } + catch + { + return false; + } + } + + private void SetFileExtension(FileOutputFormat fmt) + { + switch (fmt) + { + case FileOutputFormat.Json: + case FileOutputFormat.StructuredJson: + _fileExtension = ".json"; break; + case FileOutputFormat.Xml: + _fileExtension = ".xml"; break; + case FileOutputFormat.Csv: + _fileExtension = ".csv"; break; + default: + _fileExtension = ".log"; break; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static long EstimateSize(LogEvent log) + { + long s = 200L + + log.Message.Length * 2 + + (log.Category != null ? log.Category.Length * 2 : 0) + + log.Properties.Count * 40; + if (log.Exception != null) + { + s += 2048; // Avoid calling Exception.ToString() just for size estimation + } + + return s; + } + + private void CompileTemplate(string template) + { + _compiledTemplate = new List>(); + int pos = 0; + + while (pos < template.Length) + { + int open = template.IndexOf('{', pos); + if (open < 0) + { + string lit = template.Substring(pos); + _compiledTemplate.Add((_, sb) => sb.Append(lit)); + break; + } + + if (open > pos) + { + string lit = template.Substring(pos, open - pos); + _compiledTemplate.Add((_, sb) => sb.Append(lit)); + } + + int close = template.IndexOf('}', open); + if (close < 0) + { + string lit = template.Substring(open); + _compiledTemplate.Add((_, sb) => sb.Append(lit)); + break; + } + + string token = template.Substring(open + 1, close - (open + 1)); + _compiledTemplate.Add(ResolveToken(token)); + pos = close + 1; + } + } + + private Action ResolveToken(string token) + { + switch (token.ToLowerInvariant()) + { + case "ts": + return (log, sb) => + sb.Append(LogEvent.GetDateTime(log.Timestamp).ToString("yyyy-MM-dd HH:mm:ss.fff")); + case "tz": + return (log, sb) => + sb.Append(_timestampMode == TimestampMode.Local + ? TimeZoneInfo.Local.StandardName : "UTC"); + case "host": + return (log, sb) => sb.Append(CachedMachineName); + case "category": + return (log, sb) => { if (log.Category != null) { sb.Append(log.Category); } }; + case "thread": + return (log, sb) => sb.Append(Thread.CurrentThread.ManagedThreadId); + case "logtype": + return (log, sb) => + { + sb.Append(LevelStrings.TryGetValue(log.Level, out string s) ? s : log.Level.ToString()); + }; + case "message": + return (log, sb) => + { + if (log.Message.Length > 0) + { + sb.Append(log.Message.ToString()); + } + }; + case "exception": + return (log, sb) => + { + if (log.Exception != null) + { + sb.Append(log.Exception.ToString()); + } + }; + case "props": + return (log, sb) => AppendProperties(log, sb); + case "newline": + return (log, sb) => sb.AppendLine(); + case "pid": + return (log, sb) => sb.Append(CachedPid); + case "traceid": + return (log, sb) => + { + if (log.TraceId != default(ActivityTraceId)) + { + sb.Append(log.TraceId.ToHexString()); + } + }; + case "spanid": + return (log, sb) => + { + if (log.SpanId != default(ActivitySpanId)) + { + sb.Append(log.SpanId.ToHexString()); + } + }; + default: + return BuildCustomOrLiteralToken(token); + } + } + + private Action BuildCustomOrLiteralToken(string token) + { + string name = token; + return (log, sb) => + { + if (_customTokens.TryGetValue(name, out Action custom)) + { + custom(log, sb); + } + else + { + sb.Append('{').Append(name).Append('}'); + } + }; + } + + private void AppendProperties(LogEvent log, StringBuilder sb) + { + var scopeProps = _scopeProperties.Value; + bool hasEnrichers = _enrichers.Count > 0; + bool hasProps = log.Properties.Count > 0; + bool hasScope = scopeProps != null && scopeProps.Count > 0; + if (!hasEnrichers && !hasProps && !hasScope) + { + return; + } + + sb.Append(" {"); + bool first = true; + + foreach (KeyValuePair> kv in _enrichers) + { + if (!first) + { + sb.Append(", "); + } + + first = false; + object val = kv.Value(log); + sb.Append(kv.Key).Append('=').Append(val != null ? val.ToString() : "null"); + } + + foreach (var property in log.Properties) + { + if (!first) + { + sb.Append(", "); + } + + first = false; + sb.Append(property.Key).Append('=') + .Append(property.Value != null ? property.Value.ToString() : "null"); + } + + if (hasScope) + { + foreach (var kv in scopeProps) + { + if (!first) + { + sb.Append(", "); + } + + first = false; + sb.Append(kv.Key).Append('=') + .Append(kv.Value != null ? kv.Value.ToString() : "null"); + } + } + + sb.Append('}'); + } + } } \ No newline at end of file diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/RedisFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/RedisFlow.cs index 0e78797..682eecf 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/RedisFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/RedisFlow.cs @@ -177,9 +177,8 @@ namespace EonaCat.LogStack.Flows SendToRedis(msg); - string extra; int batch = 0; - while (batch < 64 && _queue.TryTake(out extra)) + while (batch < 64 && _queue.TryTake(out string extra)) { SendToRedis(extra); batch++; diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/ThrottledFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/ThrottledFlow.cs index 09f99a7..475e5ce 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/ThrottledFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/ThrottledFlow.cs @@ -132,7 +132,6 @@ namespace EonaCat.LogStack.Flows if (_deduplicate) { string key = MakeDedupKey(logEvent); - DedupEntry entry; // Flush expired entries to avoid unbounded growth if (_dedupMap.Count >= _dedupMaxKeys) @@ -140,7 +139,7 @@ namespace EonaCat.LogStack.Flows PurgeExpiredDedupEntries(); } - if (_dedupMap.TryGetValue(key, out entry)) + if (_dedupMap.TryGetValue(key, out DedupEntry entry)) { TimeSpan age = DateTime.UtcNow - entry.FirstSeen; if (age < _dedupWindow) @@ -172,8 +171,7 @@ namespace EonaCat.LogStack.Flows } // token bucket pass - Bucket bucket; - if (!_buckets.TryGetValue(logEvent.Level, out bucket)) + if (!_buckets.TryGetValue(logEvent.Level, out Bucket bucket)) { bucket = new Bucket(_burstCapacity, _refillPerSecond); _buckets[logEvent.Level] = bucket; @@ -221,8 +219,7 @@ namespace EonaCat.LogStack.Flows List keys = new List(_dedupMap.Keys); foreach (string key in keys) { - DedupEntry entry; - if (_dedupMap.TryGetValue(key, out entry) && entry.Count > 1) + if (_dedupMap.TryGetValue(key, out DedupEntry entry) && entry.Count > 1) { FlushDedupEntry(key, entry); } @@ -290,8 +287,7 @@ namespace EonaCat.LogStack.Flows foreach (string k in expired) { - DedupEntry entry; - if (_dedupMap.TryGetValue(k, out entry) && entry.Count > 1) + if (_dedupMap.TryGetValue(k, out DedupEntry entry) && entry.Count > 1) { FlushDedupEntry(k, entry); } diff --git a/EonaCat.LogStack/EonaCatLoggerCore/IntelligentRouter.cs b/EonaCat.LogStack/EonaCatLoggerCore/IntelligentRouter.cs index e5a07f4..4a67915 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/IntelligentRouter.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/IntelligentRouter.cs @@ -30,7 +30,11 @@ public class IntelligentRouter : IAsyncDisposable /// public IntelligentRouter AddRule(RoutingRule rule) { - if (rule == null) throw new ArgumentNullException(nameof(rule)); + if (rule == null) + { + throw new ArgumentNullException(nameof(rule)); + } + lock (_rulesLock) { _rules.Add(rule); @@ -117,8 +121,15 @@ public class IntelligentRouter : IAsyncDisposable /// public IntelligentRouter RegisterFlow(string flowName, IFlow flow) { - if (string.IsNullOrEmpty(flowName)) throw new ArgumentNullException(nameof(flowName)); - if (flow == null) throw new ArgumentNullException(nameof(flow)); + if (string.IsNullOrEmpty(flowName)) + { + throw new ArgumentNullException(nameof(flowName)); + } + + if (flow == null) + { + throw new ArgumentNullException(nameof(flow)); + } lock (_rulesLock) { @@ -190,7 +201,9 @@ public class IntelligentRouter : IAsyncDisposable .ContinueWith(t => { if (t.Status == TaskStatus.RanToCompletion && t.Result == WriteResult.Success) + { anySuccess = true; + } })); } } diff --git a/EonaCat.LogStack/EonaCatLoggerCore/LoggerChain.cs b/EonaCat.LogStack/EonaCatLoggerCore/LoggerChain.cs index 370a3f8..d6a6b58 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/LoggerChain.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/LoggerChain.cs @@ -73,15 +73,21 @@ public sealed class LoggerChain : ILoggerChain ThrowIfDisposed(); if (logger == null) + { throw new ArgumentNullException(nameof(logger)); + } if (logger == _primary) + { throw new InvalidOperationException("Cannot chain the primary logger to itself"); + } lock (_chainLock) { if (_chainedLoggers.Contains(logger)) + { return this; // Already in chain + } _chainedLoggers.Add(logger); _loggerStates[logger] = true; // Enabled by default @@ -109,7 +115,9 @@ public sealed class LoggerChain : ILoggerChain public ILoggerChain AddRange(params EonaCatLogStack[] loggers) { if (loggers == null) + { throw new ArgumentNullException(nameof(loggers)); + } foreach (var logger in loggers) { @@ -127,7 +135,9 @@ public sealed class LoggerChain : ILoggerChain ThrowIfDisposed(); if (logger == null) + { return false; + } lock (_chainLock) { @@ -192,7 +202,9 @@ public sealed class LoggerChain : ILoggerChain public void DisableLogger(EonaCatLogStack logger) { if (logger == null) + { return; + } lock (_chainLock) { @@ -209,7 +221,9 @@ public sealed class LoggerChain : ILoggerChain public void EnableLogger(EonaCatLogStack logger) { if (logger == null) + { return; + } lock (_chainLock) { @@ -226,7 +240,9 @@ public sealed class LoggerChain : ILoggerChain public bool IsLoggerEnabled(EonaCatLogStack logger) { if (logger == null) + { return false; + } lock (_chainLock) { @@ -284,20 +300,26 @@ public sealed class LoggerChain : ILoggerChain }); if (ErrorBehavior == LoggerChainErrorBehavior.StopOnError) + { throw; + } } } private void ThrowIfDisposed() { if (Interlocked.CompareExchange(ref _disposed, 0, 0) != 0) + { throw new ObjectDisposedException(nameof(LoggerChain)); + } } public async ValueTask DisposeAsync() { if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) + { return; + } // Flush all loggers before disposing await FlushAllAsync().ConfigureAwait(false); diff --git a/EonaCat.LogStack/EonaCatLoggerCore/MessageTemplate.cs b/EonaCat.LogStack/EonaCatLoggerCore/MessageTemplate.cs index 307196d..6b4bdd5 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/MessageTemplate.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/MessageTemplate.cs @@ -406,9 +406,8 @@ public sealed class MessageTemplate } string[] parts = propertyPath.Split('.'); - object? current = null; - if (!properties.TryGetValue(parts[0], out current)) + if (!properties.TryGetValue(parts[0], out object current)) { return null; } diff --git a/EonaCat.LogStack/EonaCatLoggerCore/PerformanceInsights/PerformanceInsightsCollector.cs b/EonaCat.LogStack/EonaCatLoggerCore/PerformanceInsights/PerformanceInsightsCollector.cs index 7d512e1..8d6d79c 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/PerformanceInsights/PerformanceInsightsCollector.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/PerformanceInsights/PerformanceInsightsCollector.cs @@ -41,7 +41,10 @@ namespace EonaCat.LogStack.PerformanceInsights tracker.RecordOperation(durationTicks, byteCount, isError); _totalOperations++; - if (isError) _totalErrors++; + if (isError) + { + _totalErrors++; + } } /// diff --git a/EonaCat.LogStack/EonaCatLoggerCore/PerformanceInsights/PerformanceTracker.cs b/EonaCat.LogStack/EonaCatLoggerCore/PerformanceInsights/PerformanceTracker.cs index e09a58d..4763a23 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/PerformanceInsights/PerformanceTracker.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/PerformanceInsights/PerformanceTracker.cs @@ -84,10 +84,25 @@ namespace EonaCat.LogStack.PerformanceInsights { _operationCount++; _totalDurationTicks += durationTicks; - if (durationTicks < _minDurationTicks) _minDurationTicks = durationTicks; - if (durationTicks > _maxDurationTicks) _maxDurationTicks = durationTicks; - if (byteCount > 0) _byteCount += byteCount; - if (isError) _errorCount++; + if (durationTicks < _minDurationTicks) + { + _minDurationTicks = durationTicks; + } + + if (durationTicks > _maxDurationTicks) + { + _maxDurationTicks = durationTicks; + } + + if (byteCount > 0) + { + _byteCount += byteCount; + } + + if (isError) + { + _errorCount++; + } } } diff --git a/EonaCat.LogStack/EonaCatLoggerCore/StringBuilderPool.cs b/EonaCat.LogStack/EonaCatLoggerCore/StringBuilderPool.cs index 1125d91..59c72b5 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/StringBuilderPool.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/StringBuilderPool.cs @@ -14,8 +14,7 @@ namespace EonaCat.LogStack.EonaCatLogStackCore public static StringBuilder Rent() { - StringBuilder sb; - if (Pool.TryTake(out sb)) + if (Pool.TryTake(out StringBuilder sb)) { sb.Clear(); return sb; diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Templates/MessageTemplateEngine.cs b/EonaCat.LogStack/EonaCatLoggerCore/Templates/MessageTemplateEngine.cs index 4a32514..b5967e8 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Templates/MessageTemplateEngine.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Templates/MessageTemplateEngine.cs @@ -387,9 +387,8 @@ public sealed class PropertyToken : TemplateToken } string[] parts = propertyPath.Split('.'); - object? current = null; - if (!properties.TryGetValue(parts[0], out current)) + if (!properties.TryGetValue(parts[0], out object current)) { return null; } diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Tracing/SpanFactory.cs b/EonaCat.LogStack/EonaCatLoggerCore/Tracing/SpanFactory.cs index cbed4cb..fd8422d 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Tracing/SpanFactory.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Tracing/SpanFactory.cs @@ -118,7 +118,11 @@ namespace EonaCat.LogStack.Tracing public void Dispose() { - if (_disposed) return; + if (_disposed) + { + return; + } + _disposed = true; if (_span.Status == SpanStatus.Unset) diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Tracing/TraceContextManager.cs b/EonaCat.LogStack/EonaCatLoggerCore/Tracing/TraceContextManager.cs index 78559f5..9690be3 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Tracing/TraceContextManager.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Tracing/TraceContextManager.cs @@ -36,7 +36,11 @@ namespace EonaCat.LogStack.Tracing /// public static void SetCurrent(TraceContext context) { - if (context == null) throw new ArgumentNullException(nameof(context)); + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + _current.Value = context; } diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Tracing/TracingSpan.cs b/EonaCat.LogStack/EonaCatLoggerCore/Tracing/TracingSpan.cs index 43921c6..9255b4b 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Tracing/TracingSpan.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Tracing/TracingSpan.cs @@ -102,7 +102,10 @@ namespace EonaCat.LogStack.Tracing /// public void RecordException(Exception exception, Dictionary? attributes = null) { - if (exception == null) return; + if (exception == null) + { + return; + } _exceptions.Add(exception); Status = SpanStatus.Error; @@ -129,7 +132,11 @@ namespace EonaCat.LogStack.Tracing /// public void SetAttributes(Dictionary attributes) { - if (attributes == null) return; + if (attributes == null) + { + return; + } + foreach (var kvp in attributes) { Attributes[kvp.Key] = kvp.Value; @@ -154,7 +161,10 @@ namespace EonaCat.LogStack.Tracing /// public void End(SpanStatus status = SpanStatus.Ok, string? description = null) { - if (_disposed) return; + if (_disposed) + { + return; + } _stopwatch.Stop(); EndTime = DateTime.UtcNow; @@ -173,7 +183,11 @@ namespace EonaCat.LogStack.Tracing public void Dispose() { - if (_disposed) return; + if (_disposed) + { + return; + } + _disposed = true; End(); } diff --git a/EonaCat.LogStack/Extensions/ServiceCollectionExtensions.cs b/EonaCat.LogStack/Extensions/ServiceCollectionExtensions.cs index de5fea3..1ab4849 100644 --- a/EonaCat.LogStack/Extensions/ServiceCollectionExtensions.cs +++ b/EonaCat.LogStack/Extensions/ServiceCollectionExtensions.cs @@ -350,7 +350,9 @@ public static class ServiceCollectionExtensions LoggerDIOptions? options = null) { if (services == null) + { throw new ArgumentNullException(nameof(services)); + } options ??= new LoggerDIOptions(); services.AddSingleton(options); @@ -386,7 +388,9 @@ public static class ServiceCollectionExtensions this IServiceCollection services) { if (services == null) + { throw new ArgumentNullException(nameof(services)); + } services.AddSingleton(); services.AddSingleton(); @@ -401,7 +405,9 @@ public static class ServiceCollectionExtensions this IServiceCollection services) { if (services == null) + { throw new ArgumentNullException(nameof(services)); + } services.AddSingleton(); services.AddSingleton(); @@ -415,7 +421,9 @@ public static class ServiceCollectionExtensions this IServiceCollection services) { if (services == null) + { throw new ArgumentNullException(nameof(services)); + } services.AddSingleton(); services.AddSingleton(); @@ -429,7 +437,9 @@ public static class ServiceCollectionExtensions this IServiceCollection services) { if (services == null) + { throw new ArgumentNullException(nameof(services)); + } services.AddSingleton(); services.AddSingleton(); diff --git a/EonaCat.LogStack/LogBuilder.cs b/EonaCat.LogStack/LogBuilder.cs index 1b3141d..dd33c0d 100644 --- a/EonaCat.LogStack/LogBuilder.cs +++ b/EonaCat.LogStack/LogBuilder.cs @@ -1484,7 +1484,9 @@ public sealed class LogBuilder public Chaining.ILoggerChain AddToChain(EonaCatLogStack primaryLogger, bool chainAsFlow = true) { if (primaryLogger == null) + { throw new ArgumentNullException(nameof(primaryLogger)); + } var newLogger = Build(); diff --git a/EonaCat.LogStack/SuperiorFeaturesStats.cs b/EonaCat.LogStack/SuperiorFeaturesStats.cs index aa1ff97..9f47fc4 100644 --- a/EonaCat.LogStack/SuperiorFeaturesStats.cs +++ b/EonaCat.LogStack/SuperiorFeaturesStats.cs @@ -84,19 +84,29 @@ public class SuperiorFeaturesStats var features = new List(); if (IntelligentRoutingEnabled) + { features.Add($"IntelligentRouting({RouterStats?.TotalRules ?? 0} rules)"); + } if (AdaptiveSamplingEnabled) + { features.Add($"AdaptiveSampling({SamplingMetrics?.SamplingRate:P1} rate)"); + } if (AnomalyDetectionEnabled) + { features.Add($"AnomalyDetection({AnomalyStats?.RecentAnomalies ?? 0} recent)"); + } if (ContextSnapshotsEnabled) + { features.Add($"ContextSnapshots({SnapshotStats?.CurrentSnapshots ?? 0} snapshots)"); + } if (DeadLetterQueueEnabled) + { features.Add($"DeadLetterQueue({DlqStats?.CurrentQueueSize ?? 0}/{DlqStats?.MaxCapacity ?? 0})"); + } return "Superior Features: " + (features.Count > 0 ? string.Join(", ", features) diff --git a/EonaCat.LogStack/Telemetry/AdaptiveTelemetryEngine.cs b/EonaCat.LogStack/Telemetry/AdaptiveTelemetryEngine.cs index ce8045a..1dc7eba 100644 --- a/EonaCat.LogStack/Telemetry/AdaptiveTelemetryEngine.cs +++ b/EonaCat.LogStack/Telemetry/AdaptiveTelemetryEngine.cs @@ -25,14 +25,19 @@ public sealed class AdaptiveTelemetryEngine Interlocked.Increment(ref _events); if (DetectAnomalies) + { signal.UpdateAnomalyState(); + } } public TelemetryEngineSnapshot Snapshot() { var snapshot = new TelemetryEngineSnapshot { TotalEvents = Interlocked.Read(ref _events) }; foreach (var item in _signals) + { snapshot.Signals[item.Key] = item.Value.Snapshot(); + } + return snapshot; } } diff --git a/EonaCat.LogStack/Telemetry/Histogram.cs b/EonaCat.LogStack/Telemetry/Histogram.cs index c256dca..dee8651 100644 --- a/EonaCat.LogStack/Telemetry/Histogram.cs +++ b/EonaCat.LogStack/Telemetry/Histogram.cs @@ -4,6 +4,10 @@ namespace EonaCat.LogStack.Telemetry; public sealed class Histogram { private readonly ConcurrentQueue _values = new(); - public void Record(double value){ _values.Enqueue(value); while(_values.Count>10000)_values.TryDequeue(out _); } - public double Percentile(double p){ var a=_values.ToArray(); if(a.Length==0)return 0; System.Array.Sort(a); return a[(int)((a.Length-1)*p)]; } + public void Record(double value){ _values.Enqueue(value); while(_values.Count>10000) + { + _values.TryDequeue(out _); + } + } + public double Percentile(double p){ var a=_values.ToArray(); if(a.Length==0) { return 0; } System.Array.Sort(a); return a[(int)((a.Length-1)*p)]; } } diff --git a/EonaCat.LogStack/Telemetry/TelemetryAggregator.cs b/EonaCat.LogStack/Telemetry/TelemetryAggregator.cs index c829e6b..4af4e2a 100644 --- a/EonaCat.LogStack/Telemetry/TelemetryAggregator.cs +++ b/EonaCat.LogStack/Telemetry/TelemetryAggregator.cs @@ -205,7 +205,11 @@ namespace EonaCat.LogStack.Telemetry private static string TagsKey(Dictionary? tags) { - if (tags == null || tags.Count == 0) return ""; + if (tags == null || tags.Count == 0) + { + return ""; + } + return string.Join(",", tags.OrderBy(t => t.Key).Select(t => $"{t.Key}={t.Value}")); } } @@ -250,8 +254,16 @@ namespace EonaCat.LogStack.Telemetry _values.Add(value); Value++; Sum += value; - if (value < Min) Min = value; - if (value > Max) Max = value; + if (value < Min) + { + Min = value; + } + + if (value > Max) + { + Max = value; + } + if (_values.Count > 1000) // Keep reasonable history { _values.RemoveRange(0, 100); @@ -260,7 +272,11 @@ namespace EonaCat.LogStack.Telemetry private double CalculatePercentile(double percentile) { - if (_values.Count == 0) return 0; + if (_values.Count == 0) + { + return 0; + } + var sorted = _values.OrderBy(v => v).ToList(); var index = (int)((percentile / 100.0) * sorted.Count); index = Math.Min(index, sorted.Count - 1); diff --git a/EonaCat.LogStack/Telemetry/TelemetrySignal.cs b/EonaCat.LogStack/Telemetry/TelemetrySignal.cs index 9bac61d..ea38eec 100644 --- a/EonaCat.LogStack/Telemetry/TelemetrySignal.cs +++ b/EonaCat.LogStack/Telemetry/TelemetrySignal.cs @@ -28,7 +28,9 @@ public sealed class TelemetrySignal Max = Count == 1 ? value : Math.Max(Max, value); _values.Enqueue(value); while (_values.Count > limit) + { _values.Dequeue(); + } } } @@ -36,7 +38,11 @@ public sealed class TelemetrySignal { lock (_sync) { - if (_values.Count < 10) return; + if (_values.Count < 10) + { + return; + } + var avg = _values.Average(); var variance = _values.Average(v => Math.Pow(v - avg, 2)); IsAnomaly = Math.Abs(_values.Last() - avg) > Math.Sqrt(variance) * 3; diff --git a/Testers/EonaCat.LogStack.Test.Web/Program.cs b/Testers/EonaCat.LogStack.Test.Web/Program.cs index aa14bbd..f6e0e9a 100644 --- a/Testers/EonaCat.LogStack.Test.Web/Program.cs +++ b/Testers/EonaCat.LogStack.Test.Web/Program.cs @@ -17,7 +17,6 @@ namespace EonaCat.LogStack.Test.Web public static async Task Main(string[] args) { await using var logger = LogBuilder.CreateDefault("MyApp"); - logger.Information("Application started"); logger.Warning("Low memory warning"); logger.Error(new Exception("DIT IS MIJN TEST!"), "Unexpected error occurred"); @@ -58,6 +57,8 @@ namespace EonaCat.LogStack.Test.Web logBuilder.WriteToFile(logDirectory, outputFormat: FileOutputFormat.Text); logBuilder.WriteToFile(logDirectory, outputFormat: FileOutputFormat.Json); logBuilder.WriteToFile(logDirectory, outputFormat: FileOutputFormat.Xml); + logBuilder.WriteToEncryptedFile(logDirectory, filePrefix: "encrypted", password: "MySecretKey123456"); + EncryptedFileFlow.DecryptToFile(@"C:\workdir\C#\EonaCat.LogStack\Testers\EonaCat.LogStack.Test.Web\bin\Debug\net8.0\logs\encrypted_EONACAT_20260804.eona", "./logs/decrypted.log", "MySecretKey123456"); logBuilder.WriteToTcp("127.0.0.1", 514); //logBuilder.WriteToEncryptedFile("./logs"); //logBuilder.WriteDiagnostics();