📊

Metrixis — Day-One Guide

Self-hosted web analytics · Node/Express + React/Vite + PostgreSQL

Node 22 Express 4 React + Vite PostgreSQL

1 · Overview

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.

ParticipantTechPurpose
Tracker scriptVanilla JS (IIFE)Embedded on any tracked website; collects events client-side
API serverNode.js + Express, port 4000Receives events, serves stats, runs background aggregation
DashboardReact + Vite, port 5173Single-page app; reads stats via the API; no authentication

Privacy model

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.

Security zones

The API exposes three distinct security contexts:

ZoneRouteAccess
Public ingestPOST /api/collectAny origin (CORS origin: true)
Dashboard reads/api/sites, /api/stats/*Restricted to DASHBOARD_URL
Internal cronPOST /api/internal/aggregateCRON_SECRET header, no CORS

2 · Architecture Diagram

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

┌─────────────────────────────────────────────────────────────────────────────┐ │ TRACKED WEBSITE (any domain) │ │ tracker.js (IIFE) ──────────────────────────────────────────┐ │ │ • fires pageview on load │ │ │ • fires scroll at 25/50/75/100% │ │ │ • delegates click on [data-track] elements │ │ │ • batches events; flushes every 5 s via fetch │ │ │ • flushes on tab hide via navigator.sendBeacon │ │ └──────────────────────────────────────────────────────────────┼──────────────┘ │ POST /api/collect ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ API SERVER Node/Express :4000 │ │ │ │ GET /tracker.js ──────────────────────► serves IIFE static file │ │ POST /api/collect ──► validate → cache lookup ──► INSERT events │ │ GET /api/sites ──► SELECT sites ◄── dashboard CORS only │ │ GET /api/stats/:id/* ──► SELECT daily_* + live today ◄── dash CORS │ │ POST /api/internal/aggregate ─► runs aggregate.js (CRON_SECRET required) │ │ GET /api/health ──► {"ok":true} │ └──────────────────────────────────┬──────────────────────────────────────────┘ │ read / write ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ POSTGRESQL │ │ │ │ sites (tracking_id registry, MTX_ prefix) │ │ events (raw append-only firehose — pageview | click | scroll) │ │ daily_stats ◄─ jobs/aggregate.js (runs for previous day, UTC) │ │ daily_page_stats ◄─ idempotent ON CONFLICT DO UPDATE │ │ daily_click_stats ◄─ all four written in one transaction │ │ daily_scroll_stats ◄─ │ └─────────────────────────────────────────────────────────────────────────────┘ ▲ │ fetch /api/sites /api/stats/* ┌─────────────────────────────────────────────────────────────────────────────┐ │ DASHBOARD React/Vite :5173 │ │ │ │ / → HomePage (list all sites) │ │ /sites/new → AddSitePage (register site) │ │ /sites/:siteId → SiteDashboardPage (analytics) │ │ │ │ api/client.js — thin fetch wrapper, base URL from VITE_API_URL │ │ No auth, no cookies, no token storage │ └─────────────────────────────────────────────────────────────────────────────┘
Tracker (IIFE)
API Server (Express)
PostgreSQL
Dashboard (React)

Key design decisions

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.

3 · Verified Setup Steps

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

4 · Documentation Contradictions

13 issues found. Ordered by severity (high → low). Each was verified by reading the actual source file referenced.

HIGH — Must fix before first deploy

H1 · SESSION_SECRET undocumented — IP hashes silently unsalted

server/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.

H2 · PGSSL=true claim is inverted — SSL is on by default, only false disables it

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

H3 · Always-202 collect response and silent failure modes never described

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.

MEDIUM — Fix before sharing docs with users

M1 · "Live today" pageviews only on overview endpoint, not on pages/clicks/scroll

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.

M2 · Session 30-min timeout and tab-scoped storage not documented

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.

M3 · 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>"
M4 · Schema says "SHA-256 hash" — stored value is first 64 bits only

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.

M5 · Local-dev setup callout omits several env vars from .env.example

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

LOW — Nice to fix, low operator impact

IDIssue
L1client/.env.example location only implied by context, not stated explicitly.
L2render.yaml hardcodes author's Vercel URL (metrixis.vercel.app) as a real value, not a placeholder — silently breaks CORS on other deployments.
L3Port 4000 presented as fixed; code uses process.env.PORT || 4000 — misleading for hosted deployments.
L4Port 5173 correct today but not pinned in vite.config.js — Vite auto-increments if port is busy.
L5country always-null claim is self-reported and verified correct (no drift) — listed for completeness.

5 · First Tasks for New Contributors

Five concrete tasks ordered easy → harder. None requires a running database or Render account.

T1 · Fix the broken curl backfill command S

File: 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.

T2 · Document the missing SESSION_SECRET env var S

Files: 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.

T3 · Write unit tests for parseUserAgent M

Files: 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.

T4 · Wire the existing delete route into the dashboard UI M

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.

T5 · Populate the country column using a free geo-IP lookup L

Files: 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.

#GapFilesEffort
T1Broken curl backfill commandREADME.mdS
T2SESSION_SECRET undocumentedREADME.md, .env.example, render.yamlS
T3Zero test coverage for parseUserAgentuserAgent.test.js (new)M
T4Delete route unreachable from UISiteDashboardPage.jsxM
T5country always null — geo-IP not implementedcollect.js, README.mdL

6 · Comprehension Quiz

8 questions. Click Show answer to reveal each answer.

Q1
What HTTP status code does POST /api/collect always return, and why?
Show answer
202 Accepted — always, regardless of outcome. The endpoint returns 202 in three cases: successful ingest (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.
Q2
You add a tracked website, visit it twice, and open the dashboard. The Top Pages chart shows nothing for today. Why, and how do you fix it?
Show answer
The aggregation job hasn't run for today yet. /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.
Q3
What happens when SESSION_SECRET is not set in the environment?
Show answer
IP hashes are computed over "<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'))"
Q4
Does setting PGSSL=true enable SSL for the Postgres connection?
Show answer
No — SSL is already on by default. The pool code is: 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.
Q5
A user visits your site, closes the tab, and reopens it 10 minutes later. Does their second visit count as the same session?
Show answer
No — it counts as a new session. Session IDs are stored in 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.
Q6
How does the tracker script ensure events aren't lost when a user navigates away or closes the tab?
Show answer
It listens for 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.
Q7
You run the README's curl command to manually trigger the aggregation job and get a 404. What's wrong?
Show answer
The README example uses -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.
Q8
Is it safe to re-run the aggregation job for the same date multiple times? Why?
Show answer
Yes — the job is fully idempotent. Every INSERT into the four 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.