use std::collections::HashMap;
use std::env;
use std::sync::OnceLock;
const POSTHOG_HOST: &str = "https://us.i.posthog.com";
fn resolve_posthog_key() -> String {
if let Ok(key) = env::var("SOCHDB_POSTHOG_API_KEY") {
if !key.is_empty() {
return key;
}
}
option_env!("SOCHDB_POSTHOG_API_KEY")
.unwrap_or("")
.to_string()
}
fn resolve_posthog_host() -> String {
env::var("SOCHDB_POSTHOG_HOST").unwrap_or_else(|_| POSTHOG_HOST.to_string())
}
static ANALYTICS_DISABLED: OnceLock<bool> = OnceLock::new();
static ANONYMOUS_ID: OnceLock<String> = OnceLock::new();
fn env_is_truthy(name: &str) -> bool {
env::var(name)
.map(|v| {
let v = v.to_lowercase();
v == "true" || v == "1" || v == "yes" || v == "on"
})
.unwrap_or(false)
}
pub fn is_analytics_disabled() -> bool {
*ANALYTICS_DISABLED.get_or_init(|| {
if env_is_truthy("SOCHDB_DISABLE_ANALYTICS") {
return true;
}
!env_is_truthy("SOCHDB_ENABLE_ANALYTICS")
})
}
pub fn get_anonymous_id() -> &'static str {
ANONYMOUS_ID.get_or_init(|| {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
if let Ok(hostname) = hostname::get() {
hostname.to_string_lossy().hash(&mut hasher);
}
std::env::consts::OS.hash(&mut hasher);
std::env::consts::ARCH.hash(&mut hasher);
#[cfg(unix)]
{
unsafe {
libc::getuid().hash(&mut hasher);
}
}
format!("{:016x}", hasher.finish())
})
}
pub type EventProperties = HashMap<String, serde_json::Value>;
#[derive(Clone)]
pub struct Analytics {
api_key: String,
host: String,
disabled: bool,
}
impl Default for Analytics {
fn default() -> Self {
Self::new()
}
}
impl Analytics {
pub fn new() -> Self {
let api_key = resolve_posthog_key();
let disabled = is_analytics_disabled() || api_key.is_empty();
Self {
api_key,
host: resolve_posthog_host(),
disabled,
}
}
pub fn is_enabled(&self) -> bool {
!self.disabled
}
pub fn capture(&self, event: &str, properties: Option<EventProperties>) {
if self.disabled {
return;
}
let api_key = self.api_key.clone();
let host = self.host.clone();
let event = event.to_string();
let distinct_id = get_anonymous_id().to_string();
let mut event_properties = properties.unwrap_or_default();
event_properties.insert("sdk".to_string(), serde_json::json!("rust"));
event_properties.insert(
"sdk_version".to_string(),
serde_json::json!(env!("CARGO_PKG_VERSION")),
);
event_properties.insert("os".to_string(), serde_json::json!(std::env::consts::OS));
event_properties.insert(
"arch".to_string(),
serde_json::json!(std::env::consts::ARCH),
);
std::thread::spawn(move || {
let _ = send_event(&host, &api_key, &distinct_id, &event, event_properties);
});
}
pub fn capture_error(&self, error_type: &str, location: &str) {
let mut props = EventProperties::new();
props.insert("error_type".to_string(), serde_json::json!(error_type));
props.insert("location".to_string(), serde_json::json!(location));
self.capture("error", Some(props));
}
pub fn track_database_open(&self, db_path: &str, mode: &str) {
let mut props = EventProperties::new();
props.insert("mode".to_string(), serde_json::json!(mode));
props.insert(
"has_custom_path".to_string(),
serde_json::json!(db_path != ":memory:"),
);
self.capture("database_opened", Some(props));
}
}
fn send_event(
host: &str,
api_key: &str,
distinct_id: &str,
event: &str,
properties: EventProperties,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut event_properties = properties;
event_properties.insert("$lib".to_string(), serde_json::json!("sochdb-rust"));
let payload = serde_json::json!({
"api_key": api_key,
"event": event,
"properties": event_properties,
"distinct_id": distinct_id,
});
let url = format!("{}/capture/", host);
#[cfg(feature = "analytics")]
{
ureq::post(&url)
.set("Content-Type", "application/json")
.send_json(&payload)?;
}
Ok(())
}
static GLOBAL_ANALYTICS: OnceLock<Analytics> = OnceLock::new();
pub fn analytics() -> &'static Analytics {
GLOBAL_ANALYTICS.get_or_init(Analytics::new)
}
pub fn capture(event: &str, properties: Option<EventProperties>) {
analytics().capture(event, properties);
}
pub fn capture_error(error_type: &str, location: &str) {
analytics().capture_error(error_type, location);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_analytics_disabled_default() {
let result = env::var("SOCHDB_DISABLE_ANALYTICS")
.map(|v| {
let v = v.to_lowercase();
v == "true" || v == "1" || v == "yes" || v == "on"
})
.unwrap_or(false);
assert!(result == true || result == false);
}
#[test]
fn test_anonymous_id_is_stable() {
let id1 = get_anonymous_id();
let id2 = get_anonymous_id();
assert_eq!(id1, id2);
assert_eq!(id1.len(), 16);
}
#[test]
fn test_analytics_disabled_no_op() {
let analytics = Analytics {
api_key: "test".to_string(),
host: "http://localhost".to_string(),
disabled: true,
};
analytics.capture("test_event", None);
}
}