Self-hosted web analytics · Node/Express + React/Vite + PostgreSQL
Metrixis is a self-hosted, privacy-respecting web analytics platform you can deploy on Render (backend) and Vercel (frontend) in under 15 minutes. It tracks page views, scroll depth, and opted-in click events without storing raw IP addresses or using cookies for identity.
| Participant | Tech | Purpose |
|---|---|---|
| Tracker script | Vanilla JS (IIFE) | Embedded on any tracked website; collects events client-side |
| API server | Node.js + Express, port 4000 | Receives events, serves stats, runs background aggregation |
| Dashboard | React + Vite, port 5173 | Single-page app; reads stats via the API; no authentication |
Raw IPs are never stored. Each IP is hashed with salted SHA-256 and
truncated to 16 hex characters before being written to the database.
Visitor identity is anonymous (random visitor_id in
localStorage). There are no cookies, no login, and no
third-party services in the data path.
The API exposes three distinct security contexts:
| Zone | Route | Access |
|---|---|---|
| Public ingest | POST /api/collect | Any origin (CORS origin: true) |
| Dashboard reads | /api/sites, /api/stats/* | Restricted to DASHBOARD_URL |
| Internal cron | POST /api/internal/aggregate | CRON_SECRET header, no CORS |
Data flows from left/top to bottom. The tracker sends events to the API; the aggregation job rolls them up into daily stats; the dashboard reads only the rollup tables (plus live today data for the overview endpoint).
Always-202 collect endpoint — ingest errors are swallowed server-side and never surfaced to the tracked website, preventing tracking from breaking host-page UX. Watch accepted: false in server logs.
In-memory tracking_id → site_id cache — avoids a DB round-trip on every event. Valid because tracking IDs are immutable after creation.
Idempotent aggregation — all rollup inserts use ON CONFLICT DO UPDATE, making backfills and retries safe.
visibilitychange over unload — unload is unreliable on mobile and deprecated; the tracker uses visibilitychange → hidden + sendBeacon instead.
Today's data — only GET /api/stats/:id/overview appends a live today row from raw events. The /pages, /clicks, and /scroll endpoints show no today data until the aggregation job runs.
Verified on Windows 11 (PowerShell 5.1), Node 22.19.0, npm 11.7.0, 2025-07-23. All 7 steps passed (one required a Windows-specific fix).
Confirm the following files exist before starting: README.md, server/package.json, client/package.json, server/.env.example, client/.env.example.
server/.env# Windows (PowerShell)
Copy-Item server\.env.example server\.env
# macOS / Linux
cp server/.env.example server/.env
Patch two defaults for local development:
| Variable | Default | Local override | Why |
|---|---|---|---|
PGSSL | true | false | Local Postgres has no SSL; pg rejects the connection otherwise |
DASHBOARD_URL | https://metrixis.vercel.app | http://localhost:5173 | CORS must allow the local Vite origin |
DATABASE_URL | placeholder | Your real Postgres URL | Required for all DB-backed routes |
CRON_SECRET | placeholder | Any long random string | Protects the internal aggregate endpoint |
SESSION_SECRET | missing from template | Long random string | Salts IP hashes; absent = unsalted (security gap T2) |
⚠️ SESSION_SECRET is not in .env.example — add it manually. Generate with:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
cd server
npm install
# → added 85 packages, no errors
Run once against your Postgres instance before starting the server:
psql "postgres://user:password@host:5432/metrixis" -f server/db/schema.sql
The schema creates: sites, events, daily_stats, daily_page_stats, daily_click_stats, daily_scroll_stats.
cd server
node index.js
# → Metrixis API listening on port 4000
# Health check
curl http://localhost:4000/api/health
# → {"ok":true}
The pool will log a background connection error if DATABASE_URL is a placeholder — this is non-fatal. The health endpoint and /tracker.js work without a database.
{"ok":true}client/.env and install frontend dependencies# Copy env (default value VITE_API_URL=http://localhost:4000 is correct)
cp client/.env.example client/.env # macOS/Linux
Copy-Item client\.env.example client\.env # Windows
cd client
npm install
# → added 67 packages; 4 advisory vulnerabilities in dev tooling (non-blocking)
⚠️ 4 vulnerabilities (1 moderate, 3 high) in Vite dev tooling. Do not run npm audit fix without testing — it may introduce breaking upgrades.
# macOS / Linux
cd client && npm run dev
# Windows (PowerShell) — npm is a .cmd shim; use cmd.exe as launcher:
Start-Process -FilePath "cmd.exe" -ArgumentList "/c","npm run dev" `
-NoNewWindow -WorkingDirectory "$PWD\client"
# → VITE v8.1.5 ready in ~1700 ms
# → Local: http://localhost:5173/
⚠️ Windows gotcha: Start-Process -FilePath "npm" fails with "%1 is not a valid Win32 application" because npm is a .cmd shim, not a native binary. Always launch via cmd.exe /c.
13 issues found. Ordered by severity (high → low). Each was verified by reading the actual source file referenced.
SESSION_SECRET undocumented — IP hashes silently unsaltedserver/routes/collect.js:37 salts every stored IP hash with process.env.SESSION_SECRET. That variable is absent from .env.example, render.yaml, and the README. When unset, every deployment world-wide computes sha256("<ip>undefined") — defeating the stated privacy guarantee.
PGSSL=true claim is inverted — SSL is on by default, only false disables itREADME and .env.example imply PGSSL=true enables SSL. Code reality (server/db/pool.js:9): ssl: process.env.PGSSL === 'false' ? false : { rejectUnauthorized: false }. SSL is always on unless you explicitly set PGSSL=false. The true value is a no-op.
POST /api/collect returns HTTP 202 in all three scenarios: success (accepted: true), unknown tracking ID (accepted: false), and unhandled exception (accepted: false). The README never mentions this. Operators debugging missing events have no documented signal to watch for.
README implies pageviews show up live for today. Only /overview appends a live today row. /pages, /clicks, /scroll return nothing for today until the aggregation job runs.
Sessions use sessionStorage (tab-scoped, cleared on tab close) with a 30-minute inactivity timer. Closing and reopening a tab within 30 min produces a new session. This affects session count metrics and is never described in the README.
curl example uses -G + -X POST — sends GET, returns 404-G/--get overrides -X POST; curl sends a GET. The route is router.post('/aggregate', ...) — a GET returns 404. Correct invocation:
curl -X POST "https://<render-url>/api/internal/aggregate?date=2026-07-23" \
-H "x-cron-secret: <CRON_SECRET>"
The schema comment says "salted SHA-256 of client IP". The code slices the hex digest to 16 characters (64 bits), not the full 256-bit hash. Collision probability is significantly higher than for a full SHA-256.
.env.exampleREADME says: "cp .env.example .env — fill in DATABASE_URL, CRON_SECRET". The template also contains PGSSL, DASHBOARD_URL, NODE_ENV, PORT — and SESSION_SECRET is missing from the template entirely.
| ID | Issue |
|---|---|
| L1 | client/.env.example location only implied by context, not stated explicitly. |
| L2 | render.yaml hardcodes author's Vercel URL (metrixis.vercel.app) as a real value, not a placeholder — silently breaks CORS on other deployments. |
| L3 | Port 4000 presented as fixed; code uses process.env.PORT || 4000 — misleading for hosted deployments. |
| L4 | Port 5173 correct today but not pinned in vite.config.js — Vite auto-increments if port is busy. |
| L5 | country always-null claim is self-reported and verified correct (no drift) — listed for completeness. |
Five concrete tasks ordered easy → harder. None requires a running database or Render account.
curl backfill command SFile: README.md lines 70–73
The -G flag overrides -X POST; the command sends a GET and gets a 404. Replace with:
curl -X POST \
"https://<your-render-url>/api/internal/aggregate?date=2026-07-23" \
-H "x-cron-secret: <your CRON_SECRET>"
Verify: Read server/routes/internal.js — confirm route is router.post and reads req.query.date.
SESSION_SECRET env var SFiles: server/.env.example, server/render.yaml, README.md
Add SESSION_SECRET to all three. In render.yaml use generateValue: true so Render auto-generates a secure value on first deploy.
Verify: grep SESSION_SECRET server/.env.example server/render.yaml README.md — all three must match.
parseUserAgent MFiles: server/package.json (add Jest), create server/utils/userAgent.test.js
server/utils/userAgent.js has 7 regex branches with ordering subtleties (Edge contains Chrome; Chrome contains Safari). Zero test coverage today. Regressions silently corrupt device/browser stats.
Cover: Chrome/Windows, Edge/Windows, Safari/macOS, Safari/iOS, Chrome/Android, Safari/iPad, Firefox, and empty string.
Verify: cd server && npm test — all cases pass without modifying the function.
File: client/src/pages/SiteDashboardPage.jsx
DELETE /api/sites/:id is implemented server-side; api.deleteSite(id) exists client-side. Neither is reachable from the UI — a site can only be deleted via raw curl. Add a "Delete site" button with a window.confirm guard to the SiteDashboardPage, call api.deleteSite, then refreshSites() and navigate('/').
Verify: Add a test site → navigate to its dashboard → delete → confirm app returns to home without page reload and site is gone.
country column using a free geo-IP lookup LFiles: server/routes/collect.js, README.md
The country column in events is always null. Add a non-blocking lookupCountry(ip) helper that queries a free geo-IP API (e.g. ip-api.com) with a 500 ms timeout via Promise.race, falling back to null on any failure or timeout. Run the lookup before calling ingestEvent (not inside it) and pass country in.
Note: ip-api.com free tier = 45 requests/min; comment this and note MaxMind mmdb as the production upgrade path.
| # | Gap | Files | Effort |
|---|---|---|---|
| T1 | Broken curl backfill command | README.md | S |
| T2 | SESSION_SECRET undocumented | README.md, .env.example, render.yaml | S |
| T3 | Zero test coverage for parseUserAgent | userAgent.test.js (new) | M |
| T4 | Delete route unreachable from UI | SiteDashboardPage.jsx | M |
| T5 | country always null — geo-IP not implemented | collect.js, README.md | L |
8 questions. Click Show answer to reveal each answer.
POST /api/collect always return, and why?accepted: true), unknown tracking ID (accepted: false), and any unhandled exception (accepted: false). The design choice prevents tracking from breaking the host website's UX — errors are swallowed server-side. The accepted boolean in the body is the real signal to watch for in server logs.
/pages, /clicks, and /scroll endpoints query only the daily_* rollup tables — there is no live today fallback for these endpoints. Only /overview appends a live today row. Fix: trigger the job manually:
node jobs/aggregate.js
or call the internal HTTP endpoint with your CRON_SECRET.
SESSION_SECRET is not set in the environment?"<ip>undefined". process.env.SESSION_SECRET evaluates to the string "undefined" when the var is absent, so every deployment in the world uses the same implicit "salt". Hashes become comparable across deployments, defeating the stated privacy guarantee. Set a long random value unique per deployment. Generate one with:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
PGSSL=true enable SSL for the Postgres connection?ssl: process.env.PGSSL === 'false' ? false : { rejectUnauthorized: false }. SSL is active for any value of PGSSL other than the string "false". Setting PGSSL=true has exactly the same effect as not setting it at all. The only meaningful value is PGSSL=false to disable SSL for a local Postgres without a certificate.
sessionStorage, which is tab-scoped and cleared when the tab is closed. Even though the 30-minute inactivity timer hasn't expired, closing the tab destroys the session. The visitor's visitor_id persists in localStorage (so they're still the same visitor), but session_id will be freshly generated on the new tab load.
visibilitychange → hidden and sends queued events via navigator.sendBeacon. Regular fetch calls may be cancelled by the browser when a page unloads. sendBeacon is a fire-and-forget API that the browser guarantees to deliver even after the page is torn down. The tracker also flushes every 5 seconds via fetch with keepalive: true for in-page batching.
curl command to manually trigger the aggregation job and get a 404. What's wrong?-G alongside -X POST. The -G/--get flag overrides -X POST, so curl sends a GET request. The Express route is registered as router.post('/aggregate', ...) — a GET returns 404. The correct command is:
curl -X POST \
"https://<render-url>/api/internal/aggregate?date=2026-07-23" \
-H "x-cron-secret: <CRON_SECRET>"
Note: the date is a query-string parameter, not a request body.
daily_* rollup tables uses ON CONFLICT DO UPDATE with a composite primary key (site + date + optional dimension). Re-running for the same date overwrites the existing row with the same values rather than creating duplicates. All four tables are updated in a single database transaction (all-or-nothing), so a partial failure leaves them consistent.