1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::RwLock;
4use std::time::SystemTime;
5
6use rusqlite::{Connection, params};
7use serde::{Deserialize, Serialize};
8
9use crate::queries::QueryError;
10use crate::workspace::{normalize_path, to_forward_slash};
11
12#[derive(Debug, Clone)]
13pub struct ToolInvocation<'a> {
14 pub tool: &'a str,
15 pub duration_ms: u64,
16 pub outcome: &'a str, pub error_message: Option<&'a str>,
18 pub logical_result_count: Option<usize>,
19 pub bytes_returned: usize,
20 pub est_tokens: usize,
21 pub est_tokens_saved: usize,
22 pub reconcile_ms: Option<u64>,
23 pub query_ms: Option<u64>,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
27pub enum TimeWindow {
28 Today,
29 Last7Days,
30 Last30Days,
31 ThisMonth,
32 LastYear,
33 #[default]
34 AllTime,
35}
36
37impl TimeWindow {
38 pub fn parse(s: &str) -> Option<Self> {
39 match s.to_lowercase().as_str() {
40 "today" => Some(Self::Today),
41 "7d" | "week" => Some(Self::Last7Days),
42 "30d" => Some(Self::Last30Days),
43 "month" | "this-month" => Some(Self::ThisMonth),
44 "year" | "last-year" => Some(Self::LastYear),
45 "all" | "all-time" => Some(Self::AllTime),
46 _ => None,
47 }
48 }
49
50 pub fn to_sqlite_condition(&self) -> Option<&'static str> {
51 match self {
52 Self::Today => Some("timestamp >= datetime('now', 'localtime', 'start of day')"),
53 Self::Last7Days => Some("timestamp >= datetime('now', '-7 days')"),
54 Self::Last30Days => Some("timestamp >= datetime('now', '-30 days')"),
55 Self::ThisMonth => Some("timestamp >= datetime('now', 'localtime', 'start of month')"),
56 Self::LastYear => Some("timestamp >= datetime('now', '-365 days')"),
57 Self::AllTime => None,
58 }
59 }
60}
61
62impl std::fmt::Display for TimeWindow {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 Self::Today => write!(f, "today"),
66 Self::Last7Days => write!(f, "7d"),
67 Self::Last30Days => write!(f, "30d"),
68 Self::ThisMonth => write!(f, "month"),
69 Self::LastYear => write!(f, "year"),
70 Self::AllTime => write!(f, "all"),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76pub struct TelemetryFilter {
77 pub time_window: TimeWindow,
78 pub workspace_root: Option<PathBuf>,
79 pub version: Option<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct TelemetrySummary {
84 pub total_calls: usize,
85 pub ok_calls: usize,
86 pub empty_calls: usize,
87 pub error_calls: usize,
88 pub total_tokens_returned: usize,
89 pub est_tokens_saved: usize,
90 pub time_window: TimeWindow,
91 pub scope_description: String,
92 pub tool_stats: Vec<ToolStat>,
93 pub recent_errors: Vec<TelemetryErrorRecord>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ToolStat {
98 pub tool: String,
99 pub count: usize,
100 pub ok_count: usize,
101 pub empty_count: usize,
102 pub error_count: usize,
103 pub avg_duration_ms: u64,
104 pub p50_ms: Option<u64>,
105 pub p95_ms: Option<u64>,
106 pub avg_reconcile_ms: Option<u64>,
107 pub avg_query_ms: Option<u64>,
108 pub tokens_returned: usize,
109 pub tokens_saved: usize,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct TelemetryErrorRecord {
114 pub timestamp: String,
115 pub tool: String,
116 pub error_message: String,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct IndexFacts {
121 pub extractor_version: Option<String>,
122 pub schema_version: Option<String>,
123 pub index_level: Option<String>,
124 pub updated_at: Option<String>,
125 pub file_count: i64,
126 pub symbol_count: i64,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct BugReportBundle {
131 pub os_info: String,
132 pub arch_info: String,
133 pub code_kb_version: String,
134 pub julie_extract_version: String,
135 pub active_workspace_name: Option<String>,
136 pub index: Option<IndexFacts>,
137 pub recent_errors: Vec<TelemetryErrorRecord>,
138 pub log_tail: Vec<String>,
139 pub markdown_body: String,
140 pub github_issue_url: String,
141}
142
143static TELEMETRY_DIR_OVERRIDE: RwLock<Option<PathBuf>> = RwLock::new(None);
144
145pub fn resolve_telemetry_dir(
146 env_dir: Option<String>,
147 cargo_target_tmp: Option<String>,
148 home: Option<String>,
149 userprofile: Option<String>,
150) -> PathBuf {
151 if let Some(dir) = env_dir
152 && !dir.trim().is_empty()
153 {
154 return PathBuf::from(dir);
155 }
156 if let Some(target_tmp) = cargo_target_tmp
157 && !target_tmp.trim().is_empty()
158 {
159 return PathBuf::from(target_tmp).join("test-telemetry");
160 }
161 if let Some(home) = home
162 && !home.trim().is_empty()
163 {
164 return PathBuf::from(home).join(".code-kb");
165 }
166 if let Some(profile) = userprofile
167 && !profile.trim().is_empty()
168 {
169 return PathBuf::from(profile).join(".code-kb");
170 }
171 PathBuf::from(".code-kb")
172}
173
174pub fn is_telemetry_disabled_with(no_telem: Option<&str>, disable_telem: Option<&str>) -> bool {
175 no_telem
176 .or(disable_telem)
177 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
178 .unwrap_or(false)
179}
180
181pub fn is_telemetry_disabled() -> bool {
182 let no_telem = std::env::var("CODE_KB_NO_TELEMETRY").ok();
183 let disable_telem = std::env::var("CODE_KB_DISABLE_TELEMETRY").ok();
184 is_telemetry_disabled_with(no_telem.as_deref(), disable_telem.as_deref())
185}
186
187pub fn get_global_telemetry_dir() -> PathBuf {
188 if let Ok(guard) = TELEMETRY_DIR_OVERRIDE.read()
189 && let Some(ref path) = *guard
190 {
191 return path.clone();
192 }
193 resolve_telemetry_dir(
194 std::env::var("CODE_KB_TELEMETRY_DIR").ok(),
195 std::env::var("CARGO_TARGET_TMPDIR").ok(),
196 std::env::var("HOME").ok(),
197 std::env::var("USERPROFILE").ok(),
198 )
199}
200
201#[cfg(test)]
202pub(crate) fn set_telemetry_dir_override(path: Option<PathBuf>) {
203 if let Ok(mut guard) = TELEMETRY_DIR_OVERRIDE.write() {
204 *guard = path;
205 }
206}
207
208pub fn open_global_telemetry_db() -> Result<Connection, QueryError> {
209 open_telemetry_db_at(&get_global_telemetry_dir())
210}
211
212pub fn open_telemetry_db_at(dir: &Path) -> Result<Connection, QueryError> {
213 if !dir.exists() {
214 let _ = std::fs::create_dir_all(dir);
215 }
216 let db_path = dir.join("telemetry.db");
217 let conn = Connection::open(&db_path)?;
218 init_telemetry_db(&conn)?;
219 Ok(conn)
220}
221
222fn init_telemetry_db(conn: &Connection) -> Result<(), QueryError> {
223 conn.execute_batch(
224 "PRAGMA journal_mode = WAL;
225 PRAGMA synchronous = NORMAL;
226 PRAGMA busy_timeout = 5000;
227 CREATE TABLE IF NOT EXISTS tool_telemetry (
228 id TEXT PRIMARY KEY,
229 timestamp TEXT NOT NULL,
230 workspace_root TEXT NOT NULL,
231 workspace_name TEXT NOT NULL,
232 tool TEXT NOT NULL,
233 duration_ms INTEGER NOT NULL,
234 outcome TEXT NOT NULL,
235 error_message TEXT,
236 result_count INTEGER NOT NULL DEFAULT 0,
237 result_count_known INTEGER NOT NULL DEFAULT 0,
238 bytes_returned INTEGER NOT NULL DEFAULT 0,
239 est_tokens INTEGER NOT NULL DEFAULT 0,
240 est_tokens_saved INTEGER NOT NULL DEFAULT 0,
241 code_kb_version TEXT NOT NULL,
242 reconcile_ms INTEGER DEFAULT NULL,
243 query_ms INTEGER DEFAULT NULL
244 );
245 CREATE INDEX IF NOT EXISTS idx_tool_telemetry_tool ON tool_telemetry(tool, timestamp DESC);
246 CREATE INDEX IF NOT EXISTS idx_tool_telemetry_ts ON tool_telemetry(timestamp DESC);
247 DELETE FROM tool_telemetry WHERE timestamp < datetime('now', '-365 days');",
248 )?;
249
250 let mut stmt = conn.prepare("PRAGMA table_info(tool_telemetry)")?;
252 let cols = stmt.query_map([], |row| row.get::<_, String>(1))?;
253 let mut col_names = HashSet::new();
254 for col in cols.flatten() {
255 col_names.insert(col);
256 }
257 if !col_names.contains("workspace_root") {
258 conn.execute(
259 "ALTER TABLE tool_telemetry ADD COLUMN workspace_root TEXT NOT NULL DEFAULT ''",
260 [],
261 )?;
262 }
263 if !col_names.contains("workspace_name") {
264 conn.execute(
265 "ALTER TABLE tool_telemetry ADD COLUMN workspace_name TEXT NOT NULL DEFAULT ''",
266 [],
267 )?;
268 }
269 if !col_names.contains("est_tokens_saved") {
270 conn.execute(
271 "ALTER TABLE tool_telemetry ADD COLUMN est_tokens_saved INTEGER NOT NULL DEFAULT 0",
272 [],
273 )?;
274 }
275 if !col_names.contains("result_count_known") {
276 conn.execute(
277 "ALTER TABLE tool_telemetry ADD COLUMN result_count_known INTEGER NOT NULL DEFAULT 0",
278 [],
279 )?;
280 }
281 if !col_names.contains("reconcile_ms") {
282 conn.execute(
283 "ALTER TABLE tool_telemetry ADD COLUMN reconcile_ms INTEGER DEFAULT NULL",
284 [],
285 )?;
286 }
287 if !col_names.contains("query_ms") {
288 conn.execute(
289 "ALTER TABLE tool_telemetry ADD COLUMN query_ms INTEGER DEFAULT NULL",
290 [],
291 )?;
292 }
293 if col_names.contains("version") && !col_names.contains("code_kb_version") {
294 conn.execute(
295 "ALTER TABLE tool_telemetry RENAME COLUMN version TO code_kb_version",
296 [],
297 )?;
298 } else if !col_names.contains("code_kb_version") {
299 conn.execute(
300 "ALTER TABLE tool_telemetry ADD COLUMN code_kb_version TEXT NOT NULL DEFAULT ''",
301 [],
302 )?;
303 }
304
305 conn.execute(
307 "CREATE INDEX IF NOT EXISTS idx_tool_telemetry_ws_ts ON tool_telemetry(workspace_root, timestamp DESC)",
308 [],
309 )?;
310
311 Ok(())
312}
313
314pub fn open_telemetry_db(_workspace_root: &Path) -> Result<Connection, QueryError> {
316 open_global_telemetry_db()
317}
318
319pub(crate) fn workspace_root_match_candidates(ws: &Path) -> (String, Option<String>) {
322 let norm = to_forward_slash(&normalize_path(ws));
323 let canonical = dunce::canonicalize(ws)
324 .map(|p| to_forward_slash(&normalize_path(&p)))
325 .unwrap_or_else(|_| norm.clone());
326
327 if canonical != norm {
328 return (canonical, Some(norm));
329 }
330
331 if let Some(rest) = norm.strip_prefix("/private/") {
333 if rest.starts_with("var/") || rest.starts_with("tmp/") || rest.starts_with("etc/") {
334 return (norm.clone(), Some(format!("/{}", rest)));
335 }
336 } else if norm.starts_with("/var/") || norm.starts_with("/tmp/") || norm.starts_with("/etc/") {
337 return (norm.clone(), Some(format!("/private{}", norm)));
338 }
339
340 (norm, None)
341}
342
343pub fn record_tool_call_conn(
345 conn: &Connection,
346 workspace_root: &Path,
347 invocation: &ToolInvocation,
348) {
349 if is_telemetry_disabled() {
350 return;
351 }
352 let now = SystemTime::now()
353 .duration_since(SystemTime::UNIX_EPOCH)
354 .unwrap_or_default();
355 let ts = format!("{:?}", SystemTime::now());
356 let id_source = format!("{}:{}:{}", invocation.tool, ts, now.as_nanos());
357 let id = blake3::hash(id_source.as_bytes()).to_hex().to_string();
358 let version = env!("CARGO_PKG_VERSION");
359
360 let canonical =
361 dunce::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
362 let norm_ws = to_forward_slash(&normalize_path(&canonical));
363 let ws_name = Path::new(&norm_ws)
364 .file_name()
365 .map(|n| n.to_string_lossy().to_string())
366 .unwrap_or_else(|| "repo".to_string());
367
368 let _ = conn.execute(
369 "INSERT INTO tool_telemetry (
370 id, timestamp, workspace_root, workspace_name, tool,
371 duration_ms, outcome, error_message, result_count, result_count_known,
372 bytes_returned, est_tokens, est_tokens_saved, code_kb_version,
373 reconcile_ms, query_ms
374 ) VALUES (?1, datetime('now'), ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
375 params![
376 id,
377 norm_ws,
378 ws_name,
379 invocation.tool,
380 invocation.duration_ms as i64,
381 invocation.outcome,
382 invocation.error_message,
383 invocation.logical_result_count.unwrap_or_default() as i64,
384 invocation.logical_result_count.is_some() as i64,
385 invocation.bytes_returned as i64,
386 invocation.est_tokens as i64,
387 invocation.est_tokens_saved as i64,
388 version,
389 invocation.reconcile_ms.map(|v| v as i64),
390 invocation.query_ms.map(|v| v as i64),
391 ],
392 );
393}
394
395pub fn record_tool_call(workspace_root: &Path, invocation: &ToolInvocation) {
398 if is_telemetry_disabled() {
399 return;
400 }
401 if let Ok(conn) = open_global_telemetry_db() {
402 record_tool_call_conn(&conn, workspace_root, invocation);
403 }
404}
405
406fn calculate_percentile(sorted: &[u64], pct: f64) -> Option<u64> {
407 if sorted.is_empty() {
408 return None;
409 }
410 let rank = ((pct / 100.0) * sorted.len() as f64).ceil() as usize;
411 let idx = rank.saturating_sub(1).min(sorted.len() - 1);
412 Some(sorted[idx])
413}
414
415pub fn get_telemetry_summary(
416 conn: &Connection,
417 filter: &TelemetryFilter,
418) -> Result<TelemetrySummary, QueryError> {
419 let mut conditions = Vec::new();
420 let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
421
422 if let Some(time_cond) = filter.time_window.to_sqlite_condition() {
423 conditions.push(time_cond.to_string());
424 }
425
426 if let Some(ref ws) = filter.workspace_root {
427 let (canon, alt) = workspace_root_match_candidates(ws);
428 if let Some(alt_val) = alt {
429 let idx1 = params_vec.len() + 1;
430 let idx2 = params_vec.len() + 2;
431 conditions.push(format!(
432 "(workspace_root = ?{} OR workspace_root = ?{})",
433 idx1, idx2
434 ));
435 params_vec.push(Box::new(canon));
436 params_vec.push(Box::new(alt_val));
437 } else {
438 let idx1 = params_vec.len() + 1;
439 conditions.push(format!("workspace_root = ?{}", idx1));
440 params_vec.push(Box::new(canon));
441 }
442 }
443
444 if let Some(ref v) = filter.version {
445 let idx = params_vec.len() + 1;
446 conditions.push(format!("code_kb_version = ?{}", idx));
447 params_vec.push(Box::new(v.clone()));
448 }
449
450 let where_clause = if conditions.is_empty() {
451 String::new()
452 } else {
453 format!("WHERE {}", conditions.join(" AND "))
454 };
455
456 let ws_desc = match &filter.workspace_root {
457 Some(ws) => format!("Workspace: {}", to_forward_slash(&normalize_path(ws))),
458 None => "Global (all workspaces)".to_string(),
459 };
460 let ver_desc = match &filter.version {
461 Some(v) => format!("Version: {}", v),
462 None => "Version: all".to_string(),
463 };
464 let scope_description = format!("{} | {}", ws_desc, ver_desc);
465
466 let params_slice: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
467
468 let totals_sql = format!(
470 "SELECT COUNT(*),
471 SUM(CASE WHEN outcome = 'ok' THEN 1 ELSE 0 END),
472 SUM(CASE WHEN outcome = 'empty' THEN 1 ELSE 0 END),
473 SUM(CASE WHEN outcome = 'error' THEN 1 ELSE 0 END),
474 SUM(est_tokens),
475 SUM(est_tokens_saved)
476 FROM tool_telemetry
477 {}",
478 where_clause
479 );
480
481 let (total_calls, ok_calls, empty_calls, error_calls, total_tokens_returned, est_tokens_saved) = {
482 let mut stmt = conn.prepare(&totals_sql)?;
483 let row_mapper = |row: &rusqlite::Row| {
484 let total: i64 = row.get(0)?;
485 let ok: Option<i64> = row.get(1)?;
486 let empty: Option<i64> = row.get(2)?;
487 let error: Option<i64> = row.get(3)?;
488 let tokens: Option<i64> = row.get(4)?;
489 let tokens_saved: Option<i64> = row.get(5)?;
490 Ok((
491 total as usize,
492 ok.unwrap_or(0) as usize,
493 empty.unwrap_or(0) as usize,
494 error.unwrap_or(0) as usize,
495 tokens.unwrap_or(0) as usize,
496 tokens_saved.unwrap_or(0) as usize,
497 ))
498 };
499 stmt.query_row(
500 rusqlite::params_from_iter(params_slice.iter().copied()),
501 row_mapper,
502 )?
503 };
504
505 let mut durations_by_tool: HashMap<String, Vec<u64>> = HashMap::new();
507 {
508 let durations_sql = format!(
509 "SELECT tool, duration_ms
510 FROM tool_telemetry
511 {}
512 ORDER BY tool, duration_ms ASC",
513 where_clause
514 );
515 let mut d_stmt = conn.prepare(&durations_sql)?;
516 let d_rows = d_stmt.query_map(
517 rusqlite::params_from_iter(params_slice.iter().copied()),
518 |row| {
519 let tool: String = row.get(0)?;
520 let dur: i64 = row.get(1)?;
521 Ok((tool, dur.max(0) as u64))
522 },
523 )?;
524 for item in d_rows.flatten() {
525 durations_by_tool.entry(item.0).or_default().push(item.1);
526 }
527 }
528
529 let tool_stats_sql = format!(
531 "SELECT tool,
532 COUNT(*),
533 SUM(CASE WHEN outcome = 'ok' THEN 1 ELSE 0 END),
534 SUM(CASE WHEN outcome = 'empty' THEN 1 ELSE 0 END),
535 SUM(CASE WHEN outcome = 'error' THEN 1 ELSE 0 END),
536 ROUND(AVG(duration_ms)),
537 SUM(est_tokens),
538 SUM(est_tokens_saved),
539 ROUND(AVG(reconcile_ms)),
540 ROUND(AVG(query_ms))
541 FROM tool_telemetry
542 {}
543 GROUP BY tool
544 ORDER BY COUNT(*) DESC",
545 where_clause
546 );
547
548 let mut tool_stats = Vec::new();
549 {
550 let mut stmt = conn.prepare(&tool_stats_sql)?;
551 let row_mapper = |row: &rusqlite::Row| {
552 let tool: String = row.get(0)?;
553 let count: i64 = row.get(1)?;
554 let ok_count: Option<i64> = row.get(2)?;
555 let empty_count: Option<i64> = row.get(3)?;
556 let error_count: Option<i64> = row.get(4)?;
557 let avg_duration: Option<f64> = row.get(5)?;
558 let tokens: Option<i64> = row.get(6)?;
559 let tokens_saved: Option<i64> = row.get(7)?;
560 let avg_rec: Option<f64> = row.get(8)?;
561 let avg_q: Option<f64> = row.get(9)?;
562
563 let (p50, p95) = if let Some(durs) = durations_by_tool.get(&tool) {
564 (
565 calculate_percentile(durs, 50.0),
566 calculate_percentile(durs, 95.0),
567 )
568 } else {
569 (None, None)
570 };
571
572 Ok(ToolStat {
573 tool,
574 count: count as usize,
575 ok_count: ok_count.unwrap_or(0) as usize,
576 empty_count: empty_count.unwrap_or(0) as usize,
577 error_count: error_count.unwrap_or(0) as usize,
578 avg_duration_ms: avg_duration.unwrap_or(0.0).round() as u64,
579 p50_ms: p50,
580 p95_ms: p95,
581 avg_reconcile_ms: avg_rec.map(|v| v.round() as u64),
582 avg_query_ms: avg_q.map(|v| v.round() as u64),
583 tokens_returned: tokens.unwrap_or(0) as usize,
584 tokens_saved: tokens_saved.unwrap_or(0) as usize,
585 })
586 };
587
588 let rows = stmt.query_map(
589 rusqlite::params_from_iter(params_slice.iter().copied()),
590 row_mapper,
591 )?;
592 for stat in rows.flatten() {
593 tool_stats.push(stat);
594 }
595 }
596
597 let error_where_clause = if where_clause.is_empty() {
599 "WHERE outcome = 'error' AND error_message IS NOT NULL".to_string()
600 } else {
601 format!(
602 "{} AND outcome = 'error' AND error_message IS NOT NULL",
603 where_clause
604 )
605 };
606
607 let errors_sql = format!(
608 "SELECT timestamp, tool, error_message
609 FROM tool_telemetry
610 {}
611 ORDER BY timestamp DESC
612 LIMIT 10",
613 error_where_clause
614 );
615
616 let mut recent_errors = Vec::new();
617 {
618 let mut stmt = conn.prepare(&errors_sql)?;
619 let row_mapper = |row: &rusqlite::Row| {
620 let raw_msg: String = row.get(2)?;
621 Ok(TelemetryErrorRecord {
622 timestamp: row.get(0)?,
623 tool: row.get(1)?,
624 error_message: sanitize_error_message(&raw_msg),
625 })
626 };
627
628 let rows = stmt.query_map(
629 rusqlite::params_from_iter(params_slice.iter().copied()),
630 row_mapper,
631 )?;
632 for err in rows.flatten() {
633 recent_errors.push(err);
634 }
635 }
636
637 Ok(TelemetrySummary {
638 total_calls,
639 ok_calls,
640 empty_calls,
641 error_calls,
642 total_tokens_returned,
643 est_tokens_saved,
644 time_window: filter.time_window,
645 scope_description,
646 tool_stats,
647 recent_errors,
648 })
649}
650
651fn sanitize_error_message(msg: &str) -> String {
652 let home_candidates = home_candidates();
653 sanitize_error_message_with_homes(msg, &home_candidates)
654}
655
656fn mask_home_paths(text: &str) -> String {
658 mask_home_paths_with(text, &home_candidates())
659}
660
661fn home_candidates() -> Vec<String> {
662 let mut candidates = Vec::new();
663 for var in ["HOME", "USERPROFILE"] {
664 if let Ok(home) = std::env::var(var)
665 && !home.trim().is_empty()
666 && home != "/"
667 {
668 let simplified = dunce::simplified(Path::new(&home))
669 .to_string_lossy()
670 .to_string();
671 if simplified != home {
672 candidates.push(simplified);
673 }
674 candidates.push(home);
675 }
676 }
677 candidates
678}
679
680fn mask_home_paths_with(text: &str, home_candidates: &[String]) -> String {
681 let mut masked = text.to_string();
682 for home in home_candidates {
683 let norm_home = to_forward_slash(&normalize_path(Path::new(home)));
684 masked = masked.replace(home.as_str(), "~");
685 if norm_home != *home {
686 masked = masked.replace(&norm_home, "~");
687 }
688 let backslash_home = home.replace('/', "\\");
689 if backslash_home != *home {
690 masked = masked.replace(&backslash_home, "~");
691 }
692 }
693 masked
694}
695
696fn sanitize_error_message_with_homes(msg: &str, home_candidates: &[String]) -> String {
697 let mut sanitized = mask_home_paths_with(msg, home_candidates);
698
699 sanitized = sanitized.replace("\r\n", " ").replace(['\n', '\r'], " ");
701
702 sanitized = sanitized.replace('|', "\\|");
704
705 if sanitized.chars().count() > 500 {
707 let mut truncated: String = sanitized.chars().take(500).collect();
708 if truncated.ends_with('\\') && !truncated.ends_with("\\\\") {
709 truncated.pop();
710 }
711 truncated
712 } else {
713 sanitized
714 }
715}
716
717fn index_facts(workspace_root: &Path) -> Option<IndexFacts> {
718 let db_path = workspace_root.join(".code-kb").join("artifact.db");
719 let conn = crate::db::open_read_only(&db_path).ok()?;
720 let metadata = |key: &str| {
721 conn.query_row(
722 "SELECT value FROM artifact_metadata WHERE key = ?1",
723 [key],
724 |row| row.get::<_, String>(0),
725 )
726 .ok()
727 };
728 let count = |table: &str| {
729 conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
730 row.get::<_, i64>(0)
731 })
732 .unwrap_or(0)
733 };
734 Some(IndexFacts {
735 extractor_version: metadata("binary_version"),
736 schema_version: metadata("schema_version"),
737 index_level: metadata("index_level"),
738 updated_at: metadata("updated_at"),
739 file_count: count("files"),
740 symbol_count: count("symbols"),
741 })
742}
743
744fn log_tail(workspace_root: &Path, lines: usize) -> Vec<String> {
745 let mut tail: Vec<String> = Vec::new();
746 for path in crate::workspace::log_files_newest_first(workspace_root) {
747 if tail.len() >= lines {
748 break;
749 }
750 let Ok(content) = std::fs::read_to_string(path) else {
751 continue;
752 };
753 let wanted = lines - tail.len();
754 let file_lines: Vec<&str> = content.lines().collect();
755 let mut older: Vec<String> = file_lines[file_lines.len().saturating_sub(wanted)..]
756 .iter()
757 .map(|line| mask_home_paths(line))
758 .collect();
759 older.append(&mut tail);
760 tail = older;
761 }
762 tail
763}
764
765const MAX_ISSUE_URL_LEN: usize = 8000;
767
768fn build_issue_url(title: &str, body: &str) -> Result<url::Url, QueryError> {
769 let mut issue_url = url::Url::parse("https://github.com/anortham/code-kb/issues/new")
770 .map_err(|e| QueryError::Sqlite(rusqlite::Error::ToSqlConversionFailure(Box::new(e))))?;
771 issue_url
772 .query_pairs_mut()
773 .append_pair("title", title)
774 .append_pair("body", body);
775 Ok(issue_url)
776}
777
778pub fn generate_bug_report(
782 conn: &Connection,
783 workspace_root: Option<&Path>,
784 issue_title: Option<&str>,
785 description: Option<&str>,
786 log_lines: usize,
787) -> Result<BugReportBundle, QueryError> {
788 let os_info = std::env::consts::OS.to_string();
789 let arch_info = std::env::consts::ARCH.to_string();
790 let code_kb_version = env!("CARGO_PKG_VERSION").to_string();
791
792 let exe_name = if cfg!(windows) {
793 "julie-extract.exe"
794 } else {
795 "julie-extract"
796 };
797
798 let sibling_binary = std::env::current_exe()
799 .ok()
800 .and_then(|p| p.parent().map(|d| d.join(exe_name)))
801 .filter(|p| p.is_file());
802
803 let julie_extract_version = if let Some(bin) = sibling_binary {
804 if let Ok(output) = std::process::Command::new(&bin).arg("--version").output() {
805 let ver = String::from_utf8_lossy(&output.stdout).trim().to_string();
806 if !ver.is_empty() {
807 ver
808 } else {
809 crate::sync::PINNED_JULIE_VERSION.to_string()
810 }
811 } else {
812 crate::sync::PINNED_JULIE_VERSION.to_string()
813 }
814 } else {
815 crate::sync::PINNED_JULIE_VERSION.to_string()
816 };
817
818 let active_workspace_name = workspace_root.map(|ws| {
819 let norm = to_forward_slash(&normalize_path(ws));
820 Path::new(&norm)
821 .file_name()
822 .map(|n| n.to_string_lossy().to_string())
823 .unwrap_or_else(|| "repo".to_string())
824 });
825
826 let mut recent_errors = Vec::new();
828 let (error_sql, ws_param, alt_ws_param) = if let Some(ws) = workspace_root {
829 let (canon, alt) = workspace_root_match_candidates(ws);
830 if alt.is_some() {
831 (
832 "SELECT timestamp, tool, error_message
833 FROM tool_telemetry
834 WHERE outcome = 'error' AND error_message IS NOT NULL AND (workspace_root = ?1 OR workspace_root = ?2)
835 ORDER BY timestamp DESC
836 LIMIT 10",
837 Some(canon),
838 alt,
839 )
840 } else {
841 (
842 "SELECT timestamp, tool, error_message
843 FROM tool_telemetry
844 WHERE outcome = 'error' AND error_message IS NOT NULL AND workspace_root = ?1
845 ORDER BY timestamp DESC
846 LIMIT 10",
847 Some(canon),
848 None,
849 )
850 }
851 } else {
852 (
853 "SELECT timestamp, tool, error_message
854 FROM tool_telemetry
855 WHERE outcome = 'error' AND error_message IS NOT NULL
856 ORDER BY timestamp DESC
857 LIMIT 10",
858 None,
859 None,
860 )
861 };
862
863 {
864 let mut stmt = conn.prepare(error_sql)?;
865 let row_mapper = |row: &rusqlite::Row| {
866 let raw_msg: String = row.get(2)?;
867 Ok(TelemetryErrorRecord {
868 timestamp: row.get(0)?,
869 tool: row.get(1)?,
870 error_message: sanitize_error_message(&raw_msg),
871 })
872 };
873 match (&ws_param, &alt_ws_param) {
874 (Some(ws), Some(alt)) => {
875 let rows = stmt.query_map(params![ws, alt], row_mapper)?;
876 for err in rows.flatten() {
877 recent_errors.push(err);
878 }
879 }
880 (Some(ws), None) => {
881 let rows = stmt.query_map(params![ws], row_mapper)?;
882 for err in rows.flatten() {
883 recent_errors.push(err);
884 }
885 }
886 _ => {
887 let rows = stmt.query_map([], row_mapper)?;
888 for err in rows.flatten() {
889 recent_errors.push(err);
890 }
891 }
892 }
893 }
894
895 let mut markdown = String::new();
897 markdown.push_str("### Environment\n");
898 markdown.push_str(&format!("- **OS:** {}\n", os_info));
899 markdown.push_str(&format!("- **Architecture:** {}\n", arch_info));
900 markdown.push_str(&format!("- **code-kb Version:** {}\n", code_kb_version));
901 markdown.push_str(&format!(
902 "- **julie-extract Version:** {}\n",
903 julie_extract_version
904 ));
905 if let Some(ref ws) = active_workspace_name {
906 markdown.push_str(&format!("- **Active Workspace:** {}\n", ws));
907 }
908
909 let index = workspace_root.and_then(index_facts);
910 markdown.push_str("\n### Index\n");
911 match &index {
912 Some(facts) => {
913 let field = |value: &Option<String>| value.clone().unwrap_or_else(|| "unknown".into());
914 markdown.push_str(&format!(
915 "- **Extractor:** {} (schema {}, level {})\n- **Updated:** {}\n- **Files / Symbols:** {} / {}\n",
916 field(&facts.extractor_version),
917 field(&facts.schema_version),
918 field(&facts.index_level),
919 field(&facts.updated_at),
920 facts.file_count,
921 facts.symbol_count
922 ));
923 }
924 None => markdown.push_str("- No `.code-kb/artifact.db` in the active workspace.\n"),
925 }
926
927 let short_body = markdown.clone();
928 markdown.push_str("\n### Description\n");
929 match description.map(str::trim).filter(|text| !text.is_empty()) {
930 Some(text) => markdown.push_str(&format!("{}\n\n", mask_home_paths(text))),
931 None => markdown.push_str("<!-- Please describe the bug or unexpected behavior -->\n\n"),
932 }
933
934 if !recent_errors.is_empty() {
935 markdown.push_str("### Recent Telemetry Errors\n");
936 markdown.push_str("| Timestamp | Tool | Error Message |\n");
937 markdown.push_str("|---|---|---|\n");
938 for err in &recent_errors {
939 markdown.push_str(&format!(
940 "| {} | `{}` | {} |\n",
941 err.timestamp, err.tool, err.error_message
942 ));
943 }
944 }
945
946 let title_str = issue_title
947 .map(sanitize_error_message)
948 .unwrap_or_else(|| "Bug report".to_string());
949 let mut issue_url = build_issue_url(&title_str, &markdown)?;
950 if issue_url.as_str().len() > MAX_ISSUE_URL_LEN {
951 let short_body = format!(
952 "{short_body}\n<!-- The full report was too long for a URL. Paste the output of `code-kb bug-report` here. -->\n"
953 );
954 issue_url = build_issue_url(&title_str, &short_body)?;
955 }
956
957 let log_tail = workspace_root
958 .map(|root| log_tail(root, log_lines))
959 .unwrap_or_default();
960 if !log_tail.is_empty() {
961 markdown.push_str(&format!(
962 "\n### Recent Log Lines\n```text\n{}\n```\n",
963 log_tail.join("\n")
964 ));
965 }
966
967 Ok(BugReportBundle {
968 os_info,
969 arch_info,
970 code_kb_version,
971 julie_extract_version,
972 active_workspace_name,
973 index,
974 recent_errors,
975 log_tail,
976 markdown_body: markdown,
977 github_issue_url: issue_url.to_string(),
978 })
979}
980
981pub fn format_telemetry_summary(summary: &TelemetrySummary) -> String {
982 let mut out = String::new();
983 out.push_str("=================================================================\n");
984 out.push_str(" code-kb Telemetry Summary \n");
985 out.push_str("=================================================================\n");
986
987 if summary.total_calls == 0 {
988 out.push_str(&format!(
989 "Scope: {} | Window: {}\nNo tool calls recorded for this scope yet.\n",
990 summary.scope_description, summary.time_window
991 ));
992 return out;
993 }
994
995 let success_rate = if summary.total_calls > 0 {
996 ((summary.ok_calls + summary.empty_calls) as f64 / summary.total_calls as f64) * 100.0
997 } else {
998 0.0
999 };
1000
1001 out.push_str(&format!(
1002 "Scope: {} | Window: {} | Total Tool Calls: {} | Success Rate: {:.1}% | Empty Results: {} | Tokens Served: ~{} | Est. Tokens Saved (read tools only): ~{}\n\n",
1003 summary.scope_description, summary.time_window, summary.total_calls, success_rate, summary.empty_calls, summary.total_tokens_returned, summary.est_tokens_saved
1004 ));
1005
1006 out.push_str("### Tool Invocations & Performance\n");
1007 out.push_str(
1008 "| Tool | Calls | Empty | Latency (p50 / p95 / avg) | Query / Reconcile | Tokens Served | Est. Tokens Saved | Success Rate |\n",
1009 );
1010 out.push_str("|---|---:|---:|---:|---:|---:|---:|---:|\n");
1011
1012 for stat in &summary.tool_stats {
1013 let rate = if stat.count > 0 {
1014 ((stat.ok_count + stat.empty_count) as f64 / stat.count as f64) * 100.0
1015 } else {
1016 0.0
1017 };
1018 let p50_str = stat
1019 .p50_ms
1020 .map(|v| format!("{}ms", v))
1021 .unwrap_or_else(|| "-".to_string());
1022 let p95_str = stat
1023 .p95_ms
1024 .map(|v| format!("{}ms", v))
1025 .unwrap_or_else(|| "-".to_string());
1026 let latency_str = format!("{} / {} / {}ms", p50_str, p95_str, stat.avg_duration_ms);
1027
1028 let query_str = stat
1029 .avg_query_ms
1030 .map(|v| format!("{}ms", v))
1031 .unwrap_or_else(|| "-".to_string());
1032 let rec_str = stat
1033 .avg_reconcile_ms
1034 .map(|v| format!("{}ms", v))
1035 .unwrap_or_else(|| "-".to_string());
1036 let phase_str = format!("{} / {}", query_str, rec_str);
1037
1038 out.push_str(&format!(
1039 "| `{}` | {} | {} | {} | {} | ~{} | ~{} | {:.1}% |\n",
1040 stat.tool,
1041 stat.count,
1042 stat.empty_count,
1043 latency_str,
1044 phase_str,
1045 stat.tokens_returned,
1046 stat.tokens_saved,
1047 rate
1048 ));
1049 }
1050
1051 if !summary.recent_errors.is_empty() {
1052 out.push_str("\n### Recent Errors\n");
1053 for err in &summary.recent_errors {
1054 out.push_str(&format!(
1055 "- {} [`{}`]: {}\n",
1056 err.timestamp, err.tool, err.error_message
1057 ));
1058 }
1059 }
1060
1061 out
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066 use super::*;
1067
1068 #[test]
1069 fn test_global_telemetry_dir_isolation() {
1070 let custom_dir = PathBuf::from("/custom/telemetry/path");
1071 let target_tmp = PathBuf::from("/workspace/target/tmp");
1072 let home_dir = PathBuf::from("/home/user");
1073 let profile_dir = PathBuf::from("C:\\Users\\user");
1074
1075 assert_eq!(
1077 resolve_telemetry_dir(
1078 Some(custom_dir.to_string_lossy().to_string()),
1079 Some(target_tmp.to_string_lossy().to_string()),
1080 Some(home_dir.to_string_lossy().to_string()),
1081 Some(profile_dir.to_string_lossy().to_string()),
1082 ),
1083 custom_dir
1084 );
1085
1086 assert_eq!(
1088 resolve_telemetry_dir(
1089 None,
1090 Some(target_tmp.to_string_lossy().to_string()),
1091 Some(home_dir.to_string_lossy().to_string()),
1092 Some(profile_dir.to_string_lossy().to_string()),
1093 ),
1094 target_tmp.join("test-telemetry")
1095 );
1096
1097 assert_eq!(
1099 resolve_telemetry_dir(
1100 None,
1101 None,
1102 Some(home_dir.to_string_lossy().to_string()),
1103 Some(profile_dir.to_string_lossy().to_string()),
1104 ),
1105 home_dir.join(".code-kb")
1106 );
1107
1108 assert_eq!(
1110 resolve_telemetry_dir(
1111 None,
1112 None,
1113 None,
1114 Some(profile_dir.to_string_lossy().to_string()),
1115 ),
1116 profile_dir.join(".code-kb")
1117 );
1118
1119 assert_eq!(
1121 resolve_telemetry_dir(None, None, None, None),
1122 PathBuf::from(".code-kb")
1123 );
1124
1125 let temp = crate::safe_tempdir();
1126 set_telemetry_dir_override(Some(temp.path().to_path_buf()));
1127 let dir = get_global_telemetry_dir();
1128 assert_eq!(dir, temp.path());
1129
1130 let conn = open_global_telemetry_db().expect("open_global_telemetry_db should succeed");
1131 assert!(temp.path().join("telemetry.db").exists());
1132 drop(conn);
1133 set_telemetry_dir_override(None);
1134 }
1135
1136 #[test]
1137 fn test_telemetry_disabled_flags() {
1138 assert!(is_telemetry_disabled_with(Some("1"), None));
1139 assert!(is_telemetry_disabled_with(Some("true"), None));
1140 assert!(is_telemetry_disabled_with(Some("TRUE"), None));
1141 assert!(is_telemetry_disabled_with(None, Some("1")));
1142 assert!(is_telemetry_disabled_with(None, Some("true")));
1143 assert!(is_telemetry_disabled_with(None, Some("TRUE")));
1144
1145 assert!(!is_telemetry_disabled_with(Some("0"), None));
1146 assert!(!is_telemetry_disabled_with(Some("false"), None));
1147 assert!(!is_telemetry_disabled_with(None, Some("0")));
1148 assert!(!is_telemetry_disabled_with(None, None));
1149 }
1150
1151 #[test]
1152 fn test_telemetry_filter_time_windows() {
1153 assert_eq!(TimeWindow::parse("today"), Some(TimeWindow::Today));
1154 assert_eq!(TimeWindow::parse("7d"), Some(TimeWindow::Last7Days));
1155 assert_eq!(TimeWindow::parse("week"), Some(TimeWindow::Last7Days));
1156 assert_eq!(TimeWindow::parse("30d"), Some(TimeWindow::Last30Days));
1157 assert_eq!(TimeWindow::parse("month"), Some(TimeWindow::ThisMonth));
1158 assert_eq!(TimeWindow::parse("this-month"), Some(TimeWindow::ThisMonth));
1159 assert_eq!(TimeWindow::parse("year"), Some(TimeWindow::LastYear));
1160 assert_eq!(TimeWindow::parse("last-year"), Some(TimeWindow::LastYear));
1161 assert_eq!(TimeWindow::parse("all"), Some(TimeWindow::AllTime));
1162 assert_eq!(TimeWindow::parse("all-time"), Some(TimeWindow::AllTime));
1163 assert_eq!(TimeWindow::parse("invalid"), None);
1164
1165 let temp = crate::safe_tempdir();
1166 let conn = open_telemetry_db_at(temp.path()).expect("open db");
1167
1168 let ws_root = Path::new("/workspace/test");
1169 let norm_ws =
1170 crate::workspace::to_forward_slash(&crate::workspace::normalize_path(ws_root));
1171
1172 conn.execute(
1173 "INSERT INTO tool_telemetry (id, timestamp, workspace_root, workspace_name, tool, duration_ms, outcome, est_tokens, est_tokens_saved, code_kb_version)
1174 VALUES ('id1', datetime('now'), ?1, 'test', 'find_symbol', 10, 'ok', 100, 200, '0.7.0')",
1175 params![norm_ws],
1176 ).unwrap();
1177
1178 conn.execute(
1179 "INSERT INTO tool_telemetry (id, timestamp, workspace_root, workspace_name, tool, duration_ms, outcome, est_tokens, est_tokens_saved, code_kb_version)
1180 VALUES ('id2', datetime('now', '-2 days'), ?1, 'test', 'find_symbol', 10, 'ok', 100, 200, '0.7.0')",
1181 params![norm_ws],
1182 ).unwrap();
1183
1184 conn.execute(
1185 "INSERT INTO tool_telemetry (id, timestamp, workspace_root, workspace_name, tool, duration_ms, outcome, est_tokens, est_tokens_saved, code_kb_version)
1186 VALUES ('id3', datetime('now', '-15 days'), ?1, 'test', 'find_symbol', 10, 'ok', 100, 200, '0.7.0')",
1187 params![norm_ws],
1188 ).unwrap();
1189
1190 conn.execute(
1191 "INSERT INTO tool_telemetry (id, timestamp, workspace_root, workspace_name, tool, duration_ms, outcome, est_tokens, est_tokens_saved, code_kb_version)
1192 VALUES ('id4', datetime('now', '-60 days'), ?1, 'test', 'find_symbol', 10, 'ok', 100, 200, '0.7.0')",
1193 params![norm_ws],
1194 ).unwrap();
1195
1196 let s_today = get_telemetry_summary(
1197 &conn,
1198 &TelemetryFilter {
1199 time_window: TimeWindow::Today,
1200 workspace_root: None,
1201 version: None,
1202 },
1203 )
1204 .unwrap();
1205 assert_eq!(s_today.total_calls, 1);
1206
1207 let s_7d = get_telemetry_summary(
1208 &conn,
1209 &TelemetryFilter {
1210 time_window: TimeWindow::Last7Days,
1211 workspace_root: None,
1212 version: None,
1213 },
1214 )
1215 .unwrap();
1216 assert_eq!(s_7d.total_calls, 2);
1217
1218 let s_30d = get_telemetry_summary(
1219 &conn,
1220 &TelemetryFilter {
1221 time_window: TimeWindow::Last30Days,
1222 workspace_root: None,
1223 version: None,
1224 },
1225 )
1226 .unwrap();
1227 assert_eq!(s_30d.total_calls, 3);
1228
1229 let s_year = get_telemetry_summary(
1230 &conn,
1231 &TelemetryFilter {
1232 time_window: TimeWindow::LastYear,
1233 workspace_root: None,
1234 version: None,
1235 },
1236 )
1237 .unwrap();
1238 assert_eq!(s_year.total_calls, 4);
1239
1240 let s_all = get_telemetry_summary(
1241 &conn,
1242 &TelemetryFilter {
1243 time_window: TimeWindow::AllTime,
1244 workspace_root: None,
1245 version: None,
1246 },
1247 )
1248 .unwrap();
1249 assert_eq!(s_all.total_calls, 4);
1250 }
1251
1252 #[test]
1253 fn test_telemetry_workspace_scoping() {
1254 let temp = crate::safe_tempdir();
1255 let conn = open_telemetry_db_at(temp.path()).expect("open db");
1256
1257 let ws_a = Path::new("/projects/alpha");
1258 let ws_b = Path::new("/projects/beta");
1259
1260 let inv_a = ToolInvocation {
1261 tool: "find_symbol",
1262 duration_ms: 15,
1263 outcome: "ok",
1264 error_message: None,
1265 logical_result_count: Some(1),
1266 bytes_returned: 100,
1267 est_tokens: 25,
1268 est_tokens_saved: 100,
1269 reconcile_ms: None,
1270 query_ms: None,
1271 };
1272 record_tool_call_conn(&conn, ws_a, &inv_a);
1273 record_tool_call_conn(&conn, ws_a, &inv_a);
1274
1275 let inv_b = ToolInvocation {
1276 tool: "file_skeleton",
1277 duration_ms: 8,
1278 outcome: "ok",
1279 error_message: None,
1280 logical_result_count: Some(1),
1281 bytes_returned: 200,
1282 est_tokens: 50,
1283 est_tokens_saved: 200,
1284 reconcile_ms: None,
1285 query_ms: None,
1286 };
1287 record_tool_call_conn(&conn, ws_b, &inv_b);
1288
1289 let filter_a = TelemetryFilter {
1290 time_window: TimeWindow::AllTime,
1291 workspace_root: Some(ws_a.to_path_buf()),
1292 version: None,
1293 };
1294 let sum_a = get_telemetry_summary(&conn, &filter_a).unwrap();
1295 assert_eq!(sum_a.total_calls, 2);
1296 assert_eq!(sum_a.tool_stats.len(), 1);
1297 assert_eq!(sum_a.tool_stats[0].tool, "find_symbol");
1298 assert!(sum_a.scope_description.contains("alpha"));
1299
1300 let filter_b = TelemetryFilter {
1301 time_window: TimeWindow::AllTime,
1302 workspace_root: Some(ws_b.to_path_buf()),
1303 version: None,
1304 };
1305 let sum_b = get_telemetry_summary(&conn, &filter_b).unwrap();
1306 assert_eq!(sum_b.total_calls, 1);
1307 assert_eq!(sum_b.tool_stats.len(), 1);
1308 assert_eq!(sum_b.tool_stats[0].tool, "file_skeleton");
1309 assert!(sum_b.scope_description.contains("beta"));
1310
1311 let filter_global = TelemetryFilter {
1312 time_window: TimeWindow::AllTime,
1313 workspace_root: None,
1314 version: None,
1315 };
1316 let sum_global = get_telemetry_summary(&conn, &filter_global).unwrap();
1317 assert_eq!(sum_global.total_calls, 3);
1318 assert!(
1319 sum_global
1320 .scope_description
1321 .contains("Global (all workspaces)")
1322 );
1323 }
1324
1325 #[test]
1326 fn test_est_tokens_saved_aggregation() {
1327 let temp = crate::safe_tempdir();
1328 let conn = open_telemetry_db_at(temp.path()).expect("open db");
1329 let ws = Path::new("/projects/token_test");
1330
1331 let inv1 = ToolInvocation {
1332 tool: "file_skeleton",
1333 duration_ms: 10,
1334 outcome: "ok",
1335 error_message: None,
1336 logical_result_count: Some(5),
1337 bytes_returned: 1000,
1338 est_tokens: 250,
1339 est_tokens_saved: 750,
1340 reconcile_ms: None,
1341 query_ms: None,
1342 };
1343 let inv2 = ToolInvocation {
1344 tool: "file_skeleton",
1345 duration_ms: 20,
1346 outcome: "ok",
1347 error_message: None,
1348 logical_result_count: Some(3),
1349 bytes_returned: 600,
1350 est_tokens: 150,
1351 est_tokens_saved: 450,
1352 reconcile_ms: None,
1353 query_ms: None,
1354 };
1355 let inv3 = ToolInvocation {
1356 tool: "get_symbol_body",
1357 duration_ms: 30,
1358 outcome: "ok",
1359 error_message: None,
1360 logical_result_count: Some(1),
1361 bytes_returned: 200,
1362 est_tokens: 50,
1363 est_tokens_saved: 500,
1364 reconcile_ms: None,
1365 query_ms: None,
1366 };
1367
1368 record_tool_call_conn(&conn, ws, &inv1);
1369 record_tool_call_conn(&conn, ws, &inv2);
1370 record_tool_call_conn(&conn, ws, &inv3);
1371
1372 let filter = TelemetryFilter::default();
1373 let summary = get_telemetry_summary(&conn, &filter).unwrap();
1374
1375 assert_eq!(summary.total_calls, 3);
1376 assert_eq!(summary.total_tokens_returned, 450);
1377 assert_eq!(summary.est_tokens_saved, 1700);
1378
1379 let skel_stat = summary
1380 .tool_stats
1381 .iter()
1382 .find(|s| s.tool == "file_skeleton")
1383 .unwrap();
1384 assert_eq!(skel_stat.count, 2);
1385 assert_eq!(skel_stat.tokens_returned, 400);
1386 assert_eq!(skel_stat.tokens_saved, 1200);
1387 assert_eq!(skel_stat.avg_duration_ms, 15);
1388
1389 let sym_stat = summary
1390 .tool_stats
1391 .iter()
1392 .find(|s| s.tool == "get_symbol_body")
1393 .unwrap();
1394 assert_eq!(sym_stat.count, 1);
1395 assert_eq!(sym_stat.tokens_returned, 50);
1396 assert_eq!(sym_stat.tokens_saved, 500);
1397 assert_eq!(sym_stat.avg_duration_ms, 30);
1398 }
1399
1400 #[test]
1401 fn test_bug_report_bundle_generation() {
1402 let temp = crate::safe_tempdir();
1403 let conn = open_telemetry_db_at(temp.path()).unwrap();
1404 let ws = Path::new("/home/user/src/code-kb");
1405
1406 let inv_err = ToolInvocation {
1407 tool: "replace_symbol_body",
1408 duration_ms: 50,
1409 outcome: "error",
1410 error_message: Some("Tree-sitter parse failure on invalid syntax"),
1411 logical_result_count: None,
1412 bytes_returned: 0,
1413 est_tokens: 0,
1414 est_tokens_saved: 0,
1415 reconcile_ms: None,
1416 query_ms: None,
1417 };
1418 record_tool_call_conn(&conn, ws, &inv_err);
1419
1420 let bundle = generate_bug_report(&conn, Some(ws), Some("Parser failure"), None, 0).unwrap();
1421 assert_eq!(bundle.code_kb_version, env!("CARGO_PKG_VERSION"));
1422 assert!(!bundle.os_info.is_empty());
1423 assert!(!bundle.arch_info.is_empty());
1424 assert!(
1425 bundle
1426 .julie_extract_version
1427 .contains(crate::sync::PINNED_JULIE_VERSION)
1428 );
1429 assert_eq!(bundle.active_workspace_name, Some("code-kb".to_string()));
1430 assert_eq!(bundle.recent_errors.len(), 1);
1431 assert!(
1432 bundle.recent_errors[0]
1433 .error_message
1434 .contains("Tree-sitter parse failure")
1435 );
1436
1437 assert!(bundle.markdown_body.contains("code-kb"));
1438 assert!(bundle.markdown_body.contains(&bundle.os_info));
1439 assert!(bundle.markdown_body.contains("Tree-sitter parse failure"));
1440
1441 assert!(
1442 bundle
1443 .github_issue_url
1444 .starts_with("https://github.com/anortham/code-kb/issues/new?")
1445 );
1446 assert!(bundle.github_issue_url.contains("title=Parser"));
1447
1448 let parsed_url = url::Url::parse(&bundle.github_issue_url).unwrap();
1449 assert_eq!(parsed_url.host_str(), Some("github.com"));
1450 }
1451
1452 #[test]
1453 fn test_logical_result_count_persists_known_empty_and_nonempty_results() {
1454 let temp = crate::safe_tempdir();
1455 let conn = open_telemetry_db_at(temp.path()).expect("open db");
1456 let root = temp.path();
1457
1458 for (tool, outcome, logical_result_count) in [
1459 ("search_symbols", "empty", Some(0)),
1460 ("search_symbols", "ok", Some(3)),
1461 ] {
1462 record_tool_call_conn(
1463 &conn,
1464 root,
1465 &ToolInvocation {
1466 tool,
1467 duration_ms: 1,
1468 outcome,
1469 error_message: None,
1470 logical_result_count,
1471 bytes_returned: 10,
1472 est_tokens: 2,
1473 est_tokens_saved: 0,
1474 reconcile_ms: None,
1475 query_ms: None,
1476 },
1477 );
1478 }
1479
1480 let counts = conn
1481 .prepare("SELECT result_count, result_count_known FROM tool_telemetry ORDER BY rowid")
1482 .unwrap()
1483 .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))
1484 .unwrap()
1485 .collect::<Result<Vec<_>, _>>()
1486 .unwrap();
1487 assert_eq!(counts, vec![(0, 1), (3, 1)]);
1488
1489 let summary = get_telemetry_summary(&conn, &TelemetryFilter::default()).unwrap();
1490 assert_eq!(summary.empty_calls, 1);
1491 assert_eq!(summary.ok_calls, 1);
1492 assert_eq!(summary.tool_stats[0].empty_count, 1);
1493 let formatted = format_telemetry_summary(&summary);
1494 assert!(formatted.contains("Success Rate: 100.0%"));
1495 assert!(formatted.contains("Empty Results: 1"));
1496 assert!(formatted.contains("| `search_symbols` | 2 | 1 |"));
1497 }
1498
1499 #[test]
1500 fn test_telemetry_recording_and_summary() {
1501 let temp = crate::safe_tempdir();
1502 let root = temp.path();
1503 let conn = open_telemetry_db_at(temp.path()).expect("open db");
1504
1505 let inv1 = ToolInvocation {
1506 tool: "file_skeleton",
1507 duration_ms: 6,
1508 outcome: "ok",
1509 error_message: None,
1510 logical_result_count: Some(5),
1511 bytes_returned: 1200,
1512 est_tokens: 300,
1513 est_tokens_saved: 900,
1514 reconcile_ms: None,
1515 query_ms: None,
1516 };
1517 record_tool_call_conn(&conn, root, &inv1);
1518
1519 let inv2 = ToolInvocation {
1520 tool: "file_skeleton",
1521 duration_ms: 4,
1522 outcome: "ok",
1523 error_message: None,
1524 logical_result_count: Some(3),
1525 bytes_returned: 800,
1526 est_tokens: 200,
1527 est_tokens_saved: 600,
1528 reconcile_ms: None,
1529 query_ms: None,
1530 };
1531 record_tool_call_conn(&conn, root, &inv2);
1532
1533 let inv3 = ToolInvocation {
1534 tool: "replace_symbol_body",
1535 duration_ms: 12,
1536 outcome: "error",
1537 error_message: Some("Syntax error in Rust function"),
1538 logical_result_count: None,
1539 bytes_returned: 50,
1540 est_tokens: 12,
1541 est_tokens_saved: 0,
1542 reconcile_ms: None,
1543 query_ms: None,
1544 };
1545 record_tool_call_conn(&conn, root, &inv3);
1546
1547 let summary = get_telemetry_summary(&conn, &TelemetryFilter::default()).unwrap();
1548 assert_eq!(summary.total_calls, 3);
1549 assert_eq!(summary.ok_calls, 2);
1550 assert_eq!(summary.error_calls, 1);
1551 assert_eq!(summary.total_tokens_returned, 512);
1552 assert_eq!(summary.est_tokens_saved, 1500);
1553
1554 assert_eq!(summary.tool_stats.len(), 2);
1555 let skel_stat = summary
1556 .tool_stats
1557 .iter()
1558 .find(|s| s.tool == "file_skeleton")
1559 .unwrap();
1560 assert_eq!(skel_stat.count, 2);
1561 assert_eq!(skel_stat.ok_count, 2);
1562 assert_eq!(skel_stat.avg_duration_ms, 5);
1563
1564 assert_eq!(summary.recent_errors.len(), 1);
1565 assert_eq!(summary.recent_errors[0].tool, "replace_symbol_body");
1566 assert!(
1567 summary.recent_errors[0]
1568 .error_message
1569 .contains("Syntax error")
1570 );
1571
1572 let formatted = format_telemetry_summary(&summary);
1573 assert!(formatted.contains("Total Tool Calls: 3"));
1574 assert!(formatted.contains("| `file_skeleton` | 2 |"));
1575 assert!(formatted.contains("Syntax error in Rust function"));
1576 }
1577
1578 #[test]
1579 fn test_old_schema_upgrade() {
1580 let temp = crate::safe_tempdir();
1581 let db_path = temp.path().join("telemetry.db");
1582 let conn = Connection::open(&db_path).unwrap();
1583
1584 conn.execute_batch(
1586 "CREATE TABLE tool_telemetry (
1587 id TEXT PRIMARY KEY,
1588 timestamp TEXT NOT NULL,
1589 tool TEXT NOT NULL,
1590 duration_ms INTEGER NOT NULL,
1591 outcome TEXT NOT NULL,
1592 error_message TEXT,
1593 result_count INTEGER NOT NULL DEFAULT 0,
1594 bytes_returned INTEGER NOT NULL DEFAULT 0,
1595 est_tokens INTEGER NOT NULL DEFAULT 0,
1596 code_kb_version TEXT NOT NULL
1597 );
1598 INSERT INTO tool_telemetry VALUES (
1599 'old1', '2026-09-01 12:00:00', 'find_symbol', 10, 'ok', NULL, 1, 50, 12, '0.6.0'
1600 );",
1601 )
1602 .unwrap();
1603
1604 init_telemetry_db(&conn).expect("schema upgrade should succeed on legacy DB");
1606
1607 let summary = get_telemetry_summary(&conn, &TelemetryFilter::default()).unwrap();
1609 assert_eq!(summary.total_calls, 1);
1610 assert_eq!(summary.ok_calls, 1);
1611 assert_eq!(
1612 conn.query_row(
1613 "SELECT result_count_known FROM tool_telemetry WHERE id = 'old1'",
1614 [],
1615 |row| row.get::<_, i64>(0),
1616 )
1617 .unwrap(),
1618 0
1619 );
1620
1621 let inv = ToolInvocation {
1623 tool: "file_skeleton",
1624 duration_ms: 5,
1625 outcome: "ok",
1626 error_message: None,
1627 logical_result_count: Some(1),
1628 bytes_returned: 100,
1629 est_tokens: 25,
1630 est_tokens_saved: 75,
1631 reconcile_ms: None,
1632 query_ms: None,
1633 };
1634 record_tool_call_conn(&conn, Path::new("/workspace/project"), &inv);
1635
1636 let ws_filter = TelemetryFilter {
1637 time_window: TimeWindow::AllTime,
1638 workspace_root: Some(PathBuf::from("/workspace/project")),
1639 version: None,
1640 };
1641 let ws_summary = get_telemetry_summary(&conn, &ws_filter).unwrap();
1642 assert_eq!(ws_summary.total_calls, 1);
1643 }
1644
1645 #[test]
1646 fn test_bug_report_includes_index_facts_description_and_masked_log_tail() {
1647 let telemetry_dir = crate::safe_tempdir();
1648 let conn = open_telemetry_db_at(telemetry_dir.path()).unwrap();
1649 let workspace = crate::safe_tempdir();
1650 let root = workspace.path();
1651 let kb_dir = root.join(".code-kb");
1652 std::fs::create_dir_all(kb_dir.join("logs")).unwrap();
1653 let index = Connection::open(kb_dir.join("artifact.db")).unwrap();
1654 index
1655 .execute_batch(
1656 "CREATE TABLE artifact_metadata (key TEXT PRIMARY KEY, value TEXT);
1657 INSERT INTO artifact_metadata VALUES ('binary_version', '3.1.1'), ('schema_version', '7'), ('index_level', 'facts');
1658 CREATE TABLE files (file_id TEXT); INSERT INTO files VALUES ('a'), ('b');
1659 CREATE TABLE symbols (symbol_id TEXT); INSERT INTO symbols VALUES ('s');",
1660 )
1661 .unwrap();
1662 drop(index);
1663 let home = std::env::var("HOME")
1664 .or_else(|_| std::env::var("USERPROFILE"))
1665 .unwrap_or_else(|_| "/default/home".to_string());
1666 let logs = kb_dir.join("logs");
1667 std::fs::write(
1668 logs.join("code-kb.log.2026-09-19"),
1669 "older one\nolder two\n",
1670 )
1671 .unwrap();
1672 std::thread::sleep(std::time::Duration::from_millis(20));
1673 std::fs::write(
1674 logs.join("code-kb.log.2026-09-20"),
1675 format!("first\nsecond {home}/repo/src/lib.rs\nthird\n"),
1676 )
1677 .unwrap();
1678 std::thread::sleep(std::time::Duration::from_millis(20));
1679 std::fs::write(logs.join("notes.txt"), "not a log\n").unwrap();
1680
1681 let bundle =
1682 generate_bug_report(&conn, Some(root), Some("Crash"), Some(" It crashed. "), 2)
1683 .unwrap();
1684
1685 let facts = bundle.index.as_ref().unwrap();
1686 assert_eq!(facts.extractor_version.as_deref(), Some("3.1.1"));
1687 assert_eq!(facts.schema_version.as_deref(), Some("7"));
1688 assert_eq!(facts.index_level.as_deref(), Some("facts"));
1689 assert_eq!(facts.updated_at, None);
1690 assert_eq!((facts.file_count, facts.symbol_count), (2, 1));
1691 assert_eq!(bundle.log_tail, vec!["second ~/repo/src/lib.rs", "third"]);
1692 assert!(
1693 bundle
1694 .markdown_body
1695 .contains("- **Extractor:** 3.1.1 (schema 7, level facts)")
1696 );
1697 assert!(
1698 bundle
1699 .markdown_body
1700 .contains("- **Files / Symbols:** 2 / 1")
1701 );
1702 assert!(
1703 bundle
1704 .markdown_body
1705 .contains("### Description\nIt crashed.\n")
1706 );
1707 assert!(
1708 bundle
1709 .markdown_body
1710 .contains("### Recent Log Lines\n```text\nsecond ~/repo/src/lib.rs\nthird\n```")
1711 );
1712 assert!(!bundle.markdown_body.contains(&home));
1713 assert!(!bundle.github_issue_url.contains("Recent+Log+Lines"));
1714 assert!(bundle.github_issue_url.contains("It+crashed."));
1715
1716 let across_rollover = generate_bug_report(
1717 &conn,
1718 Some(root),
1719 None,
1720 Some(&format!("{home}/x\nline two")),
1721 4,
1722 )
1723 .unwrap();
1724 assert_eq!(
1725 across_rollover.log_tail,
1726 vec!["older two", "first", "second ~/repo/src/lib.rs", "third"]
1727 );
1728 assert!(
1729 across_rollover
1730 .markdown_body
1731 .contains("### Description\n~/x\nline two\n")
1732 );
1733 assert!(!across_rollover.markdown_body.contains(&home));
1734 assert!(!across_rollover.github_issue_url.contains("notes"));
1735
1736 let oversized =
1737 generate_bug_report(&conn, Some(root), None, Some(&"y".repeat(9000)), 0).unwrap();
1738 assert!(oversized.markdown_body.contains(&"y".repeat(9000)));
1739 assert!(oversized.github_issue_url.len() <= MAX_ISSUE_URL_LEN);
1740 assert!(oversized.github_issue_url.contains("too+long+for+a+URL"));
1741 assert!(oversized.github_issue_url.contains("Files+%2F+Symbols"));
1742
1743 let without_index =
1744 generate_bug_report(&conn, Some(Path::new("/nonexistent/ws")), None, None, 0).unwrap();
1745 assert!(without_index.index.is_none());
1746 assert!(without_index.log_tail.is_empty());
1747 assert!(
1748 without_index
1749 .markdown_body
1750 .contains("No `.code-kb/artifact.db`")
1751 );
1752 assert!(without_index.markdown_body.contains("<!-- Please describe"));
1753 }
1754
1755 #[test]
1756 fn test_bug_report_sanitization_and_no_external_exec() {
1757 let temp = crate::safe_tempdir();
1758 let conn = open_telemetry_db_at(temp.path()).unwrap();
1759
1760 let current_home = std::env::var("HOME")
1761 .or_else(|_| std::env::var("USERPROFILE"))
1762 .unwrap_or_else(|_| "/default/home".to_string());
1763
1764 let sensitive_error = format!(
1765 "{}/workspace/secret-repo/src/lib.rs: syntax error | unexpected token | extra line\nsecond line of error | {}",
1766 current_home,
1767 "x".repeat(600), );
1769
1770 let inv = ToolInvocation {
1771 tool: "replace_symbol_body",
1772 duration_ms: 10,
1773 outcome: "error",
1774 error_message: Some(&sensitive_error),
1775 logical_result_count: None,
1776 bytes_returned: 0,
1777 est_tokens: 0,
1778 est_tokens_saved: 0,
1779 reconcile_ms: None,
1780 query_ms: None,
1781 };
1782 let ws_temp = crate::safe_tempdir();
1784 record_tool_call_conn(&conn, ws_temp.path(), &inv);
1785 let malicious_tools_dir = ws_temp.path().join(".tools");
1786 std::fs::create_dir_all(&malicious_tools_dir).unwrap();
1787 let fake_bin = if cfg!(windows) {
1788 malicious_tools_dir.join("julie-extract.exe")
1789 } else {
1790 malicious_tools_dir.join("julie-extract")
1791 };
1792 std::fs::write(&fake_bin, b"#!/bin/sh\necho malicious 9.9.9\nexit 0\n").unwrap();
1793 #[cfg(unix)]
1794 {
1795 use std::os::unix::fs::PermissionsExt;
1796 std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1797 }
1798
1799 let bundle = generate_bug_report(
1800 &conn,
1801 Some(ws_temp.path()),
1802 Some("Issue with | pipes"),
1803 None,
1804 0,
1805 )
1806 .unwrap();
1807
1808 if !current_home.is_empty() && current_home != "/" {
1810 assert!(
1811 !bundle.markdown_body.contains(¤t_home),
1812 "Home directory must be sanitized to ~"
1813 );
1814 assert!(
1815 bundle.markdown_body.contains("~/workspace/secret-repo"),
1816 "Home directory should be replaced with ~"
1817 );
1818 assert!(
1819 !bundle.github_issue_url.contains(¤t_home),
1820 "GitHub URL must not leak home directory"
1821 );
1822 }
1823
1824 let custom_sanitized = sanitize_error_message_with_homes(
1826 "/custom/secret/path/main.rs: err | note\nsecond line",
1827 &["/custom/secret/path".to_string()],
1828 );
1829 assert_eq!(custom_sanitized, "~/main.rs: err \\| note second line");
1830
1831 assert!(
1833 !bundle.markdown_body.contains(" | unexpected token"),
1834 "Pipe characters must be escaped"
1835 );
1836 assert!(
1837 bundle.markdown_body.contains(r" \| unexpected token"),
1838 "Pipe characters must be escaped as \\|"
1839 );
1840 assert!(
1841 !bundle.markdown_body.contains("extra line\nsecond line"),
1842 "Newlines must be sanitized"
1843 );
1844
1845 assert!(
1847 bundle.recent_errors[0].error_message.chars().count() <= 500,
1848 "Error message must be truncated to 500 chars"
1849 );
1850
1851 assert_ne!(
1853 bundle.julie_extract_version, "malicious 9.9.9",
1854 "Must not execute .tools/julie-extract from workspace"
1855 );
1856 assert_eq!(
1857 bundle.julie_extract_version,
1858 crate::sync::PINNED_JULIE_VERSION,
1859 "Must report pinned version"
1860 );
1861 }
1862
1863 #[test]
1864 fn test_telemetry_summary_symlink_and_macos_private_var_matching() {
1865 let telem_dir = crate::safe_tempdir();
1866 let conn = Connection::open(telem_dir.path().join("telemetry.db")).unwrap();
1867 init_telemetry_db(&conn).unwrap();
1868
1869 let raw_var_path = "/var/folders/zz/12345678/T/my_repo";
1871 conn.execute(
1872 "INSERT INTO tool_telemetry VALUES (
1873 't-1', datetime('now'), ?1, 'my_repo', 'lookup_symbol',
1874 12, 'error', 'Failed to find symbol Foo', 0, 0, 100, 25, 0, '0.9.0',
1875 NULL, NULL
1876 )",
1877 params![raw_var_path],
1878 )
1879 .unwrap();
1880
1881 let filter = TelemetryFilter {
1883 time_window: TimeWindow::AllTime,
1884 workspace_root: Some(PathBuf::from("/private/var/folders/zz/12345678/T/my_repo")),
1885 version: None,
1886 };
1887 let summary = get_telemetry_summary(&conn, &filter).unwrap();
1888 assert_eq!(
1889 summary.total_calls, 1,
1890 "Must match record across /private/var and /var"
1891 );
1892 assert_eq!(summary.recent_errors.len(), 1);
1893 assert!(
1894 summary.recent_errors[0]
1895 .error_message
1896 .contains("Failed to find symbol Foo")
1897 );
1898
1899 conn.execute(
1901 "INSERT INTO tool_telemetry VALUES (
1902 't-2', datetime('now'), ?1, 'other_repo', 'lookup_symbol',
1903 12, 'error', 'Reverse matching error', 0, 0, 100, 25, 0, '0.9.0',
1904 NULL, NULL
1905 )",
1906 params!["/private/var/folders/zz/99999999/T/other_repo"],
1907 )
1908 .unwrap();
1909
1910 let filter_rev = TelemetryFilter {
1911 time_window: TimeWindow::AllTime,
1912 workspace_root: Some(PathBuf::from("/var/folders/zz/99999999/T/other_repo")),
1913 version: None,
1914 };
1915 let summary_rev = get_telemetry_summary(&conn, &filter_rev).unwrap();
1916 assert_eq!(summary_rev.total_calls, 1);
1917 assert_eq!(summary_rev.recent_errors.len(), 1);
1918 assert!(
1919 summary_rev.recent_errors[0]
1920 .error_message
1921 .contains("Reverse matching error")
1922 );
1923
1924 let bug_report = generate_bug_report(
1926 &conn,
1927 Some(Path::new("/private/var/folders/zz/12345678/T/my_repo")),
1928 Some("test issue"),
1929 None,
1930 0,
1931 )
1932 .unwrap();
1933 assert_eq!(bug_report.recent_errors.len(), 1);
1934 assert!(
1935 bug_report.recent_errors[0]
1936 .error_message
1937 .contains("Failed to find symbol Foo")
1938 );
1939 }
1940
1941 #[test]
1942 fn test_phase_metrics_and_version_filtering() {
1943 let temp = crate::safe_tempdir();
1944 let db_path = temp.path().join("telemetry.db");
1945 let conn = Connection::open(&db_path).unwrap();
1946
1947 conn.execute_batch(
1949 "CREATE TABLE tool_telemetry (
1950 id TEXT PRIMARY KEY,
1951 timestamp TEXT NOT NULL,
1952 workspace_root TEXT NOT NULL,
1953 workspace_name TEXT NOT NULL,
1954 tool TEXT NOT NULL,
1955 duration_ms INTEGER NOT NULL,
1956 outcome TEXT NOT NULL,
1957 error_message TEXT,
1958 result_count INTEGER NOT NULL DEFAULT 0,
1959 result_count_known INTEGER NOT NULL DEFAULT 0,
1960 bytes_returned INTEGER NOT NULL DEFAULT 0,
1961 est_tokens INTEGER NOT NULL DEFAULT 0,
1962 est_tokens_saved INTEGER NOT NULL DEFAULT 0,
1963 code_kb_version TEXT NOT NULL
1964 );
1965 INSERT INTO tool_telemetry VALUES (
1966 'legacy1', '2026-09-01 12:00:00', '/ws', 'ws', 'lookup_symbol', 10, 'ok', NULL, 1, 1, 50, 12, 0, '1.1.0'
1967 );",
1968 )
1969 .unwrap();
1970
1971 init_telemetry_db(&conn).expect("schema upgrade should succeed on legacy DB");
1972
1973 let (rec, q): (Option<i64>, Option<i64>) = conn
1975 .query_row(
1976 "SELECT reconcile_ms, query_ms FROM tool_telemetry WHERE id = 'legacy1'",
1977 [],
1978 |row| Ok((row.get(0)?, row.get(1)?)),
1979 )
1980 .unwrap();
1981 assert_eq!(rec, None);
1982 assert_eq!(q, None);
1983
1984 let inv1 = ToolInvocation {
1986 tool: "lookup_symbol",
1987 duration_ms: 120,
1988 outcome: "ok",
1989 error_message: None,
1990 logical_result_count: Some(1),
1991 bytes_returned: 100,
1992 est_tokens: 25,
1993 est_tokens_saved: 50,
1994 reconcile_ms: Some(100),
1995 query_ms: Some(20),
1996 };
1997 conn.execute(
1999 "INSERT INTO tool_telemetry (
2000 id, timestamp, workspace_root, workspace_name, tool,
2001 duration_ms, outcome, error_message, result_count, result_count_known,
2002 bytes_returned, est_tokens, est_tokens_saved, code_kb_version,
2003 reconcile_ms, query_ms
2004 ) VALUES ('c1', datetime('now'), '/ws', 'ws', ?1, ?2, ?3, NULL, 1, 1, ?4, ?5, ?6, '1.1.2', ?7, ?8)",
2005 params![
2006 inv1.tool,
2007 inv1.duration_ms as i64,
2008 inv1.outcome,
2009 inv1.bytes_returned as i64,
2010 inv1.est_tokens as i64,
2011 inv1.est_tokens_saved as i64,
2012 inv1.reconcile_ms.map(|v| v as i64),
2013 inv1.query_ms.map(|v| v as i64),
2014 ],
2015 ).unwrap();
2016
2017 for (id, dur, q_ms) in [("c2", 5, 5), ("c3", 15, 15), ("c4", 25, 25)] {
2018 conn.execute(
2019 "INSERT INTO tool_telemetry (
2020 id, timestamp, workspace_root, workspace_name, tool,
2021 duration_ms, outcome, error_message, result_count, result_count_known,
2022 bytes_returned, est_tokens, est_tokens_saved, code_kb_version,
2023 reconcile_ms, query_ms
2024 ) VALUES (?1, datetime('now'), '/ws', 'ws', 'lookup_symbol', ?2, 'ok', NULL, 1, 1, 100, 25, 50, '1.1.3', 0, ?3)",
2025 params![id, dur as i64, q_ms as i64],
2026 ).unwrap();
2027 }
2028
2029 let filter_v113 = TelemetryFilter {
2031 time_window: TimeWindow::AllTime,
2032 workspace_root: None,
2033 version: Some("1.1.3".to_string()),
2034 };
2035 let summary_v113 = get_telemetry_summary(&conn, &filter_v113).unwrap();
2036 assert_eq!(summary_v113.total_calls, 3);
2037 assert_eq!(summary_v113.tool_stats.len(), 1);
2038 let stat = &summary_v113.tool_stats[0];
2039 assert_eq!(stat.tool, "lookup_symbol");
2040 assert_eq!(stat.count, 3);
2041 assert_eq!(stat.p50_ms, Some(15));
2042 assert_eq!(stat.p95_ms, Some(25));
2043 assert_eq!(stat.avg_reconcile_ms, Some(0));
2044 assert_eq!(stat.avg_query_ms, Some(15));
2045
2046 let filter_all = TelemetryFilter {
2048 time_window: TimeWindow::AllTime,
2049 workspace_root: None,
2050 version: None,
2051 };
2052 let summary_all = get_telemetry_summary(&conn, &filter_all).unwrap();
2053 assert_eq!(summary_all.total_calls, 5); record_tool_call_conn(&conn, Path::new("/ws"), &inv1);
2057 let (last_rec, last_q): (Option<i64>, Option<i64>) = conn
2058 .query_row(
2059 "SELECT reconcile_ms, query_ms FROM tool_telemetry ORDER BY rowid DESC LIMIT 1",
2060 [],
2061 |row| Ok((row.get(0)?, row.get(1)?)),
2062 )
2063 .unwrap();
2064 assert_eq!(last_rec, Some(100));
2065 assert_eq!(last_q, Some(20));
2066 }
2067}