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
31pub 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
115pub 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); 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 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}