Collect analytics over a /_ws WebSocket instead of POST /_a pings
This commit is contained in:
+46
-29
@@ -9,9 +9,9 @@ directory, e.g. `localhost/analytics.json`).
|
|||||||
`CrawlerHit`, `AbuseHit`, `Favicon`) and the `Store` (in-memory data + session map,
|
`CrawlerHit`, `AbuseHit`, `Favicon`) and the `Store` (in-memory data + session map,
|
||||||
atomic JSON persistence).
|
atomic JSON persistence).
|
||||||
- `pagerite/app.py` — entry-referer stashing in `show_page` (`_track_entry`),
|
- `pagerite/app.py` — entry-referer stashing in `show_page` (`_track_entry`),
|
||||||
the `POST /_a` ping endpoint, and `WebSocket /_api/ws/analytics`
|
the `/_ws` activity WebSocket, and `WebSocket /_api/ws/analytics`
|
||||||
(admin-gated like every `/_api` endpoint).
|
(admin-gated like every `/_api` endpoint).
|
||||||
- `frontend/src/pagerite.js` — client navigation pings and the 📊 pen.
|
- `frontend/src/pagerite.js` — the client activity channel and the 📊 pen.
|
||||||
- `frontend/src/AnalyticsView.vue` — viewer component rendered inside the
|
- `frontend/src/AnalyticsView.vue` — viewer component rendered inside the
|
||||||
normal site layout on the `/_a` analytics page.
|
normal site layout on the `/_a` analytics page.
|
||||||
- `frontend/src/analytics-main.js` — page entry that mounts `AnalyticsView`
|
- `frontend/src/analytics-main.js` — page entry that mounts `AnalyticsView`
|
||||||
@@ -19,46 +19,61 @@ directory, e.g. `localhost/analytics.json`).
|
|||||||
|
|
||||||
## What is collected
|
## What is collected
|
||||||
|
|
||||||
The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with
|
The client (`pagerite.js`) keeps a WebSocket connection to `/_ws` for the
|
||||||
`fr`, `to`, `hide` and `read` as query parameters (`fr` = source path;
|
whole browsing session and sends activity messages over it — JSON text
|
||||||
falsy values are omitted):
|
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, never `fr`
|
- **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
|
(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
|
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
|
the visit and counts the entry page view — the document GET alone records
|
||||||
nothing, so bots never register (admin browsing does register, but
|
nothing, so bots never register (admin browsing does register, but
|
||||||
flagged `hide`; see **Admins** below). JS-running crawlers
|
flagged `hide`; see **Admins** below). JS-running crawlers
|
||||||
(Googlebot, GoogleOther, Applebot, ...) do ping, but their User-Agent
|
(Googlebot, GoogleOther, Applebot, ...) do connect and report, but their
|
||||||
gives them away: pings whose UA matches `_is_bot_ua` (anything calling
|
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
|
itself a "bot", plus known exceptions such as GoogleOther) are ignored
|
||||||
server-side, and their document GETs land in the crawler list instead.
|
server-side, and their document GETs land in the crawler list instead.
|
||||||
No source-IP verification is done: a spoofed bot UA merely lands in the
|
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
|
crawler stats, and scanners that probe telltale paths are caught by the
|
||||||
abuse rules regardless. Reloads are not
|
abuse rules regardless. Reloads are not
|
||||||
visits: the ping is skipped (PerformanceNavigationTiming `reload`), so a
|
visits: the message 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 —
|
handler stashes a cross-origin https `Referer` (origin part only —
|
||||||
unavailable to JS once the page has loaded) and any
|
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 first
|
||||||
|
message 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
|
||||||
the swap actually happened (a failed swap falls back to a full load,
|
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).
|
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
|
- **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,
|
exit-link record; the user may continue navigating afterwards (new tab,
|
||||||
back), so the exit URL is not necessarily the last trail entry. Outbound
|
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
|
links are stored by full URL so several links to the same domain remain
|
||||||
distinct.
|
distinct.
|
||||||
- **Excluded**: back/forward (popstate) navigations, navigating *to* the
|
- **Excluded**: back/forward (popstate) navigations, navigating *to* the
|
||||||
analytics page (`/_a` — its GET is untracked, and the server rejects it
|
analytics page (`/_a` — its GET is untracked, and the server cannot
|
||||||
as a ping target anyway), and everything while the user has the editor
|
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
|
open (`body.editing`). Admin noise, not visits. Navigating *away* from
|
||||||
`/_a` does ping: the fetch-navigation already GET-ed the target page
|
`/_a` does report: the fetch-navigation already GET-ed the target page
|
||||||
without the preload header, and without the ping that GET would flush to
|
without the preload header, and without the message that GET would flush to
|
||||||
the crawler list.
|
the crawler list.
|
||||||
- **Admins**: when SSO is in use and the session is known to be an admin,
|
- **Admins**: when SSO is in use and the session is known to be an admin,
|
||||||
the client still pings but adds `hide=1`. The activity is recorded as
|
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
|
usual (navigations and all), but the `hide` flag is set on the **client
|
||||||
record** — so it covers everything that client ever did: visits and
|
record** — so it covers everything that client ever did: visits and
|
||||||
crawler hits from before the login included. Hidden clients never appear
|
crawler hits from before the login included. Hidden clients never appear
|
||||||
@@ -81,7 +96,7 @@ falsy values are omitted):
|
|||||||
extension matching the actual MIME). The origin → file name mapping is
|
extension matching the actual MIME). The origin → file name mapping is
|
||||||
recorded in `Analytics.favicons` (`Favicon.file`/`fetched`); misses are
|
recorded in `Analytics.favicons` (`Favicon.file`/`fetched`); misses are
|
||||||
recorded too and retried only after 7 days. Fetches are scheduled after
|
recorded too and retried only after 7 days. Fetches are scheduled after
|
||||||
each ping and once at startup, which backfills icons for already-recorded
|
each activity message and once at startup, which backfills icons for already-recorded
|
||||||
data. The viewer payload carries `favicons` (origin → `/_f/...` path),
|
data. The viewer payload carries `favicons` (origin → `/_f/...` path),
|
||||||
and the viewer shows the icon wherever an external site is mentioned:
|
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
|
referer/exit trail links in the visit table and the source/exit pills of
|
||||||
@@ -100,8 +115,8 @@ falsy values are omitted):
|
|||||||
`host`; local/reserved/multicast addresses are skipped. If a DB-IP MMDB
|
`host`; local/reserved/multicast addresses are skipped. If a DB-IP MMDB
|
||||||
file (`dbip-*.mmdb` or `dbip-*.mmdb.gz`) is present in the repository
|
file (`dbip-*.mmdb` or `dbip-*.mmdb.gz`) is present in the repository
|
||||||
root, it is loaded at startup and used to look up `country`/`city`. These
|
root, it is loaded at startup and used to look up `country`/`city`. These
|
||||||
lookups run in background tasks after the event is stored, so the `/_a`
|
lookups run in background tasks after the event is stored, so WebSocket
|
||||||
response is never delayed. The decompressed `dbip-*.mmdb` file is kept in
|
message handling is never delayed. The decompressed `dbip-*.mmdb` file is kept in
|
||||||
the repository root and ignored by git. The CLI flag `--dbip`
|
the repository root and ignored by git. The CLI flag `--dbip`
|
||||||
(`uv run pagerite --dbip`) downloads the latest
|
(`uv run pagerite --dbip`) downloads the latest
|
||||||
`dbip-city-lite-YYYY-MM.mmdb.gz` from DB-IP before the server starts,
|
`dbip-city-lite-YYYY-MM.mmdb.gz` from DB-IP before the server starts,
|
||||||
@@ -110,10 +125,11 @@ falsy values are omitted):
|
|||||||
file is used.
|
file is used.
|
||||||
- **Crawler hits**: every document GET is queued in RAM as a pending crawler
|
- **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
|
hit — except idle-time link preloads from pagerite.js, which carry an
|
||||||
`x-pagerite-preload` header and are not tracked at all (the ping sent when
|
`x-pagerite-preload` header and are not tracked at all (the navigation
|
||||||
the user actually navigates to a preloaded page does the counting; forging
|
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
|
the header only hides a GET from the crawler stats, the path-based abuse
|
||||||
classification is unaffected). If a ping
|
classification is unaffected). If a message
|
||||||
from the same client arrives within 10 seconds the hit is discarded;
|
from the same client arrives within 10 seconds the hit is discarded;
|
||||||
otherwise it is written to `crawlers` — unless the client is hidden
|
otherwise it is written to `crawlers` — unless the client is hidden
|
||||||
(admin), in which case the hit is discarded on expiry too. Crawlers do not count as
|
(admin), in which case the hit is discarded on expiry too. Crawlers do not count as
|
||||||
@@ -131,7 +147,7 @@ falsy values are omitted):
|
|||||||
random-UA scanner no longer pollutes the crawler stats of the legitimate
|
random-UA scanner no longer pollutes the crawler stats of the legitimate
|
||||||
bot it impersonates. Once classified, every document GET and 404 from the
|
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
|
IP is recorded as an abuse hit with the full request path (query string
|
||||||
included), and its pings are ignored. The classified IP set (`abuse_ips`)
|
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
|
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
|
viewer, abuse hits are grouped by IP (never by client/UA — scanners
|
||||||
randomize theirs) in a separate "Abuse" table. Identical paths are
|
randomize theirs) in a separate "Abuse" table. Identical paths are
|
||||||
@@ -145,9 +161,9 @@ falsy values are omitted):
|
|||||||
There are no cookies. A visit is tied together by a client hash — the first
|
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
|
6 bytes of a blake3 digest over the prettified IP (IPv4 unchanged, IPv6
|
||||||
/64 network), the raw `User-Agent` string and the extracted
|
/64 network), the raw `User-Agent` string and the extracted
|
||||||
`Accept-Language` tag. The first ping from a client hash starts a new
|
`Accept-Language` tag. The first message from a client hash starts a new
|
||||||
visit; subsequent pings extend it. Pings arriving with no known session
|
visit; subsequent messages extend it. Messages arriving with no known session
|
||||||
(server restart) start a fresh visit from the first ping — treated as
|
(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 →
|
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
|
entry-referer/UTM tables are in-memory only; client metadata is stored in
|
||||||
`Analytics.clients` keyed by the client hash.
|
`Analytics.clients` keyed by the client hash.
|
||||||
@@ -164,7 +180,7 @@ Each `Client` record:
|
|||||||
- `ua` — raw `User-Agent` string,
|
- `ua` — raw `User-Agent` string,
|
||||||
- `ua_pretty` — compact display form of the UA (browser/OS/device) when
|
- `ua_pretty` — compact display form of the UA (browser/OS/device) when
|
||||||
parsable, otherwise the raw string,
|
parsable, otherwise the raw string,
|
||||||
- `hide` — true for admin clients (`hide=1` ping): all their visits,
|
- `hide` — true for admin clients (`hide` message field): all their visits,
|
||||||
crawler hits and abuse hits are recorded but excluded from every
|
crawler hits and abuse hits are recorded but excluded from every
|
||||||
statistic and from the viewer payload.
|
statistic and from the viewer payload.
|
||||||
|
|
||||||
@@ -180,7 +196,7 @@ Each `Visit` record:
|
|||||||
reading time in seconds (`read`) and the most recent HTTP status seen
|
reading time in seconds (`read`) and the most recent HTTP status seen
|
||||||
for the target (`status`). Re-visiting an already seen target updates
|
for the target (`status`). Re-visiting an already seen target updates
|
||||||
its item instead of appending.
|
its item instead of appending.
|
||||||
- `navs` — every navigation ping (`fr`, `to`), keyed by its timestamp,
|
- `navs` — every navigation message (`fr`, `to`), keyed by its timestamp,
|
||||||
repeats included. The aggregates are computed from this log at display
|
repeats included. The aggregates are computed from this log at display
|
||||||
time.
|
time.
|
||||||
- `utm` — `utm_*` query parameters from the landing URL, as a dict.
|
- `utm` — `utm_*` query parameters from the landing URL, as a dict.
|
||||||
@@ -227,7 +243,8 @@ shapes, part of the WebSocket payload (`Display` struct alongside `visits`,
|
|||||||
|
|
||||||
- `transitions`: time series of page transitions, sparse nested dict
|
- `transitions`: time series of page transitions, sparse nested dict
|
||||||
`from -> to -> bucket -> count` with 5-minute bucketing. `from` is the
|
`from -> to -> bucket -> count` with 5-minute bucketing. `from` is the
|
||||||
referer origin or `"(direct)"` for initial loads, a page path for pings.
|
referer origin or `"(direct)"` for initial loads, a page path for
|
||||||
|
navigations.
|
||||||
- `views`: time series of page loads, `path -> bucket -> count`, sparse: only
|
- `views`: time series of page loads, `path -> bucket -> count`, sparse: only
|
||||||
non-zero 5-minute buckets exist (bucket key is its floored ISO timestamp).
|
non-zero 5-minute buckets exist (bucket key is its floored ISO timestamp).
|
||||||
Every load counts, including repeats within a visit; external exit origins
|
Every load counts, including repeats within a visit; external exit origins
|
||||||
|
|||||||
+111
-54
@@ -347,8 +347,8 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
for (const url of urls) {
|
for (const url of urls) {
|
||||||
if (pageCache.has(url)) continue;
|
if (pageCache.has(url)) continue;
|
||||||
// x-pagerite-preload: idle cache warm-up, not a page view — the
|
// x-pagerite-preload: idle cache warm-up, not a page view — the
|
||||||
// server excludes these GETs from analytics (the ping sent on actual
|
// server excludes these GETs from analytics (the navigation message
|
||||||
// navigation does the counting).
|
// sent on actual navigation does the counting).
|
||||||
fetch(url, { headers: { "x-pagerite-preload": "1" } })
|
fetch(url, { headers: { "x-pagerite-preload": "1" } })
|
||||||
.then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html")
|
.then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html")
|
||||||
? r.text() : ""))
|
? r.text() : ""))
|
||||||
@@ -432,61 +432,111 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
});
|
});
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
// --- Analytics pings ---------------------------------------------------
|
// --- Analytics over WebSocket ------------------------------------------
|
||||||
// Fire-and-forget POSTs to /_a with the fields as query parameters (a
|
// One /_ws connection follows the whole browsing session: the initial page
|
||||||
// beacon can carry no body, and query args show in server logs next to
|
// load (starts the visit — the server counts nothing from the document GET
|
||||||
// the document GET they refer to): on the initial page load (starts the
|
// alone), internal fetch-navigations, external https exits, and frequent
|
||||||
// visit — the server counts nothing from the document GET alone), for
|
// active reading-time updates. Messages are JSON text frames matching the
|
||||||
// internal fetch-navigations, for external https exits, and on window
|
// server's msgspec Ping struct: {fr?, to?, read?, hide?} — falsy fields
|
||||||
// close. ``read`` is the active time (ms) spent on ``fr``.
|
// are omitted. ``read`` is the active time (ms) accumulated on ``fr`` since
|
||||||
// Reading time pauses after 1 minute of inactivity and resumes on the
|
// the last report; reading time pauses after 1 minute of inactivity and
|
||||||
// next mouse/touch/scroll/keyboard event.
|
// resumes on the next mouse/touch/scroll/keyboard event. While the user is
|
||||||
// Excluded: back/forward (popstate never pings), everything while the
|
// active, accumulated reading time is flushed every few seconds, so a
|
||||||
|
// disconnect simply leaves the last reported time on the server — no close
|
||||||
|
// beacon is needed. After 5 minutes without any activity the client closes
|
||||||
|
// the channel itself (a sleeping tab would lose it anyway); the next
|
||||||
|
// activity reconnects and the server sees a new session.
|
||||||
|
// Excluded: back/forward (popstate never reports), everything while the
|
||||||
// editor is open (body.editing — admin noise, not visits), and
|
// editor is open (body.editing — admin noise, not visits), and
|
||||||
// navigations TO the analytics page (/_a — admin machinery, and the
|
// navigations TO the analytics page (/_a — admin machinery). Navigations
|
||||||
// server rejects it as a ping target anyway). Navigations AWAY from /_a
|
// AWAY from /_a must report: load() already fetched the target page
|
||||||
// must ping: load() already fetched the target page without the preload
|
// without the preload header, and without the message that GET would flush
|
||||||
// header, and without the ping that GET would flush to the crawler list.
|
// to the crawler list.
|
||||||
// Admins (when SSO is actually in use — with no auth proxy "admin" is
|
// Admins (when SSO is actually in use — with no auth proxy "admin" is
|
||||||
// everyone's state) ping normally but with hide=1: the server then
|
// everyone's state) report normally but with hide: the server then flags
|
||||||
// records nothing and scrubs any session the same browser accumulated
|
// the client record, scrubbing everything it ever did from the statistics,
|
||||||
// before logging in, so admins never show up as visits or crawlers.
|
// so admins never show up as visits or crawlers.
|
||||||
// See docs/analytics.md.
|
// See docs/analytics.md.
|
||||||
|
|
||||||
// fetch wrapper: every key of ``params`` becomes a query arg on /_a
|
// The activity WebSocket. Messages sent before the connection opens are
|
||||||
// (falsy values are omitted). Admins get hide=1. ``beacon`` uses
|
// queued (the queue keeps the interim activity). Reconnects are driven by
|
||||||
// sendBeacon when available, for unload-time pings.
|
// user activity only — never by timers while the page sits idle — with an
|
||||||
function pingFetch(params, { beacon = false } = {}) {
|
// exponential falloff between attempts so a failing endpoint cannot make
|
||||||
const query = new URLSearchParams();
|
// us hammer the server (or trip its security limits). After a longer
|
||||||
if (ssoAvailable && isAdmin) params = { ...params, hide: 1 };
|
// stretch without any activity we close the socket proactively: the user
|
||||||
for (const [key, value] of Object.entries(params)) {
|
// has moved on and left the tab open (a sleeping browser tab would lose
|
||||||
if (value) query.set(key, value);
|
// the connection anyway), so the next activity reconnects and registers
|
||||||
}
|
// as a fresh session. Analytics must never break navigation: every send
|
||||||
const url = `/_a?${query}`;
|
// is wrapped, and a server without the endpoint just leaves the socket
|
||||||
|
// failing in the background.
|
||||||
|
let ws = null;
|
||||||
|
const wsQueue = [];
|
||||||
|
let wsReconnectMs = 1000;
|
||||||
|
let wsNotBefore = 0;
|
||||||
|
|
||||||
|
function activityWs() {
|
||||||
|
if (ws || Date.now() < wsNotBefore) return;
|
||||||
|
const url = new URL("/_ws", location.href);
|
||||||
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||||
try {
|
try {
|
||||||
if (beacon && navigator.sendBeacon) {
|
ws = new WebSocket(url);
|
||||||
navigator.sendBeacon(url);
|
} catch {
|
||||||
} else {
|
return;
|
||||||
fetch(url, { method: "POST", keepalive: true });
|
}
|
||||||
}
|
ws.onopen = () => {
|
||||||
} catch { /* analytics must never break navigation */ }
|
wsReconnectMs = 1000;
|
||||||
|
for (const msg of wsQueue.splice(0)) ws.send(JSON.stringify(msg));
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
ws = null;
|
||||||
|
// No timer here: the next user activity retries, after the backoff.
|
||||||
|
wsNotBefore = Date.now() + wsReconnectMs;
|
||||||
|
wsReconnectMs = Math.min(wsReconnectMs * 2, 30_000);
|
||||||
|
};
|
||||||
|
ws.onerror = () => ws.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function ping({ to, fr = currentPath, read = 0, beacon = false } = {}) {
|
function report(msg) {
|
||||||
if (document.body.classList.contains("editing")) return;
|
if (document.body.classList.contains("editing")) return;
|
||||||
if (to === "/_a") return;
|
if (msg.to === "/_a") return;
|
||||||
pingFetch({ fr, to, read: Math.round(read / 1000) }, { beacon });
|
if (ssoAvailable && isAdmin) msg.hide = true;
|
||||||
|
activityWs();
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
return;
|
||||||
|
} catch { /* fall through to queueing */ }
|
||||||
|
}
|
||||||
|
wsQueue.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ping({ to, fr = currentPath, read = 0 } = {}) {
|
||||||
|
// Reading-time updates from the analytics page itself are not tracked
|
||||||
|
// (/_a is admin machinery; the server would reject the path anyway).
|
||||||
|
if (!to && currentPath === "/_a") return;
|
||||||
|
const msg = {};
|
||||||
|
if (fr) msg.fr = fr;
|
||||||
|
if (to) msg.to = to;
|
||||||
|
const secs = Math.round(read / 1000);
|
||||||
|
if (secs > 0) msg.read = secs;
|
||||||
|
if (!msg.to && !msg.read) return;
|
||||||
|
report(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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. Every READ_FLUSH_MS of accumulated activity is reported. After
|
||||||
|
// IDLE_MS with no activity at all, the remaining read time is flushed and
|
||||||
|
// the WebSocket is closed: the user has moved on, and the next activity
|
||||||
|
// reconnects as a new session.
|
||||||
const INACTIVE_MS = 60_000;
|
const INACTIVE_MS = 60_000;
|
||||||
|
const READ_FLUSH_MS = 5_000;
|
||||||
|
const IDLE_MS = 5 * 60_000;
|
||||||
let readStart = performance.now();
|
let readStart = performance.now();
|
||||||
let readElapsed = 0;
|
let readElapsed = 0;
|
||||||
let reading = true;
|
let reading = true;
|
||||||
let readInactivityTimer = null;
|
let readInactivityTimer = null;
|
||||||
let closePingedFor = null;
|
let idleTimer = null;
|
||||||
|
|
||||||
function markReadActivity() {
|
function markReadActivity() {
|
||||||
if (!reading) {
|
if (!reading) {
|
||||||
@@ -500,6 +550,24 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
reading = false;
|
reading = false;
|
||||||
}
|
}
|
||||||
}, INACTIVE_MS);
|
}, INACTIVE_MS);
|
||||||
|
// Any activity is a sign of life: (re)connect the channel if it was
|
||||||
|
// dropped or idle-closed (not while editing — admin noise), and push
|
||||||
|
// the idle disconnect forward.
|
||||||
|
if (!document.body.classList.contains("editing")) activityWs();
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
idleTimer = setTimeout(() => {
|
||||||
|
if (ws) {
|
||||||
|
// Flush what is left unsent, then hang up. report() would try to
|
||||||
|
// reconnect a dead socket, which is exactly what we avoid here.
|
||||||
|
const left = takeReadTime();
|
||||||
|
if (Math.round(left / 1000) > 0) ping({ read: left });
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
}, IDLE_MS);
|
||||||
|
// Frequently update the article read time on the server.
|
||||||
|
if (readElapsed + performance.now() - readStart >= READ_FLUSH_MS) {
|
||||||
|
ping({ read: takeReadTime() });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function takeReadTime() {
|
function takeReadTime() {
|
||||||
@@ -519,28 +587,19 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
clearTimeout(readInactivityTimer);
|
clearTimeout(readInactivityTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendClosePing() {
|
|
||||||
if (closePingedFor === currentPath) return;
|
|
||||||
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"]) {
|
||||||
addEventListener(ev, markReadActivity, { passive: true });
|
addEventListener(ev, markReadActivity, { passive: true });
|
||||||
}
|
}
|
||||||
addEventListener("pagehide", sendClosePing);
|
|
||||||
|
|
||||||
// The initial page load pings too — it is what starts the visit and
|
// The initial page load reports 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 carries only ``to``: the server attributes the entry to the referer
|
||||||
// it saw on the document GET (unavailable to JS once loaded), and an
|
// 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
|
// ``fr`` equal to ``to`` would log a bogus self-transition when a
|
||||||
// session already exists (e.g. a second tab).
|
// 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 report again. Reloads are not visits:
|
||||||
// pinging them would double-count the view and log a self-transition.
|
// reporting them would double-count the view and log a self-transition.
|
||||||
let entryPinged = false;
|
let entryPinged = false;
|
||||||
function pingEntryOnce() {
|
function pingEntryOnce() {
|
||||||
if (entryPinged) return;
|
if (entryPinged) return;
|
||||||
@@ -770,7 +829,6 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
// External link: the browser navigates; record the full https URL so
|
// External link: the browser navigates; record the full https URL so
|
||||||
// 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;
|
|
||||||
ping({ to: url.href, read: takeReadTime() });
|
ping({ to: url.href, read: takeReadTime() });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -794,7 +852,6 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
const from = currentPath;
|
const from = currentPath;
|
||||||
load(url).then((ok) => {
|
load(url).then((ok) => {
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
closePingedFor = null;
|
|
||||||
ping({ to: url.pathname, fr: from, read: takeReadTime() });
|
ping({ to: url.pathname, fr: from, read: takeReadTime() });
|
||||||
resetReadTime();
|
resetReadTime();
|
||||||
});
|
});
|
||||||
|
|||||||
+53
-26
@@ -1,18 +1,23 @@
|
|||||||
"""Server-side visit analytics (collection only; see docs/analytics.md).
|
"""Server-side visit analytics (collection only; see docs/analytics.md).
|
||||||
|
|
||||||
Events come from navigation pings POSTed to /_a by pagerite.js: the first
|
Events come from pagerite.js over the /_ws WebSocket (``Ping`` messages as
|
||||||
ping on page load starts a visit, later pings extend it, and pings with no
|
JSON text frames): the first navigation message on page load starts a
|
||||||
known session start a fresh one (missing data, not dropped). The document
|
visit, later messages extend it, and messages with no known session start a
|
||||||
|
fresh one (missing data, not dropped). Active reading time is reported as
|
||||||
|
frequent ``read`` updates while the user is active; the times are
|
||||||
|
cumulative per trail item, so a disconnect simply leaves the last logged
|
||||||
|
time in place. The document
|
||||||
GET handler stashes the entry referer (external https origin) and any
|
GET handler stashes the entry referer (external https origin) and any
|
||||||
utm_* query parameters in in-memory IP tables, consumed when the ping
|
utm_* query parameters in in-memory IP tables, consumed when the first
|
||||||
starts the visit; nothing is counted without a ping (plain bots that only
|
message starts the visit; nothing is counted without a message (plain bots
|
||||||
fetch documents end up in the crawler list). JS-running crawlers
|
that only fetch documents end up in the crawler list). JS-running crawlers
|
||||||
(Googlebot, GoogleOther, Applebot, ...) do ping, but their UA gives them
|
(Googlebot, GoogleOther, Applebot, ...) do connect and send messages, but
|
||||||
away (``_is_bot_ua``) and their pings are ignored, so they land in the
|
their UA gives them
|
||||||
|
away (``_is_bot_ua``) and their messages are ignored, so they land in the
|
||||||
crawler list too. Idle-time link preloads from pagerite.js carry an
|
crawler list too. Idle-time link preloads from pagerite.js carry an
|
||||||
``x-pagerite-preload`` header and are not tracked at all — the ping sent
|
``x-pagerite-preload`` header and are not tracked at all — the navigation
|
||||||
when the user actually navigates does the counting.
|
message sent when the user actually navigates does the counting.
|
||||||
Admin clients ping with ``hide=1``: the client record is flagged ``hide``,
|
Admin clients send ``hide``: the client record is flagged ``hide``,
|
||||||
which covers everything that client ever did — visits and crawler hits
|
which covers everything that client ever did — visits and crawler hits
|
||||||
from before the login included. Aggregates (site visits, page views,
|
from before the login included. Aggregates (site visits, page views,
|
||||||
transitions) are not stored; they are computed at display time from the
|
transitions) are not stored; they are computed at display time from the
|
||||||
@@ -70,6 +75,27 @@ def _compact_user_agent(ua: str) -> str:
|
|||||||
return " ".join(p for p in parts if p).strip()
|
return " ".join(p for p in parts if p).strip()
|
||||||
|
|
||||||
|
|
||||||
|
class Ping(msgspec.Struct, omit_defaults=True):
|
||||||
|
"""One client message on the /_ws activity WebSocket.
|
||||||
|
|
||||||
|
Sent as a JSON text frame (msgspec-encoded, decoded to str for the
|
||||||
|
wire). ``to`` set: a navigation — internal page path or external https
|
||||||
|
exit URL. ``read`` alone (with ``fr``): an active reading-time update
|
||||||
|
for the page ``fr``; these arrive frequently while the user is active
|
||||||
|
and accumulate on the trail item. ``hide`` flags the client as an
|
||||||
|
admin: everything it ever did is excluded from the statistics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Path of the page the activity happened on ("" for the initial load).
|
||||||
|
fr: str = ""
|
||||||
|
#: Navigation target: internal path or external https exit URL.
|
||||||
|
to: str = ""
|
||||||
|
#: Active reading time (seconds) spent on ``fr`` since the last report.
|
||||||
|
read: int = 0
|
||||||
|
#: Admin client: record but hide everything from the statistics.
|
||||||
|
hide: bool = False
|
||||||
|
|
||||||
|
|
||||||
class Client(msgspec.Struct, omit_defaults=True):
|
class Client(msgspec.Struct, omit_defaults=True):
|
||||||
"""Client metadata shared by visits, crawler hits and abuse hits.
|
"""Client metadata shared by visits, crawler hits and abuse hits.
|
||||||
|
|
||||||
@@ -151,7 +177,7 @@ class Visit(msgspec.Struct, omit_defaults=True):
|
|||||||
|
|
||||||
|
|
||||||
class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
||||||
"""A document GET that was never followed by an analytics ping.
|
"""A document GET that was never followed by an activity message.
|
||||||
|
|
||||||
Client metadata is held in ``Analytics.clients`` keyed by ``client``.
|
Client metadata is held in ``Analytics.clients`` keyed by ``client``.
|
||||||
"""
|
"""
|
||||||
@@ -392,19 +418,19 @@ class Store:
|
|||||||
#: client hash -> index of the current visit in data.visits
|
#: client hash -> index of the current visit in data.visits
|
||||||
self.sessions: dict[bytes, int] = {}
|
self.sessions: dict[bytes, int] = {}
|
||||||
#: ip -> external https origin of the latest document GET carrying
|
#: ip -> external https origin of the latest document GET carrying
|
||||||
#: one, stashed for the visit the client's initial ping starts.
|
#: one, stashed for the visit the client's initial message starts.
|
||||||
#: Internal or absent referers never touch the table.
|
#: Internal or absent referers never touch the table.
|
||||||
self.pending_referers: dict[str, str] = {}
|
self.pending_referers: dict[str, str] = {}
|
||||||
#: ip -> utm_* query parameters from the latest document GET that
|
#: ip -> utm_* query parameters from the latest document GET that
|
||||||
#: carried any, stashed for the visit the client's initial ping starts.
|
#: carried any, stashed for the visit the client's initial message starts.
|
||||||
#: Only non-empty sets are stored, so a later parameter-less page
|
#: Only non-empty sets are stored, so a later parameter-less page
|
||||||
#: does not overwrite an earlier tagged landing URL.
|
#: does not overwrite an earlier tagged landing URL.
|
||||||
self.pending_utms: dict[str, dict[str, str]] = {}
|
self.pending_utms: dict[str, dict[str, str]] = {}
|
||||||
#: Document GETs that have not yet been matched by a ping. Kept
|
#: Document GETs that have not yet been matched by a message. Kept
|
||||||
#: in RAM only; expired entries are written to ``data.crawlers``.
|
#: in RAM only; expired entries are written to ``data.crawlers``.
|
||||||
self.pending_crawlers: list[CrawlerHit] = []
|
self.pending_crawlers: list[CrawlerHit] = []
|
||||||
#: client hash -> {path: status} for recent document GETs, consumed
|
#: client hash -> {path: status} for recent document GETs, consumed
|
||||||
#: by the matching ping to record the status of each visited path.
|
#: by the matching message to record the status of each visited path.
|
||||||
self.pending_statuses: dict[bytes, dict[str, int]] = {}
|
self.pending_statuses: dict[bytes, dict[str, int]] = {}
|
||||||
#: ip -> number of plain (non-telltale) 404s seen, in RAM only;
|
#: ip -> number of plain (non-telltale) 404s seen, in RAM only;
|
||||||
#: reaching ``_ABUSE_404_THRESHOLD`` classifies the IP as abuse.
|
#: reaching ``_ABUSE_404_THRESHOLD`` classifies the IP as abuse.
|
||||||
@@ -721,16 +747,16 @@ class Store:
|
|||||||
) -> list[bytes]:
|
) -> list[bytes]:
|
||||||
"""Stash the entry referer/UTM tags and queue a pending crawler hit.
|
"""Stash the entry referer/UTM tags and queue a pending crawler hit.
|
||||||
|
|
||||||
Nothing is counted here — the client's initial /_a ping starts the
|
Nothing is counted here — the client's first /_ws message starts the
|
||||||
visit (only non-admin clients ping). Only a cross-origin https
|
visit (only non-admin clients report). Only a cross-origin https
|
||||||
referer updates the table; an internal or absent referer leaves any
|
referer updates the table; an internal or absent referer leaves any
|
||||||
stashed origin untouched. UTM parameters are kept only when the
|
stashed origin untouched. UTM parameters are kept only when the
|
||||||
landing URL actually carries them, so a subsequent parameter-less page
|
landing URL actually carries them, so a subsequent parameter-less page
|
||||||
does not erase an earlier tagged landing.
|
does not erase an earlier tagged landing.
|
||||||
|
|
||||||
Every document GET is also queued as a pending crawler hit. If a ping
|
Every document GET is also queued as a pending crawler hit. If a
|
||||||
from the same client arrives within ``_CRAWLER_TIMEOUT``, the hit is
|
message from the same client arrives within ``_CRAWLER_TIMEOUT``, the
|
||||||
discarded; otherwise it is flushed to ``data.crawlers``. The
|
hit is discarded; otherwise it is flushed to ``data.crawlers``. The
|
||||||
Accept-Language header is stored on the client record immediately;
|
Accept-Language header is stored on the client record immediately;
|
||||||
host/geoip are filled in later by async enrichment.
|
host/geoip are filled in later by async enrichment.
|
||||||
|
|
||||||
@@ -794,15 +820,16 @@ class Store:
|
|||||||
hide: bool = False,
|
hide: bool = False,
|
||||||
read: int = 0,
|
read: int = 0,
|
||||||
) -> tuple[int | None, list[bytes]]:
|
) -> tuple[int | None, list[bytes]]:
|
||||||
"""Record a client navigation ping ({from, to, read} from pagerite.js).
|
"""Record a client activity message (``Ping`` from pagerite.js over /_ws).
|
||||||
|
|
||||||
``to`` is an internal path ("/...") or an https URL for exit links; a
|
``to`` is an internal path ("/...") or an https URL for exit links; a
|
||||||
missing/empty ``to`` means the page is being closed and only the
|
missing/empty ``to`` means a pure reading-time update and only the
|
||||||
``read`` time should be recorded. The transition is always counted when
|
``read`` time should be recorded. The transition is always counted when
|
||||||
``to`` is present; the trail only grows on first sight of a page within
|
``to`` is present; the trail only grows on first sight of a page within
|
||||||
the visit. ``read`` is the active time (seconds) spent on ``from_``.
|
the visit. ``read`` is the active time (seconds) spent on ``from_``
|
||||||
|
since the previous report.
|
||||||
|
|
||||||
A ping with no known session starts a fresh visit, consuming the
|
A message with no known session starts a fresh visit, consuming the
|
||||||
referer and UTM tags stashed by the document GET if there are any.
|
referer and UTM tags stashed by the document GET if there are any.
|
||||||
|
|
||||||
``hide`` is set by admin clients: the client record is flagged
|
``hide`` is set by admin clients: the client record is flagged
|
||||||
@@ -811,7 +838,7 @@ class Store:
|
|||||||
normally. Hidden clients are excluded from every statistic and list
|
normally. Hidden clients are excluded from every statistic and list
|
||||||
at display time, and their pending crawler hits are discarded.
|
at display time, and their pending crawler hits are discarded.
|
||||||
|
|
||||||
Pings from IPs classified as abuse, and pings whose User-Agent
|
Messages from IPs classified as abuse, and messages whose User-Agent
|
||||||
claims a JS-running crawler identity (``_is_bot_ua``), are ignored
|
claims a JS-running crawler identity (``_is_bot_ua``), are ignored
|
||||||
entirely — the crawler's pending hits stay queued and flush to
|
entirely — the crawler's pending hits stay queued and flush to
|
||||||
``data.crawlers`` normally.
|
``data.crawlers`` normally.
|
||||||
|
|||||||
+45
-38
@@ -32,10 +32,10 @@ from xml.sax.saxutils import escape as xml_escape
|
|||||||
|
|
||||||
import blake3
|
import blake3
|
||||||
import httpx
|
import httpx
|
||||||
|
import msgspec
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
FastAPI,
|
FastAPI,
|
||||||
HTTPException,
|
HTTPException,
|
||||||
Query,
|
|
||||||
Request,
|
Request,
|
||||||
WebSocket,
|
WebSocket,
|
||||||
WebSocketDisconnect,
|
WebSocketDisconnect,
|
||||||
@@ -935,7 +935,7 @@ async def delete_page(path: str) -> None:
|
|||||||
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(request: Request) -> str:
|
def _client_ip(request: Request | WebSocket) -> str:
|
||||||
"""Client IP: first X-Forwarded-For hop (we sit behind a proxy), else
|
"""Client IP: first X-Forwarded-For hop (we sit behind a proxy), else
|
||||||
the direct peer."""
|
the direct peer."""
|
||||||
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||||
@@ -1108,48 +1108,54 @@ async def analytics_page(request: Request) -> Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/_a", status_code=204)
|
@app.websocket("/_ws")
|
||||||
async def analytics_ping(
|
async def activity_ws(ws: WebSocket) -> None:
|
||||||
request: Request,
|
"""Collect visitor activity: navigations and reading-time updates.
|
||||||
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
|
Public, like the pages themselves (only /_api is gated); one connection
|
||||||
to the referer/UTM tags stashed by the document GET (see _track_entry),
|
follows a browsing session. Messages are ``analytics.Ping`` structs as
|
||||||
which JS cannot see once the page has loaded.
|
JSON text frames; ``to`` set is a navigation, ``read`` alone a
|
||||||
|
reading-time update. The reverse-DNS and DB-IP geoip lookups happen in
|
||||||
The reverse-DNS and DB-IP geoip lookups happen in a background task so
|
background tasks so message handling is never delayed by slow DNS or
|
||||||
the response is never delayed by slow DNS or the first MMDB decompress.
|
the first MMDB decompress.
|
||||||
"""
|
"""
|
||||||
ip = _client_ip(request)
|
await ws.accept()
|
||||||
visit_index, flushed_clients = analytics_store.ping(
|
ip = _client_ip(ws)
|
||||||
fr,
|
ua = ws.headers.get("user-agent", "")
|
||||||
to,
|
accept_language = ws.headers.get("accept-language", "")
|
||||||
ip,
|
try:
|
||||||
request.headers.get("user-agent", ""),
|
while True:
|
||||||
request.headers.get("accept-language", ""),
|
text = await ws.receive_text()
|
||||||
hide=bool(hide),
|
try:
|
||||||
read=read,
|
msg = msgspec.json.decode(text.encode(), type=analytics.Ping)
|
||||||
)
|
except msgspec.DecodeError:
|
||||||
if visit_index is not None:
|
continue
|
||||||
visit = analytics_store.data.visits[visit_index]
|
visit_index, flushed_clients = analytics_store.ping(
|
||||||
asyncio.create_task(_enrich_client(visit.client))
|
msg.fr,
|
||||||
_schedule_client_enrichment(flushed_clients)
|
msg.to or None,
|
||||||
_schedule_favicon_fetch()
|
ip,
|
||||||
|
ua,
|
||||||
|
accept_language,
|
||||||
|
hide=msg.hide,
|
||||||
|
read=msg.read,
|
||||||
|
)
|
||||||
|
if visit_index is not None:
|
||||||
|
visit = analytics_store.data.visits[visit_index]
|
||||||
|
asyncio.create_task(_enrich_client(visit.client))
|
||||||
|
_schedule_client_enrichment(flushed_clients)
|
||||||
|
_schedule_favicon_fetch()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _track_entry(path: str, request: Request, *, status: int = 200) -> list[bytes]:
|
def _track_entry(path: str, request: Request, *, status: int = 200) -> list[bytes]:
|
||||||
"""Stash the referer/UTM tags and queue a pending crawler hit for the GET.
|
"""Stash the referer/UTM tags and queue a pending crawler hit for the GET.
|
||||||
|
|
||||||
Nothing is counted on the GET itself — the client's /_a ping starts the
|
Nothing is counted on the GET itself — the client's first /_ws message
|
||||||
visit, so bots never register as visits (JS-running crawlers ping too,
|
starts the visit, so bots never register as visits (JS-running crawlers
|
||||||
but the ping handler ignores known bot UAs). (Admin clients ping too,
|
connect too, but the WebSocket handler ignores known bot UAs). (Admin
|
||||||
but with hide=1, which flags their visit hidden: it is recorded but
|
clients report too, but with hide, which flags their visit hidden: it is
|
||||||
excluded from all statistics and from the crawler list.)
|
recorded but excluded from all statistics and from the crawler list.)
|
||||||
|
|
||||||
The devserver's health probe (``GET /?from=devserver.py`` from
|
The devserver's health probe (``GET /?from=devserver.py`` from
|
||||||
``127.0.0.1``) is ignored: it is not real traffic and would otherwise be
|
``127.0.0.1``) is ignored: it is not real traffic and would otherwise be
|
||||||
@@ -1161,7 +1167,8 @@ def _track_entry(path: str, request: Request, *, status: int = 200) -> list[byte
|
|||||||
"""
|
"""
|
||||||
if request.headers.get("x-pagerite-preload"):
|
if request.headers.get("x-pagerite-preload"):
|
||||||
# Idle-time page-cache warm-up by pagerite.js, not a page view: the
|
# Idle-time page-cache warm-up by pagerite.js, not a page view: the
|
||||||
# ping sent when the user actually navigates does the counting.
|
# activity message sent when the user actually navigates does the
|
||||||
|
# counting.
|
||||||
# (Forging the header only hides a GET from the crawler stats; the
|
# (Forging the header only hides a GET from the crawler stats; the
|
||||||
# path-based abuse classification is unaffected.)
|
# path-based abuse classification is unaffected.)
|
||||||
return []
|
return []
|
||||||
|
|||||||
Reference in New Issue
Block a user