use serde::Serialize;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TelemetryLevel {
#[default]
Off,
Basic,
Full,
}
impl TelemetryLevel {
pub fn from_str_key(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"basic" | "on" | "1" | "true" => Self::Basic,
"full" => Self::Full,
_ => Self::Off,
}
}
pub fn enabled(&self) -> bool {
!matches!(self, Self::Off)
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Off => "off",
Self::Basic => "basic",
Self::Full => "full",
}
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct UsageRecord {
pub input: u64,
pub output: u64,
pub cache_read: u64,
pub cache_write: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_write_5m: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_write_1h: Option<u64>,
pub hit_pct: f64,
}
impl UsageRecord {
pub fn compute_hit_pct(&mut self) {
let total = self.input + self.cache_read + self.cache_write;
self.hit_pct = if total > 0 {
(self.cache_read as f64 / total as f64 * 1000.0).round() / 10.0
} else {
0.0
};
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct RateLimitRecord {
#[serde(skip_serializing_if = "Option::is_none")]
pub requests_limit: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub requests_remaining: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens_limit: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens_remaining: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_tokens_remaining: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_tokens_remaining: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens_reset: Option<String>,
}
impl RateLimitRecord {
pub fn is_empty(&self) -> bool {
self.requests_limit.is_none()
&& self.requests_remaining.is_none()
&& self.tokens_limit.is_none()
&& self.tokens_remaining.is_none()
&& self.input_tokens_remaining.is_none()
&& self.output_tokens_remaining.is_none()
&& self.tokens_reset.is_none()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CacheDiagRecord {
pub miss_reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub missed_tokens: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ContextRecord {
pub messages: usize,
pub tools: usize,
pub system_bytes: usize,
pub breakpoints: Vec<usize>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct TelemetryRecord {
pub ts: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub msg_id: Option<String>,
pub model: String,
pub attempt: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttft_ms: Option<u64>,
pub total_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<String>,
pub usage: UsageRecord,
#[serde(skip_serializing_if = "Option::is_none")]
pub ratelimit: Option<RateLimitRecord>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_diag: Option<CacheDiagRecord>,
pub context: ContextRecord,
}
impl TelemetryRecord {
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
}
fn header_u64(headers: &reqwest::header::HeaderMap, name: &str) -> Option<u64> {
headers.get(name)?.to_str().ok()?.parse().ok()
}
fn header_string(headers: &reqwest::header::HeaderMap, name: &str) -> Option<String> {
Some(headers.get(name)?.to_str().ok()?.to_string())
}
pub fn ratelimit_from_headers(headers: &reqwest::header::HeaderMap) -> RateLimitRecord {
RateLimitRecord {
requests_limit: header_u64(headers, "anthropic-ratelimit-requests-limit"),
requests_remaining: header_u64(headers, "anthropic-ratelimit-requests-remaining"),
tokens_limit: header_u64(headers, "anthropic-ratelimit-tokens-limit"),
tokens_remaining: header_u64(headers, "anthropic-ratelimit-tokens-remaining"),
input_tokens_remaining: header_u64(headers, "anthropic-ratelimit-input-tokens-remaining"),
output_tokens_remaining: header_u64(headers, "anthropic-ratelimit-output-tokens-remaining"),
tokens_reset: header_string(headers, "anthropic-ratelimit-tokens-reset"),
}
}
pub fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<String> {
header_string(headers, "request-id")
}
pub const RETRY_DELAY_CAP: Duration = Duration::from_secs(60);
pub fn retry_delay_from_headers(
headers: &reqwest::header::HeaderMap,
attempt: u32,
) -> (Duration, bool) {
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if let Some(ra) = header_string(headers, "retry-after") {
let ra = ra.trim();
if let Ok(secs) = ra.parse::<u64>() {
let d = Duration::from_secs(secs).min(RETRY_DELAY_CAP);
return (d, true);
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(ra) {
let reset_secs = dt.timestamp().max(0) as u64;
let wait = reset_secs.saturating_sub(now_secs);
let d = Duration::from_secs(wait).min(RETRY_DELAY_CAP);
return (d, true);
}
}
let mut min_wait: Option<u64> = None;
for name in &[
"anthropic-ratelimit-tokens-reset",
"anthropic-ratelimit-requests-reset",
] {
if let Some(ts) = header_string(headers, name) {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts.trim()) {
let reset_secs = dt.timestamp().max(0) as u64;
let wait = reset_secs.saturating_sub(now_secs);
min_wait = Some(min_wait.map_or(wait, |prev| prev.min(wait)));
}
}
}
if let Some(wait) = min_wait {
let d = Duration::from_secs(wait).min(RETRY_DELAY_CAP);
return (d, true);
}
let d = Duration::from_millis(1000 * 2u64.pow(attempt.saturating_sub(1)));
(d, false)
}
fn default_log_path() -> Option<std::path::PathBuf> {
let home = std::env::var("HOME").ok()?;
Some(std::path::PathBuf::from(home).join(".cache/synaps/api-log.jsonl"))
}
pub fn write_record(record: &TelemetryRecord) {
let Some(path) = default_log_path() else { return };
let Ok(line) = serde_json::to_string(record) else { return };
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
const MAX_BYTES: u64 = 50 * 1024 * 1024;
if let Ok(meta) = std::fs::metadata(&path) {
if meta.len() > MAX_BYTES {
let mut rotated = path.as_os_str().to_owned();
rotated.push(".1");
let _ = std::fs::rename(&path, std::path::PathBuf::from(rotated));
}
}
use std::os::unix::fs::OpenOptionsExt;
let result = std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(&path);
if let Ok(mut f) = result {
use std::io::Write;
let _ = writeln!(f, "{}", line);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn level_parses_known_values() {
assert_eq!(TelemetryLevel::from_str_key("off"), TelemetryLevel::Off);
assert_eq!(TelemetryLevel::from_str_key("basic"), TelemetryLevel::Basic);
assert_eq!(TelemetryLevel::from_str_key("full"), TelemetryLevel::Full);
assert_eq!(TelemetryLevel::from_str_key("FULL"), TelemetryLevel::Full);
assert_eq!(TelemetryLevel::from_str_key("true"), TelemetryLevel::Basic);
assert_eq!(TelemetryLevel::from_str_key("garbage"), TelemetryLevel::Off);
assert_eq!(TelemetryLevel::from_str_key(""), TelemetryLevel::Off);
}
#[test]
fn level_enabled() {
assert!(!TelemetryLevel::Off.enabled());
assert!(TelemetryLevel::Basic.enabled());
assert!(TelemetryLevel::Full.enabled());
}
#[test]
fn hit_pct_computation() {
let mut u = UsageRecord {
input: 100,
cache_read: 800,
cache_write: 100,
..Default::default()
};
u.compute_hit_pct();
assert_eq!(u.hit_pct, 80.0);
}
#[test]
fn hit_pct_zero_total() {
let mut u = UsageRecord::default();
u.compute_hit_pct();
assert_eq!(u.hit_pct, 0.0);
}
#[test]
fn hit_pct_rounds_to_one_decimal() {
let mut u = UsageRecord {
input: 1,
cache_read: 2,
cache_write: 0,
..Default::default()
};
u.compute_hit_pct();
assert_eq!(u.hit_pct, 66.7);
}
#[test]
fn record_serializes_skipping_none_fields() {
let record = TelemetryRecord {
ts: 1,
model: "claude-sonnet-4-6".to_string(),
attempt: 1,
total_ms: 100,
..Default::default()
};
let json = serde_json::to_string(&record).unwrap();
assert!(!json.contains("request_id"));
assert!(!json.contains("ratelimit"));
assert!(!json.contains("cache_diag"));
assert!(json.contains("\"model\":\"claude-sonnet-4-6\""));
}
#[test]
fn ratelimit_empty_detection() {
assert!(RateLimitRecord::default().is_empty());
let r = RateLimitRecord {
requests_remaining: Some(10),
..Default::default()
};
assert!(!r.is_empty());
}
#[test]
fn ratelimit_parses_headers() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("anthropic-ratelimit-requests-limit", "5000".parse().unwrap());
headers.insert("anthropic-ratelimit-requests-remaining", "4900".parse().unwrap());
headers.insert("anthropic-ratelimit-tokens-reset", "2026-06-11T01:46:00Z".parse().unwrap());
let r = ratelimit_from_headers(&headers);
assert_eq!(r.requests_limit, Some(5000));
assert_eq!(r.requests_remaining, Some(4900));
assert_eq!(r.tokens_reset.as_deref(), Some("2026-06-11T01:46:00Z"));
assert_eq!(r.tokens_limit, None);
}
#[test]
fn ratelimit_ignores_malformed_values() {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert("anthropic-ratelimit-requests-limit", "not-a-number".parse().unwrap());
let r = ratelimit_from_headers(&headers);
assert_eq!(r.requests_limit, None);
}
}
#[cfg(test)]
mod retry_delay_tests {
use super::*;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[test]
fn integer_retry_after() {
let mut h = reqwest::header::HeaderMap::new();
h.insert("retry-after", "45".parse().unwrap());
let (d, from_hdr) = retry_delay_from_headers(&h, 1);
assert_eq!(d, Duration::from_secs(45));
assert!(from_hdr);
}
#[test]
fn integer_retry_after_capped() {
let mut h = reqwest::header::HeaderMap::new();
h.insert("retry-after", "300".parse().unwrap()); let (d, from_hdr) = retry_delay_from_headers(&h, 1);
assert_eq!(d, RETRY_DELAY_CAP);
assert!(from_hdr);
}
#[test]
fn rfc3339_reset_future() {
let future_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 30;
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(future_secs as i64, 0).unwrap();
let ts = dt.to_rfc3339();
let mut h = reqwest::header::HeaderMap::new();
h.insert("anthropic-ratelimit-tokens-reset", ts.parse().unwrap());
let (d, from_hdr) = retry_delay_from_headers(&h, 1);
assert!(d.as_secs() >= 28 && d.as_secs() <= 32, "unexpected delay: {:?}", d);
assert!(from_hdr);
}
#[test]
fn rfc3339_reset_beyond_cap() {
let future_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 600;
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(future_secs as i64, 0).unwrap();
let ts = dt.to_rfc3339();
let mut h = reqwest::header::HeaderMap::new();
h.insert("anthropic-ratelimit-tokens-reset", ts.parse().unwrap());
let (d, from_hdr) = retry_delay_from_headers(&h, 1);
assert_eq!(d, RETRY_DELAY_CAP);
assert!(from_hdr);
}
#[test]
fn no_headers_exponential_fallback() {
let h = reqwest::header::HeaderMap::new();
let (d1, hdr1) = retry_delay_from_headers(&h, 1);
let (d2, hdr2) = retry_delay_from_headers(&h, 2);
let (d3, hdr3) = retry_delay_from_headers(&h, 3);
assert_eq!(d1, Duration::from_secs(1));
assert_eq!(d2, Duration::from_secs(2));
assert_eq!(d3, Duration::from_secs(4));
assert!(!hdr1 && !hdr2 && !hdr3);
}
#[test]
fn prefers_retry_after_over_rfc3339() {
let future_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 30;
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(future_secs as i64, 0).unwrap();
let ts = dt.to_rfc3339();
let mut h = reqwest::header::HeaderMap::new();
h.insert("retry-after", "10".parse().unwrap());
h.insert("anthropic-ratelimit-tokens-reset", ts.parse().unwrap());
let (d, from_hdr) = retry_delay_from_headers(&h, 1);
assert_eq!(d, Duration::from_secs(10));
assert!(from_hdr);
}
#[test]
fn min_of_multiple_ratelimit_reset_headers() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let tokens_dt = chrono::DateTime::<chrono::Utc>::from_timestamp((now + 15) as i64, 0).unwrap();
let requests_dt = chrono::DateTime::<chrono::Utc>::from_timestamp((now + 45) as i64, 0).unwrap();
let mut h = reqwest::header::HeaderMap::new();
h.insert("anthropic-ratelimit-tokens-reset", tokens_dt.to_rfc3339().parse().unwrap());
h.insert("anthropic-ratelimit-requests-reset", requests_dt.to_rfc3339().parse().unwrap());
let (d, from_hdr) = retry_delay_from_headers(&h, 1);
assert!(d.as_secs() >= 13 && d.as_secs() <= 17, "should be ~15s, got {:?}", d);
assert!(from_hdr);
}
}