lmjtfy.git / packages / archive / src / event.rs
event.rsannotatedevent.rssource496 lines · 22.7 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,
33    pub city: String,
34    pub postcode: String,
35    pub timezone: String,
36    pub latitude: Option<f64>,
37    pub longitude: Option<f64>,

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

39    pub asn: Option<u32>,
40    pub network: String,

The Cloudflare data centre that took the request.

42    pub colo: String,
43    pub protocol: String,
44    pub tls: String,
45}

One thing that happened.

48#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
49#[serde(default)]
50pub 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).

54    pub what: String,
55    pub method: String,
56    pub host: String,
57    pub path: String,

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

59    pub query: String,

The question typed, asked or voted on, cleaned.

61    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.

64    pub detail: String,

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

66    pub status: f64,

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

69    pub sent: f64,
70    pub kept: f64,

Whether an LLM was needed, 1 or 0.

72    pub llm: f64,

How long it took or lasted, in milliseconds.

74    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.

77    pub first: f64,
78    pub daily: f64,
79    pub session: f64,

The page that linked here, whole.

81    pub referrer: String,

A campaign the link named (utm_source or ref).

83    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.

87    pub client: String,
88    pub family: String,
89    pub os: String,
90    pub device: String,

Accept-Language and User-Agent as they came.

92    pub language: String,
93    pub agent: String,

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

96    pub browser: String,
97    #[serde(flatten)]
98    pub origin: Origin,

The link's utm_medium and utm_campaign.

100    pub medium: String,
101    pub campaign: String,

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

103    pub screen: String,
104    pub viewport: String,

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

106    pub scroll: f64,
107}

A value of one column.

110#[derive(Clone, Debug, PartialEq)]
111pub enum Cell {
112    Text(String),
113    Number(f64),
114    Null,
115}
117impl Event {

The columns of events, after id and at_ms, in the order of values.

120    pub const COLUMNS: [&str; 41] = [
121        "what", "method", "host", "path", "query", "input", "detail", "status", "sent", "kept", "llm", "took_ms", "first", "daily", "session", "referrer",
122        "source", "client", "family", "os", "device", "language", "agent", "browser", "ip", "country", "region", "city", "postcode", "timezone", "latitude",
123        "longitude", "asn", "network", "colo", "protocol", "medium", "campaign", "screen", "viewport", "scroll",
124    ];
126    pub fn values(&self) -> Vec<Cell> {
127        let text = |value: &str| Cell::Text(value.to_owned());
128        let number = |value: Option<f64>| value.map_or(Cell::Null, Cell::Number);
129        let origin = &self.origin;
130        vec![
131            text(&self.what),
132            text(&self.method),
133            text(&self.host),
134            text(&self.path),
135            text(&self.query),
136            text(&self.input),
137            text(&self.detail),
138            Cell::Number(self.status),
139            Cell::Number(self.sent),
140            Cell::Number(self.kept),
141            Cell::Number(self.llm),
142            Cell::Number(self.took_ms),
143            Cell::Number(self.first),
144            Cell::Number(self.daily),
145            Cell::Number(self.session),
146            text(&self.referrer),
147            text(&self.source),
148            text(&self.client),
149            text(&self.family),
150            text(&self.os),
151            text(&self.device),
152            text(&self.language),
153            text(&self.agent),
154            text(&self.browser),
155            text(&origin.ip),
156            text(&origin.country),
157            text(&origin.region),
158            text(&origin.city),
159            text(&origin.postcode),
160            text(&origin.timezone),
161            number(origin.latitude),
162            number(origin.longitude),
163            number(origin.asn.map(f64::from)),
164            text(&origin.network),
165            text(&origin.colo),
166            text(&format!("{} {}", origin.protocol, origin.tls).trim().to_owned()),
167            text(&self.medium),
168            text(&self.campaign),
169            text(&self.screen),
170            text(&self.viewport),
171            Cell::Number(self.scroll),
172        ]
173    }

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.

177    pub fn from(method: &Method, uri: &Uri, headers: &HeaderMap, browser: Option<String>, origin: Origin) -> Event {
178        let agent = text(headers, header::USER_AGENT.as_str());
179        let lower = agent.to_ascii_lowercase();
180        let client = client(&lower, headers);
181        let person = client == "browser";
182        let query = uri.query().unwrap_or_default();
183        Event {
184            method: method.as_str().to_owned(),
185            host: cut(text(headers, header::HOST.as_str()), 200),
186            path: cut(uri.path(), 500),
187            query: cut(query, 2000),
188            referrer: cut(text(headers, header::REFERER.as_str()), 1000),
189            source: tag(query, "utm_source").or_else(|| tag(query, "ref")).unwrap_or_default(),
190            medium: tag(query, "utm_medium").unwrap_or_default(),
191            campaign: tag(query, "utm_campaign").unwrap_or_default(),
192            client: client.to_owned(),
193            family: if person { family(&lower) } else { "" }.to_owned(),
194            os: if person { os(&lower) } else { "" }.to_owned(),
195            device: if person { device(&lower, headers) } else { "" }.to_owned(),
196            language: cut(text(headers, header::ACCEPT_LANGUAGE.as_str()), 200),
197            agent: cut(agent, 500),
198            browser: browser.unwrap_or_default(),
199            origin,
200            ..Event::default()
201        }
202    }

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.

207    pub fn got(mut self) -> Option<Event> {
208        if self.method != "GET" {
209            return None;
210        }
211        self.what = match self.path.as_str() {
212            "/datastar.js" | "/emoji.woff2" | "/live.js" | "/rules.svg" | "/favicon.ico" => return None,
213            // The socket's own event is written by the archive, which keeps
214            // it to say how long the page stayed.
215            "/live" => return None,
216            path if path.ends_with("/info/refs") || path.starts_with("/icons/") => return None,
217            "/feed" => "more",
218            "/card.png" => "card",
219            _ => "view",
220        }
221        .to_owned();
222        if self.what == "view" && self.query.split('&').any(|pair| pair.starts_with("q=") && pair.len() > 2) {
223            self.detail = "shared".into();
224        }
225        Some(self)
226    }
228    pub fn named(mut self, what: &str) -> Event {
229        self.what = what.to_owned();
230        self
231    }
232
233    pub fn with(mut self, detail: impl Into<String>) -> Event {
234        self.detail = detail.into();
235        self
236    }

The question it was about.

239    pub fn about(mut self, input: &str) -> Event {
240        self.input = cut(input, 2000);
241        self
242    }

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.

248    pub fn visit(&mut self, headers: &HeaderMap, now_ms: f64) -> Option<String> {
249        if self.what != "view" || self.client != "browser" {
250            return None;
251        }
252        let cookies = text(headers, header::COOKIE.as_str());
253        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);
254        let flag = |is: bool| if is { 1.0 } else { 0.0 };
255        self.first = flag(cookies.trim().is_empty());
256        self.daily = flag(last.is_none_or(|last| (last / DAY_MS).floor() < (now_ms / DAY_MS).floor()));
257        self.session = flag(last.is_none_or(|last| now_ms - last >= SESSION_MS));
258        Some(format!("{VISIT}={now_ms:.0}; Path=/; Max-Age=31536000; Secure; HttpOnly; SameSite=Lax"))
259    }
260}
262fn text<'a>(headers: &'a HeaderMap, name: &str) -> &'a str {
263    headers.get(name).and_then(|value| value.to_str().ok()).unwrap_or_default()
264}
265
266fn cut(text: &str, most: usize) -> String {
267    text.chars().take(most).collect()
268}

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

