The language-switch trail ping read <html lang> before the view transition applied the swap, so it reported the previous language (and read pings never update trail-item languages server-side): multi- language visits recorded as single-language trails and the analytics flag combining had nothing to show. The switch ping now passes the picked tag explicitly. Language negotiation no longer prefers the article's original language anywhere in the Accept-Language list — nearly every browser lists English as a fallback, so translations were almost never served. The first servable header language now wins, the original counting only in its natural position.
436 lines
26 KiB
Markdown
436 lines
26 KiB
Markdown
# Analytics
|
||
|
||
Server-side visit analytics built on a **raw access-log-style event store**.
|
||
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`, `Get`, `Msg`, `Client`,
|
||
`Favicon`), the `Store` (raw log + atomic JSON persistence) and
|
||
`Store.display()`, where **all** classification happens.
|
||
- `pagerite/pages.py` — records every served document as one raw GET line
|
||
(`_record_get`, in `pagerite/tracking.py`) with its true HTTP status.
|
||
- `pagerite/tracking.py` — the `/_ws` activity WebSocket, and
|
||
`WebSocket /_api/ws/analytics` (admin-gated like every `/_api` endpoint).
|
||
- `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 `/_a` analytics page.
|
||
- `frontend/src/analytics-main.js` — page entry that mounts `AnalyticsView`
|
||
into `#analytics-app` inside `#main`.
|
||
|
||
## Raw records
|
||
|
||
The store is deliberately close to an access log: two append-only lists plus
|
||
shared metadata. **Nothing is classified when recorded** — whether a client
|
||
turns out to be a reader, a crawler or a scanner is decided by
|
||
`Store.display()` from the raw events, so the stored data survives any future
|
||
change to the classification rules.
|
||
|
||
Each `Get` record (one per served document):
|
||
|
||
- `t` — timestamp of the request,
|
||
- `path` — full request path, query string included (e.g. `/.env?x=1`),
|
||
- `status` — the true HTTP status of the response (200, or 404 for a category
|
||
placeholder or a missing page),
|
||
- `ref` — external https origin of the `Referer`, `""` for direct/internal
|
||
(same-origin referers are dropped by the recorder),
|
||
- `pre` — true for idle-time link preloads from pagerite.js
|
||
(`x-pagerite-preload` header): never counted as a view, crawler hit or
|
||
abuse — recorded only so a navigation later served from the in-memory page
|
||
cache (which issues no GET at all) can be attributed this GET's status,
|
||
- `lang` — rendered content language of the served document (the resolved
|
||
language of a localized page), `""` for non-localized responses (404
|
||
probes, reserved paths),
|
||
- `client` — 6-byte blake3 hash referencing `Analytics.clients`.
|
||
|
||
304 revalidation responses return before recording and are not logged.
|
||
|
||
Each `Msg` record (one per pagerite.js activity message over `/_ws`):
|
||
|
||
- `t` — timestamp,
|
||
- `client` — 6-byte blake3 hash referencing `Analytics.clients`,
|
||
- `fr` — path of the page the activity happened on (`""` for the initial
|
||
load),
|
||
- `to` — navigation target (validated at record time: internal slug path or
|
||
external https URL; anything else is dropped — sanitation, not
|
||
classification),
|
||
- `read` — active seconds spent on `fr` since the previous report,
|
||
- `lang` — rendered language reported by the client for the page the
|
||
activity happened on (the page's `<html lang>`; `""` from old clients).
|
||
|
||
Each `Client` record (shared by every event, keyed by hash):
|
||
|
||
- `ip` — visitor IP address (first `X-Forwarded-For` hop, or direct peer),
|
||
- `host` — reverse-DNS host name for `ip` when resolvable, else `""`,
|
||
- `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,
|
||
- `city` — city name from the DB-IP MMDB lookup, when available,
|
||
- `ua` — raw `User-Agent` string,
|
||
- `hide` — true for admin clients (`hide` message field): everything this
|
||
client ever did is recorded but excluded from every statistic and from the
|
||
viewer payload. This is the one flag set at record time — it is a client
|
||
property, not a classification.
|
||
|
||
The viewer payload adds one display-time field to each client, never
|
||
persisted (stored records keep the default and old data always follows the
|
||
current uarite version):
|
||
|
||
- `uarite` — the `uarite.UA` dataclass from parsing the raw UA
|
||
(`pretty`/`engine`/`os`/`provider`/`kind`/`url`): the crawler name for
|
||
bots,
|
||
with a category suffix only where a provider runs crawlers of more than
|
||
one kind (`GPTBot (AI)` vs `OAI-SearchBot (search)`, `Googlebot (search)`
|
||
vs `Google-Extended (AI)`; single-kind providers stay plain: `Facebook`,
|
||
`WhatsApp`), `Browser/major OS` on the desktop, the device where that is
|
||
the relevant information (iPhone reports its iOS version, Android phones
|
||
their model instead of the OS), otherwise the raw string; `url` is the
|
||
crawler's info page when uarite knows one (rendered as a 🔗 link after the
|
||
pretty UA in the viewer), `kind` drives the bot classification.
|
||
|
||
A reverse-DNS lookup is attempted for each new client and the result, when
|
||
available, is 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 working directory, it is loaded at startup and used to look up
|
||
`country`/`city`. These lookups run in background tasks after the event is
|
||
stored, so WebSocket message handling is never delayed. Only the downloaded
|
||
`.mmdb.gz` is kept on disk (in the working directory, ignored by git); it is
|
||
decompressed into RAM when opened. The
|
||
CLI flag `--dbip` (`uv run pagerite --dbip`) downloads the latest
|
||
`dbip-city-lite-YYYY-MM.mmdb.gz` from 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.
|
||
|
||
## What the client sends
|
||
|
||
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), `lang` (the rendered language of the page the
|
||
activity happened on — its `<html lang>`, except the language-switch
|
||
navigation ping, which passes the picked tag explicitly because the view
|
||
transition applies the new `<html lang>` only after the ping goes out) 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 times are incremental, 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; 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, never `fr`
|
||
(an `fr` equal to `to` would log a bogus self-transition when a session
|
||
already exists, e.g. a second tab). Reloads are not
|
||
visits: the message is skipped (PerformanceNavigationTiming `reload`), so a
|
||
refresh neither counts a second view nor logs a self-transition.
|
||
- **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 message counts the view instead — no gap, no double count).
|
||
- **External links** (`https` only): `to` is 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 `/_a` does report.
|
||
- **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 the `hide` flag is set on the **client
|
||
record** — so it covers everything that client ever did, including the
|
||
time before the login. Hidden clients never appear in the viewer payload:
|
||
`Store.display()` drops their events and metadata, and computes every
|
||
aggregate (site visits, page views, transitions) from the visible visits
|
||
only, so nothing needs to be reversed or redacted. With no auth proxy
|
||
(dev/test) "admin" is everyone's state, so `hide` stays 0 and everything
|
||
is recorded.
|
||
- **External-site favicons**: for every external https origin seen as a GET
|
||
referer or an exit link, the server fetches `{origin}/favicon.ico` in 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 in `Analytics.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 carries `favicons` (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).
|
||
|
||
## Display-time classification
|
||
|
||
`Store.display(in_menu)` derives the viewer payload from the raw events on
|
||
every (debounced) broadcast — O(n log n) over the log, cheap enough for a
|
||
small CMS. `in_menu(path)` resolves a path against the current menu (passed
|
||
in from `tracking.py`, which owns the content database import) so 404
|
||
responses for real menu nodes — category placeholders — are not mistaken
|
||
for misses.
|
||
|
||
- **Visits and sessions**: a client's messages are grouped into visits
|
||
chronologically; a new visit starts after 30 minutes of inactivity
|
||
(`_SESSION_GAP`). A fresh page load with an already-open visit (second
|
||
tab) extends it, logging a `(direct)` transition. The visit's trail holds
|
||
first-seen targets in order; `read` updates accumulate active seconds on
|
||
the trail item matching `fr` (preferring the item whose language matches
|
||
the report, so seconds after a language switch land on the new-language
|
||
step). Each trail item's HTTP status comes from
|
||
the client's latest GET for that path — preloads included, which is what
|
||
allows 404 pages to render red in the viewer even when the navigation
|
||
itself was served from the page cache. Each trail item also carries the
|
||
rendered language: the client's report, for the entry page falling back
|
||
to its GET's rendered language (old clients don't send one); a page
|
||
re-visited in a different language becomes a distinct trail step instead
|
||
of merging into the existing item. The entry page's referer and
|
||
`utm_*` tags come from the GET that loaded it (within 10 s before the
|
||
first message).
|
||
- **Crawler hits**: a document GET no activity message matched within
|
||
`_CRAWLER_TIMEOUT` (10 s) is a crawler hit — plain bots that only fetch
|
||
documents never register as visits. JS-running crawlers (Googlebot,
|
||
GoogleOther, Applebot, ...) do connect and send messages, but their UA
|
||
gives them away (`_is_bot_ua`, backed by `uarite.uaparse` — which
|
||
also knows the disguised ones: facebookexternalhit, Google-Extended,
|
||
WhatsApp, ...): their messages are ignored at display
|
||
time, so their GETs never match and land in the crawler list too. Real-
|
||
browser bots whose UA does not match are caught by engagement: a visit
|
||
whose total reported reading time is under 5 seconds (`_MIN_VISIT_READ`;
|
||
durations are client-provided and trusted — such bots report 0–2 s) is
|
||
reclassified as crawler hits, one per internal trail page, and counts in
|
||
no visit aggregate. 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. In the viewer, crawler hits are
|
||
grouped by client hash and shown as a trail of pages, preceded by the
|
||
referer when there is one (rendered with its favicon like visit
|
||
referers). The crawler table lists the most recent crawler first, with
|
||
the most active as a tie-breaker.
|
||
- **Abuse (scanner) hits**: a 404 on a telltale path — an empty URL segment
|
||
(`//foo` — no real client generates those), any segment starting with a
|
||
dot (`/.env`, `/.git/config`) or ending in `.php` — classifies the source
|
||
IP as abuse, and ten plain 404s within one hour (`_ABUSE_404_WINDOW`) on
|
||
paths that don't resolve to a menu node do too. Two exemptions keep
|
||
legitimate traffic out: RFC 8615 well-known URIs (`/.well-known/…` —
|
||
browsers and services probe them, e.g. Chrome's devtools fetch of
|
||
`appspecific/com.chrome.devtools.json`) are never telltale and never
|
||
count toward the threshold, and category placeholders return 404 but are
|
||
real menu nodes, so they never count either. The window keeps a
|
||
long-time reader's slowly accumulating misses from ever crossing the
|
||
threshold — scanners spray in bursts. Hidden (admin) clients never
|
||
trigger classification: editing means visiting not-found pages, since
|
||
that is where the create pen lives. Once an IP is classified, **all** its document GETs are shown in the abuse list —
|
||
including any that arrived before classification, since the raw log keeps
|
||
everything — and its activity messages are ignored. In the viewer, abuse
|
||
hits are grouped by IP (never by client/UA — scanners randomize theirs)
|
||
in a separate "Abuse" table, split by the recorded status: the 404 probes
|
||
("paths abused" — flagged paths that triggered classification first, then
|
||
other 404s, shown verbatim with query strings) versus the real articles
|
||
the abuser actually read ("articles read" — the 200 document GETs,
|
||
rendered as trail links like the visitor and crawler tables, query string
|
||
stripped). Raw User-Agent strings are shown one per line with their
|
||
occurrence counts, and the full lists are click-to-copy.
|
||
|
||
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.
|
||
|
||
## Derived shapes (the viewer payload)
|
||
|
||
The `Display` payload contains the derived `visits`, `crawlers` and `abuse`
|
||
rows (structs `Visit`/`Nav`/`TrailItem`, `CrawlerHit`, `AbuseHit` — display
|
||
DTOs only, never persisted), the visible `clients`, the fetched `favicons`,
|
||
the site language context (`multilingual` — translation languages are
|
||
configured, so the viewer can suppress language UI on single-language
|
||
sites — and `primary_lang` — the front page's primary language, so the
|
||
viewer can skip the primary-language default case),
|
||
and the aggregates below.
|
||
|
||
Each derived `Visit`:
|
||
|
||
- `start` — timestamp of the first activity,
|
||
- `entry` — first page (path) seen,
|
||
- `referer` — external https origin of the entry GET, `""` for direct,
|
||
- `client` — 6-byte blake3 hash referencing `Analytics.clients`,
|
||
- `trail` — the entry page and everything seen afterwards, keyed by the
|
||
timestamp of first sight (insertion order = first-seen order). Each item
|
||
holds `to` (page path or external exit URL), the accumulated active
|
||
reading time in seconds (`read`), the most recent HTTP status seen
|
||
for the target (`status`) and the rendered language (`lang`; a page
|
||
seen in two languages within one visit gets one item per language),
|
||
- `navs` — every navigation (`fr`, `to`), keyed by its timestamp, repeats
|
||
included. The aggregates are computed from this log,
|
||
- `utm` — `utm_*` query parameters from the landing URL, as a dict.
|
||
|
||
Each derived `CrawlerHit`:
|
||
|
||
- `start` — timestamp of the document GET,
|
||
- `entry` — page path requested,
|
||
- `client` — 6-byte blake3 hash referencing `Analytics.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),
|
||
- `lang` — rendered content language of the served document (from the GET).
|
||
|
||
Each derived `AbuseHit`:
|
||
|
||
- `start` — timestamp of the request,
|
||
- `path` — full request path including the query string,
|
||
- `client` — 6-byte blake3 hash referencing `Analytics.clients`,
|
||
- `flag` — true for the paths that triggered abuse classification (telltale
|
||
paths, or the 404 that crossed the threshold),
|
||
- `is_404` — true for 404 responses, false for real (200) document GETs.
|
||
|
||
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 requests (same path and status class) are collapsed
|
||
with their counts — a path's 404 probes and its later 200 reads never
|
||
merge. Within each list paths are sorted by count descending, then by their
|
||
earliest hit.
|
||
|
||
## Aggregates
|
||
|
||
Aggregates are **not stored**; they are computed at display time by
|
||
`Store.display()` from the derived visits (entry + `navs` log), skipping
|
||
hidden clients and short visits reclassified as crawler hits. 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 dict
|
||
`from -> to -> bucket -> count` with 5-minute bucketing. `from` is 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 -> count` of new visits started, same sparse
|
||
5-minute bucketing.
|
||
|
||
Sparseness keeps quiet sites small; dropping old data is a matter of deleting
|
||
list entries (`gets`/`msgs` are plain append-only lists).
|
||
|
||
## 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.
|
||
A file written by the pre-redesign schema (stored `visits`/`crawlers`/`abuse`
|
||
lists) is not convertible; it is renamed to `analytics.json.bak-legacy` and
|
||
recording starts fresh.
|
||
|
||
## 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 (the current week keeps the
|
||
accent color and is truncated at the current bucket, never drawing fake
|
||
zeroes for the future). Both the week and day views overlay a **"typical"
|
||
history curve** in the muted color (`analytics/seasonal.js`, a port of
|
||
`seasonal.py`): the whole recorded history is densified to 5-minute bins,
|
||
smoothed with the same Gaussian as the week view, then folded onto a weekly
|
||
grid with exponential decay over age — a 7-day half-life for the average
|
||
time-of-day pattern and a 42-day half-life for per-weekday deviations from
|
||
it, the deviation shrunk by the effective number of weeks behind each bin
|
||
(`n_eff / (n_eff + 3)`) so the estimate falls back to the common daily
|
||
pattern when history is short. History is capped at the most recent 180
|
||
days, beyond which even the slow kernel's weight is negligible (~5%). The
|
||
week view draws the full Monday-first
|
||
estimate as "Typical week" (future included); the day view cuts the rolling
|
||
24-hour window's bins from the same estimate and labels them by the weekday
|
||
("Typical Saturday"). A compact legend inside the top right of the visits
|
||
chart marks the current data in accent (ISO week label, or a bar specimen
|
||
for "Last 24 hours") and the typical curve on a muted specimen. The week
|
||
view's 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).
|