#Analytics
$analytics gives your app a portable, aggregate-on-read analytics dataset. You declare the
dimensions you group and filter by and the measures you aggregate once, and the same
declaration runs unchanged on a relational database, in memory for tests, and on Cloudflare
Workers Analytics Engine in production. Application code never names a backend — which one is
bound is a runtime decision made by the @alepha/analytics module.
It lives in its own package rather than inside alepha itself:
1import { $analytics } from "@alepha/analytics";
#Declaring a dataset
A dataset is index (which dimension Analytics Engine samples on), dimensions and measures
(each a z.object(...), exactly like an $entity schema), and an optional retention:
1import { $analytics } from "@alepha/analytics"; 2import { z } from "alepha"; 3 4class PageViews { 5 views = $analytics({ 6 index: "app", 7 dimensions: z.object({ app: z.text(), path: z.text(), country: z.text() }), 8 measures: z.object({ count: z.integer() }), 9 retention: { hot: "60d", rollup: "day", cold: "400d" },10 });11 12 async onPageView(app: string, path: string, country: string) {13 await this.views.record({ app, path, country, count: 1 });14 }15 16 async topPaths(app: string) {17 return this.views.query({18 since: "2026-01-01",19 where: { app },20 groupBy: ["path"],21 select: { count: "sum" },22 orderBy: { key: "count", direction: "desc" },23 limit: 20,24 });25 }26}
dimensions are the low-cardinality strings you group and filter by. measures are the numbers
you aggregate. Both read exactly like an entity schema because that is the point — a dataset
should not require learning a second schema language.
#Dataset names must be snake_case
A dataset defaults its storage-facing name to the property key it is declared on (views
above), the same way $storage names a bucket from its property key. That name has to match
/^[a-z][a-z0-9_]*$/ — lowercase letters, digits and underscores, starting with a letter —
because it becomes a relational table name fragment and the Analytics Engine blob1
discriminator (Analytics Engine has no table concept, so several datasets share one binding and
need something to tell their rows apart). A camelCase property key, which is the normal Alepha
convention everywhere else, is rejected at onInit with a message suggesting the snake_case
rename. If you want to keep the camelCase field, pass an explicit name:
1pageViews = $analytics({2 name: "page_views",3 index: "app",4 dimensions: z.object({ app: z.text() }),5 measures: z.object({ count: z.integer() }),6});
#Reserved names
time_bucket is the reserved column name the relational backend uses to store the bucket
itself, so it cannot be declared as a dimension or a measure. day and hour are reserved as
dimension names for the same reason — they are the pseudo-dimensions query() exposes for
grouping by time (see Querying below), and a real dimension with either name would
be permanently shadowed by them.
#Recording
1await this.views.record({ app, path, country, count: 1 });2 3// Or a batch:4await this.views.recordMany([5 { app, path, country: "FR", count: 1 },6 { app, path, country: "DE", count: 1 },7]);
Every row is stamped with an hour bucket, taken from DateTimeProvider unless you supply one
yourself ({ ...row, hour: "2026-08-09T14" }). Passing hour explicitly matters for anything
batched or retried: Analytics Engine stamps its own write-time timestamp and cannot backdate a
point, so a retried envelope has to carry the bucket it originally computed, or it lands in the
wrong hour for reasons that have nothing to do with sampling.
#Querying
1const result = await this.views.query({2 since: "2026-01-01",3 where: { app: "lore", country: { inArray: ["FR", "DE"] } },4 groupBy: ["path"],5 select: { count: "sum" },6 orderBy: { key: "count", direction: "desc" },7 limit: 20,8});
where supports equality and { inArray: [...] } — the same operator name the ORM's
repository filters use, no ranges. groupBy takes any
declared dimension, plus the pseudo-dimensions "hour" and "day" — grouping by "day" folds
hour buckets into a daily timeline with no date arithmetic on the caller's side, whichever
backend answers the query.
#The aggregate set is deliberately small
select only accepts "sum" as an aggregate. That is not a temporary gap — sum is the
complete set of aggregates that are simultaneously:
- Mergeable across a rollup boundary. When the hourly rollup folds a day's worth of hour
buckets into one day bucket (see Retention and rollup), the fold
itself has to be an aggregate: summing eight
sums produces the correct day-levelsum. There is no equivalent fold for an average or a percentile — the mean of several means is wrong the moment the buckets differ in size, and the p75 of several distributions is not the mean of their p75s. - Exactly correctable under sampling. Analytics Engine samples, and every stored row carries
a
_sample_interval.sum(x * _sample_interval)reconstructs the true total from a sampled window, exactly. Nothing aboutminormaxsurvives that reconstruction the same way: both merge across buckets by construction (the max of several maxes is the true max), but neither is sample-correctable — if the sampler happens to drop the one row holding the true extreme, no_sample_intervalweighting recovers it, and the query silently returns the extreme of whatever survived. That is the same failure mode that keeps distinct-counts out of this seam (see Unique visitors below), and admittingmin/maxdespite it would be inconsistent with excluding those.
#There is no count aggregate — declare a count measure and sum it
An earlier version of this package also accepted "count", meaning "the number of stored rows"
rather than a sum of any measure. That number is not portable: relationally it was COUNT(*),
and in memory it was one increment per recorded array entry — not the same number on identical
writes (a relational upsert accumulates repeated writes into one row; an in-memory record pushes
a new one), and it does not survive a rollup on either backend, because folding rows into a
day bucket collapses the very thing a row count was measuring. Summing eight sums across a
rollup boundary reproduces the pre-rollup total exactly; summing eight counts does not, because
after the fold there are fewer, larger rows to count. count was removed rather than special-cased,
for the same reason min/max were never admitted: an aggregate in this seam has to be correct
after a rollup and identical across every backend, not merely plausible on the one you tested
against.
The portable replacement is the pattern apps/lore's own sigil_views dataset already uses:
declare a measure that is 1 per event (call it count, or anything else) and sum it. That is
an ordinary sum, so it survives a rollup and a sampled backend for the same reason any other
measure does:
1measures: z.object({ count: z.integer() }),
1await this.views.record({ app, path, country, count: 1 });
1select: { count: "sum" },
Two patterns cover what the other missing aggregates would have given you, and both stay caller-side and obvious rather than needing a merge-rule enforcement layer inside the package:
- A mean: declare a
summeasure and a count-as-sum measure (see above), and divide them yourself once the query returns. - A percentile: see The histogram pattern below.
#What analytics cannot do
A dataset cannot answer "how many distinct visitors" — a distinct count cannot survive
sampling (a sampled window drops rows, so a naive COUNT(DISTINCT ...) under-counts) or a
rollup (once hour buckets fold into a day bucket, which visitor hashes contributed to which hour
is gone). apps/lore keeps unique-visitor counts on its own table
(LoreAnalyticsStore/sigil_uniques_daily) for exactly this reason — see that class's doc for
the full argument. If your app needs distinct counts, they need their own storage; $analytics
is not the tool for them.
#The histogram pattern
A percentile does not merge across buckets, but a histogram does — so a percentile is modelled
as an ordinary dimension holding the bucket index, with count (or whatever you call the
measure) as the thing you sum. This is exactly how apps/lore tracks Web Vitals:
1import { $analytics } from "@alepha/analytics"; 2import { z } from "alepha"; 3import { db } from "alepha/orm"; 4 5class WebVitals { 6 vitals = $analytics({ 7 name: "sigil_vitals", 8 index: "sigilId", 9 dimensions: z.object({10 sigilId: z.uuid(),11 metric: z.string(),12 path: z.string(),13 bucket: z.number(),14 }),15 measures: z.object({ samples: z.number() }),16 retention: { hot: "30d", rollup: "day", cold: "400d" },17 });18}
bucket is not special machinery — it is the histogram's bucket index, declared as an ordinary
z.number() dimension. Recording one vital sample means bucketing the raw value yourself (a CLS
score, an LCP duration in milliseconds) into a bucket index and incrementing that bucket's
samples count. To read a percentile back, query() grouped by ["metric", "bucket"] returns
the whole histogram as flat (metric, bucket, samples) rows, and the caller walks it — sums
samples from the bottom until the running total passes the target percentile of the overall
count, and that bucket's midpoint is the estimate. The walk (and any un-scaling a particular
metric's buckets need) belongs entirely to caller-side code; $analytics only ever stores and
returns counts per bucket.
#Retention and rollup
1retention: { hot: "60d", rollup: "day", cold: "400d" }
hot— how long raw, hour-bucketed rows are kept, as a day count ("60d").rollup— the granularity past the hot window. Only"day"exists today.cold— how long rolled (day-bucketed) rows are kept before deletion, also a day count. Must be at least as long ashotwhen both are set —$analytics()rejects a shortercoldat declaration time, because the sweep only ever folds up to the hot cutoff, and acoldboundary more recent than that would prune hour-precision rows the hot window still promises, before they are ever rolled up.
Both passes collapse rather than delete: folding hour buckets into a day bucket groups by
every declared dimension exactly as before and sums the measures within each group — no
dimension is dropped or merged away, and no total your UI shows ever changes. Only the
resolution of the time axis does, from hourly to daily. Deletion only ever happens past cold,
and only to already-rolled rows.
#retention.hot cannot exceed roughly 90 days on Analytics Engine
Cloudflare's own Analytics Engine keeps data for approximately 90 days regardless of what you
declare. Asking for a longer hot window does not error — it silently gives you a shorter
window than what you declared, on that one backend only. Keep retention.hot at 90 days or
under if the app might ever run on Analytics Engine.
#Declaring retention does nothing on its own
This is the sharpest edge in the whole primitive, worth stating plainly: nothing in
@alepha/analytics enforces retention automatically. Registering $analytics() datasets —
importing AlephaAnalytics — wires the provider and lets you record()/query(), full stop.
The hourly sweep that actually folds and prunes rows lives in a separate module,
AlephaAnalyticsRollup, which your app has to import explicitly alongside AlephaAnalytics:
1import { AlephaAnalytics, AlephaAnalyticsRollup } from "@alepha/analytics";2import { Alepha } from "alepha";3 4const alepha = Alepha.create()5 .with(AlephaAnalytics)6 .with(AlephaAnalyticsRollup);
Forgetting AlephaAnalyticsRollup is silent in the sense that nothing throws: record() and
query() keep working normally, and the raw table simply grows forever. It is not completely
silent, though — a boot-time log.warn from the retention guard names every dataset that
declares retention.hot while no rollup job was ever constructed, specifically so this mistake
does not stay invisible once the app is actually running.
The split exists because AnalyticsRollupJobs is built on $job, and $job always needs a
real database connection (it holds a $repository on its own job-execution table), in every
environment including tests. Folding the rollup job into AlephaAnalytics directly would mean
merely declaring one $analytics() field — the one thing this package promises works with no
database at all — starts requiring a live database connection to boot.
#Result epistemics: estimated and sampleInterval
Every query() result carries more than rows:
1export interface AnalyticsResult {2 rows: Array<Record<string, string | number>>;3 estimated: boolean;4 sampleInterval?: number;5}
estimatedisfalseon the relational and memory backends — they never sample, so their numbers are exact by construction. It istrueon Analytics Engine, which samples under load.sampleIntervalis the largest_sample_intervalseen in the window, when the backend samples. A value of1means no sampling actually occurred in that window, so the numbers are exact despiteestimatedbeingtrue— the common case at low traffic. A UI should not qualify the numbers ("approximate") whensampleInterval === 1; it should when it is greater.
The result carries this rather than a UI having to know which backend answered it, so ignoring
estimated is a visible choice made in the reading code, not an accident of which environment
happened to be running.
#Backends
The bound provider is a runtime decision the module makes, never something application code chooses:
| Environment | Provider | Behavior |
|---|---|---|
Tests (alepha.isTest()) |
MemoryAnalyticsProvider |
In-memory, exact, no sampling |
| Node / Bun, no Cloudflare binding | OrmAnalyticsProvider |
Relational tables, exact, no sampling |
| Cloudflare Worker with a dataset binding | WaeAnalyticsProvider |
Workers Analytics Engine, samples under load |
AlephaApiPayments, AlephaAnalytics and most other Alepha modules follow this same
test-substitution pattern — see Unit Tests for the general
shape.
#The Analytics Engine slot map is a wire format
Analytics Engine has no columns, only 20 positional blob slots and 20 positional double
slots per data point. AnalyticsSlotMap assigns each declared dimension a blob slot and each
measure a double slot, derived from the dimension/measure names sorted alphabetically —
never from declaration order. That has one direct consequence worth internalizing before a
dataset ships to production: reordering the fields in your dimensions/measures object
literal is a safe no-op, but renaming a dimension is a breaking change to already-stored data.
Once rows exist under the old slot assignment, changing which name maps to which slot does not
fail — it silently misreads history, because Analytics Engine has no way to know the shape
changed.
#Registering the module
A real example, from apps/lore:
1import { AlephaAnalyticsRollup } from "@alepha/analytics"; 2import { $module } from "alepha"; 3import { LoreAnalytics } from "./entities/loreAnalytics.ts"; 4 5export const LoreApi = $module({ 6 name: "lore.api", 7 // `$analytics()` (used by `LoreAnalytics`) auto-wires `AlephaAnalytics` itself 8 // the moment a dataset is injected — the same module-tagging mechanism 9 // `$repository` uses for `AlephaOrm`. The retention sweep does not auto-wire:10 // it needs its own explicit import, or retention silently does nothing.11 imports: [AlephaAnalyticsRollup],12 services: [LoreAnalytics /* ...the rest of the app's services */],13});
LoreAnalytics itself just declares two datasets — this is close to the real
apps/lore/src/api/entities/loreAnalytics.ts:
1import { $analytics } from "@alepha/analytics"; 2import { z } from "alepha"; 3import { db } from "alepha/orm"; 4import { sigils } from "./sigils.ts"; 5 6export class LoreAnalytics { 7 public readonly views = $analytics({ 8 name: "sigil_views", 9 index: "sigilId",10 dimensions: z.object({11 // db.ref works inside a dimension exactly like inside an $entity schema:12 // the relational backend gets a real foreign key with ON DELETE CASCADE,13 // Memory and Analytics Engine are unaffected since both only ever read14 // the dimension names, never the zod metadata attached to them.15 sigilId: db.ref(z.uuid(), () => sigils.cols.id, { onDelete: "cascade" }),16 path: z.string(),17 country: z.string(),18 }),19 measures: z.object({ count: z.number() }),20 retention: { hot: "30d", rollup: "day", cold: "400d" },21 });22}
#Testing
MemoryAnalyticsProvider is bound automatically under alepha.isTest() — no substitution
needed, and no sampling to account for in assertions:
1import { Alepha } from "alepha"; 2import { AlephaAnalytics } from "@alepha/analytics"; 3 4class Stats { 5 views = $analytics({ 6 index: "app", 7 dimensions: z.object({ app: z.text() }), 8 measures: z.object({ count: z.integer() }), 9 });10}11 12const alepha = Alepha.create().with(AlephaAnalytics);13const stats = alepha.inject(Stats);14await alepha.start();15 16await stats.views.record({ app: "lore", count: 1 });17const result = await stats.views.query({18 since: "2026-01-01",19 select: { count: "sum" },20});21 22expect(result.estimated).toBe(false);23expect(result.rows[0].count).toBe(1);