Skip to main content

codewhale_telemetry/
lib.rs

1//! Opt-in product telemetry for Codewhale.
2//!
3//! The whole of what this crate may ever send is [`event`]. The whole of what
4//! decides whether it may send anything is [`decision`]. Nothing else in the
5//! tree is permitted to construct a payload or to reach the wire, and nothing in
6//! here reads a prompt, a completion, a tool argument, a file, a path, a git
7//! remote, a branch, a model id, a provider table name, an MCP server name, an
8//! approval rule, an error body, a panic message, or a credential.
9//!
10//! # The shape of the guarantee
11//!
12//! Consent is a **value**, not a convention. [`decide`] is the only constructor
13//! of [`TelemetryConsent`]; [`init`] takes one by value and there is no
14//! bool-taking sibling. Six init sites cannot each drift from the predicate,
15//! because they never see the predicate.
16//!
17//! Arming is a **`OnceLock`**, consulted by every write path including
18//! [`record_blocking`]. This matters because the process panic hook is installed
19//! before the command line is even parsed, long before any config resolution: it
20//! cannot consult a resolved value, but it can consult a lock that is by
21//! construction empty until resolution completes. A disabled user's panic
22//! therefore writes nothing and creates no directory.
23//!
24//! Arming also **truncates** the buffer. No event recorded before consent can
25//! ever be in the batch that follows it.
26//!
27//! # Failure posture
28//!
29//! Fail-open is absolute. Every fallible step ends in `.ok()?` or `let _ =`.
30//! Nothing here returns an error to a caller, blocks a turn, blocks a tool, or
31//! blocks process exit. Telemetry that costs a user their session is worse than
32//! no telemetry.
33
34#![deny(missing_docs)]
35
36mod actor;
37pub mod buffer;
38pub mod client;
39pub mod counters;
40pub mod decision;
41pub mod envelope;
42pub mod event;
43pub mod notice;
44
45#[cfg(test)]
46mod tests;
47
48use std::sync::OnceLock;
49use std::sync::atomic::{AtomicU8, Ordering};
50use std::time::Duration;
51
52pub use actor::{BATCH_MAX_BYTES, BATCH_MAX_EVENTS, FlushOutcome};
53pub use counters::{Counter, ErrorCounter, SessionCounters};
54pub use decision::{
55    EndpointError, TELEMETRY_DIR, TelemetryConsent, TelemetryDecision, decide, decide_in_home,
56    re_decide, validate_endpoint,
57};
58pub use envelope::reduce_panic_site;
59pub use event::{
60    Arch, Batch, ColdStartBucket, Counters, DurationBucket, Errors, Event, ExitClass, InstallKind,
61    Libc, Os, SCHEMA_VERSION, SessionSource, Surface, TurnWall,
62};
63
64/// How long the shutdown flush may hold the process.
65///
66/// The terminal is still in alt-screen while this runs. The persistence actor's
67/// unbounded `task.await` next door is not a pattern to copy: a hung TLS
68/// handshake would hold a user's terminal past exit.
69pub const SHUTDOWN_FLUSH_TIMEOUT: Duration = Duration::from_secs(3);
70
71/// Everything a write path needs once the process is armed.
72struct Armed {
73    handle: actor::Handle,
74    root: std::path::PathBuf,
75    exit_class: AtomicU8,
76}
77
78/// The one gate. Unset means every write path is a hard no-op.
79static ARMED: OnceLock<Armed> = OnceLock::new();
80
81/// Arm telemetry for this process.
82///
83/// Takes [`TelemetryConsent`] **by value**: there is no way to call this without
84/// having gone through [`decide`], and no overload that accepts a `bool`.
85///
86/// Idempotent — a second call is ignored, so a surface that dispatches twice
87/// cannot start two writers against one buffer.
88pub fn init(consent: TelemetryConsent) {
89    if ARMED.get().is_some() {
90        return;
91    }
92    let root = consent.root().to_path_buf();
93
94    // Clear the tombstone and drop anything buffered before consent. A stale
95    // buffer is not evidence of this user's answer.
96    if let Err(error) = buffer::arm(&root) {
97        tracing::debug!("telemetry could not prepare its buffer: {error}");
98        return;
99    }
100
101    let context = actor::Context {
102        root: root.clone(),
103        endpoint: consent.endpoint().map(str::to_string),
104        surface: consent.surface(),
105        config_path: consent.config_path().map(std::path::Path::to_path_buf),
106        app_version: env!("CARGO_PKG_VERSION").to_string(),
107        git_sha: envelope::release_build_sha(),
108        tty: envelope::current_tty(),
109    };
110
111    let _ = ARMED.set(Armed {
112        handle: actor::Handle::spawn(context),
113        root: root.clone(),
114        exit_class: AtomicU8::new(ExitClass::Clean.as_u8()),
115    });
116
117    record_install_or_upgrade(&root);
118}
119
120/// Note that this binary's version differs from the one last seen on this
121/// machine, at most once per version.
122///
123/// The previous version comes from `$CODEWHALE_HOME/telemetry/state.json` and
124/// from nowhere else. Session history and config mtimes would answer the same
125/// question and carry a different privacy contract; reading them here would put
126/// this crate one refactor away from the thread store.
127///
128/// The state file is updated before the event is queued, so a process that dies
129/// between the two reports nothing rather than reporting the same upgrade on
130/// every launch.
131fn record_install_or_upgrade(root: &std::path::Path) {
132    let current = env!("CARGO_PKG_VERSION");
133    let mut state = envelope::read_state(root);
134    if state.last_version.as_deref() == Some(current) {
135        return;
136    }
137    let kind = match state.last_version.as_deref() {
138        None => InstallKind::Install,
139        Some(previous) if version_is_older(previous, current) => InstallKind::Upgrade,
140        Some(_) => InstallKind::Downgrade,
141    };
142    let previous_version = state.last_version.clone();
143    state.schema_version = SCHEMA_VERSION;
144    state.last_version = Some(current.to_string());
145    if envelope::write_state(root, &state).is_err() {
146        // Nothing was recorded, so the next launch will try again. Emitting
147        // without the write would re-report the same upgrade forever.
148        return;
149    }
150    record(Event::InstallOrUpgrade {
151        kind,
152        previous_version,
153    });
154}
155
156/// Compare two dotted release numbers, ignoring any pre-release suffix.
157///
158/// Deliberately not a semver dependency: the only question asked is which of
159/// install / upgrade / downgrade to name, and a version this crate cannot parse
160/// answers "not older", which reports a downgrade — the conservative direction,
161/// since it never invents an upgrade that did not happen.
162fn version_is_older(previous: &str, current: &str) -> bool {
163    fn parts(value: &str) -> Vec<u64> {
164        value
165            .split(['-', '+'])
166            .next()
167            .unwrap_or_default()
168            .split('.')
169            .map(|part| part.parse::<u64>().unwrap_or_default())
170            .collect()
171    }
172    let (previous, current) = (parts(previous), parts(current));
173    let width = previous.len().max(current.len());
174    for index in 0..width {
175        let left = previous.get(index).copied().unwrap_or_default();
176        let right = current.get(index).copied().unwrap_or_default();
177        if left != right {
178            return left < right;
179        }
180    }
181    false
182}
183
184/// Whether this process is armed. Every write path checks this first.
185#[must_use]
186pub fn is_armed() -> bool {
187    ARMED.get().is_some()
188}
189
190/// This process's session accumulators.
191///
192/// Deliberately **not** behind the arming gate. Every bump is a relaxed atomic
193/// increment on a counter that never leaves this process unless [`init`] was
194/// reached, so gating them would buy nothing and would put an `is_armed()`
195/// branch on eleven hot call sites. The gate that matters is on the write
196/// paths, and a snapshot of these numbers only ever reaches a payload through
197/// one.
198pub fn session_counters() -> &'static SessionCounters {
199    static COUNTERS: OnceLock<SessionCounters> = OnceLock::new();
200    COUNTERS.get_or_init(SessionCounters::default)
201}
202
203/// Queue an event for the writer thread.
204///
205/// Non-blocking, and a no-op when unarmed.
206pub fn record(event: Event) {
207    let Some(armed) = ARMED.get() else {
208        return;
209    };
210    armed.handle.record(event);
211}
212
213/// Write an event synchronously, without the writer thread and **without any
214/// lock**.
215///
216/// The synchronous escape hatch for the three paths where the async world is
217/// gone or going: the panic hook, `record_caught_panic`, and the signal task
218/// immediately before `std::process::exit`. One `O_APPEND` `write(2)` under
219/// `PIPE_BUF`, a `sync_data`, and return — microseconds.
220///
221/// Taking the compaction lock here would be a *blocking* acquisition on both of
222/// those paths. `flock` is per-fd within a process, so an actor panic while
223/// holding that lock would self-deadlock the hook, and a second Codewhale
224/// process sharing `CODEWHALE_HOME` would hang Ctrl-C.
225///
226/// A no-op when unarmed, which is what makes a disabled user's panic write
227/// nothing and create no directory.
228pub fn record_blocking(event: Event) {
229    let Some(armed) = ARMED.get() else {
230        return;
231    };
232    let Ok(line) = serde_json::to_string(&event) else {
233        return;
234    };
235    let path = buffer::buffer_path(&armed.root);
236    let _ = buffer::append(&armed.root, &path, &line);
237}
238
239/// Record how this process is ending.
240///
241/// Set by the panic hook, by the signal task before `std::process::exit`, and on
242/// the clean path from the run's termination reason. **Never derived from an
243/// exit code**: a cancelled turn and a SIGINT both exit 130, so a code-based
244/// derivation would report every Esc as a signal.
245pub fn set_exit_class(class: ExitClass) {
246    let Some(armed) = ARMED.get() else {
247        return;
248    };
249    armed.exit_class.store(class.as_u8(), Ordering::Relaxed);
250}
251
252/// The exit class recorded so far. `Clean` when unarmed or unset.
253#[must_use]
254pub fn exit_class() -> ExitClass {
255    ARMED.get().map_or(ExitClass::Clean, |armed| {
256        ExitClass::from_u8(armed.exit_class.load(Ordering::Relaxed))
257    })
258}
259
260/// Final flush, then stop the writer thread.
261///
262/// Returns [`FlushOutcome::Empty`] when unarmed.
263pub fn shutdown_blocking(deadline: Duration) -> FlushOutcome {
264    ARMED
265        .get()
266        .map_or(FlushOutcome::Empty, |armed| armed.handle.shutdown(deadline))
267}