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,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