1use std::collections::BTreeMap;
32
33use jiff::civil::Date;
34use jiff::Timestamp;
35use serde::Serialize;
36
37use crate::analysis::aggregate::Filter;
38use crate::analysis::dedup::dedup_session;
39use crate::model::Session;
40use crate::pricing::PricingTable;
41
42pub const STORM_MIN_REQUESTS: u64 = 5;
45
46pub const STORM_MAX_GAP_SECONDS: i64 = 15;
49
50const HEAVIEST_N: usize = 15;
52
53const CONCENTRATION_FRACTIONS: [f64; 4] = [0.01, 0.05, 0.10, 0.25];
55
56#[derive(Debug, Clone, Serialize)]
58pub struct HeavyRequest {
59 pub session_id: String,
60 pub request_id: Option<String>,
61 pub model: Option<String>,
62 pub ts: Option<Timestamp>,
63 pub total_tokens: u64,
65 pub cost_usd: Option<f64>,
67 pub cost_share: Option<f64>,
70 pub token_share: f64,
72 pub sidechain: bool,
74 pub has_thinking: bool,
77}
78
79#[derive(Debug, Clone, Serialize)]
82pub struct ConcentrationBucket {
83 pub request_fraction: f64,
85 pub requests: u64,
88 pub token_share: f64,
90 pub cost_share: Option<f64>,
92}
93
94#[derive(Debug, Clone, Serialize)]
97pub struct RetryStorm {
98 pub session_id: String,
99 pub project: Option<String>,
100 pub started_at: Timestamp,
101 pub ended_at: Timestamp,
102 pub requests: u64,
104 pub total_tokens: u64,
105 pub cost_usd: Option<f64>,
108 pub span_seconds: i64,
110 pub thinking_requests: u64,
112}
113
114#[derive(Debug, Clone, Serialize)]
116pub struct AnomalyReport {
117 pub agent: String,
118 pub since: Option<Date>,
119 pub requests_analyzed: u64,
121 pub total_tokens: u64,
122 pub total_cost_usd: f64,
125 pub has_unpriced: bool,
128 pub concentration: Vec<ConcentrationBucket>,
130 pub heaviest: Vec<HeavyRequest>,
132 pub retry_storms: Vec<RetryStorm>,
134 pub storm_min_requests: u64,
136 pub storm_max_gap_seconds: i64,
137}
138
139struct Req {
141 session_id: String,
142 project: Option<String>,
143 request_id: Option<String>,
144 model: Option<String>,
145 ts: Option<Timestamp>,
146 tokens: u64,
147 cost: Option<f64>,
148 sidechain: bool,
149 has_thinking: bool,
150}
151
152fn share(part: u64, whole: u64) -> f64 {
154 if whole == 0 {
155 0.0
156 } else {
157 part as f64 / whole as f64
158 }
159}
160
161pub fn detect(
168 sessions: &[Session],
169 filter: &Filter,
170 agent: &str,
171 pricing: &PricingTable,
172) -> AnomalyReport {
173 let mut reqs: Vec<Req> = Vec::new();
175 for session in sessions {
176 let (records, _stats) = dedup_session(session, filter.since);
177 for rec in records {
178 let tokens = rec.usage.known_total();
179 let cost = pricing.cost_usd(rec.model.as_deref(), &rec.usage);
180 reqs.push(Req {
181 session_id: rec.session_id,
182 project: rec.project,
183 request_id: rec.request_id,
184 model: rec.model,
185 ts: rec.ts,
186 tokens,
187 cost,
188 sidechain: rec.sidechain,
189 has_thinking: rec.has_thinking,
190 });
191 }
192 }
193
194 let n = reqs.len();
195 let total_tokens: u64 = reqs.iter().map(|r| r.tokens).sum();
196 let total_cost_usd: f64 = reqs.iter().filter_map(|r| r.cost).sum();
197 let has_unpriced = reqs.iter().any(|r| r.cost.is_none());
198
199 if n == 0 {
201 return AnomalyReport {
202 agent: agent.to_string(),
203 since: filter.since,
204 requests_analyzed: 0,
205 total_tokens: 0,
206 total_cost_usd: 0.0,
207 has_unpriced: false,
208 concentration: Vec::new(),
209 heaviest: Vec::new(),
210 retry_storms: Vec::new(),
211 storm_min_requests: STORM_MIN_REQUESTS,
212 storm_max_gap_seconds: STORM_MAX_GAP_SECONDS,
213 };
214 }
215
216 let mut order: Vec<usize> = (0..n).collect();
219 order.sort_by(|&a, &b| {
220 let (ra, rb) = (&reqs[a], &reqs[b]);
221 rb.tokens
222 .cmp(&ra.tokens)
223 .then_with(|| rb.cost.unwrap_or(0.0).total_cmp(&ra.cost.unwrap_or(0.0)))
224 .then_with(|| ra.session_id.cmp(&rb.session_id))
225 .then_with(|| ra.request_id.cmp(&rb.request_id))
226 });
227
228 let concentration = CONCENTRATION_FRACTIONS
230 .iter()
231 .map(|&frac| {
232 let k = ((frac * n as f64).ceil() as usize).clamp(1, n);
233 let slice = &order[..k];
234 let tok: u64 = slice.iter().map(|&i| reqs[i].tokens).sum();
235 let cost: f64 = slice.iter().filter_map(|&i| reqs[i].cost).sum();
236 ConcentrationBucket {
237 request_fraction: frac,
238 requests: k as u64,
239 token_share: share(tok, total_tokens),
240 cost_share: (total_cost_usd > 0.0).then_some(cost / total_cost_usd),
241 }
242 })
243 .collect();
244
245 let heaviest = order
247 .iter()
248 .take(HEAVIEST_N)
249 .map(|&i| {
250 let r = &reqs[i];
251 HeavyRequest {
252 session_id: r.session_id.clone(),
253 request_id: r.request_id.clone(),
254 model: r.model.clone(),
255 ts: r.ts,
256 total_tokens: r.tokens,
257 cost_usd: r.cost,
258 cost_share: match (r.cost, total_cost_usd > 0.0) {
259 (Some(c), true) => Some(c / total_cost_usd),
260 _ => None,
261 },
262 token_share: share(r.tokens, total_tokens),
263 sidechain: r.sidechain,
264 has_thinking: r.has_thinking,
265 }
266 })
267 .collect();
268
269 let retry_storms = detect_storms(&reqs);
271
272 AnomalyReport {
273 agent: agent.to_string(),
274 since: filter.since,
275 requests_analyzed: n as u64,
276 total_tokens,
277 total_cost_usd,
278 has_unpriced,
279 concentration,
280 heaviest,
281 retry_storms,
282 storm_min_requests: STORM_MIN_REQUESTS,
283 storm_max_gap_seconds: STORM_MAX_GAP_SECONDS,
284 }
285}
286
287fn detect_storms(reqs: &[Req]) -> Vec<RetryStorm> {
292 let mut by_session: BTreeMap<&str, Vec<(Timestamp, usize)>> = BTreeMap::new();
293 for (i, r) in reqs.iter().enumerate() {
294 if let Some(ts) = r.ts {
295 by_session
296 .entry(r.session_id.as_str())
297 .or_default()
298 .push((ts, i));
299 }
300 }
301
302 let mut storms: Vec<RetryStorm> = Vec::new();
303 for (_session, mut timeline) in by_session {
304 timeline.sort();
305 let mut run: Vec<(Timestamp, usize)> = Vec::new();
306 let mut last: Option<Timestamp> = None;
307 for &(ts, i) in &timeline {
308 let continues = matches!(
309 last,
310 Some(prev) if ts.as_second() - prev.as_second() <= STORM_MAX_GAP_SECONDS
311 );
312 if continues {
313 run.push((ts, i));
314 } else {
315 push_storm(&mut storms, reqs, &run);
316 run = vec![(ts, i)];
317 }
318 last = Some(ts);
319 }
320 push_storm(&mut storms, reqs, &run);
321 }
322
323 storms.sort_by(|a, b| {
325 b.total_tokens
326 .cmp(&a.total_tokens)
327 .then_with(|| a.started_at.cmp(&b.started_at))
328 .then_with(|| a.session_id.cmp(&b.session_id))
329 });
330 storms
331}
332
333fn push_storm(out: &mut Vec<RetryStorm>, reqs: &[Req], run: &[(Timestamp, usize)]) {
335 if (run.len() as u64) < STORM_MIN_REQUESTS {
336 return;
337 }
338 let (started_at, first_idx) = run[0];
339 let ended_at = run[run.len() - 1].0;
340
341 let mut total_tokens = 0u64;
342 let mut cost_sum = 0.0f64;
343 let mut any_priced = false;
344 let mut thinking_requests = 0u64;
345 for &(_, i) in run {
346 total_tokens += reqs[i].tokens;
347 if let Some(c) = reqs[i].cost {
348 cost_sum += c;
349 any_priced = true;
350 }
351 if reqs[i].has_thinking {
352 thinking_requests += 1;
353 }
354 }
355
356 out.push(RetryStorm {
357 session_id: reqs[first_idx].session_id.clone(),
358 project: reqs[first_idx].project.clone(),
359 started_at,
360 ended_at,
361 requests: run.len() as u64,
362 total_tokens,
363 cost_usd: any_priced.then_some(cost_sum),
364 span_seconds: ended_at.as_second() - started_at.as_second(),
365 thinking_requests,
366 });
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use crate::model::{Event, EventKind, Usage};
373
374 fn turn(rid: &str, ts: &str, input: u64, output: u64) -> Event {
376 Event {
377 kind: EventKind::Assistant,
378 ts: Some(ts.parse().unwrap()),
379 request_id: Some(rid.into()),
380 model: Some("claude-sonnet-4-5".into()),
381 usage: Some(Usage {
382 input: Some(input),
383 output: Some(output),
384 cache_creation: Some(0),
385 cache_read: Some(0),
386 ..Usage::default()
387 }),
388 tool_calls: Vec::new(),
389 sidechain: false,
390 content_summary: None,
391 content_chars: 0,
392 thinking_chars: 0,
393 has_thinking: false,
394 tool_use_id: None,
395 attachment_kind: None,
396 item_count: 0,
397 }
398 }
399
400 fn session(id: &str, parent: Option<&str>, events: Vec<Event>) -> Session {
401 Session {
402 id: id.into(),
403 agent: "claude-code".into(),
404 project: Some("proj".into()),
405 model: Some("claude-sonnet-4-5".into()),
406 parent_session: parent.map(str::to_string),
407 started_at: None,
408 ended_at: None,
409 events,
410 sub_agents: Vec::new(),
411 skipped_lines: 0,
412 }
413 }
414
415 fn bucket(r: &AnomalyReport, frac: f64) -> &ConcentrationBucket {
416 r.concentration
417 .iter()
418 .find(|b| (b.request_fraction - frac).abs() < 1e-9)
419 .expect("bucket present")
420 }
421
422 #[test]
425 fn concentration_reports_token_share_of_top_requests() {
426 let mut events = vec![turn("big", "2026-06-02T10:00:00Z", 100_000, 0)];
427 for i in 0..9 {
430 events.push(turn(
431 &format!("small{i}"),
432 &format!("2026-06-02T1{i}:00:00Z"),
433 1_000,
434 0,
435 ));
436 }
437 let s = session("s", None, events);
438 let r = detect(
439 &[s],
440 &Filter::default(),
441 "claude-code",
442 &PricingTable::embedded(),
443 );
444
445 assert_eq!(r.requests_analyzed, 10);
446 assert_eq!(r.total_tokens, 109_000);
447
448 let b10 = bucket(&r, 0.10);
450 assert_eq!(b10.requests, 1);
451 assert!((b10.token_share - 100_000.0 / 109_000.0).abs() < 1e-9);
452 let b25 = bucket(&r, 0.25);
454 assert_eq!(b25.requests, 3);
455 assert!((b25.token_share - 102_000.0 / 109_000.0).abs() < 1e-9);
456
457 assert_eq!(r.heaviest[0].request_id.as_deref(), Some("big"));
459 assert_eq!(r.heaviest[0].total_tokens, 100_000);
460 assert!((r.heaviest[0].token_share - 100_000.0 / 109_000.0).abs() < 1e-9);
461 assert!(r.heaviest[0].cost_usd.is_some());
463 assert!(r.heaviest[0].cost_share.is_some());
464 assert!(!r.has_unpriced);
465 }
466
467 #[test]
470 fn retry_storm_detects_a_temporal_burst() {
471 let s = session(
472 "s",
473 None,
474 vec![
475 turn("r0", "2026-06-02T10:00:00Z", 1_000, 10),
476 turn("r1", "2026-06-02T10:00:10Z", 1_000, 10),
477 turn("r2", "2026-06-02T10:00:20Z", 1_000, 10),
478 turn("r3", "2026-06-02T10:00:30Z", 1_000, 10),
479 turn("r4", "2026-06-02T10:00:40Z", 1_000, 10),
480 turn("late", "2026-06-02T11:00:00Z", 1_000, 10),
482 ],
483 );
484 let r = detect(
485 &[s],
486 &Filter::default(),
487 "claude-code",
488 &PricingTable::embedded(),
489 );
490 assert_eq!(r.retry_storms.len(), 1, "one burst of five");
491 let storm = &r.retry_storms[0];
492 assert_eq!(storm.requests, 5);
493 assert_eq!(storm.total_tokens, 5 * 1_010);
494 assert_eq!(storm.span_seconds, 40);
495 assert_eq!(storm.session_id, "s");
496 assert!(storm.cost_usd.is_some());
497 }
498
499 #[test]
502 fn no_storm_below_min_or_across_a_large_gap() {
503 let four = session(
505 "four",
506 None,
507 vec![
508 turn("a", "2026-06-02T10:00:00Z", 1, 1),
509 turn("b", "2026-06-02T10:00:05Z", 1, 1),
510 turn("c", "2026-06-02T10:00:10Z", 1, 1),
511 turn("d", "2026-06-02T10:00:15Z", 1, 1),
512 ],
513 );
514 let split = session(
516 "split",
517 None,
518 vec![
519 turn("a", "2026-06-02T10:00:00Z", 1, 1),
520 turn("b", "2026-06-02T10:00:05Z", 1, 1),
521 turn("c", "2026-06-02T10:00:10Z", 1, 1),
522 turn("d", "2026-06-02T10:01:10Z", 1, 1),
523 turn("e", "2026-06-02T10:01:15Z", 1, 1),
524 turn("f", "2026-06-02T10:01:20Z", 1, 1),
525 ],
526 );
527 let r = detect(
528 &[four, split],
529 &Filter::default(),
530 "claude-code",
531 &PricingTable::embedded(),
532 );
533 assert!(r.retry_storms.is_empty());
534 }
535
536 #[test]
539 fn dedup_is_applied_before_counting() {
540 let s = session(
541 "s",
542 None,
543 vec![
544 turn("req", "2026-06-02T10:00:00Z", 1_000, 200),
545 turn("req", "2026-06-02T10:00:00Z", 1_000, 200),
546 turn("req", "2026-06-02T10:00:00Z", 1_000, 200),
547 ],
548 );
549 let r = detect(
550 &[s],
551 &Filter::default(),
552 "claude-code",
553 &PricingTable::embedded(),
554 );
555 assert_eq!(r.requests_analyzed, 1, "3 lines -> 1 request");
556 assert_eq!(r.total_tokens, 1_200);
557 assert!(r.retry_storms.is_empty(), "one request is not a storm");
558 }
559
560 #[test]
563 fn unpriced_requests_are_flagged_not_guessed() {
564 let mut unp = turn("u", "2026-06-02T10:00:00Z", 5_000, 0);
565 unp.model = Some("claude-opus-5-0".into()); let priced = turn("p", "2026-06-02T11:00:00Z", 1_000, 0);
567 let s = session("s", None, vec![unp, priced]);
568 let r = detect(
569 &[s],
570 &Filter::default(),
571 "claude-code",
572 &PricingTable::embedded(),
573 );
574
575 assert!(r.has_unpriced);
576 assert_eq!(r.heaviest[0].request_id.as_deref(), Some("u"));
578 assert_eq!(r.heaviest[0].cost_usd, None);
579 assert_eq!(r.heaviest[0].cost_share, None, "no cost -> no share");
580 assert!(r.total_cost_usd > 0.0);
582 }
583
584 #[test]
586 fn since_filter_restricts_the_window() {
587 let s = session(
588 "s",
589 None,
590 vec![
591 turn("old", "2026-06-01T10:00:00Z", 9_999, 0),
592 turn("new", "2026-06-02T10:00:00Z", 100, 0),
593 ],
594 );
595 let filter = Filter {
596 since: Some("2026-06-02".parse().unwrap()),
597 };
598 let r = detect(&[s], &filter, "claude-code", &PricingTable::embedded());
599 assert_eq!(r.requests_analyzed, 1);
600 assert_eq!(r.total_tokens, 100);
601 assert_eq!(r.heaviest[0].request_id.as_deref(), Some("new"));
602 assert_eq!(r.since, filter.since);
603 }
604
605 #[test]
607 fn empty_window_is_an_empty_report() {
608 let r = detect(
609 &[],
610 &Filter::default(),
611 "claude-code",
612 &PricingTable::embedded(),
613 );
614 assert_eq!(r.requests_analyzed, 0);
615 assert!(r.concentration.is_empty());
616 assert!(r.heaviest.is_empty());
617 assert!(r.retry_storms.is_empty());
618 assert_eq!(r.storm_min_requests, STORM_MIN_REQUESTS);
619 }
620
621 #[test]
624 fn storm_within_a_subagent_transcript() {
625 let child = session(
626 "agent-x",
627 Some("parent"),
628 vec![
629 turn("c0", "2026-06-02T10:00:00Z", 100, 1),
630 turn("c1", "2026-06-02T10:00:05Z", 100, 1),
631 turn("c2", "2026-06-02T10:00:10Z", 100, 1),
632 turn("c3", "2026-06-02T10:00:14Z", 100, 1),
633 turn("c4", "2026-06-02T10:00:18Z", 100, 1),
634 ],
635 );
636 let r = detect(
637 &[child],
638 &Filter::default(),
639 "claude-code",
640 &PricingTable::embedded(),
641 );
642 assert_eq!(r.retry_storms.len(), 1);
643 assert_eq!(r.retry_storms[0].session_id, "agent-x");
644 assert!(r.heaviest.iter().all(|h| h.sidechain));
645 }
646}