Skip to main content

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