Build it yourself

advanced one sitting analytics

Build your own first-party analytics box (a personal PostHog)

You build a personal analytics box: a Node collector on your own domain, a SQLite database you own outright, and a dashboard that answers your top questions about visitors, pages, referrers, and funnels. It covers the first-party core of product analytics for a handful of sites. PostHog still survives because its bill pays for what comes after the charts: session replay, feature flags, years of queryable history, and an ingestion pipeline that keeps collecting correctly while your product and event schema drift underneath it.

What you'll learn

  • Designing an event schema: event names, properties, and what never gets collected
  • First-party tracking: serving a snippet from your own domain so browsers and blockers trust it
  • Privacy-preserving visitor counting with a daily-rotating salted hash
  • Aggregate SQL in SQLite for funnels, property breakdowns, and nightly rollups
  • Keeping a small service alive: systemd units, retention pruning, and off-box backups

Before you start

  • Node 22 installed locally and on the server (verify with node --version)
  • A small VPS with SSH access, 1 vCPU and 1 GB RAM is plenty to start
  • A domain you control with access to its DNS records, for first-party collection
  • The sqlite3 CLI available somewhere you can inspect events.db directly
  • A website you can edit so you can paste one script tag into it

The build

BY HAND

Create the smallest VPS that runs Node 22, note its IP address, and add an A record such as analytics.yourdomain.com pointing at it. First-party collection means tracking requests go to a domain you control, so this name is part of the architecture, not an afterthought. Nothing is installed yet; the win is a hostname that resolves and a box you can SSH into.

DELEGATE

Hand the skeleton to an agent whole, then review the diff before running anything. You end up with one process, one SQLite file, and an /e endpoint that turns a curl POST into a row you can SELECT. That round trip is the foundation every later step builds on.

step prompt
Build a Node 22 analytics collector skeleton. Requirements:
- Single Express app in server.js, dependencies limited to express, better-sqlite3, and dotenv, listening on port 8787.
- Create events.db beside server.js with an events table: id, site, event_name, path, referrer, utm_source, utm_medium, utm_campaign, country, device_class, properties (JSON text), visitor_id (nullable for now), created_at (ISO 8601).
- POST /e accepts JSON with those fields, requires site and event_name, returns 400 on unknown fields, inserts the row; verify with a curl POST followed by SELECT count(*) from events increasing by 1.
- GET /health returns { ok: true } plus the current row count.
- Port and SQLite path load from .env via dotenv; commit a .env.example, never the real .env.
- Out of scope: authentication, dashboards, ORM libraries, migration tooling, plain prepared statements only.
- Honest pain note: better-sqlite3 compiles native code on install, pin Node 22 or the build fails loudly.
WE

Drive the assistant to write the snippet you will paste on your sites. Because the tracker must match how your pages are built, you stay in the loop deciding what gets sent and what never leaves the browser. The payoff is immediate: paste it on a real page and watch the row land.

step prompt
Add tracker.js and route collection through your own domain. Requirements:
- Serve a dependency-free tracker.js under 80 lines from GET /tracker.js on the collector built in the previous step.
- On page load and on history pushState changes, POST a pageview to https://analytics.YOURDOMAIN/e with site, event_name 'pageview', path, referrer, and UTM parameters parsed from the URL.
- Expose window.track(name, props) that sends custom events with properties serialized into the JSON properties column.
- Use navigator.sendBeacon with a fetch keepalive fallback, never block rendering, never set cookies or localStorage.
- Reverse-proxy /e on your subdomain to the collector's /e route so requests never leave your domain.
- Out of scope: click autocapture, SPA routers other than the history API, user ids, consent banner logic.
- Honest pain note: Safari and some extensions strip referrer data, expect blank referrers on a slice of traffic.
- Verify by pasting the snippet on a real page and watching one pageview row arrive with today's date.
WE

Steer the assistant through the privacy core: a daily-rotating salt combined with the IP and user agent produces an anonymous visitor id. You set the policy, the salt stays in .env, and the database ends up holding nothing sensitive to leak. Confirm it by counting distinct visitors on a day you browsed yourself.

step prompt
Set up anonymous visitor counting with a rotating salt. Requirements:
- Generate a 32-character DAILY_SALT into .env at install time, never commit the real value.
- Inside the /e handler from step 2, compute sha256(current salt + ip + user agent), truncate to 16 hex chars, store as visitor_id, and discard the raw inputs.
- Never write raw IPs or full user agent strings to disk or logs anywhere in the codebase.
- Rotate the salt at 00:00 UTC with an in-process timer and document that day-over-day counts shift slightly as a result.
- Add GET /api/days returning date, distinct visitor_id count, and pageview count for the trailing 30 days.
- Add columns with ALTER TABLE guarded by a existence check so rerunning the migration is harmless.
- Out of scope: fingerprinting libraries, returning-visitor cookies, cross-device identity stitching.
- Verify by browsing your site, then confirm the events table has visitor_id filled in and no ip column exists.
WE

