openlatch-client 0.5.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! PostHog `/batch/` HTTP poster.
//!
//! We POST directly rather than depend on `posthog-rs` (still <1.0, unstable
//! API). The endpoint contract is well-documented and stable:
//! <https://posthog.com/docs/api/capture#batch-events>.
//!
//! Body shape:
//! ```json
//! {
//!   "api_key": "phc_...",
//!   "batch": [
//!     {
//!       "event": "cli_initialized",
//!       "distinct_id": "agt_...",
//!       "properties": { ... },
//!       "timestamp": "2026-04-13T...Z"
//!     }
//!   ]
//! }
//! ```
//!
//! Failures are swallowed: events drop silently with no retry, no disk queue
//! (invariant I4). Telemetry never blocks anything else, never produces a
//! user-visible error.

use std::sync::OnceLock;
use std::time::Duration;

use serde_json::{json, Value};

use super::client::QueuedEvent;

const POST_TIMEOUT: Duration = Duration::from_secs(5);

/// Resolve the PostHog project key. Runtime env var wins (developer override),
/// otherwise fall back to the value baked at build time by `build.rs`.
pub fn posthog_key() -> &'static str {
    static KEY: OnceLock<String> = OnceLock::new();
    KEY.get_or_init(|| {
        std::env::var("OPENLATCH_POSTHOG_KEY")
            .unwrap_or_else(|_| env!("OPENLATCH_POSTHOG_KEY").to_string())
    })
}

/// True if a non-empty key is available — gates whether `init()` even bothers
/// to construct the network client (invariant I1).
pub fn key_is_present() -> bool {
    !posthog_key().is_empty()
}

/// Resolve the ingestion host from the platform origin `api_url`.
///
/// The default is the platform's own first-party proxy, `{api_url}/ingest`, which
/// forwards to PostHog unchanged. That is what lets a customer allowlist ONE FQDN for
/// everything this client sends. There is deliberately no fallback to PostHog
/// directly: a proxy that is down drops the event (I4), because a fallback would
/// reopen the second destination the proxy exists to close.
///
/// A non-empty `OPENLATCH_POSTHOG_HOST` wins so tests and the e2e suite can point at a
/// mock without rebuilding. An EMPTY override falls through rather than being
/// honoured. Both consumers read this one resolver — product telemetry and the crash
/// path — so an exported `OPENLATCH_POSTHOG_HOST=""` would otherwise send one of them
/// to `""` while the other kept working. One resolver, one behaviour, is the point.
pub fn resolve_host(api_url: &str) -> String {
    resolve_host_with(
        std::env::var("OPENLATCH_POSTHOG_HOST").ok().as_deref(),
        api_url,
    )
}

/// The resolver's logic, with the environment read hoisted out so it can be tested
/// without mutating process-global state.
///
/// A blank `api_url` — what `Config::load` yields for `OPENLATCH_API_URL=""` — is the
/// compiled-in platform origin, never an empty host and never a PostHog one.
pub(super) fn resolve_host_with(host_override: Option<&str>, api_url: &str) -> String {
    if let Some(host) = host_override.filter(|h| !h.is_empty()) {
        return host.to_string();
    }
    let api_url = api_url.trim();
    let origin = if api_url.is_empty() {
        crate::config::CloudConfig::default().api_url
    } else {
        api_url.to_string()
    };
    format!("{}/ingest", origin.trim_end_matches('/'))
}

/// The deployment environment this binary reports as.
///
/// A debug build is always `development`. A RELEASE build is `development` too when no
/// project key was baked — which is exactly what a developer's own `cargo build
/// --release` and every CI/e2e binary produce. Without that second clause every local
/// release build would report `production` and trip the per-occurrence alerting.
///
/// There is no `staging` for the client: one key is baked per release, and it is the
/// production project's.
pub fn environment() -> &'static str {
    if cfg!(debug_assertions) || env!("OPENLATCH_POSTHOG_KEY").is_empty() {
        "development"
    } else {
        "production"
    }
}

/// The git sha baked at build time, reported as the event's `release`.
pub fn release() -> &'static str {
    env!("OPENLATCH_RELEASE_SHA")
}

/// Build a long-lived reqwest client. Reused across all batch POSTs so the
/// connection pool stays warm.
///
/// `None` means "skip POSTs" — telemetry never fails a user command over its own
/// transport (I10), so a client we cannot build is a warning and nothing more.
pub fn build_client(egress: &crate::egress::EgressConfig) -> Option<reqwest::Client> {
    match crate::egress::build_client_with(
        crate::egress::Consumer::Telemetry,
        egress,
        crate::egress::Timeouts::total(POST_TIMEOUT),
    ) {
        Ok(c) => Some(c),
        Err(e) => {
            tracing::warn!(error = %e, "telemetry http client init failed; events will be dropped");
            None
        }
    }
}

/// POST a batch to `{host}/batch/`, `host` being what [`resolve_host`] returned.
/// Silent on failure (I4 — no retry, no log surface, no telemetry-about-telemetry).
/// Returns true if the POST succeeded at the HTTP level (2xx); used only to stamp
/// `last_sent_unix` in the handle.
pub async fn post_batch(client: &reqwest::Client, host: &str, batch: &[QueuedEvent]) -> bool {
    if batch.is_empty() {
        return true;
    }
    // Also enforce the no-key invariant at the transport boundary. Tests can
    // construct enabled handles without a baked key; never send those batches
    // to the production ingestion host with an empty credential.
    if !key_is_present() {
        return false;
    }
    let url = batch_url(host);
    let body = build_body(batch);
    match client.post(&url).json(&body).send().await {
        Ok(resp) => resp.status().is_success(),
        Err(_) => false,
    }
}

