openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Typed event constructors for the openlatch-provider v1 catalog.
//!
//! The full table lives in `.local/openlatch-provider-v0.1/phase-1-editor-cli.md`
//! task P1.T1 ("Event catalog (v1)"). Adding a new event:
//!   1. Append a constructor here.
//!   2. Update the catalog table in the rule + plan.
//!   3. Add an E2E case if the event is observable.
//!
//! Body content rules (per `.claude/rules/telemetry.md`):
//!   - never include token / secret / payload / file path / hostname
//!   - aggregates only (counts, durations, enum tags) — no free-form strings
//!     unless the value is a known enum (e.g. `version_bump=minor`)

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

#[derive(Debug, Clone)]
pub struct Event {
    pub name: String,
    pub properties: Map<String, Value>,
}

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

    /// Emitted by the CLI dispatcher after every command finishes (success
    /// or failure). `command` is the dotted subcommand path (e.g.
    /// `tools.publish`); `output_mode` is the resolved 4-mode tag.
    pub fn cli_command_invoked(
        command: &str,
        output_mode: &str,
        exit_code: i32,
        duration_ms: u64,
    ) -> Self {
        let mut p = Map::new();
        p.insert("command".into(), json!(command));
        p.insert("output_mode".into(), json!(output_mode));
        p.insert("exit_code".into(), json!(exit_code));
        p.insert("duration_ms".into(), json!(duration_ms));
        Self::new("cli_command_invoked", p)
    }

    pub fn init_completed(
        editor_present: bool,
        tool_count: u32,
        provider_count: u32,
        binding_count: u32,
    ) -> Self {
        let mut p = Map::new();
        p.insert("editor_present".into(), json!(editor_present));
        p.insert("tool_count".into(), json!(tool_count));
        p.insert("provider_count".into(), json!(provider_count));
        p.insert("binding_count".into(), json!(binding_count));
        Self::new("init_completed", p)
    }

    pub fn login_succeeded(flow: &str, duration_ms: u64) -> Self {
        let mut p = Map::new();
        p.insert("flow".into(), json!(flow));
        p.insert("duration_ms".into(), json!(duration_ms));
        Self::new("login_succeeded", p)
    }

    pub fn login_failed(flow: &str, error_code: &str) -> Self {
        let mut p = Map::new();
        p.insert("flow".into(), json!(flow));
        p.insert("error_code".into(), json!(error_code));
        Self::new("login_failed", p)
    }

    pub fn tool_published(version_bump: &str, dry_run: bool, category_count: u32) -> Self {
        let mut p = Map::new();
        p.insert("version_bump".into(), json!(version_bump));
        p.insert("dry_run".into(), json!(dry_run));
        p.insert("category_count".into(), json!(category_count));
        Self::new("tool_published", p)
    }

    pub fn provider_registered(region: &str, binding_count: u32, dry_run: bool) -> Self {
        let mut p = Map::new();
        p.insert("region".into(), json!(region));
        p.insert("binding_count".into(), json!(binding_count));
        p.insert("dry_run".into(), json!(dry_run));
        Self::new("provider_registered", p)
    }

    pub fn binding_secret_rotated() -> Self {
        Self::new("binding_secret_rotated", Map::new())
    }

    /// Emitted by `cli/commands/listen.rs` when the runtime daemon comes up.
    /// `tls_mode` is one of `direct` (we own the TLS handshake) / `proxy-fronted`
    /// (`--no-tls`, behind a reverse proxy).
    pub fn listen_started(port: u16, binding_count: u32, tls_mode: &str) -> Self {
        let mut p = Map::new();
        p.insert("port".into(), json!(port));
        p.insert("binding_count".into(), json!(binding_count));
        p.insert("tls_mode".into(), json!(tls_mode));
        Self::new("listen_started", p)
    }

    /// Emitted on graceful shutdown of the runtime. `reason` is one of
    /// `sigint` / `sigterm` / `sighup_failed` / `error`.
    pub fn listen_stopped(
        reason: &str,
        uptime_ms: u64,
        events_processed: u64,
        events_failed: u64,
    ) -> Self {
        let mut p = Map::new();
        p.insert("reason".into(), json!(reason));
        p.insert("uptime_ms".into(), json!(uptime_ms));
        p.insert("events_processed".into(), json!(events_processed));
        p.insert("events_failed".into(), json!(events_failed));
        Self::new("listen_stopped", p)
    }

    /// `failure_kind` ∈ `hmac` | `timestamp` | `replay`. Replay is logged at
    /// info level on the daemon but counted here so the platform can spot
    /// retries.
    pub fn webhook_verify_failed(binding_id: &str, failure_kind: &str) -> Self {
        let mut p = Map::new();
        p.insert("binding_id".into(), json!(binding_id));
        p.insert("failure_kind".into(), json!(failure_kind));
        Self::new("webhook_verify_failed", p)
    }

    /// `failure_kind` ∈ `unreachable` | `5xx` | `oversize` | `timeout`.
    pub fn proxy_call_failed(binding_id: &str, failure_kind: &str) -> Self {
        let mut p = Map::new();
        p.insert("binding_id".into(), json!(binding_id));
        p.insert("failure_kind".into(), json!(failure_kind));
        Self::new("proxy_call_failed", p)
    }

    pub fn error_emitted(code: &str, command_or_path: &str) -> Self {
        let mut p = Map::new();
        p.insert("code".into(), json!(code));
        p.insert("command_or_path".into(), json!(command_or_path));
        Self::new("error_emitted", p)
    }

    /// PostHog `$create_alias` — emitted once per (machine_id → editor_id)
    /// pair after a successful login. Stitches pre-auth machine identity to
    /// the persistent editor identity.
    pub fn create_alias(machine_id: &str, editor_id: &str) -> Self {
        let mut p = Map::new();
        p.insert("alias".into(), json!(machine_id));
        p.insert("distinct_id".into(), json!(editor_id));
        Self::new("$create_alias", p)
    }
}

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

    #[test]
    fn cli_command_invoked_carries_required_fields() {
        let e = Event::cli_command_invoked("tools.list", "table", 0, 42);
        assert_eq!(e.name, "cli_command_invoked");
        assert_eq!(e.properties["command"], "tools.list");
        assert_eq!(e.properties["output_mode"], "table");
        assert_eq!(e.properties["exit_code"], 0);
        assert_eq!(e.properties["duration_ms"], 42);
    }

    #[test]
    fn create_alias_event_shape() {
        let e = Event::create_alias("mach_x", "edt_y");
        assert_eq!(e.name, "$create_alias");
        assert_eq!(e.properties["alias"], "mach_x");
        assert_eq!(e.properties["distinct_id"], "edt_y");
    }
}