openlatch-client 0.1.13

The open-source security layer for AI agents — client forwarder
//! 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 PostHog ingestion host. Runtime override wins so tests can
/// point at `wiremock`.
pub fn posthog_host() -> String {
    std::env::var("OPENLATCH_POSTHOG_HOST")
        .unwrap_or_else(|_| env!("OPENLATCH_POSTHOG_HOST").to_string())
}

/// Build a long-lived reqwest client. Reused across all batch POSTs so the
/// connection pool stays warm. `rustls-tls` is required by tech-stack rules.
pub fn build_client() -> Option<reqwest::Client> {
    reqwest::Client::builder()
        .timeout(POST_TIMEOUT)
        .use_rustls_tls()
        .build()
        .ok()
}

/// POST a batch to `{host}/batch/`. 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, batch: &[QueuedEvent]) -> bool {
    if batch.is_empty() {
        return true;
    }
    let url = format!("{}/batch/", posthog_host().trim_end_matches('/'));
    let body = build_body(batch);
    match client.post(&url).json(&body).send().await {
        Ok(resp) => resp.status().is_success(),
        Err(_) => false,
    }
}

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 {
    // PostHog requires `distinct_id` at the top level; we already merged
    // `agent_id` into properties via SuperProps and use it as the identity.
    let distinct_id = 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_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().unwrap();
        let ok = rt.block_on(post_batch(&client, &[]));
        assert!(ok);
    }
}