Skip to main content

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;