Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3f5d528
feat(proxy): attribution labels for savings, stats and export
mosatch Nov 7, 2026
fa60ef6
fix(proxy): honour disabled dimensions, filtered routing and label se…
mosatch Nov 8, 2026
eb343ff
fix(proxy): honour replaced resolvers and reserve extension labels
mosatch Nov 11, 2026
e45be66
fix(proxy): gate every by_dimension copy and tighten label handling
mosatch Nov 12, 2026
388e3a1
fix(proxy): percent-encode %, comma and semicolon in format_labels
mosatch Nov 13, 2026
34d56dc
Merge branch 'main' into feat/attribution-labels
JerrettDavis Nov 14, 2026
9c4f2f1
Merge branch 'main' into feat/attribution-labels
JerrettDavis Nov 14, 2026
8f58b3c
Merge upstream/main into feat/attribution-labels
mosatch Nov 14, 2026
5a2a3de
fix(proxy): size the labels header limit to the longest format_labels…
mosatch Nov 14, 2026
796a347
fix(proxy): trim labels after cutting them; decode conversation ids once
mosatch Nov 14, 2026
ce8e621
fix(proxy): the labels switch no longer takes a dimension slot
mosatch Nov 14, 2026
90ba11c
refactor(proxy): drop the unused HEADROOM_LABELS constant
mosatch Nov 14, 2026
7d7d80f
fix(proxy): percent-encode non-ASCII in format_labels, refuse oversiz…
mosatch Nov 14, 2026
127b89e
fix(proxy): count deferred tool schemas in per-label savings
mosatch Nov 14, 2026
4ea489c
fix(proxy): book deferred gateway outcomes under their own request's …
mosatch Nov 14, 2026
13b7c54
docs(attribution): label rules, header limit and per-label token fields
mosatch Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions docs/content/docs/attribution.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
---
title: Attribution
description: Split savings and spend by project, client, team or any label you choose — in /stats, `headroom savings --by`, the dashboard, OpenTelemetry and pipeline extensions.
---

Every number the proxy reports is a sum over requests. **Attribution** puts named
labels on each request — which project, which harness, which team — so those sums can
be split. One proxy shared by a team, or several projects on one laptop, then answers
"who is spending, and where is Headroom saving the most?"

Labels are **self-reported by clients**. They attribute usage; they do not
authenticate anyone. Authenticated identity is a separate concern (see
`set_identity_resolver` in `headroom.proxy.identity`); a deployment that has one can
publish it as a label with `register_dimension`, and everything below picks it up.

## Built-in dimensions

| Dimension | Where it comes from |
|---|---|
| `project` | `X-Headroom-Project` (sent by `headroom wrap`) or a `/p/<name>` base-URL prefix |
| `client` | The harness: `X-Client`, else the User-Agent (`claude-code`, `codex`, `cursor`, …) |
| `tenant` | The tenant-id header (`HEADROOM_TENANT_KEY_HEADER`), when sent |

A dimension with nothing to report is left off the request — never filled with a
placeholder — so unattributed traffic changes only the totals. Reports that split by a
dimension group such requests under `unknown`.

## Your own labels

List extra dimensions in `HEADROOM_ATTRIBUTION_DIMENSIONS` on the proxy. A name with no
built-in resolver is read from the `X-Headroom-<name>` header, or from the combined
`X-Headroom-Labels` header:

```bash
# proxy
HEADROOM_ATTRIBUTION_DIMENSIONS=default,team,cost-center headroom proxy

# clients: one header per label, or several at once
X-Headroom-Team: infra
X-Headroom-Labels: team=infra; cost-center=42
```

Any client that can set a header can send them; Claude Code takes them from
`ANTHROPIC_CUSTOM_HEADERS`.

`HEADROOM_ATTRIBUTION_DIMENSIONS` accepts `default` (the built-ins), `-name` to drop one,
`none` to turn attribution off, and `labels` to accept **any** dimension a client names
in `X-Headroom-Labels`. That last one is off by default: each dimension is persisted
state, so the operator opts in. The proxy's lifetime totals keep at most 16 dimensions
and 50 labels per dimension; past either limit the smallest, oldest one is evicted. The
savings ledger keeps each event's labels (at most 16) for its retention window.

`-name` removes a name from what the rest of the list selects, so write `default,-client`
to keep the other built-ins; `-client` alone selects nothing. A header value is
percent-decoded once, and only the first 4 KB of `X-Headroom-Labels` is read.

`none` stops labels being resolved and booked. Each ledger event still records the
harness in its own `client` key, as it did before labels existed.

With `labels`, a client may also name a built-in dimension you did not list:
`HEADROOM_ATTRIBUTION_DIMENSIONS=labels` with `X-Headroom-Labels: project=web` records
`project=web`. Three rules bound that:

