Skip to main content

rws_proxy/traffic/
record.rs

1//! Counting traffic: every request (fed from the access-log path, so all
2//! protocols and whether or not lines are written) lands in hourly counts
3//! per host and group, daily unique visitors, and daily top lists.
4//!
5//! Bounded by construction: [`MAX_HOSTS`] hosts (more go to `other`),
6//! hourly buckets for `retention_days`, top lists of [`TOP`] entries a day
7//! (the rest added to `(other)`), [`MAX_PENDING`] undecided browsers per
8//! shard, a classification cache of [`MAX_CACHE`] per shard. Unique
9//! visitors are counted with `hash(secret, day, host, ip, user-agent)` —
10//! the secret is made at start and never stored, nor are the hashes — so
11//! nobody can be followed from one day to the next and no IP or User-Agent
12//! is kept.
13//!
14//! A browser isn't a person until it loads a stylesheet, script or font as
15//! a browser rendering the page does: its requests wait in a pending record
16//! and are counted as a person's then, or as "browser, no assets" (a bot)
17//! after [`UNDECIDED`] without one — or at once after 32 requests without
18//! one.
19
20use std::collections::hash_map::RandomState;
21use std::collections::{BTreeMap, HashMap, VecDeque};
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
30/// Hosts counted separately; others are `other`.
31pub const MAX_HOSTS: usize = 16;
32/// Entries a daily top list keeps.
33pub const TOP: usize = 200;
34/// Entries of each top list kept on disk and reported.
35pub const TOP_SAVED: usize = 100;
36/// Browsers awaiting their first asset, per shard.
37pub const MAX_PENDING: usize = 4096;
38/// Classifications cached per shard.
39pub const MAX_CACHE: usize = 16384;
40/// A browser that loads no asset this long after its last request isn't
41/// a person.
42pub const UNDECIDED: Duration = Duration::from_secs(120);
43/// A browser that makes this many requests without loading an asset is
44/// decided at once: automation.
45const MAX_PENDING_REQUESTS: usize = 32;
46const SHARDS: usize = 16;
47
48/// The groups counted, as array indexes.
49const 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/// One request, as counted.
63#[derive(Clone, Debug)]
64#[doc(hidden)]
65pub struct Hit<'a> {
66    pub ip: IpAddr,
67    pub host: &'a str,
68    pub user_agent: Option<&'a str>,
69    pub referer: Option<&'a str>,
70    pub method: &'a str,
71    pub path: &'a str,
72    pub status: i16,
73    /// The response's media type, when known.
74    pub content_type: Option<&'a str>,
75    /// The request's headers: read (for the `Sec-CH-UA-Platform` and
76    /// `Sec-CH-UA-Mobile` client hints) only when a visitor is first seen,
77    /// so other requests don't pay for the lookup.
78    pub headers: &'a [crate::header::Header],
79    pub now: u64,
80}
81
82/// The per-day lists of people's audience and sources, keyed
83/// `host\tvalue`: countries, browsers, OSes and device classes count
84/// visitors; channels and UTM values count visits (page views arriving
85/// from outside the site).
86pub const DIMS: [&str; 8] = ["countries", "browsers", "os", "devices", "channels", "utm_source", "utm_medium", "utm_campaign"];
87const COUNTRIES: usize = 0;
88const CHANNELS: usize = 4;
89const UTM: usize = 5;
90/// A person's page views more than this apart are separate sessions (as
91/// Google Analytics counts them).
92pub const SESSION_GAP: u64 = 30 * 60;
93/// Sessions open at once, per shard; past it the quietest is closed.
94#[cfg(not(test))]
95pub const MAX_OPEN_SESSIONS: usize = 8192;
96#[cfg(test)]
97pub const MAX_OPEN_SESSIONS: usize = 64;
98
99/// The per-day lists the script feeds (`[traffic] script`): seconds and
100/// page views with a report, keyed `host/path`; scroll depth, screen
101/// sizes, events and event properties, keyed `host\tvalue`; goal
102/// conversions (sessions that reached the goal), keyed `host\tgoal`.
103pub const BEHAVIOUR: [&str; 7] = ["engaged_secs", "engaged_views", "scroll", "screens", "events", "event_props", "goals"];
104const ENGAGED_SECS: usize = 0;
105const ENGAGED_VIEWS: usize = 1;
106const SCROLL: usize = 2;
107const SCREENS: usize = 3;
108const EVENTS: usize = 4;
109const EVENT_PROPS: usize = 5;
110const GOALS: usize = 6;
111/// Reports a session may make; the rest are ignored.
112pub const MAX_REPORTS: u32 = 500;
113/// The longest time on one page counted (a tab left open).
114pub const MAX_ENGAGED_SECS: u64 = 6 * 3600;
115
116/// How far back the live view looks.
117pub const LIVE_WINDOW: u64 = 30 * 60;
118/// People's recent page views kept for it, per shard.
119pub const MAX_RECENT: usize = 4096;
120
121/// A person's page view, for the live view.
122#[derive(Clone, Debug)]
123#[doc(hidden)]
124pub struct Recent {
125    pub at: u64,
126    pub visitor: u64,
127    /// `host/path`.
128    pub page: String,
129    pub country: Option<[u8; 2]>,
130}
131
132/// Entries a day's audience/source list keeps (all hosts together).
133pub const DIM_TOP: usize = 400;
134/// Entries of each audience/source list kept on disk.
135pub const DIM_SAVED: usize = 300;
136
137/// A person's country and device, worked out once a day.
138#[derive(Clone, Debug)]
139struct Audience {
140    country: Option<[u8; 2]>,
141    device: super::device::Device,
142}
143
144/// How a visit arrived, for a page view from outside the site.
145#[derive(Clone, Debug, Default)]
146struct Arrival {
147    channel: &'static str,
148    /// `utm_source`, `utm_medium`, `utm_campaign`.
149    utm: [Option<String>; 3],
150}
151
152#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
153#[doc(hidden)]
154pub struct Counts {
155    pub requests: u64,
156    pub pages: u64,
157}
158
159/// A top list: counts by key, at most [`TOP`] keys and `(other)`.
160#[derive(Default, Debug, Clone)]
161#[doc(hidden)]
162pub struct Top(pub HashMap<String, u64>);
163
164impl Top {
165    fn add(&mut self, key: &str, n: u64) {
166        self.add_capped(key, n, TOP);
167    }
168
169    fn add_capped(&mut self, key: &str, n: u64, cap: usize) {
170        if let Some(count) = self.0.get_mut(key) {
171            *count += n;
172        } else if self.0.len() < cap {
173            self.0.insert(key.chars().take(300).collect(), n);
174        } else {
175            *self.0.entry("(other)".to_string()).or_default() += n;
176        }
177    }
178
179    #[doc(hidden)]
180    pub fn merge(&mut self, other: &Top) {
181        for (key, n) in &other.0 {
182            *self.0.entry(key.clone()).or_default() += n;
183        }
184    }
185
186    #[doc(hidden)]
187    pub fn sorted(&self, n: usize) -> Vec<(String, u64)> {
188        let mut out: Vec<(String, u64)> = self.0.iter().map(|(k, v)| (k.clone(), *v)).collect();
189        out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
190        out.truncate(n);
191        out
192    }
193}
194
195/// One day's lists and unique visitors (all hosts; keys carry the host).
196#[derive(Default, Debug, Clone)]
197#[doc(hidden)]
198pub struct Day {
199    pub visitors: [u64; 3],
200    pub people_pages: Top,
201    pub ai_pages: Top,
202    /// `name\toperator\tkind\tverified`.
203    pub ai_agents: Top,
204    /// `kind\tname`.
205    pub bots: Top,
206    pub referrers: Top,
207    pub not_found: Top,
208    /// The [`DIMS`] lists, in that order.
209    pub dims: [Top; 8],
210    /// People's sessions per host (by the day they started).
211    pub sessions: BTreeMap<String, Sessions>,
212    /// The first and last page of people's sessions (`host/path`).
213    pub entry_pages: Top,
214    pub exit_pages: Top,
215    /// The [`BEHAVIOUR`] lists, in that order.
216    pub behaviour: [Top; 7],
217}
218
219/// Sessions added up.
220#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
221#[doc(hidden)]
222pub struct Sessions {
223    pub sessions: u64,
224    /// Sessions of one page view.
225    pub bounces: u64,
226    /// Seconds from each session's first page view to its last.
227    pub duration: u64,
228    /// Page views in sessions.
229    pub pages: u64,
230}
231
232impl Sessions {
233    #[doc(hidden)]
234    pub fn add(&mut self, other: &Sessions) {
235        self.sessions += other.sessions;
236        self.bounces += other.bounces;
237        self.duration += other.duration;
238        self.pages += other.pages;
239    }
240}
241
242/// A person's session while it's open.
243struct Session {
244    start: u64,
245    last: u64,
246    pages: u64,
247    /// The page key (`host/path`) last viewed.
248    exit: String,
249    /// Goals reached, by index into the configured goals.
250    goals: u32,
251    /// How the session arrived (for the database's rows).
252    channel: Option<&'static str>,
253    utm: [Option<String>; 3],
254    /// Script reports so far.
255    reports: u32,
256}
257
258impl Session {
259    fn totals(&self) -> Sessions {
260        Sessions { sessions: 1, bounces: (self.pages == 1) as u64, duration: self.last - self.start, pages: self.pages }
261    }
262}
263
264impl Day {
265    #[doc(hidden)]
266    pub fn merge(&mut self, other: &Day) {
267        for g in 0..3 {
268            self.visitors[g] += other.visitors[g];
269        }
270        for (mine, theirs) in self.dims.iter_mut().zip(&other.dims) {
271            mine.merge(theirs);
272        }
273        self.people_pages.merge(&other.people_pages);
274        self.ai_pages.merge(&other.ai_pages);
275        self.ai_agents.merge(&other.ai_agents);
276        self.bots.merge(&other.bots);
277        self.referrers.merge(&other.referrers);
278        self.not_found.merge(&other.not_found);
279        for (mine, theirs) in self.behaviour.iter_mut().zip(&other.behaviour) {
280            mine.merge(theirs);
281        }
282        self.entry_pages.merge(&other.entry_pages);
283        self.exit_pages.merge(&other.exit_pages);
284        for (host, s) in &other.sessions {
285            self.sessions.entry(host.clone()).or_default().add(s);
286        }
287    }
288}
289
290/// A browser's requests while it's undecided.
291struct Pending {
292    last: u64,
293    requests: Vec<Deferred>,
294}
295
296struct Deferred {
297    /// When it was made (seconds).
298    at: u64,
299    hour: u64,
300    host: u8,
301    page: Option<String>,
302    referrer: Option<String>,
303    not_found: Option<String>,
304    arrival: Option<Arrival>,
305}
306
307/// A visitor's classification, and for browsers their audience.
308#[derive(Clone)]
309struct Cached {
310    class: Class,
311    audience: Option<Audience>,
312}
313
314#[derive(Default)]
315struct Shard {
316    /// `(hour, host)` → counts per group.
317    hours: BTreeMap<(u64, u8), [Counts; 3]>,
318    days: BTreeMap<u64, Day>,
319    /// Today's visitors and the group each was counted in.
320    seen: HashMap<u64, Group>,
321    pending: HashMap<u64, Pending>,
322    cache: HashMap<u64, Cached>,
323    /// People's open sessions by visitor.
324    open: HashMap<u64, Session>,
325    /// People's page views of the last [`LIVE_WINDOW`], oldest first.
326    recent: VecDeque<Recent>,
327    today: u64,
328}
329
330struct Recorder {
331    shards: Vec<Mutex<Shard>>,
332    hosts: RwLock<Vec<String>>,
333    /// A secret made at start and never saved; with the day it keys the
334    /// visitor hash, so one day's visitors can't be matched to the next's.
335    secret: RandomState,
336    retention_days: u64,
337    exclude: Vec<String>,
338}
339
340static ENABLED: AtomicBool = AtomicBool::new(false);
341/// Bumped when host indexes change meaning (a restore, tests' resets).
342static HOSTS_GENERATION: AtomicU64 = AtomicU64::new(1);
343static CALLS: AtomicU64 = AtomicU64::new(0);
344
345fn recorder() -> &'static Recorder {
346    static RECORDER: OnceLock<Recorder> = OnceLock::new();
347    RECORDER.get_or_init(|| Recorder {
348        shards: (0..SHARDS).map(|_| Mutex::new(Shard::default())).collect(),
349        hosts: RwLock::new(Vec::new()),
350        secret: RandomState::new(),
351        retention_days: super::config::retention_days(),
352        exclude: super::config::exclude(),
353    })
354}
355
356/// Start counting (the binary does this when the admin API is on).
357pub fn enable() {
358    // The access log (no `log` module on wasm32) reports each request here.
359    #[cfg(not(target_arch = "wasm32"))]
360    crate::log::set_access_observer(super::observe);
361    let _ = recorder();
362    ENABLED.store(true, Ordering::Relaxed);
363}
364
365/// Whether requests are being counted.
366pub fn enabled() -> bool {
367    ENABLED.load(Ordering::Relaxed)
368}
369
370/// Seconds since the epoch, as the counts use.
371pub fn now() -> u64 {
372    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
373}
374
375/// A Host header as counted: lowercase, without the port.
376fn normalize_host(host: &str) -> String {
377    let host = host.trim();
378    // `[::1]:8080` keeps its brackets; `example.com:443` loses the port.
379    let host = match host.strip_prefix('[') {
380        Some(rest) => rest.split(']').next().unwrap_or(rest),
381        None => host.split(':').next().unwrap_or(host),
382    };
383    if host.is_empty() { "(none)".to_string() } else { host.to_ascii_lowercase() }
384}
385
386fn host_index(raw: &str) -> u8 {
387    // The last host this thread saw: most requests repeat it.
388    thread_local! {
389        static LAST: std::cell::RefCell<(u64, String, u8)> = const { std::cell::RefCell::new((0, String::new(), 0)) };
390    }
391    let generation = HOSTS_GENERATION.load(Ordering::Relaxed);
392    if let Ok(Some(i)) = LAST.try_with(|last| {
393        let last = last.borrow();
394        (last.0 == generation && !last.1.is_empty() && last.1 == raw).then_some(last.2)
395    }) {
396        return i;
397    }
398    let i = host_index_slow(raw);
399    let _ = LAST.try_with(|last| *last.borrow_mut() = (generation, raw.to_string(), i));
400    i
401}
402
403fn host_index_slow(host: &str) -> u8 {
404    let host = normalize_host(host);
405    let recorder = recorder();
406    if let Some(i) = recorder.hosts.read().unwrap_or_else(|e| e.into_inner()).iter().position(|h| *h == host) {
407        return i as u8;
408    }
409    let mut hosts = recorder.hosts.write().unwrap_or_else(|e| e.into_inner());
410    if let Some(i) = hosts.iter().position(|h| *h == host) {
411        return i as u8;
412    }
413    if hosts.len() < MAX_HOSTS {
414        hosts.push(host);
415        return (hosts.len() - 1) as u8;
416    }
417    if let Some(i) = hosts.iter().position(|h| h == "other") {
418        return i as u8;
419    }
420    hosts.push("other".to_string());
421    (hosts.len() - 1) as u8
422}
423
424#[doc(hidden)]
425pub fn host_names() -> Vec<String> {
426    recorder().hosts.read().unwrap_or_else(|e| e.into_inner()).clone()
427}
428
429/// `hash(secret, day, host, ip, user-agent)` — no lock.
430fn visitor(day: u64, host: u8, ip: IpAddr, user_agent: Option<&str>) -> u64 {
431    let mut hasher = recorder().secret.build_hasher();
432    day.hash(&mut hasher);
433    host.hash(&mut hasher);
434    ip.hash(&mut hasher);
435    user_agent.unwrap_or("").hash(&mut hasher);
436    hasher.finish()
437}
438
439fn is_page(hit: &Hit<'_>) -> bool {
440    if hit.method != "GET" || !matches!(hit.status, 200 | 304) {
441        return false;
442    }
443    match hit.content_type {
444        Some(ct) => ct.trim_start().as_bytes().get(..9).is_some_and(|p| p.eq_ignore_ascii_case(b"text/html")),
445        None => {
446            let last = hit.path.rsplit('/').next().unwrap_or("");
447            last.is_empty() || !last.contains('.') || last.ends_with(".html")
448        }
449    }
450}
451
452fn is_asset(path: &str) -> bool {
453    let path = path.split('?').next().unwrap_or(path).as_bytes();
454    [&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))
455}
456
457fn page_key(host: &str, path: &str) -> String {
458    format!("{}{}", normalize_host(host), path.split('?').next().unwrap_or(path))
459}
460
461fn referrer_host(referer: Option<&str>, host: &str) -> Option<String> {
462    let rest = referer?.split_once("://")?.1;
463    let other = rest.split(['/', '?', '#']).next()?.split(':').next()?.to_ascii_lowercase();
464    (!other.is_empty() && other != normalize_host(host)).then_some(other)
465}
466
467/// The channel a referring site belongs to.
468#[doc(hidden)]
469pub fn channel_of(referrer_host: &str) -> &'static str {
470    let h = referrer_host.strip_prefix("www.").unwrap_or(referrer_host);
471    let is = |d: &str| h == d || h.ends_with(&format!(".{d}"));
472    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"];
473    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"];
474    const SOCIAL: &[&str] = &[
475        "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",
476        "youtube.com", "tiktok.com", "pinterest.com", "news.ycombinator.com", "mastodon.social", "bsky.app", "threads.net", "vk.com", "telegram.org", "t.me", "discord.com",
477        "whatsapp.com", "lobste.rs",
478    ];
479    if AI.iter().any(|d| is(d)) {
480        "ai"
481    } else if SEARCH.iter().any(|d| is(d)) || h.starts_with("google.") || h.contains(".google.") {
482        "search"
483    } else if SOCIAL.iter().any(|d| is(d)) {
484        "social"
485    } else {
486        "referral"
487    }
488}
489
490/// `%XX` and `+` decoded, at most 100 characters; `None` when empty.
491fn query_value(raw: &str) -> Option<String> {
492    let bytes = raw.as_bytes();
493    let mut out = Vec::with_capacity(bytes.len().min(300));
494    let mut i = 0;
495    while i < bytes.len() && out.len() < 300 {
496        match bytes[i] {
497            b'+' => out.push(b' '),
498            b'%' if i + 2 < bytes.len() => {
499                match std::str::from_utf8(&bytes[i + 1..i + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok()) {
500                    Some(b) => {
501                        out.push(b);
502                        i += 2;
503                    }
504                    None => out.push(b'%'),
505                }
506            }
507            b => out.push(b),
508        }
509        i += 1;
510    }
511    let value: String = String::from_utf8_lossy(&out).trim().chars().filter(|c| !c.is_control()).take(100).collect();
512    (!value.is_empty()).then_some(value)
513}
514
515/// `utm_source`, `utm_medium` and `utm_campaign` from a request target.
516#[doc(hidden)]
517pub fn utm(target: &str) -> [Option<String>; 3] {
518    let mut out: [Option<String>; 3] = Default::default();
519    let Some(query) = target.split_once('?').map(|(_, q)| q.split('#').next().unwrap_or(q)) else { return out };
520    for pair in query.split('&') {
521        let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
522        let slot = match name {
523            "utm_source" => 0,
524            "utm_medium" => 1,
525            "utm_campaign" => 2,
526            _ => continue,
527        };
528        if out[slot].is_none() {
529            out[slot] = query_value(value);
530        }
531    }
532    out
533}
534
535/// How a page view arrived: `None` for navigation within the site (a
536/// same-site `Referer`), else the channel and any UTM values.
537fn arrival(hit: &Hit<'_>) -> Option<Arrival> {
538    let utm = utm(hit.path);
539    let channel = if utm[0].is_some() || utm[1].is_some() || utm[2].is_some() {
540        "campaign"
541    } else {
542        match hit.referer {
543            None => "direct",
544            Some(_) => channel_of(&referrer_host(hit.referer, hit.host)?),
545        }
546    };
547    Some(Arrival { channel, utm })
548}
549
550/// Requests of visitors already decided, counted by the thread that served
551/// them (merged by readers): most requests are assets and repeats, and they
552/// then cost no shared lock. Pages, 404s and undecided browsers take the
553/// shared path, which also feeds the lists.
554#[derive(Default)]
555struct Local {
556    /// Visitor → (day, counted group slot), as decided by a shard.
557    decided: HashMap<u64, (u64, usize)>,
558    counts: BTreeMap<(u64, u8), [Counts; 3]>,
559}
560
561/// How many decided visitors a thread remembers.
562const LOCAL_DECIDED: usize = 1024;
563
564fn locals() -> &'static Mutex<Vec<std::sync::Weak<Mutex<Local>>>> {
565    static LOCALS: OnceLock<Mutex<Vec<std::sync::Weak<Mutex<Local>>>>> = OnceLock::new();
566    LOCALS.get_or_init(Mutex::default)
567}
568
569thread_local! {
570    static LOCAL: std::sync::Arc<Mutex<Local>> = {
571        let local = std::sync::Arc::new(Mutex::new(Local::default()));
572        let mut all = locals().lock().unwrap_or_else(|e| e.into_inner());
573        all.retain(|w| w.strong_count() > 0);
574        all.push(std::sync::Arc::downgrade(&local));
575        local
576    };
577}
578
579/// Move every thread's buffered counts into shard 0.
580fn drain_locals() {
581    let all: Vec<_> = locals().lock().unwrap_or_else(|e| e.into_inner()).iter().filter_map(|w| w.upgrade()).collect();
582    let mut target = recorder().shards[0].lock().unwrap_or_else(|e| e.into_inner());
583    for local in all {
584        let counts = std::mem::take(&mut local.lock().unwrap_or_else(|e| e.into_inner()).counts);
585        for (key, c) in counts {
586            let entry = target.hours.entry(key).or_default();
587            for g in 0..3 {
588                entry[g].requests += c[g].requests;
589                entry[g].pages += c[g].pages;
590            }
591        }
592    }
593}
594
595/// Count one request.
596#[doc(hidden)]
597pub fn record(hit: &Hit<'_>) {
598    if !enabled() {
599        return;
600    }
601    let recorder = recorder();
602    let path = hit.path.split('?').next().unwrap_or(hit.path);
603    // The script's reports are counted by `beacon`, not as requests.
604    if recorder.exclude.iter().any(|e| path == e) || path == super::script::BEACON_PATH {
605        return;
606    }
607    let day = hit.now / 86_400;
608    let hour = hit.now / 3_600;
609    let host = host_index(hit.host);
610    let visitor = visitor(day, host, hit.ip, hit.user_agent);
611    // A decided visitor's non-page request: counted by this thread (no
612    // scanner check — the visitor keeps the group it was decided in).
613    if hit.status != 404 && !is_page(hit) {
614        let counted = LOCAL.try_with(|local| {
615            let mut local = local.lock().unwrap_or_else(|e| e.into_inner());
616            match local.decided.get(&visitor) {
617                Some((d, g)) if *d == day => {
618                    let g = *g;
619                    local.counts.entry((hour, host)).or_default()[g].requests += 1;
620                    true
621                }
622                _ => false,
623            }
624        });
625        if counted == Ok(true) {
626            return;
627        }
628    }
629    let decided = record_shared(hit, day, hour, host, visitor, path);
630    if let Some(g) = decided {
631        let _ = LOCAL.try_with(|local| {
632            let mut local = local.lock().unwrap_or_else(|e| e.into_inner());
633            if local.decided.len() >= LOCAL_DECIDED {
634                local.decided.clear();
635            }
636            local.decided.insert(visitor, (day, g));
637        });
638    }
639    if CALLS.fetch_add(1, Ordering::Relaxed) % 1024 == 0 {
640        settle(hit.now);
641    }
642}
643
644/// The shared path; returns the visitor's group slot once it's decided.
645fn record_shared(hit: &Hit<'_>, day: u64, hour: u64, host: u8, visitor: u64, path: &str) -> Option<usize> {
646    let recorder = recorder();
647    let mut shard = recorder.shards[(visitor % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
648    shard.roll(day, recorder.retention_days);
649
650    let cached = match shard.cache.get(&visitor) {
651        Some(cached) => cached.clone(),
652        None => {
653            let class = classify(hit.ip, hit.user_agent, "/");
654            // Country and device only for browsers: they're shown for people.
655            let audience = (class.kind == Kind::Browser).then(|| Audience {
656                country: super::geo::country(hit.ip),
657                device: {
658                    let hint = |name: &str| hit.headers.iter().find(|h| h.name.eq_ignore_ascii_case(name)).map(|h| h.value.as_ref());
659                    super::device::parse(hit.user_agent.unwrap_or(""), hint("sec-ch-ua-platform"), hint("sec-ch-ua-mobile"))
660                },
661            });
662            let cached = Cached { class, audience };
663            if shard.cache.len() >= MAX_CACHE {
664                shard.cache.clear();
665            }
666            shard.cache.insert(visitor, cached.clone());
667            cached
668        }
669    };
670    let class = cached.class;
671    let scanner = super::classify::scanner_path(hit.path) && matches!(class.kind, Kind::Browser | Kind::Tool | Kind::NoUserAgent);
672    let class = if scanner { Class { kind: Kind::Scanner, name: "", operator: "", verified: false } } else { class };
673    let page = is_page(hit).then(|| page_key(hit.host, path));
674    let not_found = (hit.status == 404).then(|| page_key(hit.host, path));
675    let referrer = referrer_host(hit.referer, hit.host);
676    // Where a person's page view came from (only browsers become people).
677    let arrival = if page.is_some() && class.kind == Kind::Browser { arrival(hit) } else { None };
678
679    let decided = match class.group() {
680        Group::Internal => None,
681        Group::Person => {
682            match shard.seen.get(&visitor).copied() {
683                Some(Group::Person) => {
684                    shard.count_person(visitor, hit.now, host, page, referrer, not_found, arrival);
685                    Some(PEOPLE)
686                }
687                Some(_) => {
688                    shard.count(BOTS, hour, host, &browser_without_assets(), page, not_found, visitor);
689                    Some(BOTS)
690                }
691                None if is_asset(path) => {
692                    shard.seen.insert(visitor, Group::Person);
693                    shard.day(day).visitors[PEOPLE] += 1;
694                    if let Some(audience) = &cached.audience {
695                        shard.count_audience(day, &normalize_host(hit.host), audience);
696                    }
697                    if let Some(pending) = shard.pending.remove(&visitor) {
698                        for d in pending.requests {
699                            shard.count_person(visitor, d.at, d.host, d.page, d.referrer, d.not_found, d.arrival);
700                        }
701                    }
702                    shard.count_person(visitor, hit.now, host, page, referrer, not_found, arrival);
703                    Some(PEOPLE)
704                }
705                None => {
706                    if shard.pending.len() >= MAX_PENDING && !shard.pending.contains_key(&visitor) {
707                        // Too many undecided at once: count this one as a bot.
708                        shard.seen.insert(visitor, Group::Bot);
709                        shard.day(day).visitors[BOTS] += 1;
710                        shard.count(BOTS, hour, host, &browser_without_assets(), page, not_found, visitor);
711                        Some(BOTS)
712                    } else {
713                        let pending = shard.pending.entry(visitor).or_insert(Pending { last: hit.now, requests: Vec::new() });
714                        pending.last = hit.now;
715                        pending.requests.push(Deferred { at: hit.now, hour, host, page, referrer, not_found, arrival });
716                        if pending.requests.len() < MAX_PENDING_REQUESTS {
717                            None
718                        } else {
719                            // This many requests and no stylesheet, script or
720                            // font: automation, not someone reading pages.
721                            let pending = shard.pending.remove(&visitor).expect("just added");
722                            shard.seen.insert(visitor, Group::Bot);
723                            shard.day(day).visitors[BOTS] += 1;
724                            for d in pending.requests {
725                                shard.count(BOTS, d.hour, d.host, &browser_without_assets(), d.page, d.not_found, visitor);
726                            }
727                            Some(BOTS)
728                        }
729                    }
730                }
731            }
732        }
733        group => {
734            let g = slot(group).expect("counted group");
735            if shard.seen.insert(visitor, group).is_none() {
736                shard.day(day).visitors[g] += 1;
737            }
738            shard.count(g, hour, host, &class, page, not_found, visitor);
739            // A scanner request isn't the visitor's usual class: not cached.
740            (!scanner).then_some(g)
741        }
742    };
743    decided
744}
745
746fn browser_without_assets() -> Class {
747    Class { kind: Kind::Browser, name: "browser, no assets", operator: "", verified: false }
748}
749
750impl Shard {
751    fn day(&mut self, day: u64) -> &mut Day {
752        self.days.entry(day).or_default()
753    }
754
755    /// A new day: forget today's visitors and cache; drop what's past retention.
756    fn roll(&mut self, day: u64, retention_days: u64) {
757        if self.today == day {
758            return;
759        }
760        self.today = day;
761        // Visitors are new each day, so no session continues past midnight.
762        self.close_sessions(|_| true);
763        self.seen.clear();
764        self.cache.clear();
765        let oldest_day = day.saturating_sub(retention_days.saturating_sub(1));
766        self.days.retain(|d, _| *d >= oldest_day);
767        self.hours.retain(|(h, _), _| *h / 24 >= oldest_day);
768    }
769
770    /// A person's page view as a database row.
771    #[cfg(feature = "model-postgres")]
772    fn store_view(&self, visitor: u64, at: u64, page: &str, referrer: Option<&str>) {
773        let (host, path) = page.find('/').map_or((page, "/"), |i| (&page[..i], &page[i..]));
774        let session = self.open.get(&visitor);
775        let audience = self.cache.get(&visitor).and_then(|c| c.audience.as_ref());
776        super::store::enqueue(super::store::Row::PageView(super::store::PageView {
777            at,
778            host: host.to_string(),
779            path: path.to_string(),
780            visitor,
781            session_start: session.map_or(at, |s| s.start),
782            referrer_host: referrer.map(str::to_string),
783            channel: session.and_then(|s| s.channel),
784            utm: session.map(|s| s.utm.clone()).unwrap_or_default(),
785            country: audience.and_then(|a| a.country).map(|c| String::from_utf8_lossy(&c).into_owned()),
786            browser: audience.map(|a| a.device.browser),
787            os: audience.map(|a| a.device.os),
788            device: audience.map(|a| a.device.class),
789        }));
790    }
791
792    /// A new person's country, browser, OS and device class.
793    fn count_audience(&mut self, day: u64, host: &str, audience: &Audience) {
794        let day = self.day(day);
795        let country = audience.country.map(|c| String::from_utf8_lossy(&c).into_owned());
796        let values = [country.as_deref().unwrap_or("(unknown)"), audience.device.browser, audience.device.os, audience.device.class];
797        for (i, value) in values.iter().enumerate() {
798            day.dims[COUNTRIES + i].add_capped(&format!("{host}\t{value}"), 1, DIM_TOP);
799        }
800    }
801
802    /// Close the open sessions `which` picks, adding them to the day they
803    /// started.
804    fn close_sessions(&mut self, which: impl Fn(&Session) -> bool) {
805        let done: Vec<u64> = self.open.iter().filter(|(_, s)| which(s)).map(|(v, _)| *v).collect();
806        for visitor in done {
807            if let Some(session) = self.open.remove(&visitor) {
808                self.close(session);
809            }
810        }
811    }
812
813    fn close(&mut self, session: Session) {
814        let day = self.day(session.start / 86_400);
815        add_session(day, &session);
816    }
817
818    /// A person's page view at `at`: continues their session, or ends it and
819    /// starts the next. True when it started one.
820    fn session_page(&mut self, visitor: u64, at: u64, page: &str) -> bool {
821        if let Some(session) = self.open.get_mut(&visitor) {
822            if at.saturating_sub(session.last) < SESSION_GAP {
823                session.last = session.last.max(at);
824                session.pages += 1;
825                session.exit = page.to_string();
826                session.goals |= page_goals(page);
827                return false;
828            }
829            let ended = self.open.remove(&visitor).expect("just found");
830            self.close(ended);
831        }
832        if self.open.len() >= MAX_OPEN_SESSIONS {
833            if let Some(quietest) = self.open.iter().min_by_key(|(_, s)| s.last).map(|(v, _)| *v) {
834                let ended = self.open.remove(&quietest).expect("just found");
835                self.close(ended);
836            }
837        }
838        self.day(at / 86_400).entry_pages.add(page, 1);
839        self.open.insert(visitor, Session { start: at, last: at, pages: 1, exit: page.to_string(), goals: 0, reports: 0, channel: None, utm: Default::default() });
840        if let Some(session) = self.open.get_mut(&visitor) {
841            session.goals |= page_goals(page);
842        }
843        true
844    }
845
846    #[allow(clippy::too_many_arguments)]
847    fn count_person(&mut self, visitor: u64, at: u64, host: u8, page: Option<String>, referrer: Option<String>, not_found: Option<String>, arrival: Option<Arrival>) {
848        let hour = at / 3_600;
849        // Sources count once per session, from its first page view (as
850        // Google Analytics attributes a session).
851        let started = page.as_ref().is_some_and(|page| self.session_page(visitor, at, page));
852        if let Some(page) = &page {
853            let country = self.cache.get(&visitor).and_then(|c| c.audience.as_ref()).and_then(|a| a.country);
854            while self.recent.front().is_some_and(|r| r.at + LIVE_WINDOW < at) || self.recent.len() >= MAX_RECENT {
855                self.recent.pop_front();
856            }
857            self.recent.push_back(Recent { at, visitor, page: page.clone(), country });
858        }
859        let arrival = arrival.filter(|_| started);
860        if let (Some(arrival), Some(session)) = (&arrival, self.open.get_mut(&visitor)) {
861            session.channel = Some(arrival.channel);
862            session.utm = arrival.utm.clone();
863        }
864        #[cfg(feature = "model-postgres")]
865        if let (true, Some(page)) = (super::store::enabled(), &page) {
866            self.store_view(visitor, at, page, referrer.as_deref());
867        }
868        let counts = &mut self.hours.entry((hour, host)).or_default()[PEOPLE];
869        counts.requests += 1;
870        counts.pages += page.is_some() as u64;
871        let day = self.day(hour / 24);
872        if let (Some(arrival), Some(page)) = (arrival, &page) {
873            // The page key starts with the host.
874            let host = page.split('/').next().unwrap_or("");
875            day.dims[CHANNELS].add_capped(&format!("{host}\t{}", arrival.channel), 1, DIM_TOP);
876            for (i, value) in arrival.utm.iter().enumerate() {
877                if let Some(value) = value {
878                    day.dims[UTM + i].add_capped(&format!("{host}\t{value}"), 1, DIM_TOP);
879                }
880            }
881        }
882        if let Some(page) = page {
883            day.people_pages.add(&page, 1);
884            if let Some(referrer) = referrer {
885                day.referrers.add(&referrer, 1);
886            }
887        }
888        if let Some(path) = not_found {
889            day.not_found.add(&path, 1);
890        }
891    }
892
893    #[allow(clippy::too_many_arguments)]
894    fn count(&mut self, g: usize, hour: u64, host: u8, class: &Class, page: Option<String>, not_found: Option<String>, _visitor: u64) {
895        let counts = &mut self.hours.entry((hour, host)).or_default()[g];
896        counts.requests += 1;
897        counts.pages += page.is_some() as u64;
898        let day = self.day(hour / 24);
899        if g == AI {
900            day.ai_agents.add(&format!("{}\t{}\t{}\t{}", or_unknown(class.name), class.operator, class.kind.label(), class.verified), 1);
901            if let Some(page) = page {
902                day.ai_pages.add(&page, 1);
903            }
904        } else {
905            let kind = if class.kind == Kind::Browser { "browser-no-assets" } else { class.kind.label() };
906            day.bots.add(&format!("{kind}\t{}", class.name), 1);
907        }
908        if let Some(path) = not_found {
909            day.not_found.add(&path, 1);
910        }
911    }
912}
913
914fn or_unknown(name: &str) -> &str {
915    if name.is_empty() { "(unnamed)" } else { name }
916}
917
918/// Decide browsers that went quiet without loading an asset: bots.
919#[doc(hidden)]
920pub fn settle(now: u64) {
921    for shard in &recorder().shards {
922        let mut shard = shard.lock().unwrap_or_else(|e| e.into_inner());
923        shard.close_sessions(|s| now.saturating_sub(s.last) >= SESSION_GAP);
924        let done: Vec<u64> = shard.pending.iter().filter(|(_, p)| now.saturating_sub(p.last) >= UNDECIDED.as_secs()).map(|(v, _)| *v).collect();
925        for visitor in done {
926            let Some(pending) = shard.pending.remove(&visitor) else { continue };
927            let day = pending.requests.first().map_or(now / 86_400, |d| d.hour / 24);
928            if shard.seen.insert(visitor, Group::Bot).is_none() {
929                shard.day(day).visitors[BOTS] += 1;
930            }
931            for d in pending.requests {
932                shard.count(BOTS, d.hour, d.host, &browser_without_assets(), d.page, d.not_found, visitor);
933            }
934        }
935    }
936}
937
938/// Everything counted, merged across shards: `(hour, host) → counts` and
939/// `day → lists` (after settling quiet browsers as of `now`).
940#[doc(hidden)]
941pub fn snapshot(now: u64) -> (BTreeMap<(u64, u8), [Counts; 3]>, BTreeMap<u64, Day>) {
942    settle(now);
943    drain_locals();
944    let mut hours: BTreeMap<(u64, u8), [Counts; 3]> = BTreeMap::new();
945    let mut days: BTreeMap<u64, Day> = BTreeMap::new();
946    for shard in &recorder().shards {
947        let shard = shard.lock().unwrap_or_else(|e| e.into_inner());
948        for (key, counts) in &shard.hours {
949            let entry = hours.entry(*key).or_default();
950            for g in 0..3 {
951                entry[g].requests += counts[g].requests;
952                entry[g].pages += counts[g].pages;
953            }
954        }
955        for (day, lists) in &shard.days {
956            days.entry(*day).or_default().merge(lists);
957        }
958        // Open sessions count as if they ended now (they stay open).
959        for session in shard.open.values() {
960            add_session(days.entry(session.start / 86_400).or_default(), session);
961        }
962    }
963    (hours, days)
964}
965
966/// A closed (or, in a snapshot, open) session's totals, exit page and goals
967/// into its day.
968fn add_session(day: &mut Day, session: &Session) {
969    let host = session.exit.split('/').next().unwrap_or("").to_string();
970    day.sessions.entry(host.clone()).or_default().add(&session.totals());
971    day.exit_pages.add(&session.exit, 1);
972    if session.goals != 0 {
973        for (i, goal) in goals().iter().enumerate() {
974            if session.goals & (1 << i) != 0 {
975                day.behaviour[GOALS].add_capped(&format!("{host}\t{goal}"), 1, DIM_TOP);
976            }
977        }
978    }
979}
980
981fn goals_lock() -> &'static RwLock<Vec<String>> {
982    static GOALS_SET: OnceLock<RwLock<Vec<String>>> = OnceLock::new();
983    GOALS_SET.get_or_init(|| RwLock::new(super::config::goals()))
984}
985
986/// The configured goals (read from the settings once).
987fn goals() -> std::sync::RwLockReadGuard<'static, Vec<String>> {
988    goals_lock().read().unwrap_or_else(|e| e.into_inner())
989}
990
991/// Replace the goals (tests).
992#[cfg(test)]
993#[doc(hidden)]
994pub fn set_goals_for_tests(goals: &[&str]) {
995    *goals_lock().write().unwrap_or_else(|e| e.into_inner()) = goals.iter().map(|g| g.to_string()).collect();
996}
997
998/// The goals a page view (`host/path`) reaches.
999fn page_goals(page: &str) -> u32 {
1000    let path = page.find('/').map_or("/", |i| &page[i..]);
1001    let mut reached = 0;
1002    for (i, goal) in goals().iter().enumerate() {
1003        let hit = match goal.strip_suffix('*') {
1004            _ if goal.starts_with("event:") => false,
1005            Some(prefix) => path.starts_with(prefix),
1006            None => path == goal,
1007        };
1008        if hit {
1009            reached |= 1 << i;
1010        }
1011    }
1012    reached
1013}
1014
1015/// The goals an event reaches.
1016fn event_goals(name: &str) -> u32 {
1017    let mut reached = 0;
1018    for (i, goal) in goals().iter().enumerate() {
1019        if goal.strip_prefix("event:") == Some(name) {
1020            reached |= 1 << i;
1021        }
1022    }
1023    reached
1024}
1025
1026fn json_string(raw: &str) -> Option<String> {
1027    crate::mcp::json_rpc::extract_str(&format!("{{\"v\":{raw}}}"), "v")
1028}
1029
1030fn width_bucket(width: u64) -> &'static str {
1031    match width {
1032        0 => "(unknown)",
1033        1..=599 => "phone",
1034        600..=1023 => "tablet",
1035        1024..=1439 => "laptop",
1036        _ => "wide",
1037    }
1038}
1039
1040/// A report from the first-party script (`/_rws/t`): `{"t":"leave"|"view"|
1041/// "event", …}`. Counted only for a visitor already counted as a person
1042/// today — a client that never loaded the page's assets can't post reports
1043/// in.
1044#[doc(hidden)]
1045pub fn beacon(ip: IpAddr, raw_host: &str, user_agent: Option<&str>, body: &str, now: u64) {
1046    if !enabled() {
1047        return;
1048    }
1049    let Some(members) = crate::mcp::json_rpc::object_members(body) else { return };
1050    let get = |key: &str| members.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str());
1051    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);
1052    let Some(kind) = get("t").and_then(json_string) else { return };
1053    let Some(path) = get("p").and_then(json_string).filter(|p| p.starts_with('/') && p.len() <= 1000) else { return };
1054    let host = host_index(raw_host);
1055    let day = now / 86_400;
1056    let visitor = visitor(day, host, ip, user_agent);
1057    let host_name = normalize_host(raw_host);
1058
1059    if kind == "view" {
1060        // A single-page app's route change: a page view like any other,
1061        // arriving from the page before (same site, so not a new arrival).
1062        let from = get("r").and_then(json_string).filter(|r| r.starts_with('/')).unwrap_or_else(|| "/".to_string());
1063        let referer = format!("https://{host_name}{from}");
1064        let is_person = {
1065            let shard = recorder().shards[(visitor % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
1066            shard.seen.get(&visitor) == Some(&Group::Person)
1067        };
1068        if is_person {
1069            record(&Hit {
1070                ip,
1071                host: raw_host,
1072                user_agent,
1073                referer: Some(&referer),
1074                method: "GET",
1075                path: &path,
1076                status: 200,
1077                content_type: Some("text/html"),
1078                headers: &[],
1079                now,
1080            });
1081        }
1082        return;
1083    }
1084
1085    let mut shard = recorder().shards[(visitor % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
1086    if shard.seen.get(&visitor) != Some(&Group::Person) {
1087        return;
1088    }
1089    if let Some(session) = shard.open.get_mut(&visitor) {
1090        session.reports += 1;
1091        if session.reports > MAX_REPORTS {
1092            return;
1093        }
1094    }
1095    let page = page_key(raw_host, &path);
1096    match kind.as_str() {
1097        "leave" => {
1098            // A report without a usable time isn't one.
1099            let Some(ms) = number("ms") else { return };
1100            let secs = ms.min(MAX_ENGAGED_SECS * 1000) / 1000;
1101            let depth = number("sd").unwrap_or(0).min(100);
1102            let width = number("w").unwrap_or(0);
1103            let bucket = match depth {
1104                0..=25 => "25",
1105                26..=50 => "50",
1106                51..=75 => "75",
1107                _ => "100",
1108            };
1109            #[cfg(feature = "model-postgres")]
1110            super::store::enqueue(super::store::Row::Report(super::store::Report {
1111                at: now,
1112                host: host_name.clone(),
1113                path: path.clone(),
1114                visitor,
1115                kind: "leave",
1116                secs: Some(secs),
1117                scroll: Some(depth),
1118                width: Some(width),
1119                ..Default::default()
1120            }));
1121            let d = shard.day(day);
1122            d.behaviour[ENGAGED_SECS].add(&page, secs);
1123            d.behaviour[ENGAGED_VIEWS].add(&page, 1);
1124            d.behaviour[SCROLL].add_capped(&format!("{host_name}\t{bucket}"), 1, DIM_TOP);
1125            d.behaviour[SCREENS].add_capped(&format!("{host_name}\t{}", width_bucket(width)), 1, DIM_TOP);
1126        }
1127        "event" => {
1128            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 {
1129                return;
1130            };
1131            let props: Vec<(String, String)> = get("q")
1132                .and_then(crate::mcp::json_rpc::object_members)
1133                .unwrap_or_default()
1134                .into_iter()
1135                .take(4)
1136                .filter_map(|(k, v)| Some((k.chars().take(32).collect::<String>(), json_string(&v)?.chars().take(64).collect::<String>())))
1137                .collect();
1138            let reached = event_goals(&name);
1139            if reached != 0 {
1140                if let Some(session) = shard.open.get_mut(&visitor) {
1141                    session.goals |= reached;
1142                }
1143            }
1144            #[cfg(feature = "model-postgres")]
1145            super::store::enqueue(super::store::Row::Report(super::store::Report {
1146                at: now,
1147                host: host_name.clone(),
1148                path: path.clone(),
1149                visitor,
1150                kind: "event",
1151                name: Some(name.clone()),
1152                props: (!props.is_empty()).then(|| props.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&")),
1153                ..Default::default()
1154            }));
1155            let d = shard.day(day);
1156            d.behaviour[EVENTS].add_capped(&format!("{host_name}\t{name}"), 1, DIM_TOP);
1157            for (k, v) in props {
1158                d.behaviour[EVENT_PROPS].add_capped(&format!("{host_name}\t{name}\t{k}={v}"), 1, DIM_TOP);
1159            }
1160        }
1161        _ => {}
1162    }
1163}
1164
1165/// People's page views of the last [`LIVE_WINDOW`] before `now`, from every
1166/// shard (unordered). Visitor keys are the day's hashes: comparable within
1167/// the result, meaningless outside it.
1168#[doc(hidden)]
1169pub fn recent(now: u64) -> Vec<Recent> {
1170    settle(now);
1171    let mut out = Vec::new();
1172    for shard in &recorder().shards {
1173        let shard = shard.lock().unwrap_or_else(|e| e.into_inner());
1174        out.extend(shard.recent.iter().filter(|r| r.at + LIVE_WINDOW >= now && r.at <= now).cloned());
1175    }
1176    out
1177}
1178
1179/// Put back what [`snapshot`] gave (loading a saved file): counts are added
1180/// to shard 0.
1181#[doc(hidden)]
1182pub fn restore(hosts: Vec<String>, hours: BTreeMap<(u64, u8), [Counts; 3]>, days: BTreeMap<u64, Day>) {
1183    let recorder = recorder();
1184    let mut known = recorder.hosts.write().unwrap_or_else(|e| e.into_inner());
1185    let map: Vec<u8> = hosts
1186        .iter()
1187        .map(|h| match known.iter().position(|k| k == h) {
1188            Some(i) => i as u8,
1189            None if known.len() < MAX_HOSTS + 1 => {
1190                known.push(h.clone());
1191                (known.len() - 1) as u8
1192            }
1193            None => 0,
1194        })
1195        .collect();
1196    drop(known);
1197    HOSTS_GENERATION.fetch_add(1, Ordering::Relaxed);
1198    let mut shard = recorder.shards[0].lock().unwrap_or_else(|e| e.into_inner());
1199    for ((hour, host), counts) in hours {
1200        let host = map.get(host as usize).copied().unwrap_or(0);
1201        let entry = shard.hours.entry((hour, host)).or_default();
1202        for g in 0..3 {
1203            entry[g].requests += counts[g].requests;
1204            entry[g].pages += counts[g].pages;
1205        }
1206    }
1207    for (day, lists) in days {
1208        shard.days.entry(day).or_default().merge(&lists);
1209    }
1210}
1211
1212#[cfg(test)]
1213#[doc(hidden)]
1214pub fn disable_for_tests() {
1215    ENABLED.store(false, Ordering::Relaxed);
1216}
1217
1218#[cfg(test)]
1219#[doc(hidden)]
1220pub fn reset_for_tests() {
1221    let recorder = recorder();
1222    HOSTS_GENERATION.fetch_add(1, Ordering::Relaxed);
1223    for local in locals().lock().unwrap_or_else(|e| e.into_inner()).iter().filter_map(|w| w.upgrade()) {
1224        *local.lock().unwrap_or_else(|e| e.into_inner()) = Local::default();
1225    }
1226    for shard in &recorder.shards {
1227        *shard.lock().unwrap_or_else(|e| e.into_inner()) = Shard::default();
1228    }
1229    recorder.hosts.write().unwrap_or_else(|e| e.into_inner()).clear();
1230}