#WaeAnalyticsProvider
#Import
1import { WaeAnalyticsProvider } from "alepha/api/analytics";
#Overview
Hot rows on Workers Analytics Engine, rolled rows in a durable store.
A DI-injectable provider, not a plain constructor-options class — this is
the second design of this class. The first took dataset / sql / cold
as constructor options, which made it easy to unit test but impossible for
index.workerd.ts to select automatically: alepha.with({ provide, use })
constructs use via alepha.inject(use), which needs a class DI can build
on its own. Follows CloudflareEmailProvider closely — the closest
existing analogue, combining a write-only Workers binding with an
account-scoped REST API gated by CLOUDFLARE_ACCOUNT_ID /
CLOUDFLARE_ANALYTICS_TOKEN:
$inject(Alepha)+$hook({ on: "start" })readsthis.alepha.get("cloudflare.env")and stores the binding in aprotectedfield, exactly likeR2FileStorageProviderandCloudflareEmailProvider.cold = $inject(OrmAnalyticsProvider)is the concrete class, not$inject(AnalyticsProvider). Injecting the abstract seam here would be circular the momentindex.workerd.ts'sregister()substitutes this very class in for that seam — this provider would try to inject itself.AnalyticsEngineSqlis still a plain constructor-options class (nothing about it needs DI), just built internally by {@link sql} rather than passed in — the same relationshipCloudflareEmailProvider.sendViaResthas withfetch.
Testability does not regress: alepha.set("cloudflare.env", { NAME: fake })
before start() substitutes the write binding, the same pattern
CloudflareEmailProvider.spec.ts uses; a test subclass overriding
{@link httpFetch} substitutes the read transport, the same pattern
CloudflareEmailRest.spec.ts uses for httpPost.
#Every number read back is an estimate
Analytics Engine samples, and _sample_interval — how many real events each
stored row stands for — varies per row, so a constant multiplier is wrong.
Every measure comes back as sum(double * _sample_interval) — the
sample-interval-corrected sum, never a raw stored double — and the result
carries estimated: true so a UI cannot present them as measurements by
accident.
#The cold tier cannot be Analytics Engine
Writing aggregates back as new data points would give them a fresh retention
clock, re-sample already-sampled data, and require a discriminator to keep
rolled rows from being counted alongside the raw ones they summarise. So a
Cloudflare deployment needs a relational store for anything older than the
hot window — the same compromise unique visitors already forced on
WaeAnalyticsStore in @alepha/sigil.
record() never writes to cold — only to Analytics Engine — so cold's
raw tier starts every dataset's life with zero rows for it. Left alone,
cold.rollup() would fold a table nothing ever populated: a structural
no-op, not a race, and it would silently lose every hour past Analytics
Engine's own ~90-day retention, which is exactly what a hot/cold split
exists to prevent. {@link rollup} closes that gap itself, immediately
before delegating: it tops up cold's raw tier with Analytics Engine rows
older than before (hour granularity, matching what record() would have
written directly), then calls cold.rollup() to fold them — see
{@link forwardToCold}. What crosses over is the sample-corrected total
query() already computed, not a raw stored double, so cold's own
arithmetic (the upsert accumulate, the day fold) can add and fold it
exactly like any other number, the same one-way trip every folded number
already takes when OrmAnalyticsProvider moves a row from its own raw
tier to its own rolled tier. That is a statement about arithmetic, not
about epistemics, and the two must not be conflated: the number is safe to
add without re-applying a correction, but it is still, irreducibly, a
number that came from a sample. See "The read side has to merge too"
below for why estimated stays true regardless of which tier answered a
query.
#prune() cannot rely on deletion alone
Analytics Engine has no delete API, so cold.prune(dataset, before) alone
cannot make Analytics Engine's own copy of [..., before) stop existing —
it only removes cold's copy. Left at that, AnalyticsProvider.prune's
own contract ("deletes every row older than before, on whichever tier
it lives") would be silently broken on this backend: a query for an
already-pruned window would fall out of cold and fall back to Analytics
Engine, which still has it and always will until its own ~90-day
retention eventually, invisibly, ages it out. {@link prune} therefore also
durably records before as a prune floor — via
OrmAnalyticsProvider.recordPruneFloor, kept in cold because it is the
one piece of this provider's state that already survives a restart — and
{@link query}/{@link forwardToCold} both clamp their effective since to
it, on every read, regardless of what either tier currently holds. That is
what makes prune() mean the same thing here as it does on
OrmAnalyticsProvider: once pruned, gone from every result, not merely
from cold's own copy. See recordPruneFloor's own doc for why this is a
dedicated table rather than a row in the dataset's own raw/rolled table,
and {@link prune}'s own doc for why the floor is written before the
delete, not after.
#The read side has to merge too
Forwarding rows into cold is pointless if query() never reads them
back — a window older than Analytics Engine's retention would still
silently return nothing, and the worst case is a window straddling the
boundary returning only the Analytics Engine portion with no sign anything
is missing. So {@link query} queries both sources and merges, the same way
OrmAnalyticsProvider.query() already merges its own raw and rolled
tiers — same merge key (JSON.stringify of the grouped dimension values),
the same mergeable aggregate (sum, added across sources), ordering and
limit applied once to the merged set rather than per source. Two things
are specific to a composite of two different backends rather than two
tables in the same one:
- Skipping
coldwhen it cannot matter. A window entirely withindataset's declared hot retention cannot have anything forwarded into it yet in a correctly-running system — see {@link mightNeedCold} — soquery()skipscoldfor that case with zero calls to it, the same structural argument that makes {@link forwardToCold} safe rather than a speed hack. - Not double-counting an hour Analytics Engine still has after it was
forwarded. Analytics Engine has no delete API, so a forwarded hour
keeps existing on both sides forever.
query()re-derives the same watermark {@link forwardToCold} uses and narrows the Analytics Engine side'ssinceto exclude whatevercoldalready covers — see {@link nextBucket} — rather than trusting the two sources to be disjoint. estimatedis unconditionallytrue. Not just because Analytics Engine samples, but because a rowcoldholds only ever got there throughforwardToCold, which itself read it out of Analytics Engine as a sample-corrected estimate. Landing in a relational table does not retroactively make it a measurement — so a merge where every contributing row came fromcoldis not exact either, and does not reportestimated: false. Nothing this provider can return was ever measured directly; onlyOrmAnalyticsProviderrunning on its own (never forwarded through Analytics Engine at all) earns that.
#Writes are free of round-trips
writeDataPoint() returns nothing and is not awaited; the runtime writes in
the background. The sequential round-trip cost that dominates a remote
database disappears on this path entirely.