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