1use std::collections::HashMap;
2use std::fs;
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::chub_dir;
8
9#[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, pub timestamp: u64,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct AnnotateEvent {
114 pub entry_id: String,
115 pub kind: String, 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
126fn 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
154pub 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
167pub 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
186pub 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
203pub 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
215pub 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
226pub 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
236pub 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
246pub 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
256pub 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 if let Ok(event) = serde_json::from_str::<Event>(l) {
278 return Some(event);
279 }
280 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#[derive(Deserialize)]
298struct LegacyFetchEvent {
299 entry_id: String,
300 timestamp: u64,
301 #[serde(default)]
302 agent: Option<String>,
303}
304
305pub fn export_raw() -> String {
307 let path = analytics_path();
308 fs::read_to_string(&path).unwrap_or_default()
309}
310
311pub 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
321pub fn journal_size_bytes() -> u64 {
323 let path = analytics_path();
324 fs::metadata(&path).map(|m| m.len()).unwrap_or(0)
325}
326
327#[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
350pub 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 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}