use axum::http::{HeaderMap, StatusCode}; use axum::response::Json; use axum::Extension; use metrics::{counter, gauge}; use serde::Deserialize; use crate::auth::OptionalUser; #[derive(Deserialize)] pub struct TelemetryPayload { session_seconds: u64, filter_count: u64, /// Sent once on first beacon: the entry page path #[serde(default)] entry_path: Option, /// Sent once on first beacon: the document.referrer domain (or "direct") #[serde(default)] referrer: Option, } pub async fn post_telemetry( Extension(user): Extension, headers: HeaderMap, Json(payload): Json, ) -> StatusCode { let user_label = match &user.0 { Some(u) => u.email.clone(), None => "anonymous".to_string(), }; let ua = headers .get("user-agent") .and_then(|v| v.to_str().ok()) .unwrap_or("unknown"); let browser = parse_browser(ua); gauge!("user_session_seconds", "user" => user_label.clone(), "browser" => browser.clone()) .set(payload.session_seconds as f64); gauge!("user_active_filters", "user" => user_label, "browser" => browser) .set(payload.filter_count as f64); // Entrypoint tracking (sent once per session) if let Some(path) = &payload.entry_path { let referrer = normalize_referrer_label(payload.referrer.as_deref().unwrap_or("direct")); counter!("entrypoint_total", "path" => normalize_entry_path(path), "referrer" => referrer.to_string()) .increment(1); } StatusCode::NO_CONTENT } /// Normalize entry paths to prevent cardinality explosion. /// Keep known routes, parameterize dynamic segments. fn normalize_entry_path(path: &str) -> String { match path { "/" | "/dashboard" | "/pricing" | "/learn" | "/saved" | "/invites" | "/account" => { path.to_string() } p if p.starts_with("/invite/") => "/invite/:code".to_string(), p if p.starts_with("/s/") => "/s/:code".to_string(), _ => "/other".to_string(), } } fn normalize_referrer_label(referrer: &str) -> String { let referrer = referrer.trim().trim_end_matches('.').to_ascii_lowercase(); if referrer.is_empty() || referrer == "direct" { return "direct".to_string(); } if referrer.len() > 120 || !referrer .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '.' || ch == '-') || referrer.split('.').any(str::is_empty) { return "other".to_string(); } referrer } fn parse_browser(ua: &str) -> String { if ua.contains("Firefox") { "Firefox".into() } else if ua.contains("Edg/") { "Edge".into() } else if ua.contains("Chrome") { "Chrome".into() } else if ua.contains("Safari") { "Safari".into() } else { "Other".into() } }