Skip to main content

har/analysis/
cascade.rs

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
42/// Find the earliest failure and downstream failure cascades.
43pub 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    // first failure + up to 3 neighbors each side (in time order).
59    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    // cascades: a failure followed by >= min_downstream failures within window_ms.
80    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
114/// Render cascades as deterministic terminal text.
115pub 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}