Files
pagerite/docs/analytics.md
T
LeoVasanko 3100010335 Transition map: count-scaled edges, bead flows, external links
- Edge widths grow logarithmically with the connection count (~1 px at
  a single count, uncapped); connections below 1% of total traffic are
  pruned, bounding the graph to ~100 edges.
- Beads: per-direction flows emitted at a rate linear in the count,
  each bead simulated independently in JS (no in-flight limit), offset
  onto right-hand lanes so opposing flows don't collide, running under
  the node circles with a glow.
- External links: referer origins as a node row above the map, exit
  origins fanned outwards from their source page.
- Transitions are now stored per 5-minute bucket (sparse
  from -> to -> bucket -> count) so the graph filters by time range
  like the other series; legacy analytics files are discarded.
2026-08-20 22:06:29 +00:00

9.6 KiB
Raw Blame History

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: the database path with .kantadb replaced by .analytics.json, e.g. pagerite.analytics.json).

  • pagerite/analytics.py — data model (Analytics, Visit) and the Store (in-memory data + session map, atomic JSON persistence).
  • pagerite/app.py — entry-referer stashing in show_page (_track_entry), the POST /_a ping endpoint, and GET /_api/analytics (admin-gated like every /_api endpoint).
  • frontend/src/pagerite.js — client navigation pings and the 📊 pen.
  • frontend/src/AnalyticsView.vue — full-screen viewer (its own Vue app via openAnalytics()/closeAnalytics() in main.js, not a docked-panel tab).

What is collected

The client (pagerite.js) POSTs fire-and-forget pings to /_a with {fr, to} (fr = source path):

  • Initial page load: to is the loaded path. 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. 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 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 the swap actually happened (a failed swap falls back to a full load, whose initial ping counts the view instead — no gap, no double count).
  • External links (https only): to is the link's origin. This is the exit-link record; the user may continue navigating afterwards (new tab, back), so the exit origin is not necessarily the last trail entry.
  • Excluded: back/forward (popstate) navigations, and everything while the user is known to be an admin and SSO is actually in use — with no auth proxy (dev/test) "admin" is everyone's state, so the gate is off and everything is recorded — or has the editor open (body.editing) or the analytics view open (body.analytics-open) — admin noise, not visits.
  • 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.
  • The initial ping also records the visitor's User-Agent and Accept-Language headers. The first Accept-Language tag is stored as lang (e.g. en-us) and its region subtag, if present, is stored as an initial country (e.g. US).
  • The visitor IP is stored. A reverse-DNS lookup is attempted for each new visit and the result, when available, is cached in RAM and stored as host; local/reserved/multicast addresses are skipped.
  • If a DB-IP MMDB file (dbip-*.mmdb or dbip-*.mmdb.gz) is present in the repository root, it is loaded at startup and used to look up a more accurate country. The MMDB lookup and the reverse-DNS lookup run in background tasks after the visit is stored, so the / _a response is never delayed. The decompressed dbip-*.mmdb file is kept in the repository root and ignored by git.
  • Crawler hits: every document GET is queued in RAM as a pending crawler hit. If a ping from the same (IP, User-Agent) pair arrives within 10 seconds the hit is discarded; otherwise it is written to crawlers. Crawlers do not count as visits or views.

Visits and sessions

There are no cookies. A visit is tied together by the (IP, User-Agent) pair (IP from the first X-Forwarded-For hop — we sit behind a proxy — else the direct peer): the first ping from a pair starts a new visit, subsequent pings extend it. Pings arriving with no known session (server restart) start a fresh visit from the first ping — treated as missing data rather than dropped. The (IP, UA) → visit map and the IP → entry-referer/UTM tables are in-memory only, but the IP and any resolvable reverse-DNS host name are stored on the Visit record itself.

Each Visit record:

  • start — timestamp of the first event,
  • entry — first page (path) seen,
  • referer — external https origin of the initial load, "" for direct,
  • ip — visitor IP address (first X-Forwarded-For hop, or direct peer),
  • host — reverse-DNS host name for ip when resolvable, else "",
  • trail — everything seen afterwards in first-seen order: page paths and external exit origins. Re-visiting an already seen page (incl. the entry) does not append.
  • lang — first Accept-Language tag, lowercased (e.g. en-us),
  • country — two-letter country code. Initially derived from the Accept-Language region subtag, but overwritten by the DB-IP MMDB result when a database is available,
  • ua — raw User-Agent string from the initial ping,
  • ua_pretty — compact display form of the UA (browser/OS/device) when parsable, otherwise the raw string,
  • utmutm_* query parameters from the landing URL, as a dict.

Each CrawlerHit record:

  • start — timestamp of the document GET,
  • entry — page path requested,
  • ip — IP address,
  • ua — raw User-Agent header,
  • ua_pretty — compact display form of the UA when parsable,
  • referer — external https origin of the request, "" for direct/none,
  • query — raw query string of the request.

Crawler hits are grouped by User-Agent in the analytics viewer.

Aggregates

  • transitions: time series of page transitions, sparse nested dict from -> to -> bucket -> count with the same 5-minute bucketing as views. from is the referer origin or "(direct)" for initial loads, a page path for pings.
  • 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 -> count of new visits started, same sparse 5-minute bucketing.

Sparseness keeps quiet sites small; dropping old data is a matter of deleting list/dict entries (visits is a plain append-only list, buckets plain keys).

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) opens AnalyticsView.vue — a true full-screen app, not an overlay: body.analytics-open hides the page chrome and the document itself scrolls the view, styled by the active theme's variables. It is addressable by URL: #/analytics/<range> (week default; opening via the pen pushes a history entry so the back button exits, and pagerite.js auto-opens it on load for editors when the hash is present, so refresh and link sharing work).

Charts are SVG curves (Catmull-Rom over an edge-aware adaptive Gaussian — a change-point detector splits the series at traffic-level shifts, then each segment is smoothed with a bandwidth that ramps with a broad pilot estimate of the local rate: isolated events stay narrow (~0.4-unit sigma, peaking at ~1 event/unit), busy traffic widens to a 1-unit sigma. 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 floor is 1/h). The week range is aligned to Monday 00:00 UTC and overlays up to 8 previous weeks in the same accent color at decreasing opacity (the current week is truncated at the current bucket, never drawing fake zeroes for the future); 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. Year and all are rolling windows ending at now, re-bucketed to daily points, with boundary lines at months/years. Below the charts: a radial transition map (all pages from /_api/pages — front page at the center, each slug level on its own ring, siblings clockwise in navigation order from the top, radial gap equal to the arc spacing — opposite transition directions joined into organic tapered connections whose middle width grows logarithmically with the count (a single count renders as a ~1 px line, uncapped), connections carrying less than 1% of the total traffic pruned; beads are simulated one by one in JS (requestAnimationFrame) and flow along each edge, emitted at a rate linearly proportional to the directional count with no in-flight limit, opposing directions offset onto parallel lanes. External referers show as a node row above the map, external exits as small nodes fanned outwards from their source page), per-page view counts, the top transitions and the 50 most recent visit trails. Data comes from GET /_api/analytics, which returns the raw JSON file contents.