use std::future::Future;
use std::pin::Pin;
use std::sync::OnceLock;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
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_POSTHOG_KEY")
.unwrap_or_else(|_| env!("OPENLATCH_POSTHOG_KEY").to_string())
})
}
pub fn key_is_present() -> bool {
!posthog_key().is_empty()
}
pub fn resolve_host(api_url: &str) -> String {
resolve_host_with(
std::env::var("OPENLATCH_POSTHOG_HOST").ok().as_deref(),
api_url,
)
}
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('/'))
}
pub fn environment() -> &'static str {
if cfg!(debug_assertions) || env!("OPENLATCH_POSTHOG_KEY").is_empty() {
"development"
} else {
"production"
}
}
pub const ENV_DEVELOPMENT: &str = "development";
pub fn may_send_to(environment: &str, host: &str) -> bool {
environment != ENV_DEVELOPMENT || host_is_loopback(host)
}
fn host_is_loopback(host: &str) -> bool {
let Ok(url) = reqwest::Url::parse(host) else {
return false;
};
let Some(hostname) = url.host_str() else {
return false;
};
let hostname = hostname.trim_matches(['[', ']']);
hostname.eq_ignore_ascii_case("localhost")
|| hostname.to_ascii_lowercase().ends_with(".localhost")
|| hostname
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
}
pub fn release() -> &'static str {
env!("OPENLATCH_RELEASE_SHA")
}
pub fn build_client(egress: &crate::egress::EgressConfig) -> Option<reqwest::Client> {
match crate::egress::build_client(crate::egress::Consumer::Telemetry, egress) {
Ok(c) => Some(c),
Err(e) => {
tracing::warn!(error = %e, "telemetry http client init failed; events will be dropped");
None
}
}
}
pub async fn post_batch(client: &reqwest::Client, host: &str, batch: &[QueuedEvent]) -> bool {
if batch.is_empty() {
return true;
}
if !key_is_present() {
return false;
}
if !may_send_to(environment(), host) {
return false;
}
let url = batch_url(host);
let body = build_body(batch);
match WaitBudget::new(client.post(&url).json(&body).send(), POST_TIMEOUT).await {
Some(Ok(resp)) => resp.status().is_success(),
Some(Err(_)) | None => false,
}
}
struct WaitBudget<F> {
inner: Pin<Box<F>>,
deadline: Pin<Box<tokio::time::Sleep>>,
}
impl<F: Future> WaitBudget<F> {
fn new(inner: F, budget: Duration) -> Self {
Self {
inner: Box::pin(inner),
deadline: Box::pin(tokio::time::sleep(budget)),
}
}
}
impl<F: Future> Future for WaitBudget<F> {
type Output = Option<F::Output>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let polled_at = Instant::now();
if let Poll::Ready(out) = self.inner.as_mut().poll(cx) {
return Poll::Ready(Some(out));
}
let deadline = self.deadline.deadline() + polled_at.elapsed();
self.deadline.as_mut().reset(deadline);
self.deadline.as_mut().poll(cx).map(|()| None)
}
}
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 {
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() {
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);
}
struct BlocksThenWaits {
blocked: bool,
block_for: Duration,
answer_at: Option<tokio::time::Instant>,
}
impl Future for BlocksThenWaits {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if !self.blocked {
self.blocked = true;
std::thread::sleep(self.block_for);
self.answer_at = Some(tokio::time::Instant::now() + Duration::from_millis(50));
}
let answer_at = self.answer_at.expect("set on first poll");
if tokio::time::Instant::now() >= answer_at {
return Poll::Ready("answered");
}
let waker = cx.waker().clone();
tokio::spawn(async move {
tokio::time::sleep_until(answer_at).await;
waker.wake();
});
Poll::Pending
}
}
#[tokio::test]
async fn test_wait_budget_does_not_charge_time_spent_inside_a_poll() {
let budget = Duration::from_millis(200);
let blocking = BlocksThenWaits {
blocked: false,
block_for: budget * 3,
answer_at: None,
};
assert_eq!(WaitBudget::new(blocking, budget).await, Some("answered"));
}
#[tokio::test]
async fn test_wait_budget_still_bounds_a_peer_that_never_answers() {
let budget = Duration::from_millis(200);
let started = Instant::now();
assert_eq!(
WaitBudget::new(std::future::pending::<()>(), budget).await,
None
);
assert!(
started.elapsed() < budget * 10,
"the budget did not bound an idle wait: {:?}",
started.elapsed()
);
}
#[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"
);
}
#[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:?}"
);
}
}
#[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_a_development_build_may_send_to_loopback_only() {
for host in [
"http://127.0.0.1:8123",
"http://127.0.0.1:8123/ingest",
"http://localhost:9",
"http://LocalHost:9/ingest",
"http://az-qa-1.localhost:20313/ingest",
"http://127.9.9.9",
"http://[::1]:8000/ingest",
"http://127.0.0.1:34567/ingest",
] {
assert!(
may_send_to(ENV_DEVELOPMENT, host),
"loopback must stay reachable: {host:?}"
);
}
for host in [
"https://eu.i.posthog.com",
"https://us.i.posthog.com",
"https://app.openlatch.ai/ingest",
"https://ol.corp.example/ingest",
"https://127.0.0.1.evil.example/ingest",
"",
] {
assert!(
!may_send_to(ENV_DEVELOPMENT, host),
"a development build must not send to {host:?}"
);
}
}
#[test]
fn test_a_production_build_sends_wherever_the_host_resolves() {
for host in [
"https://app.openlatch.ai/ingest",
"https://eu.i.posthog.com",
"http://127.0.0.1:8123",
"",
] {
assert!(may_send_to("production", host), "host = {host:?}");
}
}
#[test]
fn test_an_empty_host_override_does_not_open_the_development_gate() {
assert!(!may_send_to(
ENV_DEVELOPMENT,
&resolve_host_with(Some(""), "https://app.openlatch.ai")
));
}
#[test]
fn test_host_is_loopback_uses_the_transport_url_parser() {
assert!(host_is_loopback("http://127.0.0.1:8000"));
assert!(host_is_loopback("http://user:pw@127.0.0.1:8000/ingest"));
assert!(host_is_loopback("http://[::1]"));
assert!(host_is_loopback("http://az-qa-1.localhost:20313"));
assert!(!host_is_loopback("http://127.0.0.1.example.com/ingest"));
assert!(!host_is_loopback("https://example.com/127.0.0.1"));
assert!(!host_is_loopback("https://example.com/?h=localhost"));
assert!(!host_is_loopback(
"http://evil.example\\@127.0.0.1:8123/ingest"
));
assert!(!host_is_loopback("http://az-qa-1.localhost.evil.example"));
assert!(!host_is_loopback("http://[::1"));
assert!(!host_is_loopback(""));
}
#[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/"
);
}
}