Analytics pings with query args, cleanup.

This commit is contained in:
2026-08-26 14:53:09 +00:00
parent eddb3f22f3
commit 7aa2abbf8f
3 changed files with 69 additions and 59 deletions
+7 -3
View File
@@ -20,9 +20,12 @@ Struct dumped to disk — separate from the kanta content database, path from
## What is collected ## What is collected
The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with 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 the visit and counts the entry page view — the document GET alone records
nothing, so bots and admin browsing never register. JS-running crawlers nothing, so bots and admin browsing never register. JS-running crawlers
(Googlebot, GoogleOther, Applebot, ...) do ping, but their User-Agent (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 abuse rules regardless. Reloads are not
visits: the ping is skipped (PerformanceNavigationTiming `reload`), so a visits: the ping is skipped (PerformanceNavigationTiming `reload`), so a
refresh neither counts a second view nor logs a self-transition. The GET 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 `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. starts the visit; internal or absent referers never touch the referer table.
- **Internal fetch-navigations**: `to` is the target path, sent only after - **Internal fetch-navigations**: `to` is the target path, sent only after
+38 -38
View File
@@ -417,10 +417,12 @@ import "overlayscrollbars/overlayscrollbars.css";
} }
// --- Analytics pings --------------------------------------------------- // --- Analytics pings ---------------------------------------------------
// Fire-and-forget POST /_a {fr, to, read}: on the initial page load // Fire-and-forget POSTs to /_a with the fields as query parameters (a
// (starts the visit — the server counts nothing from the document GET // beacon can carry no body, and query args show in server logs next to
// alone), for internal fetch-navigations, for external https exits, and // the document GET they refer to): on the initial page load (starts the
// on window close. ``read`` is the active time (ms) spent on ``fr``. // 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 // Reading time pauses after 1 minute of inactivity and resumes on the
// next mouse/touch/scroll/keyboard event. // next mouse/touch/scroll/keyboard event.
// Excluded: back/forward (popstate never pings), everything while the // 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 // records nothing and scrubs any session the same browser accumulated
// before logging in, so admins never show up as visits or crawlers. // before logging in, so admins never show up as visits or crawlers.
// See docs/analytics.md. // See docs/analytics.md.
function ping(to, fr = currentPath, read = 0) {
if (document.body.classList.contains("editing")) return; // fetch wrapper: every key of ``params`` becomes a query arg on /_a
if (to && to === "/_a") return; // (falsy values are omitted). Admins get hide=1. ``beacon`` uses
const hide = ssoAvailable && isAdmin ? 1 : 0; // sendBeacon when available, for unload-time pings.
const body = JSON.stringify({ function pingFetch(params, { beacon = false } = {}) {
fr, to, hide, const query = new URLSearchParams();
read: Math.max(0, Math.round(read / 1000)), 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 { try {
fetch("/_a", { if (beacon && navigator.sendBeacon) {
method: "POST", navigator.sendBeacon(url);
keepalive: true, } else {
headers: { "content-type": "application/json" }, fetch(url, { method: "POST", keepalive: true });
body, }
});
} catch { /* analytics must never break navigation */ } } 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 // Active reading time for the current page. The clock stops after 1 minute
// without activity and restarts on the next mouse/touch/scroll/keyboard // without activity and restarts on the next mouse/touch/scroll/keyboard
// event. // event.
@@ -495,24 +505,10 @@ import "overlayscrollbars/overlayscrollbars.css";
function sendClosePing() { function sendClosePing() {
if (closePingedFor === currentPath) return; 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; 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"]) { 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 // The initial page load pings too — it is what starts the visit and
// counts the entry page view (the document GET alone records nothing). // 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; // 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: // the pageshow re-probe must not ping again. Reloads are not visits:
// pinging them would double-count the view and log a self-transition. // pinging them would double-count the view and log a self-transition.
@@ -531,7 +531,7 @@ import "overlayscrollbars/overlayscrollbars.css";
entryPinged = true; entryPinged = true;
const nav = performance.getEntriesByType?.("navigation")[0]; const nav = performance.getEntriesByType?.("navigation")[0];
if (nav ? nav.type === "reload" : performance.navigation?.type === 1) return; if (nav ? nav.type === "reload" : performance.navigation?.type === 1) return;
ping(currentPath); ping({ to: currentPath, fr: "" });
} }
// --- Analytics page mount/unmount -------------------------------------- // --- Analytics page mount/unmount --------------------------------------
@@ -732,7 +732,7 @@ import "overlayscrollbars/overlayscrollbars.css";
// different links to the same domain stay distinct in analytics. // different links to the same domain stay distinct in analytics.
if (url.protocol === "https:") { if (url.protocol === "https:") {
closePingedFor = currentPath; closePingedFor = currentPath;
ping(url.href, currentPath, takeReadTime()); ping({ to: url.href, read: takeReadTime() });
} }
return; return;
} }
@@ -748,7 +748,7 @@ import "overlayscrollbars/overlayscrollbars.css";
load(url).then((ok) => { load(url).then((ok) => {
if (!ok) return; if (!ok) return;
closePingedFor = null; closePingedFor = null;
ping(url.pathname, from, takeReadTime()); ping({ to: url.pathname, fr: from, read: takeReadTime() });
resetReadTime(); resetReadTime();
}); });
}); });
+24 -18
View File
@@ -31,7 +31,14 @@ from xml.sax.saxutils import escape as xml_escape
import blake3 import blake3
import msgspec 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.responses import RedirectResponse, Response
from fastapi_vue import Frontend from fastapi_vue import Frontend
from kanta import Kanta 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) @app.get("/_a", response_model=None)
async def analytics_page(request: Request) -> Response: async def analytics_page(request: Request) -> Response:
"""Render the analytics viewer as a normal site page at /_a. """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) @app.post("/_a", status_code=204)
async def analytics_ping(ping: AnalyticsPing, request: Request) -> None: async def analytics_ping(
"""Record a navigation ping ({fr, to}); fire-and-forget, never fails. 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 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. the response is never delayed by slow DNS or the first MMDB decompress.
""" """
ip = _client_ip(request) ip = _client_ip(request)
visit_index, flushed_clients = analytics_store.ping( visit_index, flushed_clients = analytics_store.ping(
ping.fr, fr,
ping.to, to,
ip, ip,
request.headers.get("user-agent", ""), request.headers.get("user-agent", ""),
request.headers.get("accept-language", ""), request.headers.get("accept-language", ""),
hide=bool(ping.hide), hide=bool(hide),
read=ping.read, read=read,
) )
if visit_index is not None: if visit_index is not None:
visit = analytics_store.data.visits[visit_index] visit = analytics_store.data.visits[visit_index]