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 a promoted dimension costs

Turning a tenant into a Prometheus label is one line of config. The two guards that stop it melting your Prometheus, and how to choose.

ExplanationLast updated

On this page

Every distinct combination of label values on a Prometheus metric is a separate time series with its own memory, its own index entries and its own cost at query time. Ten tenants is ten series. A hundred thousand user ids is a hundred thousand series, and it is the classic way to take an observability stack down with a config change that looked like one word.

TokenTriage records your business dimensions on every ledger row, where the number of distinct values costs nothing. Turning one of them into a Prometheus label is a separate, opt-in decision, and this page is about making it well.

Why the ledger and the metrics disagree on purpose

The two surfaces have opposite economics, and the design leans into it rather than splitting the difference.

A ledger row is a line in a file. Writing a hundred thousand distinct user ids across a hundred thousand rows costs a hundred thousand rows, which is what a hundred thousand requests costs anyway. Querying it is a scan, and a scan is slow rather than fatal.

A Prometheus label value is a permanent series. Each new value adds a series that stays resident for the retention window, whether or not it is ever queried, and the cost lands on the monitoring system rather than on the writer.

So the rule is: put unbounded things on the row, and put only enumerable things on a label. That is why agent is not a metric label anywhere in the catalogue, even though it would be a convenient one. Agent names come from whatever your code passes, so the set has no bound, and agent-level analysis lives in the ledger where the cost of a distinct value is a row.

The two guards

The two guards between a dimension value and a Prometheus labelA request carries a business dimension such as tenant equals acme-corp. The value is written to the dims block of the decision-ledger row unconditionally, where the number of distinct values costs nothing. Promotion to a Prometheus label runs beside that and passes two guards. The first checks the dimension NAME against the promote_to_metrics allowlist, which is empty out of the box, so by default nothing is promoted. The second checks the VALUE against the fifty distinct values already admitted for that name. A value that clears both becomes a series on tokentriage_dim_requests_total. A value turned away at either guard is added to tokentriage_dims_dropped_total with a reason label, and the ledger row still carries it, so the money is never lost, only the label.A dimensiontenant=acme-corpLedger row `dims`always; cardinality is free hereGuard 1 — the namepromote_to_metricsempty by defaultGuard 2 — the value50 distinct per namefixed, not configurableA label seriesdim_requests_totalCounted, not minteddims_dropped_total by reasonA dropped label costs a chart, not a dollar

Read it left to right. The dimension value goes to the ledger row on the upper path unconditionally. Promotion runs on the lower path and passes two checks.

Guard one is the name. ledger.dimensions.promote_to_metrics is an allowlist of dimension names, and it is empty out of the box, so nothing is promoted until you say so. A dimension not on the list is recorded on the row and is not a label, with no way to override that per request. The list itself takes at most sixteen entries.

Guard two is the value. Each promoted dimension admits at most fifty distinct values as label series. The fifty-first new value is turned away, counted, and not minted, so a dimension you allowlisted by mistake costs you fifty series and a counter rather than an incident. The cap is fixed in the build rather than configurable, which is what makes it a guard rather than a setting somebody raises under pressure.

Names on the allowlist are the short forms, matching what lands on the row: tenant, feature, user, or a tag key. They are not the dim.-prefixed field names a query uses. A tag key that collides with one of the three first-class names is ignored for promotion rather than allowed to win, so a promoted label cannot disagree with the row it came from.

ledger:
  dimensions:
    enabled: true # off by default; see the dimensions how-to
    promote_to_metrics: [tenant] # allowlist; empty by default, at most 16 entries

Only promote_to_metrics belongs to this page. The flag and the headers that populate a dimension in the first place are turn on business dimensions’s.

Both guards, on real traffic

A lab run against a mock upstream sent requests carrying sixty-one distinct x-tt-tenant values, with promote_to_metrics: [tenant], plus one request carrying nine tags against a max_tags of eight. The scrape afterwards:

tokentriage_dim_requests_total{dim="tenant",value="acme-corp"} 4
tokentriage_dims_dropped_total{reason="max_tags"} 1
tokentriage_dims_dropped_total{reason="promote_cardinality"} 11

Counting the tokentriage_dim_requests_total series gives exactly 50. Sixty-one distinct tenants arrived, fifty became series, eleven were turned away and counted, and one tag past the budget was turned away and counted separately.

The two dropped numbers cost different things, and neither costs a dollar.

The eleven lost a label. Those requests are on the ledger with their tenant recorded, their spend is in tokentriage_cost_usd_total, and a query grouped by dim.tenant returns them. What they cannot do is appear as a Prometheus series.

The one lost a tag. That tag key is not on its row, so a query grouping by it will not find it. The rest of the row, including its cost and its other eight tags, is intact. Tags are admitted in sorted key order, so the tag that disappears is deterministic rather than arbitrary: it is the last one alphabetically.

The one unbounded label in the catalogue

Promotion is the guarded path. There is one label that is not guarded, and it is worth knowing about before somebody points a scanner at your data plane.

tokentriage_requests_total carries a path label, and its value is the request path as the client sent it. Every distinct path mints a series, including paths the proxy does not serve. Driving four unknown paths at a lab instance took the metric from one series to five:

tokentriage_requests_total{dialect="",mode="ledger",path="/v1/nope",status="404"} 1
tokentriage_requests_total{dialect="openai",mode="ledger",path="/v1/chat/completions",status="200"} 66

In normal traffic this is bounded by the handful of endpoints your clients call, which is why it has not been a problem. It is not bounded by anything in the build, so a data plane reachable by untrusted callers can grow the series count with 404s.

Two mitigations, neither of which needs a code change. Keep the data plane behind whatever already fronts it, so unknown paths are turned away before they reach the proxy. And if you cannot, drop or rewrite the label at scrape time with metric_relabel_configs, which is the standard Prometheus answer to a label you cannot fix at the source. This is filed.

Choosing what to promote

Ask one question: can you write down every value it will ever take?

Promote environment, plan, region, or a tenant list you can name. Tens of values, changing when you change something, and useful on a graph because you want to see them side by side over time.

Do not promote user, session, request or trace ids, or a tag whose value comes from user input. These are exactly what the ledger is for.

Think about tenant. It is the common case and the answer depends on your business. Tens of tenants is a good label. Thousands is not, and the guard will hold at fifty while your chart quietly stops representing your fleet. If you have thousands of tenants, group them by plan and promote that.

Think about feature. Usually a good label, because features are things you ship rather than things users create, so the set is bounded by your own roadmap.

What happens to the fifty-first value

Admission is first-come rather than most-valuable. The first fifty distinct values seen for a dimension since the process started become series, and the rest are counted. There is no eviction and no ranking, so a restart re-races the set.

That is a trade in favour of predictability: an evicting cache would keep the series count bounded too, and it would also make a graph’s membership change under you for reasons unrelated to your traffic. A fixed set plus a visible counter is easier to reason about at three in the morning.

If tokentriage_dims_dropped_total{reason="promote_cardinality"} is climbing, the promoted dimension is the wrong one. Take it off the allowlist and query the ledger instead; nothing is lost by doing so, because the row always had the value.