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

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.

ExplanationLast updated

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.

Where a row's attribution comes fromOne request arrives carrying business-dimension headers. TokenTriage stamps two dimensions itself from the resolved virtual key — the team node and the key id — which a client cannot influence. It reads four more from headers the client set: tenant, feature, user and free-form tags. Six of these, together with the model, tier, provider and endpoint the decision produced, form the eight-dimension hourly rollup tuple. The user dimension and the free-form tags are deliberately left out of that tuple because their value space is unbounded, so a question grouped by either is always answered by scanning rows instead.One requestx-tt-tenantx-tt-featurex-tt-user, tagsTokenTriageresolves the keywrites one rowStamped by the serverfrom the resolved virtual key — a client cannot set thesedim.teamdim.keyAsserted by the clientread from headers, off by default, capped per hourdim.tenantdim.featuredim.userdim.tag.<key>The hourly rollup tuple — 8 dimensionsthe six above minus user and tags, plus what the decision producedmodel_served · tier · provider · endpointdim.user and dim.tag.<key> are outside it — always answered from rows

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.