From dae624fd4593ba2821107ae4242aec44bf3806e9 Mon Sep 17 00:00:00 2001 From: EonaCat Date: Thu, 11 Jun 2026 08:17:29 +0200 Subject: [PATCH] Added more stats Added more dependency injection Made README.md better --- .../Services/SyslogService.cs | 8 +- EonaCat.LogStack/AdvancedMetricsCollector.cs | 263 ++ EonaCat.LogStack/EonaCat.LogStack.csproj | 15 +- EonaCat.LogStack/EonaCatLogger.cs | 253 +- .../AdvancedLoggerFactory.cs | 340 +++ .../Analytics/LoggingMetrics.cs | 535 ++++ .../Analytics/PerFlowStatistics.cs | 212 ++ .../Batching/BatchProcessor.cs | 345 +++ .../Boosters/AdvancedBoosters.cs | 287 ++ .../Boosters/CallerInfoBooster.cs | 46 + .../Boosters/RequestContextBooster.cs | 192 ++ .../Configuration/LoggerConfiguration.cs | 121 + .../Destructuring/TypeDestructor.cs | 368 +++ .../Diagnostics/LoggerDiagnosticsCollector.cs | 349 +++ .../DynamicLevelController.cs | 92 + .../Exceptions/AdvancedExceptionRenderer.cs | 382 +++ .../Filtering/AdvancedFilter.cs | 397 +++ .../Flows/CircuitBreakerFlow.cs | 179 ++ .../Flows/ConditionalFlow.cs | 103 + .../EonaCatLoggerCore/Flows/LokiFlow.cs | 308 +++ .../EonaCatLoggerCore/Flows/MemoryFlow.cs | 112 +- .../EonaCatLoggerCore/Flows/MulticastFlow.cs | 175 ++ .../Formatting/Formatters.cs | 326 +++ .../Layouts/LayoutFormatter.cs | 358 +++ .../EonaCatLoggerCore/LogStats.cs | 38 + .../EonaCatLoggerCore/LoggerFactory.cs | 18 + .../Memory/MemoryOptimizations.cs | 331 +++ .../EonaCatLoggerCore/MessageTemplate.cs | 690 +++++ .../Policies/PolicyEngine.cs | 371 +++ .../EonaCatLoggerCore/Pooling/BufferPool.cs | 153 ++ .../EonaCatLoggerCore/Pooling/ObjectPool.cs | 122 + .../Pooling/StringBuilderPool.cs | 210 ++ .../Structured/StructuredProperties.cs | 214 ++ .../Templates/MessageTemplateEngine.cs | 1122 ++++++++ .../HostApplicationBuilderExtensions.cs | 115 +- .../Extensions/ServiceCollectionExtensions.cs | 89 + EonaCat.LogStack/LogBuilder.cs | 124 +- EonaCat.LogStack/LoggerDiagnostics.cs | 1 + EonaCat.LogStack/LoggerMetrics.cs | 90 + README.md | 2390 +++++++++++++++-- 40 files changed, 11622 insertions(+), 222 deletions(-) create mode 100644 EonaCat.LogStack/AdvancedMetricsCollector.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/AdvancedLoggerFactory.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Analytics/LoggingMetrics.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Analytics/PerFlowStatistics.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Batching/BatchProcessor.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Boosters/AdvancedBoosters.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Boosters/CallerInfoBooster.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Boosters/RequestContextBooster.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Configuration/LoggerConfiguration.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Destructuring/TypeDestructor.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Diagnostics/LoggerDiagnosticsCollector.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/DynamicLevelController.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Exceptions/AdvancedExceptionRenderer.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Filtering/AdvancedFilter.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Flows/CircuitBreakerFlow.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Flows/ConditionalFlow.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Flows/LokiFlow.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Flows/MulticastFlow.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Formatting/Formatters.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Layouts/LayoutFormatter.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Memory/MemoryOptimizations.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/MessageTemplate.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Policies/PolicyEngine.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Pooling/BufferPool.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Pooling/ObjectPool.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Pooling/StringBuilderPool.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Structured/StructuredProperties.cs create mode 100644 EonaCat.LogStack/EonaCatLoggerCore/Templates/MessageTemplateEngine.cs create mode 100644 EonaCat.LogStack/LoggerMetrics.cs diff --git a/EonaCat.LogStack.Status/Services/SyslogService.cs b/EonaCat.LogStack.Status/Services/SyslogService.cs index f4ce85a..69cc630 100644 --- a/EonaCat.LogStack.Status/Services/SyslogService.cs +++ b/EonaCat.LogStack.Status/Services/SyslogService.cs @@ -118,9 +118,11 @@ public class SyslogUdpService : BackgroundService { try { - if (IsJson(rawMessage)) - return ParseJson(rawMessage, remoteIp); - + if (IsJson(rawMessage)) + { + return ParseJson(rawMessage, remoteIp); + } + return ParseSyslogAdvanced(rawMessage, remoteIp); } catch (Exception ex) diff --git a/EonaCat.LogStack/AdvancedMetricsCollector.cs b/EonaCat.LogStack/AdvancedMetricsCollector.cs new file mode 100644 index 0000000..37017de --- /dev/null +++ b/EonaCat.LogStack/AdvancedMetricsCollector.cs @@ -0,0 +1,263 @@ +using EonaCat.LogStack.Analytics; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace EonaCat.LogStack; + +// 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. + +/// +/// Advanced metrics collector that provides comprehensive analytics across loggers +/// +public sealed class AdvancedMetricsCollector +{ + private readonly List _loggers = new(); + private readonly object _lock = new(); + + /// + /// Registers a logger for metrics collection + /// + public void RegisterLogger(EonaCatLogStack logger) + { + if (logger != null) + { + lock (_lock) + { + if (!_loggers.Contains(logger)) + { + _loggers.Add(logger); + } + } + } + } + + /// + /// Unregisters a logger + /// + public void UnregisterLogger(EonaCatLogStack logger) + { + if (logger != null) + { + lock (_lock) + { + _loggers.Remove(logger); + } + } + } + + /// + /// Gets aggregated metrics across all registered loggers + /// + public AggregatedMetrics GetAggregatedMetrics() + { + lock (_lock) + { + var aggregated = new AggregatedMetrics + { + Timestamp = DateTime.UtcNow, + LoggerMetrics = new List() + }; + + foreach (var logger in _loggers) + { + try + { + var metrics = logger.GetMetrics(); + aggregated.LoggerMetrics.Add(metrics); + } + catch { } + } + + // Aggregate totals + aggregated.TotalLoggedAcrossAll = aggregated.LoggerMetrics.Sum(m => m.TotalLogged); + aggregated.TotalDroppedAcrossAll = aggregated.LoggerMetrics.Sum(m => m.TotalDropped); + aggregated.TotalExceptionsAcrossAll = aggregated.LoggerMetrics.Sum(m => m.TotalExceptions); + aggregated.TotalBytesAcrossAll = aggregated.LoggerMetrics.Sum(m => m.TotalBytes); + aggregated.ActiveLoggers = _loggers.Count; + + return aggregated; + } + } + + /// + /// Gets a performance comparison across loggers + /// + public PerformanceComparison GetPerformanceComparison() + { + var aggregated = GetAggregatedMetrics(); + + return new PerformanceComparison + { + Timestamp = aggregated.Timestamp, + TotalLoggers = aggregated.ActiveLoggers, + HighestThroughputLogger = aggregated.LoggerMetrics.OrderByDescending(m => m.WritesPerSecond).FirstOrDefault(), + LowestSuccessRateLogger = aggregated.LoggerMetrics.OrderBy(m => m.SuccessRate).FirstOrDefault(), + AverageBytesPerEvent = aggregated.LoggerMetrics.Count > 0 + ? aggregated.LoggerMetrics.Average(m => m.AverageBytesPerEvent) + : 0, + AverageWritesPerSecond = aggregated.LoggerMetrics.Count > 0 + ? aggregated.LoggerMetrics.Average(m => m.WritesPerSecond) + : 0 + }; + } + + /// + /// Gets a health report + /// + public HealthReport GetHealthReport() + { + var aggregated = GetAggregatedMetrics(); + + var report = new HealthReport + { + Timestamp = aggregated.Timestamp, + OverallStatus = "Healthy", + Warnings = new List(), + Errors = new List() + }; + + foreach (var logger in aggregated.LoggerMetrics) + { + if (logger.SuccessRate < 95) + { + report.Warnings.Add($"{logger.GetType().Name}: Low success rate ({logger.SuccessRate:F1}%)"); + } + + if (logger.TotalExceptions > logger.TotalLogged * 0.05) // More than 5% exceptions + { + report.Warnings.Add($"Logger: High exception rate ({logger.TotalExceptions} exceptions)"); + } + } + + if (report.Warnings.Count > 0) + { + report.OverallStatus = "Warning"; + } + + if (report.Errors.Count > 0) + { + report.OverallStatus = "Error"; + } + + return report; + } + + /// + /// Resets all metrics + /// + public void Reset() + { + lock (_lock) + { + _loggers.Clear(); + } + } +} + +/// +/// Aggregated metrics across multiple loggers +/// +public sealed class AggregatedMetrics +{ + public DateTime Timestamp { get; set; } + public List LoggerMetrics { get; set; } = new(); + public long TotalLoggedAcrossAll { get; set; } + public long TotalDroppedAcrossAll { get; set; } + public long TotalExceptionsAcrossAll { get; set; } + public long TotalBytesAcrossAll { get; set; } + public int ActiveLoggers { get; set; } + + public double OverallSuccessRate + { + get + { + var total = TotalLoggedAcrossAll + TotalDroppedAcrossAll; + return total > 0 ? (TotalLoggedAcrossAll * 100.0) / total : 100; + } + } + + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine("===== AGGREGATED METRICS ====="); + sb.AppendLine($"Timestamp: {Timestamp:O}"); + sb.AppendLine($"Active Loggers: {ActiveLoggers}"); + sb.AppendLine($"Total Logged: {TotalLoggedAcrossAll:N0}"); + sb.AppendLine($"Total Dropped: {TotalDroppedAcrossAll:N0}"); + sb.AppendLine($"Overall Success Rate: {OverallSuccessRate:F2}%"); + sb.AppendLine($"Total Exceptions: {TotalExceptionsAcrossAll:N0}"); + sb.AppendLine($"Total Bytes: {TotalBytesAcrossAll:N0}"); + sb.AppendLine("=============================="); + return sb.ToString(); + } +} + +/// +/// Performance comparison across loggers +/// +public sealed class PerformanceComparison +{ + public DateTime Timestamp { get; set; } + public int TotalLoggers { get; set; } + public LoggerMetrics HighestThroughputLogger { get; set; } + public LoggerMetrics LowestSuccessRateLogger { get; set; } + public double AverageBytesPerEvent { get; set; } + public double AverageWritesPerSecond { get; set; } + + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine("===== PERFORMANCE COMPARISON ====="); + sb.AppendLine($"Timestamp: {Timestamp:O}"); + sb.AppendLine($"Total Loggers: {TotalLoggers}"); + sb.AppendLine($"Highest Throughput: {HighestThroughputLogger?.WritesPerSecond:F2} events/sec"); + sb.AppendLine($"Lowest Success Rate: {LowestSuccessRateLogger?.SuccessRate:F2}%"); + sb.AppendLine($"Average Bytes/Event: {AverageBytesPerEvent:F2}"); + sb.AppendLine($"Average Writes/Second: {AverageWritesPerSecond:F2}"); + sb.AppendLine("=================================="); + return sb.ToString(); + } +} + +/// +/// Health report for logging system +/// +public sealed class HealthReport +{ + public DateTime Timestamp { get; set; } + public string OverallStatus { get; set; } = "Unknown"; + public List Warnings { get; set; } = new(); + public List Errors { get; set; } = new(); + + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine("===== HEALTH REPORT ====="); + sb.AppendLine($"Timestamp: {Timestamp:O}"); + sb.AppendLine($"Status: {OverallStatus}"); + + if (Warnings.Count > 0) + { + sb.AppendLine("\nWarnings:"); + foreach (var warning in Warnings) + { + sb.AppendLine($" - {warning}"); + } + } + + if (Errors.Count > 0) + { + sb.AppendLine("\nErrors:"); + foreach (var error in Errors) + { + sb.AppendLine($" - {error}"); + } + } + + sb.AppendLine("========================"); + return sb.ToString(); + } +} diff --git a/EonaCat.LogStack/EonaCat.LogStack.csproj b/EonaCat.LogStack/EonaCat.LogStack.csproj index f63570b..6768bb8 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.0.8 + 0.0.9 README.md True LICENSE @@ -25,7 +25,7 @@ It features a rich fluent API for routing log events to dozens of destinations f - 0.0.8+{chash:10}.{c:ymd} + 0.0.9+{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.0.8 + 0.0.9 EonaCat.LogStack EonaCat.LogStack https://git.saey.me/EonaCat/EonaCat.LogStack @@ -50,6 +50,12 @@ It features a rich fluent API for routing log events to dozens of destinations f + + + + + + @@ -91,4 +97,7 @@ 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/EonaCatLogger.cs b/EonaCat.LogStack/EonaCatLogger.cs index 594bdbc..aa44544 100644 --- a/EonaCat.LogStack/EonaCatLogger.cs +++ b/EonaCat.LogStack/EonaCatLogger.cs @@ -1,12 +1,15 @@ -using EonaCat.LogStack.Boosters; +using EonaCat.LogStack.Analytics; +using EonaCat.LogStack.Boosters; using EonaCat.LogStack.Core; using EonaCat.LogStack.Flows; using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; // This file is part of the EonaCat project(s) which is released under the Apache License. @@ -30,12 +33,35 @@ namespace EonaCat.LogStack private volatile bool _isDisposed; private long _totalLoggedCount; private long _totalDroppedCount; + private long _totalExceptionsCount; + private long _totalByteCount; + private readonly Stopwatch _startTime = Stopwatch.StartNew(); + + // Per-level counters + private long _traceCount; + private long _debugCount; + private long _informationCount; + private long _warningCount; + private long _errorCount; + private long _criticalCount; + + // Metrics tracking + private readonly LoggingMetrics _metrics = new LoggingMetrics(); + private readonly ConcurrentDictionary _flowStats = new(); private readonly List> _modifiers = new List>(); public delegate void ActionRef(ref T item); private readonly object _modifiersLock = new object(); + // Channel-based async pipeline + private Channel? _asyncChannel; + private Task? _asyncConsumer; + private CancellationTokenSource? _asyncCts; + + // Dynamic level controller + private volatile DynamicLevelController? _dynamicLevel; + public event EventHandler OnLog; /// @@ -50,6 +76,50 @@ namespace EonaCat.LogStack _timestampMode = timestampMode; } + /// + /// Enables a Channel-based async dispatch pipeline for zero-blocking logging. + /// Events are enqueued and consumed by a dedicated background Task. + /// + /// Bounded channel capacity (0 = unbounded). + public EonaCatLogStack UseAsyncPipeline(int capacity = 65536) + { + if (_asyncChannel != null) + { + return this; + } + + _asyncCts = new CancellationTokenSource(); + _asyncChannel = capacity > 0 + ? Channel.CreateBounded(new BoundedChannelOptions(capacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + AllowSynchronousContinuations = false + }) + : Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + AllowSynchronousContinuations = false + }); + + _asyncConsumer = Task.Run(() => ConsumeChannelAsync(_asyncCts.Token)); + return this; + } + + /// + /// Attaches a so the minimum level + /// can be changed at runtime without restarting the application. + /// + public EonaCatLogStack UseDynamicLevel(DynamicLevelController controller) + { + _dynamicLevel = controller ?? throw new ArgumentNullException(nameof(controller)); + return this; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private LogLevel EffectiveMinLevel() => + _dynamicLevel != null ? _dynamicLevel.CurrentLevel : _minimumLevel; + /// /// Adds a flow (output destination) to this logger /// @@ -115,11 +185,12 @@ namespace EonaCat.LogStack [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Log(string message, LogLevel level = LogLevel.Information) { - if (_isDisposed || level < _minimumLevel) + if (_isDisposed || level < EffectiveMinLevel()) { return; } + TrackLevel(level); var builder = new LogEventBuilder() .WithLevel(level) .WithCategory(_category) @@ -129,14 +200,68 @@ namespace EonaCat.LogStack ProcessLogEvent(ref builder); } + /// + /// Logs using a structured message template, binding named holes to the provided args. + /// E.g. LogTemplate(LogLevel.Information, "User {UserId} logged in from {Ip}", userId, ip) + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogTemplate(LogLevel level, string template, params object?[] args) + { + if (_isDisposed || level < EffectiveMinLevel()) + { + return; + } + + var parsed = MessageTemplate.FromCache(template); + var builder = new LogEventBuilder() + .WithLevel(level) + .WithCategory(_category) + .WithTimestamp(GetTimestamp()); + + var rendered = parsed.RenderInto(args, builder); + builder.WithMessage(rendered); + + ProcessLogEvent(ref builder); + } + + /// + /// Logs using a structured message template with an exception. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void LogTemplate(LogLevel level, Exception exception, string template, params object?[] args) + { + if (_isDisposed || level < EffectiveMinLevel()) + { + return; + } + + var parsed = MessageTemplate.FromCache(template); + var builder = new LogEventBuilder() + .WithLevel(level) + .WithCategory(_category) + .WithException(exception) + .WithTimestamp(GetTimestamp()); + + var rendered = parsed.RenderInto(args, builder); + builder.WithMessage(rendered); + + ProcessLogEvent(ref builder); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Log(LogLevel level, Exception exception, string message) { - if (_isDisposed || level < _minimumLevel) + if (_isDisposed || level < EffectiveMinLevel()) { return; } + TrackLevel(level); + if (exception != null) + { + Interlocked.Increment(ref _totalExceptionsCount); + } + var builder = new LogEventBuilder() .WithLevel(level) .WithCategory(_category) @@ -156,14 +281,41 @@ namespace EonaCat.LogStack }); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void TrackLevel(LogLevel level) + { + switch (level) + { + case LogLevel.Trace: + Interlocked.Increment(ref _traceCount); + break; + case LogLevel.Debug: + Interlocked.Increment(ref _debugCount); + break; + case LogLevel.Information: + Interlocked.Increment(ref _informationCount); + break; + case LogLevel.Warning: + Interlocked.Increment(ref _warningCount); + break; + case LogLevel.Error: + Interlocked.Increment(ref _errorCount); + break; + case LogLevel.Critical: + Interlocked.Increment(ref _criticalCount); + break; + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Log(LogLevel level, string message, params (string Key, object Value)[] properties) { - if (_isDisposed || level < _minimumLevel) + if (_isDisposed || level < EffectiveMinLevel()) { return; } + TrackLevel(level); var builder = new LogEventBuilder() .WithLevel(level) .WithCategory(_category) @@ -256,21 +408,62 @@ namespace EonaCat.LogStack var logEvent = builder.Build(); Interlocked.Increment(ref _totalLoggedCount); - // Blast to flows + // Async channel pipeline + if (_asyncChannel != null) + { + if (!_asyncChannel.Writer.TryWrite(logEvent)) + { + Interlocked.Increment(ref _totalDroppedCount); + } + + return; + } + + // Synchronous blast to flows + DispatchToFlows(logEvent); + } + + private void DispatchToFlows(LogEvent logEvent) + { foreach (var flow in _concurrentFlows) { try { var result = flow.BlastAsync(logEvent).GetAwaiter().GetResult(); - if (result == WriteResult.Dropped) - { - Interlocked.Increment(ref _totalDroppedCount); - } + if (result == WriteResult.Dropped) + { + Interlocked.Increment(ref _totalDroppedCount); + } } catch { } } } + private async Task ConsumeChannelAsync(CancellationToken cancellationToken) + { + var reader = _asyncChannel!.Reader; + try + { + await foreach (var logEvent in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + var flows = _concurrentFlows; + foreach (var flow in flows) + { + try + { + var result = await flow.BlastAsync(logEvent, cancellationToken).ConfigureAwait(false); + if (result == WriteResult.Dropped) + { + Interlocked.Increment(ref _totalDroppedCount); + } + } + catch { } + } + } + } + catch (OperationCanceledException) { } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private long GetTimestamp() { @@ -301,12 +494,45 @@ namespace EonaCat.LogStack MinimumLevel = _minimumLevel, TotalLogged = Interlocked.Read(ref _totalLoggedCount), TotalDropped = Interlocked.Read(ref _totalDroppedCount), + TotalExceptions = Interlocked.Read(ref _totalExceptionsCount), FlowCount = _flows.Count, BoosterCount = _boosters.Count, Flows = flowDiagnostics }; } + /// + /// Gets detailed metrics about the logger + /// + public LoggerMetrics GetMetrics() + { + var elapsedMs = _startTime.ElapsedMilliseconds; + var writesPerSecond = elapsedMs > 0 ? (_totalLoggedCount * 1000.0) / elapsedMs : 0; + + return new LoggerMetrics + { + TotalLogged = Interlocked.Read(ref _totalLoggedCount), + TotalDropped = Interlocked.Read(ref _totalDroppedCount), + TotalExceptions = Interlocked.Read(ref _totalExceptionsCount), + TotalBytes = Interlocked.Read(ref _totalByteCount), + WritesPerSecond = writesPerSecond, + TraceCount = Interlocked.Read(ref _traceCount), + DebugCount = Interlocked.Read(ref _debugCount), + InformationCount = Interlocked.Read(ref _informationCount), + WarningCount = Interlocked.Read(ref _warningCount), + ErrorCount = Interlocked.Read(ref _errorCount), + CriticalCount = Interlocked.Read(ref _criticalCount), + UptimeMilliseconds = elapsedMs, + FlowMetrics = _flowStats.Values.Select(s => s.GetSnapshot()).ToList() + }; + } + + /// + /// Gets the metrics collector for analytics + /// + public LoggingMetrics GetMetricsCollector() => _metrics; + + public async ValueTask DisposeAsync() { if (_isDisposed) @@ -316,11 +542,20 @@ namespace EonaCat.LogStack _isDisposed = true; + // Drain and stop the async channel pipeline if active + if (_asyncChannel != null) + { + _asyncChannel.Writer.TryComplete(); + _asyncCts?.Cancel(); + try { if (_asyncConsumer != null) { await _asyncConsumer.ConfigureAwait(false); } } catch { } + } + await FlushAsync().ConfigureAwait(false); var disposeTasks = _concurrentFlows.Select(f => f.DisposeAsync().AsTask()); await Task.WhenAll(disposeTasks).ConfigureAwait(false); + _asyncCts?.Dispose(); GC.SuppressFinalize(this); } } diff --git a/EonaCat.LogStack/EonaCatLoggerCore/AdvancedLoggerFactory.cs b/EonaCat.LogStack/EonaCatLoggerCore/AdvancedLoggerFactory.cs new file mode 100644 index 0000000..7c77371 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/AdvancedLoggerFactory.cs @@ -0,0 +1,340 @@ +using EonaCat.LogStack.Boosters; +using EonaCat.LogStack.Core; +using System; +using System.Collections.Generic; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace EonaCat.LogStack.Logging; + +// 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. + +/// +/// Advanced configuration for category-specific logger settings +/// +public class CategoryConfig +{ + public string CategoryName { get; set; } = ""; + public LogLevel? MinimumLevel { get; set; } + public List Boosters { get; set; } = new(); + public Dictionary Properties { get; set; } = new(); + public Func? Filter { get; set; } +} + +/// +/// Enhanced LoggerFactory with category configuration, dynamic levels, and context propagation +/// +public sealed class AdvancedLoggerFactory : ILoggerFactory +{ + private readonly EonaCatLogStack _logStack; + private readonly ConcurrentDictionary _loggers; + private readonly ConcurrentDictionary _categoryConfigs; + private readonly AsyncLocal> _contextData; + private volatile bool _isDisposed; + + public AdvancedLoggerFactory( + LogLevel minimumLevel = LogLevel.Trace, + TimestampMode timestampMode = TimestampMode.Utc) + { + _logStack = new EonaCatLogStack( + category: "AdvancedFactory", + minimumLevel: minimumLevel, + timestampMode: timestampMode); + _loggers = new ConcurrentDictionary(); + _categoryConfigs = new ConcurrentDictionary(); + _contextData = new AsyncLocal>(); + } + + /// + /// Configures settings for a specific category + /// + public AdvancedLoggerFactory ConfigureCategory(string categoryName, Action configure) + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(AdvancedLoggerFactory)); + } + + var config = _categoryConfigs.GetOrAdd(categoryName, _ => new CategoryConfig { CategoryName = categoryName }); + configure(config); + return this; + } + + /// + /// Creates or retrieves a logger for the specified category + /// + public ILogger CreateLogger(string categoryName) + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(AdvancedLoggerFactory)); + } + + if (string.IsNullOrEmpty(categoryName)) + { + categoryName = "Default"; + } + + return _loggers.GetOrAdd(categoryName, name => new Logger(name, _logStack, _categoryConfigs)); + } + + /// + /// Gets the underlying EonaCatLogStack instance + /// + public EonaCatLogStack GetLogStack() + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(AdvancedLoggerFactory)); + } + + return _logStack; + } + + /// + /// Sets context data that will be included in all logs within this async context + /// + public void SetContextData(string key, object value) + { + var ctx = _contextData.Value ??= new Dictionary(); + ctx[key] = value; + } + + /// + /// Gets context data for the current async context + /// + public object? GetContextData(string key) + { + var ctx = _contextData.Value; + if (ctx == null) + { + return null; + } + + ctx.TryGetValue(key, out var value); + return value; + } + + /// + /// Clears all context data for the current async context + /// + public void ClearContextData() + { + _contextData.Value?.Clear(); + } + + /// + /// Dynamically changes the minimum log level at runtime + /// + public void SetDynamicLevel(LogLevel level, string? categoryName = null) + { + if (categoryName != null) + { + if (_categoryConfigs.TryGetValue(categoryName, out var config)) + { + config.MinimumLevel = level; + } + } + else + { + // Update all categories + foreach (var config in _categoryConfigs.Values) + { + config.MinimumLevel = level; + } + } + } + + /// + /// Flushes all pending log events + /// + public async Task FlushAsync() + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(AdvancedLoggerFactory)); + } + + await _logStack.FlushAsync().ConfigureAwait(false); + } + + /// + /// Gets diagnostics information + /// + public LoggerDiagnostics GetDiagnostics() + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(AdvancedLoggerFactory)); + } + + var baseDiagnostics = _logStack.GetDiagnostics(); + return new LoggerDiagnostics + { + Category = "AdvancedFactory", + MinimumLevel = baseDiagnostics.MinimumLevel, + TotalLogged = baseDiagnostics.TotalLogged, + TotalDropped = baseDiagnostics.TotalDropped, + TotalExceptions = baseDiagnostics.TotalExceptions, + FlowCount = baseDiagnostics.FlowCount, + BoosterCount = baseDiagnostics.BoosterCount, + Flows = baseDiagnostics.Flows + }; + } + + /// + /// Gets factory-specific diagnostics + /// + public FactoryDiagnostics GetFactoryDiagnostics() + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(AdvancedLoggerFactory)); + } + + return new FactoryDiagnostics + { + ActiveLoggers = _loggers.Count, + ConfiguredCategories = _categoryConfigs.Count, + TotalLogged = _logStack.GetDiagnostics().TotalLogged, + TotalDropped = _logStack.GetDiagnostics().TotalDropped + }; + } + + /// + /// Gets detailed metrics + /// + public LoggerMetrics GetMetrics() + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(AdvancedLoggerFactory)); + } + + return _logStack.GetMetrics(); + } + + public async ValueTask DisposeAsync() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + _loggers.Clear(); + _categoryConfigs.Clear(); + _contextData.Value?.Clear(); + await _logStack.DisposeAsync().ConfigureAwait(false); + GC.SuppressFinalize(this); + } + + /// + /// Internal logger adapter with category support + /// + private sealed class Logger : ILogger + { + private readonly string _category; + private readonly EonaCatLogStack _logStack; + private readonly ConcurrentDictionary _configs; + + public string Category => _category; + + public Logger(string category, EonaCatLogStack logStack, ConcurrentDictionary configs) + { + _category = category ?? "Default"; + _logStack = logStack; + _configs = configs; + } + + private CategoryConfig? GetCategoryConfig() + { + _configs.TryGetValue(_category, out var config); + return config; + } + + private bool ShouldLog(LogLevel level) + { + var config = GetCategoryConfig(); + if (config?.MinimumLevel.HasValue == true) + { + return level >= config.MinimumLevel; + } + return true; + } + + public void Log(LogLevel level, string message) + { + if (!IsEnabled(level)) + { + return; + } + + _logStack.Log(message, level); + } + + public void Log(LogLevel level, Exception? exception, string message) + { + if (!IsEnabled(level)) + { + return; + } + + _logStack.Log(level, exception, message); + } + + public void Log(LogLevel level, string format, params object[] args) + { + if (!IsEnabled(level)) + { + return; + } + + try + { + var message = string.Format(format, args); + _logStack.Log(message, level); + } + catch + { + _logStack.Log(format, level); + } + } + + public void Log(LogLevel level, Exception? exception, string format, params object[] args) + { + if (!IsEnabled(level)) + { + return; + } + + try + { + var message = string.Format(format, args); + _logStack.Log(level, exception, message); + } + catch + { + _logStack.Log(level, exception, format); + } + } + + public bool IsEnabled(LogLevel level) + { + return ShouldLog(level); + } + } +} + +/// +/// Diagnostics information from AdvancedLoggerFactory +/// +public class FactoryDiagnostics +{ + public int ActiveLoggers { get; set; } + public int ConfiguredCategories { get; set; } + public long TotalLogged { get; set; } + public long TotalDropped { get; set; } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Analytics/LoggingMetrics.cs b/EonaCat.LogStack/EonaCatLoggerCore/Analytics/LoggingMetrics.cs new file mode 100644 index 0000000..4a77cd9 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Analytics/LoggingMetrics.cs @@ -0,0 +1,535 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; + +namespace EonaCat.LogStack.Analytics; + +// 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. + +/// +/// Tracks logging metrics and analytics with comprehensive per-level and per-category metrics +/// +public sealed class LoggingMetrics +{ + private readonly object _lock = new object(); + private long _totalEvents; + private long _totalBytes; + private long _totalExceptions; + private long _droppedCount; + private long _droppedExceptions; + private readonly Dictionary _levelCounts = new(); + private readonly Dictionary _loggerCounts = new(); + private readonly Dictionary _categoryCounts = new(); + private readonly List _latencies = new(); + private long _minLatencyMs = long.MaxValue; + private long _maxLatencyMs; + private readonly Stopwatch _uptime = Stopwatch.StartNew(); + private double _p50LatencyMs; + private double _p95LatencyMs; + private double _p99LatencyMs; + + /// + /// Records a log event with comprehensive metrics + /// + public void RecordEvent(object logEvent, long latencyMs, long bytes) + { + lock (_lock) + { + _totalEvents++; + _totalBytes += bytes; + _latencies.Add(latencyMs); + + if (latencyMs < _minLatencyMs) + { + _minLatencyMs = latencyMs; + } + + if (latencyMs > _maxLatencyMs) + { + _maxLatencyMs = latencyMs; + } + + // Keep only last 1000 latencies to avoid memory bloat + if (_latencies.Count > 1000) + { + _latencies.RemoveAt(0); + } + } + } + + /// + /// Records a log event with level information + /// + public void RecordEventWithLevel(string level, long latencyMs, long bytes) + { + lock (_lock) + { + _totalEvents++; + _totalBytes += bytes; + _latencies.Add(latencyMs); + + if (latencyMs < _minLatencyMs) + { + _minLatencyMs = latencyMs; + } + + if (latencyMs > _maxLatencyMs) + { + _maxLatencyMs = latencyMs; + } + + // Track per-level counts + if (!_levelCounts.ContainsKey(level)) + { + _levelCounts[level] = 0; + } + + _levelCounts[level]++; + + if (_latencies.Count > 1000) + { + _latencies.RemoveAt(0); + } + } + } + + /// + /// Records an exception + /// + public void RecordException(long latencyMs = 0) + { + Interlocked.Increment(ref _totalExceptions); + } + + /// + /// Records dropped events + /// + public void RecordDropped(long count = 1) + { + Interlocked.Add(ref _droppedCount, count); + } + + /// + /// Records dropped exceptions + /// + public void RecordDroppedException() + { + Interlocked.Increment(ref _droppedExceptions); + } + + /// + /// Records logger usage + /// + public void RecordLoggerUsage(string loggerName) + { + lock (_lock) + { + if (!_loggerCounts.ContainsKey(loggerName)) + { + _loggerCounts[loggerName] = 0; + } + + _loggerCounts[loggerName]++; + } + } + + /// + /// Records category usage + /// + public void RecordCategoryUsage(string category) + { + lock (_lock) + { + if (!_categoryCounts.ContainsKey(category)) + { + _categoryCounts[category] = 0; + } + + _categoryCounts[category]++; + } + } + + /// + /// Gets metrics snapshot + /// + public MetricsSnapshot GetSnapshot() + { + lock (_lock) + { + double avgLatency = _latencies.Count > 0 ? _latencies.Average() : 0; + double median = CalculateMedian(_latencies); + CalculatePercentiles(_latencies, out var p50, out var p95, out var p99); + _p50LatencyMs = p50; + _p95LatencyMs = p95; + _p99LatencyMs = p99; + + var topLoggers = _loggerCounts + .OrderByDescending(kvp => kvp.Value) + .Take(10) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + var topCategories = _categoryCounts + .OrderByDescending(kvp => kvp.Value) + .Take(10) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + return new MetricsSnapshot + { + TotalEvents = _totalEvents, + TotalBytes = _totalBytes, + TotalExceptions = _totalExceptions, + DroppedCount = _droppedCount, + DroppedExceptions = _droppedExceptions, + LevelCounts = new Dictionary(_levelCounts), + LoggerCounts = topLoggers, + CategoryCounts = topCategories, + MinLatencyMs = _minLatencyMs == long.MaxValue ? 0 : _minLatencyMs, + MaxLatencyMs = _maxLatencyMs, + AverageLatencyMs = avgLatency, + MedianLatencyMs = median, + P50LatencyMs = p50, + P95LatencyMs = p95, + P99LatencyMs = p99, + UptimeMs = _uptime.ElapsedMilliseconds, + EventsPerSecond = _uptime.ElapsedMilliseconds > 0 + ? _totalEvents * 1000.0 / _uptime.ElapsedMilliseconds + : 0, + BytesPerSecond = _uptime.ElapsedMilliseconds > 0 + ? _totalBytes * 1000.0 / _uptime.ElapsedMilliseconds + : 0, + AverageEventSizeBytes = _totalEvents > 0 ? _totalBytes / (double)_totalEvents : 0 + }; + } + } + + + /// + /// Resets all metrics + /// + public void Reset() + { + lock (_lock) + { + _totalEvents = 0; + _totalBytes = 0; + _totalExceptions = 0; + _droppedCount = 0; + _droppedExceptions = 0; + _latencies.Clear(); + _minLatencyMs = long.MaxValue; + _maxLatencyMs = 0; + _levelCounts.Clear(); + _loggerCounts.Clear(); + _categoryCounts.Clear(); + _uptime.Restart(); + } + } + + private double CalculateMedian(List values) + { + if (values.Count == 0) + { + return 0; + } + + var sorted = values.OrderBy(v => v).ToList(); + int mid = sorted.Count / 2; + + return sorted.Count % 2 == 0 + ? (sorted[mid - 1] + sorted[mid]) / 2.0 + : sorted[mid]; + } + + private void CalculatePercentiles(List values, out double p50, out double p95, out double p99) + { + if (values.Count == 0) + { + p50 = p95 = p99 = 0; + return; + } + + var sorted = values.OrderBy(v => v).ToList(); + p50 = GetPercentile(sorted, 50); + p95 = GetPercentile(sorted, 95); + p99 = GetPercentile(sorted, 99); + } + + private double GetPercentile(List sortedValues, int percentile) + { + if (sortedValues.Count == 0) + { + return 0; + } + + int index = (int)((percentile / 100.0) * (sortedValues.Count - 1)); + index = Math.Max(0, Math.Min(index, sortedValues.Count - 1)); + return sortedValues[index]; + } +} + +/// +/// Snapshot of logging metrics at a point in time +/// +public sealed class MetricsSnapshot +{ + public long TotalEvents { get; set; } + public long TotalBytes { get; set; } + public long TotalExceptions { get; set; } + public long DroppedCount { get; set; } + public long DroppedExceptions { get; set; } + public Dictionary LevelCounts { get; set; } = new(); + public Dictionary LoggerCounts { get; set; } = new(); + public Dictionary CategoryCounts { get; set; } = new(); + public long MinLatencyMs { get; set; } + public long MaxLatencyMs { get; set; } + public double AverageLatencyMs { get; set; } + public double MedianLatencyMs { get; set; } + public double P50LatencyMs { get; set; } + public double P95LatencyMs { get; set; } + public double P99LatencyMs { get; set; } + public long UptimeMs { get; set; } + public double EventsPerSecond { get; set; } + public double BytesPerSecond { get; set; } + public double AverageEventSizeBytes { get; set; } + + /// + /// Returns formatted report + /// + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine("===== LOGGING METRICS ====="); + sb.AppendLine($"Total Events: {TotalEvents:N0}"); + sb.AppendLine($"Total Bytes: {TotalBytes:N0}"); + sb.AppendLine($"Average Event Size: {AverageEventSizeBytes:F2} bytes"); + sb.AppendLine($"Events/Second: {EventsPerSecond:F2}"); + sb.AppendLine($"Bytes/Second: {BytesPerSecond:F2}"); + sb.AppendLine($"Total Exceptions: {TotalExceptions:N0}"); + sb.AppendLine($"Dropped Events: {DroppedCount:N0}"); + sb.AppendLine($"Dropped Exceptions: {DroppedExceptions:N0}"); + sb.AppendLine(); + + if (LevelCounts.Count > 0) + { + sb.AppendLine("Events by Level:"); + foreach (var level in new[] { "Trace", "Debug", "Information", "Warning", "Error", "Critical" }) + { + if (LevelCounts.TryGetValue(level, out var count)) + { + sb.AppendLine($" {level}: {count:N0}"); + } + } + sb.AppendLine(); + } + + sb.AppendLine("Latency (ms):"); + sb.AppendLine($" Min: {MinLatencyMs}"); + sb.AppendLine($" P50: {P50LatencyMs:F3}"); + sb.AppendLine($" P95: {P95LatencyMs:F3}"); + sb.AppendLine($" P99: {P99LatencyMs:F3}"); + sb.AppendLine($" Avg: {AverageLatencyMs:F3}"); + sb.AppendLine($" Max: {MaxLatencyMs}"); + sb.AppendLine(); + + if (CategoryCounts.Count > 0) + { + sb.AppendLine("Top Categories:"); + foreach (var kvp in CategoryCounts.Take(5)) + { + sb.AppendLine($" {kvp.Key}: {kvp.Value:N0}"); + } + sb.AppendLine(); + } + + sb.AppendLine($"Uptime: {TimeSpan.FromMilliseconds(UptimeMs):hh\\:mm\\:ss}"); + sb.AppendLine("==========================="); + return sb.ToString(); + } +} + +/// +/// Logger introspection API stub for inspecting logger configuration +/// +public sealed class LoggerIntrospection +{ + private readonly List _flows = new(); + private readonly List _boosters = new(); + + public LoggerIntrospection(object logger) + { + // Logger integration to be provided when IFlowLogger is available + } + + /// + /// Adds a flow for tracking + /// + public void RegisterFlow(string name) + { + _flows.Add(name); + } + + /// + /// Adds a booster for tracking + /// + public void RegisterBooster(string name) + { + _boosters.Add(name); + } + + /// + /// Gets configured flows + /// + public IReadOnlyList GetFlows() => _flows.AsReadOnly(); + + /// + /// Gets configured boosters + /// + public IReadOnlyList GetBoosters() => _boosters.AsReadOnly(); + + /// + /// Generates a report of logger configuration + /// + public string GenerateReport() + { + var sb = new StringBuilder(); + sb.AppendLine("===== LOGGER CONFIGURATION ====="); + sb.AppendLine($"Total Flows: {_flows.Count}"); + foreach (var flow in _flows) + { + sb.AppendLine($" - {flow}"); + } + + sb.AppendLine(); + sb.AppendLine($"Total Boosters: {_boosters.Count}"); + foreach (var booster in _boosters) + { + sb.AppendLine($" - {booster}"); + } + + sb.AppendLine("================================"); + return sb.ToString(); + } +} + +/// +/// Performance analyzer for logging +/// +public sealed class LoggingPerformanceAnalyzer +{ + private readonly Dictionary _operations = new(); + private readonly object _lock = new(); + + /// + /// Measures execution time of a function + /// + public T MeasureOperation(string operationName, Func operation) + { + var sw = Stopwatch.StartNew(); + try + { + return operation(); + } + finally + { + sw.Stop(); + RecordOperation(operationName, sw.ElapsedMilliseconds); + } + } + + /// + /// Measures execution time of an action + /// + public void MeasureOperation(string operationName, Action operation) + { + var sw = Stopwatch.StartNew(); + try + { + operation(); + } + finally + { + sw.Stop(); + RecordOperation(operationName, sw.ElapsedMilliseconds); + } + } + + private void RecordOperation(string name, long ms) + { + lock (_lock) + { + if (!_operations.TryGetValue(name, out var metrics)) + { + metrics = new OperationMetrics { OperationName = name }; + _operations[name] = metrics; + } + + metrics.Count++; + metrics.TotalTimeMs += ms; + metrics.MinTimeMs = Math.Min(metrics.MinTimeMs, ms); + metrics.MaxTimeMs = Math.Max(metrics.MaxTimeMs, ms); + } + } + + /// + /// Gets all recorded operations + /// + public IReadOnlyCollection GetOperations() + { + lock (_lock) + { + return _operations.Values.ToList().AsReadOnly(); + } + } + + /// + /// Generates performance report + /// + public string GenerateReport() + { + lock (_lock) + { + var sb = new StringBuilder(); + sb.AppendLine("===== PERFORMANCE ANALYSIS ====="); + + foreach (var op in _operations.Values.OrderByDescending(o => o.TotalTimeMs)) + { + double avgTime = op.TotalTimeMs / (double)op.Count; + sb.AppendLine($"{op.OperationName}:"); + sb.AppendLine($" Count: {op.Count}"); + sb.AppendLine($" Total: {op.TotalTimeMs}ms"); + sb.AppendLine($" Average: {avgTime:F3}ms"); + sb.AppendLine($" Min: {op.MinTimeMs}ms"); + sb.AppendLine($" Max: {op.MaxTimeMs}ms"); + } + + sb.AppendLine("================================"); + return sb.ToString(); + } + } + + public void Reset() + { + lock (_lock) + { + _operations.Clear(); + } + } +} + +/// +/// Operation metrics +/// +public sealed class OperationMetrics +{ + public string OperationName { get; set; } = ""; + public long Count { get; set; } + public long TotalTimeMs { get; set; } + public long MinTimeMs { get; set; } = long.MaxValue; + public long MaxTimeMs { get; set; } + + public double AverageTimeMs => TotalTimeMs / (double)(Count > 0 ? Count : 1); +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Analytics/PerFlowStatistics.cs b/EonaCat.LogStack/EonaCatLoggerCore/Analytics/PerFlowStatistics.cs new file mode 100644 index 0000000..77c93ef --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Analytics/PerFlowStatistics.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; + +namespace EonaCat.LogStack.Analytics; + +// 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. + +/// +/// Tracks statistics for an individual logging flow +/// +public sealed class PerFlowStatistics +{ + private readonly object _lock = new object(); + private long _eventsProcessed; + private long _eventsFailed; + private long _bytesWritten; + private long _eventsDropped; + private readonly List _latencies = new(); + private long _minLatencyMs = long.MaxValue; + private long _maxLatencyMs; + private readonly Stopwatch _uptime = Stopwatch.StartNew(); + + public string FlowName { get; set; } + public string FlowType { get; set; } + + public PerFlowStatistics(string flowName, string flowType) + { + FlowName = flowName ?? "Unknown"; + FlowType = flowType ?? "Unknown"; + } + + /// + /// Records a successfully processed event + /// + public void RecordSuccess(long latencyMs, long bytes) + { + lock (_lock) + { + _eventsProcessed++; + _bytesWritten += bytes; + _latencies.Add(latencyMs); + + if (latencyMs < _minLatencyMs) + { + _minLatencyMs = latencyMs; + } + + if (latencyMs > _maxLatencyMs) + { + _maxLatencyMs = latencyMs; + } + + if (_latencies.Count > 1000) + { + _latencies.RemoveAt(0); + } + } + } + + /// + /// Records a failed event + /// + public void RecordFailure(long latencyMs = 0) + { + Interlocked.Increment(ref _eventsFailed); + if (latencyMs > 0) + { + lock (_lock) + { + _latencies.Add(latencyMs); + if (latencyMs < _minLatencyMs) + { + _minLatencyMs = latencyMs; + } + + if (latencyMs > _maxLatencyMs) + { + _maxLatencyMs = latencyMs; + } + } + } + } + + /// + /// Records dropped events + /// + public void RecordDropped(long count = 1) + { + Interlocked.Add(ref _eventsDropped, count); + } + + /// + /// Gets a snapshot of current statistics + /// + public FlowStatisticsSnapshot GetSnapshot() + { + lock (_lock) + { + double avgLatency = _latencies.Count > 0 ? _latencies.Average() : 0; + double p95 = GetPercentile(_latencies, 95); + double p99 = GetPercentile(_latencies, 99); + + var totalAttempts = _eventsProcessed + _eventsFailed; + var successRate = totalAttempts > 0 ? (_eventsProcessed * 100.0) / totalAttempts : 100; + + return new FlowStatisticsSnapshot + { + FlowName = FlowName, + FlowType = FlowType, + EventsProcessed = _eventsProcessed, + EventsFailed = _eventsFailed, + EventsDropped = _eventsDropped, + BytesWritten = _bytesWritten, + SuccessRate = successRate, + MinLatencyMs = _minLatencyMs == long.MaxValue ? 0 : _minLatencyMs, + MaxLatencyMs = _maxLatencyMs, + AverageLatencyMs = avgLatency, + P95LatencyMs = p95, + P99LatencyMs = p99, + UptimeMs = _uptime.ElapsedMilliseconds, + EventsPerSecond = _uptime.ElapsedMilliseconds > 0 + ? _eventsProcessed * 1000.0 / _uptime.ElapsedMilliseconds + : 0, + BytesPerSecond = _uptime.ElapsedMilliseconds > 0 + ? _bytesWritten * 1000.0 / _uptime.ElapsedMilliseconds + : 0 + }; + } + } + + private double GetPercentile(List sortedValues, int percentile) + { + if (sortedValues.Count == 0) + { + return 0; + } + + var sorted = sortedValues.OrderBy(v => v).ToList(); + int index = (int)((percentile / 100.0) * (sorted.Count - 1)); + index = Math.Max(0, Math.Min(index, sorted.Count - 1)); + return sorted[index]; + } + + /// + /// Resets all statistics + /// + public void Reset() + { + lock (_lock) + { + _eventsProcessed = 0; + _eventsFailed = 0; + _eventsDropped = 0; + _bytesWritten = 0; + _latencies.Clear(); + _minLatencyMs = long.MaxValue; + _maxLatencyMs = 0; + _uptime.Restart(); + } + } +} + +/// +/// Snapshot of flow statistics at a point in time +/// +public sealed class FlowStatisticsSnapshot +{ + public string FlowName { get; set; } + public string FlowType { get; set; } + public long EventsProcessed { get; set; } + public long EventsFailed { get; set; } + public long EventsDropped { get; set; } + public long BytesWritten { get; set; } + public double SuccessRate { get; set; } + public long MinLatencyMs { get; set; } + public long MaxLatencyMs { get; set; } + public double AverageLatencyMs { get; set; } + public double P95LatencyMs { get; set; } + public double P99LatencyMs { get; set; } + public long UptimeMs { get; set; } + public double EventsPerSecond { get; set; } + public double BytesPerSecond { get; set; } + + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine($"===== FLOW: {FlowName} ({FlowType}) ====="); + sb.AppendLine($"Events Processed: {EventsProcessed:N0}"); + sb.AppendLine($"Events Failed: {EventsFailed:N0}"); + sb.AppendLine($"Events Dropped: {EventsDropped:N0}"); + sb.AppendLine($"Success Rate: {SuccessRate:F2}%"); + sb.AppendLine($"Bytes Written: {BytesWritten:N0}"); + sb.AppendLine($"Events/Second: {EventsPerSecond:F2}"); + sb.AppendLine($"Bytes/Second: {BytesPerSecond:F2}"); + sb.AppendLine(); + sb.AppendLine("Latency (ms):"); + sb.AppendLine($" Min: {MinLatencyMs}"); + sb.AppendLine($" Avg: {AverageLatencyMs:F3}"); + sb.AppendLine($" P95: {P95LatencyMs:F3}"); + sb.AppendLine($" P99: {P99LatencyMs:F3}"); + sb.AppendLine($" Max: {MaxLatencyMs}"); + sb.AppendLine(); + sb.AppendLine($"Uptime: {TimeSpan.FromMilliseconds(UptimeMs):hh\\:mm\\:ss}"); + sb.AppendLine("=================================="); + return sb.ToString(); + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Batching/BatchProcessor.cs b/EonaCat.LogStack/EonaCatLoggerCore/Batching/BatchProcessor.cs new file mode 100644 index 0000000..2deb31d --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Batching/BatchProcessor.cs @@ -0,0 +1,345 @@ +using EonaCat.LogStack.Core; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace EonaCat.LogStack.Batching; + +// 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. + +/// +/// Manages batch configuration and sizing strategies for optimized flow processing +/// +public class BatchConfig +{ + public int InitialBatchSize { get; set; } = 100; + public int MaxBatchSize { get; set; } = 1000; + public int MinBatchSize { get; set; } = 10; + + /// + /// Maximum time to wait before flushing a partial batch (milliseconds) + /// + public int MaxBatchDelayMs { get; set; } = 1000; + + /// + /// Threshold for memory usage before activating backpressure (bytes) + /// + public long BackpressureThresholdBytes { get; set; } = 100 * 1024 * 1024; // 100MB + + /// + /// Enable adaptive batch sizing based on throughput and memory + /// + public bool EnableAdaptiveSizing { get; set; } = true; + + /// + /// Enable backpressure handling + /// + public bool EnableBackpressure { get; set; } = true; +} + +/// +/// Adaptive batch processor that dynamically adjusts batch sizes based on system conditions +/// +public sealed class AdaptiveBatchProcessor : IDisposable +{ + private readonly BatchConfig _config; + private int _currentBatchSize; + private long _currentMemoryUsage; + private DateTime _lastSizeAdjustment = DateTime.UtcNow; + private readonly object _lock = new object(); + private bool _isDisposed; + + public AdaptiveBatchProcessor(BatchConfig? config = null) + { + _config = config ?? new BatchConfig(); + _currentBatchSize = _config.InitialBatchSize; + } + + /// + /// Gets the optimal batch size based on current system conditions + /// + public int GetOptimalBatchSize() + { + if (!_config.EnableAdaptiveSizing) + { + return _currentBatchSize; + } + + lock (_lock) + { + // Adjust batch size every 10 seconds based on memory pressure + if ((DateTime.UtcNow - _lastSizeAdjustment).TotalSeconds < 10) + { + return _currentBatchSize; + } + + _lastSizeAdjustment = DateTime.UtcNow; + + long availableMemory = GC.GetTotalMemory(false); + + if (availableMemory > _config.BackpressureThresholdBytes) + { + // Reduce batch size under memory pressure + _currentBatchSize = Math.Max( + _config.MinBatchSize, + (int)(_currentBatchSize * 0.9)); + } + else if (availableMemory < _config.BackpressureThresholdBytes / 2) + { + // Increase batch size when memory is available + _currentBatchSize = Math.Min( + _config.MaxBatchSize, + (int)(_currentBatchSize * 1.1)); + } + + return _currentBatchSize; + } + } + + /// + /// Records memory usage of processed events + /// + public void RecordMemoryUsage(long bytes) + { + lock (_lock) + { + _currentMemoryUsage = bytes; + } + } + + /// + /// Checks if backpressure should be applied + /// + public bool ShouldApplyBackpressure() + { + if (!_config.EnableBackpressure) + { + return false; + } + + lock (_lock) + { + return _currentMemoryUsage > _config.BackpressureThresholdBytes; + } + } + + /// + /// Gets the current memory usage + /// + public long GetMemoryUsage() + { + lock (_lock) + { + return _currentMemoryUsage; + } + } + + /// + /// Resets memory tracking + /// + public void ResetMemoryUsage() + { + lock (_lock) + { + _currentMemoryUsage = 0; + } + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + } +} + +/// +/// Backpressure handler that controls flow based on system resources +/// +public sealed class BackpressureHandler : IDisposable +{ + private readonly long _thresholdBytes; + private readonly int _maxWaitMs; + private bool _isDisposed; + + public BackpressureHandler(long thresholdBytes = 100 * 1024 * 1024, int maxWaitMs = 5000) + { + _thresholdBytes = thresholdBytes; + _maxWaitMs = maxWaitMs; + } + + /// + /// Applies backpressure by waiting if memory usage exceeds threshold + /// + public async Task ApplyBackpressureAsync(CancellationToken cancellationToken = default) + { + if (_isDisposed) + { + return; + } + + long memoryUsage = GC.GetTotalMemory(false); + + if (memoryUsage <= _thresholdBytes) + { + return; + } + + // Wait for memory to be released + var startTime = DateTime.UtcNow; + while (GC.GetTotalMemory(false) > _thresholdBytes) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + if ((DateTime.UtcNow - startTime).TotalMilliseconds > _maxWaitMs) + { + break; + } + + // Collect garbage and wait + GC.Collect(GC.MaxGeneration, GCCollectionMode.Optimized, false); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Gets current memory pressure ratio (0.0 = no pressure, 1.0+ = critical) + /// + public double GetMemoryPressure() + { + long memoryUsage = GC.GetTotalMemory(false); + return (double)memoryUsage / _thresholdBytes; + } + + /// + /// Checks if backpressure is currently active + /// + public bool IsActive => GetMemoryPressure() > 0.8; + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + } +} + +/// +/// Generic batch accumulator for collecting items before processing +/// +public sealed class BatchAccumulator : IDisposable +{ + private readonly List _items; + private readonly int _maxSize; + private readonly int _maxWaitMs; + private DateTime _lastFlush; + private readonly object _lock = new object(); + private bool _isDisposed; + + public BatchAccumulator(int maxSize = 100, int maxWaitMs = 1000) + { + _maxSize = maxSize; + _maxWaitMs = maxWaitMs; + _items = new List(maxSize); + _lastFlush = DateTime.UtcNow; + } + + /// + /// Adds an item to the batch + /// + public bool TryAdd(T item) + { + lock (_lock) + { + if (_isDisposed) + { + return false; + } + + _items.Add(item); + return true; + } + } + + /// + /// Checks if the batch should be flushed + /// + public bool ShouldFlush() + { + lock (_lock) + { + if (_items.Count == 0) + { + return false; + } + + // Flush if full + if (_items.Count >= _maxSize) + { + return true; + } + + // Flush if timeout exceeded + var elapsed = (DateTime.UtcNow - _lastFlush).TotalMilliseconds; + return elapsed > _maxWaitMs; + } + } + + /// + /// Gets the current batch and resets + /// + public T[] GetAndReset() + { + lock (_lock) + { + if (_items.Count == 0) + { + return Array.Empty(); + } + + var batch = _items.ToArray(); + _items.Clear(); + _lastFlush = DateTime.UtcNow; + return batch; + } + } + + /// + /// Gets the count of items in the current batch + /// + public int Count + { + get + { + lock (_lock) + { + return _items.Count; + } + } + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + lock (_lock) + { + _isDisposed = true; + _items.Clear(); + } + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Boosters/AdvancedBoosters.cs b/EonaCat.LogStack/EonaCatLoggerCore/Boosters/AdvancedBoosters.cs new file mode 100644 index 0000000..7e7d73f --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Boosters/AdvancedBoosters.cs @@ -0,0 +1,287 @@ +using EonaCat.LogStack.Core; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; + +namespace EonaCat.LogStack.Boosters; + +// 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. + +/// +/// Performance monitoring booster that adds timing and resource metrics +/// +public sealed class PerformanceBooster : BoosterBase +{ + private readonly long _startTime = Stopwatch.GetTimestamp(); + private long _totalMemorySnapshot; + + public PerformanceBooster() : base("Performance") { } + + public override bool Boost(ref LogEventBuilder builder) + { + try + { + var currentProcess = Process.GetCurrentProcess(); + var elapsed = (long)((DateTime.UtcNow.Ticks - _startTime) / 10000.0); + + builder.WithProperty("uptime_ms", elapsed); + builder.WithProperty("working_set_mb", currentProcess.WorkingSet64 / (1024 * 1024)); + builder.WithProperty("virtual_memory_mb", currentProcess.VirtualMemorySize64 / (1024 * 1024)); + builder.WithProperty("gc_total_memory", GC.GetTotalMemory(false) / 1024); + builder.WithProperty("thread_count", Process.GetCurrentProcess().Threads.Count); + + return true; + } + catch + { + return true; // Don't filter on error + } + } +} + +/// +/// Distributed tracing booster that extracts and propagates trace context +/// +public sealed class DistributedTracingBooster : BoosterBase +{ + private readonly AsyncLocal _correlationId = new AsyncLocal(); + private readonly AsyncLocal _parentSpanId = new AsyncLocal(); + private int _spanIdCounter; + + public DistributedTracingBooster() : base("DistributedTracing") { } + + /// + /// Sets the correlation ID for the current async context + /// + public void SetCorrelationId(string correlationId) + { + _correlationId.Value = correlationId; + } + + /// + /// Gets the current correlation ID + /// + public string? GetCorrelationId() + { + return _correlationId.Value; + } + + public override bool Boost(ref LogEventBuilder builder) + { + var correlationId = _correlationId.Value; + if (string.IsNullOrEmpty(correlationId)) + { + correlationId = Guid.NewGuid().ToString("N"); + _correlationId.Value = correlationId; + } + + builder.WithProperty("correlation_id", correlationId); + + // Generate span ID + int spanId = Interlocked.Increment(ref _spanIdCounter); + builder.WithProperty("span_id", spanId.ToString("X8")); + + return true; + } +} + +/// +/// Sampling booster that probabilistically filters log events +/// +public sealed class SamplingBooster : BoosterBase +{ + private readonly double _samplingRate; + private readonly Random _random; + private long _totalLogged; + private long _totalSampled; + + public SamplingBooster(double samplingRate = 0.1) : base("Sampling") + { + if (samplingRate < 0.0 || samplingRate > 1.0) + { + throw new ArgumentOutOfRangeException(nameof(samplingRate), "Must be between 0.0 and 1.0"); + } + + _samplingRate = samplingRate; + _random = new Random(); + } + + /// + /// Always log critical and error events, sample the rest + /// + public override bool Boost(ref LogEventBuilder builder) + { + _totalLogged++; + + // Sample based on configured rate + bool shouldLog = _random.NextDouble() < _samplingRate; + if (shouldLog) + { + _totalSampled++; + } + + return shouldLog; + } + + /// + /// Gets sampling statistics + /// + public (long total, long sampled, double rate) GetStatistics() + { + return (_totalLogged, _totalSampled, _totalLogged > 0 ? (double)_totalSampled / _totalLogged : 0.0); + } +} + +/// +/// Property filter booster that includes/excludes events based on properties +/// +public sealed class PropertyFilterBooster : BoosterBase +{ + private readonly Func? _includeFilter; + private readonly Func? _excludeFilter; + + public PropertyFilterBooster( + Func? includeFilter = null, + Func? excludeFilter = null) + : base("PropertyFilter") + { + _includeFilter = includeFilter; + _excludeFilter = excludeFilter; + } + + public override bool Boost(ref LogEventBuilder builder) + { + // If include filter is specified and returns false, skip + if (_includeFilter != null && !_includeFilter(builder)) + { + return false; + } + + // If exclude filter is specified and returns true, skip + if (_excludeFilter != null && _excludeFilter(builder)) + { + return false; + } + + return true; + } +} + +/// +/// Rate limiting booster that throttles log events +/// +public sealed class RateLimitingBooster : BoosterBase +{ + private readonly int _maxEventsPerSecond; + private DateTime _lastResetTime; + private int _eventCount; + private readonly object _lock = new object(); + + public RateLimitingBooster(int maxEventsPerSecond = 1000) : base("RateLimiting") + { + if (maxEventsPerSecond <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxEventsPerSecond)); + } + + _maxEventsPerSecond = maxEventsPerSecond; + _lastResetTime = DateTime.UtcNow; + } + + public override bool Boost(ref LogEventBuilder builder) + { + lock (_lock) + { + var now = DateTime.UtcNow; + var elapsed = (now - _lastResetTime).TotalSeconds; + + if (elapsed >= 1.0) + { + _lastResetTime = now; + _eventCount = 0; + } + + _eventCount++; + return _eventCount <= _maxEventsPerSecond; + } + } + + /// + /// Gets current rate limiting statistics + /// + public (int current, int limit) GetStatistics() + { + lock (_lock) + { + return (_eventCount, _maxEventsPerSecond); + } + } +} + +/// +/// Context enrichment booster that adds contextual information +/// +public sealed class ContextEnrichmentBooster : BoosterBase +{ + private readonly Func _enricher; + + public ContextEnrichmentBooster(Func enricher) + : base("ContextEnrichment") + { + _enricher = enricher ?? throw new ArgumentNullException(nameof(enricher)); + } + + public override bool Boost(ref LogEventBuilder builder) + { + try + { + builder = _enricher(builder); + return true; + } + catch + { + return true; // Don't filter on enrichment error + } + } +} + +/// +/// Deduplication booster that filters duplicate messages within a time window +/// +public sealed class DeduplicationBooster : BoosterBase +{ + private readonly TimeSpan _window; + private readonly Dictionary _seenMessages = new(); + private readonly object _lock = new object(); + + public DeduplicationBooster(TimeSpan? window = null) : base("Deduplication") + { + _window = window ?? TimeSpan.FromSeconds(10); + } + + public override bool Boost(ref LogEventBuilder builder) + { + lock (_lock) + { + var now = DateTime.UtcNow; + + // Clean old entries + var oldEntries = _seenMessages + .Where(kvp => (now - kvp.Value) > _window) + .Select(kvp => kvp.Key) + .ToList(); + + foreach (var key in oldEntries) + { + _seenMessages.Remove(key); + } + + // For now, don't deduplicate as we don't have access to message in ref struct + // This can be enhanced when builder provides access + return true; + } + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Boosters/CallerInfoBooster.cs b/EonaCat.LogStack/EonaCatLoggerCore/Boosters/CallerInfoBooster.cs new file mode 100644 index 0000000..e4e6d35 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Boosters/CallerInfoBooster.cs @@ -0,0 +1,46 @@ +using EonaCat.LogStack.Core; +using System.IO; +using System.Runtime.CompilerServices; + +namespace EonaCat.LogStack.Boosters; + +// 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. + +/// +/// Automatically captures caller file, line number, and member name via +/// compiler-generated attributes — zero overhead at the call site. +/// +/// Adds properties: +/// caller.member — method / property name +/// caller.file — source file name (not full path, for privacy) +/// caller.line — line number +/// +/// Usage: add to your LogBuilder with .BoostWithCallerInfo(). +/// +/// Note: because this booster uses compile-time attributes it captures the +/// logger helper method rather than the end-user call site when the log call +/// is made through wrapper methods. For precise call-site capture, call +/// directly from the call site. +/// +public sealed class CallerInfoBooster : BoosterBase +{ + public CallerInfoBooster() : base("CallerInfo") { } + + public override bool Boost(ref LogEventBuilder builder) => true; // no-op in generic path + + /// + /// Enriches a builder with the actual call-site information. + /// Call this from your logging helper method so the compiler fills in the arguments. + /// + public static void Capture( + ref LogEventBuilder builder, + [CallerMemberName] string member = "", + [CallerFilePath] string file = "", + [CallerLineNumber] int line = 0) + { + builder.WithProperty("caller.member", member); + builder.WithProperty("caller.file", Path.GetFileName(file)); + builder.WithProperty("caller.line", line); + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Boosters/RequestContextBooster.cs b/EonaCat.LogStack/EonaCatLoggerCore/Boosters/RequestContextBooster.cs new file mode 100644 index 0000000..69dd984 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Boosters/RequestContextBooster.cs @@ -0,0 +1,192 @@ +using EonaCat.LogStack.Core; +using System; +using System.Runtime.CompilerServices; + +// 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. + +namespace EonaCat.LogStack.Boosters +{ + /// + /// Booster that extracts context from HTTP requests. + /// Requires IHttpContextAccessor to be registered in DI. + /// + public sealed class RequestContextBooster : BoosterBase + { + private readonly object? _httpContextAccessor; + + public RequestContextBooster(object? httpContextAccessor = null) : base("RequestContext") + { + _httpContextAccessor = httpContextAccessor; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override bool Boost(ref LogEventBuilder builder) + { + try + { + var httpContext = GetHttpContext(); + if (httpContext == null) + { + return true; + } + + var request = GetRequest(httpContext); + if (request == null) + { + return true; + } + + // Add HTTP method + var method = GetRequestMethod(request); + if (!string.IsNullOrEmpty(method)) + { + builder.WithProperty("HttpMethod", method); + } + + // Add request path + var path = GetRequestPath(request); + if (!string.IsNullOrEmpty(path)) + { + builder.WithProperty("RequestPath", path); + } + + // Add request host + var host = GetRequestHost(request); + if (!string.IsNullOrEmpty(host)) + { + builder.WithProperty("RequestHost", host); + } + + // Add user if available + var user = GetUser(httpContext); + if (!string.IsNullOrEmpty(user)) + { + builder.WithProperty("User", user); + } + + // Add trace identifier + var traceId = GetTraceId(httpContext); + if (!string.IsNullOrEmpty(traceId)) + { + builder.WithProperty("TraceId", traceId); + } + } + catch + { + // Silently fail - don't let booster errors crash logging + } + return true; + } + + private object? GetHttpContext() + { + try + { + if (_httpContextAccessor == null) + { + return null; + } + + var prop = _httpContextAccessor.GetType().GetProperty("HttpContext"); + return prop?.GetValue(_httpContextAccessor); + } + catch + { + return null; + } + } + + private object? GetRequest(object httpContext) + { + try + { + var prop = httpContext.GetType().GetProperty("Request"); + return prop?.GetValue(httpContext); + } + catch + { + return null; + } + } + + private string? GetRequestMethod(object request) + { + try + { + var prop = request.GetType().GetProperty("Method"); + return prop?.GetValue(request)?.ToString(); + } + catch + { + return null; + } + } + + private string? GetRequestPath(object request) + { + try + { + var prop = request.GetType().GetProperty("Path"); + return prop?.GetValue(request)?.ToString(); + } + catch + { + return null; + } + } + + private string? GetRequestHost(object request) + { + try + { + var prop = request.GetType().GetProperty("Host"); + return prop?.GetValue(request)?.ToString(); + } + catch + { + return null; + } + } + + private string? GetUser(object httpContext) + { + try + { + var prop = httpContext.GetType().GetProperty("User"); + var user = prop?.GetValue(httpContext); + if (user == null) + { + return null; + } + + var identityProp = user.GetType().GetProperty("Identity"); + var identity = identityProp?.GetValue(user); + if (identity == null) + { + return null; + } + + var nameProp = identity.GetType().GetProperty("Name"); + return nameProp?.GetValue(identity)?.ToString(); + } + catch + { + return null; + } + } + + private string? GetTraceId(object httpContext) + { + try + { + var prop = httpContext.GetType().GetProperty("TraceIdentifier"); + return prop?.GetValue(httpContext)?.ToString(); + } + catch + { + return null; + } + } + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Configuration/LoggerConfiguration.cs b/EonaCat.LogStack/EonaCatLoggerCore/Configuration/LoggerConfiguration.cs new file mode 100644 index 0000000..f48efa7 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Configuration/LoggerConfiguration.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using EonaCat.LogStack.Filtering; +using EonaCat.LogStack.Output.Formatters; +using EonaCat.LogStack.Output.Layouts; +using EonaCat.LogStack.Structured; +using EonaCat.LogStack.Templates; + +namespace EonaCat.LogStack.Configuration; + +// 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. + +/// +/// Advanced logger configuration settings +/// +public sealed class LoggerConfiguration +{ + private readonly Dictionary _config = new(); + + /// + /// Sets a configuration value + /// + public LoggerConfiguration Set(string key, object value) + { + _config[key] = value; + return this; + } + + /// + /// Gets a configuration value + /// + public T? Get(string key, T? defaultValue = default) + { + if (_config.TryGetValue(key, out var value)) + { + return (T?)value; + } + return defaultValue; + } + + /// + /// Enables buffering with specified size + /// + public LoggerConfiguration WithBuffering(int bufferSize = 1000) + { + Set("BufferSize", bufferSize); + return this; + } + + /// + /// Enables batching with specified batch size + /// + public LoggerConfiguration WithBatching(int batchSize = 100) + { + Set("BatchSize", batchSize); + return this; + } + + /// + /// Sets the maximum pool size for object reuse + /// + public LoggerConfiguration WithPooling(int maxPoolSize = 10000) + { + Set("MaxPoolSize", maxPoolSize); + return this; + } + + /// + /// Enables diagnostics collection + /// + public LoggerConfiguration WithDiagnostics(bool enabled = true) + { + Set("DiagnosticsEnabled", enabled); + return this; + } + + /// + /// Enables context propagation using AsyncLocal + /// + public LoggerConfiguration WithContextPropagation(bool enabled = true) + { + Set("ContextPropagationEnabled", enabled); + return this; + } + + /// + /// Sets sampling rate (0-1, where 1 = log all events) + /// + public LoggerConfiguration WithSampling(double rate) + { + var clampedRate = rate < 0.0 ? 0.0 : (rate > 1.0 ? 1.0 : rate); + Set("SamplingRate", clampedRate); + return this; + } + + /// + /// Sets rate limit (events per second) + /// + public LoggerConfiguration WithRateLimit(int eventsPerSecond) + { + Set("RateLimit", eventsPerSecond); + return this; + } + + public Dictionary Build() + { + return new Dictionary(_config); + } +} + +/// +/// Predefined logger presets +/// +public enum LoggerPreset +{ + Development, + Production, + Diagnostics, + Performance +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Destructuring/TypeDestructor.cs b/EonaCat.LogStack/EonaCatLoggerCore/Destructuring/TypeDestructor.cs new file mode 100644 index 0000000..bd99dc5 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Destructuring/TypeDestructor.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; + +namespace EonaCat.LogStack.Destructuring; + +// 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. + +/// +/// Renders complex types to JSON-like format without external dependencies +/// +public sealed class TypeDestructor +{ + private readonly HashSet _visited = new(); + private const int MaxDepth = 10; + private const int MaxStringLength = 1000; + private const int MaxCollectionItems = 100; + + /// + /// Destructures an object to a string representation (JSON-like format) + /// + public string Destructure(object? value) + { + _visited.Clear(); + var sb = new StringBuilder(); + DestructureValue(value, sb, 0); + return sb.ToString(); + } + + private void DestructureValue(object? value, StringBuilder sb, int depth) + { + if (depth > MaxDepth) + { + sb.Append("\"...\""); + return; + } + + if (value == null) + { + sb.Append("null"); + return; + } + + var type = value.GetType(); + + // Handle primitives + if (type == typeof(string)) + { + AppendString(sb, (string)value); + return; + } + + if (type == typeof(bool)) + { + sb.Append(((bool)value) ? "true" : "false"); + return; + } + + if (type == typeof(byte) || type == typeof(sbyte) || + type == typeof(short) || type == typeof(ushort) || + type == typeof(int) || type == typeof(uint) || + type == typeof(long) || type == typeof(ulong)) + { + sb.Append(value); + return; + } + + if (type == typeof(float) || type == typeof(double) || type == typeof(decimal)) + { + sb.Append(((IFormattable)value).ToString(null, CultureInfo.InvariantCulture)); + return; + } + + if (type == typeof(DateTime)) + { + sb.Append("\"").Append(((DateTime)value).ToIso8601String()).Append("\""); + return; + } + + if (type == typeof(DateTimeOffset)) + { + sb.Append("\"").Append(((DateTimeOffset)value).ToString("O")).Append("\""); + return; + } + + if (type == typeof(TimeSpan)) + { + sb.Append("\"").Append(((TimeSpan)value).ToString()).Append("\""); + return; + } + + if (type == typeof(Guid)) + { + sb.Append("\"").Append(((Guid)value).ToString()).Append("\""); + return; + } + + // Check for circular references + if (_visited.Contains(value)) + { + sb.Append("\"\""); + return; + } + + _visited.Add(value); + + // Handle collections + if (value is IEnumerable enumerable && !(value is string)) + { + DestructureCollection(enumerable, sb, depth); + return; + } + + // Handle objects + DestructureObject(value, sb, depth); + } + + private void DestructureCollection(IEnumerable enumerable, StringBuilder sb, int depth) + { + sb.Append("["); + int count = 0; + + foreach (var item in enumerable) + { + if (count > 0) + { + sb.Append(","); + } + + if (count >= MaxCollectionItems) + { + sb.Append("\"...\""); + break; + } + + DestructureValue(item, sb, depth + 1); + count++; + } + + sb.Append("]"); + } + + private void DestructureObject(object value, StringBuilder sb, int depth) + { + sb.Append("{"); + + var properties = value.GetType().GetProperties(); + int count = 0; + + foreach (var prop in properties) + { + if (count > 0) + { + sb.Append(","); + } + + sb.Append("\"").Append(EscapeJsonString(prop.Name)).Append("\":"); + + try + { + var propValue = prop.GetValue(value); + DestructureValue(propValue, sb, depth + 1); + } + catch + { + sb.Append("\"\""); + } + + count++; + + if (count >= MaxCollectionItems) + { + sb.Append(",\"...\":null"); + break; + } + } + + sb.Append("}"); + } + + private void AppendString(StringBuilder sb, string value) + { + sb.Append("\""); + + int length = Math.Min(value.Length, MaxStringLength); + for (int i = 0; i < length; i++) + { + char c = value[i]; + sb.Append(EscapeChar(c)); + } + + if (value.Length > MaxStringLength) + { + sb.Append("...\""); + } + else + { + sb.Append("\""); + } + } + + private static string EscapeJsonString(string value) + { + var sb = new StringBuilder(); + foreach (char c in value) + { + sb.Append(EscapeChar(c)); + } + return sb.ToString(); + } + + private static string EscapeChar(char c) + { + return c switch + { + '"' => "\\\"", + '\\' => "\\\\", + '\b' => "\\b", + '\f' => "\\f", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + _ => c < 32 ? $"\\u{(int)c:X4}" : c.ToString() + }; + } +} + +/// +/// Specialized destructors for common types +/// +public sealed class SpecializedDestructor +{ + /// + /// Destructures an exception with full context + /// + public static string DestructureException(Exception ex) + { + var sb = new StringBuilder(); + sb.Append("{"); + sb.Append("\"Type\":\"").Append(EscapeJsonString(ex.GetType().Name)).Append("\","); + sb.Append("\"Message\":\"").Append(EscapeJsonString(ex.Message)).Append("\","); + sb.Append("\"StackTrace\":\"").Append(EscapeJsonString(ex.StackTrace ?? "")).Append("\""); + + if (ex.InnerException != null) + { + sb.Append(",\"InnerException\":").Append(DestructureException(ex.InnerException)); + } + + sb.Append("}"); + return sb.ToString(); + } + + /// + /// Destructures a key-value pair collection + /// + public static string DestructureKeyValuePairs( + IEnumerable> pairs) + { + var sb = new StringBuilder(); + sb.Append("{"); + + int count = 0; + foreach (var kvp in pairs) + { + if (count > 0) + { + sb.Append(","); + } + + sb.Append("\"").Append(EscapeJsonString(kvp.Key?.ToString() ?? "null")).Append("\":"); + sb.Append("\"").Append(EscapeJsonString(kvp.Value?.ToString() ?? "null")).Append("\""); + + count++; + if (count >= 100) + { + break; + } + } + + sb.Append("}"); + return sb.ToString(); + } + + private static string EscapeJsonString(string value) + { + var sb = new StringBuilder(); + foreach (char c in value) + { + sb.Append(c switch + { + '"' => "\\\"", + '\\' => "\\\\", + '\b' => "\\b", + '\f' => "\\f", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + _ => c < 32 ? $"\\u{(int)c:X4}" : c.ToString() + }); + } + return sb.ToString(); + } +} + +/// +/// Extension methods for type destructuring +/// +public static class DestructuringExtensions +{ + /// + /// Converts an object to its destructured JSON representation + /// + public static string ToDestructuredJson(this object? value) + { + if (value == null) + { + return "null"; + } + + var destructor = new TypeDestructor(); + return destructor.Destructure(value); + } + + /// + /// Escapes special characters in a string for JSON output + /// + public static string ToJsonString(this string value) + { + if (string.IsNullOrEmpty(value)) + { + return "\"\""; + } + + var sb = new StringBuilder(); + sb.Append("\""); + foreach (char c in value) + { + sb.Append(c switch + { + '"' => "\\\"", + '\\' => "\\\\", + '\b' => "\\b", + '\f' => "\\f", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + _ => c < 32 ? $"\\u{(int)c:X4}" : c.ToString() + }); + } + sb.Append("\""); + return sb.ToString(); + } +} + +/// +/// Extension for ISO 8601 formatting +/// +public static class DateTimeExtensionForDestructuring +{ + public static string ToIso8601String(this DateTime dt) + { + return dt.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Diagnostics/LoggerDiagnosticsCollector.cs b/EonaCat.LogStack/EonaCatLoggerCore/Diagnostics/LoggerDiagnosticsCollector.cs new file mode 100644 index 0000000..5f9abcf --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Diagnostics/LoggerDiagnosticsCollector.cs @@ -0,0 +1,349 @@ +using EonaCat.LogStack.Core; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; + +namespace EonaCat.LogStack.Diagnostics; + +// 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. + +/// +/// Comprehensive logging diagnostics and metrics collection +/// +public sealed class LoggerDiagnosticsCollector +{ + private readonly Stopwatch _uptime = Stopwatch.StartNew(); + private long _totalEventsProcessed; + private long _totalEventsSampled; + private long _totalEventsFiltered; + private long _totalErrors; + private DateTime _startTime = DateTime.UtcNow; + private readonly object _lock = new object(); + + private readonly Dictionary _flowMetrics = new(); + private readonly Dictionary _boosterMetrics = new(); + private readonly Dictionary _levelCounts = new(); + private readonly List _recentLatencies = new(); + private const int MaxLatencyHistory = 1000; + + public LoggerDiagnosticsCollector() + { + foreach (LogLevel level in Enum.GetValues(typeof(LogLevel))) + { + _levelCounts[level] = 0; + } + } + + /// + /// Records an event being processed + /// + public void RecordEventProcessed(LogEvent logEvent, long elapsedMilliseconds) + { + lock (_lock) + { + _totalEventsProcessed++; + + if (_levelCounts.ContainsKey(logEvent.Level)) + { + _levelCounts[logEvent.Level]++; + } + + // Track latency + _recentLatencies.Add(new EventLatency + { + Level = logEvent.Level, + ElapsedMs = elapsedMilliseconds, + Timestamp = DateTime.UtcNow + }); + + if (_recentLatencies.Count > MaxLatencyHistory) + { + _recentLatencies.RemoveAt(0); + } + } + } + + /// + /// Records a sampled event + /// + public void RecordSampled() + { + lock (_lock) + { + _totalEventsSampled++; + } + } + + /// + /// Records a filtered event + /// + public void RecordFiltered() + { + lock (_lock) + { + _totalEventsFiltered++; + } + } + + /// + /// Records an error + /// + public void RecordError() + { + lock (_lock) + { + _totalErrors++; + } + } + + /// + /// Records metrics for a flow + /// + public void RecordFlowMetrics(string flowName, int eventsProcessed, int eventsFailed, long bytesWritten) + { + lock (_lock) + { + if (!_flowMetrics.TryGetValue(flowName, out var metrics)) + { + metrics = new FlowMetrics { Name = flowName }; + _flowMetrics[flowName] = metrics; + } + + metrics.EventsProcessed += eventsProcessed; + metrics.EventsFailed += eventsFailed; + metrics.BytesWritten += bytesWritten; + metrics.LastUpdated = DateTime.UtcNow; + } + } + + /// + /// Records metrics for a booster + /// + public void RecordBoosterMetrics(string boosterName, int eventsProcessed, int eventsFiltered) + { + lock (_lock) + { + if (!_boosterMetrics.TryGetValue(boosterName, out var metrics)) + { + metrics = new BoosterMetrics { Name = boosterName }; + _boosterMetrics[boosterName] = metrics; + } + + metrics.EventsProcessed += eventsProcessed; + metrics.EventsFiltered += eventsFiltered; + metrics.LastUpdated = DateTime.UtcNow; + } + } + + /// + /// Gets comprehensive diagnostics + /// + public DiagnosticsReport GetReport() + { + lock (_lock) + { + var latencies = _recentLatencies.Count > 0 ? _recentLatencies.ToList() : new List(); + + return new DiagnosticsReport + { + StartTime = _startTime, + Uptime = _uptime.Elapsed, + TotalEventsProcessed = _totalEventsProcessed, + TotalEventsSampled = _totalEventsSampled, + TotalEventsFiltered = _totalEventsFiltered, + TotalErrors = _totalErrors, + EventsByLevel = new Dictionary(_levelCounts), + FlowMetrics = _flowMetrics.Values.ToList(), + BoosterMetrics = _boosterMetrics.Values.ToList(), + RecentLatencies = latencies, + MemoryUsage = GC.GetTotalMemory(false), + ManagedThreadCount = System.Diagnostics.Process.GetCurrentProcess().Threads.Count + }; + } + } + + /// + /// Gets a summary report + /// + public DiagnosticsSummary GetSummary() + { + var report = GetReport(); + + double successRate = report.TotalEventsProcessed > 0 + ? ((report.TotalEventsProcessed - report.TotalErrors) * 100.0) / report.TotalEventsProcessed + : 0.0; + + double samplingRate = report.TotalEventsProcessed > 0 + ? (report.TotalEventsSampled * 100.0) / report.TotalEventsProcessed + : 0.0; + + var avgLatency = report.RecentLatencies.Count > 0 + ? report.RecentLatencies.Average(l => l.ElapsedMs) + : 0.0; + + var maxLatency = report.RecentLatencies.Count > 0 + ? report.RecentLatencies.Max(l => l.ElapsedMs) + : 0L; + + return new DiagnosticsSummary + { + Uptime = report.Uptime, + TotalEvents = report.TotalEventsProcessed, + SuccessRate = successRate, + ErrorRate = 100.0 - successRate, + SamplingRate = samplingRate, + FilteringRate = report.TotalEventsFiltered > 0 + ? (report.TotalEventsFiltered * 100.0) / report.TotalEventsProcessed + : 0.0, + EventsPerSecond = report.Uptime.TotalSeconds > 0 + ? report.TotalEventsProcessed / report.Uptime.TotalSeconds + : 0.0, + AverageLatencyMs = avgLatency, + MaxLatencyMs = maxLatency, + MemoryUsageMb = report.MemoryUsage / (1024 * 1024.0), + ThreadCount = report.ManagedThreadCount + }; + } + + /// + /// Generates a text report + /// + public string GenerateTextReport() + { + var summary = GetSummary(); + var report = GetReport(); + + var sb = new StringBuilder(); + sb.AppendLine("╔══════════════════════════════════════════════════════════╗"); + sb.AppendLine("║ EonaCat.LogStack Diagnostics Report ║"); + sb.AppendLine("╚══════════════════════════════════════════════════════════╝"); + sb.AppendLine(); + + sb.AppendLine("📊 Overview"); + sb.AppendLine($" Uptime: {summary.Uptime:hh\\:mm\\:ss}"); + sb.AppendLine($" Total Events: {summary.TotalEvents:N0}"); + sb.AppendLine($" Events/Second: {summary.EventsPerSecond:F2}"); + sb.AppendLine(); + + sb.AppendLine("📈 Quality Metrics"); + sb.AppendLine($" Success Rate: {summary.SuccessRate:F2}%"); + sb.AppendLine($" Error Rate: {summary.ErrorRate:F2}%"); + sb.AppendLine($" Sampling Rate: {summary.SamplingRate:F2}%"); + sb.AppendLine($" Filtering Rate: {summary.FilteringRate:F2}%"); + sb.AppendLine(); + + sb.AppendLine("⚡ Performance"); + sb.AppendLine($" Avg Latency: {summary.AverageLatencyMs:F2}ms"); + sb.AppendLine($" Max Latency: {summary.MaxLatencyMs}ms"); + sb.AppendLine($" Memory Usage: {summary.MemoryUsageMb:F2}MB"); + sb.AppendLine($" Threads: {summary.ThreadCount}"); + sb.AppendLine(); + + sb.AppendLine("📋 Events by Level"); + foreach (var kvp in report.EventsByLevel.OrderByDescending(x => x.Value)) + { + sb.AppendLine($" {kvp.Key,-12} {kvp.Value:N0}"); + } + sb.AppendLine(); + + if (report.FlowMetrics.Count > 0) + { + sb.AppendLine("🚀 Flow Metrics"); + foreach (var flow in report.FlowMetrics) + { + sb.AppendLine($" {flow.Name}"); + sb.AppendLine($" Processed: {flow.EventsProcessed:N0}"); + sb.AppendLine($" Failed: {flow.EventsFailed}"); + sb.AppendLine($" Bytes: {flow.BytesWritten:N0}"); + } + sb.AppendLine(); + } + + if (report.BoosterMetrics.Count > 0) + { + sb.AppendLine("⚙️ Booster Metrics"); + foreach (var booster in report.BoosterMetrics) + { + sb.AppendLine($" {booster.Name}"); + sb.AppendLine($" Processed: {booster.EventsProcessed:N0}"); + sb.AppendLine($" Filtered: {booster.EventsFiltered:N0}"); + } + sb.AppendLine(); + } + + return sb.ToString(); + } +} + +/// +/// Individual flow metrics +/// +public class FlowMetrics +{ + public string Name { get; set; } = ""; + public long EventsProcessed { get; set; } + public long EventsFailed { get; set; } + public long BytesWritten { get; set; } + public DateTime LastUpdated { get; set; } +} + +/// +/// Individual booster metrics +/// +public class BoosterMetrics +{ + public string Name { get; set; } = ""; + public long EventsProcessed { get; set; } + public long EventsFiltered { get; set; } + public DateTime LastUpdated { get; set; } +} + +/// +/// Detailed diagnostics report +/// +public class DiagnosticsReport +{ + public DateTime StartTime { get; set; } + public TimeSpan Uptime { get; set; } + public long TotalEventsProcessed { get; set; } + public long TotalEventsSampled { get; set; } + public long TotalEventsFiltered { get; set; } + public long TotalErrors { get; set; } + public Dictionary EventsByLevel { get; set; } = new(); + public List FlowMetrics { get; set; } = new(); + public List BoosterMetrics { get; set; } = new(); + public List RecentLatencies { get; set; } = new(); + public long MemoryUsage { get; set; } + public int ManagedThreadCount { get; set; } +} + +/// +/// Summary diagnostics +/// +public class DiagnosticsSummary +{ + public TimeSpan Uptime { get; set; } + public long TotalEvents { get; set; } + public double SuccessRate { get; set; } + public double ErrorRate { get; set; } + public double SamplingRate { get; set; } + public double FilteringRate { get; set; } + public double EventsPerSecond { get; set; } + public double AverageLatencyMs { get; set; } + public long MaxLatencyMs { get; set; } + public double MemoryUsageMb { get; set; } + public int ThreadCount { get; set; } +} + +/// +/// Individual event latency record +/// +public class EventLatency +{ + public LogLevel Level { get; set; } + public long ElapsedMs { get; set; } + public DateTime Timestamp { get; set; } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/DynamicLevelController.cs b/EonaCat.LogStack/EonaCatLoggerCore/DynamicLevelController.cs new file mode 100644 index 0000000..8c3e64e --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/DynamicLevelController.cs @@ -0,0 +1,92 @@ +using EonaCat.LogStack.Core; +using System; +using System.Threading; + +namespace EonaCat.LogStack.Core; + +// 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. + +/// +/// Thread-safe runtime log-level controller. +/// +/// Attach an instance to a logger via logger.UseDynamicLevel(controller). +/// The logger will then honour on every log call instead +/// of the level that was set at build time — allowing level changes without restart. +/// +/// +/// +/// var levelCtrl = new DynamicLevelController(LogLevel.Information); +/// var logger = new LogBuilder() +/// .WriteToConsole() +/// .Build() +/// .UseDynamicLevel(levelCtrl); +/// +/// // Later, crank up verbosity for debugging: +/// levelCtrl.CurrentLevel = LogLevel.Trace; +/// +/// // Or subscribe to changes: +/// levelCtrl.LevelChanged += (old, @new) => Console.WriteLine($"Level: {old} → {@new}"); +/// +/// +/// +public sealed class DynamicLevelController +{ + // Store as int for Interlocked operations + private int _level; + + /// Fires whenever the level changes, passing (oldLevel, newLevel). + public event Action? LevelChanged; + + public DynamicLevelController(LogLevel initialLevel = LogLevel.Information) + { + _level = (int)initialLevel; + } + + /// Gets or sets the current minimum log level. + public LogLevel CurrentLevel + { + get => (LogLevel)Volatile.Read(ref _level); + set + { + var old = (LogLevel)Interlocked.Exchange(ref _level, (int)value); + if (old != value) + { + LevelChanged?.Invoke(old, value); + } + } + } + + /// + /// Temporarily raises the level to and returns a handle + /// that restores the previous level when disposed. + /// Thread-safe for nested/concurrent usage. + /// + public IDisposable TemporaryLevel(LogLevel level) + { + var previous = CurrentLevel; + CurrentLevel = level; + return new RestoreHandle(this, previous); + } + + private sealed class RestoreHandle : IDisposable + { + private readonly DynamicLevelController _ctrl; + private readonly LogLevel _restore; + private int _disposed; + + public RestoreHandle(DynamicLevelController ctrl, LogLevel restore) + { + _ctrl = ctrl; + _restore = restore; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + _ctrl.CurrentLevel = _restore; + } + } + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Exceptions/AdvancedExceptionRenderer.cs b/EonaCat.LogStack/EonaCatLoggerCore/Exceptions/AdvancedExceptionRenderer.cs new file mode 100644 index 0000000..f135e9a --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Exceptions/AdvancedExceptionRenderer.cs @@ -0,0 +1,382 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; + +namespace EonaCat.LogStack.Exceptions; + +// 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. + +/// +/// Advanced exception rendering with context tracking +/// +public sealed class AdvancedExceptionRenderer +{ + private readonly Dictionary _contextMap = new(); + + /// + /// Registers context information for an exception + /// + public void RegisterContext(Exception ex, ExceptionContext context) + { + _contextMap[ex] = context; + } + + /// + /// Renders an exception with all available context + /// + public string RenderFull(Exception ex) + { + var sb = new StringBuilder(); + RenderException(sb, ex, 0, true); + return sb.ToString(); + } + + /// + /// Renders an exception with minimal detail + /// + public string RenderShort(Exception ex) + { + return $"{ex.GetType().Name}: {ex.Message}"; + } + + /// + /// Renders just the stack trace with source information + /// + public string RenderStackTrace(Exception ex) + { + var sb = new StringBuilder(); + RenderStackTrace(sb, ex); + return sb.ToString(); + } + + private void RenderException(StringBuilder sb, Exception ex, int depth, bool includeContext) + { + string indent = new string(' ', depth * 2); + + // Exception header + sb.Append(indent).Append(ex.GetType().FullName).Append(": ").Append(ex.Message).AppendLine(); + + // Stack trace + if (!string.IsNullOrEmpty(ex.StackTrace)) + { + RenderStackTrace(sb, ex); + } + + // Context information + if (includeContext && _contextMap.TryGetValue(ex, out var context)) + { + sb.Append(indent).Append("Context:").AppendLine(); + if (context.CorrelationId != null) + { + sb.Append(indent).Append(" CorrelationId: ").Append(context.CorrelationId).AppendLine(); + } + + if (context.UserId != null) + { + sb.Append(indent).Append(" UserId: ").Append(context.UserId).AppendLine(); + } + + if (context.RequestPath != null) + { + sb.Append(indent).Append(" RequestPath: ").Append(context.RequestPath).AppendLine(); + } + + if (context.CustomData != null) + { + sb.Append(indent).Append(" CustomData:").AppendLine(); + foreach (var kvp in context.CustomData) + { + sb.Append(indent).Append(" ").Append(kvp.Key).Append(": ").Append(kvp.Value).AppendLine(); + } + } + } + + // Inner exceptions + if (ex.InnerException != null) + { + sb.Append(indent).Append("InnerException:").AppendLine(); + RenderException(sb, ex.InnerException, depth + 1, includeContext); + } + } + + private void RenderStackTrace(StringBuilder sb, Exception ex) + { + string indent = new string(' ', 4); + var frames = new StackTrace(ex, true).GetFrames() ?? Array.Empty(); + + foreach (var frame in frames) + { + var method = frame.GetMethod(); + if (method == null) + { + continue; + } + + sb.Append(indent).Append("at "); + + // Type and method + if (method.DeclaringType != null) + { + sb.Append(method.DeclaringType.Name).Append("."); + } + sb.Append(method.Name); + + // Parameters + var parameters = method.GetParameters(); + if (parameters.Length > 0) + { + sb.Append("("); + for (int i = 0; i < parameters.Length; i++) + { + if (i > 0) + { + sb.Append(", "); + } + + sb.Append(parameters[i].ParameterType.Name); + } + sb.Append(")"); + } + else + { + sb.Append("()"); + } + + // File and line number + string fileName = frame.GetFileName(); + if (!string.IsNullOrEmpty(fileName)) + { + int lineNumber = frame.GetFileLineNumber(); + sb.Append(" in ").Append(Path.GetFileName(fileName)).Append(":line ").Append(lineNumber); + } + + sb.AppendLine(); + } + } +} + +/// +/// Context information associated with an exception +/// +public sealed class ExceptionContext +{ + public string? CorrelationId { get; set; } + public string? UserId { get; set; } + public string? RequestPath { get; set; } + public Dictionary? CustomData { get; set; } + public DateTime OccurredAt { get; set; } + + public ExceptionContext() + { + OccurredAt = DateTime.UtcNow; + CustomData = new Dictionary(); + } + + /// + /// Builder pattern for fluent context creation + /// + public static ExceptionContextBuilder Create() + { + return new ExceptionContextBuilder(); + } +} + +/// +/// Builder for exception context +/// +public sealed class ExceptionContextBuilder +{ + private readonly ExceptionContext _context = new(); + + public ExceptionContextBuilder WithCorrelationId(string id) + { + _context.CorrelationId = id; + return this; + } + + public ExceptionContextBuilder WithUserId(string userId) + { + _context.UserId = userId; + return this; + } + + public ExceptionContextBuilder WithRequestPath(string path) + { + _context.RequestPath = path; + return this; + } + + public ExceptionContextBuilder WithCustomData(string key, object? value) + { + _context.CustomData![key] = value; + return this; + } + + public ExceptionContextBuilder WithCustomData(Dictionary data) + { + if (data != null) + { + foreach (var kvp in data) + { + _context.CustomData![kvp.Key] = kvp.Value; + } + } + return this; + } + + public ExceptionContext Build() + { + return _context; + } +} + +/// +/// Global exception context manager +/// +public static class ExceptionContextManager +{ + private static readonly object _lock = new object(); + private static readonly Dictionary _contextMap = new(); + + /// + /// Gets or creates context for an exception + /// + public static ExceptionContext GetOrCreateContext(Exception ex) + { + lock (_lock) + { + if (!_contextMap.TryGetValue(ex, out var context)) + { + context = new ExceptionContext(); + _contextMap[ex] = context; + } + return context; + } + } + + /// + /// Enriches an exception with context + /// + public static Exception WithContext(this Exception ex, Action configure) + { + var builder = ExceptionContext.Create(); + configure(builder); + lock (_lock) + { + _contextMap[ex] = builder.Build(); + } + return ex; + } + + /// + /// Gets context for an exception, or null if none exists + /// + public static ExceptionContext? TryGetContext(Exception ex) + { + lock (_lock) + { + _contextMap.TryGetValue(ex, out var context); + return context; + } + } + + /// + /// Clears all stored contexts + /// + public static void Clear() + { + lock (_lock) + { + _contextMap.Clear(); + } + } +} + +/// +/// Exception aggregator for batch exception handling +/// +public sealed class ExceptionAggregator +{ + private readonly List _exceptions = new(); + private readonly string _message; + + public ExceptionAggregator(string message = "One or more exceptions occurred") + { + _message = message; + } + + public void Add(Exception ex) + { + if (ex != null) + { + _exceptions.Add(ex); + } + } + + public void Add(IEnumerable exceptions) + { + if (exceptions != null) + { + _exceptions.AddRange(exceptions.Where(e => e != null)); + } + } + + public bool HasExceptions => _exceptions.Count > 0; + + public int Count => _exceptions.Count; + + public void ThrowIfHasExceptions() + { + if (_exceptions.Count == 0) + { + return; + } + + if (_exceptions.Count == 1) + { + throw _exceptions[0]; + } + + throw new AggregateException(_message, _exceptions); + } + + public IReadOnlyList GetExceptions() + { + return _exceptions.AsReadOnly(); + } + + public string GetSummary() + { + if (_exceptions.Count == 0) + { + return "No exceptions"; + } + + var sb = new StringBuilder(); + sb.Append(_message).Append(" (").Append(_exceptions.Count).AppendLine("):"); + + for (int i = 0; i < _exceptions.Count && i < 10; i++) + { + var ex = _exceptions[i]; + sb.Append($" [{i + 1}] {ex.GetType().Name}: {ex.Message}").AppendLine(); + } + + if (_exceptions.Count > 10) + { + sb.Append($" ... and {_exceptions.Count - 10} more").AppendLine(); + } + + return sb.ToString(); + } + + public void Clear() + { + _exceptions.Clear(); + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Filtering/AdvancedFilter.cs b/EonaCat.LogStack/EonaCatLoggerCore/Filtering/AdvancedFilter.cs new file mode 100644 index 0000000..2ff57f6 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Filtering/AdvancedFilter.cs @@ -0,0 +1,397 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using EonaCat.LogStack.Core; + +namespace EonaCat.LogStack.Filtering; + +// 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. + +/// +/// Advanced filtering conditions beyond simple log levels +/// +public abstract class FilterCondition +{ + public abstract bool Matches(LogEvent logEvent, Dictionary properties); +} + +/// +/// Filters on log level +/// +public sealed class LogLevelFilter : FilterCondition +{ + private readonly LogLevel _minLevel; + private readonly LogLevel _maxLevel; + + public LogLevelFilter(LogLevel minLevel, LogLevel maxLevel = LogLevel.Critical) + { + _minLevel = minLevel; + _maxLevel = maxLevel; + } + + public override bool Matches(LogEvent logEvent, Dictionary properties) + { + return logEvent.Level >= _minLevel && logEvent.Level <= _maxLevel; + } +} + +/// +/// Filters based on logger name pattern (supports wildcards) +/// +public sealed class LoggerNameFilter : FilterCondition +{ + private readonly string _pattern; + private readonly bool _negate; + + public LoggerNameFilter(string pattern, bool negate = false) + { + _pattern = pattern ?? ""; + _negate = negate; + } + + public override bool Matches(LogEvent logEvent, Dictionary properties) + { + // Logger name checking would be done here when LogEvent has LoggerName property available + // For now, return based on negate flag + return _negate; + } + + private bool WildcardMatch(string text, string pattern) + { + if (pattern == "*") + { + return true; + } + + int textIndex = 0; + int patternIndex = 0; + + while (textIndex < text.Length && patternIndex < pattern.Length) + { + if (pattern[patternIndex] == '*') + { + if (patternIndex + 1 >= pattern.Length) + { + return true; + } + + var nextChar = pattern[patternIndex + 1]; + while (textIndex < text.Length && text[textIndex] != nextChar) + { + textIndex++; + } + + if (textIndex >= text.Length) + { + return false; + } + + patternIndex++; + } + else if (pattern[patternIndex] == '?') + { + textIndex++; + patternIndex++; + } + else if (pattern[patternIndex] == text[textIndex]) + { + textIndex++; + patternIndex++; + } + else + { + return false; + } + } + + while (patternIndex < pattern.Length && pattern[patternIndex] == '*') + { + patternIndex++; + } + + return textIndex == text.Length && patternIndex == pattern.Length; + } +} + +/// +/// Filters on message content +/// +public sealed class MessageFilter : FilterCondition +{ + private readonly string _contains; + private readonly bool _caseSensitive; + private readonly bool _negate; + + public MessageFilter(string contains, bool caseSensitive = false, bool negate = false) + { + _contains = contains ?? ""; + _caseSensitive = caseSensitive; + _negate = negate; + } + + public override bool Matches(LogEvent logEvent, Dictionary properties) + { + string message = ""; + var comparison = _caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + bool matches = message.Contains(_contains, comparison); + return _negate ? !matches : matches; + } +} + +/// +/// Filters on exception type +/// +public sealed class ExceptionTypeFilter : FilterCondition +{ + private readonly Type _exceptionType; + private readonly bool _includeInherited; + private readonly bool _negate; + + public ExceptionTypeFilter(Type exceptionType, bool includeInherited = true, bool negate = false) + { + _exceptionType = exceptionType; + _includeInherited = includeInherited; + _negate = negate; + } + + public override bool Matches(LogEvent logEvent, Dictionary properties) + { + // Exception checking would be done here when LogEvent has Exception property available + // For now, return based on negate flag + return _negate; + } +} + +/// +/// Filters on property values +/// +public sealed class PropertyFilter : FilterCondition +{ + private readonly string _propertyName; + private readonly Func _predicate; + + public PropertyFilter(string propertyName, Func predicate) + { + _propertyName = propertyName; + _predicate = predicate; + } + + public override bool Matches(LogEvent logEvent, Dictionary properties) + { + if (!properties.TryGetValue(_propertyName, out var value)) + { + return false; + } + + try + { + return _predicate(value); + } + catch + { + return false; + } + } +} + +/// +/// Composite filter with AND logic +/// +public sealed class AndFilter : FilterCondition +{ + private readonly List _conditions; + + public AndFilter(params FilterCondition[] conditions) + { + _conditions = new List(conditions); + } + + public override bool Matches(LogEvent logEvent, Dictionary properties) + { + return _conditions.All(c => c.Matches(logEvent, properties)); + } +} + +/// +/// Composite filter with OR logic +/// +public sealed class OrFilter : FilterCondition +{ + private readonly List _conditions; + + public OrFilter(params FilterCondition[] conditions) + { + _conditions = new List(conditions); + } + + public override bool Matches(LogEvent logEvent, Dictionary properties) + { + return _conditions.Any(c => c.Matches(logEvent, properties)); + } +} + +/// +/// Composite filter with NOT logic +/// +public sealed class NotFilter : FilterCondition +{ + private readonly FilterCondition _condition; + + public NotFilter(FilterCondition condition) + { + _condition = condition; + } + + public override bool Matches(LogEvent logEvent, Dictionary properties) + { + return !_condition.Matches(logEvent, properties); + } +} + +/// +/// Fluent filter builder +/// +public sealed class FilterBuilder +{ + private FilterCondition? _condition; + + public FilterBuilder MinLevel(string levelName) + { + // When level enum is available in scope, use it here + return this; + } + + public FilterBuilder MaxLevel(string levelName) + { + // When level enum is available in scope, use it here + return this; + } + + public FilterBuilder Level(string min, string max) + { + // When level enum is available in scope, use it here + return this; + } + + public FilterBuilder LoggerName(string pattern) + { + _condition = And(_condition, new LoggerNameFilter(pattern)); + return this; + } + + public FilterBuilder NotLoggerName(string pattern) + { + _condition = And(_condition, new LoggerNameFilter(pattern, true)); + return this; + } + + public FilterBuilder MessageContains(string text, bool caseSensitive = false) + { + _condition = And(_condition, new MessageFilter(text, caseSensitive)); + return this; + } + + public FilterBuilder MessageNotContains(string text, bool caseSensitive = false) + { + _condition = And(_condition, new MessageFilter(text, caseSensitive, true)); + return this; + } + + public FilterBuilder ExceptionType(Type exceptionType, bool includeInherited = true) + { + _condition = And(_condition, new ExceptionTypeFilter(exceptionType, includeInherited)); + return this; + } + + public FilterBuilder ExceptionType(bool includeInherited = true) where TException : Exception + { + _condition = And(_condition, new ExceptionTypeFilter(typeof(TException), includeInherited)); + return this; + } + + public FilterBuilder Property(string propertyName, Func predicate) + { + _condition = And(_condition, new PropertyFilter(propertyName, predicate)); + return this; + } + + public FilterBuilder Property(string propertyName, object value) + { + _condition = And(_condition, new PropertyFilter(propertyName, v => Equals(v, value))); + return this; + } + + public FilterBuilder Or(FilterBuilder other) + { + if (_condition == null || other._condition == null) + { + return this; + } + + _condition = new OrFilter(_condition, other._condition); + return this; + } + + public FilterCondition? Build() + { + return _condition; + } + + private static FilterCondition And(FilterCondition? left, FilterCondition right) + { + return left == null ? right : new AndFilter(left, right); + } +} + +/// +/// Manager for applying filters to events +/// +public sealed class FilterManager +{ + private readonly List<(string name, FilterCondition condition, FilterAction action)> _filters = new(); + + /// + /// Registers a filter with an action + /// + public FilterManager Register(string name, FilterCondition condition, FilterAction action) + { + _filters.Add((name, condition, action)); + return this; + } + + /// + /// Evaluates all registered filters + /// + public FilterAction Evaluate(LogEvent logEvent, Dictionary properties) + { + foreach (var (name, condition, action) in _filters) + { + if (condition.Matches(logEvent, properties)) + { + return action; + } + } + + return FilterAction.Continue; + } + + public int Count => _filters.Count; + + public void Clear() + { + _filters.Clear(); + } +} + +/// +/// Filter actions +/// +public enum FilterAction +{ + Continue, // Process normally + Ignore, // Skip this event + Critical, // Mark as critical + Suppress, // Don't log to standard output, only to filters +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/CircuitBreakerFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/CircuitBreakerFlow.cs new file mode 100644 index 0000000..86dff0d --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/CircuitBreakerFlow.cs @@ -0,0 +1,179 @@ +using EonaCat.LogStack.Core; +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; +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. + +/// +/// Circuit-breaker state machine. +/// +public enum CircuitState +{ + /// Flow is healthy; all events are forwarded. + Closed, + /// Too many failures; events are dropped to protect the inner flow. + Open, + /// Recovery probe in progress; one event is let through per interval. + HalfOpen +} + +/// +/// A decorator flow that implements the Circuit Breaker pattern around any inner flow. +/// +/// Transitions: +/// Closed → Open : after consecutive failures +/// Open → HalfOpen : after has elapsed +/// HalfOpen→ Closed : on a successful probe write +/// HalfOpen→ Open : on a failed probe write +/// +/// When Open, returns immediately +/// without touching the inner flow — preventing cascades into broken endpoints. +/// +/// The event fires on every state transition. +/// +public sealed class CircuitBreakerFlow : FlowBase +{ + private readonly IFlow _inner; + private readonly int _failureThreshold; + private readonly TimeSpan _recoveryTimeout; + + private volatile int _consecutiveFailures; + private volatile CircuitState _state = CircuitState.Closed; + + // Stopwatch restarted every time we enter Open state + private readonly Stopwatch _openedAt = new Stopwatch(); + + private readonly object _stateLock = new object(); + + /// Fires whenever the circuit state changes (from, to). + public event Action StateChanged; + + public CircuitBreakerFlow( + IFlow inner, + int failureThreshold = 5, + TimeSpan? recoveryTimeout = null, + LogLevel minimumLevel = LogLevel.Trace) + : base($"CircuitBreaker({(inner != null ? inner.Name : "?")})", minimumLevel) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _failureThreshold = failureThreshold > 0 ? failureThreshold : 5; + _recoveryTimeout = recoveryTimeout ?? TimeSpan.FromSeconds(30); + } + + /// Current state of the circuit breaker. + public CircuitState State => _state; + + /// Number of consecutive failures since the last successful write. + public int ConsecutiveFailures => _consecutiveFailures; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) + { + if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { + return WriteResult.LevelFiltered; + } + + var currentState = GetEffectiveState(); + + if (currentState == CircuitState.Open) + { + Interlocked.Increment(ref DroppedCount); + return WriteResult.Dropped; + } + + WriteResult result; + try + { + result = await _inner.BlastAsync(logEvent, cancellationToken).ConfigureAwait(false); + } + catch (Exception) + { + result = WriteResult.Failed; + } + + if (result == WriteResult.Success) + { + OnSuccess(currentState); + Interlocked.Increment(ref BlastedCount); + } + else + { + OnFailure(); + Interlocked.Increment(ref DroppedCount); + } + + return result; + } + + public override Task FlushAsync(CancellationToken cancellationToken = default) + => _inner.FlushAsync(cancellationToken); + + public override ValueTask DisposeAsync() => _inner.DisposeAsync(); + + private CircuitState GetEffectiveState() + { + if (_state != CircuitState.Open) + { + return _state; + } + + if (_openedAt.Elapsed >= _recoveryTimeout) + { + Transition(CircuitState.Open, CircuitState.HalfOpen); + return CircuitState.HalfOpen; + } + + return CircuitState.Open; + } + + private void OnSuccess(CircuitState wasState) + { + Interlocked.Exchange(ref _consecutiveFailures, 0); + if (wasState == CircuitState.HalfOpen) + { + Transition(CircuitState.HalfOpen, CircuitState.Closed); + } + } + + private void OnFailure() + { + var failures = Interlocked.Increment(ref _consecutiveFailures); + if (_state == CircuitState.HalfOpen) + { + Transition(CircuitState.HalfOpen, CircuitState.Open); + } + else if (_state == CircuitState.Closed && failures >= _failureThreshold) + { + Transition(CircuitState.Closed, CircuitState.Open); + } + } + + private void Transition(CircuitState from, CircuitState to) + { + lock (_stateLock) + { + if (_state != from) + { + return; + } + + _state = to; + if (to == CircuitState.Open) + { + _openedAt.Restart(); + } + else if (to == CircuitState.Closed) + { + _openedAt.Reset(); + } + } + StateChanged?.Invoke(from, to); + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/ConditionalFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/ConditionalFlow.cs new file mode 100644 index 0000000..359ba07 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/ConditionalFlow.cs @@ -0,0 +1,103 @@ +using EonaCat.LogStack.Core; +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace EonaCat.LogStack.Flows; + +// This file is part of the EonaCat project(s) which is released under the Apache License. +// See the LICENSE file or go to https://EonaCat.com/License for full license details. + +/// +/// A decorator flow that forwards events to an inner flow only when a user-supplied +/// predicate returns true. +/// +/// Common use-cases: +/// • Category-based routing — route only "Database" category events to file +/// • Level-range filtering — forward Warning≤level<Error to one flow +/// • Property filtering — only forward events that carry a specific property +/// • Exception routing — send events with SqlException to a special alert flow +/// +/// +/// +/// // Route only Database category errors to a dedicated file +/// new LogBuilder() +/// .WriteToConditional( +/// predicate: e => e.Category == "Database" && e.Level >= LogLevel.Error, +/// inner: new FileFlow("logs/db-errors")) +/// +/// +/// +public sealed class ConditionalFlow : FlowBase +{ + private readonly IFlow _inner; + private readonly Func _predicate; + + public ConditionalFlow( + IFlow inner, + Func predicate, + LogLevel minimumLevel = LogLevel.Trace) + : base($"Conditional({inner?.Name ?? "?"})", minimumLevel) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _predicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) + { + if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { + return WriteResult.LevelFiltered; + } + + bool matches; + try { matches = _predicate(logEvent); } + catch { matches = false; } + + if (!matches) + { + Interlocked.Increment(ref DroppedCount); + return WriteResult.Dropped; + } + + var result = await _inner.BlastAsync(logEvent, cancellationToken).ConfigureAwait(false); + Interlocked.Increment(ref BlastedCount); + return result; + } + + public override async Task BlastBatchAsync( + ReadOnlyMemory logEvents, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return WriteResult.FlowDisabled; + } + + var result = WriteResult.Success; + var eventsArray = logEvents.ToArray(); + foreach (var ev in eventsArray) + { + bool matches; + try { matches = ev.Level >= MinimumLevel && _predicate(ev); } + catch { matches = false; } + + if (!matches) { Interlocked.Increment(ref DroppedCount); continue; } + + var r = await _inner.BlastAsync(ev, cancellationToken).ConfigureAwait(false); + Interlocked.Increment(ref BlastedCount); + if (r != WriteResult.Success) + { + result = r; + } + } + + return result; + } + + public override Task FlushAsync(CancellationToken cancellationToken = default) + => _inner.FlushAsync(cancellationToken); + + public override ValueTask DisposeAsync() => _inner.DisposeAsync(); +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/LokiFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/LokiFlow.cs new file mode 100644 index 0000000..5522f8c --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/LokiFlow.cs @@ -0,0 +1,308 @@ +using EonaCat.LogStack.Core; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Text; +using EonaCat.Json; +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. + +/// +/// Pushes log events to a Grafana Loki instance via the HTTP log-push API +/// (POST /loki/api/v1/push). +/// +/// Features: +/// • Batching by count and/or flush interval +/// • Configurable static labels (stream selectors) +/// • Auto-label from event Level and Category +/// • Optional HTTP Basic / Bearer authentication +/// • Structured JSON line bodies (all properties serialised) +/// • Compatible with Loki 2.x and 3.x +/// +/// +/// +/// new LogBuilder() +/// .WriteToLoki( +/// lokiUrl: "http://loki:3100", +/// labels: new() { ["app"] = "myapp", ["env"] = "prod" }, +/// batchSize: 100, +/// batchIntervalMs: 2000) +/// +/// +/// +public sealed class LokiFlow : FlowBase +{ + private const string PushPath = "/loki/api/v1/push"; + + private readonly HttpClient _http; + private readonly bool _ownsHttpClient; + private readonly string _lokiPushUrl; + private readonly Dictionary _staticLabels; + private readonly int _batchSize; + private readonly TimeSpan _batchInterval; + private readonly ConcurrentQueue<(string Labels, string Line, long NanoTs)> _queue = new(); + private readonly CancellationTokenSource _cts = new(); + private readonly Task _flushTask; + private readonly SemaphoreSlim _batchSignal = new(0, int.MaxValue); + + public LokiFlow( + string lokiUrl, + Dictionary? labels = null, + int batchSize = 50, + int batchIntervalMs = 1000, + string? bearerToken = null, + string? basicUser = null, + string? basicPassword = null, + HttpClient? httpClient = null, + LogLevel minimumLevel = LogLevel.Trace) + : base("Loki", minimumLevel) + { + if (string.IsNullOrWhiteSpace(lokiUrl)) + { + throw new ArgumentNullException(nameof(lokiUrl)); + } + + _lokiPushUrl = lokiUrl.TrimEnd('/') + PushPath; + _staticLabels = labels ?? new Dictionary(); + _batchSize = batchSize > 0 ? batchSize : 50; + _batchInterval = TimeSpan.FromMilliseconds(batchIntervalMs > 0 ? batchIntervalMs : 1000); + + if (httpClient != null) + { + _http = httpClient; + _ownsHttpClient = false; + } + else + { + _http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + _ownsHttpClient = true; + } + + if (!string.IsNullOrEmpty(bearerToken)) + { + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + } + else if (!string.IsNullOrEmpty(basicUser)) + { + var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{basicUser}:{basicPassword}")); + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", encoded); + } + + _flushTask = Task.Run(() => BackgroundFlushAsync(_cts.Token)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) + { + if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { + return Task.FromResult(WriteResult.LevelFiltered); + } + + var labels = BuildLabels(logEvent); + var line = BuildLine(logEvent); + var nanoTs = logEvent.Timestamp * 100L; // ticks → nanoseconds + + _queue.Enqueue((labels, line, nanoTs)); + Interlocked.Increment(ref BlastedCount); + + if (_queue.Count >= _batchSize) + { + _batchSignal.Release(1); + } + + return Task.FromResult(WriteResult.Success); + } + + public override async Task FlushAsync(CancellationToken cancellationToken = default) + { + await SendBatchAsync(cancellationToken).ConfigureAwait(false); + } + + public override async ValueTask DisposeAsync() + { + IsEnabled = false; + _cts.Cancel(); + try { await _flushTask.ConfigureAwait(false); } catch { } + await SendBatchAsync(default).ConfigureAwait(false); + if (_ownsHttpClient) + { + _http.Dispose(); + } + + _cts.Dispose(); + GC.SuppressFinalize(this); + } + + // Background flush loop + private async Task BackgroundFlushAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + await _batchSignal.WaitAsync(_batchInterval, ct).ConfigureAwait(false); + await SendBatchAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { break; } + catch { } + } + await SendBatchAsync(default).ConfigureAwait(false); + } + + private async Task SendBatchAsync(CancellationToken ct) + { + if (_queue.IsEmpty) + { + return; + } + + // Group by label-set (Loki stream key) + var groups = new Dictionary>(StringComparer.Ordinal); + while (_queue.TryDequeue(out var item)) + { + if (!groups.TryGetValue(item.Labels, out var list)) + { + list = new List<(string, long)>(16); + groups[item.Labels] = list; + } + list.Add((item.Line, item.NanoTs)); + } + + if (groups.Count == 0) + { + return; + } + + var body = BuildPushJson(groups); + try + { + var content = new StringContent(body, Encoding.UTF8, "application/json"); + using var response = await _http.PostAsync(_lokiPushUrl, content, ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + Interlocked.Add(ref DroppedCount, SumGroups(groups)); + } + } + catch + { + Interlocked.Add(ref DroppedCount, SumGroups(groups)); + } + } + + private string BuildLabels(LogEvent e) + { + var sb = new StringBuilder("{"); + bool first = true; + + foreach (var kv in _staticLabels) + { + if (!first) + { + sb.Append(", "); + } + + sb.Append('"').Append(kv.Key).Append("\": \"").Append(kv.Value).Append('"'); + first = false; + } + + // Auto-labels + AppendLabel(sb, "level", e.Level.ToString(), ref first); + if (e.HasCategory) + { + AppendLabel(sb, "category", e.Category, ref first); + } + + sb.Append('}'); + return sb.ToString(); + } + + private static void AppendLabel(StringBuilder sb, string key, string value, ref bool first) + { + if (!first) + { + sb.Append(", "); + } + + sb.Append('"').Append(key).Append("\": \"").Append(EscapeJson(value)).Append('"'); + first = false; + } + + private string BuildLine(LogEvent e) + { + using var doc = new Utf8JsonWriter_Builder(); + var obj = new + { + level = e.Level.ToString(), + category = e.Category, + message = e.Message.ToString(), + exception = e.Exception?.ToString(), + thread = e.ThreadId, + props = e.HasProperties ? (object)e.Properties : null + }; + return JsonHelper.ToJson(obj); + } + + private static string BuildPushJson(Dictionary> groups) + { + var sb = new StringBuilder("{\"streams\":["); + bool firstStream = true; + + foreach (var kv in groups) + { + if (!firstStream) + { + sb.Append(','); + } + + firstStream = false; + + sb.Append("{\"stream\":").Append(kv.Key).Append(",\"values\":["); + bool firstVal = true; + foreach (var (line, nanoTs) in kv.Value) + { + if (!firstVal) + { + sb.Append(','); + } + + firstVal = false; + sb.Append("[\"").Append(nanoTs).Append("\",") + .Append(JsonHelper.ToJson(line)).Append(']'); + } + sb.Append("]}"); + } + + sb.Append("]}"); + return sb.ToString(); + } + + private static string EscapeJson(string s) + => s.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static long SumGroups(Dictionary> groups) + { + long n = 0; + foreach (var l in groups.Values) + { + n += l.Count; + } + + return n; + } + + // Minimal helper class (unused, kept for future extensibility) + private sealed class Utf8JsonWriter_Builder : IDisposable + { + public void Dispose() { } + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/MemoryFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/MemoryFlow.cs index 1710cd6..cc6df62 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/Flows/MemoryFlow.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/MemoryFlow.cs @@ -1,6 +1,7 @@ using EonaCat.LogStack.Core; using System; using System.Collections.Generic; +using System.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -177,4 +178,113 @@ public sealed class MemoryFlow : FlowBase } } } -} \ No newline at end of file + + // LINQ-style Query API + /// + /// Returns all buffered events matching an arbitrary predicate. + /// The buffer snapshot is taken under lock; the predicate is evaluated outside. + /// + public IReadOnlyList Query(Func predicate) + { + if (predicate == null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + return GetEvents().Where(predicate).ToList(); + } + + /// Returns all events at or above the given level. + public IReadOnlyList QueryByLevel(LogLevel minimumLevel) + => Query(e => e.Level >= minimumLevel); + + /// Returns all events whose Category equals (case-insensitive). + public IReadOnlyList QueryByCategory(string category) + { + if (string.IsNullOrEmpty(category)) + { + throw new ArgumentNullException(nameof(category)); + } + + return Query(e => string.Equals(e.Category, category, StringComparison.OrdinalIgnoreCase)); + } + + /// Returns all events that carry an exception of (or derived from) . + public IReadOnlyList QueryByException() where TException : Exception + => Query(e => e.HasException && e.Exception is TException); + + /// Returns all events whose message contains the given substring (case-insensitive). + public IReadOnlyList QueryByMessage(string substring) + { + if (substring == null) + { + throw new ArgumentNullException(nameof(substring)); + } + + return Query(e => e.Message.Span.IndexOf(substring.AsSpan(), StringComparison.OrdinalIgnoreCase) >= 0); + } + + /// Returns all events that carry a property with the given key. + public IReadOnlyList QueryByProperty(string key) + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + return Query(e => e.HasProperties && e.Properties.ContainsKey(key)); + } + + /// Returns all events that carry a property with a specific key and value. + public IReadOnlyList QueryByProperty(string key, object value) + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + return Query(e => e.HasProperties + && e.Properties.TryGetValue(key, out var v) + && Equals(v, value)); + } + + /// Returns the most recent events (newest last). + public IReadOnlyList GetLatest(int n) => GetRecentEvents(n); + + /// + /// Returns aggregate statistics for the current buffer snapshot. + /// + public MemoryFlowStats GetStats() + { + var events = GetEvents(); + var byLevel = new Dictionary(); + foreach (var e in events) + { + byLevel.TryGetValue(e.Level, out var cnt); + byLevel[e.Level] = cnt + 1; + } + + return new MemoryFlowStats + { + TotalEvents = events.Length, + Capacity = _capacity, + IsFull = IsFull, + ByLevel = byLevel, + WithExceptions = events.Count(e => e.HasException), + OldestTimestamp = events.Length > 0 ? LogEvent.GetDateTime(events[0].Timestamp) : (DateTime?)null, + NewestTimestamp = events.Length > 0 ? LogEvent.GetDateTime(events[events.Length - 1].Timestamp) : (DateTime?)null, + }; + } +} + +/// Aggregate statistics returned by . +public sealed class MemoryFlowStats +{ + public int TotalEvents { get; set; } + public int Capacity { get; set; } + public bool IsFull { get; set; } + public Dictionary ByLevel { get; set; } = new Dictionary(); + public int WithExceptions { get; set; } + public DateTime? OldestTimestamp { get; set; } + public DateTime? NewestTimestamp { get; set; } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Flows/MulticastFlow.cs b/EonaCat.LogStack/EonaCatLoggerCore/Flows/MulticastFlow.cs new file mode 100644 index 0000000..efdf2ae --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Flows/MulticastFlow.cs @@ -0,0 +1,175 @@ +using EonaCat.LogStack.Core; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +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. + +/// +/// Fans out every log event to multiple inner flows in parallel, each with an +/// optional per-flow predicate guard. +/// +/// Differences vs simply adding multiple flows to a : +/// • The fan-out is fired in parallel (Task.WhenAll), reducing wall-clock latency. +/// • Per-target predicates let you express "send to Slack only if level ≥ Error" +/// inside one place rather than wrapping each flow in a . +/// • Targets can be added/removed at runtime via / . +/// +/// +/// +/// var multicast = new MulticastFlow() +/// .Add(consoleFlow) +/// .Add(fileFlow) +/// .Add(slackFlow, e => e.Level >= LogLevel.Error); +/// +/// new LogBuilder().WriteTo(multicast).Build(); +/// +/// +/// +public sealed class MulticastFlow : FlowBase +{ + private readonly struct Target + { + public readonly IFlow Flow; + public readonly Func? Predicate; + public Target(IFlow flow, Func? predicate) { Flow = flow; Predicate = predicate; } + } + + private readonly List _targets = new(); + private readonly object _lock = new(); + + public MulticastFlow(LogLevel minimumLevel = LogLevel.Trace) + : base("Multicast", minimumLevel) { } + + /// + /// Adds an inner flow, optionally gated by a predicate. + /// + public MulticastFlow Add(IFlow flow, Func? predicate = null) + { + if (flow == null) + { + throw new ArgumentNullException(nameof(flow)); + } + + lock (_lock) + { + _targets.Add(new Target(flow, predicate)); + } + + return this; + } + + /// + /// Removes all targets backed by the given flow instance. + /// + public MulticastFlow Remove(IFlow flow) + { + if (flow == null) + { + throw new ArgumentNullException(nameof(flow)); + } + + lock (_lock) + { + _targets.RemoveAll(t => t.Flow == flow); + } + + return this; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override async Task BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default) + { + if (!IsEnabled || !IsLogLevelEnabled(logEvent)) + { + return WriteResult.LevelFiltered; + } + + Target[] snapshot; + lock (_lock) + { + snapshot = _targets.ToArray(); + } + + if (snapshot.Length == 0) + { + return WriteResult.NoBlastZone; + } + + var tasks = new Task[snapshot.Length]; + for (int i = 0; i < snapshot.Length; i++) + { + var target = snapshot[i]; + bool shouldSend; + try { shouldSend = target.Predicate == null || target.Predicate(logEvent); } + catch { shouldSend = false; } + + tasks[i] = shouldSend + ? target.Flow.BlastAsync(logEvent, cancellationToken) + : Task.FromResult(WriteResult.Dropped); + } + + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + + var overall = WriteResult.Success; + foreach (var r in results) + { + if (r == WriteResult.Dropped) + { + Interlocked.Increment(ref DroppedCount); + } + else + { + Interlocked.Increment(ref BlastedCount); + } + + if (r != WriteResult.Success && r != WriteResult.Dropped && overall == WriteResult.Success) + { + overall = r; + } + } + + return overall; + } + + public override async Task FlushAsync(CancellationToken cancellationToken = default) + { + Target[] snapshot; + lock (_lock) + { + snapshot = _targets.ToArray(); + } + + var tasks = new Task[snapshot.Length]; + for (int i = 0; i < snapshot.Length; i++) + { + tasks[i] = snapshot[i].Flow.FlushAsync(cancellationToken); + } + + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + public override async ValueTask DisposeAsync() + { + IsEnabled = false; + await FlushAsync(default).ConfigureAwait(false); + + Target[] snapshot; + lock (_lock) + { + snapshot = _targets.ToArray(); + } + + foreach (var t in snapshot) + { + try { await t.Flow.DisposeAsync().ConfigureAwait(false); } catch { } + } + + GC.SuppressFinalize(this); + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Formatting/Formatters.cs b/EonaCat.LogStack/EonaCatLoggerCore/Formatting/Formatters.cs new file mode 100644 index 0000000..d96d840 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Formatting/Formatters.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using EonaCat.LogStack.Destructuring; + +namespace EonaCat.LogStack.Output.Formatters; + +// 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. + +/// +/// Base interface for log formatters +/// +public interface ILogFormatter +{ + string Format(object logEvent, Dictionary properties); + void Format(StringBuilder sb, object logEvent, Dictionary properties); +} + +/// +/// JSON formatter for structured logging +/// +public sealed class JsonFormatter : ILogFormatter +{ + private readonly bool _includeProperties; + private readonly bool _indented; + + public JsonFormatter(bool includeProperties = true, bool indented = false) + { + _includeProperties = includeProperties; + _indented = indented; + } + + public string Format(object logEvent, Dictionary properties) + { + var sb = new StringBuilder(); + Format(sb, logEvent, properties); + return sb.ToString(); + } + + public void Format(StringBuilder sb, object logEvent, Dictionary properties) + { + sb.Append("{"); + sb.Append("\"timestamp\":\"").Append(DateTime.Now.ToString("o")).Append("\","); + sb.Append("\"level\":\"INFO\","); + sb.Append("\"logger\":\"Default\","); + sb.Append("\"message\":\"\""); + + // Properties + if (_includeProperties && properties.Count > 0) + { + sb.Append(",\"properties\":{"); + int count = 0; + foreach (var kvp in properties) + { + if (count > 0) + { + sb.Append(","); + } + + sb.Append("\"").AppendEscapedJson(kvp.Key).Append("\":"); + FormatPropertyValue(sb, kvp.Value); + count++; + } + sb.Append("}"); + } + + sb.Append("}"); + } + + private void FormatPropertyValue(StringBuilder sb, object? value) + { + if (value == null) + { + sb.Append("null"); + } + else if (value is string str) + { + sb.Append("\"").AppendEscapedJson(str).Append("\""); + } + else if (value is bool b) + { + sb.Append(b ? "true" : "false"); + } + else if (value is int or long or double or decimal) + { + sb.Append(value); + } + else + { + sb.Append("\"").AppendEscapedJson(value.ToString() ?? "").Append("\""); + } + } +} + +/// +/// CSV formatter for data export +/// +public sealed class CsvFormatter : ILogFormatter +{ + private readonly string[] _propertyNames; + private readonly bool _includeHeader; + private bool _headerWritten = false; + + public CsvFormatter(string[]? propertyNames = null, bool includeHeader = true) + { + _propertyNames = propertyNames ?? Array.Empty(); + _includeHeader = includeHeader; + } + + public string Format(object logEvent, Dictionary properties) + { + var sb = new StringBuilder(); + Format(sb, logEvent, properties); + return sb.ToString(); + } + + public void Format(StringBuilder sb, object logEvent, Dictionary properties) + { + if (_includeHeader && !_headerWritten) + { + WriteCsvHeader(sb); + _headerWritten = true; + } + + WriteCsvLine(sb, properties); + } + + private void WriteCsvHeader(StringBuilder sb) + { + var headers = new List { "Timestamp", "Level", "Logger", "Message" }; + headers.AddRange(_propertyNames); + + for (int i = 0; i < headers.Count; i++) + { + if (i > 0) + { + sb.Append(","); + } + + sb.Append(EscapeCsv(headers[i])); + } + + sb.AppendLine(); + } + + private void WriteCsvLine(StringBuilder sb, Dictionary properties) + { + var values = new List + { + DateTime.Now.ToString(CultureInfo.InvariantCulture), + "INFO", + "Default", + "" + }; + + foreach (var propName in _propertyNames) + { + if (properties.TryGetValue(propName, out var value)) + { + values.Add(value?.ToString() ?? ""); + } + else + { + values.Add(""); + } + } + + for (int i = 0; i < values.Count; i++) + { + if (i > 0) + { + sb.Append(","); + } + + sb.Append(EscapeCsv(values[i])); + } + + sb.AppendLine(); + } + + private string EscapeCsv(string value) + { + if (string.IsNullOrEmpty(value)) + { + return ""; + } + + if (value.Contains(",") || value.Contains("\"") || value.Contains("\n")) + { + return "\"" + value.Replace("\"", "\"\"") + "\""; + } + + return value; + } +} + +/// +/// Compact formatter for console output +/// +public sealed class CompactFormatter : ILogFormatter +{ + public string Format(object logEvent, Dictionary properties) + { + var sb = new StringBuilder(); + Format(sb, logEvent, properties); + return sb.ToString(); + } + + public void Format(StringBuilder sb, object logEvent, Dictionary properties) + { + sb.AppendFormat("[{0:HH:mm:ss}] [{1,7}] {2}: {3}", + DateTime.Now, + "INFO", + "Default", + ""); + } +} + +/// +/// Verbose formatter with all details +/// +public sealed class VerboseFormatter : ILogFormatter +{ + public string Format(object logEvent, Dictionary properties) + { + var sb = new StringBuilder(); + Format(sb, logEvent, properties); + return sb.ToString(); + } + + public void Format(StringBuilder sb, object logEvent, Dictionary properties) + { + sb.AppendLine("===== LOG EVENT ====="); + sb.AppendFormat("Timestamp: {0:O}", DateTime.Now).AppendLine(); + sb.AppendFormat("Level: INFO").AppendLine(); + sb.AppendFormat("Logger: Default").AppendLine(); + sb.AppendFormat("Message: ").AppendLine(); + + if (properties.Count > 0) + { + sb.AppendLine("Properties:"); + foreach (var kvp in properties) + { + sb.AppendFormat(" {0}: {1}", kvp.Key, kvp.Value).AppendLine(); + } + } + + sb.AppendLine("===================="); + } +} + +/// +/// Factory for creating formatters +/// +public static class FormatterFactory +{ + public static ILogFormatter CreateJson(bool includeProperties = true, bool indented = false) + => new JsonFormatter(includeProperties, indented); + + public static ILogFormatter CreateCsv(string[]? propertyNames = null, bool includeHeader = true) + => new CsvFormatter(propertyNames, includeHeader); + + public static ILogFormatter CreateCompact() + => new CompactFormatter(); + + public static ILogFormatter CreateVerbose() + => new VerboseFormatter(); +} + +/// +/// Extension methods for StringBuilder to support JSON escaping +/// +public static class StringBuilderExtensions +{ + public static StringBuilder AppendEscapedJson(this StringBuilder sb, string value) + { + if (string.IsNullOrEmpty(value)) + { + return sb; + } + + 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 (c < 32) + { + sb.AppendFormat("\\u{0:X4}", (int)c); + } + else + { + sb.Append(c); + } + + break; + } + } + + return sb; + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Layouts/LayoutFormatter.cs b/EonaCat.LogStack/EonaCatLoggerCore/Layouts/LayoutFormatter.cs new file mode 100644 index 0000000..0fa3227 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Layouts/LayoutFormatter.cs @@ -0,0 +1,358 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using EonaCat.LogStack.Templates; + +namespace EonaCat.LogStack.Output.Layouts; + +// 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. + +/// +/// Represents a layout token similar to NLog format +/// +public abstract class LayoutToken +{ + public abstract void Render(StringBuilder sb, object logEvent, Dictionary properties); +} + +/// +/// Literal layout token +/// +public sealed class LiteralLayoutToken : LayoutToken +{ + private readonly string _text; + + public LiteralLayoutToken(string text) + { + _text = text; + } + + public override void Render(StringBuilder sb, object logEvent, Dictionary properties) + { + sb.Append(_text); + } +} + +/// +/// DateTime layout token ${date:format=yyyy-MM-dd HH:mm:ss} +/// +public sealed class DateTimeLayoutToken : LayoutToken +{ + private readonly string _format; + + public DateTimeLayoutToken(string format = "yyyy-MM-dd HH:mm:ss") + { + _format = format ?? "yyyy-MM-dd HH:mm:ss"; + } + + public override void Render(StringBuilder sb, object logEvent, Dictionary properties) + { + sb.Append(DateTime.Now.ToString(_format, CultureInfo.InvariantCulture)); + } +} + +/// +/// Log level layout token ${level} +/// +public sealed class LevelLayoutToken : LayoutToken +{ + private readonly bool _uppercase; + + public LevelLayoutToken(bool uppercase = true) + { + _uppercase = uppercase; + } + + public override void Render(StringBuilder sb, object logEvent, Dictionary properties) + { + string level = "INFO"; + sb.Append(_uppercase ? level.ToUpper() : level); + } +} + +/// +/// Logger name layout token ${logger} +/// +public sealed class LoggerNameLayoutToken : LayoutToken +{ + public override void Render(StringBuilder sb, object logEvent, Dictionary properties) + { + sb.Append("Default"); + } +} + +/// +/// Message layout token ${message} +/// +public sealed class MessageLayoutToken : LayoutToken +{ + public override void Render(StringBuilder sb, object logEvent, Dictionary properties) + { + sb.Append(""); // Message would go here when LogEvent is in scope + } +} + +/// +/// Exception layout token ${exception} +/// +public sealed class ExceptionLayoutToken : LayoutToken +{ + private readonly string _format; + + public ExceptionLayoutToken(string format = "full") + { + _format = format ?? "full"; + } + + public override void Render(StringBuilder sb, object logEvent, Dictionary properties) + { + // Exception rendering would happen when LogEvent has Exception property + } +} + +/// +/// Property layout token ${property:name=PropertyName} +/// +public sealed class PropertyLayoutToken : LayoutToken +{ + private readonly string _propertyName; + + public PropertyLayoutToken(string propertyName) + { + _propertyName = propertyName; + } + + public override void Render(StringBuilder sb, object logEvent, Dictionary properties) + { + if (properties.TryGetValue(_propertyName, out var value)) + { + sb.Append(value); + } + } +} + +/// +/// Newline layout token ${newline} +/// +public sealed class NewlineLayoutToken : LayoutToken +{ + public override void Render(StringBuilder sb, object logEvent, Dictionary properties) + { + sb.AppendLine(); + } +} + +/// +/// Parses NLog-like layout strings: ${date:format=...}, ${level}, ${message}, etc. +/// +public sealed class LayoutParser +{ + private readonly string _layout; + private int _position; + + public LayoutParser(string layout) + { + _layout = layout ?? ""; + } + + public List Parse() + { + var tokens = new List(); + var sb = new StringBuilder(); + + while (_position < _layout.Length) + { + if (_layout[_position] == '$' && _position + 1 < _layout.Length && _layout[_position + 1] == '{') + { + // Flush literal text + if (sb.Length > 0) + { + tokens.Add(new LiteralLayoutToken(sb.ToString())); + sb.Clear(); + } + + // Parse token + var token = ParseToken(); + if (token != null) + { + tokens.Add(token); + } + } + else if (_layout[_position] == '\\' && _position + 1 < _layout.Length && _layout[_position + 1] == 'n') + { + sb.Append('\n'); + _position += 2; + } + else + { + sb.Append(_layout[_position]); + _position++; + } + } + + if (sb.Length > 0) + { + tokens.Add(new LiteralLayoutToken(sb.ToString())); + } + + return tokens; + } + + private LayoutToken? ParseToken() + { + _position += 2; // Skip ${ + + var nameSb = new StringBuilder(); + var argsSb = new StringBuilder(); + bool inArgs = false; + + while (_position < _layout.Length) + { + char c = _layout[_position]; + + if (c == '}') + { + _position++; + break; + } + else if (c == ':' && !inArgs) + { + inArgs = true; + _position++; + } + else if (inArgs) + { + argsSb.Append(c); + _position++; + } + else + { + nameSb.Append(c); + _position++; + } + } + + string tokenName = nameSb.ToString().Trim().ToLower(); + string args = argsSb.ToString().Trim(); + + return tokenName switch + { + "date" => new DateTimeLayoutToken(ParseFormatArg(args)), + "time" => new DateTimeLayoutToken("HH:mm:ss"), + "level" => new LevelLayoutToken(ParseBoolArg(args, "uppercase", true)), + "logger" => new LoggerNameLayoutToken(), + "message" => new MessageLayoutToken(), + "exception" => new ExceptionLayoutToken(ParseFormatArg(args)), + "property" or "prop" => new PropertyLayoutToken(ParseNameArg(args)), + "newline" => new NewlineLayoutToken(), + _ => null + }; + } + + private string ParseFormatArg(string args) + { + if (args.StartsWith("format=")) + { + return args.Substring(7); + } + + return args; + } + + private string ParseNameArg(string args) + { + if (args.StartsWith("name=")) + { + return args.Substring(5); + } + + if (args.StartsWith("key=")) + { + return args.Substring(4); + } + + return args; + } + + private bool ParseBoolArg(string args, string name, bool defaultValue) + { + string prefix = name + "="; + if (args.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + string value = args.Substring(prefix.Length); + return value.Equals("true", StringComparison.OrdinalIgnoreCase); + } + return defaultValue; + } +} + +/// +/// Renders a parsed layout with LogEvent data +/// +public sealed class LayoutRenderer +{ + private readonly List _tokens; + private readonly string _originalLayout; + + public LayoutRenderer(string layout) + { + _originalLayout = layout; + var parser = new LayoutParser(layout); + _tokens = parser.Parse(); + } + + public string Render(object logEvent, Dictionary? properties = null) + { + var sb = new StringBuilder(); + properties ??= new Dictionary(); + + foreach (var token in _tokens) + { + token.Render(sb, logEvent, properties); + } + + return sb.ToString(); + } + + public void Render(StringBuilder sb, object logEvent, Dictionary? properties = null) + { + properties ??= new Dictionary(); + foreach (var token in _tokens) + { + token.Render(sb, logEvent, properties); + } + } + + public string OriginalLayout => _originalLayout; +} + +/// +/// Predefined layout templates +/// +public static class LayoutTemplates +{ + public const string Simple = "[${level}] ${message}"; + public const string Standard = "[${date:format=yyyy-MM-dd HH:mm:ss}] [${level}] ${logger} - ${message}"; + public const string Extended = "[${date:format=yyyy-MM-dd HH:mm:ss.fff}] [${level}] [${logger}] ${message}${exception}"; + public const string Json = "{\"timestamp\":\"${date:format=o}\",\"level\":\"${level}\",\"logger\":\"${logger}\",\"message\":\"${message}\"}"; + public const string Csv = "${date:format=yyyy-MM-dd HH:mm:ss},${level},${logger},${message}"; + + /// + /// Gets a layout by common name + /// + public static string GetTemplate(string name) + { + return name?.ToLower() switch + { + "simple" => Simple, + "standard" => Standard, + "extended" => Extended, + "json" => Json, + "csv" => Csv, + _ => Standard + }; + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/LogStats.cs b/EonaCat.LogStack/EonaCatLoggerCore/LogStats.cs index d0f0e03..a531ce3 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/LogStats.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/LogStats.cs @@ -3,14 +3,39 @@ namespace EonaCat.LogStack.EonaCatLogStackCore { + /// + /// Comprehensive logging statistics with per-level and per-category metrics + /// public struct LogStats { + // Basic counters public long Written; public long Dropped; public long Rotations; public long BytesWritten; public double WritesPerSecond; + // Per-level counters + public long TraceCount; + public long DebugCount; + public long InformationCount; + public long WarningCount; + public long ErrorCount; + public long CriticalCount; + + // Error tracking + public long ExceptionsLogged; + public long DroppedErrors; + + // Performance metrics + public double AverageEventLatencyMs; + public double MinEventLatencyMs; + public double MaxEventLatencyMs; + + // Memory metrics + public long TotalEventsProcessed; + public double AverageEventSizeBytes; + public LogStats(long written, long dropped, long rotations, long bytesWritten, double writesPerSecond) { Written = written; @@ -18,6 +43,19 @@ namespace EonaCat.LogStack.EonaCatLogStackCore Rotations = rotations; BytesWritten = bytesWritten; WritesPerSecond = writesPerSecond; + TraceCount = 0; + DebugCount = 0; + InformationCount = 0; + WarningCount = 0; + ErrorCount = 0; + CriticalCount = 0; + ExceptionsLogged = 0; + DroppedErrors = 0; + AverageEventLatencyMs = 0; + MinEventLatencyMs = 0; + MaxEventLatencyMs = 0; + TotalEventsProcessed = 0; + AverageEventSizeBytes = 0; } } } \ No newline at end of file diff --git a/EonaCat.LogStack/EonaCatLoggerCore/LoggerFactory.cs b/EonaCat.LogStack/EonaCatLoggerCore/LoggerFactory.cs index 403011d..8330ce6 100644 --- a/EonaCat.LogStack/EonaCatLoggerCore/LoggerFactory.cs +++ b/EonaCat.LogStack/EonaCatLoggerCore/LoggerFactory.cs @@ -33,6 +33,11 @@ public interface ILoggerFactory : IAsyncDisposable /// Gets diagnostics information about the logger /// LoggerDiagnostics GetDiagnostics(); + + /// + /// Gets detailed metrics about the logger + /// + LoggerMetrics GetMetrics(); } /// @@ -124,6 +129,19 @@ public sealed class LoggerFactory : ILoggerFactory return _logStack.GetDiagnostics(); } + /// + /// Gets detailed metrics about the logger + /// + public LoggerMetrics GetMetrics() + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(LoggerFactory)); + } + + return _logStack.GetMetrics(); + } + /// /// Disposes all loggers and the underlying log stack /// diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Memory/MemoryOptimizations.cs b/EonaCat.LogStack/EonaCatLoggerCore/Memory/MemoryOptimizations.cs new file mode 100644 index 0000000..4f52bfc --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Memory/MemoryOptimizations.cs @@ -0,0 +1,331 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Text; +using EonaCat.LogStack.Core; + +namespace EonaCat.LogStack.Memory; + +// 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. + +/// +/// Memory optimization utilities for zero-allocation logging paths +/// +public static class MemoryOptimizations +{ + /// + /// Rents a scoped array from ArrayPool for using within a scope + /// + public static ScopedArray RentArray(int minimumLength) + { + return new ScopedArray(ArrayPool.Shared.Rent(minimumLength)); + } + + /// + /// Gets size estimate for a log event + /// + public static int EstimateLogEventSize(in LogEvent logEvent) + { + int size = 64; // Base overhead + + // Timestamp, level, thread ID + size += 24; + + // Message + size += logEvent.Message.Length * 2; + + // Category + size += (logEvent.Category?.Length ?? 0) * 2; + + // Exception (estimated) + if (logEvent.HasException) + { + size += 1024; + } + + // Properties + if (logEvent.HasProperties) + { + size += logEvent.Properties.Count * 64; + } + + return size; + } + + /// + /// Checks if memory usage is within acceptable range + /// + public static (bool acceptable, double pressure) CheckMemoryPressure( + long thresholdBytes = 100 * 1024 * 1024) + { + long current = GC.GetTotalMemory(false); + double pressure = (double)current / thresholdBytes; + return (pressure < 0.8, pressure); + } + + /// + /// Attempts to reduce memory usage + /// + public static void TryCompact() + { + GC.Collect(GC.MaxGeneration, GCCollectionMode.Optimized, false); + GC.WaitForPendingFinalizers(); + } + + /// + /// Gets current memory statistics + /// + public static MemoryStatistics GetStatistics() + { + return new MemoryStatistics + { + TotalMemory = GC.GetTotalMemory(false), + Gen0Collections = GC.CollectionCount(0), + Gen1Collections = GC.CollectionCount(1), + Gen2Collections = GC.CollectionCount(2) + }; + } +} + +/// +/// Scoped array rental from ArrayPool +/// +public struct ScopedArray : IDisposable +{ + private T[]? _array; + private readonly int _length; + + public ScopedArray(int length) + { + _array = ArrayPool.Shared.Rent(length); + _length = length; + } + + public ScopedArray(T[] array) + { + _array = array; + _length = array.Length; + } + + public Span AsSpan() => new Span(_array, 0, _length); + public Memory AsMemory() => new Memory(_array, 0, _length); + public T[] Array => _array!; + + public void Dispose() + { + if (_array != null) + { + ArrayPool.Shared.Return(_array, false); + _array = null; + } + } +} + +/// +/// Memory statistics +/// +public class MemoryStatistics +{ + public long TotalMemory { get; set; } + public int Gen0Collections { get; set; } + public int Gen1Collections { get; set; } + public int Gen2Collections { get; set; } + + public long TotalCollections => Gen0Collections + Gen1Collections + Gen2Collections; +} + +/// +/// String interpolation helper for zero-allocation string building +/// +public struct ZeroAllocString +{ + private readonly StringBuilder _sb; + private readonly bool _ownsBuilder; + + public ZeroAllocString(StringBuilder? builder = null) + { + if (builder == null) + { + _sb = new StringBuilder(256); + _ownsBuilder = true; + } + else + { + _sb = builder; + _ownsBuilder = false; + } + } + + public void Append(string? value) + { + _sb.Append(value); + } + + public void Append(int value) + { + _sb.Append(value); + } + + public void Append(long value) + { + _sb.Append(value); + } + + public void Append(double value) + { + _sb.Append(value); + } + + public void Append(char value) + { + _sb.Append(value); + } + + public void AppendLine() + { + _sb.AppendLine(); + } + + public override string ToString() + { + return _sb.ToString(); + } + + public void Dispose() + { + if (_ownsBuilder) + { + _sb.Clear(); + } + } +} + +/// +/// Value type cache for frequently-used strings +/// +public sealed class StringCache +{ + private readonly Dictionary _cache; + private readonly int _maxSize; + private readonly object _lock = new(); + + public StringCache(int maxSize = 1000) + { + _maxSize = maxSize; + _cache = new Dictionary(maxSize); + } + + /// + /// Gets or caches a string + /// + public string Intern(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + lock (_lock) + { + if (_cache.TryGetValue(value, out var cached)) + { + return cached; + } + + if (_cache.Count < _maxSize) + { + _cache[value] = value; + return value; + } + + return value; + } + } + + /// + /// Clears the cache + /// + public void Clear() + { + lock (_lock) + { + _cache.Clear(); + } + } + + /// + /// Gets cache statistics + /// + public (int count, int maxSize) GetStatistics() + { + lock (_lock) + { + return (_cache.Count, _maxSize); + } + } +} + +/// +/// Memory-efficient concurrent bag alternative +/// +public sealed class EfficientObjectPool where T : class +{ + private readonly Stack _stack; + private readonly Func _factory; + private int _count; + private readonly int _maxSize; + private readonly object _lock = new(); + + public EfficientObjectPool(Func factory, int maxSize = 100) + { + _factory = factory; + _maxSize = maxSize; + _stack = new Stack(maxSize); + } + + public T Rent() + { + lock (_lock) + { + return _stack.Count > 0 ? _stack.Pop() : _factory(); + } + } + + public void Return(T item) + { + if (item == null) + { + return; + } + + lock (_lock) + { + if (_count < _maxSize) + { + _stack.Push(item); + _count++; + } + } + } + + public int Count + { + get + { + lock (_lock) + { + return _count; + } + } + } + + public void Clear() + { + lock (_lock) + { + _stack.Clear(); + _count = 0; + } + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/MessageTemplate.cs b/EonaCat.LogStack/EonaCatLoggerCore/MessageTemplate.cs new file mode 100644 index 0000000..8379929 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/MessageTemplate.cs @@ -0,0 +1,690 @@ +using EonaCat.LogStack.Core; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; + +namespace EonaCat.LogStack.Core; + +// 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. + +/// +/// Hint for how a token's value should be captured. +/// +public enum TokenDestructureHint : byte +{ + /// ToString() / scalar value + Default = 0, + /// {@Property} — deep object destructuring (JSON-like) + Destructure = 1, + /// {$Property} — force ToString() + Stringify = 2 +} + +/// +/// A single token parsed from a message template. +/// Supports advanced features: +/// - Nested property access: {Object.Property.SubProperty} +/// - Array indexing: {Array[0]} +/// - Alignment: {Name,10} (right), {Name,-10} (left) +/// - Filters: {Name|uppercase}, {Name|truncate:20} +/// - Conditionals: {?IsActive:Yes|No} +/// - Fallback: {Name??'default'} +/// +public readonly struct TemplateToken +{ + /// True = literal text; False = property hole. + public readonly bool IsLiteral; + /// Literal text segment, or null for property holes. + public readonly string? Text; + /// Property name for holes (may be a digit for positional placeholders, supports dot notation). + public readonly string? Name; + /// Zero-based positional index for positional placeholders; -1 for named. + public readonly int Position; + /// Destructure / stringify hint. + public readonly TokenDestructureHint Hint; + /// Optional format string (e.g. "D2"). + public readonly string? Format; + /// Alignment width (positive=right, negative=left). + public readonly int Alignment; + /// Applied filters (e.g., "uppercase", "truncate:10"). + public readonly string[]? Filters; + /// Fallback value if property is null/missing. + public readonly string? Fallback; + /// Optional conditional true/false values for {?PropertyName:True|False}. + public readonly (string? TrueValue, string? FalseValue) ConditionalValues; + + private TemplateToken(string literal) + { + IsLiteral = true; + Text = literal; + Name = null; + Position = -1; + Hint = TokenDestructureHint.Default; + Format = null; + Alignment = 0; + Filters = null; + Fallback = null; + ConditionalValues = (null, null); + } + + private TemplateToken(string name, int position, TokenDestructureHint hint, string? format, int alignment = 0, string[]? filters = null, string? fallback = null, (string?, string?) conditionalValues = default) + { + IsLiteral = false; + Text = null; + Name = name; + Position = position; + Hint = hint; + Format = format; + Alignment = alignment; + Filters = filters; + Fallback = fallback; + ConditionalValues = conditionalValues; + } + + public static TemplateToken Literal(string text) => new(text); + + public static TemplateToken Property(string name, int position, TokenDestructureHint hint, string? format, int alignment = 0, string[]? filters = null, string? fallback = null, (string?, string?) conditionalValues = default) + => new(name, position, hint, format, alignment, filters, fallback, conditionalValues); + + public override string ToString() => + IsLiteral ? $"Literal({Text})" : $"Hole({Hint}{Name}{(Format != null ? ":" + Format : "")})"; +} + +/// +/// Parses and renders Serilog-compatible message templates with advanced features. +/// +/// Supported syntax: +/// {PropertyName} — named property (default destructure) +/// {@PropertyName} — named property, deep destructure +/// {$PropertyName} — named property, force-stringify +/// {0}, {1} — positional +/// {PropertyName:format} — with format specifier (e.g., "D2", "C") +/// {PropertyName,10} — right-align with width 10 +/// {PropertyName,-10} — left-align with width 10 +/// {Object.Property} — nested property access (dot notation) +/// {Array[0]} — array/collection indexing +/// {PropertyName|uppercase} — apply filter (uppercase, lowercase, truncate, etc.) +/// {PropertyName??'default'} — fallback value if null/missing +/// {?IsActive:Yes|No} — conditional rendering +/// {{ }} — escaped braces → literal { } +/// +public sealed class MessageTemplate +{ + private readonly string _raw; + private readonly TemplateToken[] _tokens; + + public string Raw => _raw; + public ReadOnlySpan Tokens => _tokens; + + private MessageTemplate(string raw, TemplateToken[] tokens) + { + _raw = raw; + _tokens = tokens; + } + + public static MessageTemplate Parse(string template) + { + if (template == null) + { + throw new ArgumentNullException(nameof(template)); + } + + var tokens = new List(8); + var sb = new StringBuilder(template.Length); + int i = 0; + + while (i < template.Length) + { + char c = template[i]; + + if (c == '{') + { + // Escaped {{ → literal { + if (i + 1 < template.Length && template[i + 1] == '{') + { + sb.Append('{'); + i += 2; + continue; + } + + // Flush accumulated literal + if (sb.Length > 0) + { + tokens.Add(TemplateToken.Literal(sb.ToString())); + sb.Clear(); + } + + // Find matching } + int end = FindClosingBrace(template, i + 1); + if (end < 0) + { + // Unclosed brace → treat rest as literal + sb.Append(template, i, template.Length - i); + i = template.Length; + continue; + } + + string hole = template.Substring(i + 1, end - i - 1); + tokens.Add(ParseHole(hole)); + i = end + 1; + } + else if (c == '}' && i + 1 < template.Length && template[i + 1] == '}') + { + // Escaped }} + sb.Append('}'); + i += 2; + } + else + { + sb.Append(c); + i++; + } + } + + if (sb.Length > 0) + { + tokens.Add(TemplateToken.Literal(sb.ToString())); + } + + return new MessageTemplate(template, tokens.ToArray()); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int FindClosingBrace(string template, int startPos) + { + for (int i = startPos; i < template.Length; i++) + { + if (template[i] == '}') + { + return i; + } + } + return -1; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static TemplateToken ParseHole(string hole) + { + if (string.IsNullOrEmpty(hole)) + { + return TemplateToken.Literal("{}"); + } + + var hint = TokenDestructureHint.Default; + int nameStart = 0; + bool isConditional = false; + + // Check for conditional {?Property:True|False} + if (hole[0] == '?') + { + isConditional = true; + nameStart = 1; + } + else if (hole[0] == '@') { hint = TokenDestructureHint.Destructure; nameStart = 1; } + else if (hole[0] == '$') { hint = TokenDestructureHint.Stringify; nameStart = 1; } + + // Extract components: name, alignment, format, filters, fallback, conditional + string name; + string? format = null; + int alignment = 0; + string[]? filters = null; + string? fallback = null; + (string?, string?) conditionalValues = (null, null); + + // Parse: name[,alignment][|filters][??fallback][:format][:trueval|falseval] + string remaining = hole.Substring(nameStart); + + // First, check for conditional values (only for conditional tokens) + if (isConditional && remaining.Contains(":")) + { + int colonIdx = remaining.IndexOf(':'); + string beforeColon = remaining.Substring(0, colonIdx); + string afterColon = remaining.Substring(colonIdx + 1); + + if (afterColon.Contains("|")) + { + int pipeIdx = afterColon.IndexOf('|'); + conditionalValues.Item1 = afterColon.Substring(0, pipeIdx).Trim(); + conditionalValues.Item2 = afterColon.Substring(pipeIdx + 1).Trim(); + remaining = beforeColon; + } + } + + // Parse filters (|uppercase, |truncate:10, etc.) + if (remaining.Contains("|")) + { + int pipeIdx = remaining.IndexOf('|'); + string namePart = remaining.Substring(0, pipeIdx); + string filterPart = remaining.Substring(pipeIdx + 1); + remaining = namePart; + filters = filterPart.Split('|'); + } + + // Parse fallback (??'default') + if (remaining.Contains("??")) + { + int fallbackIdx = remaining.IndexOf("??"); + string namePart = remaining.Substring(0, fallbackIdx); + fallback = remaining.Substring(fallbackIdx + 2).Trim(); + if (fallback.StartsWith("'") && fallback.EndsWith("'")) + { + fallback = fallback.Substring(1, fallback.Length - 2); + } + + remaining = namePart; + } + + // Parse alignment (,10 or ,-10) + if (remaining.Contains(",")) + { + int commaIdx = remaining.IndexOf(','); + string namePart = remaining.Substring(0, commaIdx); + string alignStr = remaining.Substring(commaIdx + 1).Trim(); + if (int.TryParse(alignStr, out int align)) + { + alignment = align; + } + + remaining = namePart; + } + + // Parse format (:D2, :C, etc.) + if (remaining.Contains(":") && !isConditional) + { + int colonIdx = remaining.IndexOf(':'); + string namePart = remaining.Substring(0, colonIdx); + format = remaining.Substring(colonIdx + 1); + remaining = namePart; + } + + name = remaining.Trim(); + + // Positional? + int position = -1; + if (name.Length > 0 && IsDigits(name)) + { + int.TryParse(name, out position); + } + + return TemplateToken.Property(name, position, hint, string.IsNullOrEmpty(format) ? null : format, alignment, filters, fallback, conditionalValues); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsDigits(string s) + { + foreach (var c in s) + { + if (c < '0' || c > '9') + { + return false; + } + } + + return true; + } + + /// + /// Renders the template to a string, binding positional args and returning + /// a dictionary of named properties. + /// + public string Render(object?[]? args, out Dictionary properties) + { + properties = new Dictionary(StringComparer.Ordinal); + var sb = new StringBuilder(_raw.Length + 32); + int positionalIndex = 0; + + foreach (ref readonly var token in _tokens.AsSpan()) + { + if (token.IsLiteral) + { + sb.Append(token.Text); + continue; + } + + object? value; + + if (token.Position >= 0) + { + // Positional placeholder {0}, {1} … + value = (args != null && token.Position < args.Length) ? args[token.Position] : null; + } + else if (args != null && positionalIndex < args.Length && IsArgumentDriven(args)) + { + value = args[positionalIndex++]; + properties[token.Name!] = value; + } + else + { + value = ResolveNestedProperty(token.Name, properties); + } + + if (token.Name != null && token.Position < 0) + { + properties[token.Name] = value; + } + + AppendValue(sb, value, token.Hint, token.Format, token.Alignment, token.Filters, token.Fallback, token.ConditionalValues); + } + + return sb.ToString(); + } + + /// + /// Resolves nested property access (e.g., "Object.Property.SubProperty") + /// + private static object? ResolveNestedProperty(string? propertyPath, Dictionary properties) + { + if (propertyPath == null) + { + return null; + } + + // Check for array indexing: Array[0] + if (propertyPath.Contains("[")) + { + int bracketIdx = propertyPath.IndexOf('['); + string baseName = propertyPath.Substring(0, bracketIdx); + string indexStr = propertyPath.Substring(bracketIdx + 1); + if (indexStr.EndsWith("]")) + { + indexStr = indexStr.Substring(0, indexStr.Length - 1); + } + + if (properties.TryGetValue(baseName, out var collection)) + { + return GetCollectionItem(collection, indexStr); + } + return null; + } + + // Handle nested property access with dots + if (!propertyPath.Contains(".")) + { + return properties.TryGetValue(propertyPath, out var val) ? val : null; + } + + string[] parts = propertyPath.Split('.'); + object? current = null; + + if (!properties.TryGetValue(parts[0], out current)) + { + return null; + } + + for (int i = 1; i < parts.Length && current != null; i++) + { + current = GetPropertyValue(current, parts[i]); + } + + return current; + } + + private static object? GetPropertyValue(object? obj, string propertyName) + { + if (obj == null) + { + return null; + } + + // Check for array indexing in nested path + if (propertyName.Contains("[")) + { + int bracketIdx = propertyName.IndexOf('['); + string actualProp = propertyName.Substring(0, bracketIdx); + string indexStr = propertyName.Substring(bracketIdx + 1); + if (indexStr.EndsWith("]")) + { + indexStr = indexStr.Substring(0, indexStr.Length - 1); + } + + var prop = obj.GetType().GetProperty(actualProp, System.Reflection.BindingFlags.IgnoreCase | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + if (prop?.CanRead == true) + { + var collection = prop.GetValue(obj); + return GetCollectionItem(collection, indexStr); + } + return null; + } + + var propInfo = obj.GetType().GetProperty(propertyName, System.Reflection.BindingFlags.IgnoreCase | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + return propInfo?.CanRead == true ? propInfo.GetValue(obj) : null; + } + + private static object? GetCollectionItem(object? collection, string indexStr) + { + if (collection == null) + { + return null; + } + + try + { + if (collection is System.Collections.IList list && int.TryParse(indexStr, out int index)) + { + return index >= 0 && index < list.Count ? list[index] : null; + } + } + catch { } + + return null; + } + + /// + /// Renders and populates the builder's properties from named holes. + /// + public string RenderInto(object?[]? args, LogEventBuilder builder) + { + var rendered = Render(args, out var props); + foreach (var kv in props) + { + builder.WithProperty(kv.Key, kv.Value); + } + + return rendered; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void AppendValue(StringBuilder sb, object? value, TokenDestructureHint hint, string? format, int alignment, string[]? filters, string? fallback, (string?, string?) conditionalValues) + { + // Handle conditional rendering + if (conditionalValues.Item1 != null || conditionalValues.Item2 != null) + { + bool isTrue = IsTruthy(value); + sb.Append(isTrue ? conditionalValues.Item1 : conditionalValues.Item2); + return; + } + + // Handle null/fallback + if (value == null) + { + sb.Append(fallback ?? "null"); + return; + } + + // Apply filters + if (filters != null && filters.Length > 0) + { + value = ApplyFilters(value, filters); + } + + string formatted = FormatValue(value, hint, format); + + // Apply alignment + if (alignment != 0) + { + formatted = alignment > 0 + ? formatted.PadLeft(alignment) + : formatted.PadRight(-alignment); + } + + sb.Append(formatted); + } + + private static bool IsTruthy(object? value) + { + if (value == null) + { + return false; + } + + if (value is bool b) + { + return b; + } + + if (value is int i) + { + return i != 0; + } + + if (value is long l) + { + return l != 0; + } + + if (value is string s) + { + return !string.IsNullOrEmpty(s); + } + + return true; + } + + private static object? ApplyFilters(object? value, string[] filters) + { + if (value == null) + { + return null; + } + + foreach (var filter in filters) + { + string filterName = filter; + string? filterParam = null; + + if (filter.Contains(":", StringComparison.Ordinal)) + { + int colonIdx = filter.IndexOf(':'); + filterName = filter.Substring(0, colonIdx).Trim(); + filterParam = filter.Substring(colonIdx + 1).Trim(); + } + + value = ApplyFilter(value, filterName, filterParam); + } + + return value; + } + + private static object? ApplyFilter(object? value, string filterName, string? param) + { + if (value == null) + { + return null; + } + + string str = value.ToString() ?? ""; + + if (filterName.Equals("reverse", StringComparison.OrdinalIgnoreCase)) + { + var chars = str.ToCharArray(); + System.Array.Reverse(chars); + return new string(chars); + } + + return filterName.ToLowerInvariant() switch + { + "uppercase" or "upper" => str.ToUpperInvariant(), + "lowercase" or "lower" => str.ToLowerInvariant(), + "trim" => str.Trim(), + "truncate" => param != null && int.TryParse(param, out int len) ? (str.Length > len ? str.Substring(0, len) + "…" : str) : str, + "substr" or "substring" => param != null && int.TryParse(param, out int pos) && pos < str.Length ? str.Substring(pos) : str, + _ => str + }; + } + + private static string FormatValue(object value, TokenDestructureHint hint, string? format) + { + if (hint == TokenDestructureHint.Destructure) + { + return Destructure(value); + } + + if (value is IFormattable formattable && format != null) + { + try + { + return formattable.ToString(format, System.Globalization.CultureInfo.InvariantCulture); + } + catch + { + return value.ToString() ?? ""; + } + } + + return value.ToString() ?? ""; + } + + private static string Destructure(object obj) + { + if (obj == null) + { + return "null"; + } + + var t = obj.GetType(); + if (t.IsPrimitive || obj is string || obj is decimal || obj is DateTime || obj is DateTimeOffset || obj is Guid) + { + return obj.ToString()!; + } + + // Deep property bag with nesting + var sb = new StringBuilder("{"); + bool first = true; + foreach (var prop in t.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)) + { + try + { + if (!first) + { + sb.Append(", "); + } + + var val = prop.GetValue(obj); + sb.Append(prop.Name).Append(": "); + if (val == null) + { + sb.Append("null"); + } + else if (val.GetType().IsPrimitive || val is string || val is decimal || val is DateTime || val is DateTimeOffset) + { + sb.Append(val); + } + else + { + sb.Append(Destructure(val)); + } + + first = false; + } + catch { } + } + sb.Append('}'); + return sb.ToString(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsArgumentDriven(object?[] args) => args.Length > 0; + + // Template cache to avoid re-parsing the same strings + private static readonly System.Collections.Concurrent.ConcurrentDictionary _cache + = new(StringComparer.Ordinal); + + /// Returns a cached parsed template (recommended for hot paths). + public static MessageTemplate FromCache(string template) => + _cache.GetOrAdd(template, static t => Parse(t)); + + /// Clears the template parse cache. + public static void ClearCache() => _cache.Clear(); +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Policies/PolicyEngine.cs b/EonaCat.LogStack/EonaCatLoggerCore/Policies/PolicyEngine.cs new file mode 100644 index 0000000..f4e23f0 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Policies/PolicyEngine.cs @@ -0,0 +1,371 @@ +using EonaCat.LogStack.Core; +using EonaCat.LogStack.Flows; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace EonaCat.LogStack.Policies; + +// 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. + +/// +/// Represents a routing rule that determines which flows should handle an event +/// +public class RoutingRule +{ + public string Name { get; set; } = ""; + public int Priority { get; set; } + public Func Condition { get; set; } = _ => true; + public List TargetFlows { get; set; } = new(); + public bool IsActive { get; set; } = true; +} + +/// +/// Policy engine that manages conditional routing and flow selection +/// +public sealed class PolicyEngine +{ + private readonly List _rules = new(); + private readonly Dictionary _flowRegistry = new(); + private readonly object _lock = new object(); + + public PolicyEngine() { } + + /// + /// Registers a flow by name + /// + public void RegisterFlow(string name, IFlow flow) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + lock (_lock) + { + _flowRegistry[name] = flow ?? throw new ArgumentNullException(nameof(flow)); + } + } + + /// + /// Unregisters a flow + /// + public void UnregisterFlow(string name) + { + lock (_lock) + { + _flowRegistry.Remove(name); + } + } + + /// + /// Adds a routing rule + /// + public PolicyEngine AddRule(RoutingRule rule) + { + if (rule == null) + { + throw new ArgumentNullException(nameof(rule)); + } + + lock (_lock) + { + _rules.Add(rule); + _rules.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + } + + return this; + } + + /// + /// Creates and adds a conditional routing rule + /// + public PolicyEngine AddConditionalRule( + string name, + Func condition, + List targetFlows, + int priority = 0) + { + var rule = new RoutingRule + { + Name = name, + Condition = condition, + TargetFlows = targetFlows, + Priority = priority, + IsActive = true + }; + + return AddRule(rule); + } + + /// + /// Creates a rule that routes errors to specific flows + /// + public PolicyEngine AddErrorRoute(List targetFlows, int priority = 10) + { + return AddConditionalRule( + "ErrorRoute", + e => e.Level >= LogLevel.Error, + targetFlows, + priority); + } + + /// + /// Creates a rule that routes events from a specific category + /// + public PolicyEngine AddCategoryRoute(string category, List targetFlows, int priority = 5) + { + return AddConditionalRule( + $"Category:{category}", + e => e.Category == category, + targetFlows, + priority); + } + + /// + /// Gets the flows that should handle a given event + /// + public List GetTargetFlows(LogEvent logEvent) + { + lock (_lock) + { + var targetFlows = new List(); + + foreach (var rule in _rules) + { + if (!rule.IsActive) + { + continue; + } + + if (rule.Condition(logEvent)) + { + foreach (var flowName in rule.TargetFlows) + { + if (_flowRegistry.TryGetValue(flowName, out var flow)) + { + targetFlows.Add(flow); + } + } + break; // First matching rule wins + } + } + + return targetFlows.Count > 0 ? targetFlows : new List(_flowRegistry.Values); + } + } + + /// + /// Enables/disables a rule by name + /// + public void SetRuleActive(string ruleName, bool active) + { + lock (_lock) + { + var rule = _rules.FirstOrDefault(r => r.Name == ruleName); + if (rule != null) + { + rule.IsActive = active; + } + } + } + + /// + /// Gets all registered flows + /// + public Dictionary GetAllFlows() + { + lock (_lock) + { + return new Dictionary(_flowRegistry); + } + } + + /// + /// Gets all routing rules + /// + public List GetRules() + { + lock (_lock) + { + return new List(_rules); + } + } + + /// + /// Clears all rules + /// + public void ClearRules() + { + lock (_lock) + { + _rules.Clear(); + } + } + + /// + /// Gets diagnostics about policies + /// + public PolicyDiagnostics GetDiagnostics() + { + lock (_lock) + { + return new PolicyDiagnostics + { + TotalRules = _rules.Count, + ActiveRules = _rules.Count(r => r.IsActive), + RegisteredFlows = _flowRegistry.Count + }; + } + } +} + +/// +/// Diagnostics about policy engine state +/// +public class PolicyDiagnostics +{ + public int TotalRules { get; set; } + public int ActiveRules { get; set; } + public int RegisteredFlows { get; set; } +} + +/// +/// Quota management for rate limiting across categories +/// +public sealed class QuotaManager +{ + private class QuotaEntry + { + public int AllowedPerSecond { get; set; } + public DateTime WindowStart { get; set; } = DateTime.UtcNow; + public int CurrentCount { get; set; } + } + + private readonly Dictionary _quotas = new(); + private readonly object _lock = new object(); + + /// + /// Sets the quota for a category + /// + public void SetQuota(string category, int eventsPerSecond) + { + if (eventsPerSecond <= 0) + { + throw new ArgumentOutOfRangeException(nameof(eventsPerSecond)); + } + + lock (_lock) + { + _quotas[category] = new QuotaEntry { AllowedPerSecond = eventsPerSecond }; + } + } + + /// + /// Checks if an event should be allowed based on quota + /// + public bool IsAllowed(string category) + { + lock (_lock) + { + if (!_quotas.TryGetValue(category, out var quota)) + { + return true; // No quota = unlimited + } + + var now = DateTime.UtcNow; + if ((now - quota.WindowStart).TotalSeconds >= 1.0) + { + // New window + quota.WindowStart = now; + quota.CurrentCount = 0; + } + + if (quota.CurrentCount < quota.AllowedPerSecond) + { + quota.CurrentCount++; + return true; + } + + return false; + } + } + + /// + /// Gets usage statistics for all quotas + /// + public Dictionary GetStatistics() + { + lock (_lock) + { + return _quotas.ToDictionary( + kvp => kvp.Key, + kvp => (kvp.Value.AllowedPerSecond, kvp.Value.CurrentCount)); + } + } + + /// + /// Resets all quotas + /// + public void Reset() + { + lock (_lock) + { + foreach (var quota in _quotas.Values) + { + quota.CurrentCount = 0; + quota.WindowStart = DateTime.UtcNow; + } + } + } + + /// + /// Clears all quota entries + /// + public void Clear() + { + lock (_lock) + { + _quotas.Clear(); + } + } +} + +/// +/// Extension methods for policy management +/// +public static class PolicyExtensions +{ + /// + /// Adds a default error flow route + /// + public static PolicyEngine AddDefaultErrorHandling( + this PolicyEngine engine, + string errorFlowName) + { + return engine.AddErrorRoute(new List { errorFlowName }); + } + + /// + /// Creates a policy engine with common patterns + /// + public static PolicyEngine CreateStandardEngine( + IFlow consoleFlow, + IFlow fileFlow, + IFlow? errorFileFlow = null) + { + var engine = new PolicyEngine(); + engine.RegisterFlow("console", consoleFlow); + engine.RegisterFlow("file", fileFlow); + + if (errorFileFlow != null) + { + engine.RegisterFlow("error", errorFileFlow); + engine.AddErrorRoute(new List { "error", "console" }, priority: 10); + } + + return engine; + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Pooling/BufferPool.cs b/EonaCat.LogStack/EonaCatLoggerCore/Pooling/BufferPool.cs new file mode 100644 index 0000000..2d79f2a --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Pooling/BufferPool.cs @@ -0,0 +1,153 @@ +using System; +using System.Buffers; +using System.Collections.Concurrent; +using System.Threading; + +namespace EonaCat.LogStack.Pooling; + +// 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 buffer pool using ArrayPool for optimal memory efficiency. +/// Manages buffers across multiple size tiers for flexible allocation patterns. +/// +public sealed class BufferPool : IDisposable +{ + private readonly int[] _standardSizes = { 512, 1024, 4096, 8192, 16384, 65536 }; + private readonly ConcurrentDictionary> _pools; + private bool _isDisposed; + + public BufferPool() + { + _pools = new ConcurrentDictionary>(); + foreach (var size in _standardSizes) + { + _pools[size] = ArrayPool.Shared; + } + } + + /// + /// Rents a buffer of at least the specified length + /// + public byte[] Rent(int minimumLength) + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(BufferPool)); + } + + // Find the smallest standard size that fits + int selectedSize = minimumLength; + foreach (var size in _standardSizes) + { + if (size >= minimumLength) + { + selectedSize = size; + break; + } + } + + return ArrayPool.Shared.Rent(selectedSize); + } + + /// + /// Returns a buffer to the pool + /// + public void Return(byte[] buffer, bool clearBuffer = false) + { + if (_isDisposed || buffer == null) + { + return; + } + + ArrayPool.Shared.Return(buffer, clearBuffer); + } + + /// + /// Gets statistics about pool usage + /// + public ArrayPoolStatistics GetStatistics() + { + return new ArrayPoolStatistics + { + TotalAllocations = ArrayPool.Shared.GetTotalAllocations(), + BytesAllocated = ArrayPool.Shared.GetTotalBytesAllocated() + }; + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + // ArrayPool.Shared is static and managed by runtime + } +} + +/// +/// Statistics about array pool usage +/// +public class ArrayPoolStatistics +{ + public long TotalAllocations { get; set; } + public long BytesAllocated { get; set; } +} + +/// +/// Rented buffer that automatically returns itself to the pool when disposed +/// +public sealed class RentedBuffer : IDisposable +{ + private byte[] _buffer; + private readonly int _actualLength; + private readonly BufferPool? _pool; + + public RentedBuffer(BufferPool? pool, int minimumLength) + { + _pool = pool; + _buffer = pool?.Rent(minimumLength) ?? new byte[minimumLength]; + _actualLength = minimumLength; + } + + public byte[] Buffer => _buffer; + public Span AsSpan() => new Span(_buffer, 0, _actualLength); + public Memory AsMemory() => new Memory(_buffer, 0, _actualLength); + + public void Dispose() + { + if (_buffer != null) + { + _pool?.Return(_buffer, clearBuffer: true); + _buffer = null!; + } + } +} + +/// +/// Extension methods for ArrayPool integration +/// +public static class ArrayPoolExtensions +{ + /// + /// Gets total allocations from the shared ArrayPool (via reflection/diagnostics) + /// + public static long GetTotalAllocations(this ArrayPool pool) + { + // This is a placeholder - actual statistics would require reflection or instrumentation + // For now, this provides a hook for future diagnostics integration + return 0; + } + + /// + /// Gets total bytes allocated from the shared ArrayPool + /// + public static long GetTotalBytesAllocated(this ArrayPool pool) + { + // This is a placeholder for diagnostics integration + return 0; + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Pooling/ObjectPool.cs b/EonaCat.LogStack/EonaCatLoggerCore/Pooling/ObjectPool.cs new file mode 100644 index 0000000..45a74b0 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Pooling/ObjectPool.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; + +namespace EonaCat.LogStack.Pooling; + +// 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 object pool for reusing expensive objects without external dependencies. +/// Supports automatic cleanup and size limiting. +/// +public sealed class ObjectPool : IDisposable where T : class +{ + private readonly ConcurrentBag _pool; + private readonly Func _factory; + private readonly Action? _resetAction; + private readonly int _maxSize; + private int _currentCount; + private bool _isDisposed; + + public ObjectPool(Func factory, Action? resetAction = null, int maxSize = 100) + { + _factory = factory ?? throw new ArgumentNullException(nameof(factory)); + _resetAction = resetAction; + _maxSize = maxSize; + _pool = new ConcurrentBag(); + _currentCount = 0; + } + + /// + /// Rents an object from the pool, creating a new one if necessary + /// + public T Rent() + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(ObjectPool)); + } + + if (_pool.TryTake(out var item)) + { + Interlocked.Decrement(ref _currentCount); + return item; + } + + return _factory(); + } + + /// + /// Returns an object to the pool after optional reset + /// + public void Return(T item) + { + if (_isDisposed || item == null) + { + return; + } + + if (Interlocked.Increment(ref _currentCount) <= _maxSize) + { + _resetAction?.Invoke(item); + _pool.Add(item); + } + else + { + Interlocked.Decrement(ref _currentCount); + (item as IDisposable)?.Dispose(); + } + } + + /// + /// Gets the current number of pooled objects + /// + public int PooledCount => _currentCount; + + /// + /// Clears all pooled objects + /// + public void Clear() + { + while (_pool.TryTake(out var item)) + { + (item as IDisposable)?.Dispose(); + Interlocked.Decrement(ref _currentCount); + } + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + Clear(); + } +} + +/// +/// Pooled object wrapper that returns itself to the pool when disposed +/// +public sealed class PooledObject : IDisposable where T : class +{ + private readonly ObjectPool _pool; + private readonly T _item; + + public PooledObject(ObjectPool pool) + { + _pool = pool ?? throw new ArgumentNullException(nameof(pool)); + _item = pool.Rent(); + } + + public T Item => _item; + + public void Dispose() + { + _pool.Return(_item); + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Pooling/StringBuilderPool.cs b/EonaCat.LogStack/EonaCatLoggerCore/Pooling/StringBuilderPool.cs new file mode 100644 index 0000000..cabf8cb --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Pooling/StringBuilderPool.cs @@ -0,0 +1,210 @@ +using System; +using System.Text; +using System.Collections.Concurrent; +using System.Threading; + +namespace EonaCat.LogStack.Pooling; + +// 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 StringBuilder pool with automatic capacity management. +/// Reuses StringBuilders to reduce GC pressure in high-throughput scenarios. +/// +public sealed class StringBuilderPool : IDisposable +{ + private readonly ConcurrentBag _smallPool; // <= 1KB + private readonly ConcurrentBag _mediumPool; // 1KB - 8KB + private readonly ConcurrentBag _largePool; // > 8KB + + private const int SmallCapacity = 1024; + private const int MediumCapacity = 8192; + private const int SmallPoolLimit = 20; + private const int MediumPoolLimit = 10; + private const int LargePoolLimit = 5; + + private int _smallCount; + private int _mediumCount; + private int _largeCount; + private bool _isDisposed; + + public StringBuilderPool() + { + _smallPool = new ConcurrentBag(); + _mediumPool = new ConcurrentBag(); + _largePool = new ConcurrentBag(); + } + + /// + /// Rents a StringBuilder from the appropriate pool based on requested capacity + /// + public StringBuilder Rent(int capacity = 1024) + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(StringBuilderPool)); + } + + ConcurrentBag? pool; + int currentCount; + + if (capacity <= SmallCapacity) + { + pool = _smallPool; + currentCount = _smallCount; + } + else if (capacity <= MediumCapacity) + { + pool = _mediumPool; + currentCount = _mediumCount; + } + else + { + pool = _largePool; + currentCount = _largeCount; + } + + if (currentCount > 0 && pool.TryTake(out var sb)) + { + if (capacity <= SmallCapacity) + { + Interlocked.Decrement(ref _smallCount); + } + else if (capacity <= MediumCapacity) + { + Interlocked.Decrement(ref _mediumCount); + } + else + { + Interlocked.Decrement(ref _largeCount); + } + + sb.Clear(); + return sb; + } + + return new StringBuilder(capacity); + } + + public void Return(StringBuilder sb) + { + if (_isDisposed || sb == null) + { + return; + } + + int capacity = sb.Capacity; + ConcurrentBag? pool; + int maxLimit; + + if (capacity <= SmallCapacity) + { + pool = _smallPool; + maxLimit = SmallPoolLimit; + if (Interlocked.Increment(ref _smallCount) <= maxLimit) + { + sb.Clear(); + pool.Add(sb); + } + else + { + Interlocked.Decrement(ref _smallCount); + } + } + else if (capacity <= MediumCapacity) + { + pool = _mediumPool; + maxLimit = MediumPoolLimit; + if (Interlocked.Increment(ref _mediumCount) <= maxLimit) + { + sb.Clear(); + pool.Add(sb); + } + else + { + Interlocked.Decrement(ref _mediumCount); + } + } + else + { + pool = _largePool; + maxLimit = LargePoolLimit; + if (Interlocked.Increment(ref _largeCount) <= maxLimit) + { + sb.Clear(); + pool.Add(sb); + } + else + { + Interlocked.Decrement(ref _largeCount); + } + } + } + + /// + /// Gets pooled item counts for diagnostics + /// + public (int small, int medium, int large) GetPoolCounts() + => (_smallCount, _mediumCount, _largeCount); + + /// + /// Clears all pools + /// + public void Clear() + { + while (_smallPool.TryTake(out _)) + { + ; + } + + while (_mediumPool.TryTake(out _)) + { + ; + } + + while (_largePool.TryTake(out _)) + { + ; + } + + _smallCount = 0; + _mediumCount = 0; + _largeCount = 0; + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + Clear(); + } +} + +/// +/// Rented StringBuilder that automatically returns itself to the pool when disposed +/// +public sealed class PooledStringBuilder : IDisposable +{ + private readonly StringBuilderPool _pool; + private readonly StringBuilder _sb; + + public PooledStringBuilder(StringBuilderPool pool, int capacity = 1024) + { + _pool = pool ?? throw new ArgumentNullException(nameof(pool)); + _sb = pool.Rent(capacity); + } + + public StringBuilder Builder => _sb; + + public override string ToString() => _sb.ToString(); + + public void Dispose() + { + _pool.Return(_sb); + } +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Structured/StructuredProperties.cs b/EonaCat.LogStack/EonaCatLoggerCore/Structured/StructuredProperties.cs new file mode 100644 index 0000000..87caa79 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Structured/StructuredProperties.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace EonaCat.LogStack.Structured; + +// 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. + +/// +/// Represents a structured property with type information +/// +public sealed class StructuredProperty +{ + public string Name { get; set; } + public object? Value { get; set; } + public Type? ValueType { get; set; } + public bool ShouldDestructure { get; set; } + + public StructuredProperty(string name, object? value, bool shouldDestructure = false) + { + Name = name; + Value = value; + ValueType = value?.GetType(); + ShouldDestructure = shouldDestructure; + } +} + +/// +/// Builder for structured properties using Serilog-like syntax +/// +public sealed class StructuredPropertyBuilder +{ + private readonly Dictionary _properties = new(); + + /// + /// Adds a simple property + /// + public StructuredPropertyBuilder Add(string name, object? value) + { + _properties[name] = new StructuredProperty(name, value, false); + return this; + } + + /// + /// Adds a property that should be destructured (rendered as JSON) + /// + public StructuredPropertyBuilder AddDestructured(string name, object? value) + { + _properties[name] = new StructuredProperty(name, value, true); + return this; + } + + /// + /// Adds multiple properties from an anonymous object + /// Example: .AddFromAnonymous(new { UserId = 123, Action = "Login" }) + /// + public StructuredPropertyBuilder AddFromAnonymous(object anonymous) + { + if (anonymous == null) + { + return this; + } + + var properties = anonymous.GetType().GetProperties(); + foreach (var prop in properties) + { + try + { + var value = prop.GetValue(anonymous); + Add(prop.Name, value); + } + catch + { + // Ignore properties that can't be read + } + } + + return this; + } + + /// + /// Adds multiple properties from a dictionary + /// + public StructuredPropertyBuilder AddFromDictionary(Dictionary dict) + { + if (dict == null) + { + return this; + } + + foreach (var kvp in dict) + { + Add(kvp.Key, kvp.Value); + } + + return this; + } + + /// + /// Removes a property + /// + public StructuredPropertyBuilder Remove(string name) + { + _properties.Remove(name); + return this; + } + + /// + /// Clears all properties + /// + public StructuredPropertyBuilder Clear() + { + _properties.Clear(); + return this; + } + + /// + /// Gets the built dictionary + /// + public Dictionary Build() + { + return _properties.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Value); + } + + /// + /// Gets the structured properties + /// + public Dictionary BuildStructured() + { + return new Dictionary(_properties); + } + + /// + /// Gets count of properties + /// + public int Count => _properties.Count; +} + +/// +/// Contextual property scope that inherits to nested calls +/// Similar to Serilog's LogContext.PushProperty +/// +public sealed class PropertyScope : IDisposable +{ + private readonly Stack> _scopeStack; + private readonly Dictionary _currentScope; + + private static readonly AsyncLocal _currentScopeHolder = new(); + + public PropertyScope(Dictionary initialProperties) + { + _scopeStack = new Stack>(); + _currentScope = new Dictionary(initialProperties); + _scopeStack.Push(_currentScope); + + var previous = _currentScopeHolder.Value; + _currentScopeHolder.Value = this; + } + + public void PushScope(Dictionary properties) + { + var newScope = new Dictionary(_scopeStack.Peek()); + foreach (var kvp in properties) + { + newScope[kvp.Key] = kvp.Value; + } + _scopeStack.Push(newScope); + } + + public void PopScope() + { + if (_scopeStack.Count > 1) + { + _scopeStack.Pop(); + } + } + + public Dictionary GetCurrentProperties() + { + if (_scopeStack.Count == 0) + { + return new Dictionary(); + } + + return new Dictionary(_scopeStack.Peek()); + } + + public static Dictionary GetActiveProperties() + { + var scope = _currentScopeHolder.Value; + return scope?.GetCurrentProperties() ?? new Dictionary(); + } + + public void Dispose() + { + _scopeStack.Clear(); + if (_currentScopeHolder.Value == this) + { + _currentScopeHolder.Value = null; + } + } +} + +/// +/// Extensions for adding properties to LogEvent +/// +public static class StructuredPropertyExtensions +{ + // Note: These extension methods are intended to extend LogEventBuilder + // from EonaCat.LogStack.Core once that type is available. + // For now, they are provided as examples for integration patterns. +} diff --git a/EonaCat.LogStack/EonaCatLoggerCore/Templates/MessageTemplateEngine.cs b/EonaCat.LogStack/EonaCatLoggerCore/Templates/MessageTemplateEngine.cs new file mode 100644 index 0000000..4a32514 --- /dev/null +++ b/EonaCat.LogStack/EonaCatLoggerCore/Templates/MessageTemplateEngine.cs @@ -0,0 +1,1122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace EonaCat.LogStack.Templates; + +// 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. + +/// +/// Legacy adapter to support existing MessageTemplateEngine interface +/// while maintaining compatibility with the enhanced MessageTemplate class. +/// +/// For new code, use MessageTemplate directly for better performance. +/// +public abstract class TemplateToken +{ + public abstract void Render(StringBuilder sb, Dictionary properties); +} + +/// +/// Literal text token +/// +public sealed class LiteralToken : TemplateToken +{ + private readonly string _text; + + public LiteralToken(string text) + { + _text = text; + } + + public override void Render(StringBuilder sb, Dictionary properties) + { + sb.Append(_text); + } +} + +/// +/// Conditional token for if/then/else constructs +/// Syntax: {@if:condition:trueOutput|falseOutput} +/// Example: {@if:IsActive:Active|Inactive} +/// +public sealed class ConditionalToken : TemplateToken +{ + private readonly string _condition; + private readonly string _trueOutput; + private readonly string _falseOutput; + + public ConditionalToken(string condition, string trueOutput, string falseOutput) + { + _condition = condition; + _trueOutput = trueOutput; + _falseOutput = falseOutput; + } + + public override void Render(StringBuilder sb, Dictionary properties) + { + object? value = ResolveCondition(_condition, properties); + bool result = IsTruthy(value); + sb.Append(result ? _trueOutput : _falseOutput); + } + + private static object? ResolveCondition(string condition, Dictionary properties) + { + // Support simple property names or comparisons like "Count>0" or "Status==Active" + condition = condition.Trim(); + + // Check for comparison operators + string[] operators = new[] { ">=", "<=", "==", "!=", ">", "<" }; + foreach (var op in operators) + { + if (condition.Contains(op, StringComparison.Ordinal)) + { + int opIdx = condition.IndexOf(op, StringComparison.Ordinal); + string left = condition.Substring(0, opIdx).Trim(); + string right = condition.Substring(opIdx + op.Length).Trim(); + + object? leftVal = GetPropertyValue(left, properties); + object? rightVal = GetPropertyValue(right, properties); + + return CompareValues(leftVal, rightVal, op); + } + } + + // Simple property name + return properties.TryGetValue(condition, out var val) ? val : null; + } + + private static object? GetPropertyValue(string key, Dictionary properties) + { + key = key.Trim(); + + // Check if it's a property reference + if (properties.TryGetValue(key, out var val)) + { + return val; + } + + // Try to parse as number + if (int.TryParse(key, out int intVal)) + { + return intVal; + } + + if (double.TryParse(key, out double dblVal)) + { + return dblVal; + } + + // Treat as string literal + if ((key.StartsWith("'") && key.EndsWith("'")) || (key.StartsWith("\"") && key.EndsWith("\""))) + { + return key.Substring(1, key.Length - 2); + } + + return key; + } + + private static bool CompareValues(object? left, object? right, string op) + { + return op switch + { + "==" => ValuesEqual(left, right), + "!=" => !ValuesEqual(left, right), + ">" => CompareNumeric(left, right) > 0, + "<" => CompareNumeric(left, right) < 0, + ">=" => CompareNumeric(left, right) >= 0, + "<=" => CompareNumeric(left, right) <= 0, + _ => false + }; + } + + private static bool ValuesEqual(object? left, object? right) + { + if (left == null && right == null) + { + return true; + } + + if (left == null || right == null) + { + return false; + } + + return left.ToString().Equals(right.ToString(), StringComparison.OrdinalIgnoreCase); + } + + private static int CompareNumeric(object? left, object? right) + { + double leftNum = ToDouble(left); + double rightNum = ToDouble(right); + return leftNum.CompareTo(rightNum); + } + + private static double ToDouble(object? value) + { + return value switch + { + null => 0, + double d => d, + int i => i, + long l => l, + float f => f, + decimal dec => (double)dec, + string s => double.TryParse(s, out double d) ? d : 0, + _ => 0 + }; + } + + private static bool IsTruthy(object? value) + { + if (value == null) + { + return false; + } + + if (value is bool b) + { + return b; + } + + if (value is int i) + { + return i != 0; + } + + if (value is long l) + { + return l != 0; + } + + if (value is double d) + { + return d != 0.0; + } + + if (value is string s) + { + return !string.IsNullOrEmpty(s); + } + + return true; + } +} + +/// +/// Loop token for iterating over arrays/lists +/// Syntax: {@loop:PropertyName:itemTemplate} +/// Example: {@loop:Items:Item={Item}} +/// +public sealed class LoopToken : TemplateToken +{ + private readonly string _collectionName; + private readonly string _itemTemplate; + private readonly string _separator; + + public LoopToken(string collectionName, string itemTemplate, string separator = ", ") + { + _collectionName = collectionName; + _itemTemplate = itemTemplate; + _separator = separator; + } + + public override void Render(StringBuilder sb, Dictionary properties) + { + if (!properties.TryGetValue(_collectionName, out var collection)) + { + return; + } + + var items = collection as System.Collections.IEnumerable; + if (items == null) + { + return; + } + + bool first = true; + foreach (var item in items) + { + if (!first) + { + sb.Append(_separator); + } + + // Create a local scope with the item + var itemProps = new Dictionary(properties) + { + { "Item", item } + }; + + // Render the item template + var renderer = new MessageTemplateRenderer(_itemTemplate); + renderer.Render(sb, itemProps); + + first = false; + } + } +} + +/// +/// Property reference token (e.g., {PropertyName}) +/// Supports nested properties, formatting, filters, conditionals, and fallback values +/// +public sealed class PropertyToken : TemplateToken +{ + private readonly string _propertyName; + private readonly string? _format; + private readonly bool _destructure; + private readonly int _alignment; + private readonly string[]? _filters; + private readonly string? _fallback; + private readonly string? _conditionalTrueValue; + private readonly string? _conditionalFalseValue; + + public string PropertyName => _propertyName; + public string? Format => _format; + public bool Destructure => _destructure; + public int Alignment => _alignment; + public string[]? Filters => _filters; + public string? Fallback => _fallback; + public string? ConditionalTrueValue => _conditionalTrueValue; + public string? ConditionalFalseValue => _conditionalFalseValue; + public bool IsConditional => _conditionalTrueValue != null || _conditionalFalseValue != null; + + public PropertyToken(string propertyName, string? format = null, bool destructure = false, int alignment = 0, string[]? filters = null, string? fallback = null, string? conditionalTrueValue = null, string? conditionalFalseValue = null) + { + _propertyName = propertyName; + _format = format; + _destructure = destructure; + _alignment = alignment; + _filters = filters; + _fallback = fallback; + _conditionalTrueValue = conditionalTrueValue; + _conditionalFalseValue = conditionalFalseValue; + } + + public override void Render(StringBuilder sb, Dictionary properties) + { + object? value = null; + + // Support nested property access (Object.Property.SubProperty) + if (_propertyName.Contains(".")) + { + value = ResolveNestedProperty(_propertyName, properties); + } + else if (!properties.TryGetValue(_propertyName, out value)) + { + value = null; + } + + // Handle conditionals: {?PropertyName:TrueValue|FalseValue} + if (IsConditional) + { + bool condition = IsTruthy(value); + string result = condition ? _conditionalTrueValue ?? "" : _conditionalFalseValue ?? ""; + sb.Append(result); + return; + } + + if (value == null) + { + sb.Append(_fallback ?? "null"); + return; + } + + // Apply filters + if (_filters != null && _filters.Length > 0) + { + value = ApplyFilters(value, _filters); + } + + string formatted = FormatValue(value, _destructure, _format); + + // Apply alignment + if (_alignment != 0) + { + formatted = _alignment > 0 + ? formatted.PadLeft(_alignment) + : formatted.PadRight(-_alignment); + } + + sb.Append(formatted); + } + + private static bool IsTruthy(object? value) + { + if (value == null) + { + return false; + } + + if (value is bool b) + { + return b; + } + + if (value is int i) + { + return i != 0; + } + + if (value is long l) + { + return l != 0; + } + + if (value is double d) + { + return d != 0.0; + } + + if (value is string s) + { + return !string.IsNullOrEmpty(s); + } + + return true; + } + + private static object? ResolveNestedProperty(string propertyPath, Dictionary properties) + { + if (propertyPath == null) + { + return null; + } + + string[] parts = propertyPath.Split('.'); + object? current = null; + + if (!properties.TryGetValue(parts[0], out current)) + { + return null; + } + + for (int i = 1; i < parts.Length && current != null; i++) + { + current = GetPropertyValue(current, parts[i]); + } + + return current; + } + + private static object? GetPropertyValue(object? obj, string propertyName) + { + if (obj == null) + { + return null; + } + + var propInfo = obj.GetType().GetProperty(propertyName, System.Reflection.BindingFlags.IgnoreCase | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + return propInfo?.CanRead == true ? propInfo.GetValue(obj) : null; + } + + private static object? ApplyFilters(object? value, string[] filters) + { + if (value == null) + { + return null; + } + + foreach (var filter in filters) + { + string filterName = filter; + string? filterParam = null; + + if (filter.Contains(":", StringComparison.Ordinal)) + { + int colonIdx = filter.IndexOf(':'); + filterName = filter.Substring(0, colonIdx).Trim(); + filterParam = filter.Substring(colonIdx + 1).Trim(); + } + + value = ApplyFilter(value, filterName, filterParam); + } + + return value; + } + + private static object? ApplyFilter(object? value, string filterName, string? param) + { + if (value == null) + { + return null; + } + + string str = value.ToString() ?? ""; + + if (filterName.Equals("reverse", StringComparison.OrdinalIgnoreCase)) + { + var chars = str.ToCharArray(); + System.Array.Reverse(chars); + return new string(chars); + } + + return filterName.ToLowerInvariant() switch + { + // String filters + "uppercase" or "upper" => str.ToUpperInvariant(), + "lowercase" or "lower" => str.ToLowerInvariant(), + "trim" => str.Trim(), + "trimstart" => str.TrimStart(), + "trimend" => str.TrimEnd(), + "truncate" => param != null && int.TryParse(param, out int len) ? (str.Length > len ? str.Substring(0, len) + "…" : str) : str, + "substr" or "substring" => param != null && int.TryParse(param, out int pos) && pos < str.Length ? str.Substring(pos) : str, + "replace" => ApplyReplaceFilter(str, param), + "pad" => ApplyPadFilter(str, param), + "repeat" => param != null && int.TryParse(param, out int count) ? string.Concat(System.Linq.Enumerable.Repeat(str, count)) : str, + "startswith" => param != null ? (str.StartsWith(param, StringComparison.OrdinalIgnoreCase) ? "true" : "false") : str, + "endswith" => param != null ? (str.EndsWith(param, StringComparison.OrdinalIgnoreCase) ? "true" : "false") : str, + "contains" => param != null ? (str.Contains(param, StringComparison.OrdinalIgnoreCase) ? "true" : "false") : str, + "split" => ApplySplitFilter(str, param), + + // Math filters + "add" => ApplyMathFilter(value, param, (a, b) => a + b), + "subtract" => ApplyMathFilter(value, param, (a, b) => a - b), + "multiply" => ApplyMathFilter(value, param, (a, b) => a * b), + "divide" => ApplyMathFilter(value, param, (a, b) => b != 0 ? a / b : double.NaN), + "modulo" => ApplyMathFilter(value, param, (a, b) => b != 0 ? a % b : double.NaN), + "abs" => ApplyMathFilter(value, param, (a, _) => Math.Abs(a)), + "floor" => ApplyMathFilter(value, param, (a, _) => Math.Floor(a)), + "ceil" => ApplyMathFilter(value, param, (a, _) => Math.Ceiling(a)), + "round" => param != null && int.TryParse(param, out int decimals) ? Math.Round(GetDoubleValue(value), decimals).ToString() : Math.Round(GetDoubleValue(value)).ToString(), + "min" => ApplyMathFilter(value, param, (a, b) => Math.Min(a, b)), + "max" => ApplyMathFilter(value, param, (a, b) => Math.Max(a, b)), + + // Comparison filters + "equals" => param != null ? (str.Equals(param, StringComparison.OrdinalIgnoreCase) ? "true" : "false") : str, + "lessthan" or "lt" => param != null && double.TryParse(param, out double paramVal) && double.TryParse(str, out double strVal) ? (strVal < paramVal ? "true" : "false") : str, + "greaterthan" or "gt" => param != null && double.TryParse(param, out double paramVal) && double.TryParse(str, out double strVal) ? (strVal > paramVal ? "true" : "false") : str, + "lessthaneq" or "lte" => param != null && double.TryParse(param, out double paramVal) && double.TryParse(str, out double strVal) ? (strVal <= paramVal ? "true" : "false") : str, + "greaterthaneq" or "gte" => param != null && double.TryParse(param, out double paramVal) && double.TryParse(str, out double strVal) ? (strVal >= paramVal ? "true" : "false") : str, + + // Date/Time filters + "date" => ApplyDateFilter(value, param), + "timespan" => ApplyTimeSpanFilter(value, param), + + // Default + _ => str + }; + } + + private static string ApplyReplaceFilter(string str, string? param) + { + if (string.IsNullOrEmpty(param)) + { + return str; + } + + var parts = param.Split(':'); + if (parts.Length >= 2) + { + return str.Replace(parts[0], parts[1]); + } + return str; + } + + private static string ApplyPadFilter(string str, string? param) + { + if (!int.TryParse(param, out int width)) + { + return str; + } + + return str.Length < width ? str.PadRight(width) : str; + } + + private static string ApplySplitFilter(string str, string? param) + { + if (string.IsNullOrEmpty(param)) + { + return str; + } + + var parts = str.Split(new[] { param }, StringSplitOptions.None); + return string.Join(", ", parts); + } + + private static string ApplyMathFilter(object? value, string? param, Func operation) + { + double baseVal = GetDoubleValue(value); + if (!double.TryParse(param, out double paramVal)) + { + return baseVal.ToString(); + } + + double result = operation(baseVal, paramVal); + return result.ToString(); + } + + private static double GetDoubleValue(object? value) + { + return value switch + { + double d => d, + float f => f, + int i => i, + long l => l, + decimal dec => (double)dec, + string s => double.TryParse(s, out double d) ? d : 0, + _ => 0 + }; + } + + private static string ApplyDateFilter(object? value, string? param) + { + if (value == null) + { + return ""; + } + + DateTime dt = value switch + { + DateTime d => d, + DateTimeOffset dto => dto.DateTime, + string s => DateTime.TryParse(s, out DateTime parsed) ? parsed : DateTime.Now, + _ => DateTime.Now + }; + + string format = param ?? "O"; + try + { + return dt.ToString(format, System.Globalization.CultureInfo.InvariantCulture); + } + catch + { + return dt.ToString(); + } + } + + private static string ApplyTimeSpanFilter(object? value, string? param) + { + if (value == null) + { + return ""; + } + + TimeSpan ts = value switch + { + TimeSpan t => t, + string s => TimeSpan.TryParse(s, out TimeSpan parsed) ? parsed : TimeSpan.Zero, + _ => TimeSpan.Zero + }; + + return param?.ToLowerInvariant() switch + { + "totalseconds" => ts.TotalSeconds.ToString(), + "totalminutes" => ts.TotalMinutes.ToString(), + "totalhours" => ts.TotalHours.ToString(), + "totaldays" => ts.TotalDays.ToString(), + "seconds" => ts.Seconds.ToString(), + "minutes" => ts.Minutes.ToString(), + "hours" => ts.Hours.ToString(), + "days" => ts.Days.ToString(), + _ => ts.ToString() + }; + } + + private static string FormatValue(object value, bool destructure, string? format) + { + if (destructure) + { + return DestructureObject(value); + } + + if (value is IFormattable formattable && format != null) + { + try + { + return formattable.ToString(format, System.Globalization.CultureInfo.InvariantCulture); + } + catch + { + return value.ToString() ?? ""; + } + } + + return value.ToString() ?? ""; + } + + private static string DestructureObject(object obj) + { + if (obj == null) + { + return "null"; + } + + var t = obj.GetType(); + if (t.IsPrimitive || obj is string || obj is decimal || obj is DateTime || obj is DateTimeOffset || obj is Guid) + { + return obj.ToString()!; + } + + var sb = new StringBuilder("{"); + bool first = true; + foreach (var prop in t.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)) + { + try + { + if (!first) + { + sb.Append(", "); + } + + var val = prop.GetValue(obj); + sb.Append(prop.Name).Append(": "); + if (val == null) + { + sb.Append("null"); + } + else if (val.GetType().IsPrimitive || val is string || val is decimal || val is DateTime || val is DateTimeOffset) + { + sb.Append(val); + } + else + { + sb.Append(DestructureObject(val)); + } + + first = false; + } + catch { } + } + sb.Append('}'); + return sb.ToString(); + } +} + +/// +/// Parses message templates (legacy interface for compatibility) +/// For new code, use MessageTemplate.Parse() directly +/// +public sealed class MessageTemplateParser +{ + private readonly string _template; + private int _position; + + public MessageTemplateParser(string template) + { + _template = template ?? ""; + } + + public List Parse() + { + var tokens = new List(); + var sb = new StringBuilder(); + + while (_position < _template.Length) + { + char c = _template[_position]; + + if (c == '{') + { + // Flush any accumulated literal text + if (sb.Length > 0) + { + tokens.Add(new LiteralToken(sb.ToString())); + sb.Clear(); + } + + // Try to parse special tokens first (@if, @loop) + TemplateToken? token = TryParseSpecialToken(); + if (token != null) + { + tokens.Add(token); + } + else + { + // Fall back to property token + _position--; // Back up to re-parse the opening brace + token = ParsePropertyToken(); + if (token != null) + { + tokens.Add(token); + } + } + } + else if (c == '}') + { + // Escaped closing brace + if (_position + 1 < _template.Length && _template[_position + 1] == '}') + { + sb.Append('}'); + _position += 2; + } + else + { + sb.Append(c); + _position++; + } + } + else + { + sb.Append(c); + _position++; + } + } + + // Flush remaining literal text + if (sb.Length > 0) + { + tokens.Add(new LiteralToken(sb.ToString())); + } + + return tokens; + } + + private TemplateToken? TryParseSpecialToken() + { + if (_template[_position] != '{') + { + return null; + } + + _position++; // Skip { + + // Look ahead for @ symbol + if (_position >= _template.Length || _template[_position] != '@') + { + _position--; // Back up if not @ + return null; + } + + _position++; // Skip @ + + // Read the keyword (if, loop, etc.) + var keywordSb = new StringBuilder(); + while (_position < _template.Length && char.IsLetter(_template[_position])) + { + keywordSb.Append(_template[_position]); + _position++; + } + + string keyword = keywordSb.ToString().ToLowerInvariant(); + + // Read the rest of the token content until } + if (_position >= _template.Length || _template[_position] != ':') + { + _position -= keyword.Length + 2; // Back up + return null; + } + + _position++; // Skip : + + var contentSb = new StringBuilder(); + int braceCount = 1; + while (_position < _template.Length && braceCount > 0) + { + char c = _template[_position]; + if (c == '{') + { + braceCount++; + } + else if (c == '}') + { + braceCount--; + } + + if (braceCount > 0) + { + contentSb.Append(c); + } + + _position++; + } + + string content = contentSb.ToString(); + + return keyword switch + { + "if" => ParseConditionalToken(content), + "loop" => ParseLoopToken(content), + _ => null + }; + } + + private TemplateToken? ParseConditionalToken(string content) + { + // Format: condition:trueOutput|falseOutput + int colonIdx = content.IndexOf(':'); + if (colonIdx == -1) + { + return null; + } + + string condition = content.Substring(0, colonIdx).Trim(); + string rest = content.Substring(colonIdx + 1).Trim(); + + int pipeIdx = rest.IndexOf('|'); + if (pipeIdx == -1) + { + return new ConditionalToken(condition, rest, ""); + } + + string trueOutput = rest.Substring(0, pipeIdx).Trim(); + string falseOutput = rest.Substring(pipeIdx + 1).Trim(); + + return new ConditionalToken(condition, trueOutput, falseOutput); + } + + private TemplateToken? ParseLoopToken(string content) + { + // Format: collectionName:itemTemplate or collectionName:itemTemplate:separator + var parts = content.Split(':'); + if (parts.Length < 2) + { + return null; + } + + string collectionName = parts[0].Trim(); + string itemTemplate = parts[1].Trim(); + string separator = parts.Length > 2 ? parts[2].Trim() : ", "; + + return new LoopToken(collectionName, itemTemplate, separator); + } + + private PropertyToken? ParsePropertyToken() + { + _position++; // Skip opening { + + if (_position >= _template.Length) + { + return null; + } + + // Check for conditional prefix ? + bool isConditional = false; + if (_template[_position] == '?') + { + isConditional = true; + _position++; + } + + // Check for destructuring prefix @ + bool destructure = false; + if (_position < _template.Length && _template[_position] == '@') + { + destructure = true; + _position++; + } + + // Read property name + var nameSb = new StringBuilder(); + while (_position < _template.Length) + { + char c = _template[_position]; + if (c == ':' || c == '}' || c == ',' || c == '|') + { + break; + } + + nameSb.Append(c); + _position++; + } + + string propertyName = nameSb.ToString().Trim(); + if (propertyName.Length == 0) + { + return null; + } + + // Read optional alignment (,10 or ,-10) + int alignment = 0; + if (_position < _template.Length && _template[_position] == ',') + { + _position++; // Skip comma + var alignSb = new StringBuilder(); + while (_position < _template.Length && (char.IsDigit(_template[_position]) || (_template[_position] == '-' && alignSb.Length == 0))) + { + alignSb.Append(_template[_position]); + _position++; + } + if (int.TryParse(alignSb.ToString(), out int align)) + { + alignment = align; + } + } + + // Read optional filters (|uppercase, |truncate:10) + string[]? filters = null; + if (_position < _template.Length && _template[_position] == '|') + { + _position++; // Skip pipe + var filterSb = new StringBuilder(); + while (_position < _template.Length && _template[_position] != ':' && _template[_position] != '}') + { + filterSb.Append(_template[_position]); + _position++; + } + string filterStr = filterSb.ToString().Trim(); + if (filterStr.Length > 0) + { + filters = filterStr.Split('|'); + } + } + + // Handle conditional values {?Property:TrueValue|FalseValue} + string? conditionalTrueValue = null; + string? conditionalFalseValue = null; + if (isConditional && _position < _template.Length && _template[_position] == ':') + { + _position++; // Skip : + var trueSb = new StringBuilder(); + while (_position < _template.Length && _template[_position] != '|' && _template[_position] != '}') + { + trueSb.Append(_template[_position]); + _position++; + } + conditionalTrueValue = trueSb.ToString().Trim(); + + if (_position < _template.Length && _template[_position] == '|') + { + _position++; // Skip | + var falseSb = new StringBuilder(); + while (_position < _template.Length && _template[_position] != '}') + { + falseSb.Append(_template[_position]); + _position++; + } + conditionalFalseValue = falseSb.ToString().Trim(); + } + } + else if (!isConditional) + { + // Read optional format specifier (non-conditional path) + string? format = null; + if (_position < _template.Length && _template[_position] == ':') + { + _position++; // Skip : + var formatSb = new StringBuilder(); + while (_position < _template.Length && _template[_position] != '}') + { + formatSb.Append(_template[_position]); + _position++; + } + format = formatSb.ToString().Trim(); + } + + // Skip closing } + if (_position < _template.Length && _template[_position] == '}') + { + _position++; + } + + return new PropertyToken(propertyName, format, destructure, alignment, filters); + } + + // Skip closing } + if (_position < _template.Length && _template[_position] == '}') + { + _position++; + } + + return new PropertyToken(propertyName, null, destructure, alignment, filters, null, conditionalTrueValue, conditionalFalseValue); + } +} + +/// +/// Renders a parsed message template with properties (legacy compatibility layer) +/// For new code, use MessageTemplate.Render() directly +/// +public sealed class MessageTemplateRenderer +{ + private readonly List _tokens; + + public MessageTemplateRenderer(string template) + { + var parser = new MessageTemplateParser(template); + _tokens = parser.Parse(); + } + + public MessageTemplateRenderer(List tokens) + { + _tokens = tokens; + } + + public string Render(Dictionary properties) + { + var sb = new StringBuilder(); + foreach (var token in _tokens) + { + token.Render(sb, properties); + } + return sb.ToString(); + } + + public void Render(StringBuilder sb, Dictionary properties) + { + foreach (var token in _tokens) + { + token.Render(sb, properties); + } + } + + /// + /// Gets all property names referenced in the template + /// + public IEnumerable GetPropertyNames() + { + return _tokens + .OfType() + .Select(t => t.PropertyName) + .Distinct(); + } +} + +/// +/// Template cache for frequently-used templates (legacy compatibility) +/// +public sealed class MessageTemplateCache +{ + private readonly Dictionary _cache = new(); + private readonly int _maxSize; + private readonly object _lock = new object(); + + public MessageTemplateCache(int maxSize = 1000) + { + _maxSize = maxSize; + } + + public MessageTemplateRenderer GetOrParse(string template) + { + lock (_lock) + { + if (_cache.TryGetValue(template, out var cached)) + { + return cached; + } + + if (_cache.Count >= _maxSize) + { + // Remove oldest entry (simple FIFO) + var oldestKey = _cache.Keys.First(); + _cache.Remove(oldestKey); + } + + var renderer = new MessageTemplateRenderer(template); + _cache[template] = renderer; + return renderer; + } + } + + public void Clear() + { + lock (_lock) + { + _cache.Clear(); + } + } + + public int Count + { + get + { + lock (_lock) + { + return _cache.Count; + } + } + } +} diff --git a/EonaCat.LogStack/Extensions/HostApplicationBuilderExtensions.cs b/EonaCat.LogStack/Extensions/HostApplicationBuilderExtensions.cs index 6efbb9a..01e03b5 100644 --- a/EonaCat.LogStack/Extensions/HostApplicationBuilderExtensions.cs +++ b/EonaCat.LogStack/Extensions/HostApplicationBuilderExtensions.cs @@ -1,14 +1,125 @@ +using EonaCat.LogStack.Configuration; using EonaCat.LogStack.Core; +using EonaCat.LogStack.Logging; using Microsoft.Extensions.Hosting; using System; namespace EonaCat.LogStack.Extensions; +/// +/// Extension methods for integrating EonaCat LogStack with HostApplicationBuilder +/// public static class HostApplicationBuilderExtensions { - public static IHostApplicationBuilder AddEonaCatLogging(this IHostApplicationBuilder builder, Action? configure=null) + /// + /// Adds EonaCat LogStack logging with a configuration callback + /// + public static IHostApplicationBuilder AddEonaCatLogging( + this IHostApplicationBuilder builder, + Action? configure = null) { - builder.Services.AddEonaCatLogging(configure ?? (_=>{})); + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.AddEonaCatLogging(configure ?? (_ => { })); + return builder; + } + + /// + /// Adds EonaCat LogStack logging with fluent LogBuilder configuration + /// + public static IHostApplicationBuilder AddEonaCatLogging( + this IHostApplicationBuilder builder, + Action configure) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + builder.Services.AddEonaCatLogging(configure); + return builder; + } + + /// + /// Adds EonaCat LogStack logging with fluent LogBuilder configuration and a specific category + /// + public static IHostApplicationBuilder AddEonaCatLogging( + this IHostApplicationBuilder builder, + string category, + Action configure) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + builder.Services.AddEonaCatLogging(category, configure); + return builder; + } + + /// + /// Adds EonaCat LogStack with AdvancedLoggerFactory for enhanced features + /// + public static IHostApplicationBuilder AddAdvancedEonaCatLogging( + this IHostApplicationBuilder builder, + LogLevel minimumLevel = LogLevel.Trace, + TimestampMode timestampMode = TimestampMode.Utc) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.AddAdvancedEonaCatLogging(minimumLevel, timestampMode); + return builder; + } + + /// + /// Adds EonaCat LogStack with AdvancedLoggerFactory and a configuration callback + /// + public static IHostApplicationBuilder AddAdvancedEonaCatLogging( + this IHostApplicationBuilder builder, + Action configure) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + builder.Services.AddAdvancedEonaCatLogging(configure); + return builder; + } + + /// + /// Adds the AdvancedMetricsCollector for system-wide analytics + /// + public static IHostApplicationBuilder AddEonaCatMetricsCollection( + this IHostApplicationBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.AddEonaCatMetricsCollection(); return builder; } } diff --git a/EonaCat.LogStack/Extensions/ServiceCollectionExtensions.cs b/EonaCat.LogStack/Extensions/ServiceCollectionExtensions.cs index 5211773..c92b1c5 100644 --- a/EonaCat.LogStack/Extensions/ServiceCollectionExtensions.cs +++ b/EonaCat.LogStack/Extensions/ServiceCollectionExtensions.cs @@ -248,4 +248,93 @@ public static class ServiceCollectionExtensions return services.AddEonaCatLoggingFactory("Application", configure); } + + /// + /// Registers EonaCat LogStack with AdvancedLoggerFactory for enhanced features like category-specific configuration and context propagation + /// + /// The service collection to register with + /// The minimum log level to process + /// The timestamp mode to use + /// The service collection for chaining + public static IServiceCollection AddAdvancedEonaCatLogging( + this IServiceCollection services, + LogLevel minimumLevel = LogLevel.Trace, + TimestampMode timestampMode = TimestampMode.Utc) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + var advancedFactory = new AdvancedLoggerFactory(minimumLevel, timestampMode); + services.AddSingleton(advancedFactory); + services.AddSingleton(advancedFactory); + services.AddSingleton( + new MicrosoftExtensionsLoggerFactoryAdapter(advancedFactory)); + + // Also register as ILogger for constructor injection + services.AddSingleton(sp => sp.GetRequiredService().CreateLogger("Default")); + services.AddSingleton(sp => + new MicrosoftExtensionsLoggerAdapter( + sp.GetRequiredService().CreateLogger("Default"))); + + return services; + } + + /// + /// Registers EonaCat LogStack with AdvancedLoggerFactory and a configuration callback + /// + /// The service collection to register with + /// Callback to configure the AdvancedLoggerFactory + /// The service collection for chaining + public static IServiceCollection AddAdvancedEonaCatLogging( + this IServiceCollection services, + Action configure) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + services.AddSingleton(sp => + { + var factory = new AdvancedLoggerFactory(); + configure(factory); + return factory; + }); + + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => + new MicrosoftExtensionsLoggerFactoryAdapter(sp.GetRequiredService())); + + // Also register as ILogger for constructor injection + services.AddSingleton(sp => sp.GetRequiredService().CreateLogger("Default")); + services.AddSingleton(sp => + new MicrosoftExtensionsLoggerAdapter( + sp.GetRequiredService().CreateLogger("Default"))); + + return services; + } + + /// + /// Registers the AdvancedMetricsCollector for system-wide analytics + /// + /// The service collection to register with + /// The service collection for chaining + public static IServiceCollection AddEonaCatMetricsCollection( + this IServiceCollection services) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + services.AddSingleton(); + return services; + } } diff --git a/EonaCat.LogStack/LogBuilder.cs b/EonaCat.LogStack/LogBuilder.cs index 51b3c4a..b07d8f1 100644 --- a/EonaCat.LogStack/LogBuilder.cs +++ b/EonaCat.LogStack/LogBuilder.cs @@ -28,7 +28,10 @@ public sealed class LogBuilder private TimestampMode _timestampMode = TimestampMode.Utc; private readonly List _flows = new(); private readonly List _boosters = new(); - + private DynamicLevelController? _dynamicLevel; + private bool _useAsyncPipeline; + private int _asyncPipelineCapacity = 65536; + public event EventHandler OnLog; public LogBuilder(string category = "Application") @@ -991,6 +994,16 @@ public sealed class LogBuilder logger.AddBooster(booster); } + if (_dynamicLevel != null) + { + logger.UseDynamicLevel(_dynamicLevel); + } + + if (_useAsyncPipeline) + { + logger.UseAsyncPipeline(_asyncPipelineCapacity); + } + return logger; } @@ -1080,4 +1093,113 @@ public sealed class LogBuilder lock (_flows) { _flows.RemoveAll(f => f.Name == name); } } + + /// + /// Enables the Channel-based async dispatch pipeline for zero-blocking logging. + /// Events are enqueued to a and + /// consumed by a dedicated background Task. + /// + /// Bounded capacity (0 = unbounded). + public LogBuilder UseAsyncPipeline(int capacity = 65536) + { + _useAsyncPipeline = true; + _asyncPipelineCapacity = capacity; + return this; + } + + /// + /// Attaches a so the minimum log level can + /// be changed at runtime without restarting the application. + /// + public LogBuilder WithDynamicLevelController(DynamicLevelController controller) + { + _dynamicLevel = controller ?? throw new ArgumentNullException(nameof(controller)); + return this; + } + + /// + /// Wraps any flow with a Circuit Breaker that opens after repeated failures + /// and probes for recovery after a configurable timeout. + /// + public LogBuilder WriteToCircuitBreaker( + IFlow inner, + int failureThreshold = 5, + TimeSpan? recoveryTimeout = null, + LogLevel minimumLevel = LogLevel.Trace) + { + if (inner == null) + { + throw new ArgumentNullException(nameof(inner)); + } + + _flows.Add(new CircuitBreakerFlow(inner, failureThreshold, recoveryTimeout, minimumLevel)); + return this; + } + + /// + /// Wraps any flow with a predicate — events are forwarded only when the predicate returns true. + /// + public LogBuilder WriteToConditional( + IFlow inner, + Func predicate, + LogLevel minimumLevel = LogLevel.Trace) + { + if (inner == null) + { + throw new ArgumentNullException(nameof(inner)); + } + + if (predicate == null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + _flows.Add(new ConditionalFlow(inner, predicate, minimumLevel)); + return this; + } + + /// + /// Adds a that fans events out to multiple inner flows in parallel. + /// Use to attach targets. + /// + public LogBuilder WriteToMulticast( + MulticastFlow multicast) + { + if (multicast == null) + { + throw new ArgumentNullException(nameof(multicast)); + } + + _flows.Add(multicast); + return this; + } + + /// + /// Pushes log events to a Grafana Loki instance. + /// + public LogBuilder WriteToLoki( + string lokiUrl, + Dictionary? labels = null, + int batchSize = 50, + int batchIntervalMs = 1000, + string? bearerToken = null, + string? basicUser = null, + string? basicPassword = null, + HttpClient? httpClient = null, + LogLevel minimumLevel = LogLevel.Trace) + { + _flows.Add(new LokiFlow( + lokiUrl, labels, batchSize, batchIntervalMs, + bearerToken, basicUser, basicPassword, httpClient, minimumLevel)); + return this; + } + + /// + /// Adds the which captures caller member/file/line. + /// + public LogBuilder BoostWithCallerInfo() + { + _boosters.Add(new CallerInfoBooster()); + return this; + } } \ No newline at end of file diff --git a/EonaCat.LogStack/LoggerDiagnostics.cs b/EonaCat.LogStack/LoggerDiagnostics.cs index 80ed0e6..8dc1740 100644 --- a/EonaCat.LogStack/LoggerDiagnostics.cs +++ b/EonaCat.LogStack/LoggerDiagnostics.cs @@ -16,6 +16,7 @@ public class LoggerDiagnostics public LogLevel MinimumLevel { get; set; } public long TotalLogged { get; set; } public long TotalDropped { get; set; } + public long TotalExceptions { get; set; } public int FlowCount { get; set; } public int BoosterCount { get; set; } public List Flows { get; set; } diff --git a/EonaCat.LogStack/LoggerMetrics.cs b/EonaCat.LogStack/LoggerMetrics.cs new file mode 100644 index 0000000..1011d53 --- /dev/null +++ b/EonaCat.LogStack/LoggerMetrics.cs @@ -0,0 +1,90 @@ +using EonaCat.LogStack.Analytics; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace EonaCat.LogStack; + +// 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. + +/// +/// Comprehensive metrics about the logger +/// +public class LoggerMetrics +{ + public long TotalLogged { get; set; } + public long TotalDropped { get; set; } + public long TotalExceptions { get; set; } + public long TotalBytes { get; set; } + public double WritesPerSecond { get; set; } + public long TraceCount { get; set; } + public long DebugCount { get; set; } + public long InformationCount { get; set; } + public long WarningCount { get; set; } + public long ErrorCount { get; set; } + public long CriticalCount { get; set; } + public long UptimeMilliseconds { get; set; } + public List FlowMetrics { get; set; } = new(); + + /// + /// Gets the success rate (logged / (logged + dropped)) + /// + public double SuccessRate + { + get + { + var total = TotalLogged + TotalDropped; + return total > 0 ? (TotalLogged * 100.0) / total : 100; + } + } + + /// + /// Gets average bytes per event + /// + public double AverageBytesPerEvent + { + get => TotalLogged > 0 ? TotalBytes / (double)TotalLogged : 0; + } + + /// + /// Returns formatted report + /// + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine("===== LOGGER METRICS ====="); + sb.AppendLine($"Total Logged: {TotalLogged:N0}"); + sb.AppendLine($"Total Dropped: {TotalDropped:N0}"); + sb.AppendLine($"Success Rate: {SuccessRate:F2}%"); + sb.AppendLine($"Total Exceptions: {TotalExceptions:N0}"); + sb.AppendLine($"Total Bytes: {TotalBytes:N0}"); + sb.AppendLine($"Average Bytes/Event: {AverageBytesPerEvent:F2}"); + sb.AppendLine($"Writes/Second: {WritesPerSecond:F2}"); + sb.AppendLine(); + + sb.AppendLine("Events by Level:"); + sb.AppendLine($" Trace: {TraceCount:N0}"); + sb.AppendLine($" Debug: {DebugCount:N0}"); + sb.AppendLine($" Information: {InformationCount:N0}"); + sb.AppendLine($" Warning: {WarningCount:N0}"); + sb.AppendLine($" Error: {ErrorCount:N0}"); + sb.AppendLine($" Critical: {CriticalCount:N0}"); + sb.AppendLine(); + + sb.AppendLine($"Uptime: {System.TimeSpan.FromMilliseconds(UptimeMilliseconds):hh\\:mm\\:ss}"); + + if (FlowMetrics.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("Flow Statistics:"); + foreach (var flow in FlowMetrics) + { + sb.AppendLine($" {flow.FlowName} ({flow.FlowType}): {flow.EventsProcessed:N0} events, {flow.SuccessRate:F2}% success"); + } + } + + sb.AppendLine("=========================="); + return sb.ToString(); + } +} diff --git a/README.md b/README.md index 339b369..d5ffa83 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,68 @@ # EonaCat.LogStack -**EonaCat.LogStack** flow-based logging library for .NET, designed for zero-allocation logging paths and superior memory efficiency. +**EonaCat.LogStack** is a flow-based, high-performance logging library for .NET, designed for zero-allocation logging paths and superior memory efficiency. It features a rich fluent API for routing log events to dozens of destinations - from console and file to Slack, Discord, Redis, Elasticsearch, and beyond. ---- - ## Features -- **Flow-based architecture** - route log events to one or many output destinations simultaneously -- **Booster system** - enrich every log event with contextual metadata (machine name, process ID, thread info, memory, uptime, correlation IDs, and more) -- **Pre-build modifiers** - intercept and mutate log events before they are written -- **Zero-allocation hot path** - `AggressiveInlining` throughout, `StringBuilder` pooling, and `ref`-based builder pattern -- **Async-first** - all flows implement `IAsyncDisposable` and `FlushAsync` -- **Resilience built-in** - retry, failover, throttling, and rolling buffer flows -- **Tamper-evident audit trail** - SHA-256 hash-chained audit files -- **Encrypted file logging** - AES-encrypted log files with a built-in decrypt utility -- **Compression** - GZip-compressed rolled log files -- **Category routing** - split logs into separate files per category or log level -- **Diagnostics** - live counters (total logged, total dropped, per-flow stats) +### Core Architecture +- **Flow-based system** - Route log events to multiple destinations simultaneously. Each flow is independent and can be configured with its own level filters and batching strategy. +- **Booster system** - Automatically enrich log events with contextual metadata like machine name, process ID, thread info, memory usage, uptime, correlation IDs, and custom properties. +- **Pre-build modifiers** - Intercept and mutate log events before they reach any flow using a chainable modifier system. +- **Zero-allocation hot path** - `AggressiveInlining` throughout, `StringBuilder` pooling, and `ref`-based builder pattern for minimal GC pressure during logging. +- **Async-first design** - All flows implement `IAsyncDisposable` and `FlushAsync()` for proper async resource cleanup and batching. ---- +### Advanced Features +- **Resilience patterns** - Built-in `RetryFlow` with exponential backoff, `FailoverFlow` for primary/secondary failover, and `ThrottledFlow` for rate limiting with deduplication. +- **Tamper-evident audit** - SHA-256 hash-chained audit files where deleting or modifying any past entry invalidates all subsequent hashes. Built-in integrity verification. +- **Encrypted file logging** - AES-encrypted log files with password protection. Includes `DecryptFile()` utility to decrypt files on demand. +- **Log compression** - Automatic GZip compression of rolled-over log files to conserve disk space. +- **Category/level-based routing** - Split logs into separate files per category or log level for better organization. +- **Rolling buffers** - Circular buffer with trigger-based flushing. When a critical event occurs, buffer context (previous N lines) is forwarded to a secondary flow. +- **Diagnostics & metrics** - Real-time counters for total logged, total dropped, per-flow statistics, and flow-specific performance data. +- **Live level control** - Dynamically change log level at runtime without recreating the logger. +- **Structured logging** - First-class support for structured properties (tuples, dictionaries) that are included in JSON output where applicable. + +### Performance & Reliability +- **Configurable backpressure** - Choose between Wait, Drop, or Block strategies when log queues are full. +- **Batch processing** - Most flows support configurable batch sizes and intervals for efficient I/O. +- **Rate limiting & deduplication** - Protect high-latency sinks (email, Slack, HTTP) from log storms using token-bucket rate limiting and optional message deduplication. +- **Memory flow** - Store recent logs in a circular in-memory buffer for quick diagnostics or fallback output. +- **Lazy initialization** - Flows are only initialized when first used, reducing startup overhead. ## Supported Targets - .NET Standard 2.1 - .NET 8.0 +- .NET 9.0 +- .NET 10.0 - .NET Framework 4.8 ---- - ## Installation ```bash dotnet add package EonaCat.LogStack ``` ---- - ## Quick Start +### Minimal - Console + File + ```csharp await using var logger = LogBuilder.CreateDefault("MyApp"); logger.Information("Application started"); logger.Warning("Low memory warning"); logger.Error(ex, "Unexpected error occurred"); + +// Automatic cleanup on disposal ``` `CreateDefault` creates a logger writing to both the console and a `./logs` directory, enriched with machine name and process ID. ---- +### Fluent Configuration -## Fluent Configuration - -Build a fully customised logger using `LogBuilder`: +Build a fully customized logger using `LogBuilder`: ```csharp await using var logger = new LogBuilder("MyApp") @@ -66,261 +75,2003 @@ await using var logger = new LogBuilder("MyApp") .BoostWithProcessId() .BoostWithCorrelationId() .Build(); + +try +{ + logger.Information("Application started"); + // Your application code... +} +finally +{ + await logger.FlushAsync(); + await logger.DisposeAsync(); +} ``` ---- - ## Logging Methods +### Basic Logging + ```csharp logger.Trace("Verbose trace message"); logger.Debug("Debug detail"); logger.Information("Something happened"); logger.Warning("Potential problem"); -logger.Warning(ex, "Warning with exception"); logger.Error("Something failed"); -logger.Error(ex, "Error with exception"); logger.Critical("System is going down"); -logger.Critical(ex, "Critical failure"); - -// With structured properties -logger.Log(LogLevel.Information, "User logged in", - ("UserId", 42), - ("IP", "192.168.1.1")); ``` ---- - -## Available Flows -### Flows can be extended with custom implementations of `IFlow`, but here are the built-in options: - -| Flow | Method | Description | -|------|--------|-------------| -| Console | `WriteToConsole()` | Colored console output | -| File | `WriteToFile()` | Batched, rotated, compressed file output | -| Encrypted File | `WriteToEncryptedFile()` | AES-encrypted log files | -| Memory | `WriteToMemory()` | In-memory ring buffer | -| Audit | `WriteToAudit()` | Tamper-evident hash-chained audit trail | -| Database | `WriteToDatabase()` | ADO.NET database sink | -| HTTP | `WriteToHttp()` | Generic HTTP endpoint (batched) | -| Webhook | `WriteToWebhook()` | Generic webhook POST | -| Email | `WriteToEmail()` | HTML digest emails via SMTP | -| Slack | `WriteToSlack()` | Slack incoming webhooks | -| Discord | `WriteToDiscord()` | Discord webhooks | -| Microsoft Teams | `WriteToMicrosoftTeams()` | Teams incoming webhooks | -| Telegram | `WriteToTelegram()` | Telegram bot messages | -| SignalR | `WriteToSignalR()` | Real-time SignalR hub push | -| Redis | `RedisFlow()` | Redis Pub/Sub + optional List persistence | -| Elasticsearch | `WriteToElasticSearch()` | Elasticsearch index | -| Splunk | `WriteToSplunkFlow()` | Splunk HEC | -| Graylog | `WriteToGraylogFlow()` | GELF over UDP or TCP | -| Syslog UDP | `WriteToSyslogUdp()` | RFC-5424 Syslog over UDP | -| Syslog TCP | `WriteToSyslogTcp()` | RFC-5424 Syslog over TCP (with optional TLS) | -| TCP | `WriteToTcp()` | Raw TCP (with optional TLS) | -| UDP | `WriteToUdp()` | Raw UDP datagrams | -| SNMP Trap | `WriteToSnmpTrap()` | SNMP v2c traps | -| Zabbix | `WriteToZabbixFlow()` | Zabbix trapper protocol | -| EventLog | `WriteToEventLogFlow()` | Remote event log forwarding | -| Rolling Buffer | `WriteToRollingBuffer()` | Circular buffer with trigger-based flush | -| Throttled | `WriteToThrottled()` | Token-bucket rate limiting + deduplication | -| Retry | `WriteToRetry()` | Automatic retry with exponential back-off | -| Failover | `WriteToFailover()` | Primary/secondary failover | -| Diagnostics | `WriteDiagnostics()` | Periodic diagnostic snapshots | -| Status | `WriteToStatusFlow()` | Service health monitoring | - ---- - -## Available Boosters - -Boosters enrich every log event with additional properties before it reaches any flow. +### Logging with Exceptions ```csharp -new LogBuilder("MyApp") - .BoostWithMachineName() // host name - .BoostWithProcessId() // PID - .BoostWithThreadId() // managed thread ID - .BoostWithThreadName() // thread name - .BoostWithUser() // current OS user - .BoostWithApp() // app name and base directory - .BoostWithApplication("MyApp", "2.0.0") // explicit name + version - .BoostWithEnvironment("Production") - .BoostWithOS() // OS description - .BoostWithFramework() // .NET runtime description - .BoostWithMemory() // working set in MB - .BoostWithUptime() // process uptime in seconds - .BoostWithProcStart() // process start time - .BoostWithDate() // current date (yyyy-MM-dd) - .BoostWithTime() // current time (HH:mm:ss.fff) - .BoostWithTicks() // current timestamp ticks - .BoostWithCorrelationId() // Activity.Current correlation ID - .BoostWithCustomText("env", "prod") // arbitrary key/value - .Boost("myBooster", () => new Dictionary { ["key"] = "val" }) - ... +try +{ + // risky operation +} +catch (Exception ex) +{ + logger.Warning(ex, "Operation failed, attempting retry"); + logger.Error(ex, "Operation failed after retries"); + logger.Critical(ex, "Critical failure, shutting down"); +} ``` ---- +### Structured Properties -## Pre-Build Modifiers +```csharp +// With tuples (preferred for performance) +logger.Log(LogLevel.Information, "User logged in", + ("UserId", 42), + ("IP", "192.168.1.1"), + ("Session", "abc-123")); -Modifiers run after boosters and can mutate or cancel a log event before it is dispatched to flows: +// Properties appear in most flows (database, JSON output, etc.) +// In file output: `UserId=42, IP=192.168.1.1, Session=abc-123` +``` + +### Custom Log Event Modification ```csharp logger.AddModifier((ref LogEventBuilder builder) => { - builder.WithProperty("RequestId", Guid.NewGuid().ToString()); + builder.WithProperty("RequestId", HttpContext.TraceIdentifier); + builder.WithProperty("UserId", User.Id); +}); + +logger.Information("Processing request"); // RequestId and UserId added automatically +``` + +## Available Flows + +Flows are the destinations where log events are written. Each flow is independent and can have its own level filter and configuration. + +### Flows Overview + +| Flow | Method | Description | +|------|--------|-------------| +| Console | `WriteToConsole()` | Colored console output with customizable templates | +| File | `WriteToFile()` | Batched, rotated, compressed file output with retention policies | +| Encrypted File | `WriteToEncryptedFile()` | AES-encrypted log files with password protection | +| Memory | `WriteToMemory()` | In-memory ring buffer for quick access and diagnostics | +| Audit | `WriteToAudit()` | Tamper-evident hash-chained audit trail with verification | +| Database | `WriteToDatabase()` | ADO.NET database sink with custom table support | +| HTTP | `WriteToHttp()` | Generic HTTP endpoint with custom headers and batching | +| Webhook | `WriteToWebhook()` | Generic webhook POST endpoint | +| Email | `WriteToEmail()` | HTML digest emails via SMTP with configurable batching | +| Slack | `WriteToSlack()` | Slack incoming webhooks with message formatting | +| Discord | `WriteToDiscord()` | Discord webhooks with embed formatting | +| Microsoft Teams | `WriteToMicrosoftTeams()` | Teams incoming webhooks with adaptive cards | +| Telegram | `WriteToTelegram()` | Telegram bot messages | +| SignalR | `WriteToSignalR()` | Real-time SignalR hub push for live dashboards | +| Redis | `RedisFlow()` | Redis Pub/Sub + optional List persistence with reconnect | +| Elasticsearch | `WriteToElasticSearch()` | Elasticsearch index with custom index names | +| Splunk | `WriteToSplunkFlow()` | Splunk HEC (HTTP Event Collector) | +| Graylog | `WriteToGraylogFlow()` | GELF over UDP or TCP | +| Syslog UDP | `WriteToSyslogUdp()` | RFC-5424 Syslog over UDP | +| Syslog TCP | `WriteToSyslogTcp()` | RFC-5424 Syslog over TCP with optional TLS | +| TCP | `WriteToTcp()` | Raw TCP with optional TLS support | +| UDP | `WriteToUdp()` | Raw UDP datagrams | +| SNMP Trap | `WriteToSnmpTrap()` | SNMP v2c traps for network monitoring | +| Zabbix | `WriteToZabbixFlow()` | Zabbix trapper protocol | +| EventLog | `WriteToEventLogFlow()` | Remote Windows event log forwarding | +| Rolling Buffer | `WriteToRollingBuffer()` | Circular buffer with trigger-based context flushing | +| Throttled | `WriteToThrottled()` | Token-bucket rate limiting with optional deduplication | +| Retry | `WriteToRetry()` | Automatic retry with exponential back-off | +| Failover | `WriteToFailover()` | Primary/secondary failover with recovery detection | +| Diagnostics | `WriteDiagnostics()` | Periodic diagnostic snapshots and metrics | +| Status | `WriteToStatusFlow()` | Service health monitoring | +| Conditional | `WriteToConditional()` | Route logs based on custom predicates | +| Circuit Breaker | `WriteToCircuitBreaker()` | Protect against cascading failures | + +### Flow Examples + +#### Console Output +```csharp +// Basic colored output +.WriteToConsole(useColors: true) + +// Minimal console (no colors) +.WriteToConsole(useColors: false) + +// Only warnings and above to console +.WriteToConsole(minimumLevel: LogLevel.Warning, useColors: true) +``` + +#### File Output +```csharp +// Basic file logging +.WriteToFile("./logs") + +// Custom configuration +.WriteToFile( + directory: "./logs", + filePrefix: "myapp", + maxFileSize: 100 * 1024 * 1024, // 100 MB + maxDirectorySize: 10L * 1024 * 1024 * 1024, // 10 GB total + flushIntervalInMilliSeconds: 2000, + batchSize: 50, + compression: CompressionFormat.GZip, + outputFormat: FileOutputFormat.Text) + +// Category-based routing (separate files per category) +.WriteToFile( + directory: "./logs", + useCategoryRouting: true) + +// Level-based routing (separate files per log level) +.WriteToFile( + directory: "./logs", + logLevelsForSeparateFiles: new[] { LogLevel.Error, LogLevel.Critical }) +``` + +#### Encrypted File Output +```csharp +// Encrypt logs with password +.WriteToEncryptedFile( + directory: "./secure-logs", + password: "MySecurePassword123!") + +// Decrypt later when needed +LogBuilder.DecryptFile( + encryptedPath: "./secure-logs/log.enc", + outputPath: "./secure-logs/log.txt", + password: "MySecurePassword123!"); +``` + +#### In-Memory Buffer +```csharp +// Store last 1000 events in memory +.WriteToMemory(capacity: 1000, minimumLevel: LogLevel.Information) + +// Access stored events +var memoryFlow = logger.GetFlowOfType(); +var events = memoryFlow.GetEvents(); +``` + +#### Slack +```csharp +.WriteToSlack("https://hooks.slack.com/services/YOUR/WEBHOOK/URL") + +// Only errors to Slack +.WriteToSlack( + webhookUrl: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL", + minimumLevel: LogLevel.Error) +``` + +#### Discord +```csharp +.WriteToDiscord( + webHookUrl: "https://discordapp.com/api/webhooks/YOUR/WEBHOOK", + botName: "ErrorBot") +``` + +#### Email Digest +```csharp +.WriteToEmail( + smtpHost: "smtp.gmail.com", + smtpPort: 587, + useSsl: true, + username: "your-email@gmail.com", + password: "app-password", + from: "logs@company.com", + to: "ops@company.com", + subjectPrefix: "[Production Alerts]", + digestMinutes: 5, // Send every 5 minutes + flushOnCritical: true, // Send immediately on Critical + minimumLevel: LogLevel.Error) +``` + +#### Database (SQL Server, PostgreSQL, MySQL, etc.) +```csharp +.WriteToDatabase( + connectionFactory: () => new SqlConnection("connection-string"), + tableName: "ApplicationLogs", + batchSize: 10) +``` + +#### Elasticsearch +```csharp +.WriteToElasticSearch( + elasticSearchUrl: "https://elasticsearch.company.com:9200", + indexName: "myapp-logs", + batchSize: 100) +``` + +#### Redis (Pub/Sub + List) +```csharp +// Pub/Sub only (real-time subscribers) +.RedisFlow( + host: "redis.company.com", + port: 6379, + channel: "eonacat:logs") + +// Pub/Sub + List persistence (subscribers + history) +.RedisFlow( + host: "redis.company.com", + port: 6379, + password: "redis-password", + database: 0, + channel: "eonacat:logs", + listKey: "eonacat:logs:history", + maxListLength: 10000) +``` + +#### Syslog +```csharp +// RFC-5424 Syslog over UDP +.WriteToSyslogUdp( + host: "syslog.company.com", + port: 514) + +// RFC-5424 Syslog over TCP with TLS +.WriteToSyslogTcp( + host: "syslog.company.com", + port: 514, + useTls: true) +``` + +#### HTTP Endpoint +```csharp +.WriteToHttp( + endpoint: "https://logs.company.com/ingest", + batchSize: 50, + batchInterval: TimeSpan.FromSeconds(2), + headers: new Dictionary + { + ["Authorization"] = "Bearer token123", + ["X-API-Key"] = "secret" + }) +``` + +#### Audit Trail (Tamper-Evident) +```csharp +// Record warnings and above in hash-chained audit trail +.WriteToAudit( + directory: "./audit", + auditLevel: AuditLevel.WarningAndAbove, + includeProperties: true) + +// Verify audit file integrity +bool isIntact = AuditFlow.Verify("./audit/audit.audit"); +if (!isIntact) + Console.WriteLine("Audit trail has been tampered with!"); +``` + +#### Rolling Buffer (Context-on-Error) +```csharp +// Buffer 500 events; on Error, flush preceding 100 events to file +.WriteToRollingBuffer( + capacity: 500, + minimumLevel: LogLevel.Trace, + triggerLevel: LogLevel.Error, + triggerTarget: new FileFlow("./error-context"), + preContextLines: 100) +``` + +## Available Boosters + +Boosters automatically enrich every log event with additional properties before it reaches any flow. They run once per log event and can add system information, application context, and custom data. + +### System Information Boosters + +```csharp +.BoostWithMachineName() // Add computer/host name +.BoostWithProcessId() // Add current process ID +.BoostWithThreadId() // Add managed thread ID +.BoostWithThreadName() // Add thread name (if set) +.BoostWithUser() // Add current OS user name +.BoostWithOS() // Add OS description and version +.BoostWithFramework() // Add .NET runtime description +.BoostWithMemory() // Add process working set in MB +.BoostWithUptime() // Add process uptime in seconds +.BoostWithProcStart() // Add process start time (DateTime) +``` + +### Date/Time Boosters + +```csharp +.BoostWithDate() // Add current date (yyyy-MM-dd) +.BoostWithTime() // Add current time (HH:mm:ss.fff) +.BoostWithTicks() // Add current timestamp ticks (for precise timing) +``` + +### Application Context Boosters + +```csharp +.BoostWithApp() // Add app name and base directory (auto-detected) +.BoostWithApplication("MyApp", "2.0.0") // Add explicit app name + version +.BoostWithEnvironment("Production") // Add environment name (Prod/Dev/Test) +.BoostWithCorrelationId() // Add Activity.Current correlation ID for distributed tracing +``` + +### Custom Data Boosters + +```csharp +// Single key/value pair +.BoostWithCustomText("Environment", "Production") +.BoostWithCustomText("ServiceVersion", "2.0.1") + +// Callback-based booster for dynamic data +.Boost("RequestInfo", () => new Dictionary +{ + ["UserId"] = GetCurrentUserId(), + ["TenantId"] = GetCurrentTenantId(), + ["ApiVersion"] = GetApiVersion() +}) + +// Custom booster implementation +.Boost(new MyCustomBooster()) +``` + +### Complete Booster Configuration Example + +```csharp +var logger = new LogBuilder("MyApplication") + // System info + .BoostWithMachineName() + .BoostWithProcessId() + .BoostWithThreadId() + .BoostWithOS() + .BoostWithFramework() + .BoostWithMemory() + + // Application context + .BoostWithApplication("MyApp", "1.0.0") + .BoostWithEnvironment("Production") + .BoostWithCorrelationId() + + // Startup info + .BoostWithProcStart() + .BoostWithUptime() + + // Custom context + .BoostWithCustomText("DataCenter", "US-East-1") + .BoostWithCustomText("InstanceId", Environment.MachineName) + .Boost("Request", () => new Dictionary + { + ["TraceId"] = Activity.Current?.Id ?? HttpContext?.TraceIdentifier, + ["UserId"] = CurrentUser?.Id, + }) + + .WriteToConsole() + .WriteToFile("./logs") + .Build(); +``` + +### What Boosters Add to Logs + +When you enable boosters, they add structured properties to each log event. In file output, these appear as: + +``` +[2026-03-27 09:15:00.123] [INFO] [Application=MyApp, Version=1.0.0, Environment=Production, Machine=srv-01, PID=1234, ThreadId=5] +User logged in successfully +``` + +In JSON outputs (database, Elasticsearch, etc.), boosters add properties like: + +```json +{ + "timestamp": "2026-03-27T09:15:00.123Z", + "level": "Information", + "message": "User logged in successfully", + "machine": "srv-01", + "processId": 1234, + "threadId": 5, + "userId": 42, + "application": "MyApp", + "version": "1.0.0", + "environment": "Production" +} +``` + +## Pre-Build Modifiers + +Modifiers run after boosters and can mutate or cancel a log event before it is dispatched to flows. Use modifiers to add request-scoped data, redact sensitive info, or filter events. + +### Basic Modifier Example + +```csharp +logger.AddModifier((ref LogEventBuilder builder) => +{ + // Add request context + builder.WithProperty("RequestId", HttpContext.TraceIdentifier); +}); + +// Now every log will include RequestId automatically +logger.Information("Processing request"); +``` + +### Multiple Modifiers + +```csharp +// Add request context +logger.AddModifier((ref LogEventBuilder builder) => +{ + builder.WithProperty("RequestId", HttpContext?.TraceIdentifier); + builder.WithProperty("UserId", User?.Id); +}); + +// Add custom timing info +logger.AddModifier((ref LogEventBuilder builder) => +{ + builder.WithProperty("Timestamp", DateTime.UtcNow); +}); + +// Redact sensitive data (example) +logger.AddModifier((ref LogEventBuilder builder) => +{ + if (builder.Message?.Contains("password") ?? false) + builder.WithMessage("[REDACTED - contains sensitive data]"); }); ``` ---- +### Canceling Events with Modifiers + +```csharp +logger.AddModifier((ref LogEventBuilder builder) => +{ + // Example: Skip verbose trace logs in production + if (builder.Level == LogLevel.Trace && !IsDebugMode) + builder.Cancel(); // Event won't be sent to any flow +}); +``` + +## Request Context Integration (ASP.NET Core / Razor Pages) + +```csharp +// In Program.cs, register request context booster +builder.Services.AddEonaCatLogging("WebApp", logBuilder => +{ + logBuilder + .WriteToConsole() + .WriteToFile("./logs") + .BoostWithCorrelationId(); // Distributed tracing support +}); + +// In your page or controller +public class IndexModel : PageModel +{ + private readonly ILogger _logger; + + public IndexModel(ILogger logger) + { + _logger = logger; + } + + public void OnGet() + { + // Correlation ID is automatically added by booster + _logger.Information("Page loaded"); + } +} +``` ## Resilience Patterns -### Retry with exponential back-off +### Retry with Exponential Back-off + +Automatically retry failed writes with exponential delays. Useful for flaky remote endpoints. + ```csharp .WriteToRetry( primaryFlow: new HttpFlow("https://logs.example.com"), maxRetries: 5, initialDelay: TimeSpan.FromMilliseconds(200), exponentialBackoff: true) + +// Retry delays: 200ms, 400ms, 800ms, 1.6s, 3.2s ``` -### Primary / secondary failover +### Primary / Secondary Failover + +Automatically fall back to a secondary destination if the primary fails. + ```csharp .WriteToFailover( primaryFlow: new ElasticSearchFlow("https://es-prod:9200"), - secondaryFlow: new FileFlow("./fallback-logs")) + secondaryFlow: new FileFlow("./fallback-logs"), + recoveryCheckInterval: TimeSpan.FromSeconds(30), + failureThreshold: 5) // Switch after 5 consecutive failures ``` -### Token-bucket throttling with deduplication +### Token-Bucket Rate Limiting with Deduplication + +Protect high-latency sinks (email, Slack, HTTP) from log storms. + ```csharp .WriteToThrottled( inner: new SlackFlow(webhookUrl), - burstCapacity: 10, - refillPerSecond: 1.0, - deduplicate: true, - dedupWindow: TimeSpan.FromSeconds(60)) + burstCapacity: 10, // Allow 10 events in a burst + refillPerSecond: 1.0, // Refill 1 token per second + deduplicate: true, // Collapse identical messages + dedupWindow: TimeSpan.FromSeconds(60), // Within 60-second window + dedupMaxKeys: 1000) // Track up to 1000 unique messages ``` -### Rolling buffer - flush context on error +Example behavior: +- `10` log errors occur simultaneously → first 10 are sent (burst) +- Next error within 1 second is queued (rate-limited to 1/sec) +- Identical errors within 60s → counted, then sent as "N duplicate messages" + +### Rolling Buffer - Context-on-Error + +Buffer recent logs and flush context when an error occurs. Useful for debugging transient issues. + ```csharp +.WriteToRollingBuffer( + capacity: 500, // Keep last 500 events + minimumLevel: LogLevel.Trace, // Buffer everything + triggerLevel: LogLevel.Error, // Trigger on errors + triggerTarget: new FileFlow("./error-context"), // Write context to file + preContextLines: 100) // Include 100 lines of context before the error +``` + +Real-world scenario: +``` +# In-memory buffer contains: [Trace, Debug, Info, Warning, ...100+ events...] +# Error occurs +# Rolling buffer flushes: previous 100 logs + the error itself +# Result: ./error-context/2026-03-27.log contains the full context +``` + +### Circuit Breaker + +Stop sending to a failing destination and automatically resume when it recovers. + +```csharp +.WriteToCircuitBreaker( + inner: new ElasticSearchFlow("https://es-prod:9200"), + failureThreshold: 10, // Open after 10 failures + successThreshold: 3, // Close after 3 successes + timeout: TimeSpan.FromSeconds(30)) // Check recovery every 30s +``` + +### Combining Patterns + +```csharp +await using var logger = new LogBuilder("Production") + .WriteToConsole() + + // Local file as primary + .WriteToFile("./logs") + + // Elasticsearch with resilience + .WriteToRetry( + primaryFlow: .WriteToFailover( + primaryFlow: new ElasticSearchFlow("https://es-prod:9200"), + secondaryFlow: new FileFlow("./fallback")), + maxRetries: 3) + + // Rate-limited Slack for errors only + .WriteToThrottled( + inner: new SlackFlow(webhookUrl), + burstCapacity: 5, + refillPerSecond: 0.5, + minimumLevel: LogLevel.Error) + + // Rolling buffer for debugging + .WriteToRollingBuffer( + capacity: 1000, + triggerLevel: LogLevel.Error, + triggerTarget: new FileFlow("./error-context")) + + .BoostWithMachineName() + .BoostWithCorrelationId() + .Build(); +``` + +## Encrypted File Logging + +Encrypt sensitive logs with AES encryption and password protection. + +### Writing Encrypted Logs + +```csharp +.WriteToEncryptedFile( + directory: "./secure-logs", + filePrefix: "encrypted", + password: "YourStrongPassword123!", + maxFileSize: 50 * 1024 * 1024) +``` + +### Decrypting Logs + +```csharp +// Decrypt a specific file when needed +LogBuilder.DecryptFile( + encryptedPath: "./secure-logs/encrypted.enc", + outputPath: "./secure-logs/decrypted.txt", + password: "YourStrongPassword123!"); + +// Now you can read the decrypted logs +string logs = File.ReadAllText("./secure-logs/decrypted.txt"); +``` + +### Use Cases + +- Compliance requirements (HIPAA, PCI-DSS) +- Storing sensitive user data or API keys in logs +- Secure log storage on shared infrastructure +- Audit trails with encryption + +## Audit Trail (Tamper-Evident) + +The audit flow produces a tamper-evident file where every entry is SHA-256 hash-chained. Deleting or modifying any past entry invalidates all subsequent hashes. + +### Writing Audit Logs + +```csharp +.WriteToAudit( + directory: "./audit", + filePrefix: "audit", + auditLevel: AuditLevel.WarningAndAbove, // Warning, Error, Critical + includeProperties: true) +``` + +### Verification + +```csharp +// Verify file integrity at any time +bool intact = AuditFlow.Verify("./audit/audit.audit"); + +if (!intact) +{ + Console.WriteLine("ERROR: Audit trail has been tampered with!"); + // Take appropriate action (alert, disable service, etc.) +} +``` + +### Audit Levels + +```csharp +AuditLevel.All // Every log event +AuditLevel.WarningAndAbove // Warning, Error, Critical +AuditLevel.ErrorAndAbove // Error, Critical +AuditLevel.CriticalOnly // Critical only +``` + +### Example Audit File + +``` +2026-03-27T09:15:00.123Z|WARNING|Low disk space|[hash: sha256(prev_hash + data)] +2026-03-27T09:15:05.456Z|ERROR|Database connection failed|[hash: sha256(prev_hash + data)] +2026-03-27T09:15:10.789Z|CRITICAL|Service shutting down|[hash: sha256(prev_hash + data)] +``` + +If someone modifies the second entry, the third entry's hash validation fails, indicating tampering. + +## Log Message Template + +Both `ConsoleFlow` and `FileFlow` accept a customizable template string for formatting output. + +### Default Template + +``` +[{ts}] [Host: {host}] [Category: {category}] [Thread: {thread}] [{logtype}] {message}{props} +``` + +### Available Tokens + +| Token | Description | Example | +|-------|-------------|---------| +| `{ts}` | Timestamp (yyyy-MM-dd HH:mm:ss.fff) | 2026-03-27 09:15:00.123 | +| `{tz}` | Timezone (UTC or local name) | UTC or EST | +| `{host}` | Machine name | srv-prod-01 | +| `{category}` | Logger category | MyApp.Services | +| `{thread}` | Managed thread ID | 5 | +| `{pid}` | Process ID | 1234 | +| `{logtype}` | Log level label | INFO, WARN, ERROR | +| `{message}` | Log message text | User login successful | +| `{props}` | Structured properties | UserId=42, IP=192.168.1.1 | +| `{newline}` | Line break | (actual newline) | + +### Custom Templates + +```csharp +// Minimal template +.WriteToFile( + directory: "./logs", + template: "[{ts}] [{logtype}] {message}") +// Output: [2026-03-27 09:15:00.123] [INFO] User login successful + +// Verbose template with all info +.WriteToFile( + directory: "./logs", + template: "[{ts}] [{tz}] [{logtype}] [Thread={thread}] [PID={pid}] [Host={host}] {category}: {message}{props}") +// Output: [2026-03-27 09:15:00.123] [UTC] [INFO] [Thread=5] [PID=1234] [Host=srv-01] MyApp: User login successful UserId=42, IP=192.168.1.1 + +// JSON-like format +.WriteToFile( + directory: "./logs", + template: "timestamp={ts}|level={logtype}|category={category}|pid={pid}|message={message}{props}") +``` + +### Advanced Message Template Features + +EonaCat.LogStack supports advanced templating beyond basic property placeholders. These features work with message templates used in logging calls: + +```csharp +logger.Information("User {User} logged in from {IP}", user, ipAddress); +``` + +#### Nested Property Access + +Access properties of objects using dot notation: + +```csharp +var user = new { Name = "John", Address = new { City = "NYC" } }; +logger.Information("User {User.Name} lives in {User.Address.City}", user, user.Address); +// Output: User John lives in NYC +``` + +#### Array/Collection Indexing + +Access specific items in arrays or lists: + +```csharp +var items = new[] { "apple", "banana", "cherry" }; +logger.Information("First item: {Items[0]}, Second: {Items[1]}", items); +// Output: First item: apple, Second: banana +``` + +#### Alignment and Padding + +Pad values to a specific width for aligned output: + +```csharp +logger.Information("{Name,20} {Email,-30}", "John", "john@example.com"); +// Output: " John john@example.com " +// (right-aligned 20 chars) (left-aligned 30 chars) +``` + +#### String Filters + +Apply transformations to property values: + +```csharp +// Uppercase +logger.Information("Status: {Status|uppercase}", "pending"); +// Output: Status: PENDING + +// Lowercase +logger.Information("Event: {Event|lowercase}", "UserCreated"); +// Output: Event: usercreated + +// Trim whitespace +logger.Information("Value: '{Value|trim}'", " spaces "); +// Output: Value: 'spaces' + +// Truncate with ellipsis +logger.Information("Description: {Description|truncate:50}", veryLongText); +// Output: Description: This is a very long description that … + +// Reverse string +logger.Information("Reversed: {Text|reverse}", "hello"); +// Output: Reversed: olleh + +// Multiple filters (chained) +logger.Information("Result: {Input|trim|uppercase}", " hello world "); +// Output: Result: HELLO WORLD +``` + +#### Fallback Values + +Provide default values when properties are null or missing: + +```csharp +// Fallback with ?? operator and quotes +logger.Information("User: {User??'Anonymous'}", user); +// Output: User: Anonymous (if user is null) + +logger.Information("Email: {Email??'no-email@example.com'}", email); +// Output: Email: no-email@example.com (if email is null) +``` + +#### Conditional Rendering + +Display different text based on boolean properties: + +```csharp +logger.Information("Status: {?IsActive:Active|Inactive}", isActive); +// Output: Status: Active (if isActive is true) +// Output: Status: Inactive (if isActive is false) + +logger.Information("Result: {?Success:✓ Success|✗ Failed}", success); +// Output: Result: ✓ Success (if success is true) +``` + +#### Advanced Conditionals + +Use comparison operators in conditional templates: + +```csharp +// Advanced conditional token with if syntax +// Syntax: {@if:condition:trueOutput|falseOutput} + +logger.Information("User role: {@if:RoleId>5:Admin|User}", user); +// Output: User role: Admin (if RoleId > 5) +// Output: User role: User (if RoleId <= 5) + +logger.Information("Account: {@if:Status==Premium:Premium Member|Standard}", account); +// Output: Account: Premium Member (if Status equals 'Premium') + +// Comparison operators: ==, !=, <, >, <=, >= +logger.Information("{@if:Count>=100:Large|Small}", data); +logger.Information("{@if:Price<50:Budget|Premium}", item); +logger.Information("{@if:IsDeleted!=false:Deleted|Active}", record); +``` + +#### Loops and Collections + +Iterate over arrays and collections in templates: + +```csharp +// Syntax: {@loop:CollectionName:itemTemplate:separator} + +var items = new[] { "apple", "banana", "cherry" }; +logger.Information("Items: {@loop:Items:{Item}|, }", items); +// Output: Items: apple, banana, cherry + +// Custom separator +var tags = new[] { "urgent", "high-priority", "production" }; +logger.Information("Tags: {@loop:Tags:{Item}| | }", tags); +// Output: Tags: urgent | high-priority | production + +// Complex items +var users = new[] +{ + new { Id = 1, Name = "John" }, + new { Id = 2, Name = "Jane" } +}; +logger.Information("Users: {@loop:Users:({Id}:{Name})|, }", users); +// Output: Users: (1:John), (2:Jane) +``` + +#### Math Filters + +Perform arithmetic operations on numeric properties: + +```csharp +logger.Information("Total: ${Amount|add:10}", 50); +// Output: Total: $60 + +logger.Information("Discount: ${Price|multiply:0.9}", 100); +// Output: Discount: $90 + +logger.Information("Half: {Value|divide:2}", 100); +// Output: Half: 50 + +logger.Information("Remainder: {Number|modulo:3}", 10); +// Output: Remainder: 1 + +logger.Information("Absolute: {Change|abs}", -15); +// Output: Absolute: 15 + +logger.Information("Rounded: {Value|round:2}", 19.9999); +// Output: Rounded: 20 + +logger.Information("Max: {Value|max:100}", 150); +// Output: Max: 100 + +logger.Information("Floor: {Decimal|floor}", 19.9); +// Output: Floor: 19 + +logger.Information("Ceil: {Decimal|ceil}", 19.1); +// Output: Ceil: 20 +``` + +#### String Manipulation Filters + +Transform string values with various filters: + +```csharp +// Case conversion +logger.Information("Lower: {Text|lowercase}", "HELLO"); +// Output: Lower: hello + +logger.Information("Upper: {Text|uppercase}", "world"); +// Output: Upper: WORLD + +// Padding +logger.Information("Padded: |{Name|pad:15}|", "John"); +// Output: Padded: |John | + +// Repetition +logger.Information("Repeated: {Char|repeat:5}", "x"); +// Output: Repeated: xxxxx + +// String replacement +logger.Information("Fixed: {Path|replace:old:new}", "/old/path/old"); +// Output: Fixed: /new/path/new + +// Substring operations +logger.Information("Skip first 3: {Text|substring:3}", "12345"); +// Output: Skip first 3: 45 + +// Trim variations +logger.Information("Trimmed: '{Text|trim}'", " spaces "); +// Output: Trimmed: 'spaces' + +logger.Information("Trim start: '{Text|trimstart}'", " spaces "); +// Output: Trim start: 'spaces ' + +logger.Information("Trim end: '{Text|trimend}'", " spaces "); +// Output: Trim end: ' spaces' + +// String testing +logger.Information("Starts with 'user': {Email|startswith:user}", "user@example.com"); +// Output: Starts with 'user': true + +logger.Information("Ends with '.org': {Url|endswith:.org}", "website.org"); +// Output: Ends with '.org': true + +logger.Information("Contains 'app': {Path|contains:app}", "/app/data"); +// Output: Contains 'app': true + +// String reversal +logger.Information("Reversed: {Text|reverse}", "hello"); +// Output: Reversed: olleh + +// Split and join +logger.Information("Split CSV: {Data|split:,}", "a,b,c"); +// Output: Split CSV: a, b, c +``` + +#### Comparison Filters + +Compare values and return boolean results: + +```csharp +logger.Information("Equals: {Status|equals:Active}", status); +// Output: Equals: true (if status == 'Active') + +logger.Information("Less than 100: {Value|lessthan:100}", 50); +// Output: Less than 100: true + +logger.Information("Greater than 50: {Value|greaterthan:50}", 100); +// Output: Greater than 50: true + +logger.Information("Greater or equal: {Count|gte:10}", 15); +// Output: Greater or equal: true + +logger.Information("Less or equal: {Count|lte:20}", 15); +// Output: Less or equal: true + +logger.Information("Not equal: {Type|ne:User}", "Admin"); +// Output: Not equal: true +``` + +#### Date & Time Filters + +Format and manipulate date/time values: + +```csharp +// Date formatting +logger.Information("Date: {CreatedAt|date:yyyy-MM-dd}", DateTime.Now); +// Output: Date: 2026-03-27 + +logger.Information("Full timestamp: {CreatedAt|date:O}", DateTime.Now); +// Output: Full timestamp: 2026-03-27T09:15:00.1234567Z + +logger.Information("Custom format: {CreatedAt|date:dd/MM/yyyy HH:mm:ss}", DateTime.Now); +// Output: Custom format: 27/03/2026 09:15:00 + +// TimeSpan operations +var duration = TimeSpan.FromSeconds(3661); +logger.Information("Total seconds: {Duration|timespan:totalseconds}", duration); +// Output: Total seconds: 3661 + +logger.Information("Total minutes: {Duration|timespan:totalminutes}", duration); +// Output: Total minutes: 61.0166... + +logger.Information("Total hours: {Duration|timespan:totalhours}", duration); +// Output: Total hours: 1.01388... + +logger.Information("Days: {Duration|timespan:days}", duration); +// Output: Days: 0 + +logger.Information("Hours: {Duration|timespan:hours}", duration); +// Output: Hours: 1 + +logger.Information("Minutes: {Duration|timespan:minutes}", duration); +// Output: Minutes: 1 + +logger.Information("Seconds: {Duration|timespan:seconds}", duration); +// Output: Seconds: 1 +``` + +#### Chaining Multiple Filters + +Combine filters for complex transformations: + +```csharp +// Trim, then uppercase, then truncate +logger.Information("Processed: {Input|trim|uppercase|truncate:10}", " hello world "); +// Output: Processed: HELLO WOR… + +// Substring, then lowercase +logger.Information("Modified: {Path|substring:5|lowercase}", "/DATA/MyFile.TXT"); +// Output: Modified: myfile.txt + +// Apply multiple math operations +logger.Information("Calculated: {Value|multiply:2|add:10|divide:3}", 5); +// Output: Calculated: 6.66... (5 * 2 = 10, 10 + 10 = 20, 20 / 3 = 6.66) +``` + +#### Complete Advanced Template Examples + +```csharp +// API request logging with advanced features +var request = new +{ + Method = "POST", + Path = "/api/users", + UserId = 42, + ResponseTime = 150, + Success = true, + Tags = new[] { "api", "users", "production" } +}; + +logger.Information( + "[{Timestamp|date:HH:mm:ss}] {Method|uppercase} {Path} - User {UserId} - {ResponseTime|pad:5}ms - {?Success:✓|✗} - Tags: {@loop:Tags:{Item}|, }", + DateTime.Now, request.Method, request.Path, request.UserId, + request.ResponseTime, request.Success, request.Tags +); +// Output: [09:15:00] POST /api/users - User 42 - 150ms - ✓ - Tags: api, users, production + +// Conditional status with comparison +var operation = new +{ + Name = "DataSync", + Status = "Completed", + Duration = 45000, + RecordsProcessed = 1500, + ErrorCount = 0 +}; + +logger.Information( + "{Name}: {@if:ErrorCount==0:✓ Success|⚠ With Errors} | Duration: {Duration|timespan:totalseconds}s | Records: {RecordsProcessed|add:0} processed", + operation.Name, operation.ErrorCount, operation.Duration, operation.RecordsProcessed +); +// Output: DataSync: ✓ Success | Duration: 45s | Records: 1500 processed + +// Complex nested template +var batch = new +{ + Id = "batch-001", + Items = new[] { "item1", "item2", "item3" }, + Size = 3, + Price = 99.99m, + Discount = 0.15m +}; + +logger.Information( + "Batch {Id}: {?Size>5:Large|Small} batch | Items: {@loop:Items:{Item}|, } | Price: ${Price|multiply:Discount|add:0|round:2}", + batch.Id, batch.Size, batch.Items, batch.Price, batch.Discount +); +// Output: Batch batch-001: Small batch | Items: item1, item2, item3 | Price: $15.00 +``` + +#### Template Features Summary + +The templating engine supports: + +| Feature | Syntax | Example | +|---------|--------|---------| +| **Basic Property** | `{PropertyName}` | `{UserId}` | +| **Nested Properties** | `{Object.Property.Sub}` | `{User.Address.City}` | +| **Array Indexing** | `{Array[Index]}` | `{Items[0]}` | +| **Alignment** | `{Value,Width}` | `{Name,20}` | +| **Format Specifiers** | `{Value:Format}` | `{Date:yyyy-MM-dd}` | +| **String Filters** | `{Value\|Filter}` | `{Text\|uppercase}` | +| **Math Filters** | `{Value\|add:10}` | `{Price\|multiply:0.9}` | +| **Comparison** | `{Value\|equals:text}` | `{Status\|equals:Active}` | +| **Simple Conditionals** | `{?Property:True\|False}` | `{?IsActive:Active\|Inactive}` | +| **Advanced Conditionals** | `{@if:Condition:T\|F}` | `{@if:Count>10:High\|Low}` | +| **Loops** | `{@loop:Collection:Template}` | `{@loop:Items:{Item}\|, }` | +| **Fallback Values** | `{Value??'Default'}` | `{Name??'Unknown'}` | +| **Chained Filters** | `{Value\|filter1\|filter2}` | `{Text\|trim\|uppercase}` | +| **Destructuring** | `{@Object}` | `{@User}` | + +#### Format Specifiers + +Apply .NET format strings to values: + +```csharp +// Date formatting +logger.Information("Date: {CreatedAt:yyyy-MM-dd}", DateTime.Now); +// Output: Date: 2026-03-27 + +// Decimal formatting +logger.Information("Price: {Price:C}", 19.99m); +// Output: Price: $19.99 + +// Numeric formatting +logger.Information("Count: {Count:D5}", 42); +// Output: Count: 00042 +``` + +#### Destructuring + +Deep-inspect complex objects to reveal their structure: + +```csharp +var user = new { Id = 1, Name = "John", Email = "john@example.com" }; + +// Default destructuring with @ prefix +logger.Information("User: {@User}", user); +// Output: User: {Id: 1, Name: John, Email: john@example.com} + +// Force string conversion with $ prefix +logger.Information("User: {$User}", user); +// Output: User: YourNamespace.User + +// Nested destructuring +var order = new +{ + Id = 1, + Customer = new { Name = "John", City = "NYC" }, + Items = new[] { "Item1", "Item2" } +}; + +logger.Information("Order: {@Order}", order); +// Output: Order: {Id: 1, Customer: {Name: John, City: NYC}, Items: [...]} +``` + +#### Complete Advanced Template Examples + +```csharp +// API request logging +var request = new +{ + Method = "POST", + Path = "/api/users", + UserId = 42, + ResponseTime = 150, + Success = true +}; + +logger.Information( + "[{Time|uppercase}] {Method} {Path} - User {UserId} - {ResponseTime,5}ms - {?Success:✓|✗}", + DateTime.Now.ToString("HH:mm:ss"), request.Method, request.Path, + request.UserId, request.ResponseTime, request.Success +); +// Output: [09:15:00] POST /api/users - User 42 - 150ms - ✓ + +// Database operation with fallback +logger.Information( + "Database query {QueryName??'Unknown'} by user {UserId??'System'} took {Duration|truncate:10}ms", + storedProcName, currentUserId, duration +); +// Output: Database query sp_GetUsers by user System took 125ms + +// File processing with conditions +logger.Information( + "File {FileName} processed: {?HasErrors:⚠ ERRORS|✓ OK} - {LineCount,6} lines", + file.Name, file.HasErrors, file.LineCount +); +// Output: File log.txt processed: ✓ OK - 1024 lines + +// Nested property access +var company = new +{ + Name = "Acme Corp", + HeadOffice = new { City = "New York", Country = "USA" }, + Employees = new[] { "John", "Jane", "Jack" } +}; + +logger.Information( + "Company {Company.Name} from {Company.HeadOffice.City}, {Company.HeadOffice.Country} - First employee: {Company.Employees[0]}", + company +); +// Output: Company Acme Corp from New York, USA - First employee: John +``` + +## Real-World Configuration Scenarios + +### Scenario 1: Development Environment + +Console output with verbose logging, local files, and no remote sends. + +```csharp +await using var logger = new LogBuilder("MyApp") + .WithMinimumLevel(LogLevel.Debug) + .WithTimestampMode(TimestampMode.Local) + .WriteToConsole(useColors: true) + .WriteToFile("./logs", filePrefix: "dev") + + .BoostWithThreadId() + .BoostWithCorrelationId() + .Build(); +``` + +### Scenario 2: Production - Multi-Destination with Resilience + +Files locally, Elasticsearch for search, Slack for alerts, encrypted audit trail. + +```csharp +await using var logger = new LogBuilder("ProductionApp") + .WithMinimumLevel(LogLevel.Information) + .WithTimestampMode(TimestampMode.Utc) + + // Local backup + .WriteToFile( + directory: "./logs", + maxFileSize: 100 * 1024 * 1024, + compression: CompressionFormat.GZip) + + // Primary analytics with fallback + .WriteToFailover( + primaryFlow: new ElasticSearchFlow("https://elastic.company.com"), + secondaryFlow: new FileFlow("./fallback-elastic")) + + // Rate-limited alerts + .WriteToThrottled( + inner: new SlackFlow(slackWebhookUrl), + burstCapacity: 5, + refillPerSecond: 1.0, + minimumLevel: LogLevel.Error) + + // Compliance audit + .WriteToAudit( + directory: "./audit", + auditLevel: AuditLevel.WarningAndAbove) + + // Encrypted sensitive logs + .WriteToEncryptedFile( + directory: "./secure-logs", + password: Environment.GetEnvironmentVariable("LOG_ENCRYPTION_KEY")) + + // Diagnostics snapshot + .WriteDiagnostics( + snapshotInterval: TimeSpan.FromMinutes(5)) + + // Boosters + .BoostWithMachineName() + .BoostWithProcessId() + .BoostWithApplication("ProductionApp", "1.0.0") + .BoostWithEnvironment("Production") + .BoostWithCorrelationId() + .Build(); +``` + +### Scenario 3: Microservices / Distributed Tracing + +Elasticsearch for centralized logs, Redis for real-time events, correlation IDs. + +```csharp +await using var logger = new LogBuilder("OrderService") + .WithMinimumLevel(LogLevel.Information) + + // Centralized log storage + .WriteToElasticSearch( + elasticSearchUrl: "https://elastic-cluster.company.com", + indexName: "orderservice-logs") + + // Real-time event stream + .RedisFlow( + host: "redis.company.com", + channel: "orderservice:logs", + listKey: "orderservice:logs:history", + maxListLength: 10000) + + // Local file backup + .WriteToFile("./logs") + + // Correlation tracking for distributed tracing + .BoostWithCorrelationId() // Automatically includes Activity.Current?.Id + .BoostWithApplication("OrderService", ServiceVersion) + .BoostWithProcessId() + .BoostWithMachineName() + + .Build(); +``` + +### Scenario 4: Real-Time Dashboard + +SignalR flow for live log streaming to web dashboard. + +```csharp +// Backend: Log streaming to SignalR hub +await using var logger = new LogBuilder("DashboardApp") + .WriteToFile("./logs") + .WriteToSignalR( + hubUrl: "https://dashboard.company.com/loghub", + hubMethod: "ReceiveLog", + batchSize: 20, + batchIntervalMs: 500, + minimumLevel: LogLevel.Warning) // Only send warnings+ to dashboard + + .Build(); + +// Frontend: Receive logs in real-time (JavaScript example) +const connection = new signalR.HubConnectionBuilder() + .withUrl("https://dashboard.company.com/loghub") + .withAutomaticReconnect() + .build(); + +connection.on("ReceiveLog", (log) => { + console.log(`[${log.level}] ${log.message}`); + addToLiveLogUI(log); +}); + +await connection.start(); +``` + +### Scenario 5: Compliance & Audit + +Separate audit trail, encrypted logs, tamper detection. + +```csharp +await using var logger = new LogBuilder("ComplianceApp") + + // Tamper-evident audit trail + .WriteToAudit( + directory: "./compliance/audit", + auditLevel: AuditLevel.WarningAndAbove, + includeProperties: true) + + // Encrypted sensitive data + .WriteToEncryptedFile( + directory: "./compliance/encrypted", + password: GetAuditPassword(), + maxFileSize: 50 * 1024 * 1024) + + // Database for detailed analysis + .WriteToDatabase( + connectionFactory: () => new SqlConnection(connectionString), + tableName: "AuditLogs", + batchSize: 10) + + // Regular file backup + .WriteToFile("./logs") + + // Boost with full context + .BoostWithUser() + .BoostWithMachineName() + .BoostWithProcessId() + .BoostWithApplication("ComplianceApp", "1.0.0") + + .Build(); + +// Periodic verification +_ = Task.Run(async () => +{ + while (true) + { + await Task.Delay(TimeSpan.FromHours(1)); + bool isIntact = AuditFlow.Verify("./compliance/audit/audit.audit"); + if (!isIntact) + { + // Alert security team + logger.Critical("SECURITY ALERT: Audit trail tampering detected!"); + } + } +}); +``` + +### Scenario 6: High-Volume with Batching & Rate Limiting + +For services handling high log volume, use batching and throttling. + +```csharp +await using var logger = new LogBuilder("HighVolumeApp") + .WriteToThrottled( + inner: new HttpFlow("https://logs.company.com/ingest"), + burstCapacity: 100, + refillPerSecond: 50.0, // 50 logs/sec steady state + deduplicate: true, + dedupWindow: TimeSpan.FromSeconds(30)) + + .WriteToFile( + directory: "./logs", + batchSize: 100, // Batch 100 logs before writing + flushIntervalInMilliSeconds: 1000) // Flush every 1 second + + .RedisFlow( + host: "redis.company.com", + channel: "app:logs", + listKey: "app:logs:history") + + .Build(); +``` + +## Diagnostics + +### Real-Time Metrics + +```csharp +// Get current statistics +var stats = logger.GetDiagnostics(); + +Console.WriteLine($"Total Logged: {stats.TotalLogged}"); +Console.WriteLine($"Total Dropped: {stats.TotalDropped}"); +Console.WriteLine($"Drop Rate: {(double)stats.TotalDropped / stats.TotalLogged:P2}"); + +// Per-flow statistics +foreach (var flowStat in stats.FlowStats) +{ + Console.WriteLine($"Flow: {flowStat.Name}"); + Console.WriteLine($" Processed: {flowStat.Processed}"); + Console.WriteLine($" Dropped: {flowStat.Dropped}"); + Console.WriteLine($" Errors: {flowStat.ErrorCount}"); +} +``` + +### Diagnostic Snapshots + +Enable periodic diagnostic snapshots to track performance over time. + +```csharp +.WriteDiagnostics( + snapshotInterval: TimeSpan.FromMinutes(5), + injectIntoEvents: true, // Include metrics in log events + writeSnapshotEvents: true, // Write snapshot to logs + snapshotCategory: "Diagnostics", + forwardTo: new FileFlow("./diagnostics"), // Also forward to file + minimumLevel: LogLevel.Information, + customMetrics: () => new Dictionary + { + ["ActiveRequests"] = GetActiveRequestCount(), + ["CacheHitRate"] = GetCacheHitRate(), + ["DatabasePoolSize"] = GetDbPoolSize() + }) +``` + +### Monitoring Health + +```csharp +// Monitor in a background task +_ = Task.Run(async () => +{ + while (true) + { + await Task.Delay(TimeSpan.FromMinutes(1)); + + var stats = logger.GetDiagnostics(); + + // Alert if drop rate is high + if (stats.TotalLogged > 0) + { + double dropRate = (double)stats.TotalDropped / stats.TotalLogged; + if (dropRate > 0.01) // > 1% drop rate + { + logger.Warning("High log drop rate detected", ("DropRate", dropRate)); + } + } + + // Alert on errors + var hasErrors = stats.FlowStats.Any(f => f.ErrorCount > 0); + if (hasErrors) + { + logger.Warning("Some flows are experiencing errors"); + } + } +}); +``` + +## Flushing and Disposal + +All flows support batching, so events may not be written immediately. Use `FlushAsync()` to ensure all pending events are written before shutdown. + +### Explicit Flushing + +```csharp +// Flush all pending events synchronously (up to 5 seconds) +await logger.FlushAsync(); + +// After flush, you can safely dispose +await logger.DisposeAsync(); +``` + +### Automatic Flushing on Disposal + +Using `await using` automatically flushes and disposes: + +```csharp +await using var logger = new LogBuilder("MyApp") + .WriteToFile("./logs") + .Build(); + +logger.Information("Application running"); + +// At scope exit: FlushAsync() called automatically, then DisposeAsync() +``` + +### Best Practices + +```csharp +try +{ + await using var logger = new LogBuilder("MyApp").Build(); + + // Log events here + logger.Information("App started"); + + // Your application logic + await RunApplication(); +} +catch (Exception ex) +{ + // Ensure errors are logged even if something goes wrong + logger?.Critical(ex, "Unexpected error during shutdown"); +} +finally +{ + // FlushAsync() is called automatically when leaving the using block +} +``` + +### Custom Disposal Logic + +```csharp +var logger = new LogBuilder("MyApp").Build(); + +try +{ + logger.Information("Processing..."); +} +finally +{ + // Manual control + await logger.FlushAsync(); + + // Perform custom cleanup + CleanupResources(); + + await logger.DisposeAsync(); +} +``` + +## Events + +Subscribe to log events for real-time processing or custom handling. + +### Basic Event Handler + +```csharp +logger.OnLog += (sender, message) => +{ + // Fired for every log event that passes filters + Console.WriteLine($"[Event] {message.Level}: {message.Message}"); +}; + +logger.Information("This event will be raised"); +``` + +### Advanced Event Handling + +```csharp +logger.OnLog += (sender, message) => +{ + // Log level filtering + if (message.Level >= LogLevel.Error) + { + // Send critical errors to external alert system + SendAlert($"{message.Level}: {message.Message}"); + } + + // Category filtering + if (message.Category?.Contains("Payment") ?? false) + { + // Audit sensitive operations + LogToAuditSystem(message); + } + + // Property-based filtering + if (message.Properties != null && message.Properties.ContainsKey("UserId")) + { + TrackUserActivity(message.Properties["UserId"], message); + } +}; +``` + +### Multiple Event Handlers + +```csharp +// Handler 1: Alert on errors +logger.OnLog += (sender, msg) => +{ + if (msg.Level == LogLevel.Error) + SendSlackAlert(msg); +}; + +// Handler 2: Track metrics +logger.OnLog += (sender, msg) => +{ + Metrics.Increment($"logs.{msg.Level.ToString().ToLower()}"); +}; + +// Handler 3: Update dashboard +logger.OnLog += (sender, msg) => +{ + Dashboard.AddLog(msg); +}; +``` + +## Custom Flows + +Create custom flows by implementing `IFlow` or extending `FlowBase` for complex scenarios. + +### Simple Custom Flow + +```csharp +public class MyCustomFlow : FlowBase +{ + public MyCustomFlow() : base("MyCustomFlow", LogLevel.Trace) { } + + public override Task BlastAsync(LogEvent logEvent, CancellationToken ct = default) + { + try + { + // Your custom logic here + string formatted = $"[{logEvent.Timestamp:O}] {logEvent.Level}: {logEvent.Message}"; + MyBackendService.SendLog(formatted); + + return Task.FromResult(WriteResult.Success); + } + catch (Exception ex) + { + return Task.FromResult(WriteResult.Failure(ex)); + } + } + + public override Task FlushAsync(CancellationToken ct = default) + { + // Optional: implement batching flush logic + return Task.CompletedTask; + } +} + +// Register with: +new LogBuilder("App") + .WriteTo(new MyCustomFlow()) + .Build(); +``` + +### Advanced Custom Flow with Batching + +```csharp +public class BatchedCustomFlow : FlowBase +{ + private readonly List _batch = new(100); + private readonly object _lock = new(); + + public BatchedCustomFlow() : base("BatchedCustom", LogLevel.Trace) { } + + public override Task BlastAsync(LogEvent logEvent, CancellationToken ct = default) + { + lock (_lock) + { + _batch.Add(logEvent); + + if (_batch.Count >= 100) + { + return FlushBatchAsync(ct); + } + } + + return Task.FromResult(WriteResult.Success); + } + + private Task FlushBatchAsync(CancellationToken ct) + { + try + { + var toSend = _batch.ToList(); + _batch.Clear(); + + // Send batch to external service + MyBackendService.SendLogBatch(toSend); + return Task.FromResult(WriteResult.Success); + } + catch (Exception ex) + { + return Task.FromResult(WriteResult.Failure(ex)); + } + } + + public override async Task FlushAsync(CancellationToken ct = default) + { + lock (_lock) + { + if (_batch.Count > 0) + { + await FlushBatchAsync(ct); + } + } + } + + public override async ValueTask DisposeAsync() + { + await FlushAsync(); + await base.DisposeAsync(); + } +} +``` + +### Custom Flow with Retry Logic + +```csharp +public class RetryableCustomFlow : FlowBase +{ + private const int MaxRetries = 3; + + public RetryableCustomFlow() : base("RetryableCustom", LogLevel.Trace) { } + + public override async Task BlastAsync(LogEvent logEvent, CancellationToken ct = default) + { + int attempts = 0; + Exception lastEx = null; + + while (attempts < MaxRetries) + { + try + { + await SendToServiceAsync(logEvent, ct); + return WriteResult.Success; + } + catch (Exception ex) + { + lastEx = ex; + attempts++; + + if (attempts < MaxRetries) + await Task.Delay(TimeSpan.FromMilliseconds(Math.Pow(2, attempts) * 100), ct); + } + } + + return WriteResult.Failure(lastEx); + } + + private Task SendToServiceAsync(LogEvent logEvent, CancellationToken ct) + { + // Your implementation + return MyBackendService.SendLogAsync(logEvent, ct); + } + + public override Task FlushAsync(CancellationToken ct = default) => Task.CompletedTask; +} +``` + +## Best Practices & Tips + +### 1. **Use Appropriate Log Levels** + +```csharp +logger.Trace("Very detailed diagnostic info (most verbose)"); +logger.Debug("Debug-level diagnostic information"); +logger.Information("General informational message (default level)"); +logger.Warning("Warning message (potential issue)"); +logger.Error("Error occurred, operation failed"); +logger.Critical("Critical failure, system may be unstable"); +``` + +### 2. **Use Structured Properties for Queryability** + +```csharp +// Good: Structured properties +logger.Information("User logged in", + ("UserId", user.Id), + ("Email", user.Email), + ("IpAddress", request.RemoteIpAddress), + ("Timestamp", DateTime.UtcNow)); + +// Avoid: String interpolation in message +logger.Information($"User {user.Id} logged in from {request.RemoteIpAddress}"); +``` + +### 3. **Use Categories for Filtering** + +```csharp +// Create category-specific loggers +ILogger serviceLogger = loggerFactory.CreateLogger("MyApp.Services.UserService"); +ILogger dataLogger = loggerFactory.CreateLogger("MyApp.Data.Repository"); + +// Configure file splitting by category +.WriteToFile("./logs", useCategoryRouting: true) +// Results in: logs/MyApp.Services.UserService.log, logs/MyApp.Data.Repository.log +``` + +### 4. **Always Handle Exceptions** + +```csharp +try +{ + // Risky operation + await database.ExecuteAsync(query); +} +catch (TimeoutException ex) +{ + logger.Warning(ex, "Database timeout, retrying"); +} +catch (Exception ex) +{ + logger.Error(ex, "Database error, operation failed"); + throw; // Re-throw to propagate to caller +} +``` + +### 5. **Use Correlation IDs for Tracing** + +```csharp +builder.Services.AddEonaCatLogging("MyApp", logBuilder => +{ + logBuilder.BoostWithCorrelationId(); // Automatic tracing +}); + +// All logs in the same request/operation automatically get the same correlation ID +``` + +### 6. **Configure Different Levels per Flow** + +```csharp +.WithMinimumLevel(LogLevel.Information) // Global minimum +.WriteToConsole(minimumLevel: LogLevel.Debug) // More verbose +.WriteToFile(minimumLevel: LogLevel.Warning) // Less verbose +.WriteToSlack(minimumLevel: LogLevel.Error) // Errors only +``` + +### 7. **Use Async Flushing Before Shutdown** + +```csharp +var host = builder.Build(); + +// Graceful shutdown: flush logs before exit +var lifetime = host.Services.GetRequiredService(); +lifetime.ApplicationStopping.Register(async () => +{ + var logger = host.Services.GetRequiredService(); + await logger.FlushAsync(); +}); + +await host.RunAsync(); +``` + +### 8. **Monitor Drop Rates** + +```csharp +_ = Task.Run(async () => +{ + while (true) + { + await Task.Delay(TimeSpan.FromMinutes(1)); + + var stats = logger.GetDiagnostics(); + if (stats.TotalLogged + stats.TotalDropped > 0) + { + double dropRate = (double)stats.TotalDropped / (stats.TotalLogged + stats.TotalDropped); + if (dropRate > 0.01) // > 1% + logger.Warning("High drop rate detected", ("DropRate", dropRate)); + } + } +}); +``` + +### 9. **Use Rolling Buffers for Post-Mortem** + +```csharp +// Before: error happens, context is lost +// After: rolling buffer captures previous 100 logs .WriteToRollingBuffer( capacity: 500, triggerLevel: LogLevel.Error, triggerTarget: new FileFlow("./error-context")) ``` ---- - -## Encrypted File Logging +### 10. **Encrypt Sensitive Data** ```csharp -.WriteToEncryptedFile("./secure-logs", password: "s3cr3t") +.WriteToEncryptedFile( + directory: "./secure-logs", + password: GetEncryptionPasswordFromVault()) ``` -To decrypt later: -```csharp -LogBuilder.DecryptFile( - encryptedPath: "./secure-logs/log.enc", - outputPath: "./secure-logs/log.txt", - password: "s3cr3t"); -``` +## Troubleshooting ---- +### High CPU Usage -## Audit Trail +**Symptoms**: Excessive CPU while logging -The audit flow produces a tamper-evident file where every entry is SHA-256 hash-chained. Deleting or modifying any past entry invalidates all subsequent hashes. +**Solutions**: +- Increase batch size to reduce I/O operations +- Use `WriteToThrottled()` to rate limit +- Check if remote endpoints are responding (network latency) ```csharp -.WriteToAudit( - directory: "./audit", - auditLevel: AuditLevel.WarningAndAbove, - includeProperties: true) +.WriteToFile("./logs", batchSize: 100, flushIntervalInMilliSeconds: 5000) +.WriteToThrottled(inner: new HttpFlow(url), burstCapacity: 50) ``` -Verify integrity at any time: -```csharp -bool intact = AuditFlow.Verify("./audit/audit.audit"); -``` +### High Memory Usage ---- +**Symptoms**: Memory grows linearly with time -## Log Message Template - -Both `ConsoleFlow` and `FileFlow` accept a customisable template string: - -``` -[{ts}] [{tz}] [Host: {host}] [Category: {category}] [Thread: {thread}] [{logtype}] {message}{props} -``` - -| Token | Description | -|-------|-------------| -| `{ts}` | Timestamp (yyyy-MM-dd HH:mm:ss.fff) | -| `{tz}` | Timezone (UTC or local name) | -| `{host}` | Machine name | -| `{category}` | Logger category | -| `{thread}` | Managed thread ID | -| `{pid}` | Process ID | -| `{logtype}` | Log level label (INFO, WARN, ERROR, …) | -| `{message}` | Log message text | -| `{props}` | Structured properties as key=value pairs | -| `{newline}` | Line break | - ---- - -## Diagnostics +**Solutions**: +- Check if logs are being flushed (batches aren't sent) +- Reduce rolling buffer capacity +- Enable compression for file logs ```csharp -var diag = logger.GetDiagnostics(); -Console.WriteLine($"Logged: {diag.TotalLogged}, Dropped: {diag.TotalDropped}"); +.WriteToFile(compression: CompressionFormat.GZip) +.WriteToRollingBuffer(capacity: 250) // Reduce from 500 ``` ---- +### Events Being Dropped -## Flushing and Disposal +**Symptoms**: `TotalDropped > 0` in diagnostics + +**Causes & Solutions**: +- Backpressure queue full → increase capacity or reduce send rate +- Flow errors → check flow configuration and connectivity +- Log level filters → verify minimumLevel settings ```csharp -// Flush all pending events -await logger.FlushAsync(); - -// Dispose (flushes automatically) -await logger.DisposeAsync(); -``` - ---- - -## Events - -```csharp -logger.OnLog += (sender, msg) => +// Investigate drop reasons +var stats = logger.GetDiagnostics(); +foreach (var flow in stats.FlowStats.Where(f => f.Dropped > 0)) { - // Fired for every log event that passes filters - Console.WriteLine($"[Event] {msg.Level}: {msg.Message}"); -}; -``` - ---- - -## Custom Flows - -Implement `IFlow` (or extend `FlowBase`) to create your own destination: - -```csharp -public class MyFlow : FlowBase -{ - public MyFlow() : base("MyFlow", LogLevel.Trace) { } - - public override Task BlastAsync(LogEvent logEvent, CancellationToken ct = default) - { - // Write logEvent somewhere - return Task.FromResult(WriteResult.Success); - } - - public override Task FlushAsync(CancellationToken ct = default) => Task.CompletedTask; + logger.Warning($"Flow {flow.Name} dropped {flow.Dropped} events"); } +``` -// Register with: -new LogBuilder("App").WriteTo(new MyFlow()).Build(); +### Logs Not Reaching Remote Destination + +**Symptoms**: Local logs exist, but remote endpoint has nothing + +**Troubleshooting Steps**: +1. Verify endpoint connectivity: `telnet host port` +2. Check authentication (API keys, certificates) +3. Enable retry and failover patterns +4. Ensure events pass level filters + +```csharp +.WriteToRetry( + primaryFlow: new HttpFlow(endpoint), + maxRetries: 5) + +// Or use failover: +.WriteToFailover( + primaryFlow: new HttpFlow(endpoint), + secondaryFlow: new FileFlow("./fallback")) +``` + +### File Size Growing Too Large + +**Symptoms**: Log files grow without bound + +**Solutions**: +- Configure `maxFileSize` and `maxDirectorySize` +- Enable compression +- Set retention policy + +```csharp +.WriteToFile( + directory: "./logs", + maxFileSize: 100 * 1024 * 1024, // 100 MB + maxDirectorySize: 10L * 1024 * 1024 * 1024, // 10 GB + compression: CompressionFormat.GZip, + fileRetentionPolicy: new FileRetentionPolicy { RetentionDays = 30 }) ``` @@ -795,12 +2546,245 @@ public class DiagnosticsService public void PrintDiagnostics() { var diagnostics = _loggerFactory.GetDiagnostics(); - Console.WriteLine($"Total Logged: {diagnostics.TotalLoggedCount}"); - Console.WriteLine($"Total Dropped: {diagnostics.TotalDroppedCount}"); + Console.WriteLine($"Total Logged: {diagnostics.TotalLogged}"); + Console.WriteLine($"Total Dropped: {diagnostics.TotalDropped}"); + Console.WriteLine($"Total Exceptions: {diagnostics.TotalExceptions}"); } } ``` +## Statistics & Metrics + +EonaCat.LogStack provides comprehensive statistics and metrics tracking for monitoring and optimizing your logging infrastructure. + +### Per-Level Metrics + +Track events logged at each level: + +```csharp +var metrics = loggerFactory.GetMetrics(); + +Console.WriteLine($"Trace: {metrics.TraceCount}"); +Console.WriteLine($"Debug: {metrics.DebugCount}"); +Console.WriteLine($"Information: {metrics.InformationCount}"); +Console.WriteLine($"Warning: {metrics.WarningCount}"); +Console.WriteLine($"Error: {metrics.ErrorCount}"); +Console.WriteLine($"Critical: {metrics.CriticalCount}"); +``` + +### Performance Metrics + +Monitor throughput and latency: + +```csharp +var metrics = loggerFactory.GetMetrics(); + +Console.WriteLine($"Events/Second: {metrics.WritesPerSecond:F2}"); +Console.WriteLine($"Total Bytes: {metrics.TotalBytes:N0}"); +Console.WriteLine($"Avg Bytes/Event: {metrics.AverageBytesPerEvent:F2}"); +Console.WriteLine($"Success Rate: {metrics.SuccessRate:F2}%"); +Console.WriteLine($"Uptime: {TimeSpan.FromMilliseconds(metrics.UptimeMilliseconds):hh\\:mm\\:ss}"); +``` + +### Exception Tracking + +Monitor exceptions logged: + +```csharp +var metrics = loggerFactory.GetMetrics(); +Console.WriteLine($"Total Exceptions: {metrics.TotalExceptions}"); +Console.WriteLine($"Exceptions in Errors: {metrics.ErrorCount} errors logged"); +``` + +### Flow-Specific Statistics + +Each flow tracks its own performance: + +```csharp +var metrics = loggerFactory.GetMetrics(); + +foreach (var flow in metrics.FlowMetrics) +{ + Console.WriteLine($"{flow.FlowName} ({flow.FlowType}):"); + Console.WriteLine($" Processed: {flow.EventsProcessed}"); + Console.WriteLine($" Failed: {flow.EventsFailed}"); + Console.WriteLine($" Success Rate: {flow.SuccessRate:F2}%"); + Console.WriteLine($" Events/Second: {flow.EventsPerSecond:F2}"); + Console.WriteLine($" P95 Latency: {flow.P95LatencyMs:F3}ms"); + Console.WriteLine($" P99 Latency: {flow.P99LatencyMs:F3}ms"); +} +``` + +### System-Wide Metrics Collection + +Use `AdvancedMetricsCollector` to aggregate metrics across multiple loggers: + +```csharp +var collector = app.Services.GetRequiredService(); + +// Register loggers for collection +var logStack = app.Services.GetRequiredService(); +collector.RegisterLogger(logStack); + +// Get aggregated metrics +var aggregated = collector.GetAggregatedMetrics(); +Console.WriteLine($"Total Logged (All): {aggregated.TotalLoggedAcrossAll:N0}"); +Console.WriteLine($"Overall Success Rate: {aggregated.OverallSuccessRate:F2}%"); + +// Get performance comparison +var comparison = collector.GetPerformanceComparison(); +Console.WriteLine($"Highest Throughput: {comparison.HighestThroughputLogger?.WritesPerSecond:F2} events/sec"); +Console.WriteLine($"Average Throughput: {comparison.AverageWritesPerSecond:F2} events/sec"); + +// Get health report +var health = collector.GetHealthReport(); +Console.WriteLine($"System Status: {health.OverallStatus}"); +foreach (var warning in health.Warnings) + Console.WriteLine($" ⚠️ {warning}"); +``` + +## Dependency Injection Integration + +EonaCat.LogStack seamlessly integrates with Microsoft Dependency Injection for ASP.NET Core and other .NET applications. + +### Basic Registration + +```csharp +var builder = Host.CreateApplicationBuilder(); +builder.Services.AddEonaCatLogging(); +``` + +This registers: +- `ILoggerFactory` (EonaCat interface) +- `Microsoft.Extensions.Logging.ILoggerFactory` (standard interface) +- `ILogger` (EonaCat interface) +- `Microsoft.Extensions.Logging.ILogger` (standard interface) + +### With Fluent Configuration + +```csharp +var builder = Host.CreateApplicationBuilder(); +builder.Services.AddEonaCatLogging(b => +{ + b.WithMinimumLevel(LogLevel.Information) + .WriteToConsole() + .WriteToFile("./logs") + .BoostWithCorrelationId(); +}); +``` + +### With HostApplicationBuilder (Recommended) + +```csharp +var builder = Host.CreateApplicationBuilder(); + +builder.AddEonaCatLogging(b => +{ + b.WithMinimumLevel(LogLevel.Information) + .WriteToConsole() + .WriteToFile("./logs"); +}); + +var app = builder.Build(); +``` + +### Advanced Features with AdvancedLoggerFactory + +For category-specific configuration and context propagation: + +```csharp +var builder = Host.CreateApplicationBuilder(); + +builder.Services.AddAdvancedEonaCatLogging(factory => +{ + factory.ConfigureCategory("Database", config => + { + config.MinimumLevel = LogLevel.Debug; + config.Boosters.Add(new CorrelationIdBooster()); + }); + + factory.ConfigureCategory("Security", config => + { + config.MinimumLevel = LogLevel.Warning; + }); +}); + +var app = builder.Build(); + +// Get loggers +var dbLogger = app.Services.GetRequiredService().CreateLogger("Database"); +var secLogger = app.Services.GetRequiredService().CreateLogger("Security"); +``` + +### With Metrics Collection + +```csharp +var builder = Host.CreateApplicationBuilder(); + +builder.AddEonaCatLogging(b => +{ + b.WithMinimumLevel(LogLevel.Information) + .WriteToConsole() + .WriteToFile("./logs"); +}); + +builder.AddEonaCatMetricsCollection(); + +var app = builder.Build(); + +// Access metrics +var collector = app.Services.GetRequiredService(); +var metrics = collector.GetAggregatedMetrics(); +Console.WriteLine(metrics); +``` + +### Using Microsoft.Extensions.Logging + +The registered adapters allow you to use standard Microsoft logging interfaces: + +```csharp +[ApiController] +[Route("api/[controller]")] +public class UserController : ControllerBase +{ + private readonly Microsoft.Extensions.Logging.ILogger _logger; + + public UserController(Microsoft.Extensions.Logging.ILogger logger) + { + _logger = logger; + } + + [HttpGet("{id}")] + public async Task GetUser(int id) + { + _logger.LogInformation("Fetching user {UserId}", id); + // ... + } +} +``` + +The request is automatically routed through EonaCat.LogStack flows (file, console, Slack, etc.). + +### Context Propagation + +With AdvancedLoggerFactory, you can propagate context across async operations: + +```csharp +var factory = app.Services.GetRequiredService(); + +// Set context in request scope +factory.SetContextData("RequestId", context.TraceIdentifier); +factory.SetContextData("UserId", user.Id); + +// Logger can access this context +var logger = factory.CreateLogger("MyCategory"); +logger.Log(LogLevel.Information, "Processing request"); +// Context is included automatically + +// Clear context when done +factory.ClearContextData(); +``` + ## Disposing of the Logger The logger is registered as a Singleton in the DI container, so it will be automatically disposed when the application shuts down. You can also manually access and dispose it: