| 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
skill/keybase-rpc-log-analysis/scripts/clean-logs.sh --archive
skill/keybase-rpc-log-analysis/scripts/start-service.sh
cd shared && yarn desktop:start:hot:e2e
cd shared && yarn test:e2e:desktop:rpc
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:
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:
<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
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.
- Using plain
grep. It is wrapped in this environment and truncates. Use
python, as the scripts do.
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.