openlatch-provider 0.2.1

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)
    }

    /// Emitted by the supervisor when it spawns a managed tool process.
    /// `language_hint` is derived from `argv[0]` (see `language_hint_for`) and
    /// is one of `python` | `node` | `rust` | `other` — never a free-form
    /// command string.
    pub fn tool_process_started(
        binding_id: &str,
        language_hint: &str,
        restart_count_at_start: u32,
    ) -> Self {
        let mut p = Map::new();
        p.insert("binding_id".into(), json!(binding_id));
        p.insert("language_hint".into(), json!(language_hint));
        p.insert(
            "restart_count_at_start".into(),
            json!(restart_count_at_start),
        );
        Self::new("tool_process_started", p)
    }

    /// `failure_kind` ∈ `non_zero_exit` | `killed_by_signal` |
    /// `liveness_failed` | `startup_timeout`. Never includes stderr or exit
    /// message — only the enum tag.
    pub fn tool_process_crashed(binding_id: &str, failure_kind: &str, restart_count: u32) -> Self {
        let mut p = Map::new();
        p.insert("binding_id".into(), json!(binding_id));
        p.insert("failure_kind".into(), json!(failure_kind));
        p.insert("restart_count".into(), json!(restart_count));
        Self::new("tool_process_crashed", p)
    }

    /// Emitted exactly once when a binding hits its restart rate-limit and is
    /// marked degraded (subsequent events return OL-4224 / OL-4303).
    pub fn tool_process_degraded(
        binding_id: &str,
        restart_count: u32,
        window_seconds: u32,
    ) -> Self {
        let mut p = Map::new();
        p.insert("binding_id".into(), json!(binding_id));
        p.insert("restart_count".into(), json!(restart_count));
        p.insert("window_seconds".into(), json!(window_seconds));
        Self::new("tool_process_degraded", p)
    }

    /// Emitted once per successful v2 manifest load (via
    /// `manifest::v2::load_provider_tree`). Per .claude/rules/telemetry.md
    /// the catalog tracks counts, never content.
    pub fn manifest_v2_loaded(
        tool_count: u32,
        binding_count: u32,
        provider_count: u32,
        glob_count: u32,
    ) -> Self {
        let mut p = Map::new();
        p.insert("tool_count".into(), json!(tool_count));
        p.insert("binding_count".into(), json!(binding_count));
        p.insert("provider_count".into(), json!(provider_count));
        p.insert("glob_count".into(), json!(glob_count));
        Self::new("manifest_v2_loaded", p)
    }

    /// Emitted on the v1-compat path when a CLI command receives a v1
    /// manifest. `command` is the dotted subcommand path (e.g. `listen`,
    /// `register`, `publish`).
    pub fn manifest_v1_deprecated_used(command: &str) -> Self {
        let mut p = Map::new();
        p.insert("command".into(), json!(command));
        Self::new("manifest_v1_deprecated_used", p)
    }

    /// Emitted when a binding's `tool:` ref cannot be resolved against the
    /// local registry. `ref_kind` ∈ `qualified` | `implicit` | `latest`.
    pub fn tool_resolve_failed(error_code: &str, ref_kind: &str) -> Self {
        let mut p = Map::new();
        p.insert("error_code".into(), json!(error_code));
        p.insert("ref_kind".into(), json!(ref_kind));
        Self::new("tool_resolve_failed", p)
    }

    /// Emitted once per binding whose `process_override:` contributed at
    /// least one field. `overridden_fields` is the sorted list of keys.
    pub fn process_override_applied(binding_id: &str, overridden_fields: &[&str]) -> Self {
        let mut p = Map::new();
        p.insert("binding_id".into(), json!(binding_id));
        let mut fields: Vec<&str> = overridden_fields.to_vec();
        fields.sort();
        p.insert("overridden_fields".into(), json!(fields));
        Self::new("process_override_applied", p)
    }

    /// Emitted by `openlatch-provider migrate` after a successful v1→v2 split.
    pub fn manifest_migrated(tools_count: u32, bindings_count: u32, dry_run: bool) -> Self {
        let mut p = Map::new();
        p.insert("tools_count".into(), json!(tools_count));
        p.insert("bindings_count".into(), json!(bindings_count));
        p.insert("dry_run".into(), json!(dry_run));
        Self::new("manifest_migrated", 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)
    }
}

/// Map a tool's argv[0] to a coarse language tag for telemetry. Conservative —
/// only common interpreters are tagged; everything else is `other`. Returned
/// strings are stable so PostHog dashboards can group by them.
pub fn language_hint_for(argv0: &str) -> &'static str {
    let lower = argv0
        .rsplit(['/', '\\'])
        .next()
        .unwrap_or(argv0)
        .to_ascii_lowercase();
    let stem = lower.strip_suffix(".exe").unwrap_or(&lower);
    match stem {
        "python" | "python3" | "uv" | "uvicorn" | "gunicorn" | "hypercorn" => "python",
        "node" | "npm" | "npx" | "pnpm" | "yarn" | "bun" | "deno" => "node",
        "cargo" => "rust",
        _ => "other",
    }
}

#[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");
    }

    #[test]
    fn language_hint_recognises_common_interpreters() {
        assert_eq!(language_hint_for("/usr/bin/python3"), "python");
        assert_eq!(language_hint_for("uv"), "python");
        assert_eq!(language_hint_for("C:\\Windows\\node.exe"), "node");
        assert_eq!(language_hint_for("cargo"), "rust");
        assert_eq!(language_hint_for("/opt/weirdtool/bin/run"), "other");
    }

    #[test]
    fn tool_process_started_carries_required_fields() {
        let e = Event::tool_process_started("bnd_42", "python", 0);
        assert_eq!(e.name, "tool_process_started");
        assert_eq!(e.properties["binding_id"], "bnd_42");
        assert_eq!(e.properties["language_hint"], "python");
        assert_eq!(e.properties["restart_count_at_start"], 0);
    }
}