Skip to main content

aida_core/
telemetry.rs

1// trace:ARCH-observability | ai:claude
2//! Telemetry — track AIDA usage for measuring tool effectiveness.
3//!
4//! Records events like requirement creation, lookups, status changes,
5//! skill invocations, and AI interactions. Stored in the requirements
6//! database itself (dogfooding) so the data is always available for
7//! analysis alongside the requirements it describes.
8//!
9//! This is NOT analytics sent to a server. All data stays local.
10//! The purpose is to answer: "Is this tool actually being used, and how?"
11//!
12//! Key metrics this enables:
13//! - Requirements created per day/week
14//! - Requirements referenced in commits (traceability coverage)
15//! - Skill invocations (which skills are used most?)
16//! - Time from creation to completion (cycle time)
17//! - AI evaluation scores over time (quality trends)
18//! - Active users (who is contributing?)
19
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22use uuid::Uuid;
23
24/// A telemetry event — a single recorded action.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct TelemetryEvent {
27    /// Event ID
28    pub id: Uuid,
29    /// When the event occurred
30    pub timestamp: DateTime<Utc>,
31    /// Who triggered it (user handle or "system")
32    pub actor: String,
33    /// What happened
34    pub kind: EventKind,
35    /// Optional requirement ID this event relates to
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub requirement_id: Option<String>,
38}
39
40/// Categories of tracked events.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub enum EventKind {
43    // Requirement lifecycle
44    RequirementCreated,
45    RequirementUpdated { fields: Vec<String> },
46    RequirementStatusChanged { from: String, to: String },
47    RequirementDeleted,
48    RequirementViewed,
49
50    // Traceability
51    TraceCommentAdded { file: String },
52    CommitLinked { commit_sha: String },
53
54    // Skills
55    SkillInvoked { skill: String },
56
57    // AI
58    AiEvaluationRun { score: Option<f32> },
59    AiChatQuery,
60
61    // Sync
62    GitSyncPush,
63    GitSyncPull,
64    GitHubPush { issue_number: u64 },
65    GitHubPull { count: u32 },
66
67    // Search
68    SearchPerformed { query: String, results: u32 },
69
70    // Session
71    SessionStart,
72    SessionEnd { duration_secs: u64 },
73}
74
75/// A telemetry store — append-only event log.
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct TelemetryStore {
78    pub events: Vec<TelemetryEvent>,
79}
80
81impl TelemetryStore {
82    /// Record a new event.
83    pub fn record(&mut self, actor: &str, kind: EventKind, requirement_id: Option<&str>) {
84        self.events.push(TelemetryEvent {
85            id: Uuid::now_v7(),
86            timestamp: Utc::now(),
87            actor: actor.to_string(),
88            kind,
89            requirement_id: requirement_id.map(String::from),
90        });
91    }
92
93    /// Get events in a time range.
94    pub fn events_between(
95        &self,
96        start: DateTime<Utc>,
97        end: DateTime<Utc>,
98    ) -> Vec<&TelemetryEvent> {
99        self.events
100            .iter()
101            .filter(|e| e.timestamp >= start && e.timestamp <= end)
102            .collect()
103    }
104
105    /// Count events by kind in a time range.
106    pub fn count_by_kind(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> std::collections::HashMap<String, u32> {
107        let mut counts = std::collections::HashMap::new();
108        for event in self.events_between(start, end) {
109            let kind_name = match &event.kind {
110                EventKind::RequirementCreated => "created",
111                EventKind::RequirementUpdated { .. } => "updated",
112                EventKind::RequirementStatusChanged { .. } => "status_changed",
113                EventKind::RequirementDeleted => "deleted",
114                EventKind::RequirementViewed => "viewed",
115                EventKind::TraceCommentAdded { .. } => "trace_added",
116                EventKind::CommitLinked { .. } => "commit_linked",
117                EventKind::SkillInvoked { .. } => "skill_invoked",
118                EventKind::AiEvaluationRun { .. } => "ai_evaluation",
119                EventKind::AiChatQuery => "ai_chat",
120                EventKind::GitSyncPush => "git_push",
121                EventKind::GitSyncPull => "git_pull",
122                EventKind::GitHubPush { .. } => "github_push",
123                EventKind::GitHubPull { .. } => "github_pull",
124                EventKind::SearchPerformed { .. } => "search",
125                EventKind::SessionStart => "session_start",
126                EventKind::SessionEnd { .. } => "session_end",
127            };
128            *counts.entry(kind_name.to_string()).or_insert(0) += 1;
129        }
130        counts
131    }
132
133    /// Get the most invoked skills.
134    pub fn top_skills(&self, limit: usize) -> Vec<(String, u32)> {
135        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
136        for event in &self.events {
137            if let EventKind::SkillInvoked { skill } = &event.kind {
138                *counts.entry(skill.clone()).or_insert(0) += 1;
139            }
140        }
141        let mut sorted: Vec<_> = counts.into_iter().collect();
142        sorted.sort_by(|a, b| b.1.cmp(&a.1));
143        sorted.truncate(limit);
144        sorted
145    }
146
147    /// Calculate average cycle time (creation → completion) in hours.
148    pub fn avg_cycle_time_hours(&self) -> Option<f64> {
149        let mut creation_times: std::collections::HashMap<String, DateTime<Utc>> =
150            std::collections::HashMap::new();
151        let mut cycle_times = Vec::new();
152
153        for event in &self.events {
154            if let Some(ref req_id) = event.requirement_id {
155                match &event.kind {
156                    EventKind::RequirementCreated => {
157                        creation_times.insert(req_id.clone(), event.timestamp);
158                    }
159                    EventKind::RequirementStatusChanged { to, .. }
160                        if to == "Completed" || to == "completed" =>
161                    {
162                        if let Some(created) = creation_times.get(req_id) {
163                            let duration = event.timestamp - *created;
164                            cycle_times.push(duration.num_hours() as f64);
165                        }
166                    }
167                    _ => {}
168                }
169            }
170        }
171
172        if cycle_times.is_empty() {
173            None
174        } else {
175            let sum: f64 = cycle_times.iter().sum();
176            Some(sum / cycle_times.len() as f64)
177        }
178    }
179
180    /// Generate a usage summary report.
181    pub fn summary(&self) -> UsageSummary {
182        let total_events = self.events.len();
183        let unique_actors: std::collections::HashSet<&str> =
184            self.events.iter().map(|e| e.actor.as_str()).collect();
185        let unique_requirements: std::collections::HashSet<&str> = self
186            .events
187            .iter()
188            .filter_map(|e| e.requirement_id.as_deref())
189            .collect();
190
191        UsageSummary {
192            total_events,
193            unique_actors: unique_actors.len(),
194            unique_requirements: unique_requirements.len(),
195            top_skills: self.top_skills(5),
196            avg_cycle_time_hours: self.avg_cycle_time_hours(),
197            first_event: self.events.first().map(|e| e.timestamp),
198            last_event: self.events.last().map(|e| e.timestamp),
199        }
200    }
201
202    /// Save to a YAML file.
203    #[cfg(feature = "native")]
204    pub fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
205        let content = serde_yaml::to_string(self)?;
206        if let Some(parent) = path.parent() {
207            std::fs::create_dir_all(parent)?;
208        }
209        std::fs::write(path, content)?;
210        Ok(())
211    }
212
213    /// Load from a YAML file.
214    #[cfg(feature = "native")]
215    pub fn load(path: &std::path::Path) -> anyhow::Result<Self> {
216        if !path.exists() {
217            return Ok(Self::default());
218        }
219        let content = std::fs::read_to_string(path)?;
220        Ok(serde_yaml::from_str(&content)?)
221    }
222}
223
224/// Summary of usage metrics.
225#[derive(Debug, Clone, Serialize)]
226pub struct UsageSummary {
227    pub total_events: usize,
228    pub unique_actors: usize,
229    pub unique_requirements: usize,
230    pub top_skills: Vec<(String, u32)>,
231    pub avg_cycle_time_hours: Option<f64>,
232    pub first_event: Option<DateTime<Utc>>,
233    pub last_event: Option<DateTime<Utc>>,
234}
235
236impl std::fmt::Display for UsageSummary {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        writeln!(f, "AIDA Usage Summary")?;
239        writeln!(f, "─────────────────────────────────")?;
240        writeln!(f, "Total events:        {}", self.total_events)?;
241        writeln!(f, "Unique actors:       {}", self.unique_actors)?;
242        writeln!(f, "Requirements touched: {}", self.unique_requirements)?;
243        if let Some(first) = self.first_event {
244            writeln!(f, "First event:         {}", first.format("%Y-%m-%d"))?;
245        }
246        if let Some(last) = self.last_event {
247            writeln!(f, "Last event:          {}", last.format("%Y-%m-%d"))?;
248        }
249        if let Some(cycle) = self.avg_cycle_time_hours {
250            writeln!(f, "Avg cycle time:      {:.1} hours", cycle)?;
251        }
252        if !self.top_skills.is_empty() {
253            writeln!(f, "Top skills:")?;
254            for (skill, count) in &self.top_skills {
255                writeln!(f, "  {:20} {}", skill, count)?;
256            }
257        }
258        Ok(())
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn test_record_and_count() {
268        let mut store = TelemetryStore::default();
269
270        store.record("joe", EventKind::RequirementCreated, Some("FR-001"));
271        store.record("joe", EventKind::RequirementCreated, Some("FR-002"));
272        store.record("joe", EventKind::SkillInvoked { skill: "aida-req".into() }, None);
273        store.record("alice", EventKind::RequirementViewed, Some("FR-001"));
274
275        assert_eq!(store.events.len(), 4);
276
277        let summary = store.summary();
278        assert_eq!(summary.total_events, 4);
279        assert_eq!(summary.unique_actors, 2);
280        assert_eq!(summary.unique_requirements, 2);
281    }
282
283    #[test]
284    fn test_top_skills() {
285        let mut store = TelemetryStore::default();
286
287        for _ in 0..5 {
288            store.record("joe", EventKind::SkillInvoked { skill: "aida-req".into() }, None);
289        }
290        for _ in 0..3 {
291            store.record("joe", EventKind::SkillInvoked { skill: "aida-commit".into() }, None);
292        }
293        store.record("joe", EventKind::SkillInvoked { skill: "aida-grill".into() }, None);
294
295        let top = store.top_skills(2);
296        assert_eq!(top[0].0, "aida-req");
297        assert_eq!(top[0].1, 5);
298        assert_eq!(top[1].0, "aida-commit");
299        assert_eq!(top[1].1, 3);
300    }
301
302    #[test]
303    fn test_cycle_time() {
304        let mut store = TelemetryStore::default();
305
306        // Simulate: create at T, complete at T+2h
307        store.record("joe", EventKind::RequirementCreated, Some("FR-001"));
308
309        // Manually adjust timestamp for completion
310        let mut completion = TelemetryEvent {
311            id: Uuid::now_v7(),
312            timestamp: Utc::now() + chrono::Duration::hours(2),
313            actor: "joe".into(),
314            kind: EventKind::RequirementStatusChanged {
315                from: "Draft".into(),
316                to: "Completed".into(),
317            },
318            requirement_id: Some("FR-001".into()),
319        };
320        store.events.push(completion);
321
322        let cycle = store.avg_cycle_time_hours();
323        assert!(cycle.is_some());
324        assert!(cycle.unwrap() >= 1.0); // at least 1 hour (close to 2)
325    }
326
327    #[cfg(feature = "native")]
328    #[test]
329    fn test_persistence() {
330        let dir = tempfile::tempdir().unwrap();
331        let path = dir.path().join("telemetry.yaml");
332
333        let mut store = TelemetryStore::default();
334        store.record("joe", EventKind::RequirementCreated, Some("FR-001"));
335        store.record("joe", EventKind::SkillInvoked { skill: "aida-req".into() }, None);
336        store.save(&path).unwrap();
337
338        let loaded = TelemetryStore::load(&path).unwrap();
339        assert_eq!(loaded.events.len(), 2);
340    }
341}