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
|
@@ -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,13 @@
|
|
|
1
|
+
shadowdiff/__init__.py,sha256=vq5fsY6Cti-mJOvqQQIkzAaoY-CGFNhCU2Mblx-kau8,113
|
|
2
|
+
shadowdiff/cli.py,sha256=cbGGRNxNxw4JHHMGEU1kMkwKp4232TwZ1jQUryekdSY,4494
|
|
3
|
+
shadowdiff/config.py,sha256=hauc1m0XxkGOuM8smJRtzAxWpoE9xqaalQx4AfcLyCI,2002
|
|
4
|
+
shadowdiff/diff.py,sha256=DpZyCnLGzQFpJNzvwwW3nmyxrlFDVZ4SSaYXVRGoE1I,8253
|
|
5
|
+
shadowdiff/proxy.py,sha256=P2LjIZpYzgQeDPKmRnw39EB1uQ-iHo-xjDNqWEPGL-M,6313
|
|
6
|
+
shadowdiff/replay.py,sha256=8Yp7hu-ekC-u0tWa0BCjDhgWwAv-BjcSl1vGvijLY8o,4900
|
|
7
|
+
shadowdiff/report.py,sha256=Pj6l_UhqYNVCXP3ZjISsr1c8vOFeu3_oHGG45VZyeK8,9918
|
|
8
|
+
shadowdiff/store.py,sha256=0MXcwigds0UPgt5yG6czJ1QVqmY0xZlmzukPL4nYtq4,7738
|
|
9
|
+
traffic_diff-0.1.0.dist-info/METADATA,sha256=ylFC9U0YxEzPsSirfh98Bn8tIODvWguaSyqiM5_2S1Y,9032
|
|
10
|
+
traffic_diff-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
traffic_diff-0.1.0.dist-info/entry_points.txt,sha256=IzjEcucEBhwbPgHiL07sO8HKsn33Ur1ekRoCY3pjlHY,50
|
|
12
|
+
traffic_diff-0.1.0.dist-info/licenses/LICENSE,sha256=K2kxC7Laj4G5BICABtfiO7k92tB4biPBSAxVkQu4LTQ,1066
|
|
13
|
+
traffic_diff-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|