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