271fn client(agent: &str, headers: &HeaderMap) -> &'static str {
272    const BOTS: [&str; 10] = ["bot", "crawler", "spider", "preview", "facebookexternalhit", "slurp", "curl/", "python", "wget", "headless"];
273    if agent.starts_with("git/") || agent.starts_with("cargo") {
274        "git"
275    } else if BOTS.iter().any(|mark| agent.contains(mark)) {
276        "bot"
277    } else if headers.contains_key("sec-fetch-mode") || agent.starts_with("mozilla/") {
278        "browser"
279    } else {
280        "other"
281    }
282}

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

286fn family(agent: &str) -> &'static str {
287    [("edg", "Edge"), ("opr/", "Opera"), ("firefox", "Firefox"), ("fxios", "Firefox"), ("crios", "Chrome"), ("chrome", "Chrome"), ("safari", "Safari")]
288        .into_iter()
289        .find(|(mark, _)| agent.contains(mark))
290        .map_or("", |(_, name)| name)
291}

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

295fn os(agent: &str) -> &'static str {
296    [("android", "Android"), ("iphone", "iOS"), ("ipad", "iOS"), ("windows", "Windows"), ("cros", "ChromeOS"), ("mac os x", "macOS"), ("linux", "Linux")]
297        .into_iter()
298        .find(|(mark, _)| agent.contains(mark))
299        .map_or("", |(_, name)| name)
300}
302fn device(agent: &str, headers: &HeaderMap) -> &'static str {
303    match text(headers, "sec-ch-ua-mobile") {
304        "?1" => "mobile",
305        "?0" => "desktop",
306        _ if ["mobile", "android", "iphone"].iter().any(|mark| agent.contains(mark)) => "mobile",
307        _ => "desktop",
308    }
309}

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

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

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.

320#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
321#[serde(default)]
322pub struct Seen {

The page's own path and query.

324    pub path: String,
325    pub query: String,

Milliseconds it was in view since it last said.

327    pub ms: f64,
328    pub scroll: f64,
329    pub screen: String,
330    pub viewport: String,

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

332    pub out: String,
333}
335impl 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.

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