Skip to main content

opencrabs/rtk/
tracker.rs

1//! RTK token savings tracking and metrics
2//!
3//! This module provides functionality to track and report token savings achieved
4//! through RTK command filtering. It maintains metrics per command and provides
5//! aggregate statistics.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::sync::{Arc, OnceLock};
11use tokio::sync::Mutex;
12
13/// Global RTK tracker instance
14static GLOBAL_TRACKER: OnceLock<Arc<RtkTracker>> = OnceLock::new();
15
16/// Get the global RTK tracker instance
17pub fn global_tracker() -> Arc<RtkTracker> {
18    GLOBAL_TRACKER
19        .get_or_init(|| Arc::new(RtkTracker::new()))
20        .clone()
21}
22
23/// Token savings for a single command execution
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct TokenSavings {
26    /// Original command that was executed
27    pub command: String,
28    /// Rewritten command (with rtk prefix)
29    pub rewritten_command: String,
30    /// Estimated tokens in original output
31    pub original_tokens: usize,
32    /// Actual tokens in filtered output
33    pub filtered_tokens: usize,
34    /// Tokens saved (original - filtered)
35    pub tokens_saved: usize,
36    /// Percentage savings (0-100)
37    pub savings_percent: f64,
38    /// When the command was executed
39    pub timestamp: DateTime<Utc>,
40}
41
42/// Aggregate RTK metrics across all command executions
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct RtkMetrics {
45    /// Total commands executed through RTK
46    pub total_commands: usize,
47    /// Total tokens saved across all commands
48    pub total_tokens_saved: usize,
49    /// Average savings percentage
50    pub average_savings_percent: f64,
51    /// Savings breakdown by command type (first word)
52    pub savings_by_command: HashMap<String, CommandSavings>,
53    /// Recent savings history (last 100 commands)
54    pub recent_savings: Vec<TokenSavings>,
55    /// When metrics tracking started
56    pub tracking_since: DateTime<Utc>,
57}
58
59/// Savings statistics for a specific command type
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct CommandSavings {
62    /// Number of times this command was executed
63    pub execution_count: usize,
64    /// Total tokens saved for this command type
65    pub total_tokens_saved: usize,
66    /// Average savings percentage for this command
67    pub average_savings_percent: f64,
68}
69
70/// Thread-safe metrics tracker
71#[derive(Debug, Clone)]
72pub struct RtkTracker {
73    metrics: Arc<Mutex<RtkMetrics>>,
74}
75
76impl RtkTracker {
77    /// Create a new metrics tracker
78    pub fn new() -> Self {
79        Self {
80            metrics: Arc::new(Mutex::new(RtkMetrics {
81                total_commands: 0,
82                total_tokens_saved: 0,
83                average_savings_percent: 0.0,
84                savings_by_command: HashMap::new(),
85                recent_savings: Vec::new(),
86                tracking_since: Utc::now(),
87            })),
88        }
89    }
90
91    /// Record token savings for a command execution
92    ///
93    /// Async because it acquires a `tokio::sync::Mutex`. The critical section
94    /// is microseconds long, but using a sync `std::sync::Mutex` in an async
95    /// path is the same class of bug that hung the daemon in issue #125 — the
96    /// `which rtk` blocking process call. Defense in depth: never reach for a
97    /// sync primitive from inside `bash.rs`'s async execute path.
98    ///
99    /// # Arguments
100    /// * `savings` - The token savings data for this execution
101    pub async fn record_savings(&self, savings: TokenSavings) {
102        let mut metrics = self.metrics.lock().await;
103
104        // Update totals
105        metrics.total_commands += 1;
106        metrics.total_tokens_saved += savings.tokens_saved;
107
108        // Update average savings percentage
109        let total_percent: f64 = metrics
110            .recent_savings
111            .iter()
112            .map(|s| s.savings_percent)
113            .sum::<f64>()
114            + savings.savings_percent;
115        let count = metrics.recent_savings.len() + 1;
116        metrics.average_savings_percent = total_percent / count as f64;
117
118        // Update per-command statistics
119        let command_type = savings
120            .command
121            .split_whitespace()
122            .next()
123            .unwrap_or("unknown")
124            .to_string();
125
126        let entry = metrics
127            .savings_by_command
128            .entry(command_type)
129            .or_insert_with(|| CommandSavings {
130                execution_count: 0,
131                total_tokens_saved: 0,
132                average_savings_percent: 0.0,
133            });
134
135        entry.execution_count += 1;
136        entry.total_tokens_saved += savings.tokens_saved;
137
138        // Update command-specific average using running total
139        let cmd_total_percent: f64 = entry.average_savings_percent
140            * (entry.execution_count - 1) as f64
141            + savings.savings_percent;
142        entry.average_savings_percent = cmd_total_percent / entry.execution_count as f64;
143
144        // Add to recent history (keep last 100)
145        metrics.recent_savings.push(savings);
146        if metrics.recent_savings.len() > 100 {
147            metrics.recent_savings.remove(0);
148        }
149    }
150
151    /// Get current metrics snapshot
152    pub async fn get_metrics(&self) -> RtkMetrics {
153        self.metrics.lock().await.clone()
154    }
155
156    /// Get total tokens saved
157    pub async fn total_tokens_saved(&self) -> usize {
158        self.metrics.lock().await.total_tokens_saved
159    }
160
161    /// Get total commands executed
162    pub async fn total_commands(&self) -> usize {
163        self.metrics.lock().await.total_commands
164    }
165
166    /// Get average savings percentage
167    pub async fn average_savings_percent(&self) -> f64 {
168        self.metrics.lock().await.average_savings_percent
169    }
170
171    /// Format metrics as a human-readable string for display
172    pub async fn format_report(&self) -> String {
173        let metrics = self.get_metrics().await;
174
175        let mut report = String::new();
176        report.push_str("═══ RTK Token Savings Report ═══\n\n");
177
178        report.push_str(&format!("Total Commands: {}\n", metrics.total_commands));
179        report.push_str(&format!(
180            "Total Tokens Saved: {}\n",
181            metrics.total_tokens_saved
182        ));
183        report.push_str(&format!(
184            "Average Savings: {:.1}%\n",
185            metrics.average_savings_percent
186        ));
187        report.push_str(&format!(
188            "Tracking Since: {}\n\n",
189            metrics.tracking_since.format("%Y-%m-%d %H:%M:%S UTC")
190        ));
191
192        report.push_str("Savings by Command Type:\n");
193        let mut sorted_commands: Vec<_> = metrics.savings_by_command.iter().collect();
194        sorted_commands.sort_by_key(|b| std::cmp::Reverse(b.1.total_tokens_saved));
195
196        for (cmd, savings) in sorted_commands.iter().take(10) {
197            report.push_str(&format!(
198                "  {}: {} cmds, {} tokens saved, {:.1}% avg\n",
199                cmd,
200                savings.execution_count,
201                savings.total_tokens_saved,
202                savings.average_savings_percent
203            ));
204        }
205
206        report
207    }
208}
209
210impl Default for RtkTracker {
211    fn default() -> Self {
212        Self::new()
213    }
214}