1use std::collections::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 result_count: usize,
19 pub bytes_returned: usize,
20 pub est_tokens: usize,
21 pub est_tokens_saved: usize,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
25pub enum TimeWindow {
26 Today,
27 Last7Days,
28 Last30Days,
29 ThisMonth,
30 LastYear,
31 #[default]
32 AllTime,
33}
34
35impl TimeWindow {
36 pub fn parse(s: &str) -> Option<Self> {
37 match s.to_lowercase().as_str() {
38 "today" => Some(Self::Today),
39 "7d" | "week" => Some(Self::Last7Days),
40 "30d" => Some(Self::Last30Days),
41 "month" | "this-month" => Some(Self::ThisMonth),
42 "year" | "last-year" => Some(Self::LastYear),
43 "all" | "all-time" => Some(Self::AllTime),
44 _ => None,
45 }
46 }
47
48 pub fn to_sqlite_condition(&self) -> Option<&'static str> {
49 match self {
50 Self::Today => Some("timestamp >= datetime('now', 'localtime', 'start of day')"),
51 Self::Last7Days => Some("timestamp >= datetime('now', '-7 days')"),
52 Self::Last30Days => Some("timestamp >= datetime('now', '-30 days')"),
53 Self::ThisMonth => Some("timestamp >= datetime('now', 'localtime', 'start of month')"),
54 Self::LastYear => Some("timestamp >= datetime('now', '-365 days')"),
55 Self::AllTime => None,
56 }
57 }
58}
59
60impl std::fmt::Display for TimeWindow {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 Self::Today => write!(f, "today"),
64 Self::Last7Days => write!(f, "7d"),
65 Self::Last30Days => write!(f, "30d"),
66 Self::ThisMonth => write!(f, "month"),
67 Self::LastYear => write!(f, "year"),
68 Self::AllTime => write!(f, "all"),
69 }
70 }
71}
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74pub struct TelemetryFilter {
75 pub time_window: TimeWindow,
76 pub workspace_root: Option<PathBuf>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct TelemetrySummary {
81 pub total_calls: usize,
82 pub ok_calls: usize,
83 pub empty_calls: usize,
84 pub error_calls: usize,
85 pub total_tokens_returned: usize,
86 pub est_tokens_saved: usize,
87 pub time_window: TimeWindow,
88 pub scope_description: String,
89 pub tool_stats: Vec<ToolStat>,
90 pub recent_errors: Vec<TelemetryErrorRecord>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ToolStat {
95 pub tool: String,
96 pub count: usize,
97 pub ok_count: usize,
98 pub error_count: usize,
99 pub avg_duration_ms: u64,
100 pub tokens_returned: usize,
101 pub tokens_saved: usize,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct TelemetryErrorRecord {
106 pub timestamp: String,
107 pub tool: String,
108 pub error_message: String,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct BugReportBundle {
113 pub os_info: String,
114 pub arch_info: String,
115 pub code_kb_version: String,
116 pub julie_extract_version: String,
117 pub active_workspace_name: Option<String>,
118 pub recent_errors: Vec<TelemetryErrorRecord>,
119 pub markdown_body: String,
120 pub github_issue_url: String,
121}
122
123static TELEMETRY_DIR_OVERRIDE: RwLock<Option<PathBuf>> = RwLock::new(None);
124
125pub fn resolve_telemetry_dir(
126 env_dir: Option<String>,
127 cargo_target_tmp: Option<String>,
128 home: Option<String>,
129 userprofile: Option<String>,
130) -> PathBuf {
131 if let Some(dir) = env_dir
132 && !dir.trim().is_empty()
133 {
134 return PathBuf::from(dir);
135 }
136 if let Some(target_tmp) = cargo_target_tmp
137 && !target_tmp.trim().is_empty()
138 {
139 return PathBuf::from(target_tmp).join("test-telemetry");
140 }
141 if let Some(home) = home
142 && !home.trim().is_empty()
143 {
144 return PathBuf::from(home).join(".code-kb");
145 }
146 if let Some(profile) = userprofile
147 && !profile.trim().is_empty()
148 {
149 return PathBuf::from(profile).join(".code-kb");
150 }
151 PathBuf::from(".code-kb")
152}
153
154pub fn is_telemetry_disabled_with(no_telem: Option<&str>, disable_telem: Option<&str>) -> bool {
155 no_telem
156 .or(disable_telem)
157 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
158 .unwrap_or(false)
159}
160
161pub fn is_telemetry_disabled() -> bool {
162 let no_telem = std::env::var("CODE_KB_NO_TELEMETRY").ok();
163 let disable_telem = std::env::var("CODE_KB_DISABLE_TELEMETRY").ok();
164 is_telemetry_disabled_with(no_telem.as_deref(), disable_telem.as_deref())
165}
166
167pub fn get_global_telemetry_dir() -> PathBuf {
168 if let Ok(guard) = TELEMETRY_DIR_OVERRIDE.read()
169 && let Some(ref path) = *guard
170 {
171 return path.clone();
172 }
173 resolve_telemetry_dir(
174 std::env::var("CODE_KB_TELEMETRY_DIR").ok(),
175 std::env::var("CARGO_TARGET_TMPDIR").ok(),
176 std::env::var("HOME").ok(),
177 std::env::var("USERPROFILE").ok(),
178 )
179}
180
181#[cfg(test)]
182pub(crate) fn set_telemetry_dir_override(path: Option<PathBuf>) {
183 if let Ok(mut guard) = TELEMETRY_DIR_OVERRIDE.write() {
184 *guard = path;
185 }
186}
187
188pub fn open_global_telemetry_db() -> Result<Connection, QueryError> {
189 open_telemetry_db_at(&get_global_telemetry_dir())
190}
191
192pub fn open_telemetry_db_at(dir: &Path) -> Result<Connection, QueryError> {
193 if !dir.exists() {
194 let _ = std::fs::create_dir_all(dir);
195 }
196 let db_path = dir.join("telemetry.db");
197 let conn = Connection::open(&db_path)?;
198 init_telemetry_db(&conn)?;
199 Ok(conn)
200}
201
202fn init_telemetry_db(conn: &Connection) -> Result<(), QueryError> {
203 conn.execute_batch(
204 "PRAGMA journal_mode = WAL;
205 PRAGMA synchronous = NORMAL;
206 PRAGMA busy_timeout = 5000;
207 CREATE TABLE IF NOT EXISTS tool_telemetry (
208 id TEXT PRIMARY KEY,
209 timestamp TEXT NOT NULL,
210 workspace_root TEXT NOT NULL,
211 workspace_name TEXT NOT NULL,
212 tool TEXT NOT NULL,
213 duration_ms INTEGER NOT NULL,
214 outcome TEXT NOT NULL,
215 error_message TEXT,
216 result_count INTEGER NOT NULL DEFAULT 0,
217 bytes_returned INTEGER NOT NULL DEFAULT 0,
218 est_tokens INTEGER NOT NULL DEFAULT 0,
219 est_tokens_saved INTEGER NOT NULL DEFAULT 0,
220 code_kb_version TEXT NOT NULL
221 );
222 CREATE INDEX IF NOT EXISTS idx_tool_telemetry_tool ON tool_telemetry(tool, timestamp DESC);
223 CREATE INDEX IF NOT EXISTS idx_tool_telemetry_ts ON tool_telemetry(timestamp DESC);
224 DELETE FROM tool_telemetry WHERE timestamp < datetime('now', '-365 days');",
225 )?;
226
227 let mut stmt = conn.prepare("PRAGMA table_info(tool_telemetry)")?;
229 let cols = stmt.query_map([], |row| row.get::<_, String>(1))?;
230 let mut col_names = HashSet::new();
231 for col in cols.flatten() {
232 col_names.insert(col);
233 }
234 if !col_names.contains("workspace_root") {
235 conn.execute(
236 "ALTER TABLE tool_telemetry ADD COLUMN workspace_root TEXT NOT NULL DEFAULT ''",
237 [],
238 )?;
239 }
240 if !col_names.contains("workspace_name") {
241 conn.execute(
242 "ALTER TABLE tool_telemetry ADD COLUMN workspace_name TEXT NOT NULL DEFAULT ''",
243 [],
244 )?;
245 }
246 if !col_names.contains("est_tokens_saved") {
247 conn.execute(
248 "ALTER TABLE tool_telemetry ADD COLUMN est_tokens_saved INTEGER NOT NULL DEFAULT 0",
249 [],
250 )?;
251 }
252
253 conn.execute(
255 "CREATE INDEX IF NOT EXISTS idx_tool_telemetry_ws_ts ON tool_telemetry(workspace_root, timestamp DESC)",
256 [],
257 )?;
258
259 Ok(())
260}
261
262pub fn open_telemetry_db(_workspace_root: &Path) -> Result<Connection, QueryError> {
264 open_global_telemetry_db()
265}
266
267pub(crate) fn workspace_root_match_candidates(ws: &Path) -> (String, Option<String>) {
270 let norm = to_forward_slash(&normalize_path(ws));
271 let canonical = dunce::canonicalize(ws)
272 .map(|p| to_forward_slash(&normalize_path(&p)))
273 .unwrap_or_else(|_| norm.clone());
274
275 if canonical != norm {
276 return (canonical, Some(norm));
277 }
278
279 if let Some(rest) = norm.strip_prefix("/private/") {
281 if rest.starts_with("var/") || rest.starts_with("tmp/") || rest.starts_with("etc/") {
282 return (norm.clone(), Some(format!("/{}", rest)));
283 }
284 } else if norm.starts_with("/var/") || norm.starts_with("/tmp/") || norm.starts_with("/etc/") {
285 return (norm.clone(), Some(format!("/private{}", norm)));
286 }
287
288 (norm, None)
289}
290
291pub fn record_tool_call_conn(
293 conn: &Connection,
294 workspace_root: &Path,
295 invocation: &ToolInvocation,
296) {
297 if is_telemetry_disabled() {
298 return;
299 }
300 let now = SystemTime::now()
301 .duration_since(SystemTime::UNIX_EPOCH)
302 .unwrap_or_default();
303 let ts = format!("{:?}", SystemTime::now());
304 let id_source = format!("{}:{}:{}", invocation.tool, ts, now.as_nanos());
305 let id = blake3::hash(id_source.as_bytes()).to_hex().to_string();
306 let version = env!("CARGO_PKG_VERSION");
307
308 let canonical =
309 dunce::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
310 let norm_ws = to_forward_slash(&normalize_path(&canonical));
311 let ws_name = Path::new(&norm_ws)
312 .file_name()
313 .map(|n| n.to_string_lossy().to_string())
314 .unwrap_or_else(|| "repo".to_string());
315
316 let _ = conn.execute(
317 "INSERT INTO tool_telemetry (
318 id, timestamp, workspace_root, workspace_name, tool,
319 duration_ms, outcome, error_message, result_count,
320 bytes_returned, est_tokens, est_tokens_saved, code_kb_version
321 ) VALUES (?1, datetime('now'), ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
322 params![
323 id,
324 norm_ws,
325 ws_name,
326 invocation.tool,
327 invocation.duration_ms as i64,
328 invocation.outcome,
329 invocation.error_message,
330 invocation.result_count as i64,
331 invocation.bytes_returned as i64,
332 invocation.est_tokens as i64,
333 invocation.est_tokens_saved as i64,
334 version
335 ],
336 );
337}
338
339pub fn record_tool_call(workspace_root: &Path, invocation: &ToolInvocation) {
342 if is_telemetry_disabled() {
343 return;
344 }
345 if let Ok(conn) = open_global_telemetry_db() {
346 record_tool_call_conn(&conn, workspace_root, invocation);
347 }
348}
349
350pub fn get_telemetry_summary(
351 conn: &Connection,
352 filter: &TelemetryFilter,
353) -> Result<TelemetrySummary, QueryError> {
354 let (where_clause, ws_param, alt_ws_param) = match (
355 &filter.time_window.to_sqlite_condition(),
356 &filter.workspace_root,
357 ) {
358 (Some(time_cond), Some(ws)) => {
359 let (canon, alt) = workspace_root_match_candidates(ws);
360 if alt.is_some() {
361 (
362 format!(
363 "WHERE {} AND (workspace_root = ?1 OR workspace_root = ?2)",
364 time_cond
365 ),
366 Some(canon),
367 alt,
368 )
369 } else {
370 (
371 format!("WHERE {} AND workspace_root = ?1", time_cond),
372 Some(canon),
373 None,
374 )
375 }
376 }
377 (Some(time_cond), None) => (format!("WHERE {}", time_cond), None, None),
378 (None, Some(ws)) => {
379 let (canon, alt) = workspace_root_match_candidates(ws);
380 if alt.is_some() {
381 (
382 "WHERE (workspace_root = ?1 OR workspace_root = ?2)".to_string(),
383 Some(canon),
384 alt,
385 )
386 } else {
387 ("WHERE workspace_root = ?1".to_string(), Some(canon), None)
388 }
389 }
390 (None, None) => ("".to_string(), None, None),
391 };
392
393 let scope_description = match &filter.workspace_root {
394 Some(ws) => format!("Workspace: {}", to_forward_slash(&normalize_path(ws))),
395 None => "Global (all workspaces)".to_string(),
396 };
397
398 let totals_sql = format!(
400 "SELECT COUNT(*),
401 SUM(CASE WHEN outcome = 'ok' THEN 1 ELSE 0 END),
402 SUM(CASE WHEN outcome = 'empty' THEN 1 ELSE 0 END),
403 SUM(CASE WHEN outcome = 'error' THEN 1 ELSE 0 END),
404 SUM(est_tokens),
405 SUM(est_tokens_saved)
406 FROM tool_telemetry
407 {}",
408 where_clause
409 );
410
411 let (total_calls, ok_calls, empty_calls, error_calls, total_tokens_returned, est_tokens_saved) = {
412 let mut stmt = conn.prepare(&totals_sql)?;
413 let row_mapper = |row: &rusqlite::Row| {
414 let total: i64 = row.get(0)?;
415 let ok: Option<i64> = row.get(1)?;
416 let empty: Option<i64> = row.get(2)?;
417 let error: Option<i64> = row.get(3)?;
418 let tokens: Option<i64> = row.get(4)?;
419 let tokens_saved: Option<i64> = row.get(5)?;
420 Ok((
421 total as usize,
422 ok.unwrap_or(0) as usize,
423 empty.unwrap_or(0) as usize,
424 error.unwrap_or(0) as usize,
425 tokens.unwrap_or(0) as usize,
426 tokens_saved.unwrap_or(0) as usize,
427 ))
428 };
429 match (&ws_param, &alt_ws_param) {
430 (Some(ws), Some(alt)) => stmt.query_row(params![ws, alt], row_mapper)?,
431 (Some(ws), None) => stmt.query_row(params![ws], row_mapper)?,
432 _ => stmt.query_row([], row_mapper)?,
433 }
434 };
435
436 let tool_stats_sql = format!(
438 "SELECT tool,
439 COUNT(*),
440 SUM(CASE WHEN outcome = 'ok' THEN 1 ELSE 0 END),
441 SUM(CASE WHEN outcome = 'error' THEN 1 ELSE 0 END),
442 ROUND(AVG(duration_ms)),
443 SUM(est_tokens),
444 SUM(est_tokens_saved)
445 FROM tool_telemetry
446 {}
447 GROUP BY tool
448 ORDER BY COUNT(*) DESC",
449 where_clause
450 );
451
452 let mut tool_stats = Vec::new();
453 {
454 let mut stmt = conn.prepare(&tool_stats_sql)?;
455 let row_mapper = |row: &rusqlite::Row| {
456 let tool: String = row.get(0)?;
457 let count: i64 = row.get(1)?;
458 let ok_count: Option<i64> = row.get(2)?;
459 let error_count: Option<i64> = row.get(3)?;
460 let avg_duration: Option<f64> = row.get(4)?;
461 let tokens: Option<i64> = row.get(5)?;
462 let tokens_saved: Option<i64> = row.get(6)?;
463
464 Ok(ToolStat {
465 tool,
466 count: count as usize,
467 ok_count: ok_count.unwrap_or(0) as usize,
468 error_count: error_count.unwrap_or(0) as usize,
469 avg_duration_ms: avg_duration.unwrap_or(0.0).round() as u64,
470 tokens_returned: tokens.unwrap_or(0) as usize,
471 tokens_saved: tokens_saved.unwrap_or(0) as usize,
472 })
473 };
474
475 match (&ws_param, &alt_ws_param) {
476 (Some(ws), Some(alt)) => {
477 let rows = stmt.query_map(params![ws, alt], row_mapper)?;
478 for stat in rows.flatten() {
479 tool_stats.push(stat);
480 }
481 }
482 (Some(ws), None) => {
483 let rows = stmt.query_map(params![ws], row_mapper)?;
484 for stat in rows.flatten() {
485 tool_stats.push(stat);
486 }
487 }
488 _ => {
489 let rows = stmt.query_map([], row_mapper)?;
490 for stat in rows.flatten() {
491 tool_stats.push(stat);
492 }
493 }
494 }
495 }
496
497 let error_where_clause = if where_clause.is_empty() {
499 "WHERE outcome = 'error' AND error_message IS NOT NULL".to_string()
500 } else {
501 format!(
502 "{} AND outcome = 'error' AND error_message IS NOT NULL",
503 where_clause
504 )
505 };
506
507 let errors_sql = format!(
508 "SELECT timestamp, tool, error_message
509 FROM tool_telemetry
510 {}
511 ORDER BY timestamp DESC
512 LIMIT 10",
513 error_where_clause
514 );
515
516 let mut recent_errors = Vec::new();
517 {
518 let mut stmt = conn.prepare(&errors_sql)?;
519 let row_mapper = |row: &rusqlite::Row| {
520 let raw_msg: String = row.get(2)?;
521 Ok(TelemetryErrorRecord {
522 timestamp: row.get(0)?,
523 tool: row.get(1)?,
524 error_message: sanitize_error_message(&raw_msg),
525 })
526 };
527
528 match (&ws_param, &alt_ws_param) {
529 (Some(ws), Some(alt)) => {
530 let rows = stmt.query_map(params![ws, alt], row_mapper)?;
531 for err in rows.flatten() {
532 recent_errors.push(err);
533 }
534 }
535 (Some(ws), None) => {
536 let rows = stmt.query_map(params![ws], row_mapper)?;
537 for err in rows.flatten() {
538 recent_errors.push(err);
539 }
540 }
541 _ => {
542 let rows = stmt.query_map([], row_mapper)?;
543 for err in rows.flatten() {
544 recent_errors.push(err);
545 }
546 }
547 }
548 }
549
550 Ok(TelemetrySummary {
551 total_calls,
552 ok_calls,
553 empty_calls,
554 error_calls,
555 total_tokens_returned,
556 est_tokens_saved,
557 time_window: filter.time_window,
558 scope_description,
559 tool_stats,
560 recent_errors,
561 })
562}
563
564fn sanitize_error_message(msg: &str) -> String {
565 let mut home_candidates = Vec::new();
566 if let Ok(home) = std::env::var("HOME")
567 && !home.trim().is_empty()
568 && home != "/"
569 {
570 let simplified = dunce::simplified(Path::new(&home))
571 .to_string_lossy()
572 .to_string();
573 if simplified != home {
574 home_candidates.push(simplified);
575 }
576 home_candidates.push(home);
577 }
578 if let Ok(profile) = std::env::var("USERPROFILE")
579 && !profile.trim().is_empty()
580 && profile != "/"
581 {
582 let simplified = dunce::simplified(Path::new(&profile))
583 .to_string_lossy()
584 .to_string();
585 if simplified != profile {
586 home_candidates.push(simplified);
587 }
588 home_candidates.push(profile);
589 }
590
591 sanitize_error_message_with_homes(msg, &home_candidates)
592}
593
594fn sanitize_error_message_with_homes(msg: &str, home_candidates: &[String]) -> String {
595 let mut sanitized = msg.to_string();
596
597 for home in home_candidates {
598 let norm_home = to_forward_slash(&normalize_path(Path::new(home)));
599 sanitized = sanitized.replace(home.as_str(), "~");
600 if norm_home != *home {
601 sanitized = sanitized.replace(&norm_home, "~");
602 }
603 let backslash_home = home.replace('/', "\\");
604 if backslash_home != *home {
605 sanitized = sanitized.replace(&backslash_home, "~");
606 }
607 }
608
609 sanitized = sanitized.replace("\r\n", " ").replace(['\n', '\r'], " ");
611
612 sanitized = sanitized.replace('|', "\\|");
614
615 if sanitized.chars().count() > 500 {
617 let mut truncated: String = sanitized.chars().take(500).collect();
618 if truncated.ends_with('\\') && !truncated.ends_with("\\\\") {
619 truncated.pop();
620 }
621 truncated
622 } else {
623 sanitized
624 }
625}
626
627pub fn generate_bug_report(
628 conn: &Connection,
629 workspace_root: Option<&Path>,
630 issue_title: Option<&str>,
631) -> Result<BugReportBundle, QueryError> {
632 let os_info = std::env::consts::OS.to_string();
633 let arch_info = std::env::consts::ARCH.to_string();
634 let code_kb_version = env!("CARGO_PKG_VERSION").to_string();
635
636 let exe_name = if cfg!(windows) {
637 "julie-extract.exe"
638 } else {
639 "julie-extract"
640 };
641
642 let sibling_binary = std::env::current_exe()
643 .ok()
644 .and_then(|p| p.parent().map(|d| d.join(exe_name)))
645 .filter(|p| p.is_file());
646
647 let julie_extract_version = if let Some(bin) = sibling_binary {
648 if let Ok(output) = std::process::Command::new(&bin).arg("--version").output() {
649 let ver = String::from_utf8_lossy(&output.stdout).trim().to_string();
650 if !ver.is_empty() {
651 ver
652 } else {
653 crate::sync::PINNED_JULIE_VERSION.to_string()
654 }
655 } else {
656 crate::sync::PINNED_JULIE_VERSION.to_string()
657 }
658 } else {
659 crate::sync::PINNED_JULIE_VERSION.to_string()
660 };
661
662 let active_workspace_name = workspace_root.map(|ws| {
663 let norm = to_forward_slash(&normalize_path(ws));
664 Path::new(&norm)
665 .file_name()
666 .map(|n| n.to_string_lossy().to_string())
667 .unwrap_or_else(|| "repo".to_string())
668 });
669
670 let mut recent_errors = Vec::new();
672 let (error_sql, ws_param, alt_ws_param) = if let Some(ws) = workspace_root {
673 let (canon, alt) = workspace_root_match_candidates(ws);
674 if alt.is_some() {
675 (
676 "SELECT timestamp, tool, error_message
677 FROM tool_telemetry
678 WHERE outcome = 'error' AND error_message IS NOT NULL AND (workspace_root = ?1 OR workspace_root = ?2)
679 ORDER BY timestamp DESC
680 LIMIT 10",
681 Some(canon),
682 alt,
683 )
684 } else {
685 (
686 "SELECT timestamp, tool, error_message
687 FROM tool_telemetry
688 WHERE outcome = 'error' AND error_message IS NOT NULL AND workspace_root = ?1
689 ORDER BY timestamp DESC
690 LIMIT 10",
691 Some(canon),
692 None,
693 )
694 }
695 } else {
696 (
697 "SELECT timestamp, tool, error_message
698 FROM tool_telemetry
699 WHERE outcome = 'error' AND error_message IS NOT NULL
700 ORDER BY timestamp DESC
701 LIMIT 10",
702 None,
703 None,
704 )
705 };
706
707 {
708 let mut stmt = conn.prepare(error_sql)?;
709 let row_mapper = |row: &rusqlite::Row| {
710 let raw_msg: String = row.get(2)?;
711 Ok(TelemetryErrorRecord {
712 timestamp: row.get(0)?,
713 tool: row.get(1)?,
714 error_message: sanitize_error_message(&raw_msg),
715 })
716 };
717 match (&ws_param, &alt_ws_param) {
718 (Some(ws), Some(alt)) => {
719 let rows = stmt.query_map(params![ws, alt], row_mapper)?;
720 for err in rows.flatten() {
721 recent_errors.push(err);
722 }
723 }
724 (Some(ws), None) => {
725 let rows = stmt.query_map(params![ws], row_mapper)?;
726 for err in rows.flatten() {
727 recent_errors.push(err);
728 }
729 }
730 _ => {
731 let rows = stmt.query_map([], row_mapper)?;
732 for err in rows.flatten() {
733 recent_errors.push(err);
734 }
735 }
736 }
737 }
738
739 let mut markdown = String::new();
741 markdown.push_str("### Environment\n");
742 markdown.push_str(&format!("- **OS:** {}\n", os_info));
743 markdown.push_str(&format!("- **Architecture:** {}\n", arch_info));
744 markdown.push_str(&format!("- **code-kb Version:** {}\n", code_kb_version));
745 markdown.push_str(&format!(
746 "- **julie-extract Version:** {}\n",
747 julie_extract_version
748 ));
749 if let Some(ref ws) = active_workspace_name {
750 markdown.push_str(&format!("- **Active Workspace:** {}\n", ws));
751 }
752 markdown
753 .push_str("\n### Description\n<!-- Please describe the bug or unexpected behavior -->\n\n");
754
755 if !recent_errors.is_empty() {
756 markdown.push_str("### Recent Telemetry Errors\n");
757 markdown.push_str("| Timestamp | Tool | Error Message |\n");
758 markdown.push_str("|---|---|---|\n");
759 for err in &recent_errors {
760 markdown.push_str(&format!(
761 "| {} | `{}` | {} |\n",
762 err.timestamp, err.tool, err.error_message
763 ));
764 }
765 }
766
767 let title_str = issue_title
769 .map(sanitize_error_message)
770 .unwrap_or_else(|| "Bug report".to_string());
771 let mut issue_url = url::Url::parse("https://github.com/anortham/code-kb/issues/new")
772 .map_err(|e| QueryError::Sqlite(rusqlite::Error::ToSqlConversionFailure(Box::new(e))))?;
773 issue_url
774 .query_pairs_mut()
775 .append_pair("title", &title_str)
776 .append_pair("body", &markdown);
777
778 Ok(BugReportBundle {
779 os_info,
780 arch_info,
781 code_kb_version,
782 julie_extract_version,
783 active_workspace_name,
784 recent_errors,
785 markdown_body: markdown,
786 github_issue_url: issue_url.to_string(),
787 })
788}
789
790pub fn format_telemetry_summary(summary: &TelemetrySummary) -> String {
791 let mut out = String::new();
792 out.push_str("=================================================================\n");
793 out.push_str(" code-kb Telemetry Summary \n");
794 out.push_str("=================================================================\n");
795
796 if summary.total_calls == 0 {
797 out.push_str(&format!(
798 "Scope: {} | Window: {}\nNo tool calls recorded for this scope yet.\n",
799 summary.scope_description, summary.time_window
800 ));
801 return out;
802 }
803
804 let success_rate = if summary.total_calls > 0 {
805 (summary.ok_calls as f64 / summary.total_calls as f64) * 100.0
806 } else {
807 0.0
808 };
809
810 out.push_str(&format!(
811 "Scope: {} | Window: {} | Total Tool Calls: {} | Success Rate: {:.1}% | Tokens Served: ~{} | Est. Tokens Saved (read tools only): ~{}\n\n",
812 summary.scope_description, summary.time_window, summary.total_calls, success_rate, summary.total_tokens_returned, summary.est_tokens_saved
813 ));
814
815 out.push_str("### Tool Invocations & Performance\n");
816 out.push_str(
817 "| Tool | Calls | Avg Latency | Tokens Served | Est. Tokens Saved | Success Rate |\n",
818 );
819 out.push_str("|---|---:|---:|---:|---:|---:|\n");
820
821 for stat in &summary.tool_stats {
822 let rate = if stat.count > 0 {
823 (stat.ok_count as f64 / stat.count as f64) * 100.0
824 } else {
825 0.0
826 };
827 out.push_str(&format!(
828 "| `{}` | {} | {} ms | ~{} | ~{} | {:.1}% |\n",
829 stat.tool,
830 stat.count,
831 stat.avg_duration_ms,
832 stat.tokens_returned,
833 stat.tokens_saved,
834 rate
835 ));
836 }
837
838 if !summary.recent_errors.is_empty() {
839 out.push_str("\n### Recent Errors\n");
840 for err in &summary.recent_errors {
841 out.push_str(&format!(
842 "- {} [`{}`]: {}\n",
843 err.timestamp, err.tool, err.error_message
844 ));
845 }
846 }
847
848 out
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854
855 #[test]
856 fn test_global_telemetry_dir_isolation() {
857 let custom_dir = PathBuf::from("/custom/telemetry/path");
858 let target_tmp = PathBuf::from("/workspace/target/tmp");
859 let home_dir = PathBuf::from("/home/user");
860 let profile_dir = PathBuf::from("C:\\Users\\user");
861
862 assert_eq!(
864 resolve_telemetry_dir(
865 Some(custom_dir.to_string_lossy().to_string()),
866 Some(target_tmp.to_string_lossy().to_string()),
867 Some(home_dir.to_string_lossy().to_string()),
868 Some(profile_dir.to_string_lossy().to_string()),
869 ),
870 custom_dir
871 );
872
873 assert_eq!(
875 resolve_telemetry_dir(
876 None,
877 Some(target_tmp.to_string_lossy().to_string()),
878 Some(home_dir.to_string_lossy().to_string()),
879 Some(profile_dir.to_string_lossy().to_string()),
880 ),
881 target_tmp.join("test-telemetry")
882 );
883
884 assert_eq!(
886 resolve_telemetry_dir(
887 None,
888 None,
889 Some(home_dir.to_string_lossy().to_string()),
890 Some(profile_dir.to_string_lossy().to_string()),
891 ),
892 home_dir.join(".code-kb")
893 );
894
895 assert_eq!(
897 resolve_telemetry_dir(
898 None,
899 None,
900 None,
901 Some(profile_dir.to_string_lossy().to_string()),
902 ),
903 profile_dir.join(".code-kb")
904 );
905
906 assert_eq!(
908 resolve_telemetry_dir(None, None, None, None),
909 PathBuf::from(".code-kb")
910 );
911
912 let temp = crate::safe_tempdir();
913 set_telemetry_dir_override(Some(temp.path().to_path_buf()));
914 let dir = get_global_telemetry_dir();
915 assert_eq!(dir, temp.path());
916
917 let conn = open_global_telemetry_db().expect("open_global_telemetry_db should succeed");
918 assert!(temp.path().join("telemetry.db").exists());
919 drop(conn);
920 set_telemetry_dir_override(None);
921 }
922
923 #[test]
924 fn test_telemetry_disabled_flags() {
925 assert!(is_telemetry_disabled_with(Some("1"), None));
926 assert!(is_telemetry_disabled_with(Some("true"), None));
927 assert!(is_telemetry_disabled_with(Some("TRUE"), None));
928 assert!(is_telemetry_disabled_with(None, Some("1")));
929 assert!(is_telemetry_disabled_with(None, Some("true")));
930 assert!(is_telemetry_disabled_with(None, Some("TRUE")));
931
932 assert!(!is_telemetry_disabled_with(Some("0"), None));
933 assert!(!is_telemetry_disabled_with(Some("false"), None));
934 assert!(!is_telemetry_disabled_with(None, Some("0")));
935 assert!(!is_telemetry_disabled_with(None, None));
936 }
937
938 #[test]
939 fn test_telemetry_filter_time_windows() {
940 assert_eq!(TimeWindow::parse("today"), Some(TimeWindow::Today));
941 assert_eq!(TimeWindow::parse("7d"), Some(TimeWindow::Last7Days));
942 assert_eq!(TimeWindow::parse("week"), Some(TimeWindow::Last7Days));
943 assert_eq!(TimeWindow::parse("30d"), Some(TimeWindow::Last30Days));
944 assert_eq!(TimeWindow::parse("month"), Some(TimeWindow::ThisMonth));
945 assert_eq!(TimeWindow::parse("this-month"), Some(TimeWindow::ThisMonth));
946 assert_eq!(TimeWindow::parse("year"), Some(TimeWindow::LastYear));
947 assert_eq!(TimeWindow::parse("last-year"), Some(TimeWindow::LastYear));
948 assert_eq!(TimeWindow::parse("all"), Some(TimeWindow::AllTime));
949 assert_eq!(TimeWindow::parse("all-time"), Some(TimeWindow::AllTime));
950 assert_eq!(TimeWindow::parse("invalid"), None);
951
952 let temp = crate::safe_tempdir();
953 let conn = open_telemetry_db_at(temp.path()).expect("open db");
954
955 let ws_root = Path::new("/workspace/test");
956 let norm_ws =
957 crate::workspace::to_forward_slash(&crate::workspace::normalize_path(ws_root));
958
959 conn.execute(
960 "INSERT INTO tool_telemetry (id, timestamp, workspace_root, workspace_name, tool, duration_ms, outcome, est_tokens, est_tokens_saved, code_kb_version)
961 VALUES ('id1', datetime('now'), ?1, 'test', 'find_symbol', 10, 'ok', 100, 200, '0.7.0')",
962 params![norm_ws],
963 ).unwrap();
964
965 conn.execute(
966 "INSERT INTO tool_telemetry (id, timestamp, workspace_root, workspace_name, tool, duration_ms, outcome, est_tokens, est_tokens_saved, code_kb_version)
967 VALUES ('id2', datetime('now', '-2 days'), ?1, 'test', 'find_symbol', 10, 'ok', 100, 200, '0.7.0')",
968 params![norm_ws],
969 ).unwrap();
970
971 conn.execute(
972 "INSERT INTO tool_telemetry (id, timestamp, workspace_root, workspace_name, tool, duration_ms, outcome, est_tokens, est_tokens_saved, code_kb_version)
973 VALUES ('id3', datetime('now', '-15 days'), ?1, 'test', 'find_symbol', 10, 'ok', 100, 200, '0.7.0')",
974 params![norm_ws],
975 ).unwrap();
976
977 conn.execute(
978 "INSERT INTO tool_telemetry (id, timestamp, workspace_root, workspace_name, tool, duration_ms, outcome, est_tokens, est_tokens_saved, code_kb_version)
979 VALUES ('id4', datetime('now', '-60 days'), ?1, 'test', 'find_symbol', 10, 'ok', 100, 200, '0.7.0')",
980 params![norm_ws],
981 ).unwrap();
982
983 let s_today = get_telemetry_summary(
984 &conn,
985 &TelemetryFilter {
986 time_window: TimeWindow::Today,
987 workspace_root: None,
988 },
989 )
990 .unwrap();
991 assert_eq!(s_today.total_calls, 1);
992
993 let s_7d = get_telemetry_summary(
994 &conn,
995 &TelemetryFilter {
996 time_window: TimeWindow::Last7Days,
997 workspace_root: None,
998 },
999 )
1000 .unwrap();
1001 assert_eq!(s_7d.total_calls, 2);
1002
1003 let s_30d = get_telemetry_summary(
1004 &conn,
1005 &TelemetryFilter {
1006 time_window: TimeWindow::Last30Days,
1007 workspace_root: None,
1008 },
1009 )
1010 .unwrap();
1011 assert_eq!(s_30d.total_calls, 3);
1012
1013 let s_year = get_telemetry_summary(
1014 &conn,
1015 &TelemetryFilter {
1016 time_window: TimeWindow::LastYear,
1017 workspace_root: None,
1018 },
1019 )
1020 .unwrap();
1021 assert_eq!(s_year.total_calls, 4);
1022
1023 let s_all = get_telemetry_summary(
1024 &conn,
1025 &TelemetryFilter {
1026 time_window: TimeWindow::AllTime,
1027 workspace_root: None,
1028 },
1029 )
1030 .unwrap();
1031 assert_eq!(s_all.total_calls, 4);
1032 }
1033
1034 #[test]
1035 fn test_telemetry_workspace_scoping() {
1036 let temp = crate::safe_tempdir();
1037 let conn = open_telemetry_db_at(temp.path()).expect("open db");
1038
1039 let ws_a = Path::new("/projects/alpha");
1040 let ws_b = Path::new("/projects/beta");
1041
1042 let inv_a = ToolInvocation {
1043 tool: "find_symbol",
1044 duration_ms: 15,
1045 outcome: "ok",
1046 error_message: None,
1047 result_count: 1,
1048 bytes_returned: 100,
1049 est_tokens: 25,
1050 est_tokens_saved: 100,
1051 };
1052 record_tool_call_conn(&conn, ws_a, &inv_a);
1053 record_tool_call_conn(&conn, ws_a, &inv_a);
1054
1055 let inv_b = ToolInvocation {
1056 tool: "file_skeleton",
1057 duration_ms: 8,
1058 outcome: "ok",
1059 error_message: None,
1060 result_count: 1,
1061 bytes_returned: 200,
1062 est_tokens: 50,
1063 est_tokens_saved: 200,
1064 };
1065 record_tool_call_conn(&conn, ws_b, &inv_b);
1066
1067 let filter_a = TelemetryFilter {
1068 time_window: TimeWindow::AllTime,
1069 workspace_root: Some(ws_a.to_path_buf()),
1070 };
1071 let sum_a = get_telemetry_summary(&conn, &filter_a).unwrap();
1072 assert_eq!(sum_a.total_calls, 2);
1073 assert_eq!(sum_a.tool_stats.len(), 1);
1074 assert_eq!(sum_a.tool_stats[0].tool, "find_symbol");
1075 assert!(sum_a.scope_description.contains("alpha"));
1076
1077 let filter_b = TelemetryFilter {
1078 time_window: TimeWindow::AllTime,
1079 workspace_root: Some(ws_b.to_path_buf()),
1080 };
1081 let sum_b = get_telemetry_summary(&conn, &filter_b).unwrap();
1082 assert_eq!(sum_b.total_calls, 1);
1083 assert_eq!(sum_b.tool_stats.len(), 1);
1084 assert_eq!(sum_b.tool_stats[0].tool, "file_skeleton");
1085 assert!(sum_b.scope_description.contains("beta"));
1086
1087 let filter_global = TelemetryFilter {
1088 time_window: TimeWindow::AllTime,
1089 workspace_root: None,
1090 };
1091 let sum_global = get_telemetry_summary(&conn, &filter_global).unwrap();
1092 assert_eq!(sum_global.total_calls, 3);
1093 assert_eq!(sum_global.scope_description, "Global (all workspaces)");
1094 }
1095
1096 #[test]
1097 fn test_est_tokens_saved_aggregation() {
1098 let temp = crate::safe_tempdir();
1099 let conn = open_telemetry_db_at(temp.path()).expect("open db");
1100 let ws = Path::new("/projects/token_test");
1101
1102 let inv1 = ToolInvocation {
1103 tool: "file_skeleton",
1104 duration_ms: 10,
1105 outcome: "ok",
1106 error_message: None,
1107 result_count: 5,
1108 bytes_returned: 1000,
1109 est_tokens: 250,
1110 est_tokens_saved: 750,
1111 };
1112 let inv2 = ToolInvocation {
1113 tool: "file_skeleton",
1114 duration_ms: 20,
1115 outcome: "ok",
1116 error_message: None,
1117 result_count: 3,
1118 bytes_returned: 600,
1119 est_tokens: 150,
1120 est_tokens_saved: 450,
1121 };
1122 let inv3 = ToolInvocation {
1123 tool: "get_symbol_body",
1124 duration_ms: 30,
1125 outcome: "ok",
1126 error_message: None,
1127 result_count: 1,
1128 bytes_returned: 200,
1129 est_tokens: 50,
1130 est_tokens_saved: 500,
1131 };
1132
1133 record_tool_call_conn(&conn, ws, &inv1);
1134 record_tool_call_conn(&conn, ws, &inv2);
1135 record_tool_call_conn(&conn, ws, &inv3);
1136
1137 let filter = TelemetryFilter::default();
1138 let summary = get_telemetry_summary(&conn, &filter).unwrap();
1139
1140 assert_eq!(summary.total_calls, 3);
1141 assert_eq!(summary.total_tokens_returned, 450);
1142 assert_eq!(summary.est_tokens_saved, 1700);
1143
1144 let skel_stat = summary
1145 .tool_stats
1146 .iter()
1147 .find(|s| s.tool == "file_skeleton")
1148 .unwrap();
1149 assert_eq!(skel_stat.count, 2);
1150 assert_eq!(skel_stat.tokens_returned, 400);
1151 assert_eq!(skel_stat.tokens_saved, 1200);
1152 assert_eq!(skel_stat.avg_duration_ms, 15);
1153
1154 let sym_stat = summary
1155 .tool_stats
1156 .iter()
1157 .find(|s| s.tool == "get_symbol_body")
1158 .unwrap();
1159 assert_eq!(sym_stat.count, 1);
1160 assert_eq!(sym_stat.tokens_returned, 50);
1161 assert_eq!(sym_stat.tokens_saved, 500);
1162 assert_eq!(sym_stat.avg_duration_ms, 30);
1163 }
1164
1165 #[test]
1166 fn test_bug_report_bundle_generation() {
1167 let temp = crate::safe_tempdir();
1168 let conn = open_telemetry_db_at(temp.path()).unwrap();
1169 let ws = Path::new("/home/user/src/code-kb");
1170
1171 let inv_err = ToolInvocation {
1172 tool: "replace_symbol_body",
1173 duration_ms: 50,
1174 outcome: "error",
1175 error_message: Some("Tree-sitter parse failure on invalid syntax"),
1176 result_count: 0,
1177 bytes_returned: 0,
1178 est_tokens: 0,
1179 est_tokens_saved: 0,
1180 };
1181 record_tool_call_conn(&conn, ws, &inv_err);
1182
1183 let bundle = generate_bug_report(&conn, Some(ws), Some("Parser failure")).unwrap();
1184 assert_eq!(bundle.code_kb_version, env!("CARGO_PKG_VERSION"));
1185 assert!(!bundle.os_info.is_empty());
1186 assert!(!bundle.arch_info.is_empty());
1187 assert!(
1188 bundle
1189 .julie_extract_version
1190 .contains(crate::sync::PINNED_JULIE_VERSION)
1191 );
1192 assert_eq!(bundle.active_workspace_name, Some("code-kb".to_string()));
1193 assert_eq!(bundle.recent_errors.len(), 1);
1194 assert!(
1195 bundle.recent_errors[0]
1196 .error_message
1197 .contains("Tree-sitter parse failure")
1198 );
1199
1200 assert!(bundle.markdown_body.contains("code-kb"));
1201 assert!(bundle.markdown_body.contains(&bundle.os_info));
1202 assert!(bundle.markdown_body.contains("Tree-sitter parse failure"));
1203
1204 assert!(
1205 bundle
1206 .github_issue_url
1207 .starts_with("https://github.com/anortham/code-kb/issues/new?")
1208 );
1209 assert!(bundle.github_issue_url.contains("title=Parser"));
1210
1211 let parsed_url = url::Url::parse(&bundle.github_issue_url).unwrap();
1212 assert_eq!(parsed_url.host_str(), Some("github.com"));
1213 }
1214
1215 #[test]
1216 fn test_telemetry_recording_and_summary() {
1217 let temp = crate::safe_tempdir();
1218 let root = temp.path();
1219 let conn = open_telemetry_db_at(temp.path()).expect("open db");
1220
1221 let inv1 = ToolInvocation {
1222 tool: "file_skeleton",
1223 duration_ms: 6,
1224 outcome: "ok",
1225 error_message: None,
1226 result_count: 5,
1227 bytes_returned: 1200,
1228 est_tokens: 300,
1229 est_tokens_saved: 900,
1230 };
1231 record_tool_call_conn(&conn, root, &inv1);
1232
1233 let inv2 = ToolInvocation {
1234 tool: "file_skeleton",
1235 duration_ms: 4,
1236 outcome: "ok",
1237 error_message: None,
1238 result_count: 3,
1239 bytes_returned: 800,
1240 est_tokens: 200,
1241 est_tokens_saved: 600,
1242 };
1243 record_tool_call_conn(&conn, root, &inv2);
1244
1245 let inv3 = ToolInvocation {
1246 tool: "replace_symbol_body",
1247 duration_ms: 12,
1248 outcome: "error",
1249 error_message: Some("Syntax error in Rust function"),
1250 result_count: 0,
1251 bytes_returned: 50,
1252 est_tokens: 12,
1253 est_tokens_saved: 0,
1254 };
1255 record_tool_call_conn(&conn, root, &inv3);
1256
1257 let summary = get_telemetry_summary(&conn, &TelemetryFilter::default()).unwrap();
1258 assert_eq!(summary.total_calls, 3);
1259 assert_eq!(summary.ok_calls, 2);
1260 assert_eq!(summary.error_calls, 1);
1261 assert_eq!(summary.total_tokens_returned, 512);
1262 assert_eq!(summary.est_tokens_saved, 1500);
1263
1264 assert_eq!(summary.tool_stats.len(), 2);
1265 let skel_stat = summary
1266 .tool_stats
1267 .iter()
1268 .find(|s| s.tool == "file_skeleton")
1269 .unwrap();
1270 assert_eq!(skel_stat.count, 2);
1271 assert_eq!(skel_stat.ok_count, 2);
1272 assert_eq!(skel_stat.avg_duration_ms, 5);
1273
1274 assert_eq!(summary.recent_errors.len(), 1);
1275 assert_eq!(summary.recent_errors[0].tool, "replace_symbol_body");
1276 assert!(
1277 summary.recent_errors[0]
1278 .error_message
1279 .contains("Syntax error")
1280 );
1281
1282 let formatted = format_telemetry_summary(&summary);
1283 assert!(formatted.contains("Total Tool Calls: 3"));
1284 assert!(formatted.contains("| `file_skeleton` | 2 |"));
1285 assert!(formatted.contains("Syntax error in Rust function"));
1286 }
1287
1288 #[test]
1289 fn test_old_schema_upgrade() {
1290 let temp = crate::safe_tempdir();
1291 let db_path = temp.path().join("telemetry.db");
1292 let conn = Connection::open(&db_path).unwrap();
1293
1294 conn.execute_batch(
1296 "CREATE TABLE tool_telemetry (
1297 id TEXT PRIMARY KEY,
1298 timestamp TEXT NOT NULL,
1299 tool TEXT NOT NULL,
1300 duration_ms INTEGER NOT NULL,
1301 outcome TEXT NOT NULL,
1302 error_message TEXT,
1303 result_count INTEGER NOT NULL DEFAULT 0,
1304 bytes_returned INTEGER NOT NULL DEFAULT 0,
1305 est_tokens INTEGER NOT NULL DEFAULT 0,
1306 code_kb_version TEXT NOT NULL
1307 );
1308 INSERT INTO tool_telemetry VALUES (
1309 'old1', '2026-09-01 12:00:00', 'find_symbol', 10, 'ok', NULL, 1, 50, 12, '0.6.0'
1310 );",
1311 )
1312 .unwrap();
1313
1314 init_telemetry_db(&conn).expect("schema upgrade should succeed on legacy DB");
1316
1317 let summary = get_telemetry_summary(&conn, &TelemetryFilter::default()).unwrap();
1319 assert_eq!(summary.total_calls, 1);
1320 assert_eq!(summary.ok_calls, 1);
1321
1322 let inv = ToolInvocation {
1324 tool: "file_skeleton",
1325 duration_ms: 5,
1326 outcome: "ok",
1327 error_message: None,
1328 result_count: 1,
1329 bytes_returned: 100,
1330 est_tokens: 25,
1331 est_tokens_saved: 75,
1332 };
1333 record_tool_call_conn(&conn, Path::new("/workspace/project"), &inv);
1334
1335 let ws_filter = TelemetryFilter {
1336 time_window: TimeWindow::AllTime,
1337 workspace_root: Some(PathBuf::from("/workspace/project")),
1338 };
1339 let ws_summary = get_telemetry_summary(&conn, &ws_filter).unwrap();
1340 assert_eq!(ws_summary.total_calls, 1);
1341 }
1342
1343 #[test]
1344 fn test_bug_report_sanitization_and_no_external_exec() {
1345 let temp = crate::safe_tempdir();
1346 let conn = open_telemetry_db_at(temp.path()).unwrap();
1347
1348 let current_home = std::env::var("HOME")
1349 .or_else(|_| std::env::var("USERPROFILE"))
1350 .unwrap_or_else(|_| "/default/home".to_string());
1351
1352 let sensitive_error = format!(
1353 "{}/workspace/secret-repo/src/lib.rs: syntax error | unexpected token | extra line\nsecond line of error | {}",
1354 current_home,
1355 "x".repeat(600), );
1357
1358 let inv = ToolInvocation {
1359 tool: "replace_symbol_body",
1360 duration_ms: 10,
1361 outcome: "error",
1362 error_message: Some(&sensitive_error),
1363 result_count: 0,
1364 bytes_returned: 0,
1365 est_tokens: 0,
1366 est_tokens_saved: 0,
1367 };
1368 let ws_temp = crate::safe_tempdir();
1370 record_tool_call_conn(&conn, ws_temp.path(), &inv);
1371 let malicious_tools_dir = ws_temp.path().join(".tools");
1372 std::fs::create_dir_all(&malicious_tools_dir).unwrap();
1373 let fake_bin = if cfg!(windows) {
1374 malicious_tools_dir.join("julie-extract.exe")
1375 } else {
1376 malicious_tools_dir.join("julie-extract")
1377 };
1378 std::fs::write(&fake_bin, b"#!/bin/sh\necho malicious 9.9.9\nexit 0\n").unwrap();
1379 #[cfg(unix)]
1380 {
1381 use std::os::unix::fs::PermissionsExt;
1382 std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
1383 }
1384
1385 let bundle =
1386 generate_bug_report(&conn, Some(ws_temp.path()), Some("Issue with | pipes")).unwrap();
1387
1388 if !current_home.is_empty() && current_home != "/" {
1390 assert!(
1391 !bundle.markdown_body.contains(¤t_home),
1392 "Home directory must be sanitized to ~"
1393 );
1394 assert!(
1395 bundle.markdown_body.contains("~/workspace/secret-repo"),
1396 "Home directory should be replaced with ~"
1397 );
1398 assert!(
1399 !bundle.github_issue_url.contains(¤t_home),
1400 "GitHub URL must not leak home directory"
1401 );
1402 }
1403
1404 let custom_sanitized = sanitize_error_message_with_homes(
1406 "/custom/secret/path/main.rs: err | note\nsecond line",
1407 &["/custom/secret/path".to_string()],
1408 );
1409 assert_eq!(custom_sanitized, "~/main.rs: err \\| note second line");
1410
1411 assert!(
1413 !bundle.markdown_body.contains(" | unexpected token"),
1414 "Pipe characters must be escaped"
1415 );
1416 assert!(
1417 bundle.markdown_body.contains(r" \| unexpected token"),
1418 "Pipe characters must be escaped as \\|"
1419 );
1420 assert!(
1421 !bundle.markdown_body.contains("extra line\nsecond line"),
1422 "Newlines must be sanitized"
1423 );
1424
1425 assert!(
1427 bundle.recent_errors[0].error_message.chars().count() <= 500,
1428 "Error message must be truncated to 500 chars"
1429 );
1430
1431 assert_ne!(
1433 bundle.julie_extract_version, "malicious 9.9.9",
1434 "Must not execute .tools/julie-extract from workspace"
1435 );
1436 assert_eq!(
1437 bundle.julie_extract_version,
1438 crate::sync::PINNED_JULIE_VERSION,
1439 "Must report pinned version"
1440 );
1441 }
1442
1443 #[test]
1444 fn test_telemetry_summary_symlink_and_macos_private_var_matching() {
1445 let telem_dir = crate::safe_tempdir();
1446 let conn = Connection::open(telem_dir.path().join("telemetry.db")).unwrap();
1447 init_telemetry_db(&conn).unwrap();
1448
1449 let raw_var_path = "/var/folders/zz/12345678/T/my_repo";
1451 conn.execute(
1452 "INSERT INTO tool_telemetry VALUES (
1453 't-1', datetime('now'), ?1, 'my_repo', 'lookup_symbol',
1454 12, 'error', 'Failed to find symbol Foo', 0, 100, 25, 0, '0.9.0'
1455 )",
1456 params![raw_var_path],
1457 )
1458 .unwrap();
1459
1460 let filter = TelemetryFilter {
1462 time_window: TimeWindow::AllTime,
1463 workspace_root: Some(PathBuf::from("/private/var/folders/zz/12345678/T/my_repo")),
1464 };
1465 let summary = get_telemetry_summary(&conn, &filter).unwrap();
1466 assert_eq!(
1467 summary.total_calls, 1,
1468 "Must match record across /private/var and /var"
1469 );
1470 assert_eq!(summary.recent_errors.len(), 1);
1471 assert!(
1472 summary.recent_errors[0]
1473 .error_message
1474 .contains("Failed to find symbol Foo")
1475 );
1476
1477 conn.execute(
1479 "INSERT INTO tool_telemetry VALUES (
1480 't-2', datetime('now'), ?1, 'other_repo', 'lookup_symbol',
1481 12, 'error', 'Reverse matching error', 0, 100, 25, 0, '0.9.0'
1482 )",
1483 params!["/private/var/folders/zz/99999999/T/other_repo"],
1484 )
1485 .unwrap();
1486
1487 let filter_rev = TelemetryFilter {
1488 time_window: TimeWindow::AllTime,
1489 workspace_root: Some(PathBuf::from("/var/folders/zz/99999999/T/other_repo")),
1490 };
1491 let summary_rev = get_telemetry_summary(&conn, &filter_rev).unwrap();
1492 assert_eq!(summary_rev.total_calls, 1);
1493 assert_eq!(summary_rev.recent_errors.len(), 1);
1494 assert!(
1495 summary_rev.recent_errors[0]
1496 .error_message
1497 .contains("Reverse matching error")
1498 );
1499
1500 let bug_report = generate_bug_report(
1502 &conn,
1503 Some(Path::new("/private/var/folders/zz/12345678/T/my_repo")),
1504 Some("test issue"),
1505 )
1506 .unwrap();
1507 assert_eq!(bug_report.recent_errors.len(), 1);
1508 assert!(
1509 bug_report.recent_errors[0]
1510 .error_message
1511 .contains("Failed to find symbol Foo")
1512 );
1513 }
1514}