openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Telemetry — PostHog usage analytics + Sentry crash reports.
//!
//! Two **independent** subsystems with separate consent contracts:
//!   - PostHog: opt-IN (6-level consent precedence, `~/.openlatch/provider/telemetry.json`)
//!   - Sentry: opt-OUT (default on, `SENTRY_DISABLED=1` or
//!     `[crashreport] enabled = false`)
//!
//! Per `.claude/rules/telemetry.md`. Disabling one does not affect the other.

pub mod client;
pub mod consent;
pub mod consent_file;
pub mod events;
pub mod identity;
pub mod network;
pub mod sentry;
pub mod super_props;

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

/// PostHog ingestion key, baked at compile time via `build.rs`. Empty string
/// means "no key" — telemetry init returns a no-op handle.
pub const POSTHOG_KEY: &str = env!("OPENLATCH_PROVIDER_POSTHOG_KEY");

/// Sentry DSN, baked at compile time via `build.rs`. Empty string disables.
pub const SENTRY_DSN: &str = env!("OPENLATCH_PROVIDER_SENTRY_DSN");

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

/// Process-global telemetry handle. Set once by `install_global` at the very
/// top of `main()`; read by [`capture_global`] from anywhere in the codebase.
static GLOBAL_HANDLE: OnceLock<TelemetryHandle> = OnceLock::new();

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

/// Initialise the PostHog telemetry subsystem. Sentry is initialised
/// independently via [`sentry::init_if_enabled`].
///
/// Steps:
///   1. Resolve consent from env vars + telemetry.json.
///   2. If disabled OR baked key is empty, return a no-op handle.
///   3. Otherwise build super-props and start the background task.
pub fn init(provider_dir: &Path, machine_id: String, authenticated: bool) -> TelemetryHandle {
    let resolved = resolve(&consent_file_path(provider_dir));
    let debug_stderr = std::env::var("OPENLATCH_PROVIDER_TELEMETRY_DEBUG")
        .map(|v| !v.is_empty() && v != "0")
        .unwrap_or(false);
    let baked_key_present = network::key_is_present();
    let super_props = super_props::SuperProps::new(machine_id, authenticated);

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

/// Install the process-global handle. Idempotent — subsequent calls are
/// silently ignored.
pub fn install_global(handle: TelemetryHandle) -> bool {
    GLOBAL_HANDLE.set(handle).is_ok()
}

/// Capture an event through the process-global handle, if installed. Silent
/// no-op when uninitialised.
pub fn capture_global(event: Event) {
    if let Some(h) = GLOBAL_HANDLE.get() {
        h.capture(event);
    }
}

pub fn global() -> Option<&'static TelemetryHandle> {
    GLOBAL_HANDLE.get()
}

/// Drain and shut the subsystem down. Dropping the last clone of the handle
/// closes the channel; the batch loop sees `recv -> None` and exits. Phase 1
/// has no bounded-flush budget — P3 will add one for the runtime daemon.
pub async fn shutdown(_handle: TelemetryHandle) {
    // The handle's inner Arc goes out of scope when the last clone drops.
    // No explicit await needed in P1.
}

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

    #[test]
    fn init_without_baked_key_is_noop() {
        let tmp = TempDir::new().unwrap();
        let handle = init(tmp.path(), "mach_a".into(), false);
        // The baked key is empty during cargo test — handle is a no-op.
        assert!(!handle.is_enabled() || network::key_is_present());
    }

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