Architecture
Conveyor is a privacy-first, cookieless web analytics tool (feature set modelled on Umami) designed to run on one Cloudflare Worker on the Workers Free plan.
Free-plan budget (verified October 2026)
| Resource | Free limit | How Conveyor stays inside it |
|---|---|---|
| Worker requests | 100k / day | Only /api/* invokes the Worker. Dashboard and script.js are static assets (free, unlimited, edge-cached). One tracked event = one request. |
| Worker CPU | 10 ms / request | No heavy parsing libraries. Aggregation runs inside D1 (SQL), not in JS. PBKDF2 only on login (rare; isolates tolerate occasional overruns). |
| D1 rows written | 100k / day | Exactly one row per event (no secondary index on events, session data denormalised). |
| D1 rows read | 5M / day | Clustered PK (website_id, ts, id) → range scans only. Completed days are rolled up into daily_stats / daily_metrics. Historical responses are cached with the Cache API. |
| D1 size | 500 MB / database | Raw events are pruned after RAW_RETENTION_DAYS (default 90, ~300 B/event ⇒ ~1.5M raw events fit); rollups are kept forever, so long-range charts survive pruning. |
| D1 queries | 50 / invocation | Batched queries (db.batch) and small per-run work units in the cron. |
| Cron | 10 ms CPU | Cron only issues INSERT … SELECT statements; D1 does the work. |
Runtime layout
┌─────────────── Cloudflare Worker "conveyor" ────────────────┐browser ──GET──▶│ static assets (dashboard SPA, /script.js) ← free │tracker ──POST─▶│ /api/send → validate → enrich (cf geo, UA) → D1 insert │dashboard ─────▶│ /api/* → Hono router → auth → query D1 (+ Cache API) │cron (hourly) ─▶│ scheduled() → rotate salts, roll up days, prune raw │ └─────────────────────────────────────────────────────────┘wrangler.jsonc sets assets.run_worker_first: ["/api/*"] and
not_found_handling: "single-page-application", so everything except the API
is served by Cloudflare’s asset layer without invoking (or billing) the Worker.
Privacy model (why no cookie banner is needed)
- No cookies, localStorage, or fingerprinting storage on the visitor’s device.
- Visitor id =
SHA-256(dailySalt ‖ websiteId ‖ ip ‖ userAgent)truncated. The salt rotates every UTC day and old salts are deleted, so ids cannot be linked across days or reversed to an IP. - IP addresses and full user agents are never stored. Only coarse derived
values: browser/OS/device family, country/region/city from Cloudflare’s edge
(
request.cf), city-level lat/lon for the live map. - Respects
DNT/Sec-GPCif the site enablesdata-do-not-track. - Consequence: “visitors” are unique per day; multi-day ranges sum daily
uniques (same model as Plausible). Cross-day retention is only available
for visitors a site explicitly
identify()s.
This is the same approach used by Plausible/Fathom, which data protection authorities have generally accepted as not requiring consent under the ePrivacy Directive because nothing is stored on or read from the device. (Not legal advice; site owners remain responsible for their privacy policy.)
Visits
A visit is a run of events from one visitor with gaps < 30 minutes. At ingest
the Worker looks up session_id → visit_id in the Cache API (per data
centre, TTL 30 min, refreshed on every event). A miss starts a new visit.
Visitors almost always hit the same data centre, so this is accurate in
practice and costs zero D1 reads.
Query strategy
For a stats request over [startAt, endAt) in timezone tz:
- If any filter is set, or the range is ≤ 48 hours, query raw
events. - Otherwise split the range into: complete local days already rolled up
(
daily_*tables) + the remainder (rawevents), and merge. Day-based rollups are only used whentzequals the website timezone; otherwise fall back to raw. - Responses for ranges that end before the last rollup are immutable and are cached via the Cache API (key = normalised URL + website id).
Repository layout
apps/ worker/ Cloudflare Worker (Hono): ingest, API, cron, D1 migrations dashboard/ Preact + Vite SPA, built into apps/worker/public website/ Astro marketing site and docs (Starlight), static assets on Cloudflarepackages/ shared/ API contract types, filter encoding, constants (no runtime deps) tracker/ ~2 KB tracker script, built into apps/worker/public/script.js ui/ Design tokens and the Tailwind v4 theme shared by dashboard and website (CSS only)docs/ architecture, API, deployment, privacyCoding standards
- TypeScript strict everywhere (
noUncheckedIndexedAccess, noany). - Biome for lint + format (
npm run lint,npm run format). - Worker types come from
wrangler types(never hand-writtenEnv). - All SQL lives in
apps/worker/src/db/and uses bound parameters only. - Input validation at the edge with Valibot schemas.
- Tests: Vitest with
@cloudflare/vitest-plugin(real workerd + D1), fast-check property tests, Playwright E2E.