Skip to main content

socket_patch_core/utils/
telemetry.rs

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
9// ---------------------------------------------------------------------------
10// Session ID — generated once per process invocation
11// ---------------------------------------------------------------------------
12
13/// Unique session ID for the current CLI invocation.
14/// Shared across all telemetry events in a single run.
15static SESSION_ID: Lazy<String> = Lazy::new(|| Uuid::new_v4().to_string());
16
17/// Package version — sourced from the crate's `Cargo.toml` at build time so
18/// it always tracks the real release (matching `USER_AGENT` in `constants.rs`
19/// and the `vex` tooling string). A hardcoded literal here silently drifts
20/// from the published version.
21const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
22
23// ---------------------------------------------------------------------------
24// Types
25// ---------------------------------------------------------------------------
26
27/// Telemetry event types for the patch lifecycle.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum PatchTelemetryEventType {
30    // Write-side: apply / remove / rollback
31    PatchApplied,
32    PatchApplyFailed,
33    PatchRemoved,
34    PatchRemoveFailed,
35    PatchRolledBack,
36    PatchRollbackFailed,
37    // Read-side: scan / get (get is internally "fetch")
38    PatchScanned,
39    PatchScanFailed,
40    PatchFetched,
41    PatchFetchFailed,
42    // Inspection / housekeeping
43    PatchListed,
44    PatchRepaired,
45    PatchRepairFailed,
46    PatchSetup,
47    PatchUnlocked,
48    PatchUnlockFailed,
49    // OpenVEX attestation (added in #81)
50    VexGenerated,
51    VexFailed,
52}
53
54impl PatchTelemetryEventType {
55    /// Return the wire-format string for this event type.
56    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/// Telemetry context describing the execution environment.
81#[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/// Error details for telemetry events.
90#[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/// Telemetry event structure for patch operations.
98#[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
110/// Options for tracking a patch event.
111pub struct TrackPatchEventOptions {
112    /// The type of event being tracked.
113    pub event_type: PatchTelemetryEventType,
114    /// The CLI command being executed (e.g., "apply", "remove", "rollback").
115    pub command: String,
116    /// Optional metadata to include with the event.
117    pub metadata: Option<HashMap<String, serde_json::Value>>,
118    /// Optional error information if the operation failed.
119    /// Tuple of (error_type, message).
120    pub error: Option<(String, String)>,
121    /// Optional API token for authenticated telemetry endpoint.
122    pub api_token: Option<String>,
123    /// Optional organization slug for authenticated telemetry endpoint.
124    pub org_slug: Option<String>,
125}
126
127// ---------------------------------------------------------------------------
128// Environment checks
129// ---------------------------------------------------------------------------
130
131/// Check if telemetry is disabled via environment variables.
132///
133/// Telemetry is disabled when:
134/// - `SOCKET_TELEMETRY_DISABLED` is `"1"` or `"true"`
135///   (legacy `SOCKET_PATCH_TELEMETRY_DISABLED` still honored with warning)
136/// - `VITEST` is `"true"` (test environment)
137/// - `SOCKET_OFFLINE` is `"1"` or `"true"` (airgap mode — the telemetry
138///   endpoint is a network call, so honoring `--offline`/`SOCKET_OFFLINE`
139///   here keeps every command compliant with the strict-airgap contract)
140///
141/// Note that the CLI also exposes a `--no-telemetry` flag; when that flag
142/// is set the CLI dispatcher sets `SOCKET_TELEMETRY_DISABLED=1` for the
143/// duration of the process so this check stays the single source of truth.
144pub 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
159/// Check if debug mode is enabled. Reads `SOCKET_DEBUG` (with legacy
160/// `SOCKET_PATCH_DEBUG` shim).
161fn 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
170/// Log debug messages when debug mode is enabled.
171fn debug_log(message: &str) {
172    if is_debug_enabled() {
173        eprintln!("[socket-patch telemetry] {message}");
174    }
175}
176
177// ---------------------------------------------------------------------------
178// Build event
179// ---------------------------------------------------------------------------
180
181/// Build the telemetry context for the current environment.
182fn 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
191/// Sanitize an error message for telemetry.
192///
193/// Replaces the user's home directory path with `~` to avoid leaking
194/// sensitive file system information.
195pub 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
204/// Get the home directory as a string.
205fn home_dir_string() -> Option<String> {
206    std::env::var("HOME")
207        .ok()
208        .or_else(|| std::env::var("USERPROFILE").ok())
209}
210
211/// Build a telemetry event from the given options.
212fn 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
231/// Get the current time as an ISO 8601 string.
232fn 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
251/// Convert days since Unix epoch to (year, month, day).
252fn days_to_ymd(days: u64) -> (u64, u64, u64) {
253    // Adapted from Howard Hinnant's civil_from_days algorithm
254    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
267// ---------------------------------------------------------------------------
268// Send event
269// ---------------------------------------------------------------------------
270
271/// Send a telemetry event to the API.
272///
273/// This is fire-and-forget: errors are logged in debug mode but never
274/// propagated. Uses `reqwest` with a 5-second timeout.
275async 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
332// ---------------------------------------------------------------------------
333// Public API
334// ---------------------------------------------------------------------------
335
336/// Track a patch lifecycle event.
337///
338/// This function is non-blocking and will never return errors. Telemetry
339/// failures are logged in debug mode but do not affect CLI operation.
340///
341/// If telemetry is disabled (via environment variables), the function returns
342/// immediately.
343pub 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
358// ---------------------------------------------------------------------------
359// Convenience functions
360//
361// These accept `Option<&str>` for api_token/org_slug to make call sites
362// convenient (callers typically have `Option<String>` and call `.as_deref()`).
363// ---------------------------------------------------------------------------
364
365/// Convert a `serde_json::json!({...})` object into the `HashMap` that
366/// [`TrackPatchEventOptions::metadata`] expects, swallowing the conversion
367/// to avoid `.unwrap()` noise at every call site.
368fn 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
381/// Shared fire-and-forget helper for the per-event tracker wrappers below.
382/// Centralizes the `String::from` plumbing for the four optional fields
383/// that every tracker shares.
384async 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
403/// Track a successful patch application.
404pub 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
421/// Track a failed patch application.
422///
423/// Accepts any `Display` type for the error (works with `&str`, `String`,
424/// `anyhow::Error`, `std::io::Error`, etc.).
425pub 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
442/// Track a successful patch removal.
443pub 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
459/// Track a failed patch removal. Accepts any `Display` type for the error.
460pub 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
476/// Track a successful patch rollback.
477pub 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
493/// Track a failed patch rollback. Accepts any `Display` type for the error.
494pub 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// ---------------------------------------------------------------------------
511// Read-side trackers: scan + get
512// ---------------------------------------------------------------------------
513
514/// Track a successful `scan`. Reports per-tier patch counts and whether
515/// the call was downgraded to the public proxy after an auth-endpoint
516/// 401/403 (`fallback_to_proxy`).
517///
518/// The argument count intentionally mirrors the metadata fields the
519/// dashboard needs — grouping them into a struct would force callers
520/// to build a config object for a single fire-and-forget call, which
521/// is worse ergonomics for a tracker. `track_patch_event` is the
522/// general path when you need that flexibility.
523#[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
552/// Track a failed `scan`.
553pub 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
570/// Track a successful `get`. Reports patch identity + delivery mode and
571/// whether the call was downgraded to the public proxy after an
572/// auth-endpoint 401/403.
573pub 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
599/// Track a failed `get`. `uuid` may be empty when the failure occurred
600/// before the patch was resolved (e.g. lookup miss).
601pub 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
619// ---------------------------------------------------------------------------
620// Inspection / housekeeping trackers: list / repair / setup / unlock
621// ---------------------------------------------------------------------------
622
623/// Track a successful `list`. Reports the number of patches surfaced.
624pub 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
640/// Track a successful `repair`. Reports blob deltas and bytes freed.
641pub 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
663/// Track a failed `repair`.
664pub 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
680/// Track a successful `setup`. Reports the detected package manager so
681/// we can tell which install hooks are exercised in the wild.
682pub 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
694/// Track a successful `unlock`. `was_held` indicates whether another
695/// process was holding the lock at probe time; `released` is true when
696/// `--release` actually removed the lock file (vs. the inspect-only case).
697pub 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
714/// Track a failed `unlock`.
715pub 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
731// ---------------------------------------------------------------------------
732// OpenVEX trackers
733// ---------------------------------------------------------------------------
734
735/// Track a successful `vex` generation. `format` is e.g. `"openvex-0.2.0"`;
736/// `output_kind` describes where the document went (`"stdout"`, `"file"`).
737pub 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
759/// Track a failed `vex` generation.
760pub 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    /// Combined into a single test to avoid env-var races across parallel tests.
781    /// Exercises the `SOCKET_TELEMETRY_DISABLED` name, the legacy
782    /// `SOCKET_PATCH_TELEMETRY_DISABLED` shim, and the airgap gate via
783    /// `SOCKET_OFFLINE`.
784    #[test]
785    fn test_is_telemetry_disabled() {
786        // Save originals
787        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        // Default: not disabled
793        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        // Disabled via new var "1"
800        std::env::set_var("SOCKET_TELEMETRY_DISABLED", "1");
801        assert!(is_telemetry_disabled());
802        std::env::remove_var("SOCKET_TELEMETRY_DISABLED");
803
804        // Disabled via legacy var (with deprecation warning)
805        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        // Disabled via airgap: SOCKET_OFFLINE=1 implies "no network",
812        // which includes the telemetry endpoint.
813        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        // Non-truthy values do not disable
824        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        // Restore originals
831        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        // Write-side
867        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        // Read-side
892        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        // Inspection / housekeeping
909        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        // OpenVEX
931        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    /// Regression: the reported version must track the real crate version,
948    /// not a hardcoded literal that drifts from the published release.
949    /// Anchoring on `CARGO_PKG_VERSION` (rather than the `PACKAGE_VERSION`
950    /// const) is deliberate — comparing the context against the same const it
951    /// is built from is self-referential and can never catch a stale value.
952    #[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        // The previously-hardcoded literal must never reappear unless the crate
960        // is genuinely at that version.
961        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        // Should be a valid UUID v4 format
1037        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        // Should look like "2024-01-15T10:30:45.123Z"
1045        assert!(ts.ends_with('Z'));
1046        assert!(ts.contains('T'));
1047        assert!(ts.contains('-'));
1048        assert!(ts.contains(':'));
1049        assert_eq!(ts.len(), 24); // YYYY-MM-DDTHH:MM:SS.mmmZ
1050    }
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        // 2024-01-01 is day 19723
1061        let (y, m, d) = days_to_ymd(19723);
1062        assert_eq!((y, m, d), (2024, 1, 1));
1063    }
1064}