1use crate::analysis::{auth, duplicates, errors, rate_limit, redirects, retries, slowest, storms};
2use crate::filter::Filter;
3use crate::model::Capture;
4use serde::Serialize;
5
6#[derive(Debug, Clone, Serialize)]
7pub struct Recommendation {
8 pub severity: String, pub kind: String,
10 pub title: String,
11 pub detail: String,
12 pub evidence_ids: Vec<String>,
13 pub command: String, pub filter: Option<String>, }
16
17impl Recommendation {
18 pub fn command_line(&self) -> String {
20 match &self.filter {
21 Some(f) => format!("{} --filter \"{}\"", self.command, f),
22 None => self.command.clone(),
23 }
24 }
25}
26
27fn path_glob(norm_path: &str) -> String {
31 norm_path
32 .split('/')
33 .map(|seg| {
34 if seg.starts_with('{') && seg.ends_with('}') {
35 "*"
36 } else {
37 seg
38 }
39 })
40 .collect::<Vec<_>>()
41 .join("/")
42}
43
44pub fn sev_rank(s: &str) -> u8 {
46 match s {
47 "critical" => 3,
48 "high" => 2,
49 "medium" => 1,
50 _ => 0,
51 }
52}
53
54pub fn recommend(cap: &Capture, filter: &Filter, top: usize) -> Vec<Recommendation> {
56 let mut f: Vec<Recommendation> = Vec::new();
57
58 for g in errors::compute_errors(cap, filter, top, false).groups {
60 if (500..600).contains(&g.status) && g.count >= 3 {
61 f.push(Recommendation {
62 severity: "high".into(),
63 kind: "5xx-cluster".into(),
64 title: format!("{}x {} on {} {}", g.count, g.status, g.method, g.norm_path),
65 detail: g
66 .error_message
67 .clone()
68 .unwrap_or_else(|| "server error cluster".into()),
69 evidence_ids: g.entry_ids.clone(),
70 command: "errors".into(),
71 filter: Some(format!("host:{}", g.host)),
72 });
73 } else if (400..500).contains(&g.status) {
74 f.push(Recommendation {
75 severity: "medium".into(),
76 kind: "4xx".into(),
77 title: format!("{}x {} on {} {}", g.count, g.status, g.method, g.norm_path),
78 detail: g
79 .error_message
80 .clone()
81 .unwrap_or_else(|| "client error".into()),
82 evidence_ids: g.entry_ids.clone(),
83 command: "errors".into(),
84 filter: None,
85 });
86 }
87 }
88
89 let a = auth::compute_auth(cap, filter, top);
91 for rf in &a.refreshes {
92 if rf.old_token_reused || !rf.success {
93 let why = if rf.old_token_reused {
94 "refresh succeeded but later calls reused the old token"
95 } else {
96 "token refresh failed"
97 };
98 let mut ids = vec![rf.id.clone()];
99 ids.extend(rf.reusing_ids.clone());
100 f.push(Recommendation {
101 severity: "high".into(),
102 kind: "token-refresh-race".into(),
103 title: format!("suspicious token refresh on {}", rf.host),
104 detail: why.into(),
105 evidence_ids: ids,
106 command: "auth".into(),
107 filter: None,
108 });
109 }
110 }
111 if !a.failures.is_empty() {
112 let total: usize = a.failures.iter().map(|x| x.count).sum();
113 let ids: Vec<String> = a
114 .failures
115 .iter()
116 .flat_map(|x| x.entry_ids.clone())
117 .collect();
118 f.push(Recommendation {
119 severity: "medium".into(),
120 kind: "auth-failures".into(),
121 title: format!("{total} auth failures (401/403)"),
122 detail: "requests rejected for authentication/authorization".into(),
123 evidence_ids: ids,
124 command: "auth".into(),
125 filter: None,
126 });
127 }
128
129 for g in rate_limit::compute_rate_limit(cap, filter, top).groups {
131 if g.cooldown_violated {
132 f.push(Recommendation {
133 severity: "high".into(),
134 kind: "rate-limit-no-backoff".into(),
135 title: format!("calls during 429 cooldown on {} {}", g.host, g.norm_path),
136 detail: format!(
137 "{} 429s, follow-ups before Retry-After elapsed",
138 g.count_429
139 ),
140 evidence_ids: g.entry_ids.clone(),
141 command: "rate-limit".into(),
142 filter: None,
143 });
144 }
145 }
146
147 for g in retries::compute_retries(cap, filter, top).groups {
149 if g.retry_count >= 3 && !(200..300).contains(&g.final_status) {
150 f.push(Recommendation {
151 severity: "high".into(),
152 kind: "retry-exhaustion".into(),
153 title: format!(
154 "{} retries, final {} on {} {}",
155 g.retry_count, g.final_status, g.method, g.norm_path
156 ),
157 detail: "repeated retries did not recover".into(),
158 evidence_ids: g.entry_ids.clone(),
159 command: "retries".into(),
160 filter: None,
161 });
162 }
163 }
164
165 for s in storms::compute_storms(cap, filter, 1000, 5, top).storms {
167 if s.peak_count >= 10 {
168 f.push(Recommendation {
169 severity: "medium".into(),
170 kind: "request-storm".into(),
171 title: format!(
172 "{} {} calls/s burst to {}",
173 s.peak_count, s.scope_kind, s.scope
174 ),
175 detail: "burst of calls in a 1s window".into(),
176 evidence_ids: s.entry_ids.clone(),
177 command: "storms".into(),
178 filter: None,
179 });
180 }
181 }
182
183 for g in duplicates::compute_duplicates(cap, filter, top).groups {
185 if g.count >= 10 && !g.is_retry_pattern {
186 f.push(Recommendation {
187 severity: "medium".into(),
188 kind: "wasteful-duplicates".into(),
189 title: format!("{}x identical {} {}", g.count, g.method, g.norm_path),
190 detail: "repeated identical calls (not retries)".into(),
191 evidence_ids: g.entry_ids.clone(),
192 command: "diff".into(),
193 filter: Some(format!("host:{} path:{}", g.host, path_glob(&g.norm_path))),
194 });
195 }
196 }
197
198 for g in redirects::compute_redirects(cap, filter, top).groups {
200 if g.is_storm {
201 f.push(Recommendation {
202 severity: "low".into(),
203 kind: "redirect-storm".into(),
204 title: format!(
205 "{}x [{}] redirect on {} {}",
206 g.count, g.status, g.host, g.norm_path
207 ),
208 detail: "repeated redirects".into(),
209 evidence_ids: g.entry_ids.clone(),
210 command: "redirects".into(),
211 filter: None,
212 });
213 }
214 }
215
216 if let Some(s) = slowest::compute_slowest(cap, filter, top).entries.first()
218 && s.duration_ms > 1000.0
219 && s.bottleneck == "server wait/TTFB"
220 {
221 f.push(Recommendation {
222 severity: "low".into(),
223 kind: "slow-backend".into(),
224 title: format!(
225 "slowest call {}ms on {} {}",
226 s.duration_ms as i64, s.host, s.norm_path
227 ),
228 detail: "dominated by server wait (TTFB)".into(),
229 evidence_ids: vec![s.id.clone()],
230 command: "slowest".into(),
231 filter: None,
232 });
233 }
234
235 f.sort_by(|a, b| {
236 sev_rank(&b.severity)
237 .cmp(&sev_rank(&a.severity))
238 .then(b.evidence_ids.len().cmp(&a.evidence_ids.len()))
239 .then(a.kind.cmp(&b.kind))
240 });
241 f.truncate(top);
242 f
243}
244
245#[cfg(test)]
246mod tests {
247 use super::recommend;
248 use crate::filter::Filter;
249 use crate::model::{Entry, sample_capture, sample_entry};
250
251 fn err(index: usize, path: &str, status: i64, off: f64) -> Entry {
252 let mut e = sample_entry(index, "api.x", "POST", path, status);
253 e.started_offset_ms = off;
254 e
255 }
256
257 #[test]
258 fn surfaces_5xx_cluster_as_high_with_host_filter() {
259 let cap = sample_capture(vec![
260 err(0, "/bulk", 500, 0.0),
261 err(1, "/bulk", 500, 10.0),
262 err(2, "/bulk", 500, 20.0),
263 ]);
264 let recs = recommend(&cap, &Filter::parse(&[]).unwrap(), 20);
265 let top = &recs[0];
266 assert_eq!(top.severity, "high");
267 assert_eq!(top.kind, "5xx-cluster");
268 assert_eq!(top.command, "errors");
269 assert_eq!(top.filter.as_deref(), Some("host:api.x"));
270 assert_eq!(top.command_line(), "errors --filter \"host:api.x\"");
271 }
272
273 #[test]
274 fn clean_capture_yields_no_recommendations() {
275 let cap = sample_capture(vec![sample_entry(0, "api.x", "GET", "/ok", 200)]);
276 assert!(recommend(&cap, &Filter::parse(&[]).unwrap(), 20).is_empty());
277 }
278
279 #[test]
280 fn path_glob_replaces_normalized_segments() {
281 assert_eq!(super::path_glob("/v1/ratings/bulk"), "/v1/ratings/bulk");
282 assert_eq!(
283 super::path_glob("/{blob}/manifest.json"),
284 "/*/manifest.json"
285 );
286 assert_eq!(
287 super::path_glob("/users/{id}/orders/{id}"),
288 "/users/*/orders/*"
289 );
290 }
291
292 #[test]
293 fn wasteful_duplicates_recommendation_is_scoped_and_parses() {
294 let entries: Vec<Entry> = (0..10)
296 .map(|i| sample_entry(i, "cdn.x", "GET", "/{blob}/manifest.json", 200))
297 .collect();
298 let recs = recommend(&sample_capture(entries), &Filter::parse(&[]).unwrap(), 20);
299 let dup = recs
300 .iter()
301 .find(|r| r.kind == "wasteful-duplicates")
302 .expect("a wasteful-duplicates recommendation");
303 assert_eq!(dup.command, "diff");
304 assert_eq!(
305 dup.filter.as_deref(),
306 Some("host:cdn.x path:/*/manifest.json")
307 );
308 assert!(Filter::parse(&[dup.filter.clone().unwrap()]).is_ok());
310 }
311}