use std::collections::BTreeMap;
use std::path::Path;
use std::str::FromStr;
use std::time::Duration;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::engine::capture::{CaptureSessionExport, CapturedExchange};
use crate::error::{Result, TloxError};
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
const ANTHROPIC_VERSION: &str = "2023-06-01";
const DEFAULT_MAX_TOKENS: u32 = 4096;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExplainModel {
Haiku,
Sonnet,
Opus,
}
impl ExplainModel {
pub fn as_api_id(self) -> &'static str {
match self {
ExplainModel::Haiku => "claude-haiku-4-5-20251001",
ExplainModel::Sonnet => "claude-sonnet-4-6",
ExplainModel::Opus => "claude-opus-4-7",
}
}
pub fn display_name(self) -> &'static str {
match self {
ExplainModel::Haiku => "Haiku 4.5",
ExplainModel::Sonnet => "Sonnet 4.6",
ExplainModel::Opus => "Opus 4.7",
}
}
}
impl FromStr for ExplainModel {
type Err = String;
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"haiku" => Ok(ExplainModel::Haiku),
"sonnet" => Ok(ExplainModel::Sonnet),
"opus" => Ok(ExplainModel::Opus),
other => Err(format!(
"unknown model '{other}' — expected haiku, sonnet, or opus"
)),
}
}
}
#[derive(Debug, Clone)]
pub struct ExplainOptions {
pub model: ExplainModel,
pub max_samples: usize,
}
impl Default for ExplainOptions {
fn default() -> Self {
Self {
model: ExplainModel::Haiku,
max_samples: 15,
}
}
}
pub fn load_session(path: &Path) -> Result<CaptureSessionExport> {
let text = std::fs::read_to_string(path)?;
let session: CaptureSessionExport = serde_json::from_str(&text)
.map_err(|error| TloxError::Io(std::io::Error::other(error)))?;
Ok(session)
}
pub fn read_api_key() -> Result<String> {
match std::env::var("ANTHROPIC_API_KEY") {
Ok(value) if !value.trim().is_empty() => Ok(value),
_ => Err(TloxError::MissingApiKey),
}
}
pub fn estimate_input_tokens(system: &str, user: &str) -> usize {
(system.len() + user.len()) / 4
}
pub async fn explain_session(
session_path: &Path,
options: ExplainOptions,
) -> Result<ExplainResult> {
let api_key = read_api_key()?;
let session = load_session(session_path)?;
let summary = summarize_session(&session, &options);
let user_payload = serde_json::to_string_pretty(&summary)
.map_err(|error| TloxError::Io(std::io::Error::other(error)))?;
let system_prompt = system_prompt();
let estimated_input = estimate_input_tokens(&system_prompt, &user_payload);
let markdown = call_anthropic(&api_key, options.model, &system_prompt, &user_payload).await?;
Ok(ExplainResult {
markdown,
model: options.model,
summary_stats: summary.session_meta,
estimated_input_tokens: estimated_input,
})
}
#[derive(Debug, Clone)]
pub struct ExplainResult {
pub markdown: String,
pub model: ExplainModel,
pub summary_stats: SessionMeta,
pub estimated_input_tokens: usize,
}
fn system_prompt() -> String {
r#"You are a senior performance engineer analyzing an HTTP capture produced by the `lobe` tool.
The 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:
1. **Errors** — non-2xx responses and failed requests. Are they scoped to specific endpoints or global?
2. **Saturation** — signs the upstream is overloaded: high tail latencies (P99 much greater than P50), long TTFB values, download-phase dominance, connection reuse patterns.
3. **Utilization** — which endpoints consume the most cumulative wall-clock time across the session?
**Baseline thresholds** (rule of thumb — anything meaningfully over these is worth investigating):
- **Loopback** (localhost, 127.x): TTFB ≤ 50ms, total ≤ 80ms
- **LAN** (RFC1918 or *.local): TTFB ≤ 100ms, total ≤ 200ms
- **Remote** (public internet): TTFB ≤ 500ms, total ≤ 1.5s
**Phase interpretation cheatsheet:**
- DNS excess → resolver problem, cold DNS, custom /etc/hosts
- TCP excess → saturated network path, upstream overloaded
- TLS excess → old TLS version, deep certificate chain, slow ALPN
- 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)
- Download excess → large response body relative to bandwidth, or upstream streaming slowly
**Return format** — markdown, with these sections in this exact order:
## Summary
2-3 sentences of the headline findings. Lead with the number that matters most.
## Errors
Top error patterns. If zero errors, write "No errors observed." on a single line.
## Utilization
Which endpoints are burning the most cumulative time? Give specific method + path + numbers.
## Saturation
Tail behavior (P99 >> P50), TTFB dominance, bimodal patterns, phase excess. Cite specific numbers from the payload.
## Recommendations
Exactly 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".
Be concrete. Cite specific endpoints and numbers. If evidence is thin, say so — do not fabricate causes."#
.to_string()
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionSummary {
pub session_meta: SessionMeta,
pub errors: ErrorsBlock,
pub routes: Vec<RouteAggregate>,
pub top_slowest_samples: Vec<SampleRequest>,
pub top_error_samples: Vec<SampleRequest>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionMeta {
pub upstream: String,
pub listen_addr: String,
pub exported_at_ms: i64,
pub total_requests: usize,
pub error_requests: usize,
pub error_rate_percent: f64,
pub duration_seconds: f64,
pub distinct_routes: usize,
pub distinct_hosts: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct ErrorsBlock {
pub total_errors: usize,
pub top_status_codes: Vec<StatusCodeCount>,
}
#[derive(Debug, Clone, Serialize)]
pub struct StatusCodeCount {
pub status_code: u16,
pub count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct RouteAggregate {
pub method: String,
pub path: String,
pub host: String,
pub baseline_category: &'static str,
pub count: usize,
pub errors: usize,
pub total_ms_p50: u64,
pub total_ms_p90: u64,
pub total_ms_p99: u64,
pub total_ms_max: u64,
pub cumulative_ms: u64,
pub phase_median_ms: PhaseMedians,
}
#[derive(Debug, Clone, Serialize)]
pub struct PhaseMedians {
pub dns: u64,
pub tcp: u64,
pub tls: u64,
pub ttfb: u64,
pub download: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct SampleRequest {
pub method: String,
pub path: String,
pub host: String,
pub status: Option<u16>,
pub total_ms: u64,
pub ttfb_ms: u64,
pub download_ms: u64,
pub error: Option<String>,
}
pub fn summarize_session(session: &CaptureSessionExport, options: &ExplainOptions) -> SessionSummary {
let events = &session.events;
let mut groups: BTreeMap<(String, String, String), Vec<&CapturedExchange>> = BTreeMap::new();
let mut status_counts: BTreeMap<u16, usize> = BTreeMap::new();
let mut error_count = 0usize;
let mut hosts: BTreeMap<String, ()> = BTreeMap::new();
let mut earliest_ms = i64::MAX;
let mut latest_ms = i64::MIN;
for event in events {
let key = (
event.request_method.clone(),
event.request_path.clone(),
event.request_host.clone(),
);
groups.entry(key).or_default().push(event);
hosts.insert(event.request_host.clone(), ());
earliest_ms = earliest_ms.min(event.created_at_ms);
latest_ms = latest_ms.max(event.created_at_ms);
match event.response_status_code {
Some(code) if !(200..300).contains(&code) => {
*status_counts.entry(code).or_insert(0) += 1;
error_count += 1;
}
None => {
error_count += 1;
}
_ => {}
}
}
let duration_seconds = if earliest_ms == i64::MAX || latest_ms == i64::MIN {
0.0
} else {
((latest_ms - earliest_ms).max(0) as f64) / 1000.0
};
let mut top_status_codes: Vec<StatusCodeCount> = status_counts
.into_iter()
.map(|(status_code, count)| StatusCodeCount { status_code, count })
.collect();
top_status_codes.sort_by(|a, b| b.count.cmp(&a.count));
top_status_codes.truncate(6);
let mut routes: Vec<RouteAggregate> = groups
.iter()
.map(|((method, path, host), items)| aggregate_route(method, path, host, items))
.collect();
routes.sort_by(|a, b| b.cumulative_ms.cmp(&a.cumulative_ms));
routes.truncate(20);
let top_slowest_samples = pick_samples(events, options.max_samples, SampleKind::Slowest);
let top_error_samples = pick_samples(events, options.max_samples, SampleKind::Errors);
SessionSummary {
session_meta: SessionMeta {
upstream: session.upstream.clone(),
listen_addr: session.listen_addr.clone(),
exported_at_ms: session.exported_at_ms,
total_requests: events.len(),
error_requests: error_count,
error_rate_percent: if events.is_empty() {
0.0
} else {
(error_count as f64 / events.len() as f64) * 100.0
},
duration_seconds,
distinct_routes: groups.len(),
distinct_hosts: hosts.len(),
},
errors: ErrorsBlock {
total_errors: error_count,
top_status_codes,
},
routes,
top_slowest_samples,
top_error_samples,
}
}
fn aggregate_route(
method: &str,
path: &str,
host: &str,
items: &[&CapturedExchange],
) -> RouteAggregate {
let mut totals: Vec<u64> = items.iter().map(|item| item.report.total_ms).collect();
let mut dns: Vec<u64> = items.iter().map(|item| item.report.dns_ms).collect();
let mut tcp: Vec<u64> = items.iter().map(|item| item.report.tcp_ms).collect();
let mut tls: Vec<u64> = items.iter().map(|item| item.report.tls_ms).collect();
let mut ttfb: Vec<u64> = items.iter().map(|item| item.report.ttfb_ms).collect();
let mut download: Vec<u64> = items.iter().map(|item| item.report.download_ms).collect();
totals.sort_unstable();
dns.sort_unstable();
tcp.sort_unstable();
tls.sort_unstable();
ttfb.sort_unstable();
download.sort_unstable();
let cumulative_ms = totals.iter().sum();
let errors = items
.iter()
.filter(|item| match item.response_status_code {
Some(code) => !(200..300).contains(&code),
None => true,
})
.count();
RouteAggregate {
method: method.to_string(),
path: path.to_string(),
host: host.to_string(),
baseline_category: classify_host(host),
count: items.len(),
errors,
total_ms_p50: percentile(&totals, 50),
total_ms_p90: percentile(&totals, 90),
total_ms_p99: percentile(&totals, 99),
total_ms_max: *totals.last().unwrap_or(&0),
cumulative_ms,
phase_median_ms: PhaseMedians {
dns: percentile(&dns, 50),
tcp: percentile(&tcp, 50),
tls: percentile(&tls, 50),
ttfb: percentile(&ttfb, 50),
download: percentile(&download, 50),
},
}
}
fn percentile(sorted: &[u64], p: u8) -> u64 {
if sorted.is_empty() {
return 0;
}
let rank = ((p as usize * sorted.len()).div_ceil(100)).max(1);
sorted[rank.min(sorted.len()) - 1]
}
fn classify_host(host: &str) -> &'static str {
if host == "localhost" || host == "::1" {
return "loopback";
}
if let Some((a, b)) = ipv4_prefix(host) {
if a == 127 {
return "loopback";
}
if a == 10 {
return "lan";
}
if a == 192 && b == 168 {
return "lan";
}
if a == 172 && (16..=31).contains(&b) {
return "lan";
}
return "remote";
}
if host.ends_with(".local") {
return "lan";
}
"remote"
}
fn ipv4_prefix(host: &str) -> Option<(u8, u8)> {
let mut parts = host.split('.');
let a = parts.next()?.parse::<u8>().ok()?;
let b = parts.next()?.parse::<u8>().ok()?;
parts.next()?.parse::<u8>().ok()?;
parts.next()?.parse::<u8>().ok()?;
if parts.next().is_some() {
return None;
}
Some((a, b))
}
enum SampleKind {
Slowest,
Errors,
}
fn pick_samples(events: &[CapturedExchange], limit: usize, kind: SampleKind) -> Vec<SampleRequest> {
let mut filtered: Vec<&CapturedExchange> = match kind {
SampleKind::Slowest => events.iter().collect(),
SampleKind::Errors => events
.iter()
.filter(|item| match item.response_status_code {
Some(code) => !(200..300).contains(&code),
None => true,
})
.collect(),
};
match kind {
SampleKind::Slowest => {
filtered.sort_by(|a, b| b.report.total_ms.cmp(&a.report.total_ms));
}
SampleKind::Errors => {
filtered.sort_by(|a, b| b.report.total_ms.cmp(&a.report.total_ms));
}
}
filtered.truncate(limit);
filtered.into_iter().map(sample_from_exchange).collect()
}
fn sample_from_exchange(event: &CapturedExchange) -> SampleRequest {
SampleRequest {
method: event.request_method.clone(),
path: event.request_path.clone(),
host: event.request_host.clone(),
status: event.response_status_code,
total_ms: event.report.total_ms,
ttfb_ms: event.report.ttfb_ms,
download_ms: event.report.download_ms,
error: event.error_message.clone(),
}
}
#[derive(Debug, Serialize)]
struct AnthropicRequest<'a> {
model: &'a str,
max_tokens: u32,
system: &'a str,
messages: Vec<AnthropicMessage<'a>>,
}
#[derive(Debug, Serialize)]
struct AnthropicMessage<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Debug, Deserialize)]
struct AnthropicResponse {
content: Vec<AnthropicContentBlock>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
enum AnthropicContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(other)]
Other,
}
#[derive(Debug, Deserialize)]
struct AnthropicErrorEnvelope {
error: AnthropicErrorPayload,
}
#[derive(Debug, Deserialize)]
struct AnthropicErrorPayload {
#[serde(rename = "type")]
kind: String,
message: String,
}
async fn call_anthropic(
api_key: &str,
model: ExplainModel,
system_prompt: &str,
user_payload: &str,
) -> Result<String> {
let request = AnthropicRequest {
model: model.as_api_id(),
max_tokens: DEFAULT_MAX_TOKENS,
system: system_prompt,
messages: vec![AnthropicMessage {
role: "user",
content: user_payload,
}],
};
let body = serde_json::to_string(&request)
.map_err(|error| TloxError::Io(std::io::Error::other(error)))?;
let client = Client::builder()
.timeout(Duration::from_secs(120))
.build()
.map_err(|error| TloxError::Io(std::io::Error::other(error)))?;
let response = client
.post(ANTHROPIC_API_URL)
.header("x-api-key", api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.header("content-type", "application/json")
.body(body)
.send()
.await
.map_err(|error| TloxError::Io(std::io::Error::other(error)))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|error| TloxError::Io(std::io::Error::other(error)))?;
if !status.is_success() {
return Err(TloxError::AnthropicApi(format_api_error(status.as_u16(), &text)));
}
let parsed: AnthropicResponse = serde_json::from_str(&text).map_err(|error| {
TloxError::AnthropicApi(format!(
"unable to parse Anthropic response: {error}. Raw body: {}",
truncate(&text, 500)
))
})?;
let joined = parsed
.content
.into_iter()
.filter_map(|block| match block {
AnthropicContentBlock::Text { text } => Some(text),
AnthropicContentBlock::Other => None,
})
.collect::<Vec<_>>()
.join("\n\n");
if joined.trim().is_empty() {
return Err(TloxError::AnthropicApi(
"Anthropic returned an empty response".to_string(),
));
}
Ok(joined)
}
fn format_api_error(status: u16, body: &str) -> String {
if let Ok(envelope) = serde_json::from_str::<AnthropicErrorEnvelope>(body) {
return format!(
"Anthropic API error {status} ({}): {}",
envelope.error.kind, envelope.error.message
);
}
format!("Anthropic API error {status}: {}", truncate(body, 500))
}
fn truncate(text: &str, limit: usize) -> String {
if text.len() <= limit {
text.to_string()
} else {
format!("{}…(truncated)", &text[..limit])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percentile_uses_nearest_rank() {
let sorted = vec![1, 2, 3, 4, 5];
assert_eq!(percentile(&sorted, 50), 3);
assert_eq!(percentile(&sorted, 99), 5);
}
#[test]
fn percentile_handles_empty() {
let sorted: Vec<u64> = vec![];
assert_eq!(percentile(&sorted, 50), 0);
}
#[test]
fn classify_host_recognises_loopback_lan_and_remote() {
assert_eq!(classify_host("localhost"), "loopback");
assert_eq!(classify_host("127.0.0.1"), "loopback");
assert_eq!(classify_host("192.168.1.169"), "lan");
assert_eq!(classify_host("10.0.0.5"), "lan");
assert_eq!(classify_host("172.16.5.1"), "lan");
assert_eq!(classify_host("172.32.5.1"), "remote");
assert_eq!(classify_host("api.example.com"), "remote");
assert_eq!(classify_host("mymac.local"), "lan");
}
#[test]
fn model_parses_from_string() {
assert!(matches!(
ExplainModel::from_str("haiku"),
Ok(ExplainModel::Haiku)
));
assert!(matches!(
ExplainModel::from_str("Sonnet"),
Ok(ExplainModel::Sonnet)
));
assert!(ExplainModel::from_str("gpt-5").is_err());
}
}