Overview
- What: self-hosted product analytics in one TypeScript monorepo, installed with a single
npm installand no external services. - Hardest problem: defining aggregations, funnels and cohorts precisely, so any dashboard number traces back through the SQL to the events behind it.
- How it is validated: a vitest suite, strict TypeScript, ESLint with warnings as errors, bundle-size budgets and CI.
It is small enough to read end to end. It includes a typed ~2 kB browser SDK and production operations features such as key rotation, rate limiting, retention sweeps and Prometheus metrics.
Architecture
browser SDK (~2 kB) --POST /ingest--> API (Express) --> SQLite (better-sqlite3)
pixel fallback ------GET-----------> | |
| aggregation engine (SQL): metrics, funnels, cohorts
v |
React dashboard <-----------+
packages: shared <- sdk, api, web (one-directional) Highlights
- Batched
POST /ingestwith per-event validation and partial-success reporting (202whole batch stored,207with offending indices), plus asendBeacon-friendly pixel fallback. - Aggregations (visitors, events, sessions, bounce rate, session length) bucketed by hour/day/week/month with zero-padded series and period-over-period deltas; breakdowns by page, referrer host, country, device, browser and OS.
- Ordered, windowed funnels and cohort retention triangles (a visitor belongs to the cohort of their first-ever visit, so widening the range never reshuffles cohorts); bot traffic stored but excluded by default.
- Dashboard with a hand-rolled SVG chart, light/dark theming and keyboard navigation, with no chart library and no CSS framework.
- Operations: hashed write keys with rotation, JWT auth with rotating refresh tokens, role-based team admin, per-project rate limiting, retention sweeps, streaming CSV/NDJSON export, Prometheus
/metrics,/health. - Four packages with a one-directional dependency rule (
shared<-sdk,api,web); GitHub Actions CI, vitest suite, ESLint with warnings as errors, bundle-size budgets, six docs.
What I learned
Defining cohorts by a visitor’s first-ever visit means widening the date range never reshuffles them, which is the kind of rule worth deciding up front. Keeping the dependency direction between the four packages one-way kept the monorepo easy to read.