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,868 @@
1
+ """
2
+ SQLite-backed HTTP fixture for record/replay.
3
+
4
+ Recording path: the interceptor transports call record_exchange() after each
5
+ real HTTP round-trip. The fixture appends a row with full request/response
6
+ data and a monotonically increasing sequence_num.
7
+
8
+ Replay path: the replay transports call next_exchange() which serves rows in
9
+ sequence_num order, using a per-(method:url) cursor so that the same URL
10
+ called multiple times is replayed in the same order it was recorded.
11
+
12
+ Why SQLite and not JSON files?
13
+ - Concurrent test workers can each open their own fixture file with WAL mode.
14
+ - Large response bodies don't balloon memory — they stay on disk until needed.
15
+ - sequence_num gives a total ordering across all URLs, which is necessary for
16
+ multi-agent traces where two different hosts may be called interleaved.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import itertools
23
+ import json
24
+ import logging
25
+ import sqlite3
26
+ import threading
27
+ import time
28
+ from collections.abc import Callable
29
+ from pathlib import Path
30
+ from types import TracebackType
31
+ from typing import Any
32
+
33
+ __all__ = ["Fixture", "max_inter_chunk_gap_ms", "time_to_first_chunk_ms"]
34
+ # Note: Fixture's diff_response_shapes()/retry_groups()/
35
+ # exchanges_for_correlation_id()/correlation_ids() are public methods on the
36
+ # already-exported Fixture class, not separate module-level names.
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ _SCHEMA = """\
41
+ CREATE TABLE IF NOT EXISTS http_exchanges (
42
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
43
+ trace_id TEXT NOT NULL,
44
+ url TEXT NOT NULL,
45
+ method TEXT NOT NULL,
46
+ request_headers TEXT NOT NULL DEFAULT '{}',
47
+ request_body TEXT NOT NULL DEFAULT '',
48
+ response_status INTEGER,
49
+ response_headers TEXT NOT NULL DEFAULT '{}',
50
+ response_body TEXT NOT NULL DEFAULT '',
51
+ recorded_at REAL NOT NULL,
52
+ sequence_num INTEGER NOT NULL,
53
+ duration_ms REAL,
54
+ error_type TEXT,
55
+ error_message TEXT
56
+ );
57
+ -- Composite index so next_exchange() lookups use the PK order efficiently
58
+ -- instead of scanning and sorting the full table on every call.
59
+ CREATE INDEX IF NOT EXISTS idx_exchanges_method_url_seq
60
+ ON http_exchanges (method, url, sequence_num);
61
+ CREATE TABLE IF NOT EXISTS metadata (
62
+ key TEXT PRIMARY KEY,
63
+ value TEXT NOT NULL
64
+ );
65
+ -- WebSocket frames for persistent duplex sessions (e.g. the OpenAI Agents
66
+ -- SDK's Realtime API). Unlike http_exchanges, a single connection_id can
67
+ -- have many rows in each direction, so replay is served per
68
+ -- (connection_id, direction) rather than per (method, url).
69
+ CREATE TABLE IF NOT EXISTS ws_frames (
70
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
71
+ trace_id TEXT NOT NULL,
72
+ connection_id TEXT NOT NULL,
73
+ url TEXT NOT NULL,
74
+ direction TEXT NOT NULL,
75
+ frame_type TEXT NOT NULL DEFAULT 'text',
76
+ payload TEXT NOT NULL DEFAULT '',
77
+ recorded_at REAL NOT NULL,
78
+ sequence_num INTEGER NOT NULL
79
+ );
80
+ CREATE INDEX IF NOT EXISTS idx_ws_frames_conn_dir_seq
81
+ ON ws_frames (connection_id, direction, sequence_num);
82
+ -- MCP stdio-transport JSON-RPC frames — one row per frame flowing over a
83
+ -- child process's stdin (direction='to_server') or stdout
84
+ -- (direction='from_server'). Distinct from http_exchanges because MCP's
85
+ -- stdio transport has no HTTP request/response pairing: notifications carry
86
+ -- no id, and a single session emits many frames per (server_command, tool).
87
+ CREATE TABLE IF NOT EXISTS mcp_frames (
88
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
89
+ trace_id TEXT NOT NULL,
90
+ server_command TEXT NOT NULL,
91
+ direction TEXT NOT NULL,
92
+ frame_type TEXT NOT NULL,
93
+ rpc_id TEXT,
94
+ method TEXT,
95
+ payload TEXT NOT NULL,
96
+ recorded_at REAL NOT NULL,
97
+ sequence_num INTEGER NOT NULL
98
+ );
99
+ CREATE INDEX IF NOT EXISTS idx_mcp_frames_seq
100
+ ON mcp_frames (sequence_num);
101
+ """
102
+
103
+ # Columns added after the original schema. CREATE TABLE IF NOT EXISTS above
104
+ # only applies to a brand-new database file — a fixture.db created by an
105
+ # older agent-trace version already has an http_exchanges table without
106
+ # these columns, and SQLite has no "ADD COLUMN IF NOT EXISTS". _migrate()
107
+ # adds them defensively, treating "column already exists" as a no-op.
108
+ #
109
+ # response_status's NOT NULL constraint (dropped above, for new databases)
110
+ # cannot be relaxed on an existing table via ALTER TABLE in SQLite — a
111
+ # fixture.db created before this change keeps response_status NOT NULL, so
112
+ # record_exchange()'s failed-before-response path (response_status=None)
113
+ # only works against a fixture.db created under the current schema. This is
114
+ # a deliberate, honest limitation rather than a full table-rebuild
115
+ # migration: pre-existing fixtures keep working exactly as before for their
116
+ # existing (always-succeeded) rows.
117
+ _MIGRATION_COLUMNS: tuple[tuple[str, str], ...] = (
118
+ ("duration_ms", "REAL"),
119
+ ("error_type", "TEXT"),
120
+ ("error_message", "TEXT"),
121
+ # JSON-encoded list of per-chunk arrival offsets (seconds since the
122
+ # request was dispatched), populated only when the exchange was recorded
123
+ # via a streaming/pass-through transport (RecordingTransport(...,
124
+ # stream=True) / AsyncRecordingTransport(..., stream=True)). NULL for
125
+ # every exchange recorded the historical eager-buffering way — absence
126
+ # means "not captured", not "arrived instantly".
127
+ ("chunk_timestamps", "TEXT"),
128
+ # Caller-supplied correlation identifier (e.g. propagated via
129
+ # agent_trace.interceptor.httpx_hook.correlation_context()) tying this
130
+ # exchange back to the concurrent batch input or graph node that
131
+ # produced it. NULL when the caller didn't set one.
132
+ ("correlation_id", "TEXT"),
133
+ # Content-hash of (method, url, request_body) — always computed at
134
+ # write time (see _inspect.content_hash), regardless of caller input.
135
+ # Rows sharing the same attempt_group are attempts of one logical
136
+ # request (e.g. an SDK's own automatic retry-on-5xx), letting a
137
+ # developer see "this logical call took N retries before a 200"
138
+ # without manually sequencing same-URL rows by eye.
139
+ ("attempt_group", "TEXT"),
140
+ )
141
+
142
+
143
+ def _migrate(conn: sqlite3.Connection) -> None:
144
+ """Add any schema columns introduced after the original release to a
145
+ pre-existing http_exchanges table. Safe to call on every open — each
146
+ ALTER TABLE is caught individually so an already-migrated database
147
+ (or a brand-new one where _SCHEMA already created the column) is a
148
+ silent no-op rather than an error."""
149
+ for column, sql_type in _MIGRATION_COLUMNS:
150
+ try:
151
+ conn.execute(f"ALTER TABLE http_exchanges ADD COLUMN {column} {sql_type}")
152
+ except sqlite3.OperationalError as exc:
153
+ if "duplicate column name" not in str(exc).lower():
154
+ raise
155
+
156
+
157
+ def _content_hash(method: str, url: str, request_body: str) -> str:
158
+ """Stable identity for "this is the same logical request" — rows
159
+ sharing this hash are attempts of one logical call (see
160
+ ``Fixture.retry_groups()``). Mirrors
161
+ ``agent_trace._inspect.content_hash`` (duplicated rather than imported
162
+ to keep this module import-cycle-free of the CLI-facing package).
163
+
164
+ sha1 here is a content-identity fingerprint, not a security boundary —
165
+ usedforsecurity=False documents that and avoids FIPS-mode failures.
166
+ """
167
+ digest = hashlib.sha1(usedforsecurity=False)
168
+ digest.update(method.upper().encode("utf-8"))
169
+ digest.update(b"\0")
170
+ digest.update(url.encode("utf-8"))
171
+ digest.update(b"\0")
172
+ digest.update((request_body or "").encode("utf-8"))
173
+ return digest.hexdigest()
174
+
175
+
176
+ def _row_to_exchange(row: sqlite3.Row) -> dict[str, Any]:
177
+ keys = row.keys()
178
+ return {
179
+ "url": row["url"],
180
+ "method": row["method"],
181
+ "request_headers": json.loads(row["request_headers"]),
182
+ "request_body": row["request_body"],
183
+ "response_status": row["response_status"],
184
+ "response_headers": json.loads(row["response_headers"]),
185
+ "response_body": row["response_body"],
186
+ "recorded_at": row["recorded_at"],
187
+ "sequence_num": row["sequence_num"],
188
+ # None on a fixture.db row recorded before these columns existed —
189
+ # callers must treat absence as "unknown", not "zero"/"no error".
190
+ "duration_ms": row["duration_ms"] if "duration_ms" in keys else None,
191
+ "error_type": row["error_type"] if "error_type" in keys else None,
192
+ "error_message": row["error_message"] if "error_message" in keys else None,
193
+ "chunk_timestamps": (
194
+ json.loads(row["chunk_timestamps"])
195
+ if "chunk_timestamps" in keys and row["chunk_timestamps"]
196
+ else None
197
+ ),
198
+ "correlation_id": row["correlation_id"] if "correlation_id" in keys else None,
199
+ "attempt_group": row["attempt_group"] if "attempt_group" in keys else None,
200
+ }
201
+
202
+
203
+ def time_to_first_chunk_ms(exchange: dict[str, Any]) -> float | None:
204
+ """Milliseconds from request dispatch to the first streamed chunk
205
+ arriving, or None if this exchange has no per-chunk timestamps recorded
206
+ (not captured via a streaming transport, or a response with zero body
207
+ chunks)."""
208
+ timestamps = exchange.get("chunk_timestamps")
209
+ if not timestamps:
210
+ return None
211
+ return float(timestamps[0]) * 1000
212
+
213
+
214
+ def max_inter_chunk_gap_ms(exchange: dict[str, Any]) -> float | None:
215
+ """Largest gap (ms) between two consecutive streamed chunks, or None if
216
+ this exchange has no per-chunk timestamps recorded. 0.0 for a single
217
+ chunk (nothing to measure a gap against)."""
218
+ timestamps = exchange.get("chunk_timestamps")
219
+ if not timestamps:
220
+ return None
221
+ if len(timestamps) < 2:
222
+ return 0.0
223
+ gaps = [b - a for a, b in itertools.pairwise(timestamps)]
224
+ return float(max(gaps)) * 1000
225
+
226
+
227
+ def _row_to_ws_frame(row: sqlite3.Row) -> dict[str, Any]:
228
+ return {
229
+ "connection_id": row["connection_id"],
230
+ "url": row["url"],
231
+ "direction": row["direction"],
232
+ "frame_type": row["frame_type"],
233
+ "payload": row["payload"],
234
+ "recorded_at": row["recorded_at"],
235
+ "sequence_num": row["sequence_num"],
236
+ }
237
+
238
+
239
+ def _row_to_mcp_frame(row: sqlite3.Row) -> dict[str, Any]:
240
+ return {
241
+ "server_command": row["server_command"],
242
+ "direction": row["direction"],
243
+ "frame_type": row["frame_type"],
244
+ "rpc_id": row["rpc_id"],
245
+ "method": row["method"],
246
+ "payload": row["payload"],
247
+ "recorded_at": row["recorded_at"],
248
+ "sequence_num": row["sequence_num"],
249
+ }
250
+
251
+
252
+ class Fixture:
253
+ """Thread-safe SQLite-backed HTTP fixture.
254
+
255
+ Parameters
256
+ ----------
257
+ path:
258
+ Filesystem path for the SQLite database. The file is created if it
259
+ does not exist.
260
+ trace_id:
261
+ Optional trace identifier stored in every recorded exchange. Pass an
262
+ empty string (the default) when the trace_id is not yet known.
263
+ on_exchange_recorded:
264
+ Optional callback invoked with the just-recorded exchange dict
265
+ (same shape as an ``all_exchanges()``/``next_exchange()`` entry,
266
+ plus its ``id``) immediately after each successful
267
+ ``record_exchange()`` commit. Wire this to a remote fixture backend
268
+ (see ``agent_trace.exporters.remote_fixture``) to durably persist
269
+ each exchange as it's recorded — so a worker process killed or
270
+ swept mid-run (e.g. on a managed platform, issue #7417) still has
271
+ every exchange recorded up to that point recoverable from remote
272
+ storage, instead of only the local, ephemeral ``fixture.db`` this
273
+ process's filesystem may never be read again. Exceptions raised by
274
+ the callback are caught and logged, never propagated — a remote
275
+ upload failure must not break the local recording it's piggybacking
276
+ on.
277
+ """
278
+
279
+ def __init__(
280
+ self,
281
+ path: Path,
282
+ trace_id: str = "",
283
+ on_exchange_recorded: Callable[[dict[str, Any]], None] | None = None,
284
+ ) -> None:
285
+ self._path = path
286
+ self._trace_id = trace_id
287
+ self._on_exchange_recorded = on_exchange_recorded
288
+ self._lock = threading.Lock()
289
+ self._conn = sqlite3.connect(str(path), check_same_thread=False)
290
+ self._conn.row_factory = sqlite3.Row
291
+ self._conn.execute("PRAGMA journal_mode=WAL")
292
+ self._conn.executescript(_SCHEMA)
293
+ _migrate(self._conn)
294
+ self._conn.commit()
295
+ # Per-(method:url) last-served row id for next_exchange().
296
+ # Stores the `id` of the most recently served row (0 = none yet).
297
+ # Using id > last_id avoids O(n^2) OFFSET scans — each lookup is
298
+ # O(log n) via the composite index on (method, url, sequence_num).
299
+ self._read_cursor: dict[str, int] = {}
300
+ # Per-(connection_id:direction) last-served row id for
301
+ # next_ws_frame(). Same id > last_id strategy as _read_cursor above.
302
+ self._ws_read_cursor: dict[str, int] = {}
303
+
304
+ # ------------------------------------------------------------------
305
+ # Recording
306
+ # ------------------------------------------------------------------
307
+
308
+ def record_exchange(
309
+ self,
310
+ url: str,
311
+ method: str,
312
+ request_headers: dict[str, str],
313
+ request_body: str,
314
+ response_status: int | None = None,
315
+ response_headers: dict[str, str] | None = None,
316
+ response_body: str | None = None,
317
+ *,
318
+ duration_ms: float | None = None,
319
+ error_type: str | None = None,
320
+ error_message: str | None = None,
321
+ chunk_timestamps: list[float] | None = None,
322
+ correlation_id: str | None = None,
323
+ ) -> None:
324
+ """Persist one HTTP round-trip — or one failed-before-response
325
+ attempt — to the fixture database.
326
+
327
+ ``correlation_id``, when provided by the caller (e.g. via
328
+ ``agent_trace.interceptor.httpx_hook.correlation_context()``), ties
329
+ this exchange back to the concurrent batch input or graph node that
330
+ produced it — see ``exchanges_for_correlation_id()`` below.
331
+ ``attempt_group`` is *not* a parameter: it's always computed from
332
+ ``(method, url, request_body)`` at write time (see
333
+ ``agent_trace._inspect.content_hash``) so that repeated calls with
334
+ an identical request signature — the shape an SDK's own automatic
335
+ retry-on-5xx logic produces — are grouped as attempts of one logical
336
+ request with zero caller wiring required. See ``retry_groups()``.
337
+
338
+ Two mutually-exclusive shapes:
339
+
340
+ - A genuine exchange: pass ``response_status``/``response_headers``/
341
+ ``response_body`` (the historical call shape — every existing
342
+ caller already passes these three).
343
+ - A failed-before-response attempt (connection refused, DNS
344
+ failure, TLS failure, a malformed URL raising before any
345
+ ``httpx.Response``/``requests.Response`` exists, ...): pass
346
+ ``error_type``/``error_message`` instead and leave
347
+ ``response_status`` as None. ``request_headers``/``request_body``
348
+ are still recorded since they're fully constructed *before* the
349
+ network call is attempted, so they're always available even when
350
+ the call itself never completes.
351
+
352
+ Raises ``ValueError`` if neither a response nor an error was given —
353
+ every row must be one shape or the other, never neither.
354
+
355
+ Uses time.time() intentionally — we want the *wall-clock* moment the
356
+ exchange was recorded, not the abstract clock. This timestamp is for
357
+ audit/debugging only; replay ordering is driven by sequence_num, not
358
+ recorded_at. ``duration_ms``, when provided, is the caller-measured
359
+ elapsed time for the underlying transport call (dispatch to response,
360
+ or dispatch to the failure being raised) — likewise audit/debugging
361
+ data, not used for replay ordering. ``chunk_timestamps``, when
362
+ provided, is a list of per-chunk arrival offsets (seconds since
363
+ dispatch) for a streamed response recorded via a pass-through
364
+ transport — see ``time_to_first_chunk_ms``/``max_inter_chunk_gap_ms``
365
+ below for reading it back.
366
+ """
367
+ if response_status is None and error_type is None:
368
+ raise ValueError(
369
+ "record_exchange() requires either response_status (a "
370
+ "genuine HTTP response) or error_type (a failed-before-"
371
+ "response attempt) — got neither."
372
+ )
373
+ with self._lock:
374
+ cur = self._conn.execute(
375
+ "SELECT COALESCE(MAX(sequence_num), -1) + 1 FROM http_exchanges"
376
+ )
377
+ row = cur.fetchone()
378
+ next_seq: int = int(row[0])
379
+ attempt_group = _content_hash(method, url, request_body)
380
+
381
+ self._conn.execute(
382
+ """\
383
+ INSERT INTO http_exchanges
384
+ (trace_id, url, method, request_headers, request_body,
385
+ response_status, response_headers, response_body,
386
+ recorded_at, sequence_num, duration_ms, error_type,
387
+ error_message, chunk_timestamps, correlation_id,
388
+ attempt_group)
389
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
390
+ """,
391
+ (
392
+ self._trace_id,
393
+ url,
394
+ method.upper(),
395
+ json.dumps(request_headers),
396
+ request_body,
397
+ response_status,
398
+ json.dumps(response_headers or {}),
399
+ response_body or "",
400
+ time.time(), # wall-clock intentional — see docstring
401
+ next_seq,
402
+ duration_ms,
403
+ error_type,
404
+ error_message,
405
+ (
406
+ json.dumps(chunk_timestamps)
407
+ if chunk_timestamps is not None
408
+ else None
409
+ ),
410
+ correlation_id,
411
+ attempt_group,
412
+ ),
413
+ )
414
+ self._conn.commit()
415
+ id_row = self._conn.execute("SELECT last_insert_rowid()").fetchone()
416
+ recorded_row_id = int(id_row[0])
417
+
418
+ if self._on_exchange_recorded is not None:
419
+ try:
420
+ exchange = {
421
+ "id": recorded_row_id,
422
+ "url": url,
423
+ "method": method.upper(),
424
+ "request_headers": request_headers,
425
+ "request_body": request_body,
426
+ "response_status": response_status,
427
+ "response_headers": response_headers or {},
428
+ "response_body": response_body or "",
429
+ "recorded_at": time.time(),
430
+ "sequence_num": next_seq,
431
+ "duration_ms": duration_ms,
432
+ "error_type": error_type,
433
+ "error_message": error_message,
434
+ "chunk_timestamps": chunk_timestamps,
435
+ "correlation_id": correlation_id,
436
+ "attempt_group": attempt_group,
437
+ }
438
+ self._on_exchange_recorded(exchange)
439
+ except Exception:
440
+ logger.warning(
441
+ "agent-trace: on_exchange_recorded callback raised — "
442
+ "exchange was still recorded locally",
443
+ exc_info=True,
444
+ )
445
+
446
+ # ------------------------------------------------------------------
447
+ # MCP stdio-transport frame recording
448
+ # ------------------------------------------------------------------
449
+
450
+ def record_mcp_frame(
451
+ self,
452
+ server_command: str,
453
+ direction: str,
454
+ frame_type: str,
455
+ rpc_id: str | None,
456
+ method: str | None,
457
+ payload: str,
458
+ ) -> None:
459
+ """Persist one MCP stdio JSON-RPC frame to the fixture database.
460
+
461
+ Parameters
462
+ ----------
463
+ server_command:
464
+ The command (and args) used to launch the MCP server subprocess,
465
+ used to disambiguate frames when multiple servers are recorded
466
+ into the same fixture (e.g. a ``MultiServerMCPClient`` session).
467
+ direction:
468
+ ``"to_server"`` (client stdin) or ``"from_server"`` (client stdout).
469
+ frame_type:
470
+ ``"request"``, ``"response"``, ``"notification"``, or ``"error"``.
471
+ rpc_id:
472
+ The JSON-RPC ``id`` as a string, or None for notifications.
473
+ method:
474
+ The JSON-RPC ``method`` name, or None for responses/errors.
475
+ payload:
476
+ The raw JSON text of the frame, exactly as sent/received on the wire.
477
+ """
478
+ with self._lock:
479
+ cur = self._conn.execute(
480
+ "SELECT COALESCE(MAX(sequence_num), -1) + 1 FROM mcp_frames"
481
+ )
482
+ row = cur.fetchone()
483
+ next_seq: int = int(row[0])
484
+
485
+ self._conn.execute(
486
+ """\
487
+ INSERT INTO mcp_frames
488
+ (trace_id, server_command, direction, frame_type, rpc_id,
489
+ method, payload, recorded_at, sequence_num)
490
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
491
+ """,
492
+ (
493
+ self._trace_id,
494
+ server_command,
495
+ direction,
496
+ frame_type,
497
+ rpc_id,
498
+ method,
499
+ payload,
500
+ time.time(), # wall-clock intentional — see record_exchange
501
+ next_seq,
502
+ ),
503
+ )
504
+ self._conn.commit()
505
+
506
+ def all_mcp_frames(self) -> list[dict[str, Any]]:
507
+ """Return every recorded MCP frame in sequence_num order."""
508
+ with self._lock:
509
+ cur = self._conn.execute(
510
+ """\
511
+ SELECT server_command, direction, frame_type, rpc_id, method,
512
+ payload, recorded_at, sequence_num
513
+ FROM mcp_frames
514
+ ORDER BY sequence_num ASC
515
+ """
516
+ )
517
+ rows = cur.fetchall()
518
+
519
+ return [_row_to_mcp_frame(row) for row in rows]
520
+
521
+ def mcp_frame_count(self) -> int:
522
+ """Return total number of recorded MCP frames."""
523
+ with self._lock:
524
+ cur = self._conn.execute("SELECT COUNT(*) FROM mcp_frames")
525
+ row = cur.fetchone()
526
+ return int(row[0]) if row else 0
527
+
528
+ # ------------------------------------------------------------------
529
+ # Replay
530
+ # ------------------------------------------------------------------
531
+
532
+ def next_exchange(self, url: str, method: str) -> dict[str, Any] | None:
533
+ """Return the next recorded exchange for *(method, url)* or None.
534
+
535
+ Exchanges are served in the order they were recorded (ascending
536
+ sequence_num). Each (method:url) key maintains its own row-id cursor
537
+ so that the same URL called multiple times replays responses in order.
538
+
539
+ Uses ``id > last_served_id`` instead of OFFSET so each call is O(log n)
540
+ via the composite index — not O(n) like OFFSET would be.
541
+ """
542
+ key = f"{method.upper()}:{url}"
543
+ with self._lock:
544
+ last_id = self._read_cursor.get(key, 0)
545
+ cur = self._conn.execute(
546
+ """\
547
+ SELECT id, url, method, request_headers, request_body,
548
+ response_status, response_headers, response_body,
549
+ recorded_at, sequence_num, duration_ms, error_type,
550
+ error_message, chunk_timestamps, correlation_id,
551
+ attempt_group
552
+ FROM http_exchanges
553
+ WHERE method = ? AND url = ? AND id > ?
554
+ ORDER BY id ASC
555
+ LIMIT 1
556
+ """,
557
+ (method.upper(), url, last_id),
558
+ )
559
+ row = cur.fetchone()
560
+ if row is None:
561
+ return None
562
+
563
+ self._read_cursor[key] = int(row["id"])
564
+ return _row_to_exchange(row)
565
+
566
+ # ------------------------------------------------------------------
567
+ # Inspection helpers
568
+ # ------------------------------------------------------------------
569
+
570
+ def all_exchanges(self) -> list[dict[str, Any]]:
571
+ """Return every recorded exchange in sequence_num order."""
572
+ with self._lock:
573
+ cur = self._conn.execute(
574
+ """\
575
+ SELECT url, method, request_headers, request_body,
576
+ response_status, response_headers, response_body,
577
+ recorded_at, sequence_num, duration_ms, error_type,
578
+ error_message, chunk_timestamps, correlation_id,
579
+ attempt_group
580
+ FROM http_exchanges
581
+ ORDER BY sequence_num ASC
582
+ """
583
+ )
584
+ rows = cur.fetchall()
585
+
586
+ return [_row_to_exchange(row) for row in rows]
587
+
588
+ def diff_response_shapes(self, url: str) -> list[set[str]]:
589
+ """Return the distinct sets of top-level JSON response keys seen
590
+ across every recorded exchange to *url*, so a developer can spot
591
+ "this endpoint sometimes returns shape A, sometimes shape B"
592
+ without manually looping over ``all_exchanges()`` and diffing
593
+ ``dict.keys()`` by hand (#3994).
594
+
595
+ Only exchanges whose ``response_body`` is a JSON object are
596
+ considered — a non-JSON or non-dict body is silently skipped rather
597
+ than raised on. Returns an empty list if *url* was never called, or
598
+ if every response for it failed to parse as a JSON object.
599
+ """
600
+ shapes: list[set[str]] = []
601
+ seen: list[frozenset[str]] = []
602
+ for exchange in self.all_exchanges():
603
+ if exchange["url"] != url:
604
+ continue
605
+ try:
606
+ body = json.loads(exchange["response_body"])
607
+ except (json.JSONDecodeError, TypeError):
608
+ continue
609
+ if not isinstance(body, dict):
610
+ continue
611
+ key_set = frozenset(body.keys())
612
+ if key_set not in seen:
613
+ seen.append(key_set)
614
+ shapes.append(set(key_set))
615
+ return shapes
616
+
617
+ def retry_groups(self) -> dict[str, list[dict[str, Any]]]:
618
+ """Return ``{attempt_group: [exchanges]}`` for every attempt_group
619
+ with more than one recorded exchange — i.e. every logical request
620
+ that was attempted more than once (typically an SDK's own automatic
621
+ retry-on-5xx logic), each list ordered by sequence_num.
622
+
623
+ ``attempt_group`` is a content-hash of ``(method, url,
624
+ request_body)`` computed automatically at record time — this
625
+ requires no caller wiring; identical repeated requests are grouped
626
+ for free. See #5508.
627
+ """
628
+ groups: dict[str, list[dict[str, Any]]] = {}
629
+ for exchange in self.all_exchanges():
630
+ group = exchange.get("attempt_group")
631
+ if not group:
632
+ continue
633
+ groups.setdefault(group, []).append(exchange)
634
+ return {group: rows for group, rows in groups.items() if len(rows) > 1}
635
+
636
+ def exchanges_for_correlation_id(self, correlation_id: str) -> list[dict[str, Any]]:
637
+ """Return every recorded exchange tagged with *correlation_id* (see
638
+ ``agent_trace.interceptor.httpx_hook.correlation_context()``), in
639
+ sequence_num order — the exchanges that belong to one concurrent
640
+ batch input or graph node (#30924, #6037)."""
641
+ return [
642
+ e for e in self.all_exchanges() if e.get("correlation_id") == correlation_id
643
+ ]
644
+
645
+ def correlation_ids(self) -> list[str]:
646
+ """Return the distinct, non-null correlation_ids recorded in this
647
+ fixture, in first-seen (sequence_num) order."""
648
+ seen: list[str] = []
649
+ for exchange in self.all_exchanges():
650
+ cid = exchange.get("correlation_id")
651
+ if cid and cid not in seen:
652
+ seen.append(cid)
653
+ return seen
654
+
655
+ def reset_read_cursor(self) -> None:
656
+ """Reset all per-(method:url) read offsets to 0.
657
+
658
+ Call this at the start of each replay so the same fixture can be
659
+ replayed multiple times within one process lifetime.
660
+ """
661
+ with self._lock:
662
+ self._read_cursor.clear()
663
+
664
+ def exchange_count(self) -> int:
665
+ """Return total number of recorded exchanges."""
666
+ with self._lock:
667
+ cur = self._conn.execute("SELECT COUNT(*) FROM http_exchanges")
668
+ row = cur.fetchone()
669
+ return int(row[0]) if row else 0
670
+
671
+ def failed_exchange_count(self) -> int:
672
+ """Return the number of recorded failed-before-response attempts —
673
+ rows with no response_status (connection refused, DNS failure, a
674
+ malformed URL, ...), distinct from a genuine HTTP 4xx/5xx response.
675
+ """
676
+ with self._lock:
677
+ cur = self._conn.execute(
678
+ "SELECT COUNT(*) FROM http_exchanges WHERE response_status IS NULL"
679
+ )
680
+ row = cur.fetchone()
681
+ return int(row[0]) if row else 0
682
+
683
+ def earliest_timestamp(self) -> float | None:
684
+ """Return the earliest recorded_at timestamp, or None if empty."""
685
+ with self._lock:
686
+ cur = self._conn.execute("SELECT MIN(recorded_at) FROM http_exchanges")
687
+ row = cur.fetchone()
688
+ return float(row[0]) if row and row[0] is not None else None
689
+
690
+ # ------------------------------------------------------------------
691
+ # WebSocket frames (persistent duplex sessions, e.g. Realtime API)
692
+ # ------------------------------------------------------------------
693
+
694
+ def record_ws_frame(
695
+ self,
696
+ connection_id: str,
697
+ url: str,
698
+ direction: str,
699
+ payload: str,
700
+ frame_type: str = "text",
701
+ ) -> None:
702
+ """Persist one WebSocket frame to the fixture database.
703
+
704
+ Parameters
705
+ ----------
706
+ connection_id:
707
+ Identifier for the logical WS connection this frame belongs to
708
+ (a single fixture can hold frames from multiple connections/
709
+ sessions recorded in the same trace).
710
+ direction:
711
+ ``"send"`` for a frame the client sent, ``"recv"`` for a frame
712
+ the client received.
713
+ frame_type:
714
+ ``"text"`` or ``"binary"``. Binary payloads are stored as
715
+ UTF-8-decoded text (errors="replace"), matching how the
716
+ recording interceptor prepares them before this call.
717
+ """
718
+ with self._lock:
719
+ cur = self._conn.execute(
720
+ "SELECT COALESCE(MAX(sequence_num), -1) + 1 FROM ws_frames"
721
+ )
722
+ row = cur.fetchone()
723
+ next_seq: int = int(row[0])
724
+
725
+ self._conn.execute(
726
+ """\
727
+ INSERT INTO ws_frames
728
+ (trace_id, connection_id, url, direction, frame_type,
729
+ payload, recorded_at, sequence_num)
730
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
731
+ """,
732
+ (
733
+ self._trace_id,
734
+ connection_id,
735
+ url,
736
+ direction,
737
+ frame_type,
738
+ payload,
739
+ time.time(), # wall-clock intentional — see record_exchange
740
+ next_seq,
741
+ ),
742
+ )
743
+ self._conn.commit()
744
+
745
+ def next_ws_frame(
746
+ self, connection_id: str, direction: str
747
+ ) -> dict[str, Any] | None:
748
+ """Return the next recorded frame for *(connection_id, direction)* or None.
749
+
750
+ Frames are served in the order they were recorded (ascending
751
+ sequence_num), independently for each direction, so a replayed
752
+ session sees its inbound ("recv") frames in original order without
753
+ being coupled to how many frames were sent in between.
754
+ """
755
+ key = f"{connection_id}:{direction}"
756
+ with self._lock:
757
+ last_id = self._ws_read_cursor.get(key, 0)
758
+ cur = self._conn.execute(
759
+ """\
760
+ SELECT id, connection_id, url, direction, frame_type,
761
+ payload, recorded_at, sequence_num
762
+ FROM ws_frames
763
+ WHERE connection_id = ? AND direction = ? AND id > ?
764
+ ORDER BY id ASC
765
+ LIMIT 1
766
+ """,
767
+ (connection_id, direction, last_id),
768
+ )
769
+ row = cur.fetchone()
770
+ if row is None:
771
+ return None
772
+
773
+ self._ws_read_cursor[key] = int(row["id"])
774
+ return _row_to_ws_frame(row)
775
+
776
+ def all_ws_frames(self, connection_id: str | None = None) -> list[dict[str, Any]]:
777
+ """Return recorded WS frames in sequence_num order.
778
+
779
+ Pass *connection_id* to filter to a single connection; omit it to
780
+ return every frame across every connection recorded in this fixture.
781
+ """
782
+ with self._lock:
783
+ if connection_id is None:
784
+ cur = self._conn.execute(
785
+ """\
786
+ SELECT connection_id, url, direction, frame_type,
787
+ payload, recorded_at, sequence_num
788
+ FROM ws_frames
789
+ ORDER BY sequence_num ASC
790
+ """
791
+ )
792
+ else:
793
+ cur = self._conn.execute(
794
+ """\
795
+ SELECT connection_id, url, direction, frame_type,
796
+ payload, recorded_at, sequence_num
797
+ FROM ws_frames
798
+ WHERE connection_id = ?
799
+ ORDER BY sequence_num ASC
800
+ """,
801
+ (connection_id,),
802
+ )
803
+ rows = cur.fetchall()
804
+
805
+ return [_row_to_ws_frame(row) for row in rows]
806
+
807
+ def reset_ws_read_cursor(self) -> None:
808
+ """Reset all per-(connection_id:direction) read offsets to 0.
809
+
810
+ Call this at the start of each replay so the same fixture can be
811
+ replayed multiple times within one process lifetime.
812
+ """
813
+ with self._lock:
814
+ self._ws_read_cursor.clear()
815
+
816
+ def ws_frame_count(self, connection_id: str | None = None) -> int:
817
+ """Return total number of recorded WS frames, optionally per-connection."""
818
+ with self._lock:
819
+ if connection_id is None:
820
+ cur = self._conn.execute("SELECT COUNT(*) FROM ws_frames")
821
+ else:
822
+ cur = self._conn.execute(
823
+ "SELECT COUNT(*) FROM ws_frames WHERE connection_id = ?",
824
+ (connection_id,),
825
+ )
826
+ row = cur.fetchone()
827
+ return int(row[0]) if row else 0
828
+
829
+ # ------------------------------------------------------------------
830
+ # Metadata helpers
831
+ # ------------------------------------------------------------------
832
+
833
+ def set_metadata(self, key: str, value: str) -> None:
834
+ """Upsert a key/value pair in the metadata table."""
835
+ with self._lock:
836
+ self._conn.execute(
837
+ "INSERT INTO metadata (key, value) VALUES (?, ?) "
838
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
839
+ (key, value),
840
+ )
841
+ self._conn.commit()
842
+
843
+ def get_metadata(self, key: str) -> str | None:
844
+ """Return the stored value for *key*, or None if absent."""
845
+ with self._lock:
846
+ cur = self._conn.execute("SELECT value FROM metadata WHERE key = ?", (key,))
847
+ row = cur.fetchone()
848
+ return str(row["value"]) if row else None
849
+
850
+ # ------------------------------------------------------------------
851
+ # Lifecycle
852
+ # ------------------------------------------------------------------
853
+
854
+ def close(self) -> None:
855
+ """Close the underlying SQLite connection."""
856
+ with self._lock:
857
+ self._conn.close()
858
+
859
+ def __enter__(self) -> Fixture:
860
+ return self
861
+
862
+ def __exit__(
863
+ self,
864
+ exc_type: type[BaseException] | None,
865
+ exc_val: BaseException | None,
866
+ exc_tb: TracebackType | None,
867
+ ) -> None:
868
+ self.close()