5.9 KiB
Debug Logging & Frontend↔Backend Traceability — Design
Date: 2026-06-04 Status: Approved (pending spec review)
Goal
Make debug logging rich enough to diagnose problems across the UI↔Worker boundary, while keeping the installed (production) build near-silent. Verbosity is decided by build configuration — no runtime knob, no config field:
- Debug build (Rider run button) → verbose, console + file.
- Release build (installed app) → minimal, file only.
Decisions (from brainstorming)
- Mechanism:
#if DEBUGbuild-config split. Rider buildsDebug; the installer shipsRelease. - Scope: Worker and App/Ui. The desktop side currently has no log sink at all — UI/IPC failures vanish today.
- Release behavior: App/Ui log
Warning+ to file (not silent — capture crashes). Worker unchanged atInformation. - One shared log file across both processes, unified timeline.
- Correlation: TaskId-based (option A). Enrich log lines with
TaskIdwhen one is in scope. No changes to the SignalR contract (IWorkerClient/WorkerHubuntouched → test fakes untouched).
Verbosity matrix
| Process | Debug build | Release build |
|---|---|---|
| Worker | Debug level, console + shared file |
Information level, shared file |
| App/Ui | Debug level, console + shared file |
Warning level, shared file |
Shared log file
- Single daily-rolling file:
~/.todo-app/logs/claudedo-.log(Serilog appends the date). shared: trueon both processes' file sinks → Serilog coordinates multi-process writes via a global mutex.retainedFileCountLimit: 7(matches current Worker retention).- Each line is tagged with a
Processproperty ("worker"/"app") so the two sides are distinguishable in the interleaved timeline.
The existing
worker-.logis replaced byclaudedo-.log. Task-run NDJSON ({taskId}_run{n}.ndjson) anddaily-prep.logare out of scope — they are data streams, not diagnostic logs, and stay exactly as they are.
Output template
[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Process}/{SourceContext} [{TaskId}] {Message:lj}{NewLine}{Exception}
{Process}—workerorapp.{SourceContext}— theILogger<T>category (the logging class), so you see which component spoke.{TaskId}— the correlation key, defaulted to-when no task is in scope (see enricher below).
Traceability (TaskId correlation)
Use Serilog's LogContext (.Enrich.FromLogContext() on both processes) plus a small default enricher so TaskId is always present (renders - when absent — avoids the raw {TaskId} token leaking into output).
Push the property at the entry points where a task is in scope; all nested ILogger<T> calls inherit it automatically:
- Worker: wrap per-task execution in
TaskRunner(the run/continue entry) withusing (LogContext.PushProperty("TaskId", task.Id)). This covers the bulk of backend activity (runner, state transitions, worktree, planning) for free. - App/Ui: push
TaskIdinWorkerClienttask-targeted calls (e.g. RunNow / Cancel / Continue / review actions) so the UI side of a task action carries the same key.
Result: grep one TaskId in claudedo-.log and read the full UI→Worker→UI story in timestamp order.
This adds no parameters to the SignalR surface — correlation rides on the existing taskId arguments already present in those calls.
Implementation surface
A single shared helper keeps the two processes' Serilog setup from drifting.
- New:
ClaudeDo.Data/Logging/LoggingSetup.cs(or similar shared location reachable by both App and Worker) exposing the output template, the default-TaskId enricher, and aConfigureLogger(LoggerConfiguration, Process, logRoot)that applies the#if DEBUGlevel/sink choices. Both processes call it so level/template/retention stay in sync.- Placement note: must live in a project both
ClaudeDo.AppandClaudeDo.Workerreference.ClaudeDo.Dataqualifies; if it should not take a Serilog dependency, use a tiny new shared project. Decide during planning.
- Placement note: must live in a project both
- Worker
Program.cs:34: replace the inlineUseSerilogbody with a call into the shared helper (Process = "worker"). - App
Program.cs: add Serilog packages; build a logger via the shared helper (Process = "app") and register it withsc.AddLogging(b => b.AddSerilog(logger, dispose: true)). App currently registers no logging at all, so this also makesILogger<T>injection actually work UI-side. Remove/keep.LogToTrace()as appropriate (Avalonia internal trace, separate concern — leave it). - App shutdown: flush/close the logger (
Log.CloseAndFlush()or dispose via the container's existingfinally).
Packages to add (App project)
Serilog.Extensions.Logging(bridgeILogger→ Serilog)Serilog.Sinks.FileSerilog.Sinks.Console- (Worker already has Serilog + File sink; add
Serilog.Sinks.Consolefor the Debug console output.)
Testing
- This is logging wiring; per project policy, no tests that spawn the real Claude CLI and no heavy test scaffolding for log output.
- Light verification: a unit-level check that the shared helper produces the expected minimum level per build config (Debug vs Release) and that the default enricher yields
-when noTaskIdis pushed. If asserting on#if DEBUGis awkward in a single test run, verify the Release path (the one that matters for prod) and smoke-test Debug manually from Rider. - Manual smoke test (documented, not automated): run from Rider, confirm console +
claudedo-.logshowDebuglines withProcess/SourceContext; run a task and confirm bothappandworkerlines share the same[TaskId].
Out of scope
- Runtime/config log-level knob.
- Per-call correlation IDs for non-task flows (connect, config edits, prep) — TaskId-only for now; revisit if a non-task flow proves to be a black hole.
- Changes to task-run NDJSON capture or
daily-prep.log. - Any change to
IWorkerClient/WorkerHubsignatures.