use std::path::PathBuf;
use crate::context::ContextRef;
use crate::error::{FerriError, Result};
use crate::network::Request;
use crate::url_matcher::UrlMatcher;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum HarContentPolicy {
Embed,
Attach,
Omit,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum HarMode {
Full,
Minimal,
}
#[derive(Default)]
pub struct StartHarOptions {
pub content: Option<HarContentPolicy>,
pub mode: Option<HarMode>,
pub url_filter: Option<UrlMatcher>,
}
pub struct HarRecorder {
pub path: PathBuf,
pub content: HarContentPolicy,
pub mode: HarMode,
pub url_filter: UrlMatcher,
pub start_len: usize,
}
pub struct Tracing {
ctx: ContextRef,
}
impl Tracing {
#[must_use]
pub(crate) fn new(ctx: ContextRef) -> Self {
Self { ctx }
}
pub async fn start_har(&self, path: impl Into<PathBuf>, options: StartHarOptions) -> Result<()> {
let path = path.into();
if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip")) {
return Err(FerriError::unsupported(
"zip HAR archives are not implemented; use a .har path",
));
}
let composite = self.ctx.composite();
let start_len = self.network_log_len().await;
let recorder = HarRecorder {
path,
content: options.content.unwrap_or(HarContentPolicy::Embed),
mode: options.mode.unwrap_or(HarMode::Full),
url_filter: options.url_filter.unwrap_or_else(UrlMatcher::any),
start_len,
};
let recorders = self.ctx.har_recorders().await;
let mut guard = recorders.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if guard.contains_key(&composite) {
return Err(FerriError::backend(
"HAR recording has already been started".to_string(),
));
}
guard.insert(composite, recorder);
Ok(())
}
pub async fn stop_har(&self) -> Result<()> {
let composite = self.ctx.composite();
let recorder = {
let recorders = self.ctx.har_recorders().await;
let mut guard = recorders.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
guard.remove(&composite)
};
let Some(recorder) = recorder else {
return Err(FerriError::backend("HAR recording has not been started".to_string()));
};
let requests = self.network_log_slice(recorder.start_len).await;
let archive = build_har(&requests, &recorder).await;
let json =
serde_json::to_string_pretty(&archive).map_err(|e| FerriError::backend(format!("serialize HAR: {e}")))?;
std::fs::write(&recorder.path, json)
.map_err(|e| FerriError::backend(format!("write HAR {}: {e}", recorder.path.display())))?;
Ok(())
}
async fn network_log_len(&self) -> usize {
match self.ctx.network_log_handle().await {
Some(log) => log.read().await.len(),
None => 0,
}
}
async fn network_log_slice(&self, start: usize) -> Vec<Request> {
match self.ctx.network_log_handle().await {
Some(log) => {
let reqs = log.read().await;
reqs.iter().skip(start).cloned().collect()
},
None => Vec::new(),
}
}
pub async fn start(&self) -> Result<()> {
Err(self.trace_zip_unsupported().await)
}
pub async fn start_chunk(&self) -> Result<()> {
Err(self.trace_zip_unsupported().await)
}
pub async fn stop_chunk(&self) -> Result<()> {
Err(self.trace_zip_unsupported().await)
}
pub async fn stop(&self) -> Result<()> {
Err(self.trace_zip_unsupported().await)
}
async fn trace_zip_unsupported(&self) -> FerriError {
let har_active = {
let recorders = self.ctx.har_recorders().await;
let guard = recorders.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
guard.contains_key(&self.ctx.composite())
};
let hint = if har_active {
"a HAR recording is active on this context"
} else {
"use startHar/stopHar for network capture"
};
FerriError::unsupported(format!(
"trace.zip recording (start/stop/startChunk/stopChunk) is not implemented; {hint}"
))
}
}
async fn build_har(requests: &[Request], recorder: &HarRecorder) -> HarArchive {
let mut entries = Vec::new();
for req in requests {
if !recorder.url_filter.matches(req.url()) {
continue;
}
entries.push(build_entry(req, recorder).await);
}
HarArchive {
log: HarLogOut {
version: "1.2",
creator: HarCreator {
name: "ferridriver",
version: env!("CARGO_PKG_VERSION"),
},
entries,
},
}
}
async fn build_entry(req: &Request, recorder: &HarRecorder) -> HarEntryOut {
let request_headers = header_pairs(&req.headers());
let post_data = req.post_data().map(|text| HarPostData {
mime_type: req
.headers()
.get("content-type")
.cloned()
.unwrap_or_else(|| "application/octet-stream".to_string()),
text,
});
let (response_out, response_present) = match req.response().await.ok().flatten() {
Some(resp) => {
let headers = header_pairs(&resp.headers());
let mime_type = resp
.headers()
.get("content-type")
.cloned()
.unwrap_or_else(|| "x-unknown".to_string());
let content = build_content(&resp, &mime_type, recorder.content).await;
(
HarResponseOut {
status: resp.status(),
status_text: resp.status_text().to_string(),
http_version: "HTTP/1.1".to_string(),
headers,
content,
redirect_url: String::new(),
headers_size: -1,
body_size: -1,
},
true,
)
},
None => (HarResponseOut::empty(), false),
};
let _ = (recorder.mode, response_present);
HarEntryOut {
started_date_time: now_iso8601(),
time: 0.0,
request: HarRequestOut {
method: req.method().to_string(),
url: req.url().to_string(),
http_version: "HTTP/1.1".to_string(),
headers: request_headers,
query_string: Vec::new(),
post_data,
headers_size: -1,
body_size: -1,
},
response: response_out,
cache: HarCache {},
timings: HarTimings {
send: 0.0,
wait: 0.0,
receive: 0.0,
},
}
}
async fn build_content(resp: &crate::network::Response, mime_type: &str, policy: HarContentPolicy) -> HarContentOut {
if policy == HarContentPolicy::Omit {
return HarContentOut {
size: 0,
mime_type: mime_type.to_string(),
text: None,
encoding: None,
};
}
match resp.body().await {
Ok(bytes) => {
let size = i64::try_from(bytes.len()).unwrap_or(i64::MAX);
if let Ok(text) = std::str::from_utf8(&bytes) {
HarContentOut {
size,
mime_type: mime_type.to_string(),
text: Some(text.to_string()),
encoding: None,
}
} else {
use base64::Engine;
HarContentOut {
size,
mime_type: mime_type.to_string(),
text: Some(base64::engine::general_purpose::STANDARD.encode(&bytes)),
encoding: Some("base64".to_string()),
}
}
},
Err(_) => HarContentOut {
size: 0,
mime_type: mime_type.to_string(),
text: None,
encoding: None,
},
}
}
fn header_pairs(headers: &crate::network::Headers) -> Vec<HarHeaderOut> {
headers
.iter()
.map(|(name, value)| HarHeaderOut {
name: name.clone(),
value: value.clone(),
})
.collect()
}
fn now_iso8601() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let total_ms = i64::try_from(now.as_millis()).unwrap_or(i64::MAX);
let secs = total_ms.div_euclid(1000);
let ms = total_ms.rem_euclid(1000);
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
let (hour, minute, second) = (tod / 3600, (tod % 3600) / 60, tod % 60);
let (year, month, day) = civil_from_days(days);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{ms:03}Z")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - 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 d = u32::try_from(doy - (153 * mp + 2) / 5 + 1).unwrap_or(1);
let m = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1);
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[derive(serde::Serialize)]
struct HarArchive {
log: HarLogOut,
}
#[derive(serde::Serialize)]
struct HarLogOut {
version: &'static str,
creator: HarCreator,
entries: Vec<HarEntryOut>,
}
#[derive(serde::Serialize)]
struct HarCreator {
name: &'static str,
version: &'static str,
}
#[derive(serde::Serialize)]
struct HarEntryOut {
#[serde(rename = "startedDateTime")]
started_date_time: String,
time: f64,
request: HarRequestOut,
response: HarResponseOut,
cache: HarCache,
timings: HarTimings,
}
#[derive(serde::Serialize)]
struct HarRequestOut {
method: String,
url: String,
#[serde(rename = "httpVersion")]
http_version: String,
headers: Vec<HarHeaderOut>,
#[serde(rename = "queryString")]
query_string: Vec<HarHeaderOut>,
#[serde(rename = "postData", skip_serializing_if = "Option::is_none")]
post_data: Option<HarPostData>,
#[serde(rename = "headersSize")]
headers_size: i64,
#[serde(rename = "bodySize")]
body_size: i64,
}
#[derive(serde::Serialize)]
struct HarPostData {
#[serde(rename = "mimeType")]
mime_type: String,
text: String,
}
#[derive(serde::Serialize)]
struct HarResponseOut {
status: i64,
#[serde(rename = "statusText")]
status_text: String,
#[serde(rename = "httpVersion")]
http_version: String,
headers: Vec<HarHeaderOut>,
content: HarContentOut,
#[serde(rename = "redirectURL")]
redirect_url: String,
#[serde(rename = "headersSize")]
headers_size: i64,
#[serde(rename = "bodySize")]
body_size: i64,
}
impl HarResponseOut {
fn empty() -> Self {
Self {
status: 0,
status_text: String::new(),
http_version: "HTTP/1.1".to_string(),
headers: Vec::new(),
content: HarContentOut {
size: 0,
mime_type: "x-unknown".to_string(),
text: None,
encoding: None,
},
redirect_url: String::new(),
headers_size: -1,
body_size: -1,
}
}
}
#[derive(serde::Serialize)]
struct HarContentOut {
size: i64,
#[serde(rename = "mimeType")]
mime_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
encoding: Option<String>,
}
#[derive(serde::Serialize)]
struct HarHeaderOut {
name: String,
value: String,
}
#[derive(serde::Serialize)]
struct HarCache {}
#[derive(serde::Serialize)]
struct HarTimings {
send: f64,
wait: f64,
receive: f64,
}