// 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"; 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) { string timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + "000000"; string message = RenderLoggingEvent(loggingEvent); _logQueue.Enqueue(new Tuple(timestamp, message)); // Start background batch loop once if (!_batchingStarted) { lock (_batchLock) { 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 = "info" // Optional: capture log level from batch }, values = batch.ConvertAll(e => new[] { e.Item1, e.Item2 }) } } }; string json = JsonConvert.SerializeObject(payload); var content = new StringContent(json, Encoding.UTF8, "application/json"); 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 } }