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,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP stdio-transport capture layer.
|
|
3
|
+
|
|
4
|
+
``agent-trace``'s only capture layer historically has been HTTP-level
|
|
5
|
+
(``httpx``/``requests`` — see ``httpx_hook.py``/``requests_patch.py``). MCP's
|
|
6
|
+
``stdio`` transport communicates over subprocess stdin/stdout pipes instead —
|
|
7
|
+
zero HTTP traffic touches the wire, so an MCP-stdio-related failure (e.g. a
|
|
8
|
+
startup/tool-loading crash) is completely invisible to those interceptors,
|
|
9
|
+
regardless of which framework wraps the MCP client.
|
|
10
|
+
|
|
11
|
+
``recording_stdio_client`` wraps ``mcp.client.stdio.stdio_client`` the same
|
|
12
|
+
way ``RecordingTransport`` wraps a real ``httpx`` transport: the real
|
|
13
|
+
subprocess/streams are used unmodified, every JSON-RPC frame that flows
|
|
14
|
+
through is persisted to a :class:`~agent_trace._replay.fixture.Fixture`
|
|
15
|
+
*before* being handed back to the caller, and the caller (typically an MCP
|
|
16
|
+
``ClientSession``) is none the wiser.
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
from mcp import ClientSession, StdioServerParameters
|
|
21
|
+
from agent_trace.interceptor.stdio_hook import recording_stdio_client
|
|
22
|
+
|
|
23
|
+
server = StdioServerParameters(command="my-mcp-server", args=[])
|
|
24
|
+
async with recording_stdio_client(server, fixture) as (read, write):
|
|
25
|
+
async with ClientSession(read, write) as session:
|
|
26
|
+
await session.initialize()
|
|
27
|
+
tools = await session.list_tools()
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import sys
|
|
34
|
+
from collections.abc import AsyncIterator
|
|
35
|
+
from contextlib import asynccontextmanager
|
|
36
|
+
from typing import TYPE_CHECKING, Any, TextIO
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from agent_trace._replay.fixture import Fixture
|
|
40
|
+
|
|
41
|
+
__all__ = ["recording_stdio_client"]
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
_INSTALL_HINT = (
|
|
46
|
+
"MCP stdio recording requires the mcp package.\n"
|
|
47
|
+
"Install it with:\n\n"
|
|
48
|
+
" pip install mcp\n"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _require_mcp() -> Any:
|
|
53
|
+
"""Lazy import guard — raises a clear error if mcp is absent."""
|
|
54
|
+
try:
|
|
55
|
+
import mcp
|
|
56
|
+
|
|
57
|
+
return mcp
|
|
58
|
+
except ImportError as exc:
|
|
59
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _classify_frame(root: Any) -> tuple[str, str | None, str | None]:
|
|
63
|
+
"""Classify a ``JSONRPCMessage.root`` union member.
|
|
64
|
+
|
|
65
|
+
Returns ``(frame_type, rpc_id, method)`` where ``frame_type`` is one of
|
|
66
|
+
``"request"``, ``"notification"``, ``"response"``, ``"error"``, or
|
|
67
|
+
``"unknown"``.
|
|
68
|
+
|
|
69
|
+
Distinguishing the four ``mcp.types`` JSON-RPC frame shapes:
|
|
70
|
+
|
|
71
|
+
- ``JSONRPCRequest``: has ``method`` *and* ``id``.
|
|
72
|
+
- ``JSONRPCNotification``: has ``method``, no ``id``.
|
|
73
|
+
- ``JSONRPCResponse``: has ``id`` and ``result``, no ``method``.
|
|
74
|
+
- ``JSONRPCError``: has ``id`` and ``error``, no ``method``.
|
|
75
|
+
"""
|
|
76
|
+
rpc_id = str(root.id) if getattr(root, "id", None) is not None else None
|
|
77
|
+
method = getattr(root, "method", None)
|
|
78
|
+
if getattr(root, "error", None) is not None:
|
|
79
|
+
return "error", rpc_id, method
|
|
80
|
+
if hasattr(root, "result"):
|
|
81
|
+
return "response", rpc_id, method
|
|
82
|
+
if method is not None and rpc_id is not None:
|
|
83
|
+
return "request", rpc_id, method
|
|
84
|
+
if method is not None:
|
|
85
|
+
return "notification", rpc_id, method
|
|
86
|
+
return "unknown", rpc_id, method # pragma: no cover — defensive fallback
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _RecordingReceiveStream:
|
|
90
|
+
"""Tees every ``SessionMessage`` read from *inner* into a Fixture.
|
|
91
|
+
|
|
92
|
+
Duck-types anyio's ``ObjectReceiveStream`` interface (``receive``,
|
|
93
|
+
``aclose``, ``__aiter__``/``__anext__``, async-context-manager) so it can
|
|
94
|
+
be handed directly to ``ClientSession`` in place of the real stream.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
inner: Any,
|
|
100
|
+
fixture: Fixture,
|
|
101
|
+
server_command: str,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._inner = inner
|
|
104
|
+
self._fixture = fixture
|
|
105
|
+
self._server_command = server_command
|
|
106
|
+
|
|
107
|
+
def __aiter__(self) -> _RecordingReceiveStream:
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
async def __anext__(self) -> Any:
|
|
111
|
+
import anyio
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
return await self.receive()
|
|
115
|
+
except anyio.EndOfStream:
|
|
116
|
+
raise StopAsyncIteration from None
|
|
117
|
+
|
|
118
|
+
async def receive(self) -> Any:
|
|
119
|
+
item = await self._inner.receive()
|
|
120
|
+
self._record(item)
|
|
121
|
+
return item
|
|
122
|
+
|
|
123
|
+
def _record(self, item: Any) -> None:
|
|
124
|
+
from mcp.shared.message import SessionMessage
|
|
125
|
+
|
|
126
|
+
# read_stream carries `SessionMessage | Exception` — malformed frames
|
|
127
|
+
# the real client failed to parse arrive as bare exceptions; nothing
|
|
128
|
+
# to record on the wire for those.
|
|
129
|
+
if not isinstance(item, SessionMessage):
|
|
130
|
+
return
|
|
131
|
+
try:
|
|
132
|
+
root = item.message.root
|
|
133
|
+
frame_type, rpc_id, method = _classify_frame(root)
|
|
134
|
+
payload = item.message.model_dump_json(by_alias=True, exclude_none=True)
|
|
135
|
+
self._fixture.record_mcp_frame(
|
|
136
|
+
server_command=self._server_command,
|
|
137
|
+
direction="from_server",
|
|
138
|
+
frame_type=frame_type,
|
|
139
|
+
rpc_id=rpc_id,
|
|
140
|
+
method=method,
|
|
141
|
+
payload=payload,
|
|
142
|
+
)
|
|
143
|
+
except Exception:
|
|
144
|
+
logger.debug(
|
|
145
|
+
"agent-trace: failed to record inbound MCP frame", exc_info=True
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
async def aclose(self) -> None:
|
|
149
|
+
await self._inner.aclose()
|
|
150
|
+
|
|
151
|
+
async def __aenter__(self) -> _RecordingReceiveStream:
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
155
|
+
await self.aclose()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class _RecordingSendStream:
|
|
159
|
+
"""Tees every ``SessionMessage`` sent through *inner* into a Fixture."""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
inner: Any,
|
|
164
|
+
fixture: Fixture,
|
|
165
|
+
server_command: str,
|
|
166
|
+
) -> None:
|
|
167
|
+
self._inner = inner
|
|
168
|
+
self._fixture = fixture
|
|
169
|
+
self._server_command = server_command
|
|
170
|
+
|
|
171
|
+
async def send(self, item: Any) -> None:
|
|
172
|
+
self._record(item)
|
|
173
|
+
await self._inner.send(item)
|
|
174
|
+
|
|
175
|
+
def _record(self, item: Any) -> None:
|
|
176
|
+
from mcp.shared.message import SessionMessage
|
|
177
|
+
|
|
178
|
+
if not isinstance(item, SessionMessage):
|
|
179
|
+
return # pragma: no cover — write_stream is SessionMessage-only
|
|
180
|
+
try:
|
|
181
|
+
root = item.message.root
|
|
182
|
+
frame_type, rpc_id, method = _classify_frame(root)
|
|
183
|
+
payload = item.message.model_dump_json(by_alias=True, exclude_none=True)
|
|
184
|
+
self._fixture.record_mcp_frame(
|
|
185
|
+
server_command=self._server_command,
|
|
186
|
+
direction="to_server",
|
|
187
|
+
frame_type=frame_type,
|
|
188
|
+
rpc_id=rpc_id,
|
|
189
|
+
method=method,
|
|
190
|
+
payload=payload,
|
|
191
|
+
)
|
|
192
|
+
except Exception:
|
|
193
|
+
logger.debug(
|
|
194
|
+
"agent-trace: failed to record outbound MCP frame", exc_info=True
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
async def aclose(self) -> None:
|
|
198
|
+
await self._inner.aclose()
|
|
199
|
+
|
|
200
|
+
async def __aenter__(self) -> _RecordingSendStream:
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
204
|
+
await self.aclose()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@asynccontextmanager
|
|
208
|
+
async def recording_stdio_client(
|
|
209
|
+
server: Any,
|
|
210
|
+
fixture: Fixture,
|
|
211
|
+
errlog: TextIO | None = None,
|
|
212
|
+
) -> AsyncIterator[tuple[_RecordingReceiveStream, _RecordingSendStream]]:
|
|
213
|
+
"""Wrap ``mcp.client.stdio.stdio_client`` to record every JSON-RPC frame.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
server:
|
|
218
|
+
An ``mcp.StdioServerParameters`` instance describing the subprocess
|
|
219
|
+
to launch (``command``, ``args``, ``env``, ``cwd``).
|
|
220
|
+
fixture:
|
|
221
|
+
Open :class:`~agent_trace._replay.fixture.Fixture` to record into.
|
|
222
|
+
errlog:
|
|
223
|
+
Passed through to ``stdio_client`` for the child process's stderr
|
|
224
|
+
(defaults to ``sys.stderr``, matching the real client's default).
|
|
225
|
+
|
|
226
|
+
Yields
|
|
227
|
+
------
|
|
228
|
+
tuple[_RecordingReceiveStream, _RecordingSendStream]
|
|
229
|
+
Drop-in replacements for the ``(read_stream, write_stream)`` pair
|
|
230
|
+
``stdio_client`` normally yields — pass these straight into
|
|
231
|
+
``mcp.ClientSession``.
|
|
232
|
+
"""
|
|
233
|
+
_require_mcp()
|
|
234
|
+
from mcp.client.stdio import stdio_client
|
|
235
|
+
|
|
236
|
+
command = f"{server.command} {' '.join(server.args)}".strip()
|
|
237
|
+
effective_errlog: TextIO = errlog if errlog is not None else sys.stderr
|
|
238
|
+
|
|
239
|
+
async with stdio_client(server, errlog=effective_errlog) as (
|
|
240
|
+
read_stream,
|
|
241
|
+
write_stream,
|
|
242
|
+
):
|
|
243
|
+
recording_read = _RecordingReceiveStream(read_stream, fixture, command)
|
|
244
|
+
recording_write = _RecordingSendStream(write_stream, fixture, command)
|
|
245
|
+
yield recording_read, recording_write
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python `warnings`-capture layer for library-raised runtime warnings.
|
|
3
|
+
|
|
4
|
+
Some real, reproducible failures never raise an exception and never touch
|
|
5
|
+
the network at all — e.g. langgraph#5628's ``RuntimeWarning: Failed to trim
|
|
6
|
+
messages to fit within max_tokens limit before summarization``, raised
|
|
7
|
+
entirely inside local token-counting logic before any HTTP call is
|
|
8
|
+
attempted. Nothing in agent-trace previously captured arbitrary
|
|
9
|
+
library-raised ``warnings.warn()`` calls (the only prior ``warnings.warn()``
|
|
10
|
+
call sites in the codebase were agent-trace's own internal warnings in
|
|
11
|
+
``httpx_hook.py``/``requests_patch.py``) — this is a related but distinct
|
|
12
|
+
gap from ``agent_trace.interceptor.logging_hook`` (that one targets
|
|
13
|
+
Python's ``logging`` module for a different subsystem's warning path —
|
|
14
|
+
LangGraph's pregel-scheduler log line for langgraph#5464; this one targets
|
|
15
|
+
Python's ``warnings`` module, requiring a ``warnings.catch_warnings()``/
|
|
16
|
+
``showwarning`` override instead of a ``logging.Handler``).
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
from agent_trace import tracer
|
|
21
|
+
from agent_trace.interceptor.warnings_hook import capture_warnings
|
|
22
|
+
|
|
23
|
+
with tracer.start_trace("my_graph") as trace:
|
|
24
|
+
with capture_warnings(tracer):
|
|
25
|
+
graph.invoke(state, config={"callbacks": [...]})
|
|
26
|
+
|
|
27
|
+
Every ``UserWarning``/``RuntimeWarning`` (or whatever *categories* is
|
|
28
|
+
passed) raised anywhere during the ``with`` block is persisted as a
|
|
29
|
+
``runtime_warning`` event on a dedicated ``warnings:capture`` span —
|
|
30
|
+
independent of, and in addition to, whatever HTTP/callback capture is also
|
|
31
|
+
active.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import contextlib
|
|
37
|
+
import logging
|
|
38
|
+
import warnings
|
|
39
|
+
from collections.abc import Generator
|
|
40
|
+
from typing import TYPE_CHECKING, Any
|
|
41
|
+
|
|
42
|
+
from agent_trace.core.span import Span, SpanStatus
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from agent_trace import Tracer
|
|
46
|
+
|
|
47
|
+
__all__ = ["capture_warnings"]
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
# Bounds — this is diagnostic capture, not a full warnings mirror. A
|
|
52
|
+
# pathological hot loop emitting thousands of warnings must not grow a
|
|
53
|
+
# single span's event list unboundedly; warnings.captured_count keeps
|
|
54
|
+
# counting past the cap.
|
|
55
|
+
_MAX_ATTR_LEN = 4_000
|
|
56
|
+
_MAX_WARNING_EVENTS = 200
|
|
57
|
+
|
|
58
|
+
_DEFAULT_CATEGORIES: tuple[type[Warning], ...] = (UserWarning, RuntimeWarning)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@contextlib.contextmanager
|
|
62
|
+
def capture_warnings(
|
|
63
|
+
tracer: Tracer,
|
|
64
|
+
*,
|
|
65
|
+
categories: tuple[type[Warning], ...] = _DEFAULT_CATEGORIES,
|
|
66
|
+
span_name: str = "warnings:capture",
|
|
67
|
+
) -> Generator[Span, None, None]:
|
|
68
|
+
"""Install a ``warnings.catch_warnings()`` context with a custom
|
|
69
|
+
``showwarning`` override for the lifetime of the ``with`` block,
|
|
70
|
+
persisting every caught warning matching *categories* onto a dedicated
|
|
71
|
+
``warnings:capture`` span (opened via *tracer*).
|
|
72
|
+
|
|
73
|
+
Uses ``warnings.simplefilter("always")`` inside the ``catch_warnings()``
|
|
74
|
+
context so a warning that Python would otherwise only show once per
|
|
75
|
+
location (the default ``"default"`` filter action) is still captured on
|
|
76
|
+
every occurrence, without changing any filter state outside this
|
|
77
|
+
``with`` block — ``catch_warnings()`` saves and restores
|
|
78
|
+
``warnings.filters``/``showwarning`` on exit regardless of how the block
|
|
79
|
+
exits.
|
|
80
|
+
|
|
81
|
+
The span closes ``OK`` when the block exits normally (warning capture
|
|
82
|
+
is a diagnostic side-channel, not a success/failure signal in itself)
|
|
83
|
+
or ``ERROR`` if the block itself raises (the exception is recorded onto
|
|
84
|
+
the span, then re-raised unchanged).
|
|
85
|
+
"""
|
|
86
|
+
span = tracer.start_span(span_name)
|
|
87
|
+
count = 0
|
|
88
|
+
|
|
89
|
+
def _showwarning(
|
|
90
|
+
message: Warning | str,
|
|
91
|
+
category: type[Warning],
|
|
92
|
+
filename: str,
|
|
93
|
+
lineno: int,
|
|
94
|
+
file: Any = None,
|
|
95
|
+
line: str | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
nonlocal count
|
|
98
|
+
if not (isinstance(category, type) and issubclass(category, categories)):
|
|
99
|
+
return
|
|
100
|
+
count += 1
|
|
101
|
+
if count > _MAX_WARNING_EVENTS:
|
|
102
|
+
return
|
|
103
|
+
try:
|
|
104
|
+
span.add_event(
|
|
105
|
+
"runtime_warning",
|
|
106
|
+
attributes={
|
|
107
|
+
"warning.category": category.__name__,
|
|
108
|
+
"warning.message": str(message)[:_MAX_ATTR_LEN],
|
|
109
|
+
"warning.filename": str(filename),
|
|
110
|
+
"warning.lineno": int(lineno),
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
except Exception:
|
|
114
|
+
logger.debug(
|
|
115
|
+
"agent-trace: failed to record captured warning event",
|
|
116
|
+
exc_info=True,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
with warnings.catch_warnings():
|
|
121
|
+
warnings.simplefilter("always")
|
|
122
|
+
warnings.showwarning = _showwarning
|
|
123
|
+
yield span
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
span.record_exception(exc)
|
|
126
|
+
if span.end_time is None:
|
|
127
|
+
span.end(SpanStatus.ERROR)
|
|
128
|
+
raise
|
|
129
|
+
else:
|
|
130
|
+
span.set_attribute("warnings.captured_count", count)
|
|
131
|
+
if span.end_time is None:
|
|
132
|
+
span.end(SpanStatus.OK)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket connection wrappers for recording and replaying Realtime-API-style
|
|
3
|
+
duplex sessions (e.g. the OpenAI Agents SDK's Realtime API, which opens a
|
|
4
|
+
persistent ``websockets.asyncio.client.ClientConnection`` and exchanges many
|
|
5
|
+
JSON events over it — tool calls, handoffs, audio deltas — rather than the
|
|
6
|
+
discrete request/response model ``RecordingTransport``/``ReplayTransport``
|
|
7
|
+
(``httpx_hook.py``) assume).
|
|
8
|
+
|
|
9
|
+
RecordingWebSocketConnection wraps a real connection object (anything
|
|
10
|
+
exposing the ``websockets`` client protocol: async ``send``, async ``recv``,
|
|
11
|
+
async iteration, and ``close``): frames flow through unmodified in both
|
|
12
|
+
directions while each one is teed into the fixture store as it passes.
|
|
13
|
+
|
|
14
|
+
ReplayWebSocketConnection never touches the network. It serves previously
|
|
15
|
+
recorded inbound ("recv") frames from the fixture, in the order they were
|
|
16
|
+
captured, so a Realtime session can be replayed offline at zero API cost.
|
|
17
|
+
Frames sent by the caller during replay are accepted but not forwarded
|
|
18
|
+
anywhere — there is no live peer to send them to, matching how
|
|
19
|
+
``ReplayTransport`` ignores request bodies and only replays responses keyed
|
|
20
|
+
by (method, url).
|
|
21
|
+
|
|
22
|
+
RecordingConnect is a drop-in stand-in for ``websockets.connect(...)`` (the
|
|
23
|
+
callable this module patches) that supports both calling conventions the
|
|
24
|
+
``websockets`` library provides::
|
|
25
|
+
|
|
26
|
+
ws = await websockets.connect(uri, **kwargs)
|
|
27
|
+
async with websockets.connect(uri, **kwargs) as ws:
|
|
28
|
+
|
|
29
|
+
and returns a RecordingWebSocketConnection either way.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import logging
|
|
35
|
+
import uuid
|
|
36
|
+
import warnings
|
|
37
|
+
from collections.abc import AsyncIterator
|
|
38
|
+
from typing import TYPE_CHECKING, Any
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from agent_trace._replay.fixture import Fixture
|
|
42
|
+
|
|
43
|
+
from agent_trace.core.exceptions import NetworkGuardError, guard_active
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"NetworkGuardError",
|
|
47
|
+
"RecordingConnect",
|
|
48
|
+
"RecordingWebSocketConnection",
|
|
49
|
+
"ReplayWebSocketConnection",
|
|
50
|
+
"WebSocketReplayExhaustedError",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
DIRECTION_SEND = "send"
|
|
56
|
+
DIRECTION_RECV = "recv"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class WebSocketReplayExhaustedError(EOFError):
|
|
60
|
+
"""Raised by :meth:`ReplayWebSocketConnection.recv` when no more recorded
|
|
61
|
+
inbound frames remain and ``AGENT_TRACE_NETWORK_GUARD`` is not set.
|
|
62
|
+
|
|
63
|
+
Unlike ``ReplayTransport``'s HTTP fallback (which can fall through to a
|
|
64
|
+
real request), there is no live peer to fall back to for a duplex
|
|
65
|
+
WebSocket session during replay, so this is always the terminal signal —
|
|
66
|
+
the caller should treat it the way it would treat the underlying library's
|
|
67
|
+
own "connection closed" exception.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _decode_for_recording(message: Any) -> tuple[str, str]:
|
|
72
|
+
"""Return (payload_as_text, frame_type) for a frame about to be recorded."""
|
|
73
|
+
if isinstance(message, (bytes, bytearray)):
|
|
74
|
+
return message.decode("utf-8", errors="replace"), "binary"
|
|
75
|
+
return str(message), "text"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _encode_from_frame(frame: dict[str, Any]) -> Any:
|
|
79
|
+
"""Inverse of _decode_for_recording — reconstruct the original message shape."""
|
|
80
|
+
if frame["frame_type"] == "binary":
|
|
81
|
+
return frame["payload"].encode("utf-8")
|
|
82
|
+
return frame["payload"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RecordingWebSocketConnection:
|
|
86
|
+
"""Wraps a real WebSocket client connection, recording every frame.
|
|
87
|
+
|
|
88
|
+
Duck-types the subset of ``websockets.asyncio.client.ClientConnection``
|
|
89
|
+
that agent-trace's supported callers use — ``send``, ``recv``, async
|
|
90
|
+
iteration (``async for message in ws``), and ``close`` — and forwards
|
|
91
|
+
every other attribute access to the wrapped connection (e.g.
|
|
92
|
+
``close_code``, ``state``, ``request``, ``response``), so instrumented
|
|
93
|
+
code can use this exactly like the real connection.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
inner:
|
|
98
|
+
The real, already-connected WebSocket client connection.
|
|
99
|
+
fixture:
|
|
100
|
+
Open Fixture instance where frames will be written.
|
|
101
|
+
url:
|
|
102
|
+
The URL the connection was opened against — stored alongside every
|
|
103
|
+
frame so a fixture holding multiple connections can be filtered.
|
|
104
|
+
connection_id:
|
|
105
|
+
Identifier grouping frames from this connection in the fixture.
|
|
106
|
+
Auto-generated if not supplied.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
inner: Any,
|
|
112
|
+
fixture: Fixture,
|
|
113
|
+
url: str,
|
|
114
|
+
connection_id: str | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
self._inner = inner
|
|
117
|
+
self._fixture = fixture
|
|
118
|
+
self._url = url
|
|
119
|
+
self.connection_id: str = connection_id or uuid.uuid4().hex[:16]
|
|
120
|
+
|
|
121
|
+
async def send(self, message: Any, *args: Any, **kwargs: Any) -> None:
|
|
122
|
+
"""Send *message*, recording it as an outbound ("send") frame first.
|
|
123
|
+
|
|
124
|
+
Recording before the awaited send (rather than after) means a frame
|
|
125
|
+
that raises mid-send (e.g. connection dropped) is still captured —
|
|
126
|
+
matching the intent, if not the exact ordering, of
|
|
127
|
+
``RecordingTransport`` which always records a completed exchange.
|
|
128
|
+
"""
|
|
129
|
+
payload, frame_type = _decode_for_recording(message)
|
|
130
|
+
self._fixture.record_ws_frame(
|
|
131
|
+
connection_id=self.connection_id,
|
|
132
|
+
url=self._url,
|
|
133
|
+
direction=DIRECTION_SEND,
|
|
134
|
+
payload=payload,
|
|
135
|
+
frame_type=frame_type,
|
|
136
|
+
)
|
|
137
|
+
await self._inner.send(message, *args, **kwargs)
|
|
138
|
+
|
|
139
|
+
async def recv(self, *args: Any, **kwargs: Any) -> Any:
|
|
140
|
+
"""Receive the next message, recording it as an inbound ("recv") frame."""
|
|
141
|
+
message = await self._inner.recv(*args, **kwargs)
|
|
142
|
+
payload, frame_type = _decode_for_recording(message)
|
|
143
|
+
self._fixture.record_ws_frame(
|
|
144
|
+
connection_id=self.connection_id,
|
|
145
|
+
url=self._url,
|
|
146
|
+
direction=DIRECTION_RECV,
|
|
147
|
+
payload=payload,
|
|
148
|
+
frame_type=frame_type,
|
|
149
|
+
)
|
|
150
|
+
return message
|
|
151
|
+
|
|
152
|
+
def __aiter__(self) -> AsyncIterator[Any]:
|
|
153
|
+
return self._iter_messages()
|
|
154
|
+
|
|
155
|
+
async def _iter_messages(self) -> AsyncIterator[Any]:
|
|
156
|
+
async for message in self._inner:
|
|
157
|
+
payload, frame_type = _decode_for_recording(message)
|
|
158
|
+
self._fixture.record_ws_frame(
|
|
159
|
+
connection_id=self.connection_id,
|
|
160
|
+
url=self._url,
|
|
161
|
+
direction=DIRECTION_RECV,
|
|
162
|
+
payload=payload,
|
|
163
|
+
frame_type=frame_type,
|
|
164
|
+
)
|
|
165
|
+
yield message
|
|
166
|
+
|
|
167
|
+
async def close(self, *args: Any, **kwargs: Any) -> None:
|
|
168
|
+
await self._inner.close(*args, **kwargs)
|
|
169
|
+
|
|
170
|
+
async def __aenter__(self) -> RecordingWebSocketConnection:
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
174
|
+
await self.close()
|
|
175
|
+
|
|
176
|
+
def __getattr__(self, name: str) -> Any:
|
|
177
|
+
# Passthrough for anything not explicitly wrapped above (close_code,
|
|
178
|
+
# state, request, response, ping, pong, ...).
|
|
179
|
+
return getattr(self._inner, name)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ReplayWebSocketConnection:
|
|
183
|
+
"""Serves recorded WS frames from a Fixture without any network I/O.
|
|
184
|
+
|
|
185
|
+
Parameters
|
|
186
|
+
----------
|
|
187
|
+
fixture:
|
|
188
|
+
Open Fixture instance to serve recorded frames from.
|
|
189
|
+
url:
|
|
190
|
+
The URL the connection would have been opened against — used only
|
|
191
|
+
for NetworkGuardError messages.
|
|
192
|
+
connection_id:
|
|
193
|
+
Identifier selecting which connection's frames to serve. Must match
|
|
194
|
+
the ``connection_id`` used when the session was recorded.
|
|
195
|
+
clock:
|
|
196
|
+
Optional FixtureClock to advance with each frame's recorded_at
|
|
197
|
+
timestamp, reproducing original timing during replay.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(
|
|
201
|
+
self,
|
|
202
|
+
fixture: Fixture,
|
|
203
|
+
url: str,
|
|
204
|
+
connection_id: str,
|
|
205
|
+
clock: Any | None = None,
|
|
206
|
+
) -> None:
|
|
207
|
+
self._fixture = fixture
|
|
208
|
+
self._url = url
|
|
209
|
+
self.connection_id = connection_id
|
|
210
|
+
self._clock = clock
|
|
211
|
+
self.close_code: int | None = None
|
|
212
|
+
|
|
213
|
+
async def send(self, message: Any, *args: Any, **kwargs: Any) -> None:
|
|
214
|
+
"""Accept a send during replay without forwarding it anywhere.
|
|
215
|
+
|
|
216
|
+
There is no live peer in replay mode — this mirrors how
|
|
217
|
+
``ReplayTransport`` ignores the outbound request body and only ever
|
|
218
|
+
replays canned responses.
|
|
219
|
+
"""
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
async def recv(self, *args: Any, **kwargs: Any) -> Any:
|
|
223
|
+
"""Return the next recorded inbound frame, or raise/warn if exhausted."""
|
|
224
|
+
frame = self._fixture.next_ws_frame(self.connection_id, DIRECTION_RECV)
|
|
225
|
+
if frame is None:
|
|
226
|
+
return self._on_exhausted()
|
|
227
|
+
return self._deliver(frame)
|
|
228
|
+
|
|
229
|
+
def __aiter__(self) -> AsyncIterator[Any]:
|
|
230
|
+
return self._iter_messages()
|
|
231
|
+
|
|
232
|
+
async def _iter_messages(self) -> AsyncIterator[Any]:
|
|
233
|
+
while True:
|
|
234
|
+
frame = self._fixture.next_ws_frame(self.connection_id, DIRECTION_RECV)
|
|
235
|
+
if frame is None:
|
|
236
|
+
# Mirrors a real connection closing: the async-for loop ends
|
|
237
|
+
# normally, exactly like `_listen_for_messages`'s
|
|
238
|
+
# `async for message in self._websocket:` does on
|
|
239
|
+
# ConnectionClosedOK.
|
|
240
|
+
return
|
|
241
|
+
yield self._deliver(frame)
|
|
242
|
+
|
|
243
|
+
async def close(self, code: int = 1000, reason: str = "") -> None:
|
|
244
|
+
self.close_code = code
|
|
245
|
+
|
|
246
|
+
async def __aenter__(self) -> ReplayWebSocketConnection:
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
250
|
+
await self.close()
|
|
251
|
+
|
|
252
|
+
def _deliver(self, frame: dict[str, Any]) -> Any:
|
|
253
|
+
if self._clock is not None:
|
|
254
|
+
self._clock.advance(float(frame["recorded_at"]))
|
|
255
|
+
return _encode_from_frame(frame)
|
|
256
|
+
|
|
257
|
+
def _on_exhausted(self) -> Any:
|
|
258
|
+
if guard_active():
|
|
259
|
+
raise NetworkGuardError(
|
|
260
|
+
f"No recorded inbound WS frame for connection "
|
|
261
|
+
f"{self.connection_id!r} ({self._url}) and "
|
|
262
|
+
"AGENT_TRACE_NETWORK_GUARD=1 is set. Run in recording mode "
|
|
263
|
+
"first to capture this session."
|
|
264
|
+
)
|
|
265
|
+
warnings.warn(
|
|
266
|
+
f"agent-trace: no more recorded WS frames for connection "
|
|
267
|
+
f"{self.connection_id!r} ({self._url}); there is no live "
|
|
268
|
+
"connection to fall back to during replay. Set "
|
|
269
|
+
"AGENT_TRACE_NETWORK_GUARD=1 to make this an error.",
|
|
270
|
+
stacklevel=2,
|
|
271
|
+
)
|
|
272
|
+
raise WebSocketReplayExhaustedError(
|
|
273
|
+
f"agent-trace: WS fixture exhausted for connection "
|
|
274
|
+
f"{self.connection_id!r} ({self._url})"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class RecordingConnect:
|
|
279
|
+
"""Drop-in stand-in for ``websockets.connect(...)`` that records frames.
|
|
280
|
+
|
|
281
|
+
Constructed with the *real* ``connect`` callable/class (so the patch
|
|
282
|
+
installer keeps full control of what "real" means, exactly like
|
|
283
|
+
``RecordingTransport`` accepts an ``inner`` transport) plus the fixture
|
|
284
|
+
to record into. Supports both calling conventions ``websockets.connect``
|
|
285
|
+
provides:
|
|
286
|
+
|
|
287
|
+
ws = await websockets.connect(uri, **kwargs)
|
|
288
|
+
async with websockets.connect(uri, **kwargs) as ws:
|
|
289
|
+
|
|
290
|
+
and returns a :class:`RecordingWebSocketConnection` either way.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
def __init__(
|
|
294
|
+
self,
|
|
295
|
+
real_connect: Any,
|
|
296
|
+
fixture: Fixture,
|
|
297
|
+
uri: str,
|
|
298
|
+
*args: Any,
|
|
299
|
+
connection_id: str | None = None,
|
|
300
|
+
**kwargs: Any,
|
|
301
|
+
) -> None:
|
|
302
|
+
self._real = real_connect(uri, *args, **kwargs)
|
|
303
|
+
self._fixture = fixture
|
|
304
|
+
self._uri = uri
|
|
305
|
+
self._connection_id = connection_id
|
|
306
|
+
self._wrapped: RecordingWebSocketConnection | None = None
|
|
307
|
+
|
|
308
|
+
def __await__(self) -> Any:
|
|
309
|
+
return self._connect().__await__()
|
|
310
|
+
|
|
311
|
+
async def _connect(self) -> RecordingWebSocketConnection:
|
|
312
|
+
inner = await self._real
|
|
313
|
+
self._wrapped = RecordingWebSocketConnection(
|
|
314
|
+
inner, self._fixture, self._uri, connection_id=self._connection_id
|
|
315
|
+
)
|
|
316
|
+
return self._wrapped
|
|
317
|
+
|
|
318
|
+
async def __aenter__(self) -> RecordingWebSocketConnection:
|
|
319
|
+
return await self._connect()
|
|
320
|
+
|
|
321
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
322
|
+
if self._wrapped is not None:
|
|
323
|
+
await self._wrapped.close()
|