Updated
This commit is contained in:
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,18 @@ using System.Threading;
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,5 @@ public struct LogMessage
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public string Message { get; set; }
|
||||
public string Category { get; set; }
|
||||
}
|
||||
100
EonaCat.Logger/EonaCatCoreLogger/Internal/SensitiveDataMasker.cs
Normal file
100
EonaCat.Logger/EonaCatCoreLogger/Internal/SensitiveDataMasker.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace EonaCat.Logger.EonaCatCoreLogger
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
namespace EonaCat.Logger.EonaCatCoreLogger
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user