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
32pub 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
107pub 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), 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 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"); }
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}