Skip to content

Commit e8ea095

Browse files
committed
Implement core telemetry API
1 parent b6f41c2 commit e8ea095

13 files changed

Lines changed: 1992 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ dependencies = [
3838
anthropic = ["anthropic>=0.83.0"]
3939
mcp = ["mcp>=1.18.0"]
4040
openai = ["openai>=2.14.0"]
41+
otel = [
42+
"opentelemetry-sdk>=1.30",
43+
]
4144
vercel = [
4245
"vercel>=0.5.9",
4346
]
@@ -67,6 +70,8 @@ dev = [
6770
"ruff~=0.8.0",
6871
"async-solipsism>=0.9",
6972
"ty~=0.0.37",
73+
"opentelemetry-sdk>=1.43.0",
74+
"opentelemetry-exporter-otlp-proto-http>=1.43.0",
7075
]
7176
examples = [
7277
"fastapi>=0.136.1",

src/ai/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from . import errors, models, providers, util
1+
from . import errors, models, providers, telemetry, util
22
from .agents import (
33
Agent,
44
AgentTool,
@@ -197,6 +197,7 @@
197197
"resolve_hook",
198198
"stream",
199199
"system_message",
200+
"telemetry",
200201
"text_part",
201202
"thinking",
202203
"tool",

src/ai/telemetry/__init__.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Telemetry: spans, adapters, and the ambient current span.
2+
3+
See :mod:`.span` for the full story.
4+
"""
5+
6+
from .span import (
7+
FIRST_TOKEN,
8+
HOOK_CANCELLED,
9+
HOOK_PENDING,
10+
HOOK_RESOLVED,
11+
RESPONSE_COMPLETE,
12+
AiGenerateSpanData,
13+
AiStreamSpanData,
14+
CustomSpanData,
15+
HookSpanData,
16+
LoopTurnSpanData,
17+
RunSpanData,
18+
Span,
19+
SpanData,
20+
SpanEvent,
21+
ToolExecutionSpanData,
22+
WrapSpanAdapter,
23+
current,
24+
register,
25+
span,
26+
unregister,
27+
wrap_span,
28+
)
29+
30+
__all__ = [
31+
"FIRST_TOKEN",
32+
"HOOK_CANCELLED",
33+
"HOOK_PENDING",
34+
"HOOK_RESOLVED",
35+
"RESPONSE_COMPLETE",
36+
"AiGenerateSpanData",
37+
"AiStreamSpanData",
38+
"CustomSpanData",
39+
"HookSpanData",
40+
"LoopTurnSpanData",
41+
"RunSpanData",
42+
"Span",
43+
"SpanData",
44+
"SpanEvent",
45+
"ToolExecutionSpanData",
46+
"WrapSpanAdapter",
47+
"current",
48+
"register",
49+
"span",
50+
"unregister",
51+
"wrap_span",
52+
]

src/ai/telemetry/console.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Console adapter: prints spans to a terminal as they happen.
2+
3+
Register it like any adapter::
4+
5+
from ai.telemetry import console
6+
ai.telemetry.register(console.ConsoleAdapter())
7+
8+
Prints one ``▸`` line when a span starts (so long runs are visible
9+
live) and, when a trace's root span ends, the whole tree with
10+
durations.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import sys
16+
from typing import TYPE_CHECKING
17+
18+
from .. import telemetry
19+
20+
if TYPE_CHECKING:
21+
from typing import TextIO
22+
23+
24+
def _short(text: str, limit: int = 60) -> str:
25+
return text if len(text) <= limit else text[: limit - 1] + "…"
26+
27+
28+
def _label(sp: telemetry.Span) -> str:
29+
match sp.data:
30+
case telemetry.AiStreamSpanData() as d:
31+
tokens = (
32+
f" in:{d.usage.input_tokens} out:{d.usage.output_tokens} tok"
33+
if d.usage is not None
34+
else ""
35+
)
36+
return f"chat {d.model}{tokens}"
37+
case telemetry.AiGenerateSpanData() as d:
38+
return f"generate {d.model}"
39+
case telemetry.ToolExecutionSpanData() as d:
40+
args = ", ".join(f"{k}={v!r}" for k, v in (d.args or {}).items())
41+
return f"tool {d.tool_name}({_short(args)})"
42+
case telemetry.HookSpanData() as d:
43+
return f"hook {d.label} {d.hook_type} [{d.status}]"
44+
case telemetry.RunSpanData() as d:
45+
return f"run {d.agent} ({d.model})"
46+
case telemetry.LoopTurnSpanData() as d:
47+
return f"turn {d.index}"
48+
case telemetry.CustomSpanData() as d:
49+
attrs = ", ".join(f"{k}={v!r}" for k, v in d.attributes.items())
50+
return sp.name + (f" ({_short(attrs)})" if attrs else "")
51+
case _:
52+
return sp.name
53+
54+
55+
def _line(sp: telemetry.Span) -> str:
56+
end = sp.ended_at or sp.started_at
57+
# A span's lifetime can extend past the response (tool dispatch
58+
# while the stream is open); when the milestone is there, report
59+
# the true response latency instead.
60+
for ev in sp.span_events:
61+
if ev.name == telemetry.RESPONSE_COMPLETE:
62+
end = ev.time_ns
63+
break
64+
duration = (end - sp.started_at) / 1e9
65+
replay = "↻ " if sp.replay else ""
66+
error = (
67+
f" ✗ {type(sp.error).__name__}: {_short(str(sp.error))}"
68+
if sp.error is not None
69+
else ""
70+
)
71+
return f"{replay}{_label(sp)} {duration:.2f}s{error}"
72+
73+
74+
class ConsoleAdapter:
75+
"""Print spans to ``out`` (default: stdout)."""
76+
77+
def __init__(self, *, out: TextIO | None = None) -> None:
78+
self._out = out if out is not None else sys.stdout
79+
self._depth: dict[str, int] = {}
80+
self._ended: dict[str, list[telemetry.Span]] = {}
81+
82+
def on_span_start(self, span: telemetry.Span) -> None:
83+
depth = 0
84+
if span.parent_id is not None:
85+
depth = self._depth.get(span.parent_id, -1) + 1
86+
self._depth[span.id] = depth
87+
replay = "↻ " if span.replay else ""
88+
self._out.write(f"▸ {' ' * depth}{replay}{_label(span)}\n")
89+
90+
def on_span_event(
91+
self, span: telemetry.Span, event: telemetry.SpanEvent
92+
) -> None:
93+
depth = self._depth.get(span.id, 0) + 1
94+
offset_ms = (event.time_ns - span.started_at) / 1e6
95+
attrs = ", ".join(f"{k}={v!r}" for k, v in event.attributes.items())
96+
suffix = f" ({_short(attrs)})" if attrs else ""
97+
self._out.write(
98+
f"· {' ' * depth}{event.name} +{offset_ms:.0f}ms{suffix}\n"
99+
)
100+
101+
def on_span_end(self, span: telemetry.Span) -> None:
102+
self._ended.setdefault(span.trace_id, []).append(span)
103+
if span.parent_id is not None:
104+
return
105+
106+
# Root ended: print the tree and forget the trace.
107+
spans = self._ended.pop(span.trace_id)
108+
for s in spans:
109+
self._depth.pop(s.id, None)
110+
children: dict[str | None, list[telemetry.Span]] = {}
111+
for s in spans:
112+
children.setdefault(s.parent_id, []).append(s)
113+
114+
lines = [f"trace {span.trace_id}"]
115+
116+
def render(s: telemetry.Span, prefix: str, kid_prefix: str) -> None:
117+
lines.append(prefix + _line(s))
118+
kids = sorted(children.get(s.id, []), key=lambda c: c.started_at)
119+
for i, kid in enumerate(kids):
120+
last = i == len(kids) - 1
121+
render(
122+
kid,
123+
kid_prefix + ("└─ " if last else "├─ "),
124+
kid_prefix + (" " if last else "│ "),
125+
)
126+
127+
render(span, "", "")
128+
self._out.write("\n".join(lines) + "\n")

src/ai/telemetry/otel.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""OpenTelemetry adapter: forwards spans to an otel tracer.
2+
3+
::
4+
5+
from ai.telemetry import otel
6+
otel.install() # uses the global TracerProvider
7+
8+
Span names and attributes follow the ``gen_ai`` semantic conventions,
9+
so LLM-aware viewers (Phoenix, Braintrust, Langfuse, Datadog, ...)
10+
render them natively.
11+
12+
The adapter also attaches each current-setting otel span to the otel
13+
context in the task that opened it, so raw otel spans a user creates
14+
inside a tool still parent correctly under ours — and our root spans
15+
nest under any raw otel span the caller already has open. Spans
16+
opened with ``set_as_current=False`` are never attached: they don't
17+
parent concurrent work in our tree, so they must not do it in otel's.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import json
23+
from typing import TYPE_CHECKING, Any
24+
25+
from .. import errors, telemetry
26+
27+
try:
28+
from opentelemetry import context as otel_context
29+
from opentelemetry import trace as otel_trace
30+
except ModuleNotFoundError as exc: # pragma: no cover
31+
raise errors.InstallationError(
32+
"could not import `opentelemetry`, which is required for the otel "
33+
'telemetry adapter, you can install it with `pip install "ai[otel]"` '
34+
'or `uv add "ai[otel]"`'
35+
) from exc
36+
37+
if TYPE_CHECKING:
38+
from collections.abc import AsyncGenerator
39+
40+
from ..types import messages as messages_
41+
42+
43+
def _messages_json(messages: list[messages_.Message]) -> str:
44+
return "[" + ",".join(m.model_dump_json() for m in messages) + "]"
45+
46+
47+
def _name(sp: telemetry.Span) -> str:
48+
match sp.data:
49+
case telemetry.AiStreamSpanData() as d:
50+
return f"chat {d.model}"
51+
case telemetry.AiGenerateSpanData() as d:
52+
return f"generate_content {d.model}"
53+
case telemetry.ToolExecutionSpanData() as d:
54+
return f"execute_tool {d.tool_name}"
55+
case telemetry.RunSpanData() as d:
56+
return f"invoke_agent {d.agent}"
57+
case telemetry.LoopTurnSpanData() as d:
58+
return f"loop_turn {d.index}"
59+
case _:
60+
return sp.name
61+
62+
63+
def _attributes(sp: telemetry.Span) -> dict[str, Any]:
64+
attrs: dict[str, Any] = {}
65+
if sp.replay:
66+
attrs["ai.replay"] = True
67+
match sp.data:
68+
case telemetry.AiStreamSpanData() as d:
69+
attrs["gen_ai.operation.name"] = "chat"
70+
attrs["gen_ai.request.model"] = d.model
71+
attrs["gen_ai.input.messages"] = _messages_json(d.messages)
72+
if d.message is not None:
73+
attrs["gen_ai.output.messages"] = _messages_json([d.message])
74+
if d.usage is not None:
75+
attrs["gen_ai.usage.input_tokens"] = d.usage.input_tokens
76+
attrs["gen_ai.usage.output_tokens"] = d.usage.output_tokens
77+
case telemetry.AiGenerateSpanData() as d:
78+
attrs["gen_ai.operation.name"] = "generate_content"
79+
attrs["gen_ai.request.model"] = d.model
80+
if d.message is not None:
81+
attrs["gen_ai.output.messages"] = _messages_json([d.message])
82+
case telemetry.ToolExecutionSpanData() as d:
83+
attrs["gen_ai.operation.name"] = "execute_tool"
84+
attrs["gen_ai.tool.name"] = d.tool_name
85+
attrs["gen_ai.tool.call.id"] = d.tool_call_id
86+
if d.args is not None:
87+
attrs["gen_ai.tool.call.arguments"] = json.dumps(
88+
d.args, default=str
89+
)
90+
if d.result is not None:
91+
attrs["gen_ai.tool.call.result"] = json.dumps(
92+
d.result, default=str
93+
)
94+
if d.is_error:
95+
attrs["ai.tool.is_error"] = True
96+
case telemetry.RunSpanData() as d:
97+
attrs["gen_ai.operation.name"] = "invoke_agent"
98+
attrs["gen_ai.agent.name"] = d.agent
99+
attrs["gen_ai.request.model"] = d.model
100+
case telemetry.HookSpanData() as d:
101+
attrs["ai.hook.label"] = d.label
102+
attrs["ai.hook.type"] = d.hook_type
103+
attrs["ai.hook.status"] = d.status
104+
case telemetry.LoopTurnSpanData() as d:
105+
attrs["ai.loop_turn.index"] = d.index
106+
case telemetry.CustomSpanData() as d:
107+
for key, value in d.attributes.items():
108+
attrs[key] = (
109+
value
110+
if isinstance(value, str | bool | int | float)
111+
else repr(value)
112+
)
113+
return attrs
114+
115+
116+
def install(
117+
*, tracer_provider: otel_trace.TracerProvider | None = None
118+
) -> telemetry.WrapSpanAdapter:
119+
"""Create the otel adapter, register it, and return it.
120+
121+
Uses the global tracer provider unless one is passed.
122+
"""
123+
tracer = otel_trace.get_tracer("ai", tracer_provider=tracer_provider)
124+
125+
@telemetry.wrap_span
126+
async def otel_spans(span: telemetry.Span) -> AsyncGenerator[None, Any]:
127+
# Parenting is ambient: the current otel context holds the
128+
# parent (ours attach below; a raw otel span the caller has
129+
# open works the same way), and it mirrors the framework's
130+
# parenting because only current-setting spans attach.
131+
otel_span = tracer.start_span(_name(span), start_time=span.started_at)
132+
# Mirror the framework's currentness: a set_as_current=False
133+
# span doesn't parent concurrent work in our tree, so it must
134+
# not become current in the otel context either.
135+
token = (
136+
otel_context.attach(otel_trace.set_span_in_context(otel_span))
137+
if span.set_as_current
138+
else None
139+
)
140+
try:
141+
# Milestones resume the yield live and are forwarded right
142+
# away, so backends that render in-progress spans show them
143+
# as they happen; span end resumes with None.
144+
while (ev := (yield)) is not None:
145+
otel_span.add_event(
146+
ev.name,
147+
{
148+
k: v
149+
if isinstance(v, str | bool | int | float)
150+
else repr(v)
151+
for k, v in ev.attributes.items()
152+
},
153+
timestamp=ev.time_ns,
154+
)
155+
finally:
156+
if token is not None:
157+
otel_context.detach(token)
158+
for key, value in _attributes(span).items():
159+
otel_span.set_attribute(key, value)
160+
if span.error is not None:
161+
if isinstance(span.error, Exception):
162+
otel_span.record_exception(span.error)
163+
otel_span.set_status(
164+
otel_trace.StatusCode.ERROR, str(span.error)
165+
)
166+
otel_span.end(end_time=span.ended_at)
167+
168+
telemetry.register(otel_spans)
169+
return otel_spans

0 commit comments

Comments
 (0)