1use std::collections::hash_map::RandomState;
21use std::collections::{BTreeMap, HashMap};
22use std::hash::{BuildHasher, Hash, Hasher};
23use std::net::IpAddr;
24use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
25use std::sync::{Mutex, OnceLock, RwLock};
26use std::time::{Duration, SystemTime, UNIX_EPOCH};
27
28use super::{classify, Class, Group, Kind};
29
30pub const MAX_HOSTS: usize = 16;
32pub const TOP: usize = 200;
34pub const TOP_SAVED: usize = 100;
36pub const MAX_PENDING: usize = 4096;
38pub const MAX_CACHE: usize = 16384;
40pub const UNDECIDED: Duration = Duration::from_secs(120);
43const MAX_PENDING_REQUESTS: usize = 32;
46const SHARDS: usize = 16;
47
48const PEOPLE: usize = 0;
50const AI: usize = 1;
51const BOTS: usize = 2;
52
53fn slot(group: Group) -> Option<usize> {
54 match group {
55 Group::Person => Some(PEOPLE),
56 Group::Ai => Some(AI),
57 Group::Bot => Some(BOTS),
58 Group::Internal => None,
59 }
60}
61
62#[derive(Clone, Debug)]
64pub(crate) struct Hit<'a> {
65 pub ip: IpAddr,
66 pub host: &'a str,
67 pub user_agent: Option<&'a str>,
68 pub referer: Option<&'a str>,
69 pub method: &'a str,
70 pub path: &'a str,
71 pub status: i16,
72 pub content_type: Option<&'a str>,
74 pub now: u64,
75}
76
77#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
78pub(crate) struct Counts {
79 pub requests: u64,
80 pub pages: u64,
81}
82
83#[derive(Default, Debug, Clone)]
85pub(crate) struct Top(pub HashMap<String, u64>);
86
87impl Top {
88 fn add(&mut self, key: &str, n: u64) {
89 if let Some(count) = self.0.get_mut(key) {
90 *count += n;
91 } else if self.0.len() < TOP {
92 self.0.insert(key.chars().take(300).collect(), n);
93 } else {
94 *self.0.entry("(other)".to_string()).or_default() += n;
95 }
96 }
97
98 pub(crate) fn merge(&mut self, other: &Top) {
99 for (key, n) in &other.0 {
100 *self.0.entry(key.clone()).or_default() += n;
101 }
102 }
103
104 pub(crate) fn sorted(&self, n: usize) -> Vec<(String, u64)> {
105 let mut out: Vec<(String, u64)> = self.0.iter().map(|(k, v)| (k.clone(), *v)).collect();
106 out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
107 out.truncate(n);
108 out
109 }
110}
111
112#[derive(Default, Debug, Clone)]
114pub(crate) struct Day {
115 pub visitors: [u64; 3],
116 pub people_pages: Top,
117 pub ai_pages: Top,
118 pub ai_agents: Top,
120 pub bots: Top,
122 pub referrers: Top,
123 pub not_found: Top,
124}
125
126impl Day {
127 pub(crate) fn merge(&mut self, other: &Day) {
128 for g in 0..3 {
129 self.visitors[g] += other.visitors[g];
130 }
131 self.people_pages.merge(&other.people_pages);
132 self.ai_pages.merge(&other.ai_pages);
133 self.ai_agents.merge(&other.ai_agents);
134 self.bots.merge(&other.bots);
135 self.referrers.merge(&other.referrers);
136 self.not_found.merge(&other.not_found);
137 }
138}
139
140struct Pending {
142 last: u64,
143 requests: Vec<Deferred>,
144}
145
146struct Deferred {
147 hour: u64,
148 host: u8,
149 page: Option<String>,
150 referrer: Option<String>,
151 not_found: Option<String>,
152}
153
154#[derive(Default)]
155struct Shard {
156 hours: BTreeMap<(u64, u8), [Counts; 3]>,
158 days: BTreeMap<u64, Day>,
159 seen: HashMap<u64, Group>,
161 pending: HashMap<u64, Pending>,
162 cache: HashMap<u64, Class>,
163 today: u64,
164}
165
166struct Recorder {
167 shards: Vec<Mutex<Shard>>,
168 hosts: RwLock<Vec<String>>,
169 secret: RandomState,
172 retention_days: u64,
173 exclude: Vec<String>,
174}
175
176static ENABLED: AtomicBool = AtomicBool::new(false);
177static HOSTS_GENERATION: AtomicU64 = AtomicU64::new(1);
179static CALLS: AtomicU64 = AtomicU64::new(0);
180
181fn recorder() -> &'static Recorder {
182 static RECORDER: OnceLock<Recorder> = OnceLock::new();
183 RECORDER.get_or_init(|| Recorder {
184 shards: (0..SHARDS).map(|_| Mutex::new(Shard::default())).collect(),
185 hosts: RwLock::new(Vec::new()),
186 secret: RandomState::new(),
187 retention_days: super::config::retention_days(),
188 exclude: super::config::exclude(),
189 })
190}
191
192pub fn enable() {
194 let _ = recorder();
195 ENABLED.store(true, Ordering::Relaxed);
196}
197
198pub fn enabled() -> bool {
200 ENABLED.load(Ordering::Relaxed)
201}
202
203pub fn now() -> u64 {
205 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
206}
207
208fn normalize_host(host: &str) -> String {
210 let host = host.trim();
211 let host = match host.strip_prefix('[') {
213 Some(rest) => rest.split(']').next().unwrap_or(rest),
214 None => host.split(':').next().unwrap_or(host),
215 };
216 if host.is_empty() { "(none)".to_string() } else { host.to_ascii_lowercase() }
217}
218
219fn host_index(raw: &str) -> u8 {
220 thread_local! {
222 static LAST: std::cell::RefCell<(u64, String, u8)> = const { std::cell::RefCell::new((0, String::new(), 0)) };
223 }
224 let generation = HOSTS_GENERATION.load(Ordering::Relaxed);
225 if let Ok(Some(i)) = LAST.try_with(|last| {
226 let last = last.borrow();
227 (last.0 == generation && !last.1.is_empty() && last.1 == raw).then_some(last.2)
228 }) {
229 return i;
230 }
231 let i = host_index_slow(raw);
232 let _ = LAST.try_with(|last| *last.borrow_mut() = (generation, raw.to_string(), i));
233 i
234}
235
236fn host_index_slow(host: &str) -> u8 {
237 let host = normalize_host(host);
238 let recorder = recorder();
239 if let Some(i) = recorder.hosts.read().unwrap_or_else(|e| e.into_inner()).iter().position(|h| *h == host) {
240 return i as u8;
241 }
242 let mut hosts = recorder.hosts.write().unwrap_or_else(|e| e.into_inner());
243 if let Some(i) = hosts.iter().position(|h| *h == host) {
244 return i as u8;
245 }
246 if hosts.len() < MAX_HOSTS {
247 hosts.push(host);
248 return (hosts.len() - 1) as u8;
249 }
250 if let Some(i) = hosts.iter().position(|h| h == "other") {
251 return i as u8;
252 }
253 hosts.push("other".to_string());
254 (hosts.len() - 1) as u8
255}
256
257pub(crate) fn host_names() -> Vec<String> {
258 recorder().hosts.read().unwrap_or_else(|e| e.into_inner()).clone()
259}
260
261fn visitor(day: u64, host: u8, ip: IpAddr, user_agent: Option<&str>) -> u64 {
263 let mut hasher = recorder().secret.build_hasher();
264 day.hash(&mut hasher);
265 host.hash(&mut hasher);
266 ip.hash(&mut hasher);
267 user_agent.unwrap_or("").hash(&mut hasher);
268 hasher.finish()
269}
270
271fn is_page(hit: &Hit<'_>) -> bool {
272 if hit.method != "GET" || !matches!(hit.status, 200 | 304) {
273 return false;
274 }
275 match hit.content_type {
276 Some(ct) => ct.trim_start().as_bytes().get(..9).is_some_and(|p| p.eq_ignore_ascii_case(b"text/html")),
277 None => {
278 let last = hit.path.rsplit('/').next().unwrap_or("");
279 last.is_empty() || !last.contains('.') || last.ends_with(".html")
280 }
281 }
282}
283
284fn is_asset(path: &str) -> bool {
285 let path = path.split('?').next().unwrap_or(path).as_bytes();
286 [&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))
287}
288
289fn page_key(host: &str, path: &str) -> String {
290 format!("{}{}", normalize_host(host), path.split('?').next().unwrap_or(path))
291}
292
293fn referrer_host(referer: Option<&str>, host: &str) -> Option<String> {
294 let rest = referer?.split_once("://")?.1;
295 let other = rest.split(['/', '?', '#']).next()?.split(':').next()?.to_ascii_lowercase();
296 (!other.is_empty() && other != normalize_host(host)).then_some(other)
297}
298
299#[derive(Default)]
304struct Local {
305 decided: HashMap<u64, (u64, usize)>,
307 counts: BTreeMap<(u64, u8), [Counts; 3]>,
308}
309
310const LOCAL_DECIDED: usize = 1024;
312
313fn locals() -> &'static Mutex<Vec<std::sync::Weak<Mutex<Local>>>> {
314 static LOCALS: OnceLock<Mutex<Vec<std::sync::Weak<Mutex<Local>>>>> = OnceLock::new();
315 LOCALS.get_or_init(Mutex::default)
316}
317
318thread_local! {
319 static LOCAL: std::sync::Arc<Mutex<Local>> = {
320 let local = std::sync::Arc::new(Mutex::new(Local::default()));
321 let mut all = locals().lock().unwrap_or_else(|e| e.into_inner());
322 all.retain(|w| w.strong_count() > 0);
323 all.push(std::sync::Arc::downgrade(&local));
324 local
325 };
326}
327
328fn drain_locals() {
330 let all: Vec<_> = locals().lock().unwrap_or_else(|e| e.into_inner()).iter().filter_map(|w| w.upgrade()).collect();
331 let mut target = recorder().shards[0].lock().unwrap_or_else(|e| e.into_inner());
332 for local in all {
333 let counts = std::mem::take(&mut local.lock().unwrap_or_else(|e| e.into_inner()).counts);
334 for (key, c) in counts {
335 let entry = target.hours.entry(key).or_default();
336 for g in 0..3 {
337 entry[g].requests += c[g].requests;
338 entry[g].pages += c[g].pages;
339 }
340 }
341 }
342}
343
344pub(crate) fn record(hit: &Hit<'_>) {
346 if !enabled() {
347 return;
348 }
349 let recorder = recorder();
350 let path = hit.path.split('?').next().unwrap_or(hit.path);
351 if recorder.exclude.iter().any(|e| path == e) {
352 return;
353 }
354 let day = hit.now / 86_400;
355 let hour = hit.now / 3_600;
356 let host = host_index(hit.host);
357 let visitor = visitor(day, host, hit.ip, hit.user_agent);
358 if hit.status != 404 && !is_page(hit) {
361 let counted = LOCAL.try_with(|local| {
362 let mut local = local.lock().unwrap_or_else(|e| e.into_inner());
363 match local.decided.get(&visitor) {
364 Some((d, g)) if *d == day => {
365 let g = *g;
366 local.counts.entry((hour, host)).or_default()[g].requests += 1;
367 true
368 }
369 _ => false,
370 }
371 });
372 if counted == Ok(true) {
373 return;
374 }
375 }
376 let decided = record_shared(hit, day, hour, host, visitor, path);
377 if let Some(g) = decided {
378 let _ = LOCAL.try_with(|local| {
379 let mut local = local.lock().unwrap_or_else(|e| e.into_inner());
380 if local.decided.len() >= LOCAL_DECIDED {
381 local.decided.clear();
382 }
383 local.decided.insert(visitor, (day, g));
384 });
385 }
386 if CALLS.fetch_add(1, Ordering::Relaxed) % 1024 == 0 {
387 settle(hit.now);
388 }
389}
390
391fn record_shared(hit: &Hit<'_>, day: u64, hour: u64, host: u8, visitor: u64, path: &str) -> Option<usize> {
393 let recorder = recorder();
394 let mut shard = recorder.shards[(visitor % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
395 shard.roll(day, recorder.retention_days);
396
397 let class = match shard.cache.get(&visitor) {
398 Some(class) => class.clone(),
399 None => {
400 let class = classify(hit.ip, hit.user_agent, "/");
401 if shard.cache.len() >= MAX_CACHE {
402 shard.cache.clear();
403 }
404 shard.cache.insert(visitor, class.clone());
405 class
406 }
407 };
408 let scanner = super::classify::scanner_path(hit.path) && matches!(class.kind, Kind::Browser | Kind::Tool | Kind::NoUserAgent);
409 let class = if scanner { Class { kind: Kind::Scanner, name: "", operator: "", verified: false } } else { class };
410 let page = is_page(hit).then(|| page_key(hit.host, path));
411 let not_found = (hit.status == 404).then(|| page_key(hit.host, path));
412 let referrer = referrer_host(hit.referer, hit.host);
413
414 let decided = match class.group() {
415 Group::Internal => None,
416 Group::Person => {
417 match shard.seen.get(&visitor).copied() {
418 Some(Group::Person) => {
419 shard.count_person(hour, host, page, referrer, not_found);
420 Some(PEOPLE)
421 }
422 Some(_) => {
423 shard.count(BOTS, hour, host, &browser_without_assets(), page, not_found, visitor);
424 Some(BOTS)
425 }
426 None if is_asset(path) => {
427 shard.seen.insert(visitor, Group::Person);
428 shard.day(day).visitors[PEOPLE] += 1;
429 if let Some(pending) = shard.pending.remove(&visitor) {
430 for d in pending.requests {
431 shard.count_person(d.hour, d.host, d.page, d.referrer, d.not_found);
432 }
433 }
434 shard.count_person(hour, host, page, referrer, not_found);
435 Some(PEOPLE)
436 }
437 None => {
438 if shard.pending.len() >= MAX_PENDING && !shard.pending.contains_key(&visitor) {
439 shard.seen.insert(visitor, Group::Bot);
441 shard.day(day).visitors[BOTS] += 1;
442 shard.count(BOTS, hour, host, &browser_without_assets(), page, not_found, visitor);
443 Some(BOTS)
444 } else {
445 let pending = shard.pending.entry(visitor).or_insert(Pending { last: hit.now, requests: Vec::new() });
446 pending.last = hit.now;
447 pending.requests.push(Deferred { hour, host, page, referrer, not_found });
448 if pending.requests.len() < MAX_PENDING_REQUESTS {
449 None
450 } else {
451 let pending = shard.pending.remove(&visitor).expect("just added");
454 shard.seen.insert(visitor, Group::Bot);
455 shard.day(day).visitors[BOTS] += 1;
456 for d in pending.requests {
457 shard.count(BOTS, d.hour, d.host, &browser_without_assets(), d.page, d.not_found, visitor);
458 }
459 Some(BOTS)
460 }
461 }
462 }
463 }
464 }
465 group => {
466 let g = slot(group).expect("counted group");
467 if shard.seen.insert(visitor, group).is_none() {
468 shard.day(day).visitors[g] += 1;
469 }
470 shard.count(g, hour, host, &class, page, not_found, visitor);
471 (!scanner).then_some(g)
473 }
474 };
475 decided
476}
477
478fn browser_without_assets() -> Class {
479 Class { kind: Kind::Browser, name: "browser, no assets", operator: "", verified: false }
480}
481
482impl Shard {
483 fn day(&mut self, day: u64) -> &mut Day {
484 self.days.entry(day).or_default()
485 }
486
487 fn roll(&mut self, day: u64, retention_days: u64) {
489 if self.today == day {
490 return;
491 }
492 self.today = day;
493 self.seen.clear();
494 self.cache.clear();
495 let oldest_day = day.saturating_sub(retention_days.saturating_sub(1));
496 self.days.retain(|d, _| *d >= oldest_day);
497 self.hours.retain(|(h, _), _| *h / 24 >= oldest_day);
498 }
499
500 fn count_person(&mut self, hour: u64, host: u8, page: Option<String>, referrer: Option<String>, not_found: Option<String>) {
501 let counts = &mut self.hours.entry((hour, host)).or_default()[PEOPLE];
502 counts.requests += 1;
503 counts.pages += page.is_some() as u64;
504 let day = self.day(hour / 24);
505 if let Some(page) = page {
506 day.people_pages.add(&page, 1);
507 if let Some(referrer) = referrer {
508 day.referrers.add(&referrer, 1);
509 }
510 }
511 if let Some(path) = not_found {
512 day.not_found.add(&path, 1);
513 }
514 }
515
516 #[allow(clippy::too_many_arguments)]
517 fn count(&mut self, g: usize, hour: u64, host: u8, class: &Class, page: Option<String>, not_found: Option<String>, _visitor: u64) {
518 let counts = &mut self.hours.entry((hour, host)).or_default()[g];
519 counts.requests += 1;
520 counts.pages += page.is_some() as u64;
521 let day = self.day(hour / 24);
522 if g == AI {
523 day.ai_agents.add(&format!("{}\t{}\t{}\t{}", or_unknown(class.name), class.operator, class.kind.label(), class.verified), 1);
524 if let Some(page) = page {
525 day.ai_pages.add(&page, 1);
526 }
527 } else {
528 let kind = if class.kind == Kind::Browser { "browser-no-assets" } else { class.kind.label() };
529 day.bots.add(&format!("{kind}\t{}", class.name), 1);
530 }
531 if let Some(path) = not_found {
532 day.not_found.add(&path, 1);
533 }
534 }
535}
536
537fn or_unknown(name: &str) -> &str {
538 if name.is_empty() { "(unnamed)" } else { name }
539}
540
541pub(crate) fn settle(now: u64) {
543 for shard in &recorder().shards {
544 let mut shard = shard.lock().unwrap_or_else(|e| e.into_inner());
545 let done: Vec<u64> = shard.pending.iter().filter(|(_, p)| now.saturating_sub(p.last) >= UNDECIDED.as_secs()).map(|(v, _)| *v).collect();
546 for visitor in done {
547 let Some(pending) = shard.pending.remove(&visitor) else { continue };
548 let day = pending.requests.first().map_or(now / 86_400, |d| d.hour / 24);
549 if shard.seen.insert(visitor, Group::Bot).is_none() {
550 shard.day(day).visitors[BOTS] += 1;
551 }
552 for d in pending.requests {
553 shard.count(BOTS, d.hour, d.host, &browser_without_assets(), d.page, d.not_found, visitor);
554 }
555 }
556 }
557}
558
559pub(crate) fn snapshot(now: u64) -> (BTreeMap<(u64, u8), [Counts; 3]>, BTreeMap<u64, Day>) {
562 settle(now);
563 drain_locals();
564 let mut hours: BTreeMap<(u64, u8), [Counts; 3]> = BTreeMap::new();
565 let mut days: BTreeMap<u64, Day> = BTreeMap::new();
566 for shard in &recorder().shards {
567 let shard = shard.lock().unwrap_or_else(|e| e.into_inner());
568 for (key, counts) in &shard.hours {
569 let entry = hours.entry(*key).or_default();
570 for g in 0..3 {
571 entry[g].requests += counts[g].requests;
572 entry[g].pages += counts[g].pages;
573 }
574 }
575 for (day, lists) in &shard.days {
576 days.entry(*day).or_default().merge(lists);
577 }
578 }
579 (hours, days)
580}
581
582pub(crate) fn restore(hosts: Vec<String>, hours: BTreeMap<(u64, u8), [Counts; 3]>, days: BTreeMap<u64, Day>) {
585 let recorder = recorder();
586 let mut known = recorder.hosts.write().unwrap_or_else(|e| e.into_inner());
587 let map: Vec<u8> = hosts
588 .iter()
589 .map(|h| match known.iter().position(|k| k == h) {
590 Some(i) => i as u8,
591 None if known.len() < MAX_HOSTS + 1 => {
592 known.push(h.clone());
593 (known.len() - 1) as u8
594 }
595 None => 0,
596 })
597 .collect();
598 drop(known);
599 HOSTS_GENERATION.fetch_add(1, Ordering::Relaxed);
600 let mut shard = recorder.shards[0].lock().unwrap_or_else(|e| e.into_inner());
601 for ((hour, host), counts) in hours {
602 let host = map.get(host as usize).copied().unwrap_or(0);
603 let entry = shard.hours.entry((hour, host)).or_default();
604 for g in 0..3 {
605 entry[g].requests += counts[g].requests;
606 entry[g].pages += counts[g].pages;
607 }
608 }
609 for (day, lists) in days {
610 shard.days.entry(day).or_default().merge(&lists);
611 }
612}
613
614#[cfg(test)]
615pub(crate) fn disable_for_tests() {
616 ENABLED.store(false, Ordering::Relaxed);
617}
618
619#[cfg(test)]
620pub(crate) fn reset_for_tests() {
621 let recorder = recorder();
622 HOSTS_GENERATION.fetch_add(1, Ordering::Relaxed);
623 for local in locals().lock().unwrap_or_else(|e| e.into_inner()).iter().filter_map(|w| w.upgrade()) {
624 *local.lock().unwrap_or_else(|e| e.into_inner()) = Local::default();
625 }
626 for shard in &recorder.shards {
627 *shard.lock().unwrap_or_else(|e| e.into_inner()) = Shard::default();
628 }
629 recorder.hosts.write().unwrap_or_else(|e| e.into_inner()).clear();
630}