docs/guides/diagnostics.md
Opt-in performance instrumentation for the main process, gated by the CS_DIAGNOSTICS env var. Default off → zero overhead in a normal run.
The facility lives in src/main/core/diagnostics.ts; individual probes live next to the code they measure and check the same DIAGNOSTICS_ENABLED flag.
Diagnostics is controlled entirely by the CS_DIAGNOSTICS env var reaching the main process; all signals turn on together. The packaged build is not special — the same production binary runs with extra instrumentation when the flag is present.
CS_DIAGNOSTICS=1 pnpm dev
Double-clicking from Finder / Dock / Start Menu does not pass shell env vars, so diagnostics stays off. Launch the app binary from a terminal so the variable reaches the main process:
# macOS
CS_DIAGNOSTICS=1 "/Applications/Cherry Studio.app/Contents/MacOS/Cherry Studio"
# Windows (PowerShell) — adjust the path to your install location
$env:CS_DIAGNOSTICS=1; & "$env:LOCALAPPDATA\Programs\Cherry Studio\Cherry Studio.exe"
# Linux (AppImage; or the installed binary, whose path varies by distro)
CS_DIAGNOSTICS=1 "./Cherry Studio-<version>-<arch>.AppImage"
Both dev and packaged runs write to the app logs directory (~/Library/Logs/CherryStudio/ on macOS; application.getPath('app.logs')): signals stream into app.<date>.log, and the CPU profile lands beside it as boot-whenReady.cpuprofile.
| Signal | Log tag | Where | What it tells you |
|---|---|---|---|
| Per-service init timing | [Diagnostics/_doInit] | BaseService._doInit | onInit vs onReady duration per service. Wall-clock — in a parallel layer it absorbs sibling sync work (see Caveats). |
| Phase service spans | [Diagnostics] | LifecycleManager.startPhase | Each service's start/end offset from the phase epoch. A whole layer ending at the same instant ⇒ one service holds the thread. |
| Event-loop lag | [Diagnostics] | LifecycleManager.startPhase | totalLag high ⇒ loop blocked by sync work; near-zero over a long span ⇒ IO/macrotask bound. fires=0 ⇒ pure microtask cascade (timer never ran). |
| whenReady CPU profile | [Diagnostics] CPU profile written to … | LifecycleManager.startPhase | V8 sampling profile of the whenReady phase. Self-time by function — the only reliable attribution when startup is one microtask chain. |
| Slow DB queries | [Diagnostics/slow-query] | DbService.installSlowQueryProbe | Any query >15ms: duration, row count, SQL, caller stack. Covers single statements, multi-statement exec blocks, and transaction interiors. |
| Slow IPC handlers | [Diagnostics/ipc] | BaseService.ipcHandle | Any service IPC handler >50ms: duration + channel. Covers handlers registered via this.ipcHandle() (most); direct ipcMain.handle in ipc.ts is not covered. |
| Window creation | [Diagnostics/window] | WindowManager.createWindow | Per window: synchronous construction cost, then ready-to-show paint latency from the same start. |
| Slow DataApi requests | [Diagnostics/dataapi] | ApiServer.handleRequest | Any DataApi request >50ms: duration + method path. Duration is measured monotonically (performance.now()) and only computed when enabled. |
| Verbose logging | — | LoggerService (main + renderer) | The flag makes the logger behave exactly as in dev: file level drops to silly, console output turns on, and the CSLOGGER_* filters become active in the packaged build. See Logging. |
The slow-* thresholds are defined in one place — SLOW_THRESHOLD_MS in src/main/core/diagnostics.ts.
Written to boot-whenReady.cpuprofile in the app logs directory (next to app.<date>.log; application.getPath('app.logs')). Open in Chrome DevTools (Performance → Load profile) or VS Code's built-in .cpuprofile viewer. Sort by self time for true CPU attribution.
Sampling interval is 1000µs (V8 default). Do not lower it — 100µs oversamples ~10x and adds ~135ms of inspector overhead that only taxes the profiled whenReady phase, drowning out sub-100ms deltas.
_doInit timing is contaminated in parallel layers. Services in one layer run via Promise.allSettled; await this.onReady() yields a microtask during which a sibling's synchronous body runs to completion and is billed to whichever service is being measured. Trust the CPU profile's self-time for real attribution, not the per-service numbers.logger option logs every statement (including transaction interiors) but carries no timing, so it cannot flag slow queries. The probe instead wraps the better-sqlite3 connection's prepare and exec — every path drizzle issues queries through.import { DIAGNOSTICS_ENABLED } from '@main/core/diagnostics'.if (DIAGNOSTICS_ENABLED) { … } — the disabled path must stay zero-cost. For a slow-event probe, add a threshold to SLOW_THRESHOLD_MS instead of hardcoding ms.[Diagnostics/<name>] so they grep together.