traffic-diff 0.1.0__tar.gz
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.
- traffic_diff-0.1.0/.gitignore +18 -0
- traffic_diff-0.1.0/LICENSE +21 -0
- traffic_diff-0.1.0/PKG-INFO +207 -0
- traffic_diff-0.1.0/README.md +174 -0
- traffic_diff-0.1.0/demo/__init__.py +0 -0
- traffic_diff-0.1.0/demo/app.py +105 -0
- traffic_diff-0.1.0/demo/bench.py +85 -0
- traffic_diff-0.1.0/demo/traffic.py +72 -0
- traffic_diff-0.1.0/pyproject.toml +66 -0
- traffic_diff-0.1.0/shadowdiff/__init__.py +3 -0
- traffic_diff-0.1.0/shadowdiff/cli.py +131 -0
- traffic_diff-0.1.0/shadowdiff/config.py +57 -0
- traffic_diff-0.1.0/shadowdiff/diff.py +240 -0
- traffic_diff-0.1.0/shadowdiff/proxy.py +191 -0
- traffic_diff-0.1.0/shadowdiff/replay.py +141 -0
- traffic_diff-0.1.0/shadowdiff/report.py +299 -0
- traffic_diff-0.1.0/shadowdiff/store.py +242 -0
- traffic_diff-0.1.0/shadowdiff.yaml +13 -0
- traffic_diff-0.1.0/tests/test_cli.py +10 -0
- traffic_diff-0.1.0/tests/test_demo_app.py +33 -0
- traffic_diff-0.1.0/tests/test_diff.py +285 -0
- traffic_diff-0.1.0/tests/test_end_to_end.py +133 -0
- traffic_diff-0.1.0/tests/test_proxy.py +145 -0
- traffic_diff-0.1.0/tests/test_replay.py +70 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ayaan Ali
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: traffic-diff
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Record real API traffic, replay it against a new version, and flag only the response changes that matter.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jengaWiz/traffic-diff
|
|
6
|
+
Project-URL: Repository, https://github.com/jengaWiz/traffic-diff
|
|
7
|
+
Project-URL: Issues, https://github.com/jengaWiz/traffic-diff/issues
|
|
8
|
+
Author: Ayaan Ali
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,ci,diff,regression,testing,traffic-replay
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: fastapi>=0.110
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: pyyaml>=6.0
|
|
25
|
+
Requires-Dist: typer>=0.12
|
|
26
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
31
|
+
Requires-Dist: twine>=5; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# shadowdiff
|
|
35
|
+
|
|
36
|
+
Record real API traffic, replay it against a new version before you ship, and flag only the
|
|
37
|
+
response changes that matter.
|
|
38
|
+
|
|
39
|
+
Unit and integration tests only cover the cases engineers think of, so regressions like a renamed
|
|
40
|
+
field, a changed type or a flipped status code slip through to production. shadowdiff replays the
|
|
41
|
+
requests your users actually sent and reports structural differences, ignoring noise like
|
|
42
|
+
timestamps, generated IDs and reordered lists.
|
|
43
|
+
|
|
44
|
+
> On PyPI the package is named **`traffic-diff`** (`shadowdiff` is too close to an existing
|
|
45
|
+
> project there). The command and the Python package are still `shadowdiff`.
|
|
46
|
+
|
|
47
|
+
> Status: MVP (v0.1.0). `GET` requests only. See [Limitations](#limitations).
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
record replay
|
|
53
|
+
clients ──► shadowdiff proxy ──► API v1 shadowdiff replay ──► API v2
|
|
54
|
+
(:8080) │ │
|
|
55
|
+
▼ ▼
|
|
56
|
+
shadowdiff.db ─────► smart diff ──► report.md, exit code
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
1. **Record.** `shadowdiff record` runs a reverse proxy in front of the current version. It forwards
|
|
60
|
+
every request unchanged and records `GET` request/response pairs (2xx-4xx, bodies up to 1 MB) to
|
|
61
|
+
SQLite. Recording happens off the response path: exchanges go onto an in-memory queue that a
|
|
62
|
+
background task flushes in batches.
|
|
63
|
+
2. **Replay.** `shadowdiff replay` sends the recorded requests to the new version with async httpx,
|
|
64
|
+
a concurrency limit and a 10 s timeout. Timeouts and connection errors are recorded as failures,
|
|
65
|
+
not crashes.
|
|
66
|
+
3. **Smart diff.** Each JSON response is walked against the recording. Every difference gets a
|
|
67
|
+
JSON path and a kind: `type_changed`, `field_removed` and `status_changed` are **breaking**;
|
|
68
|
+
`field_added` and `value_changed` are warnings. Ignore rules in `shadowdiff.yaml` drop the noise.
|
|
69
|
+
4. **Report.** Identical diffs are grouped across requests ("`$.price` type changed number → string
|
|
70
|
+
on 844 requests to `GET /products/{id}`") and written to `report.md`. The CLI exits **1** if
|
|
71
|
+
there is any breaking diff or request error, so it works as a CI gate.
|
|
72
|
+
|
|
73
|
+
## Quickstart
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
git clone <this repo> && cd shadowdiff
|
|
77
|
+
pip install -e ".[dev]" # once published: pip install traffic-diff
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Run the demo shop: v1 on `:8001`, a buggy v2 on `:8002`, and the recorder on `:8080` in front of v1.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
docker compose up -d --build
|
|
84
|
+
python demo/traffic.py --requests 2000 # ~2,000 realistic GETs through the recorder
|
|
85
|
+
docker compose stop recorder # flush and close the recording database
|
|
86
|
+
shadowdiff replay --session demo --target http://localhost:8002 --db data/shadowdiff.db
|
|
87
|
+
echo $? # 1: breaking changes found
|
|
88
|
+
shadowdiff report --db data/shadowdiff.db --format html # re-render the latest run as HTML
|
|
89
|
+
docker compose down
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Without Docker, run the same three processes locally:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
VERSION=v1 uvicorn demo.app:app --port 8001 &
|
|
96
|
+
VERSION=v2 uvicorn demo.app:app --port 8002 &
|
|
97
|
+
shadowdiff record --target http://127.0.0.1:8001 --port 8080 --session demo &
|
|
98
|
+
python demo/traffic.py --base-url http://127.0.0.1:8080
|
|
99
|
+
kill %3 # stop the recorder
|
|
100
|
+
shadowdiff replay --session demo --target http://127.0.0.1:8002 --concurrency 50
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Commands
|
|
104
|
+
|
|
105
|
+
| Command | Does |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `shadowdiff record --target URL [--port 8080] [--session NAME]` | Run the recording proxy |
|
|
108
|
+
| `shadowdiff replay --session NAME --target URL [--concurrency 20] [--config shadowdiff.yaml]` | Replay, diff, write `report.md`, exit 1 on breaking diffs |
|
|
109
|
+
| `shadowdiff report [--run RUN_ID] [--format md\|html]` | Re-render a run's report (default: latest run) |
|
|
110
|
+
|
|
111
|
+
All commands take `--db` (default `shadowdiff.db`). Run any command with `--help` for every option.
|
|
112
|
+
|
|
113
|
+
### Ignore rules (`shadowdiff.yaml`)
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
ignore_fields: ["*_at", "id", "request_id"] # field-name globs; "$.meta.*" matches a full path
|
|
117
|
+
ignore_values: [uuid, iso_timestamp] # a UUID -> other UUID change is noise
|
|
118
|
+
unordered_lists:
|
|
119
|
+
"$.items": "id" # match list items by key, not position
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Unordered-list paths can use `[*]` for nested lists (`"$.orders[*].lines": "sku"`), and a `null`
|
|
123
|
+
key compares the list as a multiset.
|
|
124
|
+
|
|
125
|
+
## Sample report
|
|
126
|
+
|
|
127
|
+
From the demo: v1 traffic replayed against v2, which returns `price` as a string and renames
|
|
128
|
+
`in_stock` to `available` on `/products/{id}`. Each response also carries a fresh `request_id`
|
|
129
|
+
UUID and `served_at` timestamp, and `/products` returns its items in random order. None of that
|
|
130
|
+
noise shows up.
|
|
131
|
+
|
|
132
|
+
> **FAIL**: 2 breaking change(s) across 844 request(s)
|
|
133
|
+
>
|
|
134
|
+
> | Replayed | Passed | Failed | Breaking changes | Warnings | Duration |
|
|
135
|
+
> | --- | --- | --- | --- | --- | --- |
|
|
136
|
+
> | 2001 | 1157 | 844 | 2 | 1 | 1.93s |
|
|
137
|
+
>
|
|
138
|
+
> **Breaking changes**
|
|
139
|
+
>
|
|
140
|
+
> | Endpoint | Path | Change | Requests | Example |
|
|
141
|
+
> | --- | --- | --- | --- | --- |
|
|
142
|
+
> | `GET /products/{id}` | `$.in_stock` | field removed | 844 | `/products/20`: was `false` |
|
|
143
|
+
> | `GET /products/{id}` | `$.price` | type changed number → string | 844 | `/products/20`: `128.85` → `"128.85"` |
|
|
144
|
+
>
|
|
145
|
+
> **Warnings**
|
|
146
|
+
>
|
|
147
|
+
> | Endpoint | Path | Change | Requests | Example |
|
|
148
|
+
> | --- | --- | --- | --- | --- |
|
|
149
|
+
> | `GET /products/{id}` | `$.available` | field added | 844 | `/products/20`: added `false` |
|
|
150
|
+
>
|
|
151
|
+
> **By endpoint**
|
|
152
|
+
>
|
|
153
|
+
> | Endpoint | Requests | Passed | Failed |
|
|
154
|
+
> | --- | --- | --- | --- |
|
|
155
|
+
> | `GET /health` | 93 | 93 | 0 |
|
|
156
|
+
> | `GET /orders/{id}` | 388 | 388 | 0 |
|
|
157
|
+
> | `GET /products` | 605 | 605 | 0 |
|
|
158
|
+
> | `GET /products/{id}` | 914 | 70 | 844 |
|
|
159
|
+
|
|
160
|
+
Replaying the same recording against **v1** reports 0 breaking changes and 0 warnings, and exits 0.
|
|
161
|
+
That run is the check that noise filtering works.
|
|
162
|
+
|
|
163
|
+
## Measured numbers
|
|
164
|
+
|
|
165
|
+
Measured on an Apple M3 laptop (8 cores) with everything on one machine, using
|
|
166
|
+
`demo/traffic.py` (2,000 requests) and `demo/bench.py` (6,000 requests per side, direct and proxied
|
|
167
|
+
rounds alternated). Other desktop apps were running, so treat these as indicative.
|
|
168
|
+
|
|
169
|
+
| Metric | Result | Target |
|
|
170
|
+
| --- | --- | --- |
|
|
171
|
+
| Planted bugs caught | **2 / 2** | 2 / 2 |
|
|
172
|
+
| False positives from noise (v1 vs v1) | **0** | 0 |
|
|
173
|
+
| Replay throughput, 2,000 requests at concurrency 50 | **~1,050-1,300 req/s** | ≥ 500 req/s |
|
|
174
|
+
| Proxy overhead, sequential requests | **+1.0 ms p50, +1.2 ms p95** | < 5 ms p95 |
|
|
175
|
+
| Proxy overhead, 10 concurrent clients | +3.4-4.6 ms p50, +5.7-13.8 ms p95 | |
|
|
176
|
+
|
|
177
|
+
The single-process demo API tops out around 1,100 req/s, so replay throughput is bounded by the
|
|
178
|
+
target here, not by shadowdiff. At 10 concurrent clients the single-process proxy is close to
|
|
179
|
+
saturation on one core, which is where the higher, noisier p95 comes from.
|
|
180
|
+
|
|
181
|
+
Reproduce:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
python -m demo.bench --direct http://127.0.0.1:8001 --proxy http://127.0.0.1:8080 --concurrency 1
|
|
185
|
+
shadowdiff replay --session demo --target http://127.0.0.1:8001 --concurrency 50
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Limitations
|
|
189
|
+
|
|
190
|
+
- Only `GET` requests are recorded and replayed. Safe replay of writes is the next big piece.
|
|
191
|
+
- Recorded headers and bodies are stored as-is in SQLite, including `Authorization` headers and
|
|
192
|
+
cookies. Don't record production traffic with credentials or PII until redaction lands.
|
|
193
|
+
- `value_changed` is a warning, not breaking (per the PRD), so a changed value alone won't fail CI.
|
|
194
|
+
- The proxy is a single process. Under heavy concurrent load its added latency grows.
|
|
195
|
+
- Bodies are compared fully in memory, and diffs for a run are computed after all requests return.
|
|
196
|
+
|
|
197
|
+
## Development
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
python -m venv .venv && . .venv/bin/activate
|
|
201
|
+
pip install -e ".[dev]"
|
|
202
|
+
ruff check . && ruff format --check . && pytest
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# shadowdiff
|
|
2
|
+
|
|
3
|
+
Record real API traffic, replay it against a new version before you ship, and flag only the
|
|
4
|
+
response changes that matter.
|
|
5
|
+
|
|
6
|
+
Unit and integration tests only cover the cases engineers think of, so regressions like a renamed
|
|
7
|
+
field, a changed type or a flipped status code slip through to production. shadowdiff replays the
|
|
8
|
+
requests your users actually sent and reports structural differences, ignoring noise like
|
|
9
|
+
timestamps, generated IDs and reordered lists.
|
|
10
|
+
|
|
11
|
+
> On PyPI the package is named **`traffic-diff`** (`shadowdiff` is too close to an existing
|
|
12
|
+
> project there). The command and the Python package are still `shadowdiff`.
|
|
13
|
+
|
|
14
|
+
> Status: MVP (v0.1.0). `GET` requests only. See [Limitations](#limitations).
|
|
15
|
+
|
|
16
|
+
## How it works
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
record replay
|
|
20
|
+
clients ──► shadowdiff proxy ──► API v1 shadowdiff replay ──► API v2
|
|
21
|
+
(:8080) │ │
|
|
22
|
+
▼ ▼
|
|
23
|
+
shadowdiff.db ─────► smart diff ──► report.md, exit code
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
1. **Record.** `shadowdiff record` runs a reverse proxy in front of the current version. It forwards
|
|
27
|
+
every request unchanged and records `GET` request/response pairs (2xx-4xx, bodies up to 1 MB) to
|
|
28
|
+
SQLite. Recording happens off the response path: exchanges go onto an in-memory queue that a
|
|
29
|
+
background task flushes in batches.
|
|
30
|
+
2. **Replay.** `shadowdiff replay` sends the recorded requests to the new version with async httpx,
|
|
31
|
+
a concurrency limit and a 10 s timeout. Timeouts and connection errors are recorded as failures,
|
|
32
|
+
not crashes.
|
|
33
|
+
3. **Smart diff.** Each JSON response is walked against the recording. Every difference gets a
|
|
34
|
+
JSON path and a kind: `type_changed`, `field_removed` and `status_changed` are **breaking**;
|
|
35
|
+
`field_added` and `value_changed` are warnings. Ignore rules in `shadowdiff.yaml` drop the noise.
|
|
36
|
+
4. **Report.** Identical diffs are grouped across requests ("`$.price` type changed number → string
|
|
37
|
+
on 844 requests to `GET /products/{id}`") and written to `report.md`. The CLI exits **1** if
|
|
38
|
+
there is any breaking diff or request error, so it works as a CI gate.
|
|
39
|
+
|
|
40
|
+
## Quickstart
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone <this repo> && cd shadowdiff
|
|
44
|
+
pip install -e ".[dev]" # once published: pip install traffic-diff
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Run the demo shop: v1 on `:8001`, a buggy v2 on `:8002`, and the recorder on `:8080` in front of v1.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
docker compose up -d --build
|
|
51
|
+
python demo/traffic.py --requests 2000 # ~2,000 realistic GETs through the recorder
|
|
52
|
+
docker compose stop recorder # flush and close the recording database
|
|
53
|
+
shadowdiff replay --session demo --target http://localhost:8002 --db data/shadowdiff.db
|
|
54
|
+
echo $? # 1: breaking changes found
|
|
55
|
+
shadowdiff report --db data/shadowdiff.db --format html # re-render the latest run as HTML
|
|
56
|
+
docker compose down
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Without Docker, run the same three processes locally:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
VERSION=v1 uvicorn demo.app:app --port 8001 &
|
|
63
|
+
VERSION=v2 uvicorn demo.app:app --port 8002 &
|
|
64
|
+
shadowdiff record --target http://127.0.0.1:8001 --port 8080 --session demo &
|
|
65
|
+
python demo/traffic.py --base-url http://127.0.0.1:8080
|
|
66
|
+
kill %3 # stop the recorder
|
|
67
|
+
shadowdiff replay --session demo --target http://127.0.0.1:8002 --concurrency 50
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Commands
|
|
71
|
+
|
|
72
|
+
| Command | Does |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `shadowdiff record --target URL [--port 8080] [--session NAME]` | Run the recording proxy |
|
|
75
|
+
| `shadowdiff replay --session NAME --target URL [--concurrency 20] [--config shadowdiff.yaml]` | Replay, diff, write `report.md`, exit 1 on breaking diffs |
|
|
76
|
+
| `shadowdiff report [--run RUN_ID] [--format md\|html]` | Re-render a run's report (default: latest run) |
|
|
77
|
+
|
|
78
|
+
All commands take `--db` (default `shadowdiff.db`). Run any command with `--help` for every option.
|
|
79
|
+
|
|
80
|
+
### Ignore rules (`shadowdiff.yaml`)
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
ignore_fields: ["*_at", "id", "request_id"] # field-name globs; "$.meta.*" matches a full path
|
|
84
|
+
ignore_values: [uuid, iso_timestamp] # a UUID -> other UUID change is noise
|
|
85
|
+
unordered_lists:
|
|
86
|
+
"$.items": "id" # match list items by key, not position
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Unordered-list paths can use `[*]` for nested lists (`"$.orders[*].lines": "sku"`), and a `null`
|
|
90
|
+
key compares the list as a multiset.
|
|
91
|
+
|
|
92
|
+
## Sample report
|
|
93
|
+
|
|
94
|
+
From the demo: v1 traffic replayed against v2, which returns `price` as a string and renames
|
|
95
|
+
`in_stock` to `available` on `/products/{id}`. Each response also carries a fresh `request_id`
|
|
96
|
+
UUID and `served_at` timestamp, and `/products` returns its items in random order. None of that
|
|
97
|
+
noise shows up.
|
|
98
|
+
|
|
99
|
+
> **FAIL**: 2 breaking change(s) across 844 request(s)
|
|
100
|
+
>
|
|
101
|
+
> | Replayed | Passed | Failed | Breaking changes | Warnings | Duration |
|
|
102
|
+
> | --- | --- | --- | --- | --- | --- |
|
|
103
|
+
> | 2001 | 1157 | 844 | 2 | 1 | 1.93s |
|
|
104
|
+
>
|
|
105
|
+
> **Breaking changes**
|
|
106
|
+
>
|
|
107
|
+
> | Endpoint | Path | Change | Requests | Example |
|
|
108
|
+
> | --- | --- | --- | --- | --- |
|
|
109
|
+
> | `GET /products/{id}` | `$.in_stock` | field removed | 844 | `/products/20`: was `false` |
|
|
110
|
+
> | `GET /products/{id}` | `$.price` | type changed number → string | 844 | `/products/20`: `128.85` → `"128.85"` |
|
|
111
|
+
>
|
|
112
|
+
> **Warnings**
|
|
113
|
+
>
|
|
114
|
+
> | Endpoint | Path | Change | Requests | Example |
|
|
115
|
+
> | --- | --- | --- | --- | --- |
|
|
116
|
+
> | `GET /products/{id}` | `$.available` | field added | 844 | `/products/20`: added `false` |
|
|
117
|
+
>
|
|
118
|
+
> **By endpoint**
|
|
119
|
+
>
|
|
120
|
+
> | Endpoint | Requests | Passed | Failed |
|
|
121
|
+
> | --- | --- | --- | --- |
|
|
122
|
+
> | `GET /health` | 93 | 93 | 0 |
|
|
123
|
+
> | `GET /orders/{id}` | 388 | 388 | 0 |
|
|
124
|
+
> | `GET /products` | 605 | 605 | 0 |
|
|
125
|
+
> | `GET /products/{id}` | 914 | 70 | 844 |
|
|
126
|
+
|
|
127
|
+
Replaying the same recording against **v1** reports 0 breaking changes and 0 warnings, and exits 0.
|
|
128
|
+
That run is the check that noise filtering works.
|
|
129
|
+
|
|
130
|
+
## Measured numbers
|
|
131
|
+
|
|
132
|
+
Measured on an Apple M3 laptop (8 cores) with everything on one machine, using
|
|
133
|
+
`demo/traffic.py` (2,000 requests) and `demo/bench.py` (6,000 requests per side, direct and proxied
|
|
134
|
+
rounds alternated). Other desktop apps were running, so treat these as indicative.
|
|
135
|
+
|
|
136
|
+
| Metric | Result | Target |
|
|
137
|
+
| --- | --- | --- |
|
|
138
|
+
| Planted bugs caught | **2 / 2** | 2 / 2 |
|
|
139
|
+
| False positives from noise (v1 vs v1) | **0** | 0 |
|
|
140
|
+
| Replay throughput, 2,000 requests at concurrency 50 | **~1,050-1,300 req/s** | ≥ 500 req/s |
|
|
141
|
+
| Proxy overhead, sequential requests | **+1.0 ms p50, +1.2 ms p95** | < 5 ms p95 |
|
|
142
|
+
| Proxy overhead, 10 concurrent clients | +3.4-4.6 ms p50, +5.7-13.8 ms p95 | |
|
|
143
|
+
|
|
144
|
+
The single-process demo API tops out around 1,100 req/s, so replay throughput is bounded by the
|
|
145
|
+
target here, not by shadowdiff. At 10 concurrent clients the single-process proxy is close to
|
|
146
|
+
saturation on one core, which is where the higher, noisier p95 comes from.
|
|
147
|
+
|
|
148
|
+
Reproduce:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
python -m demo.bench --direct http://127.0.0.1:8001 --proxy http://127.0.0.1:8080 --concurrency 1
|
|
152
|
+
shadowdiff replay --session demo --target http://127.0.0.1:8001 --concurrency 50
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Limitations
|
|
156
|
+
|
|
157
|
+
- Only `GET` requests are recorded and replayed. Safe replay of writes is the next big piece.
|
|
158
|
+
- Recorded headers and bodies are stored as-is in SQLite, including `Authorization` headers and
|
|
159
|
+
cookies. Don't record production traffic with credentials or PII until redaction lands.
|
|
160
|
+
- `value_changed` is a warning, not breaking (per the PRD), so a changed value alone won't fail CI.
|
|
161
|
+
- The proxy is a single process. Under heavy concurrent load its added latency grows.
|
|
162
|
+
- Bodies are compared fully in memory, and diffs for a run are computed after all requests return.
|
|
163
|
+
|
|
164
|
+
## Development
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
python -m venv .venv && . .venv/bin/activate
|
|
168
|
+
pip install -e ".[dev]"
|
|
169
|
+
ruff check . && ruff format --check . && pytest
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Demo e-commerce API used to show shadowdiff end to end.
|
|
2
|
+
|
|
3
|
+
Run v1: VERSION=v1 uvicorn demo.app:app --port 8001
|
|
4
|
+
Run v2: VERSION=v2 uvicorn demo.app:app --port 8002
|
|
5
|
+
|
|
6
|
+
Every response carries noise (a fresh ``request_id`` UUID and a ``served_at``
|
|
7
|
+
timestamp), and ``GET /products`` returns its items in random order. v2 ships
|
|
8
|
+
two planted bugs on ``GET /products/{id}``: ``price`` is a string, and
|
|
9
|
+
``in_stock`` is renamed to ``available``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import random
|
|
14
|
+
import uuid
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
|
|
17
|
+
from fastapi import FastAPI
|
|
18
|
+
from fastapi.responses import JSONResponse
|
|
19
|
+
|
|
20
|
+
CATEGORIES = ["books", "games", "kitchen", "garden", "toys"]
|
|
21
|
+
|
|
22
|
+
_rng = random.Random(42)
|
|
23
|
+
PRODUCTS = {
|
|
24
|
+
i: {
|
|
25
|
+
"id": i,
|
|
26
|
+
"name": f"Product {i}",
|
|
27
|
+
"category": _rng.choice(CATEGORIES),
|
|
28
|
+
"price": round(_rng.uniform(3, 250), 2),
|
|
29
|
+
"in_stock": _rng.random() > 0.2,
|
|
30
|
+
"tags": sorted(_rng.sample(["new", "sale", "popular", "gift", "eco"], k=2)),
|
|
31
|
+
"updated_at": "2026-10-01T12:00:00Z",
|
|
32
|
+
}
|
|
33
|
+
for i in range(1, 101)
|
|
34
|
+
}
|
|
35
|
+
ORDERS = {
|
|
36
|
+
i: {
|
|
37
|
+
"id": i,
|
|
38
|
+
"status": _rng.choice(["pending", "shipped", "delivered"]),
|
|
39
|
+
"lines": [
|
|
40
|
+
{"product_id": pid, "qty": _rng.randint(1, 4)}
|
|
41
|
+
for pid in _rng.sample(list(PRODUCTS), k=_rng.randint(1, 4))
|
|
42
|
+
],
|
|
43
|
+
"created_at": "2026-10-02T08:30:00Z",
|
|
44
|
+
}
|
|
45
|
+
for i in range(1, 51)
|
|
46
|
+
}
|
|
47
|
+
for order in ORDERS.values():
|
|
48
|
+
order["total"] = round(
|
|
49
|
+
sum(PRODUCTS[line["product_id"]]["price"] * line["qty"] for line in order["lines"]), 2
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _noisy(body: dict, version: str, status_code: int = 200) -> JSONResponse:
|
|
54
|
+
body = {
|
|
55
|
+
**body,
|
|
56
|
+
"request_id": str(uuid.uuid4()),
|
|
57
|
+
"served_at": datetime.now(UTC).isoformat(),
|
|
58
|
+
}
|
|
59
|
+
return JSONResponse(body, status_code=status_code, headers={"x-demo-version": version})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_app(version: str = "v1") -> FastAPI:
|
|
63
|
+
app = FastAPI(title=f"shadowdiff demo shop ({version})")
|
|
64
|
+
|
|
65
|
+
def not_found(kind: str, item_id: int) -> JSONResponse:
|
|
66
|
+
return _noisy({"error": f"{kind} {item_id} not found"}, version, status_code=404)
|
|
67
|
+
|
|
68
|
+
@app.get("/health")
|
|
69
|
+
async def health():
|
|
70
|
+
return _noisy({"ok": True}, version)
|
|
71
|
+
|
|
72
|
+
@app.get("/products")
|
|
73
|
+
async def list_products(category: str | None = None, limit: int = 20, offset: int = 0):
|
|
74
|
+
items = [p for p in PRODUCTS.values() if category is None or p["category"] == category]
|
|
75
|
+
page = items[offset : offset + limit]
|
|
76
|
+
# Simulate a backend that doesn't guarantee ordering (e.g. a hash-partitioned store).
|
|
77
|
+
page = random.sample(page, k=len(page))
|
|
78
|
+
return _noisy(
|
|
79
|
+
{"items": page, "total": len(items), "limit": limit, "offset": offset}, version
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@app.get("/products/{product_id}")
|
|
83
|
+
async def get_product(product_id: int):
|
|
84
|
+
product = PRODUCTS.get(product_id)
|
|
85
|
+
if product is None:
|
|
86
|
+
return not_found("product", product_id)
|
|
87
|
+
product = dict(product)
|
|
88
|
+
if version == "v2":
|
|
89
|
+
# Planted bug 1: price serialized as a string.
|
|
90
|
+
product["price"] = f"{product['price']:.2f}"
|
|
91
|
+
# Planted bug 2: in_stock renamed to available.
|
|
92
|
+
product["available"] = product.pop("in_stock")
|
|
93
|
+
return _noisy(product, version)
|
|
94
|
+
|
|
95
|
+
@app.get("/orders/{order_id}")
|
|
96
|
+
async def get_order(order_id: int):
|
|
97
|
+
order = ORDERS.get(order_id)
|
|
98
|
+
if order is None:
|
|
99
|
+
return not_found("order", order_id)
|
|
100
|
+
return _noisy(order, version)
|
|
101
|
+
|
|
102
|
+
return app
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
app = create_app(os.environ.get("VERSION", "v1"))
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Measure the latency the recording proxy adds.
|
|
2
|
+
|
|
3
|
+
Sends the same GET requests to the demo API directly and through the proxy,
|
|
4
|
+
then prints p50/p95/p99 for each and the difference.
|
|
5
|
+
|
|
6
|
+
python demo/bench.py --direct http://localhost:8001 --proxy http://localhost:8080
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import statistics
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from demo.traffic import make_paths
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def percentile(values: list[float], p: float) -> float:
|
|
20
|
+
values = sorted(values)
|
|
21
|
+
k = max(0, min(len(values) - 1, round(p / 100 * (len(values) - 1))))
|
|
22
|
+
return values[k]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def measure(base_url: str, paths: list[str], concurrency: int) -> list[float]:
|
|
26
|
+
sem = asyncio.Semaphore(concurrency)
|
|
27
|
+
latencies: list[float] = []
|
|
28
|
+
# One single-connection client per slot, like the replayer, so client-side
|
|
29
|
+
# pool overhead doesn't dominate the measurement.
|
|
30
|
+
limits = httpx.Limits(max_connections=1, max_keepalive_connections=1)
|
|
31
|
+
free = [
|
|
32
|
+
httpx.AsyncClient(base_url=base_url, timeout=10, limits=limits) for _ in range(concurrency)
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
async def one(path: str) -> None:
|
|
36
|
+
async with sem:
|
|
37
|
+
client = free.pop()
|
|
38
|
+
try:
|
|
39
|
+
start = time.perf_counter()
|
|
40
|
+
await client.get(path)
|
|
41
|
+
latencies.append((time.perf_counter() - start) * 1000)
|
|
42
|
+
finally:
|
|
43
|
+
free.append(client)
|
|
44
|
+
|
|
45
|
+
# Warm up connections and code paths before measuring.
|
|
46
|
+
await asyncio.gather(*(one(p) for p in paths[:100]))
|
|
47
|
+
latencies.clear()
|
|
48
|
+
await asyncio.gather(*(one(p) for p in paths))
|
|
49
|
+
for client in free:
|
|
50
|
+
await client.aclose()
|
|
51
|
+
return latencies
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main() -> None:
|
|
55
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
56
|
+
parser.add_argument("--direct", default="http://localhost:8001")
|
|
57
|
+
parser.add_argument("--proxy", default="http://localhost:8080")
|
|
58
|
+
parser.add_argument("--requests", type=int, default=2000)
|
|
59
|
+
parser.add_argument("--concurrency", type=int, default=1)
|
|
60
|
+
parser.add_argument("--rounds", type=int, default=3)
|
|
61
|
+
args = parser.parse_args()
|
|
62
|
+
|
|
63
|
+
paths = make_paths(args.requests, seed=11)
|
|
64
|
+
direct: list[float] = []
|
|
65
|
+
proxied: list[float] = []
|
|
66
|
+
# Alternate rounds so drift (thermal, background load) hits both sides equally.
|
|
67
|
+
for _ in range(args.rounds):
|
|
68
|
+
direct += asyncio.run(measure(args.direct, paths, args.concurrency))
|
|
69
|
+
proxied += asyncio.run(measure(args.proxy, paths, args.concurrency))
|
|
70
|
+
|
|
71
|
+
print(f"{len(direct)} requests per side, concurrency {args.concurrency}")
|
|
72
|
+
print(f"{'':8}{'p50':>9}{'p95':>9}{'p99':>9}{'mean':>9} (ms)")
|
|
73
|
+
for name, xs in [("direct", direct), ("proxied", proxied)]:
|
|
74
|
+
row = [percentile(xs, 50), percentile(xs, 95), percentile(xs, 99), statistics.mean(xs)]
|
|
75
|
+
print(f"{name:8}" + "".join(f"{v:9.2f}" for v in row))
|
|
76
|
+
print(
|
|
77
|
+
f"added {percentile(proxied, 50) - percentile(direct, 50):9.2f}"
|
|
78
|
+
f"{percentile(proxied, 95) - percentile(direct, 95):9.2f}"
|
|
79
|
+
f"{percentile(proxied, 99) - percentile(direct, 99):9.2f}"
|
|
80
|
+
f"{statistics.mean(proxied) - statistics.mean(direct):9.2f}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Send realistic GET traffic to the demo shop (normally through the recorder).
|
|
2
|
+
|
|
3
|
+
python demo/traffic.py --base-url http://localhost:8080 --requests 2000
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import asyncio
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
from collections import Counter
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
CATEGORIES = ["books", "games", "kitchen", "garden", "toys"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def make_paths(n: int, seed: int) -> list[str]:
|
|
18
|
+
rng = random.Random(seed)
|
|
19
|
+
paths = []
|
|
20
|
+
for _ in range(n):
|
|
21
|
+
r = rng.random()
|
|
22
|
+
if r < 0.45:
|
|
23
|
+
# A few IDs past the catalogue end produce realistic 404s.
|
|
24
|
+
paths.append(f"/products/{rng.randint(1, 110)}")
|
|
25
|
+
elif r < 0.75:
|
|
26
|
+
params = {"limit": rng.choice([5, 10, 20])}
|
|
27
|
+
if rng.random() < 0.6:
|
|
28
|
+
params["category"] = rng.choice(CATEGORIES)
|
|
29
|
+
if rng.random() < 0.3:
|
|
30
|
+
params["offset"] = rng.choice([0, 5, 10])
|
|
31
|
+
paths.append("/products?" + "&".join(f"{k}={v}" for k, v in params.items()))
|
|
32
|
+
elif r < 0.95:
|
|
33
|
+
paths.append(f"/orders/{rng.randint(1, 55)}")
|
|
34
|
+
else:
|
|
35
|
+
paths.append("/health")
|
|
36
|
+
return paths
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def send(base_url: str, paths: list[str], concurrency: int) -> Counter:
|
|
40
|
+
sem = asyncio.Semaphore(concurrency)
|
|
41
|
+
statuses: Counter = Counter()
|
|
42
|
+
async with httpx.AsyncClient(base_url=base_url, timeout=10) as client:
|
|
43
|
+
|
|
44
|
+
async def one(path: str) -> None:
|
|
45
|
+
async with sem:
|
|
46
|
+
try:
|
|
47
|
+
statuses[(await client.get(path)).status_code] += 1
|
|
48
|
+
except httpx.HTTPError as exc:
|
|
49
|
+
statuses[type(exc).__name__] += 1
|
|
50
|
+
|
|
51
|
+
await asyncio.gather(*(one(p) for p in paths))
|
|
52
|
+
return statuses
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main() -> None:
|
|
56
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
57
|
+
parser.add_argument("--base-url", default="http://localhost:8080")
|
|
58
|
+
parser.add_argument("--requests", type=int, default=2000)
|
|
59
|
+
parser.add_argument("--concurrency", type=int, default=20)
|
|
60
|
+
parser.add_argument("--seed", type=int, default=7)
|
|
61
|
+
args = parser.parse_args()
|
|
62
|
+
|
|
63
|
+
paths = make_paths(args.requests, args.seed)
|
|
64
|
+
start = time.perf_counter()
|
|
65
|
+
statuses = asyncio.run(send(args.base_url, paths, args.concurrency))
|
|
66
|
+
elapsed = time.perf_counter() - start
|
|
67
|
+
summary = ", ".join(f"{k}: {v}" for k, v in sorted(statuses.items(), key=str))
|
|
68
|
+
print(f"Sent {len(paths)} requests to {args.base_url} in {elapsed:.2f}s ({summary})")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|