#![allow(dead_code)]
use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Serialize)]
pub struct NetworkTiming {
pub url: String,
pub method: String,
pub status: u16,
pub duration: Duration,
#[serde(skip_serializing_if = "Option::is_none")]
pub dns_time: Option<Duration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub connect_time: Option<Duration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tls_time: Option<Duration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttfb: Option<Duration>,
pub bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub speed_bps: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_headers: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_headers: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl NetworkTiming {
pub fn new(url: &str, method: &str) -> Self {
Self {
url: url.to_string(),
method: method.to_string(),
status: 0,
duration: Duration::ZERO,
dns_time: None,
connect_time: None,
tls_time: None,
ttfb: None,
bytes: 0,
speed_bps: None,
request_headers: None,
response_headers: None,
error: None,
}
}
pub fn complete(&mut self, status: u16, duration: Duration, bytes: u64) {
self.status = status;
self.duration = duration;
self.bytes = bytes;
if duration.as_secs_f64() > 0.0 {
self.speed_bps = Some(bytes as f64 / duration.as_secs_f64());
}
}
pub fn fail(&mut self, error: &str, duration: Duration) {
self.error = Some(error.to_string());
self.duration = duration;
}
}
#[derive(Debug, Clone, Serialize)]
pub struct DomainStats {
pub domain: String,
pub request_count: usize,
pub total_bytes: u64,
pub total_time: Duration,
pub avg_time: Duration,
pub failures: usize,
}
#[derive(Debug, Clone)]
pub struct NetworkTracer {
timings: Arc<Mutex<Vec<NetworkTiming>>>,
enabled: bool,
start: Instant,
}
impl Default for NetworkTracer {
fn default() -> Self {
Self::new()
}
}
impl NetworkTracer {
pub fn new() -> Self {
Self {
timings: Arc::new(Mutex::new(Vec::new())),
enabled: true,
start: Instant::now(),
}
}
pub fn disabled() -> Self {
Self {
timings: Arc::new(Mutex::new(Vec::new())),
enabled: false,
start: Instant::now(),
}
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn record(&self, timing: NetworkTiming) {
if !self.enabled {
return;
}
if let Ok(mut timings) = self.timings.lock() {
timings.push(timing);
}
}
pub fn start_request(&self, url: &str, method: &str) -> RequestGuard {
RequestGuard {
tracer: self.clone(),
timing: NetworkTiming::new(url, method),
start: Instant::now(),
}
}
pub fn get_timings(&self) -> Vec<NetworkTiming> {
self.timings.lock().map(|t| t.clone()).unwrap_or_default()
}
pub fn summary(&self) -> NetworkSummary {
let timings = self.get_timings();
let total_requests = timings.len();
let successful = timings
.iter()
.filter(|t| t.error.is_none() && t.status >= 200 && t.status < 400)
.count();
let failed = timings
.iter()
.filter(|t| t.error.is_some() || t.status >= 400)
.count();
let total_bytes: u64 = timings.iter().map(|t| t.bytes).sum();
let total_time: Duration = timings.iter().map(|t| t.duration).sum();
let slowest = timings.iter().max_by_key(|t| t.duration).cloned();
let largest = timings.iter().max_by_key(|t| t.bytes).cloned();
let domains = self.aggregate_by_domain(&timings);
NetworkSummary {
total_requests,
successful,
failed,
total_bytes,
total_time,
wall_time: self.start.elapsed(),
slowest,
largest,
domains,
}
}
fn aggregate_by_domain(&self, timings: &[NetworkTiming]) -> Vec<DomainStats> {
let mut domain_map: HashMap<String, (usize, u64, Duration, usize)> = HashMap::new();
for timing in timings {
let domain = extract_domain(&timing.url).unwrap_or_else(|| "unknown".to_string());
let entry = domain_map
.entry(domain)
.or_insert((0, 0, Duration::ZERO, 0));
entry.0 += 1; entry.1 += timing.bytes; entry.2 += timing.duration; if timing.error.is_some() || timing.status >= 400 {
entry.3 += 1; }
}
let mut domains: Vec<DomainStats> = domain_map
.into_iter()
.map(|(domain, (count, bytes, time, failures))| {
let avg_time = if count > 0 {
time / count as u32
} else {
Duration::ZERO
};
DomainStats {
domain,
request_count: count,
total_bytes: bytes,
total_time: time,
avg_time,
failures,
}
})
.collect();
domains.sort_by_key(|d| std::cmp::Reverse(d.total_bytes));
domains
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
let report = NetworkTraceReport {
timings: self.get_timings(),
summary: self.summary(),
};
serde_json::to_string_pretty(&report)
}
pub fn to_json_file(&self, path: &str) -> Result<(), super::error::ObservabilityError> {
let json = self.to_json()?;
std::fs::write(path, json)?;
Ok(())
}
}
pub struct RequestGuard {
tracer: NetworkTracer,
timing: NetworkTiming,
start: Instant,
}
impl RequestGuard {
pub fn complete(mut self, status: u16, bytes: u64) {
self.timing.complete(status, self.start.elapsed(), bytes);
self.tracer.record(self.timing.clone());
}
pub fn fail(mut self, error: &str) {
self.timing.fail(error, self.start.elapsed());
self.tracer.record(self.timing.clone());
}
pub fn set_response_headers(&mut self, headers: HashMap<String, String>) {
self.timing.response_headers = Some(headers);
}
}
#[derive(Debug, Clone, Serialize)]
pub struct NetworkSummary {
pub total_requests: usize,
pub successful: usize,
pub failed: usize,
pub total_bytes: u64,
pub total_time: Duration,
pub wall_time: Duration,
#[serde(skip_serializing_if = "Option::is_none")]
pub slowest: Option<NetworkTiming>,
#[serde(skip_serializing_if = "Option::is_none")]
pub largest: Option<NetworkTiming>,
pub domains: Vec<DomainStats>,
}
impl NetworkSummary {
pub fn to_summary_string(&self) -> String {
let mut output = String::new();
output.push_str("Network Trace Summary\n");
output.push_str("=====================\n\n");
output.push_str(&format!("Total requests: {}\n", self.total_requests));
output.push_str(&format!("Successful: {}\n", self.successful));
output.push_str(&format!("Failed: {}\n", self.failed));
output.push_str(&format!(
"Total data: {:.2} MB\n",
self.total_bytes as f64 / 1_000_000.0
));
output.push_str(&format!(
"Total time: {:.2}s\n",
self.total_time.as_secs_f64()
));
output.push('\n');
if let Some(ref slowest) = self.slowest {
output.push_str(&format!(
"Slowest request: {} ({:.2}s)\n",
truncate_url(&slowest.url, 50),
slowest.duration.as_secs_f64()
));
}
if let Some(ref largest) = self.largest {
output.push_str(&format!(
"Largest download: {} ({:.2} MB)\n",
truncate_url(&largest.url, 50),
largest.bytes as f64 / 1_000_000.0
));
}
if !self.domains.is_empty() {
output.push_str("\nDomains contacted:\n");
for domain in &self.domains {
output.push_str(&format!(
" {:30} {} requests, {:.2} MB\n",
domain.domain,
domain.request_count,
domain.total_bytes as f64 / 1_000_000.0
));
}
}
output
}
}
#[derive(Debug, Clone, Serialize)]
pub struct NetworkTraceReport {
pub timings: Vec<NetworkTiming>,
pub summary: NetworkSummary,
}
fn extract_domain(url: &str) -> Option<String> {
let url = url
.trim_start_matches("https://")
.trim_start_matches("http://");
url.split('/').next().map(|s| s.to_string())
}
fn truncate_url(url: &str, max_len: usize) -> String {
if url.len() <= max_len {
url.to_string()
} else {
format!("{}...", &url[..max_len - 3])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_network_timing_creation() {
let timing = NetworkTiming::new("https://example.com/file.tar.gz", "GET");
assert_eq!(timing.url, "https://example.com/file.tar.gz");
assert_eq!(timing.method, "GET");
assert_eq!(timing.status, 0);
}
#[test]
fn test_network_timing_complete() {
let mut timing = NetworkTiming::new("https://example.com/file.tar.gz", "GET");
timing.complete(200, Duration::from_secs(5), 100_000_000);
assert_eq!(timing.status, 200);
assert_eq!(timing.bytes, 100_000_000);
assert!(timing.speed_bps.is_some());
assert!((timing.speed_bps.unwrap() - 20_000_000.0).abs() < 1.0);
}
#[test]
fn test_network_tracer() {
let tracer = NetworkTracer::new();
let mut timing = NetworkTiming::new("https://example.com/test", "GET");
timing.complete(200, Duration::from_millis(100), 1000);
tracer.record(timing);
let timings = tracer.get_timings();
assert_eq!(timings.len(), 1);
assert_eq!(timings[0].status, 200);
}
#[test]
fn test_network_tracer_disabled() {
let tracer = NetworkTracer::disabled();
assert!(!tracer.is_enabled());
let timing = NetworkTiming::new("https://example.com/test", "GET");
tracer.record(timing);
let timings = tracer.get_timings();
assert!(timings.is_empty());
}
#[test]
fn test_network_summary() {
let tracer = NetworkTracer::new();
let mut t1 = NetworkTiming::new("https://example.com/a", "GET");
t1.complete(200, Duration::from_secs(1), 1000);
tracer.record(t1);
let mut t2 = NetworkTiming::new("https://example.com/b", "GET");
t2.complete(200, Duration::from_secs(2), 2000);
tracer.record(t2);
let summary = tracer.summary();
assert_eq!(summary.total_requests, 2);
assert_eq!(summary.successful, 2);
assert_eq!(summary.total_bytes, 3000);
}
#[test]
fn test_domain_aggregation() {
let tracer = NetworkTracer::new();
let mut t1 = NetworkTiming::new("https://example.com/a", "GET");
t1.complete(200, Duration::from_secs(1), 1000);
tracer.record(t1);
let mut t2 = NetworkTiming::new("https://other.com/b", "GET");
t2.complete(200, Duration::from_secs(2), 5000);
tracer.record(t2);
let summary = tracer.summary();
assert_eq!(summary.domains.len(), 2);
}
#[test]
fn test_extract_domain() {
assert_eq!(
extract_domain("https://example.com/path/file"),
Some("example.com".to_string())
);
assert_eq!(
extract_domain("http://test.org:8080/api"),
Some("test.org:8080".to_string())
);
}
#[test]
fn test_truncate_url() {
assert_eq!(truncate_url("short", 10), "short");
assert_eq!(truncate_url("this is a very long url", 10), "this is...");
}
#[test]
fn test_summary_string() {
let tracer = NetworkTracer::new();
let mut timing = NetworkTiming::new("https://example.com/test", "GET");
timing.complete(200, Duration::from_millis(100), 1000);
tracer.record(timing);
let summary = tracer.summary();
let output = summary.to_summary_string();
assert!(output.contains("Network Trace Summary"));
assert!(output.contains("Total requests:"));
assert!(output.contains("example.com"));
}
}