Privacy-first, cookie-free analytics engine for Manhali tenants. It runs on Cloudflare Workers, stores events in Analytics Engine, validates browser origins through KV, and exposes query APIs for the platform dashboard.
See ANALYTICS_ARCHITECTURE.md for the internal data model and flow details.
Browser ── GET /tracker.js?sid=... ───────────────→ Analytics Worker
Browser ── POST /api/collect ── writeDataPoint ─→ Analytics Engine
Backend ── POST /api/collect (Bearer + tid) ────→ Analytics Worker
Dashboard ── GET /api/* (admin/scoped token) ───→ Worker ── SQL API ──→ Analytics Engine
Admin ── /api/admin/domains , /api/admin/token ─→ Worker ── KV / HMAC ─→ DOMAINS_KV
Storage
- Analytics Engine: event storage and live analytics queries, with 90-day retention
- KV (
DOMAINS_KV): per-tenant domain allowlist used by browser collection
# Install dependencies
bun install
# Start local dev server
bun run dev
# → Wrangler dev (configured: http://localhost:8790)
# Run tests
bun run test
# Deploy to Cloudflare
bun run deploy
# View real-time logs
bun run tail# KV namespace for per-tenant domain allowlists
wrangler kv namespace create DOMAINS_KVSet the KV namespace ID and your Cloudflare account ID.
[[kv_namespaces]]
binding = "DOMAINS_KV"
id = "your-kv-namespace-id"
[vars]
ACCOUNT_ID = "your-account-id"
ENVIRONMENT = "production"Optional runtime vars:
[vars]
ALLOW_UNREGISTERED_DOMAINS = "true" # optional setup grace period for browser collectwrangler secret put AUTH_TOKEN # admin API token + signing secret for scoped tokens
wrangler secret put CF_API_TOKEN # Cloudflare API token for Analytics Engine SQL queries
wrangler secret put FINGERPRINT_SECRET # dedicated visitor-hash secret
wrangler secret put INGEST_TOKEN # optional dedicated token for server-to-server ingestionbun run deployBrowser-origin collection expects domains to be registered per tenant in production.
The backend syncs domains when tenants/custom domains are created or changed. For
existing production tenants, run the backfill once from apps/backend:
ANALYTICS_WORKER_URL=https://analytics.worker.manhali.com \
ANALYTICS_AUTH_TOKEN=YOUR_AUTH_TOKEN \
bun run sync:analytics-domainscurl -X POST "https://analytics.example.com/api/admin/domains?tenant_id=TENANT_ID" \
-H "Authorization: Bearer YOUR_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"domain": "tenant-site.com"}'Use the admin token to mint a tenant-scoped token for dashboards or internal services that should only read one tenant's analytics.
curl -X POST "https://analytics.example.com/api/admin/token" \
-H "Authorization: Bearer YOUR_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"tenant_id":"TENANT_ID","ttl":86400}'Add the tracker script to any tenant page:
<script
src="https://analytics.example.com/tracker.js?sid=TENANT_SITE_ID"
defer
></script>The tracker automatically:
- Sends a
pageviewevent on initial load - Sends a
beaconevent with time-on-page onvisibilitychangeandpagehide - Tracks SPA navigation via the History API (
pushState,replaceState,popstate) - Re-emits a pageview when the page is restored from the back-forward cache
- Extracts UTM parameters from the page URL
- Supports custom events via
window.manhali.track("event_name") - Supports manual pageview dispatch via
window.manhali.pageview()
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check |
GET |
/tracker.js?sid=SITE_ID |
Parameterized tracker script |
POST |
/api/collect |
Browser or server-to-server event collection |
Query routes require Authorization: Bearer <token> and ?tenant_id=.
- Admin token: unrestricted access
- Scoped token: restricted to one tenant only
| Method | Path | Query Params | Description |
|---|---|---|---|
GET |
/api/overview |
days (1/7/30/90, default 30) |
Totals and average time-on-page |
GET |
/api/pages |
days, limit (10/20/50/100) |
Top pages by views |
GET |
/api/referrers |
days, limit |
Top referrer domains plus UTM data |
GET |
/api/geo |
days |
Country and city breakdown |
GET |
/api/devices |
days |
Desktop / Mobile / Tablet breakdown |
GET |
/api/realtime |
— | Visitors active in the last 30 minutes |
GET |
/api/events |
days, limit |
Custom event totals and unique daily visitors |
GET |
/api/sections |
days, limit |
Cached dashboard section bundle |
Positive numeric values between supported buckets round up to the next bucket; invalid values use the documented default. This deliberately bounds query-cache cardinality. Query routes are also limited to 60 requests/minute per Cloudflare location, both per token and per tenant-and-token pair, preventing an admin token from evading the bound with random tenant IDs. They fail closed outside local development when the binding is unavailable.
Admin routes require the admin AUTH_TOKEN. Scoped tenant tokens are rejected.
| Method | Path | Query Params | Description |
|---|---|---|---|
GET |
/api/admin/domains |
tenant_id |
List allowed domains for a tenant |
POST |
/api/admin/domains |
tenant_id |
Add domain {"domain":"example.com"} |
DELETE |
/api/admin/domains |
tenant_id |
Remove domain {"domain":"example.com"} |
POST |
/api/admin/token |
— | Create a tenant-scoped HMAC token |
Tenant resolution priority on collect:
tid+ valid Bearer token for server-to-server ingestionsid+ allowed browser origin for tracker requests- Development fallback to the request origin hostname when
ENVIRONMENT=development
wrangler.toml defaults to ENVIRONMENT=production so deployments enforce the KV domain allowlist unless you explicitly override it for local development.
Trust model for browser collection. Like every client-side beacon endpoint (GA, Plausible, Fathom),
/api/collectis unauthenticated and theOrigin/Refererallowlist is only a best-effort filter — a non-browser client can spoof those headers, and thesidis public (it ships in/tracker.js). Treat browser-submitted analytics as best-effort, not tamper-proof; use the server-to-servertid+ Bearer path for trusted data. Per Cloudflare location, the Worker enforces 120 events/minute per IP (60 complete pageview+beacon lifecycles) and 1,200 events/minute per site and origin before domain KV resolution. Never leaveALLOW_UNREGISTERED_DOMAINS="true"enabled outside a short onboarding window (it disables origin checks entirely).
| Binding | Type | Purpose |
|---|---|---|
ANALYTICS |
Analytics Engine | Event storage via writeDataPoint() |
DOMAINS_KV |
KV | Per-tenant domain allowlist |
COLLECT_RATE_LIMIT |
Rate Limit | Public collect quota per client IP |
SITE_COLLECT_RATE_LIMIT |
Rate Limit | Site and origin quota before KV lookup |
QUERY_RATE_LIMIT |
Rate Limit | Authenticated reads per tenant/token subject |
| Variable | Source | Purpose |
|---|---|---|
AUTH_TOKEN |
Secret | Admin API token and scoped-token signing secret |
CF_API_TOKEN |
Secret | Cloudflare API token for Analytics Engine SQL queries |
FINGERPRINT_SECRET |
Secret | Dedicated secret for privacy-safe visitor hashing |
INGEST_TOKEN |
Secret | Optional separate token for server-to-server ingestion |
ACCOUNT_ID |
Var | Cloudflare account ID |
ENVIRONMENT |
Var | development, staging, or production |
ALLOW_UNREGISTERED_DOMAINS |
Var | Optional "true" grace period for unregistered site domains |
{ "t": "pageview", // "pageview" | "beacon" | "event" "p": "/courses/intro", // page path (required) "r": "https://google.com", // document.referrer "d": 42, // duration in seconds (beacon) "sid": "tenant_123", // browser tracker site ID "tid": "tenant_123", // server-to-server tenant ID (requires Bearer auth) "en": "signup_click", // custom event name "us": "google", // utm_source "um": "cpc", // utm_medium "uc": "spring_sale", // utm_campaign "co": "EG", // optional country override, server-to-server only "ci": "Cairo", // optional city override, server-to-server only }