Skip to main content

ski/
telemetry.rs

1//! Opt-in JSONL event log for debugging and calibration. **Disabled by default**
2//! — every entry point short-circuits unless telemetry is enabled, via either
3//! `telemetry = true` in `~/.config/ski/config.toml` or a truthy `SKI_TELEMETRY`
4//! env var (`1|true|yes|on`, e.g. in the `env` block of `~/.claude/settings.json`).
5//! Each entry point calls [`init`] right after `Config::load` to reflect the
6//! config flag into the process; [`enabled`] is then config-OR-env.
7//!
8//! Two event kinds, appended one JSON object per line to
9//! `$XDG_STATE_HOME/ski/telemetry.jsonl`:
10//! - `recommend` — what `ski hook` decided on a prompt. Emitted on **every**
11//!   ranked prompt, including the ones where ski injects nothing: the prompt, the
12//!   stage, the top-K `considered` ranking (id + raw stage score) the chooser
13//!   produced *before* the gate, the `candidates` that cleared the gate, which
14//!   ids survived the char budget (`injected`), and an `abstained` reason when
15//!   nothing was injected. The always-present `considered` list is what lets a
16//!   later analysis see where ski ranked a skill on a prompt it stayed silent on.
17//! - `use` — a skill the model loaded itself (seen by `ski observe`) — i.e. the
18//!   host's own (native) skill chooser's pick. Joining a `use` to the prompt's
19//!   `recommend` event by `session` + `prompt` tells us whether the native pick
20//!   was something ski injected, ranked-but-abstained-on, or never surfaced.
21//!
22//! Best-effort, like the rest of the hot path: any IO/serialization failure is
23//! swallowed so telemetry can never block or fail a prompt.
24
25use crate::confidence::Stage;
26use crate::inject::Rec;
27use serde_json::json;
28use std::fs::OpenOptions;
29use std::io::Write;
30use std::time::{SystemTime, UNIX_EPOCH};
31
32/// Set from the loaded `Config` (`telemetry = true` in `config.toml`) by each
33/// entry point before it records anything. Lets the config file enable telemetry
34/// without an env var; the env var still works on its own for the hook's `env`
35/// block. Defaults to off until [`init`] runs.
36static CONFIG_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
37
38/// Reflect `cfg.telemetry` into the process so [`enabled`] sees it. Call once,
39/// right after `Config::load`, in any entry point that may record events.
40pub fn init(config_enabled: bool) {
41    CONFIG_ENABLED.store(config_enabled, std::sync::atomic::Ordering::Relaxed);
42}
43
44/// Whether the event log is active. Cheap; called at every entry point so a
45/// disabled log costs one env lookup and nothing else. On when the config flag
46/// (via [`init`]) *or* a truthy `SKI_TELEMETRY` env var is set.
47pub fn enabled() -> bool {
48    CONFIG_ENABLED.load(std::sync::atomic::Ordering::Relaxed)
49        || matches!(
50            std::env::var("SKI_TELEMETRY").ok().as_deref(),
51            Some("1") | Some("true") | Some("yes") | Some("on")
52        )
53}
54
55/// Record the hook's decision on a prompt. Emitted on every ranked prompt, even
56/// when ski injects nothing, so the always-present `considered` ranking records
57/// where ski placed each skill on a prompt it stayed silent on.
58///
59/// - `considered` — the top-K of the ranking the winning stage produced, *before*
60///   the gate: `(id, raw stage score)` (cosine-blend for stage 1, reranker logit
61///   for stage 2). This is the chooser's view; joining the native pick (a `use`
62///   event) against it shows whether ski near-missed or never surfaced it.
63/// - `recs` — the candidates that cleared the gate (empty on abstention).
64/// - `injected` — the subset that fit the char budget (id + shown confidence).
65/// - `abstained` — why nothing was injected (`Some("below_gate")` etc.), or
66///   `None` when an injection was emitted.
67pub fn record_recommend(
68    session_id: &str,
69    prompt: &str,
70    stage: Stage,
71    considered: &[(String, f32)],
72    recs: &[Rec],
73    injected: &[(String, f32)],
74    abstained: Option<&str>,
75) {
76    if !enabled() {
77        return;
78    }
79    let considered: Vec<_> = considered
80        .iter()
81        .map(|(id, s)| json!({ "id": id, "score": s }))
82        .collect();
83    let candidates: Vec<_> = recs
84        .iter()
85        .map(|r| json!({ "id": r.id, "confidence": r.confidence }))
86        .collect();
87    let injected: Vec<_> = injected
88        .iter()
89        .map(|(id, c)| json!({ "id": id, "confidence": c }))
90        .collect();
91    let mut ev = json!({
92        "ts": now_ms(),
93        "kind": "recommend",
94        "session": session_id,
95        "prompt": prompt,
96        "stage": stage_str(stage),
97        "considered": considered,
98        "candidates": candidates,
99        "injected": injected,
100    });
101    if let Some(reason) = abstained {
102        ev["abstained"] = json!(reason);
103    }
104    append(&ev);
105}
106
107/// Record that the model loaded `skill_id` itself. `via` is `"skill"` (the
108/// `Skill` tool) or `"read"` (opened the `SKILL.md`). `prompt` is the active
109/// prompt the hook stashed in session state (empty if none), letting `ski
110/// history` tie a recall miss back to the call that triggered it.
111pub fn record_use(session_id: &str, skill_id: &str, via: &str, prompt: &str) {
112    if !enabled() {
113        return;
114    }
115    let mut ev = json!({
116        "ts": now_ms(),
117        "kind": "use",
118        "session": session_id,
119        "skill": skill_id,
120        "via": via,
121    });
122    if !prompt.is_empty() {
123        ev["prompt"] = json!(prompt);
124    }
125    append(&ev);
126}
127
128fn append(ev: &serde_json::Value) {
129    let path = crate::paths::telemetry_path();
130    let _ = (|| -> std::io::Result<()> {
131        if let Some(parent) = path.parent() {
132            std::fs::create_dir_all(parent)?;
133        }
134        let mut f = OpenOptions::new().create(true).append(true).open(&path)?;
135        writeln!(f, "{ev}")?;
136        Ok(())
137    })();
138}
139
140fn stage_str(stage: Stage) -> &'static str {
141    match stage {
142        Stage::Cosine => "cosine",
143        Stage::Rerank => "rerank",
144        Stage::Lexical => "lexical",
145    }
146}
147
148fn now_ms() -> u128 {
149    SystemTime::now()
150        .duration_since(UNIX_EPOCH)
151        .map(|d| d.as_millis())
152        .unwrap_or(0)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn disabled_by_default() {
161        // The test process has no SKI_TELEMETRY set.
162        std::env::remove_var("SKI_TELEMETRY");
163        assert!(!enabled());
164        // record_* must be no-ops when disabled (no panic, no file).
165        record_use("s", "pdf", "skill", "");
166    }
167
168    #[test]
169    fn stage_strings() {
170        assert_eq!(stage_str(Stage::Cosine), "cosine");
171        assert_eq!(stage_str(Stage::Rerank), "rerank");
172    }
173}