perfect-postcode/server-rs/src/routes/telemetry.rs
2026-05-13 12:11:54 +01:00

93 lines
2.8 KiB
Rust

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<String>,
/// Sent once on first beacon: the document.referrer domain (or "direct")
#[serde(default)]
referrer: Option<String>,
}
pub async fn post_telemetry(
Extension(user): Extension<OptionalUser>,
headers: HeaderMap,
Json(payload): Json<TelemetryPayload>,
) -> 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()
}
}