mesh_llm_analytics/lib.rs
1#![forbid(unsafe_code)]
2//! Anonymous, opt-out product analytics for mesh-llm.
3//!
4//! # What this is
5//!
6//! A narrow PostHog reporter for questions we cannot answer any other way:
7//! which platforms mesh-llm runs on, which commands people actually use,
8//! whether `serve` sessions survive, and how big real meshes get.
9//!
10//! # What it is not
11//!
12//! It is not the `[telemetry]` config section. That one is operator-facing
13//! OTLP: the operator points it at *their own* collector and nothing leaves
14//! their network. This crate is the only code in mesh-llm that reports to a
15//! vendor, and it is deliberately kept in one reviewable place.
16//!
17//! # Design constraints
18//!
19//! - **Closed vocabulary.** [`Event`] and [`Properties`] admit no free-form
20//! `String`. Text only enters through [`Label::sanitize`], whose grammar
21//! rejects paths, prose, and anything over-long. A prompt cannot reach the
22//! wire through this API even by mistake.
23//! - **No key, no reporting.** The project key is compiled in by the release
24//! pipeline. Source and development builds have none and are inert.
25//! - **Never load-bearing.** Capture is non-blocking, delivery is best
26//! effort, and no failure here changes a command's behavior or exit code.
27//! - **Anonymous by construction.** The identifier is a random UUID with no
28//! derivation from the machine, and it is not the published mesh identity.
29
30mod client;
31mod consent;
32mod event;
33mod install_id;
34mod notice;
35mod properties;
36mod version_state;
37
38pub use consent::{
39 ConfigPreference, ConsentInputs, DEFAULT_POSTHOG_HOST, Disposition, ENV_ANALYTICS,
40 ENV_DO_NOT_TRACK, ENV_POSTHOG_HOST, ENV_POSTHOG_KEY, ingestion_host, project_key,
41};
42pub use event::{
43 Event, Label, Properties, Value, bucket_count, bucket_duration_secs, bucket_gigabytes,
44};
45pub use install_id::{INSTALL_ID_FILE, InstallId, load, load_or_create, state_dir};
46pub use notice::{NOTICE, NOTICE_MARKER_FILE};
47pub use properties::{BuildChannel, LIB_NAME, base_properties, exec_env};
48pub use version_state::{PendingVersion, VERSION_FILE, VersionTransition};
49
50/// Environment variable the self-updater sets on the binary it `exec`s.
51///
52/// Must stay in step with `SELF_UPDATE_ATTEMPTED_ENV` in
53/// `mesh-llm-system`'s `autoupdate`. That crate owns the restart; this one
54/// only reads the marker to tell a self-update apart from an upgrade that
55/// arrived some other way, and depending on it would drag the whole hardware
56/// and release-fetch tree into this leaf crate. `mesh-llm-commands` sees both
57/// constants and has a test that they match.
58pub const ENV_SELF_UPDATE_MARKER: &str = "MESH_LLM_SELF_UPDATE_ATTEMPTED";
59
60/// Environment variable marking a process mesh-llm spawned as an internal
61/// helper rather than something a person ran.
62///
63/// The updater executes the freshly extracted binary with `--version` to
64/// verify the bundle before installing it. That child reaches the same CLI
65/// entry point, so without a marker it initializes reporting, records the new
66/// build as the current version, and emits a `cli_command` -- all before the
67/// install has happened. The damage is two-fold: the real restart afterwards
68/// then sees `Unchanged` and is classified `external` instead of
69/// `self_update`, and an install that fails after verification leaves a
70/// recorded upgrade that never took place.
71///
72/// Mirrored as `INTERNAL_HELPER_ENV` in `mesh-llm-system`, which sets it.
73/// `mesh-llm-commands` tests that the two agree.
74pub const ENV_INTERNAL_HELPER: &str = "MESH_LLM_INTERNAL_HELPER";
75
76use chrono::Utc;
77use client::Envelope;
78use mesh_llm_build_info::BUILD_VERSION;
79use std::path::PathBuf;
80use std::sync::OnceLock;
81use tokio::sync::mpsc;
82use tokio::task::JoinHandle;
83
84/// Queue depth. Capture drops events rather than applying backpressure, so
85/// this only has to absorb a burst between flushes.
86const QUEUE_CAPACITY: usize = 256;
87
88/// Events per ingestion request.
89const BATCH_SIZE: usize = 20;
90
91/// How long the flusher waits for more events before sending a partial batch.
92const FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
93
94/// Budget for delivering queued events at process exit.
95///
96/// A one-shot command exits in well under a second, so without a drain at
97/// exit almost nothing would ever be delivered. The budget is short on
98/// purpose: a slow or captive network must not hold the command open, and
99/// losing the event is the correct trade.
100const SHUTDOWN_BUDGET: std::time::Duration = std::time::Duration::from_millis(1_500);
101
102/// The process-global reporter. `None` once consent resolves to disabled.
103static REPORTER: OnceLock<Option<Reporter>> = OnceLock::new();
104
105struct Reporter {
106 /// Held in an `Option` so [`shutdown`] can drop it. Dropping the last
107 /// sender is what tells the flush loop the process is going away.
108 sender: std::sync::Mutex<Option<mpsc::Sender<Envelope>>>,
109 worker: std::sync::Mutex<Option<JoinHandle<()>>>,
110}
111
112impl Reporter {
113 fn sender(&self) -> Option<mpsc::Sender<Envelope>> {
114 self.sender.lock().ok().and_then(|slot| slot.clone())
115 }
116}
117
118/// How [`init`] resolved, for `mesh-llm analytics status`.
119#[derive(Clone, Debug)]
120pub struct Status {
121 /// Why reporting is on or off.
122 pub disposition: Disposition,
123 /// The install identifier, when one could be read.
124 pub install_id: Option<String>,
125 /// Where events are sent, when reporting is on.
126 pub endpoint: Option<String>,
127 /// Where the install identifier lives.
128 pub install_id_path: Option<PathBuf>,
129}
130
131/// Initialize analytics for this process.
132///
133/// `config` is what the mesh-llm config file says about analytics. Call once,
134/// early; later calls return the first result.
135///
136/// On a first run with reporting enabled, this prints the disclosure notice
137/// and captures [`Event::InstallFirstRun`].
138///
139/// Fail closed on the disclosure: if the notice cannot be delivered, this run
140/// reports nothing. On-by-default reporting is defensible only because the
141/// disclosure actually happens, so a notice that never reached stderr must not
142/// be recorded as shown.
143pub fn init(config: ConfigPreference) -> Status {
144 let inputs = ConsentInputs::from_env(config);
145 let disposition = inputs.resolve();
146 let dir = state_dir().ok();
147
148 // Nothing is written while reporting is off: no identifier, no marker.
149 // An opted-out machine should not accumulate analytics state at all.
150 if !disposition.is_enabled() {
151 let _ = REPORTER.set(None);
152 return Status {
153 disposition,
154 install_id: dir
155 .as_deref()
156 .and_then(load)
157 .map(|id| id.as_str().to_owned()),
158 endpoint: None,
159 install_id_path: dir.map(|dir| dir.join(INSTALL_ID_FILE)),
160 };
161 }
162
163 let Some(dir) = dir else {
164 // Without a readable state directory there is no stable identifier,
165 // and reporting every run as a new install would be worse than not
166 // reporting at all.
167 let _ = REPORTER.set(None);
168 return Status {
169 disposition,
170 install_id: None,
171 endpoint: None,
172 install_id_path: None,
173 };
174 };
175
176 // Disclose before anything is queued, and independently of whether the
177 // identifier already exists. The marker is what makes this once-only.
178 //
179 // Fail closed. Reporting is justified by the disclosure having actually
180 // happened, so a run that cannot deliver the notice reports nothing at all.
181 // `print_notice` records the marker only once the bytes are written, which
182 // means the next run retries rather than the disclosure being quietly
183 // spent and never shown.
184 if !notice::was_shown(&dir) && !notice::print_notice(&dir) {
185 tracing::debug!("analytics disabled: first-run disclosure not delivered");
186 let _ = REPORTER.set(None);
187 return Status {
188 disposition,
189 install_id: None,
190 endpoint: None,
191 install_id_path: Some(dir.join(INSTALL_ID_FILE)),
192 };
193 }
194
195 let Some(install) = load_or_create(&dir).ok() else {
196 let _ = REPORTER.set(None);
197 return Status {
198 disposition,
199 install_id: None,
200 endpoint: None,
201 install_id_path: Some(dir.join(INSTALL_ID_FILE)),
202 };
203 };
204
205 // Read now, written only once there is somewhere to report it. Every
206 // path between here and reporter registration can bail out -- no key, no
207 // runtime -- and advancing the record on the way past would mark the
208 // upgrade as seen while queuing nothing, so the next run would find
209 // `Unchanged` and the upgrade would be lost for good. Dropping this
210 // without committing leaves the record alone and releases the lock.
211 let version = version_state::begin(&dir, BUILD_VERSION);
212
213 let endpoint = client::batch_endpoint(&ingestion_host());
214 let status = Status {
215 disposition,
216 install_id: Some(install.as_str().to_owned()),
217 endpoint: Some(endpoint.clone()),
218 install_id_path: Some(dir.join(INSTALL_ID_FILE)),
219 };
220
221 let Some(api_key) = project_key() else {
222 // Unreachable: `resolve` already returned `DisabledNoKey`. Handled
223 // rather than unwrapped so a future refactor cannot turn this into a
224 // panic in the shipped binary.
225 let _ = REPORTER.set(None);
226 return Status {
227 endpoint: None,
228 ..status
229 };
230 };
231
232 // `tokio::spawn` panics outside a runtime. Every call site in this binary
233 // is under `block_on`, but this is a public API whose stated contract is
234 // that it is never load-bearing, so an embedder gets a clean disable
235 // rather than a panic.
236 if tokio::runtime::Handle::try_current().is_err() {
237 tracing::debug!("analytics disabled: no tokio runtime at init");
238 let _ = REPORTER.set(None);
239 return Status {
240 endpoint: None,
241 ..status
242 };
243 }
244
245 let (sender, receiver) = mpsc::channel(QUEUE_CAPACITY);
246 let worker = tokio::spawn(flush_loop(
247 receiver,
248 api_key,
249 install.as_str().to_owned(),
250 endpoint,
251 ));
252 let _ = REPORTER.set(Some(Reporter {
253 sender: std::sync::Mutex::new(Some(sender)),
254 worker: std::sync::Mutex::new(Some(worker)),
255 }));
256
257 if install.is_first_run() {
258 // A brand new install has no meaningful version to have come from,
259 // and reporting both events for one run would double-count it.
260 version.commit();
261 capture(Event::InstallFirstRun, Properties::new());
262 } else {
263 report_version_transition(version);
264 }
265
266 status
267}
268
269/// Persist the transition and report it, in that order.
270///
271/// The commit is what licenses the event: it returns whether the record now
272/// actually names this build, and only then is the upgrade reported. A write
273/// that failed leaves the previous version in place, so the next run sees the
274/// same transition and can retry rather than this one reporting an upgrade it
275/// did not manage to record -- and then reporting it again every run after.
276fn report_version_transition(version: PendingVersion) {
277 let VersionTransition::Changed { from } = version.transition() else {
278 version.commit();
279 return;
280 };
281 let from = from.clone();
282 if version.commit() {
283 capture(
284 Event::InstallUpdated,
285 Properties::new()
286 .with("from_version", Label::sanitize_or_redact(&from))
287 .with("trigger", update_trigger()),
288 );
289 }
290}
291
292/// How a version change most likely arrived.
293///
294/// `self_update` is authoritative: only mesh-llm's own updater sets the
295/// marker. `external` is a residual — `mesh-llm update`, `install.sh`, a
296/// package manager, or a hand-swapped binary all land there, because none of
297/// them leave a trace in this process's environment.
298fn update_trigger() -> &'static str {
299 if std::env::var_os(ENV_SELF_UPDATE_MARKER).is_some() {
300 "self_update"
301 } else {
302 "external"
303 }
304}
305
306/// Queue one event. Never blocks; drops the event if the queue is full.
307pub fn capture(event: Event, properties: Properties) {
308 let Some(Some(reporter)) = REPORTER.get() else {
309 return;
310 };
311 let Some(sender) = reporter.sender() else {
312 return;
313 };
314 let envelope = Envelope {
315 event,
316 properties,
317 captured_at: Utc::now(),
318 };
319 if sender.try_send(envelope).is_err() {
320 tracing::debug!(
321 event = event.name(),
322 "analytics event dropped: queue full or already shut down"
323 );
324 }
325}
326
327/// Deliver queued events, giving up after [`SHUTDOWN_BUDGET`].
328///
329/// Safe to call when analytics is disabled or was never initialized.
330pub async fn shutdown() {
331 let Some(Some(reporter)) = REPORTER.get() else {
332 return;
333 };
334 let mut worker = reporter.worker.lock().ok().and_then(|mut slot| slot.take());
335 let Some(worker) = worker.as_mut() else {
336 // Already shut down. A second call is a no-op rather than a second
337 // wait, so repeated shutdowns on an error path stay free.
338 return;
339 };
340
341 // Drop the reporter's sender. The flush loop ends once every sender is
342 // gone, sends what it has accumulated, and returns.
343 if let Ok(mut slot) = reporter.sender.lock() {
344 slot.take();
345 }
346
347 if tokio::time::timeout(SHUTDOWN_BUDGET, &mut *worker)
348 .await
349 .is_err()
350 {
351 // `timeout` cancels only its own wait. Dropping the handle would
352 // detach the flush task instead of ending it, so an embedder that
353 // keeps the runtime alive could still see a batch go out after
354 // `shutdown` returned. Abort it: the budget is the promise.
355 worker.abort();
356 tracing::debug!("analytics flush exceeded its shutdown budget; batch aborted");
357 }
358}
359
360/// Drain the queue, batching events and posting them.
361async fn flush_loop(
362 mut receiver: mpsc::Receiver<Envelope>,
363 api_key: String,
364 distinct_id: String,
365 endpoint: String,
366) {
367 let http = match reqwest::Client::builder()
368 .timeout(client::REQUEST_TIMEOUT)
369 .build()
370 {
371 Ok(http) => http,
372 Err(error) => {
373 tracing::debug!(%error, "analytics disabled: no HTTP client");
374 return;
375 }
376 };
377 let base = base_properties();
378 let mut pending: Vec<Envelope> = Vec::new();
379
380 loop {
381 match tokio::time::timeout(FLUSH_INTERVAL, receiver.recv()).await {
382 Ok(Some(envelope)) => {
383 pending.push(envelope);
384 if pending.len() >= BATCH_SIZE {
385 flush(
386 &http,
387 &endpoint,
388 &api_key,
389 &distinct_id,
390 &base,
391 &mut pending,
392 )
393 .await;
394 }
395 }
396 // The channel closed: the process is going away.
397 Ok(None) => break,
398 // Idle for a full interval: send whatever has accumulated.
399 Err(_) => {
400 flush(
401 &http,
402 &endpoint,
403 &api_key,
404 &distinct_id,
405 &base,
406 &mut pending,
407 )
408 .await
409 }
410 }
411 }
412
413 flush(
414 &http,
415 &endpoint,
416 &api_key,
417 &distinct_id,
418 &base,
419 &mut pending,
420 )
421 .await;
422}
423
424async fn flush(
425 http: &reqwest::Client,
426 endpoint: &str,
427 api_key: &str,
428 distinct_id: &str,
429 base: &Properties,
430 pending: &mut Vec<Envelope>,
431) {
432 if pending.is_empty() {
433 return;
434 }
435 let body = client::batch_body(api_key, distinct_id, base, pending);
436 // Cleared regardless of the result: retrying analytics is not worth
437 // holding memory or delaying exit for.
438 pending.clear();
439 client::send_batch(http, endpoint, &body).await;
440}
441
442#[cfg(test)]
443#[path = "lib/tests.rs"]
444mod tests;