Skip to main content

har/analysis/
summary.rs

1use crate::filter::Filter;
2use crate::fingerprint::fingerprint;
3use crate::model::Capture;
4use crate::recommender::{Recommendation, recommend};
5use ahash::AHashMap;
6use serde::Serialize;
7use std::collections::BTreeMap;
8
9#[derive(Debug, Serialize)]
10pub struct SummaryResult {
11    pub total_entries: usize,
12    pub filtered_entries: usize,
13    pub duration_ms: f64,
14    pub start_ms: Option<i64>,
15    pub end_ms: Option<i64>,
16    pub resource_breakdown: BTreeMap<String, usize>,
17    pub status_classes: BTreeMap<String, usize>,
18    pub error_count: usize,
19    pub top_hosts: Vec<HostCount>,
20    pub top_duplicates: Vec<DuplicateGroup>,
21    pub slowest: Vec<SlowEntry>,
22    pub biggest_payloads: Vec<PayloadEntry>,
23    pub hints: Vec<String>,
24    pub recommendations: Vec<Recommendation>,
25}
26
27#[derive(Debug, Serialize)]
28pub struct HostCount {
29    pub host: String,
30    pub count: usize,
31}
32
33#[derive(Debug, Serialize)]
34pub struct DuplicateGroup {
35    pub fingerprint: String,
36    pub count: usize,
37    pub example_id: String,
38}
39
40#[derive(Debug, Serialize)]
41pub struct SlowEntry {
42    pub id: String,
43    pub method: String,
44    pub host: String,
45    pub norm_path: String,
46    pub status: i64,
47    pub duration_ms: f64,
48}
49
50#[derive(Debug, Serialize)]
51pub struct PayloadEntry {
52    pub id: String,
53    pub host: String,
54    pub norm_path: String,
55    pub bytes: i64,
56}
57
58/// Compute the executive summary over the (filtered) capture. `top` bounds list sizes.
59pub fn compute_summary(cap: &Capture, filter: &Filter, top: usize) -> SummaryResult {
60    let entries: Vec<&crate::model::Entry> =
61        cap.entries.iter().filter(|e| filter.matches(e)).collect();
62
63    let mut resource_breakdown: BTreeMap<String, usize> = BTreeMap::new();
64    let mut status_classes: BTreeMap<String, usize> = BTreeMap::new();
65    let mut host_counts: AHashMap<String, usize> = AHashMap::new();
66    let mut fp_counts: AHashMap<String, (usize, String)> = AHashMap::new();
67    let mut error_count = 0usize;
68
69    for e in &entries {
70        let rt = format!("{:?}", e.resource_type).to_ascii_lowercase();
71        *resource_breakdown.entry(rt).or_default() += 1;
72
73        let class = match e.status_class() {
74            2 => "2xx",
75            3 => "3xx",
76            4 => "4xx",
77            5 => "5xx",
78            _ => "other",
79        };
80        *status_classes.entry(class.to_string()).or_default() += 1;
81
82        if e.is_error() {
83            error_count += 1;
84        }
85
86        *host_counts.entry(e.host.clone()).or_default() += 1;
87
88        let fp = fingerprint(e);
89        let slot = fp_counts.entry(fp).or_insert((0, e.id.clone()));
90        slot.0 += 1;
91    }
92
93    let top_hosts = top_n_map(&host_counts, top)
94        .into_iter()
95        .map(|(host, count)| HostCount { host, count })
96        .collect();
97
98    let mut dups: Vec<DuplicateGroup> = fp_counts
99        .into_iter()
100        .filter(|(_, (c, _))| *c > 1)
101        .map(|(fp, (c, id))| DuplicateGroup {
102            fingerprint: fp,
103            count: c,
104            example_id: id,
105        })
106        .collect();
107    dups.sort_by(|a, b| {
108        b.count
109            .cmp(&a.count)
110            .then(a.fingerprint.cmp(&b.fingerprint))
111    });
112    dups.truncate(top);
113
114    let mut slow: Vec<SlowEntry> = entries
115        .iter()
116        .map(|e| SlowEntry {
117            id: e.id.clone(),
118            method: e.method.clone(),
119            host: e.host.clone(),
120            norm_path: e.norm_path.clone(),
121            status: e.status,
122            duration_ms: e.duration_ms,
123        })
124        .collect();
125    slow.sort_by(|a, b| {
126        b.duration_ms
127            .partial_cmp(&a.duration_ms)
128            .unwrap_or(std::cmp::Ordering::Equal)
129    });
130    slow.truncate(top);
131
132    let mut payloads: Vec<PayloadEntry> = entries
133        .iter()
134        .map(|e| PayloadEntry {
135            id: e.id.clone(),
136            host: e.host.clone(),
137            norm_path: e.norm_path.clone(),
138            bytes: e.sizes.resp_content.max(e.sizes.resp_body),
139        })
140        .collect();
141    payloads.sort_by_key(|p| std::cmp::Reverse(p.bytes));
142    payloads.truncate(top);
143
144    let mut hints = Vec::new();
145    if let Some(top_dup) = dups.first()
146        && top_dup.count >= 3
147    {
148        hints.push(format!(
149            "{}x duplicate calls: {}",
150            top_dup.count, top_dup.fingerprint
151        ));
152    }
153    if error_count > 0 {
154        hints.push(format!("{error_count} error responses (4xx/5xx/failed)"));
155    }
156
157    let recommendations = recommend(cap, filter, top);
158
159    SummaryResult {
160        total_entries: cap.entries.len(),
161        filtered_entries: entries.len(),
162        duration_ms: cap.meta.duration_ms,
163        start_ms: cap.meta.start_ms,
164        end_ms: cap.meta.end_ms,
165        resource_breakdown,
166        status_classes,
167        error_count,
168        top_hosts,
169        top_duplicates: dups,
170        slowest: slow,
171        biggest_payloads: payloads,
172        hints,
173        recommendations,
174    }
175}
176
177fn top_n_map(map: &AHashMap<String, usize>, top: usize) -> Vec<(String, usize)> {
178    let mut v: Vec<(String, usize)> = map.iter().map(|(k, c)| (k.clone(), *c)).collect();
179    v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
180    v.truncate(top);
181    v
182}
183
184use crate::render::{human_bytes, human_ms};
185
186/// Render the summary as deterministic, copy-paste-safe terminal text.
187pub fn render_summary_text(s: &SummaryResult) -> String {
188    let mut out = String::new();
189    out.push_str("== wiretrail summary ==\n");
190    out.push_str(&format!(
191        "entries: {} total, {} after filter\n",
192        s.total_entries, s.filtered_entries
193    ));
194    out.push_str(&format!(
195        "duration (first start to last response): {}\n",
196        human_ms(s.duration_ms)
197    ));
198
199    out.push_str("\nstatus classes:\n");
200    for (k, v) in &s.status_classes {
201        out.push_str(&format!("  {k}: {v}\n"));
202    }
203
204    out.push_str("\nresource types:\n");
205    for (k, v) in &s.resource_breakdown {
206        out.push_str(&format!("  {k}: {v}\n"));
207    }
208
209    out.push_str("\ntop hosts (by request count):\n");
210    for h in &s.top_hosts {
211        out.push_str(&format!("  {:>5}  {}\n", h.count, h.host));
212    }
213
214    if !s.top_duplicates.is_empty() {
215        out.push_str("\ntop duplicate calls:\n");
216        for d in &s.top_duplicates {
217            out.push_str(&format!(
218                "  {:>4}x  {}  ({})\n",
219                d.count, d.fingerprint, d.example_id
220            ));
221        }
222    }
223
224    out.push_str("\nslowest requests:\n");
225    for e in &s.slowest {
226        out.push_str(&format!(
227            "  {:>8}  {} {} {}{}  [{}]\n",
228            human_ms(e.duration_ms),
229            e.id,
230            e.method,
231            e.host,
232            e.norm_path,
233            e.status
234        ));
235    }
236
237    out.push_str("\nbiggest payloads:\n");
238    for p in &s.biggest_payloads {
239        out.push_str(&format!(
240            "  {:>10}  {} {}{}\n",
241            human_bytes(p.bytes),
242            p.id,
243            p.host,
244            p.norm_path
245        ));
246    }
247
248    if !s.hints.is_empty() {
249        out.push_str("\nhints:\n");
250        for h in &s.hints {
251            out.push_str(&format!("  - {h}\n"));
252        }
253    }
254
255    if !s.recommendations.is_empty() {
256        out.push_str("\nrecommended next steps:\n");
257        for r in &s.recommendations {
258            out.push_str(&format!(
259                "  [{}] {}\n         {} — {}\n",
260                r.severity.to_ascii_uppercase(),
261                r.command_line(),
262                r.title,
263                r.detail
264            ));
265        }
266    }
267
268    out
269}
270
271#[cfg(test)]
272mod tests {
273    use super::compute_summary;
274    use crate::assemble::assemble;
275    use crate::filter::Filter;
276    use crate::loader::load;
277
278    fn fixture(name: &str) -> std::path::PathBuf {
279        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
280            .join("tests/fixtures")
281            .join(name)
282    }
283
284    #[test]
285    fn computes_summary_over_fixture() {
286        let cap = assemble(load(&fixture("someapi123.har")).unwrap());
287        let f = Filter::parse(&[]).unwrap();
288        let s = compute_summary(&cap, &f, 5);
289        assert_eq!(s.total_entries, cap.entries.len());
290        assert_eq!(s.filtered_entries, cap.entries.len());
291        // status_classes counts sum to filtered_entries
292        let sum: usize = s.status_classes.values().sum();
293        assert_eq!(sum, s.filtered_entries);
294        // top_hosts has at most 5 entries
295        assert!(s.top_hosts.len() <= 5);
296    }
297
298    #[test]
299    fn filter_reduces_filtered_count() {
300        let cap = assemble(load(&fixture("someapi123.har")).unwrap());
301        let f = Filter::parse(&["status:>=400".into()]).unwrap();
302        let s = compute_summary(&cap, &f, 5);
303        assert!(s.filtered_entries <= s.total_entries);
304    }
305
306    #[test]
307    fn populates_recommendations_when_errors_present() {
308        use crate::model::{sample_capture, sample_entry};
309        let cap = sample_capture(vec![
310            sample_entry(0, "api.x", "POST", "/bulk", 500),
311            sample_entry(1, "api.x", "POST", "/bulk", 500),
312            sample_entry(2, "api.x", "POST", "/bulk", 500),
313        ]);
314        let s = compute_summary(&cap, &Filter::parse(&[]).unwrap(), 10);
315        assert!(s.recommendations.iter().any(|r| r.kind == "5xx-cluster"));
316        let text = super::render_summary_text(&s);
317        assert!(text.contains("recommended next steps"));
318    }
319}