1use std::error::Error;
8use std::fs;
9
10use serde::Serialize;
11use uuid::Timestamp;
12
13#[derive(Debug, Serialize)]
15pub struct PackageEvent<'a> {
16 pub client_id: &'a str,
18 pub event: &'a str,
20 pub ts: String,
22 pub app_version: &'a str,
24 pub os: &'a str,
26 pub arch: &'a str,
28 pub distro: Option<String>,
30 pub shell: Option<String>,
32 pub package: MinimalPackage<'a>,
34 pub package_type: &'a str,
36 pub scope: String,
38 pub reason: String,
40 pub install_type: Option<String>
42}
43
44#[derive(Debug, Serialize)]
46pub struct MinimalPackage<'a> {
47 pub name: &'a str,
49 pub sub_package: Option<&'a String>,
51 pub repo: &'a str,
53 pub version: &'a str,
55 pub description: &'a str,
57 pub license: &'a str,
59 pub maintainer: MinimalPerson<'a>,
61 pub author: Option<MinimalPerson<'a>>,
63 pub registry: &'a str,
65 pub registry_url: &'a str
67}
68
69#[derive(Debug, Serialize)]
71pub struct MinimalPerson<'a> {
72 pub name: &'a str,
74 pub email: &'a str,
76 pub website: Option<&'a String>
78}
79
80fn 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
87pub fn get_anonymous_id() -> String {
89 ensure_client_id().unwrap_or_else(|_| "unknown".to_string())
90}
91
92fn 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#[derive(Serialize)]
117struct PosthogEvent<'a> {
118 event: &'a str,
120 distinct_id: &'a str,
122 properties: &'a PackageEvent<'a>,
124 timestamp: &'a str
126}
127
128#[derive(Serialize)]
130struct Batch<'a> {
131 api_key: &'a str,
133 batch: Vec<PosthogEvent<'a>>
135}
136
137pub 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}