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
28pub 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
75pub 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), sample_entry(1, "h", "POST", "/x", 200), sample_entry(2, "h", "GET", "/y", 200), ];
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 assert_eq!(r.rows[0].id, "e000000");
123 assert_eq!(r.rows[1].id, "e000002");
124 assert_eq!(r.rows[2].id, "e000001");
125 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}