Privacy-first web analytics that runs entirely on Cloudflare. No cookies, no external dependencies, 5-minute setup.
Website: flarelytics.dev
- 100% Cloudflare — Workers + Analytics Engine. No databases, no servers, no third-party services.
- Privacy by architecture — No cookies. No fingerprinting. Daily-rotating visitor hash that resets every 24 hours. GDPR/CCPA compliant without a cookie banner.
- Under 2KB gzipped — The tracking script is smaller than most cookie consent popups.
- Custom events — Track signups, purchases, clicks, or anything else with
flarelytics.track('event', { props }). - Scroll depth — Optional IntersectionObserver-based tracking at 25/50/75/100% milestones.
- Email reports — Weekly digests with traffic trends, top pages, and anomaly alerts.
- Open source — MIT licensed. Self-host on your own Cloudflare account.
git clone https://github.com/kalle-works/flarelytics.git
cd flarelytics && npm install
cd packages/worker
# Edit wrangler.toml with your account_id and allowed origins
npm run deploy # builds the tracker bundle, then wrangler deploy
npx wrangler secret put QUERY_API_KEY # random string for dashboard auth
npx wrangler secret put CF_API_TOKEN # CF API token (Analytics Engine read)
npx wrangler secret put CF_ACCOUNT_ID # your CF account IDQuick setup: Run
bash setup.shin the worker directory for an interactive guided setup that configures everything automatically.
<!-- Basic tracking -->
<script defer data-endpoint="https://your-worker.workers.dev" src="/tracker.js"></script>
<!-- With scroll depth tracking -->
<script defer data-endpoint="https://your-worker.workers.dev" data-scroll-depth src="/tracker.js"></script>Pageviews, outbound link clicks, and time-on-page are tracked automatically, including
client-side navigation in single-page apps (React Router, Vue Router, Astro view
transitions, etc.) — a route change fires a pageview without a full page reload. The
tracker also skips sending anything while running on localhost/127.0.0.1/file://,
so a plain npm run dev session doesn't send test traffic into production. Both are
configurable via script-tag attributes:
<!-- Disable automatic SPA pageview tracking -->
<script defer data-endpoint="https://your-worker.workers.dev" data-no-spa src="/tracker.js"></script>
<!-- Send events even on localhost (useful when testing against a real worker) -->
<script defer data-endpoint="https://your-worker.workers.dev" data-allow-localhost src="/tracker.js"></script>Or with npm:
npm install @flarelytics/trackerimport { init, track } from '@flarelytics/tracker'
// Basic
init('https://your-worker.workers.dev')
// With scroll depth tracking at 25/50/75/100% milestones
init('https://your-worker.workers.dev', { scrollDepth: true })
// Disable SPA navigation tracking, or send events on localhost
init('https://your-worker.workers.dev', { noSpa: true, allowLocalhost: true })
// Track custom events
track('signup', { props: { plan: 'pro' } })# Check the worker is running
curl https://your-worker.workers.dev/health
# → { "status": "healthy", "checks": { "analytics_binding": true } }
# Visit your site, then check data is flowing (wait ~30 seconds)
curl -H "X-API-Key: your-api-key" \
"https://your-worker.workers.dev/query?q=daily-views&period=7d&site=yoursite.com"
# → { "data": [{ "date": "2026-04-12", "views": 1 }] }Visit the dashboard and sign in with SSO — you'll see the sites your organization owns, with an org switcher when you belong to more than one.
The dashboard talks to a single worker (set PUBLIC_API_BASE at build time, or
append ?worker=https://your-worker.workers.dev for local/self-hosted use).
Programmatic access still uses the X-API-Key header (see the API examples
below); the browser dashboard no longer stores any key.
packages/
worker/ Cloudflare Worker: event tracking + query API
tracker/ Client-side script (under 2KB gzipped): pageviews, outbound links, custom events, scroll depth
dashboard/ Astro static site: analytics dashboard with charts and tables
email-reports/ Cloudflare Worker cron: weekly email digests
landing/ Astro static site: flarelytics.dev marketing + docs
| Event | Tracked automatically | Description |
|---|---|---|
pageview |
Yes | Page load with referrer and UTM params |
outbound |
Yes | External link clicks |
timing |
Yes | Time on page in seconds (fires on visibilitychange) |
scroll_depth |
Opt-in | Scroll milestones at 25/50/75/100% |
bot_hit |
Yes | Bot traffic recorded separately (UA in blob5) |
(custom) |
Manual | Any event via flarelytics.track() |
POST /track
Content-Type: application/json
{
"event": "pageview",
"path": "/pricing",
"referrer": "google.com",
"utm_source": "newsletter"
}POST /track
{
"event": "signup",
"path": "/pricing",
"props": { "plan": "pro", "source": "hero-cta" }
}GET /query?q=<query-name>&period=30d&site=yoursite.com
X-API-Key: your-api-keyPeriods: 7d, 14d, 30d, 60d, 90d, 180d
Traffic
| Query | Params | Description |
|---|---|---|
top-pages |
Most viewed pages | |
top-pages-visitors |
Top pages with views and unique visitor counts | |
top-pages-stories |
Top pages where path starts with /a/ |
|
daily-views |
Pageviews per day | |
daily-unique-visitors |
Unique visitors per day (+ total views) | |
total-sessions |
Total sessions in period. A session is a timing event, which only fires after >1s of dwell time — pages left immediately aren't counted |
|
total-pageviews |
Total pageviews in period | |
total-visitors |
Total unique visitors in period |
Referrers & Acquisition
| Query | Params | Description |
|---|---|---|
referrers |
Top referrer hostnames | |
referrers-by-page |
?page=/path |
Referrer breakdown for a specific page |
utm-campaigns |
UTM campaign totals (source, medium, campaign) | |
utm-campaign-trend |
Daily UTM visits — when each post drove traffic | |
utm-by-page |
?page=/path |
UTM campaign breakdown for a specific page |
Content & Engagement
| Query | Params | Description |
|---|---|---|
page-views-over-time |
?page=/path |
Daily views + visitors for one page |
page-timing |
Average time on page in seconds per path | |
timing-by-page |
?page=/path |
Average time on page for a specific page |
bounce-rate-by-page |
?event_name=N |
Bounce % per page (threshold seconds, default 10). Counts timing events (sessions with >1s dwell), so pages left within 1s aren't in the denominator at all |
scroll-depth |
Scroll depth distribution: how far visitors scroll across all pages | |
scroll-depth-by-page |
Scroll depth breakdown per page | |
scroll-depth-for-page |
?page=/path |
Scroll depth distribution for a specific page |
Geography & Devices
| Query | Params | Description |
|---|---|---|
countries |
Views by country | |
countries-by-page |
?page=/path |
Country breakdown for one page |
devices |
Pageviews by device type (mobile/tablet/desktop) | |
browsers |
Pageviews by browser | |
operating-systems |
Pageviews by operating system (Windows/macOS/iOS/Android/Linux/ChromeOS) |
Revenue
| Query | Params | Description |
|---|---|---|
revenue-by-event |
Events with a revenue value — total, count, average (requires value in /track calls) |
|
revenue-over-time |
Daily revenue totals and conversion counts |
Conversions
| Query | Params | Description |
|---|---|---|
outbound-links |
External link click destinations | |
page-performance |
Pages with views vs custom event CTR | |
custom-events |
Custom event counts by name and properties | |
conversion-funnel |
Daily pageviews to custom events | |
funnel-by-event |
?event_name=signup |
Daily funnel for a specific custom event |
Live (30-minute window)
| Query | Params | Description |
|---|---|---|
live-visitors |
Visitors and pageviews in the last 30 minutes | |
live-pages |
Most visited pages in the last 30 minutes | |
live-referrers |
Top referrers in the last 30 minutes | |
hourly-today |
Pageviews by hour for the last 24 hours |
Bot Reporting
| Query | Params | Description |
|---|---|---|
bot-hits |
Top bot user-agents | |
bot-hits-total |
Total bot hit count for the period | |
bot-pages |
Pages most targeted by bots | |
bot-daily |
Bot hits per day (trend) | |
bot-countries |
Countries where bot traffic originates |
GET /query?q=scroll-depth-by-page&period=30d&site=yoursite.com
X-API-Key: your-api-key{
"data": [
{ "path": "/a/my-article", "depth": "25", "count": 142 },
{ "path": "/a/my-article", "depth": "50", "count": 98 },
{ "path": "/a/my-article", "depth": "75", "count": 61 },
{ "path": "/a/my-article", "depth": "100", "count": 34 }
]
}GET /query?q=utm-campaign-trend&period=30d&site=yoursite.com
X-API-Key: your-api-key{
"data": [
{ "date": "2026-04-08", "utm_source": "bluesky", "utm_campaign": "post-abc123", "visits": 47 },
{ "date": "2026-04-09", "utm_source": "bluesky", "utm_campaign": "post-abc123", "visits": 12 }
]
}GET /query?q=bounce-rate-by-page&period=30d&site=yoursite.com&event_name=30
X-API-Key: your-api-key{
"data": [
{ "path": "/", "bounced": 120, "sessions": 340, "bounce_pct": 35.3 },
{ "path": "/pricing", "bounced": 45, "sessions": 210, "bounce_pct": 21.4 }
]
}GET /public-stats?site=yoursite.comReturns a summary of the last 30 days: pageviews, visitors, top pages, referrers, countries, devices, daily views, and bot hit total. Useful for public analytics pages.
{
"period": "30d",
"site": "yoursite.com",
"stats": {
"pageviews": 1234,
"visitors": 567,
"topPages": [{ "path": "/", "views": 300 }],
"referrers": [{ "referrer": "google.com", "visits": 120 }],
"countries": [{ "country": "FI", "views": 400 }],
"devices": [{ "device": "desktop", "views": 800 }],
"dailyViews": [{ "date": "2026-04-12", "views": 45 }],
"botHitsTotal": 89
}
}GET /health{ "status": "healthy", "checks": { "analytics_binding": true, ... }, "version": "0.2.0" }The v0 queries above return the raw Cloudflare Analytics Engine envelope ({ data: [...] }). The v1 family is a separate, structured query surface that aggregates multiple parallel SQL calls inside the worker and returns a strongly typed payload. v1 queries read from the per-family v1 datasets (pageview/engagement/share) introduced during the schema migration.
Dispatch by adding v=1:
GET /query?v=1&q=loop-overview&site=yoursite.com&period=30d
X-API-Key: your-api-keyAvailable v1 queries (also discoverable via GET /config → queries_v1):
| Query | Description |
|---|---|
loop-overview |
Distribution Loop view: shares → social inbound → engaged reads → quality score, surfaced at canonical_url_hash level |
Example response (loop-overview):
{
"period": "'30' DAY",
"site": "yoursite.com",
"partial": false,
"status": {
"shares": "ok", "pageviews": "ok", "paths": "ok",
"engagement": "ok", "socialInbound": "ok", "sharesTotal": "ok"
},
"kpis": {
"articles_driving_shares": 47,
"inbound_visits_from_social": 2341,
"secondary_share_rate": 11.66,
"avg_distribution_quality_score": 6.8
},
"articles": [
{
"canonical_url_hash": "a1b2c3d4e5f6",
"path": "/articles/breaking",
"first_seen": "2026-05-01T10:00:00Z",
"shares_out": 125,
"inbound_visits": 846,
"engaged_reads": 488,
"quality_score": 58
}
]
}When one or more underlying SQL buckets fails, the worker still returns 200 with partial: true and the affected KPIs as null (the dashboard renders —). The v0 query path is untouched — omit v=1 and you get the v0 contract.
Flarelytics collects only what you need to understand your traffic:
| Collected | Why |
|---|---|
| Page path | Know which pages are visited |
| Referrer hostname | Know where traffic comes from |
| Country (from CF headers) | Geographic distribution |
| Daily visitor hash | Unique visitor count (resets daily) |
| UTM parameters | Campaign tracking |
| Device type, browser | Audience breakdown |
| Time on page (seconds) | Engagement measurement |
| Scroll depth milestones | Content engagement |
| Custom event data | Your defined events |
Not collected: IP addresses, raw user agents, cookies, device fingerprints, personal data.
The daily visitor hash is SHA-256 of IP + User-Agent + date. It resets every midnight UTC — impossible to track users across days.
Each event writes one row to Cloudflare Analytics Engine:
| Field | Value |
|---|---|
blob1 |
Page path |
blob2 |
Referrer hostname (direct if none) |
blob3 |
Country code (from CF headers) |
blob4 |
Event name (pageview, timing, scroll_depth, custom) |
blob5 |
Event properties (pipe-separated values) |
blob6 |
utm_source |
blob7 |
utm_medium |
blob8 |
utm_campaign |
blob9 |
Visitor hash (daily-rotating) |
blob10 |
Site hostname (for multi-site support) |
blob11 |
Device type (mobile/tablet/desktop) |
blob12 |
Browser name |
blob13 |
Operating system (Windows/macOS/iOS/Android/Linux/ChromeOS/Other) |
double1 |
Event count (always 1) |
double2 |
Time on page in seconds (only for timing events) |
double3 |
Revenue/conversion value (0 unless value is sent in a /track call) |
| Feature | Flarelytics | Google Analytics | Plausible | Counterscale |
|---|---|---|---|---|
| Privacy-first | Yes | No | Yes | Yes |
| No cookies | Yes | No | Yes | Yes |
| Self-hosted | Yes | No | Yes | Yes |
| Runs on CF only | Yes | No | No | Yes |
| Scroll depth | Yes | Yes | No | No |
| Custom events | Yes | Yes | Yes | Limited |
| Email reports | Yes | Yes | Yes | No |
| Data retention | 90d (AE) | Unlimited | Unlimited | 90d |
| Setup time | 5 min | 10 min | 30 min | 5 min |
| Cost | Free (CF free tier) | Free | $20+/mo | Free |
One worker can serve multiple sites. The site hostname is derived automatically from the Origin header on each /track request.
In your wrangler.toml:
[vars]
ALLOWED_ORIGINS = "https://site-a.com,https://site-b.com,https://blog.example.com"Each site uses the same worker URL:
<!-- On site-a.com -->
<script defer data-endpoint="https://your-worker.workers.dev" src="https://your-worker.workers.dev/tracker.js"></script>
<!-- On site-b.com — same script, same worker -->
<script defer data-endpoint="https://your-worker.workers.dev" src="https://your-worker.workers.dev/tracker.js"></script>All queries accept a ?site= parameter to filter by hostname:
# Traffic for site-a.com only
curl -H "X-API-Key: your-key" "https://your-worker/query?q=daily-views&period=7d&site=site-a.com"
# Traffic for site-b.com
curl -H "X-API-Key: your-key" "https://your-worker/query?q=daily-views&period=7d&site=site-b.com"The dashboard has a site switcher dropdown for switching between configured sites.
- Check the worker is running:
curl https://your-worker.workers.dev/health - Verify the tracking script is loaded: open browser DevTools → Network tab → look for
tracker.js - Check CORS: your site's origin must be in
ALLOWED_ORIGINSin wrangler.toml - Wait 30-60 seconds — Analytics Engine has a short ingestion delay
The X-API-Key header must match the QUERY_API_KEY secret you set:
# Set or update the key
npx wrangler secret put QUERY_API_KEY
# Test it
curl -H "X-API-Key: your-key" "https://your-worker/query?q=daily-views&period=7d&site=yoursite.com"Your site's origin is not in ALLOWED_ORIGINS. Add it to wrangler.toml and redeploy:
[vars]
ALLOWED_ORIGINS = "https://yoursite.com,http://localhost:3000"Flarelytics filters bots automatically and records them separately. Check the Bot Traffic section in the dashboard to see what's being filtered. To add custom bot patterns, modify DEFAULT_BOT_PATTERNS in packages/worker/src/index.ts.
Cloudflare Analytics Engine retains data for 90 days. For longer retention, set up the email reports worker to receive weekly digests, or query the API periodically and store results in KV or an external database.
npm install # Install all workspace dependencies
npm run dev # Start worker + dashboard in dev mode
npm run build # Build all packages
npm run test # Run testsMIT