What the rollups make cheap
An hourly pre-aggregation on eight dimensions, the six clauses that decide whether your question uses it, and its two permanent blind spots.
On this page
“Spend by team, by month” sounds like one question. Against raw rows it is a scan of everything you logged that month, and it gets slower every month you keep.
The usual fix is to pre-compute. TokenTriage maintains an hourly summary keyed on eight attribution dimensions, so a month-scale question adds up a few thousand cells instead of a few million rows. What makes it worth a page of its own is not that it is faster. It is that a pre-aggregation quietly decides which questions you are allowed to ask cheaply, and most tools rarely tell you which side of that line you are on.
The tuple
One cell per hour, per combination of these eight:
| Dimension | Where the value comes from | Bounded by |
|---|---|---|
dim.tenant |
the x-tt-tenant header |
a per-hour cardinality cap |
dim.team |
the resolved virtual key’s team node | the keys you issue |
dim.key |
the resolved virtual key’s id | the keys you issue |
dim.feature |
the x-tt-feature header |
a per-hour cardinality cap |
decision.model_served |
the model actually served, after any rewrite | your configuration |
decision.tier |
the tier the router chose | your configuration |
http.provider |
the upstream the request went to | your configuration |
http.endpoint |
the API endpoint class | the dialects |
Group by any of them, filter on any of them, and the answer comes from cells. The measured figure on the project’s own benchmark box is 23.5 ms for cost-by-team-by-month against 69.3 ms for the same question over rows: three times faster, on that corpus, on that hardware. See benchmarks for the conditions; it is a ratio on one fixture rather than a promise about yours.
Every cell also stores its dollars as an integer count of nanodollars rather than a float, because a month × team × model matrix summed in floating point is precisely where two screens come to differ by a cent.
The two questions it will not answer
dim.user is not in the tuple. Neither is any free-form dim.tag.<key>. Both are left out, and permanently.
Every dimension in the list above is bounded by something you control: the keys you issue, the providers you configure, or a cap. A user dimension has no such bound: a consumer product has as many users as it has users, and a tag namespace is whatever your code invents. Pre-aggregating either would make the summary table grow with your traffic instead of with your configuration, which is the exact failure the summary exists to prevent.
So a cost-by-user question scans rows. Every time, forever. It still answers, and the number is identical. It only costs a scan, and that cost is a fact about how much you have logged rather than about this code.
Six clauses decide the path
Not being in the tuple is only one of the ways a question ends up scanning. Six clauses run in order, and any one of them sends the query to rows.
Read the clauses top to bottom; the question takes the upper path only if all six pass.
- Every grouped dimension is in the tuple. Up to three of them on the rollup path, since the tuple is already materialised.
- Every filtered field is in the tuple, or is the timestamp, and does not name a value that overflowed its cap.
- Every requested metric is one a summed cell can answer. Sums and counts can be added up.
Percentiles cannot:
p95_costandp99_ttftare distributional and cannot be reconstructed from totals, so they are always answered from rows. - A time bucket is an hour or coarser, and every UTC offset the window’s timezone takes is a whole number of hours. A half-hour zone cannot align to an hourly cell.
- No hour in the window is awaiting recomputation. A cell whose source rows changed is served from rows until it has been rebuilt.
- A top-N ordering sorts by a metric a cell can answer.
Clause 4 is the one that surprises people. Asking for a daily series in Asia/Kolkata, a
half-hour offset, falls to rows however simple the rest of the query is, because a day boundary
there does not land on an hour boundary in UTC.
Finding out which path answered
Every answer carries a provenance block, and path names it:
{
"provenance": {
"rows": 120,
"estimated_rows": 9,
"unpriced_rows": 9,
"source": "index",
"path": "rollup"
}
}
Group the same window by dim.user instead and the identical query returns "path": "rows".
The cardinality cap
Two dimensions in the tuple are set by whoever calls your gateway: dim.tenant and
dim.feature. Left unbounded, one badly behaved client sending a fresh tenant string per
request would make cell count grow with row count, which defeats the whole structure.
So each is capped at 256 distinct values per hour. Which values survive is decided by
request volume, highest first, so your real traffic stays addressable and the long tail folds
into a bucket labelled (overflow).
Three things follow, and they are the reason this is a section rather than a footnote:
- The overflow bucket is labelled wherever it renders. It cannot be mistaken for a tenant.
- Filtering on an overflowed value falls back to rows. Answering from a cell that no longer represents that value would be wrong, so the query takes the slow path and stays correct.
- The empty value is never folded. “The client asserted nothing” is a different fact from “too many distinct values to keep”, and merging them would make an unattributed row indistinguishable from a truncated one.
If you legitimately have more than 256 active tenants an hour, the rows are all still exact — you are trading the pre-aggregated view rather than the data.
What it costs to keep
Rollup maintenance runs inside the ingest transaction, so it is not free. The budget is 15% of ingest, and the honest number depends on the shape of your traffic: the cost tracks cells while ingest tracks rows, so what matters is rows per cell.
On the project’s thin default fixture (20,000 rows spread over 13,518 cells, a ratio of 1.48) the measured overhead is +20.1%, above the budget, and the gate reports rather than fails there because no production ledger has that shape. At 66 rows per cell it is enforced properly. A full rebuild with rollups measured 1.25× a rebuild without them.
None of this touches the request path. Analytics is off the hot path entirely, and the bytes your clients receive are identical with the whole layer armed.
Turning it on
Rollups live inside the derived index, so both have to be enabled:
dashboard:
enabled: true
index:
enabled: true # required: the rollup tables live in the index
analytics:
enabled: true # the master gate, off by default
rollups:
enabled: trueAsking for analytics without the index is refused at validation, naming both reasons:
$ tokentriage validate --config tokentriage.yaml
Error: configuration is invalid: config tokentriage.yaml: analytics.rollups.enabled: requires dashboard.index.enabled (the rollup tables live in the index file and are maintained by its indexer)
analytics.recommendations.enabled: requires dashboard.index.enabled (detection reads the derived index)
Error: configuration is invalid: config tokentriage.yaml: analytics.rollups.enabled: requires dashboard.index.enabled (the rollup tables live in the index file and are maintained by its indexer)
analytics.recommendations.enabled: requires dashboard.index.enabled (detection reads the derived index)The message is printed twice. That is a known display bug in the CLI’s error handling rather than two distinct problems, and it affects every command that fails, so read the block as one refusal naming two reasons.
Toggling rollups changes the index’s schema footprint, so it takes a restart rather than a live
reload. The index itself is disposable: delete it and rebuild from the log with
tokentriage index rebuild.
What can go wrong
A view is slow and you blame the hardware. Check provenance.path first. A rows answer on
a month-scale question means one of the six clauses sent it there, and the usual culprit is a
dim.user grouping or a percentile metric.
Two screens disagree. They should not: the CLI, a rollup answer, a raw scan and an export all compute the same total for the same question, and that equivalence is what the nanodollar integers protect. If they differ, the windows or the filters differ.
A tenant vanished. Look for (overflow) before looking for a bug.