openlatch-client 0.1.5

The open-source security layer for AI agents — client forwarder
//! Typed event constructors for the v1 PostHog event catalog.
//!
//! Each variant maps to a snake_case, object_action PostHog event name.
//! Phase A defines the shape and constructors only — actual wiring to call
//! sites lands in Phase B (Task 2). Super-properties are attached by the
//! client wrapper, not duplicated here.
//!
//! See `.brainstorms/2026-04-13-posthog-client-telemetry.md §5` for the full
//! catalog.

use serde_json::{json, Map, Value};

/// A PostHog event ready to be wrapped with super-properties and enqueued.
#[derive(Debug, Clone)]
pub struct Event {
    /// PostHog event name (e.g. `"cli_initialized"`).
    pub name: String,
    /// Event-specific properties. Super-properties are merged in by the client.
    pub properties: Map<String, Value>,
}

impl Event {
    fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            properties: Map::new(),
        }
    }

    fn with(mut self, key: &str, value: Value) -> Self {
        self.properties.insert(key.to_string(), value);
        self
    }

    // ---- Lifecycle ----

    pub fn cli_initialized(
        agent_detected: &str,
        hooks_installed_count: usize,
        first_run: bool,
    ) -> Self {
        Self::new("cli_initialized")
            .with("agent_detected", json!(agent_detected))
            .with("hooks_installed_count", json!(hooks_installed_count))
            .with("first_run", json!(first_run))
    }

    pub fn auth_completed(auth_method: &str, duration_ms: u64) -> Self {
        Self::new("auth_completed")
            .with("auth_method", json!(auth_method))
            .with("duration_ms", json!(duration_ms))
    }

    pub fn auth_failed(error_code: &str, stage: &str) -> Self {
        Self::new("auth_failed")
            .with("error_code", json!(error_code))
            .with("stage", json!(stage))
    }

    pub fn uninstalled(agents_removed_count: usize) -> Self {
        Self::new("uninstalled").with("agents_removed_count", json!(agents_removed_count))
    }

    /// Recorded after `openlatch init` and `openlatch supervision install` decide
    /// the daemon's persistence posture. All fields are low-cardinality enums or
    /// a short reason string — never a path or credential.
    ///
    /// - `backend`: `launchd` | `systemd` | `task_scheduler` | `none`
    /// - `mode`: `active` | `deferred` | `disabled`
    /// - `deferred_reason`: populated only when `mode != active`
    ///   (`user_opt_out`, `foreground_session`, `no_start`, `unsupported_os`,
    ///   or the `OL-XXXX ...` message when the OS install failed).
    pub fn supervision_installed(backend: &str, mode: &str, deferred_reason: Option<&str>) -> Self {
        let mut e = Self::new("supervision_installed")
            .with("backend", json!(backend))
            .with("mode", json!(mode));
        if let Some(r) = deferred_reason {
            e = e.with("deferred_reason", json!(r));
        }
        e
    }

    // ---- Wire-format unknown-variant signals ----
    //
    // The CloudEvents envelope (v1.0.2) uses open strings for `source` and
    // `type`. When the client observes a string it does not yet have a named
    // Rust variant for, emit one of these events so we can see which new
    // agents / hook events to promote to named variants in the next release.
    // Payload is the raw wire string only — anonymised, no PII.

    pub fn hook_source_unknown(source: &str) -> Self {
        Self::new("hook_source_unknown").with("source", json!(source))
    }

    pub fn hook_type_unknown(type_str: &str) -> Self {
        Self::new("hook_type_unknown").with("type", json!(type_str))
    }

    // ---- Daemon runtime ----

    pub fn daemon_started(port: u16, startup_ms: u64, cloud_enabled: bool) -> Self {
        Self::new("daemon_started")
            .with("port", json!(port))
            .with("startup_ms", json!(startup_ms))
            .with("cloud_enabled", json!(cloud_enabled))
    }

    pub fn daemon_stopped(uptime_seconds: u64, events_processed_total: u64) -> Self {
        Self::new("daemon_stopped")
            .with("uptime_seconds", json!(uptime_seconds))
            .with("events_processed_total", json!(events_processed_total))
    }

    /// `panic_location` must be `file:line` only — never the panic message or
    /// backtrace, which can leak interpolated user values (§5.3).
    pub fn daemon_crashed(panic_location: &str, uptime_seconds: u64) -> Self {
        Self::new("daemon_crashed")
            .with("panic_location", json!(panic_location))
            .with("uptime_seconds", json!(uptime_seconds))
    }

    // ---- Command usage ----

    /// Constructs a `command_invoked` event. Callers are responsible for
    /// skipping `--help` / `--version` invocations upstream (per plan).
    /// Flag values and argument text must never be passed in.
    pub fn command_invoked(
        command: &str,
        subcommand: Option<&str>,
        exit_code: i32,
        duration_ms: u64,
    ) -> Self {
        let mut e = Self::new("command_invoked")
            .with("command", json!(command))
            .with("exit_code", json!(exit_code))
            .with("duration_ms", json!(duration_ms));
        if let Some(s) = subcommand {
            e = e.with("subcommand", json!(s));
        }
        e
    }

    // ---- Hook activity (aggregated) ----

    pub fn hooks_processed(rollup: Value, window_seconds: u64) -> Self {
        Self::new("hooks_processed")
            .with("rollup", rollup)
            .with("window_seconds", json!(window_seconds))
    }

    pub fn cloud_error(
        error_code: &str,
        http_status: Option<u16>,
        retry_count: u32,
        latency_ms: u64,
    ) -> Self {
        let mut e = Self::new("cloud_error")
            .with("error_code", json!(error_code))
            .with("retry_count", json!(retry_count))
            .with("latency_ms", json!(latency_ms));
        if let Some(s) = http_status {
            e = e.with("http_status", json!(s));
        }
        e
    }

    // ---- Tamper detection (aggregates only) ----
    //
    // Emitted by the reconciler when a tracked hook entry drifts from its
    // install-time HMAC, and again when the self-heal attempt resolves.
    // Payload is strictly aggregate context — no `entry_id`, no paths, no
    // field names. The OCSF `TamperEvent` shipped through the cloud worker
    // carries the full forensic detail; PostHog only sees counts by shape.

    pub fn tamper_detected(detection_method: &str, agent_type: &str) -> Self {
        Self::new("tamper_detected")
            .with("detection_method", json!(detection_method))
            .with("agent_type", json!(agent_type))
    }

    pub fn tamper_healed(
        detection_method: &str,
        agent_type: &str,
        outcome: &str,
        attempt: u32,
    ) -> Self {
        Self::new("tamper_healed")
            .with("detection_method", json!(detection_method))
            .with("agent_type", json!(agent_type))
            .with("outcome", json!(outcome))
            .with("attempt", json!(attempt))
    }

    // ---- Doctor (--fix / --restore / --rescue) ----
    //
    // Aggregates only — never paths, file contents, or matched credential
    // text. agent_id is already a super-property and must not be re-attached.

    /// Recorded once per `openlatch doctor --fix` run.
    #[allow(clippy::too_many_arguments)] // every field is intentional telemetry context
    pub fn doctor_fix_run(
        categories_selected: Vec<&str>,
        checks_before_pass: usize,
        checks_before_fail: usize,
        checks_after_pass: usize,
        checks_after_fail: usize,
        unfixable_codes: Vec<&str>,
        duration_ms: u64,
        auto_rollback_triggered: bool,
    ) -> Self {
        Self::new("doctor_fix_run")
            .with("categories_selected", json!(categories_selected))
            .with(
                "checks_before",
                json!({ "pass": checks_before_pass, "fail": checks_before_fail }),
            )
            .with(
                "checks_after",
                json!({ "pass": checks_after_pass, "fail": checks_after_fail }),
            )
            .with("unfixable_codes", json!(unfixable_codes))
            .with("duration_ms", json!(duration_ms))
            .with("auto_rollback_triggered", json!(auto_rollback_triggered))
    }

    /// Recorded once per user-initiated `openlatch doctor --restore`.
    pub fn doctor_restore_run(
        actions_reversed: usize,
        actions_skipped: usize,
        daemon_restart_required: bool,
        duration_ms: u64,
    ) -> Self {
        Self::new("doctor_restore_run")
            .with("actions_reversed", json!(actions_reversed))
            .with("actions_skipped", json!(actions_skipped))
            .with("daemon_restart_required", json!(daemon_restart_required))
            .with("duration_ms", json!(duration_ms))
    }

    /// Recorded once per `openlatch doctor --rescue` run. `agents_found`
    /// carries kebab-case agent identifiers only (no file paths). Hit
    /// counts are aggregate across all redactor patterns; per-pattern
    /// breakdown lives in the bundled MANIFEST, not in telemetry.
    #[allow(clippy::too_many_arguments)] // every field is intentional telemetry context
    pub fn doctor_rescue_run(
        archive_size_bytes: u64,
        files_collected_count: usize,
        daemon_reachable: bool,
        agents_found: Vec<&str>,
        redactor_hits_total: u64,
        duration_ms: u64,
        fix_applied_after: bool,
    ) -> Self {
        Self::new("doctor_rescue_run")
            .with("archive_size_bytes", json!(archive_size_bytes))
            .with("files_collected_count", json!(files_collected_count))
            .with("daemon_reachable", json!(daemon_reachable))
            .with("agents_found", json!(agents_found))
            .with("redactor_hits_total", json!(redactor_hits_total))
            .with("duration_ms", json!(duration_ms))
            .with("fix_applied_after", json!(fix_applied_after))
    }
}

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

    #[test]
    fn test_cli_initialized_has_required_props() {
        let e = Event::cli_initialized("claude-code", 3, true);
        assert_eq!(e.name, "cli_initialized");
        assert_eq!(e.properties["agent_detected"], "claude-code");
        assert_eq!(e.properties["hooks_installed_count"], 3);
        assert_eq!(e.properties["first_run"], true);
    }

    #[test]
    fn test_command_invoked_omits_subcommand_when_none() {
        let e = Event::command_invoked("status", None, 0, 12);
        assert!(!e.properties.contains_key("subcommand"));
    }

    #[test]
    fn test_command_invoked_includes_subcommand_when_some() {
        let e = Event::command_invoked("auth", Some("login"), 0, 42);
        assert_eq!(e.properties["subcommand"], "login");
    }

    #[test]
    fn test_doctor_fix_run_includes_aggregate_counts_only() {
        let e = Event::doctor_fix_run(vec!["state", "hooks"], 3, 3, 6, 0, vec![], 1234, false);
        assert_eq!(e.name, "doctor_fix_run");
        assert_eq!(e.properties["checks_before"]["pass"], 3);
        assert_eq!(e.properties["checks_after"]["fail"], 0);
        assert_eq!(e.properties["auto_rollback_triggered"], false);
        assert_eq!(e.properties["duration_ms"], 1234);
    }

    #[test]
    fn test_doctor_rescue_run_carries_aggregate_redactor_hits_only() {
        let e = Event::doctor_rescue_run(1_234_567, 12, true, vec!["claude-code"], 42, 3000, false);
        assert_eq!(e.name, "doctor_rescue_run");
        assert_eq!(e.properties["archive_size_bytes"], 1_234_567);
        assert_eq!(e.properties["redactor_hits_total"], 42);
        // No per-pattern breakdown leaks into telemetry.
        assert!(!e.properties.contains_key("redactor_hits_by_pattern"));
    }

    #[test]
    fn test_supervision_installed_omits_reason_when_active() {
        let e = Event::supervision_installed("launchd", "active", None);
        assert_eq!(e.name, "supervision_installed");
        assert_eq!(e.properties["backend"], "launchd");
        assert_eq!(e.properties["mode"], "active");
        assert!(!e.properties.contains_key("deferred_reason"));
    }

    #[test]
    fn test_supervision_installed_includes_reason_when_deferred() {
        let e = Event::supervision_installed("task_scheduler", "deferred", Some("user_opt_out"));
        assert_eq!(e.properties["mode"], "deferred");
        assert_eq!(e.properties["deferred_reason"], "user_opt_out");
    }

    #[test]
    fn test_daemon_crashed_accepts_file_line_only() {
        // The constructor can't validate format, but we assert the contract:
        // callers pass file:line.
        let e = Event::daemon_crashed("src/daemon/mod.rs:214", 3600);
        let loc = e.properties["panic_location"].as_str().unwrap();
        assert!(loc.contains(':'));
        assert!(!loc.contains("panic at"));
    }

    #[test]
    fn test_tamper_detected_carries_aggregates_only() {
        let e = Event::tamper_detected("hmac_mismatch", "claude-code");
        assert_eq!(e.name, "tamper_detected");
        assert_eq!(e.properties["detection_method"], "hmac_mismatch");
        assert_eq!(e.properties["agent_type"], "claude-code");
        // No entry IDs, paths, or forensic detail in the telemetry event —
        // the OCSF CloudEvent carries that to the platform instead.
        assert!(!e.properties.contains_key("entry_id"));
        assert!(!e.properties.contains_key("settings_path_hash"));
        assert!(!e.properties.contains_key("field_deltas"));
    }

    #[test]
    fn test_tamper_healed_includes_outcome_and_attempt() {
        let e = Event::tamper_healed("hmac_mismatch", "claude-code", "succeeded", 2);
        assert_eq!(e.name, "tamper_healed");
        assert_eq!(e.properties["outcome"], "succeeded");
        assert_eq!(e.properties["attempt"], 2);
        assert_eq!(e.properties["detection_method"], "hmac_mismatch");
    }
}