Dev Console Ktor Client Plugin
Records every completed call -- headers, metadata, and a bounded copy of textual request and response bodies -- without changing anything the host application observes.
Response bodies are captured at HttpReceivePipeline.After, the same stage Ktor's own ResponseObserver/Logging plugins use, which is the earliest point where the raw body channel can be duplicated without stealing bytes from the host's later body<T>()/bodyAsText() read. Every captured body takes the same path: the raw channel is duplicated with ByteReadChannel.split and the host proceeds with a rebuilt response around one half, exactly like ResponseObserver -- deliberately including responses SaveBodyPlugin has already saved. A saved-body special path (reading a private replay and leaving the host's response untouched) is a trap, twice over:
io.ktor.client.plugins.isSaved is a request-attribute flag that survives every later re-wrap of the response, and this plugin is not necessarily the last wrapper before us: Ktor's default-installed
BodyProgress(whenever anonDownloadlistener is present) and plugins likeResponseObserver/Loggingregistered earlier at this same phase replace the response with a fixed single-shot channel whileisSavedstays true. Reading -- or worse, cancelling --rawContentoutside a split then consumes or destroys the host's only body.Even when
rawContentreally is the replay-minting wrapper,ByteChannelReplaystreams only to its first caller; every later caller waits for the whole origin to close before seeing a byte. A capture-side replay therefore either steals the streamed first read from the host -- buffering its body until the origin closes, which for a never-closing textual stream means forever -- or races it for that slot. Splitting the first access instead keeps the host's bytes flowing chunk-by-chunk as they arrive.
INVARIANT: the capture half of the split must always be fully drained and never cancelled. split's copier writes each chunk to both halves and awaits both writes before reading the next chunk from the origin, so an undrained capture half stalls -- and a cancelled one errors -- the host's half too. launchResponseBodyCapture upholds this by draining the remainder in a finally.
Re-readability is preserved without touching replay internals: the rebuilt response hands the host half to the first rawContent access and delegates every later access back to the wrapped response. On a saved body a second body<T>() therefore mints a fresh replay served from the saved copy -- complete by then, because the first read drained the origin -- exactly as it would without this plugin. With body saving off there is no replay to delegate to and a double body<T>() fails with DoubleReceiveException, again exactly as it would without this plugin.
The capture coroutine is launched on the call's own scope (response.call), not the client's -- a deliberate deviation from ResponseObserver's client-scoped launch: it dies with the individual call and can never outlive or leak past it. Exactly one transaction is recorded per received response, from a single site (PendingKtorTransaction.recordTo): immediately for metadata-only bodies, at the capture cap or channel close for captured ones, and from the observer's finally (metadata-only) if the call is torn down mid-body -- recording is never held hostage by a slow or never-ending stream. completedAtEpochMs remains the headers-received time, matching the OkHttp interceptor's timing semantics.
As before, every fallible step runs inside runCatching before the body channel is touched, so a capture bug can only ever lose a recording -- never break, replace, or delay what the host receives.