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: every site the configuration routes
6//! ([`set_configured_hosts`], up to [`MAX_SLOTS`]) and [`MAX_HOSTS`] other
7//! Host headers (more go to `other`),
8//! hourly buckets for `retention_days`, top lists of [`TOP`] entries a day
9//! (the rest added to `(other)`), [`MAX_PENDING`] undecided browsers per
10//! shard, a classification cache of [`MAX_CACHE`] per shard. Unique
11//! visitors are counted with `hash(secret, day, host, ip, user-agent)` —
12//! the secret is made at start and never stored, nor are the hashes — so
13//! nobody can be followed from one day to the next and no IP or User-Agent
14//! is kept.
15//!
16//! A browser isn't a person until it loads a stylesheet, script or font as
17//! a browser rendering the page does: its requests wait in a pending record
18//! and are counted as a person's then, or as "browser, no assets" (a bot)
19//! after [`UNDECIDED`] without one — or at once after 32 requests without
20//! one.
21
22use 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
32/// Host headers that aren't a configured site counted separately; others
33/// are `other`.
34pub const MAX_HOSTS: usize = 16;
35/// Host slots in all (the index is a `u8`; one is kept for `other`).
36pub const MAX_SLOTS: usize = 255;
37/// Entries a daily top list keeps.
38pub const TOP: usize = 200;
39/// Entries of each top list kept on disk and reported.
40pub const TOP_SAVED: usize = 100;
41/// Browsers awaiting their first asset, per shard.
42pub const MAX_PENDING: usize = 4096;
43/// Classifications cached per shard.
44pub const MAX_CACHE: usize = 16384;
45/// A browser that loads no asset this long after its last request isn't
46/// a person.
47pub const UNDECIDED: Duration = Duration::from_secs(120);
48/// A browser that makes this many requests without loading an asset is
49/// decided at once: automation.
50const MAX_PENDING_REQUESTS: usize = 32;
51const SHARDS: usize = 16;
52
53/// The groups counted, as array indexes: people, AI agents, bots, the
54/// site's apps (18.35) and the owner (18.35).
55const PEOPLE: usize = 0;
56const AI: usize = 1;
57const BOTS: usize = 2;
58const APPS: usize = 3;
59const OWNER: usize = 4;
60/// How many groups are counted.
61pub 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/// One request, as counted.
75#[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    /// The response's media type, when known.
86    pub content_type: Option<&'a str>,
87    /// The request's headers: read (for the `Sec-CH-UA-Platform` and
88    /// `Sec-CH-UA-Mobile` client hints) only when a visitor is first seen,
89    /// so other requests don't pay for the lookup.
90    pub headers: &'a [crate::header::Header],
91    pub now: u64,
92}
93
94/// The per-day lists of people's audience and sources, keyed
95/// `host\tvalue`: countries, browsers, OSes and device classes count
96/// visitors; channels and UTM values count visits (page views arriving
97/// from outside the site).
98pub 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;
102/// A person's page views more than this apart are separate sessions (as
103/// Google Analytics counts them).
104pub const SESSION_GAP: u64 = 30 * 60;
105/// Sessions open at once, per shard; past it the quietest is closed.
106#[cfg(not(test))]
107pub const MAX_OPEN_SESSIONS: usize = 8192;
108#[cfg(test)]
109pub const MAX_OPEN_SESSIONS: usize = 64;
110
111/// The per-day lists the script feeds (`[traffic] script`): seconds and
112/// page views with a report, keyed `host/path`; scroll depth, screen
113/// sizes, events and event properties, keyed `host\tvalue`; goal
114/// conversions (sessions that reached the goal), keyed `host\tgoal`.
115pub 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;
123/// Reports a session may make; the rest are ignored.
124pub const MAX_REPORTS: u32 = 500;
125/// The longest time on one page counted (a tab left open).
126pub const MAX_ENGAGED_SECS: u64 = 6 * 3600;
127
128/// How far back the live view looks.
129pub const LIVE_WINDOW: u64 = 30 * 60;
130/// People's recent page views kept for it, per shard.
131pub const MAX_RECENT: usize = 4096;
132
133/// A person's page view, for the live view.
134#[derive(Clone, Debug)]
135#[doc(hidden)]
136pub struct Recent {
137    pub at: u64,
138    pub visitor: u64,
139    /// `host/path`.
140    pub page: String,
141    pub country: Option<[u8; 2]>,
142}
143
144/// Entries a day's audience/source list keeps (all hosts together).
145pub const DIM_TOP: usize = 400;
146/// Entries of each audience/source list kept on disk.
147pub const DIM_SAVED: usize = 300;
148
149/// A person's country and device, worked out once a day.
150#[derive(Clone, Debug)]
151struct Audience {
152    country: Option<[u8; 2]>,
153    device: super::device::Device,
154}
155
156/// How a visit arrived, for a page view from outside the site.
157#[derive(Clone, Debug, Default)]
158struct Arrival {
159    channel: &'static str,
160    /// `utm_source`, `utm_medium`, `utm_campaign`.
161    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/// A top list: counts by key, at most [`TOP`] keys and `(other)`.
172#[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/// One day's lists and unique visitors (all hosts; keys carry the host).
208#[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    /// `name\toperator\tkind\tverified`, all sites: days saved before
215    /// 18.35 only (now [`Day::site_ai_agents`]).
216    pub ai_agents: Top,
217    /// `kind\tname`, all sites: days saved before 18.35 only.
218    pub bots: Top,
219    /// Referring hosts, all sites: days saved before 18.35 only.
220    pub referrers: Top,
221    /// Unique visitors per site: `host\tgroup` (`0` people, `1` AI, `2` bots).
222    pub site_visitors: Top,
223    /// `host\tname\toperator\tkind\tverified`.
224    pub site_ai_agents: Top,
225    /// `host\tkind\tname`.
226    pub site_bots: Top,
227    /// `host\treferring host`.
228    pub site_referrers: Top,
229    pub not_found: Top,
230    /// The [`DIMS`] lists, in that order.
231    pub dims: [Top; 8],
232    /// People's sessions per host (by the day they started).
233    pub sessions: BTreeMap<String, Sessions>,
234    /// The first and last page of people's sessions (`host/path`).
235    pub entry_pages: Top,
236    pub exit_pages: Top,
237    /// The [`BEHAVIOUR`] lists, in that order.
238    pub behaviour: [Top; 7],
239    /// The [`EXTRA`] lists (18.35), in that order.
240    pub extra: [Top; EXTRA.len()],
241    /// People's visits that ended this day (18.35): the recent-visitors
242    /// feed. Only the last [`MAX_VISITS`] are kept across all days.
243    pub visits: Vec<Visit>,
244}
245
246/// The per-day lists added in 18.35, all keyed by host first:
247/// - `site_apps`: app users, `host\tapp\tplatform`;
248/// - `app_versions`: app users, `host\tapp\tversion`;
249/// - `app_events`: events apps sent to `/_rws/e`, `host\tapp\tevent`;
250/// - `site_tiers`: visitors by how sure rws is they're a person,
251///   `host\ttier` (`verified` ran the script, `page-only` loaded no asset,
252///   `hosting` came from a server network);
253/// - `site_probes`: scanner requests, `host\tpath`;
254/// - `probe_sources`: scanner requests, `host\tcountry\tnetwork` (and the
255///   address with `[traffic] probe_addresses`);
256/// - `site_crawl`: named bots' and AI agents' page fetches and 404s,
257///   `host\tname\tpages|404`;
258/// - `crawl_404`: the paths named bots got a 404 for, `host/path\tname`;
259/// - `site_entries`: the first page of people's visits by channel,
260///   `host/path\tchannel`;
261/// - `downloads`: files downloaded (installers, packages, archives,
262///   `install.sh`), `host/path\tgroup`;
263/// - `owner_pages`: the owner's page views, `host/path`.
264pub 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
278/// Visits kept for the recent-visitors feed (across all days).
279pub const MAX_VISITS: usize = 300;
280/// Pages a visit lists.
281pub const VISIT_PAGES: usize = 20;
282
283/// One person's visit (a closed session, or an open one in a snapshot),
284/// for the recent-visitors feed: what they read and how they came, with
285/// no address, User-Agent or visitor key.
286#[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    /// Paths, in order (at most [`VISIT_PAGES`]).
293    pub pages: Vec<String>,
294    /// Page views in all (more than `pages` when it's long).
295    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    /// The visitor ran the script (`[traffic] script`).
304    pub verified: bool,
305    /// This visitor's visit number today (2 = came back).
306    pub number: u32,
307    pub events: Vec<String>,
308}
309
310/// Sessions added up.
311#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
312#[doc(hidden)]
313pub struct Sessions {
314    pub sessions: u64,
315    /// Sessions of one page view.
316    pub bounces: u64,
317    /// Seconds from each session's first page view to its last.
318    pub duration: u64,
319    /// Page views in sessions.
320    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
333/// A person's session while it's open.
334struct Session {
335    start: u64,
336    last: u64,
337    pages: u64,
338    /// The page key (`host/path`) last viewed.
339    exit: String,
340    /// Goals reached, by index into the configured goals.
341    goals: u32,
342    /// How the session arrived (for the database's rows).
343    channel: Option<&'static str>,
344    utm: [Option<String>; 3],
345    /// Script reports so far.
346    reports: u32,
347    /// For the visit feed: paths in order (at most [`VISIT_PAGES`]), the
348    /// visitor's audience, the referring site, events, the visit number.
349    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
420/// A browser's requests while it's undecided.
421struct Pending {
422    last: u64,
423    requests: Vec<Deferred>,
424}
425
426struct Deferred {
427    /// When it was made (seconds).
428    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/// A visitor's classification, and for browsers their audience.
438#[derive(Clone)]
439struct Cached {
440    class: Class,
441    audience: Option<Audience>,
442    /// For an app client: the version its User-Agent names.
443    app_version: Option<String>,
444}
445
446#[derive(Default)]
447struct Shard {
448    /// `(hour, host)` → counts per group.
449    hours: BTreeMap<(u64, u8), [Counts; GROUPS]>,
450    days: BTreeMap<u64, Day>,
451    /// Today's visitors and the group each was counted in.
452    seen: HashMap<u64, Group>,
453    pending: HashMap<u64, Pending>,
454    cache: HashMap<u64, Cached>,
455    /// People's open sessions by visitor.
456    open: HashMap<u64, Session>,
457    /// People's page views of the last [`LIVE_WINDOW`], oldest first.
458    recent: VecDeque<Recent>,
459    today: u64,
460    /// Today's people who ran the script (18.35).
461    verified: std::collections::HashSet<u64>,
462    /// Visits each person started today.
463    visit_numbers: HashMap<u64, u32>,
464    /// Events each app client sent today (`/_rws/e`), to cap them.
465    app_reports: HashMap<u64, u32>,
466}
467
468struct Recorder {
469    shards: Vec<Mutex<Shard>>,
470    hosts: RwLock<Vec<String>>,
471    /// The sites the configuration routes (normalized, sorted).
472    configured: RwLock<Vec<String>>,
473    /// A secret made at start and never saved; with the day it keys the
474    /// visitor hash, so one day's visitors can't be matched to the next's.
475    secret: RandomState,
476    retention_days: u64,
477    exclude: Vec<String>,
478    /// `[traffic] hosting_networks`.
479    hosting: bool,
480    /// Today's owner fingerprints (`hash(secret, day, ip, user-agent)`,
481    /// no host): `(day, set)`.
482    owners: Mutex<(u64, std::collections::HashSet<u64>)>,
483}
484
485static ENABLED: AtomicBool = AtomicBool::new(false);
486/// Bumped when host indexes change meaning (a restore, tests' resets).
487static HOSTS_GENERATION: AtomicU64 = AtomicU64::new(1);
488static CALLS: AtomicU64 = AtomicU64::new(0);
489/// Bumped when a client becomes the owner, so threads forget the group
490/// they had decided for it.
491static 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
507/// Start counting (the binary does this when the admin API is on).
508pub fn enable() {
509    // The access log (no `log` module on wasm32) reports each request here.
510    #[cfg(not(target_arch = "wasm32"))]
511    crate::log::set_access_observer(super::observe);
512    let _ = recorder();
513    ENABLED.store(true, Ordering::Relaxed);
514}
515
516/// Whether requests are being counted.
517pub fn enabled() -> bool {
518    ENABLED.load(Ordering::Relaxed)
519}
520
521/// Seconds since the epoch, as the counts use.
522pub fn now() -> u64 {
523    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
524}
525
526/// A Host header as counted: lowercase, without the port.
527fn normalize_host(host: &str) -> String {
528    let host = host.trim();
529    // `[::1]:8080` keeps its brackets; `example.com:443` loses the port.
530    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    // The last host this thread saw: most requests repeat it.
539    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
565/// The slot of `host` (normalized), added when there's room: a configured
566/// site always gets one; another Host header while fewer than
567/// [`MAX_HOSTS`] such are counted (with no configured sites, the first
568/// [`MAX_HOSTS`] hosts seen); the rest share `other`.
569fn 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
592/// The most configured sites counted separately.
593const MAX_CONFIGURED: usize = MAX_SLOTS - MAX_HOSTS - 1;
594
595/// The sites the configuration routes (`[[route]] match.host`): each gets
596/// its own counts, ahead of whatever else arrives in a Host header, and is
597/// listed first on the Traffic page. Set at start and on every reload.
598pub 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    // `www.<site>` counts as `<site>`.
605    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 that went to `other` may have a slot now.
612        HOSTS_GENERATION.fetch_add(1, Ordering::Relaxed);
613    }
614}
615
616/// The configured sites ([`set_configured_hosts`]), sorted.
617#[doc(hidden)]
618pub fn configured_hosts() -> Vec<String> {
619    recorder().configured.read().unwrap_or_else(|e| e.into_inner()).clone()
620}
621
622/// The name of host slot `i`.
623fn 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
632/// `hash(secret, day, host, ip, user-agent)` — no lock.
633fn 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
664/// The site a Host header is counted under (18.35.2): with configured sites,
665/// a configured one as is, `www.<site>` as `<site>`, anything else (addresses,
666/// `(none)`, names no route serves, `[traffic] hide_hosts`) as `other`;
667/// without, the host itself.
668fn 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
678/// [`canonical`] of a raw Host header.
679fn site_of(raw: &str) -> String {
680    canonical(normalize_host(raw), &recorder().configured.read().unwrap_or_else(|e| e.into_inner()))
681}
682
683fn referrer_host(referer: Option<&str>, host: &str) -> Option<String> {
684    let rest = referer?.split_once("://")?.1;
685    let other = rest.split(['/', '?', '#']).next()?.split(':').next()?.to_ascii_lowercase();
686    // `www.example.com` linking to `example.com` is the same site.
687    (!other.is_empty() && other != normalize_host(host) && site_of(&other) != site_of(host)).then_some(other)
688}
689
690/// The channel a referring site belongs to.
691#[doc(hidden)]
692pub fn channel_of(referrer_host: &str) -> &'static str {
693    let h = referrer_host.strip_prefix("www.").unwrap_or(referrer_host);
694    let is = |d: &str| h == d || h.ends_with(&format!(".{d}"));
695    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"];
696    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"];
697    const SOCIAL: &[&str] = &[
698        "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",
699        "youtube.com", "tiktok.com", "pinterest.com", "news.ycombinator.com", "mastodon.social", "bsky.app", "threads.net", "vk.com", "telegram.org", "t.me", "discord.com",
700        "whatsapp.com", "lobste.rs",
701    ];
702    // App stores, before search: play.google.com isn't Google search.
703    const STORE: &[&str] = &[
704        "apps.apple.com", "itunes.apple.com", "play.google.com", "chromewebstore.google.com", "addons.mozilla.org", "microsoftedge.microsoft.com",
705        "apps.microsoft.com", "snapcraft.io", "flathub.org", "f-droid.org", "appgallery.huawei.com",
706    ];
707    if AI.iter().any(|d| is(d)) {
708        "ai"
709    } else if STORE.iter().any(|d| is(d)) || (h == "chrome.google.com") {
710        "store"
711    } else if SEARCH.iter().any(|d| is(d)) || h.starts_with("google.") || h.contains(".google.") {
712        "search"
713    } else if SOCIAL.iter().any(|d| is(d)) {
714        "social"
715    } else {
716        "referral"
717    }
718}
719
720/// `%XX` and `+` decoded, at most 100 characters; `None` when empty.
721fn query_value(raw: &str) -> Option<String> {
722    let bytes = raw.as_bytes();
723    let mut out = Vec::with_capacity(bytes.len().min(300));
724    let mut i = 0;
725    while i < bytes.len() && out.len() < 300 {
726        match bytes[i] {
727            b'+' => out.push(b' '),
728            b'%' if i + 2 < bytes.len() => {
729                match std::str::from_utf8(&bytes[i + 1..i + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok()) {
730                    Some(b) => {
731                        out.push(b);
732                        i += 2;
733                    }
734                    None => out.push(b'%'),
735                }
736            }
737            b => out.push(b),
738        }
739        i += 1;
740    }
741    let value: String = String::from_utf8_lossy(&out).trim().chars().filter(|c| !c.is_control()).take(100).collect();
742    (!value.is_empty()).then_some(value)
743}
744
745/// `utm_source`, `utm_medium` and `utm_campaign` from a request target.
746#[doc(hidden)]
747pub fn utm(target: &str) -> [Option<String>; 3] {
748    let mut out: [Option<String>; 3] = Default::default();
749    let Some(query) = target.split_once('?').map(|(_, q)| q.split('#').next().unwrap_or(q)) else { return out };
750    for pair in query.split('&') {
751        let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
752        let slot = match name {
753            "utm_source" => 0,
754            "utm_medium" => 1,
755            "utm_campaign" => 2,
756            _ => continue,
757        };
758        if out[slot].is_none() {
759            out[slot] = query_value(value);
760        }
761    }
762    out
763}
764
765/// How a page view arrived: `None` for navigation within the site (a
766/// same-site `Referer`), else the channel and any UTM values.
767fn arrival(hit: &Hit<'_>) -> Option<Arrival> {
768    let utm = utm(hit.path);
769    let channel = if utm[0].is_some() || utm[1].is_some() || utm[2].is_some() {
770        "campaign"
771    } else {
772        match hit.referer {
773            None => "direct",
774            Some(_) => channel_of(&referrer_host(hit.referer, hit.host)?),
775        }
776    };
777    Some(Arrival { channel, utm })
778}
779
780/// Requests of visitors already decided, counted by the thread that served
781/// them (merged by readers): most requests are assets and repeats, and they
782/// then cost no shared lock. Pages, 404s and undecided browsers take the
783/// shared path, which also feeds the lists.
784#[derive(Default)]
785struct Local {
786    /// Visitor → (day, counted group slot), as decided by a shard.
787    /// Visitor → (day, owner generation, counted group slot).
788    decided: HashMap<u64, (u64, u64, usize)>,
789    counts: BTreeMap<(u64, u8), [Counts; GROUPS]>,
790}
791
792/// How many decided visitors a thread remembers.
793const LOCAL_DECIDED: usize = 1024;
794
795fn locals() -> &'static Mutex<Vec<std::sync::Weak<Mutex<Local>>>> {
796    static LOCALS: OnceLock<Mutex<Vec<std::sync::Weak<Mutex<Local>>>>> = OnceLock::new();
797    LOCALS.get_or_init(Mutex::default)
798}
799
800thread_local! {
801    static LOCAL: std::sync::Arc<Mutex<Local>> = {
802        let local = std::sync::Arc::new(Mutex::new(Local::default()));
803        let mut all = locals().lock().unwrap_or_else(|e| e.into_inner());
804        all.retain(|w| w.strong_count() > 0);
805        all.push(std::sync::Arc::downgrade(&local));
806        local
807    };
808}
809
810/// Move every thread's buffered counts into shard 0.
811fn drain_locals() {
812    let all: Vec<_> = locals().lock().unwrap_or_else(|e| e.into_inner()).iter().filter_map(|w| w.upgrade()).collect();
813    let mut target = recorder().shards[0].lock().unwrap_or_else(|e| e.into_inner());
814    for local in all {
815        let counts = std::mem::take(&mut local.lock().unwrap_or_else(|e| e.into_inner()).counts);
816        for (key, c) in counts {
817            let entry = target.hours.entry(key).or_default();
818            for g in 0..GROUPS {
819                entry[g].requests += c[g].requests;
820                entry[g].pages += c[g].pages;
821            }
822        }
823    }
824}
825
826/// Count one request.
827#[doc(hidden)]
828pub fn record(hit: &Hit<'_>) {
829    if !enabled() {
830        return;
831    }
832    let recorder = recorder();
833    let path = hit.path.split('?').next().unwrap_or(hit.path);
834    // rws's own endpoints (the script's reports, app events, the owner
835    // link) are counted by what they report, not as requests. The script
836    // itself is an asset like any: loading it shows a browser.
837    if recorder.exclude.iter().any(|e| path == e)
838        || (path.starts_with("/_rws/") && matches!(path, super::script::BEACON_PATH | super::script::APP_EVENTS_PATH | super::owner::PATH))
839    {
840        return;
841    }
842    let day = hit.now / 86_400;
843    // A successful admin API call: this client is the owner from now on.
844    // (JSON first: it rules out almost every request without a search.)
845    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")));
846    if json && path.contains("/api/") && super::owner::is_admin_call(hit.headers, hit.status) {
847        mark_owner(hit.ip, hit.user_agent, hit.now);
848    }
849    let hour = hit.now / 3_600;
850    let host = host_index(hit.host);
851    let visitor = visitor(day, host, hit.ip, hit.user_agent);
852    let generation = OWNER_GENERATION.load(Ordering::Relaxed);
853    let download = is_download(hit, path);
854    let request_goal = request_goals_configured() && (200..400).contains(&hit.status);
855    // A decided visitor's non-page request: counted by this thread (no
856    // scanner check — the visitor keeps the group it was decided in).
857    if hit.status != 404 && !download && !request_goal && !is_page(hit) {
858        let counted = LOCAL.try_with(|local| {
859            let mut local = local.lock().unwrap_or_else(|e| e.into_inner());
860            match local.decided.get(&visitor) {
861                Some((d, gen, g)) if *d == day && *gen == generation => {
862                    let g = *g;
863                    local.counts.entry((hour, host)).or_default()[g].requests += 1;
864                    true
865                }
866                _ => false,
867            }
868        });
869        if counted == Ok(true) {
870            return;
871        }
872    }
873    let decided = record_shared(hit, day, hour, host, visitor, path, download);
874    if let Some(g) = decided {
875        let _ = LOCAL.try_with(|local| {
876            let mut local = local.lock().unwrap_or_else(|e| e.into_inner());
877            if local.decided.len() >= LOCAL_DECIDED {
878                local.decided.clear();
879            }
880            local.decided.insert(visitor, (day, generation, g));
881        });
882    }
883    if CALLS.fetch_add(1, Ordering::Relaxed) % 1024 == 0 {
884        settle(hit.now);
885    }
886}
887
888/// The shared path; returns the visitor's group slot once it's decided.
889fn record_shared(hit: &Hit<'_>, day: u64, hour: u64, host: u8, visitor: u64, path: &str, download: bool) -> Option<usize> {
890    let recorder = recorder();
891    let mut shard = recorder.shards[(visitor % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
892    shard.roll(day, recorder.retention_days);
893
894    let cached = match shard.cache.get(&visitor) {
895        Some(cached) => cached.clone(),
896        None => {
897            let class = classify(hit.ip, hit.user_agent, "/");
898            // The owner, then the site's apps, before the network checks:
899            // an app on a phone behind a cloud VPN is still the app.
900            let header = |name: &str| hit.headers.iter().find(|h| h.name.eq_ignore_ascii_case(name)).map(|h| h.value.as_ref());
901            let owner = class.kind != Kind::Local
902                && (super::owner::in_networks(hit.ip) || super::owner::has_cookie(hit.headers) || is_owner(day, hit.ip, hit.user_agent));
903            let app = if owner || class.verified || class.kind == Kind::Local { None } else { super::apps::detect(hit.user_agent, header("origin")) };
904            let app_version = app.as_ref().and_then(|a| a.version.clone());
905            let class = match (owner, app) {
906                (true, _) => owner_class(),
907                (false, Some(app)) => Class { kind: Kind::App, name: app.name, operator: app.platform, verified: false },
908                (false, None) => class,
909            };
910            // A browser from a hosting network is a program (looked up once
911            // per visitor: the class is cached).
912            let class = match (class.kind, recorder.hosting) {
913                (Kind::Browser, true) => match super::hosting::provider(hit.ip) {
914                    Some(provider) => Class { kind: Kind::Hosting, name: provider, operator: "", verified: false },
915                    None => class,
916                },
917                _ => class,
918            };
919            // Country and device only for browsers: they're shown for people.
920            let audience = (class.kind == Kind::Browser).then(|| Audience {
921                country: super::geo::country(hit.ip),
922                device: {
923                    let hint = |name: &str| hit.headers.iter().find(|h| h.name.eq_ignore_ascii_case(name)).map(|h| h.value.as_ref());
924                    super::device::parse(hit.user_agent.unwrap_or(""), hint("sec-ch-ua-platform"), hint("sec-ch-ua-mobile"))
925                },
926            });
927            let cached = Cached { class, audience, app_version };
928            if shard.cache.len() >= MAX_CACHE {
929                shard.cache.clear();
930            }
931            shard.cache.insert(visitor, cached.clone());
932            cached
933        }
934    };
935    let class = cached.class;
936    let scanner = super::classify::scanner_path(hit.path) && matches!(class.kind, Kind::Browser | Kind::Hosting | Kind::Tool | Kind::NoUserAgent);
937    let class = if scanner { Class { kind: Kind::Scanner, name: "", operator: "", verified: false } } else { class };
938    let page = is_page(hit).then(|| page_key(hit.host, path));
939    let not_found = (hit.status == 404).then(|| page_key(hit.host, path));
940    let referrer = referrer_host(hit.referer, hit.host);
941    // Where a person's page view came from (only browsers become people).
942    let arrival = if page.is_some() && class.kind == Kind::Browser { arrival(hit) } else { None };
943
944    let decided = match class.group() {
945        Group::Internal => None,
946        Group::Person => {
947            match shard.seen.get(&visitor).copied() {
948                Some(Group::Person) => {
949                    shard.count_person(visitor, hit.now, host, page, referrer, not_found, arrival);
950                    Some(PEOPLE)
951                }
952                Some(_) => {
953                    shard.count(BOTS, hour, host, &browser_without_assets(), page, not_found, visitor);
954                    Some(BOTS)
955                }
956                None if is_asset(path) => {
957                    shard.seen.insert(visitor, Group::Person);
958                    shard.new_visitor(day, host, PEOPLE);
959                    if let Some(audience) = &cached.audience {
960                        shard.count_audience(day, &site_of(hit.host), audience);
961                    }
962                    if let Some(pending) = shard.pending.remove(&visitor) {
963                        for d in pending.requests {
964                            shard.count_person(visitor, d.at, d.host, d.page, d.referrer, d.not_found, d.arrival);
965                        }
966                    }
967                    shard.count_person(visitor, hit.now, host, page, referrer, not_found, arrival);
968                    Some(PEOPLE)
969                }
970                None => {
971                    if shard.pending.len() >= MAX_PENDING && !shard.pending.contains_key(&visitor) {
972                        // Too many undecided at once: count this one as a bot.
973                        shard.seen.insert(visitor, Group::Bot);
974                        shard.new_visitor(day, host, BOTS);
975                        shard.count(BOTS, hour, host, &browser_without_assets(), page, not_found, visitor);
976                        Some(BOTS)
977                    } else {
978                        let pending = shard.pending.entry(visitor).or_insert(Pending { last: hit.now, requests: Vec::new() });
979                        pending.last = hit.now;
980                        pending.requests.push(Deferred { at: hit.now, hour, host, page, referrer, not_found, arrival });
981                        if pending.requests.len() < MAX_PENDING_REQUESTS {
982                            None
983                        } else {
984                            // This many requests and no stylesheet, script or
985                            // font: automation, not someone reading pages.
986                            let pending = shard.pending.remove(&visitor).expect("just added");
987                            shard.seen.insert(visitor, Group::Bot);
988                            shard.new_visitor(day, host, BOTS);
989                            shard.count_page_only(day, host);
990                            for d in pending.requests {
991                                shard.count(BOTS, d.hour, d.host, &browser_without_assets(), d.page, d.not_found, visitor);
992                            }
993                            Some(BOTS)
994                        }
995                    }
996                }
997            }
998        }
999        group => {
1000            let g = slot(group).expect("counted group");
1001            if shard.seen.insert(visitor, group).is_none() {
1002                shard.new_visitor(day, host, g);
1003                let site = host_name(host);
1004                if g == APPS {
1005                    shard.count_app_user(day, &site, class.name, class.operator, cached.app_version.as_deref());
1006                } else if class.kind == Kind::Hosting {
1007                    shard.day(day).extra[SITE_TIERS].add_capped(&format!("{site}\thosting"), 1, DIM_TOP);
1008                }
1009            }
1010            if scanner {
1011                shard.count_probe(day, &host_name(host), path, hit.ip);
1012            }
1013            shard.count(g, hour, host, &class, page, not_found, visitor);
1014            // A scanner request isn't the visitor's usual class: not cached.
1015            (!scanner).then_some(g)
1016        }
1017    };
1018    if download {
1019        let label = decided.map_or("undecided", group_label);
1020        shard.day(day).extra[DOWNLOADS].add_capped(&format!("{}\t{label}", page_key(hit.host, path)), 1, DIM_TOP);
1021    }
1022    if decided == Some(PEOPLE) && request_goals_configured() {
1023        let reached = request_goals(path);
1024        if let (true, Some(session)) = (reached != 0, shard.open.get_mut(&visitor)) {
1025            session.goals |= reached;
1026        }
1027    }
1028    decided
1029}
1030
1031/// The name of a counted group slot.
1032fn group_label(g: usize) -> &'static str {
1033    ["people", "ai", "bots", "apps", "owner"].get(g).copied().unwrap_or("other")
1034}
1035
1036fn owner_class() -> Class {
1037    Class { kind: Kind::Owner, name: "", operator: "", verified: false }
1038}
1039
1040/// `hash(secret, day, ip, user-agent)`: a client on every site, for the
1041/// owner's fingerprint.
1042fn fingerprint(day: u64, ip: IpAddr, user_agent: Option<&str>) -> u64 {
1043    let mut hasher = recorder().secret.build_hasher();
1044    "owner".hash(&mut hasher);
1045    day.hash(&mut hasher);
1046    ip.hash(&mut hasher);
1047    user_agent.unwrap_or("").hash(&mut hasher);
1048    hasher.finish()
1049}
1050
1051fn is_owner(day: u64, ip: IpAddr, user_agent: Option<&str>) -> bool {
1052    let owners = recorder().owners.lock().unwrap_or_else(|e| e.into_inner());
1053    owners.0 == day && owners.1.contains(&fingerprint(day, ip, user_agent))
1054}
1055
1056/// Make the client at `ip` with `user_agent` the owner for the rest of
1057/// the day, on every site: its visitors on each site are re-decided as the
1058/// owner (an open visit is dropped, not counted).
1059#[doc(hidden)]
1060pub fn mark_owner(ip: IpAddr, user_agent: Option<&str>, now: u64) {
1061    let day = now / 86_400;
1062    let fp = fingerprint(day, ip, user_agent);
1063    {
1064        let mut owners = recorder().owners.lock().unwrap_or_else(|e| e.into_inner());
1065        if owners.0 != day {
1066            *owners = (day, std::collections::HashSet::new());
1067        }
1068        // Bounded: a day's owner clients are few; past 1024 something is off.
1069        if owners.1.len() >= 1024 || !owners.1.insert(fp) {
1070            return;
1071        }
1072    }
1073    let slots = recorder().hosts.read().unwrap_or_else(|e| e.into_inner()).len();
1074    for host in 0..slots.max(1) {
1075        let v = visitor(day, host as u8, ip, user_agent);
1076        let mut shard = recorder().shards[(v % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
1077        if shard.today != day {
1078            continue;
1079        }
1080        shard.pending.remove(&v);
1081        shard.open.remove(&v);
1082        shard.seen.insert(v, Group::Owner);
1083        if shard.cache.len() >= MAX_CACHE {
1084            shard.cache.clear();
1085        }
1086        shard.cache.insert(v, Cached { class: owner_class(), audience: None, app_version: None });
1087    }
1088    OWNER_GENERATION.fetch_add(1, Ordering::Relaxed);
1089}
1090
1091/// Extensions of files counted as downloads.
1092const DOWNLOAD_EXTENSIONS: &[&str] = &[
1093    ".dmg", ".pkg", ".zip", ".tar.gz", ".tgz", ".tar.xz", ".exe", ".msi", ".msix", ".deb", ".rpm", ".appimage", ".apk", ".aab", ".ipa", ".vsix", ".xpi",
1094    ".crx", ".flatpak", ".snap", ".7z",
1095];
1096
1097/// A downloaded file: an installer, package or archive, or an install
1098/// script (`install.sh`, `install.ps1`), fetched in full.
1099fn is_download(hit: &Hit<'_>, path: &str) -> bool {
1100    if hit.status != 200 || hit.method != "GET" {
1101        return false;
1102    }
1103    let last = path.rsplit('/').next().unwrap_or("").as_bytes();
1104    // Pages, scripts, styles and fonts end in a byte no download ends in.
1105    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')) {
1106        return false;
1107    }
1108    let ends = |ext: &str| last.len() >= ext.len() && last[last.len() - ext.len()..].eq_ignore_ascii_case(ext.as_bytes());
1109    DOWNLOAD_EXTENSIONS.iter().any(|ext| ends(ext)) || last.eq_ignore_ascii_case(b"install.sh") || last.eq_ignore_ascii_case(b"install.ps1")
1110}
1111
1112fn browser_without_assets() -> Class {
1113    Class { kind: Kind::Browser, name: "browser, no assets", operator: "", verified: false }
1114}
1115
1116impl Shard {
1117    fn day(&mut self, day: u64) -> &mut Day {
1118        self.days.entry(day).or_default()
1119    }
1120
1121    /// A visitor first seen today, in group `g`, on host slot `host`.
1122    fn new_visitor(&mut self, day: u64, host: u8, g: usize) {
1123        let name = host_name(host);
1124        let day = self.day(day);
1125        day.visitors[g] += 1;
1126        day.site_visitors.add_capped(&format!("{name}\t{g}"), 1, DIM_TOP);
1127    }
1128
1129    /// A new day: forget today's visitors and cache; drop what's past retention.
1130    fn roll(&mut self, day: u64, retention_days: u64) {
1131        if self.today == day {
1132            return;
1133        }
1134        self.today = day;
1135        // Visitors are new each day, so no session continues past midnight.
1136        self.close_sessions(|_| true);
1137        self.seen.clear();
1138        self.cache.clear();
1139        self.verified.clear();
1140        self.visit_numbers.clear();
1141        self.app_reports.clear();
1142        let oldest_day = day.saturating_sub(retention_days.saturating_sub(1));
1143        self.days.retain(|d, _| *d >= oldest_day);
1144        self.hours.retain(|(h, _), _| *h / 24 >= oldest_day);
1145    }
1146
1147    /// A person's page view as a database row.
1148    #[cfg(feature = "model-postgres")]
1149    fn store_view(&self, visitor: u64, at: u64, page: &str, referrer: Option<&str>) {
1150        let (host, path) = page.find('/').map_or((page, "/"), |i| (&page[..i], &page[i..]));
1151        let session = self.open.get(&visitor);
1152        let audience = self.cache.get(&visitor).and_then(|c| c.audience.as_ref());
1153        super::store::enqueue(super::store::Row::PageView(super::store::PageView {
1154            at,
1155            host: host.to_string(),
1156            path: path.to_string(),
1157            visitor,
1158            session_start: session.map_or(at, |s| s.start),
1159            referrer_host: referrer.map(str::to_string),
1160            channel: session.and_then(|s| s.channel),
1161            utm: session.map(|s| s.utm.clone()).unwrap_or_default(),
1162            country: audience.and_then(|a| a.country).map(|c| String::from_utf8_lossy(&c).into_owned()),
1163            browser: audience.map(|a| a.device.browser),
1164            os: audience.map(|a| a.device.os),
1165            device: audience.map(|a| a.device.class),
1166        }));
1167    }
1168
1169    /// A person ran the script today: verified (counted once a day).
1170    fn mark_verified(&mut self, visitor: u64, day: u64, host: &str) {
1171        if self.verified.insert(visitor) {
1172            self.day(day).extra[SITE_TIERS].add_capped(&format!("{host}\tverified"), 1, DIM_TOP);
1173        }
1174    }
1175
1176    /// A new app user: by app and platform, and by version when known.
1177    fn count_app_user(&mut self, day: u64, host: &str, app: &str, platform: &str, version: Option<&str>) {
1178        let day = self.day(day);
1179        day.extra[SITE_APPS].add_capped(&format!("{host}\t{app}\t{platform}"), 1, DIM_TOP);
1180        day.extra[APP_VERSIONS].add_capped(&format!("{host}\t{app}\t{}", version.unwrap_or("(unknown)")), 1, DIM_TOP);
1181    }
1182
1183    /// A scanner's request: the path, and where it came from (country and
1184    /// network; the address too with `[traffic] probe_addresses`).
1185    fn count_probe(&mut self, day: u64, host: &str, path: &str, ip: IpAddr) {
1186        let country = super::geo::country(ip).map_or("(unknown)".to_string(), |c| String::from_utf8_lossy(&c).into_owned());
1187        let network = super::hosting::provider(ip).unwrap_or("-");
1188        let source = if super::config::probe_addresses() { format!("{host}\t{country}\t{network}\t{}", ip.to_canonical()) } else { format!("{host}\t{country}\t{network}") };
1189        let day = self.day(day);
1190        day.extra[SITE_PROBES].add_capped(&format!("{host}\t{}", path.chars().take(200).collect::<String>()), 1, DIM_TOP);
1191        day.extra[PROBE_SOURCES].add_capped(&source, 1, DIM_TOP);
1192    }
1193
1194    /// A browser decided to be no person (it loaded no asset).
1195    fn count_page_only(&mut self, day: u64, host: u8) {
1196        let site = host_name(host);
1197        self.day(day).extra[SITE_TIERS].add_capped(&format!("{site}\tpage-only"), 1, DIM_TOP);
1198    }
1199
1200    /// A new person's country, browser, OS and device class.
1201    fn count_audience(&mut self, day: u64, host: &str, audience: &Audience) {
1202        let day = self.day(day);
1203        let country = audience.country.map(|c| String::from_utf8_lossy(&c).into_owned());
1204        let values = [country.as_deref().unwrap_or("(unknown)"), audience.device.browser, audience.device.os, audience.device.class];
1205        for (i, value) in values.iter().enumerate() {
1206            day.dims[COUNTRIES + i].add_capped(&format!("{host}\t{value}"), 1, DIM_TOP);
1207        }
1208    }
1209
1210    /// Close the open sessions `which` picks, adding them to the day they
1211    /// started.
1212    fn close_sessions(&mut self, which: impl Fn(&Session) -> bool) {
1213        let done: Vec<u64> = self.open.iter().filter(|(_, s)| which(s)).map(|(v, _)| *v).collect();
1214        for visitor in done {
1215            if let Some(session) = self.open.remove(&visitor) {
1216                self.close(session);
1217            }
1218        }
1219    }
1220
1221    fn close(&mut self, mut session: Session) {
1222        session.verified |= self.verified.contains(&session.visitor);
1223        let day = self.day(session.start / 86_400);
1224        add_session(day, &session);
1225        if day.visits.len() >= MAX_VISITS {
1226            day.visits.remove(0);
1227        }
1228        day.visits.push(session.visit());
1229    }
1230
1231    /// A person's page view at `at`: continues their session, or ends it and
1232    /// starts the next. True when it started one.
1233    fn session_page(&mut self, visitor: u64, at: u64, page: &str) -> bool {
1234        if let Some(session) = self.open.get_mut(&visitor) {
1235            if at.saturating_sub(session.last) < SESSION_GAP {
1236                session.last = session.last.max(at);
1237                session.pages += 1;
1238                session.exit = page.to_string();
1239                session.goals |= page_goals(page);
1240                if session.paths.len() < VISIT_PAGES {
1241                    session.paths.push(path_of(page).to_string());
1242                }
1243                return false;
1244            }
1245            let ended = self.open.remove(&visitor).expect("just found");
1246            self.close(ended);
1247        }
1248        if self.open.len() >= MAX_OPEN_SESSIONS {
1249            if let Some(quietest) = self.open.iter().min_by_key(|(_, s)| s.last).map(|(v, _)| *v) {
1250                let ended = self.open.remove(&quietest).expect("just found");
1251                self.close(ended);
1252            }
1253        }
1254        self.day(at / 86_400).entry_pages.add(page, 1);
1255        let audience = self.cache.get(&visitor).and_then(|c| c.audience.clone());
1256        let number = {
1257            let n = self.visit_numbers.entry(visitor).or_insert(0);
1258            *n += 1;
1259            *n
1260        };
1261        self.open.insert(
1262            visitor,
1263            Session {
1264                start: at,
1265                last: at,
1266                pages: 1,
1267                exit: page.to_string(),
1268                goals: 0,
1269                reports: 0,
1270                channel: None,
1271                utm: Default::default(),
1272                paths: vec![path_of(page).to_string()],
1273                visitor,
1274                audience,
1275                referrer: None,
1276                events: Vec::new(),
1277                number,
1278                verified: false,
1279            },
1280        );
1281        if let Some(session) = self.open.get_mut(&visitor) {
1282            session.goals |= page_goals(page);
1283        }
1284        true
1285    }
1286
1287    #[allow(clippy::too_many_arguments)]
1288    fn count_person(&mut self, visitor: u64, at: u64, host: u8, page: Option<String>, referrer: Option<String>, not_found: Option<String>, arrival: Option<Arrival>) {
1289        let hour = at / 3_600;
1290        // Sources count once per session, from its first page view (as
1291        // Google Analytics attributes a session).
1292        let started = page.as_ref().is_some_and(|page| self.session_page(visitor, at, page));
1293        if let Some(page) = &page {
1294            let country = self.cache.get(&visitor).and_then(|c| c.audience.as_ref()).and_then(|a| a.country);
1295            while self.recent.front().is_some_and(|r| r.at + LIVE_WINDOW < at) || self.recent.len() >= MAX_RECENT {
1296                self.recent.pop_front();
1297            }
1298            self.recent.push_back(Recent { at, visitor, page: page.clone(), country });
1299        }
1300        let arrival = arrival.filter(|_| started);
1301        if let (Some(arrival), Some(session)) = (&arrival, self.open.get_mut(&visitor)) {
1302            session.channel = Some(arrival.channel);
1303            session.utm = arrival.utm.clone();
1304            session.referrer = referrer.clone();
1305        }
1306        #[cfg(feature = "model-postgres")]
1307        if let (true, Some(page)) = (super::store::enabled(), &page) {
1308            self.store_view(visitor, at, page, referrer.as_deref());
1309        }
1310        let counts = &mut self.hours.entry((hour, host)).or_default()[PEOPLE];
1311        counts.requests += 1;
1312        counts.pages += page.is_some() as u64;
1313        let day = self.day(hour / 24);
1314        if let (Some(arrival), Some(page)) = (arrival, &page) {
1315            // The page key starts with the host.
1316            let host = page.split('/').next().unwrap_or("");
1317            day.dims[CHANNELS].add_capped(&format!("{host}\t{}", arrival.channel), 1, DIM_TOP);
1318            day.extra[SITE_ENTRIES].add_capped(&format!("{page}\t{}", arrival.channel), 1, DIM_TOP);
1319            for (i, value) in arrival.utm.iter().enumerate() {
1320                if let Some(value) = value {
1321                    day.dims[UTM + i].add_capped(&format!("{host}\t{value}"), 1, DIM_TOP);
1322                }
1323            }
1324        }
1325        if let Some(page) = page {
1326            day.people_pages.add(&page, 1);
1327            if let Some(referrer) = referrer {
1328                let host = page.split('/').next().unwrap_or("");
1329                day.site_referrers.add_capped(&format!("{host}\t{referrer}"), 1, DIM_TOP);
1330            }
1331        }
1332        if let Some(path) = not_found {
1333            day.not_found.add(&path, 1);
1334        }
1335    }
1336
1337    #[allow(clippy::too_many_arguments)]
1338    fn count(&mut self, g: usize, hour: u64, host: u8, class: &Class, page: Option<String>, not_found: Option<String>, _visitor: u64) {
1339        let counts = &mut self.hours.entry((hour, host)).or_default()[g];
1340        counts.requests += 1;
1341        counts.pages += page.is_some() as u64;
1342        let name = host_name(host);
1343        let day = self.day(hour / 24);
1344        if (g == AI || g == BOTS) && !class.name.is_empty() && !matches!(class.kind, Kind::Hosting | Kind::Browser) {
1345            // What named crawlers fetch: pages, and the paths that 404 for them.
1346            if page.is_some() {
1347                day.extra[SITE_CRAWL].add_capped(&format!("{name}\t{}\tpages", class.name), 1, DIM_TOP);
1348            }
1349            if let Some(path) = &not_found {
1350                day.extra[SITE_CRAWL].add_capped(&format!("{name}\t{}\t404", class.name), 1, DIM_TOP);
1351                day.extra[CRAWL_404].add_capped(&format!("{path}\t{}", class.name), 1, DIM_TOP);
1352            }
1353        }
1354        if g == AI {
1355            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);
1356            if let Some(page) = page {
1357                day.ai_pages.add(&page, 1);
1358            }
1359        } else if g == OWNER {
1360            if let Some(page) = page {
1361                day.extra[OWNER_PAGES].add(&page, 1);
1362            }
1363            // The owner's 404s are still the site's.
1364        } else if g == BOTS {
1365            let kind = if class.kind == Kind::Browser { "browser-no-assets" } else { class.kind.label() };
1366            day.site_bots.add_capped(&format!("{name}\t{kind}\t{}", class.name), 1, DIM_TOP);
1367        }
1368        if let Some(path) = not_found {
1369            day.not_found.add(&path, 1);
1370        }
1371    }
1372}
1373
1374fn or_unknown(name: &str) -> &str {
1375    if name.is_empty() { "(unnamed)" } else { name }
1376}
1377
1378/// Decide browsers that went quiet without loading an asset: bots.
1379#[doc(hidden)]
1380pub fn settle(now: u64) {
1381    for shard in &recorder().shards {
1382        let mut shard = shard.lock().unwrap_or_else(|e| e.into_inner());
1383        shard.close_sessions(|s| now.saturating_sub(s.last) >= SESSION_GAP);
1384        let done: Vec<u64> = shard.pending.iter().filter(|(_, p)| now.saturating_sub(p.last) >= UNDECIDED.as_secs()).map(|(v, _)| *v).collect();
1385        for visitor in done {
1386            let Some(pending) = shard.pending.remove(&visitor) else { continue };
1387            let day = pending.requests.first().map_or(now / 86_400, |d| d.hour / 24);
1388            let host = pending.requests.first().map_or(0, |d| d.host);
1389            if shard.seen.insert(visitor, Group::Bot).is_none() {
1390                shard.new_visitor(day, host, BOTS);
1391                shard.count_page_only(day, host);
1392            }
1393            for d in pending.requests {
1394                shard.count(BOTS, d.hour, d.host, &browser_without_assets(), d.page, d.not_found, visitor);
1395            }
1396        }
1397    }
1398}
1399
1400/// Everything counted, merged across shards: `(hour, host) → counts` and
1401/// `day → lists` (after settling quiet browsers as of `now`).
1402#[doc(hidden)]
1403pub fn snapshot(now: u64) -> (BTreeMap<(u64, u8), [Counts; GROUPS]>, BTreeMap<u64, Day>) {
1404    settle(now);
1405    drain_locals();
1406    let mut hours: BTreeMap<(u64, u8), [Counts; GROUPS]> = BTreeMap::new();
1407    let mut days: BTreeMap<u64, Day> = BTreeMap::new();
1408    for shard in &recorder().shards {
1409        let shard = shard.lock().unwrap_or_else(|e| e.into_inner());
1410        for (key, counts) in &shard.hours {
1411            let entry = hours.entry(*key).or_default();
1412            for g in 0..GROUPS {
1413                entry[g].requests += counts[g].requests;
1414                entry[g].pages += counts[g].pages;
1415            }
1416        }
1417        for (day, lists) in &shard.days {
1418            days.entry(*day).or_default().merge(lists);
1419        }
1420        // Open sessions count as if they ended now (they stay open).
1421        for session in shard.open.values() {
1422            let day = days.entry(session.start / 86_400).or_default();
1423            add_session(day, session);
1424            let mut visit = session.visit();
1425            visit.verified |= shard.verified.contains(&session.visitor);
1426            day.visits.push(visit);
1427        }
1428    }
1429    (hours, days)
1430}
1431
1432/// A closed (or, in a snapshot, open) session's totals, exit page and goals
1433/// into its day.
1434fn add_session(day: &mut Day, session: &Session) {
1435    let host = session.exit.split('/').next().unwrap_or("").to_string();
1436    day.sessions.entry(host.clone()).or_default().add(&session.totals());
1437    day.exit_pages.add(&session.exit, 1);
1438    if session.goals != 0 {
1439        for (i, goal) in goals().iter().enumerate() {
1440            if session.goals & (1 << i) != 0 {
1441                day.behaviour[GOALS].add_capped(&format!("{host}\t{goal}"), 1, DIM_TOP);
1442            }
1443        }
1444    }
1445}
1446
1447/// Whether any goal is a request goal: unknown (`u64::MAX`), 0 or 1.
1448static REQUEST_GOALS: AtomicU64 = AtomicU64::new(u64::MAX);
1449
1450fn goals_lock() -> &'static RwLock<Vec<String>> {
1451    static GOALS_SET: OnceLock<RwLock<Vec<String>>> = OnceLock::new();
1452    GOALS_SET.get_or_init(|| RwLock::new(super::config::goals()))
1453}
1454
1455/// The configured goals (read from the settings once).
1456fn goals() -> std::sync::RwLockReadGuard<'static, Vec<String>> {
1457    goals_lock().read().unwrap_or_else(|e| e.into_inner())
1458}
1459
1460/// Replace the goals (tests).
1461#[cfg(test)]
1462#[doc(hidden)]
1463pub fn set_goals_for_tests(goals: &[&str]) {
1464    *goals_lock().write().unwrap_or_else(|e| e.into_inner()) = goals.iter().map(|g| g.to_string()).collect();
1465    REQUEST_GOALS.store(u64::MAX, Ordering::Relaxed);
1466}
1467
1468/// The path of a page key (`host/path`).
1469fn path_of(page: &str) -> &str {
1470    page.find('/').map_or("/", |i| &page[i..])
1471}
1472
1473/// The goals a page view (`host/path`) reaches.
1474fn page_goals(page: &str) -> u32 {
1475    let path = page.find('/').map_or("/", |i| &page[i..]);
1476    let mut reached = 0;
1477    for (i, goal) in goals().iter().enumerate() {
1478        let hit = match goal.strip_suffix('*') {
1479            _ if goal.starts_with("event:") || goal.starts_with("req:") => false,
1480            Some(prefix) => path.starts_with(prefix),
1481            None => path == goal,
1482        };
1483        if hit {
1484            reached |= 1 << i;
1485        }
1486    }
1487    reached
1488}
1489
1490/// Whether any goal is a request (`req:/api/proxy`): then people's
1491/// requests other than pages are checked against them.
1492fn request_goals_configured() -> bool {
1493    match REQUEST_GOALS.load(Ordering::Relaxed) {
1494        u64::MAX => {
1495            let any = goals().iter().any(|g| g.starts_with("req:"));
1496            REQUEST_GOALS.store(any as u64, Ordering::Relaxed);
1497            any
1498        }
1499        n => n == 1,
1500    }
1501}
1502
1503/// The request goals (`req:/path`, or `req:/prefix*`) a request path reaches.
1504fn request_goals(path: &str) -> u32 {
1505    let mut reached = 0;
1506    for (i, goal) in goals().iter().enumerate() {
1507        let Some(want) = goal.strip_prefix("req:") else { continue };
1508        let hit = match want.strip_suffix('*') {
1509            Some(prefix) => path.starts_with(prefix),
1510            None => path == want,
1511        };
1512        if hit {
1513            reached |= 1 << i;
1514        }
1515    }
1516    reached
1517}
1518
1519/// The goals an event reaches.
1520fn event_goals(name: &str) -> u32 {
1521    let mut reached = 0;
1522    for (i, goal) in goals().iter().enumerate() {
1523        if goal.strip_prefix("event:") == Some(name) {
1524            reached |= 1 << i;
1525        }
1526    }
1527    reached
1528}
1529
1530fn json_string(raw: &str) -> Option<String> {
1531    crate::mcp::json_rpc::extract_str(&format!("{{\"v\":{raw}}}"), "v")
1532}
1533
1534fn width_bucket(width: u64) -> &'static str {
1535    match width {
1536        0 => "(unknown)",
1537        1..=599 => "phone",
1538        600..=1023 => "tablet",
1539        1024..=1439 => "laptop",
1540        _ => "wide",
1541    }
1542}
1543
1544/// A report from the first-party script (`/_rws/t`): `{"t":"leave"|"view"|
1545/// "event", …}`. Counted only for a visitor already counted as a person
1546/// today — a client that never loaded the page's assets can't post reports
1547/// in.
1548#[doc(hidden)]
1549pub fn beacon(ip: IpAddr, raw_host: &str, user_agent: Option<&str>, body: &str, now: u64) {
1550    if !enabled() {
1551        return;
1552    }
1553    let Some(members) = crate::mcp::json_rpc::object_members(body) else { return };
1554    let get = |key: &str| members.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str());
1555    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);
1556    let Some(kind) = get("t").and_then(json_string) else { return };
1557    let Some(path) = get("p").and_then(json_string).filter(|p| p.starts_with('/') && p.len() <= 1000) else { return };
1558    let host = host_index(raw_host);
1559    let day = now / 86_400;
1560    let visitor = visitor(day, host, ip, user_agent);
1561    let host_name = site_of(raw_host);
1562
1563    if kind == "view" {
1564        // A single-page app's route change: a page view like any other,
1565        // arriving from the page before (same site, so not a new arrival).
1566        let from = get("r").and_then(json_string).filter(|r| r.starts_with('/')).unwrap_or_else(|| "/".to_string());
1567        let referer = format!("https://{host_name}{from}");
1568        let is_person = {
1569            let mut shard = recorder().shards[(visitor % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
1570            let person = shard.seen.get(&visitor) == Some(&Group::Person);
1571            if person {
1572                shard.mark_verified(visitor, day, &host_name);
1573            }
1574            person
1575        };
1576        if is_person {
1577            record(&Hit {
1578                ip,
1579                host: raw_host,
1580                user_agent,
1581                referer: Some(&referer),
1582                method: "GET",
1583                path: &path,
1584                status: 200,
1585                content_type: Some("text/html"),
1586                headers: &[],
1587                now,
1588            });
1589        }
1590        return;
1591    }
1592
1593    let mut shard = recorder().shards[(visitor % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
1594    if shard.seen.get(&visitor) != Some(&Group::Person) {
1595        return;
1596    }
1597    shard.mark_verified(visitor, day, &host_name);
1598    if let Some(session) = shard.open.get_mut(&visitor) {
1599        session.reports += 1;
1600        if session.reports > MAX_REPORTS {
1601            return;
1602        }
1603    }
1604    let page = page_key(raw_host, &path);
1605    match kind.as_str() {
1606        "leave" => {
1607            // A report without a usable time isn't one.
1608            let Some(ms) = number("ms") else { return };
1609            let secs = ms.min(MAX_ENGAGED_SECS * 1000) / 1000;
1610            let depth = number("sd").unwrap_or(0).min(100);
1611            let width = number("w").unwrap_or(0);
1612            let bucket = match depth {
1613                0..=25 => "25",
1614                26..=50 => "50",
1615                51..=75 => "75",
1616                _ => "100",
1617            };
1618            #[cfg(feature = "model-postgres")]
1619            super::store::enqueue(super::store::Row::Report(super::store::Report {
1620                at: now,
1621                host: host_name.clone(),
1622                path: path.clone(),
1623                visitor,
1624                kind: "leave",
1625                secs: Some(secs),
1626                scroll: Some(depth),
1627                width: Some(width),
1628                ..Default::default()
1629            }));
1630            let d = shard.day(day);
1631            d.behaviour[ENGAGED_SECS].add(&page, secs);
1632            d.behaviour[ENGAGED_VIEWS].add(&page, 1);
1633            d.behaviour[SCROLL].add_capped(&format!("{host_name}\t{bucket}"), 1, DIM_TOP);
1634            d.behaviour[SCREENS].add_capped(&format!("{host_name}\t{}", width_bucket(width)), 1, DIM_TOP);
1635        }
1636        "event" => {
1637            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 {
1638                return;
1639            };
1640            let props: Vec<(String, String)> = get("q")
1641                .and_then(crate::mcp::json_rpc::object_members)
1642                .unwrap_or_default()
1643                .into_iter()
1644                .take(4)
1645                .filter_map(|(k, v)| Some((k.chars().take(32).collect::<String>(), json_string(&v)?.chars().take(64).collect::<String>())))
1646                .collect();
1647            let reached = event_goals(&name);
1648            if let Some(session) = shard.open.get_mut(&visitor) {
1649                session.goals |= reached;
1650                if session.events.len() < 10 {
1651                    session.events.push(name.clone());
1652                }
1653            }
1654            #[cfg(feature = "model-postgres")]
1655            super::store::enqueue(super::store::Row::Report(super::store::Report {
1656                at: now,
1657                host: host_name.clone(),
1658                path: path.clone(),
1659                visitor,
1660                kind: "event",
1661                name: Some(name.clone()),
1662                props: (!props.is_empty()).then(|| props.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&")),
1663                ..Default::default()
1664            }));
1665            let d = shard.day(day);
1666            d.behaviour[EVENTS].add_capped(&format!("{host_name}\t{name}"), 1, DIM_TOP);
1667            for (k, v) in props {
1668                d.behaviour[EVENT_PROPS].add_capped(&format!("{host_name}\t{name}\t{k}={v}"), 1, DIM_TOP);
1669            }
1670        }
1671        _ => {}
1672    }
1673}
1674
1675/// Platforms an app may report in an event (`p`); others are `other`.
1676const APP_PLATFORMS: &[&str] = &["ios", "ipados", "macos", "visionos", "watchos", "android", "windows", "linux", "chrome", "edge", "firefox", "safari", "web", "other"];
1677
1678/// Why an app event was refused.
1679#[derive(Debug, PartialEq, Eq)]
1680#[doc(hidden)]
1681pub enum AppEventError {
1682    /// Not JSON, or without `app` and `n`.
1683    Malformed,
1684    /// `app` isn't one of `[traffic] apps`.
1685    UnknownApp,
1686}
1687
1688/// An event from one of the site's apps (`POST /_rws/e`, 18.35):
1689/// `{"app":"HITOP","v":"2.3.1","p":"ios","n":"request_sent"}`. Native apps
1690/// and extensions don't load pages, so unlike the script's reports these
1691/// aren't tied to a person: the client counts as a user of `app` (by
1692/// `p` platform and `v` version) and the event under its name. Only
1693/// configured apps are taken, at most [`MAX_REPORTS`] events per client a
1694/// day; nothing is stored in the app.
1695#[doc(hidden)]
1696pub fn app_event(ip: IpAddr, raw_host: &str, user_agent: Option<&str>, body: &str, now: u64) -> Result<(), AppEventError> {
1697    if !enabled() {
1698        return Ok(());
1699    }
1700    let members = crate::mcp::json_rpc::object_members(body).ok_or(AppEventError::Malformed)?;
1701    let get = |key: &str| members.iter().find(|(k, _)| k == key).and_then(|(_, v)| json_string(v));
1702    let clean = |v: String, n: usize| v.chars().filter(|c| !c.is_control() && *c != '\t').take(n).collect::<String>();
1703    let app = get("app").ok_or(AppEventError::Malformed)?;
1704    let name = get("n").map(|n| clean(n, 64)).filter(|n| !n.is_empty()).ok_or(AppEventError::Malformed)?;
1705    let app = super::apps::configured(&app).ok_or(AppEventError::UnknownApp)?;
1706    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"));
1707    let version = get("v").map(|v| clean(v, 32)).filter(|v| !v.is_empty());
1708    let day = now / 86_400;
1709    let hour = now / 3_600;
1710    let host = host_index(raw_host);
1711    let visitor = visitor(day, host, ip, user_agent);
1712    let site = host_name(host);
1713    let recorder = recorder();
1714    let mut shard = recorder.shards[(visitor % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
1715    shard.roll(day, recorder.retention_days);
1716    let reports = shard.app_reports.entry(visitor).or_insert(0);
1717    *reports += 1;
1718    if *reports > MAX_REPORTS {
1719        return Ok(());
1720    }
1721    match shard.seen.get(&visitor).copied() {
1722        // The owner's own app use stays the owner's.
1723        Some(Group::Owner) => return Ok(()),
1724        Some(Group::App) => {}
1725        _ => {
1726            if shard.seen.insert(visitor, Group::App).is_none() {
1727                shard.new_visitor(day, host, APPS);
1728            }
1729            shard.count_app_user(day, &site, app, platform, version.as_deref());
1730            shard.hours.entry((hour, host)).or_default()[APPS].requests += 1;
1731        }
1732    }
1733    shard.day(day).extra[APP_EVENTS].add_capped(&format!("{site}\t{app}\t{name}"), 1, DIM_TOP);
1734    Ok(())
1735}
1736
1737/// People's page views of the last [`LIVE_WINDOW`] before `now`, from every
1738/// shard (unordered). Visitor keys are the day's hashes: comparable within
1739/// the result, meaningless outside it.
1740#[doc(hidden)]
1741pub fn recent(now: u64) -> Vec<Recent> {
1742    settle(now);
1743    let mut out = Vec::new();
1744    for shard in &recorder().shards {
1745        let shard = shard.lock().unwrap_or_else(|e| e.into_inner());
1746        out.extend(shard.recent.iter().filter(|r| r.at + LIVE_WINDOW >= now && r.at <= now).cloned());
1747    }
1748    out
1749}
1750
1751/// Put back what [`snapshot`] gave (loading a saved file): counts are added
1752/// to shard 0.
1753#[doc(hidden)]
1754pub fn restore(hosts: Vec<String>, hours: BTreeMap<(u64, u8), [Counts; GROUPS]>, days: BTreeMap<u64, Day>) {
1755    let recorder = recorder();
1756    let configured = recorder.configured.read().unwrap_or_else(|e| e.into_inner());
1757    let mut known = recorder.hosts.write().unwrap_or_else(|e| e.into_inner());
1758    // Configured sites first, so the hosts saved before them can't take
1759    // their slots.
1760    let mut order: Vec<usize> = (0..hosts.len()).collect();
1761    order.sort_by_key(|i| configured.binary_search(&hosts[*i]).is_err());
1762    let mut map = vec![0u8; hosts.len()];
1763    for i in order {
1764        map[i] = host_slot(&mut known, &configured, hosts[i].clone());
1765    }
1766    // Saved names that now count as another site (`www.`, unknown hosts):
1767    // their list keys move there too.
1768    let rename: HashMap<String, String> = hosts
1769        .iter()
1770        .enumerate()
1771        .filter_map(|(i, h)| known.get(map[i] as usize).filter(|n| *n != h).map(|n| (h.clone(), n.clone())))
1772        .collect();
1773    drop(known);
1774    drop(configured);
1775    HOSTS_GENERATION.fetch_add(1, Ordering::Relaxed);
1776    let mut shard = recorder.shards[0].lock().unwrap_or_else(|e| e.into_inner());
1777    for ((hour, host), counts) in hours {
1778        let host = map.get(host as usize).copied().unwrap_or(0);
1779        let entry = shard.hours.entry((hour, host)).or_default();
1780        for g in 0..GROUPS {
1781            entry[g].requests += counts[g].requests;
1782            entry[g].pages += counts[g].pages;
1783        }
1784    }
1785    for (day, mut lists) in days {
1786        if !rename.is_empty() {
1787            rename_hosts(&mut lists, &rename);
1788        }
1789        shard.days.entry(day).or_default().merge(&lists);
1790    }
1791}
1792
1793/// Move a day's list keys (`host/…`, `host\t…`), sessions and visits from
1794/// the old host names in `rename` to the new ones, adding up what meets.
1795fn rename_hosts(day: &mut Day, rename: &HashMap<String, String>) {
1796    let rekey = |top: &mut Top| {
1797        if !top.0.keys().any(|k| rename.contains_key(&k[..k.find(['/', '\t']).unwrap_or(k.len())])) {
1798            return;
1799        }
1800        let old = std::mem::take(&mut top.0);
1801        for (key, n) in old {
1802            let at = key.find(['/', '\t']).unwrap_or(key.len());
1803            let key = match rename.get(&key[..at]) {
1804                Some(new) => format!("{new}{}", &key[at..]),
1805                None => key,
1806            };
1807            *top.0.entry(key).or_default() += n;
1808        }
1809    };
1810    for top in [
1811        &mut day.people_pages,
1812        &mut day.ai_pages,
1813        &mut day.site_visitors,
1814        &mut day.site_ai_agents,
1815        &mut day.site_bots,
1816        &mut day.site_referrers,
1817        &mut day.not_found,
1818        &mut day.entry_pages,
1819        &mut day.exit_pages,
1820    ] {
1821        rekey(top);
1822    }
1823    day.dims.iter_mut().for_each(rekey);
1824    day.behaviour.iter_mut().for_each(rekey);
1825    day.extra.iter_mut().for_each(rekey);
1826    for (old, new) in rename {
1827        if let Some(s) = day.sessions.remove(old) {
1828            day.sessions.entry(new.clone()).or_default().add(&s);
1829        }
1830    }
1831    for v in &mut day.visits {
1832        if let Some(new) = rename.get(&v.host) {
1833            v.host = new.clone();
1834        }
1835    }
1836}
1837
1838#[cfg(test)]
1839#[doc(hidden)]
1840pub fn disable_for_tests() {
1841    ENABLED.store(false, Ordering::Relaxed);
1842}
1843
1844#[cfg(test)]
1845#[doc(hidden)]
1846pub fn reset_for_tests() {
1847    let recorder = recorder();
1848    HOSTS_GENERATION.fetch_add(1, Ordering::Relaxed);
1849    for local in locals().lock().unwrap_or_else(|e| e.into_inner()).iter().filter_map(|w| w.upgrade()) {
1850        *local.lock().unwrap_or_else(|e| e.into_inner()) = Local::default();
1851    }
1852    for shard in &recorder.shards {
1853        *shard.lock().unwrap_or_else(|e| e.into_inner()) = Shard::default();
1854    }
1855    recorder.hosts.write().unwrap_or_else(|e| e.into_inner()).clear();
1856    recorder.configured.write().unwrap_or_else(|e| e.into_inner()).clear();
1857}