1use std::collections::BTreeMap;
12use std::path::Path;
13use std::str::FromStr;
14use std::time::Duration;
15
16use reqwest::Client;
17use serde::{Deserialize, Serialize};
18
19use crate::engine::capture::{CaptureSessionExport, CapturedExchange};
20use crate::error::{Result, TloxError};
21
22const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
23const ANTHROPIC_VERSION: &str = "2023-06-01";
24const DEFAULT_MAX_TOKENS: u32 = 4096;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ExplainModel {
31 Haiku,
32 Sonnet,
33 Opus,
34}
35
36impl ExplainModel {
37 pub fn as_api_id(self) -> &'static str {
39 match self {
40 ExplainModel::Haiku => "claude-haiku-4-5-20251001",
41 ExplainModel::Sonnet => "claude-sonnet-4-6",
42 ExplainModel::Opus => "claude-opus-4-7",
43 }
44 }
45
46 pub fn display_name(self) -> &'static str {
47 match self {
48 ExplainModel::Haiku => "Haiku 4.5",
49 ExplainModel::Sonnet => "Sonnet 4.6",
50 ExplainModel::Opus => "Opus 4.7",
51 }
52 }
53}
54
55impl FromStr for ExplainModel {
56 type Err = String;
57 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
58 match value.to_ascii_lowercase().as_str() {
59 "haiku" => Ok(ExplainModel::Haiku),
60 "sonnet" => Ok(ExplainModel::Sonnet),
61 "opus" => Ok(ExplainModel::Opus),
62 other => Err(format!(
63 "unknown model '{other}' — expected haiku, sonnet, or opus"
64 )),
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
70pub struct ExplainOptions {
71 pub model: ExplainModel,
72 pub max_samples: usize,
75}
76
77impl Default for ExplainOptions {
78 fn default() -> Self {
79 Self {
80 model: ExplainModel::Haiku,
81 max_samples: 15,
82 }
83 }
84}
85
86pub fn load_session(path: &Path) -> Result<CaptureSessionExport> {
89 let text = std::fs::read_to_string(path)?;
90 let session: CaptureSessionExport = serde_json::from_str(&text)
91 .map_err(|error| TloxError::Io(std::io::Error::other(error)))?;
92 Ok(session)
93}
94
95pub fn read_api_key() -> Result<String> {
97 match std::env::var("ANTHROPIC_API_KEY") {
98 Ok(value) if !value.trim().is_empty() => Ok(value),
99 _ => Err(TloxError::MissingApiKey),
100 }
101}
102
103pub fn estimate_input_tokens(system: &str, user: &str) -> usize {
105 (system.len() + user.len()) / 4
106}
107
108pub async fn explain_session(
111 session_path: &Path,
112 options: ExplainOptions,
113) -> Result<ExplainResult> {
114 let api_key = read_api_key()?;
115 let session = load_session(session_path)?;
116
117 let summary = summarize_session(&session, &options);
118 let user_payload = serde_json::to_string_pretty(&summary)
119 .map_err(|error| TloxError::Io(std::io::Error::other(error)))?;
120 let system_prompt = system_prompt();
121
122 let estimated_input = estimate_input_tokens(&system_prompt, &user_payload);
123
124 let markdown = call_anthropic(&api_key, options.model, &system_prompt, &user_payload).await?;
125
126 Ok(ExplainResult {
127 markdown,
128 model: options.model,
129 summary_stats: summary.session_meta,
130 estimated_input_tokens: estimated_input,
131 })
132}
133
134#[derive(Debug, Clone)]
135pub struct ExplainResult {
136 pub markdown: String,
137 pub model: ExplainModel,
138 pub summary_stats: SessionMeta,
139 pub estimated_input_tokens: usize,
140}
141
142fn system_prompt() -> String {
147 r#"You are a senior performance engineer analyzing an HTTP capture produced by the `lobe` tool.
148
149The user gives you a JSON summary of a captured session. Apply the **USE method** (Utilization, Saturation, Errors) — but investigate in this priority order for triage:
150
1511. **Errors** — non-2xx responses and failed requests. Are they scoped to specific endpoints or global?
1522. **Saturation** — signs the upstream is overloaded: high tail latencies (P99 much greater than P50), long TTFB values, download-phase dominance, connection reuse patterns.
1533. **Utilization** — which endpoints consume the most cumulative wall-clock time across the session?
154
155**Baseline thresholds** (rule of thumb — anything meaningfully over these is worth investigating):
156- **Loopback** (localhost, 127.x): TTFB ≤ 50ms, total ≤ 80ms
157- **LAN** (RFC1918 or *.local): TTFB ≤ 100ms, total ≤ 200ms
158- **Remote** (public internet): TTFB ≤ 500ms, total ≤ 1.5s
159
160**Phase interpretation cheatsheet:**
161- DNS excess → resolver problem, cold DNS, custom /etc/hosts
162- TCP excess → saturated network path, upstream overloaded
163- TLS excess → old TLS version, deep certificate chain, slow ALPN
164- TTFB excess → almost always slow DB queries, missing indexes, N+1, or CPU-bound work before the response starts writing (this is the most common finding)
165- Download excess → large response body relative to bandwidth, or upstream streaming slowly
166
167**Return format** — markdown, with these sections in this exact order:
168
169## Summary
1702-3 sentences of the headline findings. Lead with the number that matters most.
171
172## Errors
173Top error patterns. If zero errors, write "No errors observed." on a single line.
174
175## Utilization
176Which endpoints are burning the most cumulative time? Give specific method + path + numbers.
177
178## Saturation
179Tail behavior (P99 >> P50), TTFB dominance, bimodal patterns, phase excess. Cite specific numbers from the payload.
180
181## Recommendations
182Exactly 3–5 specific, actionable items ranked by expected impact. Each item: a concrete change, not general advice. Prefer "add an index on users.email (GET /api/users P99=340ms, TTFB dominates)" over "look at your database".
183
184Be concrete. Cite specific endpoints and numbers. If evidence is thin, say so — do not fabricate causes."#
185 .to_string()
186}
187
188#[derive(Debug, Clone, Serialize)]
193pub struct SessionSummary {
194 pub session_meta: SessionMeta,
195 pub errors: ErrorsBlock,
196 pub routes: Vec<RouteAggregate>,
197 pub top_slowest_samples: Vec<SampleRequest>,
198 pub top_error_samples: Vec<SampleRequest>,
199}
200
201#[derive(Debug, Clone, Serialize)]
202pub struct SessionMeta {
203 pub upstream: String,
204 pub listen_addr: String,
205 pub exported_at_ms: i64,
206 pub total_requests: usize,
207 pub error_requests: usize,
208 pub error_rate_percent: f64,
209 pub duration_seconds: f64,
210 pub distinct_routes: usize,
211 pub distinct_hosts: usize,
212}
213
214#[derive(Debug, Clone, Serialize)]
215pub struct ErrorsBlock {
216 pub total_errors: usize,
217 pub top_status_codes: Vec<StatusCodeCount>,
218}
219
220#[derive(Debug, Clone, Serialize)]
221pub struct StatusCodeCount {
222 pub status_code: u16,
223 pub count: usize,
224}
225
226#[derive(Debug, Clone, Serialize)]
227pub struct RouteAggregate {
228 pub method: String,
229 pub path: String,
230 pub host: String,
231 pub baseline_category: &'static str,
232 pub count: usize,
233 pub errors: usize,
234 pub total_ms_p50: u64,
235 pub total_ms_p90: u64,
236 pub total_ms_p99: u64,
237 pub total_ms_max: u64,
238 pub cumulative_ms: u64,
239 pub phase_median_ms: PhaseMedians,
240}
241
242#[derive(Debug, Clone, Serialize)]
243pub struct PhaseMedians {
244 pub dns: u64,
245 pub tcp: u64,
246 pub tls: u64,
247 pub ttfb: u64,
248 pub download: u64,
249}
250
251#[derive(Debug, Clone, Serialize)]
252pub struct SampleRequest {
253 pub method: String,
254 pub path: String,
255 pub host: String,
256 pub status: Option<u16>,
257 pub total_ms: u64,
258 pub ttfb_ms: u64,
259 pub download_ms: u64,
260 pub error: Option<String>,
261}
262
263pub fn summarize_session(session: &CaptureSessionExport, options: &ExplainOptions) -> SessionSummary {
264 let events = &session.events;
265
266 let mut groups: BTreeMap<(String, String, String), Vec<&CapturedExchange>> = BTreeMap::new();
268 let mut status_counts: BTreeMap<u16, usize> = BTreeMap::new();
269 let mut error_count = 0usize;
270 let mut hosts: BTreeMap<String, ()> = BTreeMap::new();
271
272 let mut earliest_ms = i64::MAX;
273 let mut latest_ms = i64::MIN;
274
275 for event in events {
276 let key = (
277 event.request_method.clone(),
278 event.request_path.clone(),
279 event.request_host.clone(),
280 );
281 groups.entry(key).or_default().push(event);
282 hosts.insert(event.request_host.clone(), ());
283
284 earliest_ms = earliest_ms.min(event.created_at_ms);
285 latest_ms = latest_ms.max(event.created_at_ms);
286
287 match event.response_status_code {
288 Some(code) if !(200..300).contains(&code) => {
289 *status_counts.entry(code).or_insert(0) += 1;
290 error_count += 1;
291 }
292 None => {
293 error_count += 1;
294 }
295 _ => {}
296 }
297 }
298
299 let duration_seconds = if earliest_ms == i64::MAX || latest_ms == i64::MIN {
300 0.0
301 } else {
302 ((latest_ms - earliest_ms).max(0) as f64) / 1000.0
303 };
304
305 let mut top_status_codes: Vec<StatusCodeCount> = status_counts
306 .into_iter()
307 .map(|(status_code, count)| StatusCodeCount { status_code, count })
308 .collect();
309 top_status_codes.sort_by(|a, b| b.count.cmp(&a.count));
310 top_status_codes.truncate(6);
311
312 let mut routes: Vec<RouteAggregate> = groups
313 .iter()
314 .map(|((method, path, host), items)| aggregate_route(method, path, host, items))
315 .collect();
316
317 routes.sort_by(|a, b| b.cumulative_ms.cmp(&a.cumulative_ms));
319 routes.truncate(20);
320
321 let top_slowest_samples = pick_samples(events, options.max_samples, SampleKind::Slowest);
322 let top_error_samples = pick_samples(events, options.max_samples, SampleKind::Errors);
323
324 SessionSummary {
325 session_meta: SessionMeta {
326 upstream: session.upstream.clone(),
327 listen_addr: session.listen_addr.clone(),
328 exported_at_ms: session.exported_at_ms,
329 total_requests: events.len(),
330 error_requests: error_count,
331 error_rate_percent: if events.is_empty() {
332 0.0
333 } else {
334 (error_count as f64 / events.len() as f64) * 100.0
335 },
336 duration_seconds,
337 distinct_routes: groups.len(),
338 distinct_hosts: hosts.len(),
339 },
340 errors: ErrorsBlock {
341 total_errors: error_count,
342 top_status_codes,
343 },
344 routes,
345 top_slowest_samples,
346 top_error_samples,
347 }
348}
349
350fn aggregate_route(
351 method: &str,
352 path: &str,
353 host: &str,
354 items: &[&CapturedExchange],
355) -> RouteAggregate {
356 let mut totals: Vec<u64> = items.iter().map(|item| item.report.total_ms).collect();
357 let mut dns: Vec<u64> = items.iter().map(|item| item.report.dns_ms).collect();
358 let mut tcp: Vec<u64> = items.iter().map(|item| item.report.tcp_ms).collect();
359 let mut tls: Vec<u64> = items.iter().map(|item| item.report.tls_ms).collect();
360 let mut ttfb: Vec<u64> = items.iter().map(|item| item.report.ttfb_ms).collect();
361 let mut download: Vec<u64> = items.iter().map(|item| item.report.download_ms).collect();
362
363 totals.sort_unstable();
364 dns.sort_unstable();
365 tcp.sort_unstable();
366 tls.sort_unstable();
367 ttfb.sort_unstable();
368 download.sort_unstable();
369
370 let cumulative_ms = totals.iter().sum();
371 let errors = items
372 .iter()
373 .filter(|item| match item.response_status_code {
374 Some(code) => !(200..300).contains(&code),
375 None => true,
376 })
377 .count();
378
379 RouteAggregate {
380 method: method.to_string(),
381 path: path.to_string(),
382 host: host.to_string(),
383 baseline_category: classify_host(host),
384 count: items.len(),
385 errors,
386 total_ms_p50: percentile(&totals, 50),
387 total_ms_p90: percentile(&totals, 90),
388 total_ms_p99: percentile(&totals, 99),
389 total_ms_max: *totals.last().unwrap_or(&0),
390 cumulative_ms,
391 phase_median_ms: PhaseMedians {
392 dns: percentile(&dns, 50),
393 tcp: percentile(&tcp, 50),
394 tls: percentile(&tls, 50),
395 ttfb: percentile(&ttfb, 50),
396 download: percentile(&download, 50),
397 },
398 }
399}
400
401fn percentile(sorted: &[u64], p: u8) -> u64 {
403 if sorted.is_empty() {
404 return 0;
405 }
406 let rank = ((p as usize * sorted.len()).div_ceil(100)).max(1);
407 sorted[rank.min(sorted.len()) - 1]
408}
409
410fn classify_host(host: &str) -> &'static str {
411 if host == "localhost" || host == "::1" {
412 return "loopback";
413 }
414 if let Some((a, b)) = ipv4_prefix(host) {
415 if a == 127 {
416 return "loopback";
417 }
418 if a == 10 {
419 return "lan";
420 }
421 if a == 192 && b == 168 {
422 return "lan";
423 }
424 if a == 172 && (16..=31).contains(&b) {
425 return "lan";
426 }
427 return "remote";
428 }
429 if host.ends_with(".local") {
430 return "lan";
431 }
432 "remote"
433}
434
435fn ipv4_prefix(host: &str) -> Option<(u8, u8)> {
436 let mut parts = host.split('.');
437 let a = parts.next()?.parse::<u8>().ok()?;
438 let b = parts.next()?.parse::<u8>().ok()?;
439 parts.next()?.parse::<u8>().ok()?;
440 parts.next()?.parse::<u8>().ok()?;
441 if parts.next().is_some() {
442 return None;
443 }
444 Some((a, b))
445}
446
447enum SampleKind {
448 Slowest,
449 Errors,
450}
451
452fn pick_samples(events: &[CapturedExchange], limit: usize, kind: SampleKind) -> Vec<SampleRequest> {
453 let mut filtered: Vec<&CapturedExchange> = match kind {
454 SampleKind::Slowest => events.iter().collect(),
455 SampleKind::Errors => events
456 .iter()
457 .filter(|item| match item.response_status_code {
458 Some(code) => !(200..300).contains(&code),
459 None => true,
460 })
461 .collect(),
462 };
463 match kind {
464 SampleKind::Slowest => {
465 filtered.sort_by(|a, b| b.report.total_ms.cmp(&a.report.total_ms));
466 }
467 SampleKind::Errors => {
468 filtered.sort_by(|a, b| b.report.total_ms.cmp(&a.report.total_ms));
469 }
470 }
471 filtered.truncate(limit);
472 filtered.into_iter().map(sample_from_exchange).collect()
473}
474
475fn sample_from_exchange(event: &CapturedExchange) -> SampleRequest {
476 SampleRequest {
477 method: event.request_method.clone(),
478 path: event.request_path.clone(),
479 host: event.request_host.clone(),
480 status: event.response_status_code,
481 total_ms: event.report.total_ms,
482 ttfb_ms: event.report.ttfb_ms,
483 download_ms: event.report.download_ms,
484 error: event.error_message.clone(),
485 }
486}
487
488#[derive(Debug, Serialize)]
493struct AnthropicRequest<'a> {
494 model: &'a str,
495 max_tokens: u32,
496 system: &'a str,
497 messages: Vec<AnthropicMessage<'a>>,
498}
499
500#[derive(Debug, Serialize)]
501struct AnthropicMessage<'a> {
502 role: &'a str,
503 content: &'a str,
504}
505
506#[derive(Debug, Deserialize)]
507struct AnthropicResponse {
508 content: Vec<AnthropicContentBlock>,
509}
510
511#[derive(Debug, Deserialize)]
512#[serde(tag = "type")]
513enum AnthropicContentBlock {
514 #[serde(rename = "text")]
515 Text { text: String },
516 #[serde(other)]
517 Other,
518}
519
520#[derive(Debug, Deserialize)]
521struct AnthropicErrorEnvelope {
522 error: AnthropicErrorPayload,
523}
524
525#[derive(Debug, Deserialize)]
526struct AnthropicErrorPayload {
527 #[serde(rename = "type")]
528 kind: String,
529 message: String,
530}
531
532async fn call_anthropic(
533 api_key: &str,
534 model: ExplainModel,
535 system_prompt: &str,
536 user_payload: &str,
537) -> Result<String> {
538 let request = AnthropicRequest {
539 model: model.as_api_id(),
540 max_tokens: DEFAULT_MAX_TOKENS,
541 system: system_prompt,
542 messages: vec![AnthropicMessage {
543 role: "user",
544 content: user_payload,
545 }],
546 };
547
548 let body = serde_json::to_string(&request)
549 .map_err(|error| TloxError::Io(std::io::Error::other(error)))?;
550
551 let client = Client::builder()
552 .timeout(Duration::from_secs(120))
553 .build()
554 .map_err(|error| TloxError::Io(std::io::Error::other(error)))?;
555
556 let response = client
557 .post(ANTHROPIC_API_URL)
558 .header("x-api-key", api_key)
559 .header("anthropic-version", ANTHROPIC_VERSION)
560 .header("content-type", "application/json")
561 .body(body)
562 .send()
563 .await
564 .map_err(|error| TloxError::Io(std::io::Error::other(error)))?;
565
566 let status = response.status();
567 let text = response
568 .text()
569 .await
570 .map_err(|error| TloxError::Io(std::io::Error::other(error)))?;
571
572 if !status.is_success() {
573 return Err(TloxError::AnthropicApi(format_api_error(status.as_u16(), &text)));
574 }
575
576 let parsed: AnthropicResponse = serde_json::from_str(&text).map_err(|error| {
577 TloxError::AnthropicApi(format!(
578 "unable to parse Anthropic response: {error}. Raw body: {}",
579 truncate(&text, 500)
580 ))
581 })?;
582
583 let joined = parsed
584 .content
585 .into_iter()
586 .filter_map(|block| match block {
587 AnthropicContentBlock::Text { text } => Some(text),
588 AnthropicContentBlock::Other => None,
589 })
590 .collect::<Vec<_>>()
591 .join("\n\n");
592
593 if joined.trim().is_empty() {
594 return Err(TloxError::AnthropicApi(
595 "Anthropic returned an empty response".to_string(),
596 ));
597 }
598
599 Ok(joined)
600}
601
602fn format_api_error(status: u16, body: &str) -> String {
603 if let Ok(envelope) = serde_json::from_str::<AnthropicErrorEnvelope>(body) {
604 return format!(
605 "Anthropic API error {status} ({}): {}",
606 envelope.error.kind, envelope.error.message
607 );
608 }
609 format!("Anthropic API error {status}: {}", truncate(body, 500))
610}
611
612fn truncate(text: &str, limit: usize) -> String {
613 if text.len() <= limit {
614 text.to_string()
615 } else {
616 format!("{}…(truncated)", &text[..limit])
617 }
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623
624 #[test]
625 fn percentile_uses_nearest_rank() {
626 let sorted = vec![1, 2, 3, 4, 5];
627 assert_eq!(percentile(&sorted, 50), 3);
628 assert_eq!(percentile(&sorted, 99), 5);
629 }
630
631 #[test]
632 fn percentile_handles_empty() {
633 let sorted: Vec<u64> = vec![];
634 assert_eq!(percentile(&sorted, 50), 0);
635 }
636
637 #[test]
638 fn classify_host_recognises_loopback_lan_and_remote() {
639 assert_eq!(classify_host("localhost"), "loopback");
640 assert_eq!(classify_host("127.0.0.1"), "loopback");
641 assert_eq!(classify_host("192.168.1.169"), "lan");
642 assert_eq!(classify_host("10.0.0.5"), "lan");
643 assert_eq!(classify_host("172.16.5.1"), "lan");
644 assert_eq!(classify_host("172.32.5.1"), "remote");
645 assert_eq!(classify_host("api.example.com"), "remote");
646 assert_eq!(classify_host("mymac.local"), "lan");
647 }
648
649 #[test]
650 fn model_parses_from_string() {
651 assert!(matches!(
652 ExplainModel::from_str("haiku"),
653 Ok(ExplainModel::Haiku)
654 ));
655 assert!(matches!(
656 ExplainModel::from_str("Sonnet"),
657 Ok(ExplainModel::Sonnet)
658 ));
659 assert!(ExplainModel::from_str("gpt-5").is_err());
660 }
661}