use std::collections::HashMap;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use tokio::sync::RwLock;
use tracing::{debug, trace};
use super::har::{Har, HarEntry, HarPage, HarRequest, HarResponse, HarTimings};
use crate::error::NetworkError;
#[derive(Debug, Clone)]
pub struct HarRecordingOptions {
pub path: PathBuf,
pub url_filter: Option<String>,
pub omit_content: bool,
pub content_types: Vec<String>,
pub max_body_size: usize,
}
impl Default for HarRecordingOptions {
fn default() -> Self {
Self {
path: PathBuf::new(),
url_filter: None,
omit_content: false,
content_types: Vec::new(),
max_body_size: 0,
}
}
}
#[derive(Debug)]
pub struct HarRecordingBuilder {
options: HarRecordingOptions,
}
impl HarRecordingBuilder {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
options: HarRecordingOptions {
path: path.into(),
..Default::default()
},
}
}
#[must_use]
pub fn url_filter(mut self, pattern: impl Into<String>) -> Self {
self.options.url_filter = Some(pattern.into());
self
}
#[must_use]
pub fn omit_content(mut self, omit: bool) -> Self {
self.options.omit_content = omit;
self
}
#[must_use]
pub fn content_types(mut self, types: Vec<String>) -> Self {
self.options.content_types = types;
self
}
#[must_use]
pub fn max_body_size(mut self, size: usize) -> Self {
self.options.max_body_size = size;
self
}
pub fn build(self) -> HarRecordingOptions {
self.options
}
}
#[derive(Debug, Clone)]
struct PendingHarRequest {
request_id: String,
started_at: DateTime<Utc>,
request: HarRequest,
page_ref: Option<String>,
resource_type: String,
frame_id: String,
timing: Option<HarTimings>,
}
#[derive(Debug)]
pub struct HarRecorder {
options: HarRecordingOptions,
har: RwLock<Har>,
pending_requests: RwLock<HashMap<String, PendingHarRequest>>,
current_page_id: RwLock<Option<String>>,
is_recording: RwLock<bool>,
url_matcher: Option<glob::Pattern>,
}
impl HarRecorder {
pub fn new(options: HarRecordingOptions) -> Result<Self, NetworkError> {
let url_matcher =
if let Some(ref pattern) = options.url_filter {
Some(glob::Pattern::new(pattern).map_err(|e| {
NetworkError::InvalidResponse(format!("Invalid URL pattern: {e}"))
})?)
} else {
None
};
Ok(Self {
options,
har: RwLock::new(Har::new("viewpoint", env!("CARGO_PKG_VERSION"))),
pending_requests: RwLock::new(HashMap::new()),
current_page_id: RwLock::new(None),
is_recording: RwLock::new(true),
url_matcher,
})
}
fn should_record_url(&self, url: &str) -> bool {
match &self.url_matcher {
Some(pattern) => pattern.matches(url),
None => true,
}
}
fn should_include_content(&self, mime_type: &str) -> bool {
if self.options.omit_content {
return false;
}
if self.options.content_types.is_empty() {
return true;
}
self.options
.content_types
.iter()
.any(|t| mime_type.contains(t))
}
pub async fn start_page(&self, page_id: &str, title: &str) {
let mut har = self.har.write().await;
let page = HarPage::new(
page_id,
title,
&Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
);
har.add_page(page);
let mut current = self.current_page_id.write().await;
*current = Some(page_id.to_string());
debug!(page_id = %page_id, title = %title, "Started recording page");
}
pub async fn record_request(
&self,
request_id: &str,
url: &str,
method: &str,
headers: &HashMap<String, String>,
post_data: Option<&str>,
resource_type: &str,
frame_id: &str,
) {
if !*self.is_recording.read().await {
return;
}
if !self.should_record_url(url) {
trace!(url = %url, "Skipping request - URL filter");
return;
}
let mut request = HarRequest::new(method, url);
request.set_headers(headers);
request.parse_query_string();
let content_type = headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
.map(|(_, v)| v.as_str());
request.set_post_data(post_data, content_type);
let page_ref = self.current_page_id.read().await.clone();
let pending = PendingHarRequest {
request_id: request_id.to_string(),
started_at: Utc::now(),
request,
page_ref,
resource_type: resource_type.to_string(),
frame_id: frame_id.to_string(),
timing: None,
};
let mut pending_requests = self.pending_requests.write().await;
pending_requests.insert(request_id.to_string(), pending);
trace!(request_id = %request_id, url = %url, "Recorded request");
}
pub async fn record_timing(
&self,
request_id: &str,
dns_start: f64,
dns_end: f64,
connect_start: f64,
connect_end: f64,
ssl_start: f64,
ssl_end: f64,
send_start: f64,
send_end: f64,
receive_headers_end: f64,
) {
let mut pending_requests = self.pending_requests.write().await;
if let Some(pending) = pending_requests.get_mut(request_id) {
pending.timing = Some(HarTimings::from_resource_timing(
dns_start,
dns_end,
connect_start,
connect_end,
ssl_start,
ssl_end,
send_start,
send_end,
receive_headers_end,
));
trace!(request_id = %request_id, "Recorded timing");
}
}
pub async fn record_response(
&self,
request_id: &str,
status: i32,
status_text: &str,
headers: &HashMap<String, String>,
mime_type: &str,
body: Option<&[u8]>,
server_ip: Option<&str>,
) {
let mut pending_requests = self.pending_requests.write().await;
let pending = if let Some(p) = pending_requests.remove(request_id) {
p
} else {
trace!(request_id = %request_id, "No pending request for response");
return;
};
let started_date_time = pending
.started_at
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let mut entry = HarEntry::new(&started_date_time);
entry.pageref = pending.page_ref;
entry.set_request(pending.request);
let mut response = HarResponse::new(status, status_text);
response.set_headers(headers);
if self.should_include_content(mime_type) {
let body_text = body.map(|b| {
let mut data = b;
if self.options.max_body_size > 0 && data.len() > self.options.max_body_size {
data = &data[..self.options.max_body_size];
}
if let Ok(text) = std::str::from_utf8(data) {
text.to_string()
} else {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(data)
}
});
let encoding = body.and_then(|b| {
if std::str::from_utf8(b).is_err() {
Some("base64")
} else {
None
}
});
response.set_content(body_text.as_deref(), mime_type, encoding);
}
entry.set_response(response);
if let Some(mut timing) = pending.timing {
let elapsed = Utc::now()
.signed_duration_since(pending.started_at)
.num_milliseconds() as f64;
timing.receive = elapsed - timing.total();
if timing.receive < 0.0 {
timing.receive = 0.0;
}
entry.set_timings(timing);
}
entry.server_ip_address = server_ip.map(std::string::ToString::to_string);
let mut har = self.har.write().await;
har.add_entry(entry);
trace!(request_id = %request_id, status = %status, "Recorded response");
}
pub async fn record_failure(&self, request_id: &str, error_text: &str) {
let mut pending_requests = self.pending_requests.write().await;
let pending = match pending_requests.remove(request_id) {
Some(p) => p,
None => return,
};
let started_date_time = pending
.started_at
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let mut entry = HarEntry::new(&started_date_time);
entry.pageref = pending.page_ref;
entry.set_request(pending.request);
entry.set_response(HarResponse::error(error_text));
if let Some(timing) = pending.timing {
entry.set_timings(timing);
}
let mut har = self.har.write().await;
har.add_entry(entry);
trace!(request_id = %request_id, error = %error_text, "Recorded request failure");
}
pub async fn save(&self) -> Result<PathBuf, NetworkError> {
let har = self.har.read().await;
let path = &self.options.path;
let json = serde_json::to_string_pretty(&*har)
.map_err(|e| NetworkError::InvalidResponse(format!("Failed to serialize HAR: {e}")))?;
tokio::fs::write(path, json)
.await
.map_err(|e| NetworkError::IoError(format!("Failed to write HAR file: {e}")))?;
debug!(path = %path.display(), "Saved HAR file");
Ok(path.clone())
}
pub async fn stop(&self) {
let mut is_recording = self.is_recording.write().await;
*is_recording = false;
}
pub async fn get_har(&self) -> Har {
self.har.read().await.clone()
}
pub fn path(&self) -> &PathBuf {
&self.options.path
}
}
#[cfg(test)]
mod tests;