lmjtfy.git / packages / archive / src / event.rs
event.rsannotatedevent.rssource535 lines · 24.8 KB · raw
1//! What happened on the site, kept whole: one `Event` per request worth
2//! keeping, with everything the request said about itself and everything
3//! Cloudflare said about where it came from. The archive writes each one as
4//! a row of `events` (the owner, 2026-10-03: "anywhere in the app where we
5//! are dropping data we should plug", and of visitors, "Everything, linked").
6//!
7//! Nothing here is shown on the site. It is for the owner's admin backend,
8//! which reads the archive's tables.
9//!
10//! `COLUMNS` and `values` are the table's shape: the archive's migration
11//! spells the same columns out, and a test there holds the two together.
12
13use http::{HeaderMap, Method, Uri, header};
14use serde::{Deserialize, Serialize};
15
16/// The cookie that says when this browser was last counted as a visit, in
17/// Unix milliseconds.
18pub const VISIT: &str = "lmjtfy_visit";
19
20/// A visit ends after this long with no page viewed.
21const SESSION_MS: f64 = 30.0 * 60.0 * 1000.0;
22const DAY_MS: f64 = 24.0 * 60.0 * 60.0 * 1000.0;
23
24/// What Cloudflare says of where a request came from (`request.cf`), and
25/// the address it came from. All of it may be missing: a dev server has
26/// none.
27#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
28#[serde(default)]
29pub struct Origin {
30    pub ip: String,
31    pub country: String,
32    pub region: String,
33    /// The region's code within its country (ISO 3166-2 without the
34    /// country: `CA` for California), which a map's outlines are found by;
35    /// the name alone is spelled differently from one source to the next.
36    #[serde(default)]
37    pub region_code: String,
38    /// The continent's two letters, and the metro area's code where
39    /// Cloudflare has one (the United States).
40    #[serde(default)]
41    pub continent: String,
42    #[serde(default)]
43    pub metro: String,
44    /// What kind of bot Cloudflare has verified the request as from
45    /// ("Search Engine Crawler", "AI Crawler"), if any.
46    #[serde(default)]
47    pub bot: String,
48    pub city: String,
49    pub postcode: String,
50    pub timezone: String,
51    pub latitude: Option<f64>,
52    pub longitude: Option<f64>,
53    /// The network's number, and whose it is: the visitor's ISP.
54    pub asn: Option<u32>,
55    pub network: String,
56    /// The Cloudflare data centre that took the request.
57    pub colo: String,
58    pub protocol: String,
59    pub tls: String,
60}
61
62/// One thing that happened.
63#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
64#[serde(default)]
65pub struct Event {
66    /// `view`, `answer`, `gate`, `vote`, `more`, `card`, `fetch`, `moved`,
67    /// `live`, `left`, and from the page itself `read` (how long a page was
68    /// looked at, and how far down) and `out` (a link followed off the site).
69    pub what: String,
70    pub method: String,
71    pub host: String,
72    pub path: String,
73    /// The query as it came, the question in a shared link included.
74    pub query: String,
75    /// The question typed, asked or voted on, cleaned.
76    pub input: String,
77    /// What came of it: how an ask ended, which way a vote went, `clone` or
78    /// `pull`, `shared` for a page opened with a question in its link.
79    pub detail: String,
80    /// The response's status, or 0 when the event is not a response.
81    pub status: f64,
82    /// Requests sent to Jev or the LLM for it, and requests answered from
83    /// what was kept.
84    pub sent: f64,
85    pub kept: f64,
86    /// Whether an LLM was needed, 1 or 0.
87    pub llm: f64,
88    /// How long it took or lasted, in milliseconds.
89    pub took_ms: f64,
90    /// 1 on the first page this browser was ever given, its first of the UTC
91    /// day, and its first in half an hour.
92    pub first: f64,
93    pub daily: f64,
94    pub session: f64,
95    /// The page that linked here, whole.
96    pub referrer: String,
97    /// A campaign the link named (`utm_source` or `ref`).
98    pub source: String,
99    /// `browser`, `git`, `bot` or `other`, and for a browser its family,
100    /// operating system and `mobile` or `desktop`: read from `agent`, for
101    /// grouping.
102    pub client: String,
103    pub family: String,
104    pub os: String,
105    pub device: String,
106    /// `Accept-Language` and `User-Agent` as they came.
107    pub language: String,
108    pub agent: String,
109    /// The browser's id (the `lmjtfy_browser` cookie), which ties one
110    /// browser's events together.
111    pub browser: String,
112    #[serde(flatten)]
113    pub origin: Origin,
114    /// The link's `utm_medium` and `utm_campaign`.
115    pub medium: String,
116    pub campaign: String,
117    /// The screen and the window, `1920x1080`, as the page says them.
118    pub screen: String,
119    pub viewport: String,
120    /// How far down the page was seen, 0 to 1.
121    pub scroll: f64,
122}
123
124/// A value of one column.
125#[derive(Clone, Debug, PartialEq)]
126pub enum Cell {
127    Text(String),
128    Number(f64),
129    Null,
130}
131
132impl Event {
133    /// The columns of `events`, after `id` and `at_ms`, in the order of
134    /// `values`.
135    pub const COLUMNS: [&str; 45] = [
136        "what", "method", "host", "path", "query", "input", "detail", "status", "sent", "kept", "llm", "took_ms", "first", "daily", "session", "referrer",
137        "source", "client", "family", "os", "device", "language", "agent", "browser", "ip", "country", "region", "city", "postcode", "timezone", "latitude",
138        "longitude", "asn", "network", "colo", "protocol", "medium", "campaign", "screen", "viewport", "scroll", "region_code", "continent", "metro", "bot",
139    ];
140
141    pub fn values(&self) -> Vec<Cell> {
142        let text = |value: &str| Cell::Text(value.to_owned());
143        let number = |value: Option<f64>| value.map_or(Cell::Null, Cell::Number);
144        let origin = &self.origin;
145        vec![
146            text(&self.what),
147            text(&self.method),
148            text(&self.host),
149            text(&self.path),
150            text(&self.query),
151            text(&self.input),
152            text(&self.detail),
153            Cell::Number(self.status),
154            Cell::Number(self.sent),
155            Cell::Number(self.kept),
156            Cell::Number(self.llm),
157            Cell::Number(self.took_ms),
158            Cell::Number(self.first),
159            Cell::Number(self.daily),
160            Cell::Number(self.session),
161            text(&self.referrer),
162            text(&self.source),
163            text(&self.client),
164            text(&self.family),
165            text(&self.os),
166            text(&self.device),
167            text(&self.language),
168            text(&self.agent),
169            text(&self.browser),
170            text(&origin.ip),
171            text(&origin.country),
172            text(&origin.region),
173            text(&origin.city),
174            text(&origin.postcode),
175            text(&origin.timezone),
176            number(origin.latitude),
177            number(origin.longitude),
178            number(origin.asn.map(f64::from)),
179            text(&origin.network),
180            text(&origin.colo),
181            text(&format!("{} {}", origin.protocol, origin.tls).trim().to_owned()),
182            text(&self.medium),
183            text(&self.campaign),
184            text(&self.screen),
185            text(&self.viewport),
186            Cell::Number(self.scroll),
187            text(&origin.region_code),
188            text(&origin.continent),
189            text(&origin.metro),
190            text(&origin.bot),
191        ]
192    }
193
194    /// What a request says of itself, before it is known what came of it.
195    /// `browser` is the id in its cookie, if it has one.
196    pub fn from(method: &Method, uri: &Uri, headers: &HeaderMap, browser: Option<String>, origin: Origin) -> Event {
197        let agent = text(headers, header::USER_AGENT.as_str());
198        let lower = agent.to_ascii_lowercase();
199        let client = client(&lower, headers);
200        let person = client == "browser";
201        let query = uri.query().unwrap_or_default();
202        Event {
203            method: method.as_str().to_owned(),
204            host: cut(text(headers, header::HOST.as_str()), 200),
205            path: cut(uri.path(), 500),
206            query: cut(query, 2000),
207            referrer: cut(text(headers, header::REFERER.as_str()), 1000),
208            source: tag(query, "utm_source").or_else(|| tag(query, "ref")).unwrap_or_default(),
209            medium: tag(query, "utm_medium").unwrap_or_default(),
210            campaign: tag(query, "utm_campaign").unwrap_or_default(),
211            client: client.to_owned(),
212            family: if person { family(&lower) } else { "" }.to_owned(),
213            os: if person { os(&lower) } else { "" }.to_owned(),
214            device: if person { device(&lower, headers) } else { "" }.to_owned(),
215            language: cut(text(headers, header::ACCEPT_LANGUAGE.as_str()), 200),
216            agent: cut(agent, 500),
217            browser: browser.unwrap_or_default(),
218            origin,
219            ..Event::default()
220        }
221    }
222
223    /// What a `GET` is, if it is worth keeping: not the site's own scripts
224    /// and fonts, or git's first request. A `POST` is named by the handler
225    /// that knows what came of it.
226    pub fn got(mut self) -> Option<Event> {
227        if self.method != "GET" {
228            return None;
229        }
230        self.what = match self.path.as_str() {
231            "/datastar.js" | "/emoji.woff2" | "/live.js" | "/rules.svg" | "/favicon.ico" => return None,
232            // The socket's own event is written by the archive, which keeps
233            // it to say how long the page stayed.
234            "/live" => return None,
235            path if path.ends_with("/info/refs") || path.starts_with("/icons/") => return None,
236            // A release file's download is its own event (`download`, named
237            // by the Worker once the file is sent), not a page view. The two
238            // listing pages (`/whiskers/latest/`) are views.
239            path if is_download(path) => return None,
240            "/feed" => "more",
241            "/card.png" => "card",
242            _ => "view",
243        }
244        .to_owned();
245        if self.what == "view" && self.query.split('&').any(|pair| pair.starts_with("q=") && pair.len() > 2) {
246            self.detail = "shared".into();
247        }
248        Some(self)
249    }
250
251    pub fn named(mut self, what: &str) -> Event {
252        self.what = what.to_owned();
253        self
254    }
255
256    pub fn with(mut self, detail: impl Into<String>) -> Event {
257        self.detail = detail.into();
258        self
259    }
260
261    /// The question it was about.
262    pub fn about(mut self, input: &str) -> Event {
263        self.input = cut(input, 2000);
264        self
265    }
266
267    /// Counts a browser's page view as a visit: whether it is the browser's
268    /// first page ever (it came with no cookie of the site's), its first
269    /// today, and its first in half an hour. Returns the cookie that says it
270    /// was counted now. Only a browser's page is a visit.
271    pub fn visit(&mut self, headers: &HeaderMap, now_ms: f64) -> Option<String> {
272        if self.what != "view" || self.client != "browser" {
273            return None;
274        }
275        let cookies = text(headers, header::COOKIE.as_str());
276        let last = cookies.split(';').filter_map(|pair| pair.trim().strip_prefix(VISIT)?.strip_prefix('=')?.parse::<f64>().ok()).find(|last| last.is_finite() && *last <= now_ms);
277        let flag = |is: bool| if is { 1.0 } else { 0.0 };
278        self.first = flag(cookies.trim().is_empty());
279        self.daily = flag(last.is_none_or(|last| (last / DAY_MS).floor() < (now_ms / DAY_MS).floor()));
280        self.session = flag(last.is_none_or(|last| now_ms - last >= SESSION_MS));
281        Some(format!("{VISIT}={now_ms:.0}; Path=/; Max-Age=31536000; Secure; HttpOnly; SameSite=Lax"))
282    }
283}
284
285fn text<'a>(headers: &'a HeaderMap, name: &str) -> &'a str {
286    headers.get(name).and_then(|value| value.to_str().ok()).unwrap_or_default()
287}
288
289/// Whether a path is a release file: `/<name>/latest/<kind>` or
290/// `/<name>/releases/<tag>/<asset>`, of a name that is not a clone's
291/// (`<name>.git`, whose folders may be called anything).
292fn is_download(path: &str) -> bool {
293    let parts: Vec<&str> = path.split('/').collect();
294    matches!(parts.as_slice(), ["", name, "latest", file] if !name.ends_with(".git") && !file.is_empty())
295        || matches!(parts.as_slice(), ["", name, "releases", tag, file] if !name.ends_with(".git") && !tag.is_empty() && !file.is_empty())
296}
297
298fn cut(text: &str, most: usize) -> String {
299    text.chars().take(most).collect()
300}
301
302/// What kind of client asked, from what it says of itself.
303fn client(agent: &str, headers: &HeaderMap) -> &'static str {
304    const BOTS: [&str; 10] = ["bot", "crawler", "spider", "preview", "facebookexternalhit", "slurp", "curl/", "python", "wget", "headless"];
305    if agent.starts_with("git/") || agent.starts_with("cargo") {
306        "git"
307    } else if BOTS.iter().any(|mark| agent.contains(mark)) {
308        "bot"
309    } else if headers.contains_key("sec-fetch-mode") || agent.starts_with("mozilla/") {
310        "browser"
311    } else {
312        "other"
313    }
314}
315
316/// The browser's family. The order matters: Edge and Opera say Chrome too,
317/// and Chrome says Safari.
318fn family(agent: &str) -> &'static str {
319    [("edg", "Edge"), ("opr/", "Opera"), ("firefox", "Firefox"), ("fxios", "Firefox"), ("crios", "Chrome"), ("chrome", "Chrome"), ("safari", "Safari")]
320        .into_iter()
321        .find(|(mark, _)| agent.contains(mark))
322        .map_or("", |(_, name)| name)
323}
324
325/// The operating system. iPhones say "like Mac OS X" and Android says Linux,
326/// so they are looked for first.
327fn os(agent: &str) -> &'static str {
328    [("android", "Android"), ("iphone", "iOS"), ("ipad", "iOS"), ("windows", "Windows"), ("cros", "ChromeOS"), ("mac os x", "macOS"), ("linux", "Linux")]
329        .into_iter()
330        .find(|(mark, _)| agent.contains(mark))
331        .map_or("", |(_, name)| name)
332}
333
334fn device(agent: &str, headers: &HeaderMap) -> &'static str {
335    match text(headers, "sec-ch-ua-mobile") {
336        "?1" => "mobile",
337        "?0" => "desktop",
338        _ if ["mobile", "android", "iphone"].iter().any(|mark| agent.contains(mark)) => "mobile",
339        _ => "desktop",
340    }
341}
342
343/// One of a link's campaign tags (`utm_source`, `utm_medium`, ...), if it
344/// has it.
345fn tag(query: &str, name: &str) -> Option<String> {
346    query.split('&').find_map(|pair| pair.strip_prefix(name)?.strip_prefix('=')).filter(|value| !value.is_empty()).map(|value| cut(value, 100))
347}
348
349/// What the page says of itself when it is left or a link off the site is
350/// followed (`page.js`, posted to `/seen`). Every part is the page's word
351/// and is checked before it is kept.
352#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
353#[serde(default)]
354pub struct Seen {
355    /// The page's own path and query.
356    pub path: String,
357    pub query: String,
358    /// Milliseconds it was in view since it last said.
359    pub ms: f64,
360    pub scroll: f64,
361    pub screen: String,
362    pub viewport: String,
363    /// The address of a link followed off the site, when that is the news.
364    pub out: String,
365}
366
367impl Event {
368    /// The event of a page's report: `read`, or `out` when it names a link.
369    /// The request is the report's own (`POST /seen`); the page it is about
370    /// is in the report.
371    pub fn seen(mut self, seen: &Seen) -> Event {
372        // A size is two numbers and an `x`, and nothing else.
373        let size = |text: &str| {
374            let fits = text.len() <= 11 && text.split_once('x').is_some_and(|(wide, high)| [wide, high].iter().all(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())));
375            if fits { text.to_owned() } else { String::new() }
376        };
377        let within = |n: f64, most: f64| if n.is_finite() { n.clamp(0.0, most) } else { 0.0 };
378        self.what = if seen.out.is_empty() { "read" } else { "out" }.to_owned();
379        self.detail = cut(&seen.out, 500);
380        if seen.path.starts_with('/') {
381            self.path = cut(&seen.path, 500);
382            self.query = cut(seen.query.trim_start_matches('?'), 2000);
383        }
384        // A page cannot have been looked at for longer than a day.
385        self.took_ms = within(seen.ms, 86_400_000.0);
386        self.scroll = within(seen.scroll, 1.0);
387        self.screen = size(&seen.screen);
388        self.viewport = size(&seen.viewport);
389        self
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
398        let mut headers = HeaderMap::new();
399        for (name, value) in pairs {
400            headers.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), value.parse().unwrap());
401        }
402        headers
403    }
404
405    const CHROME: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
406
407    fn event(method: Method, uri: &str, from: &[(&str, &str)]) -> Event {
408        Event::from(&method, &uri.parse().unwrap(), &headers(from), None, Origin::default())
409    }
410
411    #[test]
412    fn a_get_is_named_for_what_it_is() {
413        let what = |uri: &str| event(Method::GET, uri, &[]).got().map(|event| event.what);
414        assert_eq!(what("/").as_deref(), Some("view"));
415        assert_eq!(what("/lmjtfy.git/packages/rules/").as_deref(), Some("view"));
416        assert_eq!(what("/feed?ms=1&q=x").as_deref(), Some("more"));
417        assert_eq!(what("/card.png?q=x").as_deref(), Some("card"));
418        // The site's own parts, the socket and git's first request are not
419        // named here.
420        assert_eq!(what("/live"), None);
421        assert_eq!(what("/emoji.woff2"), None);
422        assert_eq!(what("/icons/file-rust.png"), None);
423        assert_eq!(what("/lmjtfy.git/info/refs?service=git-upload-pack"), None);
424        // A release file is a `download`, kept by its handler; its listing
425        // pages are views, and a clone's folder of the same name is one too.
426        assert_eq!(what("/whiskers/latest/arm64.apk"), None);
427        assert_eq!(what("/whiskers/releases/2026.10.5/whiskers-2026.10.5-arm64.apk"), None);
428        assert_eq!(what("/whiskers/latest/").as_deref(), Some("view"));
429        assert_eq!(what("/whiskers/releases").as_deref(), Some("view"));
430        assert_eq!(what("/whiskers.git/latest/x").as_deref(), Some("view"));
431        // A POST is its handler's to name.
432        assert_eq!(event(Method::POST, "/ask", &[]).got(), None);
433    }
434
435    #[test]
436    fn nothing_the_request_said_is_dropped() {
437        let from = [
438            ("referer", "https://discord.com/channels/1/2?x=y"),
439            ("host", "lmjtfy.fun"),
440            ("user-agent", CHROME),
441            ("accept-language", "en-GB,en;q=0.9"),
442        ];
443        let uri: Uri = "/?q=Is+my+boss+a+lizard%3F&utm_source=discord&utm_medium=chat&utm_campaign=launch".parse().unwrap();
444        let origin = Origin { ip: "203.0.113.7".into(), country: "GB".into(), asn: Some(2856), network: "British Telecommunications PLC".into(), ..Origin::default() };
445        let event = Event::from(&Method::GET, &uri, &headers(&from), Some("0b5f2a1e".into()), origin).got().unwrap();
446        assert_eq!((event.what.as_str(), event.detail.as_str(), event.path.as_str()), ("view", "shared", "/"));
447        assert_eq!(event.query, "q=Is+my+boss+a+lizard%3F&utm_source=discord&utm_medium=chat&utm_campaign=launch");
448        assert_eq!(event.referrer, "https://discord.com/channels/1/2?x=y");
449        assert_eq!((event.source.as_str(), event.language.as_str(), event.agent.as_str()), ("discord", "en-GB,en;q=0.9", CHROME));
450        assert_eq!((event.medium.as_str(), event.campaign.as_str()), ("chat", "launch"));
451        assert_eq!((event.client.as_str(), event.family.as_str(), event.os.as_str(), event.device.as_str()), ("browser", "Chrome", "Windows", "desktop"));
452        assert_eq!((event.browser.as_str(), event.origin.ip.as_str(), event.origin.asn), ("0b5f2a1e", "203.0.113.7", Some(2856)));
453    }
454
455    #[test]
456    fn every_column_has_its_value_and_an_event_survives_the_wire() {
457        let event = Event { what: "answer".into(), origin: Origin { asn: Some(7), latitude: Some(1.5), ..Origin::default() }, ..Event::default() };
458        let values = event.values();
459        assert_eq!(values.len(), Event::COLUMNS.len());
460        let at = |name: &str| values[Event::COLUMNS.iter().position(|column| *column == name).unwrap()].clone();
461        assert_eq!(at("what"), Cell::Text("answer".into()));
462        assert_eq!(at("asn"), Cell::Number(7.0));
463        assert_eq!(at("latitude"), Cell::Number(1.5));
464        assert_eq!(at("longitude"), Cell::Null);
465        let wire = serde_json::to_string(&event).unwrap();
466        assert_eq!(serde_json::from_str::<Event>(&wire).unwrap(), event);
467    }
468
469    #[test]
470    fn a_pages_report_is_checked_before_it_is_kept() {
471        let report = event(Method::POST, "/seen", &[("user-agent", CHROME)]);
472        let read = report.clone().seen(&Seen { path: "/rules".into(), query: "?answerable=no".into(), ms: 4200.0, scroll: 0.5, screen: "1920x1080".into(), viewport: "1200x800".into(), out: String::new() });
473        assert_eq!((read.what.as_str(), read.path.as_str(), read.query.as_str()), ("read", "/rules", "answerable=no"));
474        assert_eq!((read.took_ms, read.scroll, read.screen.as_str(), read.viewport.as_str()), (4200.0, 0.5, "1920x1080", "1200x800"));
475        // A link followed off the site.
476        let out = report.clone().seen(&Seen { path: "/".into(), out: "https://docs.typesafe.ai/".into(), ..Seen::default() });
477        assert_eq!((out.what.as_str(), out.detail.as_str()), ("out", "https://docs.typesafe.ai/"));
478        // What a page makes up is cut down to what it could be.
479        let odd = report.seen(&Seen { path: "elsewhere".into(), ms: f64::INFINITY, scroll: 7.0, screen: "<b>".into(), viewport: "9999999x9999999".into(), ..Seen::default() });
480        assert_eq!((odd.path.as_str(), odd.took_ms, odd.scroll, odd.screen.as_str(), odd.viewport.as_str()), ("/seen", 0.0, 1.0, "", ""));
481        assert_eq!(serde_json::from_str::<Seen>("{\"ms\": 5}").unwrap().ms, 5.0);
482    }
483
484    #[test]
485    fn clients_are_classed() {
486        let class = |agent: &str| {
487            let event = event(Method::GET, "/", &[("user-agent", agent)]);
488            (event.client, event.family, event.os, event.device)
489        };
490        let is = |client: &str, family: &str, os: &str, device: &str| (client.to_owned(), family.to_owned(), os.to_owned(), device.to_owned());
491        assert_eq!(class("git/2.55.0"), is("git", "", "", ""));
492        assert_eq!(class("Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)"), is("bot", "", "", ""));
493        assert_eq!(class("curl/8.22.0"), is("bot", "", "", ""));
494        assert_eq!(class(""), is("other", "", "", ""));
495        assert_eq!(class(CHROME), is("browser", "Chrome", "Windows", "desktop"));
496        assert_eq!(class("Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/140.0 Safari/537.36 Edg/140.0"), is("browser", "Edge", "Windows", "desktop"));
497        assert_eq!(class("Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 Version/19.0 Mobile/15E148 Safari/604.1"), is("browser", "Safari", "iOS", "mobile"));
498        assert_eq!(class("Mozilla/5.0 (Android 16; Mobile; rv:143.0) Gecko/143.0 Firefox/143.0"), is("browser", "Firefox", "Android", "mobile"));
499        assert_eq!(class("Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0"), is("browser", "Firefox", "Linux", "desktop"));
500    }
501
502    #[test]
503    fn a_visit_is_counted_from_when_the_browser_was_last_counted() {
504        let noon = 20_000.0 * DAY_MS + DAY_MS / 2.0;
505        let visit = |cookie: Option<String>, now: f64| {
506            let mut pairs = vec![("user-agent", CHROME.to_owned())];
507            pairs.extend(cookie.map(|cookie| ("cookie", cookie)));
508            let pairs: Vec<(&str, &str)> = pairs.iter().map(|(name, value)| (*name, value.as_str())).collect();
509            let mut event = event(Method::GET, "/", &pairs).named("view");
510            let cookie = event.visit(&headers(&pairs), now);
511            ((event.first, event.daily, event.session), cookie)
512        };
513        // No cookie at all: a new browser, today's first page, a new visit.
514        let (flags, cookie) = visit(None, noon);
515        assert_eq!(flags, (1.0, 1.0, 1.0));
516        let cookie = cookie.unwrap();
517        assert!(cookie.starts_with(&format!("lmjtfy_visit={noon:.0}; ")), "{cookie}");
518        let last = |ms: f64| Some(format!("lmjtfy_browser=x; lmjtfy_visit={ms:.0}"));
519        // A minute on: none of them. An hour on: a new visit, the same day.
520        assert_eq!(visit(last(noon), noon + 60_000.0).0, (0.0, 0.0, 0.0));
521        assert_eq!(visit(last(noon), noon + 3_600_000.0).0, (0.0, 0.0, 1.0));
522        // Past midnight UTC: a new day too.
523        assert_eq!(visit(last(noon), noon + DAY_MS).0, (0.0, 1.0, 1.0));
524        // A cookie from before this one existed: known, not yet counted.
525        assert_eq!(visit(Some("lmjtfy_browser=x".into()), noon).0, (0.0, 1.0, 1.0));
526        // A time the page made up, or one from the future, is no time.
527        assert_eq!(visit(Some("lmjtfy_visit=soon".into()), noon).0, (0.0, 1.0, 1.0));
528        assert_eq!(visit(last(noon + DAY_MS), noon).0, (0.0, 1.0, 1.0));
529        // Only a browser's page view is a visit.
530        let mut bot = event(Method::GET, "/", &[("user-agent", "curl/8")]).named("view");
531        assert_eq!(bot.visit(&HeaderMap::new(), noon), None);
532        let mut vote = event(Method::POST, "/rate", &[("user-agent", CHROME)]).named("vote");
533        assert_eq!(vote.visit(&HeaderMap::new(), noon), None);
534    }
535}