Aggregate queries hide wrong answers quietly, so this step stays interactive: run each endpoint against your own real traffic and check the numbers against what you know happened. When it settles, four endpoints cover range summaries, top lists, one-event property splits, and a three-step funnel.

step prompt
Build the authenticated stats API over the existing events table. Requirements:
- Protect /api/* with basic auth, credentials in DASH_USER and DASH_PASS in .env.
- GET /api/summary?from=YYYY-MM-DD&to=YYYY-MM-DD returns per-day visitors, pageviews, and a bounce proxy (single-page visits divided by total visits).
- GET /api/top?dim=page|referrer|country with from and to returns the top 10 rows with counts.
- GET /api/event?name=signup&prop=plan splits one named event by one property using json_extract on the properties column.
- GET /api/funnel?steps=a,b,c counts distinct visitor_id values reaching each ordered step within the range.
- Parameterize every query and return 400 for malformed dates.
- Out of scope: caching layers, chart rendering, GraphQL.
- Honest pain note: funnel queries over raw rows get slow near 100k events, the nightly rollup planned in a later step is the intended fix.
DELEGATE

With the JSON contract fixed in the previous step, the page itself is precise, low-risk work an agent can finish whole. Review it in a browser instead of line by line: charts draw, ranges switch, tables render. Visual taste is the only judgment left to you.

step prompt
Build a single-page dashboard consuming the /api endpoints from the previous step. Requirements:
- One file public/dashboard.html with vanilla JavaScript, no framework, no build step, served at / behind the same basic auth.
- Fetch /api/summary, /api/top, /api/event, and /api/funnel using the exact parameter names already defined, no client-side reshaping beyond number formatting.
- Render the daily series as a canvas line chart (uPlot from CDN is acceptable), top lists as tables, the funnel as labeled horizontal bars.
- Include a date range picker with 7, 30, and 90 day presets plus custom from and to inputs.
- Handle empty ranges and missing property values gracefully, never throw on null.
- Out of scope: dark mode, saved views, team accounts, CSV buttons.
- Verify by loading the page in a browser and matching the numbers the API returns via curl.
WE

This step separates a demo from infrastructure. Drive the assistant to add the nightly rollup, prune raw rows past your retention window, copy backups off the box, and register a systemd unit so collection resumes after a reboot. The policies are yours to choose because they concern your data; the translation into scripts is shared work.

step prompt
Add the operations layer that keeps collection alive unattended. Requirements:
- Nightly job at 02:00 UTC building a daily_summary table (day, visitors, pageviews, top_referrer) from events, then deleting raw rows older than RETENTION_DAYS from .env.
- GET /export.csv streams the surviving raw rows as CSV with a header row, gated by the existing basic auth.
- scripts/backup.sh copies events.db to a timestamped file and rsyncs it to an OFFBOX_PATH configured in .env.
- Install analytics.service as a systemd unit running server.js with Restart=always, enabled at boot.
- Append a timestamped heartbeat line to heartbeat.txt every minute so silent stalls become detectable.
- Out of scope: alerting services, multi-node deployments, database replication.
- Honest pain note: pruning raw rows means old funnels lose path-level detail and only summary totals survive, so pick RETENTION_DAYS deliberately.

What you won't get

  • Dashboards show numbers and trends, not recordings of individual visits
  • No feature flags, A/B experiments, or surveys; behavior changes still ship through deploys
  • Visits are counted per browser, so one person on phone and laptop appears twice
  • Analysis runs over a rolling retention window plus nightly summaries, not open-ended history
  • The box collects as long as your VPS does; no on-call team restarts it for you

Why people still pay — and what that teaches you

scale-infra: PostHog wins on the pipeline, not the charts: bot filtering, late-arriving data, identity resolution, and query cost stay handled across millions of events per workspace. Your box handles one site and the pipeline is still most of the work, which teaches the builder that collection reliability is the actual product.

switching-costs: Teams stay because years of events, saved boards, and instrumented code emitting PostHog-shaped payloads are expensive to abandon mid-flight. The lesson for a builder is to design your event schema deliberately and keep raw rows exportable, so your own switching costs stay low forever.

Stretch goals

  • Email yourself a weekly digest of visitors and top pages from the daily_summary table
  • Track two sites side by side in the same dashboard using the site column
  • Run your box and PostHog's free tier on the same site for a month and compare the counts

About PostHog

PostHog costs $/month. Teams pay because analytics has to keep collecting and stay trustworthy while the product changes underneath it. The bill buys bot filtering, identity resolution, late-arriving data, retention, query cost, and someone else getting paged when ingestion stops. The charts are the cheap part.

Sources & further reading

  • Umami — A mature open-source analytics codebase worth reading after your v1 works, especially its schema and bot-filtering choices.
  • PostHog pricing — Shows the free allowances and metered rates, so you know exactly where the paid line sits relative to your traffic.
  • Rybbit — A hosted option with analytics and replay if you later want capabilities this build intentionally skips.
  • OpenPanel — Another hosted middle ground with product analytics and replay, useful for seeing what self-hosting trades away.

Keep building

New lessons and honest build notes, by email. No spam, one-click out.

Signups open when the site goes live.