feat(minimald): log RPC dispatch, session lifecycle, and upload bytes - #895
Conversation
`rpc.rs` held exactly one `tracing::info!` against 18 `tracing::warn!`
and zero `tracing::debug!`. `handle_ssh_rpc` built an `rpc` span carrying
`rpc`/`trace_id`/`span_id` and instrumented every handler future with it,
but emitted no event inside it — and no fmt layer enables `FmtSpan`, so a
span containing no events produces no output at all. The ids were minted
and discarded.
Add three sets of records, structured fields throughout, all emitted
inside the existing spans so they carry `conn`, `transport`, `rpc`,
`trace_id` and `span_id` without repeating them:
- `served` brackets every dispatched handler with `rpc dispatched` and
`rpc served`/`rpc failed` (`outcome`, `duration_ms`). Start-and-finish
rather than finish-only: finish-only cannot distinguish an RPC still
in flight from one never dispatched, and "started, never finished" is
exactly the shape of a hang.
- `create_session` and `destroy_session` emit `session_id` and
`session_name` at INFO.
- The workspace upload emits start and completion/failure records
carrying `bytes_received`, tallied by a `CountingReader` spliced
between the channel and the zstd decoder. The transfer previously
emitted nothing whatsoever.
The 18 per-handler `if let Err(e) { warn!("RPC handler for {} failed") }`
blocks are subsumed by the dispatch record, so the `serve_*` handlers now
return their `Result` rather than swallowing it.
Refs: #869
📝 WalkthroughWalkthroughRPC handlers now propagate ChangesRPC observability and transport handling
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant SSHChannel
participant handle_ssh_rpc
participant served
participant RPCHandler
SSHChannel->>handle_ssh_rpc: receive RPC request
handle_ssh_rpc->>served: spawn instrumented future
served->>RPCHandler: invoke RPC handler
RPCHandler-->>served: return result
served-->>handle_ssh_rpc: log outcome and duration
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@macroscope review |
|
Manual reviews triggered for commit All prior checks · these links stay valid even if you push more commits. |
|
Just FYI for future @mentions, I'm Review in progress. Results will be posted as check runs when complete. |
ApprovabilityVerdict: Would Approve Adds observability logging (RPC dispatch timing, session lifecycle, upload byte tracking) without changing runtime behavior. Author owns the modified file, and new CountingReader logic is covered by a unit test. Macroscope would have approved this PR. Enable approvability here. |
…patch-logging # Conflicts: # crates/minimald/src/rpc.rs
#918) PR #895 changed the serve! dispatch so every handler returns Result<(), ConnectionError> for outcome logging; PR #878 merged alongside it with serve_stream_diag_bundle still returning (). The two were each green against a main that lacked the other, and the type mismatch only surfaced on branches built after both landed. Failure still relays the message over the channel's extended-data stream before surfacing as ConnectionError::Internal, mirroring serve_stream_workspace_files. Refs: #878, #895 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…880) * feat(minimal): guest fetch and degraded-mode fallback for `min bug` Per provider, `min bug` now performs the staged socket probe and — when the probe handshakes — downloads the daemon's own bundle over the DiagBundleTarZst subsystem, nesting it under providers/<name>/guest/. --no-guest skips daemon contact entirely; --guest-timeout-secs bounds each provider's download. Host-side log-prefix skips are deferred until the provider loop settles whether the daemon's logs reached the bundle another way, so the manifest never claims an absence the archive does not back. Squashed rebuild of the original branch onto main after #878 landed there, replacing the merge-heavy history whose #889 squash title also failed commitlint. Refs: #802 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(minimald): return the served contract from the diag bundle handler PR #895 changed the serve! dispatch so every handler returns Result<(), ConnectionError> for outcome logging; PR #878 merged alongside it with serve_stream_diag_bundle still returning (). The two were each green against a main that lacked the other, and the type mismatch only surfaced on branches built after both landed. Failure still relays the message over the channel's extended-data stream before surfacing as ConnectionError::Internal, mirroring serve_stream_workspace_files. Refs: #878, #895 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The deficit, measured
On
main,crates/minimald/src/rpc.rs(1292 lines) contains:tracing::info!"state volume quiesced for shutdown")tracing::warn!tracing::debug!tracing::error!/tracing::trace!Every one of the 18 warns is the same interpolated line —
tracing::warn!("RPC handler for {} failed: {}", X::NAME, e)— which isalso the shape
docs/rust-coding-standards.mdexplicitly rules out.Three consequences, all verified in the tree:
handle_ssh_rpcbuilds anrpcspan carryingrpc,trace_id,span_id,parent_span_idandinstruments every handler future with it — but emits no event inside
it.
grep -rn 'FmtSpan\|with_span_events' crates/minimald/srcreturnsnothing, so neither the console layer nor
mlog::json_file_layerrendersspan open/close. A span containing no events produces no output
whatsoever. The W3C traceparent is parsed off the channel env, adopted,
re-minted as a child — and then never written anywhere.
serve_create_sessionlogs nothing on success.serve_stream_workspace_files— the workspace upload — emits nothingat all: no start, no byte count, no completion, and no failure line
beyond whatever the transport happens to log. The daemon pipes
c.make_reader()→ZstdDecoder→async_tar::Archive::unpackwith nocounter anywhere.
Why it matters — the incident
During #869, a guest-side log read, in full:
Between those two lines, 1.81 seconds apart, the daemon created a session
and received ~12.5 MB of tar. Neither is in any log. The session's UUIDv7
decodes to 0.4 ms after the accept, which is how it was eventually
established — by decoding a timestamp out of a session id, because no log
line said so.
Worse, the absence was misread as evidence. The investigation concluded the
console had dropped records and spent a stretch reasoning about a transport
that was fine. A later measurement — diffing the console mirror against the
on-volume log byte for byte — showed nothing was ever dropped. The
evidence was never written.
What each new record buys a reader
1. One pair of records per served RPC (
served)This is the record that makes the
rpcspan emit at all — the ids abovereach the log for the first time. It also gives the incident's silent
window a shape: which RPCs ran on
conn=4, in what order, and how longeach took.
duration_msis the field that separates "the daemon was busy"from "the daemon was blocked".
The rpc name,
trace_idandspan_idcome from the span rather than beingrepeated as event fields:
mlog::json_file_layersetsflatten_span_list_on_top_level(true), so span fields already land at thetop level of every JSON record, and duplicating
rpcon the event wouldemit a duplicate key.
2. Session lifecycle
Directly answers the question that cost the most time in #869: did the
daemon create a session on this connection, and which one? No UUIDv7
timestamp archaeology.
DestroySessioncarries only an id on the wire, sothe name is resolved from the record before the delete — best-effort, and a
failed lookup degrades to
<anonymous>rather than failing a destroy thatwould otherwise succeed.
3. Workspace upload, with bytes
bytes_receivedis the single most valuable number in this PR. In theincident, "12.5 MB received, then failed" against a 12.78 MB client-side
payload points straight at a transport that dies near the end of a large
transfer — as opposed to a rejected handshake (
bytes_received=0) or amalformed archive (full byte count, unpack error). That distinction was
unavailable and had to be reconstructed by hand.
The tally comes from a
CountingReaderspliced between the SSH channel andthe zstd decoder, so it counts compressed wire bytes — the figure that
compares directly against what the client sent, not the size of the
unpacked tree. The counter is an
Arc<AtomicU64>rather than a returnvalue precisely because the reader is swallowed by the decoder and then the
unpacker: on a mid-stream failure they surface an error and drop the
reader, and the shared tally is the only surviving evidence of how far the
upload got.
Start-and-finish vs finish-only
Chosen: start-and-finish. Finish-only halves the volume and is
tempting, but a finish-only log cannot distinguish these two states:
Both look identical: nothing in the log. "Started and never finished" is
exactly what a hang looks like, and a hang is the failure mode this logging
exists to catch — #869's connection died with an RPC in flight. A dispatch
record turns that from an absence into a positive statement: this rpc,
with this trace id, on this connection, began and did not return.
The same argument applies to the workspace upload's start record, which is
emitted after the session resolves and before the first byte is pulled, so
a wedged transfer still leaves a line naming its destination session.
Log-volume impact at INFO
The RPC subsystem is a per-CLI-invocation path, not a per-keystroke
one: interactive attach traffic runs over shell/exec channels, which this
PR does not touch. So the multiplier applies to command dispatches, not to
terminal I/O.
Per served RPC: 2 INFO records where there were 0. A console record at
this shape (timestamp + level +
conn/transport+ the fourrpcspanfields + target + message) runs ~260 B, so ~520 B per RPC.
A full session launch is
CreateSession→WorkspaceFilesTarZst→ConfigureLoadout(±SubmitVerdict) ≈ 4 RPCs:session createdA
minimal lsis 1 RPC → 2 records, ~0.5 KB.At a sustained 10 RPC/s — well beyond realistic interactive use; that is
ten CLI invocations every second, every second — this adds ~5 KB/s, ~19 MB/h.
The file log is
Rotation::DAILYwithmax_log_files(14), so even apathological load is bounded by rotation rather than growing without limit.
Realistic steady state is single-digit RPCs per user action: tens of
records per minute, low hundreds of KB per day.
Nothing was added at DEBUG; the level distribution stays deliberate rather
than becoming a firehose that has to be filtered back out.
Arguably already-owed scope
Spec 10 R4.3 (on
origin/arch/diagnostics-spec, notmain—docs/specs/10-spec-diagnostics/10-spec-diagnostics.md:540) reads:Daemon RPC dispatch does mint them, correctly, in the required formats.
It just never emits them, which makes the requirement satisfied on paper
and inert in practice. This PR is the missing half.
Incidental cleanup
The 18 duplicated
if let Err(e) = res { tracing::warn!("RPC handler for {} failed: {}", …) }blocks are subsumed by the dispatch record, so theserve_*handlers now return theirResultinstead of swallowing it —one error path instead of eighteen, and the interpolated-string form is
gone from the file.
serve_stream_workspace_filesstill relays its failureto the client over extended data exactly as before.
Verification
macOS cannot build
minimald(procfs), so both runs are viacrossagainstaarch64-unknown-linux-musl.The one new test,
counting_reader_tallies_every_byte_pulled_through,pulls an 8 KiB payload through
CountingReaderin many small polls andasserts the tally accumulates across them rather than recording only the
last read — the counter is worth logging only if it is exact. The record
emission itself is declarative; the arithmetic is the part that can be
wrong.
Refs: #869
Note
Add RPC dispatch logging, session lifecycle events, and upload byte counting to
minimaldserved()helper in rpc.rs that logsrpc dispatchedon start andrpc served/rpc failedwith elapsed duration on completion, replacing per-handlerwarn!calls.session createdandsession destroyedevents withsession_idandsession_name(falling back to<anonymous>) on successful create/destroy.CountingReader<R>, anAsyncReadwrapper that tracks bytes read via a sharedArc<AtomicU64>, used to reportbytes_receivedin workspace upload logs.Result<(), ConnectionError>and propagate errors to the caller instead of logging locally.Macroscope summarized e403426.
Summary by CodeRabbit