browsing 0.1.6

Browser automation: navigate, click, extract, screenshot. Standalone browser control via CDP.
Documentation
//! Optional telemetry — collect and export agent performance metrics
//!
//! Lightweight event-based telemetry for debugging and optimization.
//! Disabled by default; enabled via `Config` or env var.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

/// A single telemetry event
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TelemetryEvent {
    /// Event timestamp (ISO 8601)
    pub timestamp: String,
    /// Event category
    pub category: String,
    /// Event name
    pub name: String,
    /// Duration in milliseconds (if applicable)
    pub duration_ms: Option<u64>,
    /// Additional string properties
    pub properties: HashMap<String, String>,
}

/// Telemetry collector
#[derive(Debug, Clone)]
pub struct Telemetry {
    events: Arc<Mutex<Vec<TelemetryEvent>>>,
    enabled: bool,
}

impl Telemetry {
    /// Create a new telemetry collector (disabled by default)
    pub fn new() -> Self {
        Self {
            events: Arc::new(Mutex::new(Vec::new())),
            enabled: false,
        }
    }

    /// Enable telemetry
    pub fn enabled() -> Self {
        Self {
            events: Arc::new(Mutex::new(Vec::new())),
            enabled: true,
        }
    }

    /// Record an event
    pub fn record(&self, category: &str, name: &str) {
        if !self.enabled {
            return;
        }
        let event = TelemetryEvent {
            timestamp: chrono::Utc::now().to_rfc3339(),
            category: category.to_string(),
            name: name.to_string(),
            duration_ms: None,
            properties: HashMap::new(),
        };
        if let Ok(mut events) = self.events.lock() {
            events.push(event);
        }
    }

    /// Record an event with timing
    pub fn record_timed(&self, category: &str, name: &str, duration: Duration) {
        if !self.enabled {
            return;
        }
        let event = TelemetryEvent {
            timestamp: chrono::Utc::now().to_rfc3339(),
            category: category.to_string(),
            name: name.to_string(),
            duration_ms: Some(duration.as_millis() as u64),
            properties: HashMap::new(),
        };
        if let Ok(mut events) = self.events.lock() {
            events.push(event);
        }
    }

    /// Record an event with properties
    pub fn record_with_props(
        &self,
        category: &str,
        name: &str,
        props: HashMap<String, String>,
    ) {
        if !self.enabled {
            return;
        }
        let event = TelemetryEvent {
            timestamp: chrono::Utc::now().to_rfc3339(),
            category: category.to_string(),
            name: name.to_string(),
            duration_ms: None,
            properties: props,
        };
        if let Ok(mut events) = self.events.lock() {
            events.push(event);
        }
    }

    /// Export all events to a JSON file
    pub fn export_to_file(&self, path: impl AsRef<Path>) -> crate::error::Result<()> {
        let events = self.events.lock().map_err(|e| {
            crate::error::BrowsingError::Browser(format!("Telemetry lock poisoned: {}", e))
        })?;
        let json = serde_json::to_string_pretty(&*events)?;
        fs::write(path, json)?;
        Ok(())
    }

    /// Get a summary of recorded events
    pub fn summary(&self) -> HashMap<String, usize> {
        let mut counts = HashMap::new();
        if let Ok(events) = self.events.lock() {
            for event in events.iter() {
                let key = format!("{}:{}", event.category, event.name);
                *counts.entry(key).or_insert(0) += 1;
            }
        }
        counts
    }

    /// Clear all events
    pub fn clear(&self) {
        if let Ok(mut events) = self.events.lock() {
            events.clear();
        }
    }
}

impl Default for Telemetry {
    fn default() -> Self {
        Self::new()
    }
}

/// RAII timer that records duration on drop
pub struct TelemetryTimer<'a> {
    telemetry: &'a Telemetry,
    category: String,
    name: String,
    start: Instant,
}

impl<'a> TelemetryTimer<'a> {
    /// Start a timed event
    pub fn start(telemetry: &'a Telemetry, category: &str, name: &str) -> Self {
        Self {
            telemetry,
            category: category.to_string(),
            name: name.to_string(),
            start: Instant::now(),
        }
    }
}

impl<'a> Drop for TelemetryTimer<'a> {
    fn drop(&mut self) {
        self.telemetry
            .record_timed(&self.category, &self.name, self.start.elapsed());
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_telemetry_disabled_by_default() {
        let tel = Telemetry::new();
        tel.record("agent", "step");
        let summary = tel.summary();
        assert!(summary.is_empty());
    }

    #[test]
    fn test_telemetry_enabled() {
        let tel = Telemetry::enabled();
        tel.record("agent", "step");
        let summary = tel.summary();
        assert_eq!(summary.get("agent:step"), Some(&1));
    }

    #[test]
    fn test_telemetry_with_props() {
        let tel = Telemetry::enabled();
        let mut props = HashMap::new();
        props.insert("url".to_string(), "https://example.com".to_string());
        tel.record_with_props("navigate", "success", props);
        assert_eq!(tel.summary().get("navigate:success"), Some(&1));
    }

    #[test]
    fn test_telemetry_timer() {
        let tel = Telemetry::enabled();
        {
            let _timer = TelemetryTimer::start(&tel, "dom", "serialize");
            std::thread::sleep(Duration::from_millis(10));
        }
        let summary = tel.summary();
        assert_eq!(summary.get("dom:serialize"), Some(&1));
    }

    #[test]
    fn test_telemetry_export() {
        let tel = Telemetry::enabled();
        tel.record("test", "event");
        let temp = tempfile::NamedTempFile::new().unwrap();
        tel.export_to_file(temp.path()).unwrap();
        let contents = fs::read_to_string(temp.path()).unwrap();
        assert!(contents.contains("test"));
        assert!(contents.contains("event"));
    }
}