Statistics — cookie-free visitor statistics
On Zkrinet: add the block under Building blocks in the panel — one click, your own code is untouched and a snapshot is taken first. The installation steps below apply if you run PocketBase yourself, for example after an export.
Visits, unique visitors, top pages, where people come from, and custom events ("Köp klickat" / "Buy clicked") — without cookies, without localStorage and without storing IP addresses. The same model that Plausible uses.
Tested against PocketBase 0.40.4.
#Installation
Copy pb_hooks/ and pb_migrations/ to your PocketBase and restart. The collection zk_statistik_dagar (the aggregates) appears in the admin panel.
If PocketBase is behind a proxy (Cloudflare, nginx, Caddy …) you must set Settings → Trusted proxy so that PocketBase sees the visitor's real IP — otherwise all visitors look like the proxy and are counted as one and the same.
#Paste into the app
<script>
(function () {
var URL = "https://din-pocketbase.se/api/zk/statistik/besok"
function skicka(data) {
try {
fetch(URL, { method: "POST", body: JSON.stringify(data), keepalive: true,
headers: { "Content-Type": "text/plain" } }) // text/plain = no CORS preflight request
} catch (_) {}
}
function sida() { skicka({ sida: location.pathname, referrer: document.referrer }) }
sida()
// Apps with their own routing (React/Vue/Svelte): also count when the address changes without a reload.
var push = history.pushState
history.pushState = function () { push.apply(this, arguments); sida() }
addEventListener("popstate", sida)
// Custom events: window.zkHandelse("Köp klickat")
window.zkHandelse = function (namn) { skicka({ sida: location.pathname, handelse: namn }) }
})()
</script>
The snippet sets no cookie, writes nothing in the browser and reads nothing from it.
#Reading the statistics
GET /api/zk/statistik?fran=2026-10-01&till=2026-10-31 (superuser)
Without fran/till = the last 30 days (UTC). At most 367 days per query.
const s = await pb.send("/api/zk/statistik", { query: { fran: "2026-10-01", till: "2026-10-31" } })
// { fran, till, besok, unika,
// dagar: [{ dag, besok, unika }],
// toppsidor: [{ sida, besok, unika }], // top 20
// toppreferrers: [{ referrer, besok, unika }], // domain; "(direkt)" = no referrer, "(okänd)" = could not be read
// handelser: [{ handelse, antal, unika }] }
besok= page views.unika= unique visitors per day. Over several days,unikais the sum of the days — the same person on two days is counted twice. That is the price of the hashes not being linkable across days, and it is intentional.- Events are counted separately and are not included in
besok. - Links between pages on your own site are not counted as referrers (requires that the browser sends
Origin, which it does onfetchfrom a different address).
#How unique visitors are counted — and why no cookie is needed
- Every day (UTC) a new salt is randomly generated. It lives in an internal table that is not a collection and cannot be reached via the API.
- The visitor becomes
SHA-256(today's salt + IP + User-Agent + host). IP and User-Agent exist only in memory during the request. - The hash is stored during the day so that the same visitor is not counted twice. Then the salt is deleted (at the first visit the next day, and by the hourly cleanup) and the hashes are purged.
- What remains is only aggregates: counts per day, page, referrer domain and event.
Never stored: IP address, User-Agent, cookies, query strings (?epost=… and #… are removed from the page address), full referrer addresses (only the domain).
PocketBase's own activity log by default stores IP and User-Agent for every request. The visit route is therefore excluded from the log ($apis.skipSuccessActivityLog()) and always responds 202, even on broken input, so that it never ends up there. Tested: after visits, bots, DNT, broken input and cap hits, the visitor's IP and User-Agent are not in any table in data.db or auxiliary.db.
#What is not counted
- Do Not Track (
DNT: 1) and Global Privacy Control (Sec-GPC: 1) — not counted at all. - Bots — a rough list of search engines, AI crawlers, previews (Slack/Discord/Facebook), monitoring,
curl/python/headless browsers, plus everything that does not look like a browser (lacksMozilla/). - Inflation — one and the same visitor is counted at most 300 times per day (
ZK_STATISTIK_TAK). At most 2000 distinct pages/referrers/events per day (ZK_STATISTIK_MAX_NYCKLAR); the rest are collected under(övrigt)so that nobody can fill the database with made-up addresses.
#Settings (environment variables)
| Variable | Default | |
|---|---|---|
ZK_STATISTIK_TAK | 300 | Counted requests per visitor and day |
ZK_STATISTIK_MAX_NYCKLAR | 2000 | Distinct pages (and referrers, events) per day |
ZK_STATISTIK_RADATA_DAGAR | 1 | Days the hashes are kept (1 = today only). The salt is always deleted after the day regardless. |
ZK_STATISTIK_AGGREGAT_DAGAR | 0 | 0 = the aggregates are kept forever; otherwise number of days |
The cleanup runs every hour (cron) and can be run by hand: POST /api/zk/statistik/gallra (superuser).
#Legal — read this
We are not lawyers. The model above is the same one that Plausible and similar tools rely on to avoid a consent banner: no information is stored on or read from the visitor's device (Article 5(3) of the ePrivacy Directive — in Sweden the Electronic Communications Act, Chapter 9, Section 28), and what is stored is aggregates that cannot be linked to a person.
But be honest with yourself about two things:
- The IP address is processed — it is used in memory to compute the hash. That is processing of personal data under the GDPR, even though it is not stored. You need a legal basis (usually legitimate interest) and should mention the statistics in your privacy policy.
- During the day the salt and the hashes are in the database. Someone with full access to the server could in theory test all IPv4 addresses against a known User-Agent and find a match. After the day, when the salt has been deleted, that is not possible.
The assessment of whether this is sufficient for your particular service — ePrivacy, GDPR and any national rules — is owned by you as the customer. If you are unsure, ask a lawyer.
#Limitations
- The days are UTC. Swedish midnight is 23:00 or 22:00 UTC; a visit at 00:30 Swedish time lands on the previous UTC day.
- Unique visitors are an estimate. The same person on mobile and desktop is counted twice; several people behind the same IP with the same browser are counted as one. If someone changes network in the middle of the day, they are counted again.
- The bot filter is rough. A bot that claims to be Chrome is counted. Someone who wants to inflate the numbers with many IP addresses can do so within the caps — the caps protect the database, not the truthfulness of the numbers.
- Behind a proxy without the Trusted proxy setting, all visitors become a single one (the proxy's IP).
- PocketBase's built-in rate limit: if you turn it on and it triggers for the visit route, the 429 response is logged in the activity log — with IP. In that case set
logs.logIPoff, or exclude the route. - Other errors before our code (e.g. PocketBase rejects the request before it reaches the route) are logged by PocketBase as usual. If you want to be completely sure: turn off Log IP under Settings → Logs.
- No geography (country/city) and no device type — that would require processing more about the visitor.
- No ready-made chart/dashboard; you get JSON to display however you want.
- Not performance-measured under high load (each visit is a small write transaction in SQLite).
#Testing
./bank.py statistik
12 tests: same visitor same day = 1 unique (different IP/browser = new), next day = new visitor and yesterday's salt deleted (the day and the salt are manipulated in the database), that changing the salt alone is enough for a new hash, that IP and User-Agent are not in any table in any database file (with a check that proves the search actually sees the PocketBase log), DNT/GPC, bots, that only superuser reads, the cap per visitor, pages/referrers/events (query strings removed, internal navigation), date ranges, purging of raw data with the aggregates kept, cap on number of pages, broken input. The protections are mutant-verified: every protection has been deliberately broken and caught by at least one test.