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