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