openlatch-provider 0.2.1

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Direct HTTP poster for the PostHog `/batch/` endpoint.
//!
//! Why not `posthog-rs`: the official crate is < 1.0 and reshapes its API on
//! every minor bump. The endpoint contract itself is documented and stable
//! (<https://posthog.com/docs/api/capture#batch-events>).
//!
//! Failure mode: silent. No retry, no disk queue, no telemetry-about-telemetry
//! events. The handle stamps `last_sent_unix` regardless of HTTP outcome.

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);

pub fn posthog_key() -> &'static str {
    static KEY: OnceLock<String> = OnceLock::new();
    KEY.get_or_init(|| {
        std::env::var("OPENLATCH_PROVIDER_POSTHOG_KEY")
            .unwrap_or_else(|_| env!("OPENLATCH_PROVIDER_POSTHOG_KEY").to_string())
    })
}

pub fn key_is_present() -> bool {
    !posthog_key().is_empty()
}

pub fn posthog_host() -> String {
    std::env::var("OPENLATCH_PROVIDER_POSTHOG_HOST")
        .unwrap_or_else(|_| "https://eu.i.posthog.com".to_string())
}

pub fn build_client() -> Option<reqwest::Client> {
    reqwest::Client::builder()
        .timeout(POST_TIMEOUT)
        .use_rustls_tls()
        .build()
        .ok()
}

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. Prefer the post-auth
    // `editor_id` if it's been merged in by the identity layer; otherwise
    // fall back to the pre-auth `machine_id`.
    let distinct_id = event
        .properties
        .get("editor_id")
        .and_then(|v| v.as_str())
        .or_else(|| event.properties.get("machine_id").and_then(|v| v.as_str()))
        .unwrap_or("mach_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, machine: &str) -> QueuedEvent {
        let mut props = Map::new();
        props.insert("machine_id".into(), json!(machine));
        props.insert("os".into(), json!("linux-x64"));
        QueuedEvent {
            name: name.into(),
            properties: props,
        }
    }

    #[test]
    fn build_body_wraps_with_api_key_and_batch() {
        let events = vec![make_event("cli_command_invoked", "mach_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_command_invoked");
        assert_eq!(batch[0]["distinct_id"], "mach_a");
        assert!(batch[0]["timestamp"].is_string());
    }

    #[test]
    fn empty_batch_short_circuits() {
        let rt = tokio::runtime::Runtime::new().unwrap();
        let client = build_client().unwrap();
        assert!(rt.block_on(post_batch(&client, &[])));
    }
}