Skip to main content

code_kb_core/
telemetry.rs

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, // "ok", "empty", "error"
17    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    // Handle column migrations if table previously existed without workspace columns
228    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    // Dependent indexes must be created AFTER columns are verified to exist
254    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
262/// Open the global telemetry database, delegating to `open_global_telemetry_db()`.
263pub fn open_telemetry_db(_workspace_root: &Path) -> Result<Connection, QueryError> {
264    open_global_telemetry_db()
265}
266
267/// Returns the normalized workspace root and an optional alternate representation
268/// (e.g. resolving canonical path, or mapping macOS `/private/var`, `/private/tmp`, `/private/etc` symmetry).
269pub(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    // Handle macOS `/private/var`, `/private/tmp`, `/private/etc` symmetry
280    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
291/// Fast record of a tool invocation with normalized workspace path attribution.
292pub 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
339/// Record a tool call to the global telemetry database.
340/// Best-effort and non-panicking.
341pub 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    // 1. Overall aggregations
399    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    // 2. Per-tool statistics
437    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    // 3. Recent errors
498    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    // Replace newlines with spaces to avoid breaking markdown tables
610    sanitized = sanitized.replace("\r\n", " ").replace(['\n', '\r'], " ");
611
612    // Escape markdown table pipe characters
613    sanitized = sanitized.replace('|', "\\|");
614
615    // Truncate message to 500 characters
616    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    // Query recent errors
671    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    // Build markdown body
740    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    // Generate GitHub issue URL
768    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        // 1. Explicit CODE_KB_TELEMETRY_DIR takes highest precedence
863        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        // 2. CARGO_TARGET_TMPDIR isolates tests when explicit dir is absent
874        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        // 3. HOME directory fallback
885        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        // 4. USERPROFILE directory fallback
896        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        // 5. Default current working dir
907        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        // Create old schema v1 without workspace_root, workspace_name, or est_tokens_saved
1295        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        // Running init_telemetry_db must migrate columns before creating index
1315        init_telemetry_db(&conn).expect("schema upgrade should succeed on legacy DB");
1316
1317        // Verify that workspace_root was added and idx_tool_telemetry_ws_ts was created
1318        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        // Verify we can insert a new record with workspace_root and query via index
1323        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), // > 500 chars to test truncation
1356        );
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        // Create a fake malicious julie-extract binary in a .tools directory in workspace
1369        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        // 1. Path sanitization verification
1389        if !current_home.is_empty() && current_home != "/" {
1390            assert!(
1391                !bundle.markdown_body.contains(&current_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(&current_home),
1400                "GitHub URL must not leak home directory"
1401            );
1402        }
1403
1404        // Direct test of custom home path sanitization
1405        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        // 2. Pipe and newline escaping
1412        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        // 3. Length truncation (max 500 chars)
1426        assert!(
1427            bundle.recent_errors[0].error_message.chars().count() <= 500,
1428            "Error message must be truncated to 500 chars"
1429        );
1430
1431        // 4. No external binary execution verification
1432        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        // Insert a record using macOS /var/folders path
1450        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        // Query using canonical macOS /private/var/folders path
1461        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        // Reverse: insert with /private/var, query with /var
1478        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        // Bug report must also match
1501        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}