1use std::collections::hash_map::RandomState;
23use std::collections::{BTreeMap, HashMap, VecDeque};
24use std::hash::{BuildHasher, Hash, Hasher};
25use std::net::IpAddr;
26use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
27use std::sync::{Mutex, OnceLock, RwLock};
28use std::time::{Duration, SystemTime, UNIX_EPOCH};
29
30use super::{classify, Class, Group, Kind};
31
32pub const MAX_HOSTS: usize = 16;
35pub const MAX_SLOTS: usize = 255;
37pub const TOP: usize = 200;
39pub const TOP_SAVED: usize = 100;
41pub const MAX_PENDING: usize = 4096;
43pub const MAX_CACHE: usize = 16384;
45pub const UNDECIDED: Duration = Duration::from_secs(120);
48const MAX_PENDING_REQUESTS: usize = 32;
51const SHARDS: usize = 16;
52
53const PEOPLE: usize = 0;
56const AI: usize = 1;
57const BOTS: usize = 2;
58const APPS: usize = 3;
59const OWNER: usize = 4;
60pub const GROUPS: usize = 5;
62
63fn slot(group: Group) -> Option<usize> {
64 match group {
65 Group::Person => Some(PEOPLE),
66 Group::Ai => Some(AI),
67 Group::Bot => Some(BOTS),
68 Group::App => Some(APPS),
69 Group::Owner => Some(OWNER),
70 Group::Internal => None,
71 }
72}
73
74#[derive(Clone, Debug)]
76#[doc(hidden)]
77pub struct Hit<'a> {
78 pub ip: IpAddr,
79 pub host: &'a str,
80 pub user_agent: Option<&'a str>,
81 pub referer: Option<&'a str>,
82 pub method: &'a str,
83 pub path: &'a str,
84 pub status: i16,
85 pub content_type: Option<&'a str>,
87 pub headers: &'a [crate::header::Header],
91 pub now: u64,
92}
93
94pub const DIMS: [&str; 8] = ["countries", "browsers", "os", "devices", "channels", "utm_source", "utm_medium", "utm_campaign"];
99const COUNTRIES: usize = 0;
100const CHANNELS: usize = 4;
101const UTM: usize = 5;
102pub const SESSION_GAP: u64 = 30 * 60;
105#[cfg(not(test))]
107pub const MAX_OPEN_SESSIONS: usize = 8192;
108#[cfg(test)]
109pub const MAX_OPEN_SESSIONS: usize = 64;
110
111pub const BEHAVIOUR: [&str; 7] = ["engaged_secs", "engaged_views", "scroll", "screens", "events", "event_props", "goals"];
116const ENGAGED_SECS: usize = 0;
117const ENGAGED_VIEWS: usize = 1;
118const SCROLL: usize = 2;
119const SCREENS: usize = 3;
120const EVENTS: usize = 4;
121const EVENT_PROPS: usize = 5;
122const GOALS: usize = 6;
123pub const MAX_REPORTS: u32 = 500;
125pub const MAX_ENGAGED_SECS: u64 = 6 * 3600;
127
128pub const LIVE_WINDOW: u64 = 30 * 60;
130pub const MAX_RECENT: usize = 4096;
132
133#[derive(Clone, Debug)]
135#[doc(hidden)]
136pub struct Recent {
137 pub at: u64,
138 pub visitor: u64,
139 pub page: String,
141 pub country: Option<[u8; 2]>,
142}
143
144pub const DIM_TOP: usize = 400;
146pub const DIM_SAVED: usize = 300;
148
149#[derive(Clone, Debug)]
151struct Audience {
152 country: Option<[u8; 2]>,
153 device: super::device::Device,
154}
155
156#[derive(Clone, Debug, Default)]
158struct Arrival {
159 channel: &'static str,
160 utm: [Option<String>; 3],
162}
163
164#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
165#[doc(hidden)]
166pub struct Counts {
167 pub requests: u64,
168 pub pages: u64,
169}
170
171#[derive(Default, Debug, Clone)]
173#[doc(hidden)]
174pub struct Top(pub HashMap<String, u64>);
175
176impl Top {
177 fn add(&mut self, key: &str, n: u64) {
178 self.add_capped(key, n, TOP);
179 }
180
181 fn add_capped(&mut self, key: &str, n: u64, cap: usize) {
182 if let Some(count) = self.0.get_mut(key) {
183 *count += n;
184 } else if self.0.len() < cap {
185 self.0.insert(key.chars().take(300).collect(), n);
186 } else {
187 *self.0.entry("(other)".to_string()).or_default() += n;
188 }
189 }
190
191 #[doc(hidden)]
192 pub fn merge(&mut self, other: &Top) {
193 for (key, n) in &other.0 {
194 *self.0.entry(key.clone()).or_default() += n;
195 }
196 }
197
198 #[doc(hidden)]
199 pub fn sorted(&self, n: usize) -> Vec<(String, u64)> {
200 let mut out: Vec<(String, u64)> = self.0.iter().map(|(k, v)| (k.clone(), *v)).collect();
201 out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
202 out.truncate(n);
203 out
204 }
205}
206
207#[derive(Default, Debug, Clone)]
209#[doc(hidden)]
210pub struct Day {
211 pub visitors: [u64; GROUPS],
212 pub people_pages: Top,
213 pub ai_pages: Top,
214 pub ai_agents: Top,
217 pub bots: Top,
219 pub referrers: Top,
221 pub site_visitors: Top,
223 pub site_ai_agents: Top,
225 pub site_bots: Top,
227 pub site_referrers: Top,
229 pub not_found: Top,
230 pub dims: [Top; 8],
232 pub sessions: BTreeMap<String, Sessions>,
234 pub entry_pages: Top,
236 pub exit_pages: Top,
237 pub behaviour: [Top; 7],
239 pub extra: [Top; EXTRA.len()],
241 pub visits: Vec<Visit>,
244}
245
246pub const EXTRA: [&str; 11] =
265 ["site_apps", "app_versions", "app_events", "site_tiers", "site_probes", "probe_sources", "site_crawl", "crawl_404", "site_entries", "downloads", "owner_pages"];
266const SITE_APPS: usize = 0;
267const APP_VERSIONS: usize = 1;
268const APP_EVENTS: usize = 2;
269const SITE_TIERS: usize = 3;
270const SITE_PROBES: usize = 4;
271const PROBE_SOURCES: usize = 5;
272const SITE_CRAWL: usize = 6;
273const CRAWL_404: usize = 7;
274const SITE_ENTRIES: usize = 8;
275const DOWNLOADS: usize = 9;
276const OWNER_PAGES: usize = 10;
277
278pub const MAX_VISITS: usize = 300;
280pub const VISIT_PAGES: usize = 20;
282
283#[derive(Clone, Debug, Default, PartialEq, Eq)]
287#[doc(hidden)]
288pub struct Visit {
289 pub start: u64,
290 pub end: u64,
291 pub host: String,
292 pub pages: Vec<String>,
294 pub views: u64,
296 pub country: String,
297 pub browser: String,
298 pub os: String,
299 pub device: String,
300 pub channel: String,
301 pub referrer: String,
302 pub campaign: String,
303 pub verified: bool,
305 pub number: u32,
307 pub events: Vec<String>,
308}
309
310#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
312#[doc(hidden)]
313pub struct Sessions {
314 pub sessions: u64,
315 pub bounces: u64,
317 pub duration: u64,
319 pub pages: u64,
321}
322
323impl Sessions {
324 #[doc(hidden)]
325 pub fn add(&mut self, other: &Sessions) {
326 self.sessions += other.sessions;
327 self.bounces += other.bounces;
328 self.duration += other.duration;
329 self.pages += other.pages;
330 }
331}
332
333struct Session {
335 start: u64,
336 last: u64,
337 pages: u64,
338 exit: String,
340 goals: u32,
342 channel: Option<&'static str>,
344 utm: [Option<String>; 3],
345 reports: u32,
347 paths: Vec<String>,
350 visitor: u64,
351 audience: Option<Audience>,
352 referrer: Option<String>,
353 events: Vec<String>,
354 number: u32,
355 verified: bool,
356}
357
358impl Session {
359 fn visit(&self) -> Visit {
360 let host = self.exit.split('/').next().unwrap_or("").to_string();
361 let audience = self.audience.as_ref();
362 Visit {
363 start: self.start,
364 end: self.last,
365 host,
366 pages: self.paths.clone(),
367 views: self.pages,
368 country: audience.and_then(|a| a.country).map_or_else(String::new, |c| String::from_utf8_lossy(&c).into_owned()),
369 browser: audience.map_or("", |a| a.device.browser).to_string(),
370 os: audience.map_or("", |a| a.device.os).to_string(),
371 device: audience.map_or("", |a| a.device.class).to_string(),
372 channel: self.channel.unwrap_or("").to_string(),
373 referrer: self.referrer.clone().unwrap_or_default(),
374 campaign: self.utm[2].clone().or_else(|| self.utm[0].clone()).unwrap_or_default(),
375 verified: self.verified,
376 number: self.number,
377 events: self.events.clone(),
378 }
379 }
380
381 fn totals(&self) -> Sessions {
382 Sessions { sessions: 1, bounces: (self.pages == 1) as u64, duration: self.last - self.start, pages: self.pages }
383 }
384}
385
386impl Day {
387 #[doc(hidden)]
388 pub fn merge(&mut self, other: &Day) {
389 for g in 0..GROUPS {
390 self.visitors[g] += other.visitors[g];
391 }
392 for (mine, theirs) in self.extra.iter_mut().zip(&other.extra) {
393 mine.merge(theirs);
394 }
395 self.visits.extend(other.visits.iter().cloned());
396 for (mine, theirs) in self.dims.iter_mut().zip(&other.dims) {
397 mine.merge(theirs);
398 }
399 self.people_pages.merge(&other.people_pages);
400 self.ai_pages.merge(&other.ai_pages);
401 self.ai_agents.merge(&other.ai_agents);
402 self.bots.merge(&other.bots);
403 self.referrers.merge(&other.referrers);
404 self.site_visitors.merge(&other.site_visitors);
405 self.site_ai_agents.merge(&other.site_ai_agents);
406 self.site_bots.merge(&other.site_bots);
407 self.site_referrers.merge(&other.site_referrers);
408 self.not_found.merge(&other.not_found);
409 for (mine, theirs) in self.behaviour.iter_mut().zip(&other.behaviour) {
410 mine.merge(theirs);
411 }
412 self.entry_pages.merge(&other.entry_pages);
413 self.exit_pages.merge(&other.exit_pages);
414 for (host, s) in &other.sessions {
415 self.sessions.entry(host.clone()).or_default().add(s);
416 }
417 }
418}
419
420struct Pending {
422 last: u64,
423 requests: Vec<Deferred>,
424}
425
426struct Deferred {
427 at: u64,
429 hour: u64,
430 host: u8,
431 page: Option<String>,
432 referrer: Option<String>,
433 not_found: Option<String>,
434 arrival: Option<Arrival>,
435}
436
437#[derive(Clone)]
439struct Cached {
440 class: Class,
441 audience: Option<Audience>,
442 app_version: Option<String>,
444}
445
446#[derive(Default)]
447struct Shard {
448 hours: BTreeMap<(u64, u8), [Counts; GROUPS]>,
450 days: BTreeMap<u64, Day>,
451 seen: HashMap<u64, Group>,
453 pending: HashMap<u64, Pending>,
454 cache: HashMap<u64, Cached>,
455 open: HashMap<u64, Session>,
457 recent: VecDeque<Recent>,
459 today: u64,
460 verified: std::collections::HashSet<u64>,
462 visit_numbers: HashMap<u64, u32>,
464 app_reports: HashMap<u64, u32>,
466}
467
468struct Recorder {
469 shards: Vec<Mutex<Shard>>,
470 hosts: RwLock<Vec<String>>,
471 configured: RwLock<Vec<String>>,
473 secret: RandomState,
476 retention_days: u64,
477 exclude: Vec<String>,
478 hosting: bool,
480 owners: Mutex<(u64, std::collections::HashSet<u64>)>,
483}
484
485static ENABLED: AtomicBool = AtomicBool::new(false);
486static HOSTS_GENERATION: AtomicU64 = AtomicU64::new(1);
488static CALLS: AtomicU64 = AtomicU64::new(0);
489static OWNER_GENERATION: AtomicU64 = AtomicU64::new(1);
492
493fn recorder() -> &'static Recorder {
494 static RECORDER: OnceLock<Recorder> = OnceLock::new();
495 RECORDER.get_or_init(|| Recorder {
496 shards: (0..SHARDS).map(|_| Mutex::new(Shard::default())).collect(),
497 hosts: RwLock::new(Vec::new()),
498 configured: RwLock::new(Vec::new()),
499 secret: RandomState::new(),
500 retention_days: super::config::retention_days(),
501 exclude: super::config::exclude(),
502 hosting: super::config::hosting_networks(),
503 owners: Mutex::new((0, std::collections::HashSet::new())),
504 })
505}
506
507pub fn enable() {
509 #[cfg(not(target_arch = "wasm32"))]
511 crate::log::set_access_observer(super::observe);
512 let _ = recorder();
513 ENABLED.store(true, Ordering::Relaxed);
514}
515
516pub fn enabled() -> bool {
518 ENABLED.load(Ordering::Relaxed)
519}
520
521pub fn now() -> u64 {
523 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
524}
525
526fn normalize_host(host: &str) -> String {
528 let host = host.trim();
529 let host = match host.strip_prefix('[') {
531 Some(rest) => rest.split(']').next().unwrap_or(rest),
532 None => host.split(':').next().unwrap_or(host),
533 };
534 if host.is_empty() { "(none)".to_string() } else { host.to_ascii_lowercase() }
535}
536
537fn host_index(raw: &str) -> u8 {
538 thread_local! {
540 static LAST: std::cell::RefCell<(u64, String, u8)> = const { std::cell::RefCell::new((0, String::new(), 0)) };
541 }
542 let generation = HOSTS_GENERATION.load(Ordering::Relaxed);
543 if let Ok(Some(i)) = LAST.try_with(|last| {
544 let last = last.borrow();
545 (last.0 == generation && !last.1.is_empty() && last.1 == raw).then_some(last.2)
546 }) {
547 return i;
548 }
549 let i = host_index_slow(raw);
550 let _ = LAST.try_with(|last| *last.borrow_mut() = (generation, raw.to_string(), i));
551 i
552}
553
554fn host_index_slow(host: &str) -> u8 {
555 let host = normalize_host(host);
556 let recorder = recorder();
557 if let Some(i) = recorder.hosts.read().unwrap_or_else(|e| e.into_inner()).iter().position(|h| *h == host) {
558 return i as u8;
559 }
560 let configured = recorder.configured.read().unwrap_or_else(|e| e.into_inner());
561 let mut hosts = recorder.hosts.write().unwrap_or_else(|e| e.into_inner());
562 host_slot(&mut hosts, &configured, host)
563}
564
565fn host_slot(hosts: &mut Vec<String>, configured: &[String], host: String) -> u8 {
570 let host = canonical(host, configured);
571 if let Some(i) = hosts.iter().position(|h| *h == host) {
572 return i as u8;
573 }
574 let room = host != "other"
575 && hosts.len() < MAX_SLOTS
576 && if configured.is_empty() {
577 hosts.len() < MAX_HOSTS
578 } else {
579 configured.binary_search(&host).is_ok() || hosts.iter().filter(|h| *h != "other" && configured.binary_search(h).is_err()).count() < MAX_HOSTS
580 };
581 if room {
582 hosts.push(host);
583 return (hosts.len() - 1) as u8;
584 }
585 if let Some(i) = hosts.iter().position(|h| h == "other") {
586 return i as u8;
587 }
588 hosts.push("other".to_string());
589 (hosts.len() - 1) as u8
590}
591
592const MAX_CONFIGURED: usize = MAX_SLOTS - MAX_HOSTS - 1;
594
595pub fn set_configured_hosts<I: IntoIterator<Item = String>>(hosts: I) {
599 let hidden: Vec<String> = super::config::hide_hosts().iter().map(|h| normalize_host(h)).collect();
600 let mut list: Vec<String> =
601 hosts.into_iter().map(|h| normalize_host(&h)).filter(|h| h != "(none)" && h != "other" && !h.contains('*') && !hidden.contains(h)).collect();
602 list.sort();
603 list.dedup();
604 let apexes = list.clone();
606 list.retain(|h| h.strip_prefix("www.").is_none_or(|apex| apexes.binary_search_by(|c| c.as_str().cmp(apex)).is_err()));
607 list.truncate(MAX_CONFIGURED);
608 let mut configured = recorder().configured.write().unwrap_or_else(|e| e.into_inner());
609 if *configured != list {
610 *configured = list;
611 HOSTS_GENERATION.fetch_add(1, Ordering::Relaxed);
613 }
614}
615
616#[doc(hidden)]
618pub fn configured_hosts() -> Vec<String> {
619 recorder().configured.read().unwrap_or_else(|e| e.into_inner()).clone()
620}
621
622fn host_name(i: u8) -> String {
624 recorder().hosts.read().unwrap_or_else(|e| e.into_inner()).get(i as usize).cloned().unwrap_or_else(|| "other".to_string())
625}
626
627#[doc(hidden)]
628pub fn host_names() -> Vec<String> {
629 recorder().hosts.read().unwrap_or_else(|e| e.into_inner()).clone()
630}
631
632fn visitor(day: u64, host: u8, ip: IpAddr, user_agent: Option<&str>) -> u64 {
634 let mut hasher = recorder().secret.build_hasher();
635 day.hash(&mut hasher);
636 host.hash(&mut hasher);
637 ip.hash(&mut hasher);
638 user_agent.unwrap_or("").hash(&mut hasher);
639 hasher.finish()
640}
641
642fn is_page(hit: &Hit<'_>) -> bool {
643 if hit.method != "GET" || !matches!(hit.status, 200 | 304) {
644 return false;
645 }
646 match hit.content_type {
647 Some(ct) => ct.trim_start().as_bytes().get(..9).is_some_and(|p| p.eq_ignore_ascii_case(b"text/html")),
648 None => {
649 let last = hit.path.rsplit('/').next().unwrap_or("");
650 last.is_empty() || !last.contains('.') || last.ends_with(".html")
651 }
652 }
653}
654
655fn is_asset(path: &str) -> bool {
656 let path = path.split('?').next().unwrap_or(path).as_bytes();
657 [&b".css"[..], b".js", b".mjs", b".woff2", b".woff"].iter().any(|ext| path.len() >= ext.len() && path[path.len() - ext.len()..].eq_ignore_ascii_case(ext))
658}
659
660fn page_key(host: &str, path: &str) -> String {
661 format!("{}{}", site_of(host), path.split('?').next().unwrap_or(path))
662}
663
664fn canonical(host: String, configured: &[String]) -> String {
669 if configured.is_empty() || configured.binary_search(&host).is_ok() || host == "other" {
670 return host;
671 }
672 match host.strip_prefix("www.") {
673 Some(apex) if configured.binary_search_by(|c| c.as_str().cmp(apex)).is_ok() => apex.to_string(),
674 _ => "other".to_string(),
675 }
676}
677
678fn site_of(raw: &str) -> String {
680 canonical(normalize_host(raw), &recorder().configured.read().unwrap_or_else(|e| e.into_inner()))
681}
682
683fn ignored_referrers() -> &'static RwLock<Vec<String>> {
684 static IGNORED: OnceLock<RwLock<Vec<String>>> = OnceLock::new();
685 IGNORED.get_or_init(|| RwLock::new(normalize_ignored(&super::config::ignore_referrers())))
686}
687
688fn normalize_ignored(entries: &[String]) -> Vec<String> {
689 entries.iter().map(|e| e.trim().trim_start_matches("www.").trim_end_matches('.').to_ascii_lowercase()).filter(|e| !e.is_empty()).collect()
690}
691
692#[doc(hidden)]
694pub fn set_ignored_referrers(entries: &[String]) {
695 *ignored_referrers().write().unwrap_or_else(|e| e.into_inner()) = normalize_ignored(entries);
696}
697
698#[doc(hidden)]
701pub fn is_ignored_referrer(referrer_host: &str) -> bool {
702 let ignored = ignored_referrers().read().unwrap_or_else(|e| e.into_inner());
703 if ignored.is_empty() {
704 return false;
705 }
706 let h = referrer_host.trim_end_matches('.').to_ascii_lowercase();
707 ignored.iter().any(|d| h == *d || (h.len() > d.len() && h.ends_with(d.as_str()) && h.as_bytes()[h.len() - d.len() - 1] == b'.'))
708}
709
710fn referrer_host(referer: Option<&str>, host: &str) -> Option<String> {
711 let rest = referer?.split_once("://")?.1;
712 let other = rest.split(['/', '?', '#']).next()?.split(':').next()?.to_ascii_lowercase();
713 (!other.is_empty() && other != normalize_host(host) && site_of(&other) != site_of(host)).then_some(other)
715}
716
717#[doc(hidden)]
719pub fn channel_of(referrer_host: &str) -> &'static str {
720 let h = referrer_host.strip_prefix("www.").unwrap_or(referrer_host);
721 let is = |d: &str| h == d || h.ends_with(&format!(".{d}"));
722 const AI: &[&str] = &["chatgpt.com", "chat.openai.com", "perplexity.ai", "claude.ai", "gemini.google.com", "copilot.microsoft.com", "chat.deepseek.com", "chat.mistral.ai", "you.com", "phind.com", "kagi.com"];
723 const SEARCH: &[&str] = &["bing.com", "duckduckgo.com", "search.yahoo.com", "yandex.ru", "yandex.com", "baidu.com", "ecosia.org", "search.brave.com", "startpage.com", "qwant.com", "naver.com", "seznam.cz"];
724 const SOCIAL: &[&str] = &[
725 "facebook.com", "m.facebook.com", "l.facebook.com", "lm.facebook.com", "t.co", "twitter.com", "x.com", "linkedin.com", "lnkd.in", "reddit.com", "instagram.com",
726 "youtube.com", "tiktok.com", "pinterest.com", "news.ycombinator.com", "mastodon.social", "bsky.app", "threads.net", "vk.com", "telegram.org", "t.me", "discord.com",
727 "whatsapp.com", "lobste.rs",
728 ];
729 const STORE: &[&str] = &[
731 "apps.apple.com", "itunes.apple.com", "play.google.com", "chromewebstore.google.com", "addons.mozilla.org", "microsoftedge.microsoft.com",
732 "apps.microsoft.com", "snapcraft.io", "flathub.org", "f-droid.org", "appgallery.huawei.com",
733 ];
734 if AI.iter().any(|d| is(d)) {
735 "ai"
736 } else if STORE.iter().any(|d| is(d)) || (h == "chrome.google.com") {
737 "store"
738 } else if SEARCH.iter().any(|d| is(d)) || h.starts_with("google.") || h.contains(".google.") {
739 "search"
740 } else if SOCIAL.iter().any(|d| is(d)) {
741 "social"
742 } else {
743 "referral"
744 }
745}
746
747fn query_value(raw: &str) -> Option<String> {
749 let bytes = raw.as_bytes();
750 let mut out = Vec::with_capacity(bytes.len().min(300));
751 let mut i = 0;
752 while i < bytes.len() && out.len() < 300 {
753 match bytes[i] {
754 b'+' => out.push(b' '),
755 b'%' if i + 2 < bytes.len() => {
756 match std::str::from_utf8(&bytes[i + 1..i + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok()) {
757 Some(b) => {
758 out.push(b);
759 i += 2;
760 }
761 None => out.push(b'%'),
762 }
763 }
764 b => out.push(b),
765 }
766 i += 1;
767 }
768 let value: String = String::from_utf8_lossy(&out).trim().chars().filter(|c| !c.is_control()).take(100).collect();
769 (!value.is_empty()).then_some(value)
770}
771
772#[doc(hidden)]
774pub fn utm(target: &str) -> [Option<String>; 3] {
775 let mut out: [Option<String>; 3] = Default::default();
776 let Some(query) = target.split_once('?').map(|(_, q)| q.split('#').next().unwrap_or(q)) else { return out };
777 for pair in query.split('&') {
778 let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
779 let slot = match name {
780 "utm_source" => 0,
781 "utm_medium" => 1,
782 "utm_campaign" => 2,
783 _ => continue,
784 };
785 if out[slot].is_none() {
786 out[slot] = query_value(value);
787 }
788 }
789 out
790}
791
792fn arrival(hit: &Hit<'_>) -> Option<Arrival> {
795 let utm = utm(hit.path);
796 let channel = if utm[0].is_some() || utm[1].is_some() || utm[2].is_some() {
797 "campaign"
798 } else {
799 match hit.referer {
800 None => "direct",
801 Some(_) => match referrer_host(hit.referer, hit.host)? {
802 h if is_ignored_referrer(&h) => "direct",
803 h => channel_of(&h),
804 },
805 }
806 };
807 Some(Arrival { channel, utm })
808}
809
810#[derive(Default)]
815struct Local {
816 decided: HashMap<u64, (u64, u64, usize)>,
819 counts: BTreeMap<(u64, u8), [Counts; GROUPS]>,
820}
821
822const LOCAL_DECIDED: usize = 1024;
824
825fn locals() -> &'static Mutex<Vec<std::sync::Weak<Mutex<Local>>>> {
826 static LOCALS: OnceLock<Mutex<Vec<std::sync::Weak<Mutex<Local>>>>> = OnceLock::new();
827 LOCALS.get_or_init(Mutex::default)
828}
829
830thread_local! {
831 static LOCAL: std::sync::Arc<Mutex<Local>> = {
832 let local = std::sync::Arc::new(Mutex::new(Local::default()));
833 let mut all = locals().lock().unwrap_or_else(|e| e.into_inner());
834 all.retain(|w| w.strong_count() > 0);
835 all.push(std::sync::Arc::downgrade(&local));
836 local
837 };
838}
839
840fn drain_locals() {
842 let all: Vec<_> = locals().lock().unwrap_or_else(|e| e.into_inner()).iter().filter_map(|w| w.upgrade()).collect();
843 let mut target = recorder().shards[0].lock().unwrap_or_else(|e| e.into_inner());
844 for local in all {
845 let counts = std::mem::take(&mut local.lock().unwrap_or_else(|e| e.into_inner()).counts);
846 for (key, c) in counts {
847 let entry = target.hours.entry(key).or_default();
848 for g in 0..GROUPS {
849 entry[g].requests += c[g].requests;
850 entry[g].pages += c[g].pages;
851 }
852 }
853 }
854}
855
856#[doc(hidden)]
858pub fn record(hit: &Hit<'_>) {
859 if !enabled() {
860 return;
861 }
862 let recorder = recorder();
863 let path = hit.path.split('?').next().unwrap_or(hit.path);
864 if recorder.exclude.iter().any(|e| path == e)
868 || (path.starts_with("/_rws/") && matches!(path, super::script::BEACON_PATH | super::script::APP_EVENTS_PATH | super::owner::PATH))
869 {
870 return;
871 }
872 let day = hit.now / 86_400;
873 let json = hit.content_type.is_some_and(|ct| ct.as_bytes().get(..16).is_some_and(|p| p.eq_ignore_ascii_case(b"application/json")));
876 if json && path.contains("/api/") && super::owner::is_admin_call(hit.headers, hit.status) {
877 mark_owner(hit.ip, hit.user_agent, hit.now);
878 }
879 let hour = hit.now / 3_600;
880 let host = host_index(hit.host);
881 let visitor = visitor(day, host, hit.ip, hit.user_agent);
882 let generation = OWNER_GENERATION.load(Ordering::Relaxed);
883 let download = is_download(hit, path);
884 let request_goal = request_goals_configured() && (200..400).contains(&hit.status);
885 if hit.status != 404 && !download && !request_goal && !is_page(hit) {
888 let counted = LOCAL.try_with(|local| {
889 let mut local = local.lock().unwrap_or_else(|e| e.into_inner());
890 match local.decided.get(&visitor) {
891 Some((d, gen, g)) if *d == day && *gen == generation => {
892 let g = *g;
893 local.counts.entry((hour, host)).or_default()[g].requests += 1;
894 true
895 }
896 _ => false,
897 }
898 });
899 if counted == Ok(true) {
900 return;
901 }
902 }
903 let decided = record_shared(hit, day, hour, host, visitor, path, download);
904 if let Some(g) = decided {
905 let _ = LOCAL.try_with(|local| {
906 let mut local = local.lock().unwrap_or_else(|e| e.into_inner());
907 if local.decided.len() >= LOCAL_DECIDED {
908 local.decided.clear();
909 }
910 local.decided.insert(visitor, (day, generation, g));
911 });
912 }
913 if CALLS.fetch_add(1, Ordering::Relaxed) % 1024 == 0 {
914 settle(hit.now);
915 }
916}
917
918fn record_shared(hit: &Hit<'_>, day: u64, hour: u64, host: u8, visitor: u64, path: &str, download: bool) -> Option<usize> {
920 let recorder = recorder();
921 let mut shard = recorder.shards[(visitor % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
922 shard.roll(day, recorder.retention_days);
923
924 let cached = match shard.cache.get(&visitor) {
925 Some(cached) => cached.clone(),
926 None => {
927 let class = classify(hit.ip, hit.user_agent, "/");
928 let header = |name: &str| hit.headers.iter().find(|h| h.name.eq_ignore_ascii_case(name)).map(|h| h.value.as_ref());
931 let owner = class.kind != Kind::Local
932 && (super::owner::in_networks(hit.ip) || super::owner::has_cookie(hit.headers) || is_owner(day, hit.ip, hit.user_agent));
933 let app = if owner || class.verified || class.kind == Kind::Local { None } else { super::apps::detect(hit.user_agent, header("origin")) };
934 let app_version = app.as_ref().and_then(|a| a.version.clone());
935 let class = match (owner, app) {
936 (true, _) => owner_class(),
937 (false, Some(app)) => Class { kind: Kind::App, name: app.name, operator: app.platform, verified: false },
938 (false, None) => class,
939 };
940 let class = match (class.kind, recorder.hosting) {
943 (Kind::Browser, true) => match super::hosting::provider(hit.ip) {
944 Some(provider) => Class { kind: Kind::Hosting, name: provider, operator: "", verified: false },
945 None => class,
946 },
947 _ => class,
948 };
949 let audience = (class.kind == Kind::Browser).then(|| Audience {
951 country: super::geo::country(hit.ip),
952 device: {
953 let hint = |name: &str| hit.headers.iter().find(|h| h.name.eq_ignore_ascii_case(name)).map(|h| h.value.as_ref());
954 super::device::parse(hit.user_agent.unwrap_or(""), hint("sec-ch-ua-platform"), hint("sec-ch-ua-mobile"))
955 },
956 });
957 let cached = Cached { class, audience, app_version };
958 if shard.cache.len() >= MAX_CACHE {
959 shard.cache.clear();
960 }
961 shard.cache.insert(visitor, cached.clone());
962 cached
963 }
964 };
965 let class = cached.class;
966 let scanner = super::classify::scanner_path(hit.path) && matches!(class.kind, Kind::Browser | Kind::Hosting | Kind::Tool | Kind::NoUserAgent);
967 let class = if scanner { Class { kind: Kind::Scanner, name: "", operator: "", verified: false } } else { class };
968 let page = is_page(hit).then(|| page_key(hit.host, path));
969 let not_found = (hit.status == 404).then(|| page_key(hit.host, path));
970 let referrer = referrer_host(hit.referer, hit.host).filter(|h| !is_ignored_referrer(h));
971 let arrival = if page.is_some() && class.kind == Kind::Browser { arrival(hit) } else { None };
973
974 let decided = match class.group() {
975 Group::Internal => None,
976 Group::Person => {
977 match shard.seen.get(&visitor).copied() {
978 Some(Group::Person) => {
979 shard.count_person(visitor, hit.now, host, page, referrer, not_found, arrival);
980 Some(PEOPLE)
981 }
982 Some(_) => {
983 shard.count(BOTS, hour, host, &browser_without_assets(), page, not_found, visitor);
984 Some(BOTS)
985 }
986 None if is_asset(path) => {
987 shard.seen.insert(visitor, Group::Person);
988 shard.new_visitor(day, host, PEOPLE);
989 if let Some(audience) = &cached.audience {
990 shard.count_audience(day, &site_of(hit.host), audience);
991 }
992 if let Some(pending) = shard.pending.remove(&visitor) {
993 for d in pending.requests {
994 shard.count_person(visitor, d.at, d.host, d.page, d.referrer, d.not_found, d.arrival);
995 }
996 }
997 shard.count_person(visitor, hit.now, host, page, referrer, not_found, arrival);
998 Some(PEOPLE)
999 }
1000 None => {
1001 if shard.pending.len() >= MAX_PENDING && !shard.pending.contains_key(&visitor) {
1002 shard.seen.insert(visitor, Group::Bot);
1004 shard.new_visitor(day, host, BOTS);
1005 shard.count(BOTS, hour, host, &browser_without_assets(), page, not_found, visitor);
1006 Some(BOTS)
1007 } else {
1008 let pending = shard.pending.entry(visitor).or_insert(Pending { last: hit.now, requests: Vec::new() });
1009 pending.last = hit.now;
1010 pending.requests.push(Deferred { at: hit.now, hour, host, page, referrer, not_found, arrival });
1011 if pending.requests.len() < MAX_PENDING_REQUESTS {
1012 None
1013 } else {
1014 let pending = shard.pending.remove(&visitor).expect("just added");
1017 shard.seen.insert(visitor, Group::Bot);
1018 shard.new_visitor(day, host, BOTS);
1019 shard.count_page_only(day, host);
1020 for d in pending.requests {
1021 shard.count(BOTS, d.hour, d.host, &browser_without_assets(), d.page, d.not_found, visitor);
1022 }
1023 Some(BOTS)
1024 }
1025 }
1026 }
1027 }
1028 }
1029 group => {
1030 let g = slot(group).expect("counted group");
1031 if shard.seen.insert(visitor, group).is_none() {
1032 shard.new_visitor(day, host, g);
1033 let site = host_name(host);
1034 if g == APPS {
1035 shard.count_app_user(day, &site, class.name, class.operator, cached.app_version.as_deref());
1036 } else if class.kind == Kind::Hosting {
1037 shard.day(day).extra[SITE_TIERS].add_capped(&format!("{site}\thosting"), 1, DIM_TOP);
1038 }
1039 }
1040 if scanner {
1041 shard.count_probe(day, &host_name(host), path, hit.ip);
1042 }
1043 shard.count(g, hour, host, &class, page, not_found, visitor);
1044 (!scanner).then_some(g)
1046 }
1047 };
1048 if download {
1049 let label = decided.map_or("undecided", group_label);
1050 shard.day(day).extra[DOWNLOADS].add_capped(&format!("{}\t{label}", page_key(hit.host, path)), 1, DIM_TOP);
1051 }
1052 if decided == Some(PEOPLE) && request_goals_configured() {
1053 let reached = request_goals(path);
1054 if let (true, Some(session)) = (reached != 0, shard.open.get_mut(&visitor)) {
1055 session.goals |= reached;
1056 }
1057 }
1058 decided
1059}
1060
1061fn group_label(g: usize) -> &'static str {
1063 ["people", "ai", "bots", "apps", "owner"].get(g).copied().unwrap_or("other")
1064}
1065
1066fn owner_class() -> Class {
1067 Class { kind: Kind::Owner, name: "", operator: "", verified: false }
1068}
1069
1070fn fingerprint(day: u64, ip: IpAddr, user_agent: Option<&str>) -> u64 {
1073 let mut hasher = recorder().secret.build_hasher();
1074 "owner".hash(&mut hasher);
1075 day.hash(&mut hasher);
1076 ip.hash(&mut hasher);
1077 user_agent.unwrap_or("").hash(&mut hasher);
1078 hasher.finish()
1079}
1080
1081fn is_owner(day: u64, ip: IpAddr, user_agent: Option<&str>) -> bool {
1082 let owners = recorder().owners.lock().unwrap_or_else(|e| e.into_inner());
1083 owners.0 == day && owners.1.contains(&fingerprint(day, ip, user_agent))
1084}
1085
1086#[doc(hidden)]
1090pub fn mark_owner(ip: IpAddr, user_agent: Option<&str>, now: u64) {
1091 let day = now / 86_400;
1092 let fp = fingerprint(day, ip, user_agent);
1093 {
1094 let mut owners = recorder().owners.lock().unwrap_or_else(|e| e.into_inner());
1095 if owners.0 != day {
1096 *owners = (day, std::collections::HashSet::new());
1097 }
1098 if owners.1.len() >= 1024 || !owners.1.insert(fp) {
1100 return;
1101 }
1102 }
1103 let slots = recorder().hosts.read().unwrap_or_else(|e| e.into_inner()).len();
1104 for host in 0..slots.max(1) {
1105 let v = visitor(day, host as u8, ip, user_agent);
1106 let mut shard = recorder().shards[(v % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
1107 if shard.today != day {
1108 continue;
1109 }
1110 shard.pending.remove(&v);
1111 shard.open.remove(&v);
1112 shard.seen.insert(v, Group::Owner);
1113 if shard.cache.len() >= MAX_CACHE {
1114 shard.cache.clear();
1115 }
1116 shard.cache.insert(v, Cached { class: owner_class(), audience: None, app_version: None });
1117 }
1118 OWNER_GENERATION.fetch_add(1, Ordering::Relaxed);
1119}
1120
1121const DOWNLOAD_EXTENSIONS: &[&str] = &[
1123 ".dmg", ".pkg", ".zip", ".tar.gz", ".tgz", ".tar.xz", ".exe", ".msi", ".msix", ".deb", ".rpm", ".appimage", ".apk", ".aab", ".ipa", ".vsix", ".xpi",
1124 ".crx", ".flatpak", ".snap", ".7z",
1125];
1126
1127fn is_download(hit: &Hit<'_>, path: &str) -> bool {
1130 if hit.status != 200 || hit.method != "GET" {
1131 return false;
1132 }
1133 let last = path.rsplit('/').next().unwrap_or("").as_bytes();
1134 if !matches!(last.last().map(u8::to_ascii_lowercase), Some(b'g' | b'e' | b'p' | b'z' | b'b' | b'i' | b'x' | b'k' | b'a' | b'm' | b'h' | b'1')) {
1136 return false;
1137 }
1138 let ends = |ext: &str| last.len() >= ext.len() && last[last.len() - ext.len()..].eq_ignore_ascii_case(ext.as_bytes());
1139 DOWNLOAD_EXTENSIONS.iter().any(|ext| ends(ext)) || last.eq_ignore_ascii_case(b"install.sh") || last.eq_ignore_ascii_case(b"install.ps1")
1140}
1141
1142fn browser_without_assets() -> Class {
1143 Class { kind: Kind::Browser, name: "browser, no assets", operator: "", verified: false }
1144}
1145
1146impl Shard {
1147 fn day(&mut self, day: u64) -> &mut Day {
1148 self.days.entry(day).or_default()
1149 }
1150
1151 fn new_visitor(&mut self, day: u64, host: u8, g: usize) {
1153 let name = host_name(host);
1154 let day = self.day(day);
1155 day.visitors[g] += 1;
1156 day.site_visitors.add_capped(&format!("{name}\t{g}"), 1, DIM_TOP);
1157 }
1158
1159 fn roll(&mut self, day: u64, retention_days: u64) {
1161 if self.today == day {
1162 return;
1163 }
1164 self.today = day;
1165 self.close_sessions(|_| true);
1167 self.seen.clear();
1168 self.cache.clear();
1169 self.verified.clear();
1170 self.visit_numbers.clear();
1171 self.app_reports.clear();
1172 let oldest_day = day.saturating_sub(retention_days.saturating_sub(1));
1173 self.days.retain(|d, _| *d >= oldest_day);
1174 self.hours.retain(|(h, _), _| *h / 24 >= oldest_day);
1175 }
1176
1177 #[cfg(feature = "model-postgres")]
1179 fn store_view(&self, visitor: u64, at: u64, page: &str, referrer: Option<&str>) {
1180 let (host, path) = page.find('/').map_or((page, "/"), |i| (&page[..i], &page[i..]));
1181 let session = self.open.get(&visitor);
1182 let audience = self.cache.get(&visitor).and_then(|c| c.audience.as_ref());
1183 super::store::enqueue(super::store::Row::PageView(super::store::PageView {
1184 at,
1185 host: host.to_string(),
1186 path: path.to_string(),
1187 visitor,
1188 session_start: session.map_or(at, |s| s.start),
1189 referrer_host: referrer.map(str::to_string),
1190 channel: session.and_then(|s| s.channel),
1191 utm: session.map(|s| s.utm.clone()).unwrap_or_default(),
1192 country: audience.and_then(|a| a.country).map(|c| String::from_utf8_lossy(&c).into_owned()),
1193 browser: audience.map(|a| a.device.browser),
1194 os: audience.map(|a| a.device.os),
1195 device: audience.map(|a| a.device.class),
1196 }));
1197 }
1198
1199 fn mark_verified(&mut self, visitor: u64, day: u64, host: &str) {
1201 if self.verified.insert(visitor) {
1202 self.day(day).extra[SITE_TIERS].add_capped(&format!("{host}\tverified"), 1, DIM_TOP);
1203 }
1204 }
1205
1206 fn count_app_user(&mut self, day: u64, host: &str, app: &str, platform: &str, version: Option<&str>) {
1208 let day = self.day(day);
1209 day.extra[SITE_APPS].add_capped(&format!("{host}\t{app}\t{platform}"), 1, DIM_TOP);
1210 day.extra[APP_VERSIONS].add_capped(&format!("{host}\t{app}\t{}", version.unwrap_or("(unknown)")), 1, DIM_TOP);
1211 }
1212
1213 fn count_probe(&mut self, day: u64, host: &str, path: &str, ip: IpAddr) {
1216 let country = super::geo::country(ip).map_or("(unknown)".to_string(), |c| String::from_utf8_lossy(&c).into_owned());
1217 let network = super::hosting::provider(ip).unwrap_or("-");
1218 let source = if super::config::probe_addresses() { format!("{host}\t{country}\t{network}\t{}", ip.to_canonical()) } else { format!("{host}\t{country}\t{network}") };
1219 let day = self.day(day);
1220 day.extra[SITE_PROBES].add_capped(&format!("{host}\t{}", path.chars().take(200).collect::<String>()), 1, DIM_TOP);
1221 day.extra[PROBE_SOURCES].add_capped(&source, 1, DIM_TOP);
1222 }
1223
1224 fn count_page_only(&mut self, day: u64, host: u8) {
1226 let site = host_name(host);
1227 self.day(day).extra[SITE_TIERS].add_capped(&format!("{site}\tpage-only"), 1, DIM_TOP);
1228 }
1229
1230 fn count_audience(&mut self, day: u64, host: &str, audience: &Audience) {
1232 let day = self.day(day);
1233 let country = audience.country.map(|c| String::from_utf8_lossy(&c).into_owned());
1234 let values = [country.as_deref().unwrap_or("(unknown)"), audience.device.browser, audience.device.os, audience.device.class];
1235 for (i, value) in values.iter().enumerate() {
1236 day.dims[COUNTRIES + i].add_capped(&format!("{host}\t{value}"), 1, DIM_TOP);
1237 }
1238 }
1239
1240 fn close_sessions(&mut self, which: impl Fn(&Session) -> bool) {
1243 let done: Vec<u64> = self.open.iter().filter(|(_, s)| which(s)).map(|(v, _)| *v).collect();
1244 for visitor in done {
1245 if let Some(session) = self.open.remove(&visitor) {
1246 self.close(session);
1247 }
1248 }
1249 }
1250
1251 fn close(&mut self, mut session: Session) {
1252 session.verified |= self.verified.contains(&session.visitor);
1253 let day = self.day(session.start / 86_400);
1254 add_session(day, &session);
1255 if day.visits.len() >= MAX_VISITS {
1256 day.visits.remove(0);
1257 }
1258 day.visits.push(session.visit());
1259 }
1260
1261 fn session_page(&mut self, visitor: u64, at: u64, page: &str) -> bool {
1264 if let Some(session) = self.open.get_mut(&visitor) {
1265 if at.saturating_sub(session.last) < SESSION_GAP {
1266 session.last = session.last.max(at);
1267 session.pages += 1;
1268 session.exit = page.to_string();
1269 session.goals |= page_goals(page);
1270 if session.paths.len() < VISIT_PAGES {
1271 session.paths.push(path_of(page).to_string());
1272 }
1273 return false;
1274 }
1275 let ended = self.open.remove(&visitor).expect("just found");
1276 self.close(ended);
1277 }
1278 if self.open.len() >= MAX_OPEN_SESSIONS {
1279 if let Some(quietest) = self.open.iter().min_by_key(|(_, s)| s.last).map(|(v, _)| *v) {
1280 let ended = self.open.remove(&quietest).expect("just found");
1281 self.close(ended);
1282 }
1283 }
1284 self.day(at / 86_400).entry_pages.add(page, 1);
1285 let audience = self.cache.get(&visitor).and_then(|c| c.audience.clone());
1286 let number = {
1287 let n = self.visit_numbers.entry(visitor).or_insert(0);
1288 *n += 1;
1289 *n
1290 };
1291 self.open.insert(
1292 visitor,
1293 Session {
1294 start: at,
1295 last: at,
1296 pages: 1,
1297 exit: page.to_string(),
1298 goals: 0,
1299 reports: 0,
1300 channel: None,
1301 utm: Default::default(),
1302 paths: vec![path_of(page).to_string()],
1303 visitor,
1304 audience,
1305 referrer: None,
1306 events: Vec::new(),
1307 number,
1308 verified: false,
1309 },
1310 );
1311 if let Some(session) = self.open.get_mut(&visitor) {
1312 session.goals |= page_goals(page);
1313 }
1314 true
1315 }
1316
1317 #[allow(clippy::too_many_arguments)]
1318 fn count_person(&mut self, visitor: u64, at: u64, host: u8, page: Option<String>, referrer: Option<String>, not_found: Option<String>, arrival: Option<Arrival>) {
1319 let hour = at / 3_600;
1320 let started = page.as_ref().is_some_and(|page| self.session_page(visitor, at, page));
1323 if let Some(page) = &page {
1324 let country = self.cache.get(&visitor).and_then(|c| c.audience.as_ref()).and_then(|a| a.country);
1325 while self.recent.front().is_some_and(|r| r.at + LIVE_WINDOW < at) || self.recent.len() >= MAX_RECENT {
1326 self.recent.pop_front();
1327 }
1328 self.recent.push_back(Recent { at, visitor, page: page.clone(), country });
1329 }
1330 let arrival = arrival.filter(|_| started);
1331 if let (Some(arrival), Some(session)) = (&arrival, self.open.get_mut(&visitor)) {
1332 session.channel = Some(arrival.channel);
1333 session.utm = arrival.utm.clone();
1334 session.referrer = referrer.clone();
1335 }
1336 #[cfg(feature = "model-postgres")]
1337 if let (true, Some(page)) = (super::store::enabled(), &page) {
1338 self.store_view(visitor, at, page, referrer.as_deref());
1339 }
1340 let counts = &mut self.hours.entry((hour, host)).or_default()[PEOPLE];
1341 counts.requests += 1;
1342 counts.pages += page.is_some() as u64;
1343 let day = self.day(hour / 24);
1344 if let (Some(arrival), Some(page)) = (arrival, &page) {
1345 let host = page.split('/').next().unwrap_or("");
1347 day.dims[CHANNELS].add_capped(&format!("{host}\t{}", arrival.channel), 1, DIM_TOP);
1348 day.extra[SITE_ENTRIES].add_capped(&format!("{page}\t{}", arrival.channel), 1, DIM_TOP);
1349 for (i, value) in arrival.utm.iter().enumerate() {
1350 if let Some(value) = value {
1351 day.dims[UTM + i].add_capped(&format!("{host}\t{value}"), 1, DIM_TOP);
1352 }
1353 }
1354 }
1355 if let Some(page) = page {
1356 day.people_pages.add(&page, 1);
1357 if let Some(referrer) = referrer {
1358 let host = page.split('/').next().unwrap_or("");
1359 day.site_referrers.add_capped(&format!("{host}\t{referrer}"), 1, DIM_TOP);
1360 }
1361 }
1362 if let Some(path) = not_found {
1363 day.not_found.add(&path, 1);
1364 }
1365 }
1366
1367 #[allow(clippy::too_many_arguments)]
1368 fn count(&mut self, g: usize, hour: u64, host: u8, class: &Class, page: Option<String>, not_found: Option<String>, _visitor: u64) {
1369 let counts = &mut self.hours.entry((hour, host)).or_default()[g];
1370 counts.requests += 1;
1371 counts.pages += page.is_some() as u64;
1372 let name = host_name(host);
1373 let day = self.day(hour / 24);
1374 if (g == AI || g == BOTS) && !class.name.is_empty() && !matches!(class.kind, Kind::Hosting | Kind::Browser) {
1375 if page.is_some() {
1377 day.extra[SITE_CRAWL].add_capped(&format!("{name}\t{}\tpages", class.name), 1, DIM_TOP);
1378 }
1379 if let Some(path) = ¬_found {
1380 day.extra[SITE_CRAWL].add_capped(&format!("{name}\t{}\t404", class.name), 1, DIM_TOP);
1381 day.extra[CRAWL_404].add_capped(&format!("{path}\t{}", class.name), 1, DIM_TOP);
1382 }
1383 }
1384 if g == AI {
1385 day.site_ai_agents.add_capped(&format!("{name}\t{}\t{}\t{}\t{}", or_unknown(class.name), class.operator, class.kind.label(), class.verified), 1, DIM_TOP);
1386 if let Some(page) = page {
1387 day.ai_pages.add(&page, 1);
1388 }
1389 } else if g == OWNER {
1390 if let Some(page) = page {
1391 day.extra[OWNER_PAGES].add(&page, 1);
1392 }
1393 } else if g == BOTS {
1395 let kind = if class.kind == Kind::Browser { "browser-no-assets" } else { class.kind.label() };
1396 day.site_bots.add_capped(&format!("{name}\t{kind}\t{}", class.name), 1, DIM_TOP);
1397 }
1398 if let Some(path) = not_found {
1399 day.not_found.add(&path, 1);
1400 }
1401 }
1402}
1403
1404fn or_unknown(name: &str) -> &str {
1405 if name.is_empty() { "(unnamed)" } else { name }
1406}
1407
1408#[doc(hidden)]
1410pub fn settle(now: u64) {
1411 for shard in &recorder().shards {
1412 let mut shard = shard.lock().unwrap_or_else(|e| e.into_inner());
1413 shard.close_sessions(|s| now.saturating_sub(s.last) >= SESSION_GAP);
1414 let done: Vec<u64> = shard.pending.iter().filter(|(_, p)| now.saturating_sub(p.last) >= UNDECIDED.as_secs()).map(|(v, _)| *v).collect();
1415 for visitor in done {
1416 let Some(pending) = shard.pending.remove(&visitor) else { continue };
1417 let day = pending.requests.first().map_or(now / 86_400, |d| d.hour / 24);
1418 let host = pending.requests.first().map_or(0, |d| d.host);
1419 if shard.seen.insert(visitor, Group::Bot).is_none() {
1420 shard.new_visitor(day, host, BOTS);
1421 shard.count_page_only(day, host);
1422 }
1423 for d in pending.requests {
1424 shard.count(BOTS, d.hour, d.host, &browser_without_assets(), d.page, d.not_found, visitor);
1425 }
1426 }
1427 }
1428}
1429
1430#[doc(hidden)]
1433pub fn snapshot(now: u64) -> (BTreeMap<(u64, u8), [Counts; GROUPS]>, BTreeMap<u64, Day>) {
1434 settle(now);
1435 drain_locals();
1436 let mut hours: BTreeMap<(u64, u8), [Counts; GROUPS]> = BTreeMap::new();
1437 let mut days: BTreeMap<u64, Day> = BTreeMap::new();
1438 for shard in &recorder().shards {
1439 let shard = shard.lock().unwrap_or_else(|e| e.into_inner());
1440 for (key, counts) in &shard.hours {
1441 let entry = hours.entry(*key).or_default();
1442 for g in 0..GROUPS {
1443 entry[g].requests += counts[g].requests;
1444 entry[g].pages += counts[g].pages;
1445 }
1446 }
1447 for (day, lists) in &shard.days {
1448 days.entry(*day).or_default().merge(lists);
1449 }
1450 for session in shard.open.values() {
1452 let day = days.entry(session.start / 86_400).or_default();
1453 add_session(day, session);
1454 let mut visit = session.visit();
1455 visit.verified |= shard.verified.contains(&session.visitor);
1456 day.visits.push(visit);
1457 }
1458 }
1459 (hours, days)
1460}
1461
1462fn add_session(day: &mut Day, session: &Session) {
1465 let host = session.exit.split('/').next().unwrap_or("").to_string();
1466 day.sessions.entry(host.clone()).or_default().add(&session.totals());
1467 day.exit_pages.add(&session.exit, 1);
1468 if session.goals != 0 {
1469 for (i, goal) in goals().iter().enumerate() {
1470 if session.goals & (1 << i) != 0 {
1471 day.behaviour[GOALS].add_capped(&format!("{host}\t{goal}"), 1, DIM_TOP);
1472 }
1473 }
1474 }
1475}
1476
1477static REQUEST_GOALS: AtomicU64 = AtomicU64::new(u64::MAX);
1479
1480fn goals_lock() -> &'static RwLock<Vec<String>> {
1481 static GOALS_SET: OnceLock<RwLock<Vec<String>>> = OnceLock::new();
1482 GOALS_SET.get_or_init(|| RwLock::new(super::config::goals()))
1483}
1484
1485fn goals() -> std::sync::RwLockReadGuard<'static, Vec<String>> {
1487 goals_lock().read().unwrap_or_else(|e| e.into_inner())
1488}
1489
1490#[cfg(test)]
1492#[doc(hidden)]
1493pub fn set_goals_for_tests(goals: &[&str]) {
1494 *goals_lock().write().unwrap_or_else(|e| e.into_inner()) = goals.iter().map(|g| g.to_string()).collect();
1495 REQUEST_GOALS.store(u64::MAX, Ordering::Relaxed);
1496}
1497
1498fn path_of(page: &str) -> &str {
1500 page.find('/').map_or("/", |i| &page[i..])
1501}
1502
1503fn page_goals(page: &str) -> u32 {
1505 let path = page.find('/').map_or("/", |i| &page[i..]);
1506 let mut reached = 0;
1507 for (i, goal) in goals().iter().enumerate() {
1508 let hit = match goal.strip_suffix('*') {
1509 _ if goal.starts_with("event:") || goal.starts_with("req:") => false,
1510 Some(prefix) => path.starts_with(prefix),
1511 None => path == goal,
1512 };
1513 if hit {
1514 reached |= 1 << i;
1515 }
1516 }
1517 reached
1518}
1519
1520fn request_goals_configured() -> bool {
1523 match REQUEST_GOALS.load(Ordering::Relaxed) {
1524 u64::MAX => {
1525 let any = goals().iter().any(|g| g.starts_with("req:"));
1526 REQUEST_GOALS.store(any as u64, Ordering::Relaxed);
1527 any
1528 }
1529 n => n == 1,
1530 }
1531}
1532
1533fn request_goals(path: &str) -> u32 {
1535 let mut reached = 0;
1536 for (i, goal) in goals().iter().enumerate() {
1537 let Some(want) = goal.strip_prefix("req:") else { continue };
1538 let hit = match want.strip_suffix('*') {
1539 Some(prefix) => path.starts_with(prefix),
1540 None => path == want,
1541 };
1542 if hit {
1543 reached |= 1 << i;
1544 }
1545 }
1546 reached
1547}
1548
1549fn event_goals(name: &str) -> u32 {
1551 let mut reached = 0;
1552 for (i, goal) in goals().iter().enumerate() {
1553 if goal.strip_prefix("event:") == Some(name) {
1554 reached |= 1 << i;
1555 }
1556 }
1557 reached
1558}
1559
1560fn json_string(raw: &str) -> Option<String> {
1561 crate::mcp::json_rpc::extract_str(&format!("{{\"v\":{raw}}}"), "v")
1562}
1563
1564fn width_bucket(width: u64) -> &'static str {
1565 match width {
1566 0 => "(unknown)",
1567 1..=599 => "phone",
1568 600..=1023 => "tablet",
1569 1024..=1439 => "laptop",
1570 _ => "wide",
1571 }
1572}
1573
1574#[doc(hidden)]
1579pub fn beacon(ip: IpAddr, raw_host: &str, user_agent: Option<&str>, body: &str, now: u64) {
1580 if !enabled() {
1581 return;
1582 }
1583 let Some(members) = crate::mcp::json_rpc::object_members(body) else { return };
1584 let get = |key: &str| members.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str());
1585 let number = |key: &str| get(key).and_then(|v| v.trim().parse::<f64>().ok()).filter(|n| n.is_finite() && *n >= 0.0).map(|n| n as u64);
1586 let Some(kind) = get("t").and_then(json_string) else { return };
1587 let Some(path) = get("p").and_then(json_string).filter(|p| p.starts_with('/') && p.len() <= 1000) else { return };
1588 let host = host_index(raw_host);
1589 let day = now / 86_400;
1590 let visitor = visitor(day, host, ip, user_agent);
1591 let host_name = site_of(raw_host);
1592
1593 if kind == "view" {
1594 let from = get("r").and_then(json_string).filter(|r| r.starts_with('/')).unwrap_or_else(|| "/".to_string());
1597 let referer = format!("https://{host_name}{from}");
1598 let is_person = {
1599 let mut shard = recorder().shards[(visitor % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
1600 let person = shard.seen.get(&visitor) == Some(&Group::Person);
1601 if person {
1602 shard.mark_verified(visitor, day, &host_name);
1603 }
1604 person
1605 };
1606 if is_person {
1607 record(&Hit {
1608 ip,
1609 host: raw_host,
1610 user_agent,
1611 referer: Some(&referer),
1612 method: "GET",
1613 path: &path,
1614 status: 200,
1615 content_type: Some("text/html"),
1616 headers: &[],
1617 now,
1618 });
1619 }
1620 return;
1621 }
1622
1623 let mut shard = recorder().shards[(visitor % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
1624 if shard.seen.get(&visitor) != Some(&Group::Person) {
1625 return;
1626 }
1627 shard.mark_verified(visitor, day, &host_name);
1628 if let Some(session) = shard.open.get_mut(&visitor) {
1629 session.reports += 1;
1630 if session.reports > MAX_REPORTS {
1631 return;
1632 }
1633 }
1634 let page = page_key(raw_host, &path);
1635 match kind.as_str() {
1636 "leave" => {
1637 let Some(ms) = number("ms") else { return };
1639 let secs = ms.min(MAX_ENGAGED_SECS * 1000) / 1000;
1640 let depth = number("sd").unwrap_or(0).min(100);
1641 let width = number("w").unwrap_or(0);
1642 let bucket = match depth {
1643 0..=25 => "25",
1644 26..=50 => "50",
1645 51..=75 => "75",
1646 _ => "100",
1647 };
1648 #[cfg(feature = "model-postgres")]
1649 super::store::enqueue(super::store::Row::Report(super::store::Report {
1650 at: now,
1651 host: host_name.clone(),
1652 path: path.clone(),
1653 visitor,
1654 kind: "leave",
1655 secs: Some(secs),
1656 scroll: Some(depth),
1657 width: Some(width),
1658 ..Default::default()
1659 }));
1660 let d = shard.day(day);
1661 d.behaviour[ENGAGED_SECS].add(&page, secs);
1662 d.behaviour[ENGAGED_VIEWS].add(&page, 1);
1663 d.behaviour[SCROLL].add_capped(&format!("{host_name}\t{bucket}"), 1, DIM_TOP);
1664 d.behaviour[SCREENS].add_capped(&format!("{host_name}\t{}", width_bucket(width)), 1, DIM_TOP);
1665 }
1666 "event" => {
1667 let Some(name) = get("n").and_then(json_string).map(|n| n.chars().filter(|c| !c.is_control()).take(64).collect::<String>()).filter(|n| !n.is_empty()) else {
1668 return;
1669 };
1670 let props: Vec<(String, String)> = get("q")
1671 .and_then(crate::mcp::json_rpc::object_members)
1672 .unwrap_or_default()
1673 .into_iter()
1674 .take(4)
1675 .filter_map(|(k, v)| Some((k.chars().take(32).collect::<String>(), json_string(&v)?.chars().take(64).collect::<String>())))
1676 .collect();
1677 let reached = event_goals(&name);
1678 if let Some(session) = shard.open.get_mut(&visitor) {
1679 session.goals |= reached;
1680 if session.events.len() < 10 {
1681 session.events.push(name.clone());
1682 }
1683 }
1684 #[cfg(feature = "model-postgres")]
1685 super::store::enqueue(super::store::Row::Report(super::store::Report {
1686 at: now,
1687 host: host_name.clone(),
1688 path: path.clone(),
1689 visitor,
1690 kind: "event",
1691 name: Some(name.clone()),
1692 props: (!props.is_empty()).then(|| props.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&")),
1693 ..Default::default()
1694 }));
1695 let d = shard.day(day);
1696 d.behaviour[EVENTS].add_capped(&format!("{host_name}\t{name}"), 1, DIM_TOP);
1697 for (k, v) in props {
1698 d.behaviour[EVENT_PROPS].add_capped(&format!("{host_name}\t{name}\t{k}={v}"), 1, DIM_TOP);
1699 }
1700 }
1701 _ => {}
1702 }
1703}
1704
1705const APP_PLATFORMS: &[&str] = &["ios", "ipados", "macos", "visionos", "watchos", "android", "windows", "linux", "chrome", "edge", "firefox", "safari", "web", "other"];
1707
1708#[derive(Debug, PartialEq, Eq)]
1710#[doc(hidden)]
1711pub enum AppEventError {
1712 Malformed,
1714 UnknownApp,
1716}
1717
1718#[doc(hidden)]
1726pub fn app_event(ip: IpAddr, raw_host: &str, user_agent: Option<&str>, body: &str, now: u64) -> Result<(), AppEventError> {
1727 if !enabled() {
1728 return Ok(());
1729 }
1730 let members = crate::mcp::json_rpc::object_members(body).ok_or(AppEventError::Malformed)?;
1731 let get = |key: &str| members.iter().find(|(k, _)| k == key).and_then(|(_, v)| json_string(v));
1732 let clean = |v: String, n: usize| v.chars().filter(|c| !c.is_control() && *c != '\t').take(n).collect::<String>();
1733 let app = get("app").ok_or(AppEventError::Malformed)?;
1734 let name = get("n").map(|n| clean(n, 64)).filter(|n| !n.is_empty()).ok_or(AppEventError::Malformed)?;
1735 let app = super::apps::configured(&app).ok_or(AppEventError::UnknownApp)?;
1736 let platform = get("p").map(|p| p.to_ascii_lowercase()).map_or("other", |p| APP_PLATFORMS.iter().copied().find(|k| *k == p).unwrap_or("other"));
1737 let version = get("v").map(|v| clean(v, 32)).filter(|v| !v.is_empty());
1738 let day = now / 86_400;
1739 let hour = now / 3_600;
1740 let host = host_index(raw_host);
1741 let visitor = visitor(day, host, ip, user_agent);
1742 let site = host_name(host);
1743 let recorder = recorder();
1744 let mut shard = recorder.shards[(visitor % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
1745 shard.roll(day, recorder.retention_days);
1746 let reports = shard.app_reports.entry(visitor).or_insert(0);
1747 *reports += 1;
1748 if *reports > MAX_REPORTS {
1749 return Ok(());
1750 }
1751 match shard.seen.get(&visitor).copied() {
1752 Some(Group::Owner) => return Ok(()),
1754 Some(Group::App) => {}
1755 _ => {
1756 if shard.seen.insert(visitor, Group::App).is_none() {
1757 shard.new_visitor(day, host, APPS);
1758 }
1759 shard.count_app_user(day, &site, app, platform, version.as_deref());
1760 shard.hours.entry((hour, host)).or_default()[APPS].requests += 1;
1761 }
1762 }
1763 shard.day(day).extra[APP_EVENTS].add_capped(&format!("{site}\t{app}\t{name}"), 1, DIM_TOP);
1764 Ok(())
1765}
1766
1767#[doc(hidden)]
1771pub fn recent(now: u64) -> Vec<Recent> {
1772 settle(now);
1773 let mut out = Vec::new();
1774 for shard in &recorder().shards {
1775 let shard = shard.lock().unwrap_or_else(|e| e.into_inner());
1776 out.extend(shard.recent.iter().filter(|r| r.at + LIVE_WINDOW >= now && r.at <= now).cloned());
1777 }
1778 out
1779}
1780
1781#[doc(hidden)]
1784pub fn restore(hosts: Vec<String>, hours: BTreeMap<(u64, u8), [Counts; GROUPS]>, days: BTreeMap<u64, Day>) {
1785 let recorder = recorder();
1786 let configured = recorder.configured.read().unwrap_or_else(|e| e.into_inner());
1787 let mut known = recorder.hosts.write().unwrap_or_else(|e| e.into_inner());
1788 let mut order: Vec<usize> = (0..hosts.len()).collect();
1791 order.sort_by_key(|i| configured.binary_search(&hosts[*i]).is_err());
1792 let mut map = vec![0u8; hosts.len()];
1793 for i in order {
1794 map[i] = host_slot(&mut known, &configured, hosts[i].clone());
1795 }
1796 let rename: HashMap<String, String> = hosts
1799 .iter()
1800 .enumerate()
1801 .filter_map(|(i, h)| known.get(map[i] as usize).filter(|n| *n != h).map(|n| (h.clone(), n.clone())))
1802 .collect();
1803 drop(known);
1804 drop(configured);
1805 HOSTS_GENERATION.fetch_add(1, Ordering::Relaxed);
1806 let mut shard = recorder.shards[0].lock().unwrap_or_else(|e| e.into_inner());
1807 for ((hour, host), counts) in hours {
1808 let host = map.get(host as usize).copied().unwrap_or(0);
1809 let entry = shard.hours.entry((hour, host)).or_default();
1810 for g in 0..GROUPS {
1811 entry[g].requests += counts[g].requests;
1812 entry[g].pages += counts[g].pages;
1813 }
1814 }
1815 for (day, mut lists) in days {
1816 if !rename.is_empty() {
1817 rename_hosts(&mut lists, &rename);
1818 }
1819 shard.days.entry(day).or_default().merge(&lists);
1820 }
1821}
1822
1823fn rename_hosts(day: &mut Day, rename: &HashMap<String, String>) {
1826 let rekey = |top: &mut Top| {
1827 if !top.0.keys().any(|k| rename.contains_key(&k[..k.find(['/', '\t']).unwrap_or(k.len())])) {
1828 return;
1829 }
1830 let old = std::mem::take(&mut top.0);
1831 for (key, n) in old {
1832 let at = key.find(['/', '\t']).unwrap_or(key.len());
1833 let key = match rename.get(&key[..at]) {
1834 Some(new) => format!("{new}{}", &key[at..]),
1835 None => key,
1836 };
1837 *top.0.entry(key).or_default() += n;
1838 }
1839 };
1840 for top in [
1841 &mut day.people_pages,
1842 &mut day.ai_pages,
1843 &mut day.site_visitors,
1844 &mut day.site_ai_agents,
1845 &mut day.site_bots,
1846 &mut day.site_referrers,
1847 &mut day.not_found,
1848 &mut day.entry_pages,
1849 &mut day.exit_pages,
1850 ] {
1851 rekey(top);
1852 }
1853 day.dims.iter_mut().for_each(rekey);
1854 day.behaviour.iter_mut().for_each(rekey);
1855 day.extra.iter_mut().for_each(rekey);
1856 for (old, new) in rename {
1857 if let Some(s) = day.sessions.remove(old) {
1858 day.sessions.entry(new.clone()).or_default().add(&s);
1859 }
1860 }
1861 for v in &mut day.visits {
1862 if let Some(new) = rename.get(&v.host) {
1863 v.host = new.clone();
1864 }
1865 }
1866}
1867
1868#[cfg(test)]
1869#[doc(hidden)]
1870pub fn disable_for_tests() {
1871 ENABLED.store(false, Ordering::Relaxed);
1872}
1873
1874#[cfg(test)]
1875#[doc(hidden)]
1876pub fn reset_for_tests() {
1877 let recorder = recorder();
1878 HOSTS_GENERATION.fetch_add(1, Ordering::Relaxed);
1879 for local in locals().lock().unwrap_or_else(|e| e.into_inner()).iter().filter_map(|w| w.upgrade()) {
1880 *local.lock().unwrap_or_else(|e| e.into_inner()) = Local::default();
1881 }
1882 for shard in &recorder.shards {
1883 *shard.lock().unwrap_or_else(|e| e.into_inner()) = Shard::default();
1884 }
1885 recorder.hosts.write().unwrap_or_else(|e| e.into_inner()).clear();
1886 recorder.configured.write().unwrap_or_else(|e| e.into_inner()).clear();
1887 ignored_referrers().write().unwrap_or_else(|e| e.into_inner()).clear();
1888}