openlatch-client 0.1.13

The open-source security layer for AI agents — client forwarder
//! Telemetry subsystem — PostHog-backed anonymous usage analytics.
//!
//! Design brief: `.brainstorms/2026-04-13-posthog-client-telemetry.md`.
//!
//! Phase A (this task) ships the scaffold: consent resolver, `telemetry.json`
//! read/write, CLI surface, first-run notice, channel + background task. The
//! network path is stubbed — events emitted in debug mode go to stderr only.
//!
//! Phase B (Task 2) wires the real v1 event catalog at call sites.
//!
//! Phase C (Task 4) adds `$create_alias` identity stitching against the
//! platform's `user_db_id`.
//!
//! ## Invariants (non-negotiable)
//!
//! See §4.4 of the brainstorm. The short version:
//! - Zero network before consent (I1), zero code path when disabled (I2).
//! - Env vars are hard locks (I3).
//! - No retry, no disk queue (I4).
//! - One-command purge stops in-process capture immediately (I5).
//! - Build-time exclusion via `full-cli-no-telemetry` (I6).
//! - Observable state via `openlatch telemetry status` (I7).
//! - No telemetry about telemetry (I10).

pub mod client;
pub mod config;
pub mod consent;
pub mod events;
pub mod identity;
pub mod network;
pub mod super_props;

pub use client::TelemetryHandle;
pub use consent::{resolve, ConsentState, DecidedBy, Resolved};
pub use events::Event;

use std::path::{Path, PathBuf};
use std::sync::OnceLock;

/// Process-global telemetry handle. Set once by `init_global()` at the very
/// top of the binary's `main()`; read by `capture_global()` from anywhere in
/// the codebase. Designed for fire-and-forget instrumentation: callers do not
/// need to know whether telemetry is enabled, whether a runtime is present,
/// or whether the handle has been initialised at all.
static GLOBAL_HANDLE: OnceLock<TelemetryHandle> = OnceLock::new();

/// Canonical path to the consent file inside the openlatch directory.
pub fn consent_file_path(openlatch_dir: &Path) -> PathBuf {
    openlatch_dir.join("telemetry.json")
}

/// Whether this build links any PostHog client code. Phase A always returns
/// `true` when the module is compiled; Task 3 will tie this to the runtime
/// presence of a baked key, and the `full-cli-no-telemetry` feature will
/// compile a sibling module that returns `false` at the type level.
pub const fn build_includes_telemetry() -> bool {
    true
}

/// Initialise the telemetry subsystem.
///
/// Steps:
/// 1. Resolve consent from env vars + `telemetry.json`.
/// 2. If disabled OR baked key is empty, return a no-op handle (I1 / I2).
/// 3. Otherwise build super-properties and start the background task.
///
/// `baked_key_present` should be `true` when the release-build PostHog key
/// was baked in, or when a runtime `OPENLATCH_POSTHOG_KEY` override is set.
/// Phase A callers pass `false` unconditionally — no network path exists yet.
pub fn init(
    openlatch_dir: &Path,
    agent_id: String,
    authenticated: bool,
    baked_key_present: bool,
) -> TelemetryHandle {
    let resolved = resolve(&consent_file_path(openlatch_dir));
    let debug_stderr = std::env::var("OPENLATCH_TELEMETRY_DEBUG")
        .map(|v| !v.is_empty() && v != "0")
        .unwrap_or(false);

    let super_props = super_props::SuperProps::new(agent_id, authenticated);

    client::start(client::ClientConfig {
        resolved,
        super_props,
        debug_stderr,
        baked_key_present,
    })
}

/// Capture an event via a handle. Convenience wrapper over
/// [`TelemetryHandle::capture`] for symmetry with the `init` / `capture` /
/// `shutdown` public API described in the design doc.
pub fn capture(handle: &TelemetryHandle, event: Event) {
    handle.capture(event);
}

/// Install the process-global telemetry handle. Idempotent: subsequent calls
/// are ignored. Returns `true` if this call performed the install.
pub fn install_global(handle: TelemetryHandle) -> bool {
    GLOBAL_HANDLE.set(handle).is_ok()
}

/// Capture an event through the process-global handle, if one is installed.
/// Silent no-op when uninitialised — call sites do not need to check.
pub fn capture_global(event: Event) {
    if let Some(h) = GLOBAL_HANDLE.get() {
        h.capture(event);
    }
}

/// Emit a `hook_source_unknown` telemetry event. Convenience wrapper around
/// [`capture_global`] for the daemon's CloudEvents ingest handler — keeps the
/// call site readable and centralises the event-name contract in one place.
pub fn capture_hook_source_unknown(source: &str) {
    capture_global(Event::hook_source_unknown(source));
}

/// Emit a `hook_type_unknown` telemetry event.
pub fn capture_hook_type_unknown(type_str: &str) {
    capture_global(Event::hook_type_unknown(type_str));
}

/// Borrow the global handle. Most call sites should prefer `capture_global`.
pub fn global() -> Option<&'static TelemetryHandle> {
    GLOBAL_HANDLE.get()
}

/// Drain and shut down the telemetry subsystem.
///
/// Phase A: dropping the handle is sufficient — the background task observes
/// the closed channel and exits. Task 3 will add a bounded final-flush budget
/// for the real POST path. Kept as a public function so call sites wire up
/// the final-drain point now (daemon graceful shutdown, CLI command exit).
pub async fn shutdown(_handle: TelemetryHandle) {
    // The handle's inner Arc goes out of scope when the last clone is dropped;
    // the batch task's `rx.recv()` then returns None and the loop exits. Phase
    // A has no network to flush, so there's nothing further to await here.
}

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

    #[test]
    fn test_init_without_baked_key_is_noop() {
        let tmp = TempDir::new().unwrap();
        let handle = init(tmp.path(), "agt_a".into(), false, false);
        assert!(!handle.is_enabled());
    }

    #[test]
    fn test_init_with_disabled_consent_is_noop_even_with_key() {
        let tmp = TempDir::new().unwrap();
        config::write_consent(&consent_file_path(tmp.path()), false).unwrap();
        let handle = init(tmp.path(), "agt_a".into(), false, true);
        assert!(!handle.is_enabled());
    }

    #[tokio::test]
    async fn test_init_enabled_consent_and_key_produces_live_handle() {
        // Note: env state is process-global. We don't mutate it here to avoid
        // racing with the consent module's tests. If the CI / disable env vars
        // happen to be set in this process, the handle will be a no-op and
        // we just skip the live-capture assertions.
        let tmp = TempDir::new().unwrap();
        config::write_consent(&consent_file_path(tmp.path()), true).unwrap();
        let handle = init(tmp.path(), "agt_a".into(), false, true);
        if handle.is_enabled() {
            capture(&handle, Event::cli_initialized("claude-code", 1, true));
            tokio::task::yield_now().await;
            assert_eq!(handle.events_captured(), 1);
            shutdown(handle).await;
        }
    }
}