Skip to content

Commit b6b42ef

Browse files
committed
Make span generic wrt span_data
1 parent b74710c commit b6b42ef

5 files changed

Lines changed: 106 additions & 33 deletions

File tree

src/ai/agents/agent.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -641,9 +641,9 @@ async def __call__(self, **overrides: Any) -> events_.ToolCallResult:
641641
# re-executing the tool.
642642
cached = self._part.cached_result
643643
if cached is not None:
644-
async with telemetry.span(data, replay=True):
645-
data.result = cached.result
646-
data.is_error = cached.is_error
644+
async with telemetry.span(data, replay=True) as sp:
645+
sp.data.result = cached.result
646+
sp.data.is_error = cached.is_error
647647
msg = builders.tool_message(cached)
648648
return events_.ToolCallResult(
649649
message=msg, results=msg.tool_results
@@ -717,8 +717,8 @@ async def _real(
717717
async with telemetry.span(data) as sp:
718718
res = await chain(call)
719719
if res.results:
720-
data.result = res.results[0].result
721-
data.is_error = any(p.is_error for p in res.results)
720+
sp.data.result = res.results[0].result
721+
sp.data.is_error = any(p.is_error for p in res.results)
722722
# A tool exception is caught and converted to an error
723723
# result before it reaches this block, so it never hits the
724724
# span's own except path — thread it through explicitly.
@@ -1401,19 +1401,20 @@ async def _real(call: Context) -> AsyncGenerator[events_.AgentEvent]:
14011401
yield transition
14021402

14031403
async def _stream() -> AsyncGenerator[events_.AgentEvent]:
1404-
run_data = telemetry.RunSpanData(
1405-
agent=type(self).__name__,
1406-
model=model.id,
1407-
messages=list(messages),
1408-
)
14091404
# Activate middleware for this run (and everything it calls).
14101405
# When middleware is None (default), inherit the parent's
14111406
# middleware from the context var — this lets nested agents
14121407
# share middleware. When middleware is explicitly provided,
14131408
# *extend* the parent stack so that outer cross-cutting
14141409
# concerns (tracing, durability) are preserved. Pass
14151410
# ``_middleware=[]`` to clear the stack entirely.
1416-
async with telemetry.span(run_data):
1411+
async with telemetry.span(
1412+
telemetry.RunSpanData(
1413+
agent=type(self).__name__,
1414+
model=model.id,
1415+
messages=list(messages),
1416+
)
1417+
):
14171418
mw_token: middleware_.Token | None = None
14181419
if _middleware is not None:
14191420
parent = middleware_.get()

src/ai/agents/hooks.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,10 @@ async def _hook_impl(call: middleware_.HookContext) -> pydantic.BaseModel:
131131
# Pre-registered resolution (serverless re-entry).
132132
pre_registered = _pending_resolutions.pop(label, None)
133133
if pre_registered is not None:
134-
async with telemetry.span(data, replay=True):
134+
async with telemetry.span(data, replay=True) as sp:
135135
if isinstance(pre_registered, BaseException):
136136
raise pre_registered
137-
data.status = "resolved"
137+
sp.data.status = "resolved"
138138
return payload(**pre_registered)
139139

140140
# No resolution available — suspend. The span covers the whole
@@ -161,7 +161,7 @@ async def _hook_impl(call: middleware_.HookContext) -> pydantic.BaseModel:
161161
try:
162162
resolution = await future
163163
except asyncio.CancelledError as exc:
164-
data.status = "cancelled"
164+
sp.data.status = "cancelled"
165165
# ``cancel_hook(reason=...)`` rides on the CancelledError.
166166
attrs: dict[str, Any] = {}
167167
if exc.args and exc.args[0] is not None:
@@ -171,7 +171,7 @@ async def _hook_impl(call: middleware_.HookContext) -> pydantic.BaseModel:
171171

172172
# Clean up live registry.
173173
_live_hooks.pop(label, None)
174-
data.status = "resolved"
174+
sp.data.status = "resolved"
175175
await sp.add_event(telemetry.HOOK_RESOLVED)
176176

177177
# Emit resolved signal.

src/ai/models/core/api.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def __init__(
137137
# The telemetry span bracketing this stream, attached by
138138
# ``stream()``. None for directly constructed streams
139139
# (``Stream(gen)``, ``Stream.replay_message``).
140-
self._span: telemetry.Span | None = None
140+
self._span: telemetry.Span[telemetry.AiStreamSpanData] | None = None
141141
self._first_output_seen = False
142142

143143
@classmethod
@@ -244,7 +244,7 @@ async def __anext__(self: Self) -> types.events.Event:
244244
return event.model_copy(update={"message": self._message, **updates})
245245

246246
@property
247-
def span(self) -> telemetry.Span | None:
247+
def span(self) -> telemetry.Span[telemetry.AiStreamSpanData] | None:
248248
"""The telemetry span bracketing this stream.
249249
250250
Set when the stream came from :func:`stream` (live and replay
@@ -608,8 +608,8 @@ async def _stream(
608608
yield s
609609
finally:
610610
# Record whatever got built, even a partial message.
611-
data.message = s.message
612-
data.usage = s.usage
611+
sp.data.message = s.message
612+
sp.data.usage = s.usage
613613
await s.aclose()
614614

615615

@@ -623,12 +623,13 @@ async def generate(
623623
"""Generate a non-streaming response (images, video, etc.)."""
624624
messages = integrity.prepare_messages(messages)
625625
request = GenerateRequest(model, messages, params)
626-
data = telemetry.AiGenerateSpanData(
627-
model=model.id, messages=messages, params=params
628-
)
629-
async with telemetry.span(data):
626+
async with telemetry.span(
627+
telemetry.AiGenerateSpanData(
628+
model=model.id, messages=messages, params=params
629+
)
630+
) as sp:
630631
message = await executor._do_generate(request)
631-
data.message = message
632+
sp.data.message = message
632633
return message
633634

634635

src/ai/telemetry/span.py

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,19 @@ async def on_span_event(self, span, event): ...
4040
import inspect
4141
import logging
4242
import time
43-
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Protocol
43+
from typing import (
44+
TYPE_CHECKING,
45+
Any,
46+
ClassVar,
47+
Generic,
48+
Literal,
49+
Protocol,
50+
overload,
51+
)
52+
53+
# ``typing.TypeVar`` lacks the ``default=`` kwarg on Python <3.13.
54+
# Use the typing_extensions backport so this works on 3.12 too.
55+
from typing_extensions import TypeVar
4456

4557
from ..types import messages as messages_
4658

@@ -81,6 +93,15 @@ class RetrievalSpanData:
8193
span_name: ClassVar[str]
8294

8395

96+
# Covariant: adapters take ``Span`` (= ``Span[SpanData]``) and must
97+
# accept any concretely-typed span. They read ``data``, never replace
98+
# it, so the widened reference is safe in practice.
99+
DataT_co = TypeVar("DataT_co", bound=SpanData, default=SpanData, covariant=True)
100+
# Function-scoped variant for ``span()``: a covariant variable can't
101+
# appear as a parameter.
102+
DataT = TypeVar("DataT", bound=SpanData)
103+
104+
84105
@dataclasses.dataclass
85106
class RunSpanData:
86107
"""One ``Agent.run``: the whole loop."""
@@ -181,9 +202,13 @@ class SpanEvent:
181202

182203

183204
@dataclasses.dataclass
184-
class Span:
205+
class Span(Generic[DataT_co]):
185206
"""A record of a unit of work.
186207
208+
Generic in its data type: ``span(RetrievalSpanData(...))`` gives a
209+
``Span[RetrievalSpanData]``, so late assignments to ``sp.data``
210+
fields are type checked. A bare ``Span`` is ``Span[SpanData]``.
211+
187212
``replay=True`` marks work that is being replayed (resume,
188213
serverless re-entry) rather than performed live.
189214
@@ -197,7 +222,7 @@ class Span:
197222
"""
198223

199224
name: str
200-
data: SpanData
225+
data: DataT_co
201226
id: str
202227
trace_id: str
203228
parent_id: str | None
@@ -285,25 +310,65 @@ async def _dispatch(method: str, span_: Span, *args: Any) -> None:
285310
)
286311

287312

288-
@contextlib.asynccontextmanager
289-
async def span(
313+
@overload
314+
def span(
315+
name_or_data: str,
316+
/,
317+
*,
318+
replay: bool = False,
319+
set_as_current: bool = True,
320+
**attributes: Any,
321+
) -> contextlib.AbstractAsyncContextManager[Span[CustomSpanData]]: ...
322+
323+
324+
@overload
325+
def span(
326+
name_or_data: DataT,
327+
/,
328+
*,
329+
replay: bool = False,
330+
set_as_current: bool = True,
331+
) -> contextlib.AbstractAsyncContextManager[Span[DataT]]: ...
332+
333+
334+
def span(
290335
name_or_data: str | SpanData,
291336
/,
292337
*,
293338
replay: bool = False,
294339
set_as_current: bool = True,
295340
**attributes: Any,
296-
) -> AsyncIterator[Span]:
341+
) -> contextlib.AbstractAsyncContextManager[Span[Any]]:
297342
"""Open a span; it is "current" (parents new spans) inside the block.
298343
299344
Pass a name plus attributes for a user span, or a :class:`SpanData`
300-
instance for a typed one. Exceptions are recorded on the span and
301-
re-raised.
345+
instance for a typed one — the span is generic in it, so late
346+
assignments to ``sp.data`` fields are type checked. Exceptions are
347+
recorded on the span and re-raised.
302348
303349
``set_as_current=False`` keeps the span from becoming current:
304350
work done while it is open parents to *its* parent instead. Used by
305351
ai.stream because of the context manager api.
306352
"""
353+
# The indirection exists because type checkers can't apply
354+
# ``asynccontextmanager`` to an overloaded function directly.
355+
return _span_impl(
356+
name_or_data,
357+
replay=replay,
358+
set_as_current=set_as_current,
359+
**attributes,
360+
)
361+
362+
363+
@contextlib.asynccontextmanager
364+
async def _span_impl(
365+
name_or_data: str | SpanData,
366+
/,
367+
*,
368+
replay: bool,
369+
set_as_current: bool,
370+
**attributes: Any,
371+
) -> AsyncIterator[Span[Any]]:
307372
if isinstance(name_or_data, str):
308373
name = name_or_data
309374
data: SpanData = CustomSpanData(attributes=dict(attributes))

tests/telemetry/test_telemetry.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,13 @@ async def test_set_rejects_framework_spans() -> None:
6868

6969
async def test_data_with_attributes_rejected() -> None:
7070
with pytest.raises(TypeError):
71-
async with ai.telemetry.span(ai.telemetry.LoopTurnSpanData(), a=1):
71+
# The overloads reject this statically too; the runtime check
72+
# covers untyped callers.
73+
data = ai.telemetry.LoopTurnSpanData()
74+
async with ai.telemetry.span(
75+
data, # ty: ignore[invalid-argument-type]
76+
a=1, # type: ignore[call-overload]
77+
):
7278
pass
7379

7480

0 commit comments

Comments
 (0)