Skip to content

Commit ac46609

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 89b1b6b commit ac46609

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
@@ -27,7 +27,7 @@
2727
from agentex.lib.adk._modules.state import StateModule
2828
from agentex.lib.adk._modules.streaming import StreamingModule
2929
from agentex.lib.adk._modules.tasks import TasksModule
30-
from agentex.lib.adk._modules.tracing import TracingModule
30+
from agentex.lib.adk._modules.tracing import TracingModule, TurnSpan
3131

3232
# Unified harness surface (AGX1-375)
3333
from agentex.lib.core.harness import (
@@ -66,6 +66,7 @@
6666
"tracing",
6767
"events",
6868
"agent_task_tracker",
69+
"TurnSpan",
6970
# Checkpointing / LangGraph
7071
"create_checkpointer",
7172
"stream_langgraph_events",

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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
TracingActivityName,
2121
)
2222
from agentex.lib.core.tracing.tracer import AsyncTracer
23+
from agentex.lib.core.tracing.usage import usage_from_counts, validate_usage_blob
2324
from agentex.types.span import Span
2425
from agentex.lib.utils.logging import make_logger
2526
from agentex.lib.utils.model_utils import BaseModel
@@ -42,6 +43,73 @@ def _record_temporal_span_activity_dropped(event_type: str) -> None:
4243
pass
4344

4445

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

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

tests/lib/adk/test_tracing_module.py

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

99
import agentex.lib.adk._modules.tracing as _tracing_mod
1010
from agentex.types.span import Span
11-
from agentex.lib.adk._modules.tracing import TracingModule
11+
from agentex.lib.adk._modules.tracing import TurnSpan, TracingModule
1212
from agentex.lib.core.services.adk.tracing import TracingService
1313

1414

@@ -258,3 +258,95 @@ async def test_span_context_manager_noop_when_no_trace_id(self):
258258

259259
mock_service.start_span.assert_not_called()
260260
mock_service.end_span.assert_not_called()
261+
262+
263+
class TestTurnSpan:
264+
async def test_turn_span_records_aggregate_usage_in_data(self):
265+
mock_service, module = _make_module()
266+
started = _make_span(task_id="task-abc")
267+
mock_service.start_span.return_value = started
268+
mock_service.end_span.return_value = started
269+
270+
with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False):
271+
async with module.turn_span(
272+
trace_id="trace-123",
273+
name="turn",
274+
task_id="task-abc",
275+
) as turn:
276+
assert isinstance(turn, TurnSpan)
277+
turn.output = {"response": "hello"}
278+
turn.record_usage(
279+
usage={"input_tokens": 100, "output_tokens": 40, "total_tokens": 140},
280+
cost_usd=0.0125,
281+
)
282+
283+
ended_span = mock_service.end_span.call_args.kwargs["span"]
284+
assert ended_span.data["usage"] == {
285+
"input_tokens": 100,
286+
"output_tokens": 40,
287+
"total_tokens": 140,
288+
}
289+
assert ended_span.data["cost_usd"] == 0.0125
290+
# The aggregate lives in data, never in output — output stays payload-only
291+
assert ended_span.output == {"response": "hello"}
292+
293+
async def test_turn_span_record_usage_with_individual_counts(self):
294+
mock_service, module = _make_module()
295+
started = _make_span()
296+
mock_service.start_span.return_value = started
297+
mock_service.end_span.return_value = started
298+
299+
with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False):
300+
async with module.turn_span(trace_id="trace-123", name="turn") as turn:
301+
turn.record_usage(input_tokens=10, output_tokens=5, cached_input_tokens=2)
302+
303+
ended_span = mock_service.end_span.call_args.kwargs["span"]
304+
assert ended_span.data["usage"] == {
305+
"input_tokens": 10,
306+
"output_tokens": 5,
307+
"cached_input_tokens": 2,
308+
}
309+
310+
async def test_turn_span_preserves_existing_data(self):
311+
mock_service, module = _make_module()
312+
started = _make_span(data={"custom": "value"})
313+
mock_service.start_span.return_value = started
314+
mock_service.end_span.return_value = started
315+
316+
with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False):
317+
async with module.turn_span(
318+
trace_id="trace-123", name="turn", data={"custom": "value"}
319+
) as turn:
320+
turn.record_usage(usage={"prompt_tokens": 3, "completion_tokens": 4})
321+
322+
ended_span = mock_service.end_span.call_args.kwargs["span"]
323+
assert ended_span.data["custom"] == "value"
324+
assert ended_span.data["usage"] == {"prompt_tokens": 3, "completion_tokens": 4}
325+
326+
async def test_turn_span_cost_only(self):
327+
mock_service, module = _make_module()
328+
started = _make_span()
329+
mock_service.start_span.return_value = started
330+
mock_service.end_span.return_value = started
331+
332+
with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False):
333+
async with module.turn_span(trace_id="trace-123", name="turn") as turn:
334+
turn.record_usage(cost_usd=0.5)
335+
336+
ended_span = mock_service.end_span.call_args.kwargs["span"]
337+
assert ended_span.data == {"cost_usd": 0.5}
338+
assert "usage" not in ended_span.data
339+
340+
async def test_turn_span_noop_when_no_trace_id(self):
341+
mock_service, module = _make_module()
342+
343+
with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False):
344+
async with module.turn_span(trace_id="", name="turn") as turn:
345+
assert turn.span is None
346+
# Must not raise when tracing is disabled
347+
turn.record_usage(usage={"input_tokens": 1}, cost_usd=0.1)
348+
turn.output = {"response": "x"}
349+
assert turn.output is None
350+
351+
mock_service.start_span.assert_not_called()
352+
mock_service.end_span.assert_not_called()

0 commit comments

Comments
 (0)