Skip to main content

chub_core/team/
analytics.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::chub_dir;
8
9// ---------------------------------------------------------------------------
10// Event types
11// ---------------------------------------------------------------------------
12
13/// Every tracked event in the local journal.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(tag = "event")]
16pub enum Event {
17    #[serde(rename = "fetch")]
18    Fetch(FetchEvent),
19    #[serde(rename = "search")]
20    Search(SearchEvent),
21    #[serde(rename = "build")]
22    Build(BuildEvent),
23    #[serde(rename = "mcp_call")]
24    McpCall(McpCallEvent),
25    #[serde(rename = "pin")]
26    Pin(PinEvent),
27    #[serde(rename = "annotate")]
28    Annotate(AnnotateEvent),
29    #[serde(rename = "feedback")]
30    Feedback(FeedbackEvent),
31}
32
33impl Event {
34    pub fn timestamp(&self) -> u64 {
35        match self {
36            Event::Fetch(e) => e.timestamp,
37            Event::Search(e) => e.timestamp,
38            Event::Build(e) => e.timestamp,
39            Event::McpCall(e) => e.timestamp,
40            Event::Pin(e) => e.timestamp,
41            Event::Annotate(e) => e.timestamp,
42            Event::Feedback(e) => e.timestamp,
43        }
44    }
45
46    pub fn event_name(&self) -> &'static str {
47        match self {
48            Event::Fetch(_) => "fetch",
49            Event::Search(_) => "search",
50            Event::Build(_) => "build",
51            Event::McpCall(_) => "mcp_call",
52            Event::Pin(_) => "pin",
53            Event::Annotate(_) => "annotate",
54            Event::Feedback(_) => "feedback",
55        }
56    }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct FetchEvent {
61    pub entry_id: String,
62    pub timestamp: u64,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub agent: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub lang: Option<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub cache_hit: Option<bool>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub duration_ms: Option<u64>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SearchEvent {
75    pub query: String,
76    pub timestamp: u64,
77    pub result_count: usize,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub duration_ms: Option<u64>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub agent: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct BuildEvent {
86    pub timestamp: u64,
87    pub doc_count: usize,
88    pub duration_ms: u64,
89    #[serde(default)]
90    pub errors: usize,
91    #[serde(default)]
92    pub validate_only: bool,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct McpCallEvent {
97    pub tool: String,
98    pub timestamp: u64,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub duration_ms: Option<u64>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub agent: Option<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct PinEvent {
107    pub entry_id: String,
108    pub action: String, // "add", "remove"
109    pub timestamp: u64,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct AnnotateEvent {
114    pub entry_id: String,
115    pub kind: String, // "issue", "fix", "practice"
116    pub timestamp: u64,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct FeedbackEvent {
121    pub entry_id: String,
122    pub rating: String,
123    pub timestamp: u64,
124}
125
126// ---------------------------------------------------------------------------
127// Recording
128// ---------------------------------------------------------------------------
129
130fn analytics_path() -> PathBuf {
131    chub_dir().join("analytics.jsonl")
132}
133
134fn now_secs() -> u64 {
135    std::time::SystemTime::now()
136        .duration_since(std::time::UNIX_EPOCH)
137        .unwrap_or_default()
138        .as_secs()
139}
140
141fn append_event(event: &Event) {
142    let path = analytics_path();
143    let _ = fs::create_dir_all(path.parent().unwrap_or(&PathBuf::from(".")));
144
145    let line = serde_json::to_string(event).unwrap_or_default();
146    let mut file = fs::OpenOptions::new().create(true).append(true).open(&path);
147
148    if let Ok(ref mut f) = file {
149        use std::io::Write;
150        let _ = writeln!(f, "{}", line);
151    }
152}
153
154/// Record a doc fetch event (backwards-compatible entry point).
155pub fn record_fetch(entry_id: &str, agent: Option<&str>) {
156    let event = Event::Fetch(FetchEvent {
157        entry_id: entry_id.to_string(),
158        timestamp: now_secs(),
159        agent: agent.map(|s| s.to_string()),
160        lang: None,
161        cache_hit: None,
162        duration_ms: None,
163    });
164    append_event(&event);
165}
166
167/// Record a doc fetch with full metadata.
168pub fn record_fetch_detailed(
169    entry_id: &str,
170    agent: Option<&str>,
171    lang: Option<&str>,
172    cache_hit: Option<bool>,
173    duration_ms: Option<u64>,
174) {
175    let event = Event::Fetch(FetchEvent {
176        entry_id: entry_id.to_string(),
177        timestamp: now_secs(),
178        agent: agent.map(|s| s.to_string()),
179        lang: lang.map(|s| s.to_string()),
180        cache_hit,
181        duration_ms,
182    });
183    append_event(&event);
184}
185
186/// Record a search query.
187pub fn record_search(
188    query: &str,
189    result_count: usize,
190    duration_ms: Option<u64>,
191    agent: Option<&str>,
192) {
193    let event = Event::Search(SearchEvent {
194        query: query.to_string(),
195        timestamp: now_secs(),
196        result_count,
197        duration_ms,
198        agent: agent.map(|s| s.to_string()),
199    });
200    append_event(&event);
201}
202
203/// Record a build event.
204pub fn record_build(doc_count: usize, duration_ms: u64, errors: usize, validate_only: bool) {
205    let event = Event::Build(BuildEvent {
206        timestamp: now_secs(),
207        doc_count,
208        duration_ms,
209        errors,
210        validate_only,
211    });
212    append_event(&event);
213}
214
215/// Record an MCP tool call.
216pub fn record_mcp_call(tool: &str, duration_ms: Option<u64>, agent: Option<&str>) {
217    let event = Event::McpCall(McpCallEvent {
218        tool: tool.to_string(),
219        timestamp: now_secs(),
220        duration_ms,
221        agent: agent.map(|s| s.to_string()),
222    });
223    append_event(&event);
224}
225
226/// Record a pin add/remove.
227pub fn record_pin(entry_id: &str, action: &str) {
228    let event = Event::Pin(PinEvent {
229        entry_id: entry_id.to_string(),
230        action: action.to_string(),
231        timestamp: now_secs(),
232    });
233    append_event(&event);
234}
235
236/// Record an annotation.
237pub fn record_annotate(entry_id: &str, kind: &str) {
238    let event = Event::Annotate(AnnotateEvent {
239        entry_id: entry_id.to_string(),
240        kind: kind.to_string(),
241        timestamp: now_secs(),
242    });
243    append_event(&event);
244}
245
246/// Record a feedback submission.
247pub fn record_feedback(entry_id: &str, rating: &str) {
248    let event = Event::Feedback(FeedbackEvent {
249        entry_id: entry_id.to_string(),
250        rating: rating.to_string(),
251        timestamp: now_secs(),
252    });
253    append_event(&event);
254}
255
256// ---------------------------------------------------------------------------
257// Loading
258// ---------------------------------------------------------------------------
259
260/// Load all events from the journal, parsing both new tagged format and legacy fetch-only lines.
261pub fn load_events() -> Vec<Event> {
262    let path = analytics_path();
263    if !path.exists() {
264        return vec![];
265    }
266
267    let content = match fs::read_to_string(&path) {
268        Ok(c) => c,
269        Err(_) => return vec![],
270    };
271
272    content
273        .lines()
274        .filter(|l| !l.trim().is_empty())
275        .filter_map(|l| {
276            // Try new tagged format first
277            if let Ok(event) = serde_json::from_str::<Event>(l) {
278                return Some(event);
279            }
280            // Fall back to legacy format (plain FetchEvent without "event" tag)
281            if let Ok(legacy) = serde_json::from_str::<LegacyFetchEvent>(l) {
282                return Some(Event::Fetch(FetchEvent {
283                    entry_id: legacy.entry_id,
284                    timestamp: legacy.timestamp,
285                    agent: legacy.agent,
286                    lang: None,
287                    cache_hit: None,
288                    duration_ms: None,
289                }));
290            }
291            None
292        })
293        .collect()
294}
295
296/// Legacy format for backwards compatibility with old analytics.jsonl files.
297#[derive(Deserialize)]
298struct LegacyFetchEvent {
299    entry_id: String,
300    timestamp: u64,
301    #[serde(default)]
302    agent: Option<String>,
303}
304
305/// Export the raw JSONL content.
306pub fn export_raw() -> String {
307    let path = analytics_path();
308    fs::read_to_string(&path).unwrap_or_default()
309}
310
311/// Clear the analytics journal.
312pub fn clear_journal() -> bool {
313    let path = analytics_path();
314    if path.exists() {
315        fs::remove_file(&path).is_ok()
316    } else {
317        true
318    }
319}
320
321/// Get the journal file size in bytes.
322pub fn journal_size_bytes() -> u64 {
323    let path = analytics_path();
324    fs::metadata(&path).map(|m| m.len()).unwrap_or(0)
325}
326
327// ---------------------------------------------------------------------------
328// Statistics
329// ---------------------------------------------------------------------------
330
331/// Comprehensive usage statistics summary.
332#[derive(Debug, Clone, Serialize)]
333pub struct UsageStats {
334    pub period_days: u64,
335    pub total_events: usize,
336    pub total_fetches: usize,
337    pub total_searches: usize,
338    pub total_builds: usize,
339    pub total_mcp_calls: usize,
340    pub total_annotations: usize,
341    pub total_feedback: usize,
342    pub most_fetched: Vec<(String, usize)>,
343    pub top_queries: Vec<(String, usize)>,
344    pub top_mcp_tools: Vec<(String, usize)>,
345    pub never_fetched_pins: Vec<String>,
346    pub agents: Vec<(String, usize)>,
347    pub avg_search_results: f64,
348}
349
350/// Compute usage statistics for the last N days.
351pub fn get_stats(days: u64) -> UsageStats {
352    let events = load_events();
353    let now = std::time::SystemTime::now()
354        .duration_since(std::time::UNIX_EPOCH)
355        .unwrap_or_default()
356        .as_secs();
357    let cutoff = now.saturating_sub(days * 86400);
358
359    let recent: Vec<&Event> = events.iter().filter(|e| e.timestamp() >= cutoff).collect();
360
361    let mut fetch_counts: HashMap<String, usize> = HashMap::new();
362    let mut query_counts: HashMap<String, usize> = HashMap::new();
363    let mut mcp_tool_counts: HashMap<String, usize> = HashMap::new();
364    let mut agent_counts: HashMap<String, usize> = HashMap::new();
365    let mut total_fetches = 0usize;
366    let mut total_searches = 0usize;
367    let mut total_builds = 0usize;
368    let mut total_mcp_calls = 0usize;
369    let mut total_annotations = 0usize;
370    let mut total_feedback = 0usize;
371    let mut search_result_sum = 0usize;
372
373    for event in &recent {
374        match event {
375            Event::Fetch(e) => {
376                total_fetches += 1;
377                *fetch_counts.entry(e.entry_id.clone()).or_insert(0) += 1;
378                if let Some(ref agent) = e.agent {
379                    *agent_counts.entry(agent.clone()).or_insert(0) += 1;
380                }
381            }
382            Event::Search(e) => {
383                total_searches += 1;
384                *query_counts.entry(e.query.clone()).or_insert(0) += 1;
385                search_result_sum += e.result_count;
386                if let Some(ref agent) = e.agent {
387                    *agent_counts.entry(agent.clone()).or_insert(0) += 1;
388                }
389            }
390            Event::Build(_) => {
391                total_builds += 1;
392            }
393            Event::McpCall(e) => {
394                total_mcp_calls += 1;
395                *mcp_tool_counts.entry(e.tool.clone()).or_insert(0) += 1;
396                if let Some(ref agent) = e.agent {
397                    *agent_counts.entry(agent.clone()).or_insert(0) += 1;
398                }
399            }
400            Event::Annotate(_) => {
401                total_annotations += 1;
402            }
403            Event::Feedback(_) => {
404                total_feedback += 1;
405            }
406            Event::Pin(_) => {}
407        }
408    }
409
410    let mut most_fetched: Vec<(String, usize)> = fetch_counts.into_iter().collect();
411    most_fetched.sort_by(|a, b| b.1.cmp(&a.1));
412
413    let mut top_queries: Vec<(String, usize)> = query_counts.into_iter().collect();
414    top_queries.sort_by(|a, b| b.1.cmp(&a.1));
415
416    let mut top_mcp_tools: Vec<(String, usize)> = mcp_tool_counts.into_iter().collect();
417    top_mcp_tools.sort_by(|a, b| b.1.cmp(&a.1));
418
419    let mut agents: Vec<(String, usize)> = agent_counts.into_iter().collect();
420    agents.sort_by(|a, b| b.1.cmp(&a.1));
421
422    // Find pinned but never-fetched
423    let pins = crate::team::pins::list_pins();
424    let fetched_ids: std::collections::HashSet<String> = recent
425        .iter()
426        .filter_map(|e| match e {
427            Event::Fetch(f) => Some(f.entry_id.clone()),
428            _ => None,
429        })
430        .collect();
431    let never_fetched_pins: Vec<String> = pins
432        .iter()
433        .filter(|p| !fetched_ids.contains(&p.id))
434        .map(|p| p.id.clone())
435        .collect();
436
437    let avg_search_results = if total_searches > 0 {
438        search_result_sum as f64 / total_searches as f64
439    } else {
440        0.0
441    };
442
443    UsageStats {
444        period_days: days,
445        total_events: recent.len(),
446        total_fetches,
447        total_searches,
448        total_builds,
449        total_mcp_calls,
450        total_annotations,
451        total_feedback,
452        most_fetched,
453        top_queries,
454        top_mcp_tools,
455        never_fetched_pins,
456        agents,
457        avg_search_results,
458    }
459}