lmjtfy.git / packages / archive / src / event.rs
event.rsannotatedevent.rssource515 lines · 23.5 KB · raw

What happened on the site, kept whole: one Event per request worth keeping, with everything the request said about itself and everything Cloudflare said about where it came from. The archive writes each one as a row of events (the owner, 2026-10-03: "anywhere in the app where we are dropping data we should plug", and of visitors, "Everything, linked").

Nothing here is shown on the site. It is for the owner's admin backend, which reads the archive's tables.

COLUMNS and values are the table's shape: the archive's migration spells the same columns out, and a test there holds the two together.

13use http::{HeaderMap, Method, Uri, header};
14use serde::{Deserialize, Serialize};

The cookie that says when this browser was last counted as a visit, in Unix milliseconds.

18pub const VISIT: &str = "lmjtfy_visit";

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;

What Cloudflare says of where a request came from (request.cf), and the address it came from. All of it may be missing: a dev server has 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,

The region's code within its country (ISO 3166-2 without the country: CA for California), which a map's outlines are found by; the name alone is spelled differently from one source to the next.

36    #[serde(default)]
37    pub region_code: String,

The continent's two letters, and the metro area's code where Cloudflare has one (the United States).

40    #[serde(default)]
41    pub continent: String,
42    #[serde(default)]
43    pub metro: String,

What kind of bot Cloudflare has verified the request as from ("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>,

The network's number, and whose it is: the visitor's ISP.

54    pub asn: Option<u32>,
55    pub network: String,

The Cloudflare data centre that took the request.

57    pub colo: String,
58    pub protocol: String,
59    pub tls: String,
60}

One thing that happened.

63#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
64#[serde(default)]
65pub struct Event {

view, answer, gate, vote, more, card, fetch, moved, live, left, and from the page itself read (how long a page was 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,

The query as it came, the question in a shared link included.

74    pub query: String,

The question typed, asked or voted on, cleaned.

76    pub input: String,

What came of it: how an ask ended, which way a vote went, clone or pull, shared for a page opened with a question in its link.

79    pub detail: String,

The response's status, or 0 when the event is not a response.

81    pub status: f64,

Requests sent to Jev or the LLM for it, and requests answered from what was kept.

84    pub sent: f64,
85    pub kept: f64,

Whether an LLM was needed, 1 or 0.

87    pub llm: f64,

How long it took or lasted, in milliseconds.

89    pub took_ms: f64,

1 on the first page this browser was ever given, its first of the UTC day, and its first in half an hour.

92    pub first: f64,
93    pub daily: f64,
94    pub session: f64,

The page that linked here, whole.

96    pub referrer: String,

A campaign the link named (utm_source or ref).

98    pub source: String,

browser, git, bot or other, and for a browser its family, operating system and mobile or desktop: read from agent, for grouping.

102    pub client: String,
103    pub family: String,
104    pub os: String,
105    pub device: String,

Accept-Language and User-Agent as they came.

107    pub language: String,
108    pub agent: String,

The browser's id (the lmjtfy_browser cookie), which ties one browser's events together.

111    pub browser: String,
112    #[serde(flatten)]
113    pub origin: Origin,

The link's utm_medium and utm_campaign.

115    pub medium: String,
116    pub campaign: String,

The screen and the window, 1920x1080, as the page says them.

118    pub screen: String,
119    pub viewport: String,

How far down the page was seen, 0 to 1.

121    pub scroll: f64,
122}

A value of one column.

125#[derive(Clone, Debug, PartialEq)]
126pub enum Cell {
127    Text(String),
128    Number(f64),
129    Null,
130}
132impl Event {

The columns of events, after id and at_ms, in the order of 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    ];
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    }

What a request says of itself, before it is known what came of it. 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    }

What a GET is, if it is worth keeping: not the site's own scripts and fonts, or git's first request. A POST is named by the handler 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            "/feed" => "more",
237            "/card.png" => "card",
238            _ => "view",
239        }
240        .to_owned();
241        if self.what == "view" && self.query.split('&').any(|pair| pair.starts_with("q=") && pair.len() > 2) {
242            self.detail = "shared".into();
243        }
244        Some(self)
245    }
247    pub fn named(mut self, what: &str) -> Event {
248        self.what = what.to_owned();
249        self
250    }
251
252    pub fn with(mut self, detail: impl Into<String>) -> Event {
253        self.detail = detail.into();
254        self
255    }

The question it was about.

258    pub fn about(mut self, input: &str) -> Event {
259        self.input = cut(input, 2000);
260        self
261    }

