Network monitor
Enable the feature:
[dependencies]
zendriver = { version = "0.1", features = ["monitor"] }
tab.monitor() returns a MonitorBuilder; set an optional URL filter,
then .start().await? spawns the correlator task and hands back a
NetworkMonitor — a Stream of NetworkEvent:
use futures::StreamExt;
// Start the monitor BEFORE navigating so no events are missed.
let mut monitor = tab.monitor().url_pattern("/api/").start().await?;
tab.goto("https://example.com").await?;
while let Some(event) = monitor.next().await {
match event {
zendriver::NetworkEvent::Http(ex) => {
println!("{} {} -> {:?}", ex.request.method, ex.request.url, ex.status());
if ex.is_success() {
let body = ex.text().await?; // lazy: fetched on demand
println!("{body}");
}
}
zendriver::NetworkEvent::WebSocketFrame { direction, payload, .. } => {
println!("ws {direction:?}: {payload}");
}
_ => {}
}
}
Event model
NetworkEvent is a tagged enum:
| Variant | Emitted when | Payload |
|---|---|---|
Http(NetworkExchange) | a request reaches loadingFinished / loadingFailed | request + optional response + optional error |
HttpData | a streamed chunk arrives (stream_bodies: true only) | request_id, chunk |
WebSocketOpen | Network.webSocketCreated | request_id, url |
WebSocketFrame | a frame is sent / received | direction, opcode, inline payload |
WebSocketClose | Network.webSocketClosed | request_id |
EventSourceMessage | an SSE message arrives | event_name, event_id, inline data |
HTTP exchanges are completed: the monitor correlates
requestWillBeSent → responseReceived → loadingFinished by requestId
and emits one NetworkEvent::Http per request. WebSocket and EventSource
payloads are delivered inline (they arrive whole in the CDP event); HTTP
bodies are lazy by default, or incrementally streamed as HttpData chunks
when opted in — see Streaming bodies below.
Streaming bodies
By default HTTP bodies are whole-body-buffered (fetched on demand, above).
MonitorBuilder::stream_bodies opts a monitor into incremental delivery
instead:
let mut monitor = tab.monitor().stream_bodies(true).start().await?;
tab.goto("https://example.com").await?;
while let Some(event) = monitor.next().await {
match event {
zendriver::NetworkEvent::HttpData { request_id, chunk } => {
println!("{request_id}: {} bytes", chunk.len());
}
zendriver::NetworkEvent::Http(ex) => {
println!("{} -> {:?} (request_id {})", ex.request.url, ex.status(), ex.request_id());
}
_ => {}
}
}
Mechanism: CDP Network.streamResourceContent — passive, no Fetch domain
interception, no response pausing (unlike Fetch.takeResponseBodyAsStream,
which was rejected for exactly that reason). The correlator enables it for
each request as soon as it's observed (requestWillBeSent), before it's even
sent on the wire — empirically, waiting for responseReceived leaves too
little headroom for the enable call (an async CDP round-trip) to land before
a fast response's loadingFinished, past which Chrome rejects the call.
Filtered by MonitorBuilder::url_pattern like every other event — there is
no separate filter for streaming.
NetworkEvent::HttpData is a sibling to WebSocketFrame /
EventSourceMessage: emitted once per received chunk, not accumulated.
Chunks for a request always arrive before the NetworkEvent::Http exchange
for the same request (which only fires on loadingFinished /
loadingFailed) — correlate them by request_id, which matches
NetworkExchange::request_id on the eventual Http event. Any bytes Chrome
already buffered before the enable call landed are emitted as the first
chunk, so nothing from that window is lost.
Chrome version / graceful fallback: Network.streamResourceContent
needs roughly Chrome 124+. On an older Chrome the enable call comes back
"method not found" — the monitor logs one tracing::warn! (not once per
request), stops issuing the call, and simply never emits HttpData for that
session.
Other enable errors are treated as being about that one request rather than about the browser, and streaming stays on for the next one. The common case is a response that finished loading before the enable call landed, which a fast (especially loopback) resource can lose without saying anything about anything else; that one is logged at debug. Anything else warns once.
Either way NetworkExchange::body keeps working as the whole-body fallback
the entire time, and the monitor never fails or ends over this.
Off by default — streaming every response is wasted CDP round-trips when bodies are small; opt in only when you need bytes as they arrive rather than once the whole response completes.
Lazy bodies
HTTP bodies are fetched on demand via NetworkExchange::body /
NetworkExchange::text (CDP Network.getResponseBody):
if let zendriver::NetworkEvent::Http(ex) = event {
let bytes: Vec<u8> = ex.body().await?;
}
Chrome only retains a response body for a short window after the response
completes, so call body() / text() promptly after observing the
exchange — a later call can fail with
ZendriverError::NetworkMonitor
if the body was already evicted.
URL filtering
MonitorBuilder::url_pattern takes any Into<UrlMatcher> (a &str /
String substring, or a regex::Regex) — the same matcher type the
expect surface uses. For HTTP the request URL is matched;
for WebSocket / EventSource the connection URL observed at open time is
matched. Unmatched events are dropped before they reach the stream.
Lifecycle
NetworkMonitor owns the correlator task. Dropping the monitor — or
calling NetworkMonitor::stop — cancels that task; there is no leaked
subscriber. The correlation map is bounded (10k in-flight requests).
Delivery-loss boundaries
Http exchanges are assembled by correlating requestWillBeSent →
responseReceived → loadingFinished / loadingFailed by requestId. The
correlator rides the transport's loss-accounted event stream, so instead of
ever silently stitching a possibly-bogus "complete" exchange across a gap,
silently evicting a stuck correlation entry, or silently skipping a payload
it couldn't decode, every one of those cases surfaces as an explicit
NetworkEvent::DeliveryBoundary(NetworkDeliveryBoundary) on the
same stream:
| Variant | Emitted when | The correlator also… |
|---|---|---|
Lagged { missed, generation } | this subscription fell behind the connection's accounted event bus | clears all in-flight correlation state — a gap means any partial exchange spanning it can't be trusted |
Reconnected { previous, generation } | the transport re-established a fresh WebSocket | clears all in-flight correlation state — nothing from the old socket will ever complete |
Disconnected { generation } | the transport's WebSocket died unexpectedly | clears state, then ends the monitor task — see below |
CorrelationEvicted { url } | the in-flight correlation map exceeded its 10k bound | evicts one entry (previously silent beyond a tracing warning) |
DecodeFailed | a CDP payload didn't match the shape expected for its method | skips that one event only — no raw payload is ever included |
Unknown | a future transport-level variant this correlator doesn't yet recognize | conservatively clears state, but keeps running |
DeliveryBoundary events bypass any MonitorBuilder::url_pattern filter —
they describe the monitor's own health, not a specific exchange. A consumer
that ignores this variant still sees every fully-observed exchange exactly as
before; it just loses the ability to tell "nothing happened" apart from
"something was lost and I was never told."
match event {
zendriver::NetworkEvent::DeliveryBoundary(b) => {
eprintln!("network monitor gap: {b:?}");
// Decide whether to resync, alert, or (for `Disconnected`) restart
// the monitor.
}
_ => { /* handle the fully-observed variants as usual */ }
}
Disconnected ends the monitor task — fail closed. There is no
automatic reconnect: once a Disconnected boundary is emitted, the
correlator task returns and the NetworkMonitor stream ends (None on the
next poll). A consumer that wants to keep observing across a transport blip
must call tab.monitor()...start() again to spawn a fresh correlator.
Bounded response bodies
NetworkExchange::body / text are unbounded —
they fetch and return the entire body every time, unchanged by this section.
For a size cap, wrap the fetched bytes yourself with BoundedBody::capture:
use zendriver::BoundedBody;
if let zendriver::NetworkEvent::Http(ex) = event {
let full = ex.body().await?;
let bounded = BoundedBody::capture(&full, 1024 * 1024); // cap at 1 MiB
if bounded.truncated {
println!("body truncated: kept {} of {} bytes", bounded.bytes.len(), bounded.full_len);
}
}
max_bytes == 0 means unbounded — every byte is kept and truncated is
always false. Bounding is always computed against the raw decoded byte
length, never a base64 (or other encoded) representation's length.
The MCP browser_monitor_start tool wires this in for you: its
capture_body_max_bytes input (default 1 MiB, 0 for unbounded) bounds each
captured body, and browser_monitor_read's http events carry
body_truncated / body_full_bytes alongside body / body_base64. A
body-fetch failure (e.g. Chrome already evicted the response) sets
body_capture_error instead of silently degrading to an indistinguishable
empty body. See mcp.md.
Full example
//! Demonstrates the network monitor API (`tab.monitor()`). //! //! Launches a browser, navigates to example.com, and runs a network monitor //! that prints every HTTP exchange (method / URL / status) and WebSocket //! frame observed while the page loads. //! //! The monitor is a [`futures::Stream`] over //! [`zendriver::NetworkEvent`](zendriver::monitor::NetworkEvent) — it runs in //! the background and delivers events as the browser fires them. Dropping the //! monitor (or calling `.stop()`) cancels its background task. //! //! Requires the `monitor` cargo feature: //! `cargo run --example network_monitor --features monitor`. use futures::StreamExt; use zendriver::Browser; use zendriver::monitor::{FrameDirection, NetworkDeliveryBoundary, NetworkEvent}; #[tokio::main] #[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error async fn main() -> zendriver::Result<()> { tracing_subscriber::fmt::init(); let browser = Browser::builder().headless(true).launch().await?; let tab = browser.main_tab(); // Start the monitor BEFORE navigating so no events are missed. // An optional URL pattern restricts events to matching URLs (substring // match). Omit `.url_pattern(...)` to observe all network activity. let mut monitor = tab.monitor().url_pattern("example.com").start().await?; tab.goto("https://example.com").await?; tab.wait_for_load().await?; // Drain events until the channel is empty (monitor still running but // nothing new is in flight). In a real application you would drive the // stream until a specific event arrives or a timeout fires. while let Ok(Some(event)) = tokio::time::timeout(std::time::Duration::from_millis(500), monitor.next()).await { match event { NetworkEvent::Http(exchange) => { let status = exchange.response.as_ref().map_or_else( || exchange.error.clone().unwrap_or_default(), |r| r.status.to_string(), ); println!( "[HTTP] {} {} -> {}", exchange.request.method, exchange.request.url, status ); } // Only emitted when the monitor is started with // `.stream_bodies(true)` (not enabled in this example) — shown // here for completeness since the match must be exhaustive. NetworkEvent::HttpData { request_id, chunk } => { println!("[DATA] id={request_id} {} bytes", chunk.len()); } NetworkEvent::WebSocketOpen { url, request_id } => { println!("[WS ] open id={request_id} url={url}"); } NetworkEvent::WebSocketFrame { request_id, direction, opcode, payload, } => { let dir = match direction { FrameDirection::Sent => "sent", FrameDirection::Received => "recv", }; println!("[WS ] frame {dir} id={request_id} opcode={opcode} payload={payload:?}"); } NetworkEvent::WebSocketClose { request_id } => { println!("[WS ] close id={request_id}"); } NetworkEvent::EventSourceMessage { request_id, event_name, data, .. } => { println!("[SSE ] id={request_id} event={event_name:?} data={data:?}"); } // A delivery-loss boundary — a lagged/reconnected/disconnected // transport, a correlation-map eviction, or an undecodable // payload. Ignoring this variant is fine (every fully-observed // exchange above still arrives); printing it here just shows // where a real consumer would decide whether to resync, alert, // or restart the monitor. `Disconnected` in particular means // this monitor's correlator task has already ended — see // `NetworkDeliveryBoundary::Disconnected`. NetworkEvent::DeliveryBoundary(boundary) => { println!("[GAP ] {boundary:?}"); if matches!(boundary, NetworkDeliveryBoundary::Disconnected { .. }) { break; } } } } browser.close().await?; Ok(()) }