Skip to main content

keybase-rpc-log-analysis

Use when hunting redundant, duplicated or looping RPCs in the Keybase client — after a rate-limit error, a service log that rotates every few minutes, a slow or hot app, or to prove a caching fix actually reduced calls. Covers capturing a clean service log against a locally built service and reading it.

Source facts

Repository
keybase/client
Last source activity
September 28, 2026 at 13:34
Detected SKILL.md language
English
Stars
9,269
Forks
1,287

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
8 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
keybase-rpc-log-analysis
description
Use when hunting redundant, duplicated or looping RPCs in the Keybase client — after a rate-limit error, a service log that rotates every few minutes, a slow or hot app, or to prove a caching fix actually reduced calls. Covers capturing a clean service log against a locally built service and reading it.
# Keybase RPC log analysis The Go service logs every RPC it serves and every call it makes. That log is the only place the client's real network behaviour is visible — the JS side shows intent, the log shows what actually went out. A quiet minute is ~45 lines; a minute with a flood is 120,000. ## Capture a clean run ```bash skill/keybase-rpc-log-analysis/scripts/clean-logs.sh --archive # dry run without a flag skill/keybase-rpc-log-analysis/scripts/start-service.sh # own terminal, foreground cd shared && yarn desktop:start:hot:e2e # another terminal cd shared && yarn test:e2e:desktop:rpc # or drive the app by hand ``` `start-service.sh` builds from this checkout, stops the running service, and logs with `-d --log-file` to `/tmp/kb-analysis/service.log`. Its own file matters: the default log is shared with everything else and a flood rotates the evidence away in under two minutes. **Use `test:e2e:desktop:rpc`, not `test:e2e:desktop`.** The full suite is 6.6 minutes over two projects (light and dark), which doubles every count and makes the log rotate. `test:e2e:desktop:rpc` is the six flows that actually drive team and channel loading, on one project, in 39 seconds. `test:e2e:desktop:flows` takes file arguments if you need a different set — always pass a single `--project` (it does), or light and dark each contribute a copy of every call. `--log-file` **still rotates at 128MB** — a full e2e run produces ~135MB and will cross it. Nothing is lost, but the analysis must then include the siblings, so pass them all: ```bash python3 skill/keybase-rpc-log-analysis/scripts/rpc-report.py \ /tmp/kb-analysis/service.log-* /tmp/kb-analysis/service.log ``` Rotation is easy to miss: a script that reads only `service.log` after a long run silently analyses the tail. Check `ls -la /tmp/kb-analysis/` before believing a count. The service forks kbfs from its own bin directory, and a plain `go install` of the service does not build it. Without kbfs the Files tab is empty and git fails with `KBFS client not found`, which fails every `files-*` and `git-*` e2e test for reasons unrelated to the change under test. `start-service.sh` warns. Note the installed kbfs keeps the mount when the service is swapped for a local build, so those tests can fail even with a `kbfs` binary in place — they are not in the `test:e2e:desktop:rpc` set for that reason. Ask before stopping the service or launching the app — both are the user's. ## Prove a fix A before/after is only worth reporting if both runs did the same thing. What works: ```bash # after: with the fix in the tree <reload the renderer>; : > /tmp/kb-analysis/service.log cd shared && yarn test:e2e:desktop:rpc cp /tmp/kb-analysis/service.log /tmp/kb-analysis/after.log # before: revert ONLY the source changes, keeping any new test scripts git stash push -- <the source files you changed> <reload the renderer>; : > /tmp/kb-analysis/service.log cd shared && yarn test:e2e:desktop:rpc cp /tmp/kb-analysis/service.log /tmp/kb-analysis/before.log git stash pop ``` - **Stash by path, not wholesale.** If the fix added the very npm script the capture runs, a bare `git stash` takes it with them. - **Hard-reload the renderer between runs** (CDP `page.reload()` on `localhost:9222`). Module-level caches — the usual subject of these fixes — survive HMR, so without a reload the second run starts warm and reads better than it is. - **The service does not need rebuilding** between runs for a JS-only fix. Leave it up; only truncate its log. - Counting straight out of the log beats `rpc-diff.py` when you already know which calls you are watching: parse `+ Server: <Name>` / `+ RemoteClient: chat.1.remote.<name>`, and count how many landed within the hook's own `staleMs` of the previous call for the same subject. That last number is the one that says "this was a cache miss" rather than "this was work". ## Read it | Question | Command | |---|---| | What did this run do? | `rpc-report.py <log> [--start 2026-07-24T17:23] [--end ...]` | | What was actually slow? | `rpc-cost.py <log> [--min-mean-ms 50]` | | Why is X called so much? | `rpc-why.py <log> --match 'AttachmentHTTPSrv: GetURL'` | | Did my fix work? | `rpc-diff.py before.log after.log` | **Run `rpc-cost.py` early.** Count and cost answer different questions, and the loudest line in the log is usually not the expensive one. In one capture the top line by count was an in-memory map lookup — 15,767 calls for 0.16s total, not worth touching — while the real cost was 469 team loads at 292ms each, 186 seconds, sitting 30th by count. To *prove* a loud flood is free rather than just absent from the table, pass `--min-mean-ms 0 --top 400`: the default threshold hides cheap chatty ops, which is exactly the row you need. `AttachmentHTTPSrv: GetURL` at 4,848 calls does not appear at all by default, and shows as 0.04s total once it does. Analysing an already-rotated log is the same — pass `~/Library/Logs/keybase.service.log-<start>-<end>` directly. The filename carries its own window, so `--start/--end` are only for narrowing further. Read `rpc-report.py`'s sections in order: - **REMOTE** — service→server. Only these burn server rate limits. Start here. - **APP** — app→service. One of these usually explains a REMOTE row. - **BURSTS** — same call and subject, 3+ times in one second. Each row is a missing cache or a remounting component. - **FLOODS** — most repeated lines, ids and numbers collapsed. A BURSTS row with a bare name and no `(subject)` means the subject could not be resolved: many RPCs log no arguments. Those rows are same-*name* only — treat them as a lead, not as proof the same conversation was hit twice. `rpc-why.py` answers attribution. **ROOT** (the app RPC that opened the trace) is usually the answer; **DRIVERS** (nearest preceding call) points at the specific line. **BATCHES** needs the span that logs the item count, which is normally the local one, not the remote it wraps — match `HybridConversationSource: GetMessages`, not `getMessagesRemote`. The script says so when it finds no counts. ## What a bug looks like - **Same args, same second, N times** → no dedupe, or a keyed component remounting as its props arrive in stages. - **Fires again the instant the previous lands** → a feedback loop, not user action. Look for a cache that never records success, or an effect whose deps change on every render. - **Batch sizes almost all 1** → the caller loops where it could fetch once. - **A per-item cost inside a loop** → e.g. a gregor state read per emoji. ## Mistakes - **Guessing the trigger from neighbouring lines.** A burst surrounded by startup-looking work is not necessarily startup. Correlate against something independent: the mtimes of `shared/tests/results/test-results/*/test-finished-1.png` give you a timestamped list of which test ran when, and `app.log` records renderer reloads. One burst-per-profile-open and one burst-per-bootstrap look identical in the service log until you check. - **Attributing to the child call.** The line directly above a flood is often something the flood itself invoked. Trust ROOT over DRIVERS. - **Reading a count that went to zero as a win.** A call that disappears may have been *dropped*, not deduped. Before claiming it, check what the baseline calls actually did: pull their `chat-trace` out of the before-log and count the work hanging off it. Two `getMutualTeamsLocal` calls here went to 0 after a guard on "empty argument list" — and their traces showed 82 remote refreshes and 750ms each, so the service treats the empty case as a real query and the guard was removing a feature, not a redundancy. A real dedupe takes N to 1, not to 0. - **Forgetting that one call's result is another call's input.** Suppressing a call suppresses everything downstream of it, so an unrelated-looking count improves for the wrong reason. `getAnnotatedTeam` read 3 in the run where mutual teams was wrongly suppressed and 11 in both runs where it was not — because the shared teams it returns are what then get loaded. If a metric you did not touch moves, find out which call upstream stopped happening. - **Reading a fix as failed because the totals did not move.** If the app's request volume changed between runs, a working cache can show flat server counts. Count what the cache actually did — a hit rate, a log line — before concluding anything. One cache here looked useless in one capture and turned out to be a 79% reduction once the volume feeding it was fixed. - **Fixing what you have not measured.** Two of the loudest floods in this log — 15,767 username lookups and 7,284 chain-storage misses — were worth 0.16s and 0.24s. Both were correctly left alone. `rpc-cost.py` first. - **Blaming the biggest number.** Service-side background work — the search indexer especially — can dwarf the real bug. An empty ROOT means nothing asked for it. Check whether it recurs on a timer before spending time on it. - **Reporting a burst as same-subject when it is not.** See the BURSTS caveat above. If it matters, prove the subject repeats before claiming it. - **Comparing unlike runs.** `rpc-diff.py` is only meaningful if both runs did the same thing — same tests, same order, same project, renderer reloaded between them. See "Prove a fix". ## Shapes seen before Each of these was a real bug, and each is what its section looks like: - An app RPC in the thousands where the screen has tens of items — a per-item prime re-run on every list reload. - A local call in the low single digits dragging thousands of lines behind it — a per-item cost inside a server-side loop. - FLOODS dominated by one span whose BATCHES are almost all size 1. - An APP row whose count is a multiple of the number of times a screen was opened — a component keyed on data that arrives in stages, remounting. - **A list row eagerly loading what only its hover popup needs.** The single biggest win here was one `loadOnDemand` flag: a profile rendered a row per team and each row annotated its team up front, for a popup nobody opened. - **A hook that subscribes to a broadcast per component instance.** One notification then does N times the work, and if the work itself emits that notification it compounds. Subscribe once at module scope and fan out. - **The same expensive load repeated because each surface holds its own copy.** Two or three components asking for the same user or team at the same instant is the common case, not the rare one. - **Two identical calls the same microsecond apart.** Not a race — two *caches*. Either the hook falls back to a per-instance map (so every provider and every provider-less consumer holds its own), or two different hooks issue the same RPC with the same arguments from separate module caches and cannot see each other's in-flight request. Both were live in this client at once. Grep the codebase for the RPC name: more than one call site that is not a shared hook is the tell. - **Calls landing inside their own stale window.** If a hook declares `staleMs: 5_000` and most of its calls arrive under 5s after the previous one for the same subject, the cache is not being shared — the window is doing nothing because each caller has a fresh copy of it. This ratio is the single most useful derived number here; 81% for `getAnnotatedTeam` is what found that bug. Where the money actually is in this client: identify/proof checks and team loads. Both are hundreds of milliseconds each and both fan out to several HTTP calls. Chat localization is cheap per call but runs over every channel.
View on GitHub