Counts a browser's page view as a visit: whether it is the browser's first page ever (it came with no cookie of the site's), its first today, and its first in half an hour. Returns the cookie that says it was counted now. Only a browser's page is a visit.

267    pub fn visit(&mut self, headers: &HeaderMap, now_ms: f64) -> Option<String> {
268        if self.what != "view" || self.client != "browser" {
269            return None;
270        }
271        let cookies = text(headers, header::COOKIE.as_str());
272        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);
273        let flag = |is: bool| if is { 1.0 } else { 0.0 };
274        self.first = flag(cookies.trim().is_empty());
275        self.daily = flag(last.is_none_or(|last| (last / DAY_MS).floor() < (now_ms / DAY_MS).floor()));
276        self.session = flag(last.is_none_or(|last| now_ms - last >= SESSION_MS));
277        Some(format!("{VISIT}={now_ms:.0}; Path=/; Max-Age=31536000; Secure; HttpOnly; SameSite=Lax"))
278    }
279}
281fn text<'a>(headers: &'a HeaderMap, name: &str) -> &'a str {
282    headers.get(name).and_then(|value| value.to_str().ok()).unwrap_or_default()
283}
284
285fn cut(text: &str, most: usize) -> String {
286    text.chars().take(most).collect()
287}

What kind of client asked, from what it says of itself.

290fn client(agent: &str, headers: &HeaderMap) -> &'static str {
291    const BOTS: [&str; 10] = ["bot", "crawler", "spider", "preview", "facebookexternalhit", "slurp", "curl/", "python", "wget", "headless"];
292    if agent.starts_with("git/") || agent.starts_with("cargo") {
293        "git"
294    } else if BOTS.iter().any(|mark| agent.contains(mark)) {
295        "bot"
296    } else if headers.contains_key("sec-fetch-mode") || agent.starts_with("mozilla/") {
297        "browser"
298    } else {
299        "other"
300    }
301}

The browser's family. The order matters: Edge and Opera say Chrome too, and Chrome says Safari.

305fn family(agent: &str) -> &'static str {
306    [("edg", "Edge"), ("opr/", "Opera"), ("firefox", "Firefox"), ("fxios", "Firefox"), ("crios", "Chrome"), ("chrome", "Chrome"), ("safari", "Safari")]
307        .into_iter()
308        .find(|(mark, _)| agent.contains(mark))
309        .map_or("", |(_, name)| name)
310}

The operating system. iPhones say "like Mac OS X" and Android says Linux, so they are looked for first.

314fn os(agent: &str) -> &'static str {
315    [("android", "Android"), ("iphone", "iOS"), ("ipad", "iOS"), ("windows", "Windows"), ("cros", "ChromeOS"), ("mac os x", "macOS"), ("linux", "Linux")]
316        .into_iter()
317        .find(|(mark, _)| agent.contains(mark))
318        .map_or("", |(_, name)| name)
319}
321fn device(agent: &str, headers: &HeaderMap) -> &'static str {
322    match text(headers, "sec-ch-ua-mobile") {
323        "?1" => "mobile",
324        "?0" => "desktop",
325        _ if ["mobile", "android", "iphone"].iter().any(|mark| agent.contains(mark)) => "mobile",
326        _ => "desktop",
327    }
328}

One of a link's campaign tags (utm_source, utm_medium, ...), if it has it.

332fn tag(query: &str, name: &str) -> Option<String> {
333    query.split('&').find_map(|pair| pair.strip_prefix(name)?.strip_prefix('=')).filter(|value| !value.is_empty()).map(|value| cut(value, 100))
334}

What the page says of itself when it is left or a link off the site is followed (page.js, posted to /seen). Every part is the page's word and is checked before it is kept.

