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.
Files changed (51) hide show
  1. agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
  2. agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
  3. agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
  4. agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
  5. agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
  6. agent_trace/__init__.py +1182 -0
  7. agent_trace/_cli.py +1377 -0
  8. agent_trace/_inspect.py +2020 -0
  9. agent_trace/_replay/__init__.py +1 -0
  10. agent_trace/_replay/engine.py +268 -0
  11. agent_trace/_replay/fixture.py +868 -0
  12. agent_trace/core/__init__.py +1 -0
  13. agent_trace/core/clock.py +91 -0
  14. agent_trace/core/exceptions.py +51 -0
  15. agent_trace/core/span.py +271 -0
  16. agent_trace/core/trace.py +92 -0
  17. agent_trace/exporters/__init__.py +1 -0
  18. agent_trace/exporters/file.py +80 -0
  19. agent_trace/exporters/otlp.py +199 -0
  20. agent_trace/exporters/remote_fixture.py +307 -0
  21. agent_trace/exporters/stdout.py +258 -0
  22. agent_trace/integrations/__init__.py +69 -0
  23. agent_trace/integrations/agno.py +489 -0
  24. agent_trace/integrations/autogen.py +581 -0
  25. agent_trace/integrations/crewai.py +486 -0
  26. agent_trace/integrations/google_genai.py +731 -0
  27. agent_trace/integrations/haystack.py +254 -0
  28. agent_trace/integrations/langchain_core.py +361 -0
  29. agent_trace/integrations/langgraph.py +2093 -0
  30. agent_trace/integrations/langgraph_checkpoint.py +763 -0
  31. agent_trace/integrations/langgraph_state_diff.py +345 -0
  32. agent_trace/integrations/langgraph_stream_debug.py +202 -0
  33. agent_trace/integrations/llama_index.py +472 -0
  34. agent_trace/integrations/mcp.py +281 -0
  35. agent_trace/integrations/openai_agents.py +811 -0
  36. agent_trace/integrations/pydantic_ai.py +504 -0
  37. agent_trace/integrations/streaming.py +218 -0
  38. agent_trace/interceptor/__init__.py +64 -0
  39. agent_trace/interceptor/aiohttp_hook.py +152 -0
  40. agent_trace/interceptor/botocore_hook.py +223 -0
  41. agent_trace/interceptor/grpc_hook.py +651 -0
  42. agent_trace/interceptor/httpx_hook.py +866 -0
  43. agent_trace/interceptor/logging_hook.py +137 -0
  44. agent_trace/interceptor/requests_patch.py +184 -0
  45. agent_trace/interceptor/sse.py +176 -0
  46. agent_trace/interceptor/stdio_hook.py +245 -0
  47. agent_trace/interceptor/warnings_hook.py +132 -0
  48. agent_trace/interceptor/websocket_hook.py +323 -0
  49. agent_trace/plugins/__init__.py +35 -0
  50. agent_trace/plugins/base.py +111 -0
  51. 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.