diff --git a/EonaCat.Logger/Constants.cs b/EonaCat.Logger/Constants.cs index 299afd8..bcdec59 100644 --- a/EonaCat.Logger/Constants.cs +++ b/EonaCat.Logger/Constants.cs @@ -6,6 +6,6 @@ public static class Constants { public static class DateTimeFormats { - public static string LOGGING { get; } = "yyyy-MM-dd HH:mm:ss"; + public static string LOGGING { get; set; } = "yyyy-MM-dd HH:mm:ss.fff"; } } \ No newline at end of file diff --git a/EonaCat.Logger/EonaCatCoreLogger/BatchingDatabaseLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/BatchingDatabaseLogger.cs index fdc0d85..9d13177 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/BatchingDatabaseLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/BatchingDatabaseLogger.cs @@ -9,17 +9,17 @@ 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 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 = new(); + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); private readonly Task _processingTask; private volatile bool _disposed; @@ -31,8 +31,8 @@ namespace EonaCat.Logger.EonaCatCoreLogger public BatchingDatabaseLogger(string categoryName, BatchingDatabaseLoggerOptions options) { - _categoryName = categoryName; - _options = options; + _categoryName = categoryName ?? throw new ArgumentNullException(nameof(categoryName)); + _options = options ?? throw new ArgumentNullException(nameof(options)); IncludeCorrelationId = options.IncludeCorrelationId; _queue = new BlockingCollection(new ConcurrentQueue(), MaxQueueSize); @@ -43,26 +43,19 @@ namespace EonaCat.Logger.EonaCatCoreLogger 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 (_disposed || !IsEnabled(logLevel) || formatter == null) { return; } - var message = formatter(state, exception); + string message = formatter(state, exception); - var correlationId = IncludeCorrelationId - ? _context.Get("CorrelationId") ?? Guid.NewGuid().ToString() - : null; - - if (correlationId != null) + string correlationId = null; + if (IncludeCorrelationId) { + correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); _context.Set("CorrelationId", correlationId); } @@ -76,10 +69,9 @@ namespace EonaCat.Logger.EonaCatCoreLogger CorrelationId = correlationId }; - // Drop oldest when full (non-blocking) while (!_queue.TryAdd(entry)) { - _queue.TryTake(out _); // discard oldest + _queue.TryTake(out _); } } @@ -95,11 +87,14 @@ namespace EonaCat.Logger.EonaCatCoreLogger if (batch.Count >= _options.BatchSize) { - await FlushBatchAsync(batch); + await FlushBatchAsync(batch, token); } } } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + // Do nothing + } catch (Exception ex) { OnException?.Invoke(this, ex); @@ -108,12 +103,12 @@ namespace EonaCat.Logger.EonaCatCoreLogger { if (batch.Count > 0) { - await FlushBatchAsync(batch); // flush remaining + await FlushBatchAsync(batch, token); } } } - private async Task FlushBatchAsync(List batch) + private async Task FlushBatchAsync(List batch, CancellationToken token) { if (batch.Count == 0) { @@ -122,7 +117,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger try { - await InsertBatchAsync(batch); + await InsertBatchAsync(batch, token); } catch (Exception ex) { @@ -134,27 +129,54 @@ namespace EonaCat.Logger.EonaCatCoreLogger } } - private async Task InsertBatchAsync(List batch) + private async Task InsertBatchAsync(List batch, CancellationToken token) { - using var connection = _options.DbProviderFactory.CreateConnection() - ?? throw new InvalidOperationException("Failed to create database connection."); - - connection.ConnectionString = _options.ConnectionString; - await connection.OpenAsync(_cts.Token); - - foreach (var entry in batch) + using (var connection = _options.DbProviderFactory.CreateConnection()) { - using var command = connection.CreateCommand(); - command.CommandText = _options.InsertCommand; + if (connection == null) + { + throw new InvalidOperationException("Failed to create database connection."); + } - command.Parameters.Add(CreateParameter(command, "Timestamp", entry.Timestamp)); - command.Parameters.Add(CreateParameter(command, "LogLevel", entry.LogLevel)); - command.Parameters.Add(CreateParameter(command, "Category", entry.Category)); - command.Parameters.Add(CreateParameter(command, "Message", entry.Message)); - command.Parameters.Add(CreateParameter(command, "Exception", entry.Exception)); - command.Parameters.Add(CreateParameter(command, "CorrelationId", entry.CorrelationId)); + connection.ConnectionString = _options.ConnectionString; + await connection.OpenAsync(token); - await command.ExecuteNonQueryAsync(_cts.Token); + using (var transaction = connection.BeginTransaction()) + { + using (var command = connection.CreateCommand()) + { + command.Transaction = transaction; + command.CommandText = _options.InsertCommand; + + var timestampParam = CreateParameter(command, "Timestamp", null); + var logLevelParam = CreateParameter(command, "LogLevel", null); + var categoryParam = CreateParameter(command, "Category", null); + var messageParam = CreateParameter(command, "Message", null); + var exceptionParam = CreateParameter(command, "Exception", null); + var correlationParam = CreateParameter(command, "CorrelationId", null); + + command.Parameters.Add(timestampParam); + command.Parameters.Add(logLevelParam); + command.Parameters.Add(categoryParam); + command.Parameters.Add(messageParam); + command.Parameters.Add(exceptionParam); + command.Parameters.Add(correlationParam); + + foreach (var entry in batch) + { + timestampParam.Value = entry.Timestamp; + logLevelParam.Value = entry.LogLevel ?? (object)DBNull.Value; + categoryParam.Value = entry.Category ?? (object)DBNull.Value; + messageParam.Value = entry.Message ?? (object)DBNull.Value; + exceptionParam.Value = entry.Exception ?? (object)DBNull.Value; + correlationParam.Value = entry.CorrelationId ?? (object)DBNull.Value; + + await command.ExecuteNonQueryAsync(token); + } + } + + transaction.Commit(); + } } } @@ -178,11 +200,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger _cts.Cancel(); _queue.CompleteAdding(); - try - { - _processingTask.Wait(_options.ShutdownTimeout); - } - catch { /* ignore */ } + try { _processingTask.Wait(_options.ShutdownTimeout); } catch { } _queue.Dispose(); _cts.Dispose(); @@ -190,12 +208,12 @@ namespace EonaCat.Logger.EonaCatCoreLogger private sealed class LogEntry { - 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; } + 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; } } } -} \ No newline at end of file +} diff --git a/EonaCat.Logger/EonaCatCoreLogger/DatabaseLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/DatabaseLogger.cs index 8e048c1..9349486 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/DatabaseLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/DatabaseLogger.cs @@ -1,24 +1,24 @@ using Microsoft.Extensions.Logging; using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Data.Common; 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 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 readonly ConcurrentQueue _queue = new(); private readonly SemaphoreSlim _flushLock = new(1, 1); - private CancellationTokenSource _cts = new(); private Task _flushTask; private int _refCount; @@ -30,8 +30,8 @@ namespace EonaCat.Logger.EonaCatCoreLogger public DatabaseLogger(string categoryName, DatabaseLoggerOptions options) { - _categoryName = categoryName; - _options = options; + _categoryName = categoryName ?? throw new ArgumentNullException(nameof(categoryName)); + _options = options ?? throw new ArgumentNullException(nameof(options)); IncludeCorrelationId = options.IncludeCorrelationId; if (Interlocked.Increment(ref _refCount) == 1) @@ -43,24 +43,13 @@ namespace EonaCat.Logger.EonaCatCoreLogger public IDisposable BeginScope(TState state) => _context.BeginScope(state); - public bool IsEnabled(LogLevel logLevel) - => _options.IsEnabled; + public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled; - public void SetContext(string key, string value) - => _context.Set(key, value); + 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 ClearContext() - => _context.Clear(); - - public string GetContext(string key) - => _context.Get(key); - - 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) { @@ -69,13 +58,13 @@ namespace EonaCat.Logger.EonaCatCoreLogger if (_queue.Count >= MaxQueueSize) { - _queue.TryDequeue(out _); // drop oldest + _queue.TryDequeue(out _); } + string correlationId = null; if (IncludeCorrelationId) { - var correlationId = - _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); + correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); _context.Set("CorrelationId", correlationId); } @@ -86,7 +75,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger Category = _categoryName, Message = formatter(state, exception), Exception = exception?.ToString(), - CorrelationId = _context.Get("CorrelationId") + CorrelationId = correlationId }); } @@ -96,19 +85,16 @@ namespace EonaCat.Logger.EonaCatCoreLogger { while (!token.IsCancellationRequested) { - await Task.Delay( - TimeSpan.FromSeconds(_options.FlushIntervalSeconds), - token); - + await Task.Delay(TimeSpan.FromSeconds(_options.FlushIntervalSeconds), token); await FlushBufferAsync(); } } - catch (OperationCanceledException) + catch (OperationCanceledException) { - // Expected on shutdown + // Do nothing } - await FlushBufferAsync(); // final drain + await FlushBufferAsync(); } private async Task FlushBufferAsync() @@ -120,49 +106,75 @@ namespace EonaCat.Logger.EonaCatCoreLogger try { + var batch = new List(); while (_queue.TryDequeue(out var entry)) { - try + batch.Add(entry); + } + + if (batch.Count == 0) + { + return; + } + + using (var connection = _options.DbProviderFactory.CreateConnection()) + { + if (connection == null) { - using var connection = - _options.DbProviderFactory.CreateConnection(); + throw new InvalidOperationException("Failed to create DB connection."); + } - if (connection == null) - { - throw new InvalidOperationException("Failed to create DB connection."); - } + connection.ConnectionString = _options.ConnectionString; + await connection.OpenAsync(); - connection.ConnectionString = _options.ConnectionString; - await connection.OpenAsync(); - - using var command = connection.CreateCommand(); + using (var transaction = connection.BeginTransaction()) + using (var command = connection.CreateCommand()) + { + command.Transaction = transaction; command.CommandText = _options.InsertCommand; - command.Parameters.Add(CreateParameter(command, "Timestamp", entry.Timestamp)); - command.Parameters.Add(CreateParameter(command, "LogLevel", entry.LogLevel)); - command.Parameters.Add(CreateParameter(command, "Category", entry.Category)); - command.Parameters.Add(CreateParameter(command, "Message", entry.Message)); - command.Parameters.Add(CreateParameter(command, "Exception", entry.Exception)); - command.Parameters.Add(CreateParameter(command, "CorrelationId", entry.CorrelationId)); + // Prepare parameters once + var timestampParam = CreateParameter(command, "Timestamp", null); + var logLevelParam = CreateParameter(command, "LogLevel", null); + var categoryParam = CreateParameter(command, "Category", null); + var messageParam = CreateParameter(command, "Message", null); + var exceptionParam = CreateParameter(command, "Exception", null); + var correlationParam = CreateParameter(command, "CorrelationId", null); - await command.ExecuteNonQueryAsync(); - } - catch (Exception ex) - { - OnException?.Invoke(this, ex); + command.Parameters.Add(timestampParam); + command.Parameters.Add(logLevelParam); + command.Parameters.Add(categoryParam); + command.Parameters.Add(messageParam); + command.Parameters.Add(exceptionParam); + command.Parameters.Add(correlationParam); + + foreach (var entry in batch) + { + timestampParam.Value = entry.Timestamp; + logLevelParam.Value = entry.LogLevel ?? (object)DBNull.Value; + categoryParam.Value = entry.Category ?? (object)DBNull.Value; + messageParam.Value = entry.Message ?? (object)DBNull.Value; + exceptionParam.Value = entry.Exception ?? (object)DBNull.Value; + correlationParam.Value = entry.CorrelationId ?? (object)DBNull.Value; + + await command.ExecuteNonQueryAsync(); + } + + transaction.Commit(); } } } + catch (Exception ex) + { + OnException?.Invoke(this, ex); + } finally { _flushLock.Release(); } } - private static 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}"; @@ -175,13 +187,14 @@ namespace EonaCat.Logger.EonaCatCoreLogger if (Interlocked.Decrement(ref _refCount) == 0) { _cts.Cancel(); - - try + try { - _flushTask?.Wait(); + _flushTask?.Wait(); + } + catch + { + // Do nothing } - catch (AggregateException) { } - _cts.Dispose(); } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/DiscordLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/DiscordLogger.cs index 8cac15c..15a701b 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/DiscordLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/DiscordLogger.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.Logging; using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Net.Http; using System.Text; using System.Threading; @@ -10,6 +9,9 @@ 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 DiscordLogger : ILogger, IDisposable { private readonly string _categoryName; @@ -20,9 +22,11 @@ namespace EonaCat.Logger.EonaCatCoreLogger private readonly SemaphoreSlim _flushLock = new SemaphoreSlim(1, 1); private readonly ConcurrentQueue _messageQueue = new ConcurrentQueue(); - private bool _flushLoopStarted = false; private CancellationTokenSource _cts; private Task _flushTask; + private int _flushStarted; + + private const int MaxQueueSize = 1000; public bool IncludeCorrelationId { get; set; } public event EventHandler OnException; @@ -33,10 +37,9 @@ namespace EonaCat.Logger.EonaCatCoreLogger _options = options ?? throw new ArgumentNullException(nameof(options)); IncludeCorrelationId = options.IncludeCorrelationId; - // Start flush loop once - if (!_flushLoopStarted) + // Start flush loop exactly once + if (Interlocked.CompareExchange(ref _flushStarted, 1, 0) == 0) { - _flushLoopStarted = true; _cts = new CancellationTokenSource(); _flushTask = Task.Run(() => FlushLoopAsync(_cts.Token)); } @@ -57,35 +60,33 @@ namespace EonaCat.Logger.EonaCatCoreLogger return; } + string correlationId = null; if (IncludeCorrelationId) { - var correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); + correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); _context.Set("CorrelationId", correlationId); } - var message = formatter(state, exception); - var logParts = new List - { - $"**[{DateTime.UtcNow:u}]**", - $"**[{logLevel}]**", - $"**[{_categoryName}]**", - $"Message: {message}" - }; + // Build message efficiently + var sb = new StringBuilder(); + sb.AppendLine($"**[{DateTime.UtcNow:u}]**"); + sb.AppendLine($"**[{logLevel}]**"); + sb.AppendLine($"**[{_categoryName}]**"); + sb.AppendLine($"Message: {formatter(state, exception)}"); foreach (var kvp in _context.GetAll()) { - logParts.Add($"`{kvp.Key}`: {kvp.Value}"); + sb.AppendLine($"`{kvp.Key}`: {kvp.Value}"); } if (exception != null) { - logParts.Add($"Exception: {exception}"); + sb.AppendLine($"Exception: {exception}"); } - // Limit queue size to prevent memory growth - if (_messageQueue.Count < 1000) + if (_messageQueue.Count < MaxQueueSize) { - _messageQueue.Enqueue(string.Join("\n", logParts)); + _messageQueue.Enqueue(sb.ToString()); } else { @@ -104,14 +105,16 @@ namespace EonaCat.Logger.EonaCatCoreLogger await FlushBufferAsync(token).ConfigureAwait(false); } } - catch (OperationCanceledException) + catch (OperationCanceledException) { - // Expected on cancel + // Do nothing, expected on cancellation } catch (Exception ex) { OnException?.Invoke(this, ex); } + + await FlushBufferAsync(token); } private async Task FlushBufferAsync(CancellationToken token) @@ -123,26 +126,32 @@ namespace EonaCat.Logger.EonaCatCoreLogger try { + var batchBuilder = new StringBuilder(); while (_messageQueue.TryDequeue(out var message)) { - try - { - var payload = new { content = message }; - using var content = new StringContent(JsonHelper.ToJson(payload), Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync(_options.WebhookUrl, content, token) - .ConfigureAwait(false); - - if (!response.IsSuccessStatusCode) - { - var error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - OnException?.Invoke(this, new Exception($"Discord webhook failed: {response.StatusCode} {error}")); - } - } - catch (Exception ex) - { - OnException?.Invoke(this, ex); - } + batchBuilder.AppendLine(message); } + + if (batchBuilder.Length == 0) + { + return; + } + + var payload = new { content = batchBuilder.ToString() }; + using var content = new StringContent(JsonHelper.ToJson(payload), Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(_options.WebhookUrl, content, token) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + OnException?.Invoke(this, new Exception($"Discord webhook failed: {response.StatusCode} {error}")); + } + } + catch (Exception ex) + { + OnException?.Invoke(this, ex); } finally { @@ -152,35 +161,24 @@ namespace EonaCat.Logger.EonaCatCoreLogger public void Dispose() { - if (_cts != null && !_cts.IsCancellationRequested) + if (_cts == null) { - _cts.Cancel(); - try - { - _flushTask?.Wait(); - } - catch (AggregateException ae) - { - ae.Handle(e => e is OperationCanceledException); - } - _cts.Dispose(); - _cts = null; + return; } - } - public void Shutdown() - { - _cts?.Cancel(); - try + _cts.Cancel(); + try { - _flushTask?.Wait(); + _flushTask?.Wait(); } - catch (AggregateException ae) + catch (AggregateException ae) { ae.Handle(e => e is OperationCanceledException); } - _flushLock.Dispose(); + + _cts.Dispose(); _httpClient.Dispose(); + _flushLock.Dispose(); } } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/ElasticSearchLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/ElasticSearchLogger.cs index 78e74cc..f10feb7 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/ElasticSearchLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/ElasticSearchLogger.cs @@ -1,6 +1,7 @@ using EonaCat.Json; using Microsoft.Extensions.Logging; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -12,14 +13,12 @@ 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 ElasticSearchLogger : ILogger, IDisposable { private readonly string _categoryName; private readonly ElasticSearchLoggerOptions _options; private readonly HttpClient _httpClient; - private readonly List _buffer = new(); - private readonly object _lock = new(); + private readonly ConcurrentQueue _queue = new(); private readonly CancellationTokenSource _cts = new(); private readonly Task _flushTask; @@ -30,8 +29,8 @@ namespace EonaCat.Logger.EonaCatCoreLogger public ElasticSearchLogger(string categoryName, ElasticSearchLoggerOptions options) { - _categoryName = categoryName; - _options = options; + _categoryName = categoryName ?? throw new ArgumentNullException(nameof(categoryName)); + _options = options ?? throw new ArgumentNullException(nameof(options)); IncludeCorrelationId = options.IncludeCorrelationId; _httpClient = new HttpClient(); @@ -41,7 +40,6 @@ namespace EonaCat.Logger.EonaCatCoreLogger 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) => _context.BeginScope(state); public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled; @@ -53,9 +51,10 @@ namespace EonaCat.Logger.EonaCatCoreLogger return; } + string correlationId = null; if (IncludeCorrelationId) { - var correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); + correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); _context.Set("CorrelationId", correlationId); } @@ -70,16 +69,13 @@ namespace EonaCat.Logger.EonaCatCoreLogger customContext = _context.GetAll() }; - string json = JsonHelper.ToJson(logDoc); + var json = JsonHelper.ToJson(logDoc); - lock (_lock) + _queue.Enqueue(json); + + while (_queue.Count > _options.MaxBufferSize && _queue.TryDequeue(out _)) { - _buffer.Add(json); - // Optional: drop oldest if buffer is too large - if (_buffer.Count > _options.MaxBufferSize) - { - _buffer.RemoveAt(0); - } + // Discard oldest log entries to maintain buffer size } } @@ -90,27 +86,40 @@ namespace EonaCat.Logger.EonaCatCoreLogger while (!token.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(_options.FlushIntervalSeconds), token); - await FlushBufferAsync(); + await FlushBufferAsync(token); } } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + // Do nothing + } - await FlushBufferAsync(); // flush remaining logs on shutdown + await FlushBufferAsync(token); } - private async Task FlushBufferAsync() + private async Task FlushBufferAsync(CancellationToken token) { - List toSend; - - lock (_lock) + if (_queue.IsEmpty) { - if (_buffer.Count == 0) - { - return; - } + return; + } - toSend = new List(_buffer); - _buffer.Clear(); + var sb = new StringBuilder(); + int batchSize = 0; + + while (_queue.TryDequeue(out var log)) + { + if (_options.UseBulkInsert) + { + sb.AppendLine("{\"index\":{}}"); + } + sb.AppendLine(log); + batchSize++; + } + + if (batchSize == 0) + { + return; } string indexName = $"{_options.IndexName}-{DateTime.UtcNow:yyyy.MM.dd}"; @@ -121,7 +130,9 @@ namespace EonaCat.Logger.EonaCatCoreLogger if (!string.IsNullOrWhiteSpace(_options.Username)) { - var authToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_options.Username}:{_options.Password}")); + var authToken = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{_options.Username}:{_options.Password}") + ); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authToken); } @@ -130,11 +141,12 @@ namespace EonaCat.Logger.EonaCatCoreLogger request.Headers.TryAddWithoutValidation(header.Key, header.Value); } + // Dynamic headers var dynamicHeaders = new Dictionary { { "index", _options.IndexName }, { "date", DateTime.UtcNow.ToString("yyyy-MM-dd") }, - { "timestamp", DateTime.UtcNow.ToString("o") }, + { "timestamp", DateTime.UtcNow.ToString("o") } }; foreach (var header in _options.TemplateHeaders) @@ -148,20 +160,14 @@ namespace EonaCat.Logger.EonaCatCoreLogger request.Headers.TryAddWithoutValidation($"X-Context-{kv.Key}", kv.Value); } - request.Content = new StringContent( - _options.UseBulkInsert - ? string.Join("\n", toSend.Select(d => $"{{\"index\":{{}}}}\n{d}")) + "\n" - : string.Join("\n", toSend), - Encoding.UTF8, - "application/json" - ); + request.Content = new StringContent(sb.ToString(), Encoding.UTF8, "application/json"); try { - var response = await _httpClient.SendAsync(request); + var response = await _httpClient.SendAsync(request, token); if (!response.IsSuccessStatusCode) { - var errorContent = await response.Content.ReadAsStringAsync(); + var errorContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); OnInvalidStatusCode?.Invoke(this, $"ElasticSearch request failed: {response.StatusCode}, {errorContent}"); } } @@ -184,7 +190,15 @@ namespace EonaCat.Logger.EonaCatCoreLogger public void Dispose() { _cts.Cancel(); - _flushTask.Wait(); + try + { + _flushTask.Wait(); + } + catch + { + // Do nothing + } + _cts.Dispose(); _httpClient.Dispose(); } diff --git a/EonaCat.Logger/EonaCatCoreLogger/Extensions/BatchingDatabaseLoggerFactoryExtensions.cs b/EonaCat.Logger/EonaCatCoreLogger/Extensions/BatchingDatabaseLoggerFactoryExtensions.cs index aec8400..54b8720 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Extensions/BatchingDatabaseLoggerFactoryExtensions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Extensions/BatchingDatabaseLoggerFactoryExtensions.cs @@ -4,8 +4,24 @@ using System; namespace EonaCat.Logger.EonaCatCoreLogger.Extensions { + /// + /// Provides extension methods for registering a batching database logger with an . + /// + /// These extension methods enable the integration of a batching database logger into the logging + /// pipeline using dependency injection. Use these methods to configure and add the logger to your application's + /// logging system. public static class BatchingDatabaseLoggerFactoryExtensions { + /// + /// Adds a batching database logger to the logging builder, allowing log messages to be written to a database in + /// batches. + /// + /// Use this method to enable efficient, batched logging to a database. Batching can + /// improve performance by reducing the number of database writes. Configure batching behavior and database + /// connection settings using the provided options. + /// The logging builder to which the batching database logger will be added. + /// An action to configure the batching database logger options. Cannot be null. + /// The same instance of for chaining. public static ILoggingBuilder AddEonaCatBatchingDatabaseLogger(this ILoggingBuilder builder, Action configure) { var options = new BatchingDatabaseLoggerOptions(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/Extensions/DatabaseLoggerFactoryExtensions.cs b/EonaCat.Logger/EonaCatCoreLogger/Extensions/DatabaseLoggerFactoryExtensions.cs index db2b3c6..812e78f 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Extensions/DatabaseLoggerFactoryExtensions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Extensions/DatabaseLoggerFactoryExtensions.cs @@ -4,8 +4,23 @@ using System; namespace EonaCat.Logger.EonaCatCoreLogger.Extensions { + /// + /// Provides extension methods for registering the EonaCat database logger with an . + /// + /// These extension methods enable integration of the EonaCat database logger into the logging + /// pipeline using dependency injection. Use these methods to configure and add database logging capabilities to + /// your application's logging setup. public static class DatabaseLoggerFactoryExtensions { + /// + /// Adds a database logger provider to the logging builder, allowing log messages to be written to a database. + /// + /// Call this method to enable logging to a database using custom configuration. This + /// method registers a singleton that writes log entries to the configured + /// database. + /// The logging builder to which the database logger provider will be added. + /// An action to configure the database logger options. This parameter cannot be null. + /// The same instance of for chaining. public static ILoggingBuilder AddEonaCatDatabaseLogger(this ILoggingBuilder builder, Action configure) { var options = new DatabaseLoggerOptions(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/Extensions/DiscordLoggerFactoryExtensions.cs b/EonaCat.Logger/EonaCatCoreLogger/Extensions/DiscordLoggerFactoryExtensions.cs index 9be0dc4..964cbf7 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Extensions/DiscordLoggerFactoryExtensions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Extensions/DiscordLoggerFactoryExtensions.cs @@ -4,8 +4,23 @@ using System; namespace EonaCat.Logger.EonaCatCoreLogger.Extensions { + /// + /// Provides extension methods for registering a Discord logger with an . + /// + /// These extensions enable logging to Discord by adding a custom logger provider to the + /// application's logging pipeline. Use these methods to configure and enable Discord-based logging within your + /// application's logging setup. public static class DiscordLoggerFactoryExtensions { + /// + /// Adds a logger that sends log messages to Discord using the EonaCat Discord Logger provider. + /// + /// Call this method to enable logging to a Discord channel via the EonaCat Discord + /// Logger. The delegate allows customization of logger options such as webhook URL + /// and log level filtering. + /// The logging builder to which the Discord logger provider will be added. + /// A delegate to configure the options for the Discord logger. Cannot be null. + /// The same instance of for chaining. public static ILoggingBuilder AddEonaCatDiscordLogger(this ILoggingBuilder builder, Action configure) { var options = new DiscordLoggerOptions(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/Extensions/ElasticSearchLoggerFactoryExtensions.cs b/EonaCat.Logger/EonaCatCoreLogger/Extensions/ElasticSearchLoggerFactoryExtensions.cs index 90d2094..bab7342 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Extensions/ElasticSearchLoggerFactoryExtensions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Extensions/ElasticSearchLoggerFactoryExtensions.cs @@ -7,8 +7,20 @@ using System; namespace EonaCat.Logger.EonaCatCoreLogger.Extensions { + /// + /// Provides extension methods for configuring ElasticSearch logging for an instance. + /// public static class ElasticSearchLoggerFactoryExtensions { + /// + /// Adds an ElasticSearch-based logger to the logging builder, allowing log messages to be sent to an + /// ElasticSearch instance. + /// + /// Call this method during application startup to enable logging to ElasticSearch. The + /// provided configuration delegate allows customization of connection settings and logging behavior. + /// The logging builder to which the ElasticSearch logger will be added. + /// A delegate to configure the options for the ElasticSearch logger. Cannot be null. + /// The same instance of for chaining. public static ILoggingBuilder AddEonaCatElasticSearchLogger(this ILoggingBuilder builder, Action configure) { var options = new ElasticSearchLoggerOptions(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/Extensions/FileLoggerFactoryExtensions.cs b/EonaCat.Logger/EonaCatCoreLogger/Extensions/FileLoggerFactoryExtensions.cs index d0e83b2..acf2905 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Extensions/FileLoggerFactoryExtensions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Extensions/FileLoggerFactoryExtensions.cs @@ -11,6 +11,13 @@ namespace EonaCat.Logger.EonaCatCoreLogger.Extensions; /// public static class FileLoggerFactoryExtensions { + /// + /// Adds a file-based logger provider to the logging builder. + /// + /// This method registers a singleton that writes log messages to + /// files. Call this method during logging configuration to enable file logging in the application. + /// The logging builder to which the file logger provider will be added. + /// The same instance of for chaining. private static ILoggingBuilder AddEonaCatFileLogger(this ILoggingBuilder builder) { builder.Services.AddSingleton(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/Extensions/HttpLoggerFactoryExtensions.cs b/EonaCat.Logger/EonaCatCoreLogger/Extensions/HttpLoggerFactoryExtensions.cs index d5929ca..bdd18ee 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Extensions/HttpLoggerFactoryExtensions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Extensions/HttpLoggerFactoryExtensions.cs @@ -7,8 +7,24 @@ using System; namespace EonaCat.Logger.EonaCatCoreLogger.Extensions { + /// + /// Provides extension methods for registering the EonaCat HTTP logger with an . + /// + /// This class contains extension methods that enable integration of the EonaCat HTTP logger into + /// the logging pipeline using dependency injection. These methods are intended to be used during application + /// startup when configuring logging services. public static class HttpLoggerFactoryExtensions { + /// + /// Adds an HTTP logger provider to the logging builder, allowing log messages to be sent over HTTP with + /// configurable options. + /// + /// Use this method to enable logging to an HTTP endpoint by configuring the . This method should be called as part of the logging setup in your application's + /// dependency injection configuration. + /// The logging builder to which the HTTP logger provider will be added. + /// An action to configure the HTTP logger options. This parameter cannot be null. + /// The same instance of for chaining. public static ILoggingBuilder AddEonaCatHttpLogger(this ILoggingBuilder builder, Action configure) { var options = new HttpLoggerOptions(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/Extensions/JsonFIleLoggerFactoryExtensions.cs b/EonaCat.Logger/EonaCatCoreLogger/Extensions/JsonFIleLoggerFactoryExtensions.cs index 4c0d9b6..91e5b91 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Extensions/JsonFIleLoggerFactoryExtensions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Extensions/JsonFIleLoggerFactoryExtensions.cs @@ -7,8 +7,23 @@ using System; namespace EonaCat.Logger.EonaCatCoreLogger.Extensions { + /// + /// Provides extension methods for registering a JSON file logger with an . + /// + /// These extension methods enable logging to JSON-formatted files by adding the necessary logger + /// provider to the application's dependency injection container. Use these methods to configure and enable JSON + /// file logging in ASP.NET Core or other .NET applications that use Microsoft.Extensions.Logging. public static class JsonFileLoggerFactoryExtensions { + /// + /// Adds a JSON file logger to the logging builder with the specified configuration options. + /// + /// This method registers a singleton that writes log + /// entries to a JSON file using the specified options. Call this method during application startup to enable + /// JSON file logging. + /// The logging builder to which the JSON file logger will be added. + /// An action to configure the options for the JSON file logger. Cannot be null. + /// The same instance of for chaining. public static ILoggingBuilder AddEonaCatJsonFileLogger(this ILoggingBuilder builder, Action configure) { var options = new JsonFileLoggerOptions(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/Extensions/SlackLoggerFactoryExtensions.cs b/EonaCat.Logger/EonaCatCoreLogger/Extensions/SlackLoggerFactoryExtensions.cs index 9d2a3d0..9f24d73 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Extensions/SlackLoggerFactoryExtensions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Extensions/SlackLoggerFactoryExtensions.cs @@ -4,8 +4,24 @@ using System; namespace EonaCat.Logger.EonaCatCoreLogger.Extensions { + /// + /// Provides extension methods for registering the EonaCat Slack logger with an . + /// + /// These extension methods enable integration of Slack-based logging into applications using + /// Microsoft.Extensions.Logging. Use these methods to configure and add the EonaCat Slack logger provider to the + /// logging pipeline. public static class SlackLoggerFactoryExtensions { + /// + /// Adds a Slack-based logger to the logging builder, allowing log messages to be sent to Slack using the + /// specified configuration. + /// + /// Call this method to enable logging to Slack within your application's logging + /// pipeline. The provided configuration action allows you to specify Slack webhook URLs and other logger + /// options. This method registers the Slack logger as a singleton service. + /// The logging builder to which the Slack logger will be added. + /// An action to configure the Slack logger options. Cannot be null. + /// The same instance of for chaining. public static ILoggingBuilder AddEonaCatSlackLogger(this ILoggingBuilder builder, Action configure) { var options = new SlackLoggerOptions(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/Extensions/TcpLoggerFactoryExtensions.cs b/EonaCat.Logger/EonaCatCoreLogger/Extensions/TcpLoggerFactoryExtensions.cs index 172856e..29825bf 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Extensions/TcpLoggerFactoryExtensions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Extensions/TcpLoggerFactoryExtensions.cs @@ -7,8 +7,20 @@ using System; namespace EonaCat.Logger.EonaCatCoreLogger.Extensions { + /// + /// Provides extension methods for registering the EonaCat TCP logger with an . + /// public static class TcpLoggerFactoryExtensions { + /// + /// Adds a TCP-based logger provider to the logging builder, allowing log messages to be sent over TCP using the + /// specified configuration. + /// + /// Use this method to enable logging to a remote endpoint over TCP. The action allows customization of connection settings and other logger options. + /// The logging builder to which the TCP logger provider will be added. + /// An action to configure the TCP logger options. Cannot be null. + /// The same instance of for chaining. public static ILoggingBuilder AddEonaCatTcpLogger(this ILoggingBuilder builder, Action configure) { var options = new TcpLoggerOptions(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/Extensions/TelegramLoggerFactoryExtensions.cs b/EonaCat.Logger/EonaCatCoreLogger/Extensions/TelegramLoggerFactoryExtensions.cs index 5aed83e..ba28f4e 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Extensions/TelegramLoggerFactoryExtensions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Extensions/TelegramLoggerFactoryExtensions.cs @@ -4,8 +4,23 @@ using System; namespace EonaCat.Logger.EonaCatCoreLogger.Extensions { + /// + /// Provides extension methods for registering the EonaCat Telegram logger with an . + /// + /// These extension methods enable integration of Telegram-based logging into applications using + /// Microsoft.Extensions.Logging. Use these methods to configure and add the EonaCat Telegram logger provider to the + /// logging pipeline. public static class TelegramLoggerFactoryExtensions { + /// + /// Adds a Telegram-based logger to the logging builder using the specified configuration options. + /// + /// Use this method to enable logging to Telegram by configuring the required options, + /// such as bot token and chat ID. This logger allows log messages to be sent directly to a Telegram + /// chat. + /// The logging builder to which the Telegram logger will be added. + /// An action to configure the Telegram logger options. Cannot be null. + /// The same instance of for chaining. public static ILoggingBuilder AddEonaCatTelegramLogger(this ILoggingBuilder builder, Action configure) { var options = new TelegramLoggerOptions(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/Extensions/UdpLoggerFactoryExtensions.cs b/EonaCat.Logger/EonaCatCoreLogger/Extensions/UdpLoggerFactoryExtensions.cs index 4bd783d..0bf789b 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Extensions/UdpLoggerFactoryExtensions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Extensions/UdpLoggerFactoryExtensions.cs @@ -7,8 +7,22 @@ using System; namespace EonaCat.Logger.EonaCatCoreLogger.Extensions { + /// + /// Provides extension methods for registering the EonaCat UDP logger with an . + /// + /// These extension methods enable integration of the EonaCat UDP logger into the logging + /// pipeline using dependency injection. Use these methods to configure and add UDP-based logging to your + /// application's logging providers. public static class UdpLoggerFactoryExtensions { + /// + /// Adds a UDP-based logger to the logging builder using the specified configuration options. + /// + /// Call this method to enable logging over UDP in your application. The provided + /// configuration action allows customization of UDP logger settings such as endpoint and formatting. + /// The logging builder to which the UDP logger will be added. + /// An action to configure the for the UDP logger. Cannot be null. + /// The instance for chaining. public static ILoggingBuilder AddEonaCatUdpLogger(this ILoggingBuilder builder, Action configure) { var options = new UdpLoggerOptions(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs b/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs index 05511a4..ece1b20 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using EonaCat.Logger.EonaCatCoreLogger.Internal; @@ -11,339 +12,235 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; 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. -/// -/// An that writes logs to a file -/// [ProviderAlias("EonaCatFileLogger")] -public class FileLoggerProvider : BatchingLoggerProvider +public sealed class FileLoggerProvider : BatchingLoggerProvider { + private readonly string _path; private readonly string _fileNamePrefix; private readonly int _maxFileSize; private readonly int _maxRetainedFiles; private readonly int _maxRolloverFiles; - private readonly int _maxTries; - private readonly string _path; - private string _logFile; - private ConcurrentDictionary _buffer = new ConcurrentDictionary(); + + private readonly ConcurrentDictionary _buffer = new(); + private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly SemaphoreSlim _rolloverLock = new(1, 1); private readonly LoggerScopedContext _context = new(); + private string _logFile; + private long _currentFileSize; + private int _isWriting; + public event EventHandler OnError; - /// - /// Creates an instance of the - /// - /// The options object controlling the logger + public string LogFile => _logFile; + public FileLoggerProvider(IOptions options) : base(options) { - var loggerOptions = options.Value; - _path = loggerOptions.LogDirectory; - _fileNamePrefix = loggerOptions.FileNamePrefix; - _maxFileSize = loggerOptions.FileSizeLimit; - _maxRetainedFiles = loggerOptions.RetainedFileCountLimit; - _maxRolloverFiles = loggerOptions.MaxRolloverFiles; - _maxTries = loggerOptions.MaxWriteTries; - IncludeCorrelationId = loggerOptions.IncludeCorrelationId; + var o = options.Value; + _path = o.LogDirectory; + _fileNamePrefix = o.FileNamePrefix; + _maxFileSize = o.FileSizeLimit; + _maxRetainedFiles = o.RetainedFileCountLimit; + _maxRolloverFiles = o.MaxRolloverFiles; + IncludeCorrelationId = o.IncludeCorrelationId; } - /// - /// The file to which log messages should be appended. - /// - public string LogFile + public bool IncludeCorrelationId { get; } + + protected override async Task WriteMessagesAsync( + IReadOnlyList messages, + CancellationToken token) { - get => _logFile; - set - { - _logFile = value; - - if (!string.IsNullOrEmpty(_logFile)) - { - var dir = Path.GetDirectoryName(_logFile); - if (!string.IsNullOrEmpty(dir)) - { - if (!Directory.Exists(dir)) - { - Directory.CreateDirectory(dir); - } - } - } - } - } - - public void SetContext(string key, string value) => _context.Set(key, value); - public void ClearContext() => _context.Clear(); - public string GetContext(string key) => _context.Get(key); - - /// - protected override async Task WriteMessagesAsync(IEnumerable messages, - CancellationToken cancellationToken) - { - if (IsWriting) + if (Interlocked.Exchange(ref _isWriting, 1) == 1) { return; } - IsWriting = true; - try { Directory.CreateDirectory(_path); foreach (var group in messages.GroupBy(GetGrouping)) { - LogFile = GetFullName(group.Key); - var currentMessages = string.Join(string.Empty, group.Select(item => + var file = GetFullName(group.Key); + InitializeFile(file); + + var stringBuilder = _buffer.GetOrAdd(file, _ => new StringBuilder(4096)); + lock (stringBuilder) { - if (IncludeCorrelationId) + foreach (var message in group) { - var correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); - _context.Set("CorrelationId", correlationId); + AppendMessage(stringBuilder, message); } - - var contextData = _context.GetAll(); - var contextInfo = contextData.Count > 0 - ? string.Join(" ", contextData.Select(kvp => $"{kvp.Key}={kvp.Value}")) - : string.Empty; - - if (!string.IsNullOrWhiteSpace(contextInfo)) - { - // if the message ends with a new line, remove it - if (item.Message.EndsWith(Environment.NewLine)) - { - item.Message = item.Message.Substring(0, item.Message.Length - Environment.NewLine.Length); - } - return $"{item.Message} [{contextInfo}]{Environment.NewLine}"; - } - return $"{item.Message}"; - })); - - if (!_buffer.TryAdd(LogFile, currentMessages)) - { - _buffer[LogFile] += currentMessages; - } - - // Check file size - FileInfo fileInfo = new FileInfo(LogFile); - if (fileInfo.Exists && fileInfo.Length >= _maxFileSize) - { - // Roll over the log file - await RollOverLogFile().ConfigureAwait(false); - } - - if (await TryWriteToFileAsync(cancellationToken)) - { - // Clear buffer on success - _buffer.Clear(); - } - else if (await WriteToTempFileAsync(cancellationToken)) - { - // Fallback to temp file } + await FlushAsync(file, stringBuilder, token).ConfigureAwait(false); DeleteOldLogFiles(); } } - catch (Exception exception) + catch (Exception ex) { - OnError?.Invoke(this, new ErrorMessage { Exception = exception, Message = "Cannot write to file"}); + OnError?.Invoke(this, new ErrorMessage + { + Exception = ex, + Message = "Failed to write log file" + }); } finally { - IsWriting = false; + Interlocked.Exchange(ref _isWriting, 0); } } - public bool IsWriting { get; set; } - public bool IncludeCorrelationId { get; set; } - - private async Task TryWriteToFileAsync(CancellationToken cancellationToken) + private void AppendMessage(StringBuilder sb, LogMessage msg) { - if (!_buffer.ContainsKey(LogFile)) + // Ensure correlation id exists (once per scope) + if (IncludeCorrelationId) { - return true; + var cid = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); + _context.Set("CorrelationId", cid); } - var tries = 0; - var completed = false; + // 1. Append the already-formatted message FIRST + sb.Append(msg.Message); - while (!completed) + // 2. Append context AFTER the message + var ctx = _context.GetAll(); + if (ctx.Count > 0) { - try + sb.Append(" ["); + bool first = true; + foreach (var kv in ctx) { - using (var file = new StreamWriter(LogFile, true)) + if (!first) { - await file.WriteAsync(_buffer[LogFile]).ConfigureAwait(false); - } - completed = true; - _buffer.TryRemove(LogFile, out _); - return true; - } - catch (Exception) - { - tries++; - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - if (tries >= _maxTries) - { - OnError?.Invoke(this, new ErrorMessage { Message = "Cannot write to log folder"}); - return false; + sb.Append(' '); } + + sb.Append(kv.Key) + .Append('=') + .Append(kv.Value); + + first = false; } + sb.Append(']'); } - return false; + + // 3. End the line + sb.AppendLine(); } - private async Task WriteToTempFileAsync(CancellationToken cancellationToken) + private async Task FlushAsync(string file, StringBuilder sb, CancellationToken token) { - if (string.IsNullOrWhiteSpace(LogFile) || !_buffer.ContainsKey(LogFile)) + if (sb.Length == 0) { - return false; + return; } - var tempLogFolder = Path.Combine(Path.GetTempPath(), "EonaCatLogs"); - var tempLogFile = $"{Path.Combine(tempLogFolder, Path.GetFileNameWithoutExtension(LogFile))}.log"; - + await _writeLock.WaitAsync(token).ConfigureAwait(false); try { - Directory.CreateDirectory(tempLogFolder); + using var fs = new FileStream( + file, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 64 * 1024, + useAsync: true); - // Create new temp file - using (var file = new StreamWriter(tempLogFile, true)) + using var writer = new StreamWriter(fs); + var text = sb.ToString(); + await writer.WriteAsync(text).ConfigureAwait(false); + + _currentFileSize += Encoding.UTF8.GetByteCount(text); + sb.Clear(); + + if (_currentFileSize >= _maxFileSize) { - await file.WriteAsync(_buffer[LogFile]).ConfigureAwait(false); - } - - _buffer.TryRemove(LogFile, out _); - return true; - } - catch (Exception exception) - { - OnError?.Invoke(this, new ErrorMessage { Message = "Cannot write to temp folder ", Exception = exception}); - return false; - } - } - - private string GetFullName((int Year, int Month, int Day) group) - { - var hasPrefix = !string.IsNullOrWhiteSpace(_fileNamePrefix); - if (hasPrefix) - { - return Path.Combine(_path, $"{_fileNamePrefix}_{group.Year:0000}{group.Month:00}{group.Day:00}.log"); - } - - return Path.Combine(_path, $"{group.Year:0000}{group.Month:00}{group.Day:00}.log"); - } - - private (int Year, int Month, int Day) GetGrouping(LogMessage message) - { - return (message.Timestamp.Year, message.Timestamp.Month, message.Timestamp.Day); - } - - private readonly object lockObj = new object(); - - private async Task RollOverLogFile() - { - lock (lockObj) - { - try - { - string directoryPath = Path.GetDirectoryName(LogFile); - string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(LogFile); - string fileExtension = Path.GetExtension(LogFile); - - // Rename existing files - for (int i = _maxRolloverFiles - 1; i >= 1; i--) - { - string currentFilePath = Path.Combine(directoryPath, $"{fileNameWithoutExtension}.{i}{fileExtension}"); - string newFilePath = Path.Combine(directoryPath, $"{fileNameWithoutExtension}.{(i + 1)}{fileExtension}"); - - if (File.Exists(currentFilePath)) - { - if (File.Exists(newFilePath)) - { - File.Delete(newFilePath); - } - File.Move(currentFilePath, newFilePath); - } - - if (i != 1) - { - continue; - } - - currentFilePath = Path.Combine(directoryPath, $"{fileNameWithoutExtension}{fileExtension}"); - newFilePath = Path.Combine(directoryPath, $"{fileNameWithoutExtension}.{i}{fileExtension}"); - File.Move(currentFilePath, newFilePath); - } - - // Rename current log file - var rolloverFilePath = Path.Combine(directoryPath, $"{fileNameWithoutExtension}.1{fileExtension}"); - - // Write end message and start processing in background - Task.Run(async () => - { - await WriteEndMessageAsync(rolloverFilePath).ConfigureAwait(false); - await ProcessAsync().ConfigureAwait(false); - }); - } - catch (Exception ex) - { - // Log or handle the exception - Console.WriteLine($"Error occurred during log file rollover: {ex.Message}"); + await RollOverAsync(file).ConfigureAwait(false); } } - } - - private async Task WriteEndMessageAsync(string logFilePath) - { - var stopMessage = LogHelper.GetStopMessage(); - stopMessage = LogHelper.FormatMessageWithHeader(LoggerSettings, ELogType.INFO, stopMessage, CurrentDateTme, Category); - - using (var file = new StreamWriter(logFilePath, true)) + finally { - await file.WriteAsync(stopMessage).ConfigureAwait(false); + _writeLock.Release(); } } - private async Task ProcessAsync() + private async Task RollOverAsync(string file) { + await _rolloverLock.WaitAsync().ConfigureAwait(false); try { - await WriteStartMessage().ConfigureAwait(false); + var dir = Path.GetDirectoryName(file); + var name = Path.GetFileNameWithoutExtension(file); + var ext = Path.GetExtension(file); + + for (int i = _maxRolloverFiles - 1; i >= 1; i--) + { + var src = Path.Combine(dir, $"{name}.{i}{ext}"); + var dst = Path.Combine(dir, $"{name}.{i + 1}{ext}"); + + if (File.Exists(dst)) + { + File.Delete(dst); + } + + if (File.Exists(src)) + { + File.Move(src, dst); + } + } + + File.Move(file, Path.Combine(dir, $"{name}.1{ext}")); + _currentFileSize = 0; } - catch (Exception ex) + finally { - // Log or handle the exception - Console.WriteLine($"Error occurred during log file rollover: {ex.Message}"); + _rolloverLock.Release(); } } - /// - /// Deletes old log files, keeping a number of files defined by - /// + private void InitializeFile(string file) + { + if (_logFile == file) + { + return; + } + + _logFile = file; + _currentFileSize = File.Exists(file) + ? new FileInfo(file).Length + : 0; + } + + private (int Year, int Month, int Day) GetGrouping(LogMessage m) => + (m.Timestamp.Year, m.Timestamp.Month, m.Timestamp.Day); + + private string GetFullName((int Year, int Month, int Day) g) => + string.IsNullOrWhiteSpace(_fileNamePrefix) + ? Path.Combine(_path, $"{g.Year:0000}{g.Month:00}{g.Day:00}.log") + : Path.Combine(_path, $"{_fileNamePrefix}_{g.Year:0000}{g.Month:00}{g.Day:00}.log"); + protected void DeleteOldLogFiles() { - if (_maxRetainedFiles > 0) + if (_maxRetainedFiles <= 0) { - var hasPrefix = !string.IsNullOrWhiteSpace(_fileNamePrefix); - IEnumerable files = null; + return; + } - if (hasPrefix) - { - files = new DirectoryInfo(_path).GetFiles(_fileNamePrefix + "*"); - } - else - { - files = new DirectoryInfo(_path).GetFiles("*"); - } + var files = new DirectoryInfo(_path) + .GetFiles($"{_fileNamePrefix}*") + .Where(f => !f.FullName.Equals(_logFile, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(f => f.CreationTimeUtc) + .Skip(_maxRetainedFiles); - files = files.OrderByDescending(file => file.Name).Skip(_maxRetainedFiles); - - foreach (var item in files) - { - item.Delete(); - } + foreach (var f in files) + { + f.Delete(); } } -} \ No newline at end of file +} diff --git a/EonaCat.Logger/EonaCatCoreLogger/HttpLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/HttpLogger.cs index 8b4f3f5..bf47fdd 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/HttpLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/HttpLogger.cs @@ -1,20 +1,19 @@ using EonaCat.Json; using Microsoft.Extensions.Logging; using System; -using System.Net.Http; -using System.Text; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Net.Http; +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 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(); @@ -27,14 +26,16 @@ namespace EonaCat.Logger.EonaCatCoreLogger public event EventHandler OnException; public event EventHandler OnInvalidStatusCode; + private const int MaxQueueSize = 10_000; + public HttpLogger(string categoryName, HttpLoggerOptions options) { - _categoryName = categoryName; - _options = options; + _categoryName = categoryName ?? throw new ArgumentNullException(nameof(categoryName)); + _options = options ?? throw new ArgumentNullException(nameof(options)); IncludeCorrelationId = options.IncludeCorrelationId; _client = new HttpClient(); - _queue = new BlockingCollection>(boundedCapacity: 10000); + _queue = new BlockingCollection>(boundedCapacity: MaxQueueSize); _cts = new CancellationTokenSource(); _processingTask = Task.Run(() => ProcessQueueAsync(_cts.Token)); } @@ -54,19 +55,20 @@ namespace EonaCat.Logger.EonaCatCoreLogger return; } + string correlationId = null; if (IncludeCorrelationId) { - var correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); + correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); _context.Set("CorrelationId", correlationId); } - var payload = new Dictionary + var payload = new Dictionary(6) { - { "timestamp", DateTime.UtcNow }, - { "level", logLevel.ToString() }, - { "category", _categoryName }, - { "message", formatter(state, exception) }, - { "eventId", eventId.Id } + ["timestamp"] = DateTime.UtcNow, + ["level"] = logLevel.ToString(), + ["category"] = _categoryName, + ["message"] = formatter(state, exception), + ["eventId"] = eventId.Id }; var contextData = _context.GetAll(); @@ -75,10 +77,10 @@ namespace EonaCat.Logger.EonaCatCoreLogger payload["context"] = contextData; } - // Add to queue, drop oldest if full + // Drop oldest when full while (!_queue.TryAdd(payload)) { - _queue.TryTake(out _); // drop oldest + _queue.TryTake(out _); } } @@ -90,11 +92,11 @@ namespace EonaCat.Logger.EonaCatCoreLogger { try { - var content = _options.SendAsJson + using var content = _options.SendAsJson ? new StringContent(JsonHelper.ToJson(payload), Encoding.UTF8, "application/json") : new StringContent(payload["message"].ToString(), Encoding.UTF8, "text/plain"); - var response = await _client.PostAsync(_options.Endpoint, content, token); + using var response = await _client.PostAsync(_options.Endpoint, content, token); if (!response.IsSuccessStatusCode) { @@ -107,18 +109,25 @@ namespace EonaCat.Logger.EonaCatCoreLogger } } } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + // Do nothing, normal shutdown + } } public void Dispose() { _cts.Cancel(); _queue.CompleteAdding(); + try { _processingTask.Wait(); } - catch { /* ignore */ } + catch + { + // Do nothing + } _queue.Dispose(); _cts.Dispose(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/Internal/BatchingLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/Internal/BatchingLogger.cs index dadf54f..f946a71 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Internal/BatchingLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Internal/BatchingLogger.cs @@ -6,31 +6,40 @@ using Microsoft.Extensions.Logging; namespace EonaCat.Logger.EonaCatCoreLogger.Internal { - // 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 BatchingLogger : ILogger + public sealed class BatchingLogger : ILogger { private readonly string _category; private readonly BatchingLoggerProvider _provider; - private LoggerSettings _loggerSettings; + private readonly LoggerSettings _settings; - public BatchingLogger(BatchingLoggerProvider loggerProvider, string categoryName, LoggerSettings loggerSettings) + public BatchingLogger( + BatchingLoggerProvider provider, + string category, + LoggerSettings settings) { - _loggerSettings = loggerSettings ?? throw new ArgumentNullException(nameof(loggerSettings)); - _provider = loggerProvider ?? throw new ArgumentNullException(nameof(loggerProvider)); - _category = categoryName ?? throw new ArgumentNullException(nameof(categoryName)); + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _category = category ?? throw new ArgumentNullException(nameof(category)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); } - private DateTimeOffset CurrentDateTimeOffset => CurrentDateTime; + private DateTimeOffset Now + { + get + { + // Avoid DateTimeOffset.Now if UseLocalTime is false + return _settings.UseLocalTime ? DateTimeOffset.Now : DateTimeOffset.UtcNow; + } + } - private DateTime CurrentDateTime => _loggerSettings.UseLocalTime ? DateTime.Now : DateTime.UtcNow; - - public IDisposable BeginScope(TState state) => null; + public IDisposable BeginScope(TState state) => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception exception, Func formatter) { if (!IsEnabled(logLevel)) @@ -38,35 +47,59 @@ namespace EonaCat.Logger.EonaCatCoreLogger.Internal return; } - var timestamp = CurrentDateTimeOffset; - Log(timestamp, logLevel, eventId, state, exception, formatter, _category); - } + string rawMessage; + if (exception != null) + { + rawMessage = exception.FormatExceptionToMessage(); + } + else + { + rawMessage = formatter != null ? formatter(state, null) : string.Empty; + } - public void Log(DateTimeOffset timestamp, LogLevel logLevel, EventId eventId, TState state, - Exception exception, Func formatter, string category) - { - if (!IsEnabled(logLevel)) + if (string.IsNullOrEmpty(rawMessage)) { return; } - string message = exception != null - ? exception.FormatExceptionToMessage() + Environment.NewLine - : formatter(state, exception); + var timestamp = Now; + LogInternal(timestamp, logLevel, rawMessage, exception); + } - message = LogHelper.FormatMessageWithHeader(_loggerSettings, logLevel.FromLogLevel(), message, timestamp.DateTime, category) + Environment.NewLine; + private void LogInternal( + DateTimeOffset timestamp, + LogLevel logLevel, + string message, + Exception exception) + { + string formatted = LogHelper.FormatMessageWithHeader( + _settings, + logLevel.FromLogLevel(), + message, + timestamp.DateTime, + _category); - var currentMessage = new EonaCatLogMessage + var writtenMessage = _provider.AddMessage(timestamp, formatted, _category); + var onLogEvent = _settings.OnLogEvent; + + if (onLogEvent != null) { - DateTime = timestamp.DateTime, - Message = _provider.AddMessage(timestamp, message), - LogType = logLevel.FromLogLevel(), - Category = category, - Exception = exception, - Origin = string.IsNullOrWhiteSpace(_loggerSettings.LogOrigin) ? "BatchingLogger" : _loggerSettings.LogOrigin - }; + onLogEvent(new EonaCatLogMessage + { + DateTime = timestamp.DateTime, + Message = writtenMessage, + LogType = logLevel.FromLogLevel(), + Category = _category, + Exception = exception, + Origin = string.IsNullOrWhiteSpace(_settings.LogOrigin) ? "BatchingLogger" : _settings.LogOrigin + }); + } + } - _loggerSettings.OnLogEvent(currentMessage); + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new NullScope(); + public void Dispose() { } } } -} \ No newline at end of file +} diff --git a/EonaCat.Logger/EonaCatCoreLogger/Internal/BatchingLoggerProvider.cs b/EonaCat.Logger/EonaCatCoreLogger/Internal/BatchingLoggerProvider.cs index 21ed824..ed8f48c 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Internal/BatchingLoggerProvider.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Internal/BatchingLoggerProvider.cs @@ -2,60 +2,55 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Text.RegularExpressions; using System.Threading; -using System.Threading.Channels; using System.Threading.Tasks; namespace EonaCat.Logger.EonaCatCoreLogger.Internal; -// 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 abstract class BatchingLoggerProvider : ILoggerProvider, IDisposable { - private int _batchSize; + private readonly int _batchSize; + private readonly BlockingCollection _queue; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _worker; - private readonly Channel _channel = - Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false - }); - - private CancellationTokenSource _cancellationTokenSource; - private Task _outputTask; - private bool _isDisposed; + private bool _disposed; private LoggerSettings _loggerSettings; protected BatchingLoggerProvider(IOptions options) { - var loggerOptions = options.Value ?? throw new ArgumentNullException(nameof(options)); + var o = options.Value ?? throw new ArgumentNullException(nameof(options)); - if (loggerOptions.FlushPeriod <= TimeSpan.Zero) + if (o.FlushPeriod <= TimeSpan.Zero) { - throw new ArgumentOutOfRangeException(nameof(loggerOptions.FlushPeriod), - $"{nameof(loggerOptions.FlushPeriod)} must be longer than zero."); + throw new ArgumentOutOfRangeException(nameof(o.FlushPeriod)); } - if (options.Value is FileLoggerOptions fileLoggerOptions) + _batchSize = o.BatchSize > 0 ? o.BatchSize : 100; + _queue = new BlockingCollection(new ConcurrentQueue()); + + if (o is FileLoggerOptions file) { - UseLocalTime = fileLoggerOptions.UseLocalTime; - UseMask = fileLoggerOptions.UseMask; - LoggerSettings = fileLoggerOptions.LoggerSettings; + UseLocalTime = file.UseLocalTime; + UseMask = file.UseMask; + LoggerSettings = file.LoggerSettings; } - _batchSize = loggerOptions.BatchSize > 0 ? loggerOptions.BatchSize : 100; - StartAsync(); + _worker = Task.Factory.StartNew( + ProcessLoop, + _cts.Token, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); } - protected DateTimeOffset CurrentDateTimeOffset => UseLocalTime ? DateTimeOffset.Now : DateTimeOffset.UtcNow; - protected DateTime CurrentDateTme => UseLocalTime ? DateTime.Now : DateTime.UtcNow; + protected bool UseLocalTime { get; } + public bool UseMask { get; } - protected bool UseLocalTime { get; set; } - - public bool IsStarted { get; set; } - public bool UseMask { get; set; } - public string Category { get; set; } = "General"; + protected DateTimeOffset NowOffset => + UseLocalTime ? DateTimeOffset.Now : DateTimeOffset.UtcNow; protected LoggerSettings LoggerSettings { @@ -63,338 +58,98 @@ public abstract class BatchingLoggerProvider : ILoggerProvider, IDisposable { if (_loggerSettings == null) { - _loggerSettings = new LoggerSettings(); - _loggerSettings.UseLocalTime = UseLocalTime; - _loggerSettings.UseMask = UseMask; - //TODO: Add the tokens and custom tokens + _loggerSettings = new LoggerSettings + { + UseLocalTime = UseLocalTime, + UseMask = UseMask + }; } return _loggerSettings; } - set => _loggerSettings = value; } - /// - /// Creates a new logger instance for the specified category name. - /// - /// The category name for messages produced by the logger. Cannot be null. - /// An instance that logs messages for the specified category. public ILogger CreateLogger(string categoryName) + => new BatchingLogger(this, categoryName, LoggerSettings); + + protected abstract Task WriteMessagesAsync( + IReadOnlyList messages, + CancellationToken token); + + internal string AddMessage(DateTimeOffset timestamp, string message, string category) { - Category = categoryName; - return new BatchingLogger(this, categoryName, LoggerSettings); + var log = CreateLogMessage(message, timestamp, category); + _queue.Add(log); + return log.Message; } - protected abstract Task WriteMessagesAsync(IEnumerable messages, CancellationToken token); - - private async Task ProcessLogQueueAsync() + private LogMessage CreateLogMessage(string message, DateTimeOffset ts, string category) { - var batchSize = _batchSize > 0 ? _batchSize : 100; - var batch = new List(batchSize); + if (LoggerSettings.UseMask) + { + SensitiveDataMasker sensitiveDataMasker = new SensitiveDataMasker(LoggerSettings); + message = sensitiveDataMasker.MaskSensitiveInformation(message); + } + + return new LogMessage + { + Message = message, + Timestamp = ts, + Category = category + }; + } + + private async void ProcessLoop() + { + var batch = new List(_batchSize); try { - var token = _cancellationTokenSource.Token; - while (await _channel.Reader.WaitToReadAsync(token).ConfigureAwait(false)) + foreach (var item in _queue.GetConsumingEnumerable(_cts.Token)) { + batch.Add(item); + + if (batch.Count < _batchSize) + { + continue; + } + + await WriteMessagesAsync(batch, _cts.Token).ConfigureAwait(false); batch.Clear(); + } - while (batch.Count < batchSize && _channel.Reader.TryRead(out var message)) - { - batch.Add(message); - } - - if (batch.Count > 0) - { - await WriteMessagesAsync(batch, token).ConfigureAwait(false); - } + if (batch.Count > 0) + { + await WriteMessagesAsync(batch, _cts.Token).ConfigureAwait(false); } } catch (OperationCanceledException) { // normal shutdown } - catch (Exception) + catch (Exception ex) { - throw; + // last-resort logging + Console.Error.WriteLine(ex); } } - protected async Task WriteStartMessage() - { - var message = LogHelper.GetStartupMessage(); - var token = _cancellationTokenSource?.Token ?? CancellationToken.None; - await WriteMessagesAsync(new List { CreateLoggerMessage(message, CurrentDateTimeOffset) }, token).ConfigureAwait(false); - } - - private LogMessage CreateLoggerMessage(string message, DateTimeOffset currentDateTimeOffset) - { - var result = new LogMessage() { Message = message, Timestamp = currentDateTimeOffset }; - - if (LoggerSettings != null && LoggerSettings.UseMask) - { - // Masking sensitive information - result.Message = MaskSensitiveInformation(result.Message); - } - - return result; - } - - /// - /// Masks sensitive information within the provided message string. - /// This method is virtual and can be overridden to customize masking behavior. - /// - /// The log message potentially containing sensitive information. - /// The masked log message. - protected virtual string MaskSensitiveInformation(string message) - { - if (string.IsNullOrEmpty(message)) - { - return message; - } - - if (LoggerSettings != null && LoggerSettings.UseDefaultMasking) - { - // Mask IP addresses - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b(?:\d{1,3}\.){3}\d{1,3}\b(?!\d)", - LoggerSettings.Mask); - - // Mask MAC addresses - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b", - LoggerSettings.Mask); - - // Mask emails - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", - LoggerSettings.Mask, - System.Text.RegularExpressions.RegexOptions.IgnoreCase); - - // Mask passwords - message = System.Text.RegularExpressions.Regex.Replace(message, - @"(?i)(password\s*[:= ]\s*|pwd\s*[:= ]\s*)[^\s]+", - $"password={LoggerSettings.Mask}"); - - // Mask credit card numbers - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b(?:\d{4}[ -]?){3}\d{4}\b", - LoggerSettings.Mask); - - // Mask social security numbers (SSN) and BSN (Dutch Citizen Service Number) - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b\d{3}-\d{2}-\d{4}\b|\b\d{9}\b", - LoggerSettings.Mask); // SSN (USA) - - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b\d{9}\b", - LoggerSettings.Mask); // BSN (Dutch) - - // Mask passwords (Dutch) - message = System.Text.RegularExpressions.Regex.Replace(message, - @"(?i)(wachtwoord\s*[:= ]\s*|ww\s*=\s*)[^\s]+", - $"wachtwoord={LoggerSettings.Mask}"); - - // Mask API keys/tokens - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b[A-Za-z0-9-_]{20,}\b", - LoggerSettings.Mask); - - // Mask phone numbers (generic and Dutch specific) - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b(\+?\d{1,4}[-.\s]?)?(\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}\b", - LoggerSettings.Mask); // Generic - - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b(\+31|0031|0|06)[-\s]?\d{8}\b", - LoggerSettings.Mask); // Dutch - - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b(\+32|0032|0|06)[-\s]?\d{8}\b", - LoggerSettings.Mask); // Belgium - - // Mask dates of birth (DOB) or other date formats - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b\d{2}[/-]\d{2}[/-]\d{4}\b", - LoggerSettings.Mask); - - // Mask Dutch postal codes - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b\d{4}\s?[A-Z]{2}\b", - LoggerSettings.Mask); - - // Mask IBAN/Bank account numbers (generic and Dutch specific) - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b[A-Z]{2}\d{2}[A-Z0-9]{1,30}\b", - LoggerSettings.Mask); // Generic for EU and other country IBANs - - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\bNL\d{2}[A-Z]{4}\d{10}\b", - LoggerSettings.Mask); // Dutch IBAN - - // Mask JWT Tokens - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b[A-Za-z0-9-_]{16,}\.[A-Za-z0-9-_]{16,}\.[A-Za-z0-9-_]{16,}\b", - LoggerSettings.Mask); - - // Mask URLs with sensitive query strings - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\bhttps?:\/\/[^\s?]+(\?[^\s]+?[\&=](password|key|token|wachtwoord|sleutel))[^&\s]*", - LoggerSettings.Mask, - System.Text.RegularExpressions.RegexOptions.IgnoreCase); - - // Mask license keys - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}\b", - LoggerSettings.Mask); - - // Mask public and private keys (e.g., PEM format) - message = System.Text.RegularExpressions.Regex.Replace(message, - @"-----BEGIN [A-Z ]+KEY-----[\s\S]+?-----END [A-Z ]+KEY-----", - LoggerSettings.Mask); - - // Mask Dutch KVK number (8 or 12 digits) - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b\d{8}|\d{12}\b", - LoggerSettings.Mask); - - // Mask Dutch BTW-nummer (VAT number) - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\bNL\d{9}B\d{2}\b", - LoggerSettings.Mask); - - // Mask Dutch driving license number (10-12 characters) - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b[A-Z0-9]{10,12}\b", - LoggerSettings.Mask); - - // Mask Dutch health insurance number (Zorgnummer) - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b\d{9}\b", - LoggerSettings.Mask); - - // Mask other Dutch Bank Account numbers (9-10 digits) - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b\d{9,10}\b", - LoggerSettings.Mask); - - // Mask Dutch Passport Numbers (9 alphanumeric characters) - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b[A-Z0-9]{9}\b", - LoggerSettings.Mask); - - // Mask Dutch Identification Document Numbers (varying formats) - message = System.Text.RegularExpressions.Regex.Replace(message, - @"\b[A-Z]{2}\d{6,7}\b", - LoggerSettings.Mask); - } - - // Mask custom keywords specified in LoggerSettings - if (LoggerSettings?.MaskedKeywords != null) - { - foreach (var keyword in LoggerSettings.MaskedKeywords) - { - if (string.IsNullOrWhiteSpace(keyword)) - { - continue; - } - message = message.Replace(keyword, LoggerSettings.Mask); - } - } - - return message; - } - - internal string AddMessage(DateTimeOffset timestamp, string message) - { - var result = CreateLoggerMessage(message, timestamp); - if (!_channel.Writer.TryWrite(result)) - { - _ = _channel.Writer.WriteAsync(result); - } - - return result.Message; - } - - private void StartAsync() - { - if (_cancellationTokenSource != null) - { - return; - } - - _cancellationTokenSource = new CancellationTokenSource(); - _outputTask = Task.Run(ProcessLogQueueAsync); - IsStarted = true; - } - - private async Task StopAsync() - { - if (_cancellationTokenSource == null) - { - return; - } - - try - { - _cancellationTokenSource.Cancel(); - _channel.Writer.Complete(); - - if (_outputTask != null) - { - await _outputTask.ConfigureAwait(false); - } - } - catch (OperationCanceledException) - { - // expected on shutdown - } - catch (AggregateException exception) when (exception.InnerExceptions.Count == 1 && exception.InnerExceptions[0] is TaskCanceledException) - { - // Do nothing - } - finally - { - try - { - _cancellationTokenSource.Dispose(); - } - catch - { - // Do nothing - } - - _cancellationTokenSource = null; - _outputTask = null; - IsStarted = false; - } - } + public void Dispose() { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (_isDisposed) + if (_disposed) { return; } - _isDisposed = true; + _disposed = true; - if (disposing) - { - try - { - StopAsync().GetAwaiter().GetResult(); - } - catch - { - // Do nothing - } - } + _cts.Cancel(); + _queue.CompleteAdding(); + + try { _worker.Wait(); } catch { } + _cts.Dispose(); + _queue.Dispose(); } -} \ No newline at end of file +} diff --git a/EonaCat.Logger/EonaCatCoreLogger/Internal/LogMessage.cs b/EonaCat.Logger/EonaCatCoreLogger/Internal/LogMessage.cs index fbf0f69..25dcb4e 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Internal/LogMessage.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Internal/LogMessage.cs @@ -8,4 +8,5 @@ public struct LogMessage { public DateTimeOffset Timestamp { get; set; } public string Message { get; set; } + public string Category { get; set; } } \ No newline at end of file diff --git a/EonaCat.Logger/EonaCatCoreLogger/Internal/SensitiveDataMasker.cs b/EonaCat.Logger/EonaCatCoreLogger/Internal/SensitiveDataMasker.cs new file mode 100644 index 0000000..dc256ac --- /dev/null +++ b/EonaCat.Logger/EonaCatCoreLogger/Internal/SensitiveDataMasker.cs @@ -0,0 +1,100 @@ +using global::EonaCat.Logger.Managers; +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace EonaCat.Logger.EonaCatCoreLogger.Internal +{ + internal class SensitiveDataMasker + { + internal LoggerSettings LoggerSettings { get; } + + internal SensitiveDataMasker(LoggerSettings settings) + { + LoggerSettings = settings; + } + + // Precompiled Regexes + private static readonly Regex IpRegex = new Regex(@"\b(?:\d{1,3}\.){3}\d{1,3}\b(?!\d)", RegexOptions.Compiled); + private static readonly Regex MacRegex = new Regex(@"\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b", RegexOptions.Compiled); + private static readonly Regex EmailRegex = new Regex(@"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex PasswordRegex = new Regex(@"(?i)(password\s*[:= ]\s*|pwd\s*[:= ]\s*)[^\s]+", RegexOptions.Compiled); + private static readonly Regex DutchPasswordRegex = new Regex(@"(?i)(wachtwoord\s*[:= ]\s*|ww\s*=\s*)[^\s]+", RegexOptions.Compiled); + private static readonly Regex CreditCardRegex = new Regex(@"\b(?:\d{4}[ -]?){3}\d{4}\b", RegexOptions.Compiled); + private static readonly Regex SsnBsnRegex = new Regex(@"\b\d{3}-\d{2}-\d{4}\b|\b\d{9}\b", RegexOptions.Compiled); + private static readonly Regex ApiKeyRegex = new Regex(@"\b[A-Za-z0-9-_]{20,}\b", RegexOptions.Compiled); + private static readonly Regex PhoneGenericRegex = new Regex(@"\b(\+?\d{1,4}[-.\s]?)?(\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}\b", RegexOptions.Compiled); + private static readonly Regex PhoneDutchRegex = new Regex(@"\b(\+31|0031|0|06)[-\s]?\d{8}\b", RegexOptions.Compiled); + private static readonly Regex PhoneBelgiumRegex = new Regex(@"\b(\+32|0032|0|06)[-\s]?\d{8}\b", RegexOptions.Compiled); + private static readonly Regex DobRegex = new Regex(@"\b\d{2}[/-]\d{2}[/-]\d{4}\b", RegexOptions.Compiled); + private static readonly Regex PostalCodeDutchRegex = new Regex(@"\b\d{4}\s?[A-Z]{2}\b", RegexOptions.Compiled); + private static readonly Regex IbanGenericRegex = new Regex(@"\b[A-Z]{2}\d{2}[A-Z0-9]{1,30}\b", RegexOptions.Compiled); + private static readonly Regex IbanDutchRegex = new Regex(@"\bNL\d{2}[A-Z]{4}\d{10}\b", RegexOptions.Compiled); + private static readonly Regex JwtRegex = new Regex(@"\b[A-Za-z0-9-_]{16,}\.[A-Za-z0-9-_]{16,}\.[A-Za-z0-9-_]{16,}\b", RegexOptions.Compiled); + private static readonly Regex UrlSensitiveRegex = new Regex(@"\bhttps?:\/\/[^\s?]+(\?[^\s]+?[\&=](password|key|token|wachtwoord|sleutel))[^&\s]*", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex LicenseKeyRegex = new Regex(@"\b[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}\b", RegexOptions.Compiled); + private static readonly Regex PemKeyRegex = new Regex(@"-----BEGIN [A-Z ]+KEY-----[\s\S]+?-----END [A-Z ]+KEY-----", RegexOptions.Compiled); + private static readonly Regex KvkRegex = new Regex(@"\b\d{8}|\d{12}\b", RegexOptions.Compiled); + private static readonly Regex BtwRegex = new Regex(@"\bNL\d{9}B\d{2}\b", RegexOptions.Compiled); + private static readonly Regex DutchDrivingLicenseRegex = new Regex(@"\b[A-Z0-9]{10,12}\b", RegexOptions.Compiled); + private static readonly Regex ZorgnummerRegex = new Regex(@"\b\d{9}\b", RegexOptions.Compiled); + private static readonly Regex DutchBankAccountRegex = new Regex(@"\b\d{9,10}\b", RegexOptions.Compiled); + private static readonly Regex DutchPassportRegex = new Regex(@"\b[A-Z0-9]{9}\b", RegexOptions.Compiled); + private static readonly Regex DutchIdDocRegex = new Regex(@"\b[A-Z]{2}\d{6,7}\b", RegexOptions.Compiled); + + internal virtual string MaskSensitiveInformation(string message) + { + if (string.IsNullOrEmpty(message)) + { + return message; + } + + if (LoggerSettings != null && LoggerSettings.UseDefaultMasking) + { + string mask = LoggerSettings.Mask ?? "***"; + + message = IpRegex.Replace(message, mask); + message = MacRegex.Replace(message, mask); + message = EmailRegex.Replace(message, mask); + message = PasswordRegex.Replace(message, $"password={mask}"); + message = DutchPasswordRegex.Replace(message, $"wachtwoord={mask}"); + message = CreditCardRegex.Replace(message, mask); + message = SsnBsnRegex.Replace(message, mask); + message = ApiKeyRegex.Replace(message, mask); + message = PhoneGenericRegex.Replace(message, mask); + message = PhoneDutchRegex.Replace(message, mask); + message = PhoneBelgiumRegex.Replace(message, mask); + message = DobRegex.Replace(message, mask); + message = PostalCodeDutchRegex.Replace(message, mask); + message = IbanGenericRegex.Replace(message, mask); + message = IbanDutchRegex.Replace(message, mask); + message = JwtRegex.Replace(message, mask); + message = UrlSensitiveRegex.Replace(message, mask); + message = LicenseKeyRegex.Replace(message, mask); + message = PemKeyRegex.Replace(message, mask); + message = KvkRegex.Replace(message, mask); + message = BtwRegex.Replace(message, mask); + message = DutchDrivingLicenseRegex.Replace(message, mask); + message = ZorgnummerRegex.Replace(message, mask); + message = DutchBankAccountRegex.Replace(message, mask); + message = DutchPassportRegex.Replace(message, mask); + message = DutchIdDocRegex.Replace(message, mask); + } + + // Mask custom keywords + if (LoggerSettings?.MaskedKeywords != null) + { + foreach (var keyword in LoggerSettings.MaskedKeywords) + { + if (!string.IsNullOrWhiteSpace(keyword)) + { + message = message.Replace(keyword, LoggerSettings.Mask); + } + } + } + + return message; + } + } +} diff --git a/EonaCat.Logger/EonaCatCoreLogger/JsonFileLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/JsonFileLogger.cs index 07f8391..9b809c4 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/JsonFileLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/JsonFileLogger.cs @@ -6,34 +6,34 @@ using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; +using System.Threading.Tasks; namespace EonaCat.Logger.EonaCatCoreLogger { 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 readonly BlockingCollection _queue; - private readonly Thread _workerThread; - private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + private readonly CancellationTokenSource _cts = new(); + private readonly Task _workerTask; + + private const int MaxQueueSize = 10_000; public bool IncludeCorrelationId { get; set; } public event EventHandler OnException; public JsonFileLogger(string categoryName, JsonFileLoggerOptions options) { - _categoryName = categoryName; - _options = options; + _categoryName = categoryName ?? throw new ArgumentNullException(nameof(categoryName)); + _options = options ?? throw new ArgumentNullException(nameof(options)); IncludeCorrelationId = options.IncludeCorrelationId; _filePath = Path.Combine(_options.LogDirectory, _options.FileName); - _queue = new BlockingCollection(10000); // bounded queue + _queue = new BlockingCollection(MaxQueueSize); try { @@ -44,9 +44,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger OnException?.Invoke(this, ex); } - // Start background thread - _workerThread = new Thread(ProcessQueue) { IsBackground = true }; - _workerThread.Start(); + _workerTask = Task.Factory.StartNew(ProcessQueue, TaskCreationOptions.LongRunning); } public void SetContext(string key, string value) => _context.Set(key, value); @@ -71,14 +69,14 @@ namespace EonaCat.Logger.EonaCatCoreLogger _context.Set("CorrelationId", correlationId); } - var logObject = new Dictionary + var logObject = new Dictionary(6) { - { "timestamp", DateTime.UtcNow }, - { "level", logLevel.ToString() }, - { "category", _categoryName }, - { "message", formatter(state, exception) }, - { "exception", exception?.ToString() }, - { "eventId", eventId.Id } + ["timestamp"] = DateTime.UtcNow, + ["level"] = logLevel.ToString(), + ["category"] = _categoryName, + ["message"] = formatter(state, exception), + ["exception"] = exception?.ToString(), + ["eventId"] = eventId.Id }; var contextData = _context.GetAll(); @@ -89,10 +87,10 @@ namespace EonaCat.Logger.EonaCatCoreLogger string json = JsonHelper.ToJson(logObject); - // Enqueue, drop oldest if full + // Drop oldest if full while (!_queue.TryAdd(json)) { - _queue.TryTake(out _); // drop oldest + _queue.TryTake(out _); } } catch (Exception ex) @@ -117,14 +115,24 @@ namespace EonaCat.Logger.EonaCatCoreLogger } } } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + // normal shutdown + } + catch (Exception ex) + { + OnException?.Invoke(this, ex); + } } + /// + /// Flush all pending logs to file. + /// public void Flush() { while (_queue.Count > 0) { - Thread.Sleep(100); + Thread.Sleep(50); } } @@ -135,9 +143,12 @@ namespace EonaCat.Logger.EonaCatCoreLogger try { - _workerThread.Join(); + _workerTask.Wait(); + } + catch + { + // Do nothing } - catch { /* ignore */ } _queue.Dispose(); _cts.Dispose(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/LogContext.cs b/EonaCat.Logger/EonaCatCoreLogger/LogContext.cs index 85c186f..94a3670 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/LogContext.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/LogContext.cs @@ -5,11 +5,13 @@ 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. + internal sealed class LoggerScopedContext { private static readonly AsyncLocal>> _scopes = new(); - // Ensure there is always a scope to write into private void EnsureScope() { _scopes.Value ??= new Stack>(); @@ -35,12 +37,16 @@ namespace EonaCat.Logger.EonaCatCoreLogger public string Get(string key) { if (_scopes.Value == null) + { return null; + } foreach (var scope in _scopes.Value) { if (scope.TryGetValue(key, out var value)) + { return value; + } } return null; @@ -56,7 +62,9 @@ namespace EonaCat.Logger.EonaCatCoreLogger foreach (var kv in scope) { if (!result.ContainsKey(kv.Key)) + { result[kv.Key] = kv.Value; + } } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/SlackLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/SlackLogger.cs index 8c52379..6659690 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/SlackLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/SlackLogger.cs @@ -8,11 +8,11 @@ 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. +// 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 sealed class SlackLogger : ILogger, IDisposable { private readonly string _categoryName; @@ -23,25 +23,28 @@ namespace EonaCat.Logger.EonaCatCoreLogger private readonly CancellationTokenSource _cts = new(); private readonly Task _processingTask; + private const int MaxQueueSize = 1000; + public bool IncludeCorrelationId { get; set; } public event EventHandler OnException; public SlackLogger(string categoryName, SlackLoggerOptions options) { - _categoryName = categoryName; - _options = options; + _categoryName = categoryName ?? throw new ArgumentNullException(nameof(categoryName)); + _options = options ?? throw new ArgumentNullException(nameof(options)); IncludeCorrelationId = options.IncludeCorrelationId; _httpClient = new HttpClient(); + // Bounded channel to prevent unbounded memory growth - _logChannel = Channel.CreateBounded(new BoundedChannelOptions(1000) + _logChannel = Channel.CreateBounded(new BoundedChannelOptions(MaxQueueSize) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false }); - _processingTask = Task.Run(() => ProcessLogQueueAsync(_cts.Token)); + _processingTask = Task.Run(() => ProcessLogQueueAsync(_cts.Token), _cts.Token); } public IDisposable BeginScope(TState state) => null; @@ -61,40 +64,37 @@ namespace EonaCat.Logger.EonaCatCoreLogger try { - var message = formatter(state, exception); - if (IncludeCorrelationId) { var correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); _context.Set("CorrelationId", correlationId); } - var logParts = new List - { - $"*[{DateTime.UtcNow:u}]*", - $"*[{logLevel}]*", - $"*[{_categoryName}]*", - $"Message: {message}" - }; + var message = formatter(state, exception); + + // Build Slack message + var sb = new StringBuilder(256); + sb.AppendLine($"*[{DateTime.UtcNow:u}]*"); + sb.AppendLine($"*[{logLevel}]*"); + sb.AppendLine($"*[{_categoryName}]*"); + sb.AppendLine($"Message: {message}"); foreach (var kvp in _context.GetAll()) { - logParts.Add($"_{kvp.Key}_: {kvp.Value}"); + sb.AppendLine($"_{kvp.Key}_: {kvp.Value}"); } if (exception != null) { - logParts.Add($"Exception: {exception}"); + sb.AppendLine($"Exception: {exception}"); } - string fullMessage = string.Join("\n", logParts); - - // non-blocking, drops oldest if full - _logChannel.Writer.TryWrite(fullMessage); + // Drops oldest if full + _logChannel.Writer.TryWrite(sb.ToString()); } - catch (Exception e) + catch (Exception ex) { - OnException?.Invoke(this, e); + OnException?.Invoke(this, ex); } } @@ -107,8 +107,14 @@ namespace EonaCat.Logger.EonaCatCoreLogger try { var payload = new { text = message }; - var content = new StringContent(JsonHelper.ToJson(payload), Encoding.UTF8, "application/json"); - await _httpClient.PostAsync(_options.WebhookUrl, content, token); + using var content = new StringContent(JsonHelper.ToJson(payload), Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(_options.WebhookUrl, content, token).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + OnException?.Invoke(this, new Exception($"Slack webhook failed: {response.StatusCode}")); + } } catch (Exception ex) { @@ -116,7 +122,14 @@ namespace EonaCat.Logger.EonaCatCoreLogger } } } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + // normal shutdown + } + catch (Exception ex) + { + OnException?.Invoke(this, ex); + } } public void Dispose() @@ -128,7 +141,10 @@ namespace EonaCat.Logger.EonaCatCoreLogger { _processingTask.Wait(); } - catch { /* ignore */ } + catch + { + // Do nothing + } _cts.Dispose(); _httpClient.Dispose(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/TcpLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/TcpLogger.cs index 5496c85..5bb4465 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/TcpLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/TcpLogger.cs @@ -19,31 +19,33 @@ namespace EonaCat.Logger.EonaCatCoreLogger private readonly CancellationTokenSource _cts = new(); private readonly Task _processingTask; + private const int MaxQueueSize = 1000; + public bool IncludeCorrelationId { get; set; } public event EventHandler OnException; public TcpLogger(string categoryName, TcpLoggerOptions options) { - _categoryName = categoryName; - _options = options; + _categoryName = categoryName ?? throw new ArgumentNullException(nameof(categoryName)); + _options = options ?? throw new ArgumentNullException(nameof(options)); IncludeCorrelationId = options.IncludeCorrelationId; - // Bounded channel to prevent unbounded memory growth - _logChannel = Channel.CreateBounded(new BoundedChannelOptions(1000) + _logChannel = Channel.CreateBounded(new BoundedChannelOptions(MaxQueueSize) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false }); - _processingTask = Task.Run(() => ProcessLogQueueAsync(_cts.Token)); + _processingTask = Task.Run(() => ProcessLogQueueAsync(_cts.Token), _cts.Token); } + public IDisposable BeginScope(TState state) => _context.BeginScope(state); + public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled; + 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) => _context.BeginScope(state); - public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) @@ -55,76 +57,92 @@ namespace EonaCat.Logger.EonaCatCoreLogger try { - string message = formatter(state, exception); - if (IncludeCorrelationId) { var correlationId = _context.Get("CorrelationId") ?? Guid.NewGuid().ToString(); _context.Set("CorrelationId", correlationId); } - var logParts = new List - { - $"[{DateTime.UtcNow:u}]", - $"[{logLevel}]", - $"[{_categoryName}]", - $"Message: {message}" - }; + string message = formatter(state, exception); + + // Build message + var sb = new StringBuilder(256); + sb.Append('[').Append(DateTime.UtcNow.ToString("u")).Append("] | "); + sb.Append('[').Append(logLevel).Append("] | "); + sb.Append('[').Append(_categoryName).Append("] | "); + sb.Append("Message: ").Append(message); var contextData = _context.GetAll(); if (contextData.Count > 0) { - logParts.Add("Context:"); + sb.Append(" | Context: "); foreach (var kvp in contextData) { - logParts.Add($"{kvp.Key}: {kvp.Value}"); + sb.Append(kvp.Key).Append("=").Append(kvp.Value).Append("; "); } } if (exception != null) { - logParts.Add($"Exception: {exception}"); + sb.Append(" | Exception: ").Append(exception); } - string fullLog = string.Join(" | ", logParts); - - // Non-blocking, drop oldest if full - _logChannel.Writer.TryWrite(fullLog); + _logChannel.Writer.TryWrite(sb.ToString()); } - catch (Exception e) + catch (Exception ex) { - OnException?.Invoke(this, e); + OnException?.Invoke(this, ex); } } private async Task ProcessLogQueueAsync(CancellationToken token) { + TcpClient client = null; + StreamWriter writer = null; + try { - // Keep one TCP connection alive if possible - using var client = new TcpClient(); - await client.ConnectAsync(_options.Host, _options.Port); + client = new TcpClient(); + await client.ConnectAsync(_options.Host, _options.Port).ConfigureAwait(false); - using var stream = client.GetStream(); - using var writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true }; + writer = new StreamWriter(client.GetStream(), Encoding.UTF8) { AutoFlush = true }; await foreach (var log in _logChannel.Reader.ReadAllAsync(token)) { try { - await writer.WriteLineAsync(log); + await writer.WriteLineAsync(log).ConfigureAwait(false); } - catch (Exception e) + catch (IOException ioEx) { - OnException?.Invoke(this, e); + OnException?.Invoke(this, ioEx); + + // Attempt to reconnect + writer.Dispose(); + client.Dispose(); + client = new TcpClient(); + await client.ConnectAsync(_options.Host, _options.Port).ConfigureAwait(false); + writer = new StreamWriter(client.GetStream(), Encoding.UTF8) { AutoFlush = true }; + } + catch (Exception ex) + { + OnException?.Invoke(this, ex); } } } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + // normal shutdown + } catch (Exception ex) { OnException?.Invoke(this, ex); } + finally + { + writer?.Dispose(); + client?.Dispose(); + } } public void Dispose() @@ -132,11 +150,14 @@ namespace EonaCat.Logger.EonaCatCoreLogger _cts.Cancel(); _logChannel.Writer.Complete(); - try + try { - _processingTask.Wait(); + _processingTask.Wait(); + } + catch + { + // Do nothing } - catch { /* ignore */ } _cts.Dispose(); } diff --git a/EonaCat.Logger/EonaCatCoreLogger/TelegramLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/TelegramLogger.cs index efdce79..05e5ec8 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/TelegramLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/TelegramLogger.cs @@ -1,11 +1,14 @@ 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; + +// 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 { @@ -15,27 +18,28 @@ namespace EonaCat.Logger.EonaCatCoreLogger private readonly TelegramLoggerOptions _options; private readonly HttpClient _httpClient; private readonly Channel _logChannel; - private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + private readonly CancellationTokenSource _cts = new(); private readonly Task _processingTask; + private const int MaxQueueSize = 1000; + public event EventHandler OnException; public TelegramLogger(string categoryName, TelegramLoggerOptions options) { - _categoryName = categoryName; - _options = options; + _categoryName = categoryName ?? throw new ArgumentNullException(nameof(categoryName)); + _options = options ?? throw new ArgumentNullException(nameof(options)); _httpClient = new HttpClient(); - // Bounded channel to prevent memory leaks - _logChannel = Channel.CreateBounded(new BoundedChannelOptions(1000) + _logChannel = Channel.CreateBounded(new BoundedChannelOptions(MaxQueueSize) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false }); - _processingTask = Task.Run(() => ProcessLogQueueAsync(_cts.Token)); + _processingTask = Task.Run(() => ProcessLogQueueAsync(_cts.Token), _cts.Token); } public IDisposable BeginScope(TState state) => null; @@ -44,21 +48,22 @@ namespace EonaCat.Logger.EonaCatCoreLogger public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) { - if (!IsEnabled(logLevel) || formatter == null) - { - return; - } + if (!IsEnabled(logLevel) || formatter == null) return; try { - var message = $"[{DateTime.UtcNow:u}] [{logLevel}] {_categoryName}: {formatter(state, exception)}"; + var sb = new StringBuilder(256); + sb.Append('[').Append(DateTime.UtcNow.ToString("u")).Append("] "); + sb.Append('[').Append(logLevel).Append("] "); + sb.Append(_categoryName).Append(": "); + sb.Append(formatter(state, exception)); + if (exception != null) { - message += $"\nException: {exception}"; + sb.AppendLine().Append("Exception: ").Append(exception); } - // Non-blocking, drop oldest if full - _logChannel.Writer.TryWrite(message); + _logChannel.Writer.TryWrite(sb.ToString()); } catch (Exception ex) { @@ -70,15 +75,22 @@ namespace EonaCat.Logger.EonaCatCoreLogger { try { + var url = $"https://api.telegram.org/bot{_options.BotToken}/sendMessage"; + await foreach (var message in _logChannel.Reader.ReadAllAsync(token)) { try { - 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"); + using var content = new StringContent(JsonHelper.ToJson(payload), Encoding.UTF8, "application/json"); - await _httpClient.PostAsync(url, content, token); + var response = await _httpClient.PostAsync(url, content, token).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + OnException?.Invoke(this, new Exception($"Telegram API failed: {response.StatusCode} {error}")); + } } catch (Exception ex) { @@ -86,7 +98,10 @@ namespace EonaCat.Logger.EonaCatCoreLogger } } } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + // normal shutdown + } } public void Dispose() @@ -96,9 +111,12 @@ namespace EonaCat.Logger.EonaCatCoreLogger try { - _processingTask.Wait(); + _processingTask.Wait(); + } + catch + { + // Do nothing } - catch { /* ignore */ } _cts.Dispose(); _httpClient.Dispose(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/UdpLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/UdpLogger.cs index 6a4bf23..62ad2c4 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/UdpLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/UdpLogger.cs @@ -33,7 +33,6 @@ namespace EonaCat.Logger.EonaCatCoreLogger _udpClient = new UdpClient(); - // Bounded channel to avoid unbounded memory growth _logChannel = Channel.CreateBounded(new BoundedChannelOptions(1000) { FullMode = BoundedChannelFullMode.DropOldest, @@ -93,7 +92,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger string fullMessage = string.Join(" | ", logParts); - // Non-blocking, drop oldest if full + // Drop oldest if full _logChannel.Writer.TryWrite(fullMessage); } catch (Exception ex) @@ -119,7 +118,10 @@ namespace EonaCat.Logger.EonaCatCoreLogger } } } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + // normal shutdown + } catch (Exception ex) { OnException?.Invoke(this, ex); @@ -135,7 +137,10 @@ namespace EonaCat.Logger.EonaCatCoreLogger { _processingTask.Wait(); } - catch { /* ignore */ } + catch + { + // Do nothing + } _udpClient.Dispose(); _cts.Dispose(); diff --git a/EonaCat.Logger/EonaCatCoreLogger/XmlFileLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/XmlFileLogger.cs index 00fb1ea..77e762a 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/XmlFileLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/XmlFileLogger.cs @@ -1,21 +1,20 @@ using Microsoft.Extensions.Logging; using System; using System.IO; +using System.Text; using System.Threading; +using System.Threading.Tasks; using System.Xml.Linq; namespace EonaCat.Logger.EonaCatCoreLogger { 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 readonly SemaphoreSlim _fileLock = new SemaphoreSlim(1, 1); + private readonly SemaphoreSlim _fileLock = new(1, 1); public bool IncludeCorrelationId { get; set; } public event EventHandler OnException; @@ -51,6 +50,11 @@ namespace EonaCat.Logger.EonaCatCoreLogger return; } + _ = LogAsync(logLevel, state, exception, formatter); + } + + private async Task LogAsync(LogLevel logLevel, TState state, Exception exception, Func formatter) + { try { if (IncludeCorrelationId) @@ -66,14 +70,15 @@ namespace EonaCat.Logger.EonaCatCoreLogger new XElement("message", formatter(state, exception)) ); - var context = _context.GetAll(); - if (context.Count > 0) + var contextData = _context.GetAll(); + if (contextData.Count > 0) { var contextElement = new XElement("context"); - foreach (var item in context) + foreach (var kv in contextData) { - contextElement.Add(new XElement(item.Key, item.Value)); + contextElement.Add(new XElement(kv.Key, kv.Value)); } + logElement.Add(contextElement); } @@ -82,10 +87,17 @@ namespace EonaCat.Logger.EonaCatCoreLogger logElement.Add(new XElement("exception", exception.ToString())); } - _fileLock.Wait(); + string logString = logElement + Environment.NewLine; + + await _fileLock.WaitAsync().ConfigureAwait(false); + try { - File.AppendAllText(_filePath, logElement + Environment.NewLine); + using (var fs = new FileStream(_filePath, FileMode.Append, FileAccess.Write, FileShare.Read, 4096, true)) + using (var sw = new StreamWriter(fs, Encoding.UTF8)) + { + await sw.WriteAsync(logString).ConfigureAwait(false); + } } finally { @@ -98,9 +110,6 @@ namespace EonaCat.Logger.EonaCatCoreLogger } } - public void Dispose() - { - _fileLock.Dispose(); - } + public void Dispose() => _fileLock.Dispose(); } } diff --git a/EonaCat.Logger/Managers/LogHelper.cs b/EonaCat.Logger/Managers/LogHelper.cs index 6f50d89..27250b7 100644 --- a/EonaCat.Logger/Managers/LogHelper.cs +++ b/EonaCat.Logger/Managers/LogHelper.cs @@ -133,12 +133,14 @@ public static class LogHelper string category = null) { if (string.IsNullOrWhiteSpace(message)) + { return message; + } var ctx = new HeaderContext { Timestamp = dateTime, - TimestampFormat = settings?.TimestampFormat ?? "yyyy-MM-dd HH:mm:ss", + TimestampFormat = settings?.TimestampFormat ?? "yyyy-MM-dd HH:mm:ss.fff", HostName = HostName, Category = category ?? "General", LogType = logType, @@ -160,7 +162,9 @@ public static class LogHelper } if (!string.IsNullOrEmpty(header)) + { header += " "; + } } return header + message; diff --git a/EonaCat.Logger/Managers/LoggerSettings.cs b/EonaCat.Logger/Managers/LoggerSettings.cs index 75799df..d9415ea 100644 --- a/EonaCat.Logger/Managers/LoggerSettings.cs +++ b/EonaCat.Logger/Managers/LoggerSettings.cs @@ -25,7 +25,7 @@ public class LoggerSettings private FileLoggerOptions _fileLoggerOptions; private string _headerFormat = "{ts} {tz} {host} {category} {thread} {logtype}"; - private string _timestampFormat = "yyyy-MM-dd HH:mm:ss"; + private string _timestampFormat = "yyyy-MM-dd HH:mm:ss.fff"; /// /// Determines if we need to use the local time in the logging or UTC (default:false)