diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index 00291b10..b5bfb265 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -89,7 +89,7 @@ new editor boilerplate there rather than copying it a third time. - **IPrimeScheduleApi** — prime-schedule CRUD. - **UpdateCheckService** — polls releases; `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` feed the shell's update banner. - **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges. -- **OperationTiming** — NDJSON sink (`~/.todo-app/logs/operation-timing.ndjson`, one rolled `.1` at 4 MB) behind every hub invoke and bulk DB path; each line carries `pid` because app restarts interleave in one file. Successful calls under 25 ms are dropped, **failed/cancelled ones always land** — a 1 ms cancelled `BindAsync` is churn signal, not noise. `DetailsIsland.BindAsync:` carries the selection trigger (`TasksIslandViewModel.SelectionSource`, set via `SelectFrom` — never assign `SelectedTask` directly). `Shared` is a settable static singleton **on purpose** — `Ui.Tests`/`Worker.Tests` each carry a `TestSetup` module initializer that redirects it to a temp file before any test runs, because both projects construct real ViewModels (`DetailsIslandViewModel`, `TasksIslandViewModel`) that call `Shared.Record` directly; without the redirect a single test run appends 50-100 lines straight into the live app's log (a day of ~1900 `BindAsync` lines, 91-96% cancelled, turned out to be 13 such test runs plus pre-fix-binary artifacts — real user-driven binds that day: 4). +- **OperationTiming** — NDJSON sink (`~/.todo-app/logs/operation-timing.ndjson`, one rolled `.1` at 4 MB) behind every hub invoke and bulk DB path; each line carries `pid` because app restarts interleave in one file. Standardmäßig aus — `Shared` schreibt nur, wenn die Umgebungsvariable `CLAUDEDO_OP_TIMING=1` beim Prozessstart gesetzt ist (einmal statisch gelesen, kein Overhead pro Call). Successful calls under 25 ms are dropped, **failed/cancelled ones always land** — a 1 ms cancelled `BindAsync` is churn signal, not noise. `DetailsIsland.BindAsync:` carries the selection trigger (`TasksIslandViewModel.SelectionSource`, set via `SelectFrom` — never assign `SelectedTask` directly). `Shared` is a settable static singleton **on purpose** — `Ui.Tests`/`Worker.Tests` each carry a `TestSetup` module initializer that redirects it to a temp file before any test runs (still writing — the kill switch only gates `Shared`'s default, not the constructor), because both projects construct real ViewModels (`DetailsIslandViewModel`, `TasksIslandViewModel`) that call `Shared.Record` directly; without the redirect a single test run appends 50-100 lines straight into the live app's log (a day of ~1900 `BindAsync` lines, 91-96% cancelled, turned out to be 13 such test runs plus pre-fix-binary artifacts — real user-driven binds that day: 4). - **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**. ## Converters diff --git a/src/ClaudeDo.Ui/Services/OperationTiming.cs b/src/ClaudeDo.Ui/Services/OperationTiming.cs index 5d10a480..14cd75c9 100644 --- a/src/ClaudeDo.Ui/Services/OperationTiming.cs +++ b/src/ClaudeDo.Ui/Services/OperationTiming.cs @@ -7,6 +7,7 @@ namespace ClaudeDo.Ui.Services; /// Appends one NDJSON line per timed operation (hub invoke, bulk DB read/write) to a log file, so /// a day of normal usage yields a file that can be sorted by duration to find real outliers. /// Never throws — a failed write is swallowed, because a measurement must never disturb the app. +/// is off by default; set CLAUDEDO_OP_TIMING=1 to turn it back on. /// public sealed class OperationTiming { @@ -21,29 +22,38 @@ public sealed class OperationTiming public static string DefaultPath => Path.Combine(Paths.AppDataRoot(), "logs", "operation-timing.ndjson"); + /// Kill switch for : off by default, set + /// CLAUDEDO_OP_TIMING=1 in the environment to turn the sink back on. + private static readonly bool DefaultEnabled = + Environment.GetEnvironmentVariable("CLAUDEDO_OP_TIMING") == "1"; + // Settable, not just get-only: xUnit constructs real ViewModels (DetailsIslandViewModel, // TasksIslandViewModel, ...) that call Shared.Record directly, so without a way to redirect // it every test run appended its churn straight into the live app's NDJSON file at the real // AppData path — see Ui.Tests' TestSetup module initializer, which points this at a temp file // for the whole test process before any test runs. - public static OperationTiming Shared { get; set; } = new(DefaultPath); + public static OperationTiming Shared { get; set; } = new(DefaultPath, enabled: DefaultEnabled); private static readonly int ProcessId = Environment.ProcessId; private readonly string _path; private readonly int _minMs; private readonly long _maxBytes; + private readonly bool _enabled; private readonly object _writeLock = new(); - public OperationTiming(string filePath, int minMs = DefaultMinMs, long maxBytes = DefaultMaxBytes) + public OperationTiming(string filePath, int minMs = DefaultMinMs, long maxBytes = DefaultMaxBytes, bool enabled = true) { _path = filePath; _minMs = minMs; _maxBytes = maxBytes; + _enabled = enabled; } public void Record(string kind, string operation, TimeSpan elapsed, bool ok) { + if (!_enabled) return; + try { var ms = (long)elapsed.TotalMilliseconds; diff --git a/tests/ClaudeDo.Ui.Tests/Services/OperationTimingTests.cs b/tests/ClaudeDo.Ui.Tests/Services/OperationTimingTests.cs index 38acf017..7e90944f 100644 --- a/tests/ClaudeDo.Ui.Tests/Services/OperationTimingTests.cs +++ b/tests/ClaudeDo.Ui.Tests/Services/OperationTimingTests.cs @@ -96,6 +96,27 @@ public class OperationTimingTests } } + [Fact] + public void Record_DoesNothingWhenDisabled() + { + var dir = Path.Combine(Path.GetTempPath(), "claudedo_optiming_" + Guid.NewGuid().ToString("N")); + var path = Path.Combine(dir, "operation-timing.ndjson"); + var sink = new OperationTiming(path, enabled: false); + + try + { + var exception = Record.Exception(() => sink.Record("hub", "RunNow", TimeSpan.FromMilliseconds(1), ok: false)); + + Assert.Null(exception); + Assert.False(File.Exists(path)); + Assert.False(Directory.Exists(dir)); + } + finally + { + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + } + [Fact] public void Record_SwallowsWriteFailureInsteadOfThrowing() {