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.
@@ -0,0 +1,18 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ *.db
11
+ *.db-journal
12
+ *.db-wal
13
+ *.db-shm
14
+ report.md
15
+ report.html
16
+ .env
17
+ .pypirc
18
+ data/
@@ -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()