- A listed dimension's resolver always has the last word, even when it finds nothing, so
a client cannot override it.
- `-name` keeps a name out whoever sends it (`labels,-project`).
- A dimension an extension registered stays reserved unless the extension marks it
`client_settable`, so a label taken from authenticated identity cannot be supplied by
a client.

`labels` on its own replaces the default set, so the built-ins are then only what clients
name. Write `default,labels` to keep resolving them.

## Seeing it

```bash
headroom savings --by project
headroom savings --by client --where team=infra
headroom savings --by team --json
```

`--by` and `--where` work on the durable ledger. `--where` filters every window
(today, 7 days, 30 days); `--by` splits the whole lookback window (`--days`). An event
with no label for a dimension shows as `unknown`, which includes every event written
before attribution existed. Labels are matched exactly, case included, and each
`--where` dimension may be given once.

A dimension cannot be grouped by when its name is also a figure in the report
(`calls`, `tokens_saved`, `tokens_before`, `cost_usd`, `cost_effective_usd`, `basis`,
`savings_percent`, `new_input_tokens`, `new_input_savings_percent`): `--by calls` is
refused. `--where` still filters on it. Pick another name for a new dimension.

`/stats` (`savings.by_dimension`) and `/stats-lifetime` (`by_dimension`) carry the
lifetime totals, and the dashboard shows them in **Savings by Label** (`project` keeps
its own Per-Project table, which reads the older `projects` totals). Because labels
can name machines and people, these fields are returned only to callers allowed
to see per-request `/stats` metadata (loopback, a trusted dashboard client, or a
caller holding `HEADROOM_PROXY_TOKEN`; see the
[security model](/docs/security-model#what-the-proxy-token-unlocks)), never to other
network callers. That covers every copy: `savings.by_dimension` and
`persistent_savings.by_dimension` in `/stats`, and `by_dimension` in `/stats-lifetime`.
`/stats-history` carries `by_dimension` too, behind its own operator-only access.

## OpenTelemetry

```bash
HEADROOM_OTEL_ATTRIBUTION=project,team # or "all"
```

adds the request's labels as `headroom.attribution.<dimension>` attributes on every
Headroom OTel datapoint. Off by default: every label value becomes a metric series.

## Extensions

Labels reach every [pipeline extension](/docs/pipeline-extensions) on
`OutcomeSnapshot.attribution` at `OUTCOME_OBSERVED`, alongside `conversation_key`, so an
exporter, a chargeback job or a budget alert needs no change to the proxy. An extension
can add its own dimension:

```python
from headroom.proxy.attribution import register_dimension

register_dimension("region", lambda request: request.header("x-region"))
```

`replace=True` takes over a built-in. The new resolver is then the only source of that
label in `by_dimension`, request-log tags and `OutcomeSnapshot.attribution`: the harness
or project the proxy detects itself no longer overrides it.

Two older records keep what the proxy detected. The `projects` totals are one. The
ledger is the other, for `client` only: each event has a single `client` key, which is
always the harness, and a `client` label is never written to the event's `dims`. So
`headroom savings --by client` and `--where client=…` report the harness even when the
`client` resolver was replaced. Every other dimension, a replaced `project` included,
is written to `dims` as resolved.

Pass `client_settable=True` for a dimension that is only ever self-reported and that
clients may name through `labels`. Leave it off for anything derived from identity.
1 change: 1 addition & 0 deletions docs/content/docs/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"filesystem-contract",
"---Observability---",
"savings",
"attribution",
"metrics",
"simulation",
"---API Reference---",
Expand Down
4 changes: 2 additions & 2 deletions docs/content/docs/security-model.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,8 @@ network callers, the same fields a dashboard CIDR peer sees:

| Endpoint | Fields a token holder also sees |
|---|---|
| `/stats` (and `/stats?cached=1`) | `recent_requests` and `request_logs`: request ids, timestamps, providers, models, token counts, transforms applied, tags, error text. Never prompt or response content. Plus `config`: backend and savings profile. |
| `/stats-lifetime` | `projects` (per-project savings) and `persistence.error` |
| `/stats` (and `/stats?cached=1`) | `recent_requests` and `request_logs`: request ids, timestamps, providers, models, token counts, transforms applied, tags, error text. Never prompt or response content. Plus `config`: backend and savings profile. Plus `savings.by_dimension` and `persistent_savings.by_dimension`: savings per [attribution](/docs/attribution) label. |
| `/stats-lifetime` | `projects` (per-project savings), `by_dimension` (savings per attribution label) and `persistence.error` |

That is what a dashboard behind an authenticating reverse proxy needs. The reverse proxy
adds the token after its own sign-in, then reaches `/stats` with a domain `Host:` from a
Expand Down
108 changes: 104 additions & 4 deletions headroom/cli/savings.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,34 @@ def _echo_basis_note(basis: Any) -> None:
show_default=True,
help=f"Retention/lookback window for the ledger, in days (max {savings_ledger.MAX_RETENTION_DAYS}).",
)
@click.option(
"--by",
"group_by",
multiple=True,
metavar="DIMENSION",
help=(
"Break the lookback window down by an attribution dimension (project, "
"client, or any configured one). Repeatable."
),
)
@click.option(
"--where",
"where",
multiple=True,
metavar="DIMENSION=LABEL",
help=(
"Only count requests with this label, e.g. --where project=web. "
"Repeatable, once per dimension; all must match."
),
)
@click.option("--reset", is_flag=True, help="Delete the savings ledger and start fresh.")
def savings(as_json: bool, days: int, reset: bool) -> None:
def savings(
as_json: bool,
days: int,
reset: bool,
group_by: tuple[str, ...] = (),
where: tuple[str, ...] = (),
) -> None:
"""Show durable compression savings over time."""

if reset:
Expand All @@ -169,8 +195,16 @@ def savings(as_json: bool, days: int, reset: bool) -> None:
click.echo("Nothing to reset — ledger does not exist.")
return

report = savings_ledger.aggregate_savings(retention_days=days)
routing = _proxy_routing()
where_filter = _parse_where(where)
try:
report = savings_ledger.aggregate_savings(
retention_days=days, group_by=group_by, where=where_filter
)
except ValueError as exc:
raise click.BadParameter(str(exc), param_hint="--by") from exc
# The routing rollup is proxy-wide; next to a filtered report it would
# read as the filtered label's routing savings.
routing = None if where_filter else _proxy_routing()

if as_json:
data = report.to_dict()
Expand All @@ -184,6 +218,12 @@ def savings(as_json: bool, days: int, reset: bool) -> None:
if calls == 0 and routing is not None:
_echo_routing(routing)
return
if calls == 0 and report.where:
scope = ", ".join(f"{k}={v}" for k, v in report.where.items())
click.echo(f"No savings recorded for {scope}. Labels are case-sensitive.")
click.echo(f"Dimensions in the ledger: {', '.join(report.dimensions)}.")
click.echo(f"Ledger: {report.path}")
return
if calls == 0:
click.echo("No savings recorded yet.")
click.echo(
Expand Down Expand Up @@ -212,7 +252,8 @@ def savings(as_json: bool, days: int, reset: bool) -> None:

_echo_basis_note(report.lifetime.get("basis"))

if report.by_client:
# ``--by client`` prints the same split below, in the per-dimension format.
if report.by_client and "client" not in report.by_dimension:
click.echo("")
click.echo("Savings by client:")
for row in report.by_client:
Expand All @@ -221,5 +262,64 @@ def savings(as_json: bool, days: int, reset: bool) -> None:
f"{_tokens(int(row['tokens_saved']))} tokens saved"
)

unattributed: list[str] = []
for dimension, rows in report.by_dimension.items():
# A dimension no event carries is one "unknown" row; say so instead.
if all(str(row[dimension]) == savings_ledger.UNKNOWN for row in rows):
unattributed.append(dimension)
continue
_echo_dimension(dimension, rows)
if report.where:
scope = ", ".join(f"{k}={v}" for k, v in report.where.items())
click.echo("")
click.echo(f"Filtered to {scope}.")
if unattributed:
known = ", ".join(report.dimensions)
click.echo("")
click.echo(
f"No attributed events for {', '.join(unattributed)}. "
f"Dimensions in the ledger: {known}."
)

if routing is not None:
_echo_routing(routing)


def _parse_where(pairs: tuple[str, ...]) -> dict[str, str]:
from headroom.proxy.attribution import sanitize_dimension_name

parsed: dict[str, str] = {}
for pair in pairs:
name, sep, label = pair.partition("=")
if not sep or not name.strip() or not label.strip():
raise click.BadParameter(
f"expected DIMENSION=LABEL, got {pair!r}", param_hint="--where"
)
dimension = sanitize_dimension_name(name)
if dimension is None:
raise click.BadParameter(
f"{name.strip()!r} is not a dimension name", param_hint="--where"
)
if dimension in parsed:
raise click.BadParameter(
f"{dimension} is given twice; a request has one label per dimension",
param_hint="--where",
)
parsed[dimension] = label.strip()
return parsed


def _echo_dimension(dimension: str, rows: list[dict[str, Any]]) -> None:
if not rows:
return
click.echo("")
click.echo(f"Savings by {dimension}:")
width = max(12, min(32, max(len(str(row[dimension])) for row in rows)))
for row in rows:
effective = float(row.get("cost_effective_usd", row.get("cost_usd", 0.0)) or 0.0)
pct = float(row.get("savings_percent", 0.0) or 0.0)
click.echo(
f" {str(row[dimension]):<{width}} {_money(effective)} "
f"{int(row['calls']):,} calls · {_tokens(int(row['tokens_saved']))} tokens "
f"({pct:.1f}%)"
)
55 changes: 55 additions & 0 deletions headroom/dashboard/templates/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -1625,6 +1625,61 @@ <h3 class="mb-3 text-sm font-medium text-gray-300">Live Activity</h3>
</div>
</template>
</section>
<!-- Savings by attribution label (project, client, team, ...) -->
<section class="bg-surface rounded-lg border border-border overflow-hidden mt-6"
x-data="{ dim: '' }"
x-effect="if (!dim || !(lifetimeStats.by_dimension || {})[dim]) { dim = Object.keys(lifetimeStats.by_dimension || {}).filter(d => d !== 'project')[0] || '' }">
<div class="px-4 py-3 border-b border-border flex justify-between items-center gap-3 flex-wrap">
<div>
<span class="text-sm font-medium text-gray-300">Savings by Label</span>
<div class="text-xs text-gray-500 mt-0.5">Lifetime totals per attribution label — self-reported by clients</div>
</div>
<div class="flex gap-1 flex-wrap">
<template x-for="name in Object.keys(lifetimeStats.by_dimension || {}).filter(d => d !== 'project')" :key="name">
<button type="button" class="px-2 py-0.5 text-xs font-mono rounded border border-border"
:class="dim === name ? 'bg-accent/20 text-accent' : 'text-gray-400 hover:bg-border/40'"
@click="dim = name" x-text="name"></button>
</template>
</div>
</div>
<template x-if="!dim">
<div class="px-4 py-8 text-center text-xs text-gray-500">
No labelled requests yet. Clients send labels as
<span class="font-mono">X-Headroom-&lt;name&gt;</span> headers or
<span class="font-mono">X-Headroom-Labels: team=infra</span>.
</div>
</template>
<template x-if="dim">
<div class="overflow-x-auto">
<table class="w-full text-sm" style="table-layout:fixed">
<thead class="text-xs text-gray-500 tracking-wide">
<tr>
<th class="px-4 py-3 font-medium text-left" x-text="dim"></th>
<th class="px-4 py-3 font-medium text-right">Requests</th>
<th class="px-4 py-3 font-medium text-right">Tokens Saved</th>
<th class="px-4 py-3 font-medium text-right">Saved $</th>
<th class="px-4 py-3 font-medium text-right">Input $</th>
<th class="px-4 py-3 font-medium text-right">Savings %</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<template x-for="[label, info] in Object.entries((lifetimeStats.by_dimension || {})[dim] || {}).sort((a, b) => (b[1].tokens_saved || 0) - (a[1].tokens_saved || 0))" :key="dim + ':' + label">
<tr class="hover:bg-border/30 transition-colors">
<td class="px-4 py-3 text-left truncate">
<span class="px-2 py-0.5 bg-border text-xs font-mono" x-text="label"></span>
</td>
<td class="px-4 py-3 text-right font-mono tabular-nums" x-text="formatNumber(info.requests || 0)"></td>
<td class="px-4 py-3 text-right font-mono tabular-nums text-accent" x-text="formatNumber(info.tokens_saved || 0)"></td>
<td class="px-4 py-3 text-right font-mono tabular-nums text-emerald-400" x-text="'$' + formatCurrency(info.compression_savings_usd || 0)"></td>
<td class="px-4 py-3 text-right font-mono tabular-nums" x-text="'$' + formatCurrency(info.total_input_cost_usd || 0)"></td>
<td class="px-4 py-3 text-right font-mono tabular-nums text-xs text-accent" x-text="(info.savings_percent || 0).toFixed(1) + '%'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</section>
</div>
</template>

Expand Down
6 changes: 3 additions & 3 deletions headroom/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ class OutcomeSnapshot:
#: is re-sent and recompressed every turn, and ``tokens_saved -
#: novel_tokens_saved`` is the re-sent history removed again.
novel_tokens_saved: int = 0
#: Whose request this was: ``{dimension: label}``, for now ``project`` and
#: ``client`` when known. Self-reported labels, not authenticated
#: identity. Read-only.
#: Whose request this was: ``{dimension: label}`` from
#: :mod:`headroom.proxy.attribution` (``project``, ``client``, …).
#: Self-reported labels, not authenticated identity. Read-only.
attribution: Mapping[str, str] = field(default_factory=dict)
#: Stable across every turn of one conversation, when the handler knows it.
conversation_key: str | None = None
Expand Down
Loading
Loading