#![forbid(unsafe_code)]
mod client;
mod consent;
mod event;
mod install_id;
mod notice;
mod properties;
mod version_state;
pub use consent::{
ConfigPreference, ConsentInputs, DEFAULT_POSTHOG_HOST, Disposition, ENV_ANALYTICS,
ENV_DO_NOT_TRACK, ENV_POSTHOG_HOST, ENV_POSTHOG_KEY, ingestion_host, project_key,
};
pub use event::{
Event, Label, Properties, Value, bucket_count, bucket_duration_secs, bucket_gigabytes,
};
pub use install_id::{INSTALL_ID_FILE, InstallId, load, load_or_create, state_dir};
pub use notice::{NOTICE, NOTICE_MARKER_FILE};
pub use properties::{BuildChannel, LIB_NAME, base_properties, exec_env};
pub use version_state::{PendingVersion, VERSION_FILE, VersionTransition};
pub const ENV_SELF_UPDATE_MARKER: &str = "MESH_LLM_SELF_UPDATE_ATTEMPTED";
pub const ENV_INTERNAL_HELPER: &str = "MESH_LLM_INTERNAL_HELPER";
use chrono::Utc;
use client::Envelope;
use mesh_llm_build_info::BUILD_VERSION;
use std::path::PathBuf;
use std::sync::OnceLock;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
const QUEUE_CAPACITY: usize = 256;
const BATCH_SIZE: usize = 20;
const FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
const SHUTDOWN_BUDGET: std::time::Duration = std::time::Duration::from_millis(1_500);
static REPORTER: OnceLock<Option<Reporter>> = OnceLock::new();
struct Reporter {
sender: std::sync::Mutex<Option<mpsc::Sender<Envelope>>>,
worker: std::sync::Mutex<Option<JoinHandle<()>>>,
}
impl Reporter {
fn sender(&self) -> Option<mpsc::Sender<Envelope>> {
self.sender.lock().ok().and_then(|slot| slot.clone())
}
}
#[derive(Clone, Debug)]
pub struct Status {
pub disposition: Disposition,
pub install_id: Option<String>,
pub endpoint: Option<String>,
pub install_id_path: Option<PathBuf>,
}
pub fn init(config: ConfigPreference) -> Status {
let inputs = ConsentInputs::from_env(config);
let disposition = inputs.resolve();
let dir = state_dir().ok();
if !disposition.is_enabled() {
let _ = REPORTER.set(None);
return Status {
disposition,
install_id: dir
.as_deref()
.and_then(load)
.map(|id| id.as_str().to_owned()),
endpoint: None,
install_id_path: dir.map(|dir| dir.join(INSTALL_ID_FILE)),
};
}
let Some(dir) = dir else {
let _ = REPORTER.set(None);
return Status {
disposition,
install_id: None,
endpoint: None,
install_id_path: None,
};
};
if !notice::was_shown(&dir) && !notice::print_notice(&dir) {
tracing::debug!("analytics disabled: first-run disclosure not delivered");
let _ = REPORTER.set(None);
return Status {
disposition,
install_id: None,
endpoint: None,
install_id_path: Some(dir.join(INSTALL_ID_FILE)),
};
}
let Some(install) = load_or_create(&dir).ok() else {
let _ = REPORTER.set(None);
return Status {
disposition,
install_id: None,
endpoint: None,
install_id_path: Some(dir.join(INSTALL_ID_FILE)),
};
};
let version = version_state::begin(&dir, BUILD_VERSION);
let endpoint = client::batch_endpoint(&ingestion_host());
let status = Status {
disposition,
install_id: Some(install.as_str().to_owned()),
endpoint: Some(endpoint.clone()),
install_id_path: Some(dir.join(INSTALL_ID_FILE)),
};
let Some(api_key) = project_key() else {
let _ = REPORTER.set(None);
return Status {
endpoint: None,
..status
};
};
if tokio::runtime::Handle::try_current().is_err() {
tracing::debug!("analytics disabled: no tokio runtime at init");
let _ = REPORTER.set(None);
return Status {
endpoint: None,
..status
};
}
let (sender, receiver) = mpsc::channel(QUEUE_CAPACITY);
let worker = tokio::spawn(flush_loop(
receiver,
api_key,
install.as_str().to_owned(),
endpoint,
));
let _ = REPORTER.set(Some(Reporter {
sender: std::sync::Mutex::new(Some(sender)),
worker: std::sync::Mutex::new(Some(worker)),
}));
if install.is_first_run() {
version.commit();
capture(Event::InstallFirstRun, Properties::new());
} else {
report_version_transition(version);
}
status
}
fn report_version_transition(version: PendingVersion) {
let VersionTransition::Changed { from } = version.transition() else {
version.commit();
return;
};
let from = from.clone();
if version.commit() {
capture(
Event::InstallUpdated,
Properties::new()
.with("from_version", Label::sanitize_or_redact(&from))
.with("trigger", update_trigger()),
);
}
}
fn update_trigger() -> &'static str {
if std::env::var_os(ENV_SELF_UPDATE_MARKER).is_some() {
"self_update"
} else {
"external"
}
}
pub fn capture(event: Event, properties: Properties) {
let Some(Some(reporter)) = REPORTER.get() else {
return;
};
let Some(sender) = reporter.sender() else {
return;
};
let envelope = Envelope {
event,
properties,
captured_at: Utc::now(),
};
if sender.try_send(envelope).is_err() {
tracing::debug!(
event = event.name(),
"analytics event dropped: queue full or already shut down"
);
}
}
pub async fn shutdown() {
let Some(Some(reporter)) = REPORTER.get() else {
return;
};
let mut worker = reporter.worker.lock().ok().and_then(|mut slot| slot.take());
let Some(worker) = worker.as_mut() else {
return;
};
if let Ok(mut slot) = reporter.sender.lock() {
slot.take();
}
if tokio::time::timeout(SHUTDOWN_BUDGET, &mut *worker)
.await
.is_err()
{
worker.abort();
tracing::debug!("analytics flush exceeded its shutdown budget; batch aborted");
}
}
async fn flush_loop(
mut receiver: mpsc::Receiver<Envelope>,
api_key: String,
distinct_id: String,
endpoint: String,
) {
let http = match reqwest::Client::builder()
.timeout(client::REQUEST_TIMEOUT)
.build()
{
Ok(http) => http,
Err(error) => {
tracing::debug!(%error, "analytics disabled: no HTTP client");
return;
}
};
let base = base_properties();
let mut pending: Vec<Envelope> = Vec::new();
loop {
match tokio::time::timeout(FLUSH_INTERVAL, receiver.recv()).await {
Ok(Some(envelope)) => {
pending.push(envelope);
if pending.len() >= BATCH_SIZE {
flush(
&http,
&endpoint,
&api_key,
&distinct_id,
&base,
&mut pending,
)
.await;
}
}
Ok(None) => break,
Err(_) => {
flush(
&http,
&endpoint,
&api_key,
&distinct_id,
&base,
&mut pending,
)
.await
}
}
}
flush(
&http,
&endpoint,
&api_key,
&distinct_id,
&base,
&mut pending,
)
.await;
}
async fn flush(
http: &reqwest::Client,
endpoint: &str,
api_key: &str,
distinct_id: &str,
base: &Properties,
pending: &mut Vec<Envelope>,
) {
if pending.is_empty() {
return;
}
let body = client::batch_body(api_key, distinct_id, base, pending);
pending.clear();
client::send_batch(http, endpoint, &body).await;
}
#[cfg(test)]
#[path = "lib/tests.rs"]
mod tests;