22 KiB
Analytics
Server-side visit analytics. Data lives in a plain JSON file — a msgspec
Struct dumped to disk — separate from the kanta content database, path from
PAGERITE_ANALYTICS (default: analytics.json in the per-site data
directory, e.g. localhost/analytics.json).
pagerite/analytics.py— data model (Analytics,Client,Visit,CrawlerHit,AbuseHit,Favicon) and theStore(in-memory data + session map, atomic JSON persistence).pagerite/pages.py— entry-referer stashing inshow_page(_track_entry, inpagerite/tracking.py), 404 recording.pagerite/tracking.py— the/_wsactivity WebSocket, andWebSocket /_api/ws/analytics(admin-gated like every/_apiendpoint).frontend/src/pagerite.js— the client activity channel and the 📊 pen.frontend/src/AnalyticsView.vue— viewer component rendered inside the normal site layout on the/_aanalytics page.frontend/src/analytics-main.js— page entry that mountsAnalyticsViewinto#analytics-appinside#main.
What is collected
The client (pagerite.js) keeps a WebSocket connection to /_ws for the
whole browsing session and sends activity messages over it — JSON text
frames matching the server's Ping msgspec struct with the fields fr
(source path), to (navigation target), read (active seconds on fr
since the last report) and hide; falsy fields are omitted. One channel
follows the session, so the activity of a visit stays tied together, and
while the user is active the accumulated reading time is flushed every few
seconds: the trail times are cumulative, so a disconnection simply leaves
the last reported time in place (no close beacon). After 5 minutes without
any activity the client closes the socket itself — a sleeping browser tab
would lose it anyway — and the next activity reconnects as a fresh session;
reconnects are attempted only on user activity, with an exponential backoff
between attempts so a failing endpoint is never hammered. Idle-time link preloads
stay plain fetch() calls so the browser may cache the responses; the
WebSocket reports actual navigations and active time spent on a page.
- Initial page load: only
to— the loaded path — is sent, neverfr(anfrequal totowould log a bogus self-transition when a session already exists, e.g. a second tab). This message is what starts the visit and counts the entry page view — the document GET alone records nothing, so bots never register (admin browsing does register, but flaggedhide; see Admins below). JS-running crawlers (Googlebot, GoogleOther, Applebot, ...) do connect and report, but their User-Agent gives them away: messages whose UA matches_is_bot_ua(anything calling itself a "bot", plus known exceptions such as GoogleOther) are ignored server-side, and their document GETs land in the crawler list instead. Real-browser bots whose UA does not match still register a visit, but their reported reading time stays under 5 seconds, so they are reclassified as crawler hits at display time (see Crawler hits below). No source-IP verification is done: a spoofed bot UA merely lands in the crawler stats, and scanners that probe telltale paths are caught by the abuse rules regardless. Reloads are not visits: the message is skipped (PerformanceNavigationTimingreload), so a refresh neither counts a second view nor logs a self-transition. The GET handler stashes a cross-origin httpsReferer(origin part only — unavailable to JS once the page has loaded) and anyutm_*query parameters in in-memory IP tables, consumed by the first message that starts the visit; internal or absent referers never touch the referer table. - Internal fetch-navigations:
tois the target path, sent only after the swap actually happened (a failed swap falls back to a full load, whose initial message counts the view instead — no gap, no double count). - External links (
httpsonly):tois the link's full URL. This is the exit-link record; the user may continue navigating afterwards (new tab, back), so the exit URL is not necessarily the last trail entry. Outbound links are stored by full URL so several links to the same domain remain distinct. - Excluded: back/forward (popstate) navigations, navigating to the
analytics page (
/_a— its GET is untracked, and the server cannot record it as a navigation target anyway), and everything while the user has the editor open (body.editing). Admin noise, not visits. Navigating away from/_adoes report: the fetch-navigation already GET-ed the target page without the preload header, and without the message that GET would flush to the crawler list. - Admins: when SSO is in use and the session is known to be an admin,
the client still reports but adds
hide. The activity is recorded as usual (navigations and all), but thehideflag is set on the client record — so it covers everything that client ever did: visits and crawler hits from before the login included. Hidden clients never appear in the viewer payload:Store.display()drops their visits, crawler hits, abuse hits and metadata, and computes every aggregate (site visits, page views, transitions) from the visible visits only, so nothing needs to be reversed or redacted. Pending crawler hits from a hidden client are discarded when they expire, so admin browsing never lands in the crawler list either. With no auth proxy (dev/test) "admin" is everyone's state, sohidestays 0 and everything is recorded. - The server validates
to: internal paths must be valid slug paths ("/" or[a-z0-9_-]segments), external ones are re-derived to the https origin and accepted only when the client sent exactly that. - External-site favicons: for every external https origin seen as a visit
referer, a crawler-hit referer or an exit link, the server fetches
{origin}/favicon.icoin a background task (httpx, 8 s timeout, ≤ 64 KB, image content-types only — SVG is sniffed from the body when served without an image type) and stores the icon content-hashed on disk in the FileStore (served at/_f/{name}, extension matching the actual MIME). The origin → file name mapping is recorded inAnalytics.favicons(Favicon.file/fetched); misses are recorded too and retried only after 7 days. Fetches are scheduled after each activity message and once at startup, which backfills icons for already-recorded data. The viewer payload carriesfavicons(origin →/_f/...path), and the viewer shows the icon wherever an external site is mentioned: referer/exit trail links in the visit table and the source/exit pills of the transition map (UTM-attributed source nodes without an https origin stay text-only). - Client records: the visitor's IP (IPv4 or IPv6 /64 network), raw
User-Agentand extractedAccept-Languagetag are hashed with blake3; the first 6 bytes identify a sharedClientrecord. TheClientstores the full IP,User-Agent, compactua_pretty,lang, initialcountryfrom the language-region subtag, and asynchronously-filledcountry/cityfrom DB-IP geoip plus reverse-DNShost. Visits, crawler hits and abuse hits all reference this record by its hash, so client metadata is stored once instead of repeated per event. - The visitor IP is stored in the
Client. A reverse-DNS lookup is attempted for each new client and the result, when available, is stored ashost; local/reserved/multicast addresses are skipped. If a DB-IP MMDB file (dbip-*.mmdbordbip-*.mmdb.gz) is present in the repository root, it is loaded at startup and used to look upcountry/city. These lookups run in background tasks after the event is stored, so WebSocket message handling is never delayed. The decompresseddbip-*.mmdbfile is kept in the repository root and ignored by git. The CLI flag--dbip(uv run pagerite --dbip) downloads the latestdbip-city-lite-YYYY-MM.mmdb.gzfrom DB-IP at startup (in the app lifespan, before the MMDB is opened), skipping the download when the local database is already current and removing older versions after an update; without the flag only an existing file is used. - Crawler hits: every document GET is queued in RAM as a pending crawler
hit — except idle-time link preloads from pagerite.js, which carry an
x-pagerite-preloadheader and are not tracked at all (the navigation message sent when the user actually navigates to a preloaded page does the counting; forging the header only hides a GET from the crawler stats, the path-based abuse classification is unaffected). If a message from the same client arrives within 10 seconds the hit is discarded; otherwise it is written tocrawlers— unless the client is hidden (admin), in which case the hit is discarded on expiry too. Crawlers do not count as visits or views. Bots running real browsers can still slip past the UA check: a visit whose total reported reading time stays under 5 seconds (_MIN_VISIT_READ; durations are client-provided and trusted — such bots report 0–2 s) is reclassified as crawler hits at display time, one hit per internal trail page, and counts in no visit aggregate. TheAccept-Languageheader is stored on the sharedClientimmediately; reverse-DNS host names and DB-IP geoip country/city are filled in asynchronously, just like for real visits. In the analytics viewer, crawler hits are grouped by client hash and shown as a trail of internal pages that crawler visited, preceded by its referer when there is one — spiders often advertise their own site as the referer, and it is rendered with its favicon like visit referers (crawler referers are included in the favicon fetch origins). The crawler table lists the most recent crawler first, with the most active as a tie-breaker. - Abuse (scanner) hits: a 404 for a telltale path — any URL segment
starting with a dot (
/.env,/.git/config) or ending in.php— classifies the source IP as abuse immediately, and ten plain 404s from one IP do too. Classification reclassifies history: all earlier crawler hits from that IP (persisted and pending) move to theabuselist, so a random-UA scanner no longer pollutes the crawler stats of the legitimate bot it impersonates. Once classified, every document GET and 404 from the IP is recorded as an abuse hit with the full request path (query string included), and its activity messages are ignored. The classified IP set (abuse_ips) is persisted in the JSON file; the plain-404 counters are RAM-only. In the viewer, abuse hits are grouped by IP (never by client/UA — scanners randomize theirs) in a separate "Abuse" table. Identical paths are collapsed into one entry with their hit count. The 404 probes ("paths abused": flagged paths that triggered classification first, then other 404s) are kept in a separate column from the real articles the abuser actually read ("articles read": document GETs that returned 200, not the 404 fallback rendering — rendered as trail links like the visitor and crawler tables, with the query string stripped). Raw User-Agent strings are shown one per line with their occurrence counts, and the full lists are click-to-copy.
Visits and sessions
There are no cookies. A visit is tied together by a client hash — the first
6 bytes of a blake3 digest over the prettified IP (IPv4 unchanged, IPv6
/64 network), the raw User-Agent string and the extracted
Accept-Language tag. The first message from a client hash starts a new
visit; subsequent messages extend it. Messages arriving with no known session
(server restart) start a fresh visit from the first message — treated as
missing data rather than dropped. The client-hash → visit map and the IP →
entry-referer/UTM tables are in-memory only; client metadata is stored in
Analytics.clients keyed by the client hash.
Each Client record:
ip— visitor IP address (firstX-Forwarded-Forhop, or direct peer),host— reverse-DNS host name foripwhen resolvable, else"",lang— firstAccept-Languagetag, lowercased (e.g."en-us"),country— two-letter country code. Initially derived from theAccept-Languageregion subtag, but overwritten by the DB-IP MMDB result when a database is available,city— city name from the DB-IP MMDB lookup, when available,ua— rawUser-Agentstring,ua_pretty— compact display form of the UA (browser/OS/device) when parsable, otherwise the raw string,hide— true for admin clients (hidemessage field): all their visits, crawler hits and abuse hits are recorded but excluded from every statistic and from the viewer payload.
Each Visit record:
start— timestamp of the first event,entry— first page (path) seen,referer— external https origin of the initial load,""for direct,client— 6-byte blake3 hash referencingAnalytics.clients,trail— the entry page and everything seen afterwards, keyed by the timestamp of first sight (insertion order = first-seen order). Each item holdsto(page path or external exit URL), the accumulated active reading time in seconds (read) and the most recent HTTP status seen for the target (status). Re-visiting an already seen target updates its item instead of appending.navs— every navigation message (fr,to), keyed by its timestamp, repeats included. The aggregates are computed from this log at display time.utm—utm_*query parameters from the landing URL, as a dict.
Each CrawlerHit record:
start— timestamp of the document GET,entry— page path requested,client— 6-byte blake3 hash referencingAnalytics.clients,referer— external https origin of the request,""for direct/none,query— raw query string of the request,status— HTTP status of the served response (200 for a real page, 404 for a category placeholder or missing page).
Each AbuseHit record:
start— timestamp of the request,path— full request path including the query string (e.g./.env?x=1),client— 6-byte blake3 hash referencingAnalytics.clients,flag— true for the path that triggered abuse classification (telltale path or the 404 that crossed the threshold),is_404— true for 404 responses (probed paths and 404-fallback document GETs), false for real (200) document GETs — articles the abuser read.
Crawler hits are grouped by client hash in the analytics viewer; abuse hits
are grouped by IP alone (resolved from the referenced Client). In the
Abuse table identical paths are collapsed with their counts, split into the
404 probes (flagged paths that triggered classification first, then other
404s, shown verbatim) and the 200 document GETs shown as trail links in the
separate articles column.
Within each list paths are
sorted by count descending, then by their earliest hit.
In the visitor and crawler tables, internal paths that returned a 404 status are shown in red and the link title includes the status code, so it is easy to tell misses from real pages at a glance.
Aggregates
Aggregates are not stored; they are computed at display time by
Store.display() from the visit records (entry + navs log), skipping
hidden clients' visits and short visits reclassified as crawler hits
(under _MIN_VISIT_READ seconds of total reported reading time). This is
what allows a client to become hidden after
navigations were already logged: no counts need reversing. The computed
shapes, part of the WebSocket payload (Display struct alongside visits,
crawlers, abuse and clients):
transitions: time series of page transitions, sparse nested dictfrom -> to -> bucket -> countwith 5-minute bucketing.fromis the referer origin or"(direct)"for initial loads, a page path for navigations.views: time series of page loads,path -> bucket -> count, sparse: only non-zero 5-minute buckets exist (bucket key is its floored ISO timestamp). Every load counts, including repeats within a visit; external exit origins are not page views and are not counted here.site_visits:bucket -> countof new visits started, same sparse 5-minute bucketing.
Sparseness keeps quiet sites small; dropping old data is a matter of deleting
list entries (visits is a plain append-only list).
Persistence
The whole Analytics struct is JSON-encoded and written atomically
(temp file + rename) on every recorded event. Traffic on a small CMS makes
this cheap enough; batching can be added later without changing the format.
Viewing
The 📊 pen in the banner corner (admins only, injected by pagerite.js next to
the edit pens) links to /_a, the analytics page. It is a normal site page:
the standard banner, navigation and footer stay in place, and the analytics
content is rendered inside #main. The page itself is public, but the data
stream comes from WebSocket /_api/ws/analytics, which remains admin-gated
like the rest of the management API; visitors without access see the viewer
with a "could not be loaded" message.
Because it is a real page, fetch-navigation handles it like any other internal
link: clicking the 📊 pen (or any link to /_a) fetches the server-rendered
HTML, swaps the dynamic regions and mounts the Vue analytics app in place. The
range selector updates the URL hash (#week etc.) so links to a specific
range can be shared. When the URL has no hash, the client derives the
default from the first analytics snapshot: day if the recorded history
spans less than 24 hours, otherwise week.
AnalyticsView.vue is no longer a full-screen overlay; the body.analytics-open
page-chrome hiding and #/analytics/<range> hash routing have been removed.
Charts are SVG curves (Catmull-Rom over an edge-aware Gaussian — a
change-point detector splits the series at traffic-level shifts, then each
segment is smoothed independently with a fixed sigma chosen so N events in
a single bucket peak at N events per unit. The raw series is drawn faint
underneath). Values are
per-unit rates — per hour on the week view (5-minute bucket counts × 12,
plotted at native 5-minute resolution), per day on the month+ ranges — and
the smoothing time scale follows the unit: the month+ sigmas are 24× the
hourly ones. The y max is derived from the smoothed curves so single-bucket
spikes don't blow up the scale, and raw spikes are clamped into the plot.
Axes always start at 0 and end at a multiple of a 1-2-5 major step (max 5
labeled intervals, minor lines at fifths when integral; the minimum y-axis
range is 10 so tiny values such as a single visit are not stretched to a
fractional scale).
The week range is aligned to Monday 00:00 UTC and overlays up to 8 previous
weeks in the muted color at decreasing opacity (the current week keeps the
accent color and is
truncated at the current bucket, never drawing fake zeroes for the future);
a compact legend inside the top right of the visits chart marks the current
ISO week in accent and the overlaid past weeks as "Week M" or "Week M–N" on
a muted specimen. Its x labels are weekday names centered at midday UTC, without
vertical grid
lines (day boundaries would be misleading in the viewer's timezone). The
month view labels days the same lineless way — day numbers at noon UTC,
with the month name substituted for the 1st. Month, year and all are
rolling windows ending at now, aligned to UTC day boundaries at the start
so the labels span the whole range; the bucket size follows the window —
6 hours up to 31 days, daily beyond — with boundary lines at months/years
on the longer ranges. All uses the full data reach, but keeps
at least the past 30 days (identical to the month view when the site is
younger than that, bucket size included) so the chart never collapses to a
tiny sliver when the site is young. Below the charts: a transition map (all pages from
/_api/pages — top-level menu items on a large-radius circular arc whose
bottom point is the last item (each earlier item a bit higher), connected
by a top lane labeled 🏠︎ beside the home pill (50% thicker than
the branch lanes, its label font and guide offset scaled along), each item's
subtree fanning out below it in menu order along a large-radius circular
arc that leaves heading
straight down and gradually bends right, index pages without views omitted
and their children promoted in their place. The submenu structure is drawn
as wide branch lanes: one per path prefix with at least two visible
nodes, running behind the branch's node pills as circle arcs concentric
with the fan (parent levels one radius step outward, so all lanes of a
group share exactly one form), each labeled with its branch slug
left-aligned just past the first pill and allowed to run along the lane to
its end, disappearing under later pills when long — so the lanes reflect
the path
structure even where index pages are omitted — opposite transition
directions joined into organic
tapered connections whose middle width grows logarithmically (base 2)
with the daily hit rate (uncapped), connections
carrying less than 1% of the total traffic
pruned, as are those whose thin middle would render below ~0.8 px —
fainter strands are invisible and only their wide end flares would show; beads are simulated one by one in JS (requestAnimationFrame) and
flow along each edge, persisting across data reloads (emitters are keyed
per edge direction and beads tracked by progress, so an unrelated count
change never reshuffles them), emitted at a rate linearly proportional
to the directional count with no in-flight limit, opposing directions
offset onto parallel lanes. External sources and exits whose connectors are
all culled by the width threshold are dropped from their rows themselves
(the site's own page nodes always stay, connected or not). External sources show as a node row above the
map: each visit is attributed to utm_campaign, then utm_source, then the
referer origin, then any other utm_* tag, so UTM-tagged visits are grouped
under their campaign/source value rather than the referer domain. A UTM
source node only links to its referer when every visit carrying that tag
came from the same origin. External exits are full-size nodes in a matching
row centered below the map, so the site itself stays in the middle), per-page view
counts, the top transitions and the 50 most recent visit trails. Data is
streamed live over WebSocket /_api/ws/analytics, which pushes the latest
JSON snapshot on connect and again whenever the analytics file is updated
(with a small server-side debounce to avoid flooding under high traffic).