gemini_adk_rs/live/
telemetry.rs

1//! Lightweight session telemetry — atomic fast-lane counters + periodic aggregation.
2//!
3//! All hot-path operations (counter increments, timestamp recording) are lock-free
4//! and zero-allocation (~1ns per call). Aggregation only happens periodically on
5//! the telemetry lane or at turn boundaries, ensuring no impact on the
6//! latency-sensitive audio pipeline.
7//!
8//! The number a voice product is judged on is **response latency**: the time
9//! from the user's end of speech to the model's first audio byte. It is
10//! recorded per turn into [`LatencyStats`] — last, min, max, mean, the p50/
11//! p90/p99 of recent turns, and a fixed-bucket histogram — and read through
12//! [`SessionTelemetry::latency`] or [`SessionTelemetry::snapshot`].
13
14use std::fmt;
15use std::sync::atomic::{
16    AtomicBool, AtomicU64,
17    Ordering::{Acquire, Relaxed, Release},
18};
19use std::time::{Duration, Instant};
20
21use serde::{Deserialize, Serialize};
22use serde_json::json;
23
24/// Upper bounds, in milliseconds, of the response-latency histogram buckets.
25///
26/// A final open bucket collects everything above the last bound. The bounds
27/// are denser where voice products live (150–1000 ms) and coarser beyond.
28pub const LATENCY_BUCKETS_MS: [u64; 17] = [
29    50, 100, 150, 200, 300, 400, 500, 650, 800, 1000, 1300, 1600, 2000, 2500, 3000, 4000, 5000,
30];
31
32/// Number of most-recent samples the percentiles are computed over.
33///
34/// Percentiles are exact over this window (nearest-rank on a sorted copy),
35/// which covers every turn of all but the longest sessions; the histogram
36/// carries the full session.
37pub const LATENCY_RECENT_WINDOW: usize = 256;
38
39const BUCKET_COUNT: usize = LATENCY_BUCKETS_MS.len() + 1;
40
41/// One bucket of the response-latency histogram.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub struct LatencyBucket {
44    /// Inclusive upper bound in milliseconds; `None` for the open top bucket.
45    pub upper_ms: Option<u64>,
46    /// Number of turns whose latency fell in this bucket.
47    pub count: u64,
48}
49
50/// Response-latency distribution for the session so far.
51///
52/// All durations are whole milliseconds. Every field is `0` until the first
53/// turn has been measured (`count == 0`).
54#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
55pub struct LatencyStats {
56    /// Number of measured turns.
57    pub count: u64,
58    /// Latency of the most recent turn.
59    pub last_ms: u64,
60    /// Fastest turn.
61    pub min_ms: u64,
62    /// Slowest turn.
63    pub max_ms: u64,
64    /// Mean over all measured turns.
65    pub mean_ms: u64,
66    /// Median of the most recent [`LATENCY_RECENT_WINDOW`] turns.
67    pub p50_ms: u64,
68    /// 90th percentile of the most recent [`LATENCY_RECENT_WINDOW`] turns.
69    pub p90_ms: u64,
70    /// 99th percentile of the most recent [`LATENCY_RECENT_WINDOW`] turns.
71    pub p99_ms: u64,
72    /// Full-session histogram, one entry per [`LATENCY_BUCKETS_MS`] bound plus
73    /// the open top bucket.
74    pub histogram: Vec<LatencyBucket>,
75}
76
77impl fmt::Display for LatencyStats {
78    /// One line, fit for a log: `turns=12 last=420ms p50=380ms p90=610ms max=900ms`.
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        if self.count == 0 {
81            return write!(f, "turns=0 (no response measured yet)");
82        }
83        write!(
84            f,
85            "turns={} last={}ms p50={}ms p90={}ms p99={}ms min={}ms max={}ms",
86            self.count,
87            self.last_ms,
88            self.p50_ms,
89            self.p90_ms,
90            self.p99_ms,
91            self.min_ms,
92            self.max_ms
93        )
94    }
95}
96
97/// Lock-free per-turn latency recorder: scalars, a histogram, and a ring of
98/// recent samples, all atomics.
99struct LatencyRecorder {
100    last_ns: AtomicU64,
101    sum_ns: AtomicU64,
102    count: AtomicU64,
103    min_ns: AtomicU64,
104    max_ns: AtomicU64,
105    buckets: [AtomicU64; BUCKET_COUNT],
106    recent: [AtomicU64; LATENCY_RECENT_WINDOW],
107    recent_next: AtomicU64,
108}
109
110impl LatencyRecorder {
111    fn new() -> Self {
112        Self {
113            last_ns: AtomicU64::new(0),
114            sum_ns: AtomicU64::new(0),
115            count: AtomicU64::new(0),
116            min_ns: AtomicU64::new(u64::MAX),
117            max_ns: AtomicU64::new(0),
118            buckets: std::array::from_fn(|_| AtomicU64::new(0)),
119            recent: std::array::from_fn(|_| AtomicU64::new(0)),
120            recent_next: AtomicU64::new(0),
121        }
122    }
123
124    /// Record one turn's latency.
125    ///
126    /// `count` is the publication point and is written **last**, with
127    /// [`Release`]: a reader that observes `count == n` (with [`Acquire`], as
128    /// [`stats`](Self::stats) does) is guaranteed to see all `n` samples in the
129    /// ring and in the scalars. Bumping it first would let a reader see
130    /// `count == 1` while the first ring slot was still unwritten, and compute
131    /// a percentile over an empty window.
132    #[inline]
133    fn record(&self, latency_ns: u64) {
134        self.last_ns.store(latency_ns, Relaxed);
135        self.sum_ns.fetch_add(latency_ns, Relaxed);
136        self.min_ns.fetch_min(latency_ns, Relaxed);
137        self.max_ns.fetch_max(latency_ns, Relaxed);
138
139        let ms = latency_ns / 1_000_000;
140        let bucket = LATENCY_BUCKETS_MS
141            .iter()
142            .position(|&upper| ms <= upper)
143            .unwrap_or(BUCKET_COUNT - 1);
144        self.buckets[bucket].fetch_add(1, Relaxed);
145
146        // Ring of recent samples: one atomic slot per turn, index wraps.
147        let slot = self.recent_next.fetch_add(1, Relaxed) as usize % LATENCY_RECENT_WINDOW;
148        self.recent[slot].store(latency_ns, Relaxed);
149
150        self.count.fetch_add(1, Release);
151    }
152
153    fn stats(&self) -> LatencyStats {
154        // Acquire pairs with the Release in `record`: every sample counted here
155        // is already visible in the ring and the scalars.
156        // Acquire pairs with the Release in `record`: every sample counted here
157        // is already visible in the ring and the scalars.
158        let count = self.count.load(Acquire);
159        let histogram = self
160            .buckets
161            .iter()
162            .enumerate()
163            .map(|(i, b)| LatencyBucket {
164                upper_ms: LATENCY_BUCKETS_MS.get(i).copied(),
165                count: b.load(Relaxed),
166            })
167            .collect();
168        if count == 0 {
169            return LatencyStats {
170                histogram,
171                ..LatencyStats::default()
172            };
173        }
174
175        // `count` and `recent_next` advance together, one per sample, so the
176        // count sizes the window too — and reading one atomic instead of two
177        // leaves no room for the pair to disagree.
178        // `count` and `recent_next` advance together, one per sample, so the
179        // count sizes the window too — and reading one atomic instead of two
180        // leaves no room for the pair to disagree.
181        let filled = (count as usize).min(LATENCY_RECENT_WINDOW);
182        let mut recent: Vec<u64> = self.recent[..filled]
183            .iter()
184            .map(|s| s.load(Relaxed))
185            .collect();
186        recent.sort_unstable();
187        // Nearest-rank percentile over the sorted recent window.
188        let pct = |p: usize| -> u64 {
189            let rank = (p * recent.len()).div_ceil(100).max(1);
190            recent.get(rank - 1).copied().unwrap_or(0) / 1_000_000
191        };
192
193        LatencyStats {
194            count,
195            last_ms: self.last_ns.load(Relaxed) / 1_000_000,
196            min_ms: self.min_ns.load(Relaxed) / 1_000_000,
197            max_ms: self.max_ns.load(Relaxed) / 1_000_000,
198            mean_ms: self.sum_ns.load(Relaxed) / count / 1_000_000,
199            p50_ms: pct(50),
200            p90_ms: pct(90),
201            p99_ms: pct(99),
202            histogram,
203        }
204    }
205}
206
207/// Zero-overhead telemetry collector for speech-to-speech sessions.
208///
209/// Designed for the three-lane processor model:
210/// - **Fast lane** (sync, <1ms): No telemetry calls — pure audio/text forwarding.
211/// - **Telemetry lane** (async, debounced): Calls `record_*` methods on every event.
212///   These use only atomic operations — no allocations, no locks, no syscalls.
213/// - **Control lane** (async): Calls `snapshot()` at turn boundaries to get
214///   aggregated stats as a JSON value ready to send to the browser.
215pub struct SessionTelemetry {
216    start: Instant,
217
218    // ── Audio throughput ──
219    audio_chunks_out: AtomicU64,
220    audio_bytes_out: AtomicU64,
221
222    // ── Interruptions ──
223    interruptions: AtomicU64,
224
225    // ── Response latency tracking ──
226    // Stores nanos-since-session-start for atomic compatibility with Instant.
227    vad_end_ns: AtomicU64,
228    awaiting_response: AtomicBool,
229    /// Timestamp when user sent text (for text-input latency tracking).
230    text_send_ns: AtomicU64,
231    awaiting_text_response: AtomicBool,
232    /// Per-turn response latency: end of user speech (or text send) to the
233    /// model's first output.
234    latency: LatencyRecorder,
235
236    // ── Turn timing ──
237    turn_complete_count: AtomicU64,
238    last_turn_start_ns: AtomicU64,
239    turn_duration_sum_ns: AtomicU64,
240    turn_duration_count: AtomicU64,
241
242    // ── Token usage (from UsageMetadata) ──
243    /// Latest total token count from server.
244    total_token_count: AtomicU64,
245    /// Latest prompt token count from server.
246    prompt_token_count: AtomicU64,
247    /// Latest response token count from server.
248    response_token_count: AtomicU64,
249    /// Latest cached content token count from server.
250    cached_content_token_count: AtomicU64,
251    /// Latest thoughts token count (thinking models).
252    thoughts_token_count: AtomicU64,
253    /// Session totals by modality (`TEXT`, `AUDIO`, ...): prompt and
254    /// response tokens, summed over turns.
255    tokens_by_modality: parking_lot::Mutex<std::collections::BTreeMap<String, [u64; 2]>>,
256}
257
258impl SessionTelemetry {
259    /// Create a new telemetry tracker, starting the session clock.
260    pub fn new() -> Self {
261        Self {
262            start: Instant::now(),
263            audio_chunks_out: AtomicU64::new(0),
264            audio_bytes_out: AtomicU64::new(0),
265            interruptions: AtomicU64::new(0),
266            vad_end_ns: AtomicU64::new(0),
267            awaiting_response: AtomicBool::new(false),
268            text_send_ns: AtomicU64::new(0),
269            awaiting_text_response: AtomicBool::new(false),
270            latency: LatencyRecorder::new(),
271            turn_complete_count: AtomicU64::new(0),
272            last_turn_start_ns: AtomicU64::new(0),
273            turn_duration_sum_ns: AtomicU64::new(0),
274            turn_duration_count: AtomicU64::new(0),
275            total_token_count: AtomicU64::new(0),
276            prompt_token_count: AtomicU64::new(0),
277            response_token_count: AtomicU64::new(0),
278            cached_content_token_count: AtomicU64::new(0),
279            thoughts_token_count: AtomicU64::new(0),
280            tokens_by_modality: parking_lot::Mutex::new(std::collections::BTreeMap::new()),
281        }
282    }
283
284    // ── Atomic methods (~1ns each) ──
285
286    /// Record an outgoing audio chunk. Called from the telemetry lane.
287    ///
288    /// Returns the response latency when this chunk is the model's first
289    /// output after the user's end of speech (or text send) — once per turn,
290    /// via a CAS so only the first chunk wins.
291    #[inline]
292    pub fn record_audio_out(&self, byte_len: usize) -> Option<Duration> {
293        self.audio_chunks_out.fetch_add(1, Relaxed);
294        self.audio_bytes_out.fetch_add(byte_len as u64, Relaxed);
295
296        // A text send answered with audio counts as a response too.
297        let text = self.record_text_response_latency();
298
299        // Latency: if we're awaiting the model's first byte after VAD end,
300        // record the response latency via CAS (only the first chunk wins).
301        if self
302            .awaiting_response
303            .compare_exchange(true, false, Relaxed, Relaxed)
304            .is_ok()
305        {
306            let now_ns = self.elapsed_ns();
307            let vad_end = self.vad_end_ns.load(Relaxed);
308            if now_ns > vad_end && vad_end > 0 {
309                let latency = now_ns - vad_end;
310                self.latency.record(latency);
311                gemini_genai_rs::telemetry::metrics::record_response_latency(latency as f64 / 1e6);
312                return Some(Duration::from_nanos(latency));
313            }
314        }
315        text
316    }
317
318    /// Record VAD end (user stopped speaking).
319    #[inline]
320    pub fn record_vad_end(&self) {
321        self.vad_end_ns.store(self.elapsed_ns(), Relaxed);
322        self.awaiting_response.store(true, Relaxed);
323    }
324
325    /// Record that user sent a text message (for text-input latency tracking).
326    #[inline]
327    pub fn record_text_send(&self) {
328        self.text_send_ns.store(self.elapsed_ns(), Relaxed);
329        self.awaiting_text_response.store(true, Relaxed);
330    }
331
332    /// Record first model output for text-input latency.
333    /// Call on first TextDelta or AudioData after a text send.
334    #[inline]
335    fn record_text_response_latency(&self) -> Option<Duration> {
336        if self
337            .awaiting_text_response
338            .compare_exchange(true, false, Relaxed, Relaxed)
339            .is_ok()
340        {
341            let now_ns = self.elapsed_ns();
342            let send_ns = self.text_send_ns.load(Relaxed);
343            if now_ns > send_ns && send_ns > 0 {
344                let latency = now_ns - send_ns;
345                self.latency.record(latency);
346                gemini_genai_rs::telemetry::metrics::record_response_latency(latency as f64 / 1e6);
347                return Some(Duration::from_nanos(latency));
348            }
349        }
350        None
351    }
352
353    /// Record first model text output (TextDelta). Tracks text-input latency.
354    ///
355    /// Returns the response latency when this delta is the model's first
356    /// output after a text send.
357    #[inline]
358    pub fn record_text_out(&self) -> Option<Duration> {
359        self.record_text_response_latency()
360    }
361
362    /// Record an interruption (barge-in).
363    #[inline]
364    pub fn record_interruption(&self) {
365        self.interruptions.fetch_add(1, Relaxed);
366    }
367
368    /// Record turn completion for duration tracking.
369    #[inline]
370    pub fn record_turn_complete(&self) {
371        self.turn_complete_count.fetch_add(1, Relaxed);
372        let now = self.elapsed_ns();
373        let turn_start = self.last_turn_start_ns.swap(now, Relaxed);
374        if turn_start > 0 {
375            let duration = now.saturating_sub(turn_start);
376            self.turn_duration_sum_ns.fetch_add(duration, Relaxed);
377            self.turn_duration_count.fetch_add(1, Relaxed);
378        }
379    }
380
381    /// Record token usage from a `UsageMetadata` event.
382    #[inline]
383    pub fn record_usage(
384        &self,
385        total: Option<u32>,
386        prompt: Option<u32>,
387        response: Option<u32>,
388        cached: Option<u32>,
389        thoughts: Option<u32>,
390    ) {
391        if let Some(v) = total {
392            self.total_token_count.store(v as u64, Relaxed);
393        }
394        if let Some(v) = prompt {
395            self.prompt_token_count.store(v as u64, Relaxed);
396        }
397        if let Some(v) = response {
398            self.response_token_count.store(v as u64, Relaxed);
399        }
400        if let Some(v) = cached {
401            self.cached_content_token_count.store(v as u64, Relaxed);
402        }
403        if let Some(v) = thoughts {
404            self.thoughts_token_count.store(v as u64, Relaxed);
405        }
406    }
407
408    /// Add one turn's usage, by modality, to the session totals, and to the
409    /// `gemini_genai_rs_tokens_total` metric. Pass the last usage report of
410    /// the turn: each report covers the turn so far.
411    pub fn record_turn_usage(&self, usage: &gemini_genai_rs::prelude::UsageMetadata) {
412        let mut totals = self.tokens_by_modality.lock();
413        for (index, direction, details) in [
414            (0, "prompt", &usage.prompt_tokens_details),
415            (1, "response", &usage.response_tokens_details),
416        ] {
417            for detail in details {
418                let (Some(modality), Some(count)) = (&detail.modality, detail.token_count) else {
419                    continue;
420                };
421                totals.entry(modality.clone()).or_default()[index] += u64::from(count);
422                gemini_genai_rs::telemetry::metrics::record_tokens(
423                    direction,
424                    modality,
425                    count.into(),
426                );
427            }
428        }
429    }
430
431    /// Mark the beginning of a new turn (e.g., when model starts responding).
432    #[inline]
433    pub fn mark_turn_start(&self) {
434        let now = self.elapsed_ns();
435        // Only set if not already set (first call per turn wins)
436        self.last_turn_start_ns
437            .compare_exchange(0, now, Relaxed, Relaxed)
438            .ok();
439    }
440
441    // ── Aggregation (called at turn boundaries / periodic flush) ──
442
443    /// Per-turn response latency: end of user speech (or text send) to the
444    /// model's first output, as a distribution over the session so far.
445    ///
446    /// Cheap enough to call once per turn (it sorts at most
447    /// [`LATENCY_RECENT_WINDOW`] samples); not for the audio hot path.
448    pub fn latency(&self) -> LatencyStats {
449        self.latency.stats()
450    }
451
452    /// Snapshot all metrics as a JSON value.
453    ///
454    /// The flat `*_response_latency_ms` keys are kept for existing dashboards;
455    /// `response_latency` carries the full [`LatencyStats`] (percentiles and
456    /// histogram included).
457    pub fn snapshot(&self) -> serde_json::Value {
458        let elapsed = self.start.elapsed();
459        let elapsed_secs = elapsed.as_secs_f64();
460
461        let chunks = self.audio_chunks_out.load(Relaxed);
462        let bytes = self.audio_bytes_out.load(Relaxed);
463        let latency = self.latency.stats();
464
465        let turn_count = self.turn_duration_count.load(Relaxed);
466        let turn_complete_count = self.turn_complete_count.load(Relaxed);
467        let avg_turn_ms = if turn_count > 0 {
468            self.turn_duration_sum_ns.load(Relaxed) / turn_count / 1_000_000
469        } else {
470            0
471        };
472
473        // Audio throughput (KB/s over session lifetime)
474        let throughput_kbps = if elapsed_secs > 0.0 {
475            (bytes as f64 / 1024.0) / elapsed_secs
476        } else {
477            0.0
478        };
479
480        let total_tokens = self.total_token_count.load(Relaxed);
481        let prompt_tokens = self.prompt_token_count.load(Relaxed);
482        let response_tokens = self.response_token_count.load(Relaxed);
483        let cached_tokens = self.cached_content_token_count.load(Relaxed);
484        let thoughts_tokens = self.thoughts_token_count.load(Relaxed);
485
486        json!({
487            "uptime_secs": elapsed.as_secs(),
488            "audio_chunks_out": chunks,
489            "audio_kbytes_out": bytes / 1024,
490            "audio_throughput_kbps": (throughput_kbps * 10.0).round() / 10.0,
491            "interruptions": self.interruptions.load(Relaxed),
492            "last_response_latency_ms": latency.last_ms,
493            "avg_response_latency_ms": latency.mean_ms,
494            "min_response_latency_ms": latency.min_ms,
495            "max_response_latency_ms": latency.max_ms,
496            "response_count": latency.count,
497            "response_latency": latency,
498            "turn_count": turn_complete_count,
499            "avg_turn_duration_ms": avg_turn_ms,
500            "total_token_count": total_tokens,
501            "prompt_token_count": prompt_tokens,
502            "response_token_count": response_tokens,
503            "cached_content_token_count": cached_tokens,
504            "thoughts_token_count": thoughts_tokens,
505            "tokens_by_modality": self
506                .tokens_by_modality
507                .lock()
508                .iter()
509                .map(|(modality, [prompt, response])| {
510                    (modality.clone(), json!({ "prompt": prompt, "response": response }))
511                })
512                .collect::<serde_json::Map<_, _>>(),
513        })
514    }
515
516    #[inline]
517    fn elapsed_ns(&self) -> u64 {
518        self.start.elapsed().as_nanos() as u64
519    }
520}
521
522impl Default for SessionTelemetry {
523    fn default() -> Self {
524        Self::new()
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    #[test]
533    fn turn_usage_is_summed_by_modality() {
534        let t = SessionTelemetry::new();
535        let usage: gemini_genai_rs::prelude::UsageMetadata = serde_json::from_value(json!({
536            "promptTokenCount": 30,
537            "promptTokensDetails": [
538                { "modality": "AUDIO", "tokenCount": 20 },
539                { "modality": "TEXT", "tokenCount": 10 }
540            ],
541            "responseTokensDetails": [{ "modality": "AUDIO", "tokenCount": 40 }]
542        }))
543        .unwrap();
544        t.record_turn_usage(&usage);
545        t.record_turn_usage(&usage);
546        let snap = t.snapshot();
547        assert_eq!(
548            snap["tokens_by_modality"]["AUDIO"],
549            json!({ "prompt": 40, "response": 80 })
550        );
551        assert_eq!(
552            snap["tokens_by_modality"]["TEXT"],
553            json!({ "prompt": 20, "response": 0 })
554        );
555    }
556
557    #[test]
558    fn new_snapshot_is_zeroed() {
559        let t = SessionTelemetry::new();
560        let snap = t.snapshot();
561        assert_eq!(snap["audio_chunks_out"], 0);
562        assert_eq!(snap["interruptions"], 0);
563        assert_eq!(snap["last_response_latency_ms"], 0);
564        assert_eq!(snap["response_count"], 0);
565        assert_eq!(snap["turn_count"], 0);
566        assert_eq!(snap["response_latency"]["count"], 0);
567        assert_eq!(
568            t.latency(),
569            LatencyStats {
570                histogram: t.latency().histogram.clone(),
571                ..LatencyStats::default()
572            }
573        );
574        assert_eq!(
575            t.latency().to_string(),
576            "turns=0 (no response measured yet)"
577        );
578    }
579
580    #[test]
581    fn audio_counters_accumulate() {
582        let t = SessionTelemetry::new();
583        t.record_audio_out(480);
584        t.record_audio_out(480);
585        t.record_audio_out(480);
586        let snap = t.snapshot();
587        assert_eq!(snap["audio_chunks_out"], 3);
588    }
589
590    #[test]
591    fn interruption_counter() {
592        let t = SessionTelemetry::new();
593        t.record_interruption();
594        t.record_interruption();
595        assert_eq!(t.snapshot()["interruptions"], 2);
596    }
597
598    #[test]
599    fn turn_complete_counter_is_independent_of_latency() {
600        let t = SessionTelemetry::new();
601        t.record_turn_complete();
602        t.record_turn_complete();
603
604        let snap = t.snapshot();
605        assert_eq!(snap["turn_count"], 2);
606        assert_eq!(snap["response_count"], 0);
607    }
608
609    #[test]
610    fn latency_tracking() {
611        let t = SessionTelemetry::new();
612        // Simulate: VAD end → short delay → first audio chunk
613        t.record_vad_end();
614        std::thread::sleep(std::time::Duration::from_millis(10));
615        let first = t.record_audio_out(480);
616        assert!(
617            first.is_some(),
618            "first chunk after VAD end reports the latency"
619        );
620        // Subsequent chunks should not re-record latency
621        assert!(t.record_audio_out(480).is_none());
622        assert!(t.record_audio_out(480).is_none());
623
624        let snap = t.snapshot();
625        assert_eq!(snap["response_count"], 1);
626        // Latency should be >= 10ms (we slept 10ms)
627        assert!(snap["last_response_latency_ms"].as_u64().unwrap() >= 5);
628        assert_eq!(snap["response_latency"]["count"], 1);
629    }
630
631    #[test]
632    fn multiple_turns_average_latency() {
633        let t = SessionTelemetry::new();
634
635        // Turn 1
636        t.record_vad_end();
637        std::thread::sleep(std::time::Duration::from_millis(10));
638        t.record_audio_out(480);
639
640        // Turn 2
641        t.record_vad_end();
642        std::thread::sleep(std::time::Duration::from_millis(10));
643        t.record_audio_out(480);
644
645        let snap = t.snapshot();
646        assert_eq!(snap["response_count"], 2);
647        assert!(snap["avg_response_latency_ms"].as_u64().unwrap() >= 5);
648    }
649
650    #[test]
651    fn text_input_latency_via_text_out() {
652        let t = SessionTelemetry::new();
653        // Simulate: user sends text → delay → model responds with text
654        t.record_text_send();
655        std::thread::sleep(std::time::Duration::from_millis(10));
656        assert!(t.record_text_out().is_some());
657        // Subsequent text outputs should not re-record
658        assert!(t.record_text_out().is_none());
659
660        let snap = t.snapshot();
661        assert_eq!(snap["response_count"], 1);
662        assert!(snap["last_response_latency_ms"].as_u64().unwrap() >= 5);
663    }
664
665    #[test]
666    fn text_input_latency_via_audio_out() {
667        let t = SessionTelemetry::new();
668        // Simulate: user sends text → delay → model responds with audio
669        t.record_text_send();
670        std::thread::sleep(std::time::Duration::from_millis(10));
671        assert!(t.record_audio_out(480).is_some());
672
673        let snap = t.snapshot();
674        // Should record text-send latency (response_count = 1)
675        assert_eq!(snap["response_count"], 1);
676        assert!(snap["last_response_latency_ms"].as_u64().unwrap() >= 5);
677    }
678
679    #[test]
680    fn mixed_voice_and_text_turns() {
681        let t = SessionTelemetry::new();
682
683        // Voice turn
684        t.record_vad_end();
685        std::thread::sleep(std::time::Duration::from_millis(10));
686        t.record_audio_out(480);
687
688        // Text turn
689        t.record_text_send();
690        std::thread::sleep(std::time::Duration::from_millis(10));
691        t.record_text_out();
692
693        let snap = t.snapshot();
694        assert_eq!(snap["response_count"], 2);
695    }
696
697    // ── LatencyRecorder: distribution semantics, fed directly in nanos ──
698
699    fn ms(n: u64) -> u64 {
700        n * 1_000_000
701    }
702
703    #[test]
704    fn recorder_scalars_and_percentiles() {
705        let r = LatencyRecorder::new();
706        for v in [300, 100, 500, 200, 400] {
707            r.record(ms(v));
708        }
709        let s = r.stats();
710        assert_eq!(s.count, 5);
711        assert_eq!(s.last_ms, 400);
712        assert_eq!(s.min_ms, 100);
713        assert_eq!(s.max_ms, 500);
714        assert_eq!(s.mean_ms, 300);
715        // Nearest rank over [100,200,300,400,500]: p50 → rank 3 → 300,
716        // p90 → rank 5 → 500, p99 → rank 5 → 500.
717        assert_eq!(s.p50_ms, 300);
718        assert_eq!(s.p90_ms, 500);
719        assert_eq!(s.p99_ms, 500);
720        assert_eq!(
721            s.to_string(),
722            "turns=5 last=400ms p50=300ms p90=500ms p99=500ms min=100ms max=500ms"
723        );
724    }
725
726    #[test]
727    fn recorder_first_sample_is_visible_to_percentiles() {
728        // A count of 1 must come with a window of 1 — never an empty window
729        // that the nearest-rank index would then read past the end of.
730        let r = LatencyRecorder::new();
731        r.record(ms(420));
732        let s = r.stats();
733        assert_eq!(s.count, 1);
734        assert_eq!((s.p50_ms, s.p90_ms, s.p99_ms), (420, 420, 420));
735        assert_eq!(s.mean_ms, 420);
736    }
737
738    #[test]
739    fn recorder_stats_survives_a_count_without_its_sample() {
740        // The panic this pins: `stats()` gated on `count` but sized its
741        // percentile window from `recent_next`. A count that ran ahead of the
742        // ring — as it did while `record` bumped the count first — left an
743        // empty window that the nearest-rank index then read past the end of.
744        // `stats()` now sizes the window from the same counter it gates on.
745        let r = LatencyRecorder::new();
746        r.count.store(1, Release); // counted, ring slot not yet written
747        let s = r.stats();
748        assert_eq!(s.count, 1);
749        assert_eq!((s.p50_ms, s.p90_ms, s.p99_ms), (0, 0, 0));
750    }
751
752    #[test]
753    fn recorder_snapshots_stay_consistent_under_concurrent_records() {
754        // `stats()` runs on whatever thread the caller likes while the
755        // telemetry lane records; no snapshot may mix samples from different
756        // moments into an impossible summary.
757        use std::sync::Arc;
758        use std::sync::atomic::AtomicBool;
759
760        let r = Arc::new(LatencyRecorder::new());
761        let done = Arc::new(AtomicBool::new(false));
762
763        let reader = {
764            let r = Arc::clone(&r);
765            let done = Arc::clone(&done);
766            std::thread::spawn(move || {
767                while !done.load(Relaxed) {
768                    let s = r.stats();
769                    if s.count > 0 {
770                        assert!(s.p99_ms >= s.p50_ms);
771                        assert!(s.max_ms >= s.p99_ms);
772                        assert!(s.min_ms <= s.max_ms);
773                    }
774                }
775            })
776        };
777
778        for _ in 0..2_000 {
779            r.record(ms(100));
780        }
781        done.store(true, Relaxed);
782        reader.join().expect("reader saw an inconsistent snapshot");
783        assert_eq!(r.stats().count, 2_000);
784    }
785
786    #[test]
787    fn recorder_histogram_buckets_by_upper_bound() {
788        let r = LatencyRecorder::new();
789        r.record(ms(50)); // ≤ 50 → bucket 0 (inclusive bound)
790        r.record(ms(51)); // ≤ 100 → bucket 1
791        r.record(ms(999)); // ≤ 1000 → bucket 9
792        r.record(ms(9_000)); // > 5000 → open top bucket
793        let h = r.stats().histogram;
794        assert_eq!(h.len(), LATENCY_BUCKETS_MS.len() + 1);
795        assert_eq!(
796            h[0],
797            LatencyBucket {
798                upper_ms: Some(50),
799                count: 1
800            }
801        );
802        assert_eq!(
803            h[1],
804            LatencyBucket {
805                upper_ms: Some(100),
806                count: 1
807            }
808        );
809        assert_eq!(
810            h[9],
811            LatencyBucket {
812                upper_ms: Some(1000),
813                count: 1
814            }
815        );
816        assert_eq!(
817            h[h.len() - 1],
818            LatencyBucket {
819                upper_ms: None,
820                count: 1
821            }
822        );
823        assert_eq!(h.iter().map(|b| b.count).sum::<u64>(), 4);
824    }
825
826    #[test]
827    fn recorder_percentiles_use_recent_window_only() {
828        let r = LatencyRecorder::new();
829        // Fill the window with slow turns, then overwrite it entirely with
830        // fast ones: the percentiles follow the recent window, min/max and
831        // the histogram keep the whole session.
832        for _ in 0..LATENCY_RECENT_WINDOW {
833            r.record(ms(2_000));
834        }
835        for _ in 0..LATENCY_RECENT_WINDOW {
836            r.record(ms(200));
837        }
838        let s = r.stats();
839        assert_eq!(s.count, 2 * LATENCY_RECENT_WINDOW as u64);
840        assert_eq!(s.p50_ms, 200);
841        assert_eq!(s.p99_ms, 200);
842        assert_eq!(s.max_ms, 2_000);
843        assert_eq!(s.min_ms, 200);
844        let slow: u64 = s
845            .histogram
846            .iter()
847            .filter(|b| b.upper_ms == Some(2000))
848            .map(|b| b.count)
849            .sum();
850        assert_eq!(slow, LATENCY_RECENT_WINDOW as u64);
851    }
852
853    #[test]
854    fn stats_round_trip_through_json() {
855        let r = LatencyRecorder::new();
856        r.record(ms(420));
857        let s = r.stats();
858        let v = serde_json::to_value(&s).unwrap();
859        assert_eq!(v["p50_ms"], 420);
860        let back: LatencyStats = serde_json::from_value(v).unwrap();
861        assert_eq!(back, s);
862    }
863}