From 7aa2abbf8fefcfa5f79defcff3dc201c7b5357ba Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 26 Aug 2026 14:53:09 +0000 Subject: [PATCH] Analytics pings with query args, cleanup. --- docs/analytics.md | 10 ++++-- frontend/src/pagerite.js | 76 ++++++++++++++++++++-------------------- pagerite/app.py | 42 ++++++++++++---------- 3 files changed, 69 insertions(+), 59 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index 03d76f0..aee68fc 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -20,9 +20,12 @@ Struct dumped to disk — separate from the kanta content database, path from ## What is collected The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with -`{fr, to}` (`fr` = source path): +`fr`, `to`, `hide` and `read` as query parameters (`fr` = source path; +falsy values are omitted): -- **Initial page load**: `to` is the loaded path. This ping is what starts +- **Initial page load**: only `to` — the loaded path — is sent, never `fr` + (an `fr` equal to `to` would log a bogus self-transition when a session + already exists, e.g. a second tab). This ping is what starts the visit and counts the entry page view — the document GET alone records nothing, so bots and admin browsing never register. JS-running crawlers (Googlebot, GoogleOther, Applebot, ...) do ping, but their User-Agent @@ -34,7 +37,8 @@ The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with abuse rules regardless. Reloads are not visits: the ping is skipped (PerformanceNavigationTiming `reload`), so a refresh neither counts a second view nor logs a self-transition. The GET - handler stashes a cross-origin https `Referer` (origin part only) and any + handler stashes a cross-origin https `Referer` (origin part only — + unavailable to JS once the page has loaded) and any `utm_*` query parameters in in-memory IP tables, consumed by the ping that starts the visit; internal or absent referers never touch the referer table. - **Internal fetch-navigations**: `to` is the target path, sent only after diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index 073674a..70c532c 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -417,10 +417,12 @@ import "overlayscrollbars/overlayscrollbars.css"; } // --- Analytics pings --------------------------------------------------- - // Fire-and-forget POST /_a {fr, to, read}: on the initial page load - // (starts the visit — the server counts nothing from the document GET - // alone), for internal fetch-navigations, for external https exits, and - // on window close. ``read`` is the active time (ms) spent on ``fr``. + // Fire-and-forget POSTs to /_a with the fields as query parameters (a + // beacon can carry no body, and query args show in server logs next to + // the document GET they refer to): on the initial page load (starts the + // visit — the server counts nothing from the document GET alone), for + // internal fetch-navigations, for external https exits, and on window + // close. ``read`` is the active time (ms) spent on ``fr``. // Reading time pauses after 1 minute of inactivity and resumes on the // next mouse/touch/scroll/keyboard event. // Excluded: back/forward (popstate never pings), everything while the @@ -434,24 +436,32 @@ import "overlayscrollbars/overlayscrollbars.css"; // records nothing and scrubs any session the same browser accumulated // before logging in, so admins never show up as visits or crawlers. // See docs/analytics.md. - function ping(to, fr = currentPath, read = 0) { - if (document.body.classList.contains("editing")) return; - if (to && to === "/_a") return; - const hide = ssoAvailable && isAdmin ? 1 : 0; - const body = JSON.stringify({ - fr, to, hide, - read: Math.max(0, Math.round(read / 1000)), - }); + + // fetch wrapper: every key of ``params`` becomes a query arg on /_a + // (falsy values are omitted). Admins get hide=1. ``beacon`` uses + // sendBeacon when available, for unload-time pings. + function pingFetch(params, { beacon = false } = {}) { + const query = new URLSearchParams(); + if (ssoAvailable && isAdmin) params = { ...params, hide: 1 }; + for (const [key, value] of Object.entries(params)) { + if (value) query.set(key, value); + } + const url = `/_a?${query}`; try { - fetch("/_a", { - method: "POST", - keepalive: true, - headers: { "content-type": "application/json" }, - body, - }); + if (beacon && navigator.sendBeacon) { + navigator.sendBeacon(url); + } else { + fetch(url, { method: "POST", keepalive: true }); + } } catch { /* analytics must never break navigation */ } } + function ping({ to, fr = currentPath, read = 0, beacon = false } = {}) { + if (document.body.classList.contains("editing")) return; + if (to === "/_a") return; + pingFetch({ fr, to, read: Math.round(read / 1000) }, { beacon }); + } + // Active reading time for the current page. The clock stops after 1 minute // without activity and restarts on the next mouse/touch/scroll/keyboard // event. @@ -495,24 +505,10 @@ import "overlayscrollbars/overlayscrollbars.css"; function sendClosePing() { if (closePingedFor === currentPath) return; - const read = Math.max(0, Math.round(takeReadTime() / 1000)); - if (read <= 0) return; - const hide = ssoAvailable && isAdmin ? 1 : 0; - const body = JSON.stringify({ fr: currentPath, hide, read }); - const blob = new Blob([body], { type: "application/json" }); - try { - if (navigator.sendBeacon) { - navigator.sendBeacon("/_a", blob); - } else { - fetch("/_a", { - method: "POST", - keepalive: true, - headers: { "content-type": "application/json" }, - body, - }); - } - } catch { /* analytics must never break navigation */ } closePingedFor = currentPath; + const read = takeReadTime(); + if (Math.round(read / 1000) <= 0) return; + ping({ read, beacon: true }); } for (const ev of ["mousemove", "mousedown", "touchstart", "touchmove", "scroll", "keydown"]) { @@ -522,6 +518,10 @@ import "overlayscrollbars/overlayscrollbars.css"; // The initial page load pings too — it is what starts the visit and // counts the entry page view (the document GET alone records nothing). + // It carries only ``to``: the server attributes the entry to the referer + // it saw on the document GET (unavailable to JS once loaded), and an + // ``fr`` equal to ``to`` would log a bogus self-transition when a + // session already exists (e.g. a second tab). // Sent once per load, after the auth probes so the admin gate applies; // the pageshow re-probe must not ping again. Reloads are not visits: // pinging them would double-count the view and log a self-transition. @@ -531,7 +531,7 @@ import "overlayscrollbars/overlayscrollbars.css"; entryPinged = true; const nav = performance.getEntriesByType?.("navigation")[0]; if (nav ? nav.type === "reload" : performance.navigation?.type === 1) return; - ping(currentPath); + ping({ to: currentPath, fr: "" }); } // --- Analytics page mount/unmount -------------------------------------- @@ -732,7 +732,7 @@ import "overlayscrollbars/overlayscrollbars.css"; // different links to the same domain stay distinct in analytics. if (url.protocol === "https:") { closePingedFor = currentPath; - ping(url.href, currentPath, takeReadTime()); + ping({ to: url.href, read: takeReadTime() }); } return; } @@ -748,7 +748,7 @@ import "overlayscrollbars/overlayscrollbars.css"; load(url).then((ok) => { if (!ok) return; closePingedFor = null; - ping(url.pathname, from, takeReadTime()); + ping({ to: url.pathname, fr: from, read: takeReadTime() }); resetReadTime(); }); }); diff --git a/pagerite/app.py b/pagerite/app.py index c67eb9c..df91fc8 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -31,7 +31,14 @@ from xml.sax.saxutils import escape as xml_escape import blake3 import msgspec -from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect +from fastapi import ( + FastAPI, + HTTPException, + Query, + Request, + WebSocket, + WebSocketDisconnect, +) from fastapi.responses import RedirectResponse, Response from fastapi_vue import Frontend from kanta import Kanta @@ -802,17 +809,6 @@ def _schedule_analytics_broadcast() -> None: ) -class AnalyticsPing(BaseModel): - """Navigation ping from pagerite.js (see docs/analytics.md).""" - - fr: str = "" - to: str | None = None - #: 1 from admin clients: scrub the session instead of recording it. - hide: int = 0 - #: Active reading time on ``fr`` (ms), if any. - read: int = 0 - - @app.get("/_a", response_model=None) async def analytics_page(request: Request) -> Response: """Render the analytics viewer as a normal site page at /_a. @@ -831,21 +827,31 @@ async def analytics_page(request: Request) -> Response: @app.post("/_a", status_code=204) -async def analytics_ping(ping: AnalyticsPing, request: Request) -> None: - """Record a navigation ping ({fr, to}); fire-and-forget, never fails. +async def analytics_ping( + request: Request, + fr: str = Query(""), + to: str | None = Query(None), + hide: int = Query(0), + read: int = Query(0), +) -> None: + """Record a navigation ping (?fr=&to=&hide=&read=); fire-and-forget. + + The initial page-load ping carries only ``to``: the entry is attributed + to the referer/UTM tags stashed by the document GET (see _track_entry), + which JS cannot see once the page has loaded. The reverse-DNS and DB-IP geoip lookups happen in a background task so the response is never delayed by slow DNS or the first MMDB decompress. """ ip = _client_ip(request) visit_index, flushed_clients = analytics_store.ping( - ping.fr, - ping.to, + fr, + to, ip, request.headers.get("user-agent", ""), request.headers.get("accept-language", ""), - hide=bool(ping.hide), - read=ping.read, + hide=bool(hide), + read=read, ) if visit_index is not None: visit = analytics_store.data.visits[visit_index]