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};
7use crate::utils::env_compat::read_env_with_legacy;
8
9static SESSION_ID: Lazy<String> = Lazy::new(|| Uuid::new_v4().to_string());
16
17const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum PatchTelemetryEventType {
30 PatchApplied,
32 PatchApplyFailed,
33 PatchRemoved,
34 PatchRemoveFailed,
35 PatchRolledBack,
36 PatchRollbackFailed,
37 PatchScanned,
39 PatchScanFailed,
40 PatchFetched,
41 PatchFetchFailed,
42 PatchListed,
44 PatchRepaired,
45 PatchRepairFailed,
46 PatchSetup,
47 PatchUnlocked,
48 PatchUnlockFailed,
49 VexGenerated,
51 VexFailed,
52}
53
54impl PatchTelemetryEventType {
55 pub fn as_str(&self) -> &'static str {
57 match self {
58 Self::PatchApplied => "patch_applied",
59 Self::PatchApplyFailed => "patch_apply_failed",
60 Self::PatchRemoved => "patch_removed",
61 Self::PatchRemoveFailed => "patch_remove_failed",
62 Self::PatchRolledBack => "patch_rolled_back",
63 Self::PatchRollbackFailed => "patch_rollback_failed",
64 Self::PatchScanned => "patch_scanned",
65 Self::PatchScanFailed => "patch_scan_failed",
66 Self::PatchFetched => "patch_fetched",
67 Self::PatchFetchFailed => "patch_fetch_failed",
68 Self::PatchListed => "patch_listed",
69 Self::PatchRepaired => "patch_repaired",
70 Self::PatchRepairFailed => "patch_repair_failed",
71 Self::PatchSetup => "patch_setup",
72 Self::PatchUnlocked => "patch_unlocked",
73 Self::PatchUnlockFailed => "patch_unlock_failed",
74 Self::VexGenerated => "vex_generated",
75 Self::VexFailed => "vex_failed",
76 }
77 }
78}
79
80#[derive(Debug, Clone, serde::Serialize)]
82pub struct PatchTelemetryContext {
83 pub version: String,
84 pub platform: String,
85 pub arch: String,
86 pub command: String,
87}
88
89#[derive(Debug, Clone, serde::Serialize)]
91pub struct PatchTelemetryError {
92 #[serde(rename = "type")]
93 pub error_type: String,
94 pub message: Option<String>,
95}
96
97#[derive(Debug, Clone, serde::Serialize)]
99pub struct PatchTelemetryEvent {
100 pub event_sender_created_at: String,
101 pub event_type: String,
102 pub context: PatchTelemetryContext,
103 pub session_id: String,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub metadata: Option<HashMap<String, serde_json::Value>>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub error: Option<PatchTelemetryError>,
108}
109
110pub struct TrackPatchEventOptions {
112 pub event_type: PatchTelemetryEventType,
114 pub command: String,
116 pub metadata: Option<HashMap<String, serde_json::Value>>,
118 pub error: Option<(String, String)>,
121 pub api_token: Option<String>,
123 pub org_slug: Option<String>,
125}
126
127pub fn is_telemetry_disabled() -> bool {
145 let env_value = read_env_with_legacy(
146 "SOCKET_TELEMETRY_DISABLED",
147 "SOCKET_PATCH_TELEMETRY_DISABLED",
148 )
149 .unwrap_or_default();
150 let disabled_via_env = matches!(env_value.as_str(), "1" | "true");
151 let vitest = std::env::var("VITEST").unwrap_or_default() == "true";
152 let offline = matches!(
153 std::env::var("SOCKET_OFFLINE").unwrap_or_default().as_str(),
154 "1" | "true"
155 );
156 disabled_via_env || vitest || offline
157}
158
159fn is_debug_enabled() -> bool {
162 matches!(
163 read_env_with_legacy("SOCKET_DEBUG", "SOCKET_PATCH_DEBUG")
164 .unwrap_or_default()
165 .as_str(),
166 "1" | "true"
167 )
168}
169
170fn debug_log(message: &str) {
172 if is_debug_enabled() {
173 eprintln!("[socket-patch telemetry] {message}");
174 }
175}
176
177fn build_telemetry_context(command: &str) -> PatchTelemetryContext {
183 PatchTelemetryContext {
184 version: PACKAGE_VERSION.to_string(),
185 platform: std::env::consts::OS.to_string(),
186 arch: std::env::consts::ARCH.to_string(),
187 command: command.to_string(),
188 }
189}
190
191pub fn sanitize_error_message(message: &str) -> String {
196 if let Some(home) = home_dir_string() {
197 if !home.is_empty() {
198 return message.replace(&home, "~");
199 }
200 }
201 message.to_string()
202}
203
204fn home_dir_string() -> Option<String> {
206 std::env::var("HOME")
207 .ok()
208 .or_else(|| std::env::var("USERPROFILE").ok())
209}
210
211fn build_telemetry_event(options: &TrackPatchEventOptions) -> PatchTelemetryEvent {
213 let error = options
214 .error
215 .as_ref()
216 .map(|(error_type, message)| PatchTelemetryError {
217 error_type: error_type.clone(),
218 message: Some(sanitize_error_message(message)),
219 });
220
221 PatchTelemetryEvent {
222 event_sender_created_at: chrono_now_iso(),
223 event_type: options.event_type.as_str().to_string(),
224 context: build_telemetry_context(&options.command),
225 session_id: SESSION_ID.clone(),
226 metadata: options.metadata.clone(),
227 error,
228 }
229}
230
231fn chrono_now_iso() -> String {
233 let now = std::time::SystemTime::now();
234 let duration = now
235 .duration_since(std::time::UNIX_EPOCH)
236 .unwrap_or_default();
237 let secs = duration.as_secs();
238
239 let days = secs / 86400;
240 let remaining = secs % 86400;
241 let hours = remaining / 3600;
242 let minutes = (remaining % 3600) / 60;
243 let seconds = remaining % 60;
244 let millis = duration.subsec_millis();
245
246 let (year, month, day) = days_to_ymd(days);
247
248 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{millis:03}Z")
249}
250
251fn days_to_ymd(days: u64) -> (u64, u64, u64) {
253 let z = days as i64 + 719468;
255 let era = if z >= 0 { z } else { z - 146096 } / 146097;
256 let doe = (z - era * 146097) as u64;
257 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
258 let y = yoe as i64 + era * 400;
259 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
260 let mp = (5 * doy + 2) / 153;
261 let d = doy - (153 * mp + 2) / 5 + 1;
262 let m = if mp < 10 { mp + 3 } else { mp - 9 };
263 let y = if m <= 2 { y + 1 } else { y };
264 (y as u64, m, d)
265}
266
267async fn send_telemetry_event(
276 event: &PatchTelemetryEvent,
277 api_token: Option<&str>,
278 org_slug: Option<&str>,
279) {
280 let (url, use_auth) = match (api_token, org_slug) {
281 (Some(_token), Some(slug)) => {
282 let api_url = std::env::var("SOCKET_API_URL")
283 .unwrap_or_else(|_| DEFAULT_SOCKET_API_URL.to_string());
284 (format!("{api_url}/v0/orgs/{slug}/telemetry"), true)
285 }
286 _ => {
287 let proxy_url = read_env_with_legacy("SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL")
288 .unwrap_or_else(|| DEFAULT_PATCH_API_PROXY_URL.to_string());
289 (format!("{proxy_url}/patch/telemetry"), false)
290 }
291 };
292
293 debug_log(&format!("Sending telemetry to {url}"));
294
295 let client = match reqwest::Client::builder()
296 .timeout(std::time::Duration::from_secs(5))
297 .build()
298 {
299 Ok(c) => c,
300 Err(e) => {
301 debug_log(&format!("Failed to build HTTP client: {e}"));
302 return;
303 }
304 };
305
306 let mut request = client
307 .post(&url)
308 .header("Content-Type", "application/json")
309 .header("User-Agent", USER_AGENT);
310
311 if use_auth {
312 if let Some(token) = api_token {
313 request = request.header("Authorization", format!("Bearer {token}"));
314 }
315 }
316
317 match request.json(event).send().await {
318 Ok(response) => {
319 let status = response.status();
320 if status.is_success() {
321 debug_log("Telemetry sent successfully");
322 } else {
323 debug_log(&format!("Telemetry request returned status {status}"));
324 }
325 }
326 Err(e) => {
327 debug_log(&format!("Telemetry request failed: {e}"));
328 }
329 }
330}
331
332pub async fn track_patch_event(options: TrackPatchEventOptions) {
344 if is_telemetry_disabled() {
345 debug_log("Telemetry is disabled, skipping event");
346 return;
347 }
348
349 let event = build_telemetry_event(&options);
350 send_telemetry_event(
351 &event,
352 options.api_token.as_deref(),
353 options.org_slug.as_deref(),
354 )
355 .await;
356}
357
358fn metadata_from_json(value: serde_json::Value) -> Option<HashMap<String, serde_json::Value>> {
369 match value {
370 serde_json::Value::Object(map) => {
371 if map.is_empty() {
372 None
373 } else {
374 Some(map.into_iter().collect())
375 }
376 }
377 _ => None,
378 }
379}
380
381async fn fire(
385 event_type: PatchTelemetryEventType,
386 command: &'static str,
387 metadata: serde_json::Value,
388 error: Option<impl std::fmt::Display>,
389 api_token: Option<&str>,
390 org_slug: Option<&str>,
391) {
392 track_patch_event(TrackPatchEventOptions {
393 event_type,
394 command: command.to_string(),
395 metadata: metadata_from_json(metadata),
396 error: error.map(|e| ("Error".to_string(), e.to_string())),
397 api_token: api_token.map(String::from),
398 org_slug: org_slug.map(String::from),
399 })
400 .await;
401}
402
403pub async fn track_patch_applied(
405 patches_count: usize,
406 dry_run: bool,
407 api_token: Option<&str>,
408 org_slug: Option<&str>,
409) {
410 fire(
411 PatchTelemetryEventType::PatchApplied,
412 "apply",
413 serde_json::json!({ "patches_count": patches_count, "dry_run": dry_run }),
414 None::<&str>,
415 api_token,
416 org_slug,
417 )
418 .await;
419}
420
421pub async fn track_patch_apply_failed(
426 error: impl std::fmt::Display,
427 dry_run: bool,
428 api_token: Option<&str>,
429 org_slug: Option<&str>,
430) {
431 fire(
432 PatchTelemetryEventType::PatchApplyFailed,
433 "apply",
434 serde_json::json!({ "dry_run": dry_run }),
435 Some(error),
436 api_token,
437 org_slug,
438 )
439 .await;
440}
441
442pub async fn track_patch_removed(
444 removed_count: usize,
445 api_token: Option<&str>,
446 org_slug: Option<&str>,
447) {
448 fire(
449 PatchTelemetryEventType::PatchRemoved,
450 "remove",
451 serde_json::json!({ "removed_count": removed_count }),
452 None::<&str>,
453 api_token,
454 org_slug,
455 )
456 .await;
457}
458
459pub async fn track_patch_remove_failed(
461 error: impl std::fmt::Display,
462 api_token: Option<&str>,
463 org_slug: Option<&str>,
464) {
465 fire(
466 PatchTelemetryEventType::PatchRemoveFailed,
467 "remove",
468 serde_json::Value::Null,
469 Some(error),
470 api_token,
471 org_slug,
472 )
473 .await;
474}
475
476pub async fn track_patch_rolled_back(
478 rolled_back_count: usize,
479 api_token: Option<&str>,
480 org_slug: Option<&str>,
481) {
482 fire(
483 PatchTelemetryEventType::PatchRolledBack,
484 "rollback",
485 serde_json::json!({ "rolled_back_count": rolled_back_count }),
486 None::<&str>,
487 api_token,
488 org_slug,
489 )
490 .await;
491}
492
493pub async fn track_patch_rollback_failed(
495 error: impl std::fmt::Display,
496 api_token: Option<&str>,
497 org_slug: Option<&str>,
498) {
499 fire(
500 PatchTelemetryEventType::PatchRollbackFailed,
501 "rollback",
502 serde_json::Value::Null,
503 Some(error),
504 api_token,
505 org_slug,
506 )
507 .await;
508}
509
510#[allow(clippy::too_many_arguments)]
524pub async fn track_patch_scanned(
525 packages_scanned: usize,
526 free_patches: usize,
527 paid_patches: usize,
528 can_access_paid: bool,
529 ecosystems: &[String],
530 fallback_to_proxy: bool,
531 api_token: Option<&str>,
532 org_slug: Option<&str>,
533) {
534 fire(
535 PatchTelemetryEventType::PatchScanned,
536 "scan",
537 serde_json::json!({
538 "packages_scanned": packages_scanned,
539 "free_patches": free_patches,
540 "paid_patches": paid_patches,
541 "can_access_paid": can_access_paid,
542 "ecosystems": ecosystems,
543 "fallback_to_proxy": fallback_to_proxy,
544 }),
545 None::<&str>,
546 api_token,
547 org_slug,
548 )
549 .await;
550}
551
552pub async fn track_patch_scan_failed(
554 error: impl std::fmt::Display,
555 fallback_to_proxy: bool,
556 api_token: Option<&str>,
557 org_slug: Option<&str>,
558) {
559 fire(
560 PatchTelemetryEventType::PatchScanFailed,
561 "scan",
562 serde_json::json!({ "fallback_to_proxy": fallback_to_proxy }),
563 Some(error),
564 api_token,
565 org_slug,
566 )
567 .await;
568}
569
570pub async fn track_patch_fetched(
574 uuid: &str,
575 tier: &str,
576 ecosystem: &str,
577 download_mode: &str,
578 fallback_to_proxy: bool,
579 api_token: Option<&str>,
580 org_slug: Option<&str>,
581) {
582 fire(
583 PatchTelemetryEventType::PatchFetched,
584 "get",
585 serde_json::json!({
586 "uuid": uuid,
587 "tier": tier,
588 "ecosystem": ecosystem,
589 "download_mode": download_mode,
590 "fallback_to_proxy": fallback_to_proxy,
591 }),
592 None::<&str>,
593 api_token,
594 org_slug,
595 )
596 .await;
597}
598
599pub async fn track_patch_fetch_failed(
602 uuid: &str,
603 error: impl std::fmt::Display,
604 fallback_to_proxy: bool,
605 api_token: Option<&str>,
606 org_slug: Option<&str>,
607) {
608 fire(
609 PatchTelemetryEventType::PatchFetchFailed,
610 "get",
611 serde_json::json!({ "uuid": uuid, "fallback_to_proxy": fallback_to_proxy }),
612 Some(error),
613 api_token,
614 org_slug,
615 )
616 .await;
617}
618
619pub async fn track_patch_listed(
625 patches_count: usize,
626 api_token: Option<&str>,
627 org_slug: Option<&str>,
628) {
629 fire(
630 PatchTelemetryEventType::PatchListed,
631 "list",
632 serde_json::json!({ "patches_count": patches_count }),
633 None::<&str>,
634 api_token,
635 org_slug,
636 )
637 .await;
638}
639
640pub async fn track_patch_repaired(
642 blobs_added: usize,
643 blobs_removed: usize,
644 bytes_freed: u64,
645 api_token: Option<&str>,
646 org_slug: Option<&str>,
647) {
648 fire(
649 PatchTelemetryEventType::PatchRepaired,
650 "repair",
651 serde_json::json!({
652 "blobs_added": blobs_added,
653 "blobs_removed": blobs_removed,
654 "bytes_freed": bytes_freed,
655 }),
656 None::<&str>,
657 api_token,
658 org_slug,
659 )
660 .await;
661}
662
663pub async fn track_patch_repair_failed(
665 error: impl std::fmt::Display,
666 api_token: Option<&str>,
667 org_slug: Option<&str>,
668) {
669 fire(
670 PatchTelemetryEventType::PatchRepairFailed,
671 "repair",
672 serde_json::Value::Null,
673 Some(error),
674 api_token,
675 org_slug,
676 )
677 .await;
678}
679
680pub async fn track_patch_setup(manager: &str, api_token: Option<&str>, org_slug: Option<&str>) {
683 fire(
684 PatchTelemetryEventType::PatchSetup,
685 "setup",
686 serde_json::json!({ "manager": manager }),
687 None::<&str>,
688 api_token,
689 org_slug,
690 )
691 .await;
692}
693
694pub async fn track_patch_unlocked(
698 was_held: bool,
699 released: bool,
700 api_token: Option<&str>,
701 org_slug: Option<&str>,
702) {
703 fire(
704 PatchTelemetryEventType::PatchUnlocked,
705 "unlock",
706 serde_json::json!({ "was_held": was_held, "released": released }),
707 None::<&str>,
708 api_token,
709 org_slug,
710 )
711 .await;
712}
713
714pub async fn track_patch_unlock_failed(
716 error: impl std::fmt::Display,
717 api_token: Option<&str>,
718 org_slug: Option<&str>,
719) {
720 fire(
721 PatchTelemetryEventType::PatchUnlockFailed,
722 "unlock",
723 serde_json::Value::Null,
724 Some(error),
725 api_token,
726 org_slug,
727 )
728 .await;
729}
730
731pub async fn track_vex_generated(
738 advisories_count: usize,
739 format: &str,
740 output_kind: &str,
741 api_token: Option<&str>,
742 org_slug: Option<&str>,
743) {
744 fire(
745 PatchTelemetryEventType::VexGenerated,
746 "vex",
747 serde_json::json!({
748 "advisories_count": advisories_count,
749 "format": format,
750 "output_kind": output_kind,
751 }),
752 None::<&str>,
753 api_token,
754 org_slug,
755 )
756 .await;
757}
758
759pub async fn track_vex_failed(
761 error: impl std::fmt::Display,
762 api_token: Option<&str>,
763 org_slug: Option<&str>,
764) {
765 fire(
766 PatchTelemetryEventType::VexFailed,
767 "vex",
768 serde_json::Value::Null,
769 Some(error),
770 api_token,
771 org_slug,
772 )
773 .await;
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779
780 #[test]
785 fn test_is_telemetry_disabled() {
786 let orig_new = std::env::var("SOCKET_TELEMETRY_DISABLED").ok();
788 let orig_legacy = std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED").ok();
789 let orig_vitest = std::env::var("VITEST").ok();
790 let orig_offline = std::env::var("SOCKET_OFFLINE").ok();
791
792 std::env::remove_var("SOCKET_TELEMETRY_DISABLED");
794 std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED");
795 std::env::remove_var("VITEST");
796 std::env::remove_var("SOCKET_OFFLINE");
797 assert!(!is_telemetry_disabled());
798
799 std::env::set_var("SOCKET_TELEMETRY_DISABLED", "1");
801 assert!(is_telemetry_disabled());
802 std::env::remove_var("SOCKET_TELEMETRY_DISABLED");
803
804 std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "1");
806 assert!(is_telemetry_disabled());
807 std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "true");
808 assert!(is_telemetry_disabled());
809 std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED");
810
811 std::env::set_var("SOCKET_OFFLINE", "1");
814 assert!(
815 is_telemetry_disabled(),
816 "SOCKET_OFFLINE=1 must disable telemetry (airgap)"
817 );
818 std::env::set_var("SOCKET_OFFLINE", "true");
819 assert!(
820 is_telemetry_disabled(),
821 "SOCKET_OFFLINE=true must disable telemetry (airgap)"
822 );
823 std::env::set_var("SOCKET_OFFLINE", "0");
825 assert!(!is_telemetry_disabled());
826 std::env::set_var("SOCKET_OFFLINE", "");
827 assert!(!is_telemetry_disabled());
828 std::env::remove_var("SOCKET_OFFLINE");
829
830 match orig_new {
832 Some(v) => std::env::set_var("SOCKET_TELEMETRY_DISABLED", v),
833 None => std::env::remove_var("SOCKET_TELEMETRY_DISABLED"),
834 }
835 match orig_legacy {
836 Some(v) => std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", v),
837 None => std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"),
838 }
839 match orig_vitest {
840 Some(v) => std::env::set_var("VITEST", v),
841 None => std::env::remove_var("VITEST"),
842 }
843 match orig_offline {
844 Some(v) => std::env::set_var("SOCKET_OFFLINE", v),
845 None => std::env::remove_var("SOCKET_OFFLINE"),
846 }
847 }
848
849 #[test]
850 fn test_sanitize_error_message() {
851 let home = home_dir_string().unwrap_or_else(|| "/home/testuser".to_string());
852 let msg = format!("Failed to read {home}/projects/secret/file.txt");
853 let sanitized = sanitize_error_message(&msg);
854 assert!(sanitized.contains("~/projects/secret/file.txt"));
855 assert!(!sanitized.contains(&home));
856 }
857
858 #[test]
859 fn test_sanitize_error_message_no_home() {
860 let msg = "Some error without paths";
861 assert_eq!(sanitize_error_message(msg), msg);
862 }
863
864 #[test]
865 fn test_event_type_as_str() {
866 assert_eq!(
868 PatchTelemetryEventType::PatchApplied.as_str(),
869 "patch_applied"
870 );
871 assert_eq!(
872 PatchTelemetryEventType::PatchApplyFailed.as_str(),
873 "patch_apply_failed"
874 );
875 assert_eq!(
876 PatchTelemetryEventType::PatchRemoved.as_str(),
877 "patch_removed"
878 );
879 assert_eq!(
880 PatchTelemetryEventType::PatchRemoveFailed.as_str(),
881 "patch_remove_failed"
882 );
883 assert_eq!(
884 PatchTelemetryEventType::PatchRolledBack.as_str(),
885 "patch_rolled_back"
886 );
887 assert_eq!(
888 PatchTelemetryEventType::PatchRollbackFailed.as_str(),
889 "patch_rollback_failed"
890 );
891 assert_eq!(
893 PatchTelemetryEventType::PatchScanned.as_str(),
894 "patch_scanned"
895 );
896 assert_eq!(
897 PatchTelemetryEventType::PatchScanFailed.as_str(),
898 "patch_scan_failed"
899 );
900 assert_eq!(
901 PatchTelemetryEventType::PatchFetched.as_str(),
902 "patch_fetched"
903 );
904 assert_eq!(
905 PatchTelemetryEventType::PatchFetchFailed.as_str(),
906 "patch_fetch_failed"
907 );
908 assert_eq!(
910 PatchTelemetryEventType::PatchListed.as_str(),
911 "patch_listed"
912 );
913 assert_eq!(
914 PatchTelemetryEventType::PatchRepaired.as_str(),
915 "patch_repaired"
916 );
917 assert_eq!(
918 PatchTelemetryEventType::PatchRepairFailed.as_str(),
919 "patch_repair_failed"
920 );
921 assert_eq!(PatchTelemetryEventType::PatchSetup.as_str(), "patch_setup");
922 assert_eq!(
923 PatchTelemetryEventType::PatchUnlocked.as_str(),
924 "patch_unlocked"
925 );
926 assert_eq!(
927 PatchTelemetryEventType::PatchUnlockFailed.as_str(),
928 "patch_unlock_failed"
929 );
930 assert_eq!(
932 PatchTelemetryEventType::VexGenerated.as_str(),
933 "vex_generated"
934 );
935 assert_eq!(PatchTelemetryEventType::VexFailed.as_str(), "vex_failed");
936 }
937
938 #[test]
939 fn test_build_telemetry_context() {
940 let ctx = build_telemetry_context("apply");
941 assert_eq!(ctx.command, "apply");
942 assert_eq!(ctx.version, PACKAGE_VERSION);
943 assert!(!ctx.platform.is_empty());
944 assert!(!ctx.arch.is_empty());
945 }
946
947 #[test]
953 fn test_telemetry_version_tracks_crate_version() {
954 assert_eq!(PACKAGE_VERSION, env!("CARGO_PKG_VERSION"));
955 assert_eq!(
956 build_telemetry_context("apply").version,
957 env!("CARGO_PKG_VERSION")
958 );
959 assert!(
962 PACKAGE_VERSION != "1.0.0" || env!("CARGO_PKG_VERSION") == "1.0.0",
963 "telemetry version is still hardcoded to the stale 1.0.0 literal"
964 );
965 }
966
967 #[test]
968 fn test_build_telemetry_event_basic() {
969 let options = TrackPatchEventOptions {
970 event_type: PatchTelemetryEventType::PatchApplied,
971 command: "apply".to_string(),
972 metadata: None,
973 error: None,
974 api_token: None,
975 org_slug: None,
976 };
977
978 let event = build_telemetry_event(&options);
979 assert_eq!(event.event_type, "patch_applied");
980 assert_eq!(event.context.command, "apply");
981 assert!(!event.session_id.is_empty());
982 assert!(!event.event_sender_created_at.is_empty());
983 assert!(event.metadata.is_none());
984 assert!(event.error.is_none());
985 }
986
987 #[test]
988 fn test_build_telemetry_event_with_metadata() {
989 let mut metadata = HashMap::new();
990 metadata.insert(
991 "patches_count".to_string(),
992 serde_json::Value::Number(5.into()),
993 );
994
995 let options = TrackPatchEventOptions {
996 event_type: PatchTelemetryEventType::PatchApplied,
997 command: "apply".to_string(),
998 metadata: Some(metadata),
999 error: None,
1000 api_token: None,
1001 org_slug: None,
1002 };
1003
1004 let event = build_telemetry_event(&options);
1005 assert!(event.metadata.is_some());
1006 let meta = event.metadata.unwrap();
1007 assert_eq!(
1008 meta.get("patches_count").unwrap(),
1009 &serde_json::Value::Number(5.into())
1010 );
1011 }
1012
1013 #[test]
1014 fn test_build_telemetry_event_with_error() {
1015 let options = TrackPatchEventOptions {
1016 event_type: PatchTelemetryEventType::PatchApplyFailed,
1017 command: "apply".to_string(),
1018 metadata: None,
1019 error: Some(("IoError".to_string(), "file not found".to_string())),
1020 api_token: None,
1021 org_slug: None,
1022 };
1023
1024 let event = build_telemetry_event(&options);
1025 assert!(event.error.is_some());
1026 let err = event.error.unwrap();
1027 assert_eq!(err.error_type, "IoError");
1028 assert_eq!(err.message.unwrap(), "file not found");
1029 }
1030
1031 #[test]
1032 fn test_session_id_is_consistent() {
1033 let id1 = SESSION_ID.clone();
1034 let id2 = SESSION_ID.clone();
1035 assert_eq!(id1, id2);
1036 assert_eq!(id1.len(), 36);
1038 assert!(id1.contains('-'));
1039 }
1040
1041 #[test]
1042 fn test_chrono_now_iso_format() {
1043 let ts = chrono_now_iso();
1044 assert!(ts.ends_with('Z'));
1046 assert!(ts.contains('T'));
1047 assert!(ts.contains('-'));
1048 assert!(ts.contains(':'));
1049 assert_eq!(ts.len(), 24); }
1051
1052 #[test]
1053 fn test_days_to_ymd_epoch() {
1054 let (y, m, d) = days_to_ymd(0);
1055 assert_eq!((y, m, d), (1970, 1, 1));
1056 }
1057
1058 #[test]
1059 fn test_days_to_ymd_known_date() {
1060 let (y, m, d) = days_to_ymd(19723);
1062 assert_eq!((y, m, d), (2024, 1, 1));
1063 }
1064}