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
|
## 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
@@ -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
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user