feat(worker): route Serilog Warn/Error to footer + buffer recent logs for overlay
This commit is contained in:
100
src/ClaudeDo.Worker/Logging/BroadcastLogSink.cs
Normal file
100
src/ClaudeDo.Worker/Logging/BroadcastLogSink.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Serilog sink that (a) buffers every event (all levels) into <see cref="LogRingBuffer"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class BroadcastLogSink : ILogEventSink
|
||||
{
|
||||
private static readonly TimeSpan RateLimitWindow = TimeSpan.FromSeconds(120);
|
||||
|
||||
private readonly LogRingBuffer _buffer;
|
||||
private readonly Func<DateTime> _utcNow;
|
||||
private readonly Dictionary<string, DateTime> _lastBroadcast = new();
|
||||
private readonly object _gate = new();
|
||||
private Func<string, WorkerLogLevel, DateTime, Task>? _broadcast;
|
||||
|
||||
public BroadcastLogSink(LogRingBuffer buffer, Func<DateTime>? utcNow = null)
|
||||
{
|
||||
_buffer = buffer;
|
||||
_utcNow = utcNow ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Wired post-build, once the SignalR hub context exists.</summary>
|
||||
public void Attach(Func<string, WorkerLogLevel, DateTime, Task> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user