Skip to main content

chub_core/team/
analytics.rs

1use std::fs;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::config::chub_dir;
7
8/// A single fetch event in the analytics log.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct FetchEvent {
11    pub entry_id: String,
12    pub timestamp: u64,
13    #[serde(default)]
14    pub agent: Option<String>,
15}
16
17/// Usage statistics summary.
18#[derive(Debug, Clone, Serialize)]
19pub struct UsageStats {
20    pub most_fetched: Vec<(String, usize)>,
21    pub never_fetched_pins: Vec<String>,
22    pub total_fetches: usize,
23    pub period_days: u64,
24}
25
26fn analytics_path() -> PathBuf {
27    chub_dir().join("analytics.jsonl")
28}
29
30/// Record a doc fetch event.
31pub fn record_fetch(entry_id: &str, agent: Option<&str>) {
32    let event = FetchEvent {
33        entry_id: entry_id.to_string(),
34        timestamp: std::time::SystemTime::now()
35            .duration_since(std::time::UNIX_EPOCH)
36            .unwrap_or_default()
37            .as_secs(),
38        agent: agent.map(|s| s.to_string()),
39    };
40
41    let path = analytics_path();
42    let _ = fs::create_dir_all(path.parent().unwrap_or(&PathBuf::from(".")));
43
44    // Append as JSONL (one JSON object per line)
45    let line = serde_json::to_string(&event).unwrap_or_default();
46    let mut file = fs::OpenOptions::new().create(true).append(true).open(&path);
47
48    if let Ok(ref mut f) = file {
49        use std::io::Write;
50        let _ = writeln!(f, "{}", line);
51    }
52}
53
54/// Load all fetch events.
55fn load_events() -> Vec<FetchEvent> {
56    let path = analytics_path();
57    if !path.exists() {
58        return vec![];
59    }
60
61    let content = match fs::read_to_string(&path) {
62        Ok(c) => c,
63        Err(_) => return vec![],
64    };
65
66    content
67        .lines()
68        .filter(|l| !l.trim().is_empty())
69        .filter_map(|l| serde_json::from_str(l).ok())
70        .collect()
71}
72
73/// Compute usage statistics for the last N days.
74pub fn get_stats(days: u64) -> UsageStats {
75    let events = load_events();
76    let now = std::time::SystemTime::now()
77        .duration_since(std::time::UNIX_EPOCH)
78        .unwrap_or_default()
79        .as_secs();
80    let cutoff = now.saturating_sub(days * 86400);
81
82    let recent: Vec<&FetchEvent> = events.iter().filter(|e| e.timestamp >= cutoff).collect();
83
84    // Count fetches per entry
85    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
86    for event in &recent {
87        *counts.entry(event.entry_id.clone()).or_insert(0) += 1;
88    }
89
90    let mut most_fetched: Vec<(String, usize)> = counts.into_iter().collect();
91    most_fetched.sort_by(|a, b| b.1.cmp(&a.1));
92
93    // Find pinned but never-fetched
94    let pins = crate::team::pins::list_pins();
95    let fetched_ids: std::collections::HashSet<&str> =
96        recent.iter().map(|e| e.entry_id.as_str()).collect();
97
98    let never_fetched_pins: Vec<String> = pins
99        .iter()
100        .filter(|p| !fetched_ids.contains(p.id.as_str()))
101        .map(|p| p.id.clone())
102        .collect();
103
104    UsageStats {
105        total_fetches: recent.len(),
106        most_fetched,
107        never_fetched_pins,
108        period_days: days,
109    }
110}