Observe usage and timing#
Record token usage, prompt-cache hits, and reasoning/content timing without changing how your application runs an agent. The model’s counts are fixtures; the example does not measure provider prices.
Run it#
From the repository root:
uv run python docs/_examples/observability.py
The output includes:
Reasoning and content boundaries recorded
This program uses deterministic offline models and temporary storage where needed. It requires no API key and is executed by the documentation test suite.
Record the completed message#
Use an extension when you want the finalized message, usage, and timing together, rather than each display delta:
class UsageObserver(AgentExtension):
"""Retain immutable observations for a demo; use a bounded sink in production."""
def __init__(self) -> None:
self.records: list[tuple[str, MessageAppendedEvent]] = []
async def on_event(self, context: AgentRunContext, event: ExtensionEvent) -> None:
if isinstance(event, MessageAppendedEvent) and event.usage is not None:
self.records.append((context.session_id, event))
The example retains records in a list and checks a 60% cache-hit rate. For a long-running service, send records to a bounded queue or telemetry sink instead of keeping an unbounded list in memory. Reported cache usage depends on the provider; missing usage is not a measured zero.
Complete source#
1"""Observe persisted-message timing and provider usage without changing the loop."""
2
3import asyncio
4from collections.abc import AsyncIterator
5
6from zett_agent.agent import AgentRunContext
7from zett_agent.client import create_agent
8from zett_agent.extensions.base import AgentExtension
9from zett_agent.extensions.events import (
10 ExtensionEvent,
11 MessageAppendedEvent,
12)
13from zett_agent.messages import AssistantMessage
14from zett_agent.model import (
15 ModelEvent,
16 ModelRequest,
17 ModelResponse,
18 ModelUsage,
19 RetryOptions,
20)
21
22
23class UsageObserver(AgentExtension):
24 """Retain immutable observations for a demo; use a bounded sink in production."""
25
26 def __init__(self) -> None:
27 self.records: list[tuple[str, MessageAppendedEvent]] = []
28
29 async def on_event(self, context: AgentRunContext, event: ExtensionEvent) -> None:
30 if isinstance(event, MessageAppendedEvent) and event.usage is not None:
31 self.records.append((context.session_id, event))
32
33
34class UsageModel:
35 retry = RetryOptions(max_retries=0)
36
37 async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
38 yield ModelEvent.reasoning("Inspect the request.")
39 await asyncio.sleep(0)
40 yield ModelEvent.text("Done")
41 yield ModelEvent.completed(
42 ModelResponse(
43 AssistantMessage(content="Done", reasoning="Inspect the request."),
44 usage=ModelUsage(input_tokens=100, output_tokens=20, cache_read_tokens=60, reasoning_tokens=5),
45 )
46 )
47
48
49async def main() -> None:
50 observer = UsageObserver()
51 client = await create_agent(UsageModel(), extensions=[observer])
52 await client.run("Inspect usage")
53 _, record = observer.records[0]
54 assert record.usage is not None
55 assert record.timing.duration_ns >= 0
56 assert record.timing.reasoning_duration_ns is not None
57 assert record.timing.content_duration_ns is not None
58 assert record.usage.total_tokens == 120
59 assert record.usage.cache_hit_rate == 0.6
60 print("Tokens: 120; cache hit: 60%")
61 print("Reasoning and content boundaries recorded")
62
63
64if __name__ == "__main__":
65 asyncio.run(main())
Next: Connect a model provider for prompt caching, or Three kinds of events for choosing between display and lifecycle events.