Cloudsteading
Product image still needed. This listing has source documentation, but no reviewed screenshot yet.

Traks

Self-hosted pageview and visitor analytics with live and historical views

Traks is a self-hosted Fathom Analytics/Google Analytics alternative built on Cloudflare (Analytics Engine, D1, Durable Objects, KV, R2). Paid services required. Inspect the source and license in the linked repository.

Source & license

Upstream license: MIT

License TL;DR

You can use it, change it, self-host it and sell it. Keep the original copyright and license notice with copies of the code. You don’t have to publish your changes. The authors don’t promise it will work.

Explain MIT in plain English →

Summary of the main license. Separate packages and assets can have different terms.

Inspect repository ↗Read this project’s actual license ↗

Repository owner

@shivamanupadi

See the upstream repository for the original creator and contributors.

Maintain this project? Maintainer verification →

Cloudflare hosting

Paid services required

The README explicitly requires Workers Paid, starting at $5 USD/account/month. Pipelines, R2 Data Catalog, R2 SQL, DO writes and other services are metered beyond allowances. Its small-site approximately $5 estimate is upstream documentation, not a measured catalog deployment or a guaranteed bill.

Hosting requirements
  • Two or more events per pageview and DO indexed writes affect costs. Review current data-service tariffs and actual event retention before scaling.
  • The documented one-DO-per-site throughput ceiling and R2 SQL fallback can undercount the live view during spikes. The maintainer installer RELEASES bucket is excluded from the runtime architecture.
  • Source and configuration review establishes a deployment path and conditional costs; this candidate was not executed or load-tested.
Check current pricing ↗
Sources checked 01/10/2026

