Call timing
Every log call is timed. You can see where the time went from the numbers, so nobody has to guess (rule 165). One timing layer runs across Bun and Python:
- It gives every call an id.
- It records every stage inside the call as a span.
- It stores the spans in
~/.logCli/log.db, in thespanstable, for 90 days. - It reports p50, p95 and p99 for each tool and for each stage.
What a call looks like
twitterDms 9.5 s (call 3f9a1c2b7e44)
├─ inbox.readSource the .db or the platform
├─ engine twitter inbox
│ ├─ python.start the interpreter starting
│ ├─ python.imports the engine's imports
│ ├─ host.connect reaching the account's one session
│ └─ host.roundTrip
│ ├─ host.queue waiting for the account's turn
│ ├─ account.lock
│ ├─ spacing the gap since the last call (rule 104, 110)
│ ├─ governor the governor's own pacing
│ ├─ client
│ ├─ action inbox
│ │ └─ http GET x.com graphql … every request X answered
│ └─ session.keep
└─ unaccounted the call's own time outside every stageA send also shows inbox.waitCaughtUp, inbox.claim and the voice gate, step by step: voice.sameness, voice.chatContext, voice.room, voice.rankedContext, voice.evidence and voice.reading. Every fetch the call makes, to SubQ, Jina, Twenty or anything else, is a span of its own.
Reading it
log timing(MCPtiming) gives p50, p95 and p99 for every tool in the last 24 hours. Under each tool it lists the stages, the largest share of the time first.log timing --tool twitterDms --hours 72narrows it to one tool and a wider window.log timing --call <id>shows one call as a waterfall, with every stage in order, when it started and how long it took. The id is on the call's▶and■lines in the live log (~/.logCli/mcp/<day>.log). The■line also says how long the call took.--jsongives the numbers, for a benchmark or a before-and-after.
A stage's share is its time across all of a tool's calls, divided by the tool's total time. Nested stages count inside their parent as well, so shares can add up to more than 100%. unaccounted is time the call spent in its own code, outside every stage. When it is large, add a span where that time goes.
Seeing it as a timeline
Set LOG_OTLP_URL (for example http://127.0.0.1:4318) and every call is also sent as OTLP to a trace viewer. otel-desktop-viewer is one Windows .exe that needs no Docker; its UI is on port 8000. Nothing else needs installing: log writes OTLP JSON itself.
When a span is slow and you can't tell why
The spans show which stage is slow. A profiler shows which function inside that stage, and which library:
- Python: run
py-spy record --native --pid <host pid>in an admin terminal. The host's pid is on the hook's account line. It attaches without a restart, so the account keeps its one session (rule 105). - Bun: run
bun --cpu-profand open the.cpuprofilein Chrome DevTools.
Adding a span
- Bun:
timed("name", async (span) => …, { attrs }), ortimedSync, fromsrc/observability/timing. Outside a call they only run the work. - Python:
with timing.span("name", key=value):fromplatformKit.timing.
Never put a cookie, a token or message text in a name or an attribute.
Research: RESEARCH/LOG_CLI_CALL_TRACING_TOOLING_2026_11_07.md in the workspace. Spec: callTiming.