Interaction trace (slow-click, jank, and navigation latency log)
A single log file capturing every slow click, keypress and rendering stall while Omniscio is open, kept as a continuous tape across launches with a worst-offenders summary at the bottom. It exists so a report of "clicks feel laggy" travels as one attachable file anyone can read.
What it is
A dedicated log file — interaction.log — that captures every slow interaction (click, keypress, tap) and rendering-thread stall that happens while Omniscio is open, in one continuous self-contained timeline. It exists so a user reporting "clicks feel laggy" or "the UI freezes for a second when I switch sessions" can attach exactly one file to a bug report, and any agent (or outside AI like ChatGPT) reading that pasted file can immediately tell which interactions were slow and where the time was spent — without seeing the rest of the codebase.
The file lives next to the regular logs:
- Windows:
%APPDATA%/omniscio/logs/interaction.log - macOS:
~/Library/Application Support/omniscio/logs/interaction.log - Linux:
~/.config/omniscio/logs/interaction.log
Unlike startup.log (which rotates per-launch), interaction.log is a continuous tape across launches — the same file keeps growing every time you open the app, capped at 1 MB. When it reaches the cap, it rotates to interaction.log.1 → … → interaction.log.5 (oldest dropped). This is intentional: a "lag that only shows up sometimes" needs a tape that spans days, not a fresh file each launch.
It is separate from main.log on purpose: main.log is a rolling firehose of every log line during runtime; interaction.log is a focused timeline of just the perf-sensitive events, with a TOP 10 worst-offenders summary at the bottom so even a non-technical reader can see the pattern.
Where to find it
The file sits in your Omniscio logs folder, next to the other logs — on Windows under %APPDATA%/omniscio/logs/interaction.log, on macOS under ~/Library/Application Support/omniscio/logs/, and on Linux under ~/.config/omniscio/logs/. There is no in-app viewer; you open it, or attach it to a bug report.
How it behaves
How to use it
To attach it to a bug report
- Open Settings → Diagnostics. You'll see a card titled Interaction Trace with a single button: Reveal Interaction Log.
- Click it. Omniscio flushes a fresh TOP 10 summary block, then opens your file manager with
interaction.loghighlighted. - Drag the file into the bug report (or paste its contents into a chat).
The file is plain text, opens in any editor. The very top has a self-describing header so an LLM reading a pasted copy with no other context understands what each section means.
What you'll see in the file
═══════════════════════════════════════════════════════════════
Omniscio INTERACTION TRACE
…self-describing header (purpose, Version, Electron, Mode, PID)…
═══════════════════════════════════════════════════════════════
── Launch resumed at 2026-05-18T15:42:11.123Z (PID 12345) ──
2026-05-18T15:42:14.901Z [event] name=click duration=423ms inputDelay=32ms processing=280ms target=button.send iid=7
2026-05-18T15:42:15.044Z [loaf] duration=240ms blocking=180ms scripts=3 slowest=120ms at DOMWebSocket.onmessage
2026-05-18T15:42:18.221Z [phased-nav] activation=7 total=850ms session=sess-abc
nav-to-mount 12ms
mount-to-fetch 4ms
fetch 380ms
build-turns 18ms
commit-to-paint 436ms
…
────────────────────────────────────────────────
SUMMARY (flushed at 2026-05-18T16:01:33.444Z)
────────────────────────────────────────────────
TOP 10 SLOWEST INTERACTIONS
1. click 423ms (input=32ms processing=280ms) target=button.send at=2026-05-18T15:42:14.901Z
2. keypress 308ms (input=18ms processing=210ms) target=textarea#composer at=2026-05-18T15:43:02.118Z
…
TOP 10 LONG ANIMATION FRAMES
1. 240ms blocking=180ms scripts=3 slowest=120ms at DOMWebSocket.onmessage at=…
…
TOP 10 PHASED-NAV
1. activation=7 850ms session=sess-abc at=2026-05-18T15:42:18.221Z
nav-to-mount 12ms
mount-to-fetch 4ms
fetch 380ms
build-turns 18ms
commit-to-paint 436ms
…
The TOP 10 sections are the most useful for triage — they sort by duration so you can immediately see the worst offenders without scanning the whole tape. Phased-nav entries break down a single session cold-mount into five named phases — nav-to-mount → mount-to-fetch → fetch → build-turns → commit-to-paint — so you can see where the time went between the user clicking a session and the first paint.
How to read each line
[event]— a click / tap / keypress that took longer than 200 ms end-to-end.inputDelayis wall-clock time from event hit to JS handler start;processingis the synchronous JS work the handler did.targetis a short CSS-selector-ish description of the element.iidis the browser's interaction id (groups multiple events from one logical interaction).[loaf]— a "long animation frame" (>100 ms of render-thread time).blockingis time the main thread couldn't yield to input.scriptsis how many JS callbacks ran in that frame.slowestnames the worst single callback, formatted<ms>ms at <invoker>— the separator isat, never@(a@joins into an email-shaped string that the log scrubber rewrites to[EMAIL_REDACTED], destroying the one field that says what blocked the frame; guarded byloaf-attribution-avoids-email-shape.test.ts). Burst protection coalesces multiple LoAFs in the same 1-second window into a single entry with acoalesced=Nsuffix.[phased-nav]— a session/route switch broken down into named phases (currently click-to-state-set → state-set-to-mount → mount-to-first-message). These are emitted explicitly by the navigation code rather than auto-detected, so they are the ground truth for "where did the second go."
When the window stops painting — the [present-stall] lines (in renderer-diag.log)
Sometimes the window freezes although nothing above looks slow: the keystrokes show inputDelay and processing near 0, and the [loaf] line reads blocking=0ms scripts=0. That is a presentation stall — the app handled the input, but the finished frame did not reach the screen. A small probe watches for exactly this and writes to renderer-diag.log, in the same folder:
[present-stall]— one line per stall you could feel: after a text edit or a click, the next frame was still missing half a second later. It carrieslate(how late the frame was),input(textorpointer),verdict, then the readings taken at both ends of the stall:gpuCpu(the graphics process's CPU time, and its share of one core),gpuFaults(its page faults),gpuWs(its memory in use),gpuIo/gpuPage(its current disk and memory priority),kernel(the share of all CPU time the machine spent inside Windows itself),dwm(alivewhen the Windows desktop compositor drew during the stall;quietis ambiguous, because it only draws when something on screen changes),windowandbeginLag(how much of the stall was measured),live(running sessions),episode, anddropped(stalls skipped by the six-a-minute cap).- The verdict:
gpu-busy— the graphics process worked hard through the stall;gpu-blocked— it barely ran while the machine was in a kernel storm, so the machine starved it;gpu-idle— it barely ran on a calm machine, so nothing asked it to draw;inconclusive— areason=says why (a reading failed, the window went hidden, the stall never ended). A value that could not be read is writtenn/a, never 0. [present-stall-count]— at most once a minute, and only for a minute in which you typed or clicked:composerKeys(characters typed into a message box),otherInputs, andlate(late frames). A log with count lines and no stall lines means "watched, and nothing stalled"; no count line at all means nobody was typing.
The probe runs only in the main desktop window, and only while Settings → Diagnostics → "Performance & interaction tracing" is on. It reads nothing until a frame is already late, and every reading happens off the app's main thread. It sees frames whose drawing started late; a frame drawn on time but shown late is not caught.
How it works
Two parts: the renderer observer that detects slow events, and the main-process recorder that writes them to disk.
Renderer (observer)
Lives in /src/renderer/src/lib/interaction-trace.ts. The useRendererDiagnostics hook (/src/renderer/src/app/useRendererDiagnostics.ts) — called once from /src/renderer/src/App.tsx at boot — runs an effect that calls installInteractionObserver({ reporter }):
- Wraps two
PerformanceObserverstreams:event(Event Timing API, Chrome 85+) — fires for every event whoseduration≥ 200 ms.long-animation-frame(LoAF API, Chrome 123+) — fires for every animation frame whose duration ≥ 100 ms.
- Coalesces LoAF bursts in a 1-second window (
setTimeoutflush) so a misbehaving render loop can't flood the log. - Caps every user-derived string at 200 chars before forwarding (DoS protection — a giant
aria-labelon a slow button can't blow the IPC budget). - Returns a
{ stop, reportPhasedNav }handle. ThereportPhasedNavshim is also exposed at module-level via /src/renderer/src/lib/phased-nav-reporter.ts so navigation code can fire phased-nav events without prop-drilling.
Each detected entry goes through ipc.invoke(IPC.RENDERER_DIAG_REPORT, payload) — the same channel the existing longtask/snapshot diagnostics use. The Zod schema is a discriminated union so every payload shape (longtask / snapshot / interaction-event / long-anim-frame / phased-nav, plus freeze attribution and the two presentation-stall reports) stays independently validatable.
Kill switch: set AMC_DISABLE_INTERACTION_TRACE=1 in the environment before launch and the observer never installs (returns a no-op handle). The reveal button still works — it'll just open whatever file is on disk.
Main process (recorder)
Lives in /src/main/services/diagnostics/interaction-trace.ts. Initialized once from /src/main/index.ts immediately after initStartupTrace():
initInteractionTrace()— opensinteraction.log. If the existing file is already over 1 MB, rotates it first; otherwise appends a── Launch resumed at … ──marker and continues writing.recordInteractionEvent/recordLongAnimFrame/recordPhasedNav— three append helpers, each also maintaining a rolling in-memory TOP 10 sorted by duration. Per-record format is one line per entry; phased-nav writes its line plus an indented breakdown per phase.flushInteractionSummary()— appends a freshSUMMARY (flushed at …)block with all three TOP 10 sections. Called from two sites: (a)gracefulShutdown()inindex.tsso the final file always ends with an up-to-date summary, and (b) the reveal handler itself, so clicking the button always reveals a file with current data even if the user has been running for hours.
All disk writes are wrapped in try/catch — a failed appendFileSync must never disrupt the renderer or the IPC handler. If the log directory can't be created, the recorder silently no-ops.
The IPC wiring is in /src/main/ipc/diagnostic-handlers.ts:
RENDERER_DIAG_REPORT— routes bypayload.type:longtask,freeze-attributionandsnapshotbecome lines in the non-blockingrenderer-diag.log(buffered and flushed off the main thread;snapshotonly in dev builds), the three perf-trace variants → the recorder (does NOT write tomain.log, which would balloon), and the two presentation-stall variants → the presentation-stall probe.INTERACTION_LOG_REVEAL— flushes summary, thenshell.showItemInFolder(getInteractionLogPath()).
Why a separate file (not just main.log)
main.log would be wrong for three reasons:
- LoAF can fire hundreds of times per second during pathological renders — interleaving that into
main.logwould crowd out actionable lines. - A user reporting "clicks have felt laggy for a few days" needs a tape that spans days, but
main.logrotates by size — your bad-Tuesday afternoon may be gone by Thursday. - The TOP 10 summary at the bottom only makes sense if every line in the file is the same shape — interleaving with unrelated log lines defeats the summary.
interaction.log solves all three: only perf-sensitive events, 1 MB cap with 5 rotated backups, and a self-describing header + TOP 10 sections that make it interpretable in isolation.
Limitations
- Sub-200ms events are invisible. The Event Timing threshold is hard-coded at 200 ms (INP-style) — a click that takes 180 ms feels noticeable but won't show up here. Lower thresholds would flood the file.
- LoAF requires Chrome 123+. Electron 44 bundles a Chromium newer than 123, so it's covered, but if a future Electron downgrade removes the API the observer silently no-ops on that stream (Event Timing still works).
- Phased-nav events are explicit, not auto-detected. Navigation code has to call
reportPhasedNav(...)fromphased-nav-reporter.ts. If a nav site doesn't, it just won't appear in the trace. - Continuous tape means cross-launch context lives in one file. If you only care about the most recent launch, look for the latest
── Launch resumed at … ──marker and read forward from there.
Related
- logs-and-debugging.md — the broader logging system (
main.log, Debug Log Viewer, crash dumps, ZIP export). - startup-trace.md — the sibling launch-only timeline (per-launch rotation, not continuous).
- heap-snapshot-diagnostics.md — the on-demand heap snapshot endpoint, complementary for memory bloat.
Last verified 2026-10-06