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

Turn on business dimensions

Group spend by tenant, feature, user and your own tags. Ten minutes of configuration, and a header your application sets on every request.

How-to guideLast updated

On this page

By the end of this page a costed row carries your tenant, your feature and your own tags, and you can split a month’s spend by any of them.

Business dimensions are off out of the box. With the flag off, TokenTriage strips the dimension headers and records nothing from them, so this is a change you want in place before the month you need to explain, because it applies to rows written after it and never backwards.

1. Turn the flag on

Add three lines under ledger:

ledger:
  dimensions:
    enabled: true # off by default
    max_tags: 8 # free-form x-tt-tag-<key> per request; default 8

Check it loads:

$ tokentriage validate --config tokentriage.yaml
OK: configuration is valid

Then restart. The flag is read when a request is handled, so a running process picks it up on a config reload or a restart, and rows written before either are unaffected.

2. Set the headers on your requests

Your application sets these on the requests it sends to the proxy. Only send the ones you have a value for. An empty header is the same as no header.

Header Records Use it for
x-tt-tenant dim.tenant the customer or workspace the request belongs to
x-tt-feature dim.feature the product surface that made the call
x-tt-user dim.user the end user, when you need per-seat spend
x-tt-tag-<key> dim.tag.<key> anything else: x-tt-tag-env, x-tt-tag-region

A tag key needs no declaration anywhere, so x-tt-tag-env works immediately. Keep keys to a single segment: a key containing a dot is recorded on the row but cannot then be named as a filter or group-by field, because dim.tag.a.b is ambiguous to the query parser.

$ curl -s http://127.0.0.1:8787/v1/chat/completions \
    -H 'content-type: application/json' \
    -H 'x-tt-tenant: acme-corp' \
    -H 'x-tt-feature: nightly-summary' \
    -H 'x-tt-user: u-4471' \
    -H 'x-tt-tag-env: prod' \
    -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Summarise the quarterly report."}]}'

All of them are removed from the request before it goes upstream, so your provider never sees them.

3. Check a row picked them up

The last line of the decision log is the request you sent. Its dims block is the proof:

{
  "dims": {
    "user": "u-4471",
    "tenant": "acme-corp",
    "feature": "nightly-summary",
    "tags": { "env": "prod" }
  }
}

If dims is absent from the row, one of two things is true: the flag is still off, or none of the headers arrived. Send one request with a single x-tt-tenant header to tell them apart — with the flag on and one header set, dims appears with one field in it.

4. Split spend by a dimension

Now the question you turned this on for. Group a window by tenant:

$ curl -s -X POST http://127.0.0.1:9090/tt/api/v1/query \
    -H 'content-type: application/json' \
    -d '{"group_by":["dim.tenant"],"metrics":["cost_usd","requests"]}'
{
  "groups": [
    { "key": { "dim.tenant": "" },          "metrics": { "cost_usd": { "usd": 0.0003708 }, "requests": 1 } },
    { "key": { "dim.tenant": "acme-corp" }, "metrics": { "cost_usd": { "usd": 0.0014832 }, "requests": 4 } },
    { "key": { "dim.tenant": "globex" },    "metrics": { "cost_usd": { "usd": 0.0003708 }, "requests": 1 } }
  ]
}

That capture is from a local sandbox against a mock upstream, trimmed to the fields this step is about. The full response also carries each group’s estimated and unpriced counts and a provenance block. The dollar amounts are arithmetic over the mock’s token counts, not anyone’s bill.

The first group is the interesting one. An empty key is spend that arrived with no x-tt-tenant header, and it is rendered as its own row rather than spread across the named tenants. A large unattributed slice means a caller you have not instrumented yet, and it is usually the first thing worth chasing.

Promoting a dimension to Prometheus

Dimensions stay in the ledger unless you ask for them as metric labels, via ledger.dimensions.promote_to_metrics. That is a monitoring decision with a cardinality cost, and it is documented where the rest of the metrics are: what a promoted dimension costs covers the allowlist, the two guards, and how to choose what is safe to promote.

The short version: promote tenant if you have tens of tenants, and do not promote user.

What can go wrong

The breakdown is empty and the flag is on. The headers are not arriving. Check for a proxy or service mesh between your application and TokenTriage that strips unknown headers.

Every request lands in one tenant. A shared client is stamping a constant. Check that the value is read per request rather than captured once at client construction.

A tag went missing. Tags are admitted in sorted key order up to max_tags, and distinct keys past the cap are dropped and counted. Eight is the default; if you are sending nine, the last one alphabetically is the one that disappears.

Your tenant list became (overflow). dim.tenant and dim.feature are capped at 256 distinct values per hour in the hourly pre-aggregation. Beyond that, values fold into a labelled bucket. The rows are all still there and still exact; the pre-aggregated view of them is not. See the cardinality cap.

The byte caps, exactly

A dimension value is truncated to 128 bytes. A tag key is truncated to 32 bytes, and its value to the same 128. Truncation happens at a byte boundary, so a multi-byte character can be split; dimension values are treated as opaque identifiers rather than text.

Two tag keys that differ only past the 32-byte cap collide into one, and the last in sorted order wins. That is deterministic rather than arbitrary, but it is still a collision, so keep tag keys short.