How a dollar gets a name
A row's attribution comes from three authorities. Two can be set by anyone who can call your gateway, and it matters which is which.
On this page
A total is only as useful as the ways you can split it. $61,000 last month prompts one
question and answers none. $44,000 of it was one tenant's nightly summarisation job ends the
meeting.
Getting from the first sentence to the second means every request has to arrive carrying enough identity to be filed under a name you recognise. This page is about where those names come from, which of them you can rely on in an argument about money, and which of them anybody who can reach your gateway can write whatever they like into.
You want turn on business dimensions if you are here to do the configuration. This page is the model behind it.
Nothing is inferred
TokenTriage will not guess. A request that arrives with no identity headers produces a row with
no tenant, no feature and no user, and its spend lands in an unattributed bucket that is
rendered as its own first-class row rather than distributed across the named ones.
That is a deliberate trade and it costs something. A gateway that inferred a tenant from the source IP, or stitched a conversation from timing gaps, would produce a fuller-looking breakdown. It would also produce a breakdown that is wrong in ways nobody can see, and a cost allocation nobody can see the errors in is one you cannot defend when a team disputes their share. So attribution comes only from explicit identity on the wire, and a gap stays visible as a gap.
Three authorities, not one
A row’s dimensions come from three places, and the difference between them is the difference between evidence and a claim.
Read it left to right. The request arrives carrying whatever headers the client chose to set. TokenTriage stamps two dimensions itself, reads four more from headers, and folds most of them into the hourly pre-aggregation on the right, leaving out the two whose value space has no bound.
Stamped: dim.team and dim.key
When a request authenticates with a virtual key, the proxy
records the team node that key belongs to and the key’s own id. Neither is read from the
request. A client that sends x-tt-team: finance gets that header stripped and ignored; the
value on the row is the one the server resolved.
This is the attribution you can take to a billing dispute. It cannot be spoofed by the caller, because the caller never supplies it. It follows from which credential was presented, and changing it means being issued a different key.
Asserted: dim.tenant, dim.feature, dim.user, dim.tag.<key>
These come from headers the client sets, and the client can set them to anything.
| Header | Dimension | Cap |
|---|---|---|
x-tt-tenant |
dim.tenant |
128 bytes |
x-tt-feature |
dim.feature |
128 bytes |
x-tt-user |
dim.user |
128 bytes |
x-tt-tag-<key> |
dim.tag.<key> |
key 32 bytes, value 128 bytes, 8 tags |
That is not a flaw. Your application is the only thing that knows which feature a request came
from, so asking it is the only way to find out. But it means an asserted dimension is a claim by
your own code, and it is worth as much as your code’s discipline about setting it. TokenTriage
marks the distinction rather than smoothing it over: every chargeback row carries an asserted
field, and a row built from a client-supplied dimension does not get to look like one the server
stamped.
Derived: what the decision produced
Four more dimensions come from the request’s own handling and need no client cooperation at
all: decision.model_served (the model actually served, after any rewrite), decision.tier,
http.provider and http.endpoint. These are always present, on every install, with nothing
turned on. They are why a fresh deployment can still tell you that spend concentrated in one
model on one provider, and why it cannot tell you whose it was.
Identity versus attribution
The x-tt-* namespace carries two different families of header and they answer different
questions. Mixing them up is the most common source of “I set the headers and the breakdown is
still empty”.
| Family | Headers | Answers | Where it is documented |
|---|---|---|---|
| Identity | x-tt-trace-id, x-tt-step-id, x-tt-parent-id, x-tt-agent, x-tt-step-class |
which run, which step, which agent | what identity is on the wire |
| Business dimensions | x-tt-tenant, x-tt-feature, x-tt-user, x-tt-tag-<key> |
whose budget this comes out of | this section |
Identity headers work out of the box. Business dimensions need
ledger.dimensions.enabled: true, which is off by default. With the flag off nothing is
extracted, whatever the client sends, and every dim.* reads as absent everywhere including in
routing rules.
Both families are stripped from the request before it is forwarded, so your provider never sees them.
Working the trace tree
Identity headers earn their keep when the question is which step of which agent, rather than
whose budget. With x-tt-trace-id and x-tt-step-id set, one conversation collapses into a
costed tree:
$ tokentriage top --trace tt_00000000000000000000000001
trace tt_00000000000000000000000001 (1 steps)
STEP CLASS AGENT MODEL COST IN OUT CACHE-R
tt_00000000000000000000000001 user_turn batch-runner anthropic/claude-opus-4-8 $0.100000 20000 0 0
TOTAL $0.100000 20000 0 0
That capture is from tokentriage demo, whose synthetic traces are single-step, so the tree is
one row deep. A real agent that sets x-tt-parent-id on its delegated calls renders as an
indented tree with each sub-agent’s subtotal underneath it.
Attribute cost per agent step is the recipe.
What can go wrong
You turned dimensions on and last month is still unattributed. Dimension extraction applies to rows as they are written. Turning the flag on does not reach back into rows that were logged without it, and no rebuild will recover a header nobody sent. Turn it on before you need it.
One team’s spend is spread across four spellings of its name. Nothing normalises an asserted
dimension. finance, Finance and finance-team are three tenants as far as the ledger is
concerned. Decide the vocabulary in your own code, ideally in one place that all your services
call.
A tenant’s spend vanished into (overflow). The two asserted dimensions that are in the
hourly pre-aggregation, dim.tenant and dim.feature, are capped at 256 distinct values per
hour to stop one caller making the rollup table grow without limit. Past the cap, values fold
into a labelled (overflow) bucket, chosen by request volume so your real traffic stays
addressable. If you legitimately have thousands of tenants an hour, see
what the rollups make cheap.
Why dim.user is not in the pre-aggregation, and never will be
The hourly rollup materialises one cell per hour per combination of its eight dimensions. Every
one of those eight is bounded by something: dim.team and dim.key by the keys you issue,
model_served, tier, provider and endpoint by your configuration, and dim.tenant and
dim.feature by the cardinality cap.
dim.user has no such bound, since a consumer product has as many users as it has users, and
neither does the free-form tag namespace. Pre-aggregating them would make the rollup table grow
with your traffic rather than with your configuration, which is the failure mode the
pre-aggregation exists to avoid.
So a per-user question scans rows every time. It still answers, and the answer is identical; it only costs a scan. This is a permanent property of the design rather than something a later release tunes away.