Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

VariantEmitted whenPayload
Http(NetworkExchange)a request reaches loadingFinished / loadingFailedrequest + optional response + optional error
HttpDataa streamed chunk arrives (stream_bodies: true only)request_id, chunk
WebSocketOpenNetwork.webSocketCreatedrequest_id, url
WebSocketFramea frame is sent / receiveddirection, opcode, inline payload
WebSocketCloseNetwork.webSocketClosedrequest_id
EventSourceMessagean SSE message arrivesevent_name, event_id, inline data

HTTP exchanges are completed: the monitor correlates requestWillBeSentresponseReceivedloadingFinished 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 requestWillBeSentresponseReceivedloadingFinished / 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:

VariantEmitted whenThe correlator also…
Lagged { missed, generation }this subscription fell behind the connection's accounted event busclears 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 WebSocketclears all in-flight correlation state — nothing from the old socket will ever complete
Disconnected { generation }the transport's WebSocket died unexpectedlyclears state, then ends the monitor task — see below
CorrelationEvicted { url }the in-flight correlation map exceeded its 10k boundevicts one entry (previously silent beyond a tracing warning)
DecodeFaileda CDP payload didn't match the shape expected for its methodskips that one event only — no raw payload is ever included
Unknowna future transport-level variant this correlator doesn't yet recognizeconservatively 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(())
}