fn batch_url(host: &str) -> String {
    format!("{}/batch/", host.trim_end_matches('/'))
}

fn build_body(batch: &[QueuedEvent]) -> Value {
    let events: Vec<Value> = batch.iter().map(event_to_payload).collect();
    json!({
        "api_key": posthog_key(),
        "batch": events,
    })
}

fn event_to_payload(event: &QueuedEvent) -> Value {
    // Use the canonical identity captured with the event, including aliases.
    // agent_id remains a fallback for older queued-event producers.
    let distinct_id = event
        .properties
        .get("distinct_id")
        .or_else(|| event.properties.get("agent_id"))
        .and_then(|v| v.as_str())
        .unwrap_or("agt_unknown")
        .to_string();
    let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
    json!({
        "event": event.name,
        "distinct_id": distinct_id,
        "properties": event.properties,
        "timestamp": timestamp,
    })
}

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

    fn make_event(name: &str, agent: &str) -> QueuedEvent {
        let mut props = Map::new();
        props.insert("agent_id".into(), json!(agent));
        props.insert("os".into(), json!("linux-x64"));
        QueuedEvent {
            name: name.into(),
            properties: props,
        }
    }

    #[test]
    fn test_build_body_wraps_with_api_key_and_batch() {
        let events = vec![make_event("cli_initialized", "agt_a")];
        let body = build_body(&events);
        assert!(body["api_key"].is_string());
        let batch = body["batch"].as_array().unwrap();
        assert_eq!(batch.len(), 1);
        assert_eq!(batch[0]["event"], "cli_initialized");
        assert_eq!(batch[0]["distinct_id"], "agt_a");
        assert!(batch[0]["timestamp"].is_string());
    }

    #[test]
    fn test_event_to_payload_falls_back_when_agent_id_missing() {
        let mut props = Map::new();
        props.insert("os".into(), json!("linux-x64"));
        let ev = QueuedEvent {
            name: "test".into(),
            properties: props,
        };
        let p = event_to_payload(&ev);
        assert_eq!(p["distinct_id"], "agt_unknown");
    }

    #[test]
    fn test_batch_uses_canonical_user_and_organization() {
        let mut event = make_event("daemon_started", "agt_a");
        let mut props = super::super::super_props::SuperProps::new("agt_a".into(), true);
        props.user_db_id = Some("user_1".into());
        props.org_id = Some("org_1".into());
        props.merge_into(&mut event.properties);
        let body = build_body(&[event]);
        assert_eq!(body["batch"][0]["distinct_id"], "user_1");
        assert_eq!(body["batch"][0]["properties"]["agent_id"], "agt_a");
        assert_eq!(
            body["batch"][0]["properties"]["$groups"]["organization"],
            "org_1"
        );
    }

    #[test]
    fn test_alias_top_level_identity_matches_canonical_user() {
        let event = super::super::identity::create_alias_event("agt_a", "user_1");
        let mut props = event.properties;
        super::super::super_props::SuperProps::new("agt_a".into(), false).merge_into(&mut props);
        let payload = event_to_payload(&QueuedEvent {
            name: event.name,
            properties: props,
        });
        assert_eq!(payload["distinct_id"], "user_1");
        assert_eq!(payload["properties"]["distinct_id"], "user_1");
        assert_eq!(payload["properties"]["alias"], "agt_a");
    }

    #[test]
    fn test_post_batch_empty_short_circuits() {
        // No client needed: empty batch returns true without making a request.
        let rt = tokio::runtime::Runtime::new().unwrap();
        let client = build_client(&crate::egress::EgressConfig::direct()).unwrap();
        let ok = rt.block_on(post_batch(&client, "http://127.0.0.1:9", &[]));
        assert!(ok);
    }

    #[test]
    fn test_host_override_wins_over_the_platform_origin() {
        assert_eq!(
            resolve_host_with(Some("http://127.0.0.1:8123"), "https://app.openlatch.ai"),
            "http://127.0.0.1:8123"
        );
    }

    /// `OPENLATCH_POSTHOG_HOST=""` is the shell's way of neutralising a variable, so
    /// it must fall through to the derived host rather than become an empty one.
    #[test]
    fn test_empty_host_override_is_ignored() {
        assert_eq!(
            resolve_host_with(Some(""), "https://staging.example"),
            "https://staging.example/ingest"
        );
    }

    #[test]
    fn test_default_platform_origin_derives_the_first_party_ingest_host() {
        let default_api_url = crate::config::CloudConfig::default().api_url;
        assert_eq!(
            resolve_host_with(None, &default_api_url),
            "https://app.openlatch.ai/ingest"
        );
    }

    #[test]
    fn test_custom_platform_origin_with_and_without_trailing_slash() {
        for api_url in ["https://ol.corp.example", "https://ol.corp.example/"] {
            assert_eq!(
                resolve_host_with(None, api_url),
                "https://ol.corp.example/ingest",
                "api_url = {api_url:?}"
            );
        }
    }

    /// A blank `OPENLATCH_API_URL` reaches the resolver as a blank `api_url`, both from
    /// `Config::load` and from the load-failure fallback. It is the default origin —
    /// never `/ingest` on its own, and never a PostHog host.
    #[test]
    fn test_blank_platform_origin_is_the_default() {
        for api_url in ["", "   "] {
            assert_eq!(
                resolve_host_with(None, api_url),
                "https://app.openlatch.ai/ingest",
                "api_url = {api_url:?}"
            );
        }
    }

    #[test]
    fn test_batches_post_to_ingest_batch_on_the_platform() {
        assert_eq!(
            batch_url(&resolve_host_with(None, "https://app.openlatch.ai")),
            "https://app.openlatch.ai/ingest/batch/"
        );
    }
}