lc/analytics/
usage_stats.rs

1use crate::database::{ChatEntry, Database};
2use anyhow::Result;
3use chrono::{DateTime, Datelike, Duration, Utc};
4use colored::Colorize;
5use std::collections::HashMap;
6
7#[derive(Debug, Clone)]
8pub struct UsageStats {
9    pub total_tokens: u64,
10    pub total_requests: u64,
11    pub input_tokens: u64,
12    pub output_tokens: u64,
13    pub model_usage: Vec<(String, u64, u64)>, // (model, requests, tokens)
14    pub daily_usage: Vec<(String, u64, u64)>, // (date, requests, tokens)
15    pub weekly_usage: Vec<(String, u64, u64)>, // (week, requests, tokens)
16    pub monthly_usage: Vec<(String, u64, u64)>, // (month, requests, tokens)
17    pub yearly_usage: Vec<(String, u64, u64)>, // (year, requests, tokens)
18    pub date_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
19}
20
21#[derive(Debug, Clone)]
22#[allow(dead_code)]
23pub enum TimeFrame {
24    Daily,
25    Weekly,
26    Monthly,
27    Yearly,
28}
29
30pub struct UsageAnalyzer {
31    db: Database,
32}
33
34impl UsageAnalyzer {
35    pub fn new() -> Result<Self> {
36        Ok(Self {
37            db: Database::new()?,
38        })
39    }
40
41    pub fn get_usage_stats(&self, days_back: Option<u32>) -> Result<UsageStats> {
42        let entries = if let Some(days) = days_back {
43            let cutoff_date = Utc::now() - Duration::days(days as i64);
44            self.get_entries_since(cutoff_date)?
45        } else {
46            self.db.get_all_logs()?
47        };
48
49        if entries.is_empty() {
50            return Ok(UsageStats {
51                total_tokens: 0,
52                total_requests: 0,
53                input_tokens: 0,
54                output_tokens: 0,
55                model_usage: Vec::new(),
56                daily_usage: Vec::new(),
57                weekly_usage: Vec::new(),
58                monthly_usage: Vec::new(),
59                yearly_usage: Vec::new(),
60                date_range: None,
61            });
62        }
63
64        let mut total_input_tokens = 0u64;
65        let mut total_output_tokens = 0u64;
66        let mut model_stats: HashMap<String, (u64, u64)> = HashMap::new(); // (requests, tokens)
67        let mut daily_stats: HashMap<String, (u64, u64)> = HashMap::new();
68        let mut weekly_stats: HashMap<String, (u64, u64)> = HashMap::new();
69        let mut monthly_stats: HashMap<String, (u64, u64)> = HashMap::new();
70        let mut yearly_stats: HashMap<String, (u64, u64)> = HashMap::new();
71
72        let mut earliest_date = entries[0].timestamp;
73        let mut latest_date = entries[0].timestamp;
74
75        for entry in &entries {
76            // Update date range
77            if entry.timestamp < earliest_date {
78                earliest_date = entry.timestamp;
79            }
80            if entry.timestamp > latest_date {
81                latest_date = entry.timestamp;
82            }
83
84            // Calculate tokens
85            let input_tokens = entry.input_tokens.unwrap_or(0) as u64;
86            let output_tokens = entry.output_tokens.unwrap_or(0) as u64;
87            let total_entry_tokens = input_tokens + output_tokens;
88
89            total_input_tokens += input_tokens;
90            total_output_tokens += output_tokens;
91
92            // Model usage
93            let model_entry = model_stats.entry(entry.model.clone()).or_insert((0, 0));
94            model_entry.0 += 1; // requests
95            model_entry.1 += total_entry_tokens; // tokens
96
97            // Time-based usage
98            let date = entry.timestamp.date_naive();
99            let daily_key = date.format("%Y-%m-%d").to_string();
100            let daily_entry = daily_stats.entry(daily_key).or_insert((0, 0));
101            daily_entry.0 += 1;
102            daily_entry.1 += total_entry_tokens;
103
104            // Weekly usage (ISO week)
105            let year = entry.timestamp.year();
106            let week = entry.timestamp.iso_week().week();
107            let weekly_key = format!("{}-W{:02}", year, week);
108            let weekly_entry = weekly_stats.entry(weekly_key).or_insert((0, 0));
109            weekly_entry.0 += 1;
110            weekly_entry.1 += total_entry_tokens;
111
112            // Monthly usage
113            let monthly_key = date.format("%Y-%m").to_string();
114            let monthly_entry = monthly_stats.entry(monthly_key).or_insert((0, 0));
115            monthly_entry.0 += 1;
116            monthly_entry.1 += total_entry_tokens;
117
118            // Yearly usage
119            let yearly_key = year.to_string();
120            let yearly_entry = yearly_stats.entry(yearly_key).or_insert((0, 0));
121            yearly_entry.0 += 1;
122            yearly_entry.1 += total_entry_tokens;
123        }
124
125        // Convert to sorted vectors
126        let mut model_usage: Vec<(String, u64, u64)> = model_stats
127            .into_iter()
128            .map(|(model, (requests, tokens))| (model, requests, tokens))
129            .collect();
130        model_usage.sort_by(|a, b| b.2.cmp(&a.2)); // Sort by tokens descending
131
132        let mut daily_usage: Vec<(String, u64, u64)> = daily_stats
133            .into_iter()
134            .map(|(date, (requests, tokens))| (date, requests, tokens))
135            .collect();
136        daily_usage.sort_by(|a, b| a.0.cmp(&b.0)); // Sort by date ascending
137
138        let mut weekly_usage: Vec<(String, u64, u64)> = weekly_stats
139            .into_iter()
140            .map(|(week, (requests, tokens))| (week, requests, tokens))
141            .collect();
142        weekly_usage.sort_by(|a, b| a.0.cmp(&b.0));
143
144        let mut monthly_usage: Vec<(String, u64, u64)> = monthly_stats
145            .into_iter()
146            .map(|(month, (requests, tokens))| (month, requests, tokens))
147            .collect();
148        monthly_usage.sort_by(|a, b| a.0.cmp(&b.0));
149
150        let mut yearly_usage: Vec<(String, u64, u64)> = yearly_stats
151            .into_iter()
152            .map(|(year, (requests, tokens))| (year, requests, tokens))
153            .collect();
154        yearly_usage.sort_by(|a, b| a.0.cmp(&b.0));
155
156        Ok(UsageStats {
157            total_tokens: total_input_tokens + total_output_tokens,
158            total_requests: entries.len() as u64,
159            input_tokens: total_input_tokens,
160            output_tokens: total_output_tokens,
161            model_usage,
162            daily_usage,
163            weekly_usage,
164            monthly_usage,
165            yearly_usage,
166            date_range: Some((earliest_date, latest_date)),
167        })
168    }
169
170    fn get_entries_since(&self, cutoff_date: DateTime<Utc>) -> Result<Vec<ChatEntry>> {
171        // This would need a custom query in the database
172        // For now, we'll filter after getting all entries
173        let all_entries = self.db.get_all_logs()?;
174        Ok(all_entries
175            .into_iter()
176            .filter(|entry| entry.timestamp >= cutoff_date)
177            .collect())
178    }
179}
180
181pub struct BarChart;
182
183impl BarChart {
184    pub fn render_horizontal(
185        title: &str,
186        data: &[(String, u64, u64)],
187        value_type: &str, // "tokens" or "requests"
188        max_width: usize,
189        max_items: usize,
190    ) {
191        if data.is_empty() {
192            println!("{} No data available", "ℹ️".blue());
193            return;
194        }
195
196        println!("\n{}", title.bold().blue());
197
198        let display_data: Vec<_> = data.iter().take(max_items).collect();
199        let max_value = display_data
200            .iter()
201            .map(|(_, requests, tokens)| {
202                if value_type == "tokens" {
203                    *tokens
204                } else {
205                    *requests
206                }
207            })
208            .max()
209            .unwrap_or(1);
210
211        let max_label_width = display_data
212            .iter()
213            .map(|(label, _, _)| label.len())
214            .max()
215            .unwrap_or(10);
216
217        for (label, requests, tokens) in display_data {
218            let value = if value_type == "tokens" {
219                *tokens
220            } else {
221                *requests
222            };
223            let bar_width = if max_value > 0 {
224                ((value as f64 / max_value as f64) * max_width as f64) as usize
225            } else {
226                0
227            };
228
229            let bar = "█".repeat(bar_width);
230            let formatted_value = if value_type == "tokens" {
231                Self::format_tokens(*tokens)
232            } else {
233                format!("{}", requests)
234            };
235
236            println!(
237                "  {:width$} │{:bar_width$} {} ({})",
238                label.bold(),
239                bar.green(),
240                formatted_value.yellow(),
241                if value_type == "tokens" {
242                    format!("{} req", requests)
243                } else {
244                    Self::format_tokens(*tokens)
245                },
246                width = max_label_width,
247                bar_width = max_width
248            );
249        }
250    }
251
252    pub fn render_time_series(
253        title: &str,
254        data: &[(String, u64, u64)],
255        value_type: &str,
256        max_width: usize,
257        max_items: usize,
258    ) {
259        if data.is_empty() {
260            println!("{} No data available", "ℹ️".blue());
261            return;
262        }
263
264        println!("\n{}", title.bold().blue());
265
266        let display_data: Vec<_> = data.iter().rev().take(max_items).rev().collect();
267        let max_value = display_data
268            .iter()
269            .map(|(_, requests, tokens)| {
270                if value_type == "tokens" {
271                    *tokens
272                } else {
273                    *requests
274                }
275            })
276            .max()
277            .unwrap_or(1);
278
279        let max_label_width = display_data
280            .iter()
281            .map(|(label, _, _)| label.len())
282            .max()
283            .unwrap_or(10);
284
285        for (label, requests, tokens) in display_data {
286            let value = if value_type == "tokens" {
287                *tokens
288            } else {
289                *requests
290            };
291            let bar_width = if max_value > 0 {
292                ((value as f64 / max_value as f64) * max_width as f64) as usize
293            } else {
294                0
295            };
296
297            let bar = "▓".repeat(bar_width);
298            let formatted_value = if value_type == "tokens" {
299                Self::format_tokens(*tokens)
300            } else {
301                format!("{}", requests)
302            };
303
304            println!(
305                "  {:width$} │{:bar_width$} {} ({})",
306                label.bold(),
307                bar.cyan(),
308                formatted_value.yellow(),
309                if value_type == "tokens" {
310                    format!("{} req", requests)
311                } else {
312                    Self::format_tokens(*tokens)
313                },
314                width = max_label_width,
315                bar_width = max_width
316            );
317        }
318    }
319
320    fn format_tokens(tokens: u64) -> String {
321        if tokens >= 1_000_000 {
322            format!("{:.1}M", tokens as f64 / 1_000_000.0)
323        } else if tokens >= 1_000 {
324            format!("{:.1}k", tokens as f64 / 1_000.0)
325        } else {
326            format!("{}", tokens)
327        }
328    }
329}
330
331pub fn display_usage_overview(stats: &UsageStats) {
332    println!("\n{}", "📊 Usage Overview".bold().blue());
333    println!();
334
335    // Basic stats
336    println!(
337        "{} {}",
338        "Total Requests:".bold(),
339        stats.total_requests.to_string().green()
340    );
341    println!(
342        "{} {}",
343        "Total Tokens:".bold(),
344        BarChart::format_tokens(stats.total_tokens).green()
345    );
346    println!(
347        "{} {}",
348        "Input Tokens:".bold(),
349        BarChart::format_tokens(stats.input_tokens).cyan()
350    );
351    println!(
352        "{} {}",
353        "Output Tokens:".bold(),
354        BarChart::format_tokens(stats.output_tokens).yellow()
355    );
356
357    if let Some((earliest, latest)) = stats.date_range {
358        let duration = latest.signed_duration_since(earliest);
359        println!(
360            "{} {} to {} ({} days)",
361            "Date Range:".bold(),
362            earliest.format("%Y-%m-%d").to_string().dimmed(),
363            latest.format("%Y-%m-%d").to_string().dimmed(),
364            duration.num_days().max(1)
365        );
366    }
367
368    // Average tokens per request
369    if stats.total_requests > 0 {
370        let avg_tokens = stats.total_tokens / stats.total_requests;
371        let avg_input = stats.input_tokens / stats.total_requests;
372        let avg_output = stats.output_tokens / stats.total_requests;
373        println!();
374        println!("{}", "📈 Averages per Request".bold().blue());
375        println!(
376            "{} {}",
377            "Total Tokens:".bold(),
378            BarChart::format_tokens(avg_tokens).green()
379        );
380        println!(
381            "{} {}",
382            "Input Tokens:".bold(),
383            BarChart::format_tokens(avg_input).cyan()
384        );
385        println!(
386            "{} {}",
387            "Output Tokens:".bold(),
388            BarChart::format_tokens(avg_output).yellow()
389        );
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_usage_analyzer_creation() {
399        // Test that creating a UsageAnalyzer works on all platforms
400        // Note: This may fail if database setup fails, which is acceptable for this test
401        let _result = UsageAnalyzer::new();
402        // We don't assert success here because database setup might fail in test environment
403        // but we're primarily testing the compilation path
404    }
405
406    #[test]
407    fn test_usage_stats_struct_creation() {
408        // Test that we can create empty UsageStats (should work on all platforms)
409        let stats = UsageStats {
410            total_tokens: 0,
411            total_requests: 0,
412            input_tokens: 0,
413            output_tokens: 0,
414            model_usage: Vec::new(),
415            daily_usage: Vec::new(),
416            weekly_usage: Vec::new(),
417            monthly_usage: Vec::new(),
418            yearly_usage: Vec::new(),
419            date_range: None,
420        };
421
422        assert_eq!(stats.total_tokens, 0);
423        assert_eq!(stats.total_requests, 0);
424        assert!(stats.model_usage.is_empty());
425    }
426
427    #[test]
428    fn test_bar_chart_format_tokens() {
429        // Test token formatting function (should work on all platforms)
430        assert_eq!(BarChart::format_tokens(500), "500");
431        assert_eq!(BarChart::format_tokens(1500), "1.5k");
432        assert_eq!(BarChart::format_tokens(1_500_000), "1.5M");
433    }
434}