Skip to main content

glass/browser/session/
har.rs

1//! Network recording (HTTP Archive) capture.
2//!
3//! Provides bounded network event recording via CDP `Network` domain
4//! integration. Use [`BrowserSession::start_network_recording`] to
5//! begin capture and [`NetworkRecorder::stop`] to retrieve a
6//! [`NetworkRecording`] with collected entries.
7
8use super::*;
9use std::collections::HashMap;
10use std::sync::Arc;
11use tokio::sync::Mutex;
12
13/// A single recorded network request / response entry.
14#[derive(Debug, Clone, serde::Serialize)]
15pub struct NetworkEntry {
16    /// CDP request ID assigned by the browser.
17    pub request_id: String,
18    /// HTTP method (e.g. GET, POST).
19    pub method: String,
20    /// Request URL.
21    pub url: String,
22    /// HTTP status code, if the response was received.
23    pub status: Option<u16>,
24    /// Failure reason (e.g. "net::ERR_CONNECTION_REFUSED").
25    pub failure: Option<String>,
26    /// Number of redirects for this request chain.
27    pub redirect_count: u16,
28}
29
30/// The result of a completed network recording.
31///
32/// Returned by [`NetworkRecorder::stop`]. Contains all captured
33/// entries, approximate duration, and a count of dropped events.
34#[derive(Debug, Clone, serde::Serialize)]
35pub struct NetworkRecording {
36    /// Captured network request/response entries.
37    pub entries: Vec<NetworkEntry>,
38    /// Approximate duration of the recording in milliseconds.
39    pub duration_ms: u64,
40    /// Number of events dropped due to the 128-entry cap.
41    pub dropped_events: u64,
42}
43
44/// Scoped lease for network recording.
45///
46/// Created by [`BrowserSession::start_network_recording`]. While this
47/// guard is alive, `Network.requestWillBeSent`, `Network.responseReceived`,
48/// and `Network.loadingFailed` events are collected into an internal buffer
49/// (capped at 128 entries).
50///
51/// Call [`drain`](Self::drain) periodically to process events, then
52/// [`stop`](Self::stop) to finalize and retrieve the recording.
53///
54/// On drop, `Network.disable` is sent for best-effort cleanup.
55pub struct NetworkRecorder {
56    cdp: CdpClient,
57    session_id: String,
58    events: tokio::sync::broadcast::Receiver<crate::browser::cdp::CdpEventWithParams>,
59    entries: Arc<Mutex<Vec<NetworkEntry>>>,
60    request_indexes: Arc<Mutex<HashMap<String, usize>>>,
61    dropped: Arc<Mutex<u64>>,
62    armed: bool,
63}
64
65impl NetworkRecorder {
66    /// Enable the Network domain and begin recording.
67    pub(crate) async fn start(cdp: CdpClient) -> BrowserResult<Self> {
68        let session_id = cdp
69            .current_session_id()
70            .ok_or("network recorder requires an active page session")?;
71        let events = cdp.subscribe_events_with_params();
72        cdp.send_to_session(&session_id, "Network.enable", None)
73            .await?;
74        Ok(Self {
75            cdp,
76            session_id,
77            events,
78            entries: Arc::new(Mutex::new(Vec::new())),
79            request_indexes: Arc::new(Mutex::new(HashMap::new())),
80            dropped: Arc::new(Mutex::new(0)),
81            armed: true,
82        })
83    }
84
85    /// Stop recording, disable the Network domain, and return captured entries.
86    pub async fn stop(mut self) -> BrowserResult<NetworkRecording> {
87        self.armed = false;
88        disable_network_for(&self.cdp, Some(&self.session_id)).await?;
89        let entries = std::mem::take(&mut *self.entries.lock().await);
90        Ok(NetworkRecording {
91            entries,
92            duration_ms: 0,
93            dropped_events: *self.dropped.lock().await,
94        })
95    }
96
97    /// Process any pending network events into the internal buffer.
98    ///
99    /// Call this periodically during recording (e.g. in a loop) to
100    /// collect events. Idempotent — safe to call after the recording
101    /// is done.
102    pub async fn drain(&mut self) {
103        let mut entries = self.entries.lock().await;
104        let mut indexes = self.request_indexes.lock().await;
105        let mut dropped = self.dropped.lock().await;
106        while let Ok(event) = self.events.try_recv() {
107            if event.method == "Network.requestWillBeSent" {
108                let Some(rid) = event.params["requestId"].as_str() else {
109                    continue;
110                };
111                if let Some(idx) = indexes.get(rid).copied() {
112                    entries[idx].redirect_count = entries[idx].redirect_count.saturating_add(1);
113                    continue;
114                }
115                if entries.len() >= 128 {
116                    *dropped = dropped.saturating_add(1);
117                    continue;
118                }
119                let req = &event.params["request"];
120                let idx = entries.len();
121                indexes.insert(rid.to_string(), idx);
122                entries.push(NetworkEntry {
123                    request_id: rid.to_string(),
124                    method: req["method"].as_str().unwrap_or("").to_string(),
125                    url: req["url"].as_str().unwrap_or("").to_string(),
126                    status: None,
127                    failure: None,
128                    redirect_count: 0,
129                });
130            } else if event.method == "Network.responseReceived" {
131                if let Some(idx) = event.params["requestId"]
132                    .as_str()
133                    .and_then(|id| indexes.get(id))
134                    .copied()
135                {
136                    entries[idx].status = event.params["response"]["status"]
137                        .as_u64()
138                        .and_then(|s| u16::try_from(s).ok());
139                }
140            } else if event.method == "Network.loadingFailed"
141                && let Some(idx) = event.params["requestId"]
142                    .as_str()
143                    .and_then(|id| indexes.get(id))
144                    .copied()
145            {
146                entries[idx].failure = Some(
147                    event.params["errorText"]
148                        .as_str()
149                        .unwrap_or("unknown")
150                        .to_string(),
151                );
152            }
153        }
154    }
155}
156
157impl Drop for NetworkRecorder {
158    fn drop(&mut self) {
159        if self.armed {
160            let cdp = self.cdp.clone();
161            let sid = self.session_id.clone();
162            tokio::spawn(async move {
163                let _ = disable_network_for(&cdp, Some(&sid)).await;
164            });
165        }
166    }
167}
168
169impl BrowserSession {
170    /// Start recording network traffic for the active page session.
171    ///
172    /// Returns a `NetworkRecorder` guard. Call `NetworkRecorder::drain`
173    /// to collect events and `NetworkRecorder::stop` to finalize.
174    pub async fn start_network_recording(&self) -> BrowserResult<NetworkRecorder> {
175        self.cdp
176            .with_current_route(async { NetworkRecorder::start(self.cdp.clone()).await })
177            .await
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn network_entry_serializes_to_json() {
187        let entry = NetworkEntry {
188            request_id: "req-1".to_string(),
189            method: "GET".to_string(),
190            url: "https://example.com".to_string(),
191            status: Some(200),
192            failure: None,
193            redirect_count: 0,
194        };
195        let json = serde_json::to_value(&entry).unwrap();
196        assert_eq!(json["request_id"], "req-1");
197        assert_eq!(json["method"], "GET");
198        assert_eq!(json["url"], "https://example.com");
199        assert_eq!(json["status"], 200);
200        assert!(json["failure"].is_null());
201        assert_eq!(json["redirect_count"], 0);
202    }
203
204    #[test]
205    fn network_entry_serializes_failure_when_present() {
206        let entry = NetworkEntry {
207            request_id: "req-2".to_string(),
208            method: "POST".to_string(),
209            url: "https://example.com/api".to_string(),
210            status: None,
211            failure: Some("net::ERR_CONNECTION_REFUSED".to_string()),
212            redirect_count: 0,
213        };
214        let json = serde_json::to_value(&entry).unwrap();
215        assert_eq!(json["failure"], "net::ERR_CONNECTION_REFUSED");
216        assert!(json["status"].is_null());
217    }
218
219    #[test]
220    fn network_recording_serializes_to_json() {
221        let recording = NetworkRecording {
222            entries: vec![],
223            duration_ms: 1500,
224            dropped_events: 3,
225        };
226        let json = serde_json::to_value(&recording).unwrap();
227        assert_eq!(json["duration_ms"], 1500);
228        assert_eq!(json["dropped_events"], 3);
229        assert!(json["entries"].as_array().unwrap().is_empty());
230    }
231
232    #[test]
233    fn network_recording_defaults_start_at_zero() {
234        // Verify the zero-state that users see for empty recordings
235        let recording = NetworkRecording {
236            entries: vec![],
237            duration_ms: 0,
238            dropped_events: 0,
239        };
240        assert_eq!(recording.duration_ms, 0);
241        assert_eq!(recording.dropped_events, 0);
242        assert!(recording.entries.is_empty());
243    }
244
245    #[test]
246    fn network_entry_with_redirects_serializes_count() {
247        let entry = NetworkEntry {
248            request_id: "req-redirect".to_string(),
249            method: "GET".to_string(),
250            url: "https://example.com/redirected".to_string(),
251            status: Some(301),
252            failure: None,
253            redirect_count: 2,
254        };
255        let json = serde_json::to_value(&entry).unwrap();
256        assert_eq!(json["redirect_count"], 2);
257        assert_eq!(json["status"], 301);
258    }
259
260    #[test]
261    fn network_recording_with_entries_serializes_all() {
262        let recording = NetworkRecording {
263            entries: vec![
264                NetworkEntry {
265                    request_id: "r1".to_string(),
266                    method: "GET".to_string(),
267                    url: "/a".to_string(),
268                    status: Some(200),
269                    failure: None,
270                    redirect_count: 0,
271                },
272                NetworkEntry {
273                    request_id: "r2".to_string(),
274                    method: "POST".to_string(),
275                    url: "/b".to_string(),
276                    status: None,
277                    failure: Some("net::ERR_FAILED".to_string()),
278                    redirect_count: 0,
279                },
280            ],
281            duration_ms: 500,
282            dropped_events: 0,
283        };
284        let json = serde_json::to_value(&recording).unwrap();
285        assert_eq!(json["entries"].as_array().unwrap().len(), 2);
286        assert_eq!(json["entries"][0]["method"], "GET");
287        assert_eq!(json["entries"][1]["failure"], "net::ERR_FAILED");
288    }
289}