agent-observability-trace-cli 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
- agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
- agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
- agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
- agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
- agent_trace/__init__.py +1182 -0
- agent_trace/_cli.py +1377 -0
- agent_trace/_inspect.py +2020 -0
- agent_trace/_replay/__init__.py +1 -0
- agent_trace/_replay/engine.py +268 -0
- agent_trace/_replay/fixture.py +868 -0
- agent_trace/core/__init__.py +1 -0
- agent_trace/core/clock.py +91 -0
- agent_trace/core/exceptions.py +51 -0
- agent_trace/core/span.py +271 -0
- agent_trace/core/trace.py +92 -0
- agent_trace/exporters/__init__.py +1 -0
- agent_trace/exporters/file.py +80 -0
- agent_trace/exporters/otlp.py +199 -0
- agent_trace/exporters/remote_fixture.py +307 -0
- agent_trace/exporters/stdout.py +258 -0
- agent_trace/integrations/__init__.py +69 -0
- agent_trace/integrations/agno.py +489 -0
- agent_trace/integrations/autogen.py +581 -0
- agent_trace/integrations/crewai.py +486 -0
- agent_trace/integrations/google_genai.py +731 -0
- agent_trace/integrations/haystack.py +254 -0
- agent_trace/integrations/langchain_core.py +361 -0
- agent_trace/integrations/langgraph.py +2093 -0
- agent_trace/integrations/langgraph_checkpoint.py +763 -0
- agent_trace/integrations/langgraph_state_diff.py +345 -0
- agent_trace/integrations/langgraph_stream_debug.py +202 -0
- agent_trace/integrations/llama_index.py +472 -0
- agent_trace/integrations/mcp.py +281 -0
- agent_trace/integrations/openai_agents.py +811 -0
- agent_trace/integrations/pydantic_ai.py +504 -0
- agent_trace/integrations/streaming.py +218 -0
- agent_trace/interceptor/__init__.py +64 -0
- agent_trace/interceptor/aiohttp_hook.py +152 -0
- agent_trace/interceptor/botocore_hook.py +223 -0
- agent_trace/interceptor/grpc_hook.py +651 -0
- agent_trace/interceptor/httpx_hook.py +866 -0
- agent_trace/interceptor/logging_hook.py +137 -0
- agent_trace/interceptor/requests_patch.py +184 -0
- agent_trace/interceptor/sse.py +176 -0
- agent_trace/interceptor/stdio_hook.py +245 -0
- agent_trace/interceptor/warnings_hook.py +132 -0
- agent_trace/interceptor/websocket_hook.py +323 -0
- agent_trace/plugins/__init__.py +35 -0
- agent_trace/plugins/base.py +111 -0
- agent_trace/py.typed +0 -0
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAI Agents SDK integration.
|
|
3
|
+
|
|
4
|
+
Wraps openai-agents Runner to emit spans for each agent turn, tool call,
|
|
5
|
+
and handoff.
|
|
6
|
+
|
|
7
|
+
Usage (hook-based — recommended)::
|
|
8
|
+
|
|
9
|
+
from agents import Agent, Runner
|
|
10
|
+
from agent_trace import Tracer
|
|
11
|
+
from agent_trace.integrations.openai_agents import AgentTraceHook
|
|
12
|
+
|
|
13
|
+
t = Tracer()
|
|
14
|
+
with t.start_trace("my_agent_run", record=True) as trace:
|
|
15
|
+
hook = AgentTraceHook(tracer=t, trace=trace)
|
|
16
|
+
result = await Runner.run(agent, "hello", hooks=hook)
|
|
17
|
+
|
|
18
|
+
Usage (convenience wrapper)::
|
|
19
|
+
|
|
20
|
+
from agent_trace.integrations.openai_agents import instrument_runner
|
|
21
|
+
|
|
22
|
+
result = await instrument_runner(agent, "hello", tracer=t, trace=trace)
|
|
23
|
+
|
|
24
|
+
Usage (streamed convenience wrapper)::
|
|
25
|
+
|
|
26
|
+
from agent_trace.integrations.openai_agents import instrument_runner_streamed
|
|
27
|
+
|
|
28
|
+
streamed = await instrument_runner_streamed(agent, "hello", tracer=t, trace=trace)
|
|
29
|
+
async for event in streamed.stream_events():
|
|
30
|
+
...
|
|
31
|
+
print(streamed.final_output)
|
|
32
|
+
|
|
33
|
+
Note on the openai-agents SDK's hook interface (as of 0.17+/0.18.x): the
|
|
34
|
+
``RunHooksBase`` class the SDK actually calls only exposes seven ``async def``
|
|
35
|
+
methods — ``on_agent_start``, ``on_agent_end``, ``on_handoff``,
|
|
36
|
+
``on_llm_start``, ``on_llm_end``, ``on_tool_start``, ``on_tool_end``. There is
|
|
37
|
+
no ``on_agent_error``/``on_tool_error`` hook anywhere in the SDK; most
|
|
38
|
+
in-process errors (e.g. a failed tool call) are caught internally by the SDK
|
|
39
|
+
and turned into a normal-looking tool result, never reaching any hook at all.
|
|
40
|
+
Exception-to-span attribution is therefore handled by
|
|
41
|
+
``instrument_runner``/``instrument_runner_streamed`` wrapping the ``Runner``
|
|
42
|
+
call itself in a try/except, not by a hook method — see
|
|
43
|
+
``AgentTraceHook._close_open_spans_with_exception``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import inspect
|
|
49
|
+
import logging
|
|
50
|
+
import re
|
|
51
|
+
import threading
|
|
52
|
+
from collections.abc import AsyncIterator
|
|
53
|
+
from typing import TYPE_CHECKING, Any
|
|
54
|
+
|
|
55
|
+
from agent_trace._inspect import _edit_distance
|
|
56
|
+
from agent_trace.core.span import Span, SpanStatus
|
|
57
|
+
|
|
58
|
+
if TYPE_CHECKING:
|
|
59
|
+
from agent_trace import Trace, Tracer
|
|
60
|
+
|
|
61
|
+
# ``agents`` (openai-agents) is an optional dependency — see the
|
|
62
|
+
# ``openai-agents`` extra in pyproject.toml. Resolve ``RunHooksBase`` at
|
|
63
|
+
# import time so ``AgentTraceHook`` genuinely satisfies the SDK's hook
|
|
64
|
+
# interface (``Runner.run(..., hooks=hook)`` calls ``validate_run_hooks()``,
|
|
65
|
+
# which rejects anything that isn't a ``RunHooksBase`` instance) when the
|
|
66
|
+
# package is installed, while still importing cleanly when it isn't.
|
|
67
|
+
try:
|
|
68
|
+
from agents.lifecycle import RunHooksBase as _RunHooksBase
|
|
69
|
+
except ImportError: # pragma: no cover - exercised by unit tests without the extra
|
|
70
|
+
_RunHooksBase = object # type: ignore[assignment,misc]
|
|
71
|
+
|
|
72
|
+
if TYPE_CHECKING:
|
|
73
|
+
from agents.realtime import RealtimeSession, RealtimeSessionEvent
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"AgentTraceHook",
|
|
77
|
+
"AgentTraceRealtimeHook",
|
|
78
|
+
"instrument_runner",
|
|
79
|
+
"instrument_runner_streamed",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
logger = logging.getLogger(__name__)
|
|
83
|
+
|
|
84
|
+
_INSTALL_HINT = (
|
|
85
|
+
"The OpenAI Agents integration requires the openai-agents package.\n"
|
|
86
|
+
"Install it with:\n\n"
|
|
87
|
+
" pip install openai-agents\n"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Cap on how much of a tool result / stream-event payload gets persisted onto
|
|
91
|
+
# a span attribute. Attribute values must stay small, primitive strings —
|
|
92
|
+
# this is not a place to dump multi-megabyte tool outputs.
|
|
93
|
+
_MAX_ATTR_LEN = 4000
|
|
94
|
+
|
|
95
|
+
# `ModelResponse.output` item `.type` values that represent the model asking
|
|
96
|
+
# to invoke a tool (as opposed to a plain message/reasoning item). Sourced
|
|
97
|
+
# from the openai-agents SDK's `openai.types.responses` output-item union.
|
|
98
|
+
_TOOL_CALL_OUTPUT_TYPES = frozenset(
|
|
99
|
+
{
|
|
100
|
+
"function_call",
|
|
101
|
+
"custom_tool_call",
|
|
102
|
+
"computer_call",
|
|
103
|
+
"file_search_call",
|
|
104
|
+
"web_search_call",
|
|
105
|
+
"code_interpreter_call",
|
|
106
|
+
"local_shell_call",
|
|
107
|
+
"shell_call",
|
|
108
|
+
"apply_patch_call",
|
|
109
|
+
"mcp_call",
|
|
110
|
+
"image_generation_call",
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _require_openai_agents() -> Any:
|
|
116
|
+
"""Lazy import guard — raises a clear error if openai-agents is absent."""
|
|
117
|
+
# Try the canonical package name first, then the legacy alias.
|
|
118
|
+
for module_name in ("agents", "openai_agents"):
|
|
119
|
+
try:
|
|
120
|
+
import importlib
|
|
121
|
+
|
|
122
|
+
return importlib.import_module(module_name)
|
|
123
|
+
except ImportError:
|
|
124
|
+
continue
|
|
125
|
+
raise ImportError(_INSTALL_HINT)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _get_runner_cls(sdk: Any) -> Any:
|
|
129
|
+
runner_cls = getattr(sdk, "Runner", None)
|
|
130
|
+
if runner_cls is None:
|
|
131
|
+
import importlib
|
|
132
|
+
|
|
133
|
+
runner_mod = importlib.import_module("agents.run")
|
|
134
|
+
runner_cls = runner_mod.Runner
|
|
135
|
+
return runner_cls
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# Matches the "Tool <name> not found" shape openai-agents' ModelBehaviorError
|
|
139
|
+
# raises when the model hallucinates a tool name (#1671), optionally
|
|
140
|
+
# single/double-quoted, e.g. `Tool 'lookup_wather' not found in agent
|
|
141
|
+
# 'voice-agent'` or `Tool transfer_back_to_supervisor not found`.
|
|
142
|
+
_TOOL_NOT_FOUND_RE = re.compile(r"Tool ['\"]?([\w\-.]+)['\"]? not found")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _truncate(value: Any) -> str:
|
|
146
|
+
text = str(value)
|
|
147
|
+
if len(text) > _MAX_ATTR_LEN:
|
|
148
|
+
return text[:_MAX_ATTR_LEN] + f"...<truncated, {len(text)} chars total>"
|
|
149
|
+
return text
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _extract_reasoning_effort(model_settings: Any) -> str | None:
|
|
153
|
+
"""Pull ``model_settings.reasoning["effort"]`` out, dict- or attr-style."""
|
|
154
|
+
reasoning = getattr(model_settings, "reasoning", None)
|
|
155
|
+
if reasoning is None:
|
|
156
|
+
return None
|
|
157
|
+
if isinstance(reasoning, dict):
|
|
158
|
+
effort = reasoning.get("effort")
|
|
159
|
+
else:
|
|
160
|
+
effort = getattr(reasoning, "effort", None)
|
|
161
|
+
return str(effort) if effort is not None else None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _response_has_tool_calls(response: Any) -> bool:
|
|
165
|
+
"""Whether a ``ModelResponse.output`` contains any tool-call-shaped item."""
|
|
166
|
+
output = getattr(response, "output", None)
|
|
167
|
+
if not output:
|
|
168
|
+
return False
|
|
169
|
+
for item in output:
|
|
170
|
+
if getattr(item, "type", None) in _TOOL_CALL_OUTPUT_TYPES:
|
|
171
|
+
return True
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# AgentTraceHook
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class AgentTraceHook(_RunHooksBase): # type: ignore[misc]
|
|
181
|
+
"""Hook implementation for the openai-agents SDK lifecycle events.
|
|
182
|
+
|
|
183
|
+
Pass an instance of this class to a ``Runner`` (via ``hooks=hook``) to
|
|
184
|
+
receive callbacks on agent start/end, tool calls, LLM calls, and
|
|
185
|
+
handoffs. Subclasses ``agents.lifecycle.RunHooksBase`` so it satisfies
|
|
186
|
+
``Runner.run()``'s ``validate_run_hooks()`` check, and every method is
|
|
187
|
+
``async def`` to match the SDK's own hook interface (the SDK
|
|
188
|
+
``await``s each hook via ``asyncio.gather(...)``).
|
|
189
|
+
|
|
190
|
+
Parameters
|
|
191
|
+
----------
|
|
192
|
+
tracer:
|
|
193
|
+
The active :class:`~agent_trace.Tracer` instance.
|
|
194
|
+
trace:
|
|
195
|
+
The :class:`~agent_trace.Trace` that spans will be registered on.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(self, tracer: Tracer, trace: Trace) -> None:
|
|
199
|
+
self._tracer: Tracer = tracer
|
|
200
|
+
self._trace: Trace = trace
|
|
201
|
+
# Thread-safe span registry keyed by a string context key.
|
|
202
|
+
self._spans: dict[str, Span] = {}
|
|
203
|
+
# id(context) -> (from_agent_name, to_agent_name), set by on_handoff
|
|
204
|
+
# and consumed by on_agent_end to open a duration-based handoff span.
|
|
205
|
+
self._pending_handoffs: dict[int, tuple[str, str]] = {}
|
|
206
|
+
self._lock: threading.Lock = threading.Lock()
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
# Internal helpers
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def _open_span(self, key: str, name: str, parent_key: str | None = None) -> Span:
|
|
213
|
+
parent_span_id: str | None = None
|
|
214
|
+
if parent_key is not None:
|
|
215
|
+
with self._lock:
|
|
216
|
+
parent_span = self._spans.get(parent_key)
|
|
217
|
+
if parent_span is not None:
|
|
218
|
+
parent_span_id = parent_span.span_id
|
|
219
|
+
|
|
220
|
+
span = self._tracer.start_span(name, parent_id=parent_span_id)
|
|
221
|
+
with self._lock:
|
|
222
|
+
self._spans[key] = span
|
|
223
|
+
return span
|
|
224
|
+
|
|
225
|
+
def _close_span(self, key: str, status: SpanStatus = SpanStatus.OK) -> Span | None:
|
|
226
|
+
with self._lock:
|
|
227
|
+
span = self._spans.pop(key, None)
|
|
228
|
+
if span is not None and span.end_time is None:
|
|
229
|
+
span.end(status)
|
|
230
|
+
return span
|
|
231
|
+
|
|
232
|
+
def _close_open_spans_with_exception(self, exc: BaseException) -> None:
|
|
233
|
+
"""Close every still-open span this hook is tracking as an error.
|
|
234
|
+
|
|
235
|
+
The current openai-agents SDK has no ``on_agent_error``/
|
|
236
|
+
``on_tool_error`` hook, so a crashed turn otherwise leaves its
|
|
237
|
+
``agent:``/``tool:``/``llm:`` span open forever with no error status
|
|
238
|
+
and no exception recorded. Call this from the ``except`` branch
|
|
239
|
+
around ``Runner.run(...)``/``Runner.run_streamed(...)`` so the trace
|
|
240
|
+
shows *which* span was in flight when the run failed, not just that
|
|
241
|
+
it silently stopped.
|
|
242
|
+
"""
|
|
243
|
+
with self._lock:
|
|
244
|
+
spans = list(self._spans.values())
|
|
245
|
+
self._spans.clear()
|
|
246
|
+
self._pending_handoffs.clear()
|
|
247
|
+
for span in spans:
|
|
248
|
+
if span.end_time is None:
|
|
249
|
+
span.record_exception(exc)
|
|
250
|
+
span.end(SpanStatus.ERROR)
|
|
251
|
+
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
# openai-agents hook interface (all async — see module docstring)
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
async def on_agent_start(self, context: Any, agent: Any) -> None:
|
|
257
|
+
"""Called when an agent turn begins."""
|
|
258
|
+
agent_name: str = getattr(agent, "name", None) or "agent"
|
|
259
|
+
model: str = getattr(agent, "model", None) or "unknown"
|
|
260
|
+
ctx_id = id(context)
|
|
261
|
+
|
|
262
|
+
# If a handoff transition into this agent is pending, close its
|
|
263
|
+
# duration-based handoff span now that the incoming agent has
|
|
264
|
+
# actually started.
|
|
265
|
+
self._close_span(f"handoff:{ctx_id}:{agent_name}", SpanStatus.OK)
|
|
266
|
+
|
|
267
|
+
key = f"agent:{ctx_id}:{agent_name}"
|
|
268
|
+
span = self._open_span(key, f"agent:{agent_name}")
|
|
269
|
+
span.set_attribute("agent.name", agent_name)
|
|
270
|
+
span.set_attribute("agent.model", model)
|
|
271
|
+
|
|
272
|
+
async def on_agent_end(self, context: Any, agent: Any, output: Any) -> None:
|
|
273
|
+
"""Called when an agent turn completes successfully."""
|
|
274
|
+
agent_name: str = getattr(agent, "name", None) or "agent"
|
|
275
|
+
ctx_id = id(context)
|
|
276
|
+
key = f"agent:{ctx_id}:{agent_name}"
|
|
277
|
+
closed_span = self._close_span(key, SpanStatus.OK)
|
|
278
|
+
|
|
279
|
+
# If on_handoff fired for this agent, open a bounded transition span
|
|
280
|
+
# now, to be closed by the next agent's on_agent_start. This gives
|
|
281
|
+
# the handoff itself an explicit duration_ms instead of only a
|
|
282
|
+
# point-in-time event on the outgoing agent's span.
|
|
283
|
+
with self._lock:
|
|
284
|
+
pending = self._pending_handoffs.pop(ctx_id, None)
|
|
285
|
+
if pending is not None:
|
|
286
|
+
from_name, to_name = pending
|
|
287
|
+
if from_name == agent_name:
|
|
288
|
+
parent_id = closed_span.parent_id if closed_span is not None else None
|
|
289
|
+
handoff_span = self._tracer.start_span(
|
|
290
|
+
f"handoff:{from_name}->{to_name}", parent_id=parent_id
|
|
291
|
+
)
|
|
292
|
+
handoff_span.set_attribute("handoff.from_agent", from_name)
|
|
293
|
+
handoff_span.set_attribute("handoff.to_agent", to_name)
|
|
294
|
+
with self._lock:
|
|
295
|
+
self._spans[f"handoff:{ctx_id}:{to_name}"] = handoff_span
|
|
296
|
+
|
|
297
|
+
async def on_tool_start(self, context: Any, agent: Any, tool: Any) -> None:
|
|
298
|
+
"""Called when a tool invocation begins."""
|
|
299
|
+
tool_name: str = getattr(tool, "name", None) or "tool"
|
|
300
|
+
agent_name: str = getattr(agent, "name", None) or "agent"
|
|
301
|
+
parent_key = f"agent:{id(context)}:{agent_name}"
|
|
302
|
+
key = f"tool:{id(context)}:{tool_name}"
|
|
303
|
+
span = self._open_span(key, f"tool:{tool_name}", parent_key=parent_key)
|
|
304
|
+
span.set_attribute("tool.name", tool_name)
|
|
305
|
+
|
|
306
|
+
async def on_tool_end(
|
|
307
|
+
self, context: Any, agent: Any, tool: Any, result: Any
|
|
308
|
+
) -> None:
|
|
309
|
+
"""Called when a tool invocation completes."""
|
|
310
|
+
tool_name: str = getattr(tool, "name", None) or "tool"
|
|
311
|
+
key = f"tool:{id(context)}:{tool_name}"
|
|
312
|
+
span = self._close_span(key, SpanStatus.OK)
|
|
313
|
+
if span is not None:
|
|
314
|
+
try:
|
|
315
|
+
span.set_attribute(
|
|
316
|
+
"tool.result_length",
|
|
317
|
+
len(str(result)) if result is not None else 0,
|
|
318
|
+
)
|
|
319
|
+
# Persist the actual result content (not just its length) —
|
|
320
|
+
# this is frequently the only diagnostic text available,
|
|
321
|
+
# e.g. when the SDK's own error-handling wraps a tool
|
|
322
|
+
# exception into its "successful" string return value.
|
|
323
|
+
if result is not None:
|
|
324
|
+
span.set_attribute("tool.result", _truncate(result))
|
|
325
|
+
except Exception:
|
|
326
|
+
logger.debug(
|
|
327
|
+
"agent-trace: failed to record tool result for %r",
|
|
328
|
+
tool_name,
|
|
329
|
+
exc_info=True,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
async def on_handoff(self, context: Any, from_agent: Any, to_agent: Any) -> None:
|
|
333
|
+
"""Called when control is handed off from one agent to another."""
|
|
334
|
+
from_name: str = getattr(from_agent, "name", None) or "unknown"
|
|
335
|
+
to_name: str = getattr(to_agent, "name", None) or "unknown"
|
|
336
|
+
ctx_id = id(context)
|
|
337
|
+
parent_key = f"agent:{ctx_id}:{from_name}"
|
|
338
|
+
|
|
339
|
+
with self._lock:
|
|
340
|
+
parent_span = self._spans.get(parent_key)
|
|
341
|
+
|
|
342
|
+
if parent_span is not None:
|
|
343
|
+
parent_span.add_event(
|
|
344
|
+
"handoff",
|
|
345
|
+
attributes={
|
|
346
|
+
"handoff.from_agent": from_name,
|
|
347
|
+
"handoff.to_agent": to_name,
|
|
348
|
+
},
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Record the pending transition so on_agent_end (for from_agent) can
|
|
352
|
+
# open a duration-based handoff span, closed by on_agent_start (for
|
|
353
|
+
# to_agent).
|
|
354
|
+
with self._lock:
|
|
355
|
+
self._pending_handoffs[ctx_id] = (from_name, to_name)
|
|
356
|
+
|
|
357
|
+
async def on_llm_start(
|
|
358
|
+
self, context: Any, agent: Any, system_prompt: Any, input_items: Any
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Called before each LLM request — record model + settings used."""
|
|
361
|
+
agent_name: str = getattr(agent, "name", None) or "agent"
|
|
362
|
+
model: str = getattr(agent, "model", None) or "unknown"
|
|
363
|
+
key = f"llm:{id(context)}:{agent_name}"
|
|
364
|
+
parent_key = f"agent:{id(context)}:{agent_name}"
|
|
365
|
+
span = self._open_span(key, f"llm:{model}", parent_key=parent_key)
|
|
366
|
+
span.set_attribute("llm.model", model)
|
|
367
|
+
|
|
368
|
+
model_settings = getattr(agent, "model_settings", None)
|
|
369
|
+
if model_settings is not None:
|
|
370
|
+
try:
|
|
371
|
+
effort = _extract_reasoning_effort(model_settings)
|
|
372
|
+
if effort is not None:
|
|
373
|
+
span.set_attribute("llm.model_settings.reasoning_effort", effort)
|
|
374
|
+
|
|
375
|
+
verbosity = getattr(model_settings, "verbosity", None)
|
|
376
|
+
if verbosity is not None:
|
|
377
|
+
span.set_attribute("llm.model_settings.verbosity", str(verbosity))
|
|
378
|
+
except Exception:
|
|
379
|
+
logger.debug(
|
|
380
|
+
"agent-trace: failed to record model_settings",
|
|
381
|
+
exc_info=True,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
async def on_llm_end(self, context: Any, agent: Any, response: Any) -> None:
|
|
385
|
+
"""Called after each LLM response — record token usage + tool-call presence."""
|
|
386
|
+
agent_name: str = getattr(agent, "name", None) or "agent"
|
|
387
|
+
key = f"llm:{id(context)}:{agent_name}"
|
|
388
|
+
with self._lock:
|
|
389
|
+
span = self._spans.get(key)
|
|
390
|
+
if span is not None:
|
|
391
|
+
try:
|
|
392
|
+
usage = getattr(response, "usage", None)
|
|
393
|
+
if usage is not None:
|
|
394
|
+
pt = getattr(usage, "input_tokens", None)
|
|
395
|
+
ct = getattr(usage, "output_tokens", None)
|
|
396
|
+
tt = getattr(usage, "total_tokens", None)
|
|
397
|
+
if pt is not None:
|
|
398
|
+
span.set_attribute("llm.usage.prompt_tokens", int(pt))
|
|
399
|
+
if ct is not None:
|
|
400
|
+
span.set_attribute("llm.usage.completion_tokens", int(ct))
|
|
401
|
+
# total_tokens: prefer explicit field, fall back to sum
|
|
402
|
+
if tt is not None:
|
|
403
|
+
span.set_attribute("llm.usage.total_tokens", int(tt))
|
|
404
|
+
elif pt is not None and ct is not None:
|
|
405
|
+
span.set_attribute("llm.usage.total_tokens", int(pt) + int(ct))
|
|
406
|
+
|
|
407
|
+
span.set_attribute(
|
|
408
|
+
"llm.response.has_tool_calls", _response_has_tool_calls(response)
|
|
409
|
+
)
|
|
410
|
+
except Exception:
|
|
411
|
+
logger.debug(
|
|
412
|
+
"agent-trace: failed to record LLM response attributes",
|
|
413
|
+
exc_info=True,
|
|
414
|
+
)
|
|
415
|
+
self._close_span(key, SpanStatus.OK)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
# instrument_runner — high-level async wrapper (Runner.run)
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def instrument_runner(
|
|
424
|
+
agent: Any,
|
|
425
|
+
input: Any,
|
|
426
|
+
*,
|
|
427
|
+
tracer: Tracer,
|
|
428
|
+
trace: Trace,
|
|
429
|
+
**kwargs: Any,
|
|
430
|
+
) -> Any:
|
|
431
|
+
"""Run an openai-agents ``Agent`` through ``Runner.run`` with span instrumentation.
|
|
432
|
+
|
|
433
|
+
This is a convenience wrapper that combines ``AgentTraceHook`` with
|
|
434
|
+
``Runner.run``. Pass an ``Agent`` and the input text; agent-trace handles
|
|
435
|
+
hooking in the span collection automatically. If the run raises, every
|
|
436
|
+
span still open in the hook's registry is closed as an error (with the
|
|
437
|
+
exception attached) before the exception is re-raised — see
|
|
438
|
+
``AgentTraceHook._close_open_spans_with_exception``.
|
|
439
|
+
|
|
440
|
+
Parameters
|
|
441
|
+
----------
|
|
442
|
+
agent:
|
|
443
|
+
An openai-agents ``Agent`` instance.
|
|
444
|
+
input:
|
|
445
|
+
The input string (or message list) to pass to ``Runner.run``.
|
|
446
|
+
tracer:
|
|
447
|
+
The active :class:`~agent_trace.Tracer`.
|
|
448
|
+
trace:
|
|
449
|
+
The current :class:`~agent_trace.Trace`.
|
|
450
|
+
**kwargs:
|
|
451
|
+
Additional keyword arguments forwarded to ``Runner.run``
|
|
452
|
+
(e.g. ``max_turns``, ``context``).
|
|
453
|
+
|
|
454
|
+
Returns
|
|
455
|
+
-------
|
|
456
|
+
Any
|
|
457
|
+
The ``RunResult`` from ``Runner.run``.
|
|
458
|
+
"""
|
|
459
|
+
sdk = _require_openai_agents()
|
|
460
|
+
runner_cls = _get_runner_cls(sdk)
|
|
461
|
+
|
|
462
|
+
hook = AgentTraceHook(tracer=tracer, trace=trace)
|
|
463
|
+
root_span = tracer.start_span("agent_run")
|
|
464
|
+
result: Any = None
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
raw = runner_cls.run(agent, input, hooks=hook, **kwargs)
|
|
468
|
+
if inspect.isawaitable(raw):
|
|
469
|
+
result = await raw
|
|
470
|
+
else:
|
|
471
|
+
result = raw
|
|
472
|
+
|
|
473
|
+
root_span.end(SpanStatus.OK)
|
|
474
|
+
except Exception as exc:
|
|
475
|
+
hook._close_open_spans_with_exception(exc)
|
|
476
|
+
root_span.record_exception(exc)
|
|
477
|
+
if root_span.end_time is None:
|
|
478
|
+
root_span.end(SpanStatus.ERROR)
|
|
479
|
+
raise
|
|
480
|
+
|
|
481
|
+
return result
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
# instrument_runner_streamed — high-level wrapper (Runner.run_streamed)
|
|
486
|
+
# ---------------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class _TracedRunResultStreaming:
|
|
490
|
+
"""Wraps openai-agents' ``RunResultStreaming``, instrumenting ``stream_events()``.
|
|
491
|
+
|
|
492
|
+
Every attribute/method other than ``stream_events`` is proxied straight
|
|
493
|
+
through to the wrapped ``RunResultStreaming`` (``final_output``,
|
|
494
|
+
``last_agent``, ``to_input_list``, ``cancel``, ...) so this is a drop-in
|
|
495
|
+
replacement for the object ``Runner.run_streamed()`` itself returns.
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
def __init__(
|
|
499
|
+
self, result_streaming: Any, root_span: Span, hook: AgentTraceHook
|
|
500
|
+
) -> None:
|
|
501
|
+
self._result_streaming = result_streaming
|
|
502
|
+
self._root_span = root_span
|
|
503
|
+
self._hook = hook
|
|
504
|
+
|
|
505
|
+
def __getattr__(self, name: str) -> Any:
|
|
506
|
+
return getattr(self._result_streaming, name)
|
|
507
|
+
|
|
508
|
+
async def stream_events(self) -> AsyncIterator[Any]:
|
|
509
|
+
"""Yield each event exactly as ``stream_events()`` would, recording it.
|
|
510
|
+
|
|
511
|
+
A ``SpanEvent`` is added to the root span for every event actually
|
|
512
|
+
delivered to this iterator — i.e. this observes the consumer's real
|
|
513
|
+
drain rate, not just whether ``RunHooks`` callbacks fired internally
|
|
514
|
+
(those are two distinct channels inside ``RunResultStreaming``).
|
|
515
|
+
"""
|
|
516
|
+
try:
|
|
517
|
+
async for event in self._result_streaming.stream_events():
|
|
518
|
+
event_type = getattr(event, "type", type(event).__name__)
|
|
519
|
+
self._root_span.add_event(
|
|
520
|
+
"stream_event", attributes={"event.type": str(event_type)}
|
|
521
|
+
)
|
|
522
|
+
yield event
|
|
523
|
+
if self._root_span.end_time is None:
|
|
524
|
+
self._root_span.end(SpanStatus.OK)
|
|
525
|
+
except Exception as exc:
|
|
526
|
+
self._hook._close_open_spans_with_exception(exc)
|
|
527
|
+
self._root_span.record_exception(exc)
|
|
528
|
+
if self._root_span.end_time is None:
|
|
529
|
+
self._root_span.end(SpanStatus.ERROR)
|
|
530
|
+
raise
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
async def instrument_runner_streamed(
|
|
534
|
+
agent: Any,
|
|
535
|
+
input: Any,
|
|
536
|
+
*,
|
|
537
|
+
tracer: Tracer,
|
|
538
|
+
trace: Trace,
|
|
539
|
+
**kwargs: Any,
|
|
540
|
+
) -> _TracedRunResultStreaming:
|
|
541
|
+
"""Run an openai-agents ``Agent`` via ``Runner.run_streamed``, span-instrumented.
|
|
542
|
+
|
|
543
|
+
``instrument_runner`` only ever calls ``Runner.run`` — a reporter calling
|
|
544
|
+
``Runner.run_streamed()``/consuming ``stream_events()`` directly gets no
|
|
545
|
+
exception attribution at all, since ``RunHooks`` firing correctly does
|
|
546
|
+
not imply the corresponding event actually reached the consumer's
|
|
547
|
+
``stream_events()`` loop. This wrapper covers that call shape: it
|
|
548
|
+
returns a :class:`_TracedRunResultStreaming` immediately (matching
|
|
549
|
+
``Runner.run_streamed()``'s own non-blocking contract), whose
|
|
550
|
+
``stream_events()`` is span-instrumented and whose "agent_run_streamed"
|
|
551
|
+
root span stays open until the caller's consumption of the stream
|
|
552
|
+
actually finishes (or raises).
|
|
553
|
+
|
|
554
|
+
Parameters
|
|
555
|
+
----------
|
|
556
|
+
agent:
|
|
557
|
+
An openai-agents ``Agent`` instance.
|
|
558
|
+
input:
|
|
559
|
+
The input string (or message list) to pass to ``Runner.run_streamed``.
|
|
560
|
+
tracer:
|
|
561
|
+
The active :class:`~agent_trace.Tracer`.
|
|
562
|
+
trace:
|
|
563
|
+
The current :class:`~agent_trace.Trace`.
|
|
564
|
+
**kwargs:
|
|
565
|
+
Additional keyword arguments forwarded to ``Runner.run_streamed``
|
|
566
|
+
(e.g. ``max_turns``, ``context``).
|
|
567
|
+
|
|
568
|
+
Returns
|
|
569
|
+
-------
|
|
570
|
+
_TracedRunResultStreaming
|
|
571
|
+
A drop-in wrapper around the SDK's ``RunResultStreaming``.
|
|
572
|
+
"""
|
|
573
|
+
sdk = _require_openai_agents()
|
|
574
|
+
runner_cls = _get_runner_cls(sdk)
|
|
575
|
+
|
|
576
|
+
hook = AgentTraceHook(tracer=tracer, trace=trace)
|
|
577
|
+
root_span = tracer.start_span("agent_run_streamed")
|
|
578
|
+
|
|
579
|
+
raw = runner_cls.run_streamed(agent, input, hooks=hook, **kwargs)
|
|
580
|
+
result_streaming = await raw if inspect.isawaitable(raw) else raw
|
|
581
|
+
|
|
582
|
+
return _TracedRunResultStreaming(result_streaming, root_span, hook)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
# ---------------------------------------------------------------------------
|
|
586
|
+
# AgentTraceRealtimeHook — Realtime API (agents.realtime) instrumentation
|
|
587
|
+
# ---------------------------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
class AgentTraceRealtimeHook:
|
|
591
|
+
"""Span instrumentation for the OpenAI Agents SDK's Realtime API.
|
|
592
|
+
|
|
593
|
+
The Realtime API (``agents.realtime``) has no ``RunHooks``-style
|
|
594
|
+
callback surface at all — ``RealtimeRunner.run()`` doesn't take a
|
|
595
|
+
``hooks=`` kwarg, and ``RealtimeSession`` exposes turns, tool calls, and
|
|
596
|
+
handoffs only by *iterating the session itself*
|
|
597
|
+
(``RealtimeSession`` is an ``AsyncIterator[RealtimeSessionEvent]``).
|
|
598
|
+
``AgentTraceHook``, which is scoped entirely to the turn-based
|
|
599
|
+
``Runner.run()``/``Runner.run_streamed()`` loop, never fires for a
|
|
600
|
+
``RealtimeSession`` at all — so today a handoff, tool-call, or error
|
|
601
|
+
happening inside a realtime voice session produces no span whatsoever.
|
|
602
|
+
|
|
603
|
+
This class wraps that iteration instead, translating each
|
|
604
|
+
``RealtimeSessionEvent`` into the same span open/close/event pattern
|
|
605
|
+
``AgentTraceHook`` uses for the turn-based loop:
|
|
606
|
+
|
|
607
|
+
- ``agent_start`` / ``agent_end`` -> open/close an ``agent:<name>`` span
|
|
608
|
+
- ``tool_start`` / ``tool_end`` -> open/close a ``tool:<name>`` span
|
|
609
|
+
(child of the current agent span), persisting arguments/result
|
|
610
|
+
- ``handoff`` -> an event on the outgoing agent's span
|
|
611
|
+
- ``error`` / ``guardrail_tripped`` -> ``record_exception``-style event
|
|
612
|
+
on the session's root span, without ending the session (a realtime
|
|
613
|
+
session's error/guardrail events are not necessarily fatal). When an
|
|
614
|
+
``error`` event's text matches the "Tool <name> not found" shape
|
|
615
|
+
(#1671's ``ModelBehaviorError`` traceback — an intermittent crash
|
|
616
|
+
inside a Realtime/WebSocket voice session, distinct from the
|
|
617
|
+
httpx-captured chat-completion exchanges
|
|
618
|
+
``check_tool_call_name_fuzzy_match`` diagnoses), the offending name is
|
|
619
|
+
fuzzy-matched against the tool names registered on the most recently
|
|
620
|
+
started agent for this session (captured from the ``agent_start``
|
|
621
|
+
event's ``event.agent.tools``) and the nearest match + edit distance
|
|
622
|
+
are attached as ``exception.nearest_registered_tool``/
|
|
623
|
+
``exception.edit_distance`` attributes on the exception event.
|
|
624
|
+
|
|
625
|
+
Usage::
|
|
626
|
+
|
|
627
|
+
from agents.realtime import RealtimeRunner
|
|
628
|
+
from agent_trace.integrations.openai_agents import AgentTraceRealtimeHook
|
|
629
|
+
|
|
630
|
+
session = await RealtimeRunner(realtime_agent).run()
|
|
631
|
+
rt_hook = AgentTraceRealtimeHook(tracer=t, trace=trace)
|
|
632
|
+
async with session:
|
|
633
|
+
async for event in rt_hook.wrap(session):
|
|
634
|
+
... # your own event handling; spans are recorded as a side effect
|
|
635
|
+
"""
|
|
636
|
+
|
|
637
|
+
def __init__(self, tracer: Tracer, trace: Trace) -> None:
|
|
638
|
+
self._tracer: Tracer = tracer
|
|
639
|
+
self._trace: Trace = trace
|
|
640
|
+
self._spans: dict[str, Span] = {}
|
|
641
|
+
self._lock: threading.Lock = threading.Lock()
|
|
642
|
+
# session_key -> registered tool names of the most recently started
|
|
643
|
+
# agent for that session (#1671). Populated from `agent_start`'s
|
|
644
|
+
# `event.agent.tools`, the only place a realtime session's tool
|
|
645
|
+
# roster is available — there is no separate "tools registered"
|
|
646
|
+
# event.
|
|
647
|
+
self._registered_tools: dict[str, set[str]] = {}
|
|
648
|
+
|
|
649
|
+
# ------------------------------------------------------------------
|
|
650
|
+
# Internal helpers (same pattern as AgentTraceHook)
|
|
651
|
+
# ------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
def _open_span(self, key: str, name: str, parent_key: str | None = None) -> Span:
|
|
654
|
+
parent_span_id: str | None = None
|
|
655
|
+
if parent_key is not None:
|
|
656
|
+
with self._lock:
|
|
657
|
+
parent_span = self._spans.get(parent_key)
|
|
658
|
+
if parent_span is not None:
|
|
659
|
+
parent_span_id = parent_span.span_id
|
|
660
|
+
|
|
661
|
+
span = self._tracer.start_span(name, parent_id=parent_span_id)
|
|
662
|
+
with self._lock:
|
|
663
|
+
self._spans[key] = span
|
|
664
|
+
return span
|
|
665
|
+
|
|
666
|
+
def _close_span(self, key: str, status: SpanStatus = SpanStatus.OK) -> Span | None:
|
|
667
|
+
with self._lock:
|
|
668
|
+
span = self._spans.pop(key, None)
|
|
669
|
+
if span is not None and span.end_time is None:
|
|
670
|
+
span.end(status)
|
|
671
|
+
return span
|
|
672
|
+
|
|
673
|
+
def _diagnose_tool_not_found(
|
|
674
|
+
self, session_key: str, error_message: str
|
|
675
|
+
) -> dict[str, Any]:
|
|
676
|
+
"""Fuzzy-match a "Tool <name> not found" error's ``<name>`` against
|
|
677
|
+
this session's registered tool names (#1671 — an intermittent
|
|
678
|
+
``ModelBehaviorError``-shaped crash inside a Realtime/WebSocket
|
|
679
|
+
voice session), reusing the same edit-distance helper
|
|
680
|
+
``check_tool_call_name_fuzzy_match`` already applies to
|
|
681
|
+
httpx-captured chat-completion exchanges. Returns an empty dict
|
|
682
|
+
(no attributes added) when the error text doesn't match the
|
|
683
|
+
pattern, or no registered tool names were captured for this
|
|
684
|
+
session."""
|
|
685
|
+
match = _TOOL_NOT_FOUND_RE.search(error_message)
|
|
686
|
+
if match is None:
|
|
687
|
+
return {}
|
|
688
|
+
with self._lock:
|
|
689
|
+
registered = self._registered_tools.get(session_key)
|
|
690
|
+
if not registered:
|
|
691
|
+
return {}
|
|
692
|
+
called_name = match.group(1)
|
|
693
|
+
nearest = min(registered, key=lambda r: _edit_distance(called_name, r))
|
|
694
|
+
return {
|
|
695
|
+
"exception.nearest_registered_tool": nearest,
|
|
696
|
+
"exception.edit_distance": _edit_distance(called_name, nearest),
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
def _capture_registered_tools(self, session_key: str, agent: Any) -> None:
|
|
700
|
+
"""Track *agent*'s registered tool names for *session_key* (#1671),
|
|
701
|
+
so a later "Tool <name> not found" error event on this session can
|
|
702
|
+
be fuzzy-matched against them (see _diagnose_tool_not_found)."""
|
|
703
|
+
tool_names = {
|
|
704
|
+
name
|
|
705
|
+
for tool in getattr(agent, "tools", None) or ()
|
|
706
|
+
if isinstance((name := getattr(tool, "name", None)), str)
|
|
707
|
+
}
|
|
708
|
+
if tool_names:
|
|
709
|
+
with self._lock:
|
|
710
|
+
self._registered_tools[session_key] = tool_names
|
|
711
|
+
|
|
712
|
+
def _handle_event(self, session_key: str, event: Any) -> None:
|
|
713
|
+
event_type = getattr(event, "type", None)
|
|
714
|
+
|
|
715
|
+
if event_type == "agent_start":
|
|
716
|
+
agent = getattr(event, "agent", None)
|
|
717
|
+
agent_name = getattr(agent, "name", None) or "agent"
|
|
718
|
+
self._open_span(
|
|
719
|
+
f"agent:{session_key}:{agent_name}",
|
|
720
|
+
f"agent:{agent_name}",
|
|
721
|
+
parent_key=session_key,
|
|
722
|
+
).set_attribute("agent.name", agent_name)
|
|
723
|
+
self._capture_registered_tools(session_key, agent)
|
|
724
|
+
|
|
725
|
+
elif event_type == "agent_end":
|
|
726
|
+
agent_name = getattr(getattr(event, "agent", None), "name", None) or "agent"
|
|
727
|
+
self._close_span(f"agent:{session_key}:{agent_name}", SpanStatus.OK)
|
|
728
|
+
|
|
729
|
+
elif event_type == "tool_start":
|
|
730
|
+
agent_name = getattr(getattr(event, "agent", None), "name", None) or "agent"
|
|
731
|
+
tool_name = getattr(getattr(event, "tool", None), "name", None) or "tool"
|
|
732
|
+
span = self._open_span(
|
|
733
|
+
f"tool:{session_key}:{tool_name}",
|
|
734
|
+
f"tool:{tool_name}",
|
|
735
|
+
parent_key=f"agent:{session_key}:{agent_name}",
|
|
736
|
+
)
|
|
737
|
+
span.set_attribute("tool.name", tool_name)
|
|
738
|
+
arguments = getattr(event, "arguments", None)
|
|
739
|
+
if arguments is not None:
|
|
740
|
+
span.set_attribute("tool.arguments", _truncate(arguments))
|
|
741
|
+
|
|
742
|
+
elif event_type == "tool_end":
|
|
743
|
+
tool_name = getattr(getattr(event, "tool", None), "name", None) or "tool"
|
|
744
|
+
tool_key = f"tool:{session_key}:{tool_name}"
|
|
745
|
+
closed_span = self._close_span(tool_key, SpanStatus.OK)
|
|
746
|
+
if closed_span is not None:
|
|
747
|
+
output = getattr(event, "output", None)
|
|
748
|
+
if output is not None:
|
|
749
|
+
closed_span.set_attribute("tool.result", _truncate(output))
|
|
750
|
+
closed_span.set_attribute("tool.result_length", len(str(output)))
|
|
751
|
+
|
|
752
|
+
elif event_type == "handoff":
|
|
753
|
+
from_agent = getattr(event, "from_agent", None)
|
|
754
|
+
to_agent = getattr(event, "to_agent", None)
|
|
755
|
+
from_name = getattr(from_agent, "name", None) or "unknown"
|
|
756
|
+
to_name = getattr(to_agent, "name", None) or "unknown"
|
|
757
|
+
with self._lock:
|
|
758
|
+
parent_span = self._spans.get(f"agent:{session_key}:{from_name}")
|
|
759
|
+
if parent_span is not None:
|
|
760
|
+
parent_span.add_event(
|
|
761
|
+
"handoff",
|
|
762
|
+
attributes={
|
|
763
|
+
"handoff.from_agent": from_name,
|
|
764
|
+
"handoff.to_agent": to_name,
|
|
765
|
+
},
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
elif event_type in ("error", "guardrail_tripped"):
|
|
769
|
+
with self._lock:
|
|
770
|
+
root_span = self._spans.get(session_key)
|
|
771
|
+
if root_span is not None:
|
|
772
|
+
detail = (
|
|
773
|
+
getattr(event, "error", None)
|
|
774
|
+
or getattr(event, "message", None)
|
|
775
|
+
or event
|
|
776
|
+
)
|
|
777
|
+
message = _truncate(detail)
|
|
778
|
+
attributes = {
|
|
779
|
+
"exception.type": event_type,
|
|
780
|
+
"exception.message": message,
|
|
781
|
+
**(
|
|
782
|
+
self._diagnose_tool_not_found(session_key, message)
|
|
783
|
+
if event_type == "error"
|
|
784
|
+
else {}
|
|
785
|
+
),
|
|
786
|
+
}
|
|
787
|
+
root_span.add_event(
|
|
788
|
+
"exception" if event_type == "error" else "guardrail_tripped",
|
|
789
|
+
attributes=attributes,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
async def wrap(
|
|
793
|
+
self, session: RealtimeSession
|
|
794
|
+
) -> AsyncIterator[RealtimeSessionEvent]:
|
|
795
|
+
"""Iterate *session*, recording spans as a side effect; yields events as-is."""
|
|
796
|
+
session_key = f"realtime:{id(session)}"
|
|
797
|
+
self._open_span(session_key, "realtime_session")
|
|
798
|
+
try:
|
|
799
|
+
async for event in session:
|
|
800
|
+
self._handle_event(session_key, event)
|
|
801
|
+
yield event
|
|
802
|
+
self._close_span(session_key, SpanStatus.OK)
|
|
803
|
+
except Exception as exc:
|
|
804
|
+
with self._lock:
|
|
805
|
+
spans = list(self._spans.values())
|
|
806
|
+
self._spans.clear()
|
|
807
|
+
for span in spans:
|
|
808
|
+
if span.end_time is None:
|
|
809
|
+
span.record_exception(exc)
|
|
810
|
+
span.end(SpanStatus.ERROR)
|
|
811
|
+
raise
|