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,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Outbound event-stream/SSE (or websocket) delivery instrumentation.
|
|
3
|
+
|
|
4
|
+
agent-trace's LangGraph integration only hooks ``langchain_core`` callbacks
|
|
5
|
+
that fire *in-process* during graph execution — the same layer LangSmith
|
|
6
|
+
already instruments. Failures where the backend graph completes
|
|
7
|
+
successfully but the frontend never receives the terminal event (e.g.
|
|
8
|
+
dropped/stalled SSE connections, a silent exception raised inside a
|
|
9
|
+
streaming response writer, a websocket send that raises after the client
|
|
10
|
+
disconnected) happen entirely *downstream* of that layer — langgraph#6202's
|
|
11
|
+
failure class. There was previously zero code anywhere in agent-trace that
|
|
12
|
+
touched this transport boundary (confirmed via repo-wide grep for
|
|
13
|
+
``stream|sse|websocket|asgi|fastapi`` prior to this module).
|
|
14
|
+
|
|
15
|
+
This module instruments the layer that actually carries chunks out to a
|
|
16
|
+
browser client, independent of the in-process LangGraph/LangChain callback
|
|
17
|
+
layer — a thin, framework-agnostic wrapper around whatever iterable/
|
|
18
|
+
async-iterable/send-callable a server framework hands its transport (a
|
|
19
|
+
Starlette/FastAPI ``StreamingResponse`` body iterator, the generator behind
|
|
20
|
+
LangGraph's own ``astream_events``, or a websocket's ``.send()`` method).
|
|
21
|
+
No hard dependency on any specific web framework — it only needs a
|
|
22
|
+
generator/async-generator or an async callable.
|
|
23
|
+
|
|
24
|
+
Usage (SSE via a Starlette/FastAPI ``StreamingResponse``)::
|
|
25
|
+
|
|
26
|
+
from agent_trace.integrations.streaming import traced_sse_astream
|
|
27
|
+
|
|
28
|
+
async def event_source():
|
|
29
|
+
async for chunk in graph.astream_events(state, version="v2"):
|
|
30
|
+
yield f"data: {json.dumps(chunk)}\\n\\n"
|
|
31
|
+
|
|
32
|
+
return StreamingResponse(
|
|
33
|
+
traced_sse_astream(tracer, event_source()),
|
|
34
|
+
media_type="text/event-stream",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
Usage (websocket)::
|
|
38
|
+
|
|
39
|
+
from agent_trace.integrations.streaming import traced_websocket_send
|
|
40
|
+
|
|
41
|
+
send = traced_websocket_send(tracer, websocket.send_text)
|
|
42
|
+
await send(json.dumps(chunk))
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from collections.abc import (
|
|
48
|
+
AsyncGenerator,
|
|
49
|
+
AsyncIterable,
|
|
50
|
+
Awaitable,
|
|
51
|
+
Callable,
|
|
52
|
+
Generator,
|
|
53
|
+
Iterable,
|
|
54
|
+
)
|
|
55
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
56
|
+
|
|
57
|
+
from agent_trace.core.span import Span, SpanStatus
|
|
58
|
+
|
|
59
|
+
if TYPE_CHECKING:
|
|
60
|
+
from agent_trace import Tracer
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"traced_sse_astream",
|
|
64
|
+
"traced_sse_stream",
|
|
65
|
+
"traced_websocket_send",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
# Bounds — this records enough of each outbound chunk to diagnose a
|
|
69
|
+
# dropped/stalled transport, not a full-fidelity mirror of every byte sent.
|
|
70
|
+
_MAX_CHUNK_PREVIEW_LEN = 500
|
|
71
|
+
_MAX_TRANSPORT_EVENTS = 500
|
|
72
|
+
|
|
73
|
+
T = TypeVar("T")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _stringify_chunk(item: Any) -> str:
|
|
77
|
+
"""Best-effort, bounded preview of an outbound chunk for a SpanEvent
|
|
78
|
+
attribute. Never raises — falls back to a placeholder."""
|
|
79
|
+
try:
|
|
80
|
+
if isinstance(item, bytes):
|
|
81
|
+
text = item.decode("utf-8", errors="replace")
|
|
82
|
+
else:
|
|
83
|
+
text = str(item)
|
|
84
|
+
except Exception:
|
|
85
|
+
return "<unrepresentable-chunk>"
|
|
86
|
+
if len(text) > _MAX_CHUNK_PREVIEW_LEN:
|
|
87
|
+
text = text[:_MAX_CHUNK_PREVIEW_LEN] + "...<truncated>"
|
|
88
|
+
return text
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _record_transport_chunk_sent(span: Span, index: int, item: Any) -> None:
|
|
92
|
+
"""Append one bounded transport_chunk_sent SpanEvent, capped at
|
|
93
|
+
_MAX_TRANSPORT_EVENTS so a very long-lived stream can't grow a single
|
|
94
|
+
span's event list without limit. transport.chunk_count (set at span
|
|
95
|
+
close) still reflects every chunk that was actually sent."""
|
|
96
|
+
if index >= _MAX_TRANSPORT_EVENTS:
|
|
97
|
+
return
|
|
98
|
+
span.add_event(
|
|
99
|
+
"transport_chunk_sent",
|
|
100
|
+
attributes={
|
|
101
|
+
"transport.index": index,
|
|
102
|
+
"transport.chunk_preview": _stringify_chunk(item),
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def traced_sse_stream(
|
|
108
|
+
tracer: Tracer,
|
|
109
|
+
source: Iterable[T],
|
|
110
|
+
*,
|
|
111
|
+
span_name: str = "transport:sse",
|
|
112
|
+
) -> Generator[T, None, None]:
|
|
113
|
+
"""Wrap a synchronous outbound SSE/streaming-response iterable (e.g. the
|
|
114
|
+
body iterator handed to a WSGI/Starlette ``StreamingResponse``) so each
|
|
115
|
+
chunk's actual send moment — and a bounded preview of its content — lands
|
|
116
|
+
on the trace timeline, independent of the in-process LangGraph callback
|
|
117
|
+
layer.
|
|
118
|
+
|
|
119
|
+
Opens one ``transport:sse`` span (customizable via *span_name*) that
|
|
120
|
+
stays open for the lifetime of iteration, closing ``OK`` once the
|
|
121
|
+
source is exhausted (every chunk successfully handed off to the
|
|
122
|
+
transport), ``ERROR`` if the source itself raises (e.g. a broken pipe
|
|
123
|
+
surfaced back into application code) with the exception recorded onto
|
|
124
|
+
the span then re-raised unchanged, or ``CANCELLED`` if the *caller*
|
|
125
|
+
(the ASGI/WSGI server) stops iterating early — the exact shape of a
|
|
126
|
+
dropped/stalled client connection — detected via ``GeneratorExit`` when
|
|
127
|
+
this generator is closed/garbage-collected before exhaustion.
|
|
128
|
+
"""
|
|
129
|
+
span = tracer.start_span(span_name)
|
|
130
|
+
index = 0
|
|
131
|
+
status = SpanStatus.OK
|
|
132
|
+
try:
|
|
133
|
+
for item in source:
|
|
134
|
+
_record_transport_chunk_sent(span, index, item)
|
|
135
|
+
index += 1
|
|
136
|
+
yield item
|
|
137
|
+
except GeneratorExit:
|
|
138
|
+
status = SpanStatus.CANCELLED
|
|
139
|
+
raise
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
status = SpanStatus.ERROR
|
|
142
|
+
span.record_exception(exc)
|
|
143
|
+
raise
|
|
144
|
+
finally:
|
|
145
|
+
span.set_attribute("transport.chunk_count", index)
|
|
146
|
+
if span.end_time is None:
|
|
147
|
+
span.end(status)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def traced_sse_astream(
|
|
151
|
+
tracer: Tracer,
|
|
152
|
+
source: AsyncIterable[T],
|
|
153
|
+
*,
|
|
154
|
+
span_name: str = "transport:sse",
|
|
155
|
+
) -> AsyncGenerator[T, None]:
|
|
156
|
+
"""Async equivalent of :func:`traced_sse_stream` — wraps an async
|
|
157
|
+
outbound SSE generator (e.g. the body passed to FastAPI's
|
|
158
|
+
``StreamingResponse``, or the generator behind ``graph.astream_events``)
|
|
159
|
+
the same way."""
|
|
160
|
+
span = tracer.start_span(span_name)
|
|
161
|
+
index = 0
|
|
162
|
+
status = SpanStatus.OK
|
|
163
|
+
try:
|
|
164
|
+
async for item in source:
|
|
165
|
+
_record_transport_chunk_sent(span, index, item)
|
|
166
|
+
index += 1
|
|
167
|
+
yield item
|
|
168
|
+
except GeneratorExit:
|
|
169
|
+
status = SpanStatus.CANCELLED
|
|
170
|
+
raise
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
status = SpanStatus.ERROR
|
|
173
|
+
span.record_exception(exc)
|
|
174
|
+
raise
|
|
175
|
+
finally:
|
|
176
|
+
span.set_attribute("transport.chunk_count", index)
|
|
177
|
+
if span.end_time is None:
|
|
178
|
+
span.end(status)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def traced_websocket_send(
|
|
182
|
+
tracer: Tracer,
|
|
183
|
+
send_fn: Callable[[T], Awaitable[Any]],
|
|
184
|
+
*,
|
|
185
|
+
span_name: str = "transport:websocket",
|
|
186
|
+
) -> Callable[[T], Awaitable[Any]]:
|
|
187
|
+
"""Wrap an async websocket ``send``-shaped callable (e.g.
|
|
188
|
+
``websocket.send_text``/``send_json``/``send_bytes``) so every outbound
|
|
189
|
+
send is recorded with a timestamp and success/failure status,
|
|
190
|
+
independent of the in-process LangGraph callback layer.
|
|
191
|
+
|
|
192
|
+
Returns a new async callable with the same signature as *send_fn*.
|
|
193
|
+
Opens one dedicated span per call (rather than one long-lived span for
|
|
194
|
+
the whole connection, since a websocket connection's lifetime is not
|
|
195
|
+
naturally scoped the way a single HTTP streaming response's is) —
|
|
196
|
+
closing ``OK`` immediately after a successful send, or ``ERROR`` (with
|
|
197
|
+
the exception recorded, then re-raised unchanged) if the send itself
|
|
198
|
+
raises — e.g. because the client already disconnected.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
async def _wrapped(item: T) -> Any:
|
|
202
|
+
span = tracer.start_span(span_name)
|
|
203
|
+
# _stringify_chunk() is itself defensive (never raises) and
|
|
204
|
+
# set_attribute() on a plain string cannot raise either.
|
|
205
|
+
span.set_attribute("transport.chunk_preview", _stringify_chunk(item))
|
|
206
|
+
try:
|
|
207
|
+
result = await send_fn(item)
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
span.record_exception(exc)
|
|
210
|
+
if span.end_time is None:
|
|
211
|
+
span.end(SpanStatus.ERROR)
|
|
212
|
+
raise
|
|
213
|
+
else:
|
|
214
|
+
if span.end_time is None:
|
|
215
|
+
span.end(SpanStatus.OK)
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
return _wrapped
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent-trace transport interceptors.
|
|
3
|
+
|
|
4
|
+
Each submodule patches one HTTP/RPC/WS client library (httpx, requests,
|
|
5
|
+
aiohttp, botocore/boto3, grpc, websockets, MCP stdio, ...) to record real
|
|
6
|
+
traffic into a ``Fixture`` and/or replay it offline — see
|
|
7
|
+
``httpx_hook.py``'s module docstring for the canonical Recording*/Replay*
|
|
8
|
+
Transport shape every other interceptor here mirrors.
|
|
9
|
+
|
|
10
|
+
Submodules are exported lazily here (PEP 562 module ``__getattr__``) so that
|
|
11
|
+
``import agent_trace.interceptor`` never fails regardless of which optional
|
|
12
|
+
client libraries happen to be installed. Most submodules (httpx_hook.py,
|
|
13
|
+
aiohttp_hook.py, botocore_hook.py, websocket_hook.py, stdio_hook.py) already
|
|
14
|
+
defer their own ``import <library>`` to call time and are always safely
|
|
15
|
+
importable; ``grpc_hook.py`` and ``requests_patch.py`` import their target
|
|
16
|
+
library eagerly (``grpc``/``requests``) since ``Tracer._patch_grpc`` /
|
|
17
|
+
``Tracer._patch_requests`` in ``agent_trace/__init__.py`` already wrap the
|
|
18
|
+
*submodule* import itself in ``try/except ImportError`` — lazy resolution
|
|
19
|
+
here means accessing e.g. ``agent_trace.interceptor.grpc_hook`` only fails
|
|
20
|
+
at that point (with the normal ``ImportError``), not merely by importing
|
|
21
|
+
this package.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import importlib
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from agent_trace.interceptor import (
|
|
31
|
+
aiohttp_hook,
|
|
32
|
+
botocore_hook,
|
|
33
|
+
grpc_hook,
|
|
34
|
+
httpx_hook,
|
|
35
|
+
logging_hook,
|
|
36
|
+
requests_patch,
|
|
37
|
+
sse,
|
|
38
|
+
stdio_hook,
|
|
39
|
+
warnings_hook,
|
|
40
|
+
websocket_hook,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"aiohttp_hook",
|
|
45
|
+
"botocore_hook",
|
|
46
|
+
"grpc_hook",
|
|
47
|
+
"httpx_hook",
|
|
48
|
+
"logging_hook",
|
|
49
|
+
"requests_patch",
|
|
50
|
+
"sse",
|
|
51
|
+
"stdio_hook",
|
|
52
|
+
"warnings_hook",
|
|
53
|
+
"websocket_hook",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def __getattr__(name: str) -> Any:
|
|
58
|
+
if name in __all__:
|
|
59
|
+
return importlib.import_module(f"agent_trace.interceptor.{name}")
|
|
60
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def __dir__() -> list[str]:
|
|
64
|
+
return sorted(__all__)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
aiohttp interceptor for recording HTTP exchanges made via aiohttp.ClientSession.
|
|
3
|
+
|
|
4
|
+
Why a third interceptor (alongside httpx_hook.py / requests_patch.py)?
|
|
5
|
+
LiteLLM — the standard route agent frameworks (crewAI's fallback path,
|
|
6
|
+
LangChain's non-native providers, etc.) use to reach non-OpenAI/Anthropic
|
|
7
|
+
backends such as Gemini/Vertex AI — defaults to routing outbound async calls
|
|
8
|
+
through an aiohttp-based transport (`_should_use_aiohttp_transport()` returns
|
|
9
|
+
True unless explicitly disabled). That traffic never touches
|
|
10
|
+
`httpx.Client`/`httpx.AsyncClient` or `requests.Session` at all, so
|
|
11
|
+
agent-trace's two existing interceptors silently miss it with no error or
|
|
12
|
+
warning.
|
|
13
|
+
|
|
14
|
+
`aiohttp.ClientSession` has no pluggable "transport" object the way httpx
|
|
15
|
+
does, so recording is implemented by wrapping `ClientSession._request` — the
|
|
16
|
+
single coroutine every request-verb helper (`get`/`post`/`request`/...)
|
|
17
|
+
funnels through, and the same method the `aioresponses` mocking library
|
|
18
|
+
itself patches for exactly this reason (see `patch("aiohttp.client.
|
|
19
|
+
ClientSession._request", ...)` in aioresponses' own source).
|
|
20
|
+
|
|
21
|
+
Deliberately NOT a `ClientSession` subclass: the installed aiohttp (3.12+)
|
|
22
|
+
raises `DeprecationWarning: Inheritance class ... from ClientSession is
|
|
23
|
+
discouraged` from `ClientSession.__init_subclass__` for any subclass,
|
|
24
|
+
confirmed by direct introspection of the installed package. `make_recording_
|
|
25
|
+
request` instead builds a plain replacement function for `ClientSession.
|
|
26
|
+
_request` that `Tracer._patch_aiohttp` assigns onto the class directly —
|
|
27
|
+
the same class-level monkey-patch pattern already used for `httpx.Client.
|
|
28
|
+
__init__`/`httpx.AsyncClient.__init__` and `requests.Session.get_adapter`.
|
|
29
|
+
|
|
30
|
+
The replacement wraps the real (or previously patched) `_request()` call,
|
|
31
|
+
eagerly reads the response body (aiohttp caches it on
|
|
32
|
+
`ClientResponse._body`, so callers can still call `.read()`/`.text()`/
|
|
33
|
+
`.json()` afterwards without re-consuming the stream), and persists the
|
|
34
|
+
exchange to the fixture before returning the original response object
|
|
35
|
+
unmodified.
|
|
36
|
+
|
|
37
|
+
Only a recording path is provided here (no replay counterpart): this
|
|
38
|
+
interceptor's job is to stop agent-trace from *silently missing* aiohttp
|
|
39
|
+
traffic during recording. Offline replay of aiohttp-captured fixtures is a
|
|
40
|
+
separate, larger piece of work (constructing a faithful fake
|
|
41
|
+
`aiohttp.ClientResponse` without a live connection) and is not required to
|
|
42
|
+
close the gap this interceptor targets.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import json
|
|
48
|
+
import logging
|
|
49
|
+
from typing import TYPE_CHECKING, Any
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from collections.abc import Awaitable, Callable
|
|
53
|
+
|
|
54
|
+
import aiohttp
|
|
55
|
+
from yarl import URL
|
|
56
|
+
|
|
57
|
+
from agent_trace._replay.fixture import Fixture
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"make_recording_request",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
logger = logging.getLogger(__name__)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _serialize_request_body(kwargs: dict[str, Any]) -> str:
|
|
67
|
+
"""Best-effort reconstruction of the body bytes aiohttp will send.
|
|
68
|
+
|
|
69
|
+
Mirrors RecordingAdapter's (requests_patch.py) bytes/str/else fallback:
|
|
70
|
+
`json=` is serialized the same way `json.dumps` would represent it (the
|
|
71
|
+
exact separators aiohttp's internal `json_serialize` uses can differ,
|
|
72
|
+
but the semantic content — e.g. the model/messages payload a developer
|
|
73
|
+
needs to root-cause a bug — is preserved); `data=` is decoded when it's
|
|
74
|
+
already bytes/str (the common case for SDKs like LiteLLM's aiohttp
|
|
75
|
+
transport, which hand aiohttp pre-serialized JSON bytes); anything else
|
|
76
|
+
(FormData, an IO stream, a dict passed as `data=`) falls back to `str()`
|
|
77
|
+
rather than raising, since raw evidence beats no evidence.
|
|
78
|
+
"""
|
|
79
|
+
body = kwargs.get("json")
|
|
80
|
+
if body is not None:
|
|
81
|
+
try:
|
|
82
|
+
return json.dumps(body)
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
return str(body)
|
|
85
|
+
|
|
86
|
+
data = kwargs.get("data")
|
|
87
|
+
if data is None:
|
|
88
|
+
return ""
|
|
89
|
+
if isinstance(data, bytes):
|
|
90
|
+
return data.decode("utf-8", errors="replace")
|
|
91
|
+
if isinstance(data, str):
|
|
92
|
+
return data
|
|
93
|
+
return str(data)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def _record_exchange(
|
|
97
|
+
fixture: Fixture,
|
|
98
|
+
method: str,
|
|
99
|
+
str_or_url: str | URL,
|
|
100
|
+
kwargs: dict[str, Any],
|
|
101
|
+
response: aiohttp.ClientResponse,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Read *response*'s body and persist the exchange to *fixture*.
|
|
104
|
+
|
|
105
|
+
`response.read()` caches the body on `ClientResponse._body`, so calling
|
|
106
|
+
it here does not prevent the caller from reading the response again via
|
|
107
|
+
`.read()` / `.text()` / `.json()`.
|
|
108
|
+
"""
|
|
109
|
+
await response.read()
|
|
110
|
+
|
|
111
|
+
req_headers = dict(kwargs.get("headers") or {})
|
|
112
|
+
req_body = _serialize_request_body(kwargs)
|
|
113
|
+
|
|
114
|
+
resp_headers = dict(response.headers)
|
|
115
|
+
resp_body = await response.text()
|
|
116
|
+
|
|
117
|
+
fixture.record_exchange(
|
|
118
|
+
url=str(str_or_url),
|
|
119
|
+
method=str(method).upper(),
|
|
120
|
+
request_headers=req_headers,
|
|
121
|
+
request_body=req_body,
|
|
122
|
+
response_status=response.status,
|
|
123
|
+
response_headers=resp_headers,
|
|
124
|
+
response_body=resp_body,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def make_recording_request(
|
|
129
|
+
fixture: Fixture,
|
|
130
|
+
original_request: Callable[..., Awaitable[aiohttp.ClientResponse]],
|
|
131
|
+
) -> Callable[..., Awaitable[aiohttp.ClientResponse]]:
|
|
132
|
+
"""Build a replacement for `aiohttp.ClientSession._request` that records.
|
|
133
|
+
|
|
134
|
+
*original_request* is the real (or previously-patched) `_request`
|
|
135
|
+
method, called through unconditionally so behaviour — redirects, auth,
|
|
136
|
+
proxies, etc. — is unchanged; only the body-eager-read + fixture-record
|
|
137
|
+
step is added around it. Used by `Tracer._patch_aiohttp` to patch
|
|
138
|
+
`aiohttp.ClientSession` at the class level so plain `aiohttp.ClientSession()`
|
|
139
|
+
call sites inside third-party SDKs are captured with zero code changes.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
async def _patched_request(
|
|
143
|
+
session_self: aiohttp.ClientSession,
|
|
144
|
+
method: str,
|
|
145
|
+
str_or_url: str | URL,
|
|
146
|
+
**kwargs: Any,
|
|
147
|
+
) -> aiohttp.ClientResponse:
|
|
148
|
+
response = await original_request(session_self, method, str_or_url, **kwargs)
|
|
149
|
+
await _record_exchange(fixture, method, str_or_url, kwargs, response)
|
|
150
|
+
return response
|
|
151
|
+
|
|
152
|
+
return _patched_request
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
botocore session wrappers for recording and replaying AWS SDK (boto3) HTTP
|
|
3
|
+
exchanges — the botocore equivalent of httpx_hook.py / requests_patch.py.
|
|
4
|
+
|
|
5
|
+
Every boto3 service client (bedrock-runtime, sagemaker-runtime, s3, ...)
|
|
6
|
+
ultimately routes its outbound HTTP request through a single low-level
|
|
7
|
+
object: ``botocore.httpsession.URLLib3Session``, whose ``send(request)``
|
|
8
|
+
method takes an ``AWSPreparedRequest`` (``.method``/``.url``/``.headers``/
|
|
9
|
+
``.body``) and returns an ``AWSResponse`` (``.status_code``/``.headers``/
|
|
10
|
+
``.content``/``.text``) — the same request-in/response-out shape as
|
|
11
|
+
``requests.HTTPAdapter.send`` and ``httpx.BaseTransport.handle_request``.
|
|
12
|
+
|
|
13
|
+
RecordingSession wraps a real session-like object (anything exposing
|
|
14
|
+
``.send(request)``): it lets the request go through, reads the full
|
|
15
|
+
response body, and saves the exchange to the fixture before returning the
|
|
16
|
+
response to the caller.
|
|
17
|
+
|
|
18
|
+
ReplaySession never touches the network: it looks up the next recorded
|
|
19
|
+
response for the requested (method, url) from the fixture, mirroring
|
|
20
|
+
ReplayTransport/ReplayAdapter.
|
|
21
|
+
|
|
22
|
+
Streaming operations (e.g. Bedrock's ``InvokeModel``, whose response body
|
|
23
|
+
is delivered via ``StreamingBody``, or ``InvokeModelWithResponseStream``/
|
|
24
|
+
``ConverseStream``, whose response is an AWS event stream) set
|
|
25
|
+
``request.stream_output = True``. For those, botocore does *not* eagerly
|
|
26
|
+
buffer the response at the urllib3 layer, so ``response.content`` is only
|
|
27
|
+
populated the first time something reads it. RecordingSession still drains
|
|
28
|
+
and records these — mirroring httpx_hook.py's ``response.read()``/
|
|
29
|
+
``response.aread()`` eager-buffering of every exchange, including SSE — but
|
|
30
|
+
afterwards replaces ``response.raw`` with a fresh in-memory buffer over the
|
|
31
|
+
same bytes so downstream body consumption (``StreamingBody.read()``, event
|
|
32
|
+
stream iteration) keeps working transparently. True non-buffering
|
|
33
|
+
pass-through capture for streaming responses is a known follow-up, not
|
|
34
|
+
yet implemented, exactly as it is for the httpx/SSE case.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import io
|
|
40
|
+
import logging
|
|
41
|
+
import warnings
|
|
42
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from agent_trace._replay.fixture import Fixture
|
|
46
|
+
|
|
47
|
+
from agent_trace.core.exceptions import NetworkGuardError, guard_active
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"NetworkGuardError",
|
|
51
|
+
"RecordingSession",
|
|
52
|
+
"ReplaySession",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _SendableSession(Protocol):
|
|
59
|
+
"""Structural type for anything exposing botocore's session.send(request)."""
|
|
60
|
+
|
|
61
|
+
def send(self, request: Any) -> Any: ...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _stringify_headers(headers: Any) -> dict[str, str]:
|
|
65
|
+
"""Coerce a botocore/urllib3 header mapping to a plain ``dict[str, str]``.
|
|
66
|
+
|
|
67
|
+
botocore request headers are commonly a mix of ``bytes`` and ``str``
|
|
68
|
+
values (e.g. ``Content-Type``/``Authorization`` are bytes, while
|
|
69
|
+
``Content-Length`` is str) — confirmed via direct inspection of a real
|
|
70
|
+
signed ``AWSPreparedRequest``. ``Fixture.record_exchange`` JSON-encodes
|
|
71
|
+
the header dict, which fails on bytes values, so normalise everything to
|
|
72
|
+
str here.
|
|
73
|
+
"""
|
|
74
|
+
result: dict[str, str] = {}
|
|
75
|
+
for raw_key, raw_value in dict(headers).items():
|
|
76
|
+
str_key = (
|
|
77
|
+
raw_key.decode("utf-8", errors="replace")
|
|
78
|
+
if isinstance(raw_key, bytes)
|
|
79
|
+
else str(raw_key)
|
|
80
|
+
)
|
|
81
|
+
str_value = (
|
|
82
|
+
raw_value.decode("utf-8", errors="replace")
|
|
83
|
+
if isinstance(raw_value, bytes)
|
|
84
|
+
else str(raw_value)
|
|
85
|
+
)
|
|
86
|
+
result[str_key] = str_value
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _stringify_body(body: Any) -> str:
|
|
91
|
+
"""Coerce a botocore request body (bytes | str | file-like | None) to str."""
|
|
92
|
+
if isinstance(body, bytes):
|
|
93
|
+
return body.decode("utf-8", errors="replace")
|
|
94
|
+
if isinstance(body, str):
|
|
95
|
+
return body
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RecordingSession:
|
|
100
|
+
"""botocore session wrapper that records every exchange to a Fixture.
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
fixture:
|
|
105
|
+
Open Fixture instance where exchanges will be written.
|
|
106
|
+
inner:
|
|
107
|
+
The real session (or session-like object exposing ``.send(request)``)
|
|
108
|
+
to use for outbound requests. Defaults to a fresh
|
|
109
|
+
``botocore.httpsession.URLLib3Session()`` when None.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
fixture: Fixture,
|
|
115
|
+
inner: _SendableSession | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
self._fixture = fixture
|
|
118
|
+
if inner is not None:
|
|
119
|
+
self._inner: _SendableSession = inner
|
|
120
|
+
else:
|
|
121
|
+
import botocore.httpsession
|
|
122
|
+
|
|
123
|
+
self._inner = botocore.httpsession.URLLib3Session()
|
|
124
|
+
|
|
125
|
+
def send(self, request: Any) -> Any:
|
|
126
|
+
"""Forward the request, record the exchange, return the response."""
|
|
127
|
+
response = self._inner.send(request)
|
|
128
|
+
|
|
129
|
+
# Force the response body to be fully read so we can persist it.
|
|
130
|
+
# For non-streaming operations botocore already preloaded this at
|
|
131
|
+
# the urllib3 layer (see URLLib3Session.send), so this is a no-op
|
|
132
|
+
# cache hit; for streaming operations (stream_output=True) this is
|
|
133
|
+
# the first read and drains the live connection.
|
|
134
|
+
content: bytes = response.content
|
|
135
|
+
|
|
136
|
+
if request.stream_output:
|
|
137
|
+
# StreamingBody / EventStream wrap `response.raw` directly
|
|
138
|
+
# rather than `.content` (see
|
|
139
|
+
# botocore.endpoint.convert_to_response_dict). We just drained
|
|
140
|
+
# the real socket above to record it, so hand the caller a
|
|
141
|
+
# fresh in-memory buffer over the same bytes — BytesIO supports
|
|
142
|
+
# the same incremental .read()/.readinto() interface — instead
|
|
143
|
+
# of the now-exhausted urllib3 stream.
|
|
144
|
+
response.raw = io.BytesIO(content)
|
|
145
|
+
|
|
146
|
+
self._fixture.record_exchange(
|
|
147
|
+
url=str(request.url),
|
|
148
|
+
method=str(request.method),
|
|
149
|
+
request_headers=_stringify_headers(request.headers),
|
|
150
|
+
request_body=_stringify_body(request.body),
|
|
151
|
+
response_status=int(response.status_code),
|
|
152
|
+
response_headers=_stringify_headers(response.headers),
|
|
153
|
+
response_body=response.text,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return response
|
|
157
|
+
|
|
158
|
+
def close(self) -> None:
|
|
159
|
+
close = getattr(self._inner, "close", None)
|
|
160
|
+
if close is not None:
|
|
161
|
+
close()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ReplaySession:
|
|
165
|
+
"""botocore session wrapper that serves responses from a Fixture without
|
|
166
|
+
network I/O.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
fixture: Fixture,
|
|
172
|
+
clock: Any | None = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
self._fixture = fixture
|
|
175
|
+
self._clock = clock
|
|
176
|
+
|
|
177
|
+
def send(self, request: Any) -> Any:
|
|
178
|
+
"""Return the next recorded response for *(method, url)*."""
|
|
179
|
+
import botocore.awsrequest
|
|
180
|
+
|
|
181
|
+
url = str(request.url)
|
|
182
|
+
method = str(request.method)
|
|
183
|
+
exchange: dict[str, Any] | None = self._fixture.next_exchange(url, method)
|
|
184
|
+
|
|
185
|
+
if exchange is None:
|
|
186
|
+
if guard_active():
|
|
187
|
+
raise NetworkGuardError(
|
|
188
|
+
f"No recorded exchange for {method} {url} and "
|
|
189
|
+
"AGENT_TRACE_NETWORK_GUARD=1 is set. "
|
|
190
|
+
"Run in recording mode first to capture this request."
|
|
191
|
+
)
|
|
192
|
+
warnings.warn(
|
|
193
|
+
f"agent-trace: no fixture entry for {method} {url}; "
|
|
194
|
+
"falling through to live network. Set AGENT_TRACE_NETWORK_GUARD=1 "
|
|
195
|
+
"to make this an error.",
|
|
196
|
+
stacklevel=2,
|
|
197
|
+
)
|
|
198
|
+
import botocore.httpsession
|
|
199
|
+
|
|
200
|
+
fallback = botocore.httpsession.URLLib3Session()
|
|
201
|
+
try:
|
|
202
|
+
return fallback.send(request)
|
|
203
|
+
finally:
|
|
204
|
+
fallback.close()
|
|
205
|
+
|
|
206
|
+
if self._clock is not None:
|
|
207
|
+
self._clock.advance(float(exchange["recorded_at"]))
|
|
208
|
+
|
|
209
|
+
content = exchange["response_body"].encode("utf-8")
|
|
210
|
+
response = botocore.awsrequest.AWSResponse(
|
|
211
|
+
url,
|
|
212
|
+
int(exchange["response_status"]),
|
|
213
|
+
exchange["response_headers"],
|
|
214
|
+
io.BytesIO(content),
|
|
215
|
+
)
|
|
216
|
+
# AWSResponse.content lazily reads `.raw`; pre-seed the cache so
|
|
217
|
+
# repeated access (ours and the caller's) doesn't require `.raw` to
|
|
218
|
+
# still be positioned at the start.
|
|
219
|
+
response._content = content
|
|
220
|
+
return response
|
|
221
|
+
|
|
222
|
+
def close(self) -> None:
|
|
223
|
+
pass # No resources to release; fixture lifecycle is managed externally.
|