Skip to main content

zoi_telemetry/
lib.rs

1//! Anonymous telemetry for Zoi.
2//!
3//! This crate handles the collection and transmission of anonymous usage
4//! statistics to help improve Zoi. It ensures privacy by only collecting
5//! non-identifiable data and requiring explicit user opt-in.
6
7use std::error::Error;
8use std::fs;
9
10use serde::Serialize;
11use uuid::Timestamp;
12
13/// Represents an anonymous telemetry event sent to `PostHog`.
14#[derive(Debug, Serialize)]
15pub struct PackageEvent<'a> {
16    /// Unique anonymous identifier for the client.
17    pub client_id: &'a str,
18    /// The name of the event (e.g. "install", "uninstall").
19    pub event: &'a str,
20    /// RFC3339 formatted timestamp of the event.
21    pub ts: String,
22    /// Version of the Zoi application.
23    pub app_version: &'a str,
24    /// Operating system name.
25    pub os: &'a str,
26    /// CPU architecture.
27    pub arch: &'a str,
28    /// Linux distribution name, if applicable.
29    pub distro: Option<String>,
30    /// The user's current shell.
31    pub shell: Option<String>,
32    /// Minimal package metadata.
33    pub package: MinimalPackage<'a>,
34    /// Type of the package (e.g. "Package", "App").
35    pub package_type: &'a str,
36    /// Installation scope (e.g. "global", "user").
37    pub scope: String,
38    /// Reason for the installation (e.g. "direct", "dependency").
39    pub reason: String,
40    /// How the package was installed (e.g. "source", "binary").
41    pub install_type: Option<String>
42}
43
44/// A privacy-preserving subset of package metadata for analytics.
45#[derive(Debug, Serialize)]
46pub struct MinimalPackage<'a> {
47    /// Name of the package.
48    pub name: &'a str,
49    /// Optional sub-package name.
50    pub sub_package: Option<&'a String>,
51    /// Repository where the package is hosted.
52    pub repo: &'a str,
53    /// Package version.
54    pub version: &'a str,
55    /// Brief description of the package.
56    pub description: &'a str,
57    /// License of the package.
58    pub license: &'a str,
59    /// Maintainer of the package.
60    pub maintainer: MinimalPerson<'a>,
61    /// Original author of the package.
62    pub author: Option<MinimalPerson<'a>>,
63    /// Registry handle.
64    pub registry: &'a str,
65    /// URL of the registry.
66    pub registry_url: &'a str
67}
68
69/// A minimal representation of a person (maintainer or author) for telemetry.
70#[derive(Debug, Serialize)]
71pub struct MinimalPerson<'a> {
72    /// Name of the person.
73    pub name: &'a str,
74    /// Email address of the person.
75    pub email: &'a str,
76    /// Optional website URL.
77    pub website: Option<&'a String>
78}
79
80/// Returns the path to the file where the anonymous client ID is stored.
81fn get_client_id_path() -> Result<std::path::PathBuf, Box<dyn Error>> {
82    let home = zoi_core::utils::get_user_home()
83        .ok_or("Could not find home directory")?;
84    Ok(home.join(".zoi").join("telemetry").join("client_id"))
85}
86
87/// Returns the anonymous client ID, or "unknown" if it cannot be retrieved.
88pub fn get_anonymous_id() -> String {
89    ensure_client_id().unwrap_or_else(|_| "unknown".to_string())
90}
91
92/// Ensures that an anonymous client ID exists, creating a new one if necessary.
93fn ensure_client_id() -> Result<String, Box<dyn Error>> {
94    let path = get_client_id_path()?;
95    if let Some(dir) = path.parent() {
96        fs::create_dir_all(dir)?;
97    }
98    if path.exists() {
99        let id = fs::read_to_string(&path)?;
100        Ok(id.trim().to_string())
101    } else {
102        let id = {
103            let ts = Timestamp::from_unix(
104                uuid::NoContext,
105                chrono::Utc::now().timestamp_millis().cast_unsigned(),
106                0
107            );
108            uuid::Uuid::new_v7(ts).to_string()
109        };
110        fs::write(&path, &id)?;
111        Ok(id)
112    }
113}
114
115/// A single event formatted for the `PostHog` API.
116#[derive(Serialize)]
117struct PosthogEvent<'a> {
118    /// Name of the event.
119    event: &'a str,
120    /// Unique identifier for the user/client.
121    distinct_id: &'a str,
122    /// The event properties (telemetry data).
123    properties: &'a PackageEvent<'a>,
124    /// RFC3339 formatted timestamp.
125    timestamp: &'a str
126}
127
128/// A batch of events to be sent to the `PostHog` API.
129#[derive(Serialize)]
130struct Batch<'a> {
131    /// The `PostHog` project API key.
132    api_key: &'a str,
133    /// List of events to capture.
134    batch: Vec<PosthogEvent<'a>>
135}
136
137/// Securely captures an anonymous event and sends it to `PostHog`.
138///
139/// Privacy Guarantee:
140/// - No IP addresses, hostnames, or personal data are ever collected.
141/// - The `client_id` is a randomly generated UUID v7 stored in
142///   `~/.zoi/telemetry/client_id`.
143/// - Telemetry is strictly opt-in. This function returns `false` immediately if
144///   `telemetry_enabled` is not set to `true` in the user's config.
145///
146/// Data collected is limited to: event type (install/uninstall), package
147/// metadata (name, version, license), and basic environment info (OS, Arch,
148/// Shell).
149///
150/// # Errors
151///
152/// Returns an error if:
153/// - The Zoi configuration cannot be read.
154/// - The anonymous client ID cannot be ensured.
155/// - `POSTHOG_API_KEY` is not set.
156/// - The network request to `PostHog` fails.
157pub fn posthog_capture_event(
158    event_name: &str,
159    pkg: &zoi_core::types::Package,
160    app_version: &str,
161    registry_handle: &str,
162    install_type: Option<&str>
163) -> Result<bool, Box<dyn Error>> {
164    let config = zoi_core::config::read_config()?;
165    if !config.telemetry_enabled {
166        return Ok(false);
167    }
168
169    let client_id = ensure_client_id()?;
170
171    let platform = zoi_core::utils::get_platform()
172        .unwrap_or_else(|_| "unknown-unknown".into());
173    let mut parts = platform.split('-');
174    let os = parts.next().unwrap_or("unknown");
175    let arch = parts.next().unwrap_or("unknown");
176    let distro = zoi_core::utils::get_linux_distribution();
177    let shell = zoi_core::utils::get_current_shell().map(|s| s.to_string());
178
179    let package_type_str = match pkg.package_type {
180        zoi_core::types::PackageType::Package => "Package",
181        zoi_core::types::PackageType::Collection => "Collection",
182        zoi_core::types::PackageType::App => "App",
183        zoi_core::types::PackageType::Extension => "Extension"
184    };
185
186    let scope_str = format!("{:?}", pkg.scope).to_lowercase();
187    let reason_str = match &pkg.reason {
188        Some(zoi_core::types::InstallReason::Direct) => "direct".to_string(),
189        Some(zoi_core::types::InstallReason::Dependency { parent }) => {
190            format!("dependency:{parent}")
191        }
192        None => "unknown".to_string()
193    };
194
195    let registry_url = config
196        .default_registry
197        .as_ref()
198        .filter(|r| r.handle == registry_handle)
199        .map(|r| r.url.as_str())
200        .or_else(|| {
201            config
202                .added_registries
203                .iter()
204                .find(|r| r.handle == registry_handle)
205                .map(|r| r.url.as_str())
206        })
207        .unwrap_or("unknown");
208
209    let ev = PackageEvent {
210        client_id: &client_id,
211        event: event_name,
212        ts: chrono::Utc::now().to_rfc3339(),
213        app_version,
214        os,
215        arch,
216        distro,
217        shell,
218        package: MinimalPackage {
219            name: &pkg.name,
220            sub_package: pkg.sub_package.as_ref(),
221            repo: &pkg.repo,
222            version: pkg.version.as_deref().unwrap_or("unknown"),
223            description: &pkg.description,
224            license: &pkg.license,
225            maintainer: MinimalPerson {
226                name: &pkg.maintainer.name,
227                email: &pkg.maintainer.email,
228                website: pkg.maintainer.website.as_ref()
229            },
230            author: pkg.author.as_ref().map(|a| MinimalPerson {
231                name: &a.name,
232                email: a.email.as_deref().unwrap_or_default(),
233                website: a.website.as_ref()
234            }),
235            registry: registry_handle,
236            registry_url
237        },
238        package_type: package_type_str,
239        scope: scope_str,
240        reason: reason_str,
241        install_type: install_type.map(std::string::ToString::to_string)
242    };
243
244    let ph_host =
245        option_env!("POSTHOG_API_HOST").unwrap_or("https://eu.i.posthog.com");
246    let ph_key = option_env!("POSTHOG_API_KEY").unwrap_or_default();
247    if ph_key.is_empty() {
248        return Err("Telemetry enabled but POSTHOG_API_KEY is not set".into());
249    }
250
251    let client = reqwest::blocking::Client::builder()
252        .timeout(std::time::Duration::from_secs(4))
253        .use_rustls_tls()
254        .build()?;
255
256    let payload = Batch {
257        api_key: ph_key,
258        batch: vec![PosthogEvent {
259            event: ev.event,
260            distinct_id: ev.client_id,
261            properties: &ev,
262            timestamp: &ev.ts
263        }]
264    };
265    let url = format!("{}/batch", ph_host.trim_end_matches('/'));
266    let resp = client.post(url).json(&payload).send()?;
267    if !resp.status().is_success() {
268        return Err(format!("`PostHog` HTTP {}", resp.status()).into());
269    }
270    Ok(true)
271}