This commit is contained in:
2026-02-02 20:15:22 +01:00
parent 4b69b217e0
commit 57d398a003
31 changed files with 1068 additions and 985 deletions

View File

@@ -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";
}
}

View File

@@ -9,17 +9,17 @@ using System.Threading.Tasks;
namespace EonaCat.Logger.EonaCatCoreLogger
{
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.
public sealed class BatchingDatabaseLogger : ILogger, IDisposable
{
private readonly string _categoryName;
private readonly BatchingDatabaseLoggerOptions _options;
private readonly LoggerScopedContext _context = new();
private readonly BlockingCollection<LogEntry> _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<LogEntry>(new ConcurrentQueue<LogEntry>(), MaxQueueSize);
@@ -43,26 +43,19 @@ namespace EonaCat.Logger.EonaCatCoreLogger
public bool IsEnabled(LogLevel logLevel) => !_disposed && _options.IsEnabled;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception exception,
Func<TState, Exception, string> formatter)
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> 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<LogEntry> batch)
private async Task FlushBatchAsync(List<LogEntry> 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<LogEntry> batch)
private async Task InsertBatchAsync(List<LogEntry> batch, CancellationToken token)
{
using var connection = _options.DbProviderFactory.CreateConnection()
?? throw new InvalidOperationException("Failed to create database connection.");
using (var connection = _options.DbProviderFactory.CreateConnection())
{
if (connection == null)
{
throw new InvalidOperationException("Failed to create database connection.");
}
connection.ConnectionString = _options.ConnectionString;
await connection.OpenAsync(_cts.Token);
await connection.OpenAsync(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)
{
using var command = connection.CreateCommand();
command.CommandText = _options.InsertCommand;
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;
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));
await command.ExecuteNonQueryAsync(token);
}
}
await command.ExecuteNonQueryAsync(_cts.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; }
}
}
}

View File

@@ -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
{
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.
public sealed class DatabaseLogger : ILogger, IDisposable
{
private readonly string _categoryName;
private readonly DatabaseLoggerOptions _options;
private readonly LoggerScopedContext _context = new();
private readonly ConcurrentQueue<LogEntry> _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>(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<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception exception,
Func<TState, Exception, string> formatter)
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> 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)
{
// Expected on shutdown
// Do nothing
}
await FlushBufferAsync(); // final drain
await FlushBufferAsync();
}
private async Task FlushBufferAsync()
@@ -120,13 +106,19 @@ namespace EonaCat.Logger.EonaCatCoreLogger
try
{
var batch = new List<LogEntry>();
while (_queue.TryDequeue(out var entry))
{
try
{
using var connection =
_options.DbProviderFactory.CreateConnection();
batch.Add(entry);
}
if (batch.Count == 0)
{
return;
}
using (var connection = _options.DbProviderFactory.CreateConnection())
{
if (connection == null)
{
throw new InvalidOperationException("Failed to create DB connection.");
@@ -135,34 +127,54 @@ namespace EonaCat.Logger.EonaCatCoreLogger
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);
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
{
_flushTask?.Wait();
}
catch (AggregateException) { }
catch
{
// Do nothing
}
_cts.Dispose();
}
}

View File

@@ -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<string> _messageQueue = new ConcurrentQueue<string>();
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<Exception> 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<string>
{
$"**[{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
{
@@ -106,12 +107,14 @@ namespace EonaCat.Logger.EonaCatCoreLogger
}
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,12 +126,20 @@ namespace EonaCat.Logger.EonaCatCoreLogger
try
{
var batchBuilder = new StringBuilder();
while (_messageQueue.TryDequeue(out var message))
{
try
batchBuilder.AppendLine(message);
}
if (batchBuilder.Length == 0)
{
var payload = new { content = message };
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);
@@ -142,8 +153,6 @@ namespace EonaCat.Logger.EonaCatCoreLogger
{
OnException?.Invoke(this, ex);
}
}
}
finally
{
_flushLock.Release();
@@ -152,8 +161,11 @@ namespace EonaCat.Logger.EonaCatCoreLogger
public void Dispose()
{
if (_cts != null && !_cts.IsCancellationRequested)
if (_cts == null)
{
return;
}
_cts.Cancel();
try
{
@@ -163,24 +175,10 @@ namespace EonaCat.Logger.EonaCatCoreLogger
{
ae.Handle(e => e is OperationCanceledException);
}
_cts.Dispose();
_cts = null;
}
}
public void Shutdown()
{
_cts?.Cancel();
try
{
_flushTask?.Wait();
}
catch (AggregateException ae)
{
ae.Handle(e => e is OperationCanceledException);
}
_flushLock.Dispose();
_cts.Dispose();
_httpClient.Dispose();
_flushLock.Dispose();
}
}
}

