Skip to main content

har/analysis/
transitions.rs

1use crate::filter::Filter;
2use crate::model::{Capture, Entry};
3use crate::render::human_ms;
4use ahash::AHashMap;
5use serde::Serialize;
6
7#[derive(Debug, Serialize)]
8pub struct TransitionsResult {
9    pub transitions: Vec<Transition>,
10}
11
12#[derive(Debug, Serialize)]
13pub struct Transition {
14    pub host: String,
15    pub method: String,
16    pub norm_path: String,
17    pub from_status: i64,
18    pub to_status: i64,
19    pub from_id: String,
20    pub to_id: String,
21    pub gap_ms: f64,
22    pub label: String,
23}
24
25/// Detect endpoint-local status transitions where a failed attempt is followed
26/// by another attempt of the same (method, host, norm_path). `top` bounds the list.
27pub fn compute_transitions(cap: &Capture, filter: &Filter, top: usize) -> TransitionsResult {
28    // Group by endpoint, preserving time order.
29    let mut by_key: AHashMap<(String, String, String), Vec<&Entry>> = AHashMap::new();
30    for e in cap.entries.iter().filter(|e| filter.matches(e)) {
31        let key = (
32            e.method.to_ascii_uppercase(),
33            e.host.clone(),
34            e.norm_path.clone(),
35        );
36        by_key.entry(key).or_default().push(e);
37    }
38
39    let mut transitions: Vec<Transition> = Vec::new();
40    for (_, mut group) in by_key {
41        group.sort_by(|a, b| {
42            a.started_offset_ms
43                .partial_cmp(&b.started_offset_ms)
44                .unwrap_or(std::cmp::Ordering::Equal)
45                .then(a.index.cmp(&b.index))
46        });
47        for w in group.windows(2) {
48            let (prev, curr) = (w[0], w[1]);
49            if let Some(label) = label_for(prev.status, curr.status) {
50                transitions.push(Transition {
51                    host: prev.host.clone(),
52                    method: prev.method.to_ascii_uppercase(),
53                    norm_path: prev.norm_path.clone(),
54                    from_status: prev.status,
55                    to_status: curr.status,
56                    from_id: prev.id.clone(),
57                    to_id: curr.id.clone(),
58                    gap_ms: (curr.started_offset_ms - prev.started_offset_ms).max(0.0),
59                    label: label.to_string(),
60                });
61            }
62        }
63    }
64
65    transitions.sort_by(|a, b| a.from_id.cmp(&b.from_id).then(a.to_id.cmp(&b.to_id)));
66    transitions.truncate(top);
67    TransitionsResult { transitions }
68}
69
70/// Classify a transition between two consecutive same-endpoint attempts. Returns
71/// None when the prior attempt did not fail (no transition worth reporting).
72fn label_for(prev: i64, curr: i64) -> Option<&'static str> {
73    match (prev, curr) {
74        (401 | 403, c) if class_of(c) == 2 => Some("auth-recovered"),
75        (429, 429) => Some("rate-limit-persisted"),
76        (429, c) if class_of(c) == 2 => Some("rate-limit-recovered"),
77        (p, c) if class_of(p) == 5 && class_of(c) == 2 => Some("recovered-5xx"),
78        (p, c) if is_failure(p) && c != p && is_failure(c) => Some("error-changed"),
79        _ => None,
80    }
81}
82
83fn class_of(status: i64) -> i64 {
84    if (100..600).contains(&status) {
85        status / 100
86    } else {
87        0
88    }
89}
90
91fn is_failure(status: i64) -> bool {
92    status == 0 || class_of(status) == 4 || class_of(status) == 5
93}
94
95/// Render transitions as deterministic terminal text.
96pub fn render_transitions_text(r: &TransitionsResult) -> String {
97    let mut out = String::new();
98    out.push_str("== wiretrail transitions ==\n");
99    for t in &r.transitions {
100        out.push_str(&format!(
101            "\n{} -> {}  [{}]  {} {}{}\n",
102            t.from_status, t.to_status, t.label, t.method, t.host, t.norm_path
103        ));
104        out.push_str(&format!(
105            "  {} -> {}  (gap {})\n",
106            t.from_id,
107            t.to_id,
108            human_ms(t.gap_ms)
109        ));
110    }
111    out
112}
113
114#[cfg(test)]
115mod tests {
116    use super::compute_transitions;
117    use crate::filter::Filter;
118    use crate::model::{sample_capture, sample_entry};
119
120    #[test]
121    fn detects_auth_recovery() {
122        // same endpoint: 401 then 200 -> auth-recovered
123        let cap = sample_capture(vec![
124            sample_entry(0, "h", "GET", "/me", 401),
125            sample_entry(1, "h", "GET", "/me", 200),
126        ]);
127        let r = compute_transitions(&cap, &Filter::parse(&[]).unwrap(), 10);
128        assert_eq!(r.transitions.len(), 1);
129        let t = &r.transitions[0];
130        assert_eq!(t.from_status, 401);
131        assert_eq!(t.to_status, 200);
132        assert_eq!(t.label, "auth-recovered");
133        assert_eq!(t.from_id, "e000000");
134        assert_eq!(t.to_id, "e000001");
135    }
136
137    #[test]
138    fn detects_rate_limit_persisted_and_recovered_5xx() {
139        let cap = sample_capture(vec![
140            sample_entry(0, "h", "GET", "/a", 429),
141            sample_entry(1, "h", "GET", "/a", 429),
142            sample_entry(2, "h", "POST", "/b", 500),
143            sample_entry(3, "h", "POST", "/b", 200),
144        ]);
145        let r = compute_transitions(&cap, &Filter::parse(&[]).unwrap(), 10);
146        assert!(
147            r.transitions
148                .iter()
149                .any(|t| t.label == "rate-limit-persisted")
150        );
151        assert!(r.transitions.iter().any(|t| t.label == "recovered-5xx"));
152    }
153
154    #[test]
155    fn no_transition_when_no_prior_error() {
156        let cap = sample_capture(vec![
157            sample_entry(0, "h", "GET", "/a", 200),
158            sample_entry(1, "h", "GET", "/a", 200),
159        ]);
160        let r = compute_transitions(&cap, &Filter::parse(&[]).unwrap(), 10);
161        assert!(r.transitions.is_empty());
162    }
163}