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
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
+38 -38
View File
@@ -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();
});
});
+24 -18
View File
@@ -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]