At some point every app needs to know how it's being used. Which screens people open, at what time of day, whether push notifications arrive in time, whether moderation keeps up. For Perron, our train-station chat app, we wanted those answers without putting a third-party analytics SDK in the app, and without storing anything that could tell us what one particular person did.
That second part isn't only a privacy stance, though it's that too. Perron's users are anonymous by default. The app knows which station you're at for as long as you're there, and the less of that we keep, the less we have to protect, explain and delete.
Cloudflare's Workers Analytics Engine turned out to be a good fit. It's a time-series store you write to from a Worker with one function call and query with SQL. We use it for three streams: general usage, the push notification pipeline, and the moderation pipeline. This is how we lay out the data, how we query it, and where we deliberately don't use it.
The basics
You add a dataset binding to your Worker and call writeDataPoint(). The write doesn't return a promise you need to wait on, and it doesn't slow the request down. Later you query the dataset over an HTTP SQL API.
"analytics_engine_datasets": [
{ "binding": "USAGE_AE", "dataset": "app_usage" },
{ "binding": "PUSH_AE", "dataset": "app_push" },
{ "binding": "MOD_AE", "dataset": "app_moderation" }
]
We keep one dataset per subject. Each has its own column layout, and keeping them apart means a change to the push layout can't confuse a usage query. The staging Worker binds its own datasets with a _staging suffix, so a load test never shows up in production's graphs.
A data point has no column names
This is the first thing that feels strange. A data point has up to 20 blobs (strings), stored as blob1 to blob20, up to 20 doubles (numbers), stored as double1 to double20, and a single index of at most 96 bytes, which controls how the data is sampled. The blobs in one point must total under 16 KB. A single Worker invocation can write at most 250 points. Data is kept for three months. All of that is on the Analytics Engine limits page.
There's no schema in the database. Columns are positional, so the schema lives in your code, and if you don't write it down, it lives only in your head. We put it in a comment directly above the write, every time. Here's the push pipeline's:
// index1 = kind
// blob1 = event, blob2 = kind, blob3 = station, blob4 = error code
// double1 = targets, double2 = delivered, double3 = failed, double4 = queued,
// double5 = attempt, double6 = wait ms, double7 = job age ms
env.PUSH_AE?.writeDataPoint({
indexes: [p.kind],
blobs: [p.event, p.kind, p.station, p.code ?? ""],
doubles: [p.targets, p.delivered ?? 0, p.failed ?? 0, p.queued ?? 0,
p.attempt ?? 0, p.waitMs ?? 0, p.ageMs ?? 0],
});
And the moderation pipeline's, which records every batch, provider failure, takedown, deferral and budget switch:
// index1 = event
// blob1 = event, blob2 = provider, blob3 = model, blob4 = reason
// double1 = items, double2 = unsafe, double3 = ms, double4 = neurons,
// double5 = pending, double6 = failure streak
Two rules keep this manageable over time. Never change the meaning of a position; new fields go on the end, so old rows keep their meaning and old queries keep working. And analytics must never break the feature. Every write is wrapped so a failure is swallowed. If we hit the per-invocation limit, we lose a data point, not a push notification.
The optional chaining on env.PUSH_AE?. is there for the same reason. In unit tests there's no binding, and a missing analytics binding should mean no analytics, not a crash.
No IDs, by design
Our usage events carry an event name and a count. That's all. No user ID, no device ID, no IP address, no text. A moderation point records the outcome and how long it took, never the post. A push point records the station code and the counts, never the push token or the message.
That's a deliberate limit, and it shapes what we can ask. Analytics Engine answers "how many, when, of what kind" very well. It's the wrong tool for "what did this person do", and that's a question we've decided not to be able to answer.
The index deserves a moment. It's tempting to put a user ID there because the docs describe it as the sampling key, and high-cardinality keys are what it's built for. We put the event name or the kind there instead. That means sampling happens per kind of event, which is what we'd want anyway: a flood of one common event can't crowd out a rare one.
Counting people without tracking them
There's one number every dashboard wants that a pure event stream can't give you: how many distinct devices used the app today, this week, this month. You can't compute that from points that carry no ID.
For that, Perron has a separate ledger in a Durable Object, and it's built to be as small as it can be. The app keeps an anonymous analytics ID on the device. It isn't the session ID and it's never attached to any behavioural event. When the app reports activity, the Durable Object hashes that ID with SHA-256 and inserts one row: this hash was active on this day. That's the whole record.
private async recordActive(anonId: string, day: string): Promise<void> {
const anon = await sha256Hex(anonId);
this.ctx.storage.sql.exec(
"INSERT OR IGNORE INTO active (day, anon) VALUES (?, ?)", day, anon,
);
// … plus a last-seen day per hash, for new vs returning
}
Daily, weekly and monthly actives are then a COUNT(DISTINCT anon) over a date range. The ledger is swept on a short retention: day rows go after 35 days, and the last-seen record after 60. There's no way to join it to an event, because no event carries the hash.
It's not perfect anonymity, and we don't pretend it is. A hash of a stable ID is still a stable pseudonym for as long as it's kept. What it gives us is a ledger that can only answer "how many", that forgets on a schedule, and that holds nothing else.
Querying with SQL
The SQL API is one POST per query to your account, with a token that has Account Analytics: Read permission. The query goes in the body as plain text, not JSON:
const res = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`,
{
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "text/plain" },
body: sql,
},
);
const { data } = await res.json();
That token is read-only and scoped to analytics. It's a Worker secret, and the admin dashboard is the only thing that uses it.
Always weight by _sample_interval
This is the mistake everyone makes once, and it's worth getting right before your numbers are big enough for it to matter.
At high volume, Analytics Engine samples. A stored row may stand for several original rows, and the _sample_interval column says how many. A plain COUNT() counts stored rows, so it starts undercounting the moment sampling kicks in, and nothing tells you it's happened. Your graph just gets quietly wrong.
So count with SUM(_sample_interval), and sum a value with SUM(_sample_interval * doubleN). Here's activity by hour for the last day:
SELECT toStartOfHour(timestamp) AS hour,
sum(_sample_interval * double1) AS count
FROM app_usage
WHERE timestamp > now() - INTERVAL '1' DAY
GROUP BY hour
ORDER BY hour ASC
The daily rhythm over a week, folded into hours of the day. For a train app, this is the graph that shows whether people use it on the commute or only when something goes wrong.
SELECT toHour(timestamp) AS hour,
sum(_sample_interval * double1) AS count
FROM app_usage
WHERE timestamp > now() - INTERVAL '7' DAY
GROUP BY hour
ORDER BY hour ASC
And the live event mix, which is handy right after a release:
SELECT blob1 AS event, sum(_sample_interval * double1) AS count
FROM app_usage
WHERE timestamp > now() - INTERVAL '1' DAY
GROUP BY event
ORDER BY count DESC
Timestamps are UTC. If you show hours of the day to people in Amsterdam, convert them, or your morning peak will appear at 06:00 in winter and 05:00 in summer.
Durable Object counters next to Analytics Engine
We count the same usage events in two places, on purpose.
The Usage Durable Object keeps exact daily totals per event in SQLite. The admin dashboard reads them instantly, they're always exact, and they don't depend on any external API. Analytics Engine gets the same events mirrored, for the questions daily counters can't answer: hour by hour, any time range, the mix right now.
The dashboard treats Analytics Engine as best effort. If a query fails or a dataset is still empty, that panel just doesn't render. The exact counters are still there next to it. It means a hiccup in one API can never blank the admin page we use when something's going wrong.
There's one more thing the Usage object tracks: how many Analytics Engine writes we make. Per-request figures for Analytics Engine writes aren't available in the account's GraphQL analytics, so we can't see them alongside everything else Cloudflare reports. A small self-meter in the Durable Object fills the gap, and it shows up on the admin services page next to the numbers from Cloudflare.
What it costs
At the time of writing, Cloudflare says Analytics Engine isn't being billed yet. The published allowances are 100,000 data points written and 10,000 read queries a day on the free plan, and 10 million data points and a million queries a month on Workers Paid, then $0.25 per extra million points and $1 per extra million queries. Check the pricing page before building on it.
We plan as if billing has already started. One point per meaningful event, never one per request, and nothing written in a loop without a cap. At those prices it'd take a lot to notice, but "free for now" has a way of becoming a line item, and it's easier to design for it from the start.
Where it fits, and where it doesn't
Analytics Engine is good at counts and timings with a handful of dimensions: feature usage, error codes, latencies, queue behaviour, model costs. It's fast to write, cheap to query, and needs no infrastructure.
It's not where we'd keep anything we need exactly, like billing numbers or a count we promise someone; those go in a database. It's not for per-user histories, by its nature and by our choice. And it only keeps three months, so anything you want year over year needs rolling up into something more permanent.
Inside those limits, it means the usage questions we actually ask get answered on our own infrastructure, with no analytics SDK in the app.
More from the same pipelines: push notifications through Cloudflare Queues and AI moderation with Workers AI.