1use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22use uuid::Uuid;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct TelemetryEvent {
27 pub id: Uuid,
29 pub timestamp: DateTime<Utc>,
31 pub actor: String,
33 pub kind: EventKind,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub requirement_id: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub enum EventKind {
43 RequirementCreated,
45 RequirementUpdated { fields: Vec<String> },
46 RequirementStatusChanged { from: String, to: String },
47 RequirementDeleted,
48 RequirementViewed,
49
50 TraceCommentAdded { file: String },
52 CommitLinked { commit_sha: String },
53
54 SkillInvoked { skill: String },
56
57 AiEvaluationRun { score: Option<f32> },
59 AiChatQuery,
60
61 GitSyncPush,
63 GitSyncPull,
64 GitHubPush { issue_number: u64 },
65 GitHubPull { count: u32 },
66
67 SearchPerformed { query: String, results: u32 },
69
70 SessionStart,
72 SessionEnd { duration_secs: u64 },
73}
74
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct TelemetryStore {
78 pub events: Vec<TelemetryEvent>,
79}
80
81impl TelemetryStore {
82 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 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 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 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 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 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 #[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 #[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#[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 store.record("joe", EventKind::RequirementCreated, Some("FR-001"));
308
309 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); }
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}