use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::{Path, PathBuf};
use traverse_contracts::{NoOpUsageTelemetrySink, UsageEvent, UsageEventKind, UsageTelemetrySink};
use traverse_runtime::{
LocalExecutor, Runtime, RuntimeExecutionOutcome, RuntimeRequest, RuntimeResultStatus,
};
use uuid::Uuid;
const CONFIG_FILE_NAME: &str = "cli-config.json";
const SEND_TIMEOUT_SECS: &str = "2";
const DEFAULT_TELEMETRY_ENDPOINT: &str = "https://us.i.posthog.com/i/v0/e/";
const DEFAULT_TELEMETRY_API_KEY: &str = "phc_sfxB4CGzDYn346ntzf685P7UWMJ28gtHTGJxfWRNUcEF";
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct TelemetryConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub install_id: Option<String>,
}
fn cli_home_dir() -> Result<PathBuf, String> {
if let Ok(override_dir) = std::env::var("TRAVERSE_HOME") {
return Ok(PathBuf::from(override_dir));
}
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| "neither HOME, USERPROFILE, nor TRAVERSE_HOME is set".to_string())?;
Ok(PathBuf::from(home).join(".traverse"))
}
fn default_config_path() -> Result<PathBuf, String> {
Ok(cli_home_dir()?.join(CONFIG_FILE_NAME))
}
fn load_telemetry_config_at(path: &Path) -> TelemetryConfig {
let Ok(contents) = std::fs::read_to_string(path) else {
return TelemetryConfig::default();
};
serde_json::from_str(&contents).unwrap_or_default()
}
fn write_telemetry_config_at(path: &Path, config: &TelemetryConfig) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
}
let serialized = serde_json::to_string_pretty(config)
.map_err(|error| format!("failed to serialize telemetry config: {error}"))?;
std::fs::write(path, format!("{serialized}\n"))
.map_err(|error| format!("failed to write {}: {error}", path.display()))
}
fn enable_telemetry_at(path: &Path) -> Result<TelemetryConfig, String> {
let mut config = load_telemetry_config_at(path);
config.enabled = true;
if config.install_id.is_none() {
config.install_id = Some(Uuid::new_v4().to_string());
}
write_telemetry_config_at(path, &config)?;
Ok(config)
}
fn disable_telemetry_at(path: &Path) -> Result<TelemetryConfig, String> {
let mut config = load_telemetry_config_at(path);
config.enabled = false;
write_telemetry_config_at(path, &config)?;
Ok(config)
}
#[must_use]
pub fn load_telemetry_config() -> TelemetryConfig {
match default_config_path() {
Ok(path) => load_telemetry_config_at(&path),
Err(_) => TelemetryConfig::default(),
}
}
pub fn enable_telemetry() -> Result<TelemetryConfig, String> {
enable_telemetry_at(&default_config_path()?)
}
pub fn disable_telemetry() -> Result<TelemetryConfig, String> {
disable_telemetry_at(&default_config_path()?)
}
fn event_kind_label(kind: UsageEventKind) -> &'static str {
match kind {
UsageEventKind::Resolve => "resolve",
UsageEventKind::Execute => "execute",
}
}
fn build_event_payload(api_key: &str, install_id: &str, event: &UsageEvent) -> Value {
serde_json::json!({
"api_key": api_key,
"event": event_kind_label(event.kind),
"distinct_id": install_id,
"timestamp": event.timestamp,
"properties": {
"capability_ref": event.capability_ref,
"install_id": install_id,
"$process_person_profile": false,
}
})
}
pub struct HttpUsageTelemetrySink {
endpoint: String,
api_key: String,
install_id: String,
}
impl HttpUsageTelemetrySink {
#[must_use]
pub fn new(endpoint: String, api_key: String, install_id: String) -> Self {
Self {
endpoint,
api_key,
install_id,
}
}
}
impl UsageTelemetrySink for HttpUsageTelemetrySink {
fn record(&self, event: UsageEvent) {
let payload = build_event_payload(&self.api_key, &self.install_id, &event);
let Ok(body) = serde_json::to_string(&payload) else {
return;
};
let _ = std::process::Command::new("curl")
.args([
"-fsSL",
"--max-time",
SEND_TIMEOUT_SECS,
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
&body,
&self.endpoint,
])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
}
fn wire_usage_telemetry_sink_from(
config: &TelemetryConfig,
endpoint: Option<String>,
api_key: Option<String>,
) -> Box<dyn UsageTelemetrySink> {
let (true, Some(install_id), Some(endpoint), Some(api_key)) =
(config.enabled, config.install_id.clone(), endpoint, api_key)
else {
return Box::new(NoOpUsageTelemetrySink);
};
Box::new(HttpUsageTelemetrySink::new(endpoint, api_key, install_id))
}
#[must_use]
pub fn wire_usage_telemetry_sink() -> Box<dyn UsageTelemetrySink> {
let endpoint = std::env::var("TRAVERSE_TELEMETRY_ENDPOINT")
.unwrap_or_else(|_| DEFAULT_TELEMETRY_ENDPOINT.to_string());
let api_key = std::env::var("TRAVERSE_TELEMETRY_API_KEY")
.unwrap_or_else(|_| DEFAULT_TELEMETRY_API_KEY.to_string());
wire_usage_telemetry_sink_from(&load_telemetry_config(), Some(endpoint), Some(api_key))
}
fn record_execute_event(
status: RuntimeResultStatus,
selected_capability_id: Option<&str>,
selected_capability_version: Option<&str>,
sink: &dyn UsageTelemetrySink,
) {
if status == RuntimeResultStatus::Error {
return;
}
let (Some(id), Some(version)) = (selected_capability_id, selected_capability_version) else {
return;
};
sink.record(UsageEvent {
kind: UsageEventKind::Execute,
capability_ref: format!("{id}@{version}"),
timestamp: Utc::now().to_rfc3339(),
});
}
pub fn execute_with_telemetry<E: LocalExecutor>(
runtime: &Runtime<E>,
request: RuntimeRequest,
sink: &dyn UsageTelemetrySink,
) -> RuntimeExecutionOutcome {
let outcome = runtime.execute(request);
record_execute_event(
outcome.result.status,
outcome.trace.selection.selected_capability_id.as_deref(),
outcome
.trace
.selection
.selected_capability_version
.as_deref(),
sink,
);
outcome
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
#[derive(Default)]
struct SpySink {
events: Arc<Mutex<Vec<UsageEvent>>>,
}
impl UsageTelemetrySink for SpySink {
fn record(&self, event: UsageEvent) {
self.events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(event);
}
}
fn unique_temp_config_path() -> PathBuf {
static NEXT_TEST_DIR: AtomicU64 = AtomicU64::new(1);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let sequence = NEXT_TEST_DIR.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("traverse-cli-telemetry-test-{nanos}-{sequence}"));
dir.join(CONFIG_FILE_NAME)
}
fn test_event(kind: UsageEventKind) -> UsageEvent {
UsageEvent {
kind,
capability_ref: "hello.world/say-hello@1.0.0".to_string(),
timestamp: "2026-08-04T00:00:00Z".to_string(),
}
}
#[test]
fn enable_generates_and_persists_install_id_once() {
let path = unique_temp_config_path();
let first = enable_telemetry_at(&path).expect("enable must succeed");
assert!(first.enabled);
let install_id = first.install_id.clone().expect("install id must be set");
let parsed = Uuid::parse_str(&install_id).expect("install id must be a valid UUID");
assert_eq!(parsed.get_version_num(), 4, "install id must be a v4 UUID");
assert!(
!install_id.contains(std::env::consts::OS),
"install id must not embed machine-identifying data"
);
let second = enable_telemetry_at(&path).expect("second enable must succeed");
assert_eq!(second.install_id, first.install_id);
let reloaded = load_telemetry_config_at(&path);
assert_eq!(reloaded, second);
}
#[test]
fn disable_keeps_install_id_but_clears_enabled_flag() {
let path = unique_temp_config_path();
let enabled = enable_telemetry_at(&path).expect("enable must succeed");
let disabled = disable_telemetry_at(&path).expect("disable must succeed");
assert!(!disabled.enabled);
assert_eq!(disabled.install_id, enabled.install_id);
let re_enabled = enable_telemetry_at(&path).expect("re-enable must succeed");
assert_eq!(re_enabled.install_id, enabled.install_id);
}
#[test]
fn load_defaults_to_disabled_when_config_file_is_missing_or_corrupt() {
let missing_path = unique_temp_config_path();
assert_eq!(
load_telemetry_config_at(&missing_path),
TelemetryConfig::default()
);
let corrupt_path = unique_temp_config_path();
if let Some(parent) = corrupt_path.parent() {
std::fs::create_dir_all(parent).expect("dir must create");
}
std::fs::write(&corrupt_path, b"not valid json").expect("corrupt file must write");
assert_eq!(
load_telemetry_config_at(&corrupt_path),
TelemetryConfig::default(),
"a corrupt config file must fail open to disabled, never accidentally enabled"
);
}
#[test]
fn build_event_payload_contains_exactly_the_fr_004_fields() {
let payload = build_event_payload(
"test-key",
"install-123",
&test_event(UsageEventKind::Resolve),
);
assert_eq!(payload["api_key"], "test-key");
assert_eq!(payload["event"], "resolve");
assert_eq!(payload["distinct_id"], "install-123");
assert_eq!(
payload["timestamp"], "2026-08-04T00:00:00Z",
"PostHog only recognizes a top-level timestamp; a nested \
properties.timestamp is silently ignored in favor of \
server-side ingestion time"
);
let properties = payload["properties"]
.as_object()
.expect("properties must be an object");
assert_eq!(
properties.len(),
3,
"properties must contain exactly capability_ref, install_id, and \
$process_person_profile -- no more"
);
assert_eq!(properties["capability_ref"], "hello.world/say-hello@1.0.0");
assert_eq!(properties["install_id"], "install-123");
assert_eq!(
properties["$process_person_profile"], false,
"events must stay anonymous -- no PostHog person profile per install id"
);
let execute_payload = build_event_payload(
"test-key",
"install-123",
&test_event(UsageEventKind::Execute),
);
assert_eq!(execute_payload["event"], "execute");
}
#[test]
fn no_op_wiring_when_telemetry_disabled() {
let sink = wire_usage_telemetry_sink_from(
&TelemetryConfig {
enabled: false,
install_id: Some("install-123".to_string()),
},
Some("http://127.0.0.1:1/".to_string()),
Some("test-key".to_string()),
);
sink.record(test_event(UsageEventKind::Resolve));
}
#[test]
fn no_op_wiring_when_collector_is_unconfigured() {
let sink = wire_usage_telemetry_sink_from(
&TelemetryConfig {
enabled: true,
install_id: Some("install-123".to_string()),
},
None,
None,
);
sink.record(test_event(UsageEventKind::Resolve));
}
#[test]
fn no_op_wiring_when_enabled_but_install_id_missing() {
let sink = wire_usage_telemetry_sink_from(
&TelemetryConfig {
enabled: true,
install_id: None,
},
Some("http://127.0.0.1:1/".to_string()),
Some("test-key".to_string()),
);
sink.record(test_event(UsageEventKind::Resolve));
}
#[test]
fn real_sink_is_wired_when_enabled_and_configured() {
let sink = wire_usage_telemetry_sink_from(
&TelemetryConfig {
enabled: true,
install_id: Some("install-123".to_string()),
},
Some("http://127.0.0.1:1/".to_string()),
Some("test-key".to_string()),
);
sink.record(test_event(UsageEventKind::Execute));
}
#[test]
fn record_never_blocks_the_caller_even_when_the_collector_is_unreachable() {
let sink = HttpUsageTelemetrySink::new(
"http://127.0.0.1:1/".to_string(),
"test-key".to_string(),
"install-123".to_string(),
);
let started = Instant::now();
sink.record(test_event(UsageEventKind::Execute));
let elapsed = started.elapsed();
assert!(
elapsed.as_millis() < 500,
"record() must return immediately after spawning, not wait for the \
collector or its {SEND_TIMEOUT_SECS}s timeout; took {elapsed:?}"
);
}
#[test]
fn record_execute_event_fires_on_successful_completion() {
let spy = SpySink::default();
record_execute_event(
RuntimeResultStatus::Completed,
Some("hello.world.say-hello"),
Some("1.0.0"),
&spy,
);
let events = spy.events.lock().expect("lock must not be poisoned");
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, UsageEventKind::Execute);
assert_eq!(events[0].capability_ref, "hello.world.say-hello@1.0.0");
}
#[test]
fn record_execute_event_is_silent_on_error_status() {
let spy = SpySink::default();
record_execute_event(
RuntimeResultStatus::Error,
Some("hello.world.say-hello"),
Some("1.0.0"),
&spy,
);
assert!(
spy.events
.lock()
.expect("lock must not be poisoned")
.is_empty()
);
}
#[test]
fn record_execute_event_is_silent_when_no_capability_was_resolved() {
let spy = SpySink::default();
record_execute_event(RuntimeResultStatus::Completed, None, None, &spy);
assert!(
spy.events
.lock()
.expect("lock must not be poisoned")
.is_empty()
);
}
#[test]
fn wire_usage_telemetry_sink_reads_the_real_environment() {
let sink = wire_usage_telemetry_sink();
sink.record(test_event(UsageEventKind::Resolve));
}
}