Skip to main content

har/analysis/
hosts.rs

1use crate::filter::Filter;
2use crate::fingerprint::fingerprint;
3use crate::model::{Capture, Entry};
4use crate::render::{human_bytes, human_ms};
5use crate::stats::percentiles;
6use ahash::AHashMap;
7use serde::Serialize;
8use std::collections::BTreeMap;
9
10#[derive(Debug, Serialize)]
11pub struct HostsResult {
12    pub hosts: Vec<HostStat>,
13}
14
15#[derive(Debug, Serialize)]
16pub struct HostStat {
17    pub host: String,
18    pub count: usize,
19    pub methods: BTreeMap<String, usize>,
20    pub status_classes: BTreeMap<String, usize>,
21    pub error_count: usize,
22    pub p50_ms: f64,
23    pub p95_ms: f64,
24    pub max_ms: f64,
25    pub bytes_sent: i64,
26    pub bytes_received: i64,
27    pub duplicate_count: usize,
28    pub first_offset_ms: f64,
29    pub last_offset_ms: f64,
30}
31
32/// Aggregate the filtered capture per host. `top` bounds the returned list.
33pub fn compute_hosts(cap: &Capture, filter: &Filter, top: usize) -> HostsResult {
34    let mut by_host: AHashMap<String, Vec<&Entry>> = AHashMap::new();
35    for e in cap.entries.iter().filter(|e| filter.matches(e)) {
36        by_host.entry(e.host.clone()).or_default().push(e);
37    }
38
39    let mut hosts: Vec<HostStat> = by_host
40        .into_iter()
41        .map(|(host, entries)| host_stat(host, &entries))
42        .collect();
43
44    hosts.sort_by(|a, b| b.count.cmp(&a.count).then(a.host.cmp(&b.host)));
45    hosts.truncate(top);
46    HostsResult { hosts }
47}
48
49fn host_stat(host: String, entries: &[&Entry]) -> HostStat {
50    let mut methods: BTreeMap<String, usize> = BTreeMap::new();
51    let mut status_classes: BTreeMap<String, usize> = BTreeMap::new();
52    let mut fp_counts: AHashMap<String, usize> = AHashMap::new();
53    let mut durations: Vec<f64> = Vec::with_capacity(entries.len());
54    let mut error_count = 0usize;
55    let mut bytes_sent = 0i64;
56    let mut bytes_received = 0i64;
57    let mut first = f64::MAX;
58    let mut last = f64::MIN;
59
60    for e in entries {
61        *methods.entry(e.method.to_ascii_uppercase()).or_default() += 1;
62        *status_classes
63            .entry(status_class_label(e.status_class()))
64            .or_default() += 1;
65        if e.is_error() {
66            error_count += 1;
67        }
68        durations.push(e.duration_ms);
69        bytes_sent += e.sizes.req_body.max(0);
70        bytes_received += e.sizes.resp_content.max(e.sizes.resp_body).max(0);
71        first = first.min(e.started_offset_ms);
72        last = last.max(e.started_offset_ms);
73        *fp_counts.entry(fingerprint(e)).or_default() += 1;
74    }
75
76    let duplicate_count: usize = fp_counts.values().filter(|c| **c > 1).sum();
77    let p = percentiles(&durations);
78
79    HostStat {
80        host,
81        count: entries.len(),
82        methods,
83        status_classes,
84        error_count,
85        p50_ms: p.p50,
86        p95_ms: p.p95,
87        max_ms: p.max,
88        bytes_sent,
89        bytes_received,
90        duplicate_count,
91        first_offset_ms: if first == f64::MAX { 0.0 } else { first },
92        last_offset_ms: if last == f64::MIN { 0.0 } else { last },
93    }
94}
95
96fn status_class_label(class: i64) -> String {
97    match class {
98        2 => "2xx",
99        3 => "3xx",
100        4 => "4xx",
101        5 => "5xx",
102        _ => "other",
103    }
104    .to_string()
105}
106
107/// Render hosts as deterministic terminal text.
108pub fn render_hosts_text(r: &HostsResult) -> String {
109    let mut out = String::new();
110    out.push_str("== wiretrail hosts ==\n");
111    for h in &r.hosts {
112        out.push_str(&format!(
113            "\n{}  ({} req, {} err, {} dup)\n",
114            h.host, h.count, h.error_count, h.duplicate_count
115        ));
116        out.push_str(&format!(
117            "  latency p50/p95/max: {} / {} / {}\n",
118            human_ms(h.p50_ms),
119            human_ms(h.p95_ms),
120            human_ms(h.max_ms)
121        ));
122        out.push_str(&format!(
123            "  bytes sent/received: {} / {}\n",
124            human_bytes(h.bytes_sent),
125            human_bytes(h.bytes_received)
126        ));
127        let methods: Vec<String> = h.methods.iter().map(|(m, c)| format!("{m}:{c}")).collect();
128        out.push_str(&format!("  methods: {}\n", methods.join(" ")));
129        let statuses: Vec<String> = h
130            .status_classes
131            .iter()
132            .map(|(s, c)| format!("{s}:{c}"))
133            .collect();
134        out.push_str(&format!("  status: {}\n", statuses.join(" ")));
135    }
136    out
137}
138
139#[cfg(test)]
140mod tests {
141    use super::compute_hosts;
142    use crate::filter::Filter;
143    use crate::model::{sample_capture, sample_entry};
144
145    fn cap() -> crate::model::Capture {
146        let mut entries = vec![
147            sample_entry(0, "api.foo.com", "GET", "/v1/a", 200),
148            sample_entry(1, "api.foo.com", "GET", "/v1/a", 200), // duplicate of e0
149            sample_entry(2, "api.foo.com", "POST", "/v1/b", 500),
150            sample_entry(3, "cdn.bar.com", "GET", "/img", 200),
151        ];
152        entries[2].duration_ms = 100.0;
153        sample_capture(entries)
154    }
155
156    #[test]
157    fn groups_by_host_with_counts_and_errors() {
158        let r = compute_hosts(&cap(), &Filter::parse(&[]).unwrap(), 10);
159        let foo = r.hosts.iter().find(|h| h.host == "api.foo.com").unwrap();
160        assert_eq!(foo.count, 3);
161        assert_eq!(foo.error_count, 1);
162        assert_eq!(foo.methods.get("GET"), Some(&2));
163        assert_eq!(foo.methods.get("POST"), Some(&1));
164        // e0 and e1 are identical -> 2 duplicate members
165        assert_eq!(foo.duplicate_count, 2);
166        assert_eq!(foo.max_ms, 100.0);
167    }
168
169    #[test]
170    fn sorted_by_count_desc() {
171        let r = compute_hosts(&cap(), &Filter::parse(&[]).unwrap(), 10);
172        assert_eq!(r.hosts[0].host, "api.foo.com"); // 3 > 1
173    }
174
175    #[test]
176    fn top_bounds_list() {
177        let r = compute_hosts(&cap(), &Filter::parse(&[]).unwrap(), 1);
178        assert_eq!(r.hosts.len(), 1);
179    }
180}