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,651 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gRPC interceptors for recording and replaying LLM SDK traffic that goes over
|
|
3
|
+
the gRPC transport instead of HTTP (e.g. Vertex AI, and google-generativeai /
|
|
4
|
+
langchain-google-genai on paths that default ``transport`` to ``None``,
|
|
5
|
+
which resolves to ``grpc``/``grpc_asyncio`` rather than REST).
|
|
6
|
+
|
|
7
|
+
Why gRPC needs a module separate from httpx_hook.py / requests_patch.py
|
|
8
|
+
-------------------------------------------------------------------------
|
|
9
|
+
Google's Python client libraries build their gRPC channel via
|
|
10
|
+
``google.api_core.grpc_helpers.create_channel()`` (sync) or
|
|
11
|
+
``grpc_helpers_async.create_channel()`` (async), both of which call
|
|
12
|
+
``grpc.secure_channel(...)`` / ``grpc.aio.secure_channel(...)`` directly --
|
|
13
|
+
verified against the installed ``google-api-core`` package
|
|
14
|
+
(``grpc_helpers.py:378`` and ``grpc_helpers_async.py:307``: both do
|
|
15
|
+
``import grpc`` / ``from grpc import aio`` and then call the module-qualified
|
|
16
|
+
function). That path never touches ``httpx`` or ``requests``, so
|
|
17
|
+
agent-trace's existing interceptors record zero wire-level evidence for it.
|
|
18
|
+
|
|
19
|
+
Interception strategy
|
|
20
|
+
----------------------
|
|
21
|
+
``grpc.insecure_channel`` / ``grpc.secure_channel`` (and their ``grpc.aio``
|
|
22
|
+
equivalents) are plain module-level factory functions, not methods on a
|
|
23
|
+
shared base class the way ``httpx.Client.__init__`` is. We therefore
|
|
24
|
+
monkey-patch the *module attribute* (``grpc.secure_channel = patched``)
|
|
25
|
+
rather than a class method. This intercepts every caller that accesses the
|
|
26
|
+
function through the module object at call time (``import grpc;
|
|
27
|
+
grpc.secure_channel(...)``) -- confirmed to be exactly how
|
|
28
|
+
``google-api-core``'s ``grpc_helpers.py`` calls it. A call site that did
|
|
29
|
+
``from grpc import secure_channel`` before the patch was installed would
|
|
30
|
+
bypass it; no such call site was found in ``google-api-core``.
|
|
31
|
+
|
|
32
|
+
RPC-shape coverage
|
|
33
|
+
-------------------
|
|
34
|
+
Fully recorded/replayed (sync ``grpc`` and async ``grpc.aio``):
|
|
35
|
+
unary-unary (e.g. ``GenerateContent``) -- the shape non-streaming Gemini /
|
|
36
|
+
Vertex AI calls use.
|
|
37
|
+
|
|
38
|
+
Fully recorded/replayed (sync ``grpc`` only): unary-stream (e.g.
|
|
39
|
+
``StreamGenerateContent``) -- the shape streaming Gemini / Vertex AI chat
|
|
40
|
+
calls use.
|
|
41
|
+
|
|
42
|
+
NOT recorded: client-streaming and bidirectional-streaming RPCs.
|
|
43
|
+
``GRPCRecordingInterceptor`` does not implement
|
|
44
|
+
``StreamUnaryClientInterceptor`` / ``StreamStreamClientInterceptor``, so
|
|
45
|
+
``grpc.intercept_channel`` routes those calls straight to the real network,
|
|
46
|
+
unintercepted, per grpc's own fallback behaviour
|
|
47
|
+
(``grpc._interceptor._Channel.stream_unary`` / ``.stream_stream``: "if
|
|
48
|
+
isinstance(self._interceptor, grpc.StreamUnaryClientInterceptor): ... else:
|
|
49
|
+
return thunk(method)"). ``GRPCReplayInterceptor`` *does* implement both, but
|
|
50
|
+
only to raise a clear error rather than silently leaking a real network call
|
|
51
|
+
during what is supposed to be an offline replay. Async (``grpc.aio``)
|
|
52
|
+
streaming of any shape is not covered by this module at all -- Gemini/Vertex
|
|
53
|
+
AI's async streaming path is comparatively rare relative to sync, and out of
|
|
54
|
+
scope for this pass.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import base64
|
|
60
|
+
import json
|
|
61
|
+
import logging
|
|
62
|
+
import warnings
|
|
63
|
+
from typing import TYPE_CHECKING, Any
|
|
64
|
+
|
|
65
|
+
import grpc
|
|
66
|
+
|
|
67
|
+
if TYPE_CHECKING:
|
|
68
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
69
|
+
|
|
70
|
+
from agent_trace._replay.fixture import Fixture
|
|
71
|
+
|
|
72
|
+
from agent_trace.core.exceptions import NetworkGuardError, guard_active
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
"AsyncGRPCRecordingInterceptor",
|
|
76
|
+
"AsyncGRPCReplayInterceptor",
|
|
77
|
+
"GRPCRecordingInterceptor",
|
|
78
|
+
"GRPCReplayInterceptor",
|
|
79
|
+
"NetworkGuardError",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
logger = logging.getLogger(__name__)
|
|
83
|
+
|
|
84
|
+
# Metadata keys used to smuggle protobuf reconstruction info through the
|
|
85
|
+
# fixture's response_headers column (which is otherwise a plain str->str
|
|
86
|
+
# metadata dict). Namespaced so they can never collide with a real gRPC
|
|
87
|
+
# trailing-metadata key (gRPC metadata keys are restricted to
|
|
88
|
+
# `[a-z0-9._-]+`, so an uppercase-and-underscore key like this is not a
|
|
89
|
+
# value a server could ever legitimately send).
|
|
90
|
+
_TYPE_KEY = "X-AGENT-TRACE-GRPC-RESPONSE-TYPE"
|
|
91
|
+
_KIND_KEY = "X-AGENT-TRACE-GRPC-KIND"
|
|
92
|
+
_KIND_UNARY = "unary"
|
|
93
|
+
_KIND_STREAM = "stream"
|
|
94
|
+
|
|
95
|
+
# Fixture.record_exchange()/next_exchange() upper-case whatever is passed as
|
|
96
|
+
# `method` (it's designed around HTTP verbs, which are conventionally
|
|
97
|
+
# upper-cased). A gRPC full method path (e.g.
|
|
98
|
+
# "/agenttrace.test.Echo/UnaryEcho") is case-sensitive, so it must NOT go in
|
|
99
|
+
# the `method` column -- it goes in `url` instead, alongside the target.
|
|
100
|
+
# `method` carries a constant RPC-shape marker instead (itself fine to
|
|
101
|
+
# upper-case, and useful as a coarse filter in fixture inspection tools).
|
|
102
|
+
_METHOD_UNARY_UNARY = "GRPC_UNARY_UNARY"
|
|
103
|
+
_METHOD_UNARY_STREAM = "GRPC_UNARY_STREAM"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _exchange_url(target: str, method_path: str) -> str:
|
|
107
|
+
return f"grpc://{target}{method_path}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# int -> grpc.StatusCode lookup, built once, for reconstructing a status
|
|
111
|
+
# object from the int we persisted (Fixture.record_exchange only accepts a
|
|
112
|
+
# plain int for response_status).
|
|
113
|
+
_STATUS_BY_INT: dict[int, grpc.StatusCode] = {
|
|
114
|
+
code.value[0]: code for code in grpc.StatusCode
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _status_from_int(value: int) -> grpc.StatusCode:
|
|
119
|
+
return _STATUS_BY_INT.get(value, grpc.StatusCode.UNKNOWN)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _method_name(method: bytes | str) -> str:
|
|
123
|
+
"""client_call_details.method is str on sync channels, bytes on aio."""
|
|
124
|
+
if isinstance(method, bytes):
|
|
125
|
+
return method.decode("utf-8", errors="replace")
|
|
126
|
+
return method
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _metadata_to_dict(metadata: Sequence[Any] | None) -> dict[str, str]:
|
|
130
|
+
"""Flatten grpc metadata (list of (key, value) pairs) to a str dict."""
|
|
131
|
+
if not metadata:
|
|
132
|
+
return {}
|
|
133
|
+
result: dict[str, str] = {}
|
|
134
|
+
for item in metadata:
|
|
135
|
+
key, value = item[0], item[1]
|
|
136
|
+
if isinstance(value, bytes):
|
|
137
|
+
value = value.decode("utf-8", errors="replace")
|
|
138
|
+
result[str(key)] = str(value)
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _encode_message(message: Any) -> str:
|
|
143
|
+
"""Serialize a protobuf message to a fixture-storable (base64) string.
|
|
144
|
+
|
|
145
|
+
Protobuf wire bytes are not valid UTF-8 in general, and the fixture's
|
|
146
|
+
body columns are TEXT, so base64 round-trips exactly where a raw
|
|
147
|
+
``.decode("utf-8", errors="replace")`` (as httpx_hook.py uses for JSON
|
|
148
|
+
HTTP bodies) would silently corrupt binary payloads.
|
|
149
|
+
"""
|
|
150
|
+
return base64.b64encode(message.SerializeToString()).decode("ascii")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _lookup_message_class(full_name: str) -> Any:
|
|
154
|
+
"""Resolve a protobuf message class from its fully-qualified proto name.
|
|
155
|
+
|
|
156
|
+
Uses the default SymbolDatabase, which every generated protobuf module
|
|
157
|
+
registers itself into at import time. This works as long as the SDK
|
|
158
|
+
that produced the original message is importable in the replay
|
|
159
|
+
environment too (it must be -- it's how the request message got built
|
|
160
|
+
in the first place).
|
|
161
|
+
"""
|
|
162
|
+
from google.protobuf import symbol_database
|
|
163
|
+
|
|
164
|
+
return symbol_database.Default().GetSymbol(full_name)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _decode_message(data: str, message_cls: Any) -> Any:
|
|
168
|
+
msg = message_cls()
|
|
169
|
+
msg.ParseFromString(base64.b64decode(data))
|
|
170
|
+
return msg
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _build_response_from_exchange(exchange: dict[str, Any]) -> Any:
|
|
174
|
+
type_name = exchange["response_headers"].get(_TYPE_KEY)
|
|
175
|
+
if not type_name:
|
|
176
|
+
raise NetworkGuardError(
|
|
177
|
+
f"agent-trace: recorded gRPC exchange for {exchange['method']} is "
|
|
178
|
+
"missing its response-type marker; it was likely recorded by an "
|
|
179
|
+
"older/incompatible version of grpc_hook.py and cannot be replayed."
|
|
180
|
+
)
|
|
181
|
+
message_cls = _lookup_message_class(type_name)
|
|
182
|
+
return _decode_message(exchange["response_body"], message_cls)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# Sync recording
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _RecordingStreamProxy:
|
|
191
|
+
"""Wraps a real streaming Call so consumption is recorded once it ends.
|
|
192
|
+
|
|
193
|
+
Proxies iteration item-by-item (so the caller still streams normally,
|
|
194
|
+
with no added buffering latency) while collecting every yielded message.
|
|
195
|
+
Once the underlying iterator raises StopIteration, the collected
|
|
196
|
+
messages plus the call's final status/trailing-metadata are persisted to
|
|
197
|
+
the fixture exactly once via *on_complete*. All other attribute access
|
|
198
|
+
(``cancel()``, ``code()`` mid-stream, etc.) is proxied straight through
|
|
199
|
+
to the real call.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
call: Any,
|
|
205
|
+
on_complete: Callable[[list[Any], Any], None],
|
|
206
|
+
) -> None:
|
|
207
|
+
self._call = call
|
|
208
|
+
self._iterator: Iterator[Any] = iter(call)
|
|
209
|
+
self._items: list[Any] = []
|
|
210
|
+
self._on_complete = on_complete
|
|
211
|
+
self._done = False
|
|
212
|
+
|
|
213
|
+
def __iter__(self) -> _RecordingStreamProxy:
|
|
214
|
+
return self
|
|
215
|
+
|
|
216
|
+
def __next__(self) -> Any:
|
|
217
|
+
try:
|
|
218
|
+
item = next(self._iterator)
|
|
219
|
+
except StopIteration:
|
|
220
|
+
if not self._done:
|
|
221
|
+
self._done = True
|
|
222
|
+
self._on_complete(self._items, self._call)
|
|
223
|
+
raise
|
|
224
|
+
else:
|
|
225
|
+
self._items.append(item)
|
|
226
|
+
return item
|
|
227
|
+
|
|
228
|
+
def __getattr__(self, name: str) -> Any:
|
|
229
|
+
return getattr(self._call, name)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class GRPCRecordingInterceptor(
|
|
233
|
+
grpc.UnaryUnaryClientInterceptor, # type: ignore[misc]
|
|
234
|
+
grpc.UnaryStreamClientInterceptor, # type: ignore[misc]
|
|
235
|
+
):
|
|
236
|
+
"""Records every unary-unary and unary-stream gRPC exchange to a Fixture.
|
|
237
|
+
|
|
238
|
+
Install via ``grpc.intercept_channel(channel,
|
|
239
|
+
GRPCRecordingInterceptor(fixture, target))``. Client-streaming/bidi RPCs
|
|
240
|
+
are intentionally not intercepted -- see the module docstring.
|
|
241
|
+
|
|
242
|
+
Parameters
|
|
243
|
+
----------
|
|
244
|
+
fixture:
|
|
245
|
+
Open Fixture instance where exchanges will be written.
|
|
246
|
+
target:
|
|
247
|
+
The channel's target string (e.g. ``"generativelanguage.googleapis.com:443"``),
|
|
248
|
+
stored as the fixture's ``url`` column so replay can look the
|
|
249
|
+
exchange back up by (method, target).
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def __init__(self, fixture: Fixture, target: str) -> None:
|
|
253
|
+
self._fixture = fixture
|
|
254
|
+
self._target = target
|
|
255
|
+
|
|
256
|
+
def intercept_unary_unary(
|
|
257
|
+
self,
|
|
258
|
+
continuation: Callable[[Any, Any], Any],
|
|
259
|
+
client_call_details: Any,
|
|
260
|
+
request: Any,
|
|
261
|
+
) -> Any:
|
|
262
|
+
"""Forward the call, record once it resolves, return it unchanged."""
|
|
263
|
+
call = continuation(client_call_details, request)
|
|
264
|
+
# call.result() blocks until the RPC resolves (or raises grpc.RpcError
|
|
265
|
+
# for a non-OK status). We only persist exchanges that resolve with a
|
|
266
|
+
# concrete response -- mirroring RecordingTransport, which never sees
|
|
267
|
+
# a Response object at all for a transport-level failure.
|
|
268
|
+
response = call.result()
|
|
269
|
+
self._record_unary(client_call_details, request, call, response)
|
|
270
|
+
return call
|
|
271
|
+
|
|
272
|
+
def _record_unary(
|
|
273
|
+
self,
|
|
274
|
+
client_call_details: Any,
|
|
275
|
+
request: Any,
|
|
276
|
+
call: Any,
|
|
277
|
+
response: Any,
|
|
278
|
+
) -> None:
|
|
279
|
+
status = call.code()
|
|
280
|
+
resp_headers = _metadata_to_dict(call.trailing_metadata())
|
|
281
|
+
resp_headers[_TYPE_KEY] = response.DESCRIPTOR.full_name
|
|
282
|
+
resp_headers[_KIND_KEY] = _KIND_UNARY
|
|
283
|
+
self._fixture.record_exchange(
|
|
284
|
+
url=_exchange_url(self._target, _method_name(client_call_details.method)),
|
|
285
|
+
method=_METHOD_UNARY_UNARY,
|
|
286
|
+
request_headers=_metadata_to_dict(client_call_details.metadata),
|
|
287
|
+
request_body=_encode_message(request),
|
|
288
|
+
response_status=status.value[0] if status is not None else 0,
|
|
289
|
+
response_headers=resp_headers,
|
|
290
|
+
response_body=_encode_message(response),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def intercept_unary_stream(
|
|
294
|
+
self,
|
|
295
|
+
continuation: Callable[[Any, Any], Any],
|
|
296
|
+
client_call_details: Any,
|
|
297
|
+
request: Any,
|
|
298
|
+
) -> Any:
|
|
299
|
+
"""Forward the call and record the full stream once it's exhausted."""
|
|
300
|
+
call = continuation(client_call_details, request)
|
|
301
|
+
|
|
302
|
+
def _finish(items: list[Any], real_call: Any) -> None:
|
|
303
|
+
self._record_stream(client_call_details, request, items, real_call)
|
|
304
|
+
|
|
305
|
+
return _RecordingStreamProxy(call, _finish)
|
|
306
|
+
|
|
307
|
+
def _record_stream(
|
|
308
|
+
self,
|
|
309
|
+
client_call_details: Any,
|
|
310
|
+
request: Any,
|
|
311
|
+
items: list[Any],
|
|
312
|
+
call: Any,
|
|
313
|
+
) -> None:
|
|
314
|
+
status = call.code()
|
|
315
|
+
resp_headers = _metadata_to_dict(call.trailing_metadata())
|
|
316
|
+
resp_headers[_KIND_KEY] = _KIND_STREAM
|
|
317
|
+
if items:
|
|
318
|
+
resp_headers[_TYPE_KEY] = items[0].DESCRIPTOR.full_name
|
|
319
|
+
self._fixture.record_exchange(
|
|
320
|
+
url=_exchange_url(self._target, _method_name(client_call_details.method)),
|
|
321
|
+
method=_METHOD_UNARY_STREAM,
|
|
322
|
+
request_headers=_metadata_to_dict(client_call_details.metadata),
|
|
323
|
+
request_body=_encode_message(request),
|
|
324
|
+
response_status=status.value[0] if status is not None else 0,
|
|
325
|
+
response_headers=resp_headers,
|
|
326
|
+
response_body=json.dumps([_encode_message(item) for item in items]),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
# Sync replay
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class _ReplayUnaryCall(grpc.Call, grpc.Future): # type: ignore[misc]
|
|
336
|
+
"""Minimal Call+Future so a replayed unary-unary response satisfies the
|
|
337
|
+
``call.result()`` contract that ``grpc._interceptor._UnaryUnaryMultiCallable``
|
|
338
|
+
unconditionally invokes on whatever ``intercept_unary_unary`` returns.
|
|
339
|
+
|
|
340
|
+
Modelled directly on grpc's own internal ``_interceptor._UnaryOutcome``.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
def __init__(
|
|
344
|
+
self,
|
|
345
|
+
response: Any,
|
|
346
|
+
status_code: int,
|
|
347
|
+
trailing_metadata: Mapping[str, str],
|
|
348
|
+
) -> None:
|
|
349
|
+
self._response = response
|
|
350
|
+
self._status_code = status_code
|
|
351
|
+
self._trailing_metadata = tuple(trailing_metadata.items())
|
|
352
|
+
|
|
353
|
+
def initial_metadata(self) -> tuple[tuple[str, str], ...]:
|
|
354
|
+
return self._trailing_metadata
|
|
355
|
+
|
|
356
|
+
def trailing_metadata(self) -> tuple[tuple[str, str], ...]:
|
|
357
|
+
return self._trailing_metadata
|
|
358
|
+
|
|
359
|
+
def code(self) -> grpc.StatusCode:
|
|
360
|
+
return _status_from_int(self._status_code)
|
|
361
|
+
|
|
362
|
+
def details(self) -> str:
|
|
363
|
+
return ""
|
|
364
|
+
|
|
365
|
+
def is_active(self) -> bool:
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
def time_remaining(self) -> float | None:
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
def cancel(self) -> bool:
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
def cancelled(self) -> bool:
|
|
375
|
+
return False
|
|
376
|
+
|
|
377
|
+
def running(self) -> bool:
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
def done(self) -> bool:
|
|
381
|
+
return True
|
|
382
|
+
|
|
383
|
+
def add_callback(self, callback: Callable[[], None]) -> bool:
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
def result(self, timeout: float | None = None) -> Any:
|
|
387
|
+
return self._response
|
|
388
|
+
|
|
389
|
+
def exception(self, timeout: float | None = None) -> BaseException | None:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
def traceback(self, timeout: float | None = None) -> Any:
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
def add_done_callback(self, fn: Callable[[Any], None]) -> None:
|
|
396
|
+
fn(self)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class GRPCReplayInterceptor(
|
|
400
|
+
grpc.UnaryUnaryClientInterceptor, # type: ignore[misc]
|
|
401
|
+
grpc.UnaryStreamClientInterceptor, # type: ignore[misc]
|
|
402
|
+
grpc.StreamUnaryClientInterceptor, # type: ignore[misc]
|
|
403
|
+
grpc.StreamStreamClientInterceptor, # type: ignore[misc]
|
|
404
|
+
):
|
|
405
|
+
"""Serves unary-unary/unary-stream gRPC calls from a Fixture, no network I/O.
|
|
406
|
+
|
|
407
|
+
Implements ``StreamUnaryClientInterceptor``/``StreamStreamClientInterceptor``
|
|
408
|
+
purely as a safety net: without them, grpc's own fallback would route
|
|
409
|
+
client-streaming/bidi calls straight to the real network during what's
|
|
410
|
+
meant to be an offline replay (see module docstring). Both raise
|
|
411
|
+
immediately instead.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
def __init__(self, fixture: Fixture, target: str) -> None:
|
|
415
|
+
self._fixture = fixture
|
|
416
|
+
self._target = target
|
|
417
|
+
|
|
418
|
+
def _lookup(
|
|
419
|
+
self, client_call_details: Any, kind_method: str
|
|
420
|
+
) -> dict[str, Any] | None:
|
|
421
|
+
url = _exchange_url(self._target, _method_name(client_call_details.method))
|
|
422
|
+
return self._fixture.next_exchange(url, kind_method)
|
|
423
|
+
|
|
424
|
+
def intercept_unary_unary(
|
|
425
|
+
self,
|
|
426
|
+
continuation: Callable[[Any, Any], Any],
|
|
427
|
+
client_call_details: Any,
|
|
428
|
+
request: Any,
|
|
429
|
+
) -> Any:
|
|
430
|
+
exchange = self._lookup(client_call_details, _METHOD_UNARY_UNARY)
|
|
431
|
+
if exchange is None:
|
|
432
|
+
return self._fallback_unary(continuation, client_call_details, request)
|
|
433
|
+
response = _build_response_from_exchange(exchange)
|
|
434
|
+
return _ReplayUnaryCall(
|
|
435
|
+
response,
|
|
436
|
+
int(exchange["response_status"]),
|
|
437
|
+
exchange["response_headers"],
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def _fallback_unary(
|
|
441
|
+
self,
|
|
442
|
+
continuation: Callable[[Any, Any], Any],
|
|
443
|
+
client_call_details: Any,
|
|
444
|
+
request: Any,
|
|
445
|
+
) -> Any:
|
|
446
|
+
if guard_active():
|
|
447
|
+
method = _method_name(client_call_details.method)
|
|
448
|
+
raise NetworkGuardError(
|
|
449
|
+
f"No recorded gRPC exchange for {method} on {self._target} and "
|
|
450
|
+
"AGENT_TRACE_NETWORK_GUARD=1 is set. Run in recording mode "
|
|
451
|
+
"first to capture this call."
|
|
452
|
+
)
|
|
453
|
+
warnings.warn(
|
|
454
|
+
f"agent-trace: no fixture entry for gRPC call "
|
|
455
|
+
f"{_method_name(client_call_details.method)} on {self._target}; "
|
|
456
|
+
"falling through to live network. Set AGENT_TRACE_NETWORK_GUARD=1 "
|
|
457
|
+
"to make this an error.",
|
|
458
|
+
stacklevel=2,
|
|
459
|
+
)
|
|
460
|
+
return continuation(client_call_details, request)
|
|
461
|
+
|
|
462
|
+
def intercept_unary_stream(
|
|
463
|
+
self,
|
|
464
|
+
continuation: Callable[[Any, Any], Any],
|
|
465
|
+
client_call_details: Any,
|
|
466
|
+
request: Any,
|
|
467
|
+
) -> Any:
|
|
468
|
+
exchange = self._lookup(client_call_details, _METHOD_UNARY_STREAM)
|
|
469
|
+
if exchange is None:
|
|
470
|
+
if guard_active():
|
|
471
|
+
raise NetworkGuardError(
|
|
472
|
+
f"No recorded gRPC exchange for "
|
|
473
|
+
f"{_method_name(client_call_details.method)} on {self._target} and "
|
|
474
|
+
"AGENT_TRACE_NETWORK_GUARD=1 is set. Run in recording mode first "
|
|
475
|
+
"to capture this call."
|
|
476
|
+
)
|
|
477
|
+
warnings.warn(
|
|
478
|
+
f"agent-trace: no fixture entry for gRPC stream "
|
|
479
|
+
f"{_method_name(client_call_details.method)} on {self._target}; "
|
|
480
|
+
"falling through to live network. Set AGENT_TRACE_NETWORK_GUARD=1 "
|
|
481
|
+
"to make this an error.",
|
|
482
|
+
stacklevel=2,
|
|
483
|
+
)
|
|
484
|
+
return continuation(client_call_details, request)
|
|
485
|
+
|
|
486
|
+
raw_items: list[str] = json.loads(exchange["response_body"])
|
|
487
|
+
if not raw_items:
|
|
488
|
+
return iter(())
|
|
489
|
+
type_name = exchange["response_headers"].get(_TYPE_KEY)
|
|
490
|
+
message_cls = _lookup_message_class(type_name) if type_name else None
|
|
491
|
+
if message_cls is None:
|
|
492
|
+
raise NetworkGuardError(
|
|
493
|
+
f"agent-trace: recorded gRPC stream exchange for "
|
|
494
|
+
f"{_method_name(client_call_details.method)} is missing its "
|
|
495
|
+
"response-type marker and cannot be replayed."
|
|
496
|
+
)
|
|
497
|
+
return iter(_decode_message(item, message_cls) for item in raw_items)
|
|
498
|
+
|
|
499
|
+
def intercept_stream_unary(
|
|
500
|
+
self,
|
|
501
|
+
continuation: Callable[[Any, Any], Any],
|
|
502
|
+
client_call_details: Any,
|
|
503
|
+
request_iterator: Any,
|
|
504
|
+
) -> Any:
|
|
505
|
+
raise NotImplementedError(
|
|
506
|
+
"agent-trace: gRPC client-streaming replay is not supported yet "
|
|
507
|
+
f"({_method_name(client_call_details.method)}). Recording is not "
|
|
508
|
+
"supported for this RPC shape either, so no fixture data could "
|
|
509
|
+
"exist for it; falling through to the real network during replay "
|
|
510
|
+
"would defeat the point of replay, so this raises instead."
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def intercept_stream_stream(
|
|
514
|
+
self,
|
|
515
|
+
continuation: Callable[[Any, Any], Any],
|
|
516
|
+
client_call_details: Any,
|
|
517
|
+
request_iterator: Any,
|
|
518
|
+
) -> Any:
|
|
519
|
+
raise NotImplementedError(
|
|
520
|
+
"agent-trace: gRPC bidirectional-streaming replay is not "
|
|
521
|
+
f"supported yet ({_method_name(client_call_details.method)}). "
|
|
522
|
+
"Recording is not supported for this RPC shape either, so no "
|
|
523
|
+
"fixture data could exist for it; falling through to the real "
|
|
524
|
+
"network during replay would defeat the point of replay, so "
|
|
525
|
+
"this raises instead."
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# ---------------------------------------------------------------------------
|
|
530
|
+
# Async (grpc.aio) recording / replay -- unary-unary only, see module
|
|
531
|
+
# docstring for why streaming shapes aren't covered here.
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _get_aio_unary_unary_interceptor_base() -> Any:
|
|
536
|
+
"""Lazy import: grpc.aio pulls in the asyncio C-extension bits, which we
|
|
537
|
+
don't want to force-import for consumers who only use sync grpc.
|
|
538
|
+
"""
|
|
539
|
+
from grpc import aio
|
|
540
|
+
|
|
541
|
+
return aio.UnaryUnaryClientInterceptor
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class AsyncGRPCRecordingInterceptor:
|
|
545
|
+
"""Records unary-unary grpc.aio exchanges to a Fixture.
|
|
546
|
+
|
|
547
|
+
Not defined as a direct subclass of ``grpc.aio.UnaryUnaryClientInterceptor``
|
|
548
|
+
at import time so that importing this module never requires grpc's aio
|
|
549
|
+
extra to be importable; the real base class is spliced in via
|
|
550
|
+
:func:`_recording_interceptor_class` the first time an instance is built.
|
|
551
|
+
"""
|
|
552
|
+
|
|
553
|
+
def __new__(cls, fixture: Fixture, target: str) -> AsyncGRPCRecordingInterceptor:
|
|
554
|
+
impl_cls = _recording_interceptor_class()
|
|
555
|
+
return impl_cls(fixture, target) # type: ignore[no-any-return]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
class AsyncGRPCReplayInterceptor:
|
|
559
|
+
"""Serves unary-unary grpc.aio calls from a Fixture, no network I/O."""
|
|
560
|
+
|
|
561
|
+
def __new__(cls, fixture: Fixture, target: str) -> AsyncGRPCReplayInterceptor:
|
|
562
|
+
impl_cls = _replay_interceptor_class()
|
|
563
|
+
return impl_cls(fixture, target) # type: ignore[no-any-return]
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
_AsyncGRPCRecordingImpl: type | None = None
|
|
567
|
+
_AsyncGRPCReplayImpl: type | None = None
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _recording_interceptor_class() -> type:
|
|
571
|
+
global _AsyncGRPCRecordingImpl # noqa: PLW0603
|
|
572
|
+
if _AsyncGRPCRecordingImpl is not None:
|
|
573
|
+
return _AsyncGRPCRecordingImpl
|
|
574
|
+
|
|
575
|
+
base_cls = _get_aio_unary_unary_interceptor_base()
|
|
576
|
+
|
|
577
|
+
class _Impl(base_cls): # type: ignore[misc, valid-type]
|
|
578
|
+
def __init__(self, fixture: Fixture, target: str) -> None:
|
|
579
|
+
self._fixture = fixture
|
|
580
|
+
self._target = target
|
|
581
|
+
|
|
582
|
+
async def intercept_unary_unary(
|
|
583
|
+
self,
|
|
584
|
+
continuation: Callable[[Any, Any], Any],
|
|
585
|
+
client_call_details: Any,
|
|
586
|
+
request: Any,
|
|
587
|
+
) -> Any:
|
|
588
|
+
call = await continuation(client_call_details, request)
|
|
589
|
+
response = await call
|
|
590
|
+
status = await call.code()
|
|
591
|
+
trailing_metadata = await call.trailing_metadata()
|
|
592
|
+
resp_headers = _metadata_to_dict(trailing_metadata)
|
|
593
|
+
resp_headers[_TYPE_KEY] = response.DESCRIPTOR.full_name
|
|
594
|
+
resp_headers[_KIND_KEY] = _KIND_UNARY
|
|
595
|
+
self._fixture.record_exchange(
|
|
596
|
+
url=_exchange_url(
|
|
597
|
+
self._target, _method_name(client_call_details.method)
|
|
598
|
+
),
|
|
599
|
+
method=_METHOD_UNARY_UNARY,
|
|
600
|
+
request_headers=_metadata_to_dict(client_call_details.metadata),
|
|
601
|
+
request_body=_encode_message(request),
|
|
602
|
+
response_status=status.value[0] if status is not None else 0,
|
|
603
|
+
response_headers=resp_headers,
|
|
604
|
+
response_body=_encode_message(response),
|
|
605
|
+
)
|
|
606
|
+
return response
|
|
607
|
+
|
|
608
|
+
_AsyncGRPCRecordingImpl = _Impl
|
|
609
|
+
return _Impl
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _replay_interceptor_class() -> type:
|
|
613
|
+
global _AsyncGRPCReplayImpl # noqa: PLW0603
|
|
614
|
+
if _AsyncGRPCReplayImpl is not None:
|
|
615
|
+
return _AsyncGRPCReplayImpl
|
|
616
|
+
|
|
617
|
+
base_cls = _get_aio_unary_unary_interceptor_base()
|
|
618
|
+
|
|
619
|
+
class _Impl(base_cls): # type: ignore[misc, valid-type]
|
|
620
|
+
def __init__(self, fixture: Fixture, target: str) -> None:
|
|
621
|
+
self._fixture = fixture
|
|
622
|
+
self._target = target
|
|
623
|
+
|
|
624
|
+
async def intercept_unary_unary(
|
|
625
|
+
self,
|
|
626
|
+
continuation: Callable[[Any, Any], Any],
|
|
627
|
+
client_call_details: Any,
|
|
628
|
+
request: Any,
|
|
629
|
+
) -> Any:
|
|
630
|
+
method = _method_name(client_call_details.method)
|
|
631
|
+
url = _exchange_url(self._target, method)
|
|
632
|
+
exchange = self._fixture.next_exchange(url, _METHOD_UNARY_UNARY)
|
|
633
|
+
if exchange is None:
|
|
634
|
+
if guard_active():
|
|
635
|
+
raise NetworkGuardError(
|
|
636
|
+
f"No recorded gRPC exchange for {method} on "
|
|
637
|
+
f"{self._target} and AGENT_TRACE_NETWORK_GUARD=1 is "
|
|
638
|
+
"set. Run in recording mode first to capture this call."
|
|
639
|
+
)
|
|
640
|
+
warnings.warn(
|
|
641
|
+
f"agent-trace: no fixture entry for gRPC call {method} on "
|
|
642
|
+
f"{self._target}; falling through to live network. Set "
|
|
643
|
+
"AGENT_TRACE_NETWORK_GUARD=1 to make this an error.",
|
|
644
|
+
stacklevel=2,
|
|
645
|
+
)
|
|
646
|
+
call = await continuation(client_call_details, request)
|
|
647
|
+
return await call
|
|
648
|
+
return _build_response_from_exchange(exchange)
|
|
649
|
+
|
|
650
|
+
_AsyncGRPCReplayImpl = _Impl
|
|
651
|
+
return _Impl
|