Search

↑↓ to move↵ to openesc to closetop 6 documentation results

Book a demoTry TokenTriage

In development · Explore the product preview. Join the launch list or book a walkthrough.

Browse docs

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.

ExplanationLast updated

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.

How a cost question picks its pathA cost question runs through six clauses in order. Every grouped dimension must be in the eight-dimension tuple; every filtered field must be in the tuple or be the timestamp, and must not name a value that overflowed its per-hour cap; every requested metric must be one a summed cell can answer, which percentiles never are; a time bucket must be an hour or coarser and the window's UTC offsets must be whole hours; no hour in the window may be awaiting recomputation; and a top-N ordering must sort by a rollup-servable metric. Pass all six and the question is answered from pre-aggregated hourly cells. Fail any one and it is answered by scanning rows, which returns the same numbers and takes longer. The response discloses which path answered, but not which clause sent it there.Your questiongroup, filter, metricSix clauses, in order1 · group dims ⊆ the tuple2 · filter fields ⊆ tuple, no (overflow)3 · metrics are summable4 · bucket ≥ 1h, whole-hour offsets5 · no hour pending recomputation6 · top-N sorts by a summable metricall six pass → cells · any one fails → rowsboth answers are the same numbersrolluppre-aggregated cellsrowsscanned on the fly

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:

{
  "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: true

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