Metadata-Version: 2.5
Name: adk-ledger
Version: 0.1.2
Summary: Token, cost, tool-call, cache and budget metering for Google ADK agents, exported to OpenTelemetry, JSONL or any sink.
Project-URL: Homepage, https://github.com/delta-fde/ravidalal-adk-ledger
Author: Ravi Dalal
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: adk,anthropic,budgets,cost,gemini,google-adk,observability,openai,opentelemetry,tokens
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.13
Requires-Dist: google-adk>=2.12
Requires-Dist: google-genai>=1.0
Requires-Dist: opentelemetry-api>=1.25
Requires-Dist: pydantic>=2.0
Provides-Extra: gcp
Requires-Dist: google-cloud-billing>=1.12; extra == 'gcp'
Provides-Extra: otel
Requires-Dist: opentelemetry-sdk>=1.25; extra == 'otel'
Description-Content-Type: text/markdown

# adk-ledger

Token, cost, tool-call, cache and budget metering for [Google ADK](https://adk.dev) agents,
exported to OpenTelemetry, JSONL or any sink you plug in.

- **One record per LLM call** — whether ADK made the call or your tool called `google.genai`,
  `openai` or `anthropic` directly — with exact counts from the provider's `usage_metadata`
  (prompt, output, cached, cache writes, thoughts, per-modality). Never double counted.
- **Cost on every event**, opt-in, from Google's Cloud Billing catalog, the LiteLLM price
  list or your own table; long-context tiers, cache discounts, per-modality rates.
- **Tool-call metering**: latency, failures, retries / loops, argument and result **sizes** —
  never content.
- **Budgets**: cap tokens, cost or calls per session / user / tenant / invocation, per day or
  hour; warn, block (refuse the run, model call or tool call) or raise.
- **Cache effectiveness and context-window pressure**: hit ratio, "configured but never
  hits" detection, dollars saved, prompt size vs the model's input window.
- **Configuration-first**: `adk_ledger.setup().plugins` wires everything from
  `adk_ledger.toml` / `pyproject.toml` / YAML and `ADK_LEDGER_*` env vars; the imperative
  API is there when you want the objects in code.

Counts and identifiers only: no prompt, response or tool payload is ever stored.

## Install

```bash
uv add adk-ledger            # or: pip install adk-ledger
uv add "adk-ledger[otel]"    # + OpenTelemetry SDK, for local exporters
```

Requires **Python ≥ 3.13** (the code uses PEP 695 generics, `StrEnum` and `datetime.UTC`;
older interpreters are deliberately not supported in this release) and **`google-adk` ≥ 2.12**
(the version the test suite runs against; the plugin error hooks, `App` object and branch-aware
callback context it relies on are 2.x APIs). `google-genai` and `opentelemetry-api` come with it; `openai` / `anthropic` are only
needed if you meter those SDKs.

## Quick start — see your first event in 60 seconds

**1. Wire it in.** One line of Python; the plugins go on the `App`, not on individual agents.

```python
from google.adk.agents import LlmAgent
from google.adk.apps import App
import adk_ledger

wiring = adk_ledger.setup(sinks=["memory"])  # keep events in process, for now
root_agent = LlmAgent(name="helper", model="gemini-3.8-flash", instruction="Be brief.")
app = App(name="my_app", root_agent=root_agent, plugins=wiring.plugins)
```

**2. Run one turn** as you normally would — `Runner`, `adk run`, `adk web`, anything.

**3. Look.** The `memory` sink is the first (here, only) entry of `wiring.sinks`:

```python
mem = wiring.sinks[0]
mem.total()  # TokenUsage(prompt=212, candidates=38, thoughts=17, total=267, ...)
mem.totals_by_agent()  # {"helper": TokenUsage(...)}
mem.events[
    -1
].as_dict()  # {"event": "llm_call", "source": "adk", "model": "gemini-3.8-flash", "latency_ms": 731.2, "usage": {...}, ...}
```

Prefer a file? `ADK_LEDGER_SINKS=jsonl:./ledger.jsonl` (no code change, `setup()` reads it)
and `tail -f ledger.jsonl` — one JSON line per model call, shown in full
[below](#recipe-log-every-call-of-an-agent-to-a-jsonl-file). Both work with **no config file
and no credentials beyond the model's own**.

<details>
<summary><b>Or, in code</b> — the same wiring without <code>setup()</code>, for when you want to construct the objects yourself</summary>

```python
from google.adk.agents import LlmAgent
from google.adk.apps import App
from adk_ledger import InMemorySink, TokenMeterPlugin, ToolMeterPlugin, configure

mem = InMemorySink()
configure(
    sinks=[mem], instrument_genai=True
)  # once, at start-up; instrument_genai also meters direct google.genai calls

root_agent = LlmAgent(name="helper", model="gemini-3.8-flash", instruction="Be brief.")
app = App(name="my_app", root_agent=root_agent, plugins=[TokenMeterPlugin(), ToolMeterPlugin()])
```

`setup()` and `configure()` are the same machinery: every config key is an argument of
`configure()` or of a plugin constructor. The feature sections below use the imperative form
because it names the objects; each heading's configuration equivalent is in
[docs/configuration.md](docs/configuration.md).

</details>

> **Nothing shows up?** `setup()` with no file, no `sinks=` and no `ADK_LEDGER_SINKS` configures
> the plugins but **no sink** — events are produced and dropped. See
> [Troubleshooting](#troubleshooting).

### The production file

`setup()` reads `./adk_ledger.toml` (or `adk_ledger.yaml`, `[tool.adk_ledger]` in
`pyproject.toml`, or the file named by `ADK_LEDGER_CONFIG`), applies `ADK_LEDGER_*` environment
variables on top, calls `configure()` and returns the plugins. A typical **production** file —
it needs an OpenTelemetry exporter installed before `setup()` and Google credentials for the
price catalog; on a laptop start from `sinks = ["memory"]` / `pricing = "litellm"` instead:

```toml
# adk_ledger.toml
default_tags = { env = "prod", service = "my_app" }
instrument = ["genai"]                       # explicit google.genai calls are metered too
pricing = "cloud_billing"                    # or "litellm", or a composed table — see docs/configuration.md
context_limits = { source = "genai", prefetch = true }

[[sinks]]
type = "otel"                                # adk_ledger.llm.* / adk_ledger.tool.* metrics

[[sinks]]
type = "jsonl"
path = "/var/log/my_app/adk_ledger.jsonl"

[budgets]
default_action = "block"

[[budgets.items]]
name = "session-tokens"
limit = 200000                               # total_tokens per session (defaults)

[[budgets.items]]
name = "tenant-daily-spend"
metric = "cost"
limit = 50.00
scope = "tag:tenant"
window = "1d"
action = "warn"
```

Prefer to keep it in **`pyproject.toml`**? Put the same keys under `[tool.adk_ledger]`
(`[[tool.adk_ledger.sinks]]`, `[[tool.adk_ledger.budgets.items]]`, …) and run from the project
root; `setup()` finds it — see
[configuration.md → In `pyproject.toml`](docs/configuration.md#in-pyprojecttoml) and
[examples/pyproject_config/](examples/pyproject_config).

Every key maps 1:1 onto an argument of the imperative API ([docs/api.md](docs/api.md)); the
full schema and environment-variable table are in
[docs/configuration.md](docs/configuration.md), and [examples/zero_code.py](examples/zero_code.py)
is the runnable version.

## Documentation

| | |
|---|---|
| [docs/configuration.md](docs/configuration.md) | every knob: `adk_ledger.toml` / YAML / `pyproject.toml` schema, each key's type, default and accepted values, `ADK_LEDGER_*` environment variables, resolution order |
| [docs/events.md](docs/events.md) | **field reference**: every field of `LlmCallEvent` / `ToolCallEvent` / `BudgetEvent`, the enumerated values (`source`, `kind`, `hook`, …), the JSONL shape, the OpenTelemetry instruments and attributes, `InMemorySink` aggregates |
| [docs/api.md](docs/api.md) | the imperative Python API, parameter by parameter: `setup()`, `configure()`, the three plugins, `Budget`, `PriceTable` / `Price`, `ContextLimits`, sinks, the explicit-call helpers |
| [docs/telemetry.md](docs/telemetry.md) | **walkthrough**: annotated output of four live runs — what the numbers look like against a real model, and what they revealed |
| [docs/performance.md](docs/performance.md) | per-event and per-invocation overhead, measured |
| [examples/README.md](examples/README.md) | one runnable script per feature, plus the `concierge` agent for `adk web` |
| [CHANGELOG.md](CHANGELOG.md) | what changed in each release |
| [CONTRIBUTING.md](CONTRIBUTING.md) | development set-up, test suites, security scanning, releasing |

## Features

### Explicit SDK calls inherit the agent that made them

A tool or callback that calls `google.genai` (or `openai` / `anthropic`) directly has no
ADK session of its own. By default adk-ledger still attributes it: `TokenMeterPlugin`
tracks which agent run / tool call is executing, and the meter copies that run's
`app_name` / `user_id` / `session_id` / `invocation_id` / `agent_name` onto any
non-ADK event emitted inside it. The side call is then charged to the same session and
user budgets and appears in the same per-session / per-agent roll-ups; `source`
(`genai_auto`, `explicit`, …) still tells it apart from the ADK call, and
context-window statistics ignore it. Turn it off with `configure(attribute_sdk_calls=False)`.

For calls made where the ADK context does not reach — a background task you spawned, a
queue consumer — bind the scope yourself:

```python
from adk_ledger import adk_scope

with adk_scope(session_id=session_id, user_id=user_id):
    client.models.generate_content(...)
```

### Turning implicit (ADK-issued) call metering on or off

ADK already surfaces `usage_metadata` on its own events for the model calls it makes.
If you consume those directly and only want adk-ledger for explicit `google.genai`
calls, switch the implicit side off — globally or per plugin:

```python
configure(sinks=[...], instrument_genai=True, meter_adk_calls=False)  # global default
app = App(..., plugins=[TokenMeterPlugin()])  # follows the global setting

# or per plugin (overrides the global setting); also settable at runtime
plugin = TokenMeterPlugin(enabled=False)
plugin.enabled = True
```

Keep the plugin registered even when it is off: it still marks ADK's model calls so
the `google.genai` auto-instrumentation does not re-count them as `genai_auto` events.
"Off" means the implicit calls are not recorded anywhere by adk-ledger.

### Reading counts at runtime

```python
from adk_ledger import InMemorySink, configure

mem = InMemorySink()
configure(sinks=[mem])
...
mem.total().prompt  # all prompt tokens so far
mem.totals_by_agent()  # {"helper": TokenUsage(...)}
mem.totals_by_session()  # per ADK session
mem.events[-1].as_dict()  # flat dict, JSON-ready
mem.total_cost().total  # USD so far (see "Cost attribution")
mem.cost_by_agent()  # {"helper": Cost(...)}
```

### Cost attribution

**Opt-in.** Pass `pricing=` and every event whose model is in the table gets a `Cost`
(input / cached input / cache write / output / thoughts, `total`, and `savings` from
caching); everything else carries `cost=None`. No prices are bundled, so nothing is ever
estimated from stale data. Three sources, composable:

| `pricing=` | what it is | needs |
|---|---|---|
| `"cloud_billing"` | **Google's official catalog** (Cloud Billing Catalog API) — Gemini API and Agent Platform services, whose list prices differ; parsed into per-model prices incl. long-context tiers, cache and audio rates | Google credentials (ADC or an API key); Gemini models only |
| `"litellm"` | the **community price list** maintained in the LiteLLM repo — ~4 500 ids across Gemini, OpenAI, Anthropic, … | one HTTPS GET; an estimate, not an invoice |
| `PriceTable(...)` | **yours** — negotiated rates, new or private models | nothing |

```python
from adk_ledger import Price, PriceTable, configure
from adk_ledger.cost import scaled

configure(
    sinks=[...], pricing="cloud_billing"
)  # Google catalog; picks the service billing this process

# Agent Platform prices for Gemini, LiteLLM for everything else, a contract discount on top:
table = PriceTable.from_cloud_billing(services=("agent_platform",)).with_prices(
    {"openai/gpt-5": Price(input=1.00, output=8.00, cached_input=0.10)},  # per 1M tokens
    as_of="2026-Q4 contract",
)
configure(sinks=[...], pricing=table)
```

Lookup is longest-prefix (`gemini-3.8-flash` prices `gemini-3.8-flash-001`); unknown models
get `cost=None` and `InMemorySink.unpriced_models()` lists them. Both live sources are cached
on disk for 24 h and degrade to "unpriced" with a warning if a fetch fails — never a guess.
Negotiated discounts are not in any public catalog; layer them on with `with_prices` /
`scaled` and reconcile against your invoice.
Source selection, `services`, overlays and every `Price` field:
[configuration.md → Pricing](docs/configuration.md#pricing); the `Cost` arithmetic:
[events.md → Cost](docs/events.md#cost).

### Explicit calls

```python
from adk_ledger import meter_llm_call, track_llm_call, record_usage, TokenUsage


@meter_llm_call(feature="classify")  # sync, async, generator or async-generator
def classify(*, model: str, text: str):
    return client.models.generate_content(model=model, contents=text)


with track_llm_call(model="gemini-3.1-pro-preview", tenant="acme") as call:
    call.response = client.models.generate_content(model="gemini-3.1-pro-preview", contents="…")

record_usage(model="gpt-5", provider="openai", usage=TokenUsage(prompt=12, candidates=3, total=15))
```

### Tool calls

`ToolMeterPlugin` hooks ADK's `before/after/on_error_tool_callback` and emits one
`ToolCallEvent` per invocation — something ADK's own telemetry doesn't aggregate:

```python
from adk_ledger import InMemorySink, ToolMeterPlugin, configure

mem = InMemorySink()
configure(sinks=[mem])
app = App(..., plugins=[ToolMeterPlugin()])  # ToolMeterPlugin(measure_payloads=False) to skip sizes
...
mem.tool_events[
    -1
]  # ToolCallEvent(tool_name, latency_ms, error, args_bytes, result_bytes, attempt, …)
mem.tool_stats()["search"]  # ToolStats(calls, errors, retries, error_rate, mean/max latency, bytes)
mem.tool_stats_by(lambda e: (e.agent_name, e.tool_name))
```

- `attempt` is 1 for the first call of a given `(tool, args)` within an invocation and
  2+ when the same call repeats — a cheap detector for tool loops and for retries made by
  ADK's `ReflectAndRetryToolPlugin`. A failure that another plugin recovers is recorded
  **once**, as the failure.
- `args_bytes` / `result_bytes` are the JSON-serialised sizes (how much the model had to
  emit, how much lands back in the prompt). Arguments and results themselves are never stored.
- `is_long_running` flags ADK long-running tools (metered request → first result).

### Budgets

ADK has no notion of a token or spend cap. `BudgetPlugin` adds declarative limits over the
events adk-ledger already produces and enforces them at ADK's pre-call hooks:

```python
from datetime import timedelta
from adk_ledger import Budget, BudgetPlugin, Metric, Scope

budgets = BudgetPlugin(
    default_action="block",  # for budgets without their own action; "warn" is the default
    notify="model",  # let the agent tell the user when a warning threshold is crossed
    budgets=[
        Budget(name="session-tokens", limit=200_000, scope=Scope.SESSION, warn_at=(0.5, 0.8)),
        Budget(
            name="tenant-daily-spend",
            limit=50.00,
            metric=Metric.COST,
            scope=Scope.tag("tenant"),
            window=timedelta(days=1),
            action="warn",
        ),
        Budget(name="tool-loop-guard", limit=25, metric=Metric.TOOL_CALLS, scope=Scope.INVOCATION),
        Budget(
            name="kill-switch",
            limit=1_000.00,
            metric=Metric.COST,
            scope=Scope.GLOBAL,
            action="raise",
        ),
    ],
)
app = App(..., plugins=[TokenMeterPlugin(), ToolMeterPlugin(), budgets])

budgets.tracker.status(
    "session-tokens", session_id
)  # BudgetStatus(used, limit, ratio, remaining, …)
```

- A budget counts one **metric** (tokens, cost, LLM calls or tool calls) per **scope** key
  (global, app, user, session, invocation, or a tag such as `tenant`), optionally in fixed
  calendar **windows** (`days=1` resets at 00:00 UTC — a quota, not a sliding window).
- When exhausted, `warn` only emits an event; `block` refuses the next run, model call or
  tool call *before it is made* (the refusal text becomes the reply, with
  `error_code="BUDGET_EXCEEDED"` on refused model calls); `raise` fails the invocation.
  `dry_run=True` records what *would* be blocked.
- Counting rides on the events the other plugins (and `instrument_*`) produce, so a side
  call a tool makes through an SDK is charged to the same session as the agent's own calls.
- Every warning, breach and refusal is a `BudgetEvent` to all sinks, with an optional
  `on_breach` callback per budget.

Every parameter and its accepted values: [configuration.md → Budgets](docs/configuration.md#budgets)
(data form) and [api.md → Budgets](docs/api.md#budgets--budgetplugin-and-budget) (Python form).
What each hook does when it refuses, and the `BudgetEvent` fields:
[events.md → BudgetEvent](docs/events.md#budgetevent--a-threshold-a-limit-or-a-refusal).

#### Capturing budget events in a client

Every warning, breach and refusal is a `BudgetEvent` delivered to the sinks, so the
simplest client hook is a `CallbackSink` (or `setup(on_breach=…)`) that files them by
session; the run loop then has both the model's reply and the budget facts behind it:

```python
import threading
from collections import defaultdict

from adk_ledger import BudgetEvent, CallbackSink, MeterEvent

budget_events: dict[str, list[BudgetEvent]] = defaultdict(list)
_lock = threading.Lock()


def collect(event: MeterEvent) -> None:
    if isinstance(event, BudgetEvent) and event.session_id:
        with _lock:
            budget_events[event.session_id].append(event)


wiring = adk_ledger.setup(extra_sinks=[CallbackSink(collect)])  # or configure(sinks=[...])
app = App(name="my_app", root_agent=agent, plugins=wiring.plugins)

...

async for event in runner.run_async(user_id=user_id, session_id=session_id, new_message=msg):
    if event.error_code == "BUDGET_EXCEEDED":  # a model call was refused
        ui.banner("Budget reached — this conversation is paused.")
    ui.render(event.content)  # the refusal text, or the reply

with _lock:
    new = budget_events.pop(session_id, [])
for ev in new:  # warning / exceeded / blocked
    ui.toast(f"{ev.budget}: {ev.ratio:.0%} used ({ev.kind}{' at ' + ev.hook if ev.hook else ''})")

for st in wiring.budgets.tracker.statuses(session_id=session_id, user_id=user_id):
    ui.meter(st.budget.name, st.ratio)  # a live gauge, no events needed
```

[`examples/concierge/__main__.py`](examples/concierge/__main__.py) runs exactly this loop
(`client_turn()`) against the concierge agent. Two details that matter for the detector:

- a `before_model` / `before_tool` refusal carries `error_code="BUDGET_EXCEEDED"`, but a
  **`before_run` refusal does not** — ADK emits it as a plain model event whose content
  is the refusal text. The `BudgetEvent(kind="blocked", hook="before_run")` from the sink
  is the reliable signal for that case;
- `BudgetEvent`s are emitted synchronously, inside the hook or the meter call that triggered
  them, so by the time the next runner event arrives they are already in the list.

#### Telling the user

A refusal is visible by construction — it *is* the reply. Warnings are silent unless the
plugin is asked to pass them on: `notify="model"` adds a system-instruction note to the next
model call so the agent mentions it in its own words; `notify="append"` appends a fixed
`notify_message` to the next final reply. Each budget announces each `warn_at` ratio (and
100 % for `warn` budgets) once per session, remembered in session state. Details and
templates: [configuration.md → Budgets](docs/configuration.md#budgets).

### Cache effectiveness and context-window pressure

Both are derived from data every event already carries — `TokenMeterPlugin` fills the
inputs, the sinks aggregate.

**Context cache.** Gemini serves prompt prefixes from a server-side cache, implicitly or
through an explicit `CachedContent` (in ADK, `App(context_cache_config=...)`). Each event
records whether caching was *asked for* (`cache_configured`), which explicit cache served it
(`cache_name`), how much was served (`usage.cached`, `cache_hit_ratio`) and what that saved
(`cost.savings`). `cache_missed` — configured but zero cached tokens — is the
misconfiguration signal: a prefix below the model's minimum cacheable size, a prefix that
changes every turn, or an expired TTL.

```python
mem.cache_stats()  # calls, calls_with_hits, calls_configured, calls_configured_missed, savings, hit_ratio
mem.cache_stats_by_agent()  # the agent whose cache never hits
```

> ADK's `cache_performance_analyzer` answers the same question for explicit ADK-managed
> caches, offline, by replaying a session. This is live, covers implicit caching, includes
> savings, and reaches OTel / JSONL / budgets. [telemetry.md](docs/telemetry.md) shows a
> cache that never hit (and why) next to one that did.

**Context window.** `event.context_limit` is the model's input window and
`context_utilization = prompt / context_limit`. By default the limit comes from the Gemini
API itself (`models.get` → `input_token_limit`), lazily per model id; alternatives are the
LiteLLM list (OpenAI, Anthropic, …) or your own table. Unknown limits stay `None` — never
guessed.

```python
stats = mem.context_stats_by_session()[session_id]
stats.max_utilization  # how close this conversation got to the window
stats.growth_per_call  # prompt tokens added per turn — when will it hit the limit?
```

Sources and `prefetch`: [configuration.md → Context limits](docs/configuration.md#context-limits).
The OTel instruments for both (`adk_ledger.llm.cache.hit_ratio`, `.cache.savings`,
`.context.utilization`) let you alert on "p95 utilisation > 0.8" or "hit ratio 0 while
configured" without per-model rules.

### Tags

```python
from adk_ledger import bind_tags

with bind_tags(tenant="acme", experiment="v2"):
    async for ev in runner.run_async(...):
        ...  # every event inside carries the tags
```

## What gets recorded

Three event types, **counts and identifiers only — never prompt, response or tool content**:

- `LlmCallEvent` — one per model call: `source` (how it was observed: `adk`, `genai_auto`,
  `openai_auto`, `anthropic_auto`, `explicit`), `provider`, `model`, `usage`
  (prompt / candidates / cached / cache writes / thoughts / per-modality), `cost`,
  cache and context-window fields, latency, error, the ADK context
  (`app_name` … `invocation_id`) and `tags`.
- `ToolCallEvent` — one per tool invocation: name, latency, error, argument / result
  **sizes**, `attempt` (a retry / loop detector), ADK context and tags.
- `BudgetEvent` — a `warning`, `exceeded` or `blocked` (with the refusing `hook`), the
  budget's numbers, ADK context and tags.

All three serialise with an `"event"` discriminator and reach every sink. Field-by-field
definitions, when each is `null`, every enumerated value and the JSONL shape:
[docs/events.md](docs/events.md).

## Sinks

| sink | import | notes |
|---|---|---|
| `InMemorySink` | `adk_ledger` | `events` / `tool_events`; tokens: `total()`, `totals_by_model/agent/session()`, `totals_by(key)`; cost: `total_cost()`, `cost_by_model/agent/session()`, `cost_by(key)`, `unpriced_models()`; tools: `tool_stats()`, `tool_stats_by(key)` |
| `JsonlSink` | `adk_ledger` | one JSON object per line, appended to a file (`JsonlSink("usage.jsonl")`) or a stream (`JsonlSink(stream=sys.stdout)`); lazy open, thread-safe, `flush=True` by default |
| `OpenTelemetrySink` | `adk_ledger.sinks.otel` | metrics + span attributes, see below |
| `CallbackSink(fn)` | `adk_ledger` | wrap any callable / coroutine function |

### Recipe: log every call of an agent to a JSONL file

Works for `adk web`, `adk run`, and your own `Runner`. ADK imports your agent package
once per process, so `configure()` at module level is the right place.

```
my_agent/
├── __init__.py      # from .agent import app, root_agent
└── agent.py
```

```python
# my_agent/agent.py
import os
from pathlib import Path

from google.adk.agents import LlmAgent
from google.adk.apps import App
from adk_ledger import JsonlSink, TokenMeterPlugin, configure

# 1. Where to write. Env-overridable so dev/CI/prod can point elsewhere.
log_path = Path(os.environ.get("ADK_LEDGER_JSONL", "logs/llm_usage.jsonl"))

# 2. Wire the sink once per process. instrument_genai=True also captures any
#    direct google.genai calls your tools make; drop it if you only want ADK calls.
configure(
    sinks=[JsonlSink(log_path)],
    default_tags={"env": os.environ.get("ENV", "dev"), "service": "my_agent"},
    instrument_genai=True,
)

# 3. Define the agent as usual and attach the plugin to the App.
root_agent = LlmAgent(
    name="helper",
    model="gemini-3.8-flash",
    instruction="Answer briefly.",
)

app = App(name="my_agent", root_agent=root_agent, plugins=[TokenMeterPlugin()])
```

```bash
adk web my_agent          # or: adk run my_agent
tail -f logs/llm_usage.jsonl | jq '{agent: .agent_name, model, in: .usage.prompt, out: .usage.candidates}'
```

Each turn appends one line per model call, e.g.

```json
{"event":"llm_call","timestamp":"2026-10-29T18:20:11.412318+00:00","source":"adk","provider":"gemini","model":"gemini-3.8-flash","latency_ms":731.2,"streaming":false,"error":null,"app_name":"my_agent","agent_name":"helper","user_id":"user","session_id":"5b1c…","invocation_id":"e-9f…","usage":{"prompt":212,"candidates":38,"cached":0,"cache_creation":0,"thoughts":17,"tool_use_prompt":0,"total":267,"prompt_by_modality":{"text":212},"candidates_by_modality":{"text":38}},"cost":{"input":0.000159,"cached_input":0.0,"cache_write":0.0,"output":0.0001425,"thoughts":6.375e-05,"total":0.00036525,"currency":"USD","savings":0.0},"cache_configured":false,"cache_name":null,"cache_hit_ratio":0.0,"context_limit":1048576,"context_utilization":0.000202178955078125,"tags":{"env":"dev","service":"my_agent"}}
```

Variations:

- **Per-request context** — wrap the run in `with bind_tags(tenant=..., feature=...):` to add columns without touching the agent.
- **Several destinations** — `configure(sinks=[JsonlSink(log_path), OpenTelemetrySink()])`; sinks are independent and a failing one never affects the others.
- **Stdout for containers** — `JsonlSink(stream=sys.stdout)` lets the platform's log collector pick lines up; nothing is written to disk.
- **Own `Runner`** — identical setup; just pass the same `app` to `Runner(app=app, session_service=...)`. Call `adk_ledger.reset()` (or `sink.close()`) at shutdown to flush and close the file.

> Keep the plugin on the **`App`**, not on individual agents — one plugin covers the whole
> agent tree (sub-agents, `SequentialAgent`, `LoopAgent`, …) and reports `agent_name` per call.

```python
import pandas as pd

df = pd.json_normalize(pd.read_json("usage.jsonl", lines=True).to_dict("records"))
df.groupby("agent_name")["usage.total"].sum()
```

## OpenTelemetry

`OpenTelemetrySink` emits metrics in its own `adk_ledger.*` namespace — token usage split by
type (incl. `thoughts` and `cached_input`), call duration and count, cost by component,
cache hit ratio and savings, context utilisation, tool duration and count, budget events and
utilisation — with attributes that follow the GenAI semantic conventions (`gen_ai.system`,
`gen_ai.request.model`, `gen_ai.token.type`) plus `adk.source`, `adk.agent_name` and your
tags as `adk.tag.<key>`. When a span is active the same values are set as span attributes.
It records on the global `MeterProvider`, so install your exporter before `setup()`; without
an SDK it is a no-op.

> **Why not `gen_ai.client.token.usage`?** ADK emits that metric itself when deployed with
> telemetry enabled. A separate name keeps the two from being merged into one Cloud
> Monitoring series, while the shared `gen_ai.*` attributes let you compare them side by
> side — [telemetry.md §4](docs/telemetry.md#4-opentelemetry-metrics-opentelemetrysink) does.

The instrument table — kind, unit, when each point is recorded, every attribute:
[events.md → OpenTelemetry](docs/events.md#opentelemetry-opentelemetrysink).

## Troubleshooting

| symptom | usual cause | fix |
|---|---|---|
| **No events at all** | `setup()` was given no sink: no config file, no `sinks=`, no `ADK_LEDGER_SINKS` → plugins run, events are dropped | `setup(sinks=["memory"])`, `ADK_LEDGER_SINKS=jsonl:./ledger.jsonl`, or a `[[sinks]]` entry |
| | plugins not attached to the **`App`** (`App(..., plugins=wiring.plugins)`) | attach them to the `App`; one plugin covers the whole agent tree |
| | `configure()` / `setup()` called *after* ADK imported your agent module, or called twice (the second call replaces the first meter and closes its sinks) | call it once, at module level of the agent package |
| **Nothing in OpenTelemetry** | the `otel` sink records on the **global** `MeterProvider`; without an SDK + exporter installed *before* `setup()` it is a no-op by design | install `adk-ledger[otel]`, set `opentelemetry.metrics.set_meter_provider(...)` first; `ADK_LEDGER_SINKS=memory` to confirm events exist |
| **`cost` is `None`** | no `pricing=` (cost is opt-in) | `pricing="litellm"` (no credentials), `"cloud_billing"` (Google credentials), or a `PriceTable` |
| | the model is not in the table | `InMemorySink.unpriced_models()` lists them; add a `Price` with `with_prices` / `prices = {...}` |
| | `cloud_billing` could not be fetched (a warning is logged; events are *unpriced*, never guessed) | ADC or `GOOGLE_CLOUD_API_KEY` with the Cloud Billing API enabled; or `"litellm"` |
| **`context_limit` is `None`** | the first call(s) for each model id: the limit is fetched lazily on a background thread | `context_limits = { source = "genai", prefetch = true }`, or an explicit `limits = {...}`; non-Gemini models need `source = "litellm"` |
| **A tool's direct SDK call is missing** | auto-instrumentation is off by default | `instrument = ["genai", "openai", "anthropic"]` / `configure(instrument_genai=True, ...)` |
| **Direct SDK calls are counted twice** | `TokenMeterPlugin` not registered (it marks ADK's own calls so the SDK patch steps aside) | keep `TokenMeterPlugin` on the `App` even with `meter_adk_calls=False` |
| **A budget never blocks** | `default_action` is `"warn"` (events only) | `default_action = "block"` or `action = "block"` on the budget; `dry_run = true` to preview |
| | the budget's scope key is missing on the event (e.g. `scope = "tag:tenant"` but no `tenant` tag) | `bind_tags(tenant=...)` around the run or `default_tags` |
| **Budget warning never reaches the user** | warnings are silent events unless `notify` is set | `notify = "model"` (the agent says it) or `"append"` (fixed text on the reply) |
| **`ConfigError` at start-up** | a typo'd key, a value out of range (`limit <= 0`, `warn_at` outside (0, 1), negative token limits …) or a bad template placeholder | the message names the key; schema in [docs/configuration.md](docs/configuration.md) |
| **`[EXPERIMENTAL] feature … is enabled` warnings** | ADK itself flags JSON-schema tool declarations and `ContextCacheConfig` as experimental | expected; not from adk-ledger |

## Examples

[`examples/all_features.py`](examples/all_features.py) is the whole library wired up in one
file and [`examples/concierge/`](examples/concierge) is the same thing as an agent you can
chat with in `adk web` — including a `ledger_report` tool that answers *"what has this chat
cost so far, and is the cache working?"*, and a system instruction sized to clear Gemini's
minimum cacheable prefix. [`examples/`](examples/README.md) also has
[`quickstart.py`](examples/quickstart.py) and one focused script per feature — cost
attribution, tool metering, budgets, cache & context-window metrics, SDK auto-instrumentation
+ explicit API, sinks & tags. Each runs **live** against Gemini or **offline** against a
scripted model (`ADK_LEDGER_EXAMPLE_OFFLINE=1`, no keys):

```bash
ADK_LEDGER_EXAMPLE_OFFLINE=1 uv run python -m examples.all_features
mise run examples        # all of them, offline
mise run web             # adk web → "concierge" (live)
```

## API reference

The imperative API — `setup()`, `configure()`, `TokenMeterPlugin`, `ToolMeterPlugin`,
`BudgetPlugin` / `Budget`, `PriceTable` / `Price`, `ContextLimits`, the sinks, the
explicit-call helpers, and how non-Gemini providers are handled — is documented parameter
by parameter in [docs/api.md](docs/api.md). Its data form (TOML / YAML / env vars) is
[docs/configuration.md](docs/configuration.md); the two map one-to-one.

## Extending

| extension point | how |
|---|---|
| New sink | implement `emit(event)` / `close()` → `configure(sinks=[...])` |
| New provider | implement `BaseUsageExtractor` (`provider`, `supports`, `extract`, `model_of`) → `configure(extractors=[...])` or advertise via the `adk_ledger.extractors` entry point and call `configure(load_entry_points=True)` |
| Pre-call estimate | `TokenMeterPlugin(estimator=GeminiCountTokensEstimator(client))` adds `tags["estimated_prompt_tokens"]`; `StaticCharEstimator` is a free heuristic |
| New feature | add `adk_ledger/<feature>/`, reuse `adk_ledger.core` (events, sinks, meter, context) |

## How double counting is avoided

ADK's Gemini model calls `google.genai` under the hood. `TokenMeterPlugin` sets a
context-local marker for the duration of each ADK model call; the `google.genai`
patches see the marker and step aside. Within one ADK call the plugin buffers
streaming chunks and emits once on the final aggregate response.

## Performance

Everything on costs **≈ 40 µs per model call** and **well under 1 ms per agent invocation**
(0.03–0.3 % of a real model round-trip). The one hot-path cost to configure deliberately is
the lazy context-limit lookup (one Gemini API call per new model id) — prefetch it at start-up
in latency-sensitive services. Numbers, method and the full cost table:
[docs/performance.md](docs/performance.md); reproduce with `mise run bench`.

## What the telemetry looks like

[docs/telemetry.md](docs/telemetry.md) is the unedited output of `examples/all_features.py`
and of the `examples/concierge/` agent against a live Gemini model: the in-memory reports,
every JSONL record, and the OpenTelemetry export side by side with ADK's own `gen_ai.*`
metrics — including a context cache that never hits (and why), one that does, and a tool's
own `google.genai` call attributed back to its session.

## Contributing

Development set-up, the test suites, security scanning and the release process are in
[CONTRIBUTING.md](CONTRIBUTING.md). Licensed under Apache-2.0 ([LICENSE](LICENSE)).
