6.3 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, detected at runtime — no runtime knob, no config field, no #if DEBUG:
- Debug build (Rider run button) → verbose, console + file.
- Release build (installed app) → minimal, file only.
Decisions (from brainstorming)
- Mechanism: runtime build-config detection via the entry assembly's
DebuggableAttribute(JIT optimizer disabled ⇒ Debug build). A singleBuildConfig.IsDebughelper drives ordinaryifbranching — no#if DEBUGdirectives. Rider's run button buildsDebug; the installer ships-c Release. - Scope: Worker and App/Ui. The desktop side currently has no log sink at all — UI/IPC failures vanish today.
- Release behavior: all three log
Warning+ to file (not silent — capture crashes). Worker drops from its currentInformationtoWarning. - 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 |
Warning 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: 2.- 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 project:
ClaudeDo.Logging— a small library bothClaudeDo.AppandClaudeDo.Workerreference (keepsClaudeDo.Datafree of any Serilog dependency). Contains:BuildConfig.IsDebug— checks the entry assembly'sDebuggableAttribute(IsJITOptimizerDisabled⇒ Debug build). Cached static.- The output template and the default-TaskId enricher.
ConfigureLogger(LoggerConfiguration, processTag, logRoot)— applies level/sink choices by branching onBuildConfig.IsDebug(Debug ⇒Debuglevel + console + file; Release ⇒Warninglevel + file only). Both processes call it so level/template/retention stay in sync.
- Worker
Program.cs:34: replace the inlineUseSerilogbody with a call into the shared helper (processTag = "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 default enricher yields
-when noTaskIdis pushed, and (if practical) thatConfigureLoggerwires the expected sinks.BuildConfig.IsDebugreflects the test assembly's own build config, so it can't be flipped within one run — assert each branch by passing the level/flag explicitly rather than relying on the ambient value, or verify the Release path 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.