openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! Telemetry subsystem — PostHog-backed anonymous usage analytics.
//!
//! Design brief: `.brainstorms/2026-04-13-posthog-client-telemetry.md`.
//!
//! The subsystem is live: consent resolver, `telemetry.json` read/write, CLI
//! surface, first-run notice, channel + background task, and the PostHog
//! `/batch/` network poster (see `network.rs`). Events flow to PostHog when a
//! non-empty key is baked (or overridden) AND consent resolves enabled;
//! otherwise the handle is a no-op and no network is used. Debug mode
//! (`OPENLATCH_TELEMETRY_DEBUG=1`) additionally mirrors event envelopes to
//! stderr.
//!
//! ## 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. Returns `true` when the
/// module is compiled; the `full-cli-no-telemetry` feature compiles 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 —
/// it gates whether the network path is wired for this handle.
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()
}

/// Detect common CI environments (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`,
/// `CIRCLECI`, `JENKINS_URL`, `BUILDKITE`, `TF_BUILD`,
/// `TEAMCITY_VERSION`, `BITBUCKET_BUILD_NUMBER`). The auto-update
/// worker uses this to short-circuit in CI: every CI run installs from
/// scratch, so applying an update mid-run would only churn telemetry.
pub fn is_ci_environment() -> bool {
    consent::in_ci()
}

/// How long a shutdown path will wait for the last telemetry POST.
///
/// Bounded on purpose: a metric is never worth holding a daemon's exit open on
/// an unreachable network. `BATCH_INTERVAL` is 1s, so this is enough for a
/// flush that is merely slow and short enough that a dead endpoint costs less
/// than the socket timeout would.
pub const FLUSH_BUDGET: std::time::Duration = std::time::Duration::from_secs(2);

/// Flush the global handle's buffered events, waiting up to [`FLUSH_BUDGET`].
///
/// Call this on any path that is about to end the process. Without it a
/// terminal event — `daemon_stopped` above all — is enqueued and then lost
/// when the runtime is torn down, because the global handle's sender lives in
/// a `OnceLock` and so never closes to trigger the batch task's final drain.
///
/// Returns `false` if the flush did not complete in budget. Callers treat that
/// as information, not failure.
pub async fn flush_global() -> bool {
    match global() {
        Some(handle) => handle.flush(FLUSH_BUDGET).await,
        None => true,
    }
}

/// Drain and shut down the telemetry subsystem.
///
/// Flushes first, then lets the handle drop: the batch task observes the
/// closed channel and exits. Call sites that only own the global handle should
/// use [`flush_global`] instead — the global is never dropped.
pub async fn shutdown(handle: TelemetryHandle) {
    handle.flush(FLUSH_BUDGET).await;
    // Dropping `handle` closes this clone; the batch task exits once the last
    // sender is gone.
}

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