Skip to main content

code_kb_core/
telemetry.rs

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