Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions examples/agents/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
@ai.tool
async def get_weather(city: str) -> str:
"""Get current weather for a city."""
async with ai.experimental_telemetry.span("lookup", city=city) as span:
async with ai.experimental_telemetry.span("lookup") as span:
span.set_attrs(city=city)
await asyncio.sleep(0.1)
span.set_attributes(source="cache")
span.set_attrs(source="cache")
return f"Sunny, 72F in {city}"


Expand Down
12 changes: 6 additions & 6 deletions src/ai/experimental_telemetry/otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ def _attributes(sp: telemetry.Span, *, capture_content: bool) -> dict[str, Any]:
attrs["ai.hook.type"] = d.hook_type
attrs["ai.hook.status"] = d.status
case telemetry.CustomSpanData() as d:
for key, value in d.attributes.items():
for key, value in d.attrs.items():
# otel only allows scalar attribute values or lists of them
attrs[key] = (
value
Expand Down Expand Up @@ -488,14 +488,14 @@ def span_name(self, span_: telemetry.Span, /) -> str:
"""Return the exported otel span name. Override to customize."""
return _semconv_name(span_)

def span_attributes(self, span_: telemetry.Span, /) -> dict[str, Any]:
def span_attrs(self, span_: telemetry.Span, /) -> dict[str, Any]:
"""Return the attributes set at span end. Override to enrich.

::

class MyAdapter(otel.OtelAdapter):
def span_attributes(self, span_):
return super().span_attributes(span_) | {"k": "v"}
def span_attrs(self, span_):
return super().span_attrs(span_) | {"k": "v"}
"""
return _attributes(span_, capture_content=self._is_capturing_content)

Expand Down Expand Up @@ -595,13 +595,13 @@ async def wrap_span(
k: v # squash everything into scalars
if isinstance(v, str | bool | int | float)
else repr(v)
for k, v in ev.attributes.items()
for k, v in ev.attrs.items()
},
timestamp=ev.time_ns,
)
finally:
self._live.pop(span_.id, None)
for key, value in self.span_attributes(span_).items():
for key, value in self.span_attrs(span_).items():
otel_span.set_attribute(key, value)
if span_.error is not None:
otel_span.set_attribute("error.type", span_.error.type)
Expand Down
86 changes: 31 additions & 55 deletions src/ai/experimental_telemetry/span.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
:class:`Span` is a record of work. It carries ids, timestamps, parent id, and
work-specific data::

async with ai.experimental_telemetry.span("retrieval", query=q) as sp:
async with ai.experimental_telemetry.span("retrieval") as sp:
sp.set_attrs(query=q)
docs = await search(q)
sp.set_attributes(count=len(docs))
sp.set_attrs(count=len(docs))

Nesting is automatic: the current span is tracked using a context var.

Expand All @@ -22,7 +23,8 @@
The default sink is the adapter registry, meaning on push all registered
adapters will get the updated version of the span::

sp = create_span("turn", session=sid) # identity only, reports nothing
sp = create_span("turn") # identity only, reports nothing
sp.set_attrs(session=sid)
sp.stamp_start() # writes started_at
await sp.push() # visible to sinks/adapters
... # possibly elsewhere, later:
Expand Down Expand Up @@ -299,10 +301,13 @@ class HookSpanData(pydantic.BaseModel):


class CustomSpanData(pydantic.BaseModel):
"""A user span made with ``span("name", key=value, ...)``."""
"""A user span made with ``span("name")``.

Attributes are set with :meth:`Span.set_attrs`.
"""

kind: Literal["custom"] = "custom"
attributes: dict[str, Any]
attrs: dict[str, Any]


# names for span events shared between producers,
Expand All @@ -329,7 +334,7 @@ class SpanEvent(pydantic.BaseModel):

name: str
time_ns: int
attributes: dict[str, Any]
attrs: dict[str, Any]


class SpanError(pydantic.BaseModel):
Expand Down Expand Up @@ -418,30 +423,30 @@ class Span(pydantic.BaseModel, Generic[DataT_co]):
set_as_current: bool = True
events: list[SpanEvent] = pydantic.Field(default_factory=list)

schema_version: ClassVar[int] = 3
schema_version: ClassVar[int] = 4

def set_attributes(
self, attributes: Mapping[str, Any] | None = None, /, **kwargs: Any
def set_attrs(
self, attrs: Mapping[str, Any] | None = None, /, **kwargs: Any
) -> None:
"""Attach attributes to a span created with ``span("name", ...)``.
"""Attach attributes to a span created with ``span("name")``.

Attribute names that aren't valid Python keywords (viewers use
dotted names like ``"output.value"``) go in the positional
mapping; it merges with the keyword arguments::

