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