Skip to main content

har/analysis/
errors.rs

1use crate::errorbody::parse_error_fields;
2use crate::filter::Filter;
3use crate::model::{Capture, Entry};
4use crate::redact::redact_body;
5use ahash::AHashMap;
6use serde::Serialize;
7
8const SNIPPET_MAX: usize = 200;
9
10#[derive(Debug, Serialize)]
11pub struct ErrorsResult {
12    pub groups: Vec<ErrorGroup>,
13}
14
15#[derive(Debug, Serialize)]
16pub struct ErrorGroup {
17    pub host: String,
18    pub method: String,
19    pub norm_path: String,
20    pub status: i64,
21    pub count: usize,
22    pub error_message: Option<String>,
23    pub error_code: Option<String>,
24    pub body_snippet: Option<String>,
25    pub correlation_ids: Vec<String>,
26    pub entry_ids: Vec<String>,
27    pub first_offset_ms: f64,
28    pub last_offset_ms: f64,
29}
30
31/// Group 4xx/5xx/failed responses by (host, method, norm_path, status).
32/// `unsafe_include` disables body redaction. `top` bounds the list.
33pub fn compute_errors(
34    cap: &Capture,
35    filter: &Filter,
36    top: usize,
37    unsafe_include: bool,
38) -> ErrorsResult {
39    let mut by_key: AHashMap<(String, String, String, i64), Vec<&Entry>> = AHashMap::new();
40    for e in cap
41        .entries
42        .iter()
43        .filter(|e| filter.matches(e) && e.is_error())
44    {
45        let key = (
46            e.host.clone(),
47            e.method.to_ascii_uppercase(),
48            e.norm_path.clone(),
49            e.status,
50        );
51        by_key.entry(key).or_default().push(e);
52    }
53
54    let mut groups: Vec<ErrorGroup> = by_key
55        .into_iter()
56        .map(|((host, method, norm_path, status), mut g)| {
57            g.sort_by(|a, b| {
58                a.started_offset_ms
59                    .partial_cmp(&b.started_offset_ms)
60                    .unwrap_or(std::cmp::Ordering::Equal)
61                    .then(a.index.cmp(&b.index))
62            });
63            error_group(host, method, norm_path, status, &g, unsafe_include)
64        })
65        .collect();
66
67    groups.sort_by(|a, b| {
68        b.count
69            .cmp(&a.count)
70            .then(b.status.cmp(&a.status))
71            .then(a.host.cmp(&b.host))
72            .then(a.norm_path.cmp(&b.norm_path))
73    });
74    groups.truncate(top);
75    ErrorsResult { groups }
76}
77
78fn error_group(
79    host: String,
80    method: String,
81    norm_path: String,
82    status: i64,
83    g: &[&Entry],
84    unsafe_include: bool,
85) -> ErrorGroup {
86    let sample = g[0];
87    let fields = sample
88        .resp_body
89        .as_deref()
90        .map(parse_error_fields)
91        .unwrap_or_default();
92    let body_snippet = sample
93        .resp_body
94        .as_deref()
95        .filter(|b| !b.is_empty())
96        .map(|b| redact_body(b, unsafe_include, SNIPPET_MAX));
97    let correlation_ids: Vec<String> = sample.correlation.iter().map(|(_, v)| v.clone()).collect();
98
99    ErrorGroup {
100        host,
101        method,
102        norm_path,
103        status,
104        count: g.len(),
105        error_message: fields.message,
106        error_code: fields.code,
107        body_snippet,
108        correlation_ids,
109        entry_ids: g.iter().map(|e| e.id.clone()).collect(),
110        first_offset_ms: g.first().map(|e| e.started_offset_ms).unwrap_or(0.0),
111        last_offset_ms: g.last().map(|e| e.started_offset_ms).unwrap_or(0.0),
112    }
113}
114
115/// Render errors as deterministic terminal text.
116pub fn render_errors_text(r: &ErrorsResult) -> String {
117    let mut out = String::new();
118    out.push_str("== wiretrail errors ==\n");
119    for g in &r.groups {
120        out.push_str(&format!(
121            "\n{:>4}x  [{}] {} {}{}\n",
122            g.count, g.status, g.method, g.host, g.norm_path
123        ));
124        if let Some(m) = &g.error_message {
125            out.push_str(&format!("  message: {m}\n"));
126        }
127        if let Some(c) = &g.error_code {
128            out.push_str(&format!("  code: {c}\n"));
129        }
130        if !g.correlation_ids.is_empty() {
131            out.push_str(&format!(
132                "  correlation: {}\n",
133                g.correlation_ids.join(", ")
134            ));
135        }
136        if let Some(s) = &g.body_snippet {
137            out.push_str(&format!("  body: {s}\n"));
138        }
139        out.push_str(&format!("  entries: {}\n", g.entry_ids.join(", ")));
140    }
141    out
142}
143
144#[cfg(test)]
145mod tests {
146    use super::compute_errors;
147    use crate::filter::Filter;
148    use crate::model::{sample_capture, sample_entry};
149
150    fn cap() -> crate::model::Capture {
151        let mut e0 = sample_entry(0, "api.x", "POST", "/bulk", 500);
152        e0.resp_body = Some(r#"{"message":"boom","code":"E500"}"#.to_string());
153        let mut e1 = sample_entry(1, "api.x", "POST", "/bulk", 500);
154        e1.resp_body = Some(r#"{"message":"boom","code":"E500"}"#.to_string());
155        let e2 = sample_entry(2, "api.x", "GET", "/ok", 200); // not an error
156        let e3 = sample_entry(3, "api.x", "GET", "/missing", 404);
157        sample_capture(vec![e0, e1, e2, e3])
158    }
159
160    #[test]
161    fn groups_4xx_5xx_only() {
162        let r = compute_errors(&cap(), &Filter::parse(&[]).unwrap(), 10, false);
163        // /bulk 500 (x2) and /missing 404 (x1) -> 2 groups; /ok excluded
164        assert_eq!(r.groups.len(), 2);
165        let bulk = r.groups.iter().find(|g| g.norm_path == "/bulk").unwrap();
166        assert_eq!(bulk.count, 2);
167        assert_eq!(bulk.status, 500);
168        assert_eq!(bulk.error_message.as_deref(), Some("boom"));
169        assert_eq!(bulk.error_code.as_deref(), Some("E500"));
170        assert_eq!(bulk.entry_ids, vec!["e000000", "e000001"]);
171    }
172
173    #[test]
174    fn redacts_body_snippet_by_default() {
175        let mut e = sample_entry(0, "api.x", "POST", "/login", 401);
176        e.resp_body = Some(r#"{"access_token":"leak","message":"no"}"#.to_string());
177        let r = compute_errors(
178            &sample_capture(vec![e]),
179            &Filter::parse(&[]).unwrap(),
180            10,
181            false,
182        );
183        let snip = r.groups[0].body_snippet.as_deref().unwrap();
184        assert!(!snip.contains("leak"));
185    }
186}