sp.set_attributes({"output.value": title}, model="haiku")
sp.set_attrs({"output.value": title}, model="haiku")
"""
if not isinstance(self.data, CustomSpanData):
raise TypeError(
"set_attributes() only works on user spans; framework "
"set_attrs() only works on user spans; framework "
"spans carry typed data, assign its fields directly"
)
self.data.attributes.update({**(attributes or {}), **kwargs})
self.data.attrs.update({**(attrs or {}), **kwargs})

def add_event(
self,
name: str,
attributes: Mapping[str, Any] | None = None,
attrs: Mapping[str, Any] | None = None,
/,
**kwargs: Any,
) -> SpanEvent:
Expand All @@ -459,7 +464,7 @@ def add_event(
name=name,
# a noop span (telemetry off at creation) reads no clock
time_ns=now_ns() if self.id else 0,
attributes={**(attributes or {}), **kwargs},
attrs={**(attrs or {}), **kwargs},
)
self.events.append(event)
return event
Expand Down Expand Up @@ -735,13 +740,11 @@ async def _dispatch(
@overload
def create_span(
name_or_data: str,
attributes: Mapping[str, Any] | None = None,
/,
*,
parent: Span | None = None,
replay: bool = False,
set_as_current: bool = True,
**kwargs: Any,
) -> Span[CustomSpanData]: ...


Expand All @@ -758,13 +761,11 @@ def create_span(

def create_span(
name_or_data: str | SpanData,
attributes: Mapping[str, Any] | None = None,
/,
*,
parent: Span | None = None,
replay: bool = False,
set_as_current: bool = True,
**kwargs: Any,
) -> Span[Any]:
"""Create a span: identity only, nothing is reported.

