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
25pub fn compute_transitions(cap: &Capture, filter: &Filter, top: usize) -> TransitionsResult {
28 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
70fn 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
95pub 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 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}