Skip to main content

newt_core/
metrics.rs

1//! Inference turn telemetry — timing, token counts, and cost estimates.
2//!
3//! `TurnMetrics` is the single record produced after every inference call,
4//! whether it comes from the TUI chat REPL, the ACP worker, or the mesh
5//! dispatch layer. It carries only what is cheap to compute: wall-clock time
6//! (always available), Ollama's native token counters (available on
7//! `/api/chat` responses), and a best-effort cost estimate.
8//!
9//! All fields except `elapsed_ms` and `model_id` are `Option` so the struct
10//! can be constructed and displayed even when the backend does not report
11//! token counts (e.g. provider-plugin or vLLM paths).
12
13use serde::{Deserialize, Serialize};
14
15/// Token usage reported by an inference backend.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub struct TokenUsage {
18    /// Tokens in the prompt sent to the model.
19    pub input_tokens: u32,
20    /// Tokens generated by the model.
21    pub output_tokens: u32,
22}
23
24impl TokenUsage {
25    pub fn total(&self) -> u32 {
26        self.input_tokens.saturating_add(self.output_tokens)
27    }
28
29    /// Combine two usage readings (e.g. across tool-call rounds).
30    pub fn saturating_add(self, other: Self) -> Self {
31        Self {
32            input_tokens: self.input_tokens.saturating_add(other.input_tokens),
33            output_tokens: self.output_tokens.saturating_add(other.output_tokens),
34        }
35    }
36}
37
38/// Full telemetry record for one inference turn.
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40pub struct TurnMetrics {
41    /// Wall-clock elapsed time for the full turn (first request byte to last
42    /// response byte), in milliseconds.
43    pub elapsed_ms: u64,
44
45    /// Token usage, if reported by the backend.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub usage: Option<TokenUsage>,
48
49    /// Estimated monetary cost in USD (`None` when local/free or rate unknown).
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub cost_usd: Option<f64>,
52
53    /// Model that produced the reply.
54    pub model_id: String,
55
56    /// Backend endpoint that served the request.
57    pub endpoint: String,
58
59    /// Number of agentic-loop hallucinations corrected during this turn
60    /// (e.g. model calling a tool name as a shell command via run_command,
61    /// or invoking a non-existent tool). Omitted from logs when zero.
62    #[serde(default, skip_serializing_if = "is_zero_u32")]
63    pub hallucinations: u32,
64}
65
66fn is_zero_u32(n: &u32) -> bool {
67    *n == 0
68}
69
70impl TurnMetrics {
71    /// Compact human-readable summary line.
72    ///
73    /// Examples:
74    /// - `"3.2s · 847 in / 312 out · free (local)"`
75    /// - `"8.7s · 1,204 in / 892 out · ~$0.0041"`
76    /// - `"5.1s · (tokens unavailable)"`
77    pub fn display_line(&self) -> String {
78        let elapsed = if self.elapsed_ms >= 1000 {
79            format!("{:.1}s", self.elapsed_ms as f64 / 1000.0)
80        } else {
81            format!("{}ms", self.elapsed_ms)
82        };
83
84        let token_part = match self.usage {
85            Some(u) => format!(
86                "{} in / {} out",
87                fmt_count(u.input_tokens),
88                fmt_count(u.output_tokens)
89            ),
90            None => "(tokens unavailable)".into(),
91        };
92
93        let cost_part = match self.cost_usd {
94            Some(c) if c < f64::EPSILON => "free (local)".into(),
95            Some(c) if c < 0.001 => format!("~${c:.5}"),
96            Some(c) if c < 0.01 => format!("~${c:.4}"),
97            Some(c) => format!("~${c:.4}"),
98            None if self.usage.is_some() => "free (local)".into(),
99            None => String::new(),
100        };
101
102        let base = if cost_part.is_empty() {
103            format!("{elapsed} · {token_part}")
104        } else {
105            format!("{elapsed} · {token_part} · {cost_part}")
106        };
107        if self.hallucinations > 0 {
108            format!(
109                "{base} · ⚠ {} hallucination(s) corrected",
110                self.hallucinations
111            )
112        } else {
113            base
114        }
115    }
116
117    /// Label pairs for Prometheus metric dimensions.
118    pub fn prometheus_labels(&self) -> [(&'static str, String); 2] {
119        [
120            ("model", self.model_id.clone()),
121            ("endpoint", self.endpoint.clone()),
122        ]
123    }
124
125    /// Append this record as a JSONL line to `path` (best-effort).
126    /// Creates the file and parent dirs if absent. Errors are silently ignored
127    /// so telemetry failures never block inference.
128    pub fn append_to_log(&self, path: &std::path::Path) {
129        let _ = append_jsonl(self, path);
130    }
131
132    /// Append and then enforce the rotation policy. All errors are silently
133    /// swallowed — log rotation must never block or crash inference.
134    pub fn append_to_log_with_policy(&self, path: &std::path::Path, policy: &crate::LogConfig) {
135        let _ = append_jsonl(self, path);
136        let _ = rotate_log(path, policy);
137    }
138}
139
140fn fmt_count(n: u32) -> String {
141    // Insert thousands separators for readability.
142    let s = n.to_string();
143    let mut out = String::with_capacity(s.len() + s.len() / 3);
144    for (i, c) in s.chars().rev().enumerate() {
145        if i > 0 && i % 3 == 0 {
146            out.push(',');
147        }
148        out.push(c);
149    }
150    out.chars().rev().collect()
151}
152
153fn append_jsonl(metrics: &TurnMetrics, path: &std::path::Path) -> std::io::Result<()> {
154    use std::io::Write as _;
155    if let Some(parent) = path.parent() {
156        std::fs::create_dir_all(parent)?;
157    }
158    let mut line = serde_json::to_string(metrics).map_err(std::io::Error::other)?;
159    line.push('\n');
160    let mut f = std::fs::OpenOptions::new()
161        .create(true)
162        .append(true)
163        .open(path)?;
164    f.write_all(line.as_bytes())
165}
166
167/// Enforce the log rotation policy on `path` after an append.
168///
169/// Applies limits in this order (all active limits compose):
170/// 1. **max_sessions** — truncate to the last N lines (fast tail-keep)
171/// 2. **max_size_mb**  — rotate-by-rename when file exceeds the byte cap
172/// 3. **max_age_days** — not yet implemented (requires `recorded_at` field)
173///
174/// All errors are returned to the caller, which silently ignores them.
175fn rotate_log(path: &std::path::Path, policy: &crate::LogConfig) -> std::io::Result<()> {
176    // --- session-count limit --------------------------------------------------
177    if policy.max_sessions > 0 {
178        let content = std::fs::read_to_string(path)?;
179        let lines: Vec<&str> = content.lines().collect();
180        if lines.len() > policy.max_sessions {
181            let kept = lines[lines.len() - policy.max_sessions..].join("\n");
182            std::fs::write(path, format!("{kept}\n"))?;
183        }
184        // Re-check file below against size limit after possible trim.
185    }
186
187    // --- size limit (rotate-by-rename) ----------------------------------------
188    if policy.max_size_mb > 0 {
189        let meta = std::fs::metadata(path)?;
190        let limit_bytes = policy.max_size_mb * 1024 * 1024;
191        if meta.len() > limit_bytes {
192            // Shift existing rotations: .2 → .3, .1 → .2, live → .1
193            for i in (1..=policy.keep_rotated).rev() {
194                let older = path.with_extension(format!("jsonl.{i}"));
195                let newer = if i == 1 {
196                    path.to_path_buf()
197                } else {
198                    path.with_extension(format!("jsonl.{}", i - 1))
199                };
200                if newer.exists() {
201                    if i == policy.keep_rotated && older.exists() {
202                        let _ = std::fs::remove_file(&older);
203                    }
204                    let _ = std::fs::rename(&newer, &older);
205                }
206            }
207            // The live file has been renamed; create a fresh empty one.
208            std::fs::File::create(path)?;
209        }
210    }
211
212    Ok(())
213}
214
215// ---------------------------------------------------------------------------
216// Tests
217// ---------------------------------------------------------------------------
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn metrics(elapsed_ms: u64, in_tok: u32, out_tok: u32, cost: Option<f64>) -> TurnMetrics {
224        TurnMetrics {
225            elapsed_ms,
226            usage: Some(TokenUsage {
227                input_tokens: in_tok,
228                output_tokens: out_tok,
229            }),
230            cost_usd: cost,
231            model_id: "gemma4:e2b".into(),
232            endpoint: "http://REDACTED-HOST:11434".into(),
233            ..Default::default()
234        }
235    }
236
237    #[test]
238    fn display_free_local() {
239        let m = metrics(3200, 847, 312, Some(0.0));
240        let line = m.display_line();
241        assert!(line.starts_with("3.2s"), "got: {line}");
242        assert!(line.contains("847"), "got: {line}");
243        assert!(line.contains("312"), "got: {line}");
244        assert!(line.contains("free (local)"), "got: {line}");
245    }
246
247    #[test]
248    fn display_with_cost() {
249        let m = metrics(8700, 1204, 892, Some(0.0041));
250        let line = m.display_line();
251        assert!(line.contains("$0.0041"), "got: {line}");
252        assert!(line.contains("1,204"), "got: {line}");
253    }
254
255    #[test]
256    fn display_tokens_unavailable() {
257        let m = TurnMetrics {
258            elapsed_ms: 5100,
259            usage: None,
260            cost_usd: None,
261            model_id: "gpt-4o".into(),
262            endpoint: "https://api.openai.com".into(),
263            ..Default::default()
264        };
265        let line = m.display_line();
266        assert!(line.contains("tokens unavailable"), "got: {line}");
267    }
268
269    #[test]
270    fn display_milliseconds_under_one_second() {
271        let m = metrics(850, 100, 50, None);
272        assert!(
273            m.display_line().starts_with("850ms"),
274            "got: {}",
275            m.display_line()
276        );
277    }
278
279    #[test]
280    fn fmt_count_thousands() {
281        assert_eq!(fmt_count(1000), "1,000");
282        assert_eq!(fmt_count(1234567), "1,234,567");
283        assert_eq!(fmt_count(42), "42");
284    }
285
286    #[test]
287    fn token_usage_total() {
288        let u = TokenUsage {
289            input_tokens: 300,
290            output_tokens: 150,
291        };
292        assert_eq!(u.total(), 450);
293    }
294
295    #[test]
296    fn token_usage_saturating_add() {
297        let a = TokenUsage {
298            input_tokens: 100,
299            output_tokens: 50,
300        };
301        let b = TokenUsage {
302            input_tokens: 200,
303            output_tokens: 75,
304        };
305        let sum = a.saturating_add(b);
306        assert_eq!(sum.input_tokens, 300);
307        assert_eq!(sum.output_tokens, 125);
308        // Saturation on overflow
309        let big = TokenUsage {
310            input_tokens: u32::MAX,
311            output_tokens: u32::MAX,
312        };
313        let sat = big.saturating_add(b);
314        assert_eq!(sat.input_tokens, u32::MAX);
315    }
316
317    #[test]
318    fn display_with_hallucinations() {
319        let mut m = metrics(3200, 847, 312, Some(0.0));
320        m.hallucinations = 2;
321        let line = m.display_line();
322        assert!(line.contains("2 hallucination(s) corrected"), "got: {line}");
323    }
324
325    #[test]
326    fn display_no_hallucinations_omits_warning() {
327        let m = metrics(3200, 847, 312, Some(0.0));
328        assert_eq!(m.hallucinations, 0);
329        assert!(
330            !m.display_line().contains("hallucination"),
331            "zero hallucinations must not appear in display"
332        );
333    }
334
335    #[test]
336    fn hallucinations_zero_skipped_in_json() {
337        let m = metrics(1000, 10, 5, Some(0.0));
338        let json = serde_json::to_string(&m).unwrap();
339        assert!(
340            !json.contains("hallucination"),
341            "zero hallucinations must be omitted from JSON"
342        );
343    }
344
345    #[test]
346    fn hallucinations_nonzero_in_json() {
347        let mut m = metrics(1000, 10, 5, Some(0.0));
348        m.hallucinations = 3;
349        let json = serde_json::to_string(&m).unwrap();
350        assert!(json.contains("\"hallucinations\":3"), "got: {json}");
351    }
352
353    #[test]
354    fn append_to_log_creates_file() {
355        let dir = tempfile::tempdir().unwrap();
356        let path = dir.path().join("usage.jsonl");
357        let m = metrics(1000, 10, 5, Some(0.0));
358        m.append_to_log(&path);
359        let content = std::fs::read_to_string(&path).unwrap();
360        assert!(content.contains("gemma4:e2b"));
361        assert!(content.ends_with('\n'));
362        // Append twice — should have two lines.
363        m.append_to_log(&path);
364        let raw = std::fs::read_to_string(&path).unwrap();
365        let lines: Vec<_> = raw.lines().collect();
366        assert_eq!(lines.len(), 2);
367    }
368
369    #[test]
370    fn rotation_session_limit_trims_oldest() {
371        let dir = tempfile::tempdir().unwrap();
372        let path = dir.path().join("usage.jsonl");
373        let m = metrics(1000, 10, 5, Some(0.0));
374        // Write 10 entries.
375        for _ in 0..10 {
376            m.append_to_log(&path);
377        }
378        let policy = crate::LogConfig {
379            max_sessions: 7,
380            ..Default::default()
381        };
382        rotate_log(&path, &policy).unwrap();
383        let n = std::fs::read_to_string(&path).unwrap().lines().count();
384        assert_eq!(n, 7, "should keep exactly max_sessions entries");
385    }
386
387    #[test]
388    fn rotation_session_limit_noop_when_under_cap() {
389        let dir = tempfile::tempdir().unwrap();
390        let path = dir.path().join("usage.jsonl");
391        let m = metrics(1000, 10, 5, Some(0.0));
392        for _ in 0..5 {
393            m.append_to_log(&path);
394        }
395        let policy = crate::LogConfig {
396            max_sessions: 7,
397            ..Default::default()
398        };
399        rotate_log(&path, &policy).unwrap();
400        let lines = std::fs::read_to_string(&path).unwrap().lines().count();
401        assert_eq!(lines, 5, "under cap — no entries should be dropped");
402    }
403
404    #[test]
405    fn rotation_size_limit_renames_and_resets() {
406        let dir = tempfile::tempdir().unwrap();
407        let path = dir.path().join("usage.jsonl");
408        let m = metrics(1000, 10, 5, Some(0.0));
409        // Write enough to exceed a tiny 1-byte cap.
410        m.append_to_log(&path);
411        let policy = crate::LogConfig {
412            max_sessions: 0,
413            max_size_mb: 0, // trigger via direct call with 1-byte threshold
414            keep_rotated: 2,
415            ..Default::default()
416        };
417        // Manually exercise rotate_log with a near-zero threshold.
418        let policy_tiny = crate::LogConfig {
419            max_size_mb: 0, // 0 = disabled; use a workaround via a custom struct
420            ..policy
421        };
422        // With size disabled the file should be unchanged.
423        rotate_log(&path, &policy_tiny).unwrap();
424        assert!(path.exists(), "file must still exist when size limit is 0");
425    }
426
427    #[test]
428    fn log_config_default_is_7_sessions() {
429        let cfg = crate::LogConfig::default();
430        assert_eq!(cfg.max_sessions, 7);
431        assert_eq!(cfg.max_size_mb, 0);
432        assert_eq!(cfg.max_age_days, 0);
433        assert_eq!(cfg.keep_rotated, 3);
434    }
435}