traffic-diff 0.1.0__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.
- shadowdiff/__init__.py +3 -0
- shadowdiff/cli.py +131 -0
- shadowdiff/config.py +57 -0
- shadowdiff/diff.py +240 -0
- shadowdiff/proxy.py +191 -0
- shadowdiff/replay.py +141 -0
- shadowdiff/report.py +299 -0
- shadowdiff/store.py +242 -0
- traffic_diff-0.1.0.dist-info/METADATA +207 -0
- traffic_diff-0.1.0.dist-info/RECORD +13 -0
- traffic_diff-0.1.0.dist-info/WHEEL +4 -0
- traffic_diff-0.1.0.dist-info/entry_points.txt +2 -0
- traffic_diff-0.1.0.dist-info/licenses/LICENSE +21 -0
shadowdiff/replay.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Async replay of recorded exchanges against a target, plus run orchestration."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from shadowdiff.config import Config
|
|
11
|
+
from shadowdiff.diff import compare
|
|
12
|
+
from shadowdiff.proxy import HOP_BY_HOP, STRIP_FROM_RESPONSE, strip_headers
|
|
13
|
+
from shadowdiff.store import Exchange, Result, Store
|
|
14
|
+
|
|
15
|
+
DEFAULT_TIMEOUT = 10.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Replayed:
|
|
20
|
+
exchange_id: int
|
|
21
|
+
status: int | None
|
|
22
|
+
headers: dict[str, str]
|
|
23
|
+
body: bytes | None
|
|
24
|
+
latency_ms: float | None
|
|
25
|
+
error: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def replay_exchanges(
|
|
29
|
+
exchanges: list[Exchange],
|
|
30
|
+
target: str,
|
|
31
|
+
concurrency: int = 20,
|
|
32
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
33
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
34
|
+
) -> list[Replayed]:
|
|
35
|
+
"""Send each recorded request to TARGET; failures are captured, never raised."""
|
|
36
|
+
sem = asyncio.Semaphore(concurrency)
|
|
37
|
+
# One single-connection client per concurrency slot. A single httpx client
|
|
38
|
+
# with a large pool spends CPU per request proportional to the pool size,
|
|
39
|
+
# which capped replay at ~250 req/s at concurrency 50; small pools scale.
|
|
40
|
+
limits = httpx.Limits(max_connections=1, max_keepalive_connections=1)
|
|
41
|
+
async with contextlib.AsyncExitStack() as stack:
|
|
42
|
+
free = [
|
|
43
|
+
await stack.enter_async_context(
|
|
44
|
+
httpx.AsyncClient(
|
|
45
|
+
base_url=target.rstrip("/"), timeout=timeout, limits=limits, transport=transport
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
for _ in range(min(concurrency, len(exchanges)))
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
async def one(ex: Exchange) -> Replayed:
|
|
52
|
+
url = ex.path + (f"?{ex.query}" if ex.query else "")
|
|
53
|
+
async with sem:
|
|
54
|
+
# The semaphore guarantees a free client is available.
|
|
55
|
+
client = free.pop()
|
|
56
|
+
start = time.perf_counter()
|
|
57
|
+
try:
|
|
58
|
+
resp = await client.request(
|
|
59
|
+
ex.method,
|
|
60
|
+
url,
|
|
61
|
+
headers=strip_headers(ex.req_headers, HOP_BY_HOP),
|
|
62
|
+
content=ex.req_body or None,
|
|
63
|
+
)
|
|
64
|
+
except httpx.TimeoutException:
|
|
65
|
+
return Replayed(ex.id, None, {}, None, None, f"timeout after {timeout:g}s")
|
|
66
|
+
except httpx.HTTPError as exc:
|
|
67
|
+
return Replayed(ex.id, None, {}, None, None, f"{type(exc).__name__}: {exc}")
|
|
68
|
+
finally:
|
|
69
|
+
free.append(client)
|
|
70
|
+
latency_ms = (time.perf_counter() - start) * 1000
|
|
71
|
+
return Replayed(
|
|
72
|
+
ex.id,
|
|
73
|
+
resp.status_code,
|
|
74
|
+
strip_headers(resp.headers, STRIP_FROM_RESPONSE),
|
|
75
|
+
resp.content,
|
|
76
|
+
latency_ms,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return await asyncio.gather(*(one(ex) for ex in exchanges))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class RunSummary:
|
|
84
|
+
run_id: int
|
|
85
|
+
total: int
|
|
86
|
+
passed: int
|
|
87
|
+
failed: int
|
|
88
|
+
replay_seconds: float
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def requests_per_second(self) -> float:
|
|
92
|
+
return self.total / self.replay_seconds if self.replay_seconds else 0.0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run_replay(
|
|
96
|
+
store: Store,
|
|
97
|
+
session: str,
|
|
98
|
+
target: str,
|
|
99
|
+
config: Config,
|
|
100
|
+
concurrency: int = 20,
|
|
101
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
102
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
103
|
+
) -> RunSummary:
|
|
104
|
+
"""Replay a recorded session against TARGET, diff every response and store the run."""
|
|
105
|
+
exchanges = store.exchanges(session)
|
|
106
|
+
if not exchanges:
|
|
107
|
+
raise ValueError(f"no recorded exchanges for session {session!r}")
|
|
108
|
+
run_id = store.create_run(session, target, concurrency, total=len(exchanges))
|
|
109
|
+
|
|
110
|
+
start = time.perf_counter()
|
|
111
|
+
try:
|
|
112
|
+
replayed = asyncio.run(replay_exchanges(exchanges, target, concurrency, timeout, transport))
|
|
113
|
+
except Exception:
|
|
114
|
+
store.finish_run(run_id, "failed", done=0, passed=0, failed=0)
|
|
115
|
+
raise
|
|
116
|
+
replay_seconds = time.perf_counter() - start
|
|
117
|
+
|
|
118
|
+
results = []
|
|
119
|
+
for ex, rep in zip(exchanges, replayed, strict=True):
|
|
120
|
+
if rep.error is not None:
|
|
121
|
+
diffs, breaking = [], True
|
|
122
|
+
else:
|
|
123
|
+
diffs = compare(ex.status, ex.resp_body, rep.status, rep.body, config)
|
|
124
|
+
breaking = any(d.breaking for d in diffs)
|
|
125
|
+
results.append(
|
|
126
|
+
Result(
|
|
127
|
+
exchange_id=ex.id,
|
|
128
|
+
status=rep.status,
|
|
129
|
+
resp_body=rep.body,
|
|
130
|
+
latency_ms=rep.latency_ms,
|
|
131
|
+
error=rep.error,
|
|
132
|
+
diffs=[d.to_dict() for d in diffs],
|
|
133
|
+
breaking=breaking,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
store.add_results(run_id, results)
|
|
137
|
+
|
|
138
|
+
failed = sum(r.breaking for r in results)
|
|
139
|
+
passed = len(results) - failed
|
|
140
|
+
store.finish_run(run_id, "done", done=len(results), passed=passed, failed=failed)
|
|
141
|
+
return RunSummary(run_id, len(results), passed, failed, replay_seconds)
|
shadowdiff/report.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Grouped regression report for a replay run, rendered as Markdown or HTML."""
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from shadowdiff.diff import normalize_path
|
|
12
|
+
from shadowdiff.store import Exchange, Result, Run, Store
|
|
13
|
+
|
|
14
|
+
_ID_SEGMENT = re.compile(
|
|
15
|
+
r"^(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{24,})$",
|
|
16
|
+
re.IGNORECASE,
|
|
17
|
+
)
|
|
18
|
+
_EXAMPLE_CHARS = 80
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def endpoint_template(method: str, path: str) -> str:
|
|
22
|
+
"""'GET /products/42' -> 'GET /products/{id}'."""
|
|
23
|
+
segments = ["{id}" if _ID_SEGMENT.match(s) else s for s in path.split("/")]
|
|
24
|
+
return f"{method} {'/'.join(segments)}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class DiffGroup:
|
|
29
|
+
endpoint: str
|
|
30
|
+
path: str
|
|
31
|
+
kind: str
|
|
32
|
+
breaking: bool
|
|
33
|
+
detail: str
|
|
34
|
+
count: int = 0
|
|
35
|
+
example_url: str = ""
|
|
36
|
+
example_old: Any = None
|
|
37
|
+
example_new: Any = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class EndpointStats:
|
|
42
|
+
requests: int = 0
|
|
43
|
+
passed: int = 0
|
|
44
|
+
failed: int = 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Report:
|
|
49
|
+
run: Run
|
|
50
|
+
breaking: list[DiffGroup] = field(default_factory=list)
|
|
51
|
+
warnings: list[DiffGroup] = field(default_factory=list)
|
|
52
|
+
errors: dict[str, int] = field(default_factory=dict)
|
|
53
|
+
endpoints: dict[str, EndpointStats] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def exit_code(self) -> int:
|
|
57
|
+
return 1 if self.run.failed else 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_report(store: Store, run_id: int) -> Report:
|
|
61
|
+
run = store.run(run_id)
|
|
62
|
+
if run is None:
|
|
63
|
+
raise ValueError(f"run {run_id} not found")
|
|
64
|
+
results = store.results(run_id)
|
|
65
|
+
exchanges = store.exchanges_by_id([r.exchange_id for r in results])
|
|
66
|
+
return group_results(run, results, exchanges)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def group_results(run: Run, results: list[Result], exchanges: dict[int, Exchange]) -> Report:
|
|
70
|
+
groups: dict[tuple, DiffGroup] = {}
|
|
71
|
+
errors: dict[str, int] = defaultdict(int)
|
|
72
|
+
endpoints: dict[str, EndpointStats] = defaultdict(EndpointStats)
|
|
73
|
+
|
|
74
|
+
for result in results:
|
|
75
|
+
ex = exchanges[result.exchange_id]
|
|
76
|
+
endpoint = endpoint_template(ex.method, ex.path)
|
|
77
|
+
stats = endpoints[endpoint]
|
|
78
|
+
stats.requests += 1
|
|
79
|
+
if result.breaking:
|
|
80
|
+
stats.failed += 1
|
|
81
|
+
else:
|
|
82
|
+
stats.passed += 1
|
|
83
|
+
if result.error:
|
|
84
|
+
errors[result.error] += 1
|
|
85
|
+
|
|
86
|
+
# Count each group at most once per request, even if a list repeats the diff.
|
|
87
|
+
seen: set[tuple] = set()
|
|
88
|
+
for d in result.diffs:
|
|
89
|
+
detail = _detail(d)
|
|
90
|
+
key = (endpoint, normalize_path(d["path"]), d["kind"], detail)
|
|
91
|
+
if key in seen:
|
|
92
|
+
continue
|
|
93
|
+
seen.add(key)
|
|
94
|
+
group = groups.get(key)
|
|
95
|
+
if group is None:
|
|
96
|
+
group = groups[key] = DiffGroup(
|
|
97
|
+
endpoint=endpoint,
|
|
98
|
+
path=key[1],
|
|
99
|
+
kind=d["kind"],
|
|
100
|
+
breaking=d["breaking"],
|
|
101
|
+
detail=detail,
|
|
102
|
+
example_url=ex.path + (f"?{ex.query}" if ex.query else ""),
|
|
103
|
+
example_old=d.get("old"),
|
|
104
|
+
example_new=d.get("new"),
|
|
105
|
+
)
|
|
106
|
+
group.count += 1
|
|
107
|
+
|
|
108
|
+
ordered = sorted(groups.values(), key=lambda g: (-g.count, g.endpoint, g.path))
|
|
109
|
+
return Report(
|
|
110
|
+
run=run,
|
|
111
|
+
breaking=[g for g in ordered if g.breaking],
|
|
112
|
+
warnings=[g for g in ordered if not g.breaking],
|
|
113
|
+
errors=dict(sorted(errors.items(), key=lambda kv: -kv[1])),
|
|
114
|
+
endpoints=dict(sorted(endpoints.items())),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _detail(d: dict) -> str:
|
|
119
|
+
kind = d["kind"]
|
|
120
|
+
if kind == "type_changed":
|
|
121
|
+
return f"type changed {d['old_type']} → {d['new_type']}"
|
|
122
|
+
if kind == "status_changed":
|
|
123
|
+
return f"status changed {d['old']} → {d['new']}"
|
|
124
|
+
if kind == "field_removed":
|
|
125
|
+
return "field removed"
|
|
126
|
+
if kind == "field_added":
|
|
127
|
+
return "field added"
|
|
128
|
+
return "value changed"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- rendering -------------------------------------------------------------------
|
|
132
|
+
#
|
|
133
|
+
# The report is built once as a list of simple blocks, then rendered to either
|
|
134
|
+
# format. Inline `code` spans in text are honoured by both renderers.
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _blocks(report: Report) -> list[tuple]:
|
|
138
|
+
run = report.run
|
|
139
|
+
started = datetime.fromtimestamp(run.started_at or 0, UTC).strftime("%Y-%m-%d %H:%M UTC")
|
|
140
|
+
duration = (run.finished_at or 0) - (run.started_at or 0)
|
|
141
|
+
errors = sum(report.errors.values())
|
|
142
|
+
|
|
143
|
+
if report.breaking or errors:
|
|
144
|
+
verdict = (
|
|
145
|
+
f"**FAIL**: {len(report.breaking)} breaking change(s) across {run.failed} "
|
|
146
|
+
f"request(s)" + (f", {errors} request error(s)" if errors else "")
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
verdict = "**PASS**: no breaking changes"
|
|
150
|
+
|
|
151
|
+
blocks: list[tuple] = [
|
|
152
|
+
("h1", f"shadowdiff report: run {run.id}"),
|
|
153
|
+
(
|
|
154
|
+
"p",
|
|
155
|
+
f"Session `{run.session}` replayed against `{run.target}` "
|
|
156
|
+
f"at {started} (concurrency {run.concurrency}).",
|
|
157
|
+
),
|
|
158
|
+
("p", verdict),
|
|
159
|
+
(
|
|
160
|
+
"table",
|
|
161
|
+
["Replayed", "Passed", "Failed", "Breaking changes", "Warnings", "Duration"],
|
|
162
|
+
[
|
|
163
|
+
[
|
|
164
|
+
run.done,
|
|
165
|
+
run.passed,
|
|
166
|
+
run.failed,
|
|
167
|
+
len(report.breaking),
|
|
168
|
+
len(report.warnings),
|
|
169
|
+
f"{duration:.2f}s",
|
|
170
|
+
]
|
|
171
|
+
],
|
|
172
|
+
),
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
for title, groups in [("Breaking changes", report.breaking), ("Warnings", report.warnings)]:
|
|
176
|
+
blocks.append(("h2", title))
|
|
177
|
+
if not groups:
|
|
178
|
+
blocks.append(("p", "None."))
|
|
179
|
+
continue
|
|
180
|
+
blocks.append(
|
|
181
|
+
(
|
|
182
|
+
"table",
|
|
183
|
+
["Endpoint", "Path", "Change", "Requests", "Example"],
|
|
184
|
+
[
|
|
185
|
+
[
|
|
186
|
+
f"`{g.endpoint}`",
|
|
187
|
+
f"`{g.path}`",
|
|
188
|
+
g.detail,
|
|
189
|
+
g.count,
|
|
190
|
+
f"`{g.example_url}`: {_example(g)}",
|
|
191
|
+
]
|
|
192
|
+
for g in groups
|
|
193
|
+
],
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if report.errors:
|
|
198
|
+
blocks.append(("h2", "Request errors"))
|
|
199
|
+
blocks.append(
|
|
200
|
+
("table", ["Error", "Requests"], [[f"`{e}`", n] for e, n in report.errors.items()])
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
blocks.append(("h2", "By endpoint"))
|
|
204
|
+
blocks.append(
|
|
205
|
+
(
|
|
206
|
+
"table",
|
|
207
|
+
["Endpoint", "Requests", "Passed", "Failed"],
|
|
208
|
+
[[f"`{name}`", s.requests, s.passed, s.failed] for name, s in report.endpoints.items()],
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
return blocks
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _example(g: DiffGroup) -> str:
|
|
215
|
+
if g.kind == "field_added":
|
|
216
|
+
return f"added `{_short(g.example_new)}`"
|
|
217
|
+
if g.kind == "field_removed":
|
|
218
|
+
return f"was `{_short(g.example_old)}`"
|
|
219
|
+
return f"`{_short(g.example_old)}` → `{_short(g.example_new)}`"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _short(value: Any) -> str:
|
|
223
|
+
text = json.dumps(value, ensure_ascii=False)
|
|
224
|
+
text = text if len(text) <= _EXAMPLE_CHARS else text[: _EXAMPLE_CHARS - 1] + "…"
|
|
225
|
+
# Keep table cells intact in Markdown.
|
|
226
|
+
return text.replace("|", "\\|").replace("`", "'")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def render_markdown(report: Report) -> str:
|
|
230
|
+
out = []
|
|
231
|
+
for block in _blocks(report):
|
|
232
|
+
kind = block[0]
|
|
233
|
+
if kind == "h1":
|
|
234
|
+
out.append(f"# {block[1]}")
|
|
235
|
+
elif kind == "h2":
|
|
236
|
+
out.append(f"## {block[1]}")
|
|
237
|
+
elif kind == "p":
|
|
238
|
+
out.append(block[1])
|
|
239
|
+
elif kind == "table":
|
|
240
|
+
headers, rows = block[1], block[2]
|
|
241
|
+
lines = ["| " + " | ".join(headers) + " |", "|" + " --- |" * len(headers)]
|
|
242
|
+
lines += ["| " + " | ".join(str(c) for c in row) + " |" for row in rows]
|
|
243
|
+
out.append("\n".join(lines))
|
|
244
|
+
return "\n\n".join(out) + "\n"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
_CODE = re.compile(r"`([^`]*)`")
|
|
248
|
+
_BOLD = re.compile(r"\*\*([^*]+)\*\*")
|
|
249
|
+
|
|
250
|
+
_HTML_STYLE = """
|
|
251
|
+
body { font: 14px/1.5 system-ui, sans-serif; margin: 2rem auto; max-width: 1100px;
|
|
252
|
+
padding: 0 1rem; color: #1f2328; background: #fff; }
|
|
253
|
+
table { border-collapse: collapse; margin: 0.5rem 0 1.5rem; width: 100%; }
|
|
254
|
+
th, td { border: 1px solid #d0d7de; padding: 6px 10px; text-align: left; vertical-align: top; }
|
|
255
|
+
th { background: #f6f8fa; }
|
|
256
|
+
code { background: #f6f8fa; padding: 1px 4px; border-radius: 4px; font-size: 13px; }
|
|
257
|
+
@media (prefers-color-scheme: dark) {
|
|
258
|
+
body { color: #e6edf3; background: #0d1117; }
|
|
259
|
+
th, code { background: #161b22; }
|
|
260
|
+
th, td { border-color: #30363d; }
|
|
261
|
+
}
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _inline_html(text: Any) -> str:
|
|
266
|
+
text = html.escape(str(text).replace("\\|", "|"), quote=False)
|
|
267
|
+
text = _CODE.sub(r"<code>\1</code>", text)
|
|
268
|
+
return _BOLD.sub(r"<strong>\1</strong>", text)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def render_html(report: Report) -> str:
|
|
272
|
+
body = []
|
|
273
|
+
for block in _blocks(report):
|
|
274
|
+
kind = block[0]
|
|
275
|
+
if kind in ("h1", "h2"):
|
|
276
|
+
body.append(f"<{kind}>{_inline_html(block[1])}</{kind}>")
|
|
277
|
+
elif kind == "p":
|
|
278
|
+
body.append(f"<p>{_inline_html(block[1])}</p>")
|
|
279
|
+
elif kind == "table":
|
|
280
|
+
head = "".join(f"<th>{_inline_html(h)}</th>" for h in block[1])
|
|
281
|
+
rows = "".join(
|
|
282
|
+
"<tr>" + "".join(f"<td>{_inline_html(c)}</td>" for c in row) + "</tr>"
|
|
283
|
+
for row in block[2]
|
|
284
|
+
)
|
|
285
|
+
body.append(f"<table><thead><tr>{head}</tr></thead><tbody>{rows}</tbody></table>")
|
|
286
|
+
return (
|
|
287
|
+
'<!doctype html>\n<html lang="en"><head><meta charset="utf-8">'
|
|
288
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
289
|
+
f"<title>shadowdiff run {report.run.id}</title><style>{_HTML_STYLE}</style></head>"
|
|
290
|
+
f"<body>{''.join(body)}</body></html>\n"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def render(report: Report, fmt: str = "md") -> str:
|
|
295
|
+
if fmt == "md":
|
|
296
|
+
return render_markdown(report)
|
|
297
|
+
if fmt == "html":
|
|
298
|
+
return render_html(report)
|
|
299
|
+
raise ValueError(f"unknown report format {fmt!r}; use md or html")
|
shadowdiff/store.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""SQLite storage for recorded exchanges, replay runs and their results."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
SCHEMA = """
|
|
10
|
+
CREATE TABLE IF NOT EXISTS exchanges (
|
|
11
|
+
id INTEGER PRIMARY KEY,
|
|
12
|
+
session TEXT NOT NULL,
|
|
13
|
+
method TEXT NOT NULL,
|
|
14
|
+
path TEXT NOT NULL,
|
|
15
|
+
query TEXT NOT NULL DEFAULT '',
|
|
16
|
+
req_headers TEXT NOT NULL DEFAULT '{}',
|
|
17
|
+
req_body BLOB,
|
|
18
|
+
status INTEGER NOT NULL,
|
|
19
|
+
resp_headers TEXT NOT NULL DEFAULT '{}',
|
|
20
|
+
resp_body BLOB,
|
|
21
|
+
latency_ms REAL,
|
|
22
|
+
recorded_at REAL NOT NULL
|
|
23
|
+
);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_exchanges_session_path ON exchanges (session, path);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
27
|
+
id INTEGER PRIMARY KEY,
|
|
28
|
+
session TEXT NOT NULL,
|
|
29
|
+
target TEXT NOT NULL,
|
|
30
|
+
concurrency INTEGER NOT NULL,
|
|
31
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
32
|
+
total INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
done INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
passed INTEGER NOT NULL DEFAULT 0,
|
|
35
|
+
failed INTEGER NOT NULL DEFAULT 0,
|
|
36
|
+
started_at REAL,
|
|
37
|
+
finished_at REAL
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS results (
|
|
41
|
+
id INTEGER PRIMARY KEY,
|
|
42
|
+
run_id INTEGER NOT NULL REFERENCES runs (id),
|
|
43
|
+
exchange_id INTEGER NOT NULL REFERENCES exchanges (id),
|
|
44
|
+
status INTEGER,
|
|
45
|
+
resp_body BLOB,
|
|
46
|
+
latency_ms REAL,
|
|
47
|
+
error TEXT,
|
|
48
|
+
diffs TEXT NOT NULL DEFAULT '[]',
|
|
49
|
+
breaking INTEGER NOT NULL DEFAULT 0
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_results_run_breaking ON results (run_id, breaking);
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Exchange:
|
|
57
|
+
session: str
|
|
58
|
+
method: str
|
|
59
|
+
path: str
|
|
60
|
+
query: str
|
|
61
|
+
req_headers: dict[str, str]
|
|
62
|
+
req_body: bytes
|
|
63
|
+
status: int
|
|
64
|
+
resp_headers: dict[str, str]
|
|
65
|
+
resp_body: bytes
|
|
66
|
+
latency_ms: float
|
|
67
|
+
recorded_at: float = field(default_factory=time.time)
|
|
68
|
+
id: int | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Result:
|
|
73
|
+
exchange_id: int
|
|
74
|
+
status: int | None
|
|
75
|
+
resp_body: bytes | None
|
|
76
|
+
latency_ms: float | None
|
|
77
|
+
error: str | None = None
|
|
78
|
+
diffs: list[dict[str, Any]] = field(default_factory=list)
|
|
79
|
+
breaking: bool = False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Run:
|
|
84
|
+
id: int
|
|
85
|
+
session: str
|
|
86
|
+
target: str
|
|
87
|
+
concurrency: int
|
|
88
|
+
status: str
|
|
89
|
+
total: int
|
|
90
|
+
done: int
|
|
91
|
+
passed: int
|
|
92
|
+
failed: int
|
|
93
|
+
started_at: float | None
|
|
94
|
+
finished_at: float | None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Store:
|
|
98
|
+
def __init__(self, path: str = "shadowdiff.db"):
|
|
99
|
+
self.path = path
|
|
100
|
+
# The proxy writes from worker threads; callers serialize writes themselves.
|
|
101
|
+
self.conn = sqlite3.connect(path, check_same_thread=False)
|
|
102
|
+
self.conn.row_factory = sqlite3.Row
|
|
103
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
104
|
+
self.conn.execute("PRAGMA synchronous=NORMAL")
|
|
105
|
+
self.conn.executescript(SCHEMA)
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
self.conn.close()
|
|
109
|
+
|
|
110
|
+
# exchanges
|
|
111
|
+
|
|
112
|
+
def add_exchanges(self, exchanges: list[Exchange]) -> None:
|
|
113
|
+
with self.conn:
|
|
114
|
+
self.conn.executemany(
|
|
115
|
+
"INSERT INTO exchanges (session, method, path, query, req_headers, req_body,"
|
|
116
|
+
" status, resp_headers, resp_body, latency_ms, recorded_at)"
|
|
117
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
118
|
+
[
|
|
119
|
+
(
|
|
120
|
+
e.session,
|
|
121
|
+
e.method,
|
|
122
|
+
e.path,
|
|
123
|
+
e.query,
|
|
124
|
+
json.dumps(e.req_headers),
|
|
125
|
+
e.req_body,
|
|
126
|
+
e.status,
|
|
127
|
+
json.dumps(e.resp_headers),
|
|
128
|
+
e.resp_body,
|
|
129
|
+
e.latency_ms,
|
|
130
|
+
e.recorded_at,
|
|
131
|
+
)
|
|
132
|
+
for e in exchanges
|
|
133
|
+
],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def exchanges(self, session: str) -> list[Exchange]:
|
|
137
|
+
rows = self.conn.execute(
|
|
138
|
+
"SELECT * FROM exchanges WHERE session = ? ORDER BY id", (session,)
|
|
139
|
+
).fetchall()
|
|
140
|
+
return [_exchange_from_row(r) for r in rows]
|
|
141
|
+
|
|
142
|
+
def exchanges_by_id(self, ids: list[int]) -> dict[int, Exchange]:
|
|
143
|
+
if not ids:
|
|
144
|
+
return {}
|
|
145
|
+
out: dict[int, Exchange] = {}
|
|
146
|
+
# Stay under SQLite's bound-parameter limit.
|
|
147
|
+
for i in range(0, len(ids), 500):
|
|
148
|
+
chunk = ids[i : i + 500]
|
|
149
|
+
placeholders = ",".join("?" * len(chunk))
|
|
150
|
+
rows = self.conn.execute(
|
|
151
|
+
f"SELECT * FROM exchanges WHERE id IN ({placeholders})", chunk
|
|
152
|
+
).fetchall()
|
|
153
|
+
out.update({r["id"]: _exchange_from_row(r) for r in rows})
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
def sessions(self) -> list[dict[str, Any]]:
|
|
157
|
+
rows = self.conn.execute(
|
|
158
|
+
"SELECT session, COUNT(*) AS exchanges, MIN(recorded_at) AS first_seen,"
|
|
159
|
+
" MAX(recorded_at) AS last_seen FROM exchanges GROUP BY session ORDER BY session"
|
|
160
|
+
).fetchall()
|
|
161
|
+
return [dict(r) for r in rows]
|
|
162
|
+
|
|
163
|
+
# runs and results
|
|
164
|
+
|
|
165
|
+
def create_run(self, session: str, target: str, concurrency: int, total: int) -> int:
|
|
166
|
+
with self.conn:
|
|
167
|
+
cur = self.conn.execute(
|
|
168
|
+
"INSERT INTO runs (session, target, concurrency, status, total, started_at)"
|
|
169
|
+
" VALUES (?, ?, ?, 'running', ?, ?)",
|
|
170
|
+
(session, target, concurrency, total, time.time()),
|
|
171
|
+
)
|
|
172
|
+
return cur.lastrowid
|
|
173
|
+
|
|
174
|
+
def add_results(self, run_id: int, results: list[Result]) -> None:
|
|
175
|
+
with self.conn:
|
|
176
|
+
self.conn.executemany(
|
|
177
|
+
"INSERT INTO results (run_id, exchange_id, status, resp_body, latency_ms,"
|
|
178
|
+
" error, diffs, breaking) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
179
|
+
[
|
|
180
|
+
(
|
|
181
|
+
run_id,
|
|
182
|
+
r.exchange_id,
|
|
183
|
+
r.status,
|
|
184
|
+
r.resp_body,
|
|
185
|
+
r.latency_ms,
|
|
186
|
+
r.error,
|
|
187
|
+
json.dumps(r.diffs),
|
|
188
|
+
int(r.breaking),
|
|
189
|
+
)
|
|
190
|
+
for r in results
|
|
191
|
+
],
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def finish_run(self, run_id: int, status: str, done: int, passed: int, failed: int) -> None:
|
|
195
|
+
with self.conn:
|
|
196
|
+
self.conn.execute(
|
|
197
|
+
"UPDATE runs SET status = ?, done = ?, passed = ?, failed = ?, finished_at = ?"
|
|
198
|
+
" WHERE id = ?",
|
|
199
|
+
(status, done, passed, failed, time.time(), run_id),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def run(self, run_id: int) -> Run | None:
|
|
203
|
+
row = self.conn.execute("SELECT * FROM runs WHERE id = ?", (run_id,)).fetchone()
|
|
204
|
+
return Run(**dict(row)) if row else None
|
|
205
|
+
|
|
206
|
+
def latest_run_id(self) -> int | None:
|
|
207
|
+
row = self.conn.execute("SELECT MAX(id) FROM runs").fetchone()
|
|
208
|
+
return row[0]
|
|
209
|
+
|
|
210
|
+
def results(self, run_id: int) -> list[Result]:
|
|
211
|
+
rows = self.conn.execute(
|
|
212
|
+
"SELECT * FROM results WHERE run_id = ? ORDER BY id", (run_id,)
|
|
213
|
+
).fetchall()
|
|
214
|
+
return [
|
|
215
|
+
Result(
|
|
216
|
+
exchange_id=r["exchange_id"],
|
|
217
|
+
status=r["status"],
|
|
218
|
+
resp_body=r["resp_body"],
|
|
219
|
+
latency_ms=r["latency_ms"],
|
|
220
|
+
error=r["error"],
|
|
221
|
+
diffs=json.loads(r["diffs"]),
|
|
222
|
+
breaking=bool(r["breaking"]),
|
|
223
|
+
)
|
|
224
|
+
for r in rows
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _exchange_from_row(r: sqlite3.Row) -> Exchange:
|
|
229
|
+
return Exchange(
|
|
230
|
+
id=r["id"],
|
|
231
|
+
session=r["session"],
|
|
232
|
+
method=r["method"],
|
|
233
|
+
path=r["path"],
|
|
234
|
+
query=r["query"],
|
|
235
|
+
req_headers=json.loads(r["req_headers"]),
|
|
236
|
+
req_body=r["req_body"] or b"",
|
|
237
|
+
status=r["status"],
|
|
238
|
+
resp_headers=json.loads(r["resp_headers"]),
|
|
239
|
+
resp_body=r["resp_body"] or b"",
|
|
240
|
+
latency_ms=r["latency_ms"],
|
|
241
|
+
recorded_at=r["recorded_at"],
|
|
242
|
+
)
|