Skip to main content

har/analysis/
timeline.rs

1use crate::filter::Filter;
2use crate::fingerprint::fingerprint;
3use crate::grouping::retry_entry_ids;
4use crate::model::Capture;
5use crate::render::{human_bytes, human_ms};
6use ahash::AHashMap;
7use serde::Serialize;
8
9#[derive(Debug, Serialize)]
10pub struct TimelineResult {
11    pub rows: Vec<TimelineRow>,
12}
13
14#[derive(Debug, Serialize)]
15pub struct TimelineRow {
16    pub id: String,
17    pub offset_ms: f64,
18    pub duration_ms: f64,
19    pub method: String,
20    pub host: String,
21    pub norm_path: String,
22    pub status: i64,
23    pub bytes: i64,
24    pub correlation_id: Option<String>,
25    pub marker: Option<String>,
26}
27
28/// Chronological per-request timeline. `top` bounds the number of rows (earliest first).
29pub fn compute_timeline(cap: &Capture, filter: &Filter, top: usize) -> TimelineResult {
30    let entries: Vec<&crate::model::Entry> =
31        cap.entries.iter().filter(|e| filter.matches(e)).collect();
32
33    let retries = retry_entry_ids(&entries);
34    let mut fp_counts: AHashMap<String, usize> = AHashMap::new();
35    for e in &entries {
36        *fp_counts.entry(fingerprint(e)).or_default() += 1;
37    }
38
39    let mut rows: Vec<TimelineRow> = entries
40        .iter()
41        .map(|e| {
42            let is_dup = fp_counts.get(&fingerprint(e)).copied().unwrap_or(0) > 1;
43            let marker = if retries.contains(&e.id) {
44                Some("RETRY".to_string())
45            } else if is_dup {
46                Some("DUP".to_string())
47            } else {
48                None
49            };
50            TimelineRow {
51                id: e.id.clone(),
52                offset_ms: e.started_offset_ms,
53                duration_ms: e.duration_ms,
54                method: e.method.to_ascii_uppercase(),
55                host: e.host.clone(),
56                norm_path: e.norm_path.clone(),
57                status: e.status,
58                bytes: e.sizes.resp_content.max(e.sizes.resp_body).max(0),
59                correlation_id: e.correlation.first().map(|(_, v)| v.clone()),
60                marker,
61            }
62        })
63        .collect();
64
65    rows.sort_by(|a, b| {
66        a.offset_ms
67            .partial_cmp(&b.offset_ms)
68            .unwrap_or(std::cmp::Ordering::Equal)
69            .then(a.id.cmp(&b.id))
70    });
71    rows.truncate(top);
72    TimelineResult { rows }
73}
74
75/// Render the timeline as deterministic terminal text.
76pub fn render_timeline_text(r: &TimelineResult) -> String {
77    let mut out = String::new();
78    out.push_str("== wiretrail timeline ==\n");
79    for row in &r.rows {
80        let marker = row
81            .marker
82            .as_deref()
83            .map(|m| format!(" {m}"))
84            .unwrap_or_default();
85        out.push_str(&format!(
86            "{:>8}  {:>7}  {} {} {}{}  [{}] {}{}\n",
87            human_ms(row.offset_ms),
88            human_ms(row.duration_ms),
89            row.id,
90            row.method,
91            row.host,
92            row.norm_path,
93            row.status,
94            human_bytes(row.bytes),
95            marker,
96        ));
97    }
98    out
99}
100
101#[cfg(test)]
102mod tests {
103    use super::compute_timeline;
104    use crate::filter::Filter;
105    use crate::model::{sample_capture, sample_entry};
106
107    fn cap() -> crate::model::Capture {
108        let mut entries = vec![
109            sample_entry(0, "h", "POST", "/x", 500), // retry trigger
110            sample_entry(1, "h", "POST", "/x", 200), // retry
111            sample_entry(2, "h", "GET", "/y", 200),  // unique
112        ];
113        entries[1].started_offset_ms = 50.0;
114        entries[2].started_offset_ms = 20.0;
115        sample_capture(entries)
116    }
117
118    #[test]
119    fn ordered_by_offset_with_markers() {
120        let r = compute_timeline(&cap(), &Filter::parse(&[]).unwrap(), 100);
121        // offsets: e0=0, e2=20, e1=50 -> chronological order
122        assert_eq!(r.rows[0].id, "e000000");
123        assert_eq!(r.rows[1].id, "e000002");
124        assert_eq!(r.rows[2].id, "e000001");
125        // e0 and e1 are duplicates; e1 follows a 500 -> RETRY
126        assert_eq!(r.rows[2].marker.as_deref(), Some("RETRY"));
127        assert_eq!(r.rows[0].marker.as_deref(), Some("DUP"));
128        assert!(r.rows[1].marker.is_none());
129    }
130}