Skip to main content

synaps_cli/runtime/
telemetry.rs

1//! Structured per-request API telemetry.
2//!
3//! Opt-in via the `telemetry` config key (`off` | `basic` | `full`).
4//! Writes one JSON record per API call to `~/.cache/synaps/api-log.jsonl`
5//! (mode 0600, O_NOFOLLOW — same hardening as the legacy usage log).
6//!
7//! `basic` records timing + usage + cost. `full` additionally records
8//! rate-limit headers and cache-diagnostics results when available.
9//!
10//! Writes are best-effort: a broken log path must never break the request
11//! pipeline. All errors are silently dropped (matching `log_usage`).
12
13use serde::Serialize;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16/// Telemetry verbosity level, parsed from the `telemetry` config key.
17#[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/// Token usage for one API call, including the cache-creation TTL breakdown.
48#[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    /// Cache writes with 5-minute TTL (from `usage.cache_creation.ephemeral_5m_input_tokens`).
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub cache_write_5m: Option<u64>,
57    /// Cache writes with 1-hour TTL (from `usage.cache_creation.ephemeral_1h_input_tokens`).
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub cache_write_1h: Option<u64>,
60    /// Cache hit percentage: cache_read / (input + cache_read + cache_write) * 100.
61    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/// Rate-limit headroom captured from `anthropic-ratelimit-*` response headers.
76/// Only recorded at `full` level.
77#[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    /// RFC 3339 timestamp when the most restrictive token limit resets.
92    #[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/// Cache-diagnostics result (beta `cache-diagnosis-2026-04-07`).
109/// Only present when the user opted in via `cache_diagnostics = true`.
110#[derive(Debug, Clone, Serialize)]
111pub struct CacheDiagRecord {
112    /// `cache_miss_reason.type` — e.g. "system_changed", "tools_changed".
113    pub miss_reason: String,
114    /// `cache_missed_input_tokens` — estimated tokens lost after divergence.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub missed_tokens: Option<u64>,
117}
118
119/// Request-shape context: what we sent, for correlating cache behavior.
120#[derive(Debug, Clone, Default, Serialize)]
121pub struct ContextRecord {
122    pub messages: usize,
123    pub tools: usize,
124    pub system_bytes: usize,
125    /// Indices of user messages carrying a conversational cache_control marker.
126    pub breakpoints: Vec<usize>,
127}
128
129/// One JSONL record per API call.
130#[derive(Debug, Clone, Default, Serialize)]
131pub struct TelemetryRecord {
132    /// Unix epoch milliseconds at request completion.
133    pub ts: u64,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub request_id: Option<String>,
136    /// Anthropic message id (`msg_...`) from message_start.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub msg_id: Option<String>,
139    pub model: String,
140    /// 1-based attempt number that succeeded (1 = no retries).
141    pub attempt: u32,
142    /// Milliseconds from request send to first SSE byte.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub ttft_ms: Option<u64>,
145    /// Milliseconds from request send to stream close.
146    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
166/// Parse a `u64` rate-limit header value.
167fn header_u64(headers: &reqwest::header::HeaderMap, name: &str) -> Option<u64> {
168    headers.get(name)?.to_str().ok()?.parse().ok()
169}
170
171/// Parse a string rate-limit header value.
172fn header_string(headers: &reqwest::header::HeaderMap, name: &str) -> Option<String> {
173    Some(headers.get(name)?.to_str().ok()?.to_string())
174}
175
176/// Extract rate-limit headroom from response headers.
177pub 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
189/// Extract the `request-id` response header.
190pub fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<String> {
191    header_string(headers, "request-id")
192}
193
194/// Default telemetry log path: `~/.cache/synaps/api-log.jsonl`.
195fn 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
200/// Append a record to the telemetry log. Best-effort — all errors are
201/// silently dropped so a broken log path never breaks the request pipeline.
202///
203/// File is created 0600 with O_NOFOLLOW (CWE-59 hardening, matching
204/// `HelperMethods::log_usage`).
205pub 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}