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}