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