1use serde::Serialize;
28use std::time::{Duration, SystemTime, UNIX_EPOCH};
29
30pub mod writer;
31
32pub use writer::{
33 default_telemetry_log_path, default_trace_log_path, ShutdownOutcome, TelemetryWriter,
34 WriteGate, WriterOptions, WriterStats, WriterTraceSink, DEFAULT_MAX_FILE_BYTES,
35 DEFAULT_MAX_GENERATIONS, DEFAULT_QUEUE_CAPACITY, DEFAULT_SHUTDOWN_FLUSH_TIMEOUT,
36};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum TelemetryLevel {
41 #[default]
42 Off,
43 Basic,
44 Full,
45}
46
47impl TelemetryLevel {
48 pub fn from_str_key(s: &str) -> Self {
49 match s.trim().to_ascii_lowercase().as_str() {
50 "basic" | "on" | "1" | "true" => Self::Basic,
51 "full" => Self::Full,
52 _ => Self::Off,
53 }
54 }
55
56 pub fn enabled(&self) -> bool {
57 !matches!(self, Self::Off)
58 }
59
60 pub fn as_str(&self) -> &'static str {
61 match self {
62 Self::Off => "off",
63 Self::Basic => "basic",
64 Self::Full => "full",
65 }
66 }
67}
68
69#[derive(Debug, Clone, Default)]
76pub struct WorkerTelemetry {
77 pub level: TelemetryLevel,
78 pub writer: Option<TelemetryWriter>,
80 pub parent_session_id: Option<String>,
84 pub parent_runtime_session_id: Option<String>,
87 pub agent: Option<String>,
89 pub handle: Option<String>,
91}
92
93impl WorkerTelemetry {
94 pub fn for_worker(mut self, agent: &str, handle: &str) -> Self {
96 self.agent = Some(agent.to_string());
97 self.handle = Some(handle.to_string());
98 self
99 }
100
101 pub fn worker_writer(&self) -> Option<TelemetryWriter> {
105 self.writer.as_ref().map(|w| {
106 w.with_stamp(RecordStamp {
107 session_id: None,
108 worker_agent: self.agent.clone(),
109 worker_handle: self.handle.clone(),
110 parent_session_id: self.parent_session_id.clone(),
111 parent_runtime_session_id: self.parent_runtime_session_id.clone(),
112 })
113 })
114 }
115}
116
117#[derive(Debug, Clone, Default, PartialEq, Eq)]
122pub struct RecordStamp {
123 pub session_id: Option<String>,
124 pub worker_agent: Option<String>,
125 pub worker_handle: Option<String>,
126 pub parent_session_id: Option<String>,
127 pub parent_runtime_session_id: Option<String>,
128}
129
130impl RecordStamp {
131 pub fn apply(&self, record: &mut TelemetryRecord) {
133 fn fill(slot: &mut Option<String>, value: &Option<String>) {
134 if slot.is_none() {
135 slot.clone_from(value);
136 }
137 }
138 fill(&mut record.session_id, &self.session_id);
139 fill(&mut record.worker_agent, &self.worker_agent);
140 fill(&mut record.worker_handle, &self.worker_handle);
141 fill(&mut record.parent_session_id, &self.parent_session_id);
142 fill(
143 &mut record.parent_runtime_session_id,
144 &self.parent_runtime_session_id,
145 );
146 }
147}
148
149#[derive(Debug, Clone, Default, Serialize)]
151pub struct UsageRecord {
152 pub input: u64,
153 pub output: u64,
154 pub cache_read: u64,
155 pub cache_write: u64,
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub cache_write_5m: Option<u64>,
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub cache_write_1h: Option<u64>,
162 pub hit_pct: f64,
164}
165
166impl UsageRecord {
167 pub fn cost_usd(&self, model: &str) -> Option<f64> {
171 if self.input == 0 && self.output == 0 && self.cache_read == 0 && self.cache_write == 0 {
172 return None;
173 }
174 Some(agent_core::pricing::calculate_cost_optional_split(
175 model,
176 self.input,
177 self.output,
178 self.cache_read,
179 self.cache_write,
180 self.cache_write_5m,
181 self.cache_write_1h,
182 ))
183 }
184
185 pub fn compute_hit_pct(&mut self) {
186 let total = self.input + self.cache_read + self.cache_write;
187 self.hit_pct = if total > 0 {
188 (self.cache_read as f64 / total as f64 * 1000.0).round() / 10.0
189 } else {
190 0.0
191 };
192 }
193}
194
195#[derive(Debug, Clone, Default, Serialize)]
198pub struct RateLimitRecord {
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub requests_limit: Option<u64>,
201 #[serde(skip_serializing_if = "Option::is_none")]
202 pub requests_remaining: Option<u64>,
203 #[serde(skip_serializing_if = "Option::is_none")]
204 pub tokens_limit: Option<u64>,
205 #[serde(skip_serializing_if = "Option::is_none")]
206 pub tokens_remaining: Option<u64>,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub input_tokens_remaining: Option<u64>,
209 #[serde(skip_serializing_if = "Option::is_none")]
210 pub output_tokens_remaining: Option<u64>,
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub tokens_reset: Option<String>,
214}
215
216impl RateLimitRecord {
217 pub fn is_empty(&self) -> bool {
218 self.requests_limit.is_none()
219 && self.requests_remaining.is_none()
220 && self.tokens_limit.is_none()
221 && self.tokens_remaining.is_none()
222 && self.input_tokens_remaining.is_none()
223 && self.output_tokens_remaining.is_none()
224 && self.tokens_reset.is_none()
225 }
226}
227
228#[derive(Debug, Clone, Serialize)]
231pub struct CacheDiagRecord {
232 pub miss_reason: String,
234 #[serde(skip_serializing_if = "Option::is_none")]
236 pub missed_tokens: Option<u64>,
237}
238
239#[derive(Debug, Clone, Default, Serialize)]
241pub struct ContextRecord {
242 pub messages: usize,
243 pub tools: usize,
244 pub system_bytes: usize,
245 pub breakpoints: Vec<usize>,
247}
248
249#[derive(Debug, Clone, Default, Serialize)]
251pub struct TelemetryRecord {
252 pub ts: u64,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 pub request_id: Option<String>,
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub msg_id: Option<String>,
259 pub model: String,
260 pub attempt: u32,
262 #[serde(skip_serializing_if = "Option::is_none")]
264 pub refusal_retries_used: Option<u32>,
265 #[serde(skip_serializing_if = "Option::is_none")]
269 pub ttft_ms: Option<u64>,
270 pub total_ms: u64,
272 #[serde(skip_serializing_if = "Option::is_none")]
273 pub stop_reason: Option<String>,
274 pub usage: UsageRecord,
275 #[serde(skip_serializing_if = "Option::is_none")]
278 pub cost_usd: Option<f64>,
279 #[serde(skip_serializing_if = "Option::is_none")]
280 pub ratelimit: Option<RateLimitRecord>,
281 #[serde(skip_serializing_if = "Option::is_none")]
282 pub cache_diag: Option<CacheDiagRecord>,
283 pub context: ContextRecord,
284 #[serde(skip_serializing_if = "std::ops::Not::not")]
287 pub keepwarm: bool,
288 #[serde(skip_serializing_if = "std::ops::Not::not")]
292 pub worker: bool,
293 #[serde(skip_serializing_if = "Option::is_none")]
297 pub session_id: Option<String>,
298 #[serde(skip_serializing_if = "Option::is_none")]
301 pub runtime_session_id: Option<String>,
302 #[serde(skip_serializing_if = "Option::is_none")]
306 pub turn_id: Option<String>,
307 #[serde(skip_serializing_if = "Option::is_none")]
310 pub trace_request_id: Option<String>,
311 #[serde(skip_serializing_if = "Option::is_none")]
314 pub worker_agent: Option<String>,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub worker_handle: Option<String>,
318 #[serde(skip_serializing_if = "Option::is_none")]
320 pub parent_session_id: Option<String>,
321 #[serde(skip_serializing_if = "Option::is_none")]
323 pub parent_runtime_session_id: Option<String>,
324 #[serde(skip_serializing_if = "Option::is_none")]
328 pub rollover_window: Option<u64>,
329 #[serde(skip_serializing_if = "std::ops::Not::not")]
332 pub rollover: bool,
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338pub struct RolloverStamp {
339 pub window: u64,
341 pub first_request: bool,
343}
344
345impl TelemetryRecord {
346 pub fn set_correlation(
350 &mut self,
351 session: &crate::runtime::trace::TraceId,
352 ids: Option<(
353 &crate::runtime::trace::TraceId,
354 &crate::runtime::trace::TraceId,
355 &crate::runtime::trace::TraceId,
356 )>,
357 ) {
358 let (session, turn, request) = match ids {
359 Some((s, t, r)) => (s, Some(t), Some(r)),
360 None => (session, None, None),
361 };
362 self.runtime_session_id = Some(session.as_str().to_string());
363 self.turn_id = turn.map(|t| t.as_str().to_string());
364 self.trace_request_id = request.map(|r| r.as_str().to_string());
365 }
366
367 pub fn now_ms() -> u64 {
368 SystemTime::now()
369 .duration_since(UNIX_EPOCH)
370 .map(|d| d.as_millis() as u64)
371 .unwrap_or(0)
372 }
373}
374
375fn header_u64(headers: &reqwest::header::HeaderMap, name: &str) -> Option<u64> {
377 headers.get(name)?.to_str().ok()?.parse().ok()
378}
379
380fn header_string(headers: &reqwest::header::HeaderMap, name: &str) -> Option<String> {
382 Some(headers.get(name)?.to_str().ok()?.to_string())
383}
384
385pub fn ratelimit_from_headers(headers: &reqwest::header::HeaderMap) -> RateLimitRecord {
387 RateLimitRecord {
388 requests_limit: header_u64(headers, "anthropic-ratelimit-requests-limit"),
389 requests_remaining: header_u64(headers, "anthropic-ratelimit-requests-remaining"),
390 tokens_limit: header_u64(headers, "anthropic-ratelimit-tokens-limit"),
391 tokens_remaining: header_u64(headers, "anthropic-ratelimit-tokens-remaining"),
392 input_tokens_remaining: header_u64(headers, "anthropic-ratelimit-input-tokens-remaining"),
393 output_tokens_remaining: header_u64(headers, "anthropic-ratelimit-output-tokens-remaining"),
394 tokens_reset: header_string(headers, "anthropic-ratelimit-tokens-reset"),
395 }
396}
397
398pub fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<String> {
400 header_string(headers, "request-id")
401}
402
403pub const RETRY_DELAY_CAP: Duration = Duration::from_secs(60);
406
407pub fn retry_delay_from_headers(
424 headers: &reqwest::header::HeaderMap,
425 attempt: u32,
426) -> (Duration, bool) {
427 let now_secs = SystemTime::now()
428 .duration_since(UNIX_EPOCH)
429 .map(|d| d.as_secs())
430 .unwrap_or(0);
431
432 if let Some(ra) = header_string(headers, "retry-after") {
434 let ra = ra.trim();
435 if let Ok(secs) = ra.parse::<u64>() {
437 let d = Duration::from_secs(secs).min(RETRY_DELAY_CAP);
438 return (d, true);
439 }
440 if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(ra) {
442 let reset_secs = dt.timestamp().max(0) as u64;
443 let wait = reset_secs.saturating_sub(now_secs);
444 let d = Duration::from_secs(wait).min(RETRY_DELAY_CAP);
445 return (d, true);
446 }
447 }
448
449 let mut min_wait: Option<u64> = None;
451 for name in &[
452 "anthropic-ratelimit-tokens-reset",
453 "anthropic-ratelimit-requests-reset",
454 ] {
455 if let Some(ts) = header_string(headers, name) {
456 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts.trim()) {
457 let reset_secs = dt.timestamp().max(0) as u64;
458 let wait = reset_secs.saturating_sub(now_secs);
459 min_wait = Some(min_wait.map_or(wait, |prev| prev.min(wait)));
460 }
461 }
462 }
463 if let Some(wait) = min_wait {
464 let d = Duration::from_secs(wait).min(RETRY_DELAY_CAP);
465 return (d, true);
466 }
467
468 let d = Duration::from_millis(1000 * 2u64.pow(attempt.saturating_sub(1)));
470 (d, false)
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn level_parses_known_values() {
479 assert_eq!(TelemetryLevel::from_str_key("off"), TelemetryLevel::Off);
480 assert_eq!(TelemetryLevel::from_str_key("basic"), TelemetryLevel::Basic);
481 assert_eq!(TelemetryLevel::from_str_key("full"), TelemetryLevel::Full);
482 assert_eq!(TelemetryLevel::from_str_key("FULL"), TelemetryLevel::Full);
483 assert_eq!(TelemetryLevel::from_str_key("true"), TelemetryLevel::Basic);
484 assert_eq!(TelemetryLevel::from_str_key("garbage"), TelemetryLevel::Off);
485 assert_eq!(TelemetryLevel::from_str_key(""), TelemetryLevel::Off);
486 }
487
488 #[test]
489 fn level_enabled() {
490 assert!(!TelemetryLevel::Off.enabled());
491 assert!(TelemetryLevel::Basic.enabled());
492 assert!(TelemetryLevel::Full.enabled());
493 }
494
495 #[test]
496 fn hit_pct_computation() {
497 let mut u = UsageRecord {
498 input: 100,
499 cache_read: 800,
500 cache_write: 100,
501 ..Default::default()
502 };
503 u.compute_hit_pct();
504 assert_eq!(u.hit_pct, 80.0);
505 }
506
507 #[test]
508 fn hit_pct_zero_total() {
509 let mut u = UsageRecord::default();
510 u.compute_hit_pct();
511 assert_eq!(u.hit_pct, 0.0);
512 }
513
514 #[test]
515 fn hit_pct_rounds_to_one_decimal() {
516 let mut u = UsageRecord {
517 input: 1,
518 cache_read: 2,
519 cache_write: 0,
520 ..Default::default()
521 };
522 u.compute_hit_pct();
523 assert_eq!(u.hit_pct, 66.7);
524 }
525
526 #[test]
527 fn record_serializes_skipping_none_fields() {
528 let record = TelemetryRecord {
529 ts: 1,
530 model: "claude-sonnet-4-6".to_string(),
531 attempt: 1,
532 total_ms: 100,
533 ..Default::default()
534 };
535 let json = serde_json::to_string(&record).unwrap();
536 assert!(!json.contains("request_id"));
537 assert!(!json.contains("ratelimit"));
538 assert!(!json.contains("cache_diag"));
539 assert!(!json.contains("keepwarm"), "turn records carry no keepwarm field");
540 assert!(!json.contains("worker"), "foreground records carry no worker field");
541 assert!(json.contains("\"model\":\"claude-sonnet-4-6\""));
542 }
543
544 #[test]
545 fn rollover_fields_appear_only_after_a_rollover() {
546 let plain = serde_json::to_string(&TelemetryRecord::default()).unwrap();
547 assert!(!plain.contains("rollover"), "{plain}");
548 let first = TelemetryRecord {
549 rollover_window: Some(2),
550 rollover: true,
551 worker: true,
552 ..Default::default()
553 };
554 let json = serde_json::to_string(&first).unwrap();
555 assert!(json.contains("\"rollover_window\":2"), "{json}");
556 assert!(json.contains("\"rollover\":true"), "{json}");
557 let later = TelemetryRecord {
558 rollover_window: Some(2),
559 ..Default::default()
560 };
561 let json = serde_json::to_string(&later).unwrap();
562 assert!(json.contains("\"rollover_window\":2"), "{json}");
563 assert!(!json.contains("\"rollover\":"), "{json}");
564 }
565
566 #[test]
567 fn keepwarm_record_is_marked() {
568 let record = TelemetryRecord {
569 ts: 1,
570 model: "claude-opus-4-7".to_string(),
571 keepwarm: true,
572 ..Default::default()
573 };
574 let json = serde_json::to_string(&record).unwrap();
575 assert!(json.contains("\"keepwarm\":true"), "{json}");
576 }
577
578 #[test]
579 fn worker_record_is_marked() {
580 let record = TelemetryRecord {
581 ts: 1,
582 model: "claude-opus-5-5".to_string(),
583 worker: true,
584 ..Default::default()
585 };
586 let json = serde_json::to_string(&record).unwrap();
587 assert!(json.contains("\"worker\":true"), "{json}");
588 }
589
590 #[test]
591 fn ratelimit_empty_detection() {
592 assert!(RateLimitRecord::default().is_empty());
593 let r = RateLimitRecord {
594 requests_remaining: Some(10),
595 ..Default::default()
596 };
597 assert!(!r.is_empty());
598 }
599
600 #[test]
601 fn ratelimit_parses_headers() {
602 let mut headers = reqwest::header::HeaderMap::new();
603 headers.insert(
604 "anthropic-ratelimit-requests-limit",
605 "5000".parse().unwrap(),
606 );
607 headers.insert(
608 "anthropic-ratelimit-requests-remaining",
609 "4900".parse().unwrap(),
610 );
611 headers.insert(
612 "anthropic-ratelimit-tokens-reset",
613 "2026-06-11T01:46:00Z".parse().unwrap(),
614 );
615 let r = ratelimit_from_headers(&headers);
616 assert_eq!(r.requests_limit, Some(5000));
617 assert_eq!(r.requests_remaining, Some(4900));
618 assert_eq!(r.tokens_reset.as_deref(), Some("2026-06-11T01:46:00Z"));
619 assert_eq!(r.tokens_limit, None);
620 }
621
622 #[test]
623 fn ratelimit_ignores_malformed_values() {
624 let mut headers = reqwest::header::HeaderMap::new();
625 headers.insert(
626 "anthropic-ratelimit-requests-limit",
627 "not-a-number".parse().unwrap(),
628 );
629 let r = ratelimit_from_headers(&headers);
630 assert_eq!(r.requests_limit, None);
631 }
632}
633
634#[cfg(test)]
635mod retry_delay_tests {
636 use super::*;
637 use std::time::{Duration, SystemTime, UNIX_EPOCH};
638
639 #[test]
640 fn integer_retry_after() {
641 let mut h = reqwest::header::HeaderMap::new();
642 h.insert("retry-after", "45".parse().unwrap());
643 let (d, from_hdr) = retry_delay_from_headers(&h, 1);
644 assert_eq!(d, Duration::from_secs(45));
645 assert!(from_hdr);
646 }
647
648 #[test]
649 fn integer_retry_after_capped() {
650 let mut h = reqwest::header::HeaderMap::new();
651 h.insert("retry-after", "300".parse().unwrap()); let (d, from_hdr) = retry_delay_from_headers(&h, 1);
653 assert_eq!(d, RETRY_DELAY_CAP);
654 assert!(from_hdr);
655 }
656
657 #[test]
658 fn rfc3339_reset_future() {
659 let future_secs = SystemTime::now()
660 .duration_since(UNIX_EPOCH)
661 .unwrap()
662 .as_secs()
663 + 30;
664 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(future_secs as i64, 0).unwrap();
665 let ts = dt.to_rfc3339();
666
667 let mut h = reqwest::header::HeaderMap::new();
668 h.insert("anthropic-ratelimit-tokens-reset", ts.parse().unwrap());
669 let (d, from_hdr) = retry_delay_from_headers(&h, 1);
670 assert!(
671 d.as_secs() >= 28 && d.as_secs() <= 32,
672 "unexpected delay: {:?}",
673 d
674 );
675 assert!(from_hdr);
676 }
677
678 #[test]
679 fn rfc3339_reset_beyond_cap() {
680 let future_secs = SystemTime::now()
681 .duration_since(UNIX_EPOCH)
682 .unwrap()
683 .as_secs()
684 + 600;
685 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(future_secs as i64, 0).unwrap();
686 let ts = dt.to_rfc3339();
687
688 let mut h = reqwest::header::HeaderMap::new();
689 h.insert("anthropic-ratelimit-tokens-reset", ts.parse().unwrap());
690 let (d, from_hdr) = retry_delay_from_headers(&h, 1);
691 assert_eq!(d, RETRY_DELAY_CAP);
692 assert!(from_hdr);
693 }
694
695 #[test]
696 fn no_headers_exponential_fallback() {
697 let h = reqwest::header::HeaderMap::new();
698 let (d1, hdr1) = retry_delay_from_headers(&h, 1);
699 let (d2, hdr2) = retry_delay_from_headers(&h, 2);
700 let (d3, hdr3) = retry_delay_from_headers(&h, 3);
701 assert_eq!(d1, Duration::from_secs(1));
702 assert_eq!(d2, Duration::from_secs(2));
703 assert_eq!(d3, Duration::from_secs(4));
704 assert!(!hdr1 && !hdr2 && !hdr3);
705 }
706
707 #[test]
708 fn prefers_retry_after_over_rfc3339() {
709 let future_secs = SystemTime::now()
710 .duration_since(UNIX_EPOCH)
711 .unwrap()
712 .as_secs()
713 + 30;
714 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(future_secs as i64, 0).unwrap();
715 let ts = dt.to_rfc3339();
716
717 let mut h = reqwest::header::HeaderMap::new();
718 h.insert("retry-after", "10".parse().unwrap());
719 h.insert("anthropic-ratelimit-tokens-reset", ts.parse().unwrap());
720 let (d, from_hdr) = retry_delay_from_headers(&h, 1);
721 assert_eq!(d, Duration::from_secs(10));
722 assert!(from_hdr);
723 }
724
725 #[test]
726 fn min_of_multiple_ratelimit_reset_headers() {
727 let now = SystemTime::now()
729 .duration_since(UNIX_EPOCH)
730 .unwrap()
731 .as_secs();
732 let tokens_dt =
733 chrono::DateTime::<chrono::Utc>::from_timestamp((now + 15) as i64, 0).unwrap();
734 let requests_dt =
735 chrono::DateTime::<chrono::Utc>::from_timestamp((now + 45) as i64, 0).unwrap();
736
737 let mut h = reqwest::header::HeaderMap::new();
738 h.insert(
739 "anthropic-ratelimit-tokens-reset",
740 tokens_dt.to_rfc3339().parse().unwrap(),
741 );
742 h.insert(
743 "anthropic-ratelimit-requests-reset",
744 requests_dt.to_rfc3339().parse().unwrap(),
745 );
746 let (d, from_hdr) = retry_delay_from_headers(&h, 1);
747 assert!(
748 d.as_secs() >= 13 && d.as_secs() <= 17,
749 "should be ~15s, got {:?}",
750 d
751 );
752 assert!(from_hdr);
753 }
754}
755
756pub(super) fn network_retry_delay(attempt: u32) -> Duration {
759 Duration::from_secs(1u64 << attempt.saturating_sub(1).min(6))
760}
761
762#[cfg(test)]
763mod network_backoff_tests {
764 #[test]
765 fn configured_large_retry_counts_never_overflow() {
766 let seconds: Vec<_> = [0, 1, 2, 3, 7, 8, u32::MAX]
767 .into_iter()
768 .map(|n| super::network_retry_delay(n).as_secs())
769 .collect();
770 assert_eq!(seconds, [1, 1, 2, 4, 64, 64, 64]);
771 }
772}