1use crate::filter::Filter;
2use crate::model::{Capture, Entry};
3use serde::Serialize;
4
5#[derive(Debug, Serialize)]
6pub struct CascadeResult {
7 pub first_failure: Option<FailureContext>,
8 pub cascades: Vec<Cascade>,
9}
10
11#[derive(Debug, Serialize)]
12pub struct FailureContext {
13 pub id: String,
14 pub status: i64,
15 pub host: String,
16 pub norm_path: String,
17 pub before_ids: Vec<String>,
18 pub after_ids: Vec<String>,
19}
20
21#[derive(Debug, Serialize)]
22pub struct Cascade {
23 pub trigger_id: String,
24 pub trigger_kind: String,
25 pub downstream_failures: usize,
26 pub downstream_ids: Vec<String>,
27}
28
29fn trigger_kind(np: &str) -> &'static str {
30 let p = np.to_ascii_lowercase();
31 if p.contains("/config") {
32 "config"
33 } else if p.contains("/auth") || p.contains("/token") || p.contains("/oauth") {
34 "auth"
35 } else if p.contains("bootstrap") || p.contains("/init") {
36 "bootstrap"
37 } else {
38 "request"
39 }
40}
41
42pub fn compute_cascade(
44 cap: &Capture,
45 filter: &Filter,
46 window_ms: u64,
47 min_downstream: usize,
48 top: usize,
49) -> CascadeResult {
50 let mut entries: Vec<&Entry> = cap.entries.iter().filter(|e| filter.matches(e)).collect();
51 entries.sort_by(|a, b| {
52 a.started_offset_ms
53 .partial_cmp(&b.started_offset_ms)
54 .unwrap_or(std::cmp::Ordering::Equal)
55 .then(a.index.cmp(&b.index))
56 });
57
58 let first_failure = entries.iter().position(|e| e.is_error()).map(|pos| {
60 let e = entries[pos];
61 let before_ids = entries[pos.saturating_sub(3)..pos]
62 .iter()
63 .map(|x| x.id.clone())
64 .collect();
65 let after_ids = entries[pos + 1..(pos + 4).min(entries.len())]
66 .iter()
67 .map(|x| x.id.clone())
68 .collect();
69 FailureContext {
70 id: e.id.clone(),
71 status: e.status,
72 host: e.host.clone(),
73 norm_path: e.norm_path.clone(),
74 before_ids,
75 after_ids,
76 }
77 });
78
79 let w = window_ms as f64;
81 let mut cascades: Vec<Cascade> = Vec::new();
82 for (i, trigger) in entries.iter().enumerate() {
83 if !trigger.is_error() {
84 continue;
85 }
86 let t = trigger.started_offset_ms;
87 let downstream: Vec<String> = entries[i + 1..]
88 .iter()
89 .filter(|e| e.is_error() && e.started_offset_ms > t && e.started_offset_ms <= t + w)
90 .map(|e| e.id.clone())
91 .collect();
92 if downstream.len() >= min_downstream {
93 cascades.push(Cascade {
94 trigger_id: trigger.id.clone(),
95 trigger_kind: trigger_kind(&trigger.norm_path).to_string(),
96 downstream_failures: downstream.len(),
97 downstream_ids: downstream.into_iter().take(top).collect(),
98 });
99 }
100 }
101 cascades.sort_by(|a, b| {
102 b.downstream_failures
103 .cmp(&a.downstream_failures)
104 .then(a.trigger_id.cmp(&b.trigger_id))
105 });
106 cascades.truncate(top);
107
108 CascadeResult {
109 first_failure,
110 cascades,
111 }
112}
113
114pub fn render_cascade_text(r: &CascadeResult) -> String {
116 let mut out = String::new();
117 out.push_str("== wiretrail cascade ==\n");
118 if let Some(f) = &r.first_failure {
119 out.push_str(&format!(
120 "\nfirst failure: {} [{}] {}{}\n",
121 f.id, f.status, f.host, f.norm_path
122 ));
123 out.push_str(&format!(" before: {}\n", f.before_ids.join(", ")));
124 out.push_str(&format!(" after: {}\n", f.after_ids.join(", ")));
125 } else {
126 out.push_str("\nno failures in capture\n");
127 }
128 if !r.cascades.is_empty() {
129 out.push_str("\ncascades:\n");
130 for c in &r.cascades {
131 out.push_str(&format!(
132 " {} [{}] -> {} downstream failures\n",
133 c.trigger_id, c.trigger_kind, c.downstream_failures
134 ));
135 out.push_str(&format!(" {}\n", c.downstream_ids.join(", ")));
136 }
137 }
138 out
139}
140
141#[cfg(test)]
142mod tests {
143 use super::compute_cascade;
144 use crate::filter::Filter;
145 use crate::model::{Entry, sample_capture, sample_entry};
146
147 fn at(index: usize, path: &str, status: i64, offset: f64) -> Entry {
148 let mut e = sample_entry(index, "api.x", "GET", path, status);
149 e.started_offset_ms = offset;
150 e
151 }
152
153 #[test]
154 fn finds_first_failure_with_neighbors() {
155 let cap = sample_capture(vec![
156 at(0, "/ok1", 200, 0.0),
157 at(1, "/boom", 500, 10.0),
158 at(2, "/ok2", 200, 20.0),
159 ]);
160 let r = compute_cascade(&cap, &Filter::parse(&[]).unwrap(), 5000, 3, 10);
161 let f = r.first_failure.unwrap();
162 assert_eq!(f.id, "e000001");
163 assert!(f.before_ids.contains(&"e000000".to_string()));
164 assert!(f.after_ids.contains(&"e000002".to_string()));
165 }
166
167 #[test]
168 fn detects_cascade_from_config_failure() {
169 let mut es = vec![at(0, "/config", 500, 0.0)];
170 for i in 1..=4 {
171 es.push(at(i, "/data", 500, i as f64 * 100.0));
172 }
173 let r = compute_cascade(
174 &sample_capture(es),
175 &Filter::parse(&[]).unwrap(),
176 5000,
177 3,
178 10,
179 );
180 let c = r
181 .cascades
182 .iter()
183 .find(|c| c.trigger_id == "e000000")
184 .unwrap();
185 assert_eq!(c.trigger_kind, "config");
186 assert!(c.downstream_failures >= 3);
187 }
188}