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("").Append(tag).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("").Append(tag).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();