1use std::collections::HashMap;
2
3use once_cell::sync::Lazy;
4use uuid::Uuid;
5
6use crate::constants::{DEFAULT_PATCH_API_PROXY_URL, DEFAULT_SOCKET_API_URL, USER_AGENT};
7
8static SESSION_ID: Lazy<String> = Lazy::new(|| Uuid::new_v4().to_string());
15
16const PACKAGE_VERSION: &str = "1.0.0";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum PatchTelemetryEventType {
26 PatchApplied,
27 PatchApplyFailed,
28 PatchRemoved,
29 PatchRemoveFailed,
30 PatchRolledBack,
31 PatchRollbackFailed,
32}
33
34impl PatchTelemetryEventType {
35 pub fn as_str(&self) -> &'static str {
37 match self {
38 Self::PatchApplied => "patch_applied",
39 Self::PatchApplyFailed => "patch_apply_failed",
40 Self::PatchRemoved => "patch_removed",
41 Self::PatchRemoveFailed => "patch_remove_failed",
42 Self::PatchRolledBack => "patch_rolled_back",
43 Self::PatchRollbackFailed => "patch_rollback_failed",
44 }
45 }
46}
47
48#[derive(Debug, Clone, serde::Serialize)]
50pub struct PatchTelemetryContext {
51 pub version: String,
52 pub platform: String,
53 pub arch: String,
54 pub command: String,
55}
56
57#[derive(Debug, Clone, serde::Serialize)]
59pub struct PatchTelemetryError {
60 #[serde(rename = "type")]
61 pub error_type: String,
62 pub message: Option<String>,
63}
64
65#[derive(Debug, Clone, serde::Serialize)]
67pub struct PatchTelemetryEvent {
68 pub event_sender_created_at: String,
69 pub event_type: String,
70 pub context: PatchTelemetryContext,
71 pub session_id: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub metadata: Option<HashMap<String, serde_json::Value>>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub error: Option<PatchTelemetryError>,
76}
77
78pub struct TrackPatchEventOptions {
80 pub event_type: PatchTelemetryEventType,
82 pub command: String,
84 pub metadata: Option<HashMap<String, serde_json::Value>>,
86 pub error: Option<(String, String)>,
89 pub api_token: Option<String>,
91 pub org_slug: Option<String>,
93}
94
95pub fn is_telemetry_disabled() -> bool {
105 matches!(
106 std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED")
107 .unwrap_or_default()
108 .as_str(),
109 "1" | "true"
110 ) || std::env::var("VITEST").unwrap_or_default() == "true"
111}
112
113fn is_debug_enabled() -> bool {
115 matches!(
116 std::env::var("SOCKET_PATCH_DEBUG")
117 .unwrap_or_default()
118 .as_str(),
119 "1" | "true"
120 )
121}
122
123fn debug_log(message: &str) {
125 if is_debug_enabled() {
126 eprintln!("[socket-patch telemetry] {message}");
127 }
128}
129
130fn build_telemetry_context(command: &str) -> PatchTelemetryContext {
136 PatchTelemetryContext {
137 version: PACKAGE_VERSION.to_string(),
138 platform: std::env::consts::OS.to_string(),
139 arch: std::env::consts::ARCH.to_string(),
140 command: command.to_string(),
141 }
142}
143
144pub fn sanitize_error_message(message: &str) -> String {
149 if let Some(home) = home_dir_string() {
150 if !home.is_empty() {
151 return message.replace(&home, "~");
152 }
153 }
154 message.to_string()
155}
156
157fn home_dir_string() -> Option<String> {
159 std::env::var("HOME")
160 .ok()
161 .or_else(|| std::env::var("USERPROFILE").ok())
162}
163
164fn build_telemetry_event(options: &TrackPatchEventOptions) -> PatchTelemetryEvent {
166 let error = options.error.as_ref().map(|(error_type, message)| {
167 PatchTelemetryError {
168 error_type: error_type.clone(),
169 message: Some(sanitize_error_message(message)),
170 }
171 });
172
173 PatchTelemetryEvent {
174 event_sender_created_at: chrono_now_iso(),
175 event_type: options.event_type.as_str().to_string(),
176 context: build_telemetry_context(&options.command),
177 session_id: SESSION_ID.clone(),
178 metadata: options.metadata.clone(),
179 error,
180 }
181}
182
183fn chrono_now_iso() -> String {
185 let now = std::time::SystemTime::now();
186 let duration = now
187 .duration_since(std::time::UNIX_EPOCH)
188 .unwrap_or_default();
189 let secs = duration.as_secs();
190
191 let days = secs / 86400;
192 let remaining = secs % 86400;
193 let hours = remaining / 3600;
194 let minutes = (remaining % 3600) / 60;
195 let seconds = remaining % 60;
196 let millis = duration.subsec_millis();
197
198 let (year, month, day) = days_to_ymd(days);
199
200 format!(
201 "{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{millis:03}Z"
202 )
203}
204
205fn days_to_ymd(days: u64) -> (u64, u64, u64) {
207 let z = days as i64 + 719468;
209 let era = if z >= 0 { z } else { z - 146096 } / 146097;
210 let doe = (z - era * 146097) as u64;
211 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
212 let y = yoe as i64 + era * 400;
213 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
214 let mp = (5 * doy + 2) / 153;
215 let d = doy - (153 * mp + 2) / 5 + 1;
216 let m = if mp < 10 { mp + 3 } else { mp - 9 };
217 let y = if m <= 2 { y + 1 } else { y };
218 (y as u64, m, d)
219}
220
221async fn send_telemetry_event(
230 event: &PatchTelemetryEvent,
231 api_token: Option<&str>,
232 org_slug: Option<&str>,
233) {
234 let (url, use_auth) = match (api_token, org_slug) {
235 (Some(_token), Some(slug)) => {
236 let api_url = std::env::var("SOCKET_API_URL")
237 .unwrap_or_else(|_| DEFAULT_SOCKET_API_URL.to_string());
238 (format!("{api_url}/v0/orgs/{slug}/telemetry"), true)
239 }
240 _ => {
241 let proxy_url = std::env::var("SOCKET_PATCH_PROXY_URL")
242 .unwrap_or_else(|_| DEFAULT_PATCH_API_PROXY_URL.to_string());
243 (format!("{proxy_url}/patch/telemetry"), false)
244 }
245 };
246
247 debug_log(&format!("Sending telemetry to {url}"));
248
249 let client = match reqwest::Client::builder()
250 .timeout(std::time::Duration::from_secs(5))
251 .build()
252 {
253 Ok(c) => c,
254 Err(e) => {
255 debug_log(&format!("Failed to build HTTP client: {e}"));
256 return;
257 }
258 };
259
260 let mut request = client
261 .post(&url)
262 .header("Content-Type", "application/json")
263 .header("User-Agent", USER_AGENT);
264
265 if use_auth {
266 if let Some(token) = api_token {
267 request = request.header("Authorization", format!("Bearer {token}"));
268 }
269 }
270
271 match request.json(event).send().await {
272 Ok(response) => {
273 let status = response.status();
274 if status.is_success() {
275 debug_log("Telemetry sent successfully");
276 } else {
277 debug_log(&format!("Telemetry request returned status {status}"));
278 }
279 }
280 Err(e) => {
281 debug_log(&format!("Telemetry request failed: {e}"));
282 }
283 }
284}
285
286pub async fn track_patch_event(options: TrackPatchEventOptions) {
298 if is_telemetry_disabled() {
299 debug_log("Telemetry is disabled, skipping event");
300 return;
301 }
302
303 let event = build_telemetry_event(&options);
304 send_telemetry_event(
305 &event,
306 options.api_token.as_deref(),
307 options.org_slug.as_deref(),
308 )
309 .await;
310}
311
312pub fn track_patch_event_fire_and_forget(options: TrackPatchEventOptions) {
315 if is_telemetry_disabled() {
316 debug_log("Telemetry is disabled, skipping event");
317 return;
318 }
319
320 let event = build_telemetry_event(&options);
321 let api_token = options.api_token.clone();
322 let org_slug = options.org_slug.clone();
323
324 tokio::spawn(async move {
325 send_telemetry_event(&event, api_token.as_deref(), org_slug.as_deref()).await;
326 });
327}
328
329pub async fn track_patch_applied(
338 patches_count: usize,
339 dry_run: bool,
340 api_token: Option<&str>,
341 org_slug: Option<&str>,
342) {
343 let mut metadata = HashMap::new();
344 metadata.insert(
345 "patches_count".to_string(),
346 serde_json::Value::Number(serde_json::Number::from(patches_count)),
347 );
348 metadata.insert("dry_run".to_string(), serde_json::Value::Bool(dry_run));
349
350 track_patch_event(TrackPatchEventOptions {
351 event_type: PatchTelemetryEventType::PatchApplied,
352 command: "apply".to_string(),
353 metadata: Some(metadata),
354 error: None,
355 api_token: api_token.map(|s| s.to_string()),
356 org_slug: org_slug.map(|s| s.to_string()),
357 })
358 .await;
359}
360
361pub async fn track_patch_apply_failed(
366 error: impl std::fmt::Display,
367 dry_run: bool,
368 api_token: Option<&str>,
369 org_slug: Option<&str>,
370) {
371 let mut metadata = HashMap::new();
372 metadata.insert("dry_run".to_string(), serde_json::Value::Bool(dry_run));
373
374 track_patch_event(TrackPatchEventOptions {
375 event_type: PatchTelemetryEventType::PatchApplyFailed,
376 command: "apply".to_string(),
377 metadata: Some(metadata),
378 error: Some(("Error".to_string(), error.to_string())),
379 api_token: api_token.map(|s| s.to_string()),
380 org_slug: org_slug.map(|s| s.to_string()),
381 })
382 .await;
383}
384
385pub async fn track_patch_removed(
387 removed_count: usize,
388 api_token: Option<&str>,
389 org_slug: Option<&str>,
390) {
391 let mut metadata = HashMap::new();
392 metadata.insert(
393 "removed_count".to_string(),
394 serde_json::Value::Number(serde_json::Number::from(removed_count)),
395 );
396
397 track_patch_event(TrackPatchEventOptions {
398 event_type: PatchTelemetryEventType::PatchRemoved,
399 command: "remove".to_string(),
400 metadata: Some(metadata),
401 error: None,
402 api_token: api_token.map(|s| s.to_string()),
403 org_slug: org_slug.map(|s| s.to_string()),
404 })
405 .await;
406}
407
408pub async fn track_patch_remove_failed(
412 error: impl std::fmt::Display,
413 api_token: Option<&str>,
414 org_slug: Option<&str>,
415) {
416 track_patch_event(TrackPatchEventOptions {
417 event_type: PatchTelemetryEventType::PatchRemoveFailed,
418 command: "remove".to_string(),
419 metadata: None,
420 error: Some(("Error".to_string(), error.to_string())),
421 api_token: api_token.map(|s| s.to_string()),
422 org_slug: org_slug.map(|s| s.to_string()),
423 })
424 .await;
425}
426
427pub async fn track_patch_rolled_back(
429 rolled_back_count: usize,
430 api_token: Option<&str>,
431 org_slug: Option<&str>,
432) {
433 let mut metadata = HashMap::new();
434 metadata.insert(
435 "rolled_back_count".to_string(),
436 serde_json::Value::Number(serde_json::Number::from(rolled_back_count)),
437 );
438
439 track_patch_event(TrackPatchEventOptions {
440 event_type: PatchTelemetryEventType::PatchRolledBack,
441 command: "rollback".to_string(),
442 metadata: Some(metadata),
443 error: None,
444 api_token: api_token.map(|s| s.to_string()),
445 org_slug: org_slug.map(|s| s.to_string()),
446 })
447 .await;
448}
449
450pub async fn track_patch_rollback_failed(
454 error: impl std::fmt::Display,
455 api_token: Option<&str>,
456 org_slug: Option<&str>,
457) {
458 track_patch_event(TrackPatchEventOptions {
459 event_type: PatchTelemetryEventType::PatchRollbackFailed,
460 command: "rollback".to_string(),
461 metadata: None,
462 error: Some(("Error".to_string(), error.to_string())),
463 api_token: api_token.map(|s| s.to_string()),
464 org_slug: org_slug.map(|s| s.to_string()),
465 })
466 .await;
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472
473 #[test]
475 fn test_is_telemetry_disabled() {
476 let orig_disabled = std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED").ok();
478 let orig_vitest = std::env::var("VITEST").ok();
479
480 std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED");
482 std::env::remove_var("VITEST");
483 assert!(!is_telemetry_disabled());
484
485 std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "1");
487 assert!(is_telemetry_disabled());
488
489 std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "true");
491 assert!(is_telemetry_disabled());
492
493 match orig_disabled {
495 Some(v) => std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", v),
496 None => std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"),
497 }
498 match orig_vitest {
499 Some(v) => std::env::set_var("VITEST", v),
500 None => std::env::remove_var("VITEST"),
501 }
502 }
503
504 #[test]
505 fn test_sanitize_error_message() {
506 let home = home_dir_string().unwrap_or_else(|| "/home/testuser".to_string());
507 let msg = format!("Failed to read {home}/projects/secret/file.txt");
508 let sanitized = sanitize_error_message(&msg);
509 assert!(sanitized.contains("~/projects/secret/file.txt"));
510 assert!(!sanitized.contains(&home));
511 }
512
513 #[test]
514 fn test_sanitize_error_message_no_home() {
515 let msg = "Some error without paths";
516 assert_eq!(sanitize_error_message(msg), msg);
517 }
518
519 #[test]
520 fn test_event_type_as_str() {
521 assert_eq!(PatchTelemetryEventType::PatchApplied.as_str(), "patch_applied");
522 assert_eq!(
523 PatchTelemetryEventType::PatchApplyFailed.as_str(),
524 "patch_apply_failed"
525 );
526 assert_eq!(PatchTelemetryEventType::PatchRemoved.as_str(), "patch_removed");
527 assert_eq!(
528 PatchTelemetryEventType::PatchRemoveFailed.as_str(),
529 "patch_remove_failed"
530 );
531 assert_eq!(
532 PatchTelemetryEventType::PatchRolledBack.as_str(),
533 "patch_rolled_back"
534 );
535 assert_eq!(
536 PatchTelemetryEventType::PatchRollbackFailed.as_str(),
537 "patch_rollback_failed"
538 );
539 }
540
541 #[test]
542 fn test_build_telemetry_context() {
543 let ctx = build_telemetry_context("apply");
544 assert_eq!(ctx.command, "apply");
545 assert_eq!(ctx.version, PACKAGE_VERSION);
546 assert!(!ctx.platform.is_empty());
547 assert!(!ctx.arch.is_empty());
548 }
549
550 #[test]
551 fn test_build_telemetry_event_basic() {
552 let options = TrackPatchEventOptions {
553 event_type: PatchTelemetryEventType::PatchApplied,
554 command: "apply".to_string(),
555 metadata: None,
556 error: None,
557 api_token: None,
558 org_slug: None,
559 };
560
561 let event = build_telemetry_event(&options);
562 assert_eq!(event.event_type, "patch_applied");
563 assert_eq!(event.context.command, "apply");
564 assert!(!event.session_id.is_empty());
565 assert!(!event.event_sender_created_at.is_empty());
566 assert!(event.metadata.is_none());
567 assert!(event.error.is_none());
568 }
569
570 #[test]
571 fn test_build_telemetry_event_with_metadata() {
572 let mut metadata = HashMap::new();
573 metadata.insert(
574 "patches_count".to_string(),
575 serde_json::Value::Number(5.into()),
576 );
577
578 let options = TrackPatchEventOptions {
579 event_type: PatchTelemetryEventType::PatchApplied,
580 command: "apply".to_string(),
581 metadata: Some(metadata),
582 error: None,
583 api_token: None,
584 org_slug: None,
585 };
586
587 let event = build_telemetry_event(&options);
588 assert!(event.metadata.is_some());
589 let meta = event.metadata.unwrap();
590 assert_eq!(
591 meta.get("patches_count").unwrap(),
592 &serde_json::Value::Number(5.into())
593 );
594 }
595
596 #[test]
597 fn test_build_telemetry_event_with_error() {
598 let options = TrackPatchEventOptions {
599 event_type: PatchTelemetryEventType::PatchApplyFailed,
600 command: "apply".to_string(),
601 metadata: None,
602 error: Some(("IoError".to_string(), "file not found".to_string())),
603 api_token: None,
604 org_slug: None,
605 };
606
607 let event = build_telemetry_event(&options);
608 assert!(event.error.is_some());
609 let err = event.error.unwrap();
610 assert_eq!(err.error_type, "IoError");
611 assert_eq!(err.message.unwrap(), "file not found");
612 }
613
614 #[test]
615 fn test_session_id_is_consistent() {
616 let id1 = SESSION_ID.clone();
617 let id2 = SESSION_ID.clone();
618 assert_eq!(id1, id2);
619 assert_eq!(id1.len(), 36);
621 assert!(id1.contains('-'));
622 }
623
624 #[test]
625 fn test_chrono_now_iso_format() {
626 let ts = chrono_now_iso();
627 assert!(ts.ends_with('Z'));
629 assert!(ts.contains('T'));
630 assert!(ts.contains('-'));
631 assert!(ts.contains(':'));
632 assert_eq!(ts.len(), 24); }
634
635 #[test]
636 fn test_days_to_ymd_epoch() {
637 let (y, m, d) = days_to_ymd(0);
638 assert_eq!((y, m, d), (1970, 1, 1));
639 }
640
641 #[test]
642 fn test_days_to_ymd_known_date() {
643 let (y, m, d) = days_to_ymd(19723);
645 assert_eq!((y, m, d), (2024, 1, 1));
646 }
647}