diff --git a/bsmd.util/LokiAppender.cs b/bsmd.util/LokiAppender.cs index 5eb385fb..b75d62f3 100644 --- a/bsmd.util/LokiAppender.cs +++ b/bsmd.util/LokiAppender.cs @@ -1,59 +1,155 @@ -namespace bsmd.util +// Copyright (c) 2025 schick Informatik +// Description: Appender for Loki/Grafana remote logging +// + +namespace bsmd.util { using log4net.Appender; using log4net.Core; using Newtonsoft.Json; using System; + using System.Collections.Concurrent; + using System.Collections.Generic; using System.Net.Http; using System.Text; + using System.Threading; using System.Threading.Tasks; public class LokiAppender : AppenderSkeleton { + #region Fields + + private static readonly HttpClient _httpClient = new HttpClient(); + + private readonly ConcurrentQueue> _logQueue = new ConcurrentQueue>(); + + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + + private bool _batchingStarted = false; + + private readonly object _batchLock = new object(); + + #endregion + + #region Properties + public string LokiUrl { get; set; } public string ApplicationName { get; set; } = "log4net-app"; - private static readonly HttpClient httpClient = new HttpClient(); + public int BatchSize { get; set; } = 10; + + public int BatchIntervalMs { get; set; } = 2000; + + public int MaxRetries { get; set; } = 5; + + #endregion + + #region overrides protected override void Append(LoggingEvent loggingEvent) { - try - { - var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString() + "000000"; // nanoseconds - var message = RenderLoggingEvent(loggingEvent); + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + "000000"; + string message = RenderLoggingEvent(loggingEvent); - var payload = new + _logQueue.Enqueue(new Tuple(timestamp, message)); + + // Start background batch loop once + if (!_batchingStarted) + { + lock (_batchLock) { - streams = new[] + if (!_batchingStarted) { + _ = Task.Run(() => BatchLoop(_cts.Token)); + _batchingStarted = true; + } + } + } + } + + private async Task BatchLoop(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + await Task.Delay(BatchIntervalMs, ct); + + var batch = new List>(); + while (batch.Count < BatchSize && _logQueue.TryDequeue(out var entry)) + batch.Add(entry); + + if (batch.Count == 0) continue; + + var payload = new + { + streams = new[] + { new { stream = new { app = ApplicationName, - level = loggingEvent.Level.Name.ToLower() + level = "info" // Optional: capture log level from batch }, - values = new[] - { - new[] { timestamp, message } - } + values = batch.ConvertAll(e => new[] { e.Item1, e.Item2 }) } } - }; + }; - var json = JsonConvert.SerializeObject(payload); + string json = JsonConvert.SerializeObject(payload); + var content = new StringContent(json, Encoding.UTF8, "application/json"); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - // Fire and forget - Task.Run(() => httpClient.PostAsync(LokiUrl, content)); - } - catch (Exception ex) - { - ErrorHandler.Error("Error sending log to Loki", ex); + await SendWithRetry(content, MaxRetries, ct); + } + catch (OperationCanceledException) + { + // Graceful shutdown + } + catch (Exception ex) + { + ErrorHandler.Error("Error in Loki batch loop", ex); + } } } + + private async Task SendWithRetry(HttpContent content, int maxRetries, CancellationToken ct) + { + int attempt = 0; + TimeSpan delay = TimeSpan.FromSeconds(1); + + while (attempt <= maxRetries && !ct.IsCancellationRequested) + { + try + { + var response = await _httpClient.PostAsync(LokiUrl, content, ct); + if (response.IsSuccessStatusCode) + return; + + attempt++; + await Task.Delay(delay, ct); + delay = TimeSpan.FromSeconds(delay.TotalSeconds * 2); // exponential backoff + } + catch + { + attempt++; + await Task.Delay(delay, ct); + delay = TimeSpan.FromSeconds(delay.TotalSeconds * 2); + } + } + + ErrorHandler.Error($"Failed to send logs to Loki after {attempt} retries."); + } + + protected override void OnClose() + { + _cts.Cancel(); + base.OnClose(); + } + + #endregion + } } diff --git a/bsmd.util/bsmd.util.csproj b/bsmd.util/bsmd.util.csproj index eae53f0c..bd8f6a44 100644 --- a/bsmd.util/bsmd.util.csproj +++ b/bsmd.util/bsmd.util.csproj @@ -53,6 +53,7 @@ + diff --git a/bsmd.util/bsmd.util.licenseheader b/bsmd.util/bsmd.util.licenseheader new file mode 100644 index 00000000..dae0ba0c --- /dev/null +++ b/bsmd.util/bsmd.util.licenseheader @@ -0,0 +1,5 @@ +extensions: designer.cs generated.cs +extensions: .cs .cpp .h +// Copyright (c) 2025 schick Informatik +// Description: +// \ No newline at end of file