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

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

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

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

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

Check it loads:

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

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

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

```console
$ 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"]}'
```

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

<Callout type="tip">
Set the headers in one place. A shared HTTP client or middleware that stamps tenant and feature
on every outbound call gives you a complete breakdown; per-call-site headers give you a
breakdown with holes in it, and the holes are invisible until you are trying to explain a bill.
</Callout>

## 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](/docs/build/observability-cardinality) 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](/docs/build/cost-rollups#the-cardinality-cap).

<Details summary="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.

</Details>

<NextSteps items={[
  {title: "How a dollar gets a name", href: "/docs/build/cost-attribution", description: "Why a header-supplied dimension and a server-stamped one are different kinds of evidence."},
  {title: "Attribute cost per agent step", href: "/docs/recipes/attribute-cost-per-agent-step", description: "The identity half: trace, step and agent headers, and the costed tree they produce."},
  {title: "What the rollups make cheap", href: "/docs/build/cost-rollups", description: "Which of these dimensions are pre-aggregated, which always scan, and the per-hour cap."},
  {title: "Chargeback, showback and FOCUS", href: "/docs/build/cost-exports", description: "Turn the breakdown into a report finance will accept."},
]} />
