Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
1use std::path::Path;
2use uaparser::{Parser, UserAgentParser};
3
4pub struct UaParser {
5 parser: Option<UserAgentParser>,
6}
7
8#[derive(Debug, Clone, Default)]
9pub struct ParsedUa {
10 pub platform: Option<String>,
11 pub browser: Option<String>,
12 pub device: Option<String>, // Mobile | Tablet | Desktop
13 pub is_bot: bool,
14 pub bot_name: Option<String>,
15}
16
17impl UaParser {
18 /// Loads regexes.yaml from `data_dir/regexes.yaml` if present, else falls
19 /// back to a substring heuristic. Use `ensure_regexes` to download.
20 pub fn load(path: &std::path::Path) -> Self {
21 if path.exists() {
22 if let Some(parser) = try_load(path) {
23 tracing::info!("uaparser regexes loaded from {}", path.display());
24 return Self { parser: Some(parser) };
25 }
26 }
27 tracing::warn!(
28 "uaparser regexes.yaml not found — ua parsing falls back to a substring heuristic until refresh"
29 );
30 Self { parser: None }
31 }
32
33 pub fn reload(&mut self, path: &std::path::Path) {
34 if path.exists() {
35 if let Some(parser) = try_load(path) {
36 self.parser = Some(parser);
37 }
38 }
39 }
40
41 pub fn parse(&self, ua: &str) -> ParsedUa {
42 if let Some(parser) = &self.parser {
43 let client = parser.parse(ua);
44 let platform = match client.os.family.as_ref() {
45 "Other" => None,
46 other => Some(other.to_string()),
47 };
48 let browser = match client.user_agent.family.as_ref() {
49 "Other" => None,
50 other => Some(other.to_string()),
51 };
52 let device_family = client.device.family.as_ref();
53 let is_bot = matches!(
54 device_family,
55 "Spider" | "Spider Desktop" | "Spider Smartphone" | "Spider Tablet"
56 ) || classify_bot_by_ua(ua);
57
58 let device = if is_bot {
59 None
60 } else {
61 Some(classify_device(ua, device_family).to_string())
62 };
63 let bot_name = if is_bot {
64 Some(client.user_agent.family.to_string()).filter(|s| s != "Other")
65 } else {
66 None
67 };
68 return ParsedUa { platform, browser, device, is_bot, bot_name };
69 }
70 // Fallback heuristic so the collector still works without regexes.yaml.
71 let is_bot = classify_bot_by_ua(ua);
72 let device = if is_bot { None } else { Some(classify_device(ua, "").to_string()) };
73 ParsedUa {
74 platform: None,
75 browser: None,
76 device,
77 is_bot,
78 bot_name: if is_bot { Some("Unknown bot".to_string()) } else { None },
79 }
80 }
81}
82
83fn try_load(path: &Path) -> Option<UserAgentParser> {
84 UserAgentParser::builder()
85 .with_unicode_support(false)
86 .build_from_yaml(path.to_string_lossy().as_ref())
87 .ok()
88}
89
90/// Download the canonical ua-parser regexes.yaml on first boot if missing.
91/// Source: https://github.com/ua-parser/uap-core (Apache-2.0).
92pub async fn ensure_regexes(dest: &Path) -> anyhow::Result<bool> {
93 if dest.exists() {
94 return Ok(false);
95 }
96 let url = "https://raw.githubusercontent.com/ua-parser/uap-core/master/regexes.yaml";
97 let bytes = reqwest::get(url).await?.error_for_status()?.bytes().await?;
98 if let Some(parent) = dest.parent() {
99 std::fs::create_dir_all(parent)?;
100 }
101 std::fs::write(dest, &bytes)?;
102 tracing::info!("downloaded ua-parser regexes to {}", dest.display());
103 Ok(true)
104}
105
106fn classify_bot_by_ua(ua: &str) -> bool {
107 let ua = ua.to_ascii_lowercase();
108 const NEEDLES: &[&str] = &[
109 "bot", "crawl", "spider", "slurp", "facebookexternalhit", "ahrefs", "semrush",
110 "petalbot", "yandex", "bingpreview", "duckduckgo", "discordbot", "whatsapp",
111 "telegrambot", "applebot", "linkedinbot", "embedly", "headlesschrome",
112 "phantomjs", "lighthouse", "pingdom", "uptimerobot", "monitor",
113 ];
114 NEEDLES.iter().any(|n| ua.contains(n))
115}
116
117fn classify_device(ua: &str, family: &str) -> &'static str {
118 let lower = ua.to_ascii_lowercase();
119 let tablet = matches!(family, "iPad" | "Tablet")
120 || lower.contains("tablet")
121 || lower.contains("ipad");
122 if tablet {
123 return "Tablet";
124 }
125 let mobile = matches!(family, "iPhone" | "iPod" | "Generic Smartphone")
126 || lower.contains("mobile")
127 || lower.contains("iphone")
128 || lower.contains("android");
129 if mobile {
130 "Mobile"
131 } else {
132 "Desktop"
133 }
134}