Indeks

Web analytics you host yourself

shipped2025 — present

stack
TypeScriptBunNext.jsElysiaClickHousePostgreSQLDrizzlebetter-authTailwindDocker
license
MIT
links
server reposdk reponpm

analytics watches everyone who visits your site. most options then send that record to someone else's server.

the hosted ones are convenient because they hold the data, which is also the problem. the self-hosted ones often treat privacy as a settings page: a switch you can flip off, defaults you can widen, cleanup that depends on a job actually running.

indeks starts from the other end. you host it, so you hold the data, and the guarantees come from what the code does and how the database is set up.


how it works

there are two paths and they stay apart.

ingest. POST /api/v1/collect checks the request shape, then the project API key, then rate limits, then truncates the visitor's IP, adds rough location if you've set up the geo database, and inserts the batch into ClickHouse. up to 500 events per request, from any origin.

neither database is read-only here, which is worth being exact about. ClickHouse only ever gets written to on this path and never read. Postgres gets read once to find the project the API key belongs to, and written by the rate limiter, which keeps its token buckets in a table.

rollup. a nightly job groups the previous day's ClickHouse events into analytics_* tables in Postgres. long charts read those rows, so a year of traffic is a Postgres scan instead of a warehouse query on every page load. realtime, journeys and location views still query ClickHouse directly, since they're about right now rather than history.

the split is why both halves stay simple. events are append-only and get queried in bulk, so ClickHouse. users, projects, orgs, auth and the rollups are relational and get edited, so Postgres.


what happens to a visitor's IP

the full address is used for two things and stored for neither.

it becomes a rate-limit key, hashed with HMAC and a pepper you set, so the limiter can tell two visitors apart without keeping either of them. and it gets truncated — IPv4 to a /24, IPv6 to a /48 — before anything writes it down. private addresses are dropped entirely. there is no full-IP column in ClickHouse to leak later, because there's no full-IP column at all. if even the prefix is more than you want, STORE_IP_PREFIX=false drops that too.

retention is the database's job. EVENT_RETENTION_DAYS becomes a ClickHouse table TTL, so old events are removed by the storage engine and not by a cron that might be broken.

nothing calls out during ingest. location comes from a MaxMind file on your disk, and only if you point at one. exports are generated and streamed by the same server. there's no analytics on the analytics, no error reporting service, no outbound request on the write path at all.


what the SDK captures, and what it won't

it isn't off by default, and it would be misleading to say so. clicks, scrolls, pageviews, form submissions, performance timings — those are on when you install it, because that's what you installed it for.

the line is drawn around content rather than behaviour. keystrokes, mouse movement and hover, text selection, form values, clipboard text, search terms and fingerprinting all start off. those are the ones that would let you reconstruct what somebody typed, so they need an explicit yes.

that set is also a one-way ratchet. updateConfig can turn those ten off at runtime but never on, so a flag you didn't opt into at startup can't be switched on later by some other code path. the rest of the capture flags are ordinary settings and can be changed either way.

the rest of the privacy work:

  • Do Not Track and Global Privacy Control are both honoured, and stop tracking before anything is sent.
  • element attributes are allow-listed rather than blocked, so a new attribute is dropped by default instead of collected by accident. value is always dropped.
  • query strings keep utm_* and a few referrer params, and redact anything that looks sensitive by name. it's a deny-list with heuristics, so a weird param name of your own can still get through — worth knowing if your URLs carry anything private.

a few decisions i'd defend

missing config crashes on import. no fallbacks. there used to be defaults, and they were worse than a crash — a missing ClickHouse URL quietly becoming localhost is a much longer outage than a process that won't boot. the error names the variable so you know which one.

schema changes are a deploy step. migrate:all runs Drizzle against Postgres and an idempotent script against ClickHouse. nothing creates a table while handling a request.

exports stream. ClickHouse formats the rows and the bytes go straight to the client, so memory doesn't grow with the export. there are still limits — five million rows and a year of range — because "stream it" isn't the same as "unbounded".

the rollup cron admits partial failure. it returns 207 when some projects failed and 200 only when they all worked. a job that always returns 200 is a job nobody is watching.


the SDK

three packages, ESM and CommonJS with types:

bash
npm install @indeks/core @indeks/react
  • @indeks/core — the tracking itself
  • @indeks/react — an <Indeks> provider and a useIndeks hook
  • @indeks/shared — the types both sides agree on, pulled in automatically

fifty-one event types across seventeen groups. the usual ones, plus the three that are actually useful for working out why a page is annoying: rage clicks, dead clicks, and error clicks.

if a send can't finish before the page closes, the pending events go to localStorage, capped at 500, and get replayed next time. the dashboard runs the SDK on itself, which is the fastest way to find out your own event schema is awkward to query.


running it

Postgres and ClickHouse come up from docker-compose.yml with healthchecks. the app is a third service behind a compose profile, so you can bring up just the databases and run the app from source. the runtime image runs as a non-root user and has no build tooling in it. migrations are one command. two GitHub Actions workflows cover the rollup and the uptime checks if you'd rather not run a scheduler.

both repos are MIT. host it, fork it, change it.

back to work