Skip to main content

har/
recommender.rs

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, // "critical" | "high" | "medium" | "low"
9    pub kind: String,
10    pub title: String,
11    pub detail: String,
12    pub evidence_ids: Vec<String>,
13    pub command: String,        // drill-down subcommand
14    pub filter: Option<String>, // scoping filter expression, if any
15}
16
17impl Recommendation {
18    /// The reproducing command tail, e.g. `errors --filter "host:api.x"` or `auth`.
19    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
27/// Turn a normalized path into a filter glob: each `{id}`/`{blob}` segment
28/// becomes `*` so it matches the raw paths in the capture (the filter language
29/// matches `path:` against the raw, un-normalized path).
30fn 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
44/// Severity ordering shared across the recommender, diagnose, summary, and auto.
45pub fn sev_rank(s: &str) -> u8 {
46    match s {
47        "critical" => 3,
48        "high" => 2,
49        "medium" => 1,
50        _ => 0,
51    }
52}
53
54/// Rank actionable recommendations by composing the existing analyses.
55pub fn recommend(cap: &Capture, filter: &Filter, top: usize) -> Vec<Recommendation> {
56    let mut f: Vec<Recommendation> = Vec::new();
57
58    // 5xx clusters / 4xx groups
59    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    // auth: refresh races + failures
90    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    // rate-limit without backoff
130    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    // retry exhaustion
148    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    // request storms
166    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    // wasteful duplicates (not retries)
184    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    // redirect storms
199    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    // slow backend
217    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        // 10 identical GETs on a normalized-path route -> a wasteful-duplicates rec.
295        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        // the scoping filter must itself be a valid filter expression
309        assert!(Filter::parse(&[dup.filter.clone().unwrap()]).is_ok());
310    }
311}