返回 CodeWhale
lib.rs
根目录 / crates / telemetry / src / lib.rs
1 //! Default-on, user-disableable aggregate 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
36 mod actor;
37 pub mod buffer;
38 pub mod client;
39 pub mod counters;
40 pub mod decision;
41 pub mod envelope;
42 pub mod event;
43 pub mod notice;
44
45 #[cfg(test)]
46 mod tests;
47
48 use std::sync::OnceLock;
49 use std::sync::atomic::{AtomicU8, Ordering};
50 use std::time::Duration;
51
52 pub use actor::{BATCH_MAX_BYTES, BATCH_MAX_EVENTS, FlushOutcome};
53 pub use counters::{Counter, ErrorCounter, SessionCounters};
54 pub 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 };
58 pub use envelope::reduce_panic_site;
59 pub use event::{
60 Arch, Batch, ColdStartBucket, Counters, DurationBucket, Errors, Event, ExitClass, InstallKind,
61 Libc, NOTICE_VERSION, Os, ProductCounters, 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.
69 pub const SHUTDOWN_FLUSH_TIMEOUT: Duration = Duration::from_secs(3);
70
71 /// Everything a write path needs once the process is armed.
72 struct 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.
79 static 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.
88 pub 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 notice::show_startup_disclosure(consent.surface());
108
109 let context = actor::Context {
110 root: root.clone(),
111 endpoint: consent.endpoint().map(str::to_string),
112 surface: consent.surface(),
113 config_path: consent.config_path().map(std::path::Path::to_path_buf),
114 app_version: env!("CARGO_PKG_VERSION").to_string(),
115 git_sha: envelope::release_build_sha(),
116 tty: envelope::current_tty(),
117 };
118
119 let _ = ARMED.set(Armed {
120 handle: actor::Handle::spawn(context),
121 root: root.clone(),
122 exit_class: AtomicU8::new(ExitClass::Clean.as_u8()),
123 });
124
125 record_install_or_upgrade(&root);
126 }
127
128 /// Note that this binary's version differs from the one last seen on this
129 /// machine, at most once per version.
130 ///
131 /// The previous version comes from `$CODEWHALE_HOME/telemetry/state.json` and
132 /// from nowhere else. Session history and config mtimes would answer the same
133 /// question and carry a different privacy contract; reading them here would put
134 /// this crate one refactor away from the thread store.
135 ///
136 /// The state file is updated before the event is queued, so a process that dies
137 /// between the two reports nothing rather than reporting the same upgrade on
138 /// every launch.
139 fn record_install_or_upgrade(root: &std::path::Path) {
140 let current = env!("CARGO_PKG_VERSION");
141 let mut state = envelope::read_state(root);
142 if state.last_version.as_deref() == Some(current) {
143 return;
144 }
145 let kind = match state.last_version.as_deref() {
146 None => InstallKind::Install,
147 Some(previous) if version_is_older(previous, current) => InstallKind::Upgrade,
148 Some(_) => InstallKind::Downgrade,
149 };
150 let previous_version = state.last_version.clone();
151 state.schema_version = SCHEMA_VERSION;
152 state.last_version = Some(current.to_string());
153 if envelope::write_state(root, &state).is_err() {
154 // Nothing was recorded, so the next launch will try again. Emitting
155 // without the write would re-report the same upgrade forever.
156 return;
157 }
158 record(Event::InstallOrUpgrade {
159 kind,
160 previous_version,
161 });
162 }
163
164 /// Compare two dotted release numbers, ignoring any pre-release suffix.
165 ///
166 /// Deliberately not a semver dependency: the only question asked is which of
167 /// install / upgrade / downgrade to name, and a version this crate cannot parse
168 /// answers "not older", which reports a downgrade — the conservative direction,
169 /// since it never invents an upgrade that did not happen.
170 fn version_is_older(previous: &str, current: &str) -> bool {
171 fn parts(value: &str) -> Vec<u64> {
172 value
173 .split(['-', '+'])
174 .next()
175 .unwrap_or_default()
176 .split('.')
177 .map(|part| part.parse::<u64>().unwrap_or_default())
178 .collect()
179 }
180 let (previous, current) = (parts(previous), parts(current));
181 let width = previous.len().max(current.len());
182 for index in 0..width {
183 let left = previous.get(index).copied().unwrap_or_default();
184 let right = current.get(index).copied().unwrap_or_default();
185 if left != right {
186 return left < right;
187 }
188 }
189 false
190 }
191
192 /// Whether this process is armed. Every write path checks this first.
193 #[must_use]
194 pub fn is_armed() -> bool {
195 ARMED.get().is_some()
196 }
197
198 /// This process's session accumulators.
199 ///
200 /// Deliberately **not** behind the arming gate. Every bump is a relaxed atomic
201 /// increment on a counter that never leaves this process unless [`init`] was
202 /// reached, so gating them would buy nothing and would put an `is_armed()`
203 /// branch on eleven hot call sites. The gate that matters is on the write
204 /// paths, and a snapshot of these numbers only ever reaches a payload through
205 /// one.
206 pub fn session_counters() -> &'static SessionCounters {
207 static COUNTERS: OnceLock<SessionCounters> = OnceLock::new();
208 COUNTERS.get_or_init(SessionCounters::default)
209 }
210
211 /// Queue an event for the writer thread.
212 ///
213 /// Non-blocking, and a no-op when unarmed.
214 pub fn record(event: Event) {
215 let Some(armed) = ARMED.get() else {
216 return;
217 };
218 armed.handle.record(event);
219 }
220
221 /// Write an event synchronously, without the writer thread.
222 ///
223 /// The synchronous escape hatch for the three paths where the async world is
224 /// gone or going: the panic hook, `record_caught_panic`, and the signal task
225 /// immediately before `std::process::exit`. One `O_APPEND` `write(2)` under
226 /// `PIPE_BUF`, a `sync_data`, and return — microseconds.
227 ///
228 /// The append takes the shared privacy lock with `try_write()`, never a blocking
229 /// acquisition. If the actor, a wipe, or another Codewhale process sharing
230 /// `CODEWHALE_HOME` holds it, the event is dropped immediately. This preserves
231 /// the panic/SIGINT liveness contract without allowing a write to race past a
232 /// completed opt-out.
233 ///
234 /// A no-op when unarmed, which is what makes a disabled user's panic write
235 /// nothing and create no directory.
236 pub fn record_blocking(event: Event) {
237 let Some(armed) = ARMED.get() else {
238 return;
239 };
240 let Ok(line) = serde_json::to_string(&event) else {
241 return;
242 };
243 let path = buffer::buffer_path(&armed.root);
244 let _ = buffer::append(&armed.root, &path, &line);
245 }
246
247 /// Record how this process is ending.
248 ///
249 /// Set by the panic hook, by the signal task before `std::process::exit`, and on
250 /// the clean path from the run's termination reason. **Never derived from an
251 /// exit code**: a cancelled turn and a SIGINT both exit 130, so a code-based
252 /// derivation would report every Esc as a signal.
253 pub fn set_exit_class(class: ExitClass) {
254 let Some(armed) = ARMED.get() else {
255 return;
256 };
257 armed.exit_class.store(class.as_u8(), Ordering::Relaxed);
258 }
259
260 /// The exit class recorded so far. `Clean` when unarmed or unset.
261 #[must_use]
262 pub fn exit_class() -> ExitClass {
263 ARMED.get().map_or(ExitClass::Clean, |armed| {
264 ExitClass::from_u8(armed.exit_class.load(Ordering::Relaxed))
265 })
266 }
267
268 /// Final flush, then stop the writer thread.
269 ///
270 /// Returns [`FlushOutcome::Empty`] when unarmed.
271 pub fn shutdown_blocking(deadline: Duration) -> FlushOutcome {
272 ARMED
273 .get()
274 .map_or(FlushOutcome::Empty, |armed| armed.handle.shutdown(deadline))
275 }
276
277 /// Persist every queued event locally, then stop the writer without networking.
278 ///
279 /// With an explicitly empty endpoint this finalizes the local dry-run batch.
280 /// With a configured endpoint it leaves events in the pending buffer for the
281 /// next full flush. Returns [`FlushOutcome::Empty`] when unarmed.
282 ///
283 /// This joins the writer instead of racing it (#6269). The local path seals
284 /// at most a consent re-check, a tombstone probe, and one fsync append —
285 /// bounded disk work with no network in it. A deadline here buys nothing the
286 /// rest of the CLI does not already forgo: startup reads config from the
287 /// same disk with no timeout either. The writer always acknowledges, even on
288 /// panic, so the only fail-open outcome is a writer that is already gone.
289 /// Only the network flush keeps a deadline.
290 #[must_use]
291 pub fn persist_local_blocking() -> FlushOutcome {
292 ARMED
293 .get()
294 .map_or(FlushOutcome::Empty, |armed| armed.handle.persist_local())
295 }
296
296 lines RUST