Skip to main content

har/analysis/
subsystems.rs

1use crate::config::Config;
2use crate::filter::Filter;
3use crate::fingerprint::fingerprint;
4use crate::model::{Capture, Entry};
5use crate::render::human_ms;
6use ahash::{AHashMap, AHashSet};
7use serde::Serialize;
8
9#[derive(Debug, Serialize)]
10pub struct SubsystemsResult {
11    pub subsystems: Vec<SubsystemStat>,
12}
13
14#[derive(Debug, Serialize)]
15pub struct SubsystemStat {
16    pub name: String,
17    pub owner: Option<String>,
18    pub criticality: Option<String>,
19    pub count: usize,
20    pub hosts: Vec<String>,
21    pub error_count: usize,
22    pub duplicate_count: usize,
23    pub first_offset_ms: f64,
24    pub last_offset_ms: f64,
25}
26
27struct Acc<'a> {
28    owner: Option<String>,
29    criticality: Option<String>,
30    entries: Vec<&'a Entry>,
31    hosts: AHashSet<String>,
32}
33
34/// Aggregate the filtered capture per resolved subsystem. `top` bounds the list.
35pub fn compute_subsystems(
36    cap: &Capture,
37    filter: &Filter,
38    config: &Config,
39    top: usize,
40) -> SubsystemsResult {
41    let mut by_name: AHashMap<String, Acc> = AHashMap::new();
42    for e in cap.entries.iter().filter(|e| filter.matches(e)) {
43        let sub = config.subsystem_for(e);
44        let acc = by_name.entry(sub.name.clone()).or_insert_with(|| Acc {
45            owner: sub.owner.clone(),
46            criticality: sub.criticality.clone(),
47            entries: Vec::new(),
48            hosts: AHashSet::new(),
49        });
50        acc.entries.push(e);
51        acc.hosts.insert(e.host.clone());
52    }
53
54    let mut subsystems: Vec<SubsystemStat> = by_name
55        .into_iter()
56        .map(|(name, acc)| subsystem_stat(name, acc))
57        .collect();
58
59    subsystems.sort_by(|a, b| b.count.cmp(&a.count).then(a.name.cmp(&b.name)));
60    subsystems.truncate(top);
61    SubsystemsResult { subsystems }
62}
63
64fn subsystem_stat(name: String, acc: Acc) -> SubsystemStat {
65    let mut fp_counts: AHashMap<String, usize> = AHashMap::new();
66    let mut error_count = 0usize;
67    let mut first = f64::MAX;
68    let mut last = f64::MIN;
69    for e in &acc.entries {
70        if e.is_error() {
71            error_count += 1;
72        }
73        first = first.min(e.started_offset_ms);
74        last = last.max(e.started_offset_ms);
75        *fp_counts.entry(fingerprint(e)).or_default() += 1;
76    }
77    let duplicate_count: usize = fp_counts.values().filter(|c| **c > 1).sum();
78    let mut hosts: Vec<String> = acc.hosts.into_iter().collect();
79    hosts.sort();
80
81    SubsystemStat {
82        name,
83        owner: acc.owner,
84        criticality: acc.criticality,
85        count: acc.entries.len(),
86        hosts,
87        error_count,
88        duplicate_count,
89        first_offset_ms: if first == f64::MAX { 0.0 } else { first },
90        last_offset_ms: if last == f64::MIN { 0.0 } else { last },
91    }
92}
93
94/// Render the dossier-style subsystem category table as terminal text.
95pub fn render_subsystems_text(r: &SubsystemsResult) -> String {
96    let mut out = String::new();
97    out.push_str("== wiretrail subsystems ==\n");
98    for s in &r.subsystems {
99        let owner = s.owner.as_deref().unwrap_or("-");
100        out.push_str(&format!(
101            "\n{}  [{}]  ({} req, {} err, {} dup)\n",
102            s.name, owner, s.count, s.error_count, s.duplicate_count
103        ));
104        out.push_str(&format!(
105            "  window: {} - {}\n",
106            human_ms(s.first_offset_ms),
107            human_ms(s.last_offset_ms)
108        ));
109        out.push_str(&format!("  hosts: {}\n", s.hosts.join(", ")));
110    }
111    out
112}
113
114#[cfg(test)]
115mod tests {
116    use super::compute_subsystems;
117    use crate::config::Config;
118    use crate::filter::Filter;
119    use crate::model::{sample_capture, sample_entry};
120
121    fn cap() -> crate::model::Capture {
122        sample_capture(vec![
123            sample_entry(0, "api.github.com", "GET", "/repos/x", 200),
124            sample_entry(1, "raw.githubusercontent.com", "GET", "/y", 404),
125            sample_entry(2, "torii.nexioapp.org", "GET", "/manifest.json", 308),
126        ])
127    }
128
129    #[test]
130    fn groups_by_resolved_subsystem() {
131        let r = compute_subsystems(&cap(), &Filter::parse(&[]).unwrap(), &Config::default(), 10);
132        let gh = r.subsystems.iter().find(|s| s.name == "GitHub").unwrap();
133        // both github hosts collapse into one subsystem
134        assert_eq!(gh.count, 2);
135        assert_eq!(gh.error_count, 1);
136        assert_eq!(gh.hosts.len(), 2);
137        // unknown host becomes its own subsystem named after the host
138        assert!(r.subsystems.iter().any(|s| s.name == "torii.nexioapp.org"));
139    }
140
141    #[test]
142    fn sorted_by_count_desc() {
143        let r = compute_subsystems(&cap(), &Filter::parse(&[]).unwrap(), &Config::default(), 10);
144        assert_eq!(r.subsystems[0].name, "GitHub");
145    }
146}