Skip to content

Commit 4d1ff90

Browse files
committed
feat(adk): add turn_span helper for per-turn aggregate usage and cost
TurnSpan.record_usage writes the billable aggregate to span.data (usage + cost_usd), encapsulating the contract so agents cannot re-introduce the double-count bug. Documents the usage/cost span contract in the tracing tutorial.
1 parent 8384ee9 commit 4d1ff90

4 files changed

Lines changed: 237 additions & 2 deletions

File tree

examples/tutorials/10_async/00_base/030_tracing/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,37 @@ await adk.tracing.end_span(
4141

4242
Spans create a hierarchical view of agent execution, making it easy to see which operations take time and where errors occur.
4343

44+
## Token Usage & Cost Tracking
45+
46+
Token usage on spans is what the backend bills from, and it reads two shapes:
47+
48+
- **Per-turn aggregate**`span.data["usage"]` + `span.data["cost_usd"]`. Emit at most
49+
once per turn, holding that turn's own usage (not a session-cumulative total). When a
50+
trace has an aggregate, the backend keeps it and de-dups all per-call spans against it.
51+
- **Per-call detail**`span.output["usage"]`. Optional; the SDK's LLM adapters
52+
(litellm, OpenAI Agents SDK, LangGraph) emit this automatically. Summed only when no
53+
aggregate exists in the trace.
54+
55+
Record the turn rollup with `adk.tracing.turn_span()` instead of hand-writing usage keys:
56+
57+
```python
58+
async with adk.tracing.turn_span(
59+
trace_id=task.id,
60+
name="turn",
61+
input={"prompt": prompt},
62+
task_id=task.id,
63+
) as turn:
64+
result = await run_llm_calls()
65+
turn.output = {"response": result.text}
66+
turn.record_usage(usage=result.usage, cost_usd=result.cost_usd)
67+
```
68+
69+
**Never put usage on both a rollup span's `output` and its per-call children's
70+
`output`** — that double-counts. `turn_span` writes the aggregate to `data`, so child
71+
spans stay safe to emit. Recognized token keys: `input_tokens`/`prompt_tokens`,
72+
`output_tokens`/`completion_tokens`, `cached_input_tokens`/`cached_tokens`,
73+
`reasoning_tokens`; cost is `cost_usd`.
74+
4475
## When to Use
4576
- Debugging complex agent behaviors
4677
- Performance optimization and bottleneck identification

src/agentex/lib/adk/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from agentex.lib.adk._modules.state import StateModule
1515
from agentex.lib.adk._modules.streaming import StreamingModule
1616
from agentex.lib.adk._modules.tasks import TasksModule
17-
from agentex.lib.adk._modules.tracing import TracingModule
17+
from agentex.lib.adk._modules.tracing import TracingModule, TurnSpan
1818

1919
from agentex.lib.adk import providers
2020
from agentex.lib.adk import utils
@@ -40,6 +40,7 @@
4040
"tracing",
4141
"events",
4242
"agent_task_tracker",
43+
"TurnSpan",
4344

4445
# Checkpointing / LangGraph
4546
"create_checkpointer",

src/agentex/lib/adk/_modules/tracing.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
TracingActivityName,
1919
)
2020
from agentex.lib.core.tracing.tracer import AsyncTracer
21+
from agentex.lib.core.tracing.usage import usage_from_counts, validate_usage_blob
2122
from agentex.types.span import Span
2223
from agentex.lib.utils.logging import make_logger
2324
from agentex.lib.utils.model_utils import BaseModel
@@ -28,6 +29,73 @@
2829
DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1)
2930

3031

