93 lines
2.8 KiB
Rust
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()
|
|
}
|
|
}
|