Repository snapshot: 5b3a97e. Hosting eligibility reflects the deployment documentation and listed assumptions.

  • plausible ↗

    **Self-hosted, privacy-friendly web analytics built entirely on Cloudflare.**

  • google-analytics ↗

    **Self-hosted, privacy-friendly web analytics built entirely on Cloudflare.**

  • fathom ↗

    **Self-hosted, privacy-friendly web analytics built entirely on Cloudflare.**

  • workers ↗

    name = "traks-api-dev" main = "src/index.ts" compatibility_date = "2026-06-01" compatibility_flags = ["nodejs_compat"] # Dashboard requests are back-end bound: each one makes several sequential hops # to D1, the live Durable Object and R2 SQL, none of which live at the edge. # Running near the data beats running near the viewer here - the opposite of # the collect worker, which stays at the edge because ingest latency is felt on # the visitor's page. [placement] mode = "smart" # --- Dev (local-first: wrangler dev, shared state in <root>/.wrangler-state) --- # Local dev serves the SPA from vite (:5012, proxying /ap

  • r2 ↗

    ▼ zero ingest delay, ms queries Iceberg sink → R2 Data Catalog serves: today, realtime table `traks.events` ▲ (zstd parquet, 60s roll, │ auto compaction + │ snapshot expiration) │ ▼ │ COLD PATH: R2 SQL ◄── api Worker (apps/platform/api) ── Better Auth, D1 metadata serves: 7d/30d/90d/1y/all ▲ today/realtime → DO (edge-cached 5-15 min) │ history → R2 SQL │ (DO failure → R2 SQL fallback) web dashboard (apps/platform/web) ``` ### How the pieces fit - **`apps/platform/collect`** — ingest Worker. Validates the site key against D1, filters bots, computes the daily-rotating visitor ID, enriches events with Cloudflare geo data, then **dual-writes**: to the Pipelines stream (durable system of record

  • durable-objects ↗

    r "today"/realtime dashboards. [[migrations]] tag = "v1" new_sqlite_classes = ["SiteLiveStore"] [[durable_objects.bindings]] name = "LIVE" class_name = "SiteLiveStore" # Pipelines *stream* binding - events are sent to the stream, the pipeline # moves them into the Iceberg sink. (`stream` replaced the deprecated # `pipeline` key in June 2026.) [[pipelines]] binding = "EVENTS" stream = "f0ee6ab42aa7462094f374857e996b47" # traks_events_dev_stream (dev) [vars] ENVIRONMENT = "development" # The platform ships to customers as a release bundle; the ONLY install path # is the traks.dev deploy wizard. There is no wrangler prod deploy - our own # instance (and any staging one)

  • d1 ↗

    events # warehouse (R2 SQL over traks-events-dev) is a real cloud dependency. [[d1_databases]] binding = "DB" database_name = "traks-db-dev" database_id = "cd0ff4bb-5cc4-4002-a259-be02b3234e29" migrations_dir = "src/db/migrations" # Cross-script binding to the collect worker's live-stats Durable Object # (hot path for "today"/realtime queries). The collect worker owns the class. [[durable_objects.bindings]] name = "LIVE" class_name = "SiteLiveStore" script_name = "traks-collect-dev" # Global (cross-colo) result cache for R2 SQL dashboard queries. Layered # behind the per-colo Cache API: a scan any colo already paid for is reused # worldwide for the TTL. On workers.dev

  • kv ↗

    ult) the # Cache API is a no-op, so this KV namespace is the only result cache. [[kv_namespaces]] binding = "R2SQL_CACHE" id = "fc5eb93cfb8f4cb18969af2ccd4f9ea5" # Query telemetry: one row per R2 SQL cache decision (wall ms, rows, outcome). [[analytics_engine_datasets]] binding = "METRICS" dataset = "traks_api_metrics_dev" # Every minute: the pre-warm sweep (src/lib/prewarm.ts) - a no-op unless a # cache bucket just rolled for a recently viewed site. # Brute-force guard on the auth surface, keyed by client IP. Better Auth's # built-in limiter is in-memory - per-isolate on Workers, so effectively no # limiter at all. A single-owner instance has one known account, so this

  • analytics-engine ↗

    # Query telemetry: one row per R2 SQL cache decision (wall ms, rows, outcome). [[analytics_engine_datasets]] binding = "METRICS" dataset = "traks_api_metrics_dev" # Every minute: the pre-warm sweep (src/lib/prewarm.ts) - a no-op unless a # cache bucket just rolled for a recently viewed site. # Brute-force guard on the auth surface, keyed by client IP. Better Auth's # built-in limiter is in-memory - per-isolate on Workers, so effectively no # limiter at all. A single-owner instance has one known account, so this is # the only thing standing between it and offline-speed guessing. [[ratelimits]] name = "AUTH_LIMIT" namespace_id = "1004" simple = { limit = 20, period = 60 }

  • paid ↗

    # traks.dev home worker - deploy-wizard backend ONLY. Physically separate # from the platform api that ships to customer instances: different worker, # different secrets, and no database of its own. The marketing site + wizard # UI is the traks-site static-assets worker; this worker claims the dynamic # paths on traks.dev via zone routes (which beat custom domains). name = "traks-home-api-dev" main = "src/index.ts" compatibility_date = "2026-06-01" compatibility_flags = ["nodejs_compat"] # The only state: one Durable Object per wizard session (progress replay) and # per instance (run lock), each wiping itself 24 hours after its first write. # Nothing about anyone's instance is kept beyond that. [[migrations]] tag = "v1" new_sqlite_classes = ["DeploySession"] # --- Dev (wrangler dev, port

  • paid ↗

    The Workers Paid plan includes Workers, Pages Functions, Workers KV, Hyperdrive, and Durable Objects usage for a minimum charge of $5 USD per month for an account. The plan includes increased initial usage allotments, with clear charges for usage that exceeds the base plan. There are no additional charges for data transfer (egress) or throughput (bandwidth).

  • paid ↗

    | Storage | 10 GB-month / month |

  • paid ↗

    Durable Objects are available both on Workers Free and Workers Paid plans.

  • paid ↗

    | Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows |

  • paid ↗

    | Keys read | 100,000 / day | 10 million/month, + $0.50/million |

  • paid ↗

    | **Workers Paid** | 10 million included per month <br> (+$0.25 per additional million) | 1 million included per month (+$1.00 per additional million) |

  • paid ↗

    Currently, you will not be billed for your use of Workers Analytics Engine. Pricing information here is shared in advance, so that you can estimate what your costs will be once Cloudflare starts billing for usage in the coming months.

  • paid ↗

    | Class A Operations | 1 million requests / month |

  • paid ↗

    | Class B Operations | 10 million requests / month |

  • paid ↗

    | Rows written | 100,000 / day | First 50 million / month included + $1.00 / million rows |

  • paid ↗

    | Keys written | 1,000 / day | 1 million/month, + $5.00/million |

  • paid ↗

    | SQL Stored data <sup>5</sup> | 5 GB (total) | 5 GB-month, + $0.20/ GB-month |

  • MIT ↗

    MIT License Copyright (c) 2026 Shivaprasad Manupadi Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR I

  • architecture ↗

    s tracker (packages/tracker) │ POST /api/event ▼ collect Worker (apps/platform/collect) ── site-key auth + timezone (D1) │ bot filtering, UA/referrer parsing, │ dual write daily-rotating visitor hash (HMAC) ├────────────────────────────┐ ▼ env.EVENTS.send() ▼ env.LIVE (SiteLiveStore DO) Pipelines stream HOT PATH: per-site SQLite DO │ pass-through pipeline rolling ~48h event window ▼ zero ingest delay, ms queries Iceberg sink → R2 Data Catalog serves: today, realtime table `traks.events` ▲ (zstd parquet, 60s roll, │ auto compaction + │ snapshot expiration) │ ▼ │ COLD PATH: R2 SQL ◄── api Worker (apps/platform/api) ── Better Auth, D1 metadata serves: 7d/30d/90d/1y/all ▲ today/realtime → DO (edge-cached 5-15 min) │ history → R2 SQL │ (DO failure → R2 SQL fallback) web dashboard (apps/platform/web) ``` ### How the pieces fit - **`apps/platform/collect`** — ingest Worker. Validates the site key against D1, filters bots, computes the daily-rotating visitor ID, enriches events with Cloudflare geo data, then **dual-writes**: to the Pipelines stream (durable system of record) and to the site's **SiteLiveStore Durable Object** (hot path). Each write fails independently. - **`SiteLiveStore` DO** — one SQLite-backed instance per site holding a rolling ~48h event window (today plus the previous-day comparison window in any timezone). Today/realtime queries run against local SQLite with millisecond latency. It is also the **realtime push** source: the dashboard opens one authenticated WebSocket (WebSocket Hibernation API) and receives a frame — live visitors, their pages, referrers, countries, and city-level coordinates — whenever a pageview changes the picture, plus a 30s tick so counts decay as visitors leave. Coordinates exist only in this hot window and are never written to Iceberg. - **Pipeline** — pass-thr

  • architecture ↗

    t runs end to end on Cloudflare's data platform — Workers, Durable Objects, D1, Pipelines, R2 Data Catalog (Apache Iceberg), and R2 SQL. No servers to manage, no third-party services in the data path, and no personal data stored. [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) ![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen) ![Built on Cloudflare](https://img.shields.io/badge/built%20on-Cloudflare-orange) Open source under the MIT license. Free to run, forever: the only cost is your own Cloudflare usage, which stays inside the free allowances for most sites. Install it at [traks.dev](https://traks.dev); read the release notes at [traks.dev/changelog](https://traks.dev/changelog). ## Highlights - **Privacy-first** — no cookies, no fingerprinting persistence. Visitors are counted with a Plausible-style daily-rotating hash (`HMAC(secret + date, ip + ua + siteKey)`); raw IP addresses are never stored. - **Realtime by default** — a hot/cold split serves "today" and live views from per-site SQLite Durable Objects in milliseconds, with zero ingest delay. A WebSocket pushes live visitors, pages, referrers, and city-level map dots to the dashboard as they happen. - **Cheap at any scale** — history lives in Apache Iceberg on R2 and is queried with R2 SQL, edge-cached, and scan-minimized. A side project runs for ~$5/mo, 5M pageviews/mo for ~$7, and past that about $4.60 per additional million events (see [cost model](#what-it-costs-to-run)). - **Fully self-contained** — auth is [Better Auth](https://better-auth.com) on D1 (no auth SaaS), the world map is self-hosted (no tile servers), and the dashboard never calls a third party. - **Agent-ready** — analytics are exposed to AI agents via MCP/WebMCP tools, with bot and agent traffic classified and reported alongside human traffic. - **Tiny t

  • architecture ↗

    name = "traks-api-dev" main = "src/index.ts" compatibility_date = "2026-06-01" compatibility_flags = ["nodejs_compat"] # Dashboard requests are back-end bound: each one makes several sequential hops # to D1, the live Durable Object and R2 SQL, none of which live at the edge. # Running near the data beats running near the viewer here - the opposite of # the collect worker, which stays at the edge because ingest latency is felt on # the visitor's page. [placement] mode = "smart" # --- Dev (local-first: wrangler dev, shared state in <root>/.wrangler-state) --- # Local dev serves the SPA from vite (:5012, proxying /api to :5011). D1/KV # are local simulations; ids below are inert locally. Only the events # warehouse (R2 SQL over traks-events-dev) is a real cloud dependency. [[d1_databases]] binding = "DB" database_name = "traks-db-dev" database_id = "cd0ff4bb-5cc4-4002-a259-be02b3234e29" migrations_dir = "src/db/migrations" # Cross-script binding to the collect worker's live-stats Durable Object # (hot path for "today"/realtime queries). The collect worker owns the class. [[durable_objects.bindings]] name = "LIVE" class_name = "SiteLiveStore" script_name = "traks-collect-dev" # Global (cross-colo) result cache for R2 SQL dashboard queries. Layered # behind the per-colo Cache API: a scan any colo already paid for is reused # worldwide for the TTL. On workers.dev installs (the wizard default) the # Cache API is a no-op, so this KV namespace is the only result cache. [[kv_namespaces]] binding = "R2SQL_CACHE" id = "fc5eb93cfb8f4cb18969af2ccd4f9ea5" # Query telemetry: one row per R2 SQL cache decision (wall ms, rows, outcome). [[analytics_engine_datasets]] binding = "METRICS" dataset = "traks_api_metrics_dev" # Every minute: the pre-warm sweep (src/lib/prewarm.ts) - a no-op unless a # cache bucket just rolled for a recently viewed site. # Brute-force guard on the auth surface, keyed by client IP. Better Auth's # built-in limiter is in-memory - per-isolate on Workers, so effectively no # limiter at all. A single-owner instance has one known account, so this is # the only thing standing between it and offline-speed guessing. [[ratelimits]] name = "AUTH_LIMIT" namespace_id = "1004" simple = { limit = 20, period = 60 } [triggers] crons = ["* * * * *"] [vars] ENVIRONMENT = "development" R2_BUCKET_NAME = "traks-events-dev" R2_ACCOUNT_ID = "4cf68c768770ccda55d287d6

  • architecture ↗

    name = "traks-collect-dev" main = "src/index.ts" compatibility_date = "2026-06-01" compatibility_flags = ["nodejs_compat"] # --- Dev (local-first: wrangler dev, shared state in <root>/.wrangler-state) --- [[d1_databases]] binding = "DB" database_name = "traks-db-dev" database_id = "cd0ff4bb-5cc4-4002-a259-be02b3234e29" migrations_dir = "../api/src/db/migrations" # Per-site-key flood guard (counted per colo). Pure abuse backstop - # there are no product quotas. 100/s per colo per site key. [[ratelimits]] name = "RATE_LIMIT" namespace_id = "1001" simple = { limit = 6000, period = 60 } # Ingest failure counters (live-DO write failures, pipeline send failures), # queryable via the Analytics Engine SQL API to spot sites outgrowing their # per-site Durable Object before it becomes an outage. [[analytics_engine_datasets]] binding = "METRICS" dataset = "traks_collect_metrics_dev" # Hot-path live stats: one SQLite-backed Durable Object per site, written at # ingest, read by the api worker for "today"/realtime dashboards. [[migrations]] tag = "v1" new_sqlite_classes = ["SiteLiveStore"] [[durable_objects.bindings]] name = "LIVE" class_name = "SiteLiveStore" # Pipelines *stream* binding - events are sent to the stream, the pipeline # moves them into the Iceberg sink. (`stream` replaced the deprecated # `pipeline` key in June 2026.) [[pipelines]] binding = "EVENTS" stream = "f0ee6ab42aa7462094f374857e996b47" # traks_events_dev_stream (dev) [vars] ENVIRONMENT = "development" # The platform ships to customers as a release bundle; the ONLY install path # is the traks.dev deploy wizard. There is no wrangler prod deploy - our own # instance (and any staging one) is installed through the wizard like any # other customer. Dev config above is for local iteration only.

  • architecture ↗

    name = "traks-api-dev" main = "src/index.ts" compatibility_date = "2026-06-01" compatibility_flags = ["nodejs_compat"] # Dashboard requests are back-end bound: each one makes several sequential hops # to D1, the live Durable Object and R2 SQL, none of which live at the edge. # Running near the data beats running near the viewer here - the opposite of # the collect worker, which stays at the edge because ingest latency is felt on # the visitor's page. [placement] mode = "smart" # --- Dev (local-first: wrangler dev, shared state in <root>/.wrangler-state) --- # Local dev serves the SPA from vite (:5012, proxying /ap

  • architecture ↗

    ▼ zero ingest delay, ms queries Iceberg sink → R2 Data Catalog serves: today, realtime table `traks.events` ▲ (zstd parquet, 60s roll, │ auto compaction + │ snapshot expiration) │ ▼ │ COLD PATH: R2 SQL ◄── api Worker (apps/platform/api) ── Better Auth, D1 metadata serves: 7d/30d/90d/1y/all ▲ today/realtime → DO (edge-cached 5-15 min) │ history → R2 SQL │ (DO failure → R2 SQL fallback) web dashboard (apps/platform/web) ``` ### How the pieces fit - **`apps/platform/collect`** — ingest Worker. Validates the site key against D1, filters bots, computes the daily-rotating visitor ID, enriches events with Cloudflare geo data, then **dual-writes**: to the Pipelines stream (durable system of record

  • architecture ↗

    r "today"/realtime dashboards. [[migrations]] tag = "v1" new_sqlite_classes = ["SiteLiveStore"] [[durable_objects.bindings]] name = "LIVE" class_name = "SiteLiveStore" # Pipelines *stream* binding - events are sent to the stream, the pipeline # moves them into the Iceberg sink. (`stream` replaced the deprecated # `pipeline` key in June 2026.) [[pipelines]] binding = "EVENTS" stream = "f0ee6ab42aa7462094f374857e996b47" # traks_events_dev_stream (dev) [vars] ENVIRONMENT = "development" # The platform ships to customers as a release bundle; the ONLY install path # is the traks.dev deploy wizard. There is no wrangler prod deploy - our own # instance (and any staging one)

  • architecture ↗

    events # warehouse (R2 SQL over traks-events-dev) is a real cloud dependency. [[d1_databases]] binding = "DB" database_name = "traks-db-dev" database_id = "cd0ff4bb-5cc4-4002-a259-be02b3234e29" migrations_dir = "src/db/migrations" # Cross-script binding to the collect worker's live-stats Durable Object # (hot path for "today"/realtime queries). The collect worker owns the class. [[durable_objects.bindings]] name = "LIVE" class_name = "SiteLiveStore" script_name = "traks-collect-dev" # Global (cross-colo) result cache for R2 SQL dashboard queries. Layered # behind the per-colo Cache API: a scan any colo already paid for is reused # worldwide for the TTL. On workers.dev

  • architecture ↗

    ult) the # Cache API is a no-op, so this KV namespace is the only result cache. [[kv_namespaces]] binding = "R2SQL_CACHE" id = "fc5eb93cfb8f4cb18969af2ccd4f9ea5" # Query telemetry: one row per R2 SQL cache decision (wall ms, rows, outcome). [[analytics_engine_datasets]] binding = "METRICS" dataset = "traks_api_metrics_dev" # Every minute: the pre-warm sweep (src/lib/prewarm.ts) - a no-op unless a # cache bucket just rolled for a recently viewed site. # Brute-force guard on the auth surface, keyed by client IP. Better Auth's # built-in limiter is in-memory - per-isolate on Workers, so effectively no # limiter at all. A single-owner instance has one known account, so this

  • architecture ↗

    # Query telemetry: one row per R2 SQL cache decision (wall ms, rows, outcome). [[analytics_engine_datasets]] binding = "METRICS" dataset = "traks_api_metrics_dev" # Every minute: the pre-warm sweep (src/lib/prewarm.ts) - a no-op unless a # cache bucket just rolled for a recently viewed site. # Brute-force guard on the auth surface, keyed by client IP. Better Auth's # built-in limiter is in-memory - per-isolate on Workers, so effectively no # limiter at all. A single-owner instance has one known account, so this is # the only thing standing between it and offline-speed guessing. [[ratelimits]] name = "AUTH_LIMIT" namespace_id = "1004" simple = { limit = 20, period = 60 }

What it can replace

Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.

Plausible logoPlausible ↗

Website pageviews, visitors, referrers and live/historical reports; no advertising attribution, universal analytics integration or complete proprietary analytics-platform parity.

See supporting source ↗
Fathom logoFathom ↗

Website pageviews, visitors, referrers and live/historical reports; no advertising attribution, universal analytics integration or complete proprietary analytics-platform parity.

See supporting source ↗
external SaaS target
varies
external SaaS target
varies
external SaaS target
varies

How it works

The shape of Traks on Cloudflare, and how it stacks up against the rented tools it replaces.

Architecture

Diagram based on the linked repository documentation. See the sources and hosting assumptions above.

View upstream source ↗
Public interface
Documented interface1
Traks browser or API client
Configuration/source review; runtime not tested
↓
App
Event collector
entry
Cloudflare Worker
Validate and ingest website events
Dashboard API
entry
Cloudflare Worker
Serve live and historical reports
delegates to↓
Pipelines → Iceberg → R2 SQL
backing
Metered Cloudflare data services
↓

Configuration and workflow sources

Reviewed commit 5b3a97eb4d40. Files were read as data; upstream applications and CI jobs were not executed.

Partial source coverage: 19 files outside collection bounds; 0 collection or parsing issues. Dynamic imports and generated entrypoints may need manual review.

Deployment configuration · 4 files
apps/home/api/wrangler.toml ↗

Cloudflare Workers · compatibility 2026-06-01

traks-home-api-dev · default

Entrypoint: src/index.ts

  • RELEASES → R2
  • SESSIONS → Durable Objects · class DeploySession

traks-home-api · env.production

Inherited from default: main, compatibility_date, compatibility_flags

Entrypoint: src/index.ts

Configured route patterns: traks.dev/api/* · traks.dev/deploy/callback*

  • RELEASES → R2
  • SESSIONS → Durable Objects · class DeploySession
apps/home/web/wrangler.toml ↗

Cloudflare Workers · compatibility 2026-06-01

traks-site · default

Static assets: ./dist · single-page-application

Configured route patterns: traks.dev · www.traks.dev

  • Static assets → Static assets
apps/platform/api/wrangler.toml ↗

Cloudflare Workers · compatibility 2026-06-01

traks-api-dev · default

Entrypoint: src/index.ts

Cron triggers (UTC): * * * * *

  • DB → D1
  • R2SQL_CACHE → KV
  • LIVE → Durable Objects · class SiteLiveStore · Worker traks-collect-dev
  • METRICS → Analytics Engine
apps/platform/collect/wrangler.toml ↗

Cloudflare Workers · compatibility 2026-06-01

traks-collect-dev · default

Entrypoint: src/index.ts

  • DB → D1
  • LIVE → Durable Objects · class SiteLiveStore
  • METRICS → Analytics Engine

Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.

Runtime source · handlers, binding usage and workflow steps

Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.

apps/home/api/src/index.ts ↗
  • L37 · fetch handler exported
  • L18 · app.get("/")
  • L19 · app.get("/api/health")
  • L22 · app.get("/api/config")
  • L25 · app.get("/deploy/callback")
  • L27 · app.route("/api/deploy")

Environment references: c.env.CF_OAUTH_CLIENT_ID

apps/platform/api/src/index.ts ↗
  • L174 · fetch handler exported
  • L176 · scheduled handler exported · calls ctx.waitUntil, runPrewarm
  • L22 · app.use("/api/*")
  • L29 · app.get("/")
  • L32 · app.get("/health")
  • L33 · app.get("/api/health")
  • L100 · app.get("/api/claim-status")
  • L108 · app.get("/api/config")
  • L124 · app.use("/api/workspaces/*")
  • L125 · app.use("/api/invitations/*")
  • L126 · app.use("/api/me")
  • L127 · app.use("/api/tokens")
  • L128 · app.use("/api/tokens/*")
  • L131 · app.route("/api/me")
  • L143 · app.use("/api/public/analytics/*")
  • L144 · app.route("/api/public/analytics")
  • L145 · app.route("/api/public")
  • L150 · app.post("/api/mcp")

Environment references: c.env.DB · c.env.TRAKS_VERSION · c.env.AUTH_LIMIT · c.env.CLAIM_TOKEN · c.env.COLLECT_URL · c.env.TRAKS_INSTANCE · c.env.R2_BUCKET_NAME · c.env.DEPLOY_INSTANCE_ID · c.env.ASSETS

apps/platform/collect/src/index.ts ↗
  • L52 · app.use("/*")
  • L55 · app.get("/")
  • L56 · app.get("/health")
  • L58 · app.get("/t.js")
  • L288 · app.post("/api/event")
  • L100 · authenticateSiteCached calls (conditional paths may differ): authCache.get, Date.now, authInFlight.has, background, catch, authenticateSite, authInFlight.get, finally, authInFlight.delete, authInFlight.set
  • L126 · authenticateSite calls (conditional paths may differ): first, bind, db.prepare, toLowerCase, authCache.clear, authCache.set, Date.now
  • L161 · getDailyCryptoKey calls (conditional paths may differ): cryptoKeys.get, encoder.encode, crypto.subtle.importKey, cryptoKeys.clear, cryptoKeys.set
  • L196 · generateVisitorId calls (conditional paths may differ): getDailyCryptoKey, encoder.encode, crypto.subtle.sign, join, Array.from, hashArray.slice, padStart, b.toString
  • L221 · authenticateDomain calls (conditional paths may differ): first, bind, db.prepare, normalizeDomain, toLowerCase, domainAuthCache.clear, domainAuthCache.set, Date.now
  • L239 · authenticateDomainCached calls (conditional paths may differ): domainAuthCache.get, Date.now, domainAuthInFlight.has, background, catch, authenticateDomain, domainAuthInFlight.get, finally, domainAuthInFlight.delete, domainAuthInFlight.set
  • L264 · originAllowed calls (conditional paths may differ): hostname.toLowerCase, host.endsWith, replace, domain.replace
  • L282 · parseCoordinate calls (conditional paths may differ): Number, Number.isFinite, Math.abs

Environment references: env.METRICS · c.env.RATE_LIMIT · c.env.DB · c.env.VISITOR_HASH_SECRET · c.env.LIVE · c.env.EVENTS

apps/home/api/src/deploy/routes.ts ↗
  • L256 · app.get("/oauth/start")
  • L88 · oauthCallback calls (conditional paths may differ): url.searchParams.get, state.split, getCookie, cookie.split, c.redirect, encodeURIComponent, back, deleteCookie, fetch, redirectUri, catch, res.json
  • L176 · loadArtifacts calls (conditional paths may differ): releases.get, manifestObj.json, obj.arrayBuffer, obj.text, bytes, manifest.assets.map, manifest.migrations.map, text, JSON.parse
  • L219 · authorizeSession calls (conditional paths may differ): listAccounts, accounts.some
  • L247 · limited calls (conditional paths may differ): limiter.limit, clientIp, c.json

Environment references: c.env.SESSIONS · c.env.CF_OAUTH_CLIENT_ID · c.env.CF_OAUTH_CLIENT_SECRET · c.env.SESSION_LIMIT · c.env.VERIFY_LIMIT · c.env.RELEASES

apps/platform/api/src/lib/auth.ts ↗
  • L32 · safeEqual calls (conditional paths may differ): enc.encode, Math.max
  • L43 · isClaimed calls (conditional paths may differ): where, from, db.select, eq
  • L64 · stashLegacyUser calls (conditional paths may differ): where, from, db.select, eq, set, db.update
  • L76 · adoptLegacyData calls (conditional paths may differ): where, from, db.select, eq, set, db.update, db.delete, and, ne
  • L95 · createAuth calls (conditional paths may differ): drizzle, betterAuth, drizzleAdapter, organization, where, from, db.select, eq, Number, evictOrphanedMembers, and, createAuthMiddleware, isClaimed, findPendingInvitation, safeEqual, env.OWNER_EMAIL.toLowerCase, stashLegacyUser, consumeInvitation, adoptLegacyData, set
  • L324 · getAuth calls (conditional paths may differ): authInstances.get, authInstances.clear, createAuth, authInstances.set
  • L336 · claimStatus calls (conditional paths may differ): isClaimed, drizzle

Environment references: env.DB · env.BETTER_AUTH_SECRET · env.CLAIM_TOKEN · env.OWNER_EMAIL

apps/platform/api/src/lib/workspaces.ts ↗
  • L9 · memberWorkspaceIds calls (conditional paths may differ): where, from, db.select, eq
  • L23 · siteAccessFilter calls (conditional paths may differ): or, and, eq, isNull, inArray, memberWorkspaceIds
  • L41 · getAccessibleSite calls (conditional paths may differ): limit, where, from, db.select, and, eq, siteAccessFilter
  • L60 · getSiteAccess calls (conditional paths may differ): getAccessibleSite, getMembership
  • L80 · siteManageFilter calls (conditional paths may differ): or, and, eq, isNull, inArray, where, from, db.select
  • L101 · getMembership calls (conditional paths may differ): limit, where, from, db.select, and, eq
  • L122 · ensureDefaultWorkspace calls (conditional paths may differ): limit, orderBy, where, from, db.select, eq, createId, db.batch, values, db.insert, set, db.update, and, isNull
  • L174 · evictOrphanedMembers calls (conditional paths may differ): Date.now, where, from, db.select, and, eq, lt, notInArray, limit, set, db.update, db.delete, console.error, Number, onConflictDoNothing, values, db.insert
apps/platform/api/src/lib/permissions.ts ↗
  • L48 · roleAllows calls (conditional paths may differ): resolved.authorize
apps/platform/api/src/routes/sites.ts ↗
  • L67 · app.get("/")
  • L33 · refreshFavicon calls (conditional paths may differ): fetchSiteFavicon, where, set, db.update, eq
  • L52 · checkManage calls (conditional paths may differ): getSiteAccess, roleAllows

Environment references: c.env.LIVE

apps/platform/api/src/routes/analytics.ts ↗
  • L666 · app.get("/batch/stats")
  • L75 · authOrPublic calls (conditional paths may differ): c.get, next, requireAuth
  • L93 · getSite calls (conditional paths may differ): c.get, noteSiteView, c.req.query, limit, where, from, db.select, and, eq, siteAccessFilter
  • L150 · queryTime calls (conditional paths may differ): cacheTtlSeconds, Math.floor, Date.now
  • L155 · sha256Hex calls (conditional paths may differ): crypto.subtle.digest, encode, join, Array.from, padStart, b.toString
  • L179 · asPayload calls (conditional paths may differ): Array.isArray
  • L214 · recordQueryMetric calls (conditional paths may differ): replace, c.req.query, c.req.param, isIngestPrune, site.slice, console.log
  • L240 · cachedR2Sql calls (conditional paths may differ): getQueryConfig, buildQuery, sha256Hex, Date.now, queryR2SqlWithStats, isMissingIngestTs, isIngestPrune, setIngestPrune, console.warn, inFlightScans.get, finally, then, catch, execute, recordQueryMetric, JSON.stringify, c.executionCtx.waitUntil, Promise.all, cache.put, c.env.R2SQL_CACHE.put
  • L381 · parseFilters calls (conditional paths may differ): toLiveFilters
  • L427 · parseMetaUrl calls (conditional paths may differ): String, JSON.parse
  • L440 · foldWebmcpMeta calls (conditional paths may differ): JSON.parse, String, byTool.get, byTool.set, sort, map, Array.from, byTool.entries, Math.round
  • L476 · aggregateMetaProps calls (conditional paths may differ): JSON.parse, Array.isArray, Object.entries, slice, String, byKey.get, byKey.set, values.set, values.get, map, Array.from, byKey.entries, reduce, values.values, keyTotals.sort, sort, values.entries, sorted.slice, out.push
  • L538 · mainStatsPayload calls (conditional paths may differ): Math.min, Math.round, rate, duration, pctChange
  • L567 · assembleMainStats calls (conditional paths may differ): statRows.find, sessionRows.find, engagementRows.find, toNumber, mainStatsPayload, totals
  • L599 · liveStore calls (conditional paths may differ): ns.get, ns.idFromName
  • L606 · logLiveFallback calls (conditional paths may differ): console.error
  • L610 · formatDevices calls (conditional paths may differ): rows.reduce, rows.map, Math.round
  • L624 · fillTimeseries calls (conditional paths may differ): rows.map, range.buckets.map, byKey.get
  • L649 · runQueries calls (conditional paths may differ): fn, console.error, c.json
  • L788 · legacyBreakdown calls (conditional paths may differ): buildTopPagesQuery, buildTopReferrersQuery, buildUtmQuery, buildLocationsQuery, buildDevicesQuery, buildScreenSizesQuery, buildAiSourcesQuery
  • L823 · legacyRow calls (conditional paths may differ): String, toNumber
  • L839 · cachedBreakdown calls (conditional paths may differ): Date.now, cachedR2Sql, buildBreakdownsQuery, map, rows.filter, String, toNumber, console.warn, err.message.slice, legacyBreakdown, rows.map, legacyRow
  • L894 · cachedSpecialGoals calls (conditional paths may differ): Date.now, chunks.push, defs.slice, Promise.all, chunks.map, cachedR2Sql, buildSpecialGoalsQuery, rowsPerChunk.forEach, forEach, out.push, toNumber, console.warn, err.message.slice, defs.map, buildGoalEventPropQuery, buildGoalPagePrefixQuery, perGoal.map
  • L959 · fetchDashboard calls (conditional paths may differ): resolvePeriod, previousRange, liveStore, live.dashboard, ms, mainStatsPayload, fillTimeseries, d.pages.map, d.referrers.map, d.locations.map, formatDevices, d.browsers.map, d.os.map, logLiveFallback, queryTime, freshTtlSeconds, runQueries, Promise.all, cachedR2Sql, buildStatsWithComparisonQuery

Environment references: c.env.R2_ACCOUNT_ID · c.env.R2_BUCKET_NAME · c.env.R2_SQL_TOKEN · c.env.ENVIRONMENT · c.env.METRICS · c.env.TRAKS_VERSION · c.env.R2SQL_CACHE · c.env.LIVE

apps/platform/api/src/routes/invitations.ts ↗
  • L14 · app.get("/:token")

Environment references: c.env.AUTH_LIMIT

apps/platform/api/src/routes/mcp.ts ↗
  • L307 · mcpHandler calls (conditional paths may differ): c.req.json, c.json, rpcError, c.body, String, rpcResult, PROTOCOL_VERSIONS.includes, TOOLS.map, TOOLS.find, required.filter, JSON.stringify, missing.join, tool.request, dispatch, c.req.header, res.text, JSON.parse, trackerSnippet

Environment references: c.env.TRAKS_VERSION · c.env.COLLECT_URL

apps/platform/api/src/routes/public.ts ↗
  • L16 · sectionFor calls (conditional paths may differ): test
  • L38 · loadPublicSite calls (conditional paths may differ): limit, where, from, select, c.get, and, eq
  • L58 · publicAnalyticsGate calls (conditional paths may differ): c.json, notFound, exec, sectionFor, loadPublicSite, c.set, next
apps/platform/api/src/middleware/auth.ts ↗
  • L10 · sessionOnly calls (conditional paths may differ): c.req.header, c.json, next
  • L34 · requireAuth calls (conditional paths may differ): c.req.header, c.get, resolveToken, bearer.slice, c.json, c.set, Date.now, lastUsedTouched.get, lastUsedTouched.set, lastUsedTouched.clear, c.executionCtx.waitUntil, then, where, set, db.update, eq, next, api.getSession, getAuth
apps/platform/api/src/lib/prewarm.ts ↗
  • L26 · getInternalToken calls (conditional paths may differ): crypto.randomUUID
  • L59 · prewarmHours calls (conditional paths may differ): Number, Number.isFinite, Math.min
  • L74 · noteSiteView calls (conditional paths may differ): prewarmHours, FILTER_KEYS.some, Date.now, lastNoted.get, lastNoted.set, lastNoted.clear, ctx.waitUntil, env.R2SQL_CACHE.get, HOT.has, slice, periods.filter, catch, env.R2SQL_CACHE.put, JSON.stringify, Math.max, Math.round, console.error
  • L110 · bucketJustStarted calls (conditional paths may differ): cacheTtlSeconds
  • L116 · runPrewarm calls (conditional paths may differ): prewarmHours, Date.now, catch, env.R2SQL_CACHE.list, name.slice, env.R2SQL_CACHE.get, HOT.has, bucketJustStarted, jobs.push, then, Promise.resolve, app.request, encodeURIComponent, getInternalToken, console.warn, console.error, console.log, Promise.all

Environment references: env.PREWARM_HOURS · env.R2SQL_CACHE

apps/platform/collect/src/lib/plausible.ts ↗
  • L45 · parsePlausible calls (conditional paths may differ): str, toLowerCase, parsed.hostname.toLowerCase, parsed.searchParams.get, JSON.stringify, Number, Number.isFinite, Math.round, Math.min
  • L136 · getSessionKey calls (conditional paths may differ): catch, crypto.subtle.importKey, encoder.encode
  • L155 · deriveSessionId calls (conditional paths may differ): Math.floor, getSessionKey, crypto.subtle.sign, encoder.encode, join, map, slice, padStart, b.toString
apps/platform/collect/src/lib/bots.ts ↗
  • L61 · botName calls (conditional paths may differ): pattern.test, GENERIC_BOT.exec, slice
  • L71 · isBot calls (conditional paths may differ): botName
apps/platform/collect/src/lib/ua.ts ↗
  • L7 · parseUA calls (conditional paths may differ): parseBrowser, parseOS, parseDeviceType
  • L14 · parseBrowser calls (conditional paths may differ): test
  • L27 · parseOS calls (conditional paths may differ): test
  • L37 · parseDeviceType calls (conditional paths may differ): test
Build and deployment pipeline · 1 GitHub Actions workflows

Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.

CI · .github/workflows/ci.yml ↗

Triggers: pull_request, push

format, lint, build, schema, tracker, changelog · no job dependencies declared

  1. actions/checkout@v4actions/checkout@v4
  2. actions/setup-node@v4actions/setup-node@v4
  3. Shell commandyarn install --frozen-lockfile
  4. Shell commandyarn check:ci
package.json ↗
  • build: turbo run build
apps/home/api/package.json ↗
  • build: tsc --noEmit
  • deploy:dev: wrangler deploy
  • release:prod: yarn secrets:prod:sync && yarn worker:prod:deploy
apps/home/web/package.json ↗
  • build: tsc --noEmit && vite build
  • build:prod: yarn build
  • deploy:prod: yarn build && wrangler deploy
apps/platform/web/package.json ↗
  • build: node scripts/copy-flags.mjs && tsc --noEmit && vite build

Full upstream document by @shivamanupadi · README.md · snapshot 5b3a97e

Traks

Self-hosted, privacy-friendly web analytics built entirely on Cloudflare.

Traks is a lightweight, cookie-free analytics platform that runs end to end on Cloudflare's data platform — Workers, Durable Objects, D1, Pipelines, R2 Data Catalog (Apache Iceberg), and R2 SQL. No servers to manage, no third-party services in the data path, and no personal data stored.

License: MIT Node Built on Cloudflare

Open source under the MIT license. Free to run, forever: the only cost is your own Cloudflare usage, which stays inside the free allowances for most sites. Install it at traks.dev; read the release notes at traks.dev/changelog.

Highlights

  • Privacy-first — no cookies, no fingerprinting persistence. Visitors are counted with a Plausible-style daily-rotating hash (HMAC(secret + date, ip + ua + siteKey)); raw IP addresses are never stored.
  • Realtime by default — a hot/cold split serves "today" and live views from per-site SQLite Durable Objects in milliseconds, with zero ingest delay. A WebSocket pushes live visitors, pages, referrers, and city-level map dots to the dashboard as they happen.
  • Cheap at any scale — history lives in Apache Iceberg on R2 and is queried with R2 SQL, edge-cached, and scan-minimized. A side project runs for ~$5/mo, 5M pageviews/mo for ~$7, and past that about $4.60 per additional million events (see cost model).
  • Fully self-contained — auth is Better Auth on D1 (no auth SaaS), the world map is self-hosted (no tile servers), and the dashboard never calls a third party.
  • Agent-ready — analytics are exposed to AI agents via MCP/WebMCP tools, with bot and agent traffic classified and reported alongside human traffic.
  • Tiny tracker — a single t.js script tag, served inline from the edge.

Architecture

Fresh data is served from per-site Durable Objects in milliseconds; history is served from Iceberg via R2 SQL.

customer site
  └─ t.js tracker (packages/tracker)
       │  POST /api/event
       ▼
collect Worker (apps/platform/collect)            ── site-key auth + timezone (D1)
       │                                    bot filtering, UA/referrer parsing,
       │  dual write                        daily-rotating visitor hash (HMAC)
       ├────────────────────────────┐
       ▼ env.EVENTS.send()          ▼ env.LIVE (SiteLiveStore DO)
Pipelines stream                 HOT PATH: per-site SQLite DO
       │  pass-through pipeline     rolling ~48h event window
       ▼                            zero ingest delay, ms queries
Iceberg sink → R2 Data Catalog      serves: today, realtime
  table `traks.events`                      ▲
  (zstd parquet, 60s roll,                  │
   auto compaction +                        │
   snapshot expiration)                     │
       ▼                                    │
COLD PATH: R2 SQL ◄── api Worker (apps/platform/api) ── Better Auth, D1 metadata
  serves: 7d/30d/90d/1y/all   ▲                today/realtime → DO
  (edge-cached 5-15 min)      │                history → R2 SQL
                              │                (DO failure → R2 SQL fallback)
                 web dashboard (apps/platform/web)

How the pieces fit

  • apps/platform/collect — ingest Worker. Validates the site key against D1, filters bots, computes the daily-rotating visitor ID, enriches events with Cloudflare geo data, then dual-writes: to the Pipelines stream (durable system of record) and to the site's SiteLiveStore Durable Object (hot path). Each write fails independently.
  • SiteLiveStore DO — one SQLite-backed instance per site holding a rolling ~48h event window (today plus the previous-day comparison window in any timezone). Today/realtime queries run against local SQLite with millisecond latency. It is also the realtime push source: the dashboard opens one authenticated WebSocket (WebSocket Hibernation API) and receives a frame — live visitors, their pages, referrers, countries, and city-level coordinates — whenever a pageview changes the picture, plus a 30s tick so counts decay as visitors leave. Coordinates exist only in this hot window and are never written to Iceberg.
  • Pipeline — pass-through INSERT INTO <sink> SELECT * FROM <stream>; the stream schema lives in scripts/pipeline-schema.json.
  • apps/platform/api — dashboard API. Site/user metadata in D1 (Drizzle); historical analytics served by R2 SQL over HTTP, cached at the edge. Also serves the dashboard SPA as static assets, so the session cookie is first-party by construction.
  • apps/platform/web — the dashboard UI, including the live view with a self-hosted dotted world map generated from Natural Earth data.
  • apps/home — the traks.dev site: landing page, docs, and the install wizard. The wizard backend has no database and keeps no record of anyone's instance: instances are discovered live from the user's own Cloudflare account on each sign-in, and a run's progress lives in a Durable Object that wipes itself after a day.
  • packages/tracker — the t.js tracking snippet.
  • packages/shared — event schema (zod), timezone-aware period math, and all R2 SQL query builders.

Bucket keys (date_key, hour_key, week_key) are computed at ingest in the site's IANA timezone, so dashboard buckets align with the user's local clock.

Repository layout

apps/
  home/            traks.dev site (landing, docs, install wizard)
    api/           home API Worker
    web/           home web app
  platform/        the analytics product
    collect/       ingest Worker + SiteLiveStore Durable Object
    api/           dashboard API Worker (auth, R2 SQL, static assets)
    web/           dashboard SPA
packages/
  tracker/         t.js tracking script
  shared/          event schema, period math, R2 SQL query builders
  eslint-config/   shared lint config
  typescript-config/ shared tsconfig
installer/         release build + upload tooling
scripts/           data-platform provisioning, seeding, tracker inlining

Monorepo managed with Yarn workspaces + Turborepo. Requires Node ≥ 20.

Getting started

Use Traks

You do not need this repository to run Traks. Open traks.dev/deploy, sign in with Cloudflare, and the wizard provisions everything into your own account in about two minutes: both Workers, D1, KV, the R2 bucket with Data Catalog, the Pipelines stream and Iceberg sink. Updates and removal are one click each at traks.dev/update and traks.dev/destroy. traks.dev keeps no record of your instance; every visit rediscovers it from your account.

Develop Traks

The rest of this section is for working on the platform itself.

Secrets come from Doppler and nowhere else (see Development). The maintainers' projects are traks-api, traks-collect, and traks-home; to run the platform locally you need Doppler projects of your own with those names and the keys listed below.

1. Provision a dev data platform (once per Cloudflare account; reads CATALOG_TOKEN from Doppler traks-home/prd):

./scripts/setup-data-platform.sh dev

This creates the R2 bucket, enables the Data Catalog with automatic compaction (128 MB) and snapshot expiration (30 days / keep 5), then creates the stream, Iceberg sink (60 s roll interval for ~1-minute dashboard freshness), and pipeline. Paste the printed stream ID into apps/platform/collect/wrangler.toml.

2. Migrate D1 and start the dev servers:

yarn install
yarn workspace @traks/platform-api db:migrate:dev
yarn dev                                            # collect :5010, api :5011, web :5012, home :5013/:5014
Doppler project (dev config) Keys
traks-api BETTER_AUTH_SECRET, R2_SQL_TOKEN (Workers R2 SQL Read on the warehouse bucket)
traks-collect VISITOR_HASH_SECRET
traks-home none required

3. Seed test data:

node scripts/seed-events.mjs <SITE_KEY> 500

Useful commands

# Ad-hoc queries (token needs Workers R2 SQL Read)
WRANGLER_R2_SQL_AUTH_TOKEN=<token> npx wrangler r2 sql query \
  "<ACCOUNT_ID>_traks-events-dev" "SELECT COUNT(*) FROM traks.events"

# Catalog / maintenance status
npx wrangler r2 bucket catalog get traks-events-dev

# Pipeline plumbing
npx wrangler pipelines list
npx wrangler pipelines streams list
npx wrangler pipelines sinks list

Warning: never delete objects manually in the catalog-enabled bucket — data/metadata files under the warehouse prefix are Iceberg table state.

Authentication

Auth is Better Auth running inside the api Worker — no auth SaaS, no third party. Email + password only; users, sessions, and credential accounts live in D1.

First-run claim: a fresh instance is unclaimed — /login shows a "create your owner account" screen, and the first sign-up claims the instance; sign-ups are rejected server-side after that. The install wizard mints a one-time CLAIM_TOKEN worker secret and links to /login?claim=<code> so predictable instance hostnames can't be hijacked.

Recovery (forgot password, no email sending configured): delete the owner's row in accounts (+ sessions) and re-claim with the same email — site ownership is re-adopted by email.

What it costs to run

Everything runs inside a Cloudflare Workers Paid plan. Billing for Pipelines, R2 Data Catalog, and R2 SQL has been live since 3 Aug 2026; each has a monthly free allowance that most sites never exhaust, so the bill for a small install is essentially the $5/mo Workers Paid base. Cloudflare bills per event, and a pageview produces about two (the pageview plus its engagement event), so the tiers below are stated in both. Rates verified 4 Sep 2026; the same model drives the calculator on traks.dev.

Scale Traffic Estimated monthly cost
Side project 100k pageviews (200k events) ≈ $5 (base plan only)
Startup 5M pageviews (10M events) ≈ $7
Growth 10M pageviews (20M events) ≈ $42
Scale 50M pageviews (100M events) ≈ $410

Above roughly 6M pageviews the bill is dominated by one line: the hot-path Durable Object writes every event to SQLite, and Cloudflare bills 4 row writes per event over its lifetime (1 row + 2 index entries on insert, 1 on prune; measured with cursor.rowsWritten, the figure Cloudflare bills on). That is $4.00 of the ≈ $4.60 each additional million events costs; Worker requests, DO requests, Pipelines and R2 together are the remaining cents. For comparison, hosted analytics vendors publish $16–34 per million pageviews at their top tiers.

The hot/cold split is what keeps everything else flat: the always-open "today" dashboard is served by Durable Objects for ~free, historical queries are minimized to single scans (CASE split for current + previous period comparisons) and cached for 5–15 minutes, and egress is always $0.

Full rate table
Component Rate Monthly free allowance (paid plan)
Workers Paid base $5/mo 10M requests, 30M CPU-ms incl.
Workers requests / CPU over included $0.30/M requests / $0.02/M CPU-ms —
Durable Objects requests $0.15/M 1M
DO duration $12.50/M GB-s (idle objects are not billed) 400k GB-s
DO SQLite writes / reads / storage $1.00/M rows / $0.001/M rows / $0.20/GB-mo 50M / 25B rows / 5GB
Pipelines: ingest → transform → delivery free → $0.04/GB → $0.06/GB (Parquet), uncompressed bytes; the pass-through INSERT … SELECT * counts as a transform 50GB per dimension
R2 storage $0.015/GB-mo 10GB
R2 Data Catalog operations $9.00/M 1M
Catalog compaction $0.005/GB + $2.00/M objects 10GB + 1M objects
R2 SQL $2.50/TB scanned (10MB min/query) 10GB scanned

Cloudflare data platform status

R2 Data Catalog, R2 SQL, and Pipelines are still open beta (as of Sep 2026) but production-trending: pricing is published and billing has been on since Aug 2026, the catalog has a dedicated dashboard, GraphQL metrics, and Terraform support, and R2 SQL supports JOINs, CTEs, CASE, window functions, set operations, exact COUNT(DISTINCT), and ~200 functions.

Known platform gaps this codebase works around: catalog sinks have no user-defined partition spec and cannot be modified or re-attached to an existing table (so a sink's roll interval is fixed for the table's lifetime), stream schemas are immutable, R2 SQL has no timezone conversion and no metrics dataset for bytes scanned, and every R2 SQL query bills a 10 MB minimum.

Known limits of this design, from Cloudflare's published numbers:

  • One Durable Object per site is the per-site ceiling. Cloudflare rates a single object at roughly 200–500 requests/s for operations that write storage, and a pageview costs about two DO requests, so one site sustaining 150–250 pageviews/s (a front-page spike, or roughly 10M+ pageviews/month with peaks) saturates its object. The failure is graceful: the write is counted in the live_write_failed metric and the dashboard falls back to R2 SQL, but "today" undercounts until traffic drops. Sharding a site across several objects (keyed by site plus shard, fan-in on read) is the planned path if a real site gets there.
  • Pipelines beta limits per account: 20 streams, 20 sinks, 20 pipelines, and 5 MB/s ingest per stream (about 6k events/s at Traks' record size). Each instance uses one of each.
  • The Workers Cache API is only functional on custom domains. Wizard installs default to workers.dev, where the per-colo cache layer is a no-op and the KV result cache does all the work. Instances on a custom domain get both layers.

Platform features this codebase relies on:

Feature Since Where used
Streams/sinks/pipelines split, exactly-once Iceberg delivery Sep 2025 ingest path
stream key in [[pipelines]] Workers binding Jun 2026 apps/platform/collect/wrangler.toml
Automatic compaction (64–512 MB target) Sep 2025 scripts/setup-data-platform.sh
Snapshot expiration incl. data-file cleanup Dec 2025 / Apr 2026 scripts/setup-data-platform.sh
R2 SQL aggregations + approx_distinct Dec 2025 all stat queries
R2 SQL CASE + expression GROUP BY Mar 2026 single-scan period comparison
R2 SQL CTEs + subqueries Mar–May 2026 bounce-rate session rollup

Abuse guards

Ingest is protected without any per-event database reads: the collect Worker applies per-site-key burst limits counted per colo, caches site-key auth per isolate, and accepts events only from the site's registered domain, its subdomains, and localhost.

Development

yarn dev          # run all apps (collect :5010, api :5011, web :5012)
yarn lint         # lint all workspaces
yarn type-check   # typecheck all workspaces
yarn build        # build all workspaces
yarn check:ci     # everything CI runs: format, lint, build, db, tracker

Secrets come from Doppler only. Nothing reads a secret from the shell environment or a local file. The dev scripts download each Worker's dev config (traks-api, traks-collect, traks-home) into a git-ignored .dev.vars.doppler and hand it to wrangler dev; the release and setup scripts under installer/ and scripts/ read traks-home/prd. Running any of them needs doppler login and access to those projects (or Doppler projects of your own with the same names and keys: VISITOR_HASH_SECRET for collect, BETTER_AUTH_SECRET and R2_SQL_TOKEN for the api, ADMIN_KEY for home).

Contributing

Issues and pull requests are welcome; see CONTRIBUTING.md for the setup, the checks to run, and how release notes work. Security issues go through SECURITY.md, not the public tracker.

License

MIT © 2026 Shivaprasad Manupadi

Frequently asked about Traks

What is Traks?+

Traks is a self-hosted Fathom Analytics/Google Analytics alternative built on the Cloudflare developer platform. Self-hosted pageview and visitor analytics with live and historical views

What does Traks replace?+

Traks is listed as an alternative to Fathom Analytics, Google Analytics, Plausible. Compare the features and tradeoffs before migrating.

What Cloudflare primitives does Traks use?+

Traks is built on Analytics Engine, D1, Durable Objects, KV, R2, Workers.

How much does Traks cost to run?+

The README explicitly requires Workers Paid, starting at $5 USD/account/month. Pipelines, R2 Data Catalog, R2 SQL, DO writes and other services are metered beyond allowances. Its small-site approximately $5 estimate is upstream documentation, not a measured catalog deployment or a guaranteed bill. Two or more events per pageview and DO indexed writes affect costs. Review current data-service tariffs and actual event retention before scaling. The documented one-DO-per-site throughput ceiling and R2 SQL fallback can undercount the live view during spikes. The maintainer installer RELEASES bucket is excluded from the runtime architecture. Source and configuration review establishes a deployment path and conditional costs; this candidate was not executed or load-tested. Check current Cloudflare pricing before deploying.

Is Traks open source?+

The upstream repository declares the MIT license. Read its terms at https://raw.githubusercontent.com/shivamanupadi/traks/5b3a97eb4d403c170479a2626697eb4fd9604c36/LICENSE. Source code and contributor credit are available at https://github.com/shivamanupadi/traks.

Discussion · 0

sign in to comment →
No comments yet — be the first.