ZKRINET Docs
Building blocks

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 }] }
  1. 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.
  2. The visitor becomes SHA-256(today's salt + IP + User-Agent + host). IP and User-Agent exist only in memory during the request.
  3. 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.
  4. 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

Settings (environment variables)

VariableDefault
ZK_STATISTIK_TAK300Counted requests per visitor and day
ZK_STATISTIK_MAX_NYCKLAR2000Distinct pages (and referrers, events) per day
ZK_STATISTIK_RADATA_DAGAR1Days the hashes are kept (1 = today only). The salt is always deleted after the day regardless.
ZK_STATISTIK_AGGREGAT_DAGAR00 = 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).

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 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

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.