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
58pub 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
186pub 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 let sum: usize = s.status_classes.values().sum();
293 assert_eq!(sum, s.filtered_entries);
294 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}