From 576a5901049e0ba69b4b3a111e2baf2bbaf76304 Mon Sep 17 00:00:00 2001 From: EonaCat Date: Mon, 12 Jan 2026 22:09:07 +0100 Subject: [PATCH] Removed some memory leaks --- .../EonaCat.Logger.LogClient.csproj | 4 +- EonaCat.Logger.LogClient/LogCentralClient.cs | 10 +- EonaCat.Logger/EonaCat.Logger.csproj | 2 +- .../BatchingDatabaseLogger.cs | 171 ++++++++------- .../BatchingDatabaseLoggerOptions.cs | 1 + .../EonaCatCoreLogger/DatabaseLogger.cs | 133 +++++++----- .../EonaCatCoreLogger/DiscordLogger.cs | 87 +++++--- .../EonaCatCoreLogger/ElasticSearchLogger.cs | 96 +++++---- .../ElasticSearchLoggerOptions.cs | 1 + .../EonaCatCoreLogger/HttpLogger.cs | 122 ++++++----- .../EonaCatCoreLogger/JsonFileLogger.cs | 72 +++++-- .../EonaCatCoreLogger/LogContext.cs | 74 +++++-- .../EonaCatCoreLogger/SlackLogger.cs | 63 ++++-- EonaCat.Logger/EonaCatCoreLogger/TcpLogger.cs | 80 ++++--- .../EonaCatCoreLogger/TelegramLogger.cs | 73 +++++-- EonaCat.Logger/EonaCatCoreLogger/UdpLogger.cs | 75 +++++-- .../EonaCatCoreLogger/XmlFileLogger.cs | 30 +-- EonaCat.Logger/Managers/LogManager.cs | 57 +++-- EonaCat.Logger/Servers/GrayLog/Graylog.cs | 67 +++--- .../Servers/Splunk/Models/SplunkPayload.cs | 3 + EonaCat.Logger/Servers/Splunk/Splunk.cs | 43 +++- EonaCat.Logger/Servers/Syslog/Syslog.cs | 81 +++++-- EonaCat.Logger/Servers/Tcp/Tcp.cs | 46 +++- EonaCat.Logger/Servers/Udp/Udp.cs | 46 +++- .../Servers/Zabbix/API/ZabbixApi.cs | 67 +++++- .../Servers/Zabbix/ZabbixRequest.cs | 198 ++++++++++-------- .../EonaCat.Logger.Test.Web.csproj | 2 +- Testers/EonaCat.Logger.Test.Web/Program.cs | 2 +- 28 files changed, 1117 insertions(+), 589 deletions(-) diff --git a/EonaCat.Logger.LogClient/EonaCat.Logger.LogClient.csproj b/EonaCat.Logger.LogClient/EonaCat.Logger.LogClient.csproj index 2a2117f..5c8e159 100644 --- a/EonaCat.Logger.LogClient/EonaCat.Logger.LogClient.csproj +++ b/EonaCat.Logger.LogClient/EonaCat.Logger.LogClient.csproj @@ -3,7 +3,7 @@ netstandard2.1 true EonaCat.Logger.LogClient - 1.0.1 + 1.0.2 EonaCat (Jeroen Saey) Logging client for the EonaCat Logger LogServer logging;monitoring;analytics;diagnostics @@ -25,7 +25,7 @@ - + diff --git a/EonaCat.Logger.LogClient/LogCentralClient.cs b/EonaCat.Logger.LogClient/LogCentralClient.cs index 30973cb..3165b39 100644 --- a/EonaCat.Logger.LogClient/LogCentralClient.cs +++ b/EonaCat.Logger.LogClient/LogCentralClient.cs @@ -93,7 +93,10 @@ namespace EonaCat.Logger.LogClient private async Task FlushAsync() { - if (_logQueue.IsEmpty) return; + if (_logQueue.IsEmpty) + { + return; + } await _flushSemaphore.WaitAsync(); try @@ -166,7 +169,10 @@ namespace EonaCat.Logger.LogClient public void Dispose() { - if (_disposed) return; + if (_disposed) + { + return; + } _flushTimer?.Dispose(); FlushAsync().GetAwaiter().GetResult(); diff --git a/EonaCat.Logger/EonaCat.Logger.csproj b/EonaCat.Logger/EonaCat.Logger.csproj index da005ff..1ef5afb 100644 --- a/EonaCat.Logger/EonaCat.Logger.csproj +++ b/EonaCat.Logger/EonaCat.Logger.csproj @@ -23,7 +23,7 @@ - 1.5.2+{chash:10}.{c:ymd} + 1.5.3+{chash:10}.{c:ymd} true true v[0-9]* diff --git a/EonaCat.Logger/EonaCatCoreLogger/BatchingDatabaseLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/BatchingDatabaseLogger.cs index 11a60e9..537eab4 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/BatchingDatabaseLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/BatchingDatabaseLogger.cs @@ -1,26 +1,32 @@ -using Microsoft.Extensions.Logging; +using EonaCat.Logger.EonaCatCoreLogger; +using Microsoft.Extensions.Logging; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Data.Common; -using System.Text; using System.Threading; using System.Threading.Tasks; namespace EonaCat.Logger.EonaCatCoreLogger { - // 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. - public class BatchingDatabaseLogger : ILogger, IDisposable + public sealed class BatchingDatabaseLogger : ILogger, IDisposable { + // 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. + private readonly string _categoryName; private readonly BatchingDatabaseLoggerOptions _options; private readonly LoggerScopedContext _context = new(); + private readonly BlockingCollection _queue; - private readonly CancellationTokenSource _cts; + private readonly CancellationTokenSource _cts = new(); private readonly Task _processingTask; - public bool IncludeCorrelationId { get; set; } + private volatile bool _disposed; + + private const int MaxQueueSize = 10_000; + + public bool IncludeCorrelationId { get; } public event EventHandler OnException; public BatchingDatabaseLogger(string categoryName, BatchingDatabaseLoggerOptions options) @@ -29,22 +35,22 @@ namespace EonaCat.Logger.EonaCatCoreLogger _options = options; IncludeCorrelationId = options.IncludeCorrelationId; - _queue = new BlockingCollection(new ConcurrentQueue()); - _cts = new CancellationTokenSource(); - _processingTask = Task.Run(ProcessQueueAsync); + _queue = new BlockingCollection(new ConcurrentQueue(), MaxQueueSize); + _processingTask = Task.Run(() => ProcessQueueAsync(_cts.Token)); } - public IDisposable BeginScope(TState state) => null; - public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled; + public IDisposable BeginScope(TState state) => _context.BeginScope(state); - public void SetContext(string key, string value) => _context.Set(key, value); - public void ClearContext() => _context.Clear(); - public string GetContext(string key) => _context.Get(key); + public bool IsEnabled(LogLevel logLevel) => !_disposed && _options.IsEnabled; - public void Log(LogLevel logLevel, EventId eventId, TState state, - Exception exception, Func formatter) + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception exception, + Func formatter) { - if (!IsEnabled(logLevel) || formatter == null) + if (_disposed || !IsEnabled(logLevel) || formatter == null) { return; } @@ -60,7 +66,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger _context.Set("CorrelationId", correlationId); } - _queue.Add(new LogEntry + var entry = new LogEntry { Timestamp = DateTime.UtcNow, LogLevel = logLevel.ToString(), @@ -68,53 +74,52 @@ namespace EonaCat.Logger.EonaCatCoreLogger Message = message, Exception = exception?.ToString(), CorrelationId = correlationId - }); - } + }; - private async Task ProcessQueueAsync() - { - var batch = new List(); - var timeoutMs = (int)Math.Min(_options.BatchInterval.TotalMilliseconds, int.MaxValue); - - while (!_cts.Token.IsCancellationRequested) + // Drop oldest when full (non-blocking) + while (!_queue.TryAdd(entry)) { - try - { - if (_queue.TryTake(out var logEntry, timeoutMs, _cts.Token)) - { - batch.Add(logEntry); - - // Drain the queue quickly without waiting - while (_queue.TryTake(out var additionalEntry)) - { - batch.Add(additionalEntry); - } - } - - if (batch.Count > 0) - { - await InsertBatchSafelyAsync(batch); - } - } - catch (OperationCanceledException) - { - break; - } - catch (Exception ex) - { - OnException?.Invoke(this, ex); - } - } - - // Final flush outside the loop - if (batch.Count > 0) - { - await InsertBatchSafelyAsync(batch); + _queue.TryTake(out _); // discard oldest } } - private async Task InsertBatchSafelyAsync(List batch) + private async Task ProcessQueueAsync(CancellationToken token) { + var batch = new List(_options.BatchSize); + + try + { + foreach (var entry in _queue.GetConsumingEnumerable(token)) + { + batch.Add(entry); + + if (batch.Count >= _options.BatchSize) + { + await FlushBatchAsync(batch); + } + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + OnException?.Invoke(this, ex); + } + finally + { + if (batch.Count > 0) + { + await FlushBatchAsync(batch); // flush remaining + } + } + } + + private async Task FlushBatchAsync(List batch) + { + if (batch.Count == 0) + { + return; + } + try { await InsertBatchAsync(batch); @@ -131,20 +136,16 @@ namespace EonaCat.Logger.EonaCatCoreLogger private async Task InsertBatchAsync(List batch) { - using var connection = _options.DbProviderFactory.CreateConnection(); - if (connection == null) - { - throw new InvalidOperationException("Failed to create database connection."); - } + using var connection = _options.DbProviderFactory.CreateConnection() + ?? throw new InvalidOperationException("Failed to create database connection."); connection.ConnectionString = _options.ConnectionString; - await connection.OpenAsync(); + await connection.OpenAsync(_cts.Token); foreach (var entry in batch) { using var command = connection.CreateCommand(); command.CommandText = _options.InsertCommand; - command.Parameters.Clear(); command.Parameters.Add(CreateParameter(command, "Timestamp", entry.Timestamp)); command.Parameters.Add(CreateParameter(command, "LogLevel", entry.LogLevel)); @@ -153,11 +154,11 @@ namespace EonaCat.Logger.EonaCatCoreLogger command.Parameters.Add(CreateParameter(command, "Exception", entry.Exception)); command.Parameters.Add(CreateParameter(command, "CorrelationId", entry.CorrelationId)); - await command.ExecuteNonQueryAsync(); + await command.ExecuteNonQueryAsync(_cts.Token); } } - private DbParameter CreateParameter(DbCommand command, string name, object value) + private static DbParameter CreateParameter(DbCommand command, string name, object value) { var param = command.CreateParameter(); param.ParameterName = $"@{name}"; @@ -167,26 +168,34 @@ namespace EonaCat.Logger.EonaCatCoreLogger public void Dispose() { + if (_disposed) + { + return; + } + + _disposed = true; + _cts.Cancel(); _queue.CompleteAdding(); + try { _processingTask.Wait(_options.ShutdownTimeout); } - catch (Exception ex) - { - OnException?.Invoke(this, ex); - } + catch { /* ignore */ } + + _queue.Dispose(); + _cts.Dispose(); } - private class LogEntry + private sealed class LogEntry { - public DateTime Timestamp { get; set; } - public string LogLevel { get; set; } - public string Category { get; set; } - public string Message { get; set; } - public string Exception { get; set; } - public string CorrelationId { get; set; } + public DateTime Timestamp { get; init; } + public string LogLevel { get; init; } + public string Category { get; init; } + public string Message { get; init; } + public string Exception { get; init; } + public string CorrelationId { get; init; } } } -} +} \ No newline at end of file diff --git a/EonaCat.Logger/EonaCatCoreLogger/BatchingDatabaseLoggerOptions.cs b/EonaCat.Logger/EonaCatCoreLogger/BatchingDatabaseLoggerOptions.cs index f77607b..6b2a8d5 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/BatchingDatabaseLoggerOptions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/BatchingDatabaseLoggerOptions.cs @@ -15,6 +15,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger public bool IsEnabled { get; set; } = true; public bool IncludeCorrelationId { get; set; } + public int BatchSize { get; set; } = 50; public TimeSpan BatchInterval { get; set; } = TimeSpan.FromSeconds(5); // 5 sec batch public TimeSpan ShutdownTimeout { get; set; } = TimeSpan.FromSeconds(10); // wait on shutdown diff --git a/EonaCat.Logger/EonaCatCoreLogger/DatabaseLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/DatabaseLogger.cs index 648fb48..9570ed3 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/DatabaseLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/DatabaseLogger.cs @@ -7,18 +7,23 @@ using System.Threading.Tasks; namespace EonaCat.Logger.EonaCatCoreLogger { - // 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. - public class DatabaseLogger : ILogger, IDisposable + public sealed class DatabaseLogger : ILogger, IDisposable { + // 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. + private readonly string _categoryName; private readonly DatabaseLoggerOptions _options; private readonly LoggerScopedContext _context = new(); private static readonly ConcurrentQueue _queue = new(); private static readonly SemaphoreSlim _flushLock = new(1, 1); - private static bool _flushLoopStarted = false; - private static CancellationTokenSource _cts; + + private static CancellationTokenSource _cts = new(); + private static Task _flushTask; + private static int _refCount; + + private const int MaxQueueSize = 10_000; public bool IncludeCorrelationId { get; set; } public event EventHandler OnException; @@ -29,69 +34,88 @@ namespace EonaCat.Logger.EonaCatCoreLogger _options = options; IncludeCorrelationId = options.IncludeCorrelationId; - if (!_flushLoopStarted) + if (Interlocked.Increment(ref _refCount) == 1) { - _flushLoopStarted = true; - _cts = new CancellationTokenSource(); - Task.Run(() => FlushLoopAsync(_cts.Token)); + _flushTask = Task.Run(() => FlushLoopAsync(_cts.Token)); } } - public IDisposable BeginScope(TState state) => null; - public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled; + public IDisposable BeginScope(TState state) + => _context.BeginScope(state); - public void SetContext(string key, string value) => _context.Set(key, value); - public void ClearContext() => _context.Clear(); - public string GetContext(string key) => _context.Get(key); + public bool IsEnabled(LogLevel logLevel) + => _options.IsEnabled; - public void Log(LogLevel logLevel, EventId eventId, TState state, - Exception exception, Func formatter) + public void SetContext(string key, string value) + => _context.Set(key, value); + + public void ClearContext() + => _context.Clear(); + + public string GetContext(string key) + => _context.Get(key); + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception exception, + Func formatter) { if (!IsEnabled(logLevel) || formatter == null) { return; } + if (_queue.Count >= MaxQueueSize) + { + _queue.TryDequeue(out _); // drop oldest + } + if (IncludeCorrelationId) { - var correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); + var correlationId = + _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); _context.Set("CorrelationId", correlationId); } - var message = formatter(state, exception); - - var entry = new LogEntry + _queue.Enqueue(new LogEntry { Timestamp = DateTime.UtcNow, LogLevel = logLevel.ToString(), Category = _categoryName, - Message = message, + Message = formatter(state, exception), Exception = exception?.ToString(), CorrelationId = _context.Get("CorrelationId") - }; - - _queue.Enqueue(entry); - - if (_queue.Count >= _options.FlushBatchSize) - { - _ = FlushBufferAsync(); - } + }); } private async Task FlushLoopAsync(CancellationToken token) { - while (!token.IsCancellationRequested) + try { - await Task.Delay(TimeSpan.FromSeconds(_options.FlushIntervalSeconds), token); - await FlushBufferAsync(); + while (!token.IsCancellationRequested) + { + await Task.Delay( + TimeSpan.FromSeconds(_options.FlushIntervalSeconds), + token); + + await FlushBufferAsync(); + } } + catch (OperationCanceledException) + { + // Expected on shutdown + } + + await FlushBufferAsync(); // final drain } private async Task FlushBufferAsync() { if (!await _flushLock.WaitAsync(0)) { - return; // Another flush is in progress + return; } try @@ -100,10 +124,12 @@ namespace EonaCat.Logger.EonaCatCoreLogger { try { - using var connection = _options.DbProviderFactory.CreateConnection(); + using var connection = + _options.DbProviderFactory.CreateConnection(); + if (connection == null) { - throw new InvalidOperationException("Failed to create database connection."); + throw new InvalidOperationException("Failed to create DB connection."); } connection.ConnectionString = _options.ConnectionString; @@ -111,7 +137,6 @@ namespace EonaCat.Logger.EonaCatCoreLogger using var command = connection.CreateCommand(); command.CommandText = _options.InsertCommand; - command.Parameters.Clear(); command.Parameters.Add(CreateParameter(command, "Timestamp", entry.Timestamp)); command.Parameters.Add(CreateParameter(command, "LogLevel", entry.LogLevel)); @@ -134,7 +159,10 @@ namespace EonaCat.Logger.EonaCatCoreLogger } } - private DbParameter CreateParameter(DbCommand command, string name, object value) + private static DbParameter CreateParameter( + DbCommand command, + string name, + object value) { var param = command.CreateParameter(); param.ParameterName = $"@{name}"; @@ -144,19 +172,28 @@ namespace EonaCat.Logger.EonaCatCoreLogger public void Dispose() { - _cts?.Cancel(); - _flushLock.Dispose(); - _cts?.Dispose(); + if (Interlocked.Decrement(ref _refCount) == 0) + { + _cts.Cancel(); + + try + { + _flushTask?.Wait(); + } + catch (AggregateException) { } + + _cts.Dispose(); + } } - private class LogEntry + private sealed class LogEntry { - public DateTime Timestamp { get; set; } - public string LogLevel { get; set; } - public string Category { get; set; } - public string Message { get; set; } - public string Exception { get; set; } - public string CorrelationId { get; set; } + public DateTime Timestamp { get; init; } + public string LogLevel { get; init; } + public string Category { get; init; } + public string Message { get; init; } + public string Exception { get; init; } + public string CorrelationId { get; init; } } - } + } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/DiscordLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/DiscordLogger.cs index fb2872e..8c0030a 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/DiscordLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/DiscordLogger.cs @@ -8,36 +8,42 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +// 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.Logger.EonaCatCoreLogger { - // 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. public class DiscordLogger : ILogger, IDisposable { private readonly string _categoryName; private readonly DiscordLoggerOptions _options; - private readonly LoggerScopedContext _context = new(); + private readonly LoggerScopedContext _context = new LoggerScopedContext(); + + // Static shared resources + private static readonly ConcurrentQueue _messageQueue = new ConcurrentQueue(); + private static readonly HttpClient _httpClient = new HttpClient(); + private static readonly SemaphoreSlim _flushLock = new SemaphoreSlim(1, 1); - private static readonly ConcurrentQueue _messageQueue = new(); - private static readonly HttpClient _httpClient = new(); - private static readonly SemaphoreSlim _flushLock = new(1, 1); private static bool _flushLoopStarted = false; private static CancellationTokenSource _cts; + private static Task _flushTask; public bool IncludeCorrelationId { get; set; } public event EventHandler OnException; public DiscordLogger(string categoryName, DiscordLoggerOptions options) { - _categoryName = categoryName; - _options = options; + _categoryName = categoryName ?? throw new ArgumentNullException(nameof(categoryName)); + _options = options ?? throw new ArgumentNullException(nameof(options)); IncludeCorrelationId = options.IncludeCorrelationId; + // Start flush loop once if (!_flushLoopStarted) { _flushLoopStarted = true; _cts = new CancellationTokenSource(); - _ = Task.Run(() => FlushLoopAsync(_cts.Token)); + _flushTask = Task.Factory.StartNew(() => FlushLoop(_cts.Token), + _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); } } @@ -69,7 +75,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger $"**[{DateTime.UtcNow:u}]**", $"**[{logLevel}]**", $"**[{_categoryName}]**", - $"Message: {message}", + $"Message: {message}" }; foreach (var kvp in _context.GetAll()) @@ -82,26 +88,34 @@ namespace EonaCat.Logger.EonaCatCoreLogger logParts.Add($"Exception: {exception}"); } - string fullMessage = string.Join("\n", logParts); - - // Enqueue and return immediately - non-blocking - _messageQueue.Enqueue(fullMessage); + _messageQueue.Enqueue(string.Join("\n", logParts)); } - private async Task FlushLoopAsync(CancellationToken token) + private void FlushLoop(CancellationToken token) { - while (!token.IsCancellationRequested) + try { - await Task.Delay(TimeSpan.FromSeconds(_options.FlushIntervalSeconds), token); - await FlushBufferAsync(); + while (!token.IsCancellationRequested) + { + Thread.Sleep(TimeSpan.FromSeconds(_options.FlushIntervalSeconds)); + FlushBuffer(token).Wait(token); + } + } + catch (OperationCanceledException) + { + // Expected when cancelling + } + catch (Exception ex) + { + OnException?.Invoke(this, ex); } } - private async Task FlushBufferAsync() + private async Task FlushBuffer(CancellationToken token) { - if (!await _flushLock.WaitAsync(0)) + if (!await _flushLock.WaitAsync(0, token)) { - return; // Already flushing + return; } try @@ -111,12 +125,14 @@ namespace EonaCat.Logger.EonaCatCoreLogger try { var payload = new { content = message }; - var content = new StringContent(JsonHelper.ToJson(payload), Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync(_options.WebhookUrl, content); - if (!response.IsSuccessStatusCode) + using (var content = new StringContent(JsonHelper.ToJson(payload), Encoding.UTF8, "application/json")) { - var error = await response.Content.ReadAsStringAsync(); - OnException?.Invoke(this, new Exception($"Discord webhook failed: {response.StatusCode} {error}")); + var response = await _httpClient.PostAsync(_options.WebhookUrl, content, token); + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + OnException?.Invoke(this, new Exception($"Discord webhook failed: {response.StatusCode} {error}")); + } } } catch (Exception ex) @@ -133,9 +149,24 @@ namespace EonaCat.Logger.EonaCatCoreLogger public void Dispose() { - _cts?.Cancel(); + if (_cts != null && !_cts.IsCancellationRequested) + { + _cts.Cancel(); + + try + { + _flushTask?.Wait(); + } + catch (AggregateException ae) + { + ae.Handle(e => e is OperationCanceledException); + } + + _cts.Dispose(); + _cts = null; + } + _flushLock.Dispose(); - _cts?.Dispose(); } } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/ElasticSearchLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/ElasticSearchLogger.cs index 07e1550..04726a5 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/ElasticSearchLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/ElasticSearchLogger.cs @@ -1,32 +1,32 @@ -using System.Linq; - -// 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. +using EonaCat.Json; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; namespace EonaCat.Logger.EonaCatCoreLogger { - using EonaCat.Json; - using Microsoft.Extensions.Logging; - using System; - using System.Collections.Generic; - using System.Net.Http; - using System.Text; - using System.Text.Json; - using System.Threading.Tasks; + // 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. - public class ElasticSearchLogger : ILogger + public class ElasticSearchLogger : ILogger, IDisposable { - private readonly string _categoryName; private readonly ElasticSearchLoggerOptions _options; - private static readonly HttpClient _httpClient = new HttpClient(); - private static readonly List _buffer = new List(); - private static readonly object _lock = new object(); - private static bool _flushLoopStarted = false; - public event EventHandler OnException; - public event EventHandler OnInvalidStatusCode; + private readonly HttpClient _httpClient; + private readonly List _buffer = new(); + private readonly object _lock = new(); + private readonly CancellationTokenSource _cts = new(); + private readonly Task _flushTask; + private readonly LoggerScopedContext _context = new(); public bool IncludeCorrelationId { get; set; } + public event EventHandler OnException; + public event EventHandler OnInvalidStatusCode; public ElasticSearchLogger(string categoryName, ElasticSearchLoggerOptions options) { @@ -34,18 +34,15 @@ namespace EonaCat.Logger.EonaCatCoreLogger _options = options; IncludeCorrelationId = options.IncludeCorrelationId; - if (!_flushLoopStarted) - { - _flushLoopStarted = true; - Task.Run(FlushLoopAsync); - } + _httpClient = new HttpClient(); + _flushTask = Task.Run(() => FlushLoopAsync(_cts.Token)); } public void SetContext(string key, string value) => _context.Set(key, value); public void ClearContext() => _context.Clear(); public string GetContext(string key) => _context.Get(key); - public IDisposable BeginScope(TState state) => null; + public IDisposable BeginScope(TState state) => _context.BeginScope(state); public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled; public void Log(LogLevel logLevel, EventId eventId, TState state, @@ -56,14 +53,12 @@ namespace EonaCat.Logger.EonaCatCoreLogger return; } - // Get correlation ID from context or generate new one if (IncludeCorrelationId) { var correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); _context.Set("CorrelationId", correlationId); } - // Prepare the log entry var logDoc = new { timestamp = DateTime.UtcNow, @@ -72,35 +67,41 @@ namespace EonaCat.Logger.EonaCatCoreLogger message = formatter(state, exception), exception = exception?.ToString(), eventId = eventId.Id, - customContext = _context.GetAll() + customContext = _context.GetAll() }; - // Serialize log to JSON string json = JsonHelper.ToJson(logDoc); - // Add to the buffer lock (_lock) { _buffer.Add(json); - if (_buffer.Count >= _options.RetryBufferSize) + // Optional: drop oldest if buffer is too large + if (_buffer.Count > _options.MaxBufferSize) { - _ = FlushBufferAsync(); + _buffer.RemoveAt(0); } } } - private async Task FlushLoopAsync() + private async Task FlushLoopAsync(CancellationToken token) { - while (true) + try { - await Task.Delay(TimeSpan.FromSeconds(_options.FlushIntervalSeconds)); - await FlushBufferAsync(); + while (!token.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(_options.FlushIntervalSeconds), token); + await FlushBufferAsync(); + } } + catch (OperationCanceledException) { } + + await FlushBufferAsync(); // flush remaining logs on shutdown } private async Task FlushBufferAsync() { List toSend; + lock (_lock) { if (_buffer.Count == 0) @@ -112,27 +113,23 @@ namespace EonaCat.Logger.EonaCatCoreLogger _buffer.Clear(); } - // Elasticsearch URL with dynamic index string indexName = $"{_options.IndexName}-{DateTime.UtcNow:yyyy.MM.dd}"; string url = $"{_options.Uri.TrimEnd('/')}/{(_options.UseBulkInsert ? "_bulk" : indexName + "/_doc")}"; - var request = new HttpRequestMessage(HttpMethod.Post, url); + using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Headers.Accept.ParseAdd("application/json"); - // Add authentication headers if configured if (!string.IsNullOrWhiteSpace(_options.Username)) { var authToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_options.Username}:{_options.Password}")); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authToken); } - // Add custom headers (static ones) foreach (var header in _options.CustomHeaders) { request.Headers.TryAddWithoutValidation(header.Key, header.Value); } - // Add template headers (dynamic ones based on log data) var dynamicHeaders = new Dictionary { { "index", _options.IndexName }, @@ -146,13 +143,11 @@ namespace EonaCat.Logger.EonaCatCoreLogger request.Headers.TryAddWithoutValidation(header.Key, value); } - // Add context headers (correlationId, custom context) foreach (var kv in _context.GetAll()) { request.Headers.TryAddWithoutValidation($"X-Context-{kv.Key}", kv.Value); } - // Prepare the content for the request request.Content = new StringContent( _options.UseBulkInsert ? string.Join("\n", toSend.Select(d => $"{{\"index\":{{}}}}\n{d}")) + "\n" @@ -170,9 +165,9 @@ namespace EonaCat.Logger.EonaCatCoreLogger OnInvalidStatusCode?.Invoke(this, $"ElasticSearch request failed: {response.StatusCode}, {errorContent}"); } } - catch (Exception exception) + catch (Exception ex) { - OnException?.Invoke(this, exception); + OnException?.Invoke(this, ex); } } @@ -182,7 +177,16 @@ namespace EonaCat.Logger.EonaCatCoreLogger { template = template.Replace($"{{{kv.Key}}}", kv.Value); } + return template; } + + public void Dispose() + { + _cts.Cancel(); + _flushTask.Wait(); + _cts.Dispose(); + _httpClient.Dispose(); + } } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/ElasticSearchLoggerOptions.cs b/EonaCat.Logger/EonaCatCoreLogger/ElasticSearchLoggerOptions.cs index ede4602..87ca835 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/ElasticSearchLoggerOptions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/ElasticSearchLoggerOptions.cs @@ -20,5 +20,6 @@ namespace EonaCat.Logger.EonaCatCoreLogger public int FlushIntervalSeconds { get; set; } = 5; public bool UseBulkInsert { get; set; } = true; public bool IncludeCorrelationId { get; set; } + public int MaxBufferSize { get; set; } = 1000; } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/HttpLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/HttpLogger.cs index 08dd6ba..8b4f3f5 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/HttpLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/HttpLogger.cs @@ -3,22 +3,27 @@ using Microsoft.Extensions.Logging; using System; using System.Net.Http; using System.Text; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace EonaCat.Logger.EonaCatCoreLogger { - // 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. - public class HttpLogger : ILogger + public sealed class HttpLogger : ILogger, IDisposable { + // 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. + private readonly string _categoryName; private readonly HttpLoggerOptions _options; private readonly LoggerScopedContext _context = new(); - private static readonly HttpClient _client = new(); + private readonly HttpClient _client; + private readonly BlockingCollection> _queue; + private readonly CancellationTokenSource _cts; + private readonly Task _processingTask; public bool IncludeCorrelationId { get; set; } - public event EventHandler OnException; public event EventHandler OnInvalidStatusCode; @@ -27,13 +32,18 @@ namespace EonaCat.Logger.EonaCatCoreLogger _categoryName = categoryName; _options = options; IncludeCorrelationId = options.IncludeCorrelationId; + + _client = new HttpClient(); + _queue = new BlockingCollection>(boundedCapacity: 10000); + _cts = new CancellationTokenSource(); + _processingTask = Task.Run(() => ProcessQueueAsync(_cts.Token)); } public void SetContext(string key, string value) => _context.Set(key, value); public void ClearContext() => _context.Clear(); public string GetContext(string key) => _context.Get(key); - public IDisposable BeginScope(TState state) => null; + public IDisposable BeginScope(TState state) => _context.BeginScope(state); public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled; public void Log(LogLevel logLevel, EventId eventId, TState state, @@ -44,8 +54,6 @@ namespace EonaCat.Logger.EonaCatCoreLogger return; } - string message = formatter(state, exception); - if (IncludeCorrelationId) { var correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); @@ -57,7 +65,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger { "timestamp", DateTime.UtcNow }, { "level", logLevel.ToString() }, { "category", _categoryName }, - { "message", message }, + { "message", formatter(state, exception) }, { "eventId", eventId.Id } }; @@ -67,66 +75,54 @@ namespace EonaCat.Logger.EonaCatCoreLogger payload["context"] = contextData; } - Task.Run(async () => + // Add to queue, drop oldest if full + while (!_queue.TryAdd(payload)) { - try + _queue.TryTake(out _); // drop oldest + } + } + + private async Task ProcessQueueAsync(CancellationToken token) + { + try + { + foreach (var payload in _queue.GetConsumingEnumerable(token)) { - var content = _options.SendAsJson - ? new StringContent(JsonHelper.ToJson(payload), Encoding.UTF8, "application/json") - : new StringContent(message, Encoding.UTF8, "text/plain"); - - var response = await _client.PostAsync(_options.Endpoint, content); - - if (!response.IsSuccessStatusCode) + try { - var statusCode = response.StatusCode; - var statusCodeMessage = statusCode switch - { - System.Net.HttpStatusCode.BadRequest => "400 Bad Request", - System.Net.HttpStatusCode.Unauthorized => "401 Unauthorized", - System.Net.HttpStatusCode.Forbidden => "403 Forbidden", - System.Net.HttpStatusCode.NotFound => "404 Not Found", - System.Net.HttpStatusCode.MethodNotAllowed => "405 Method Not Allowed", - System.Net.HttpStatusCode.NotAcceptable => "406 Not Acceptable", - System.Net.HttpStatusCode.ProxyAuthenticationRequired => "407 Proxy Authentication Required", - System.Net.HttpStatusCode.RequestTimeout => "408 Request Timeout", - System.Net.HttpStatusCode.Conflict => "409 Conflict", - System.Net.HttpStatusCode.Gone => "410 Gone", - System.Net.HttpStatusCode.LengthRequired => "411 Length Required", - System.Net.HttpStatusCode.PreconditionFailed => "412 Precondition Failed", - System.Net.HttpStatusCode.RequestEntityTooLarge => "413 Request Entity Too Large", - System.Net.HttpStatusCode.RequestUriTooLong => "414 Request URI Too Long", - System.Net.HttpStatusCode.UnsupportedMediaType => "415 Unsupported Media Type", - System.Net.HttpStatusCode.RequestedRangeNotSatisfiable => "416 Requested Range Not Satisfiable", - System.Net.HttpStatusCode.ExpectationFailed => "417 Expectation Failed", - (System.Net.HttpStatusCode)418 => "418 I'm a teapot", - (System.Net.HttpStatusCode)421 => "421 Misdirected Request", - (System.Net.HttpStatusCode)422 => "422 Unprocessable Entity", - (System.Net.HttpStatusCode)423 => "423 Locked", - (System.Net.HttpStatusCode)424 => "424 Failed Dependency", - (System.Net.HttpStatusCode)425 => "425 Too Early", - (System.Net.HttpStatusCode)426 => "426 Upgrade Required", - (System.Net.HttpStatusCode)428 => "428 Precondition Required", - (System.Net.HttpStatusCode)429 => "429 Too Many Requests", - (System.Net.HttpStatusCode)431 => "431 Request Header Fields Too Large", - (System.Net.HttpStatusCode)451 => "451 Unavailable For Legal Reasons", - System.Net.HttpStatusCode.InternalServerError => "500 Internal Server Error", - System.Net.HttpStatusCode.NotImplemented => "501 Not Implemented", - System.Net.HttpStatusCode.BadGateway => "502 Bad Gateway", - System.Net.HttpStatusCode.ServiceUnavailable => "503 Service Unavailable", - System.Net.HttpStatusCode.GatewayTimeout => "504 Gateway Timeout", - System.Net.HttpStatusCode.HttpVersionNotSupported => "505 HTTP Version Not Supported", - _ => statusCode.ToString() - }; + var content = _options.SendAsJson + ? new StringContent(JsonHelper.ToJson(payload), Encoding.UTF8, "application/json") + : new StringContent(payload["message"].ToString(), Encoding.UTF8, "text/plain"); - OnInvalidStatusCode?.Invoke(this, statusCodeMessage); + var response = await _client.PostAsync(_options.Endpoint, content, token); + + if (!response.IsSuccessStatusCode) + { + OnInvalidStatusCode?.Invoke(this, response.StatusCode.ToString()); + } + } + catch (Exception ex) + { + OnException?.Invoke(this, ex); } } - catch (Exception ex) - { - OnException?.Invoke(this, ex); - } - }); + } + catch (OperationCanceledException) { } + } + + public void Dispose() + { + _cts.Cancel(); + _queue.CompleteAdding(); + try + { + _processingTask.Wait(); + } + catch { /* ignore */ } + + _queue.Dispose(); + _cts.Dispose(); + _client.Dispose(); } } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/JsonFileLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/JsonFileLogger.cs index ef25a27..15098f9 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/JsonFileLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/JsonFileLogger.cs @@ -1,6 +1,7 @@ using EonaCat.Json; using Microsoft.Extensions.Logging; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Text; @@ -8,15 +9,19 @@ using System.Threading; namespace EonaCat.Logger.EonaCatCoreLogger { - // 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. - public class JsonFileLogger : ILogger + public sealed class JsonFileLogger : ILogger, IDisposable { + // 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. + private readonly string _categoryName; private readonly JsonFileLoggerOptions _options; private readonly string _filePath; private readonly LoggerScopedContext _context = new(); - private static readonly SemaphoreSlim _fileLock = new(1, 1); + + private readonly BlockingCollection _queue; + private readonly Thread _workerThread; + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); public bool IncludeCorrelationId { get; set; } public event EventHandler OnException; @@ -25,9 +30,11 @@ namespace EonaCat.Logger.EonaCatCoreLogger { _categoryName = categoryName; _options = options; - _filePath = Path.Combine(_options.LogDirectory, _options.FileName); IncludeCorrelationId = options.IncludeCorrelationId; + _filePath = Path.Combine(_options.LogDirectory, _options.FileName); + _queue = new BlockingCollection(10000); // bounded queue + try { Directory.CreateDirectory(_options.LogDirectory); @@ -36,13 +43,16 @@ namespace EonaCat.Logger.EonaCatCoreLogger { OnException?.Invoke(this, ex); } + + // Start background thread + _workerThread = new Thread(ProcessQueue) { IsBackground = true }; + _workerThread.Start(); } public void SetContext(string key, string value) => _context.Set(key, value); public void ClearContext() => _context.Clear(); public string GetContext(string key) => _context.Get(key); - - public IDisposable BeginScope(TState state) => null; + public IDisposable BeginScope(TState state) => _context.BeginScope(state); public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled; public void Log(LogLevel logLevel, EventId eventId, TState state, @@ -55,8 +65,6 @@ namespace EonaCat.Logger.EonaCatCoreLogger try { - string message = formatter(state, exception); - if (IncludeCorrelationId) { var correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); @@ -68,7 +76,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger { "timestamp", DateTime.UtcNow }, { "level", logLevel.ToString() }, { "category", _categoryName }, - { "message", message }, + { "message", formatter(state, exception) }, { "exception", exception?.ToString() }, { "eventId", eventId.Id } }; @@ -81,14 +89,10 @@ namespace EonaCat.Logger.EonaCatCoreLogger string json = JsonHelper.ToJson(logObject); - _fileLock.Wait(); - try + // Enqueue, drop oldest if full + while (!_queue.TryAdd(json)) { - File.AppendAllText(_filePath, json + Environment.NewLine, Encoding.UTF8); - } - finally - { - _fileLock.Release(); + _queue.TryTake(out _); // drop oldest } } catch (Exception ex) @@ -96,5 +100,39 @@ namespace EonaCat.Logger.EonaCatCoreLogger OnException?.Invoke(this, ex); } } + + private void ProcessQueue() + { + try + { + foreach (var log in _queue.GetConsumingEnumerable(_cts.Token)) + { + try + { + File.AppendAllText(_filePath, log + Environment.NewLine, Encoding.UTF8); + } + catch (Exception ex) + { + OnException?.Invoke(this, ex); + } + } + } + catch (OperationCanceledException) { } + } + + public void Dispose() + { + _cts.Cancel(); + _queue.CompleteAdding(); + + try + { + _workerThread.Join(); + } + catch { /* ignore */ } + + _queue.Dispose(); + _cts.Dispose(); + } } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/LogContext.cs b/EonaCat.Logger/EonaCatCoreLogger/LogContext.cs index 44361e6..ed11c05 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/LogContext.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/LogContext.cs @@ -1,47 +1,89 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Threading; namespace EonaCat.Logger.EonaCatCoreLogger { - public class LoggerScopedContext : IDisposable + internal sealed class LoggerScopedContext { - private readonly ConcurrentDictionary _context = new(); - private readonly string _correlationId; + private static readonly AsyncLocal>> _scopes + = new(); - public LoggerScopedContext(string correlationId = null) + public IDisposable BeginScope(TState state) { - _correlationId = correlationId; + _scopes.Value ??= new Stack>(); + _scopes.Value.Push(new Dictionary()); + return new ScopePopper(); } public void Set(string key, string value) { - _context[key] = value; + if (_scopes.Value == null || _scopes.Value.Count == 0) + { + return; + } + + _scopes.Value.Peek()[key] = value; } public string Get(string key) { - return _context.TryGetValue(key, out var value) ? value : null; + if (_scopes.Value == null) + { + return null; + } + + foreach (var scope in _scopes.Value) + { + if (scope.TryGetValue(key, out var value)) + { + return value; + } + } + + return null; } public IReadOnlyDictionary GetAll() { - return new Dictionary(_context); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (_scopes.Value == null) + { + return result; + } + + // Iterate from top of stack to bottom + foreach (var scope in _scopes.Value) + { + foreach (var kv in scope) + { + if (!result.ContainsKey(kv.Key)) + { + result[kv.Key] = kv.Value; + } + } + } + + return result; } + public void Clear() { - _context.Clear(); + _scopes.Value?.Clear(); } - public void Dispose() + private sealed class ScopePopper : IDisposable { - Clear(); - } - - public IDisposable CreateScope() - { - return this; + public void Dispose() + { + if (_scopes.Value?.Count > 0) + { + _scopes.Value.Pop(); + } + } } } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/SlackLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/SlackLogger.cs index de1b34f..8c52379 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/SlackLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/SlackLogger.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Net.Http; using System.Text; +using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; @@ -11,13 +12,16 @@ namespace EonaCat.Logger.EonaCatCoreLogger { // 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. - public class SlackLogger : ILogger + + public sealed class SlackLogger : ILogger, IDisposable { private readonly string _categoryName; private readonly SlackLoggerOptions _options; private readonly LoggerScopedContext _context = new(); - private readonly Channel _logChannel = Channel.CreateUnbounded(); + private readonly Channel _logChannel; private readonly HttpClient _httpClient; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _processingTask; public bool IncludeCorrelationId { get; set; } public event EventHandler OnException; @@ -29,7 +33,15 @@ namespace EonaCat.Logger.EonaCatCoreLogger IncludeCorrelationId = options.IncludeCorrelationId; _httpClient = new HttpClient(); - Task.Run(ProcessLogQueueAsync); + // Bounded channel to prevent unbounded memory growth + _logChannel = Channel.CreateBounded(new BoundedChannelOptions(1000) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + + _processingTask = Task.Run(() => ProcessLogQueueAsync(_cts.Token)); } public IDisposable BeginScope(TState state) => null; @@ -62,7 +74,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger $"*[{DateTime.UtcNow:u}]*", $"*[{logLevel}]*", $"*[{_categoryName}]*", - $"Message: {message}", + $"Message: {message}" }; foreach (var kvp in _context.GetAll()) @@ -76,7 +88,9 @@ namespace EonaCat.Logger.EonaCatCoreLogger } string fullMessage = string.Join("\n", logParts); - _logChannel.Writer.TryWrite(fullMessage); // non-blocking + + // non-blocking, drops oldest if full + _logChannel.Writer.TryWrite(fullMessage); } catch (Exception e) { @@ -84,21 +98,40 @@ namespace EonaCat.Logger.EonaCatCoreLogger } } - private async Task ProcessLogQueueAsync() + private async Task ProcessLogQueueAsync(CancellationToken token) { - await foreach (var message in _logChannel.Reader.ReadAllAsync()) + try { - try + await foreach (var message in _logChannel.Reader.ReadAllAsync(token)) { - var payload = new { text = message }; - var content = new StringContent(JsonHelper.ToJson(payload), Encoding.UTF8, "application/json"); - await _httpClient.PostAsync(_options.WebhookUrl, content); - } - catch (Exception ex) - { - OnException?.Invoke(this, ex); + try + { + var payload = new { text = message }; + var content = new StringContent(JsonHelper.ToJson(payload), Encoding.UTF8, "application/json"); + await _httpClient.PostAsync(_options.WebhookUrl, content, token); + } + catch (Exception ex) + { + OnException?.Invoke(this, ex); + } } } + catch (OperationCanceledException) { } + } + + public void Dispose() + { + _cts.Cancel(); + _logChannel.Writer.Complete(); + + try + { + _processingTask.Wait(); + } + catch { /* ignore */ } + + _cts.Dispose(); + _httpClient.Dispose(); } } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/TcpLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/TcpLogger.cs index e83f4bd..5496c85 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/TcpLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/TcpLogger.cs @@ -4,19 +4,20 @@ using System.Collections.Generic; using System.IO; using System.Net.Sockets; using System.Text; +using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; namespace EonaCat.Logger.EonaCatCoreLogger { - // 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. - public class TcpLogger : ILogger + public sealed class TcpLogger : ILogger, IDisposable { private readonly string _categoryName; private readonly TcpLoggerOptions _options; private readonly LoggerScopedContext _context = new(); - private readonly Channel _logChannel = Channel.CreateUnbounded(); + private readonly Channel _logChannel; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _processingTask; public bool IncludeCorrelationId { get; set; } public event EventHandler OnException; @@ -27,21 +28,27 @@ namespace EonaCat.Logger.EonaCatCoreLogger _options = options; IncludeCorrelationId = options.IncludeCorrelationId; - // Start background log writer - Task.Run(ProcessLogQueueAsync); + // Bounded channel to prevent unbounded memory growth + _logChannel = Channel.CreateBounded(new BoundedChannelOptions(1000) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + + _processingTask = Task.Run(() => ProcessLogQueueAsync(_cts.Token)); } public void SetContext(string key, string value) => _context.Set(key, value); public void ClearContext() => _context.Clear(); public string GetContext(string key) => _context.Get(key); - - public IDisposable BeginScope(TState state) => null; + public IDisposable BeginScope(TState state) => _context.BeginScope(state); public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) { - if (!IsEnabled(logLevel)) + if (!IsEnabled(logLevel) || formatter == null) { return; } @@ -61,7 +68,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger $"[{DateTime.UtcNow:u}]", $"[{logLevel}]", $"[{_categoryName}]", - $"Message: {message}", + $"Message: {message}" }; var contextData = _context.GetAll(); @@ -81,6 +88,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger string fullLog = string.Join(" | ", logParts); + // Non-blocking, drop oldest if full _logChannel.Writer.TryWrite(fullLog); } catch (Exception e) @@ -89,28 +97,48 @@ namespace EonaCat.Logger.EonaCatCoreLogger } } - private async Task ProcessLogQueueAsync() + private async Task ProcessLogQueueAsync(CancellationToken token) { - await foreach (var log in _logChannel.Reader.ReadAllAsync()) + try { - try - { - using var client = new TcpClient(); - await client.ConnectAsync(_options.Host, _options.Port); + // Keep one TCP connection alive if possible + using var client = new TcpClient(); + await client.ConnectAsync(_options.Host, _options.Port); - using var stream = client.GetStream(); - using var writer = new StreamWriter(stream, Encoding.UTF8) + using var stream = client.GetStream(); + using var writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true }; + + await foreach (var log in _logChannel.Reader.ReadAllAsync(token)) + { + try { - AutoFlush = true - }; - - await writer.WriteLineAsync(log); - } - catch (Exception e) - { - OnException?.Invoke(this, e); + await writer.WriteLineAsync(log); + } + catch (Exception e) + { + OnException?.Invoke(this, e); + } } } + catch (OperationCanceledException) { } + catch (Exception ex) + { + OnException?.Invoke(this, ex); + } + } + + public void Dispose() + { + _cts.Cancel(); + _logChannel.Writer.Complete(); + + try + { + _processingTask.Wait(); + } + catch { /* ignore */ } + + _cts.Dispose(); } } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/TelegramLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/TelegramLogger.cs index 8f81304..efdce79 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/TelegramLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/TelegramLogger.cs @@ -1,21 +1,22 @@ using EonaCat.Json; using Microsoft.Extensions.Logging; using System; -using System.Net.Http; using System.Text; +using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; +using System.Net.Http; namespace EonaCat.Logger.EonaCatCoreLogger { - // 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. - public class TelegramLogger : ILogger + public sealed class TelegramLogger : ILogger, IDisposable { private readonly string _categoryName; private readonly TelegramLoggerOptions _options; - private readonly HttpClient _httpClient = new(); - private readonly Channel _logChannel = Channel.CreateUnbounded(); + private readonly HttpClient _httpClient; + private readonly Channel _logChannel; + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + private readonly Task _processingTask; public event EventHandler OnException; @@ -24,14 +25,24 @@ namespace EonaCat.Logger.EonaCatCoreLogger _categoryName = categoryName; _options = options; - // Start background task - Task.Run(ProcessLogQueueAsync); + _httpClient = new HttpClient(); + + // Bounded channel to prevent memory leaks + _logChannel = Channel.CreateBounded(new BoundedChannelOptions(1000) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + + _processingTask = Task.Run(() => ProcessLogQueueAsync(_cts.Token)); } public IDisposable BeginScope(TState state) => null; public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled; - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + public void Log(LogLevel logLevel, EventId eventId, TState state, + Exception exception, Func formatter) { if (!IsEnabled(logLevel) || formatter == null) { @@ -46,6 +57,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger message += $"\nException: {exception}"; } + // Non-blocking, drop oldest if full _logChannel.Writer.TryWrite(message); } catch (Exception ex) @@ -54,27 +66,42 @@ namespace EonaCat.Logger.EonaCatCoreLogger } } - private async Task ProcessLogQueueAsync() + private async Task ProcessLogQueueAsync(CancellationToken token) { - await foreach (var message in _logChannel.Reader.ReadAllAsync()) + try { - try + await foreach (var message in _logChannel.Reader.ReadAllAsync(token)) { - var url = $"https://api.telegram.org/bot{_options.BotToken}/sendMessage"; - var payload = new + try { - chat_id = _options.ChatId, - text = message - }; + var url = $"https://api.telegram.org/bot{_options.BotToken}/sendMessage"; + var payload = new { chat_id = _options.ChatId, text = message }; + var content = new StringContent(JsonHelper.ToJson(payload), Encoding.UTF8, "application/json"); - var content = new StringContent(JsonHelper.ToJson(payload), Encoding.UTF8, "application/json"); - await _httpClient.PostAsync(url, content); - } - catch (Exception ex) - { - OnException?.Invoke(this, ex); + await _httpClient.PostAsync(url, content, token); + } + catch (Exception ex) + { + OnException?.Invoke(this, ex); + } } } + catch (OperationCanceledException) { } + } + + public void Dispose() + { + _cts.Cancel(); + _logChannel.Writer.Complete(); + + try + { + _processingTask.Wait(); + } + catch { /* ignore */ } + + _cts.Dispose(); + _httpClient.Dispose(); } } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/UdpLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/UdpLogger.cs index f161ab5..6a4bf23 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/UdpLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/UdpLogger.cs @@ -3,20 +3,24 @@ using System; using System.Collections.Generic; using System.Net.Sockets; using System.Text; +using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; namespace EonaCat.Logger.EonaCatCoreLogger { - // 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. - public class UdpLogger : ILogger + public sealed class UdpLogger : ILogger, IDisposable { + // 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. + private readonly string _categoryName; private readonly UdpLoggerOptions _options; private readonly LoggerScopedContext _context = new(); - private readonly Channel _logChannel = Channel.CreateUnbounded(); - private readonly UdpClient _udpClient = new(); + private readonly Channel _logChannel; + private readonly UdpClient _udpClient; + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + private readonly Task _processingTask; public bool IncludeCorrelationId { get; set; } public event EventHandler OnException; @@ -27,15 +31,23 @@ namespace EonaCat.Logger.EonaCatCoreLogger _options = options; IncludeCorrelationId = options.IncludeCorrelationId; - Task.Run(ProcessLogQueueAsync); + _udpClient = new UdpClient(); + + // Bounded channel to avoid unbounded memory growth + _logChannel = Channel.CreateBounded(new BoundedChannelOptions(1000) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + + _processingTask = Task.Run(() => ProcessLogQueueAsync(_cts.Token)); } public void SetContext(string key, string value) => _context.Set(key, value); public void ClearContext() => _context.Clear(); public string GetContext(string key) => _context.Get(key); - - public IDisposable BeginScope(TState state) => null; - + public IDisposable BeginScope(TState state) => _context.BeginScope(state); public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled; public void Log(LogLevel logLevel, EventId eventId, TState state, @@ -61,7 +73,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger $"[{DateTime.UtcNow:u}]", $"[{logLevel}]", $"[{_categoryName}]", - $"Message: {message}", + $"Message: {message}" }; var contextData = _context.GetAll(); @@ -80,6 +92,8 @@ namespace EonaCat.Logger.EonaCatCoreLogger } string fullMessage = string.Join(" | ", logParts); + + // Non-blocking, drop oldest if full _logChannel.Writer.TryWrite(fullMessage); } catch (Exception ex) @@ -88,20 +102,43 @@ namespace EonaCat.Logger.EonaCatCoreLogger } } - private async Task ProcessLogQueueAsync() + private async Task ProcessLogQueueAsync(CancellationToken token) { - await foreach (var message in _logChannel.Reader.ReadAllAsync()) + try { - try + await foreach (var message in _logChannel.Reader.ReadAllAsync(token)) { - byte[] bytes = Encoding.UTF8.GetBytes(message); - await _udpClient.SendAsync(bytes, bytes.Length, _options.Host, _options.Port); - } - catch (Exception ex) - { - OnException?.Invoke(this, ex); + try + { + byte[] bytes = Encoding.UTF8.GetBytes(message); + await _udpClient.SendAsync(bytes, bytes.Length, _options.Host, _options.Port); + } + catch (Exception ex) + { + OnException?.Invoke(this, ex); + } } } + catch (OperationCanceledException) { } + catch (Exception ex) + { + OnException?.Invoke(this, ex); + } + } + + public void Dispose() + { + _cts.Cancel(); + _logChannel.Writer.Complete(); + + try + { + _processingTask.Wait(); + } + catch { /* ignore */ } + + _udpClient.Dispose(); + _cts.Dispose(); } } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/XmlFileLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/XmlFileLogger.cs index 7729630..00fb1ea 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/XmlFileLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/XmlFileLogger.cs @@ -6,23 +6,24 @@ using System.Xml.Linq; namespace EonaCat.Logger.EonaCatCoreLogger { - // 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. - public class XmlFileLogger : ILogger + public sealed class XmlFileLogger : ILogger, IDisposable { + // 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. + private readonly string _categoryName; private readonly XmlFileLoggerOptions _options; private readonly string _filePath; private readonly LoggerScopedContext _context = new(); - private static readonly SemaphoreSlim _fileLock = new(1, 1); + private readonly SemaphoreSlim _fileLock = new SemaphoreSlim(1, 1); public bool IncludeCorrelationId { get; set; } public event EventHandler OnException; public XmlFileLogger(string categoryName, XmlFileLoggerOptions options) { - _categoryName = categoryName; - _options = options; + _categoryName = categoryName ?? throw new ArgumentNullException(nameof(categoryName)); + _options = options ?? throw new ArgumentNullException(nameof(options)); _filePath = Path.Combine(_options.LogDirectory, _options.FileName); IncludeCorrelationId = options.IncludeCorrelationId; @@ -30,18 +31,16 @@ namespace EonaCat.Logger.EonaCatCoreLogger { Directory.CreateDirectory(_options.LogDirectory); } - catch (Exception e) + catch (Exception ex) { - OnException?.Invoke(this, e); + OnException?.Invoke(this, ex); } } public void SetContext(string key, string value) => _context.Set(key, value); public void ClearContext() => _context.Clear(); public string GetContext(string key) => _context.Get(key); - - public IDisposable BeginScope(TState state) => null; - + public IDisposable BeginScope(TState state) => _context.BeginScope(state); public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled; public void Log(LogLevel logLevel, EventId eventId, TState state, @@ -93,10 +92,15 @@ namespace EonaCat.Logger.EonaCatCoreLogger _fileLock.Release(); } } - catch (Exception e) + catch (Exception ex) { - OnException?.Invoke(this, e); + OnException?.Invoke(this, ex); } } + + public void Dispose() + { + _fileLock.Dispose(); + } } } diff --git a/EonaCat.Logger/Managers/LogManager.cs b/EonaCat.Logger/Managers/LogManager.cs index 4582e2b..ea43d2b 100644 --- a/EonaCat.Logger/Managers/LogManager.cs +++ b/EonaCat.Logger/Managers/LogManager.cs @@ -17,6 +17,9 @@ using EonaCat.Logger.Servers.Udp; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.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. + namespace EonaCat.Logger.Managers { public class LogManager : ILogManager, IDisposable @@ -59,18 +62,20 @@ namespace EonaCat.Logger.Managers public event EventHandler OnException; public event EventHandler OnLogLevelDisabled; + public async Task WriteAsync(object currentObject, ELogType logType = ELogType.INFO, bool? writeToConsole = null, string customSplunkSourceType = null, string grayLogFacility = null, string grayLogSource = null, string grayLogVersion = "1.1", bool disableSplunkSSL = false, DumpFormat dumpFormat = DumpFormat.Json, bool isDetailedDump = false, int? dumpDepth = null, int? maxCollectionItems = null) { - if (currentObject == null) + if (_isDisposing || currentObject == null) { return; } await WriteAsync(currentObject.Dump(dumpFormat, isDetailedDump, dumpDepth, maxCollectionItems), - logType, writeToConsole, customSplunkSourceType, grayLogFacility, grayLogSource, grayLogVersion, disableSplunkSSL); + logType, writeToConsole, customSplunkSourceType, grayLogFacility, grayLogSource, grayLogVersion, disableSplunkSSL) + .ConfigureAwait(false); } public async Task WriteAsync(Exception exception, string module = null, string method = null, @@ -78,39 +83,41 @@ namespace EonaCat.Logger.Managers string customSplunkSourceType = null, string grayLogFacility = null, string grayLogSource = null, string grayLogVersion = "1.1", bool disableSplunkSSL = false) { - if (exception == null) + if (_isDisposing || exception == null) { return; } await WriteAsync(exception.FormatExceptionToMessage(module, method), criticalException ? ELogType.CRITICAL : ELogType.ERROR, - writeToConsole, customSplunkSourceType, grayLogFacility, grayLogSource, grayLogVersion, disableSplunkSSL); + writeToConsole, customSplunkSourceType, grayLogFacility, grayLogSource, grayLogVersion, disableSplunkSSL) + .ConfigureAwait(false); } public async Task WriteAsync(string message, ELogType logType = ELogType.INFO, bool? writeToConsole = null, string customSplunkSourceType = null, string grayLogFacility = null, string grayLogSource = null, string grayLogVersion = "1.1", bool disableSplunkSSL = false) { - if (logType == ELogType.NONE) + if (_isDisposing || string.IsNullOrWhiteSpace(message) || logType == ELogType.NONE) { return; } await InternalWriteAsync(CurrentDateTime, message, logType, writeToConsole, - customSplunkSourceType, grayLogFacility, grayLogSource, grayLogVersion, disableSplunkSSL); + customSplunkSourceType, grayLogFacility, grayLogSource, grayLogVersion, disableSplunkSSL) + .ConfigureAwait(false); } public async Task StartNewLogAsync() { - if (_tokenSource.IsCancellationRequested) + if (_isDisposing || _tokenSource.IsCancellationRequested) { return; } if (IsRunning && CurrentDateTime.Date > _logDate.Date) { - await StopLoggingAsync(); + await StopLoggingAsync().ConfigureAwait(false); } IsRunning = true; @@ -119,11 +126,17 @@ namespace EonaCat.Logger.Managers _logDate = CurrentDateTime; } + private async Task StopLoggingAsync() { + if (_isDisposing) + { + return; + } + WriteStopMessage(); IsRunning = false; - await Task.CompletedTask; + await Task.CompletedTask.ConfigureAwait(false); } private void WriteStopMessage() @@ -134,7 +147,11 @@ namespace EonaCat.Logger.Managers private void CreateLogger() { - // Dispose previous providers safely + if (_isDisposing) + { + return; + } + LoggerProvider?.Dispose(); LoggerFactory?.Dispose(); @@ -182,7 +199,7 @@ namespace EonaCat.Logger.Managers bool? writeToConsole = null, string customSplunkSourceType = null, string grayLogFacility = null, string grayLogSource = null, string grayLogVersion = "1.1", bool disableSplunkSSL = false) { - if (string.IsNullOrWhiteSpace(message) || logType == ELogType.NONE || !IsLogLevelEnabled(logType) || _isDisposing) + if (_isDisposing || string.IsNullOrWhiteSpace(message) || logType == ELogType.NONE || !IsLogLevelEnabled(logType)) { return; } @@ -244,6 +261,7 @@ namespace EonaCat.Logger.Managers private void LogHelper_OnException(object sender, ErrorMessage e) => OnException?.Invoke(sender, e); private void LogHelper_OnLogLevelDisabled(object sender, ErrorMessage e) => OnLogLevelDisabled?.Invoke(sender, e); + public void Dispose() { DisposeAsync(true).GetAwaiter().GetResult(); @@ -261,21 +279,17 @@ namespace EonaCat.Logger.Managers { _isDisposing = true; - await StopLoggingAsync(); + await StopLoggingAsync().ConfigureAwait(false); - // Unsubscribe events LogHelper.OnException -= LogHelper_OnException; LogHelper.OnLogLevelDisabled -= LogHelper_OnLogLevelDisabled; - // Cancel token - _tokenSource.Cancel(); - _tokenSource.Dispose(); + _tokenSource?.Cancel(); + _tokenSource?.Dispose(); _tokenSource = null; LoggerProvider?.Dispose(); LoggerFactory?.Dispose(); - - await Task.Delay(50); } } @@ -286,6 +300,7 @@ namespace EonaCat.Logger.Managers _instance.Value.Dispose(); } } + public void DeleteCurrentLogFile() { if (!string.IsNullOrEmpty(CurrentLogFile) && File.Exists(CurrentLogFile)) @@ -294,12 +309,9 @@ namespace EonaCat.Logger.Managers } } - private static LoggerSettings CreateDefaultSettings() => new() { Id = DllInfo.ApplicationName }; - private void SetupFileLogger(LoggerSettings settings = null, string logFolder = null, bool defaultPrefix = true) { settings ??= Settings; - if (!settings.EnableFileLogging) { return; @@ -318,7 +330,8 @@ namespace EonaCat.Logger.Managers private void SetupLogManager() => _logDate = CurrentDateTime; - // Syslog + private static LoggerSettings CreateDefaultSettings() => new() { Id = DllInfo.ApplicationName }; + public bool AddSyslogServer(string address, int port, string nickName = null, List typesToLog = null, bool convertToRfc5424 = false, bool convertToRfc3164 = false) { Settings.SysLogServers ??= new List(); diff --git a/EonaCat.Logger/Servers/GrayLog/Graylog.cs b/EonaCat.Logger/Servers/GrayLog/Graylog.cs index 9673a92..57c4046 100644 --- a/EonaCat.Logger/Servers/GrayLog/Graylog.cs +++ b/EonaCat.Logger/Servers/GrayLog/Graylog.cs @@ -16,6 +16,7 @@ public class Graylog : IDisposable private string _Hostname = "127.0.0.1"; private int _Port = 12201; internal UdpClient Udp; + private bool _disposed; public bool SupportsTcp { get; set; } public string Nickname { get; set; } @@ -23,22 +24,13 @@ public class Graylog : IDisposable public Graylog() { } - /// - /// Graylog server - /// - /// - /// - /// - /// - /// - /// public Graylog(string hostname = "127.0.0.1", int port = 12201, string nickName = null, List typesToLog = null) { _Hostname = hostname ?? throw new ArgumentNullException(nameof(hostname)); _Port = port >= 0 ? port : throw new ArgumentException("Port must be zero or greater."); TypesToLog = typesToLog; - Nickname = nickName ?? IpPort; + SetUdp(); } @@ -73,10 +65,15 @@ public class Graylog : IDisposable } public List TypesToLog { get; set; } - public bool IsConnected { get; set; } + public bool IsConnected { get; private set; } internal void SetUdp() { + if (_disposed) + { + return; + } + try { DisposeUdp(); @@ -91,31 +88,51 @@ public class Graylog : IDisposable internal void DisposeUdp() { - if (Udp != null) + if (Udp == null) { - IsConnected = false; + return; + } - try - { - Udp?.Close(); - Udp.Dispose(); - } - catch - { - // Do nothing - } + IsConnected = false; + + try + { + Udp.Close(); + Udp.Dispose(); + } + catch + { + // Do nothing + } + finally + { Udp = null; } } public void Dispose() { - DisposeUdp(); + Dispose(true); GC.SuppressFinalize(this); } + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (disposing) + { + DisposeUdp(); + } + } + ~Graylog() { - Dispose(); + Dispose(false); } -} \ No newline at end of file +} diff --git a/EonaCat.Logger/Servers/Splunk/Models/SplunkPayload.cs b/EonaCat.Logger/Servers/Splunk/Models/SplunkPayload.cs index 4fa59d9..f4c225d 100644 --- a/EonaCat.Logger/Servers/Splunk/Models/SplunkPayload.cs +++ b/EonaCat.Logger/Servers/Splunk/Models/SplunkPayload.cs @@ -1,5 +1,8 @@ namespace EonaCat.Logger.Servers.Splunk.Models; +// 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. + public class SplunkPayload { /// diff --git a/EonaCat.Logger/Servers/Splunk/Splunk.cs b/EonaCat.Logger/Servers/Splunk/Splunk.cs index 37d6380..89aab9e 100644 --- a/EonaCat.Logger/Servers/Splunk/Splunk.cs +++ b/EonaCat.Logger/Servers/Splunk/Splunk.cs @@ -6,6 +6,9 @@ using System.Threading.Tasks; using EonaCat.Json; using EonaCat.Logger.Servers.Splunk.Models; +// 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.Logger.Servers.Splunk { /// @@ -16,6 +19,8 @@ namespace EonaCat.Logger.Servers.Splunk private string _splunkHecUrl = "https://127.0.0.1:8088/services/collector/event"; private HttpClient _httpClient; private HttpClientHandler _httpClientHandler; + private bool _disposed; + public event EventHandler OnException; public string SplunkHecToken { get; set; } = "splunk-hec-token"; @@ -43,7 +48,7 @@ namespace EonaCat.Logger.Servers.Splunk public bool HasHecToken => !string.IsNullOrWhiteSpace(SplunkHecToken) && SplunkHecToken != "splunk-hec-token"; public bool HasHecUrl => !string.IsNullOrWhiteSpace(_splunkHecUrl); public bool IsHttpsHecUrl => _splunkHecUrl.StartsWith("https", StringComparison.OrdinalIgnoreCase); - public bool IsLocalHost => _splunkHecUrl.ToLower().Contains("127.0.0.1") || _splunkHecUrl.ToLower().Contains("localhost"); + public bool IsLocalHost => _splunkHecUrl.Contains("127.0.0.1") || _splunkHecUrl.Contains("localhost"); public Splunk(string splunkHecUrl, string splunkHecToken, HttpClientHandler handler = null, string nickName = null, List typesToLog = null) { @@ -51,6 +56,7 @@ namespace EonaCat.Logger.Servers.Splunk SplunkHecUrl = splunkHecUrl ?? throw new ArgumentNullException(nameof(splunkHecUrl)); Nickname = nickName ?? $"{splunkHecUrl}_{splunkHecToken}"; TypesToLog = typesToLog; + _httpClientHandler = handler ?? new HttpClientHandler(); CreateHttpClient(); } @@ -90,14 +96,14 @@ namespace EonaCat.Logger.Servers.Splunk public async Task SendAsync(SplunkPayload payload, bool disableSplunkSSL = false) { + if (_disposed) + { + throw new ObjectDisposedException(nameof(Splunk)); + } + try { - if (payload == null) - { - return null; - } - - if (!HasHecToken || !HasHecUrl) + if (payload == null || !HasHecToken || !HasHecUrl) { return null; } @@ -121,12 +127,12 @@ namespace EonaCat.Logger.Servers.Splunk var json = JsonHelper.ToJson(eventObject); - var request = new HttpRequestMessage(HttpMethod.Post, "services/collector/event") + using var request = new HttpRequestMessage(HttpMethod.Post, "services/collector/event") { Content = new StringContent(json, Encoding.UTF8, "application/json") }; - return await _httpClient.SendAsync(request); + return await _httpClient.SendAsync(request).ConfigureAwait(false); } catch (Exception ex) { @@ -155,13 +161,28 @@ namespace EonaCat.Logger.Servers.Splunk public void Dispose() { - DisposeHttpClient(); + Dispose(true); GC.SuppressFinalize(this); } + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (disposing) + { + DisposeHttpClient(); + } + } + ~Splunk() { - Dispose(); + Dispose(false); } } } diff --git a/EonaCat.Logger/Servers/Syslog/Syslog.cs b/EonaCat.Logger/Servers/Syslog/Syslog.cs index e2eadb8..d287736 100644 --- a/EonaCat.Logger/Servers/Syslog/Syslog.cs +++ b/EonaCat.Logger/Servers/Syslog/Syslog.cs @@ -7,13 +7,17 @@ using System.Threading.Tasks; namespace EonaCat.Logger.Servers.Syslog { - // This file is part of the EonaCat project(s) released under the Apache License. + // 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. + public class Syslog : IDisposable { private const int MaxUdpPacketSize = 4096; private string _hostname = "127.0.0.1"; private int _port = 514; private UdpClient _udp; + private bool _disposed; + public event EventHandler OnException; public bool IsConnected { get; private set; } @@ -26,7 +30,8 @@ namespace EonaCat.Logger.Servers.Syslog public Syslog() { } - public Syslog(string hostname = "127.0.0.1", int port = 514, string nickName = null, List typesToLog = null, bool convertToRfc5424 = false, bool convertToRfc3164 = false) + public Syslog(string hostname = "127.0.0.1", int port = 514, string nickName = null, + List typesToLog = null, bool convertToRfc5424 = false, bool convertToRfc3164 = false) { Hostname = hostname; Port = port; @@ -68,6 +73,11 @@ namespace EonaCat.Logger.Servers.Syslog internal void SetUdp() { + if (_disposed) + { + return; + } + try { DisposeUdp(); @@ -82,24 +92,28 @@ namespace EonaCat.Logger.Servers.Syslog internal void DisposeUdp() { - IsConnected = false; - - try + if (_udp != null) { - _udp?.Close(); - _udp?.Dispose(); + try + { + IsConnected = false; + _udp.Close(); + _udp.Dispose(); + } + catch + { + // Swallow cleanup exceptions + } + finally + { + _udp = null; + } } - catch - { - // Swallow cleanup exceptions - } - - _udp = null; } public async Task WriteAsync(string message) { - if (string.IsNullOrWhiteSpace(message)) + if (_disposed || string.IsNullOrWhiteSpace(message)) { return; } @@ -110,15 +124,17 @@ namespace EonaCat.Logger.Servers.Syslog public async Task WriteAsync(byte[] data) { - if (data is { Length: > 0 }) + if (_disposed || data == null || data.Length == 0) { - await SendAsync(data); + return; } + + await SendAsync(data); } private async Task SendAsync(byte[] data) { - if (_udp == null) + if (_disposed || _udp == null) { return; } @@ -147,6 +163,11 @@ namespace EonaCat.Logger.Servers.Syslog private async Task SendViaTcpAsync(byte[] data) { + if (_disposed) + { + return; + } + try { using var tcpClient = new TcpClient(); @@ -158,12 +179,17 @@ namespace EonaCat.Logger.Servers.Syslog } catch { - // TCP failure fallback is silent here + // TCP failure is silent } } private async Task SendUdpInChunksAsync(byte[] data, int chunkSize) { + if (_disposed) + { + return; + } + int offset = 0; byte[] buffer = ArrayPool.Shared.Rent(chunkSize); @@ -189,13 +215,28 @@ namespace EonaCat.Logger.Servers.Syslog public void Dispose() { - DisposeUdp(); + Dispose(true); GC.SuppressFinalize(this); } + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (disposing) + { + DisposeUdp(); + } + } + ~Syslog() { - Dispose(); + Dispose(false); } } } diff --git a/EonaCat.Logger/Servers/Tcp/Tcp.cs b/EonaCat.Logger/Servers/Tcp/Tcp.cs index 6ac8f2b..aeac49e 100644 --- a/EonaCat.Logger/Servers/Tcp/Tcp.cs +++ b/EonaCat.Logger/Servers/Tcp/Tcp.cs @@ -9,12 +9,15 @@ namespace EonaCat.Logger.Servers.Tcp { // 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. + public class Tcp : IDisposable { private string _hostname = "127.0.0.1"; private int _port = 514; private TcpClient _tcp; private readonly SemaphoreSlim _sendLock = new(1, 1); + private bool _disposed; + public event EventHandler OnException; public bool IsConnected { get; private set; } @@ -64,6 +67,11 @@ namespace EonaCat.Logger.Servers.Tcp internal void SetTcp() { + if (_disposed) + { + return; + } + try { DisposeTcp(); @@ -89,13 +97,15 @@ namespace EonaCat.Logger.Servers.Tcp { // Silent fail } - - _tcp = null; + finally + { + _tcp = null; + } } public async Task WriteAsync(byte[] data) { - if (data == null || data.Length == 0) + if (_disposed || data == null || data.Length == 0) { return; } @@ -105,7 +115,7 @@ namespace EonaCat.Logger.Servers.Tcp public async Task WriteAsync(string data) { - if (string.IsNullOrWhiteSpace(data)) + if (_disposed || string.IsNullOrWhiteSpace(data)) { return; } @@ -116,6 +126,11 @@ namespace EonaCat.Logger.Servers.Tcp private async Task InternalWriteAsync(byte[] data) { + if (_disposed) + { + return; + } + await _sendLock.WaitAsync(); try { @@ -129,7 +144,7 @@ namespace EonaCat.Logger.Servers.Tcp return; } - var stream = _tcp.GetStream(); + using var stream = _tcp.GetStream(); await stream.WriteAsync(data, 0, data.Length); await stream.FlushAsync(); } @@ -146,14 +161,29 @@ namespace EonaCat.Logger.Servers.Tcp public void Dispose() { - DisposeTcp(); - _sendLock.Dispose(); + Dispose(true); GC.SuppressFinalize(this); } + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (disposing) + { + DisposeTcp(); + _sendLock.Dispose(); + } + } + ~Tcp() { - Dispose(); + Dispose(false); } } } diff --git a/EonaCat.Logger/Servers/Udp/Udp.cs b/EonaCat.Logger/Servers/Udp/Udp.cs index bafa52a..c82ce1d 100644 --- a/EonaCat.Logger/Servers/Udp/Udp.cs +++ b/EonaCat.Logger/Servers/Udp/Udp.cs @@ -10,6 +10,7 @@ namespace EonaCat.Logger.Servers.Udp { // 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. + public class Udp : IDisposable { private const int MaxUdpPacketSize = 4096; @@ -17,6 +18,8 @@ namespace EonaCat.Logger.Servers.Udp private int _port = 514; private UdpClient _udp; private readonly SemaphoreSlim _sendLock = new(1, 1); + private bool _disposed; + public event EventHandler OnException; public bool IsConnected { get; private set; } @@ -30,6 +33,7 @@ namespace EonaCat.Logger.Servers.Udp _port = port >= 0 ? port : throw new ArgumentException("Port must be zero or greater."); TypesToLog = typesToLog; Nickname = nickname ?? IpPort; + SetUdp(); } @@ -65,6 +69,11 @@ namespace EonaCat.Logger.Servers.Udp internal void SetUdp() { + if (_disposed) + { + return; + } + try { DisposeUdp(); @@ -93,13 +102,15 @@ namespace EonaCat.Logger.Servers.Udp { // Silently ignore } - - _udp = null; + finally + { + _udp = null; + } } public async Task WriteAsync(byte[] data, bool dontFragment = false) { - if (data == null || data.Length == 0) + if (_disposed || data == null || data.Length == 0) { return; } @@ -118,7 +129,6 @@ namespace EonaCat.Logger.Servers.Udp } _udp.DontFragment = dontFragment; - await SendChunksAsync(data); } finally @@ -129,7 +139,7 @@ namespace EonaCat.Logger.Servers.Udp public async Task WriteAsync(string data, bool dontFragment = false) { - if (string.IsNullOrWhiteSpace(data)) + if (_disposed || string.IsNullOrWhiteSpace(data)) { return; } @@ -140,6 +150,11 @@ namespace EonaCat.Logger.Servers.Udp private async Task SendChunksAsync(byte[] data) { + if (_disposed || _udp == null) + { + return; + } + int offset = 0; int length = data.Length; byte[] buffer = ArrayPool.Shared.Rent(MaxUdpPacketSize); @@ -167,14 +182,29 @@ namespace EonaCat.Logger.Servers.Udp public void Dispose() { - DisposeUdp(); - _sendLock.Dispose(); + Dispose(true); GC.SuppressFinalize(this); } + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (disposing) + { + DisposeUdp(); + _sendLock.Dispose(); + } + } + ~Udp() { - Dispose(); + Dispose(false); } } } diff --git a/EonaCat.Logger/Servers/Zabbix/API/ZabbixApi.cs b/EonaCat.Logger/Servers/Zabbix/API/ZabbixApi.cs index 429114e..27d777c 100644 --- a/EonaCat.Logger/Servers/Zabbix/API/ZabbixApi.cs +++ b/EonaCat.Logger/Servers/Zabbix/API/ZabbixApi.cs @@ -5,20 +5,24 @@ using System.IO; using System.Net; using System.Text; +// 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.Logger.Servers.Zabbix.API { /// /// Zabbix API client for authentication and communication with Zabbix server. /// - public class ZabbixApi + public class ZabbixApi : IDisposable { private readonly string _user; private readonly string _password; private readonly string _zabbixUrl; private readonly string _basicAuth; private string _auth; - public event EventHandler OnException; + private bool _disposed; + public event EventHandler OnException; public bool IsLoggedIn { get; private set; } public ZabbixApi(string user, string password, string zabbixUrl, bool useBasicAuth = false) @@ -37,6 +41,11 @@ namespace EonaCat.Logger.Servers.Zabbix.API /// public void Login() { + if (_disposed) + { + throw new ObjectDisposedException(nameof(ZabbixApi)); + } + dynamic authParams = new ExpandoObject(); authParams.user = _user; authParams.password = _password; @@ -51,6 +60,11 @@ namespace EonaCat.Logger.Servers.Zabbix.API /// public bool Logout() { + if (_disposed) + { + throw new ObjectDisposedException(nameof(ZabbixApi)); + } + var response = ResponseAsObject("user.logout", Array.Empty()); return response?.Result ?? false; } @@ -60,6 +74,11 @@ namespace EonaCat.Logger.Servers.Zabbix.API /// public string ResponseAsJson(string method, object parameters) { + if (_disposed) + { + throw new ObjectDisposedException(nameof(ZabbixApi)); + } + var request = CreateRequest(method, parameters); var jsonParams = JsonHelper.ToJson(request); return SendRequest(jsonParams); @@ -70,6 +89,11 @@ namespace EonaCat.Logger.Servers.Zabbix.API /// public ZabbixApiResponse ResponseAsObject(string method, object parameters) { + if (_disposed) + { + throw new ObjectDisposedException(nameof(ZabbixApi)); + } + var request = CreateRequest(method, parameters); var jsonParams = JsonHelper.ToJson(request); var responseJson = SendRequest(jsonParams); @@ -81,9 +105,14 @@ namespace EonaCat.Logger.Servers.Zabbix.API private string SendRequest(string jsonParams) { + if (_disposed) + { + throw new ObjectDisposedException(nameof(ZabbixApi)); + } + try { - var request = WebRequest.Create(_zabbixUrl); + var request = (HttpWebRequest)WebRequest.Create(_zabbixUrl); request.Method = "POST"; request.ContentType = "application/json-rpc"; @@ -92,13 +121,22 @@ namespace EonaCat.Logger.Servers.Zabbix.API request.Headers.Add("Authorization", $"Basic {_basicAuth}"); } - using (var writer = new StreamWriter(request.GetRequestStream())) + // Write JSON request + using (var requestStream = request.GetRequestStream()) + using (var writer = new StreamWriter(requestStream, Encoding.UTF8)) { writer.Write(jsonParams); } - using var response = request.GetResponse(); - using var reader = new StreamReader(response.GetResponseStream()); + // Read JSON response + using var response = (HttpWebResponse)request.GetResponse(); + using var responseStream = response.GetResponseStream(); + if (responseStream == null) + { + return null; + } + + using var reader = new StreamReader(responseStream, Encoding.UTF8); return reader.ReadToEnd(); } catch (Exception ex) @@ -107,5 +145,22 @@ namespace EonaCat.Logger.Servers.Zabbix.API return null; } } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + GC.SuppressFinalize(this); + } + + ~ZabbixApi() + { + Dispose(); + } } } diff --git a/EonaCat.Logger/Servers/Zabbix/ZabbixRequest.cs b/EonaCat.Logger/Servers/Zabbix/ZabbixRequest.cs index bbac506..0d4aa2e 100644 --- a/EonaCat.Logger/Servers/Zabbix/ZabbixRequest.cs +++ b/EonaCat.Logger/Servers/Zabbix/ZabbixRequest.cs @@ -1,90 +1,114 @@ -using EonaCat.Json; -using System; -using System.Net.Sockets; -using System.Text; -using System.Threading.Tasks; - -namespace EonaCat.Logger.Servers.Zabbix -{ - // 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. - public class ZabbixRequest - { - private const int BUFFER_SIZE = 1024; - - /// - /// The request to send to the Zabbix server - /// - public string Request { get; set; } - - /// - /// The data to send to the Zabbix server - /// - public ZabbixData[] Data { get; set; } - - /// - /// Zabbix request - /// - /// - /// - /// - public ZabbixRequest(string host, string key, string value) - { - Request = "sender data"; - Data = [new ZabbixData(host, key, value)]; - } - - /// - /// Sends the request to the Zabbix server - /// - /// - /// - /// - /// - /// - public async Task SendAsync(string server, int port = 10051, int timeout = 500) - { +using EonaCat.Json; +using System; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; + +namespace EonaCat.Logger.Servers.Zabbix +{ + // 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. + public class ZabbixRequest + { + private const int BUFFER_SIZE = 1024; + + /// + /// The request to send to the Zabbix server + /// + public string Request { get; set; } + + /// + /// The data to send to the Zabbix server + /// + public ZabbixData[] Data { get; set; } + + /// + /// Zabbix request + /// + public ZabbixRequest(string host, string key, string value) + { + Request = "sender data"; + Data = new[] { new ZabbixData(host, key, value) }; + } + + /// + /// Sends the request to the Zabbix server + /// + public async Task SendAsync(string server, int port = 10051, int timeout = 500) + { if (string.IsNullOrWhiteSpace(server)) { return new ZabbixResponse(); - } - - string json = JsonHelper.ToJson(new ZabbixRequest(Data[0].Host, Data[0].Key, Data[0].Value)); - using (TcpClient tcpClient = new TcpClient(server, port)) - using (NetworkStream stream = tcpClient.GetStream()) - { - byte[] header = Encoding.ASCII.GetBytes("ZBXD\x01"); - byte[] dataLength = BitConverter.GetBytes((long)json.Length); - byte[] content = Encoding.ASCII.GetBytes(json); - byte[] message = new byte[header.Length + dataLength.Length + content.Length]; - - Buffer.BlockCopy(header, 0, message, 0, header.Length); - Buffer.BlockCopy(dataLength, 0, message, header.Length, dataLength.Length); - Buffer.BlockCopy(content, 0, message, header.Length + dataLength.Length, content.Length); - - stream.Write(message, 0, message.Length); - stream.Flush(); - - int counter = 0; - while (!stream.DataAvailable) - { - if (counter < timeout / 50) - { - counter++; - await Task.Delay(50).ConfigureAwait(false); - } - else - { - throw new TimeoutException(); - } - } - - byte[] responseBytes = new byte[BUFFER_SIZE]; - stream.Read(responseBytes, 0, responseBytes.Length); - string responseAsString = Encoding.UTF8.GetString(responseBytes); - string jsonResult = responseAsString.Substring(responseAsString.IndexOf('{')); - return JsonHelper.ToObject(jsonResult); - } - } - } -} \ No newline at end of file + } + + string json = JsonHelper.ToJson(new ZabbixRequest(Data[0].Host, Data[0].Key, Data[0].Value)); + + using var tcpClient = new TcpClient(); + var connectTask = tcpClient.ConnectAsync(server, port); + if (await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false) != connectTask) + { + throw new TimeoutException("Connection timed out."); + } + + using NetworkStream stream = tcpClient.GetStream(); + stream.ReadTimeout = timeout; + stream.WriteTimeout = timeout; + + // Header and length + byte[] header = Encoding.ASCII.GetBytes("ZBXD\x01"); + byte[] dataLength = BitConverter.GetBytes((long)Encoding.ASCII.GetByteCount(json)); + if (!BitConverter.IsLittleEndian) + { + Array.Reverse(dataLength); + } + + byte[] content = Encoding.ASCII.GetBytes(json); + + byte[] message = new byte[header.Length + dataLength.Length + content.Length]; + Buffer.BlockCopy(header, 0, message, 0, header.Length); + Buffer.BlockCopy(dataLength, 0, message, header.Length, dataLength.Length); + Buffer.BlockCopy(content, 0, message, header.Length + dataLength.Length, content.Length); + + await stream.WriteAsync(message, 0, message.Length).ConfigureAwait(false); + await stream.FlushAsync().ConfigureAwait(false); + + // Wait for data available + int counter = 0; + while (!stream.DataAvailable) + { + if (counter < timeout / 50) + { + counter++; + await Task.Delay(50).ConfigureAwait(false); + } + else + { + throw new TimeoutException("No response received from Zabbix server."); + } + } + + // Read response safely + using var memoryStream = new System.IO.MemoryStream(); + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + do + { + bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + if (bytesRead > 0) + { + memoryStream.Write(buffer, 0, bytesRead); + } + } while (bytesRead == buffer.Length); + + string responseAsString = Encoding.UTF8.GetString(memoryStream.ToArray()); + int jsonStart = responseAsString.IndexOf('{'); + if (jsonStart < 0) + { + return new ZabbixResponse(); + } + + string jsonResult = responseAsString.Substring(jsonStart); + return JsonHelper.ToObject(jsonResult); + } + } +} diff --git a/Testers/EonaCat.Logger.Test.Web/EonaCat.Logger.Test.Web.csproj b/Testers/EonaCat.Logger.Test.Web/EonaCat.Logger.Test.Web.csproj index 9ba1976..b5aafed 100644 --- a/Testers/EonaCat.Logger.Test.Web/EonaCat.Logger.Test.Web.csproj +++ b/Testers/EonaCat.Logger.Test.Web/EonaCat.Logger.Test.Web.csproj @@ -7,7 +7,7 @@ - + all diff --git a/Testers/EonaCat.Logger.Test.Web/Program.cs b/Testers/EonaCat.Logger.Test.Web/Program.cs index 72d2c84..4f457ad 100644 --- a/Testers/EonaCat.Logger.Test.Web/Program.cs +++ b/Testers/EonaCat.Logger.Test.Web/Program.cs @@ -6,7 +6,6 @@ using EonaCat.Logger.EonaCatCoreLogger.Models; using EonaCat.Logger.LogClient; using EonaCat.Logger.Managers; - using EonaCat.Logger.Test.Web; using EonaCat.MemoryGuard; using EonaCat.Versioning.Helpers; using EonaCat.Web.RateLimiter; @@ -24,6 +23,7 @@ PredictionInterval = TimeSpan.FromSeconds(15), LeakDetectionThreshold = TimeSpan.FromSeconds(5), SuspiciousObjectThreshold = TimeSpan.FromSeconds(3), + BackgroundReportingInterval = TimeSpan.FromMinutes(1.5), CaptureStackTraces = true, EnableAutoRemediation = true, AutoSaveReports = true,