Skip to content

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.

  • 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-GPC if the site enables data-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:

  1. If any filter is set, or the range is ≤ 48 hours, query raw events.
  2. Otherwise split the range into: complete local days already rolled up (daily_* tables) + the remainder (raw events), and merge. Day-based rollups are only used when tz equals the website timezone; otherwise fall back to raw.
  3. 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 Cloudflare
packages/
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, privacy

Coding standards

  • TypeScript strict everywhere (noUncheckedIndexedAccess, no any).
  • Biome for lint + format (npm run lint, npm run format).
  • Worker types come from wrangler types (never hand-written Env).
  • 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.