View File

@@ -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<string> _buffer = new();
private readonly object _lock = new();
private readonly ConcurrentQueue<string> _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>(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) { }
await FlushBufferAsync(); // flush remaining logs on shutdown
}
private async Task FlushBufferAsync()
catch (OperationCanceledException)
{
List<string> toSend;
// Do nothing
}
lock (_lock)
await FlushBufferAsync(token);
}
private async Task FlushBufferAsync(CancellationToken token)
{
if (_buffer.Count == 0)
if (_queue.IsEmpty)
{
return;
}
toSend = new List<string>(_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<string, string>
{
{ "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();
try
{
_flushTask.Wait();
}
catch
{
// Do nothing
}
_cts.Dispose();
_httpClient.Dispose();
}

View File

@@ -4,8 +4,24 @@ using System;
namespace EonaCat.Logger.EonaCatCoreLogger.Extensions
{
/// <summary>
/// Provides extension methods for registering a batching database logger with an <see cref="ILoggingBuilder"/>.
/// </summary>
/// <remarks>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.</remarks>
public static class BatchingDatabaseLoggerFactoryExtensions
{
/// <summary>
/// Adds a batching database logger to the logging builder, allowing log messages to be written to a database in
/// batches.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="builder">The logging builder to which the batching database logger will be added.</param>
/// <param name="configure">An action to configure the batching database logger options. Cannot be null.</param>
/// <returns>The same instance of <see cref="ILoggingBuilder"/> for chaining.</returns>
public static ILoggingBuilder AddEonaCatBatchingDatabaseLogger(this ILoggingBuilder builder, Action<BatchingDatabaseLoggerOptions> configure)
{
var options = new BatchingDatabaseLoggerOptions();

View File

@@ -4,8 +4,23 @@ using System;
namespace EonaCat.Logger.EonaCatCoreLogger.Extensions
{
/// <summary>
/// Provides extension methods for registering the EonaCat database logger with an <see cref="ILoggingBuilder"/>.
/// </summary>
/// <remarks>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.</remarks>
public static class DatabaseLoggerFactoryExtensions
{
/// <summary>
/// Adds a database logger provider to the logging builder, allowing log messages to be written to a database.
/// </summary>
/// <remarks>Call this method to enable logging to a database using custom configuration. This
/// method registers a singleton <see cref="ILoggerProvider"/> that writes log entries to the configured
/// database.</remarks>
/// <param name="builder">The logging builder to which the database logger provider will be added.</param>
/// <param name="configure">An action to configure the database logger options. This parameter cannot be null.</param>
/// <returns>The same instance of <see cref="ILoggingBuilder"/> for chaining.</returns>
public static ILoggingBuilder AddEonaCatDatabaseLogger(this ILoggingBuilder builder, Action<DatabaseLoggerOptions> configure)
{
var options = new DatabaseLoggerOptions();

View File

@@ -4,8 +4,23 @@ using System;
namespace EonaCat.Logger.EonaCatCoreLogger.Extensions
{
/// <summary>
/// Provides extension methods for registering a Discord logger with an <see cref="ILoggingBuilder"/>.
/// </summary>
/// <remarks>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.</remarks>
public static class DiscordLoggerFactoryExtensions
{
/// <summary>
/// Adds a logger that sends log messages to Discord using the EonaCat Discord Logger provider.
/// </summary>
/// <remarks>Call this method to enable logging to a Discord channel via the EonaCat Discord
/// Logger. The <paramref name="configure"/> delegate allows customization of logger options such as webhook URL
/// and log level filtering.</remarks>
/// <param name="builder">The logging builder to which the Discord logger provider will be added.</param>
/// <param name="configure">A delegate to configure the options for the Discord logger. Cannot be null.</param>
/// <returns>The same instance of <see cref="ILoggingBuilder"/> for chaining.</returns>
public static ILoggingBuilder AddEonaCatDiscordLogger(this ILoggingBuilder builder, Action<DiscordLoggerOptions> configure)
{
var options = new DiscordLoggerOptions();

View File

@@ -7,8 +7,20 @@ using System;
namespace EonaCat.Logger.EonaCatCoreLogger.Extensions
{
/// <summary>
/// Provides extension methods for configuring ElasticSearch logging for an <see cref="ILoggingBuilder"/> instance.
/// </summary>
public static class ElasticSearchLoggerFactoryExtensions
{
/// <summary>
/// Adds an ElasticSearch-based logger to the logging builder, allowing log messages to be sent to an
/// ElasticSearch instance.
/// </summary>
/// <remarks>Call this method during application startup to enable logging to ElasticSearch. The
/// provided configuration delegate allows customization of connection settings and logging behavior.</remarks>
/// <param name="builder">The logging builder to which the ElasticSearch logger will be added.</param>
/// <param name="configure">A delegate to configure the options for the ElasticSearch logger. Cannot be null.</param>
/// <returns>The same instance of <see cref="ILoggingBuilder"/> for chaining.</returns>
public static ILoggingBuilder AddEonaCatElasticSearchLogger(this ILoggingBuilder builder, Action<ElasticSearchLoggerOptions> configure)
{
var options = new ElasticSearchLoggerOptions();

View File

@@ -11,6 +11,13 @@ namespace EonaCat.Logger.EonaCatCoreLogger.Extensions;
/// </summary>
public static class FileLoggerFactoryExtensions
{
/// <summary>
/// Adds a file-based logger provider to the logging builder.
/// </summary>
/// <remarks>This method registers a singleton <see cref="ILoggerProvider"/> that writes log messages to
/// files. Call this method during logging configuration to enable file logging in the application.</remarks>
/// <param name="builder">The logging builder to which the file logger provider will be added.</param>
/// <returns>The same instance of <see cref="ILoggingBuilder"/> for chaining.</returns>
private static ILoggingBuilder AddEonaCatFileLogger(this ILoggingBuilder builder)
{
builder.Services.AddSingleton<ILoggerProvider, FileLoggerProvider>();

View File

@@ -7,8 +7,24 @@ using System;
namespace EonaCat.Logger.EonaCatCoreLogger.Extensions
{
/// <summary>
/// Provides extension methods for registering the EonaCat HTTP logger with an <see cref="ILoggingBuilder"/>.
/// </summary>
/// <remarks>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.</remarks>
public static class HttpLoggerFactoryExtensions
{
/// <summary>
/// Adds an HTTP logger provider to the logging builder, allowing log messages to be sent over HTTP with
/// configurable options.
/// </summary>
/// <remarks>Use this method to enable logging to an HTTP endpoint by configuring the <see
/// cref="HttpLoggerOptions"/>. This method should be called as part of the logging setup in your application's
/// dependency injection configuration.</remarks>
/// <param name="builder">The logging builder to which the HTTP logger provider will be added.</param>
/// <param name="configure">An action to configure the HTTP logger options. This parameter cannot be null.</param>
/// <returns>The same instance of <see cref="ILoggingBuilder"/> for chaining.</returns>
public static ILoggingBuilder AddEonaCatHttpLogger(this ILoggingBuilder builder, Action<HttpLoggerOptions> configure)
{
var options = new HttpLoggerOptions();

View File

@@ -7,8 +7,23 @@ using System;
namespace EonaCat.Logger.EonaCatCoreLogger.Extensions
{
/// <summary>
/// Provides extension methods for registering a JSON file logger with an <see cref="ILoggingBuilder"/>.
/// </summary>
/// <remarks>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.</remarks>
public static class JsonFileLoggerFactoryExtensions
{
/// <summary>
/// Adds a JSON file logger to the logging builder with the specified configuration options.
/// </summary>
/// <remarks>This method registers a singleton <see cref="ILoggerProvider"/> that writes log
/// entries to a JSON file using the specified options. Call this method during application startup to enable
/// JSON file logging.</remarks>
/// <param name="builder">The logging builder to which the JSON file logger will be added.</param>
/// <param name="configure">An action to configure the options for the JSON file logger. Cannot be null.</param>
/// <returns>The same instance of <see cref="ILoggingBuilder"/> for chaining.</returns>
public static ILoggingBuilder AddEonaCatJsonFileLogger(this ILoggingBuilder builder, Action<JsonFileLoggerOptions> configure)
{
var options = new JsonFileLoggerOptions();

View File

@@ -4,8 +4,24 @@ using System;
namespace EonaCat.Logger.EonaCatCoreLogger.Extensions
{
/// <summary>
/// Provides extension methods for registering the EonaCat Slack logger with an <see cref="ILoggingBuilder"/>.
/// </summary>
/// <remarks>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.</remarks>
public static class SlackLoggerFactoryExtensions
{
/// <summary>
/// Adds a Slack-based logger to the logging builder, allowing log messages to be sent to Slack using the
/// specified configuration.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="builder">The logging builder to which the Slack logger will be added.</param>
/// <param name="configure">An action to configure the Slack logger options. Cannot be null.</param>
/// <returns>The same instance of <see cref="ILoggingBuilder"/> for chaining.</returns>
public static ILoggingBuilder AddEonaCatSlackLogger(this ILoggingBuilder builder, Action<SlackLoggerOptions> configure)
{
var options = new SlackLoggerOptions();

View File

@@ -7,8 +7,20 @@ using System;
namespace EonaCat.Logger.EonaCatCoreLogger.Extensions
{
/// <summary>
/// Provides extension methods for registering the EonaCat TCP logger with an <see cref="ILoggingBuilder"/>.
/// </summary>
public static class TcpLoggerFactoryExtensions
{
/// <summary>
/// Adds a TCP-based logger provider to the logging builder, allowing log messages to be sent over TCP using the
/// specified configuration.
/// </summary>
/// <remarks>Use this method to enable logging to a remote endpoint over TCP. The <paramref
/// name="configure"/> action allows customization of connection settings and other logger options.</remarks>
/// <param name="builder">The logging builder to which the TCP logger provider will be added.</param>
/// <param name="configure">An action to configure the TCP logger options. Cannot be null.</param>
/// <returns>The same instance of <see cref="ILoggingBuilder"/> for chaining.</returns>
public static ILoggingBuilder AddEonaCatTcpLogger(this ILoggingBuilder builder, Action<TcpLoggerOptions> configure)
{
var options = new TcpLoggerOptions();

View File

@@ -4,8 +4,23 @@ using System;
namespace EonaCat.Logger.EonaCatCoreLogger.Extensions
{
/// <summary>
/// Provides extension methods for registering the EonaCat Telegram logger with an <see cref="ILoggingBuilder"/>.
/// </summary>
/// <remarks>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.</remarks>
public static class TelegramLoggerFactoryExtensions
{
/// <summary>
/// Adds a Telegram-based logger to the logging builder using the specified configuration options.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="builder">The logging builder to which the Telegram logger will be added.</param>
/// <param name="configure">An action to configure the Telegram logger options. Cannot be null.</param>
/// <returns>The same instance of <see cref="ILoggingBuilder"/> for chaining.</returns>
public static ILoggingBuilder AddEonaCatTelegramLogger(this ILoggingBuilder builder, Action<TelegramLoggerOptions> configure)
{
var options = new TelegramLoggerOptions();

View File

@@ -7,8 +7,22 @@ using System;
namespace EonaCat.Logger.EonaCatCoreLogger.Extensions
{
/// <summary>
/// Provides extension methods for registering the EonaCat UDP logger with an <see cref="ILoggingBuilder"/>.
/// </summary>
/// <remarks>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.</remarks>
public static class UdpLoggerFactoryExtensions
{
/// <summary>
/// Adds a UDP-based logger to the logging builder using the specified configuration options.
/// </summary>
/// <remarks>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.</remarks>
/// <param name="builder">The logging builder to which the UDP logger will be added.</param>
/// <param name="configure">An action to configure the <see cref="UdpLoggerOptions"/> for the UDP logger. Cannot be null.</param>
/// <returns>The <see cref="ILoggingBuilder"/> instance for chaining.</returns>
public static ILoggingBuilder AddEonaCatUdpLogger(this ILoggingBuilder builder, Action<UdpLoggerOptions> configure)
{
var options = new UdpLoggerOptions();

View File

@@ -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.
/// <summary>
/// An <see cref="ILoggerProvider" /> that writes logs to a file
/// </summary>
[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<string,string> _buffer = new ConcurrentDictionary<string, string>();
private readonly ConcurrentDictionary<string, StringBuilder> _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<ErrorMessage> OnError;
/// <summary>
/// Creates an instance of the <see cref="FileLoggerProvider" />
/// </summary>
/// <param name="options">The options object controlling the logger</param>
public string LogFile => _logFile;
public FileLoggerProvider(IOptions<FileLoggerOptions> 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;
}
/// <summary>
/// The file to which log messages should be appended.
/// </summary>
public string LogFile
{
get => _logFile;
set
{
_logFile = value;
public bool IncludeCorrelationId { get; }
if (!string.IsNullOrEmpty(_logFile))
protected override async Task WriteMessagesAsync(
IReadOnlyList<LogMessage> messages,
CancellationToken token)
{
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);
/// <inheritdoc />
protected override async Task WriteMessagesAsync(IEnumerable<LogMessage> 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);
}
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
AppendMessage(stringBuilder, message);
}
}
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<bool> 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)
{
sb.Append(" [");
bool first = true;
foreach (var kv in ctx)
{
if (!first)
{
sb.Append(' ');
}
sb.Append(kv.Key)
.Append('=')
.Append(kv.Value);
first = false;
}
sb.Append(']');
}
// 3. End the line
sb.AppendLine();
}
private async Task FlushAsync(string file, StringBuilder sb, CancellationToken token)
{
if (sb.Length == 0)
{
return;
}
await _writeLock.WaitAsync(token).ConfigureAwait(false);
try
{
using (var file = new StreamWriter(LogFile, true))
using var fs = new FileStream(
file,
FileMode.Append,
FileAccess.Write,
FileShare.Read,
64 * 1024,
useAsync: 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);
await RollOverAsync(file).ConfigureAwait(false);
}
completed = true;
_buffer.TryRemove(LogFile, out _);
return true;
}
catch (Exception)
finally
{
tries++;
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
if (tries >= _maxTries)
{
OnError?.Invoke(this, new ErrorMessage { Message = "Cannot write to log folder"});
return false;
_writeLock.Release();
}
}
}
return false;
}
private async Task<bool> WriteToTempFileAsync(CancellationToken cancellationToken)
private async Task RollOverAsync(string file)
{
if (string.IsNullOrWhiteSpace(LogFile) || !_buffer.ContainsKey(LogFile))
{
return false;
}
var tempLogFolder = Path.Combine(Path.GetTempPath(), "EonaCatLogs");
var tempLogFile = $"{Path.Combine(tempLogFolder, Path.GetFileNameWithoutExtension(LogFile))}.log";
await _rolloverLock.WaitAsync().ConfigureAwait(false);
try
{
Directory.CreateDirectory(tempLogFolder);
var dir = Path.GetDirectoryName(file);
var name = Path.GetFileNameWithoutExtension(file);
var ext = Path.GetExtension(file);
// Create new temp file
using (var file = new StreamWriter(tempLogFile, true))
{
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}");
var src = Path.Combine(dir, $"{name}.{i}{ext}");
var dst = Path.Combine(dir, $"{name}.{i + 1}{ext}");
if (File.Exists(currentFilePath))
if (File.Exists(dst))
{
if (File.Exists(newFilePath))
{
File.Delete(newFilePath);
}
File.Move(currentFilePath, newFilePath);
File.Delete(dst);
}
if (i != 1)
if (File.Exists(src))
{
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}");
}
File.Move(src, dst);
}
}
private async Task WriteEndMessageAsync(string logFilePath)
File.Move(file, Path.Combine(dir, $"{name}.1{ext}"));
_currentFileSize = 0;
}
finally
{
var stopMessage = LogHelper.GetStopMessage();
stopMessage = LogHelper.FormatMessageWithHeader(LoggerSettings, ELogType.INFO, stopMessage, CurrentDateTme, Category);
using (var file = new StreamWriter(logFilePath, true))
{
await file.WriteAsync(stopMessage).ConfigureAwait(false);
_rolloverLock.Release();
}
}
private async Task ProcessAsync()
private void InitializeFile(string file)
{
try
if (_logFile == file)
{
await WriteStartMessage().ConfigureAwait(false);
}
catch (Exception ex)
{
// Log or handle the exception
Console.WriteLine($"Error occurred during log file rollover: {ex.Message}");
}
return;
}
/// <summary>
/// Deletes old log files, keeping a number of files defined by <see cref="FileLoggerOptions.RetainedFileCountLimit" />
/// </summary>
_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<FileInfo> files = null;
if (hasPrefix)
{
files = new DirectoryInfo(_path).GetFiles(_fileNamePrefix + "*");
}
else
{
files = new DirectoryInfo(_path).GetFiles("*");
return;
}
files = files.OrderByDescending(file => file.Name).Skip(_maxRetainedFiles);
var files = new DirectoryInfo(_path)
.GetFiles($"{_fileNamePrefix}*")
.Where(f => !f.FullName.Equals(_logFile, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(f => f.CreationTimeUtc)
.Skip(_maxRetainedFiles);
foreach (var item in files)
foreach (var f in files)
{
item.Delete();
}
f.Delete();
}
}
}

View File

@@ -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
{
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.
public sealed class HttpLogger : ILogger, IDisposable
{
private readonly string _categoryName;
private readonly HttpLoggerOptions _options;
private readonly LoggerScopedContext _context = new();
@@ -27,14 +26,16 @@ namespace EonaCat.Logger.EonaCatCoreLogger
public event EventHandler<Exception> OnException;
public event EventHandler<string> 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<Dictionary<string, object>>(boundedCapacity: 10000);
_queue = new BlockingCollection<Dictionary<string, object>>(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<string, object>
var payload = new Dictionary<string, object>(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();

View File

@@ -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>(TState state) => null;
public IDisposable BeginScope<TState>(TState state) => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception,
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception exception,
Func<TState, Exception, string> 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<TState>(DateTimeOffset timestamp, LogLevel logLevel, EventId eventId, TState state,
Exception exception, Func<TState, Exception, string> 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)
{
onLogEvent(new EonaCatLogMessage
{
DateTime = timestamp.DateTime,
Message = _provider.AddMessage(timestamp, message),
Message = writtenMessage,
LogType = logLevel.FromLogLevel(),
Category = category,
Category = _category,
Exception = exception,
Origin = string.IsNullOrWhiteSpace(_loggerSettings.LogOrigin) ? "BatchingLogger" : _loggerSettings.LogOrigin
};
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() { }
}
}
}

View File

@@ -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<LogMessage> _queue;
private readonly CancellationTokenSource _cts = new();
private readonly Task _worker;
private readonly Channel<LogMessage> _channel =
Channel.CreateUnbounded<LogMessage>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false
});
private CancellationTokenSource _cancellationTokenSource;
private Task _outputTask;
private bool _isDisposed;
private bool _disposed;
private LoggerSettings _loggerSettings;
protected BatchingLoggerProvider(IOptions<BatchingLoggerOptions> 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<LogMessage>(new ConcurrentQueue<LogMessage>());
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;
}
/// <summary>
/// Creates a new logger instance for the specified category name.
/// </summary>
/// <param name="categoryName">The category name for messages produced by the logger. Cannot be null.</param>
/// <returns>An <see cref="ILogger"/> instance that logs messages for the specified category.</returns>
public ILogger CreateLogger(string categoryName)
=> new BatchingLogger(this, categoryName, LoggerSettings);
protected abstract Task WriteMessagesAsync(
IReadOnlyList<LogMessage> 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<LogMessage> 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<LogMessage>(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<LogMessage>(_batchSize);
try
{
var token = _cancellationTokenSource.Token;
while (await _channel.Reader.WaitToReadAsync(token).ConfigureAwait(false))
foreach (var item in _queue.GetConsumingEnumerable(_cts.Token))
{
batch.Clear();
batch.Add(item);
while (batch.Count < batchSize && _channel.Reader.TryRead(out var message))
if (batch.Count < _batchSize)
{
batch.Add(message);
continue;
}
await WriteMessagesAsync(batch, _cts.Token).ConfigureAwait(false);
batch.Clear();
}
if (batch.Count > 0)
{
await WriteMessagesAsync(batch, token).ConfigureAwait(false);
}
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<LogMessage> { 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;
}
/// <summary>
/// Masks sensitive information within the provided message string.
/// This method is virtual and can be overridden to customize masking behavior.
/// </summary>
/// <param name="message">The log message potentially containing sensitive information.</param>
/// <returns>The masked log message.</returns>
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();
}
}

View File

@@ -8,4 +8,5 @@ public struct LogMessage
{
public DateTimeOffset Timestamp { get; set; }
public string Message { get; set; }
public string Category { get; set; }
}

View File

@@ -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;
}
}
}

View File

@@ -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<string> _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<Exception> 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<string>(10000); // bounded queue
_queue = new BlockingCollection<string>(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<string, object>
var logObject = new Dictionary<string, object>(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);
}
}
/// <summary>
/// Flush all pending logs to file.
/// </summary>
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();

View File

@@ -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<Stack<Dictionary<string, string>>> _scopes = new();
// Ensure there is always a scope to write into
private void EnsureScope()
{
_scopes.Value ??= new Stack<Dictionary<string, string>>();
@@ -35,13 +37,17 @@ 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,9 +62,11 @@ namespace EonaCat.Logger.EonaCatCoreLogger
foreach (var kv in scope)
{
if (!result.ContainsKey(kv.Key))
{
result[kv.Key] = kv.Value;
}
}
}
return result;
}

View File

@@ -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<Exception> 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<string>(new BoundedChannelOptions(1000)
_logChannel = Channel.CreateBounded<string>(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>(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<string>
{
$"*[{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();

View File

@@ -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<Exception> 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<string>(new BoundedChannelOptions(1000)
_logChannel = Channel.CreateBounded<string>(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>(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>(TState state) => _context.BeginScope(state);
public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception exception, Func<TState, Exception, string> formatter)
@@ -55,77 +57,93 @@ 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<string>
{
$"[{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 (OperationCanceledException) { }
catch (Exception ex)
{
OnException?.Invoke(this, ex);
}
}
}
catch (OperationCanceledException)
{
// normal shutdown
}
catch (Exception ex)
{
OnException?.Invoke(this, ex);
}
finally
{
writer?.Dispose();
client?.Dispose();
}
}
public void Dispose()
{
@@ -136,7 +154,10 @@ namespace EonaCat.Logger.EonaCatCoreLogger
{
_processingTask.Wait();
}
catch { /* ignore */ }
catch
{
// Do nothing
}
_cts.Dispose();
}

View File

@@ -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<string> _logChannel;
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private readonly CancellationTokenSource _cts = new();
private readonly Task _processingTask;
private const int MaxQueueSize = 1000;
public event EventHandler<Exception> 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<string>(new BoundedChannelOptions(1000)
_logChannel = Channel.CreateBounded<string>(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>(TState state) => null;
@@ -44,21 +48,22 @@ namespace EonaCat.Logger.EonaCatCoreLogger
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception exception, Func<TState, Exception, string> 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()
@@ -98,7 +113,10 @@ namespace EonaCat.Logger.EonaCatCoreLogger
{
_processingTask.Wait();
}
catch { /* ignore */ }
catch
{
// Do nothing
}
_cts.Dispose();
_httpClient.Dispose();

View File

@@ -33,7 +33,6 @@ namespace EonaCat.Logger.EonaCatCoreLogger
_udpClient = new UdpClient();
// Bounded channel to avoid unbounded memory growth
_logChannel = Channel.CreateBounded<string>(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();

View File

@@ -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<Exception> OnException;
@@ -51,6 +50,11 @@ namespace EonaCat.Logger.EonaCatCoreLogger
return;
}
_ = LogAsync(logLevel, state, exception, formatter);
}
private async Task LogAsync<TState>(LogLevel logLevel, TState state, Exception exception, Func<TState, Exception, string> 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();
}
}

View File

@@ -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,8 +162,10 @@ public static class LogHelper
}
if (!string.IsNullOrEmpty(header))
{
header += " ";
}
}
return header + message;
}

View File

@@ -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";
/// <summary>
/// Determines if we need to use the local time in the logging or UTC (default:false)