Skip to content

Commit 8023c28

Browse files
feat(observe): attribute recorded runs and costs to explicit tenants
Propagate optional tenant labels through runtime, planner, agent and server traces. Add tenant cost aggregation and CLI reports while preserving unlabelled legacy traces, separating recorded prices from estimates, and counting planner spend.
1 parent 99c6892 commit 8023c28

20 files changed

Lines changed: 759 additions & 46 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ print(g.compile().invoke({"question": "meaning of life"}))
7070

7171
Typed state, declared writes, a budget — none optional. The rest of the surface (agents, tools, memory, sessions, policy documents, cost attribution, OTel) is in the [deep dive](docs/deep-dive.md) and the [cookbook](docs/cookbook/).
7272

73+
Cost attribution also supports explicit tenants: pass `tenant="team-a"` to a compiled graph or governed loop, then read its bill with `attribute_tenant(trace, "team-a", rates=...)` or `grapharc cost trace.jsonl --tenant team-a --json`. `run`, `plan`, `go` and policy-governed `agent` runs carry an explicit `--tenant`, `GRAPHARC_TENANT` or config value into the trace. Recorded USD and rate-card estimates stay separate; old unlabelled runs remain unassigned. See [the cost guide](docs/cookbook/06-serving-and-ops.md#where-did-the-tokens-go) for the boundaries.
74+
7375
## The admission gate
7476

7577
You cannot pre-author a graph for "investigate this incident" — the shape is discovered while working. So the graph is proposed at runtime, and a deterministic checker stands between proposing and running: registry, policy, remaining budget, depth, acyclicity, all on every proposal. A rejection is structured feedback the planner replans against; work discovered mid-run re-enters the same gate. Watch it refuse (free, scripted, the first proposal names a policy-denied kind):

‎ROADMAP.md‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ allowances; the agent records document-driven denials and approval decisions.
451451
importing the module needs no OTel. Previously documented as unverified
452452
against the real SDK — verified during this pass against
453453
`opentelemetry-sdk` 1.44.0, with spans reaching an `InMemorySpanExporter`.
454-
- [~] **10.4 — Cost attribution** per run, thread (session) and node. The
454+
- [x] **10.4 — Cost attribution** per run, thread (session), node and explicit tenant. The
455455
price is now recorded, not guessed: every gateway publishes a
456456
`cost_usd` through the same `llm_output` envelope, the runtime's usage
457457
callback accumulates it per node and writes it onto the `end` event, and
@@ -461,9 +461,16 @@ allowances; the agent records document-driven denials and approval decisions.
461461
figure wins outright rather than being averaged with a guess. Tokens are
462462
counted from the same events `metrics.summarize` uses — node `end` events
463463
*plus* work outside any node span, which is what a `grapharc agent` run
464-
consists of entirely — and the suite asserts the two agree. **One gap
465-
left:** there is no tenant on a trace event, so tenant attribution is not
466-
offered rather than being approximated.
464+
consists of entirely — and the suite asserts the two agree. An optional
465+
tenant on `RunContext` labels every native graph, planner and agent event;
466+
the loop carries it through round sub-runs. `attribute_tenant` and
467+
`grapharc cost --tenant NAME` select whole labelled runs, refuse conflicting
468+
labels, and leave old unlabelled runs unassigned. Planning tokens without
469+
a recorded price are estimated or marked unpriced, including in tenant
470+
totals. **Producer limit:** planner turns and failed node terminal events
471+
still omit the provider price ([#138](https://github.com/CodeGraphContext/GraphARC/issues/138));
472+
a rate-card estimate is not a recorded bill.
473+
Tenant labels provide attribution, not authorization.
467474
- [ ] **10.2 — Rollback** and versioned graph/prompt configs.
468475
- [ ] **10.5 — Alerting** on budget, failure, and verifier-drift.
469476

‎docs/cookbook/06-serving-and-ops.md‎

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1570,7 +1570,9 @@ from pathlib import Path
15701570

15711571
from langchain_core.messages import HumanMessage
15721572

1573-
from grapharc.observe import RateCard, TraceRecorder, attribute, summarize, to_mermaid
1573+
from grapharc.observe import (
1574+
RateCard, TraceRecorder, attribute, attribute_tenant, summarize, to_mermaid,
1575+
)
15741576
from grapharc.runtime.graph import END, START, GraphARC
15751577
from grapharc.runtime.state import GraphARCState
15761578
from grapharc.testing import ScriptedChatModel
@@ -1612,6 +1614,12 @@ metrics = summarize(trace, "r1")
16121614
print("metrics :", metrics.nodes_executed, "nodes,", metrics.tokens, "tokens,", metrics.per_node)
16131615
print()
16141616
print(to_mermaid(trace, "r1"))
1617+
1618+
# A second run has an explicit tenant; the old unlabelled run stays excluded.
1619+
g.compile().invoke({"question": "budgets?"}, run_id="r2", tenant="team-a")
1620+
bill = attribute_tenant(trace, "team-a", rates=RateCard(default=3.0))
1621+
assert [run.run_id for run in bill.runs] == ["r2"]
1622+
assert bill.tokens == attribute(trace, "r2").tokens
16151623
```
16161624

16171625
```
@@ -1634,24 +1642,25 @@ flowchart TD
16341642
```
16351643

16361644
`RunCost.tokens` and `RunMetrics.tokens` agree by construction — both count the
1637-
`end` events of node executions, and the test suite asserts they match, because
1645+
`end` and `error` events plus work outside node spans, and the suite asserts they match, because
16381646
a cost report and an audit trail that disagree are worse than either alone.
16391647

16401648
**Three honest limits:**
16411649

1642-
- **Nothing in GraphARC writes `cost_usd` onto a trace event today.** The field
1643-
exists and `TraceRecorder.event` accepts it, but the kernel does not pass it,
1644-
so `recorded_cost_usd` is always `None` on a trace from today's runtime.
1645-
Everything you see above is `estimated_cost_usd` — tokens × your rate card,
1646-
reported in its own field so nobody mistakes an estimate for an invoice.
1650+
- **Recorded cost depends on the producer.** The kernel writes provider prices
1651+
onto successful node terminal events, and agents record their per-call prices.
1652+
Planner turns and failed node terminal events currently omit the provider
1653+
price. The example above uses a backend that reports no price, so its money
1654+
is `estimated_cost_usd` — tokens × your rate card. Recorded and estimated
1655+
figures remain separate; a recorded figure takes precedence over an estimate.
16471656
- **`complete` is what tells you the total is a total.** Tokens with neither a
16481657
recorded cost nor a matching rate are counted in `unpriced_tokens`, and a
16491658
non-zero count means `cost_usd` is a lower bound. A `RateCard` with no
16501659
`default` and no matching model prices nothing.
1651-
- **A node that raised has no token count at node level.** No `end` event was
1652-
written. Where it was an `AgentNode`, its per-call sub-events still hold the
1653-
spend, reported as `tokens_before_error` — kept out of the total so the total
1654-
keeps matching `metrics`.
1660+
- **Older error events may have no token count.** Current node wrappers record
1661+
tokens on both `end` and `error`. When an older or hand-built trace omits the
1662+
failed node's count, an agent's per-call sub-events may still hold the spend,
1663+
reported as `tokens_before_error` and kept out of the total that matches `metrics`.
16551664

16561665
`to_mermaid` renders the graph's *declared topology* — the `topology` event every
16571666
run now writes — with execution status overlaid per node: `done`, `running`,
@@ -1666,11 +1675,54 @@ renderer that speaks Mermaid.
16661675
`attribute_thread(trace, thread_id)` is the same for a whole session across
16671676
resumes, and `by_node(trace)` ranks every node in a file by cost.
16681677

1678+
For tenant attribution, supply one label at the run entry point, as the example
1679+
above does with `tenant="team-a"`. `attribute_tenant` selects its second run,
1680+
`r2`, while the first unlabelled run stays excluded.
1681+
1682+
Every compiled execution entry (`invoke`, `stream`, `ainvoke`, `astream`,
1683+
`astream_events`) accepts `tenant=`, as does `GovernedLoop.run`. The loop carries
1684+
it through planning, admission, approval, round execution and stop events;
1685+
`AgentNode` inherits its supplied `RunContext.tenant`. Custom producers must
1686+
label their own events consistently. Attribution is a caller-supplied label,
1687+
not an authorization check; it does not apply a policy on its own.
1688+
1689+
`attribute_tenant(trace, name, rates=...)` reads one snapshot and returns
1690+
`TenantCost`: the matching runs, tokens, duration, recorded USD, estimated USD,
1691+
combined known USD, unpriced tokens and `complete`. It prices the same whole
1692+
runs as `attribute`, so model substeps do not add a second copy of node spend.
1693+
An unknown tenant raises `ReplayError`. Any run with inconsistent labels,
1694+
including a mixture of labelled and unlabelled events, makes the reader refuse
1695+
the report rather than guess a bill. Legacy runs with no tenant remain
1696+
unassigned, and writing an unlabelled run adds no tenant key.
1697+
1698+
The CLI uses the same reader:
1699+
1700+
```bash
1701+
grapharc cost trace.jsonl --tenant team-a --json
1702+
grapharc cost trace.jsonl --run-id r2
1703+
```
1704+
1705+
Choose exactly one scope. JSON includes separate `recorded_cost_usd` and
1706+
`estimated_cost_usd`, `unpriced_tokens`, `complete`, and per-run detail for a
1707+
tenant report. The CLI supplies no rate card, so tokens without recorded prices
1708+
remain unpriced; use the Python API for estimates. Missing or unreadable files
1709+
exit 2, and missing selections or inconsistent labels exit 1, including in JSON.
1710+
1711+
`run`, `plan`, `go` and policy-governed `agent` runs carry a tenant supplied by
1712+
`--tenant`, `GRAPHARC_TENANT`
1713+
or `grapharc.toml` into their traces. The policy layer's implicit `"default"`
1714+
does not label a trace; an explicit `--tenant default` does. Executing a saved
1715+
plan uses the current invocation's explicit tenant, rather than inheriting a
1716+
label from the planning run. The standalone `grapharc agent` command
1717+
currently requires `--policy` when selecting a tenant; its trace uses the explicit
1718+
selection, while the document's implicit default tenant stays unlabelled.
1719+
1720+
16691721
---
16701722

16711723
## The CLI tour
16721724

1673-
Twelve commands. Every one takes `--json`, which prints the same payload as one
1725+
The commands below all take `--json`, which prints the same payload as one
16741726
document on stdout — including failures, which become the document rather than a
16751727
line on stderr.
16761728

@@ -1687,6 +1739,7 @@ line on stderr.
16871739
| `grapharc diff <trace> <a> <b>` | compare two runs in one trace |
16881740
| `grapharc trace <trace>` | pretty-print a trace file |
16891741
| `grapharc metrics <trace> <run>` | summarize one run |
1742+
| `grapharc cost <trace> --tenant NAME` | attribute whole labelled runs; alternatively select `--run-id ID` |
16901743
| `grapharc viz <trace> <run>` | render the executed path as Mermaid |
16911744

16921745
`run --check-only` prints a `topology` digest for comparing the same graph

0 commit comments

Comments
 (0)