An analytics product is a claim about a number.Here is how this one counts.
Six decisions in the architecture, each one made to remove a way of being confidently wrong. Every claim below names the file it can be checked against, because the only useful answer to “why should I trust your numbers” is the mechanism.
The failure this is built against
Not downtime, and not a missing feature. A number that is wrong and looks fine.
An analytics tool that is unavailable is obvious, annoying, and survivable. One that quietly under-counts is neither obvious nor survivable: the chart renders, the trend looks plausible, and a decision gets made on it.
Most of the decisions below exist to close one of those routes. Sampling that silently under-counts, a column layout that can drift between writer and reader, a uniqueness question asked too late to answer — each is a way to produce a number that is wrong without producing an error.
A wrong number is worse than no number. Everything here follows from taking that literally.
Uniqueness is decided when the event is written, not when it is read
Decision 1Analytics Engine has no COUNT(DISTINCT). There is no query that can be issued later to recover how many different people a set of rows represents, so the question has to be answered once, at the edge, while the request is still in hand.
The collector asks KV whether it has seen this visitor today and writes the answer as a 1-or-0 column. Counting uniques afterwards is then a sum over that column rather than a set operation the store cannot perform.
The cost of that choice is worth stating plainly: uniqueness cannot be recomputed later under a different grouping. A flag written for a site is a flag for that site, and no query can retroactively ask what it would have been per country.
in the code
apps/collector/src/dedup.tsresolveFlags asks KV once per event and returns the flag the writer records.Every total is multiplied by the sample interval
Decision 2Above roughly a million writes a minute, Analytics Engine begins sampling: it keeps a subset of rows and records in _sample_interval how many real events each surviving row stands for.
A plain SUM over that data does not fail. It returns a number, quickly, and that number is too low. There is no error, no warning, and nothing in the result that looks wrong — which is the most expensive failure an analytics product can have.
So the weighting is not applied by whoever writes a query. It is built into the one helper that constructs them, and there is no path to a total that skips it.
in the code
packages/analytics/src/schema.tsEvery measure is emitted as SUM(column * _sample_interval) by construction.The column layout is defined once, for writer and reader alike
Decision 3Analytics Engine columns are positional. They are blob1, double3, index1 — the store holds no names, so nothing validates that the field the collector wrote into blob2 is the field the dashboard reads out of it.
A drift between the two would be silent. Not an error, not an empty chart: a chart of the wrong thing, labelled correctly, with plausible numbers in it.
One file defines the positions and both sides import it. Positions are append-only, because renumbering a field would reinterpret every row already written under the old layout.
in the code
packages/analytics/src/schema.tsThe collector builds data points from these positions; the dashboard builds SQL from them.Two stores, split by window rather than by preference
Decision 4Analytics Engine is fast and cheap, holds ninety days, and samples under load. The Iceberg table in R2 is exact and unsampled, answers real SQL with DISTINCT and CTEs, and is billed by the bytes a query scans.
Neither is better. They answer different questions, so reads are routed by the window being asked for: anything inside retention — which is every default view — goes to Analytics Engine, and only the long tail pays for the lake.
The comparison period is part of that calculation and easy to miss. A ninety-day range compares against days 91 to 180, which Analytics Engine no longer holds, so the range that looks like it fits is exactly the one that does not.
Realtime always comes from Analytics Engine, whatever the plan. The lake lands on its sink's roll interval, so who is on the site right now would be structurally stale if it were read from there.
Both stores are fed by the same collector. They describe the same events and differ in freshness and precision, never in content.
in the code
packages/analytics/src/drivers/cloudflare.driver.tsroute() picks the store from the range; range.ts holds the 90-day boundary and the comparison maths.A visitor is a hash that expires at midnight
Decision 5Identity is SHA-256 over a daily-rotating salt, the site id, and the subject — for a browser, the address and user agent; for a server-side event, the caller's own user id.
None of those inputs leave the function that hashes them. The raw value is not written to Analytics Engine, not sent to the Pipelines stream, and not stored in KV. What is stored is the digest, and the salt it was made with is gone by the next day.
That makes a person countable within a day and unrecognisable across days. It is a real limit, not a softened one: Inqetra cannot tell you that today's visitor came back on Thursday, because the architecture destroys the means to know.
It is also the reason there is no consent banner. Not a policy position — there is nothing stored to consent to.
in the code
apps/collector/src/identity.tsderiveVisitorId hashes salt:day, siteId and subject, and returns only the digest.The ingest path cannot reach the database
Decision 6Ingest is the hottest path in the system. A database round trip per pageview would put the tenant database — users, sites, API keys, billing — inside the blast radius of a traffic spike on any customer's site.
So the collector has no binding to it. Not a convention, not a code review rule: the Worker declares no database binding at all, so there is no route from a pageview to the tenant data even if someone wrote the query.
Sites and API keys are projected into KV by the dashboard. KV is a read-only projection; the database remains the source of truth.
in the code
apps/collector/wrangler.jsoncNo d1_databases binding is declared. The projection is written by the dashboard, never by ingest.
What this architecture cannot do
The same decisions that make the numbers trustworthy remove capabilities other tools have. Stating them is cheaper than having you discover them.
- Follow a person across days
- The salt rotates at midnight UTC, so yesterday's identifier cannot be reconstructed. There is no cohort retention and no returning-visitor curve, because the input to one no longer exists.
- Recompute uniqueness a different way
- The 1-or-0 flag was written under one grouping. No later query can ask what uniqueness would have been under another, because the store never held the identities to compare.
- Answer long ranges as fast as short ones
- Beyond ninety days the read leaves Analytics Engine for R2 SQL, which is exact and unsampled but scans billable bytes. Slower, and labelled slower in the interface.
- Show realtime from the lake
- The Iceberg table lands on its sink's roll interval. Who is online now always comes from Analytics Engine, on every plan, because the alternative would be stale by construction.
Read the rest in the source
Every file named above is in the repository, with the reasoning written beside the code rather than summarised away from it.
Start freeLast updated 30 September 2026