using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using ClaudeDo.Data.Models; using Serilog.Core; using Serilog.Events; namespace ClaudeDo.Worker.Logging; /// /// Serilog sink that (a) buffers every event (all levels) into /// for the Log Visualizer overlay, and (b) broadcasts Warn/Error events to the UI footer /// via the attached delegate — deduped within a rate-limit window, with a loop guard so /// SignalR's own log output cannot feed back into another broadcast. /// public sealed class BroadcastLogSink : ILogEventSink { private static readonly TimeSpan RateLimitWindow = TimeSpan.FromSeconds(120); private readonly LogRingBuffer _buffer; private readonly Func _utcNow; private readonly Dictionary _lastBroadcast = new(); private readonly object _gate = new(); private Func? _broadcast; public BroadcastLogSink(LogRingBuffer buffer, Func? utcNow = null) { _buffer = buffer; _utcNow = utcNow ?? (() => DateTime.UtcNow); } /// Wired post-build, once the SignalR hub context exists. public void Attach(Func broadcast) => _broadcast = broadcast; public void Emit(LogEvent logEvent) { var level = Map(logEvent.Level); var message = Render(logEvent); var tsUtc = logEvent.Timestamp.UtcDateTime; _buffer.Append(new WorkerLogRecord(message, level, tsUtc)); if (level is not (WorkerLogLevel.Warn or WorkerLogLevel.Error)) return; if (IsPlumbing(logEvent)) return; var broadcast = _broadcast; if (broadcast is null) return; lock (_gate) { if (_lastBroadcast.TryGetValue(message, out var last) && _utcNow() - last < RateLimitWindow) return; _lastBroadcast[message] = _utcNow(); Prune(); } // Fire-and-forget. A broadcast failure itself logs (re-entering this sink), so it // must never throw out of Emit nor leave an unobserved faulted task. try { _ = broadcast(message, level, tsUtc).ContinueWith(t => { _ = t.Exception; }, TaskScheduler.Default); } catch { /* swallow — logging must not crash on a transport hiccup */ } } private static WorkerLogLevel Map(LogEventLevel level) => level switch { LogEventLevel.Warning => WorkerLogLevel.Warn, LogEventLevel.Error or LogEventLevel.Fatal => WorkerLogLevel.Error, _ => WorkerLogLevel.Info, }; private static string Render(LogEvent e) { var msg = e.RenderMessage(); if (e.Exception is { } ex) { var first = ex.Message.Split('\n', 2)[0].Trim(); msg = $"{msg}: {ex.GetType().Name}: {first}"; } return msg; } private static bool IsPlumbing(LogEvent e) { if (!e.Properties.TryGetValue("SourceContext", out var v) || v is not ScalarValue { Value: string sc }) return false; return sc.StartsWith("Microsoft.AspNetCore.SignalR", StringComparison.Ordinal) || sc.StartsWith("Microsoft.AspNetCore.Http.Connections", StringComparison.Ordinal); } private void Prune() { if (_lastBroadcast.Count <= 256) return; var cutoff = _utcNow() - RateLimitWindow; foreach (var key in _lastBroadcast.Where(kv => kv.Value < cutoff).Select(kv => kv.Key).ToList()) _lastBroadcast.Remove(key); } }