Skip to main content

lobe_core/
explain.rs

1//! `lobe explain` — AI-driven analysis of a captured session.
2//!
3//! Reads a `CaptureSessionExport` JSON file, preprocesses it into a compact summary,
4//! and calls the Anthropic Messages API with a USE-method prompt. Returns the
5//! rendered markdown report as a String.
6//!
7//! Key setup: reads `ANTHROPIC_API_KEY` from the process environment. If unset,
8//! `explain_session` returns a `TloxError::MissingApiKey` error which the CLI
9//! layer converts to a friendly onboarding message.
10
11use 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/// Which Claude model powers the analysis. Haiku is the default because the
27/// task is structured USE-method application over a preprocessed payload, not
28/// open-ended reasoning — Haiku handles it well at a fraction of the cost.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ExplainModel {
31    Haiku,
32    Sonnet,
33    Opus,
34}
35
36impl ExplainModel {
37    /// The exact API model identifier sent in the request body.
38    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    /// Cap on how many raw sample requests we ship to the model. Aggregates are
73    /// always included; this only controls the "example requests" section.
74    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
86/// Load a session export from disk. Errors carry the underlying I/O or parse
87/// failure so the CLI can print an actionable message.
88pub 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
95/// Read the API key from environment. Empty string counts as missing.
96pub 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
103/// Estimate input tokens for cost warning. Very rough — 4 chars per token.
104pub fn estimate_input_tokens(system: &str, user: &str) -> usize {
105    (system.len() + user.len()) / 4
106}
107
108/// Top-level entry point. Reads env, loads session, builds prompt, calls
109/// Anthropic, returns the markdown report.
110pub 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
142// ---------------------------------------------------------------------------
143// Prompt building
144// ---------------------------------------------------------------------------
145
146fn 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// ---------------------------------------------------------------------------
189// Session summarization — the compact JSON we ship to the model
190// ---------------------------------------------------------------------------
191
192#[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    // Group by (method, path, host) — a "route" key.
267    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    // Rank routes by cumulative wall-clock time (utilization).
318    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
401/// Nearest-rank percentile on a *sorted* slice.
402fn 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// ---------------------------------------------------------------------------
489// Anthropic API call
490// ---------------------------------------------------------------------------
491
492#[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}