# 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.

Source: https://tokentriage.com/docs/build/cost-rollups

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';
import RollupPlanDiagram from '../../../components/RollupPlanDiagram.astro';

"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](/docs/reference/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.

<Callout type="key">
A question outside the tuple is answered, not refused. The rollup and rows paths return the same
numbers; the only difference is what it took to get them. Falling back is correctness-preserving
by construction, and nothing is silently dropped from your query to make it fit.
</Callout>

## 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.

<RollupPlanDiagram />

Read the clauses top to bottom; the question takes the upper path only if all six pass.

1. **Every grouped dimension is in the tuple.** Up to three of them on the rollup path, since
   the tuple is already materialised.
2. **Every filtered field is in the tuple**, or is the timestamp, and does not name a value
   that overflowed its cap.
3. **Every requested metric is one a summed cell can answer.** Sums and counts can be added up.
   Percentiles cannot: `p95_cost` and `p99_ttft` are distributional and cannot be reconstructed
   from totals, so they are always answered from rows.
4. **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.
5. **No hour in the window is awaiting recomputation.** A cell whose source rows changed is
   served from rows until it has been rebuilt.
6. **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:

```json
{
  "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"`.

<Callout type="warning">
The response tells you **which** path answered, not **why**. The planner computes a precise
reason (`group_by dimension "dim.user" is not in the rollup tuple`), but it is not carried on
the wire, and only surfaces in one case: a three-dimension group-by that is not rollup-servable,
which is a `400` rather than a fallback because the rows path cannot serve three. If a cost view
is slow, check `path` first, then work back through the six clauses yourself.
</Callout>

## 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.

<Details summary="Turning it on">

Rollups live inside the derived index, so both have to be enabled:

```yaml
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: true
```

Asking for analytics without the index is refused at validation, naming both reasons:

```console
$ 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`.

</Details>

## 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.

<NextSteps items={[
  {title: "How a dollar gets a name", href: "/docs/build/cost-attribution", description: "Where each of the eight dimensions comes from, and which two of them a caller can write."},
  {title: "Read your spend from the terminal", href: "/docs/build/cost-cli", description: "The path that needs none of this: top and report straight over the log."},
  {title: "Chargeback, showback and FOCUS", href: "/docs/build/cost-exports", description: "Reports served from these cells, and what they do with the rows that could not be priced."},
  {title: "Benchmarks", href: "/docs/reference/benchmarks", description: "The measured figures on this page with the corpus size and hardware they were taken on."},
]} />