Expand All @@ -781,12 +782,8 @@ def create_span(
"""
if isinstance(name_or_data, str):
name = name_or_data
data: SpanData = CustomSpanData(
attributes={**(attributes or {}), **kwargs}
)
data: SpanData = CustomSpanData(attrs={})
else:
if attributes is not None or kwargs:
raise TypeError("attributes only go with a str span name")
name = name_or_data.kind
data = name_or_data
if not is_enabled():
Expand Down Expand Up @@ -819,13 +816,11 @@ def create_span(
@overload
def span(
name_or_data: str,
attributes: Mapping[str, Any] | None = None,
/,
*,
parent: Span | None = None,
replay: bool = False,
set_as_current: bool = True,
**kwargs: Any,
) -> contextlib.AbstractAsyncContextManager[Span[CustomSpanData]]: ...


Expand All @@ -842,24 +837,22 @@ def span(

def span(
name_or_data: str | SpanData,
attributes: Mapping[str, Any] | None = None,
/,
*,
parent: Span | None = None,
replay: bool = False,
set_as_current: bool = True,
**kwargs: Any,
) -> contextlib.AbstractAsyncContextManager[Span[Any]]:
"""Open a span; it sets itself as current inside the block.

Sugar over the data api: creates the span, stamps ``started_at``
and pushes on enter; stamps ``ended_at`` (and ``error``, if the
block raised) and pushes on exit.

Pass a name plus attributes for a user span (a mapping for dotted
attribute names, keywords for the rest), or a :class:`SpanData`
instance for a typed one. Exceptions are recorded on the span
and re-raised.
Pass a name for a user span (set attributes on it with
:meth:`Span.set_attrs`), or a :class:`SpanData` instance
for a typed one. Exceptions are recorded on the span and
re-raised.

``parent`` overrides the ambient parent for this span: a live
:class:`Span`, or one restored from another process to continue its
Expand All @@ -873,44 +866,27 @@ def span(
# to an overloaded function directly.
return _span_impl(
name_or_data,
attributes,
parent=parent,
replay=replay,
set_as_current=set_as_current,
**kwargs,
)


@contextlib.asynccontextmanager
async def _span_impl(
name_or_data: str | SpanData,
attributes: Mapping[str, Any] | None,
/,
*,
parent: Span | None,
replay: bool,
set_as_current: bool,
**kwargs: Any,
) -> AsyncIterator[Span[Any]]:
sp: Span[Any]
if isinstance(name_or_data, str):
sp = create_span(
name_or_data,
attributes,
parent=parent,
replay=replay,
set_as_current=set_as_current,
**kwargs,
)
else:
sp = create_span(
name_or_data,
parent=parent,
replay=replay,
set_as_current=set_as_current,
)
if attributes is not None or kwargs:
raise TypeError("attributes only go with a str span name")
sp: Span[Any] = create_span(
name_or_data,
parent=parent,
replay=replay,
set_as_current=set_as_current,
)
if not sp.id:
# noop: no timestamps, no pushes, never current
yield sp
Expand Down
4 changes: 2 additions & 2 deletions src/ai/experimental_telemetry/utils/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def _label(sp: telemetry.Span) -> str:
case telemetry.RunSpanData() as d:
return f"run {d.agent} ({d.model})"
case telemetry.CustomSpanData() as d:
attrs = ", ".join(f"{k}={v!r}" for k, v in d.attributes.items())
attrs = ", ".join(f"{k}={v!r}" for k, v in d.attrs.items())
return sp.name + (f" ({_short(attrs)})" if attrs else "")
case _:
return sp.name
Expand Down Expand Up @@ -93,7 +93,7 @@ async def on_span_event(
) -> None:
depth = self._depth.get(span.id, 0) + 1
offset_ms = (event.time_ns - (span.started_at or 0)) / 1e6
attrs = ", ".join(f"{k}={v!r}" for k, v in event.attributes.items())
attrs = ", ".join(f"{k}={v!r}" for k, v in event.attrs.items())
suffix = f" ({_short(attrs)})" if attrs else ""
self._out.write(
f"· {' ' * depth}{event.name} +{offset_ms:.0f}ms{suffix}\n"
Expand Down
3 changes: 2 additions & 1 deletion tests/agents/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ async def test_agent_run_span_tree(recorder: Recorder) -> None:
@ai.tool
async def lookup(x: int) -> str:
"""Tool that opens a user span."""
async with ai.experimental_telemetry.span("user_work", x=x):
async with ai.experimental_telemetry.span("user_work") as sp:
sp.set_attrs(x=x)
return "ok"

mock_llm(
Expand Down
2 changes: 1 addition & 1 deletion tests/agents/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ async def loop(
deferred, cancelled = hook_span.events
assert deferred.name == ai.experimental_telemetry.HOOK_DEFERRED
assert cancelled.name == ai.experimental_telemetry.HOOK_CANCELLED
assert cancelled.attributes == {"reason": "denied"}
assert cancelled.attrs == {"reason": "denied"}


async def test_pre_registered_hook_is_replay_span(recorder: Recorder) -> None:
Expand Down
15 changes: 8 additions & 7 deletions tests/experimental_telemetry/test_otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ async def test_nesting_names_and_attributes(
otel_env: tuple[InMemorySpanExporter, TracerProvider],
) -> None:
exporter, _ = otel_env
async with ai.experimental_telemetry.span("outer", foo="bar"):
async with ai.experimental_telemetry.span("outer") as sp:
sp.set_attrs(foo="bar")
async with ai.experimental_telemetry.span("inner"):
pass

Expand Down Expand Up @@ -192,14 +193,14 @@ def __repr__(self) -> str:
first = ai.experimental_telemetry.SpanEvent(
name="first_token",
time_ns=ai.experimental_telemetry.now_ns(),
attributes={"event_type": "TextStart"},
attrs={"event_type": "TextStart"},
)
sp.events.append(first)
await sp.push()
second = ai.experimental_telemetry.SpanEvent(
name="custom",
time_ns=ai.experimental_telemetry.now_ns(),
attributes={"obj": marker},
attrs={"obj": marker},
)
sp.events.append(second)
await sp.push()
Expand Down Expand Up @@ -351,16 +352,16 @@ class Enriched(otel.OtelAdapter):
def span_name(self, span_: ai.experimental_telemetry.Span, /) -> str:
return f"seal:{super().span_name(span_)}"

def span_attributes(
def span_attrs(
self, span_: ai.experimental_telemetry.Span, /
) -> dict[str, Any]:
return super().span_attributes(span_) | {"extra": True}
return super().span_attrs(span_) | {"extra": True}

adapter = Enriched(tracer_provider=provider)
ai.experimental_telemetry.register(adapter)
try:
async with ai.experimental_telemetry.span("s", foo="bar"):
pass
async with ai.experimental_telemetry.span("s") as sp:
sp.set_attrs(foo="bar")
finally:
ai.experimental_telemetry.unregister(adapter)

Expand Down
Loading
Loading