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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shadowdiff = shadowdiff.cli:app
@@ -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.