32+
class TurnSpan:
33+
"""Handle for a turn-level (rollup) span, yielded by ``TracingModule.turn_span``.
34+
35+
Encapsulates the billing contract so agents cannot double-count usage:
36+
the turn's aggregate usage goes to ``span.data["usage"]`` (+
37+
``span.data["cost_usd"]``) via :meth:`record_usage`. The backend keeps the
38+
aggregate and de-dups any per-call ``output["usage"]`` children against it.
39+
Never hand-write usage into ``output`` on a rollup span — that is the
40+
double-count bug this helper exists to prevent.
41+
42+
All methods no-op when tracing is disabled (``span`` is None), so agent
43+
code needs no ``if span:`` guards.
44+
"""
45+
46+
def __init__(self, span: Span | None):
47+
self.span = span
48+
49+
def record_usage(
50+
self,
51+
usage: dict[str, Any] | None = None,
52+
cost_usd: float | None = None,
53+
*,
54+
input_tokens: int | None = None,
55+
output_tokens: int | None = None,
56+
total_tokens: int | None = None,
57+
cached_input_tokens: int | None = None,
58+
reasoning_tokens: int | None = None,
59+
) -> None:
60+
"""Record the turn's aggregate usage on the span's ``data``.
61+
62+
Pass either a prebuilt ``usage`` mapping (framework token spellings
63+
like ``prompt_tokens``/``completion_tokens`` are accepted by the
64+
backend) or individual token counts, plus an optional ``cost_usd``.
65+
Individual counts are merged over the ``usage`` mapping. The usage must
66+
be this turn's own tokens, not a session-cumulative total.
67+
"""
68+
if self.span is None:
69+
return
70+
71+
blob: dict[str, Any] = validate_usage_blob(usage) if usage else {}
72+
blob.update(
73+
usage_from_counts(
74+
input_tokens=input_tokens,
75+
output_tokens=output_tokens,
76+
total_tokens=total_tokens,
77+
cached_input_tokens=cached_input_tokens,
78+
reasoning_tokens=reasoning_tokens,
79+
)
80+
)
81+
82+
data = self.span.data if isinstance(self.span.data, dict) else {}
83+
if blob:
84+
data["usage"] = blob
85+
if cost_usd is not None:
86+
data["cost_usd"] = cost_usd
87+
self.span.data = data
88+
89+
@property
90+
def output(self) -> Any:
91+
return self.span.output if self.span is not None else None
92+
93+
@output.setter
94+
def output(self, value: Any) -> None:
95+
if self.span is not None:
96+
self.span.output = value
97+
98+
3199
class TracingModule:
32100
"""
33101
Module for managing tracing and span operations in Agentex.
@@ -143,6 +211,49 @@ async def span(
143211
retry_policy=retry_policy,
144212
)
145213

214+
@asynccontextmanager
215+
async def turn_span(
216+
self,
217+
trace_id: str,
218+
name: str,
219+
input: list[Any] | dict[str, Any] | BaseModel | None = None,
220+
data: list[Any] | dict[str, Any] | BaseModel | None = None,
221+
parent_id: str | None = None,
222+
task_id: str | None = None,
223+
start_to_close_timeout: timedelta = timedelta(seconds=5),
224+
heartbeat_timeout: timedelta = timedelta(seconds=5),
225+
retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY,
226+
) -> AsyncGenerator[TurnSpan, None]:
227+
"""Span for one agent turn, with usage recorded as the billable aggregate.
228+
229+
Same lifecycle as :meth:`span`, but yields a :class:`TurnSpan` whose
230+
``record_usage(usage=..., cost_usd=...)`` writes the turn's rollup
231+
usage to ``span.data`` — the shape the backend bills once per turn.
232+
Per-call child spans (LLM adapters) may still carry
233+
``output["usage"]``; the backend de-dups them against this aggregate.
234+
235+
Example::
236+
237+
async with adk.tracing.turn_span(
238+
trace_id=task.id, name="turn", input={...}, task_id=task.id
239+
) as turn:
240+
result = await run_llm_calls()
241+
turn.output = {"response": result.text}
242+
turn.record_usage(usage=result.usage, cost_usd=result.cost_usd)
243+
"""
244+
async with self.span(
245+
trace_id=trace_id,
246+
name=name,
247+
input=input,
248+
data=data,
249+
parent_id=parent_id,
250+
task_id=task_id,
251+
start_to_close_timeout=start_to_close_timeout,
252+
heartbeat_timeout=heartbeat_timeout,
253+
retry_policy=retry_policy,
254+
) as span:
255+
yield TurnSpan(span)
256+
146257
async def start_span(
147258
self,
148259
trace_id: str,

tests/lib/adk/test_tracing_module.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import agentex.lib.adk._modules.tracing as _tracing_mod
77
from agentex.types.span import Span
8-
from agentex.lib.adk._modules.tracing import TracingModule
8+
from agentex.lib.adk._modules.tracing import TurnSpan, TracingModule
99
from agentex.lib.core.services.adk.tracing import TracingService
1010

1111

@@ -115,3 +115,95 @@ async def test_span_context_manager_noop_when_no_trace_id(self):
115115

116116
mock_service.start_span.assert_not_called()
117117
mock_service.end_span.assert_not_called()
118+
119+
120+
class TestTurnSpan:
121+
async def test_turn_span_records_aggregate_usage_in_data(self):
122+
mock_service, module = _make_module()
123+
started = _make_span(task_id="task-abc")
124+
mock_service.start_span.return_value = started
125+
mock_service.end_span.return_value = started
126+
127+
with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False):
128+
async with module.turn_span(
129+
trace_id="trace-123",
130+
name="turn",
131+
task_id="task-abc",
132+
) as turn:
133+
assert isinstance(turn, TurnSpan)
134+
turn.output = {"response": "hello"}
135+
turn.record_usage(
136+
usage={"input_tokens": 100, "output_tokens": 40, "total_tokens": 140},
137+
cost_usd=0.0125,
138+
)
139+
140+
ended_span = mock_service.end_span.call_args.kwargs["span"]
141+
assert ended_span.data["usage"] == {
142+
"input_tokens": 100,
143+
"output_tokens": 40,
144+
"total_tokens": 140,
145+
}
146+
assert ended_span.data["cost_usd"] == 0.0125
147+
# The aggregate lives in data, never in output — output stays payload-only
148+
assert ended_span.output == {"response": "hello"}
149+
150+
async def test_turn_span_record_usage_with_individual_counts(self):
151+
mock_service, module = _make_module()
152+
started = _make_span()
153+
mock_service.start_span.return_value = started
154+
mock_service.end_span.return_value = started
155+
156+
with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False):
157+
async with module.turn_span(trace_id="trace-123", name="turn") as turn:
158+
turn.record_usage(input_tokens=10, output_tokens=5, cached_input_tokens=2)
159+
160+
ended_span = mock_service.end_span.call_args.kwargs["span"]
161+
assert ended_span.data["usage"] == {
162+
"input_tokens": 10,
163+
"output_tokens": 5,
164+
"cached_input_tokens": 2,
165+
}
166+
167+
async def test_turn_span_preserves_existing_data(self):
168+
mock_service, module = _make_module()
169+
started = _make_span(data={"custom": "value"})
170+
mock_service.start_span.return_value = started
171+
mock_service.end_span.return_value = started
172+
173+
with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False):
174+
async with module.turn_span(
175+
trace_id="trace-123", name="turn", data={"custom": "value"}
176+
) as turn:
177+
turn.record_usage(usage={"prompt_tokens": 3, "completion_tokens": 4})
178+
179+
ended_span = mock_service.end_span.call_args.kwargs["span"]
180+
assert ended_span.data["custom"] == "value"
181+
assert ended_span.data["usage"] == {"prompt_tokens": 3, "completion_tokens": 4}
182+
183+
async def test_turn_span_cost_only(self):
184+
mock_service, module = _make_module()
185+
started = _make_span()
186+
mock_service.start_span.return_value = started
187+
mock_service.end_span.return_value = started
188+
189+
with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False):
190+
async with module.turn_span(trace_id="trace-123", name="turn") as turn:
191+
turn.record_usage(cost_usd=0.5)
192+
193+
ended_span = mock_service.end_span.call_args.kwargs["span"]
194+
assert ended_span.data == {"cost_usd": 0.5}
195+
assert "usage" not in ended_span.data
196+
197+
async def test_turn_span_noop_when_no_trace_id(self):
198+
mock_service, module = _make_module()
199+
200+
with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False):
201+
async with module.turn_span(trace_id="", name="turn") as turn:
202+
assert turn.span is None
203+
# Must not raise when tracing is disabled
204+
turn.record_usage(usage={"input_tokens": 1}, cost_usd=0.1)
205+
turn.output = {"response": "x"}
206+
assert turn.output is None
207+
208+
mock_service.start_span.assert_not_called()
209+
mock_service.end_span.assert_not_called()

0 commit comments

Comments
 (0)