diff --git a/EonaCat.Logger/EonaCatCoreLogger/Extensions/FileLoggerFactoryExtensions.cs b/EonaCat.Logger/EonaCatCoreLogger/Extensions/FileLoggerFactoryExtensions.cs index acf2905..23596eb 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/Extensions/FileLoggerFactoryExtensions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/Extensions/FileLoggerFactoryExtensions.cs @@ -60,6 +60,7 @@ public static class FileLoggerFactoryExtensions options.Mask = fileLoggerOptions.Mask; options.UseDefaultMasking = fileLoggerOptions.UseDefaultMasking; options.MaskedKeywords = fileLoggerOptions.MaskedKeywords; + options.IncludeCorrelationId = fileLoggerOptions.IncludeCorrelationId; } ); return builder; diff --git a/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs b/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs index 442b1b1..0c88033 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/FileLoggerProvider.cs @@ -24,7 +24,7 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider private string _logFile; private long _currentFileSize; - + private Timer _flushTimer; private readonly ConcurrentDictionary _buffers = new(); private readonly ConcurrentDictionary _fileSizes = new(); private readonly SemaphoreSlim _writeLock = new(1, 1); @@ -35,6 +35,7 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider public string LogFile => _logFile ?? string.Empty; public event EventHandler OnError; + public event EventHandler OnRollOver; public FileLoggerProvider(IOptions options) : base(options) { @@ -48,6 +49,15 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider Directory.CreateDirectory(_path); InitializeCurrentFile(); + _flushTimer = new Timer(_ => FlushAllAsync().GetAwaiter().GetResult(), null, 5000, 5000); + } + + private async Task FlushAllAsync() + { + foreach (var kv in _buffers) + { + await FlushAsync(kv.Key, kv.Value, CancellationToken.None); + } } public bool IncludeCorrelationId { get; } @@ -179,17 +189,17 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider for (int i = _maxRolloverFiles - 1; i >= 1; i--) { - var src = Path.Combine(dir, $"{name}.{i}{ext}"); - var dst = Path.Combine(dir, $"{name}.{i + 1}{ext}"); + var source = Path.Combine(dir, $"{name}.{i}{ext}"); + var destination = Path.Combine(dir, $"{name}.{i + 1}{ext}"); - if (File.Exists(dst)) + if (File.Exists(destination)) { - File.Delete(dst); + File.Delete(destination); } - if (File.Exists(src)) + if (File.Exists(source)) { - File.Move(src, dst); + File.Move(source, destination); } } @@ -204,6 +214,7 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider finally { _rolloverLock.Release(); + OnRollOver?.Invoke(this, file); } } @@ -213,13 +224,10 @@ public sealed class FileLoggerProvider : BatchingLoggerProvider _buffers.TryAdd(file, new StringBuilder(4096)); } - 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"); + ? Path.Combine(_path, $"{Environment.MachineName}_{g.Year:0000}{g.Month:00}{g.Day:00}.log") + : Path.Combine(_path, $"{_fileNamePrefix}_{Environment.MachineName}_{g.Year:0000}{g.Month:00}{g.Day:00}.log"); private void DeleteOldLogFiles() { diff --git a/EonaCat.Logger/EonaCatCoreLogger/TcpLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/TcpLogger.cs index 5bb4465..d349d36 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/TcpLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/TcpLogger.cs @@ -19,10 +19,12 @@ namespace EonaCat.Logger.EonaCatCoreLogger private readonly CancellationTokenSource _cts = new(); private readonly Task _processingTask; - private const int MaxQueueSize = 1000; + private const int MaxQueueSize = 5000; + private const int MaxBatchSize = 20; // send logs in batches to reduce TCP writes public bool IncludeCorrelationId { get; set; } public event EventHandler OnException; + public event EventHandler OnLogDropped; public TcpLogger(string categoryName, TcpLoggerOptions options) { @@ -37,6 +39,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger SingleWriter = false }); + // Start background processing task _processingTask = Task.Run(() => ProcessLogQueueAsync(_cts.Token), _cts.Token); } @@ -51,9 +54,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger Exception exception, Func formatter) { if (!IsEnabled(logLevel) || formatter == null) - { return; - } try { @@ -65,7 +66,6 @@ namespace EonaCat.Logger.EonaCatCoreLogger 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("] | "); @@ -77,17 +77,14 @@ namespace EonaCat.Logger.EonaCatCoreLogger { sb.Append(" | Context: "); foreach (var kvp in contextData) - { sb.Append(kvp.Key).Append("=").Append(kvp.Value).Append("; "); - } } if (exception != null) - { sb.Append(" | Exception: ").Append(exception); - } - _logChannel.Writer.TryWrite(sb.ToString()); + if (!_logChannel.Writer.TryWrite(sb.ToString())) + OnLogDropped?.Invoke(this, sb.ToString()); // notify if log is dropped } catch (Exception ex) { @@ -100,59 +97,154 @@ namespace EonaCat.Logger.EonaCatCoreLogger TcpClient client = null; StreamWriter writer = null; - try + while (!token.IsCancellationRequested) { - client = new TcpClient(); - await client.ConnectAsync(_options.Host, _options.Port).ConfigureAwait(false); - - writer = new StreamWriter(client.GetStream(), Encoding.UTF8) { AutoFlush = true }; - - await foreach (var log in _logChannel.Reader.ReadAllAsync(token)) + try { - try + // Attempt to connect with exponential backoff + int attempt = 0; + while (client == null || !client.Connected) { - await writer.WriteLineAsync(log).ConfigureAwait(false); + try + { + client?.Dispose(); + client = new TcpClient(); + await ConnectWithRetryAsync(client, _options.Host, _options.Port, token); + writer = new StreamWriter(client.GetStream(), Encoding.UTF8) { AutoFlush = true }; + break; // connected + } + catch (Exception ex) + { + OnException?.Invoke(this, ex); + attempt++; + int delay = Math.Min(1000 * (int)Math.Pow(2, attempt), 30000); // max 30s backoff + await Task.Delay(delay, token).ConfigureAwait(false); + } } - catch (IOException ioEx) + + // Process logs in batches + var batch = new List(MaxBatchSize); + await foreach (var log in _logChannel.Reader.ReadAllAsync(token)) { - OnException?.Invoke(this, ioEx); - - // Attempt to reconnect - writer.Dispose(); - client.Dispose(); - client = new TcpClient(); - await client.ConnectAsync(_options.Host, _options.Port).ConfigureAwait(false); - writer = new StreamWriter(client.GetStream(), Encoding.UTF8) { AutoFlush = true }; - } - catch (Exception ex) - { - OnException?.Invoke(this, ex); + batch.Add(log); + if (batch.Count >= MaxBatchSize) + { + await SafeWriteBatch(writer, batch, client, token); + batch.Clear(); + } } + + // Flush any remaining logs + if (batch.Count > 0) + await SafeWriteBatch(writer, batch, client, token); + } + catch (OperationCanceledException) + { + break; // normal shutdown + } + catch (Exception ex) + { + OnException?.Invoke(this, ex); + await Task.Delay(1000, token); // prevent tight loop } } - catch (OperationCanceledException) + + // Cleanup + writer?.Dispose(); + client?.Dispose(); + } + + private async Task ConnectWithRetryAsync(TcpClient client, string host, int port, CancellationToken token) + { + int attempt = 0; + + while (!token.IsCancellationRequested) { - // normal shutdown + try + { + var connectTask = client.ConnectAsync(host, port); + var delayTask = Task.Delay(Timeout.Infinite, token); + + var completed = await Task.WhenAny(connectTask, delayTask); + if (completed == connectTask) + { + await connectTask; + return; + } + else + { + token.ThrowIfCancellationRequested(); + } + } + catch (Exception ex) + { + attempt++; + OnException?.Invoke(this, ex); + int delayMs = Math.Min(1000 * (int)Math.Pow(2, attempt), 30000); + await Task.Delay(delayMs, token); + client.Dispose(); + client = new TcpClient(); + } + } + } + + private async Task SafeWriteBatch(StreamWriter writer, List batch, TcpClient client, CancellationToken token) + { + try + { + if (writer != null && client?.Connected == true) + { + await writer.WriteLineAsync(string.Join(Environment.NewLine, batch)).ConfigureAwait(false); + } + else + { + // Optionally write to local file if TCP server is down + await FallbackWriteAsync(batch).ConfigureAwait(false); + } } catch (Exception ex) { OnException?.Invoke(this, ex); - } - finally - { - writer?.Dispose(); - client?.Dispose(); + await FallbackWriteAsync(batch).ConfigureAwait(false); } } + private async Task FallbackWriteAsync(List batch) + { + try + { + if (!_options.EnableFallbackLogging) + { + return; + } + + string fallbackFile = _options.FallbackLogFilePath ?? Path.Combine(AppContext.BaseDirectory, "tcp_logger_fallback.log"); + + Directory.CreateDirectory(Path.GetDirectoryName(fallbackFile)!); + + using (var writer = new StreamWriter(fallbackFile, append: true, encoding: Encoding.UTF8)) + { + foreach (var line in batch) + { + await writer.WriteLineAsync(line).ConfigureAwait(false); + } + } + } + catch + { + // Do nothing + } + } + + public void Dispose() { _cts.Cancel(); _logChannel.Writer.Complete(); - try + try { - _processingTask.Wait(); + _processingTask.Wait(TimeSpan.FromSeconds(5)); } catch { diff --git a/EonaCat.Logger/EonaCatCoreLogger/TcpLoggerOptions.cs b/EonaCat.Logger/EonaCatCoreLogger/TcpLoggerOptions.cs index 9356350..2ac3f30 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/TcpLoggerOptions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/TcpLoggerOptions.cs @@ -13,5 +13,7 @@ namespace EonaCat.Logger.EonaCatCoreLogger public int Port { get; set; } public bool IsEnabled { get; set; } = true; public bool IncludeCorrelationId { get; set; } + public string FallbackLogFilePath { get; set; } + public bool EnableFallbackLogging { get; set; } } } diff --git a/EonaCat.Logger/EonaCatCoreLogger/UdpLogger.cs b/EonaCat.Logger/EonaCatCoreLogger/UdpLogger.cs index 62ad2c4..c5b7bbc 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/UdpLogger.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/UdpLogger.cs @@ -23,7 +23,9 @@ namespace EonaCat.Logger.EonaCatCoreLogger private readonly Task _processingTask; public bool IncludeCorrelationId { get; set; } + public event EventHandler OnException; + public event EventHandler OnLogDropped; public UdpLogger(string categoryName, UdpLoggerOptions options) { @@ -93,7 +95,10 @@ namespace EonaCat.Logger.EonaCatCoreLogger string fullMessage = string.Join(" | ", logParts); // Drop oldest if full - _logChannel.Writer.TryWrite(fullMessage); + if (!_logChannel.Writer.TryWrite(fullMessage)) + { + OnLogDropped?.Invoke(this, fullMessage); + } } catch (Exception ex) { @@ -105,23 +110,73 @@ namespace EonaCat.Logger.EonaCatCoreLogger { try { - await foreach (var message in _logChannel.Reader.ReadAllAsync(token)) + if (_options.EnableBatching && _options.BatchSize > 1) { - try + // batching mode + var batch = new List(_options.BatchSize); + + await foreach (var message in _logChannel.Reader.ReadAllAsync(token)) { - byte[] bytes = Encoding.UTF8.GetBytes(message); - await _udpClient.SendAsync(bytes, bytes.Length, _options.Host, _options.Port); + batch.Add(message); + + if (batch.Count >= _options.BatchSize) + { + await SendBatchAsync(batch, token); + batch.Clear(); + } } - catch (Exception ex) + + // send remaining messages + if (batch.Count > 0) { - OnException?.Invoke(this, ex); + await SendBatchAsync(batch, token); + } + } + else + { + // non-batching mode: send messages one by one + await foreach (var message in _logChannel.Reader.ReadAllAsync(token)) + { + try + { + byte[] bytes = Encoding.UTF8.GetBytes(message); + await _udpClient.SendAsync(bytes, bytes.Length, _options.Host, _options.Port); + } + catch (Exception ex) + { + OnException?.Invoke(this, ex); + } } } } - catch (OperationCanceledException) + catch (OperationCanceledException) { // normal shutdown } + catch (Exception exception) + { + OnException?.Invoke(this, exception); + } + } + + /// + /// Sends a batch of log messages as a single UDP packet. + /// + private async Task SendBatchAsync(List batch, CancellationToken token) + { + try + { + string combinedMessage = string.Join(Environment.NewLine, batch); + + const int maxUdpSize = 60_000; + if (combinedMessage.Length > maxUdpSize) + { + combinedMessage = combinedMessage.Substring(0, maxUdpSize); + } + + byte[] bytes = Encoding.UTF8.GetBytes(combinedMessage); + await _udpClient.SendAsync(bytes, bytes.Length, _options.Host, _options.Port); + } catch (Exception ex) { OnException?.Invoke(this, ex); diff --git a/EonaCat.Logger/EonaCatCoreLogger/UdpLoggerOptions.cs b/EonaCat.Logger/EonaCatCoreLogger/UdpLoggerOptions.cs index 9dff491..7bbd2a8 100644 --- a/EonaCat.Logger/EonaCatCoreLogger/UdpLoggerOptions.cs +++ b/EonaCat.Logger/EonaCatCoreLogger/UdpLoggerOptions.cs @@ -15,6 +15,8 @@ namespace EonaCat.Logger.EonaCatCoreLogger public bool IsEnabled { get; set; } = true; public bool IncludeCorrelationId { get; set; } + public bool EnableBatching { get; set; } + public int BatchSize { get; set; } = 10; } } diff --git a/EonaCat.Logger/Extensions/ObjectExtensions.cs b/EonaCat.Logger/Extensions/ObjectExtensions.cs index 31f5289..06fb992 100644 --- a/EonaCat.Logger/Extensions/ObjectExtensions.cs +++ b/EonaCat.Logger/Extensions/ObjectExtensions.cs @@ -26,6 +26,59 @@ namespace EonaCat.Logger.Extensions public static class ObjectExtensions { + /// + /// Executes an action on the object if it satisfies a predicate. + /// + public static T If(this T obj, Func predicate, Action action) + { + if (obj != null && predicate(obj)) + { + action(obj); + } + + return obj; + } + + /// + /// Executes an action on the object if it does NOT satisfy a predicate. + /// + public static T IfNot(this T obj, Func predicate, Action action) + { + if (obj != null && !predicate(obj)) + { + action(obj); + } + + return obj; + } + + /// + /// Executes a function on an object if not null, returns object itself. + /// Useful for chaining. + /// + public static T Tap(this T obj, Action action) + { + if (obj != null) + { + action(obj); + } + + return obj; + } + + /// + /// Returns true if object implements a given interface. + /// + public static bool Implements(this object obj) + { + if (obj == null) + { + return false; + } + + return typeof(TInterface).IsAssignableFrom(obj.GetType()); + } + /// /// Dumps any object to a string in JSON, XML, or detailed tree format. /// @@ -61,6 +114,178 @@ namespace EonaCat.Logger.Extensions } } + /// + /// Returns a default value if the object is null. + /// + public static T OrDefault(this T obj, T defaultValue = default) => + obj == null ? defaultValue : obj; + + /// + /// Returns the object if not null; otherwise executes a function to get a fallback. + /// + public static T OrElse(this T obj, Func fallback) => + obj != null ? obj : fallback(); + + /// + /// Converts an object to JSON string with optional formatting. + /// + public static string ToJson(this object obj, bool indented = false) + { + try + { + return obj == null + ? string.Empty + : !indented ? Json.JsonHelper.ToJson(obj, Formatting.None) : Json.JsonHelper.ToJson(obj, Formatting.Indented); + } + catch + { + return string.Empty; + } + } + + /// + /// Converts an object to string safely, returns empty string if null. + /// + public static string SafeToString(this object obj) => + obj?.ToString() ?? string.Empty; + + /// + /// Checks if an object is null or default. + /// + public static bool IsNullOrDefault(this T obj) => + EqualityComparer.Default.Equals(obj, default); + + /// + /// Safely casts an object to a specific type, returns default if cast fails. + /// + public static T SafeCast(this object obj) + { + if (obj is T variable) + { + return variable; + } + + return default; + } + + /// + /// Safely tries to convert object to integer. + /// + public static int ToInt(this object obj, int defaultValue = 0) + { + if (obj == null) + { + return defaultValue; + } + + return int.TryParse(obj.ToString(), out var val) ? val : defaultValue; + } + + /// + /// Safely tries to convert object to long. + /// + public static long ToLong(this object obj, long defaultValue = 0) + { + if (obj == null) + { + return defaultValue; + } + + return long.TryParse(obj.ToString(), out var val) ? val : defaultValue; + } + + /// + /// Safely tries to convert object to double. + /// + public static double ToDouble(this object obj, double defaultValue = 0) + { + if (obj == null) + { + return defaultValue; + } + + return double.TryParse(obj.ToString(), out var val) ? val : defaultValue; + } + + /// + /// Safely tries to convert object to bool. + /// + public static bool ToBool(this object obj, bool defaultValue = false) + { + if (obj == null) + { + return defaultValue; + } + + return bool.TryParse(obj.ToString(), out var val) ? val : defaultValue; + } + + /// + /// Checks if an object is of a specific type. + /// + public static bool IsType(this object obj) => obj is T; + + /// + /// Executes an action if the object is not null. + /// + public static void IfNotNull(this T obj, Action action) + { + if (obj != null) + { + action(obj); + } + } + + /// + /// Executes an action if the object is null. + /// + public static void IfNull(this T obj, Action action) + { + if (obj == null) + { + action(); + } + } + + /// + /// Wraps the object into a single-item enumerable. + /// + public static IEnumerable AsEnumerable(this T obj) + { + if (obj != null) + { + yield return obj; + } + } + + /// + /// Safely returns a string representation with max length truncation. + /// + public static string ToSafeString(this object obj, int maxLength) + { + string str = obj.SafeToString(); + return str.Length <= maxLength ? str : str.Substring(0, maxLength); + } + + /// + /// Returns object hash code safely (0 if null). + /// + public static int SafeHashCode(this object obj) => + obj?.GetHashCode() ?? 0; + + /// + /// Returns the object or throws a custom exception if null. + /// + public static T OrThrow(this T obj, Func exceptionFactory) + { + if (obj == null) + { + throw exceptionFactory(); + } + + return obj; + } + private static string DumpJson(object currentObject, bool isDetailed) { var settings = new JsonSerializerSettings @@ -421,5 +646,218 @@ namespace EonaCat.Logger.Extensions } return dict; } + + /// + /// Converts any object to a human-readable log string, including collections and nested objects. + /// + public static string ToLogString(this object obj, int maxDepth = 3, int currentDepth = 0) + { + if (obj == null) + { + return "null"; + } + + if (currentDepth >= maxDepth) + { + return "..."; + } + + // Handle strings separately + if (obj is string str) + { + return str; + } + + // Handle IEnumerable + if (obj is IEnumerable enumerable) + { + var items = new List(); + foreach (var item in enumerable) + { + items.Add(item.ToLogString(maxDepth, currentDepth + 1)); + } + return "[" + string.Join(", ", items) + "]"; + } + + // Handle primitive types + var type = obj.GetType(); + if (type.IsPrimitive || obj is decimal || obj is DateTime || obj is Guid) + { + return obj.ToString(); + } + + // Handle objects with properties + try + { + var props = type.GetProperties(); + var sb = new StringBuilder("{"); + bool first = true; + foreach (var p in props) + { + if (!first) + { + sb.Append(", "); + } + + var val = p.GetValue(obj); + sb.Append($"{p.Name}={val.ToLogString(maxDepth, currentDepth + 1)}"); + first = false; + } + sb.Append("}"); + return sb.ToString(); + } + catch + { + return obj.ToString(); + } + } + + /// + /// Checks if an object is considered "empty": null, empty string, empty collection. + /// + public static bool IsEmpty(this object obj) + { + if (obj == null) + { + return true; + } + + if (obj is string str) + { + return string.IsNullOrWhiteSpace(str); + } + + if (obj is ICollection col) + { + return col.Count == 0; + } + + if (obj is IEnumerable enumerable) + { + return !enumerable.Cast().Any(); + } + + return false; + } + + /// + /// Executes an action if the object is not null and not empty. + /// + public static void IfNotEmpty(this T obj, Action action) + { + if (!obj.IsEmpty()) + { + action(obj); + } + } + + /// + /// Returns a default value if the object is null or empty. + /// + public static T OrDefaultIfEmpty(this T obj, T defaultValue) + { + return obj.IsEmpty() ? defaultValue : obj; + } + + /// + /// Returns true if the object is numeric (int, float, double, decimal, long, etc.). + /// + public static bool IsNumeric(this object obj) + { + if (obj == null) + { + return false; + } + + return double.TryParse(obj.ToString(), out _); + } + + /// + /// Converts an object to a numeric double, returns default if conversion fails. + /// + public static double ToDoubleSafe(this object obj, double defaultValue = 0) + { + if (obj == null) + { + return defaultValue; + } + + return double.TryParse(obj.ToString(), out var d) ? d : defaultValue; + } + + /// + /// Converts an object to a numeric int, returns default if conversion fails. + /// + public static int ToIntSafe(this object obj, int defaultValue = 0) + { + if (obj == null) + { + return defaultValue; + } + + return int.TryParse(obj.ToString(), out var i) ? i : defaultValue; + } + + /// + /// Returns the type name of an object safely. + /// + public static string GetTypeName(this object obj) => + obj?.GetType().Name ?? "null"; + + /// + /// Executes a function if object is not null and returns a fallback value otherwise. + /// + public static TResult Map(this T obj, Func mapper, TResult fallback = default) + { + if (obj == null) + { + return fallback; + } + + return mapper(obj); + } + + /// + /// Masks sensitive strings (like passwords, tokens). Keeps first and last 2 characters visible. + /// + public static string MaskSensitive(this string str) + { + if (string.IsNullOrEmpty(str) || str.Length <= 4) + { + return "****"; + } + + int len = str.Length - 4; + return str.Substring(0, 2) + new string('*', len) + str.Substring(str.Length - 2, 2); + } + + /// + /// Masks sensitive data in any object property that matches a keyword. + /// + public static void MaskProperties(this object obj, params string[] keywords) + { + if (obj == null || keywords == null || keywords.Length == 0) + { + return; + } + + var props = obj.GetType().GetProperties(); + foreach (var p in props) + { + if (!p.CanRead || !p.CanWrite) + { + continue; + } + + if (keywords.Any(k => p.Name.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0)) + { + var val = p.GetValue(obj) as string; + if (!string.IsNullOrEmpty(val)) + { + p.SetValue(obj, val.MaskSensitive()); + } + } + } + } } }