Skip to main content

codewhale_telemetry/
lib.rs

1//! Default-on, user-disableable anonymous product usage counting 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//! Permission 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** any stale buffer before a newly permitted process
25//! begins recording.
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    load_setup_state_for_decision, load_setup_state_for_decision_at, 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    let observed_generation = consent.tombstone_generation().cloned();
94    let config_path = consent.config_path().map(std::path::Path::to_path_buf);
95
96    // Re-check durable permission under the same ordering lock as wipe. The
97    // generation match prevents consent resolved before a newer opt-out from
98    // clearing that opt-out; the fresh predicate preserves intentional
99    // `config set telemetry true` re-enablement.
100    if let Err(error) = buffer::arm(&root, observed_generation.as_ref(), || {
101        decision::permission_still_enabled(config_path.as_deref(), &root)
102    }) {
103        tracing::debug!("telemetry could not prepare its buffer: {error}");
104        return;
105    }
106
107    let context = actor::Context {
108        root: root.clone(),
109        endpoint: consent.endpoint().map(str::to_string),
110        surface: consent.surface(),
111        config_path: consent.config_path().map(std::path::Path::to_path_buf),
112        app_version: env!("CARGO_PKG_VERSION").to_string(),
113        git_sha: envelope::release_build_sha(),
114        tty: envelope::current_tty(),
115    };
116
117    let _ = ARMED.set(Armed {
118        handle: actor::Handle::spawn(context),
119        root: root.clone(),
120        exit_class: AtomicU8::new(ExitClass::Clean.as_u8()),
121    });
122
123    record_install_or_upgrade(&root);
124}
125
126/// Note that this binary's version differs from the one last seen on this
127/// machine, at most once per version.
128///
129/// The previous version comes from `$CODEWHALE_HOME/telemetry/state.json` and
130/// from nowhere else. Session history and config mtimes would answer the same
131/// question and carry a different privacy contract; reading them here would put
132/// this crate one refactor away from the thread store.
133///
134/// The state file is updated before the event is queued, so a process that dies
135/// between the two reports nothing rather than reporting the same upgrade on
136/// every launch.
137fn record_install_or_upgrade(root: &std::path::Path) {
138    let current = env!("CARGO_PKG_VERSION");
139    let mut state = envelope::read_state(root);
140    if state.last_version.as_deref() == Some(current) {
141        return;
142    }
143    let kind = match state.last_version.as_deref() {
144        None => InstallKind::Install,
145        Some(previous) if version_is_older(previous, current) => InstallKind::Upgrade,
146        Some(_) => InstallKind::Downgrade,
147    };
148    let previous_version = state.last_version.clone();
149    state.schema_version = SCHEMA_VERSION;
150    state.last_version = Some(current.to_string());
151    if envelope::write_state(root, &state).is_err() {
152        // Nothing was recorded, so the next launch will try again. Emitting
153        // without the write would re-report the same upgrade forever.
154        return;
155    }
156    record(Event::InstallOrUpgrade {
157        kind,
158        previous_version,
159    });
160}
161
162/// Compare two dotted release numbers, ignoring any pre-release suffix.
163///
164/// Deliberately not a semver dependency: the only question asked is which of
165/// install / upgrade / downgrade to name, and a version this crate cannot parse
166/// answers "not older", which reports a downgrade — the conservative direction,
167/// since it never invents an upgrade that did not happen.
168fn version_is_older(previous: &str, current: &str) -> bool {
169    fn parts(value: &str) -> Vec<u64> {
170        value
171            .split(['-', '+'])
172            .next()
173            .unwrap_or_default()
174            .split('.')
175            .map(|part| part.parse::<u64>().unwrap_or_default())
176            .collect()
177    }
178    let (previous, current) = (parts(previous), parts(current));
179    let width = previous.len().max(current.len());
180    for index in 0..width {
181        let left = previous.get(index).copied().unwrap_or_default();
182        let right = current.get(index).copied().unwrap_or_default();
183        if left != right {
184            return left < right;
185        }
186    }
187    false
188}
189
190/// Whether this process is armed. Every write path checks this first.
191#[must_use]
192pub fn is_armed() -> bool {
193    ARMED.get().is_some()
194}
195
196/// This process's session accumulators.
197///
198/// Deliberately **not** behind the arming gate. Every bump is a relaxed atomic
199/// increment on a counter that never leaves this process unless [`init`] was
200/// reached, so gating them would buy nothing and would put an `is_armed()`
201/// branch on eleven hot call sites. The gate that matters is on the write
202/// paths, and a snapshot of these numbers only ever reaches a payload through
203/// one.
204pub fn session_counters() -> &'static SessionCounters {
205    static COUNTERS: OnceLock<SessionCounters> = OnceLock::new();
206    COUNTERS.get_or_init(SessionCounters::default)
207}
208
209/// Queue an event for the writer thread.
210///
211/// Non-blocking, and a no-op when unarmed.
212pub fn record(event: Event) {
213    let Some(armed) = ARMED.get() else {
214        return;
215    };
216    armed.handle.record(event);
217}
218
219/// Write an event synchronously, without the writer thread.
220///
221/// The synchronous escape hatch for the three paths where the async world is
222/// gone or going: the panic hook, `record_caught_panic`, and the signal task
223/// immediately before `std::process::exit`. One `O_APPEND` `write(2)` under
224/// `PIPE_BUF`, a `sync_data`, and return — microseconds.
225///
226/// The append takes the shared privacy lock with `try_write()`, never a blocking
227/// acquisition. If the actor, a wipe, or another Codewhale process sharing
228/// `CODEWHALE_HOME` holds it, the event is dropped immediately. This preserves
229/// the panic/SIGINT liveness contract without allowing a write to race past a
230/// completed opt-out.
231///
232/// A no-op when unarmed, which is what makes a disabled user's panic write
233/// nothing and create no directory.
234pub fn record_blocking(event: Event) {
235    let Some(armed) = ARMED.get() else {
236        return;
237    };
238    let Ok(line) = serde_json::to_string(&event) else {
239        return;
240    };
241    let path = buffer::buffer_path(&armed.root);
242    let _ = buffer::append(&armed.root, &path, &line);
243}
244
245/// Record how this process is ending.
246///
247/// Set by the panic hook, by the signal task before `std::process::exit`, and on
248/// the clean path from the run's termination reason. **Never derived from an
249/// exit code**: a cancelled turn and a SIGINT both exit 130, so a code-based
250/// derivation would report every Esc as a signal.
251pub fn set_exit_class(class: ExitClass) {
252    let Some(armed) = ARMED.get() else {
253        return;
254    };
255    armed.exit_class.store(class.as_u8(), Ordering::Relaxed);
256}
257
258/// The exit class recorded so far. `Clean` when unarmed or unset.
259#[must_use]
260pub fn exit_class() -> ExitClass {
261    ARMED.get().map_or(ExitClass::Clean, |armed| {
262        ExitClass::from_u8(armed.exit_class.load(Ordering::Relaxed))
263    })
264}
265
266/// Final flush, then stop the writer thread.
267///
268/// Returns [`FlushOutcome::Empty`] when unarmed.
269pub fn shutdown_blocking(deadline: Duration) -> FlushOutcome {
270    ARMED
271        .get()
272        .map_or(FlushOutcome::Empty, |armed| armed.handle.shutdown(deadline))
273}