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};
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    pub now: u64,
76}
77
78#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
79#[doc(hidden)]
80pub struct Counts {
81    pub requests: u64,
82    pub pages: u64,
83}
84
85/// A top list: counts by key, at most [`TOP`] keys and `(other)`.
86#[derive(Default, Debug, Clone)]
87#[doc(hidden)]
88pub struct Top(pub HashMap<String, u64>);
89
90impl Top {
91    fn add(&mut self, key: &str, n: u64) {
92        if let Some(count) = self.0.get_mut(key) {
93            *count += n;
94        } else if self.0.len() < TOP {
95            self.0.insert(key.chars().take(300).collect(), n);
96        } else {
97            *self.0.entry("(other)".to_string()).or_default() += n;
98        }
99    }
100
101    #[doc(hidden)]
102    pub fn merge(&mut self, other: &Top) {
103        for (key, n) in &other.0 {
104            *self.0.entry(key.clone()).or_default() += n;
105        }
106    }
107
108    #[doc(hidden)]
109    pub fn sorted(&self, n: usize) -> Vec<(String, u64)> {
110        let mut out: Vec<(String, u64)> = self.0.iter().map(|(k, v)| (k.clone(), *v)).collect();
111        out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
112        out.truncate(n);
113        out
114    }
115}
116
117/// One day's lists and unique visitors (all hosts; keys carry the host).
118#[derive(Default, Debug, Clone)]
119#[doc(hidden)]
120pub struct Day {
121    pub visitors: [u64; 3],
122    pub people_pages: Top,
123    pub ai_pages: Top,
124    /// `name\toperator\tkind\tverified`.
125    pub ai_agents: Top,
126    /// `kind\tname`.
127    pub bots: Top,
128    pub referrers: Top,
129    pub not_found: Top,
130}
131
132impl Day {
133    #[doc(hidden)]
134    pub fn merge(&mut self, other: &Day) {
135        for g in 0..3 {
136            self.visitors[g] += other.visitors[g];
137        }
138        self.people_pages.merge(&other.people_pages);
139        self.ai_pages.merge(&other.ai_pages);
140        self.ai_agents.merge(&other.ai_agents);
141        self.bots.merge(&other.bots);
142        self.referrers.merge(&other.referrers);
143        self.not_found.merge(&other.not_found);
144    }
145}
146
147/// A browser's requests while it's undecided.
148struct Pending {
149    last: u64,
150    requests: Vec<Deferred>,
151}
152
153struct Deferred {
154    hour: u64,
155    host: u8,
156    page: Option<String>,
157    referrer: Option<String>,
158    not_found: Option<String>,
159}
160
161#[derive(Default)]
162struct Shard {
163    /// `(hour, host)` → counts per group.
164    hours: BTreeMap<(u64, u8), [Counts; 3]>,
165    days: BTreeMap<u64, Day>,
166    /// Today's visitors and the group each was counted in.
167    seen: HashMap<u64, Group>,
168    pending: HashMap<u64, Pending>,
169    cache: HashMap<u64, Class>,
170    today: u64,
171}
172
173struct Recorder {
174    shards: Vec<Mutex<Shard>>,
175    hosts: RwLock<Vec<String>>,
176    /// A secret made at start and never saved; with the day it keys the
177    /// visitor hash, so one day's visitors can't be matched to the next's.
178    secret: RandomState,
179    retention_days: u64,
180    exclude: Vec<String>,
181}
182
183static ENABLED: AtomicBool = AtomicBool::new(false);
184/// Bumped when host indexes change meaning (a restore, tests' resets).
185static HOSTS_GENERATION: AtomicU64 = AtomicU64::new(1);
186static CALLS: AtomicU64 = AtomicU64::new(0);
187
188fn recorder() -> &'static Recorder {
189    static RECORDER: OnceLock<Recorder> = OnceLock::new();
190    RECORDER.get_or_init(|| Recorder {
191        shards: (0..SHARDS).map(|_| Mutex::new(Shard::default())).collect(),
192        hosts: RwLock::new(Vec::new()),
193        secret: RandomState::new(),
194        retention_days: super::config::retention_days(),
195        exclude: super::config::exclude(),
196    })
197}
198
199/// Start counting (the binary does this when the admin API is on).
200pub fn enable() {
201    // The access log (no `log` module on wasm32) reports each request here.
202    #[cfg(not(target_arch = "wasm32"))]
203    crate::log::set_access_observer(super::observe);
204    let _ = recorder();
205    ENABLED.store(true, Ordering::Relaxed);
206}
207
208/// Whether requests are being counted.
209pub fn enabled() -> bool {
210    ENABLED.load(Ordering::Relaxed)
211}
212
213/// Seconds since the epoch, as the counts use.
214pub fn now() -> u64 {
215    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
216}
217
218/// A Host header as counted: lowercase, without the port.
219fn normalize_host(host: &str) -> String {
220    let host = host.trim();
221    // `[::1]:8080` keeps its brackets; `example.com:443` loses the port.
222    let host = match host.strip_prefix('[') {
223        Some(rest) => rest.split(']').next().unwrap_or(rest),
224        None => host.split(':').next().unwrap_or(host),
225    };
226    if host.is_empty() { "(none)".to_string() } else { host.to_ascii_lowercase() }
227}
228
229fn host_index(raw: &str) -> u8 {
230    // The last host this thread saw: most requests repeat it.
231    thread_local! {
232        static LAST: std::cell::RefCell<(u64, String, u8)> = const { std::cell::RefCell::new((0, String::new(), 0)) };
233    }
234    let generation = HOSTS_GENERATION.load(Ordering::Relaxed);
235    if let Ok(Some(i)) = LAST.try_with(|last| {
236        let last = last.borrow();
237        (last.0 == generation && !last.1.is_empty() && last.1 == raw).then_some(last.2)
238    }) {
239        return i;
240    }
241    let i = host_index_slow(raw);
242    let _ = LAST.try_with(|last| *last.borrow_mut() = (generation, raw.to_string(), i));
243    i
244}
245
246fn host_index_slow(host: &str) -> u8 {
247    let host = normalize_host(host);
248    let recorder = recorder();
249    if let Some(i) = recorder.hosts.read().unwrap_or_else(|e| e.into_inner()).iter().position(|h| *h == host) {
250        return i as u8;
251    }
252    let mut hosts = recorder.hosts.write().unwrap_or_else(|e| e.into_inner());
253    if let Some(i) = hosts.iter().position(|h| *h == host) {
254        return i as u8;
255    }
256    if hosts.len() < MAX_HOSTS {
257        hosts.push(host);
258        return (hosts.len() - 1) as u8;
259    }
260    if let Some(i) = hosts.iter().position(|h| h == "other") {
261        return i as u8;
262    }
263    hosts.push("other".to_string());
264    (hosts.len() - 1) as u8
265}
266
267#[doc(hidden)]
268pub fn host_names() -> Vec<String> {
269    recorder().hosts.read().unwrap_or_else(|e| e.into_inner()).clone()
270}
271
272/// `hash(secret, day, host, ip, user-agent)` — no lock.
273fn visitor(day: u64, host: u8, ip: IpAddr, user_agent: Option<&str>) -> u64 {
274    let mut hasher = recorder().secret.build_hasher();
275    day.hash(&mut hasher);
276    host.hash(&mut hasher);
277    ip.hash(&mut hasher);
278    user_agent.unwrap_or("").hash(&mut hasher);
279    hasher.finish()
280}
281
282fn is_page(hit: &Hit<'_>) -> bool {
283    if hit.method != "GET" || !matches!(hit.status, 200 | 304) {
284        return false;
285    }
286    match hit.content_type {
287        Some(ct) => ct.trim_start().as_bytes().get(..9).is_some_and(|p| p.eq_ignore_ascii_case(b"text/html")),
288        None => {
289            let last = hit.path.rsplit('/').next().unwrap_or("");
290            last.is_empty() || !last.contains('.') || last.ends_with(".html")
291        }
292    }
293}
294
295fn is_asset(path: &str) -> bool {
296    let path = path.split('?').next().unwrap_or(path).as_bytes();
297    [&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))
298}
299
300fn page_key(host: &str, path: &str) -> String {
301    format!("{}{}", normalize_host(host), path.split('?').next().unwrap_or(path))
302}
303
304fn referrer_host(referer: Option<&str>, host: &str) -> Option<String> {
305    let rest = referer?.split_once("://")?.1;
306    let other = rest.split(['/', '?', '#']).next()?.split(':').next()?.to_ascii_lowercase();
307    (!other.is_empty() && other != normalize_host(host)).then_some(other)
308}
309
310/// Requests of visitors already decided, counted by the thread that served
311/// them (merged by readers): most requests are assets and repeats, and they
312/// then cost no shared lock. Pages, 404s and undecided browsers take the
313/// shared path, which also feeds the lists.
314#[derive(Default)]
315struct Local {
316    /// Visitor → (day, counted group slot), as decided by a shard.
317    decided: HashMap<u64, (u64, usize)>,
318    counts: BTreeMap<(u64, u8), [Counts; 3]>,
319}
320
321/// How many decided visitors a thread remembers.
322const LOCAL_DECIDED: usize = 1024;
323
324fn locals() -> &'static Mutex<Vec<std::sync::Weak<Mutex<Local>>>> {
325    static LOCALS: OnceLock<Mutex<Vec<std::sync::Weak<Mutex<Local>>>>> = OnceLock::new();
326    LOCALS.get_or_init(Mutex::default)
327}
328
329thread_local! {
330    static LOCAL: std::sync::Arc<Mutex<Local>> = {
331        let local = std::sync::Arc::new(Mutex::new(Local::default()));
332        let mut all = locals().lock().unwrap_or_else(|e| e.into_inner());
333        all.retain(|w| w.strong_count() > 0);
334        all.push(std::sync::Arc::downgrade(&local));
335        local
336    };
337}
338
339/// Move every thread's buffered counts into shard 0.
340fn drain_locals() {
341    let all: Vec<_> = locals().lock().unwrap_or_else(|e| e.into_inner()).iter().filter_map(|w| w.upgrade()).collect();
342    let mut target = recorder().shards[0].lock().unwrap_or_else(|e| e.into_inner());
343    for local in all {
344        let counts = std::mem::take(&mut local.lock().unwrap_or_else(|e| e.into_inner()).counts);
345        for (key, c) in counts {
346            let entry = target.hours.entry(key).or_default();
347            for g in 0..3 {
348                entry[g].requests += c[g].requests;
349                entry[g].pages += c[g].pages;
350            }
351        }
352    }
353}
354
355/// Count one request.
356#[doc(hidden)]
357pub fn record(hit: &Hit<'_>) {
358    if !enabled() {
359        return;
360    }
361    let recorder = recorder();
362    let path = hit.path.split('?').next().unwrap_or(hit.path);
363    if recorder.exclude.iter().any(|e| path == e) {
364        return;
365    }
366    let day = hit.now / 86_400;
367    let hour = hit.now / 3_600;
368    let host = host_index(hit.host);
369    let visitor = visitor(day, host, hit.ip, hit.user_agent);
370    // A decided visitor's non-page request: counted by this thread (no
371    // scanner check — the visitor keeps the group it was decided in).
372    if hit.status != 404 && !is_page(hit) {
373        let counted = LOCAL.try_with(|local| {
374            let mut local = local.lock().unwrap_or_else(|e| e.into_inner());
375            match local.decided.get(&visitor) {
376                Some((d, g)) if *d == day => {
377                    let g = *g;
378                    local.counts.entry((hour, host)).or_default()[g].requests += 1;
379                    true
380                }
381                _ => false,
382            }
383        });
384        if counted == Ok(true) {
385            return;
386        }
387    }
388    let decided = record_shared(hit, day, hour, host, visitor, path);
389    if let Some(g) = decided {
390        let _ = LOCAL.try_with(|local| {
391            let mut local = local.lock().unwrap_or_else(|e| e.into_inner());
392            if local.decided.len() >= LOCAL_DECIDED {
393                local.decided.clear();
394            }
395            local.decided.insert(visitor, (day, g));
396        });
397    }
398    if CALLS.fetch_add(1, Ordering::Relaxed) % 1024 == 0 {
399        settle(hit.now);
400    }
401}
402
403/// The shared path; returns the visitor's group slot once it's decided.
404fn record_shared(hit: &Hit<'_>, day: u64, hour: u64, host: u8, visitor: u64, path: &str) -> Option<usize> {
405    let recorder = recorder();
406    let mut shard = recorder.shards[(visitor % SHARDS as u64) as usize].lock().unwrap_or_else(|e| e.into_inner());
407    shard.roll(day, recorder.retention_days);
408
409    let class = match shard.cache.get(&visitor) {
410        Some(class) => class.clone(),
411        None => {
412            let class = classify(hit.ip, hit.user_agent, "/");
413            if shard.cache.len() >= MAX_CACHE {
414                shard.cache.clear();
415            }
416            shard.cache.insert(visitor, class.clone());
417            class
418        }
419    };
420    let scanner = super::classify::scanner_path(hit.path) && matches!(class.kind, Kind::Browser | Kind::Tool | Kind::NoUserAgent);
421    let class = if scanner { Class { kind: Kind::Scanner, name: "", operator: "", verified: false } } else { class };
422    let page = is_page(hit).then(|| page_key(hit.host, path));
423    let not_found = (hit.status == 404).then(|| page_key(hit.host, path));
424    let referrer = referrer_host(hit.referer, hit.host);
425
426    let decided = match class.group() {
427        Group::Internal => None,
428        Group::Person => {
429            match shard.seen.get(&visitor).copied() {
430                Some(Group::Person) => {
431                    shard.count_person(hour, host, page, referrer, not_found);
432                    Some(PEOPLE)
433                }
434                Some(_) => {
435                    shard.count(BOTS, hour, host, &browser_without_assets(), page, not_found, visitor);
436                    Some(BOTS)
437                }
438                None if is_asset(path) => {
439                    shard.seen.insert(visitor, Group::Person);
440                    shard.day(day).visitors[PEOPLE] += 1;
441                    if let Some(pending) = shard.pending.remove(&visitor) {
442                        for d in pending.requests {
443                            shard.count_person(d.hour, d.host, d.page, d.referrer, d.not_found);
444                        }
445                    }
446                    shard.count_person(hour, host, page, referrer, not_found);
447                    Some(PEOPLE)
448                }
449                None => {
450                    if shard.pending.len() >= MAX_PENDING && !shard.pending.contains_key(&visitor) {
451                        // Too many undecided at once: count this one as a bot.
452                        shard.seen.insert(visitor, Group::Bot);
453                        shard.day(day).visitors[BOTS] += 1;
454                        shard.count(BOTS, hour, host, &browser_without_assets(), page, not_found, visitor);
455                        Some(BOTS)
456                    } else {
457                        let pending = shard.pending.entry(visitor).or_insert(Pending { last: hit.now, requests: Vec::new() });
458                        pending.last = hit.now;
459                        pending.requests.push(Deferred { hour, host, page, referrer, not_found });
460                        if pending.requests.len() < MAX_PENDING_REQUESTS {
461                            None
462                        } else {
463                            // This many requests and no stylesheet, script or
464                            // font: automation, not someone reading pages.
465                            let pending = shard.pending.remove(&visitor).expect("just added");
466                            shard.seen.insert(visitor, Group::Bot);
467                            shard.day(day).visitors[BOTS] += 1;
468                            for d in pending.requests {
469                                shard.count(BOTS, d.hour, d.host, &browser_without_assets(), d.page, d.not_found, visitor);
470                            }
471                            Some(BOTS)
472                        }
473                    }
474                }
475            }
476        }
477        group => {
478            let g = slot(group).expect("counted group");
479            if shard.seen.insert(visitor, group).is_none() {
480                shard.day(day).visitors[g] += 1;
481            }
482            shard.count(g, hour, host, &class, page, not_found, visitor);
483            // A scanner request isn't the visitor's usual class: not cached.
484            (!scanner).then_some(g)
485        }
486    };
487    decided
488}
489
490fn browser_without_assets() -> Class {
491    Class { kind: Kind::Browser, name: "browser, no assets", operator: "", verified: false }
492}
493
494impl Shard {
495    fn day(&mut self, day: u64) -> &mut Day {
496        self.days.entry(day).or_default()
497    }
498
499    /// A new day: forget today's visitors and cache; drop what's past retention.
500    fn roll(&mut self, day: u64, retention_days: u64) {
501        if self.today == day {
502            return;
503        }
504        self.today = day;
505        self.seen.clear();
506        self.cache.clear();
507        let oldest_day = day.saturating_sub(retention_days.saturating_sub(1));
508        self.days.retain(|d, _| *d >= oldest_day);
509        self.hours.retain(|(h, _), _| *h / 24 >= oldest_day);
510    }
511
512    fn count_person(&mut self, hour: u64, host: u8, page: Option<String>, referrer: Option<String>, not_found: Option<String>) {
513        let counts = &mut self.hours.entry((hour, host)).or_default()[PEOPLE];
514        counts.requests += 1;
515        counts.pages += page.is_some() as u64;
516        let day = self.day(hour / 24);
517        if let Some(page) = page {
518            day.people_pages.add(&page, 1);
519            if let Some(referrer) = referrer {
520                day.referrers.add(&referrer, 1);
521            }
522        }
523        if let Some(path) = not_found {
524            day.not_found.add(&path, 1);
525        }
526    }
527
528    #[allow(clippy::too_many_arguments)]
529    fn count(&mut self, g: usize, hour: u64, host: u8, class: &Class, page: Option<String>, not_found: Option<String>, _visitor: u64) {
530        let counts = &mut self.hours.entry((hour, host)).or_default()[g];
531        counts.requests += 1;
532        counts.pages += page.is_some() as u64;
533        let day = self.day(hour / 24);
534        if g == AI {
535            day.ai_agents.add(&format!("{}\t{}\t{}\t{}", or_unknown(class.name), class.operator, class.kind.label(), class.verified), 1);
536            if let Some(page) = page {
537                day.ai_pages.add(&page, 1);
538            }
539        } else {
540            let kind = if class.kind == Kind::Browser { "browser-no-assets" } else { class.kind.label() };
541            day.bots.add(&format!("{kind}\t{}", class.name), 1);
542        }
543        if let Some(path) = not_found {
544            day.not_found.add(&path, 1);
545        }
546    }
547}
548
549fn or_unknown(name: &str) -> &str {
550    if name.is_empty() { "(unnamed)" } else { name }
551}
552
553/// Decide browsers that went quiet without loading an asset: bots.
554#[doc(hidden)]
555pub fn settle(now: u64) {
556    for shard in &recorder().shards {
557        let mut shard = shard.lock().unwrap_or_else(|e| e.into_inner());
558        let done: Vec<u64> = shard.pending.iter().filter(|(_, p)| now.saturating_sub(p.last) >= UNDECIDED.as_secs()).map(|(v, _)| *v).collect();
559        for visitor in done {
560            let Some(pending) = shard.pending.remove(&visitor) else { continue };
561            let day = pending.requests.first().map_or(now / 86_400, |d| d.hour / 24);
562            if shard.seen.insert(visitor, Group::Bot).is_none() {
563                shard.day(day).visitors[BOTS] += 1;
564            }
565            for d in pending.requests {
566                shard.count(BOTS, d.hour, d.host, &browser_without_assets(), d.page, d.not_found, visitor);
567            }
568        }
569    }
570}
571
572/// Everything counted, merged across shards: `(hour, host) → counts` and
573/// `day → lists` (after settling quiet browsers as of `now`).
574#[doc(hidden)]
575pub fn snapshot(now: u64) -> (BTreeMap<(u64, u8), [Counts; 3]>, BTreeMap<u64, Day>) {
576    settle(now);
577    drain_locals();
578    let mut hours: BTreeMap<(u64, u8), [Counts; 3]> = BTreeMap::new();
579    let mut days: BTreeMap<u64, Day> = BTreeMap::new();
580    for shard in &recorder().shards {
581        let shard = shard.lock().unwrap_or_else(|e| e.into_inner());
582        for (key, counts) in &shard.hours {
583            let entry = hours.entry(*key).or_default();
584            for g in 0..3 {
585                entry[g].requests += counts[g].requests;
586                entry[g].pages += counts[g].pages;
587            }
588        }
589        for (day, lists) in &shard.days {
590            days.entry(*day).or_default().merge(lists);
591        }
592    }
593    (hours, days)
594}
595
596/// Put back what [`snapshot`] gave (loading a saved file): counts are added
597/// to shard 0.
598#[doc(hidden)]
599pub fn restore(hosts: Vec<String>, hours: BTreeMap<(u64, u8), [Counts; 3]>, days: BTreeMap<u64, Day>) {
600    let recorder = recorder();
601    let mut known = recorder.hosts.write().unwrap_or_else(|e| e.into_inner());
602    let map: Vec<u8> = hosts
603        .iter()
604        .map(|h| match known.iter().position(|k| k == h) {
605            Some(i) => i as u8,
606            None if known.len() < MAX_HOSTS + 1 => {
607                known.push(h.clone());
608                (known.len() - 1) as u8
609            }
610            None => 0,
611        })
612        .collect();
613    drop(known);
614    HOSTS_GENERATION.fetch_add(1, Ordering::Relaxed);
615    let mut shard = recorder.shards[0].lock().unwrap_or_else(|e| e.into_inner());
616    for ((hour, host), counts) in hours {
617        let host = map.get(host as usize).copied().unwrap_or(0);
618        let entry = shard.hours.entry((hour, host)).or_default();
619        for g in 0..3 {
620            entry[g].requests += counts[g].requests;
621            entry[g].pages += counts[g].pages;
622        }
623    }
624    for (day, lists) in days {
625        shard.days.entry(day).or_default().merge(&lists);
626    }
627}
628
629#[cfg(test)]
630#[doc(hidden)]
631pub fn disable_for_tests() {
632    ENABLED.store(false, Ordering::Relaxed);
633}
634
635#[cfg(test)]
636#[doc(hidden)]
637pub fn reset_for_tests() {
638    let recorder = recorder();
639    HOSTS_GENERATION.fetch_add(1, Ordering::Relaxed);
640    for local in locals().lock().unwrap_or_else(|e| e.into_inner()).iter().filter_map(|w| w.upgrade()) {
641        *local.lock().unwrap_or_else(|e| e.into_inner()) = Local::default();
642    }
643    for shard in &recorder.shards {
644        *shard.lock().unwrap_or_else(|e| e.into_inner()) = Shard::default();
645    }
646    recorder.hosts.write().unwrap_or_else(|e| e.into_inner()).clear();
647}