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};
7
8// ---------------------------------------------------------------------------
9// Session ID — generated once per process invocation
10// ---------------------------------------------------------------------------
11
12/// Unique session ID for the current CLI invocation.
13/// Shared across all telemetry events in a single run.
14static SESSION_ID: Lazy<String> = Lazy::new(|| Uuid::new_v4().to_string());
15
16/// Package version — updated during build.
17const PACKAGE_VERSION: &str = "1.0.0";
18
19// ---------------------------------------------------------------------------
20// Types
21// ---------------------------------------------------------------------------
22
23/// Telemetry event types for the patch lifecycle.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum PatchTelemetryEventType {
26    PatchApplied,
27    PatchApplyFailed,
28    PatchRemoved,
29    PatchRemoveFailed,
30    PatchRolledBack,
31    PatchRollbackFailed,
32}
33
34impl PatchTelemetryEventType {
35    /// Return the wire-format string for this event type.
36    pub fn as_str(&self) -> &'static str {
37        match self {
38            Self::PatchApplied => "patch_applied",
39            Self::PatchApplyFailed => "patch_apply_failed",
40            Self::PatchRemoved => "patch_removed",
41            Self::PatchRemoveFailed => "patch_remove_failed",
42            Self::PatchRolledBack => "patch_rolled_back",
43            Self::PatchRollbackFailed => "patch_rollback_failed",
44        }
45    }
46}
47
48/// Telemetry context describing the execution environment.
49#[derive(Debug, Clone, serde::Serialize)]
50pub struct PatchTelemetryContext {
51    pub version: String,
52    pub platform: String,
53    pub arch: String,
54    pub command: String,
55}
56
57/// Error details for telemetry events.
58#[derive(Debug, Clone, serde::Serialize)]
59pub struct PatchTelemetryError {
60    #[serde(rename = "type")]
61    pub error_type: String,
62    pub message: Option<String>,
63}
64
65/// Telemetry event structure for patch operations.
66#[derive(Debug, Clone, serde::Serialize)]
67pub struct PatchTelemetryEvent {
68    pub event_sender_created_at: String,
69    pub event_type: String,
70    pub context: PatchTelemetryContext,
71    pub session_id: String,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub metadata: Option<HashMap<String, serde_json::Value>>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub error: Option<PatchTelemetryError>,
76}
77
78/// Options for tracking a patch event.
79pub struct TrackPatchEventOptions {
80    /// The type of event being tracked.
81    pub event_type: PatchTelemetryEventType,
82    /// The CLI command being executed (e.g., "apply", "remove", "rollback").
83    pub command: String,
84    /// Optional metadata to include with the event.
85    pub metadata: Option<HashMap<String, serde_json::Value>>,
86    /// Optional error information if the operation failed.
87    /// Tuple of (error_type, message).
88    pub error: Option<(String, String)>,
89    /// Optional API token for authenticated telemetry endpoint.
90    pub api_token: Option<String>,
91    /// Optional organization slug for authenticated telemetry endpoint.
92    pub org_slug: Option<String>,
93}
94
95// ---------------------------------------------------------------------------
96// Environment checks
97// ---------------------------------------------------------------------------
98
99/// Check if telemetry is disabled via environment variables.
100///
101/// Telemetry is disabled when:
102/// - `SOCKET_PATCH_TELEMETRY_DISABLED` is `"1"` or `"true"`
103/// - `VITEST` is `"true"` (test environment)
104pub fn is_telemetry_disabled() -> bool {
105    matches!(
106        std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED")
107            .unwrap_or_default()
108            .as_str(),
109        "1" | "true"
110    ) || std::env::var("VITEST").unwrap_or_default() == "true"
111}
112
113/// Check if debug mode is enabled.
114fn is_debug_enabled() -> bool {
115    matches!(
116        std::env::var("SOCKET_PATCH_DEBUG")
117            .unwrap_or_default()
118            .as_str(),
119        "1" | "true"
120    )
121}
122
123/// Log debug messages when debug mode is enabled.
124fn debug_log(message: &str) {
125    if is_debug_enabled() {
126        eprintln!("[socket-patch telemetry] {message}");
127    }
128}
129
130// ---------------------------------------------------------------------------
131// Build event
132// ---------------------------------------------------------------------------
133
134/// Build the telemetry context for the current environment.
135fn build_telemetry_context(command: &str) -> PatchTelemetryContext {
136    PatchTelemetryContext {
137        version: PACKAGE_VERSION.to_string(),
138        platform: std::env::consts::OS.to_string(),
139        arch: std::env::consts::ARCH.to_string(),
140        command: command.to_string(),
141    }
142}
143
144/// Sanitize an error message for telemetry.
145///
146/// Replaces the user's home directory path with `~` to avoid leaking
147/// sensitive file system information.
148pub fn sanitize_error_message(message: &str) -> String {
149    if let Some(home) = home_dir_string() {
150        if !home.is_empty() {
151            return message.replace(&home, "~");
152        }
153    }
154    message.to_string()
155}
156
157/// Get the home directory as a string.
158fn home_dir_string() -> Option<String> {
159    std::env::var("HOME")
160        .ok()
161        .or_else(|| std::env::var("USERPROFILE").ok())
162}
163
164/// Build a telemetry event from the given options.
165fn build_telemetry_event(options: &TrackPatchEventOptions) -> PatchTelemetryEvent {
166    let error = options.error.as_ref().map(|(error_type, message)| {
167        PatchTelemetryError {
168            error_type: error_type.clone(),
169            message: Some(sanitize_error_message(message)),
170        }
171    });
172
173    PatchTelemetryEvent {
174        event_sender_created_at: chrono_now_iso(),
175        event_type: options.event_type.as_str().to_string(),
176        context: build_telemetry_context(&options.command),
177        session_id: SESSION_ID.clone(),
178        metadata: options.metadata.clone(),
179        error,
180    }
181}
182
183/// Get the current time as an ISO 8601 string.
184fn chrono_now_iso() -> String {
185    let now = std::time::SystemTime::now();
186    let duration = now
187        .duration_since(std::time::UNIX_EPOCH)
188        .unwrap_or_default();
189    let secs = duration.as_secs();
190
191    let days = secs / 86400;
192    let remaining = secs % 86400;
193    let hours = remaining / 3600;
194    let minutes = (remaining % 3600) / 60;
195    let seconds = remaining % 60;
196    let millis = duration.subsec_millis();
197
198    let (year, month, day) = days_to_ymd(days);
199
200    format!(
201        "{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{millis:03}Z"
202    )
203}
204
205/// Convert days since Unix epoch to (year, month, day).
206fn days_to_ymd(days: u64) -> (u64, u64, u64) {
207    // Adapted from Howard Hinnant's civil_from_days algorithm
208    let z = days as i64 + 719468;
209    let era = if z >= 0 { z } else { z - 146096 } / 146097;
210    let doe = (z - era * 146097) as u64;
211    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
212    let y = yoe as i64 + era * 400;
213    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
214    let mp = (5 * doy + 2) / 153;
215    let d = doy - (153 * mp + 2) / 5 + 1;
216    let m = if mp < 10 { mp + 3 } else { mp - 9 };
217    let y = if m <= 2 { y + 1 } else { y };
218    (y as u64, m, d)
219}
220
221// ---------------------------------------------------------------------------
222// Send event
223// ---------------------------------------------------------------------------
224
225/// Send a telemetry event to the API.
226///
227/// This is fire-and-forget: errors are logged in debug mode but never
228/// propagated. Uses `reqwest` with a 5-second timeout.
229async fn send_telemetry_event(
230    event: &PatchTelemetryEvent,
231    api_token: Option<&str>,
232    org_slug: Option<&str>,
233) {
234    let (url, use_auth) = match (api_token, org_slug) {
235        (Some(_token), Some(slug)) => {
236            let api_url = std::env::var("SOCKET_API_URL")
237                .unwrap_or_else(|_| DEFAULT_SOCKET_API_URL.to_string());
238            (format!("{api_url}/v0/orgs/{slug}/telemetry"), true)
239        }
240        _ => {
241            let proxy_url = std::env::var("SOCKET_PATCH_PROXY_URL")
242                .unwrap_or_else(|_| DEFAULT_PATCH_API_PROXY_URL.to_string());
243            (format!("{proxy_url}/patch/telemetry"), false)
244        }
245    };
246
247    debug_log(&format!("Sending telemetry to {url}"));
248
249    let client = match reqwest::Client::builder()
250        .timeout(std::time::Duration::from_secs(5))
251        .build()
252    {
253        Ok(c) => c,
254        Err(e) => {
255            debug_log(&format!("Failed to build HTTP client: {e}"));
256            return;
257        }
258    };
259
260    let mut request = client
261        .post(&url)
262        .header("Content-Type", "application/json")
263        .header("User-Agent", USER_AGENT);
264
265    if use_auth {
266        if let Some(token) = api_token {
267            request = request.header("Authorization", format!("Bearer {token}"));
268        }
269    }
270
271    match request.json(event).send().await {
272        Ok(response) => {
273            let status = response.status();
274            if status.is_success() {
275                debug_log("Telemetry sent successfully");
276            } else {
277                debug_log(&format!("Telemetry request returned status {status}"));
278            }
279        }
280        Err(e) => {
281            debug_log(&format!("Telemetry request failed: {e}"));
282        }
283    }
284}
285
286// ---------------------------------------------------------------------------
287// Public API
288// ---------------------------------------------------------------------------
289
290/// Track a patch lifecycle event.
291///
292/// This function is non-blocking and will never return errors. Telemetry
293/// failures are logged in debug mode but do not affect CLI operation.
294///
295/// If telemetry is disabled (via environment variables), the function returns
296/// immediately.
297pub async fn track_patch_event(options: TrackPatchEventOptions) {
298    if is_telemetry_disabled() {
299        debug_log("Telemetry is disabled, skipping event");
300        return;
301    }
302
303    let event = build_telemetry_event(&options);
304    send_telemetry_event(
305        &event,
306        options.api_token.as_deref(),
307        options.org_slug.as_deref(),
308    )
309    .await;
310}
311
312/// Fire-and-forget version of `track_patch_event` that spawns the request
313/// on a background task so it never blocks the caller.
314pub fn track_patch_event_fire_and_forget(options: TrackPatchEventOptions) {
315    if is_telemetry_disabled() {
316        debug_log("Telemetry is disabled, skipping event");
317        return;
318    }
319
320    let event = build_telemetry_event(&options);
321    let api_token = options.api_token.clone();
322    let org_slug = options.org_slug.clone();
323
324    tokio::spawn(async move {
325        send_telemetry_event(&event, api_token.as_deref(), org_slug.as_deref()).await;
326    });
327}
328
329// ---------------------------------------------------------------------------
330// Convenience functions
331//
332// These accept `Option<&str>` for api_token/org_slug to make call sites
333// convenient (callers typically have `Option<String>` and call `.as_deref()`).
334// ---------------------------------------------------------------------------
335
336/// Track a successful patch application.
337pub async fn track_patch_applied(
338    patches_count: usize,
339    dry_run: bool,
340    api_token: Option<&str>,
341    org_slug: Option<&str>,
342) {
343    let mut metadata = HashMap::new();
344    metadata.insert(
345        "patches_count".to_string(),
346        serde_json::Value::Number(serde_json::Number::from(patches_count)),
347    );
348    metadata.insert("dry_run".to_string(), serde_json::Value::Bool(dry_run));
349
350    track_patch_event(TrackPatchEventOptions {
351        event_type: PatchTelemetryEventType::PatchApplied,
352        command: "apply".to_string(),
353        metadata: Some(metadata),
354        error: None,
355        api_token: api_token.map(|s| s.to_string()),
356        org_slug: org_slug.map(|s| s.to_string()),
357    })
358    .await;
359}
360
361/// Track a failed patch application.
362///
363/// Accepts any `Display` type for the error (works with `&str`, `String`,
364/// `anyhow::Error`, `std::io::Error`, etc.).
365pub async fn track_patch_apply_failed(
366    error: impl std::fmt::Display,
367    dry_run: bool,
368    api_token: Option<&str>,
369    org_slug: Option<&str>,
370) {
371    let mut metadata = HashMap::new();
372    metadata.insert("dry_run".to_string(), serde_json::Value::Bool(dry_run));
373
374    track_patch_event(TrackPatchEventOptions {
375        event_type: PatchTelemetryEventType::PatchApplyFailed,
376        command: "apply".to_string(),
377        metadata: Some(metadata),
378        error: Some(("Error".to_string(), error.to_string())),
379        api_token: api_token.map(|s| s.to_string()),
380        org_slug: org_slug.map(|s| s.to_string()),
381    })
382    .await;
383}
384
385/// Track a successful patch removal.
386pub async fn track_patch_removed(
387    removed_count: usize,
388    api_token: Option<&str>,
389    org_slug: Option<&str>,
390) {
391    let mut metadata = HashMap::new();
392    metadata.insert(
393        "removed_count".to_string(),
394        serde_json::Value::Number(serde_json::Number::from(removed_count)),
395    );
396
397    track_patch_event(TrackPatchEventOptions {
398        event_type: PatchTelemetryEventType::PatchRemoved,
399        command: "remove".to_string(),
400        metadata: Some(metadata),
401        error: None,
402        api_token: api_token.map(|s| s.to_string()),
403        org_slug: org_slug.map(|s| s.to_string()),
404    })
405    .await;
406}
407
408/// Track a failed patch removal.
409///
410/// Accepts any `Display` type for the error.
411pub async fn track_patch_remove_failed(
412    error: impl std::fmt::Display,
413    api_token: Option<&str>,
414    org_slug: Option<&str>,
415) {
416    track_patch_event(TrackPatchEventOptions {
417        event_type: PatchTelemetryEventType::PatchRemoveFailed,
418        command: "remove".to_string(),
419        metadata: None,
420        error: Some(("Error".to_string(), error.to_string())),
421        api_token: api_token.map(|s| s.to_string()),
422        org_slug: org_slug.map(|s| s.to_string()),
423    })
424    .await;
425}
426
427/// Track a successful patch rollback.
428pub async fn track_patch_rolled_back(
429    rolled_back_count: usize,
430    api_token: Option<&str>,
431    org_slug: Option<&str>,
432) {
433    let mut metadata = HashMap::new();
434    metadata.insert(
435        "rolled_back_count".to_string(),
436        serde_json::Value::Number(serde_json::Number::from(rolled_back_count)),
437    );
438
439    track_patch_event(TrackPatchEventOptions {
440        event_type: PatchTelemetryEventType::PatchRolledBack,
441        command: "rollback".to_string(),
442        metadata: Some(metadata),
443        error: None,
444        api_token: api_token.map(|s| s.to_string()),
445        org_slug: org_slug.map(|s| s.to_string()),
446    })
447    .await;
448}
449
450/// Track a failed patch rollback.
451///
452/// Accepts any `Display` type for the error.
453pub async fn track_patch_rollback_failed(
454    error: impl std::fmt::Display,
455    api_token: Option<&str>,
456    org_slug: Option<&str>,
457) {
458    track_patch_event(TrackPatchEventOptions {
459        event_type: PatchTelemetryEventType::PatchRollbackFailed,
460        command: "rollback".to_string(),
461        metadata: None,
462        error: Some(("Error".to_string(), error.to_string())),
463        api_token: api_token.map(|s| s.to_string()),
464        org_slug: org_slug.map(|s| s.to_string()),
465    })
466    .await;
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    /// Combined into a single test to avoid env-var races across parallel tests.
474    #[test]
475    fn test_is_telemetry_disabled() {
476        // Save originals
477        let orig_disabled = std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED").ok();
478        let orig_vitest = std::env::var("VITEST").ok();
479
480        // Default: not disabled
481        std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED");
482        std::env::remove_var("VITEST");
483        assert!(!is_telemetry_disabled());
484
485        // Disabled via "1"
486        std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "1");
487        assert!(is_telemetry_disabled());
488
489        // Disabled via "true"
490        std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "true");
491        assert!(is_telemetry_disabled());
492
493        // Restore originals
494        match orig_disabled {
495            Some(v) => std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", v),
496            None => std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"),
497        }
498        match orig_vitest {
499            Some(v) => std::env::set_var("VITEST", v),
500            None => std::env::remove_var("VITEST"),
501        }
502    }
503
504    #[test]
505    fn test_sanitize_error_message() {
506        let home = home_dir_string().unwrap_or_else(|| "/home/testuser".to_string());
507        let msg = format!("Failed to read {home}/projects/secret/file.txt");
508        let sanitized = sanitize_error_message(&msg);
509        assert!(sanitized.contains("~/projects/secret/file.txt"));
510        assert!(!sanitized.contains(&home));
511    }
512
513    #[test]
514    fn test_sanitize_error_message_no_home() {
515        let msg = "Some error without paths";
516        assert_eq!(sanitize_error_message(msg), msg);
517    }
518
519    #[test]
520    fn test_event_type_as_str() {
521        assert_eq!(PatchTelemetryEventType::PatchApplied.as_str(), "patch_applied");
522        assert_eq!(
523            PatchTelemetryEventType::PatchApplyFailed.as_str(),
524            "patch_apply_failed"
525        );
526        assert_eq!(PatchTelemetryEventType::PatchRemoved.as_str(), "patch_removed");
527        assert_eq!(
528            PatchTelemetryEventType::PatchRemoveFailed.as_str(),
529            "patch_remove_failed"
530        );
531        assert_eq!(
532            PatchTelemetryEventType::PatchRolledBack.as_str(),
533            "patch_rolled_back"
534        );
535        assert_eq!(
536            PatchTelemetryEventType::PatchRollbackFailed.as_str(),
537            "patch_rollback_failed"
538        );
539    }
540
541    #[test]
542    fn test_build_telemetry_context() {
543        let ctx = build_telemetry_context("apply");
544        assert_eq!(ctx.command, "apply");
545        assert_eq!(ctx.version, PACKAGE_VERSION);
546        assert!(!ctx.platform.is_empty());
547        assert!(!ctx.arch.is_empty());
548    }
549
550    #[test]
551    fn test_build_telemetry_event_basic() {
552        let options = TrackPatchEventOptions {
553            event_type: PatchTelemetryEventType::PatchApplied,
554            command: "apply".to_string(),
555            metadata: None,
556            error: None,
557            api_token: None,
558            org_slug: None,
559        };
560
561        let event = build_telemetry_event(&options);
562        assert_eq!(event.event_type, "patch_applied");
563        assert_eq!(event.context.command, "apply");
564        assert!(!event.session_id.is_empty());
565        assert!(!event.event_sender_created_at.is_empty());
566        assert!(event.metadata.is_none());
567        assert!(event.error.is_none());
568    }
569
570    #[test]
571    fn test_build_telemetry_event_with_metadata() {
572        let mut metadata = HashMap::new();
573        metadata.insert(
574            "patches_count".to_string(),
575            serde_json::Value::Number(5.into()),
576        );
577
578        let options = TrackPatchEventOptions {
579            event_type: PatchTelemetryEventType::PatchApplied,
580            command: "apply".to_string(),
581            metadata: Some(metadata),
582            error: None,
583            api_token: None,
584            org_slug: None,
585        };
586
587        let event = build_telemetry_event(&options);
588        assert!(event.metadata.is_some());
589        let meta = event.metadata.unwrap();
590        assert_eq!(
591            meta.get("patches_count").unwrap(),
592            &serde_json::Value::Number(5.into())
593        );
594    }
595
596    #[test]
597    fn test_build_telemetry_event_with_error() {
598        let options = TrackPatchEventOptions {
599            event_type: PatchTelemetryEventType::PatchApplyFailed,
600            command: "apply".to_string(),
601            metadata: None,
602            error: Some(("IoError".to_string(), "file not found".to_string())),
603            api_token: None,
604            org_slug: None,
605        };
606
607        let event = build_telemetry_event(&options);
608        assert!(event.error.is_some());
609        let err = event.error.unwrap();
610        assert_eq!(err.error_type, "IoError");
611        assert_eq!(err.message.unwrap(), "file not found");
612    }
613
614    #[test]
615    fn test_session_id_is_consistent() {
616        let id1 = SESSION_ID.clone();
617        let id2 = SESSION_ID.clone();
618        assert_eq!(id1, id2);
619        // Should be a valid UUID v4 format
620        assert_eq!(id1.len(), 36);
621        assert!(id1.contains('-'));
622    }
623
624    #[test]
625    fn test_chrono_now_iso_format() {
626        let ts = chrono_now_iso();
627        // Should look like "2024-01-15T10:30:45.123Z"
628        assert!(ts.ends_with('Z'));
629        assert!(ts.contains('T'));
630        assert!(ts.contains('-'));
631        assert!(ts.contains(':'));
632        assert_eq!(ts.len(), 24); // YYYY-MM-DDTHH:MM:SS.mmmZ
633    }
634
635    #[test]
636    fn test_days_to_ymd_epoch() {
637        let (y, m, d) = days_to_ymd(0);
638        assert_eq!((y, m, d), (1970, 1, 1));
639    }
640
641    #[test]
642    fn test_days_to_ymd_known_date() {
643        // 2024-01-01 is day 19723
644        let (y, m, d) = days_to_ymd(19723);
645        assert_eq!((y, m, d), (2024, 1, 1));
646    }
647}