feat(worker): route Serilog Warn/Error to footer + buffer recent logs for overlay

This commit is contained in:
Mika Kuns
2026-06-23 08:55:06 +02:00
parent eb0ddb56d3
commit 08a4f97a78
7 changed files with 334 additions and 2 deletions

View File

@@ -0,0 +1,107 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Logging;
using Serilog.Events;
using Serilog.Parsing;
namespace ClaudeDo.Worker.Tests.Logging;
public class BroadcastLogSinkTests
{
private static readonly DateTimeOffset EvtTime = new(2026, 6, 23, 8, 0, 0, TimeSpan.Zero);
private static readonly MessageTemplateParser Parser = new();
private static LogEvent Evt(LogEventLevel level, string template, Exception? ex = null, string? sourceContext = null)
{
var props = new List<LogEventProperty>();
if (sourceContext is not null)
props.Add(new LogEventProperty("SourceContext", new ScalarValue(sourceContext)));
return new LogEvent(EvtTime, level, ex, Parser.Parse(template), props);
}
private static (BroadcastLogSink sink, LogRingBuffer buffer, List<(string msg, WorkerLogLevel lvl)> calls)
NewSink(Func<DateTime> clock)
{
var buffer = new LogRingBuffer(TimeSpan.FromHours(1), utcNow: () => new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc));
var sink = new BroadcastLogSink(buffer, clock);
var calls = new List<(string, WorkerLogLevel)>();
sink.Attach((m, l, _) => { calls.Add((m, l)); return Task.CompletedTask; });
return (sink, buffer, calls);
}
[Fact]
public void Buffers_all_levels()
{
var (sink, buffer, _) = NewSink(() => EvtTime.UtcDateTime);
sink.Emit(Evt(LogEventLevel.Information, "info"));
sink.Emit(Evt(LogEventLevel.Warning, "warn"));
sink.Emit(Evt(LogEventLevel.Error, "err"));
Assert.Equal(3, buffer.Snapshot().Count);
}
[Fact]
public void Broadcasts_only_warn_and_error()
{
var (sink, _, calls) = NewSink(() => EvtTime.UtcDateTime);
sink.Emit(Evt(LogEventLevel.Information, "info"));
sink.Emit(Evt(LogEventLevel.Warning, "warn"));
sink.Emit(Evt(LogEventLevel.Error, "err"));
Assert.Equal(new[] { WorkerLogLevel.Warn, WorkerLogLevel.Error }, calls.Select(c => c.lvl));
}
[Fact]
public void Dedupes_repeat_within_window_but_still_buffers()
{
var now = EvtTime.UtcDateTime;
var (sink, buffer, calls) = NewSink(() => now);
sink.Emit(Evt(LogEventLevel.Warning, "same"));
now = now.AddSeconds(30);
sink.Emit(Evt(LogEventLevel.Warning, "same"));
Assert.Single(calls); // second broadcast suppressed
Assert.Equal(2, buffer.Snapshot().Count); // both buffered
}
[Fact]
public void Allows_repeat_after_window()
{
var now = EvtTime.UtcDateTime;
var (sink, _, calls) = NewSink(() => now);
sink.Emit(Evt(LogEventLevel.Warning, "same"));
now = now.AddSeconds(121);
sink.Emit(Evt(LogEventLevel.Warning, "same"));
Assert.Equal(2, calls.Count);
}
[Fact]
public void Appends_exception_to_message()
{
var (sink, _, calls) = NewSink(() => EvtTime.UtcDateTime);
sink.Emit(Evt(LogEventLevel.Error, "boom", new InvalidOperationException("bad dir")));
Assert.Single(calls);
Assert.Equal("boom: InvalidOperationException: bad dir", calls[0].msg);
}
[Fact]
public void Plumbing_source_buffered_but_not_broadcast()
{
var (sink, buffer, calls) = NewSink(() => EvtTime.UtcDateTime);
sink.Emit(Evt(LogEventLevel.Error, "transport hiccup", sourceContext: "Microsoft.AspNetCore.SignalR.HubConnectionHandler"));
Assert.Empty(calls);
Assert.Single(buffer.Snapshot());
}
[Fact]
public void Does_not_throw_when_detached()
{
var buffer = new LogRingBuffer(TimeSpan.FromHours(1));
var sink = new BroadcastLogSink(buffer);
sink.Emit(Evt(LogEventLevel.Error, "no subscriber"));
Assert.Single(buffer.Snapshot());
}
}

View File

@@ -0,0 +1,48 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Logging;
namespace ClaudeDo.Worker.Tests.Logging;
public class LogRingBufferTests
{
private static readonly DateTime Now = new(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc);
private static WorkerLogRecord Rec(DateTime ts, string msg = "m", WorkerLogLevel lvl = WorkerLogLevel.Info)
=> new(msg, lvl, ts);
[Fact]
public void Evicts_records_older_than_window_on_append()
{
var buf = new LogRingBuffer(TimeSpan.FromMinutes(30), utcNow: () => Now);
buf.Append(Rec(Now.AddMinutes(-40), "old"));
buf.Append(Rec(Now.AddMinutes(-10), "recent"));
var snap = buf.Snapshot();
Assert.Single(snap);
Assert.Equal("recent", snap[0].Message);
}
[Fact]
public void Evicts_oldest_beyond_cap()
{
var buf = new LogRingBuffer(TimeSpan.FromHours(1), cap: 3, utcNow: () => Now);
for (var i = 0; i < 5; i++) buf.Append(Rec(Now, $"m{i}"));
var snap = buf.Snapshot();
Assert.Equal(3, snap.Count);
Assert.Equal(new[] { "m2", "m3", "m4" }, snap.Select(r => r.Message));
}
[Fact]
public void Snapshot_is_oldest_first()
{
var buf = new LogRingBuffer(TimeSpan.FromHours(1), utcNow: () => Now);
buf.Append(Rec(Now.AddMinutes(-3), "a"));
buf.Append(Rec(Now.AddMinutes(-2), "b"));
buf.Append(Rec(Now.AddMinutes(-1), "c"));
Assert.Equal(new[] { "a", "b", "c" }, buf.Snapshot().Select(r => r.Message));
}
}