Skip to main content

har/analysis/
handoff.rs

1use crate::analysis::curl::entry_to_curl;
2use crate::filter::Filter;
3use crate::model::{Capture, Entry};
4use ahash::AHashSet;
5use serde::Serialize;
6
7#[derive(Debug, Serialize)]
8pub struct HandoffResult {
9    pub items: Vec<HandoffItem>,
10}
11
12#[derive(Debug, Serialize)]
13pub struct HandoffItem {
14    pub id: String,
15    pub method: String,
16    pub host: String,
17    pub norm_path: String,
18    pub status: i64,
19    pub timestamp: Option<String>,
20    pub offset_ms: f64,
21    pub correlation_ids: Vec<String>,
22    pub server_ip: Option<String>,
23    pub curl: String,
24}
25
26fn abs_iso(start_ms: Option<i64>, offset_ms: f64) -> Option<String> {
27    let ms = start_ms? + offset_ms as i64;
28    chrono::DateTime::from_timestamp_millis(ms).map(|dt| dt.to_rfc3339())
29}
30
31/// Backend trace-handoff blocks for every failed request and the top-N slowest.
32pub fn compute_handoff(
33    cap: &Capture,
34    filter: &Filter,
35    top: usize,
36    unsafe_include: bool,
37) -> HandoffResult {
38    let entries: Vec<&Entry> = cap.entries.iter().filter(|e| filter.matches(e)).collect();
39
40    // top-N slowest
41    let mut by_dur: Vec<&Entry> = entries.clone();
42    by_dur.sort_by(|a, b| {
43        b.duration_ms
44            .partial_cmp(&a.duration_ms)
45            .unwrap_or(std::cmp::Ordering::Equal)
46    });
47    let slow_ids: AHashSet<String> = by_dur.iter().take(top).map(|e| e.id.clone()).collect();
48
49    let mut selected: Vec<&Entry> = entries
50        .iter()
51        .filter(|e| e.is_error() || slow_ids.contains(&e.id))
52        .copied()
53        .collect();
54    selected.sort_by(|a, b| {
55        a.started_offset_ms
56            .partial_cmp(&b.started_offset_ms)
57            .unwrap_or(std::cmp::Ordering::Equal)
58            .then(a.index.cmp(&b.index))
59    });
60
61    let items = selected
62        .iter()
63        .map(|e| HandoffItem {
64            id: e.id.clone(),
65            method: e.method.to_ascii_uppercase(),
66            host: e.host.clone(),
67            norm_path: e.norm_path.clone(),
68            status: e.status,
69            timestamp: abs_iso(cap.meta.start_ms, e.started_offset_ms),
70            offset_ms: e.started_offset_ms,
71            correlation_ids: e.correlation.iter().map(|(_, v)| v.clone()).collect(),
72            server_ip: e.server_ip.clone(),
73            curl: entry_to_curl(e, unsafe_include).command,
74        })
75        .collect();
76
77    HandoffResult { items }
78}
79
80/// Render handoff blocks as deterministic terminal text.
81pub fn render_handoff_text(r: &HandoffResult) -> String {
82    let mut out = String::new();
83    out.push_str("== wiretrail handoff ==\n");
84    for i in &r.items {
85        out.push_str(&format!(
86            "\n# {} [{}] {} {}{}\n",
87            i.id, i.status, i.method, i.host, i.norm_path
88        ));
89        if let Some(ts) = &i.timestamp {
90            out.push_str(&format!("  time: {ts} (+{}ms)\n", i.offset_ms as i64));
91        }
92        if !i.correlation_ids.is_empty() {
93            out.push_str(&format!(
94                "  correlation: {}\n",
95                i.correlation_ids.join(", ")
96            ));
97        }
98        if let Some(ip) = &i.server_ip {
99            out.push_str(&format!("  server ip: {ip}\n"));
100        }
101        out.push_str(&format!("  {}\n", i.curl.replace('\n', "\n  ")));
102    }
103    out
104}
105
106#[cfg(test)]
107mod tests {
108    use super::compute_handoff;
109    use crate::filter::Filter;
110    use crate::model::{sample_capture, sample_entry};
111
112    #[test]
113    fn emits_block_for_failed_request() {
114        let mut e = sample_entry(0, "api.x", "POST", "/bulk", 500);
115        e.correlation = vec![("x-request-id".to_string(), "abc-123".to_string())];
116        e.server_ip = Some("10.0.0.1".to_string());
117        let cap = sample_capture(vec![e, sample_entry(1, "api.x", "GET", "/ok", 200)]);
118        let r = compute_handoff(&cap, &Filter::parse(&[]).unwrap(), 10, false);
119        let item = r.items.iter().find(|i| i.id == "e000000").unwrap();
120        assert_eq!(item.status, 500);
121        assert!(item.curl.contains("curl -X POST"));
122        assert_eq!(item.correlation_ids, vec!["abc-123".to_string()]);
123        assert_eq!(item.server_ip.as_deref(), Some("10.0.0.1"));
124    }
125
126    #[test]
127    fn redacts_curl_by_default() {
128        let mut e = sample_entry(0, "api.x", "GET", "/x", 500);
129        e.req_headers = vec![("Authorization".to_string(), "Bearer secret".to_string())];
130        let r = compute_handoff(
131            &sample_capture(vec![e]),
132            &Filter::parse(&[]).unwrap(),
133            10,
134            false,
135        );
136        assert!(!r.items[0].curl.contains("Bearer secret"));
137    }
138}