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