339#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
340#[serde(default)]
341pub struct Seen {

The page's own path and query.

343    pub path: String,
344    pub query: String,

Milliseconds it was in view since it last said.

346    pub ms: f64,
347    pub scroll: f64,
348    pub screen: String,
349    pub viewport: String,

The address of a link followed off the site, when that is the news.

351    pub out: String,
352}
354impl Event {

The event of a page's report: read, or out when it names a link. The request is the report's own (POST /seen); the page it is about is in the report.

358    pub fn seen(mut self, seen: &Seen) -> Event {
359        // A size is two numbers and an `x`, and nothing else.
360        let size = |text: &str| {
361            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())));
362            if fits { text.to_owned() } else { String::new() }
363        };
364        let within = |n: f64, most: f64| if n.is_finite() { n.clamp(0.0, most) } else { 0.0 };
365        self.what = if seen.out.is_empty() { "read" } else { "out" }.to_owned();
366        self.detail = cut(&seen.out, 500);
367        if seen.path.starts_with('/') {
368            self.path = cut(&seen.path, 500);
369            self.query = cut(seen.query.trim_start_matches('?'), 2000);
370        }
371        // A page cannot have been looked at for longer than a day.
372        self.took_ms = within(seen.ms, 86_400_000.0);
373        self.scroll = within(seen.scroll, 1.0);
374        self.screen = size(&seen.screen);
375        self.viewport = size(&seen.viewport);
376        self
377    }
378}
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
385        let mut headers = HeaderMap::new();
386        for (name, value) in pairs {
387            headers.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), value.parse().unwrap());
388        }
389        headers
390    }
391
392    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";
393
394    fn event(method: Method, uri: &str, from: &[(&str, &str)]) -> Event {
395        Event::from(&method, &uri.parse().unwrap(), &headers(from), None, Origin::default())
396    }
397
398    #[test]
399    fn a_get_is_named_for_what_it_is() {
400        let what = |uri: &str| event(Method::GET, uri, &[]).got().map(|event| event.what);
401        assert_eq!(what("/").as_deref(), Some("view"));
402        assert_eq!(what("/lmjtfy.git/packages/rules/").as_deref(), Some("view"));
403        assert_eq!(what("/feed?ms=1&q=x").as_deref(), Some("more"));
404        assert_eq!(what("/card.png?q=x").as_deref(), Some("card"));
405        // The site's own parts, the socket and git's first request are not
406        // named here.
407        assert_eq!(what("/live"), None);
408        assert_eq!(what("/emoji.woff2"), None);
409        assert_eq!(what("/icons/file-rust.png"), None);
410        assert_eq!(what("/lmjtfy.git/info/refs?service=git-upload-pack"), None);
411        // A POST is its handler's to name.
412        assert_eq!(event(Method::POST, "/ask", &[]).got(), None);
413    }
414
415    #[test]
416    fn nothing_the_request_said_is_dropped() {
417        let from = [
418            ("referer", "https://discord.com/channels/1/2?x=y"),
419            ("host", "lmjtfy.fun"),
420            ("user-agent", CHROME),
421            ("accept-language", "en-GB,en;q=0.9"),
422        ];
423        let uri: Uri = "/?q=Is+my+boss+a+lizard%3F&utm_source=discord&utm_medium=chat&utm_campaign=launch".parse().unwrap();
424        let origin = Origin { ip: "203.0.113.7".into(), country: "GB".into(), asn: Some(2856), network: "British Telecommunications PLC".into(), ..Origin::default() };
425        let event = Event::from(&Method::GET, &uri, &headers(&from), Some("0b5f2a1e".into()), origin).got().unwrap();
426        assert_eq!((event.what.as_str(), event.detail.as_str(), event.path.as_str()), ("view", "shared", "/"));
427        assert_eq!(event.query, "q=Is+my+boss+a+lizard%3F&utm_source=discord&utm_medium=chat&utm_campaign=launch");
428        assert_eq!(event.referrer, "https://discord.com/channels/1/2?x=y");
429        assert_eq!((event.source.as_str(), event.language.as_str(), event.agent.as_str()), ("discord", "en-GB,en;q=0.9", CHROME));
430        assert_eq!((event.medium.as_str(), event.campaign.as_str()), ("chat", "launch"));
431        assert_eq!((event.client.as_str(), event.family.as_str(), event.os.as_str(), event.device.as_str()), ("browser", "Chrome", "Windows", "desktop"));
432        assert_eq!((event.browser.as_str(), event.origin.ip.as_str(), event.origin.asn), ("0b5f2a1e", "203.0.113.7", Some(2856)));
433    }
434
435    #[test]
436    fn every_column_has_its_value_and_an_event_survives_the_wire() {
437        let event = Event { what: "answer".into(), origin: Origin { asn: Some(7), latitude: Some(1.5), ..Origin::default() }, ..Event::default() };
438        let values = event.values();
439        assert_eq!(values.len(), Event::COLUMNS.len());
440        let at = |name: &str| values[Event::COLUMNS.iter().position(|column| *column == name).unwrap()].clone();
441        assert_eq!(at("what"), Cell::Text("answer".into()));
442        assert_eq!(at("asn"), Cell::Number(7.0));
443        assert_eq!(at("latitude"), Cell::Number(1.5));
444        assert_eq!(at("longitude"), Cell::Null);
445        let wire = serde_json::to_string(&event).unwrap();
446        assert_eq!(serde_json::from_str::<Event>(&wire).unwrap(), event);
447    }
448
449    #[test]
450    fn a_pages_report_is_checked_before_it_is_kept() {
451        let report = event(Method::POST, "/seen", &[("user-agent", CHROME)]);
452        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() });
453        assert_eq!((read.what.as_str(), read.path.as_str(), read.query.as_str()), ("read", "/rules", "answerable=no"));
454        assert_eq!((read.took_ms, read.scroll, read.screen.as_str(), read.viewport.as_str()), (4200.0, 0.5, "1920x1080", "1200x800"));
455        // A link followed off the site.
456        let out = report.clone().seen(&Seen { path: "/".into(), out: "https://docs.typesafe.ai/".into(), ..Seen::default() });
457        assert_eq!((out.what.as_str(), out.detail.as_str()), ("out", "https://docs.typesafe.ai/"));
458        // What a page makes up is cut down to what it could be.
459        let odd = report.seen(&Seen { path: "elsewhere".into(), ms: f64::INFINITY, scroll: 7.0, screen: "<b>".into(), viewport: "9999999x9999999".into(), ..Seen::default() });
460        assert_eq!((odd.path.as_str(), odd.took_ms, odd.scroll, odd.screen.as_str(), odd.viewport.as_str()), ("/seen", 0.0, 1.0, "", ""));
461        assert_eq!(serde_json::from_str::<Seen>("{\"ms\": 5}").unwrap().ms, 5.0);
462    }
463
464    #[test]
465    fn clients_are_classed() {
466        let class = |agent: &str| {
467            let event = event(Method::GET, "/", &[("user-agent", agent)]);
468            (event.client, event.family, event.os, event.device)
469        };
470        let is = |client: &str, family: &str, os: &str, device: &str| (client.to_owned(), family.to_owned(), os.to_owned(), device.to_owned());
471        assert_eq!(class("git/2.55.0"), is("git", "", "", ""));
472        assert_eq!(class("Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)"), is("bot", "", "", ""));
473        assert_eq!(class("curl/8.22.0"), is("bot", "", "", ""));
474        assert_eq!(class(""), is("other", "", "", ""));
475        assert_eq!(class(CHROME), is("browser", "Chrome", "Windows", "desktop"));
476        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"));
477        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"));
478        assert_eq!(class("Mozilla/5.0 (Android 16; Mobile; rv:143.0) Gecko/143.0 Firefox/143.0"), is("browser", "Firefox", "Android", "mobile"));
479        assert_eq!(class("Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0"), is("browser", "Firefox", "Linux", "desktop"));
480    }
481
482    #[test]
483    fn a_visit_is_counted_from_when_the_browser_was_last_counted() {
484        let noon = 20_000.0 * DAY_MS + DAY_MS / 2.0;
485        let visit = |cookie: Option<String>, now: f64| {
486            let mut pairs = vec![("user-agent", CHROME.to_owned())];
487            pairs.extend(cookie.map(|cookie| ("cookie", cookie)));
488            let pairs: Vec<(&str, &str)> = pairs.iter().map(|(name, value)| (*name, value.as_str())).collect();
489            let mut event = event(Method::GET, "/", &pairs).named("view");
490            let cookie = event.visit(&headers(&pairs), now);
491            ((event.first, event.daily, event.session), cookie)
492        };
493        // No cookie at all: a new browser, today's first page, a new visit.
494        let (flags, cookie) = visit(None, noon);
495        assert_eq!(flags, (1.0, 1.0, 1.0));
496        let cookie = cookie.unwrap();
497        assert!(cookie.starts_with(&format!("lmjtfy_visit={noon:.0}; ")), "{cookie}");
498        let last = |ms: f64| Some(format!("lmjtfy_browser=x; lmjtfy_visit={ms:.0}"));
499        // A minute on: none of them. An hour on: a new visit, the same day.
500        assert_eq!(visit(last(noon), noon + 60_000.0).0, (0.0, 0.0, 0.0));
501        assert_eq!(visit(last(noon), noon + 3_600_000.0).0, (0.0, 0.0, 1.0));
502        // Past midnight UTC: a new day too.
503        assert_eq!(visit(last(noon), noon + DAY_MS).0, (0.0, 1.0, 1.0));
504        // A cookie from before this one existed: known, not yet counted.
505        assert_eq!(visit(Some("lmjtfy_browser=x".into()), noon).0, (0.0, 1.0, 1.0));
506        // A time the page made up, or one from the future, is no time.
507        assert_eq!(visit(Some("lmjtfy_visit=soon".into()), noon).0, (0.0, 1.0, 1.0));
508        assert_eq!(visit(last(noon + DAY_MS), noon).0, (0.0, 1.0, 1.0));
509        // Only a browser's page view is a visit.
510        let mut bot = event(Method::GET, "/", &[("user-agent", "curl/8")]).named("view");
511        assert_eq!(bot.visit(&HeaderMap::new(), noon), None);
512        let mut vote = event(Method::POST, "/rate", &[("user-agent", CHROME)]).named("vote");
513        assert_eq!(vote.visit(&HeaderMap::new(), noon), None);
514    }
515}