Files
EonaCat.LogStack/EonaCat.LogStack/Server.cs
T
EonaCat 2909500bb8
EonaCat/ci-workflows: SonarQube / sonar (push) Successful in 22s
Updated
2026-10-08 21:33:54 +02:00

852 lines
32 KiB
C#

using System.Collections.Concurrent;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System;
using System.Linq;
using System.Collections.Generic;
namespace EonaCat.LogStack.Server
{
// 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>
/// Minimum log level the server will persist. Entries below this level are silently dropped.
/// </summary>
public enum ServerLogLevel { Trace = 0, Debug = 1, Information = 2, Warning = 3, Error = 4, Critical = 5 }
/// <summary>
/// Live counters exposed via <see cref="Server.Metrics"/>.
/// </summary>
public class ServerMetrics
{
public long TotalReceived;
public long TotalWritten;
public long TotalDropped;
public long TotalBytes;
public long ActiveTcpConnections;
public DateTime StartedAt = DateTime.UtcNow;
public TimeSpan Uptime => DateTime.UtcNow - StartedAt;
}
/// <summary>
/// Full configuration for the log server. All fields have sensible defaults.
/// </summary>
public class ServerOptions
{
/// <summary>Enable UDP listener (default: true).</summary>
public bool UseUdp { get; set; } = true;
/// <summary>
/// Enable TCP listener (default: true).
/// Both TCP and UDP can run simultaneously on the same port.
/// </summary>
public bool UseTcp { get; set; } = true;
/// <summary>
/// Enable a minimal HTTP ingest endpoint (default: false).
/// POST /ingest - accepts a single JSON entry or a JSON array.
/// GET /metrics - returns a JSON metrics snapshot.
/// </summary>
public bool UseHttp { get; set; } = false;
/// <summary>Port for UDP/TCP listeners (default: 5555).</summary>
public int Port { get; set; } = 5555;
/// <summary>Port for the HTTP ingest endpoint (default: 5556).</summary>
public int HttpPort { get; set; } = 5556;
/// <summary>IP address to bind to (null = IPAddress.Any).</summary>
public IPAddress? BindAddress { get; set; }
/// <summary>
/// Only persist entries at or above this level (default: Trace = everything).
/// Plain-text entries (non-JSON) always pass the level filter.
/// </summary>
public ServerLogLevel MinimumLevel { get; set; } = ServerLogLevel.Trace;
/// <summary>Max log file size before rolling over (default: 200 MB).</summary>
public long MaxLogFileSize { get; set; } = 200L * 1024 * 1024;
/// <summary>Days to keep daily log directories (default: 30).</summary>
public int LogRetentionDays { get; set; } = 30;
/// <summary>Maximum total size of the logs directory before oldest dirs are purged (default: 10 GB).</summary>
public long MaxLogDirectorySize { get; set; } = 10L * 1024 * 1024 * 1024;
/// <summary>Maximum concurrent TCP and HTTP clients (default: 128).</summary>
public int MaxConcurrentConnections { get; set; } = 128;
/// <summary>Maximum characters accepted in one TCP or HTTP message (default: 1 MiB).</summary>
public int MaxMessageChars { get; set; } = 1024 * 1024;
/// <summary>
/// Max messages accepted per second per remote endpoint (0 = disabled).
/// Excess messages are dropped and counted in <see cref="ServerMetrics.TotalDropped"/>.
/// </summary>
public int RateLimitPerSecond { get; set; } = 0;
/// <summary>Root directory where log files are written (default: "logs").</summary>
public string LogsRootDirectory { get; set; } = "logs";
}
public class Server
{
private TcpListener? _tcpListener;
private UdpClient? _udpListener;
private HttpListener? _httpListener;
private CancellationTokenSource _cts = new();
private bool _isRunning;
private readonly ServerOptions _options;
private readonly SemaphoreSlim _connectionSlots;
private readonly ConcurrentDictionary<TcpClient, byte> _activeTcpClients = new();
private readonly object _cleanupLock = new();
private DateTime _nextCleanupUtc = DateTime.MinValue;
private bool _cleanupRunning;
private readonly object _rateLimiterCleanupLock = new();
private DateTime _lastRateLimiterCleanupUtc = DateTime.MinValue;
private readonly ConcurrentDictionary<string, (DateTime window, int count)> _rateLimiter = new();
private const int UdpBufferSize = 65507;
private const int TcpReadBufferChars = 8192;
/// <summary>Live connection and throughput counters.</summary>
public ServerMetrics Metrics { get; } = new();
/// <summary>Raised after a log line has been written to disk.</summary>
public event Action<string>? LogWritten;
/// <summary>Raised when a log line is dropped (level filter or rate limit).</summary>
public event Action<string>? LogDropped;
/// <summary>Create a server using explicit <see cref="ServerOptions"/>.</summary>
public Server(ServerOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_connectionSlots = new SemaphoreSlim(Math.Max(1, options.MaxConcurrentConnections));
}
/// <summary>
/// Backwards-compatible constructor matching the original API.
/// When <paramref name="useUdp"/> is true UDP is enabled; TCP is also enabled by default.
/// </summary>
public Server(bool useUdp = true, int logRetentionDays = 30, long maxLogDirectorySize = 10L * 1024 * 1024 * 1024)
: this(new ServerOptions
{
UseUdp = useUdp,
UseTcp = true,
LogRetentionDays = logRetentionDays,
MaxLogDirectorySize = maxLogDirectorySize
})
{
}
/// <summary>Start all configured transports and begin accepting log entries.</summary>
public async Task Start(IPAddress? ipAddress = null, int port = 5555)
{
if (_isRunning)
{
Console.WriteLine("[EonaCat Server] Already running.");
return;
}
_cts = new CancellationTokenSource();
_isRunning = true;
Metrics.StartedAt = DateTime.UtcNow;
// Initialize log directory with permission checking
if (!InitializeLogDirectory())
{
Console.WriteLine("[EonaCat Server] WARNING: Could not initialize log directory. Logs will be dropped if write fails.");
}
var bind = ipAddress ?? _options.BindAddress ?? IPAddress.Any;
var tasks = new List<Task>();
if (_options.UseTcp)
{
_tcpListener = new TcpListener(bind, port);
_tcpListener.Start();
Console.WriteLine($"[EonaCat Server] TCP listener on {bind}:{port}");
tasks.Add(ListenTcpAsync(_cts.Token));
}
if (_options.UseUdp)
{
_udpListener = new UdpClient(port);
Console.WriteLine($"[EonaCat Server] UDP listener on port {port}");
tasks.Add(ListenUdpAsync(_cts.Token));
}
if (_options.UseHttp)
{
_httpListener = new HttpListener();
_httpListener.Prefixes.Add($"http://*:{_options.HttpPort}/");
_httpListener.Start();
Console.WriteLine($"[EonaCat Server] HTTP ingest on port {_options.HttpPort}");
tasks.Add(ListenHttpAsync(_cts.Token));
}
if (tasks.Count == 0)
{
Console.WriteLine("[EonaCat Server] WARNING: No transports are enabled. Check ServerOptions.");
_isRunning = false;
return;
}
await Task.WhenAll(tasks).ConfigureAwait(false);
}
/// <summary>
/// Initializes the log directory during startup, ensuring write permissions.
/// Returns true if successful, false if there were issues (but doesn't prevent startup).
/// </summary>
private bool InitializeLogDirectory()
{
try
{
var logDir = _options.LogsRootDirectory;
if (!Helpers.DirectoryPermissionHelper.EnsureDirectoryHierarchy(logDir))
{
Console.WriteLine($"[EonaCat Server] WARNING: Could not fully initialize log directory: {logDir}");
Console.WriteLine($"[EonaCat Server] Diagnosis: {Helpers.DirectoryPermissionHelper.GetAccessIssueDiagnosis(logDir)}");
return false;
}
if (!Helpers.DirectoryPermissionHelper.CanWrite(logDir))
{
Console.WriteLine($"[EonaCat Server] WARNING: Log directory exists but is not writable: {logDir}");
Console.WriteLine($"[EonaCat Server] Please check file system permissions.");
return false;
}
Console.WriteLine($"[EonaCat Server] Log directory ready: {Path.GetFullPath(logDir)}");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[EonaCat Server] Error initializing log directory: {ex.GetType().Name}: {ex.Message}");
return false;
}
}
/// <summary>Gracefully stop all transports.</summary>
public void Stop()
{
if (!_isRunning)
{
return;
}
_cts.Cancel();
_tcpListener?.Stop();
_udpListener?.Close();
_httpListener?.Stop();
foreach (var client in _activeTcpClients.Keys)
{
try { client.Close(); } catch { }
}
_cts.Dispose();
_isRunning = false;
Console.WriteLine(
$"[EonaCat Server] Stopped. " +
$"Received={Metrics.TotalReceived} Written={Metrics.TotalWritten} " +
$"Dropped={Metrics.TotalDropped} Bytes={Metrics.TotalBytes} " +
$"Uptime={Metrics.Uptime:hh\\:mm\\:ss}");
}
private async Task ListenTcpAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
await _connectionSlots.WaitAsync(ct).ConfigureAwait(false);
TcpClient client;
try
{
client = await _tcpListener!.AcceptTcpClientAsync().ConfigureAwait(false);
}
catch
{
_connectionSlots.Release();
throw;
}
_activeTcpClients.TryAdd(client, 0);
Interlocked.Increment(ref Metrics.ActiveTcpConnections);
_ = HandleTcpClientAsync(client, ct);
}
}
catch (OperationCanceledException)
{
// Do nothing
}
catch (SocketException) when (ct.IsCancellationRequested) { }
catch (Exception ex) { Console.WriteLine($"[TCP] Fatal: {ex.Message}"); }
finally { Console.WriteLine("[EonaCat Server] TCP listener stopped."); }
}
private async Task HandleTcpClientAsync(TcpClient client, CancellationToken ct)
{
var remote = client.Client.RemoteEndPoint?.ToString() ?? "unknown";
try
{
using (client)
using (var stream = client.GetStream())
using (var reader = new StreamReader(stream, Encoding.UTF8))
{
var raw = await ReadBoundedMessageAsync(reader, _options.MaxMessageChars).ConfigureAwait(false);
if (raw == null)
{
Interlocked.Increment(ref Metrics.TotalDropped);
return;
}
Interlocked.Add(ref Metrics.TotalBytes, Encoding.UTF8.GetByteCount(raw));
await DispatchAsync(raw, remote).ConfigureAwait(false);
}
}
catch (Exception ex)
{
if (!ct.IsCancellationRequested)
{
Console.WriteLine($"[TCP] {remote}: {ex.Message}");
}
}
finally
{
_activeTcpClients.TryRemove(client, out _);
Interlocked.Decrement(ref Metrics.ActiveTcpConnections);
_connectionSlots.Release();
}
}
private async Task ListenUdpAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
var result = await _udpListener!.ReceiveAsync().ConfigureAwait(false);
var remote = result.RemoteEndPoint.ToString();
Interlocked.Add(ref Metrics.TotalBytes, result.Buffer.Length);
var raw = Encoding.UTF8.GetString(result.Buffer);
await DispatchAsync(raw, remote).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Do nothing
}
catch (Exception ex) { Console.WriteLine($"[UDP] Fatal: {ex.Message}"); }
finally { Console.WriteLine("[EonaCat Server] UDP listener stopped."); }
}
private async Task ListenHttpAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
await _connectionSlots.WaitAsync(ct).ConfigureAwait(false);
HttpListenerContext context;
try
{
context = await _httpListener!.GetContextAsync().ConfigureAwait(false);
}
catch
{
_connectionSlots.Release();
throw;
}
_ = HandleHttpContextAsync(context, ct);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
catch (HttpListenerException) when (ct.IsCancellationRequested) { }
catch (Exception ex) { Console.WriteLine($"[HTTP] Fatal: {ex.Message}"); }
finally { Console.WriteLine("[EonaCat Server] HTTP listener stopped."); }
}
private async Task HandleHttpContextAsync(HttpListenerContext ctx, CancellationToken ct)
{
var req = ctx.Request;
var res = ctx.Response;
var remote = req.RemoteEndPoint?.ToString() ?? "http";
var path = req.Url?.AbsolutePath.TrimEnd('/') ?? "";
try
{
// GET /metrics
if (req.HttpMethod == "GET" && path == "/metrics")
{
var json = JsonSerializer.Serialize(new
{
totalReceived = Metrics.TotalReceived,
totalWritten = Metrics.TotalWritten,
totalDropped = Metrics.TotalDropped,
totalBytes = Metrics.TotalBytes,
activeTcpConnections = Metrics.ActiveTcpConnections,
uptimeSeconds = (long)Metrics.Uptime.TotalSeconds,
startedAt = Metrics.StartedAt.ToString("o")
});
await WriteJsonResponseAsync(res, json, 200, ct).ConfigureAwait(false);
return;
}
// POST /ingest
if (req.HttpMethod == "POST" && path == "/ingest")
{
using var reader = new StreamReader(req.InputStream, req.ContentEncoding);
var body = await ReadBoundedMessageAsync(reader, _options.MaxMessageChars).ConfigureAwait(false);
if (body == null)
{
Interlocked.Increment(ref Metrics.TotalDropped);
await WriteJsonResponseAsync(res, "{\"accepted\":false}", 413, ct).ConfigureAwait(false);
return;
}
Interlocked.Add(ref Metrics.TotalBytes, Encoding.UTF8.GetByteCount(body));
await DispatchAsync(body, remote).ConfigureAwait(false);
await WriteJsonResponseAsync(res, "{\"accepted\":true}", 202, ct).ConfigureAwait(false);
return;
}
res.StatusCode = 404;
}
catch (Exception ex)
{
Console.WriteLine($"[HTTP] Handler error: {ex.Message}");
try
{
res.StatusCode = 500;
}
catch
{
// Do nothing
}
}
finally
{
try { res.Close(); } catch { }
_connectionSlots.Release();
}
}
private static async Task<string?> ReadBoundedMessageAsync(TextReader reader, int maxChars)
{
maxChars = Math.Max(1, maxChars);
var buffer = new char[Math.Min(TcpReadBufferChars, maxChars)];
var message = new StringBuilder(Math.Min(TcpReadBufferChars, maxChars));
int charsRead;
while ((charsRead = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0)
{
if (message.Length > maxChars - charsRead)
{
return null;
}
message.Append(buffer, 0, charsRead);
}
return message.ToString();
}
private static async Task WriteJsonResponseAsync(HttpListenerResponse res, string json, int statusCode, CancellationToken ct)
{
var bytes = Encoding.UTF8.GetBytes(json);
res.StatusCode = statusCode;
res.ContentType = "application/json; charset=utf-8";
res.ContentLength64 = bytes.Length;
await res.OutputStream.WriteAsync(bytes, 0, bytes.Length, ct).ConfigureAwait(false);
}
private async Task DispatchAsync(string raw, string remote)
{
if (string.IsNullOrWhiteSpace(raw))
{
return;
}
Interlocked.Increment(ref Metrics.TotalReceived);
// Rate limiting
if (_options.RateLimitPerSecond > 0 && IsRateLimited(remote))
{
Interlocked.Increment(ref Metrics.TotalDropped);
LogDropped?.Invoke(raw);
return;
}
// Try structured JSON path first
var entries = TryParseStructured(raw);
if (entries.Count == 0)
{
// Plain text - always passes level filter
await ProcessLogAsync(raw).ConfigureAwait(false);
return;
}
foreach (var (line, level) in entries)
{
if (level < _options.MinimumLevel)
{
Interlocked.Increment(ref Metrics.TotalDropped);
LogDropped?.Invoke(line);
continue;
}
await ProcessLogAsync(line).ConfigureAwait(false);
}
}
private List<(string line, ServerLogLevel level)> TryParseStructured(string raw)
{
var trimmed = raw.Trim();
if (trimmed.Length == 0 || (trimmed[0] != '{' && trimmed[0] != '['))
{
return new List<(string, ServerLogLevel)>();
}
var result = new List<(string, ServerLogLevel)>();
try
{
using var doc = JsonDocument.Parse(trimmed);
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var el in doc.RootElement.EnumerateArray())
{
result.Add(ParseJsonEntry(el));
}
}
else
{
result.Add(ParseJsonEntry(doc.RootElement));
}
}
catch
{
// if parsing fails, treat as plain text
}
return result;
}
private static (string line, ServerLogLevel level) ParseJsonEntry(JsonElement jsonElement)
{
var level = ExtractLevel(jsonElement);
var timestamp = jsonElement.TryGetProperty("timestamp", out var tsProp) ? tsProp.GetString() ?? "" : DateTime.UtcNow.ToString("o");
var message = jsonElement.TryGetProperty("message", out var mProp) ? mProp.GetString() ?? ""
: jsonElement.TryGetProperty("Message", out var mProp2) ? mProp2.GetString() ?? "" : jsonElement.GetRawText();
var source = jsonElement.TryGetProperty("source", out var sProp) ? sProp.GetString() ?? ""
: jsonElement.TryGetProperty("application", out var aProp) ? aProp.GetString() ?? "" : "";
var host = jsonElement.TryGetProperty("host", out var hProp) ? hProp.GetString() ?? "" : "";
var exception = jsonElement.TryGetProperty("exception", out var eProp) ? eProp.GetString() : null;
var traceId = jsonElement.TryGetProperty("traceId", out var trProp) ? trProp.GetString() : null;
var stringBuilder = new StringBuilder();
stringBuilder.Append($"[{timestamp}] [{level.ToString().ToUpper()}]");
if (!string.IsNullOrEmpty(source))
{
stringBuilder.Append($" [{source}]");
}
if (!string.IsNullOrEmpty(host))
{
stringBuilder.Append($" host={host}");
}
if (!string.IsNullOrEmpty(traceId))
{
stringBuilder.Append($" trace={traceId}");
}
stringBuilder.Append($" {message}");
if (!string.IsNullOrEmpty(exception))
{
stringBuilder.Append($"\n EXCEPTION: {exception}");
}
return (stringBuilder.ToString(), level);
}
private static ServerLogLevel ExtractLevel(JsonElement element)
{
string? level = null;
foreach (var key in new[] { "level", "Level", "severity", "Severity" })
{
if (element.TryGetProperty(key, out var p))
{
level = p.GetString();
break;
}
}
return level?.ToLowerInvariant() switch
{
"trace" or "verbose" => ServerLogLevel.Trace,
"debug" => ServerLogLevel.Debug,
"info" or "information" => ServerLogLevel.Information,
"warn" or "warning" => ServerLogLevel.Warning,
"error" => ServerLogLevel.Error,
"fatal" or "critical" => ServerLogLevel.Critical,
_ => ServerLogLevel.Information
};
}
private bool IsRateLimited(string remote)
{
var now = DateTime.UtcNow;
PruneRateLimiter(now);
var entry = _rateLimiter.GetOrAdd(remote, _ => (now, 0));
if ((now - entry.window).TotalSeconds >= 1.0)
{
_rateLimiter[remote] = (now, 1);
return false;
}
if (entry.count >= _options.RateLimitPerSecond)
{
return true;
}
_rateLimiter[remote] = (entry.window, entry.count + 1);
return false;
}
private void PruneRateLimiter(DateTime now)
{
lock (_rateLimiterCleanupLock)
{
if (now - _lastRateLimiterCleanupUtc < TimeSpan.FromMinutes(1))
{
return;
}
_lastRateLimiterCleanupUtc = now;
}
var cutoff = now.AddMinutes(-1);
var entries = (ICollection<KeyValuePair<string, (DateTime window, int count)>>)_rateLimiter;
foreach (var entry in _rateLimiter)
{
if (entry.Value.window < cutoff)
{
entries.Remove(entry);
}
}
}
protected virtual async Task ProcessLogAsync(string logData)
{
const int maxRetries = 3;
const int retryDelayMs = 100;
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
var root = _options.LogsRootDirectory;
// Ensure directory exists with permission handling
if (!EnsureLogDirectory(root))
{
if (attempt < maxRetries - 1)
{
await Task.Delay(retryDelayMs).ConfigureAwait(false);
continue;
}
// Last attempt failed - log that we couldn't write
Console.WriteLine($"[EonaCat Server] WARNING: Cannot write to log directory. Reason: {Helpers.DirectoryPermissionHelper.GetAccessIssueDiagnosis(root)}");
Interlocked.Increment(ref Metrics.TotalDropped);
return;
}
var daily = Path.Combine(root, DateTime.Now.ToString("yyyyMMdd"));
if (!EnsureLogDirectory(daily))
{
if (attempt < maxRetries - 1)
{
await Task.Delay(retryDelayMs).ConfigureAwait(false);
continue;
}
Console.WriteLine($"[EonaCat Server] WARNING: Cannot create daily log directory: {daily}");
Interlocked.Increment(ref Metrics.TotalDropped);
return;
}
var basePath = Path.Combine(daily, "EonaCatLogs");
var filePath = basePath + ".log";
int idx = 1;
while (File.Exists(filePath) && new FileInfo(filePath).Length > _options.MaxLogFileSize)
{
filePath = $"{basePath}_{idx++}.log";
}
using (var stream = new FileStream(filePath, FileMode.Append, FileAccess.Write, FileShare.Read | FileShare.Delete))
using (var writer = new StreamWriter(stream))
{
writer.WriteLine(logData);
}
Interlocked.Increment(ref Metrics.TotalWritten);
LogWritten?.Invoke(logData);
ScheduleLogCleanup();
return;
}
catch (UnauthorizedAccessException ex) when (attempt < maxRetries - 1)
{
Console.WriteLine($"[EonaCat Server] Access denied to log directory (attempt {attempt + 1}/{maxRetries}): {ex.Message}");
await Task.Delay(retryDelayMs * (attempt + 1)).ConfigureAwait(false);
}
catch (IOException ex) when (attempt < maxRetries - 1)
{
Console.WriteLine($"[EonaCat Server] IO error writing to log (attempt {attempt + 1}/{maxRetries}): {ex.Message}");
await Task.Delay(retryDelayMs * (attempt + 1)).ConfigureAwait(false);
}
catch (Exception ex)
{
// Any other exception - log and drop the message to prevent crash
Console.WriteLine($"[EonaCat Server] Error writing log: {ex.GetType().Name}: {ex.Message}");
Interlocked.Increment(ref Metrics.TotalDropped);
LogDropped?.Invoke(logData);
return;
}
}
// All retries exhausted
Console.WriteLine("[EonaCat Server] Max retries exhausted, dropping log message");
Interlocked.Increment(ref Metrics.TotalDropped);
LogDropped?.Invoke(logData);
}
/// <summary>
/// Ensures a log directory exists and is writable, with permission fixing if needed.
/// Returns true if successful, false otherwise.
/// </summary>
private bool EnsureLogDirectory(string dirPath)
{
try
{
return Helpers.DirectoryPermissionHelper.EnsureDirectoryHierarchy(dirPath);
}
catch (Exception ex)
{
Console.WriteLine($"[EonaCat Server] Failed to ensure log directory: {ex.Message}");
return false;
}
}
private void ScheduleLogCleanup()
{
lock (_cleanupLock)
{
var now = DateTime.UtcNow;
if (_cleanupRunning || now < _nextCleanupUtc)
{
return;
}
_cleanupRunning = true;
_nextCleanupUtc = now.AddMinutes(5);
}
_ = Task.Run(() =>
{
try
{
CleanUpOldLogs();
}
catch (Exception ex)
{
Console.WriteLine($"[Retention] Cleanup failed: {ex.Message}");
}
finally
{
lock (_cleanupLock)
{
_cleanupRunning = false;
}
}
});
}
private void CleanUpOldLogs()
{
var root = _options.LogsRootDirectory;
if (!Directory.Exists(root))
{
return;
}
foreach (var dir in Directory.GetDirectories(root))
{
try
{
if (new DirectoryInfo(dir).CreationTime < DateTime.Now.AddDays(-_options.LogRetentionDays))
{
Directory.Delete(dir, true);
Console.WriteLine($"[Retention] Deleted expired directory: {dir}");
}
}
catch (Exception ex) { Console.WriteLine($"[Retention] Error: {ex.Message}"); }
}
long total = GetDirectorySize(root);
if (total <= _options.MaxLogDirectorySize)
{
return;
}
Console.WriteLine($"[Retention] Directory {total / (1024 * 1024)} MB over limit. Purging oldest...");
foreach (var dir in Directory.GetDirectories(root).OrderBy(d => new DirectoryInfo(d).CreationTime))
{
try
{
total -= GetDirectorySize(dir);
Directory.Delete(dir, true);
Console.WriteLine($"[Retention] Purged: {dir}");
if (total <= _options.MaxLogDirectorySize)
{
break;
}
}
catch (Exception ex) { Console.WriteLine($"[Retention] Error purging {dir}: {ex.Message}"); }
}
}
private static long GetDirectorySize(string directory)
{
long size = 0;
try
{
size += Directory.GetFiles(directory).Sum(f => new FileInfo(f).Length);
foreach (var sub in Directory.GetDirectories(directory))
{
size += GetDirectorySize(sub);
}
}
catch
{
// ignored
}
return size;
}
}
}