Analytics pings with query args, cleanup.
This commit is contained in:
+7
-3
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user