use std::sync::OnceLock;
use std::time::Duration;
use chrono::Utc;
use serde_json::{Value, json};
use tracing::{debug, warn};
use umbral::plugin::PluginError;
use umbral::prelude::*;
pub const DEFAULT_POSTHOG_HOST: &str = "https://us.i.posthog.com";
const HTTP_TIMEOUT_SECS: u64 = 10;
const HTTP_CONNECT_TIMEOUT_SECS: u64 = 5;
static AMBIENT_CLIENT: OnceLock<AnalyticsClient> = OnceLock::new();
pub fn ambient_client() -> Option<&'static AnalyticsClient> {
AMBIENT_CLIENT.get()
}
static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
const MAX_CONCURRENT_ANALYTICS_SENDS: usize = 64;
static SEND_SLOTS: OnceLock<std::sync::Arc<tokio::sync::Semaphore>> = OnceLock::new();
fn send_slots() -> &'static std::sync::Arc<tokio::sync::Semaphore> {
SEND_SLOTS.get_or_init(|| {
std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYTICS_SENDS))
})
}
pub fn http_client() -> reqwest::Client {
HTTP_CLIENT
.get_or_init(|| {
reqwest::Client::builder()
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
.connect_timeout(Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECS))
.build()
.expect("failed to build the shared analytics HTTP client")
})
.clone()
}
#[derive(Clone, Debug)]
pub struct AnalyticsClient {
api_key: String,
host: String,
exclude_prefixes: Vec<String>,
}
impl AnalyticsClient {
pub fn new(api_key: impl Into<String>, host: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
host: host.into(),
exclude_prefixes: Vec::new(),
}
}
pub fn with_exclude_prefixes(mut self, prefixes: Vec<String>) -> Self {
self.exclude_prefixes = prefixes;
self
}
pub fn should_capture_path(&self, path: &str) -> bool {
!self
.exclude_prefixes
.iter()
.any(|p| path.starts_with(p.as_str()))
}
pub fn scrub_path(path: &str) -> String {
path.split('/')
.map(scrub_segment)
.collect::<Vec<_>>()
.join("/")
}
pub fn build_payload(&self, distinct_id: &str, event: &str, properties: Value) -> Value {
json!({
"api_key": self.api_key,
"event": event,
"distinct_id": distinct_id,
"properties": properties,
"timestamp": Utc::now().to_rfc3339(),
})
}
pub fn capture_fire_and_forget(
&self,
distinct_id: impl Into<String>,
event: impl Into<String>,
properties: Value,
) {
let permit = match send_slots().clone().try_acquire_owned() {
Ok(p) => p,
Err(_) => {
debug!("analytics: concurrent-send limit reached; dropping event");
return;
}
};
let payload = self.build_payload(&distinct_id.into(), &event.into(), properties);
let url = format!("{}/capture/", self.host.trim_end_matches('/'));
let client = http_client();
tokio::spawn(async move {
let _permit = permit; match client.post(&url).json(&payload).send().await {
Ok(resp) if resp.status().is_success() => {
debug!(url = %url, "analytics: event captured");
}
Ok(resp) => {
warn!(
url = %url,
status = %resp.status(),
"analytics: PostHog returned non-success status (swallowed)"
);
}
Err(e) => {
warn!(
url = %url,
error = %e,
"analytics: PostHog send failed (swallowed)"
);
}
}
});
}
}
fn scrub_segment(seg: &str) -> &str {
if seg.is_empty() {
return seg;
}
if seg.contains('@') {
return ":email";
}
if seg.len() == 36
&& seg.as_bytes().iter().enumerate().all(|(i, &b)| match i {
8 | 13 | 18 | 23 => b == b'-',
_ => b.is_ascii_hexdigit(),
})
{
return ":uuid";
}
if seg.bytes().all(|b| b.is_ascii_digit()) {
return ":id";
}
if seg.len() >= 24
&& seg
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'='))
&& seg.bytes().any(|b| b.is_ascii_digit())
&& seg.bytes().any(|b| b.is_ascii_alphabetic())
{
return ":token";
}
seg
}
pub async fn capture(distinct_id: impl Into<String>, event: impl Into<String>, properties: Value) {
if let Some(client) = ambient_client() {
client.capture_fire_and_forget(distinct_id, event, properties);
} else {
debug!("analytics: capture called with no client installed (no-op)");
}
}
pub async fn identify(distinct_id: impl Into<String>, properties: Value) {
if let Some(client) = ambient_client() {
client.capture_fire_and_forget(distinct_id, "$identify", properties);
} else {
debug!("analytics: identify called with no client installed (no-op)");
}
}
async fn pageview_middleware(
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
let path = req.uri().path().to_string();
let method = req.method().to_string();
let response = next.run(req).await;
let status = response.status().as_u16();
if let Some(client) = ambient_client() {
if client.should_capture_path(&path) {
let scrubbed = AnalyticsClient::scrub_path(&path);
let props = json!({
"path": scrubbed,
"method": method,
"status": status,
"$current_url": scrubbed,
});
client.capture_fire_and_forget("anonymous", "$pageview", props);
}
}
response
}
pub struct AnalyticsPlugin {
api_key: Option<String>,
host: String,
auto_capture_requests: bool,
exclude_prefixes: Vec<String>,
}
impl AnalyticsPlugin {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: Some(api_key.into()),
host: DEFAULT_POSTHOG_HOST.to_string(),
auto_capture_requests: false,
exclude_prefixes: Vec::new(),
}
}
pub fn from_env() -> Self {
Self::default()
}
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = host.into();
self
}
pub fn capture_requests(mut self) -> Self {
self.auto_capture_requests = true;
self
}
pub fn exclude_path_prefix(mut self, prefix: impl Into<String>) -> Self {
self.exclude_prefixes.push(prefix.into());
self
}
fn resolve_api_key(&self) -> Option<String> {
if let Some(ref key) = self.api_key {
if !key.trim().is_empty() {
return Some(key.clone());
}
}
if let Ok(val) = std::env::var("UMBRAL_POSTHOG_API_KEY") {
if !val.trim().is_empty() {
return Some(val);
}
}
if let Ok(settings) = umbral::Settings::from_env() {
if let Some(v) = settings.extra.get("posthog_api_key") {
if let Some(key) = v.as_str() {
if !key.trim().is_empty() {
return Some(key.to_string());
}
}
}
}
None
}
fn resolve_host(&self) -> String {
if self.host != DEFAULT_POSTHOG_HOST {
return self.host.clone();
}
if let Ok(val) = std::env::var("UMBRAL_POSTHOG_HOST") {
if !val.trim().is_empty() {
return val;
}
}
if let Ok(settings) = umbral::Settings::from_env() {
if let Some(v) = settings.extra.get("posthog_host") {
if let Some(h) = v.as_str() {
if !h.trim().is_empty() {
return h.to_string();
}
}
}
}
DEFAULT_POSTHOG_HOST.to_string()
}
}
impl Default for AnalyticsPlugin {
fn default() -> Self {
Self {
api_key: None,
host: DEFAULT_POSTHOG_HOST.to_string(),
auto_capture_requests: false,
exclude_prefixes: Vec::new(),
}
}
}
impl Plugin for AnalyticsPlugin {
fn name(&self) -> &'static str {
"analytics"
}
fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {
match self.resolve_api_key() {
Some(key) => {
let host = self.resolve_host();
let client = AnalyticsClient::new(key, host.clone())
.with_exclude_prefixes(self.exclude_prefixes.clone());
if AMBIENT_CLIENT.set(client).is_err() {
warn!(
"AnalyticsPlugin: an ambient analytics client was already installed; \
ignoring this registration."
);
} else {
tracing::info!(host = %host, "analytics: PostHog client installed");
}
}
None => {
warn!(
"AnalyticsPlugin registered with no PostHog API key. Set \
UMBRAL_POSTHOG_API_KEY or pass an explicit key via \
AnalyticsPlugin::new(key). Capture calls will be silent no-ops."
);
}
}
Ok(())
}
fn wrap_router(&self, router: Router) -> Router {
if self.auto_capture_requests {
router.layer(axum::middleware::from_fn(pageview_middleware))
} else {
router
}
}
}
#[cfg(test)]
mod tests {
use super::AnalyticsClient;
#[test]
fn outbound_sends_are_concurrency_bounded() {
let sem = super::send_slots();
assert_eq!(
sem.available_permits(),
super::MAX_CONCURRENT_ANALYTICS_SENDS
);
let mut held = Vec::new();
for _ in 0..super::MAX_CONCURRENT_ANALYTICS_SENDS {
held.push(sem.clone().try_acquire_owned().expect("permit"));
}
assert!(
sem.clone().try_acquire_owned().is_err(),
"at capacity, a further send must be refused (dropped)"
);
}
#[test]
fn excluded_prefixes_are_not_captured() {
let client = AnalyticsClient::new("k", "https://h")
.with_exclude_prefixes(vec!["/reset-password".to_string(), "/verify".to_string()]);
assert!(!client.should_capture_path("/reset-password/abc123token"));
assert!(!client.should_capture_path("/verify/xyz"));
assert!(client.should_capture_path("/"));
assert!(client.should_capture_path("/pricing"));
let open = AnalyticsClient::new("k", "https://h");
assert!(open.should_capture_path("/reset-password/abc"));
}
#[test]
fn scrub_path_replaces_identifying_segments() {
use super::AnalyticsClient;
assert_eq!(AnalyticsClient::scrub_path("/orders/8412"), "/orders/:id");
assert_eq!(
AnalyticsClient::scrub_path("/users/ada@example.com/orders/9"),
"/users/:email/orders/:id"
);
assert_eq!(
AnalyticsClient::scrub_path("/t/550e8400-e29b-41d4-a716-446655440000"),
"/t/:uuid"
);
assert_eq!(
AnalyticsClient::scrub_path("/reset-password/aB3xK9zLmQ7pR2tV5wY8nC1dE4fG6hJ0"),
"/reset-password/:token"
);
assert_eq!(
AnalyticsClient::scrub_path("/blog/my-first-post"),
"/blog/my-first-post"
);
assert_eq!(AnalyticsClient::scrub_path("/"), "/");
assert_eq!(AnalyticsClient::scrub_path("/orders/8412/"), "/orders/:id/");
}
}