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 — updated during build.
18const PACKAGE_VERSION: &str = "1.0.0";
19
20// ---------------------------------------------------------------------------
21// Types
22// ---------------------------------------------------------------------------
23
24/// Telemetry event types for the patch lifecycle.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum PatchTelemetryEventType {
27    // Write-side: apply / remove / rollback
28    PatchApplied,
29    PatchApplyFailed,
30    PatchRemoved,
31    PatchRemoveFailed,
32    PatchRolledBack,
33    PatchRollbackFailed,
34    // Read-side: scan / get (get is internally "fetch")
35    PatchScanned,
36    PatchScanFailed,
37    PatchFetched,
38    PatchFetchFailed,
39    // Inspection / housekeeping
40    PatchListed,
41    PatchRepaired,
42    PatchRepairFailed,
43    PatchSetup,
44    PatchUnlocked,
45    PatchUnlockFailed,
46    // OpenVEX attestation (added in #81)
47    VexGenerated,
48    VexFailed,
49}
50
51impl PatchTelemetryEventType {
52    /// Return the wire-format string for this event type.
53    pub fn as_str(&self) -> &'static str {
54        match self {
55            Self::PatchApplied => "patch_applied",
56            Self::PatchApplyFailed => "patch_apply_failed",
57            Self::PatchRemoved => "patch_removed",
58            Self::PatchRemoveFailed => "patch_remove_failed",
59            Self::PatchRolledBack => "patch_rolled_back",
60            Self::PatchRollbackFailed => "patch_rollback_failed",
61            Self::PatchScanned => "patch_scanned",
62            Self::PatchScanFailed => "patch_scan_failed",
63            Self::PatchFetched => "patch_fetched",
64            Self::PatchFetchFailed => "patch_fetch_failed",
65            Self::PatchListed => "patch_listed",
66            Self::PatchRepaired => "patch_repaired",
67            Self::PatchRepairFailed => "patch_repair_failed",
68            Self::PatchSetup => "patch_setup",
69            Self::PatchUnlocked => "patch_unlocked",
70            Self::PatchUnlockFailed => "patch_unlock_failed",
71            Self::VexGenerated => "vex_generated",
72            Self::VexFailed => "vex_failed",
73        }
74    }
75}
76
77/// Telemetry context describing the execution environment.
78#[derive(Debug, Clone, serde::Serialize)]
79pub struct PatchTelemetryContext {
80    pub version: String,
81    pub platform: String,
82    pub arch: String,
83    pub command: String,
84}
85
86/// Error details for telemetry events.
87#[derive(Debug, Clone, serde::Serialize)]
88pub struct PatchTelemetryError {
89    #[serde(rename = "type")]
90    pub error_type: String,
91    pub message: Option<String>,
92}
93
94/// Telemetry event structure for patch operations.
95#[derive(Debug, Clone, serde::Serialize)]
96pub struct PatchTelemetryEvent {
97    pub event_sender_created_at: String,
98    pub event_type: String,
99    pub context: PatchTelemetryContext,
100    pub session_id: String,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub metadata: Option<HashMap<String, serde_json::Value>>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub error: Option<PatchTelemetryError>,
105}
106
107/// Options for tracking a patch event.
108pub struct TrackPatchEventOptions {
109    /// The type of event being tracked.
110    pub event_type: PatchTelemetryEventType,
111    /// The CLI command being executed (e.g., "apply", "remove", "rollback").
112    pub command: String,
113    /// Optional metadata to include with the event.
114    pub metadata: Option<HashMap<String, serde_json::Value>>,
115    /// Optional error information if the operation failed.
116    /// Tuple of (error_type, message).
117    pub error: Option<(String, String)>,
118    /// Optional API token for authenticated telemetry endpoint.
119    pub api_token: Option<String>,
120    /// Optional organization slug for authenticated telemetry endpoint.
121    pub org_slug: Option<String>,
122}
123
124// ---------------------------------------------------------------------------
125// Environment checks
126// ---------------------------------------------------------------------------
127
128/// Check if telemetry is disabled via environment variables.
129///
130/// Telemetry is disabled when:
131/// - `SOCKET_TELEMETRY_DISABLED` is `"1"` or `"true"`
132///   (legacy `SOCKET_PATCH_TELEMETRY_DISABLED` still honored with warning)
133/// - `VITEST` is `"true"` (test environment)
134/// - `SOCKET_OFFLINE` is `"1"` or `"true"` (airgap mode — the telemetry
135///   endpoint is a network call, so honoring `--offline`/`SOCKET_OFFLINE`
136///   here keeps every command compliant with the strict-airgap contract)
137///
138/// Note that the CLI also exposes a `--no-telemetry` flag; when that flag
139/// is set the CLI dispatcher sets `SOCKET_TELEMETRY_DISABLED=1` for the
140/// duration of the process so this check stays the single source of truth.
141pub fn is_telemetry_disabled() -> bool {
142    let env_value =
143        read_env_with_legacy("SOCKET_TELEMETRY_DISABLED", "SOCKET_PATCH_TELEMETRY_DISABLED")
144            .unwrap_or_default();
145    let disabled_via_env = matches!(env_value.as_str(), "1" | "true");
146    let vitest = std::env::var("VITEST").unwrap_or_default() == "true";
147    let offline = matches!(
148        std::env::var("SOCKET_OFFLINE").unwrap_or_default().as_str(),
149        "1" | "true"
150    );
151    disabled_via_env || vitest || offline
152}
153
154/// Check if debug mode is enabled. Reads `SOCKET_DEBUG` (with legacy
155/// `SOCKET_PATCH_DEBUG` shim).
156fn is_debug_enabled() -> bool {
157    matches!(
158        read_env_with_legacy("SOCKET_DEBUG", "SOCKET_PATCH_DEBUG")
159            .unwrap_or_default()
160            .as_str(),
161        "1" | "true"
162    )
163}
164
165/// Log debug messages when debug mode is enabled.
166fn debug_log(message: &str) {
167    if is_debug_enabled() {
168        eprintln!("[socket-patch telemetry] {message}");
169    }
170}
171
172// ---------------------------------------------------------------------------
173// Build event
174// ---------------------------------------------------------------------------
175
176/// Build the telemetry context for the current environment.
177fn build_telemetry_context(command: &str) -> PatchTelemetryContext {
178    PatchTelemetryContext {
179        version: PACKAGE_VERSION.to_string(),
180        platform: std::env::consts::OS.to_string(),
181        arch: std::env::consts::ARCH.to_string(),
182        command: command.to_string(),
183    }
184}
185
186/// Sanitize an error message for telemetry.
187///
188/// Replaces the user's home directory path with `~` to avoid leaking
189/// sensitive file system information.
190pub fn sanitize_error_message(message: &str) -> String {
191    if let Some(home) = home_dir_string() {
192        if !home.is_empty() {
193            return message.replace(&home, "~");
194        }
195    }
196    message.to_string()
197}
198
199/// Get the home directory as a string.
200fn home_dir_string() -> Option<String> {
201    std::env::var("HOME")
202        .ok()
203        .or_else(|| std::env::var("USERPROFILE").ok())
204}
205
206/// Build a telemetry event from the given options.
207fn build_telemetry_event(options: &TrackPatchEventOptions) -> PatchTelemetryEvent {
208    let error = options.error.as_ref().map(|(error_type, message)| {
209        PatchTelemetryError {
210            error_type: error_type.clone(),
211            message: Some(sanitize_error_message(message)),
212        }
213    });
214
215    PatchTelemetryEvent {
216        event_sender_created_at: chrono_now_iso(),
217        event_type: options.event_type.as_str().to_string(),
218        context: build_telemetry_context(&options.command),
219        session_id: SESSION_ID.clone(),
220        metadata: options.metadata.clone(),
221        error,
222    }
223}
224
225/// Get the current time as an ISO 8601 string.
226fn chrono_now_iso() -> String {
227    let now = std::time::SystemTime::now();
228    let duration = now
229        .duration_since(std::time::UNIX_EPOCH)
230        .unwrap_or_default();
231    let secs = duration.as_secs();
232
233    let days = secs / 86400;
234    let remaining = secs % 86400;
235    let hours = remaining / 3600;
236    let minutes = (remaining % 3600) / 60;
237    let seconds = remaining % 60;
238    let millis = duration.subsec_millis();
239
240    let (year, month, day) = days_to_ymd(days);
241
242    format!(
243        "{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{millis:03}Z"
244    )
245}
246
247/// Convert days since Unix epoch to (year, month, day).
248fn days_to_ymd(days: u64) -> (u64, u64, u64) {
249    // Adapted from Howard Hinnant's civil_from_days algorithm
250    let z = days as i64 + 719468;
251    let era = if z >= 0 { z } else { z - 146096 } / 146097;
252    let doe = (z - era * 146097) as u64;
253    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
254    let y = yoe as i64 + era * 400;
255    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
256    let mp = (5 * doy + 2) / 153;
257    let d = doy - (153 * mp + 2) / 5 + 1;
258    let m = if mp < 10 { mp + 3 } else { mp - 9 };
259    let y = if m <= 2 { y + 1 } else { y };
260    (y as u64, m, d)
261}
262
263// ---------------------------------------------------------------------------
264// Send event
265// ---------------------------------------------------------------------------
266
267/// Send a telemetry event to the API.
268///
269/// This is fire-and-forget: errors are logged in debug mode but never
270/// propagated. Uses `reqwest` with a 5-second timeout.
271async fn send_telemetry_event(
272    event: &PatchTelemetryEvent,
273    api_token: Option<&str>,
274    org_slug: Option<&str>,
275) {
276    let (url, use_auth) = match (api_token, org_slug) {
277        (Some(_token), Some(slug)) => {
278            let api_url = std::env::var("SOCKET_API_URL")
279                .unwrap_or_else(|_| DEFAULT_SOCKET_API_URL.to_string());
280            (format!("{api_url}/v0/orgs/{slug}/telemetry"), true)
281        }
282        _ => {
283            let proxy_url =
284                read_env_with_legacy("SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL")
285                    .unwrap_or_else(|| DEFAULT_PATCH_API_PROXY_URL.to_string());
286            (format!("{proxy_url}/patch/telemetry"), false)
287        }
288    };
289
290    debug_log(&format!("Sending telemetry to {url}"));
291
292    let client = match reqwest::Client::builder()
293        .timeout(std::time::Duration::from_secs(5))
294        .build()
295    {
296        Ok(c) => c,
297        Err(e) => {
298            debug_log(&format!("Failed to build HTTP client: {e}"));
299            return;
300        }
301    };
302
303    let mut request = client
304        .post(&url)
305        .header("Content-Type", "application/json")
306        .header("User-Agent", USER_AGENT);
307
308    if use_auth {
309        if let Some(token) = api_token {
310            request = request.header("Authorization", format!("Bearer {token}"));
311        }
312    }
313
314    match request.json(event).send().await {
315        Ok(response) => {
316            let status = response.status();
317            if status.is_success() {
318                debug_log("Telemetry sent successfully");
319            } else {
320                debug_log(&format!("Telemetry request returned status {status}"));
321            }
322        }
323        Err(e) => {
324            debug_log(&format!("Telemetry request failed: {e}"));
325        }
326    }
327}
328
329// ---------------------------------------------------------------------------
330// Public API
331// ---------------------------------------------------------------------------
332
333/// Track a patch lifecycle event.
334///
335/// This function is non-blocking and will never return errors. Telemetry
336/// failures are logged in debug mode but do not affect CLI operation.
337///
338/// If telemetry is disabled (via environment variables), the function returns
339/// immediately.
340pub async fn track_patch_event(options: TrackPatchEventOptions) {
341    if is_telemetry_disabled() {
342        debug_log("Telemetry is disabled, skipping event");
343        return;
344    }
345
346    let event = build_telemetry_event(&options);
347    send_telemetry_event(
348        &event,
349        options.api_token.as_deref(),
350        options.org_slug.as_deref(),
351    )
352    .await;
353}
354
355// ---------------------------------------------------------------------------
356// Convenience functions
357//
358// These accept `Option<&str>` for api_token/org_slug to make call sites
359// convenient (callers typically have `Option<String>` and call `.as_deref()`).
360// ---------------------------------------------------------------------------
361
362/// Track a successful patch application.
363pub async fn track_patch_applied(
364    patches_count: usize,
365    dry_run: bool,
366    api_token: Option<&str>,
367    org_slug: Option<&str>,
368) {
369    let mut metadata = HashMap::new();
370    metadata.insert(
371        "patches_count".to_string(),
372        serde_json::Value::Number(serde_json::Number::from(patches_count)),
373    );
374    metadata.insert("dry_run".to_string(), serde_json::Value::Bool(dry_run));
375
376    track_patch_event(TrackPatchEventOptions {
377        event_type: PatchTelemetryEventType::PatchApplied,
378        command: "apply".to_string(),
379        metadata: Some(metadata),
380        error: None,
381        api_token: api_token.map(|s| s.to_string()),
382        org_slug: org_slug.map(|s| s.to_string()),
383    })
384    .await;
385}
386
387/// Track a failed patch application.
388///
389/// Accepts any `Display` type for the error (works with `&str`, `String`,
390/// `anyhow::Error`, `std::io::Error`, etc.).
391pub async fn track_patch_apply_failed(
392    error: impl std::fmt::Display,
393    dry_run: bool,
394    api_token: Option<&str>,
395    org_slug: Option<&str>,
396) {
397    let mut metadata = HashMap::new();
398    metadata.insert("dry_run".to_string(), serde_json::Value::Bool(dry_run));
399
400    track_patch_event(TrackPatchEventOptions {
401        event_type: PatchTelemetryEventType::PatchApplyFailed,
402        command: "apply".to_string(),
403        metadata: Some(metadata),
404        error: Some(("Error".to_string(), error.to_string())),
405        api_token: api_token.map(|s| s.to_string()),
406        org_slug: org_slug.map(|s| s.to_string()),
407    })
408    .await;
409}
410
411/// Track a successful patch removal.
412pub async fn track_patch_removed(
413    removed_count: usize,
414    api_token: Option<&str>,
415    org_slug: Option<&str>,
416) {
417    let mut metadata = HashMap::new();
418    metadata.insert(
419        "removed_count".to_string(),
420        serde_json::Value::Number(serde_json::Number::from(removed_count)),
421    );
422
423    track_patch_event(TrackPatchEventOptions {
424        event_type: PatchTelemetryEventType::PatchRemoved,
425        command: "remove".to_string(),
426        metadata: Some(metadata),
427        error: None,
428        api_token: api_token.map(|s| s.to_string()),
429        org_slug: org_slug.map(|s| s.to_string()),
430    })
431    .await;
432}
433
434/// Track a failed patch removal.
435///
436/// Accepts any `Display` type for the error.
437pub async fn track_patch_remove_failed(
438    error: impl std::fmt::Display,
439    api_token: Option<&str>,
440    org_slug: Option<&str>,
441) {
442    track_patch_event(TrackPatchEventOptions {
443        event_type: PatchTelemetryEventType::PatchRemoveFailed,
444        command: "remove".to_string(),
445        metadata: None,
446        error: Some(("Error".to_string(), error.to_string())),
447        api_token: api_token.map(|s| s.to_string()),
448        org_slug: org_slug.map(|s| s.to_string()),
449    })
450    .await;
451}
452
453/// Track a successful patch rollback.
454pub async fn track_patch_rolled_back(
455    rolled_back_count: usize,
456    api_token: Option<&str>,
457    org_slug: Option<&str>,
458) {
459    let mut metadata = HashMap::new();
460    metadata.insert(
461        "rolled_back_count".to_string(),
462        serde_json::Value::Number(serde_json::Number::from(rolled_back_count)),
463    );
464
465    track_patch_event(TrackPatchEventOptions {
466        event_type: PatchTelemetryEventType::PatchRolledBack,
467        command: "rollback".to_string(),
468        metadata: Some(metadata),
469        error: None,
470        api_token: api_token.map(|s| s.to_string()),
471        org_slug: org_slug.map(|s| s.to_string()),
472    })
473    .await;
474}
475
476/// Track a failed patch rollback.
477///
478/// Accepts any `Display` type for the error.
479pub async fn track_patch_rollback_failed(
480    error: impl std::fmt::Display,
481    api_token: Option<&str>,
482    org_slug: Option<&str>,
483) {
484    track_patch_event(TrackPatchEventOptions {
485        event_type: PatchTelemetryEventType::PatchRollbackFailed,
486        command: "rollback".to_string(),
487        metadata: None,
488        error: Some(("Error".to_string(), error.to_string())),
489        api_token: api_token.map(|s| s.to_string()),
490        org_slug: org_slug.map(|s| s.to_string()),
491    })
492    .await;
493}
494
495// ---------------------------------------------------------------------------
496// Read-side trackers: scan + get
497// ---------------------------------------------------------------------------
498
499/// Track a successful `scan`. Reports per-tier patch counts and whether
500/// the call was downgraded to the public proxy after an auth-endpoint
501/// 401/403 (`fallback_to_proxy`).
502///
503/// The argument count intentionally mirrors the metadata fields the
504/// dashboard needs — grouping them into a struct would force callers
505/// to build a config object for a single fire-and-forget call, which
506/// is worse ergonomics for a tracker. `track_patch_event` is the
507/// general path when you need that flexibility.
508#[allow(clippy::too_many_arguments)]
509pub async fn track_patch_scanned(
510    packages_scanned: usize,
511    free_patches: usize,
512    paid_patches: usize,
513    can_access_paid: bool,
514    ecosystems: &[String],
515    fallback_to_proxy: bool,
516    api_token: Option<&str>,
517    org_slug: Option<&str>,
518) {
519    let mut metadata = HashMap::new();
520    metadata.insert(
521        "packages_scanned".to_string(),
522        serde_json::Value::Number(serde_json::Number::from(packages_scanned)),
523    );
524    metadata.insert(
525        "free_patches".to_string(),
526        serde_json::Value::Number(serde_json::Number::from(free_patches)),
527    );
528    metadata.insert(
529        "paid_patches".to_string(),
530        serde_json::Value::Number(serde_json::Number::from(paid_patches)),
531    );
532    metadata.insert(
533        "can_access_paid".to_string(),
534        serde_json::Value::Bool(can_access_paid),
535    );
536    metadata.insert(
537        "ecosystems".to_string(),
538        serde_json::Value::Array(
539            ecosystems
540                .iter()
541                .map(|e| serde_json::Value::String(e.clone()))
542                .collect(),
543        ),
544    );
545    metadata.insert(
546        "fallback_to_proxy".to_string(),
547        serde_json::Value::Bool(fallback_to_proxy),
548    );
549
550    track_patch_event(TrackPatchEventOptions {
551        event_type: PatchTelemetryEventType::PatchScanned,
552        command: "scan".to_string(),
553        metadata: Some(metadata),
554        error: None,
555        api_token: api_token.map(|s| s.to_string()),
556        org_slug: org_slug.map(|s| s.to_string()),
557    })
558    .await;
559}
560
561/// Track a failed `scan`.
562pub async fn track_patch_scan_failed(
563    error: impl std::fmt::Display,
564    fallback_to_proxy: bool,
565    api_token: Option<&str>,
566    org_slug: Option<&str>,
567) {
568    let mut metadata = HashMap::new();
569    metadata.insert(
570        "fallback_to_proxy".to_string(),
571        serde_json::Value::Bool(fallback_to_proxy),
572    );
573
574    track_patch_event(TrackPatchEventOptions {
575        event_type: PatchTelemetryEventType::PatchScanFailed,
576        command: "scan".to_string(),
577        metadata: Some(metadata),
578        error: Some(("Error".to_string(), error.to_string())),
579        api_token: api_token.map(|s| s.to_string()),
580        org_slug: org_slug.map(|s| s.to_string()),
581    })
582    .await;
583}
584
585/// Track a successful `get`. Reports patch identity + delivery mode and
586/// whether the call was downgraded to the public proxy after an
587/// auth-endpoint 401/403.
588pub async fn track_patch_fetched(
589    uuid: &str,
590    tier: &str,
591    ecosystem: &str,
592    download_mode: &str,
593    fallback_to_proxy: bool,
594    api_token: Option<&str>,
595    org_slug: Option<&str>,
596) {
597    let mut metadata = HashMap::new();
598    metadata.insert(
599        "uuid".to_string(),
600        serde_json::Value::String(uuid.to_string()),
601    );
602    metadata.insert(
603        "tier".to_string(),
604        serde_json::Value::String(tier.to_string()),
605    );
606    metadata.insert(
607        "ecosystem".to_string(),
608        serde_json::Value::String(ecosystem.to_string()),
609    );
610    metadata.insert(
611        "download_mode".to_string(),
612        serde_json::Value::String(download_mode.to_string()),
613    );
614    metadata.insert(
615        "fallback_to_proxy".to_string(),
616        serde_json::Value::Bool(fallback_to_proxy),
617    );
618
619    track_patch_event(TrackPatchEventOptions {
620        event_type: PatchTelemetryEventType::PatchFetched,
621        command: "get".to_string(),
622        metadata: Some(metadata),
623        error: None,
624        api_token: api_token.map(|s| s.to_string()),
625        org_slug: org_slug.map(|s| s.to_string()),
626    })
627    .await;
628}
629
630/// Track a failed `get`. `uuid` may be empty when the failure occurred
631/// before the patch was resolved (e.g. lookup miss).
632pub async fn track_patch_fetch_failed(
633    uuid: &str,
634    error: impl std::fmt::Display,
635    fallback_to_proxy: bool,
636    api_token: Option<&str>,
637    org_slug: Option<&str>,
638) {
639    let mut metadata = HashMap::new();
640    metadata.insert(
641        "uuid".to_string(),
642        serde_json::Value::String(uuid.to_string()),
643    );
644    metadata.insert(
645        "fallback_to_proxy".to_string(),
646        serde_json::Value::Bool(fallback_to_proxy),
647    );
648
649    track_patch_event(TrackPatchEventOptions {
650        event_type: PatchTelemetryEventType::PatchFetchFailed,
651        command: "get".to_string(),
652        metadata: Some(metadata),
653        error: Some(("Error".to_string(), error.to_string())),
654        api_token: api_token.map(|s| s.to_string()),
655        org_slug: org_slug.map(|s| s.to_string()),
656    })
657    .await;
658}
659
660// ---------------------------------------------------------------------------
661// Inspection / housekeeping trackers: list / repair / setup / unlock
662// ---------------------------------------------------------------------------
663
664/// Track a successful `list`. Reports the number of patches surfaced.
665pub async fn track_patch_listed(
666    patches_count: usize,
667    api_token: Option<&str>,
668    org_slug: Option<&str>,
669) {
670    let mut metadata = HashMap::new();
671    metadata.insert(
672        "patches_count".to_string(),
673        serde_json::Value::Number(serde_json::Number::from(patches_count)),
674    );
675
676    track_patch_event(TrackPatchEventOptions {
677        event_type: PatchTelemetryEventType::PatchListed,
678        command: "list".to_string(),
679        metadata: Some(metadata),
680        error: None,
681        api_token: api_token.map(|s| s.to_string()),
682        org_slug: org_slug.map(|s| s.to_string()),
683    })
684    .await;
685}
686
687/// Track a successful `repair`. Reports blob deltas and bytes freed.
688pub async fn track_patch_repaired(
689    blobs_added: usize,
690    blobs_removed: usize,
691    bytes_freed: u64,
692    api_token: Option<&str>,
693    org_slug: Option<&str>,
694) {
695    let mut metadata = HashMap::new();
696    metadata.insert(
697        "blobs_added".to_string(),
698        serde_json::Value::Number(serde_json::Number::from(blobs_added)),
699    );
700    metadata.insert(
701        "blobs_removed".to_string(),
702        serde_json::Value::Number(serde_json::Number::from(blobs_removed)),
703    );
704    metadata.insert(
705        "bytes_freed".to_string(),
706        serde_json::Value::Number(serde_json::Number::from(bytes_freed)),
707    );
708
709    track_patch_event(TrackPatchEventOptions {
710        event_type: PatchTelemetryEventType::PatchRepaired,
711        command: "repair".to_string(),
712        metadata: Some(metadata),
713        error: None,
714        api_token: api_token.map(|s| s.to_string()),
715        org_slug: org_slug.map(|s| s.to_string()),
716    })
717    .await;
718}
719
720/// Track a failed `repair`.
721pub async fn track_patch_repair_failed(
722    error: impl std::fmt::Display,
723    api_token: Option<&str>,
724    org_slug: Option<&str>,
725) {
726    track_patch_event(TrackPatchEventOptions {
727        event_type: PatchTelemetryEventType::PatchRepairFailed,
728        command: "repair".to_string(),
729        metadata: None,
730        error: Some(("Error".to_string(), error.to_string())),
731        api_token: api_token.map(|s| s.to_string()),
732        org_slug: org_slug.map(|s| s.to_string()),
733    })
734    .await;
735}
736
737/// Track a successful `setup`. Reports the detected package manager so
738/// we can tell which install hooks are exercised in the wild.
739pub async fn track_patch_setup(
740    manager: &str,
741    api_token: Option<&str>,
742    org_slug: Option<&str>,
743) {
744    let mut metadata = HashMap::new();
745    metadata.insert(
746        "manager".to_string(),
747        serde_json::Value::String(manager.to_string()),
748    );
749
750    track_patch_event(TrackPatchEventOptions {
751        event_type: PatchTelemetryEventType::PatchSetup,
752        command: "setup".to_string(),
753        metadata: Some(metadata),
754        error: None,
755        api_token: api_token.map(|s| s.to_string()),
756        org_slug: org_slug.map(|s| s.to_string()),
757    })
758    .await;
759}
760
761/// Track a successful `unlock`. `was_held` indicates whether another
762/// process was holding the lock at probe time; `released` is true when
763/// `--release` actually removed the lock file (vs. the inspect-only case).
764pub async fn track_patch_unlocked(
765    was_held: bool,
766    released: bool,
767    api_token: Option<&str>,
768    org_slug: Option<&str>,
769) {
770    let mut metadata = HashMap::new();
771    metadata.insert("was_held".to_string(), serde_json::Value::Bool(was_held));
772    metadata.insert("released".to_string(), serde_json::Value::Bool(released));
773
774    track_patch_event(TrackPatchEventOptions {
775        event_type: PatchTelemetryEventType::PatchUnlocked,
776        command: "unlock".to_string(),
777        metadata: Some(metadata),
778        error: None,
779        api_token: api_token.map(|s| s.to_string()),
780        org_slug: org_slug.map(|s| s.to_string()),
781    })
782    .await;
783}
784
785/// Track a failed `unlock`.
786pub async fn track_patch_unlock_failed(
787    error: impl std::fmt::Display,
788    api_token: Option<&str>,
789    org_slug: Option<&str>,
790) {
791    track_patch_event(TrackPatchEventOptions {
792        event_type: PatchTelemetryEventType::PatchUnlockFailed,
793        command: "unlock".to_string(),
794        metadata: None,
795        error: Some(("Error".to_string(), error.to_string())),
796        api_token: api_token.map(|s| s.to_string()),
797        org_slug: org_slug.map(|s| s.to_string()),
798    })
799    .await;
800}
801
802// ---------------------------------------------------------------------------
803// OpenVEX trackers
804// ---------------------------------------------------------------------------
805
806/// Track a successful `vex` generation. `format` is e.g. `"openvex-0.2.0"`;
807/// `output_kind` describes where the document went (`"stdout"`, `"file"`).
808pub async fn track_vex_generated(
809    advisories_count: usize,
810    format: &str,
811    output_kind: &str,
812    api_token: Option<&str>,
813    org_slug: Option<&str>,
814) {
815    let mut metadata = HashMap::new();
816    metadata.insert(
817        "advisories_count".to_string(),
818        serde_json::Value::Number(serde_json::Number::from(advisories_count)),
819    );
820    metadata.insert(
821        "format".to_string(),
822        serde_json::Value::String(format.to_string()),
823    );
824    metadata.insert(
825        "output_kind".to_string(),
826        serde_json::Value::String(output_kind.to_string()),
827    );
828
829    track_patch_event(TrackPatchEventOptions {
830        event_type: PatchTelemetryEventType::VexGenerated,
831        command: "vex".to_string(),
832        metadata: Some(metadata),
833        error: None,
834        api_token: api_token.map(|s| s.to_string()),
835        org_slug: org_slug.map(|s| s.to_string()),
836    })
837    .await;
838}
839
840/// Track a failed `vex` generation.
841pub async fn track_vex_failed(
842    error: impl std::fmt::Display,
843    api_token: Option<&str>,
844    org_slug: Option<&str>,
845) {
846    track_patch_event(TrackPatchEventOptions {
847        event_type: PatchTelemetryEventType::VexFailed,
848        command: "vex".to_string(),
849        metadata: None,
850        error: Some(("Error".to_string(), error.to_string())),
851        api_token: api_token.map(|s| s.to_string()),
852        org_slug: org_slug.map(|s| s.to_string()),
853    })
854    .await;
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860
861    /// Combined into a single test to avoid env-var races across parallel tests.
862    /// Exercises the `SOCKET_TELEMETRY_DISABLED` name, the legacy
863    /// `SOCKET_PATCH_TELEMETRY_DISABLED` shim, and the airgap gate via
864    /// `SOCKET_OFFLINE`.
865    #[test]
866    fn test_is_telemetry_disabled() {
867        // Save originals
868        let orig_new = std::env::var("SOCKET_TELEMETRY_DISABLED").ok();
869        let orig_legacy = std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED").ok();
870        let orig_vitest = std::env::var("VITEST").ok();
871        let orig_offline = std::env::var("SOCKET_OFFLINE").ok();
872
873        // Default: not disabled
874        std::env::remove_var("SOCKET_TELEMETRY_DISABLED");
875        std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED");
876        std::env::remove_var("VITEST");
877        std::env::remove_var("SOCKET_OFFLINE");
878        assert!(!is_telemetry_disabled());
879
880        // Disabled via new var "1"
881        std::env::set_var("SOCKET_TELEMETRY_DISABLED", "1");
882        assert!(is_telemetry_disabled());
883        std::env::remove_var("SOCKET_TELEMETRY_DISABLED");
884
885        // Disabled via legacy var (with deprecation warning)
886        std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "1");
887        assert!(is_telemetry_disabled());
888        std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "true");
889        assert!(is_telemetry_disabled());
890        std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED");
891
892        // Disabled via airgap: SOCKET_OFFLINE=1 implies "no network",
893        // which includes the telemetry endpoint.
894        std::env::set_var("SOCKET_OFFLINE", "1");
895        assert!(
896            is_telemetry_disabled(),
897            "SOCKET_OFFLINE=1 must disable telemetry (airgap)"
898        );
899        std::env::set_var("SOCKET_OFFLINE", "true");
900        assert!(
901            is_telemetry_disabled(),
902            "SOCKET_OFFLINE=true must disable telemetry (airgap)"
903        );
904        // Non-truthy values do not disable
905        std::env::set_var("SOCKET_OFFLINE", "0");
906        assert!(!is_telemetry_disabled());
907        std::env::set_var("SOCKET_OFFLINE", "");
908        assert!(!is_telemetry_disabled());
909        std::env::remove_var("SOCKET_OFFLINE");
910
911        // Restore originals
912        match orig_new {
913            Some(v) => std::env::set_var("SOCKET_TELEMETRY_DISABLED", v),
914            None => std::env::remove_var("SOCKET_TELEMETRY_DISABLED"),
915        }
916        match orig_legacy {
917            Some(v) => std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", v),
918            None => std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"),
919        }
920        match orig_vitest {
921            Some(v) => std::env::set_var("VITEST", v),
922            None => std::env::remove_var("VITEST"),
923        }
924        match orig_offline {
925            Some(v) => std::env::set_var("SOCKET_OFFLINE", v),
926            None => std::env::remove_var("SOCKET_OFFLINE"),
927        }
928    }
929
930    #[test]
931    fn test_sanitize_error_message() {
932        let home = home_dir_string().unwrap_or_else(|| "/home/testuser".to_string());
933        let msg = format!("Failed to read {home}/projects/secret/file.txt");
934        let sanitized = sanitize_error_message(&msg);
935        assert!(sanitized.contains("~/projects/secret/file.txt"));
936        assert!(!sanitized.contains(&home));
937    }
938
939    #[test]
940    fn test_sanitize_error_message_no_home() {
941        let msg = "Some error without paths";
942        assert_eq!(sanitize_error_message(msg), msg);
943    }
944
945    #[test]
946    fn test_event_type_as_str() {
947        // Write-side
948        assert_eq!(PatchTelemetryEventType::PatchApplied.as_str(), "patch_applied");
949        assert_eq!(
950            PatchTelemetryEventType::PatchApplyFailed.as_str(),
951            "patch_apply_failed"
952        );
953        assert_eq!(PatchTelemetryEventType::PatchRemoved.as_str(), "patch_removed");
954        assert_eq!(
955            PatchTelemetryEventType::PatchRemoveFailed.as_str(),
956            "patch_remove_failed"
957        );
958        assert_eq!(
959            PatchTelemetryEventType::PatchRolledBack.as_str(),
960            "patch_rolled_back"
961        );
962        assert_eq!(
963            PatchTelemetryEventType::PatchRollbackFailed.as_str(),
964            "patch_rollback_failed"
965        );
966        // Read-side
967        assert_eq!(PatchTelemetryEventType::PatchScanned.as_str(), "patch_scanned");
968        assert_eq!(
969            PatchTelemetryEventType::PatchScanFailed.as_str(),
970            "patch_scan_failed"
971        );
972        assert_eq!(PatchTelemetryEventType::PatchFetched.as_str(), "patch_fetched");
973        assert_eq!(
974            PatchTelemetryEventType::PatchFetchFailed.as_str(),
975            "patch_fetch_failed"
976        );
977        // Inspection / housekeeping
978        assert_eq!(PatchTelemetryEventType::PatchListed.as_str(), "patch_listed");
979        assert_eq!(
980            PatchTelemetryEventType::PatchRepaired.as_str(),
981            "patch_repaired"
982        );
983        assert_eq!(
984            PatchTelemetryEventType::PatchRepairFailed.as_str(),
985            "patch_repair_failed"
986        );
987        assert_eq!(PatchTelemetryEventType::PatchSetup.as_str(), "patch_setup");
988        assert_eq!(
989            PatchTelemetryEventType::PatchUnlocked.as_str(),
990            "patch_unlocked"
991        );
992        assert_eq!(
993            PatchTelemetryEventType::PatchUnlockFailed.as_str(),
994            "patch_unlock_failed"
995        );
996        // OpenVEX
997        assert_eq!(PatchTelemetryEventType::VexGenerated.as_str(), "vex_generated");
998        assert_eq!(PatchTelemetryEventType::VexFailed.as_str(), "vex_failed");
999    }
1000
1001    #[test]
1002    fn test_build_telemetry_context() {
1003        let ctx = build_telemetry_context("apply");
1004        assert_eq!(ctx.command, "apply");
1005        assert_eq!(ctx.version, PACKAGE_VERSION);
1006        assert!(!ctx.platform.is_empty());
1007        assert!(!ctx.arch.is_empty());
1008    }
1009
1010    #[test]
1011    fn test_build_telemetry_event_basic() {
1012        let options = TrackPatchEventOptions {
1013            event_type: PatchTelemetryEventType::PatchApplied,
1014            command: "apply".to_string(),
1015            metadata: None,
1016            error: None,
1017            api_token: None,
1018            org_slug: None,
1019        };
1020
1021        let event = build_telemetry_event(&options);
1022        assert_eq!(event.event_type, "patch_applied");
1023        assert_eq!(event.context.command, "apply");
1024        assert!(!event.session_id.is_empty());
1025        assert!(!event.event_sender_created_at.is_empty());
1026        assert!(event.metadata.is_none());
1027        assert!(event.error.is_none());
1028    }
1029
1030    #[test]
1031    fn test_build_telemetry_event_with_metadata() {
1032        let mut metadata = HashMap::new();
1033        metadata.insert(
1034            "patches_count".to_string(),
1035            serde_json::Value::Number(5.into()),
1036        );
1037
1038        let options = TrackPatchEventOptions {
1039            event_type: PatchTelemetryEventType::PatchApplied,
1040            command: "apply".to_string(),
1041            metadata: Some(metadata),
1042            error: None,
1043            api_token: None,
1044            org_slug: None,
1045        };
1046
1047        let event = build_telemetry_event(&options);
1048        assert!(event.metadata.is_some());
1049        let meta = event.metadata.unwrap();
1050        assert_eq!(
1051            meta.get("patches_count").unwrap(),
1052            &serde_json::Value::Number(5.into())
1053        );
1054    }
1055
1056    #[test]
1057    fn test_build_telemetry_event_with_error() {
1058        let options = TrackPatchEventOptions {
1059            event_type: PatchTelemetryEventType::PatchApplyFailed,
1060            command: "apply".to_string(),
1061            metadata: None,
1062            error: Some(("IoError".to_string(), "file not found".to_string())),
1063            api_token: None,
1064            org_slug: None,
1065        };
1066
1067        let event = build_telemetry_event(&options);
1068        assert!(event.error.is_some());
1069        let err = event.error.unwrap();
1070        assert_eq!(err.error_type, "IoError");
1071        assert_eq!(err.message.unwrap(), "file not found");
1072    }
1073
1074    #[test]
1075    fn test_session_id_is_consistent() {
1076        let id1 = SESSION_ID.clone();
1077        let id2 = SESSION_ID.clone();
1078        assert_eq!(id1, id2);
1079        // Should be a valid UUID v4 format
1080        assert_eq!(id1.len(), 36);
1081        assert!(id1.contains('-'));
1082    }
1083
1084    #[test]
1085    fn test_chrono_now_iso_format() {
1086        let ts = chrono_now_iso();
1087        // Should look like "2024-01-15T10:30:45.123Z"
1088        assert!(ts.ends_with('Z'));
1089        assert!(ts.contains('T'));
1090        assert!(ts.contains('-'));
1091        assert!(ts.contains(':'));
1092        assert_eq!(ts.len(), 24); // YYYY-MM-DDTHH:MM:SS.mmmZ
1093    }
1094
1095    #[test]
1096    fn test_days_to_ymd_epoch() {
1097        let (y, m, d) = days_to_ymd(0);
1098        assert_eq!((y, m, d), (1970, 1, 1));
1099    }
1100
1101    #[test]
1102    fn test_days_to_ymd_known_date() {
1103        // 2024-01-01 is day 19723
1104        let (y, m, d) = days_to_ymd(19723);
1105        assert_eq!((y, m, d), (2024, 1, 1));
1106    }
1107}