use std::{
collections::HashMap,
path::Path,
sync::atomic::{AtomicBool, Ordering},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing::debug;
use crate::error::{BrowserError, Result};
#[derive(Debug, Clone)]
pub struct RecorderConfig {
pub output_dir: std::path::PathBuf,
pub max_events: usize,
pub max_network_entries: usize,
}
impl Default for RecorderConfig {
fn default() -> Self {
let output_dir = std::env::var("STYGIAN_RECORD_DIR").map_or_else(
|_| std::path::PathBuf::from("./recordings"),
std::path::PathBuf::from,
);
Self {
output_dir,
max_events: 10_000,
max_network_entries: 5_000,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CdpEvent {
pub elapsed_ms: u64,
pub method: String,
pub params: Value,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Har {
pub log: HarLog,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HarLog {
pub version: String,
pub creator: HarCreator,
pub entries: Vec<HarEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct HarCreator {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HarEntry {
pub started_date_time: String,
pub time: f64,
pub request: HarRequest,
pub response: HarResponse,
pub timings: HarTimings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HarRequest {
pub method: String,
pub url: String,
pub http_version: String,
pub headers: Vec<HarHeader>,
pub query_string: Vec<HarQueryParam>,
pub headers_size: i64,
pub body_size: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HarResponse {
pub status: u16,
pub status_text: String,
pub http_version: String,
pub headers: Vec<HarHeader>,
pub content_mime_type: String,
pub body_size: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HarHeader {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HarQueryParam {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HarTimings {
pub receive: f64,
}
#[derive(Debug, Clone)]
struct NetworkEntry {
started_at: Instant,
started_iso: String,
#[allow(dead_code)]
request_id: String,
method: String,
url: String,
request_headers: Vec<HarHeader>,
status: u16,
status_text: String,
response_headers: Vec<HarHeader>,
mime_type: String,
encoded_data_length: i64,
}
fn iso_timestamp() -> String {
let d = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO);
let secs = d.as_secs();
let millis = d.subsec_millis();
let s = secs % 60;
let m = (secs / 60) % 60;
let h = (secs / 3600) % 24;
let days = secs / 86400;
let (year, month, day) = epoch_days_to_ymd(days);
format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}.{millis:03}Z")
}
fn epoch_days_to_ymd(days: u64) -> (u32, u32, u32) {
let d = i64::try_from(days)
.unwrap_or(i64::MAX)
.saturating_add(719_468); let era = d.div_euclid(146_097);
let doe = d.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if month <= 2 { y + 1 } else { y };
(
u32::try_from(year).unwrap_or(9999),
u32::try_from(month).unwrap_or(12),
u32::try_from(day).unwrap_or(31),
)
}
fn parse_query(url: &str) -> Vec<HarQueryParam> {
let query = url.split_once('?').map_or("", |(_, q)| q);
query
.split('&')
.filter(|p| !p.is_empty())
.filter_map(|p| p.split_once('='))
.map(|(k, v)| HarQueryParam {
name: k.to_string(),
value: v.to_string(),
})
.collect()
}
pub struct SessionRecorder {
config: RecorderConfig,
start: Instant,
running: AtomicBool,
events: std::sync::Mutex<Vec<CdpEvent>>,
pending: std::sync::Mutex<HashMap<String, NetworkEntry>>,
completed: std::sync::Mutex<Vec<NetworkEntry>>,
}
impl SessionRecorder {
pub fn start(config: RecorderConfig) -> Self {
debug!("SessionRecorder started");
Self {
config,
start: Instant::now(),
running: AtomicBool::new(true),
events: std::sync::Mutex::new(Vec::new()),
pending: std::sync::Mutex::new(HashMap::new()),
completed: std::sync::Mutex::new(Vec::new()),
}
}
pub fn is_running(&self) -> bool {
self.running.load(Ordering::Relaxed)
}
pub fn stop(&self) {
self.running.store(false, Ordering::Relaxed);
debug!("SessionRecorder stopped");
}
pub fn record_event(&self, method: &str, params: Value) {
if !self.is_running() {
return;
}
let elapsed_ms = u64::try_from(self.start.elapsed().as_millis()).unwrap_or(u64::MAX);
match method {
"Network.requestWillBeSent" => self.on_request_sent(¶ms, elapsed_ms),
"Network.responseReceived" => self.on_response_received(¶ms),
"Network.loadingFinished" => self.on_loading_finished(¶ms),
_ => {}
}
let Ok(mut guard) = self.events.lock() else {
return;
};
if guard.len() >= self.config.max_events {
guard.remove(0);
}
guard.push(CdpEvent {
elapsed_ms,
method: method.to_string(),
params,
});
}
pub fn export_event_log(&self, path: impl AsRef<Path>) -> Result<()> {
let guard = self
.events
.lock()
.map_err(|_| BrowserError::ConfigError("event log lock poisoned".to_string()))?;
let mut lines: Vec<String> = Vec::with_capacity(guard.len());
for event in guard.iter() {
if let Ok(s) = serde_json::to_string(event) {
lines.push(s);
}
}
drop(guard);
std::fs::write(path, lines.join("\n")).map_err(BrowserError::Io)
}
pub fn export_har(&self, path: impl AsRef<Path>) -> Result<()> {
let har = self.build_har();
let json = serde_json::to_string_pretty(&har)
.map_err(|e| BrowserError::ConfigError(format!("Failed to serialise HAR: {e}")))?;
std::fs::create_dir_all(path.as_ref().parent().unwrap_or_else(|| Path::new(".")))
.map_err(BrowserError::Io)?;
std::fs::write(path, json).map_err(BrowserError::Io)
}
pub fn event_count(&self) -> usize {
self.events.lock().map(|g| g.len()).unwrap_or(0)
}
pub fn network_entry_count(&self) -> usize {
self.completed.lock().map(|g| g.len()).unwrap_or(0)
}
fn on_request_sent(&self, params: &Value, _elapsed_ms: u64) {
let request_id = params
.get("requestId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let method = params
.pointer("/request/method")
.and_then(|v| v.as_str())
.unwrap_or("GET")
.to_string();
let url = params
.pointer("/request/url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let request_headers: Vec<HarHeader> = params
.pointer("/request/headers")
.and_then(|v| v.as_object())
.into_iter()
.flat_map(|m| {
m.iter().map(|(k, v)| HarHeader {
name: k.clone(),
value: v.as_str().unwrap_or("").to_string(),
})
})
.collect();
let entry = NetworkEntry {
started_at: Instant::now(),
started_iso: iso_timestamp(),
request_id: request_id.clone(),
method,
url,
request_headers,
status: 0,
status_text: String::new(),
response_headers: vec![],
mime_type: String::new(),
encoded_data_length: -1,
};
if let Ok(mut guard) = self.pending.lock() {
guard.insert(request_id, entry);
}
}
fn on_response_received(&self, params: &Value) {
let request_id = params
.get("requestId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let status = u16::try_from(
params
.pointer("/response/status")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
)
.unwrap_or(0);
let status_text = params
.pointer("/response/statusText")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let mime_type = params
.pointer("/response/mimeType")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let response_headers: Vec<HarHeader> = params
.pointer("/response/headers")
.and_then(|v| v.as_object())
.into_iter()
.flat_map(|m| {
m.iter().map(|(k, v)| HarHeader {
name: k.clone(),
value: v.as_str().unwrap_or("").to_string(),
})
})
.collect();
if let Ok(mut guard) = self.pending.lock()
&& let Some(entry) = guard.get_mut(&request_id)
{
entry.status = status;
entry.status_text = status_text;
entry.mime_type = mime_type;
entry.response_headers = response_headers;
}
}
fn on_loading_finished(&self, params: &Value) {
let request_id = params
.get("requestId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let encoded_data_length = params
.get("encodedDataLength")
.and_then(serde_json::Value::as_i64)
.unwrap_or(-1);
let Ok(mut pending_guard) = self.pending.lock() else {
return;
};
if let Some(mut entry) = pending_guard.remove(&request_id) {
entry.encoded_data_length = encoded_data_length;
if let Ok(mut completed) = self.completed.lock() {
if completed.len() >= self.config.max_network_entries {
completed.remove(0);
}
completed.push(entry);
}
}
}
fn build_har(&self) -> Har {
let completed = self.completed.lock().map(|g| g.clone()).unwrap_or_default();
let entries: Vec<HarEntry> = completed
.into_iter()
.map(|entry| {
let elapsed = entry.started_at.elapsed().as_secs_f64() * 1000.0;
let query_string = parse_query(&entry.url);
HarEntry {
started_date_time: entry.started_iso.clone(),
time: elapsed,
request: HarRequest {
method: entry.method,
url: entry.url,
http_version: "HTTP/1.1".to_string(),
headers: entry.request_headers,
query_string,
headers_size: -1,
body_size: -1,
},
response: HarResponse {
status: entry.status,
status_text: entry.status_text,
http_version: "HTTP/1.1".to_string(),
headers: entry.response_headers,
content_mime_type: entry.mime_type,
body_size: entry.encoded_data_length,
},
timings: HarTimings { receive: elapsed },
}
})
.collect();
Har {
log: HarLog {
version: "1.2".to_string(),
creator: HarCreator {
name: "stygian-browser".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
entries,
},
}
}
}
pub fn is_recording_enabled() -> bool {
matches!(
std::env::var("STYGIAN_RECORD_SESSION")
.unwrap_or_default()
.to_lowercase()
.as_str(),
"true" | "1" | "yes"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_recorder() -> SessionRecorder {
SessionRecorder::start(RecorderConfig {
output_dir: std::env::temp_dir(),
max_events: 100,
max_network_entries: 50,
})
}
#[test]
fn recorder_starts_running() {
let r = test_recorder();
assert!(r.is_running());
}
#[test]
fn recorder_stops() {
let r = test_recorder();
r.stop();
assert!(!r.is_running());
}
#[test]
fn records_events_while_running() {
let r = test_recorder();
r.record_event("Page.loadEventFired", serde_json::json!({"timestamp": 1.0}));
r.record_event("Page.frameNavigated", serde_json::json!({}));
assert_eq!(r.event_count(), 2);
}
#[test]
fn does_not_record_after_stop() {
let r = test_recorder();
r.stop();
r.record_event("Page.loadEventFired", serde_json::json!({}));
assert_eq!(r.event_count(), 0);
}
#[test]
fn max_events_caps_buffer() {
let r = SessionRecorder::start(RecorderConfig {
output_dir: std::env::temp_dir(),
max_events: 3,
max_network_entries: 10,
});
for i in 0..10 {
r.record_event("Test.event", serde_json::json!({"i": i}));
}
assert_eq!(r.event_count(), 3);
}
#[test]
fn network_tracking_builds_entry() {
let r = test_recorder();
r.record_event(
"Network.requestWillBeSent",
serde_json::json!({
"requestId": "req-1",
"request": {
"method": "GET",
"url": "https://example.com/api?foo=bar",
"headers": {"User-Agent": "test/1.0"}
}
}),
);
r.record_event(
"Network.responseReceived",
serde_json::json!({
"requestId": "req-1",
"response": {
"status": 200,
"statusText": "OK",
"mimeType": "application/json",
"headers": {"Content-Type": "application/json"}
}
}),
);
r.record_event(
"Network.loadingFinished",
serde_json::json!({
"requestId": "req-1",
"encodedDataLength": 512
}),
);
assert_eq!(r.network_entry_count(), 1);
}
#[test]
fn export_har_writes_valid_json() -> std::result::Result<(), Box<dyn std::error::Error>> {
let r = test_recorder();
r.record_event(
"Network.requestWillBeSent",
serde_json::json!({
"requestId": "r1",
"request": {"method": "GET", "url": "https://example.com/", "headers": {}}
}),
);
r.record_event(
"Network.responseReceived",
serde_json::json!({
"requestId": "r1",
"response": {"status": 200, "statusText": "OK", "mimeType": "text/html", "headers": {}}
}),
);
r.record_event(
"Network.loadingFinished",
serde_json::json!({"requestId": "r1", "encodedDataLength": 1024}),
);
let path = std::env::temp_dir().join("stygian_test.har");
r.export_har(&path)?;
let contents = std::fs::read_to_string(&path)?;
let har: Har = serde_json::from_str(&contents)?;
assert_eq!(har.log.entries.len(), 1);
if let Some(entry) = har.log.entries.first() {
assert_eq!(entry.request.method, "GET");
assert_eq!(entry.response.status, 200);
}
let _ = std::fs::remove_file(&path);
Ok(())
}
#[test]
fn event_log_export_writes_ndjson() -> std::result::Result<(), Box<dyn std::error::Error>> {
let r = test_recorder();
r.record_event("A", serde_json::json!({"x": 1}));
r.record_event("B", serde_json::json!({"y": 2}));
let path = std::env::temp_dir().join("stygian_events.ndjson");
r.export_event_log(&path)?;
let contents = std::fs::read_to_string(&path)?;
assert_eq!(contents.lines().count(), 2);
let _ = std::fs::remove_file(&path);
Ok(())
}
#[test]
fn parse_query_string() {
let params = parse_query("https://example.com/path?a=1&b=hello%20world");
assert_eq!(params.len(), 2);
let names: Vec<_> = params.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"a"), "missing 'a'");
assert!(names.contains(&"b"), "missing 'b'");
}
#[test]
fn iso_timestamp_format() {
let ts = iso_timestamp();
assert!(ts.ends_with('Z'), "should end with Z: {ts}");
assert_eq!(ts.len(), 24, "length should be 24: {ts}");
}
}