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}