1use crate::commands::TelemetryInfo;
20use serde::{Deserialize, Serialize};
21use std::env;
22use std::io::{Read, Write};
23use std::process::{Command, Stdio};
24use std::time::Instant;
25
26fn debug_enabled() -> bool {
28 env::var("SPAWN_DEBUG_TELEMETRY").is_ok()
29}
30
31macro_rules! debug_telemetry {
33 ($($arg:tt)*) => {
34 if debug_enabled() {
35 eprintln!("[telemetry] {}", format!($($arg)*));
36 }
37 };
38}
39
40const POSTHOG_API_KEY: &str = "phc_yD13QBdCJSnbIjmkTcSf03dRhpLJdCMfTVRzD7XTFqd";
42
43const POSTHOG_ENDPOINT: &str = "https://eu.i.posthog.com/batch/";
45
46const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct TelemetryEvent {
52 pub distinct_id: String,
53 pub command: String,
54 pub duration_ms: u64,
55 pub status: CommandStatus,
56 pub error_kind: Option<String>,
57 pub properties: Vec<(String, String)>,
58}
59
60fn spawn_telemetry_child(events: &[TelemetryEvent]) {
62 if events.is_empty() {
63 return;
64 }
65
66 let exe_path = match env::current_exe() {
68 Ok(path) => path,
69 Err(e) => {
70 debug_telemetry!("failed to get current exe: {:?}", e);
71 return;
72 }
73 };
74
75 let json = match serde_json::to_string(events) {
77 Ok(j) => j,
78 Err(e) => {
79 debug_telemetry!("failed to serialize events: {:?}", e);
80 return;
81 }
82 };
83
84 let mut cmd = Command::new(&exe_path);
86 cmd.arg("--internal-telemetry")
87 .stdin(Stdio::piped())
88 .stdout(Stdio::null())
89 .stderr(Stdio::null());
90
91 #[cfg(windows)]
93 {
94 use std::os::windows::process::CommandExt;
95 cmd.creation_flags(0x08000008);
97 }
98
99 let mut child = match cmd.spawn() {
101 Ok(c) => c,
102 Err(e) => {
103 debug_telemetry!("failed to spawn telemetry child: {:?}", e);
104 return;
105 }
106 };
107
108 if let Some(mut stdin) = child.stdin.take() {
110 if let Err(e) = stdin.write_all(json.as_bytes()) {
111 debug_telemetry!("failed to write to child stdin: {:?}", e);
112 }
113 }
115
116 debug_telemetry!(
117 "spawned telemetry child process for {} event(s)",
118 events.len()
119 );
120 }
122
123pub struct TelemetryRecorder {
128 enabled: bool,
129 distinct_id: String,
130 command: String,
131 properties: Vec<(String, String)>,
132 start_time: Instant,
133}
134
135#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
137pub enum CommandStatus {
138 Success,
139 Error,
140}
141
142impl std::fmt::Display for CommandStatus {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 match self {
145 CommandStatus::Success => write!(f, "success"),
146 CommandStatus::Error => write!(f, "error"),
147 }
148 }
149}
150
151impl TelemetryRecorder {
152 pub fn with_start_time(
164 project_id: Option<&str>,
165 telemetry_enabled: bool,
166 info: TelemetryInfo,
167 start_time: Instant,
168 ) -> Self {
169 let do_not_track = env::var("DO_NOT_TRACK").is_ok();
171
172 let enabled = !do_not_track && telemetry_enabled;
174
175 let distinct_id = if enabled {
178 project_id
179 .map(|s| s.to_string())
180 .unwrap_or_else(|| format!("e-{}", uuid::Uuid::new_v4()))
181 } else {
182 String::new()
183 };
184
185 let properties = info
187 .properties
188 .into_iter()
189 .map(|(k, v)| (k.to_string(), v))
190 .collect();
191
192 Self {
193 enabled,
194 distinct_id,
195 command: info.label,
196 properties,
197 start_time,
198 }
199 }
200
201 pub fn new(project_id: Option<&str>, telemetry_enabled: bool, info: TelemetryInfo) -> Self {
210 Self::with_start_time(project_id, telemetry_enabled, info, Instant::now())
211 }
212
213 pub fn finish(self, status: CommandStatus, error_kind: Option<&str>) {
218 debug_telemetry!("finish() called, enabled={}", self.enabled);
219 if !self.enabled {
220 return;
221 }
222
223 let event = TelemetryEvent {
224 distinct_id: self.distinct_id,
225 command: self.command,
226 duration_ms: self.start_time.elapsed().as_millis() as u64,
227 status,
228 error_kind: error_kind.map(|s| s.to_string()),
229 properties: self.properties,
230 };
231
232 debug_telemetry!(
233 "spawning child for event: command={}, distinct_id={}, duration_ms={}",
234 event.command,
235 event.distinct_id,
236 event.duration_ms
237 );
238
239 spawn_telemetry_child(&[event]);
241 }
242}
243
244pub fn send_events(events: Vec<TelemetryEvent>) {
249 if events.is_empty() {
250 return;
251 }
252 debug_telemetry!("sending {} event(s)", events.len());
253 spawn_telemetry_child(&events);
254}
255
256async fn send_events_to_posthog(events: &[TelemetryEvent]) -> Result<(), reqwest::Error> {
258 let batch: Vec<serde_json::Value> = events
260 .iter()
261 .map(|event| {
262 let mut props = serde_json::Map::new();
263 props.insert(
264 "distinct_id".to_string(),
265 serde_json::json!(event.distinct_id),
266 );
267 props.insert("app_version".to_string(), serde_json::json!(APP_VERSION));
268 props.insert(
269 "os_platform".to_string(),
270 serde_json::json!(std::env::consts::OS),
271 );
272 props.insert(
273 "os_arch".to_string(),
274 serde_json::json!(std::env::consts::ARCH),
275 );
276 props.insert("is_ci".to_string(), serde_json::json!(is_ci()));
277 props.insert("command".to_string(), serde_json::json!(event.command));
278 props.insert(
279 "duration_ms".to_string(),
280 serde_json::json!(event.duration_ms),
281 );
282 props.insert(
283 "status".to_string(),
284 serde_json::json!(event.status.to_string()),
285 );
286 props.insert("$lib".to_string(), serde_json::json!("spawn"));
287 props.insert("$lib_version".to_string(), serde_json::json!(APP_VERSION));
288 props.insert(
290 "$process_person_profile".to_string(),
291 serde_json::json!(false),
292 );
293
294 if let Some(ref kind) = event.error_kind {
295 props.insert("error_kind".to_string(), serde_json::json!(kind));
296 }
297
298 for (key, value) in &event.properties {
299 props.insert(key.clone(), serde_json::json!(value));
300 }
301
302 serde_json::json!({
303 "event": "command_completed",
304 "properties": props
305 })
306 })
307 .collect();
308
309 let payload = serde_json::json!({
310 "api_key": POSTHOG_API_KEY,
311 "batch": batch
312 });
313
314 debug_telemetry!("POST to {}", POSTHOG_ENDPOINT);
315 debug_telemetry!(
316 "payload: {}",
317 serde_json::to_string_pretty(&payload).unwrap_or_default()
318 );
319
320 let client = reqwest::Client::new();
321 let response = client
322 .post(POSTHOG_ENDPOINT)
323 .header("Content-Type", "application/json")
324 .json(&payload)
325 .send()
326 .await?;
327
328 let status = response.status();
329 let body = response.text().await.unwrap_or_default();
330
331 debug_telemetry!("response status: {}, body: {}", status, body);
332
333 Ok(())
334}
335
336fn is_ci() -> bool {
338 env::var("CI").is_ok()
340 || env::var("CONTINUOUS_INTEGRATION").is_ok()
341 || env::var("GITHUB_ACTIONS").is_ok()
342 || env::var("GITLAB_CI").is_ok()
343 || env::var("CIRCLECI").is_ok()
344 || env::var("TRAVIS").is_ok()
345 || env::var("JENKINS_URL").is_ok()
346 || env::var("BUILDKITE").is_ok()
347 || env::var("TEAMCITY_VERSION").is_ok()
348}
349
350pub fn run_internal_telemetry() {
355 let mut input = String::new();
357 if let Err(e) = std::io::stdin().read_to_string(&mut input) {
358 debug_telemetry!("failed to read stdin: {:?}", e);
359 return;
360 }
361
362 let events: Vec<TelemetryEvent> = match serde_json::from_str(&input) {
364 Ok(e) => e,
365 Err(e) => {
366 debug_telemetry!("failed to parse events JSON: {:?}", e);
367 return;
368 }
369 };
370
371 if events.is_empty() {
372 debug_telemetry!("no events to send");
373 return;
374 }
375
376 debug_telemetry!("child received {} event(s)", events.len());
377
378 let rt = match tokio::runtime::Builder::new_current_thread()
380 .enable_all()
381 .build()
382 {
383 Ok(rt) => rt,
384 Err(e) => {
385 debug_telemetry!("failed to create runtime: {:?}", e);
386 return;
387 }
388 };
389
390 let result = rt.block_on(send_events_to_posthog(&events));
392
393 match result {
394 Ok(()) => debug_telemetry!("successfully sent {} event(s)", events.len()),
395 Err(e) => debug_telemetry!("failed to send events: {:?}", e),
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use std::sync::Mutex;
403
404 static ENV_MUTEX: Mutex<()> = Mutex::new(());
406
407 #[test]
408 fn test_do_not_track_disables_telemetry() {
409 let _guard = ENV_MUTEX.lock().unwrap();
410 env::set_var("DO_NOT_TRACK", "1");
411 let recorder = TelemetryRecorder::new(Some("test-id"), true, TelemetryInfo::new("test"));
412 assert!(!recorder.enabled);
413 env::remove_var("DO_NOT_TRACK");
414 }
415
416 #[test]
417 fn test_telemetry_enabled_false_disables() {
418 let _guard = ENV_MUTEX.lock().unwrap();
419 env::remove_var("DO_NOT_TRACK"); let recorder = TelemetryRecorder::new(Some("test-id"), false, TelemetryInfo::new("test"));
421 assert!(!recorder.enabled);
422 }
423
424 #[test]
425 fn test_uses_project_id_as_distinct_id() {
426 let _guard = ENV_MUTEX.lock().unwrap();
427 env::remove_var("DO_NOT_TRACK"); let recorder =
429 TelemetryRecorder::new(Some("my-project-123"), true, TelemetryInfo::new("test"));
430 assert!(recorder.enabled);
431 assert_eq!(recorder.distinct_id, "my-project-123");
432 }
433
434 #[test]
435 fn test_generates_ephemeral_id_without_project_id() {
436 let _guard = ENV_MUTEX.lock().unwrap();
437 env::remove_var("DO_NOT_TRACK"); let recorder = TelemetryRecorder::new(None, true, TelemetryInfo::new("test"));
439 assert!(recorder.enabled);
440 assert!(recorder.distinct_id.starts_with("e-"));
442 let uuid_part = recorder.distinct_id.strip_prefix("e-").unwrap();
443 assert!(uuid::Uuid::parse_str(uuid_part).is_ok());
444 }
445
446 #[test]
447 fn test_properties_are_stored() {
448 let _guard = ENV_MUTEX.lock().unwrap();
449 env::remove_var("DO_NOT_TRACK"); let recorder = TelemetryRecorder::new(
451 Some("test-id"),
452 true,
453 TelemetryInfo::new("migration build")
454 .with_properties(vec![("opt_pinned", "true".to_string())]),
455 );
456 assert_eq!(recorder.command, "migration build");
457 assert_eq!(recorder.properties.len(), 1);
458 assert_eq!(
459 recorder.properties[0],
460 ("opt_pinned".to_string(), "true".to_string())
461 );
462 }
463}