1use serde::Serialize;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum TelemetryLevel {
19 #[default]
20 Off,
21 Basic,
22 Full,
23}
24
25impl TelemetryLevel {
26 pub fn from_str_key(s: &str) -> Self {
27 match s.trim().to_ascii_lowercase().as_str() {
28 "basic" | "on" | "1" | "true" => Self::Basic,
29 "full" => Self::Full,
30 _ => Self::Off,
31 }
32 }
33
34 pub fn enabled(&self) -> bool {
35 !matches!(self, Self::Off)
36 }
37
38 pub fn as_str(&self) -> &'static str {
39 match self {
40 Self::Off => "off",
41 Self::Basic => "basic",
42 Self::Full => "full",
43 }
44 }
45}
46
47#[derive(Debug, Clone, Default, Serialize)]
49pub struct UsageRecord {
50 pub input: u64,
51 pub output: u64,
52 pub cache_read: u64,
53 pub cache_write: u64,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub cache_write_5m: Option<u64>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub cache_write_1h: Option<u64>,
60 pub hit_pct: f64,
62}
63
64impl UsageRecord {
65 pub fn compute_hit_pct(&mut self) {
66 let total = self.input + self.cache_read + self.cache_write;
67 self.hit_pct = if total > 0 {
68 (self.cache_read as f64 / total as f64 * 1000.0).round() / 10.0
69 } else {
70 0.0
71 };
72 }
73}
74
75#[derive(Debug, Clone, Default, Serialize)]
78pub struct RateLimitRecord {
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub requests_limit: Option<u64>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub requests_remaining: Option<u64>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub tokens_limit: Option<u64>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub tokens_remaining: Option<u64>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub input_tokens_remaining: Option<u64>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub output_tokens_remaining: Option<u64>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub tokens_reset: Option<String>,
94}
95
96impl RateLimitRecord {
97 pub fn is_empty(&self) -> bool {
98 self.requests_limit.is_none()
99 && self.requests_remaining.is_none()
100 && self.tokens_limit.is_none()
101 && self.tokens_remaining.is_none()
102 && self.input_tokens_remaining.is_none()
103 && self.output_tokens_remaining.is_none()
104 && self.tokens_reset.is_none()
105 }
106}
107
108#[derive(Debug, Clone, Serialize)]
111pub struct CacheDiagRecord {
112 pub miss_reason: String,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub missed_tokens: Option<u64>,
117}
118
119#[derive(Debug, Clone, Default, Serialize)]
121pub struct ContextRecord {
122 pub messages: usize,
123 pub tools: usize,
124 pub system_bytes: usize,
125 pub breakpoints: Vec<usize>,
127}
128
129#[derive(Debug, Clone, Default, Serialize)]
131pub struct TelemetryRecord {
132 pub ts: u64,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub request_id: Option<String>,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub msg_id: Option<String>,
139 pub model: String,
140 pub attempt: u32,
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub ttft_ms: Option<u64>,
145 pub total_ms: u64,
147 #[serde(skip_serializing_if = "Option::is_none")]
148 pub stop_reason: Option<String>,
149 pub usage: UsageRecord,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub ratelimit: Option<RateLimitRecord>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub cache_diag: Option<CacheDiagRecord>,
154 pub context: ContextRecord,
155}
156
157impl TelemetryRecord {
158 pub fn now_ms() -> u64 {
159 SystemTime::now()
160 .duration_since(UNIX_EPOCH)
161 .map(|d| d.as_millis() as u64)
162 .unwrap_or(0)
163 }
164}
165
166fn header_u64(headers: &reqwest::header::HeaderMap, name: &str) -> Option<u64> {
168 headers.get(name)?.to_str().ok()?.parse().ok()
169}
170
171fn header_string(headers: &reqwest::header::HeaderMap, name: &str) -> Option<String> {
173 Some(headers.get(name)?.to_str().ok()?.to_string())
174}
175
176pub fn ratelimit_from_headers(headers: &reqwest::header::HeaderMap) -> RateLimitRecord {
178 RateLimitRecord {
179 requests_limit: header_u64(headers, "anthropic-ratelimit-requests-limit"),
180 requests_remaining: header_u64(headers, "anthropic-ratelimit-requests-remaining"),
181 tokens_limit: header_u64(headers, "anthropic-ratelimit-tokens-limit"),
182 tokens_remaining: header_u64(headers, "anthropic-ratelimit-tokens-remaining"),
183 input_tokens_remaining: header_u64(headers, "anthropic-ratelimit-input-tokens-remaining"),
184 output_tokens_remaining: header_u64(headers, "anthropic-ratelimit-output-tokens-remaining"),
185 tokens_reset: header_string(headers, "anthropic-ratelimit-tokens-reset"),
186 }
187}
188
189pub fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<String> {
191 header_string(headers, "request-id")
192}
193
194fn default_log_path() -> Option<std::path::PathBuf> {
196 let home = std::env::var("HOME").ok()?;
197 Some(std::path::PathBuf::from(home).join(".cache/synaps/api-log.jsonl"))
198}
199
200pub fn write_record(record: &TelemetryRecord) {
206 let Some(path) = default_log_path() else { return };
207 let Ok(line) = serde_json::to_string(record) else { return };
208
209 if let Some(parent) = path.parent() {
210 let _ = std::fs::create_dir_all(parent);
211 }
212
213 use std::os::unix::fs::OpenOptionsExt;
214 #[cfg(target_os = "linux")]
215 const O_NOFOLLOW_FLAG: i32 = 0o400000;
216 #[cfg(target_os = "macos")]
217 const O_NOFOLLOW_FLAG: i32 = 0x0100;
218 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
219 const O_NOFOLLOW_FLAG: i32 = 0;
220
221 let result = std::fs::OpenOptions::new()
222 .create(true)
223 .append(true)
224 .mode(0o600)
225 .custom_flags(O_NOFOLLOW_FLAG)
226 .open(&path);
227 if let Ok(mut f) = result {
228 use std::io::Write;
229 let _ = writeln!(f, "{}", line);
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn level_parses_known_values() {
239 assert_eq!(TelemetryLevel::from_str_key("off"), TelemetryLevel::Off);
240 assert_eq!(TelemetryLevel::from_str_key("basic"), TelemetryLevel::Basic);
241 assert_eq!(TelemetryLevel::from_str_key("full"), TelemetryLevel::Full);
242 assert_eq!(TelemetryLevel::from_str_key("FULL"), TelemetryLevel::Full);
243 assert_eq!(TelemetryLevel::from_str_key("true"), TelemetryLevel::Basic);
244 assert_eq!(TelemetryLevel::from_str_key("garbage"), TelemetryLevel::Off);
245 assert_eq!(TelemetryLevel::from_str_key(""), TelemetryLevel::Off);
246 }
247
248 #[test]
249 fn level_enabled() {
250 assert!(!TelemetryLevel::Off.enabled());
251 assert!(TelemetryLevel::Basic.enabled());
252 assert!(TelemetryLevel::Full.enabled());
253 }
254
255 #[test]
256 fn hit_pct_computation() {
257 let mut u = UsageRecord {
258 input: 100,
259 cache_read: 800,
260 cache_write: 100,
261 ..Default::default()
262 };
263 u.compute_hit_pct();
264 assert_eq!(u.hit_pct, 80.0);
265 }
266
267 #[test]
268 fn hit_pct_zero_total() {
269 let mut u = UsageRecord::default();
270 u.compute_hit_pct();
271 assert_eq!(u.hit_pct, 0.0);
272 }
273
274 #[test]
275 fn hit_pct_rounds_to_one_decimal() {
276 let mut u = UsageRecord {
277 input: 1,
278 cache_read: 2,
279 cache_write: 0,
280 ..Default::default()
281 };
282 u.compute_hit_pct();
283 assert_eq!(u.hit_pct, 66.7);
284 }
285
286 #[test]
287 fn record_serializes_skipping_none_fields() {
288 let record = TelemetryRecord {
289 ts: 1,
290 model: "claude-sonnet-4-6".to_string(),
291 attempt: 1,
292 total_ms: 100,
293 ..Default::default()
294 };
295 let json = serde_json::to_string(&record).unwrap();
296 assert!(!json.contains("request_id"));
297 assert!(!json.contains("ratelimit"));
298 assert!(!json.contains("cache_diag"));
299 assert!(json.contains("\"model\":\"claude-sonnet-4-6\""));
300 }
301
302 #[test]
303 fn ratelimit_empty_detection() {
304 assert!(RateLimitRecord::default().is_empty());
305 let r = RateLimitRecord {
306 requests_remaining: Some(10),
307 ..Default::default()
308 };
309 assert!(!r.is_empty());
310 }
311
312 #[test]
313 fn ratelimit_parses_headers() {
314 let mut headers = reqwest::header::HeaderMap::new();
315 headers.insert("anthropic-ratelimit-requests-limit", "5000".parse().unwrap());
316 headers.insert("anthropic-ratelimit-requests-remaining", "4900".parse().unwrap());
317 headers.insert("anthropic-ratelimit-tokens-reset", "2026-06-11T01:46:00Z".parse().unwrap());
318 let r = ratelimit_from_headers(&headers);
319 assert_eq!(r.requests_limit, Some(5000));
320 assert_eq!(r.requests_remaining, Some(4900));
321 assert_eq!(r.tokens_reset.as_deref(), Some("2026-06-11T01:46:00Z"));
322 assert_eq!(r.tokens_limit, None);
323 }
324
325 #[test]
326 fn ratelimit_ignores_malformed_values() {
327 let mut headers = reqwest::header::HeaderMap::new();
328 headers.insert("anthropic-ratelimit-requests-limit", "not-a-number".parse().unwrap());
329 let r = ratelimit_from_headers(&headers);
330 assert_eq!(r.requests_limit, None);
331 }
332}