1use crate::filter::Filter;
2use crate::grouping::{group_by_fingerprint, group_has_retry, is_retry_trigger};
3use crate::model::Capture;
4use crate::render::human_ms;
5use serde::Serialize;
6
7#[derive(Debug, Serialize)]
8pub struct RetriesResult {
9 pub groups: Vec<RetryGroup>,
10}
11
12#[derive(Debug, Serialize)]
13pub struct RetryGroup {
14 pub fingerprint: String,
15 pub method: String,
16 pub host: String,
17 pub norm_path: String,
18 pub attempts: usize,
19 pub retry_count: usize,
20 pub trigger_statuses: Vec<i64>,
21 pub gaps_ms: Vec<f64>,
22 pub entry_ids: Vec<String>,
23 pub final_status: i64,
24}
25
26pub fn compute_retries(cap: &Capture, filter: &Filter, top: usize) -> RetriesResult {
29 let entries: Vec<&crate::model::Entry> =
30 cap.entries.iter().filter(|e| filter.matches(e)).collect();
31
32 let mut groups: Vec<RetryGroup> = group_by_fingerprint(&entries)
33 .into_iter()
34 .filter(|(_, g)| group_has_retry(g))
35 .map(|(fp, g)| retry_group(fp, &g))
36 .collect();
37
38 groups.sort_by(|a, b| {
39 b.retry_count
40 .cmp(&a.retry_count)
41 .then(a.fingerprint.cmp(&b.fingerprint))
42 });
43 groups.truncate(top);
44 RetriesResult { groups }
45}
46
47fn retry_group(fingerprint: String, g: &[&crate::model::Entry]) -> RetryGroup {
48 let mut retry_count = 0usize;
49 let mut trigger_statuses: Vec<i64> = Vec::new();
50 let mut seen_failure = false;
51 for e in g {
52 if seen_failure {
53 retry_count += 1;
54 }
55 if is_retry_trigger(e) {
56 seen_failure = true;
57 if !trigger_statuses.contains(&e.status) {
58 trigger_statuses.push(e.status);
59 }
60 }
61 }
62 let gaps_ms: Vec<f64> = g
63 .windows(2)
64 .map(|w| (w[1].started_offset_ms - w[0].started_offset_ms).max(0.0))
65 .collect();
66
67 RetryGroup {
68 fingerprint,
69 method: g[0].method.to_ascii_uppercase(),
70 host: g[0].host.clone(),
71 norm_path: g[0].norm_path.clone(),
72 attempts: g.len(),
73 retry_count,
74 trigger_statuses,
75 gaps_ms,
76 entry_ids: g.iter().map(|e| e.id.clone()).collect(),
77 final_status: g.last().map(|e| e.status).unwrap_or(0),
78 }
79}
80
81pub fn render_retries_text(r: &RetriesResult) -> String {
83 let mut out = String::new();
84 out.push_str("== wiretrail retries ==\n");
85 for g in &r.groups {
86 let triggers: Vec<String> = g.trigger_statuses.iter().map(|s| s.to_string()).collect();
87 out.push_str(&format!(
88 "\n{} {}{} ({} attempts, {} retries, final {})\n",
89 g.method, g.host, g.norm_path, g.attempts, g.retry_count, g.final_status
90 ));
91 out.push_str(&format!(" triggered by: {}\n", triggers.join(", ")));
92 let gaps: Vec<String> = g.gaps_ms.iter().map(|ms| human_ms(*ms)).collect();
93 out.push_str(&format!(" backoff gaps: {}\n", gaps.join(", ")));
94 out.push_str(&format!(" entries: {}\n", g.entry_ids.join(", ")));
95 }
96 out
97}
98
99#[cfg(test)]
100mod tests {
101 use super::compute_retries;
102 use crate::filter::Filter;
103 use crate::model::{sample_capture, sample_entry};
104
105 fn cap() -> crate::model::Capture {
106 let mut entries = vec![
107 sample_entry(0, "h", "POST", "/bulk", 500),
108 sample_entry(1, "h", "POST", "/bulk", 500),
109 sample_entry(2, "h", "POST", "/bulk", 200),
110 sample_entry(3, "h", "GET", "/clean", 200),
111 sample_entry(4, "h", "GET", "/clean", 200), ];
113 entries[1].started_offset_ms = 100.0;
114 entries[2].started_offset_ms = 300.0;
115 sample_capture(entries)
116 }
117
118 #[test]
119 fn reports_only_retry_groups() {
120 let r = compute_retries(&cap(), &Filter::parse(&[]).unwrap(), 10);
121 assert_eq!(r.groups.len(), 1);
122 let g = &r.groups[0];
123 assert_eq!(g.attempts, 3);
124 assert_eq!(g.retry_count, 2);
125 assert_eq!(g.final_status, 200);
126 assert!(g.trigger_statuses.contains(&500));
127 assert_eq!(g.gaps_ms, vec![100.0, 200.0]);
129 }
130}