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