shadowdiff
Record real API traffic, replay it against a new version before you ship, and flag only the response changes that matter.
Unit and integration tests only cover the cases engineers think of, so regressions like a renamed field, a changed type or a flipped status code slip through to production. shadowdiff replays the requests your users actually sent and reports structural differences, ignoring noise like timestamps, generated IDs and reordered lists.
On PyPI the package is named
traffic-diff(shadowdiffis too close to an existing project there). The command and the Python package are stillshadowdiff.
Status: MVP (v0.1.0).
GETrequests only. See Limitations.
How it works
record replay
clients ──► shadowdiff proxy ──► API v1 shadowdiff replay ──► API v2
(:8080) │ │
▼ ▼
shadowdiff.db ─────► smart diff ──► report.md, exit code
- Record.
shadowdiff recordruns a reverse proxy in front of the current version. It forwards every request unchanged and recordsGETrequest/response pairs (2xx-4xx, bodies up to 1 MB) to SQLite. Recording happens off the response path: exchanges go onto an in-memory queue that a background task flushes in batches. - Replay.
shadowdiff replaysends the recorded requests to the new version with async httpx, a concurrency limit and a 10 s timeout. Timeouts and connection errors are recorded as failures, not crashes. - Smart diff. Each JSON response is walked against the recording. Every difference gets a
JSON path and a kind:
type_changed,field_removedandstatus_changedare breaking;field_addedandvalue_changedare warnings. Ignore rules inshadowdiff.yamldrop the noise. - Report. Identical diffs are grouped across requests ("
$.pricetype changed number → string on 844 requests toGET /products/{id}") and written toreport.md. The CLI exits 1 if there is any breaking diff or request error, so it works as a CI gate.
Quickstart
git clone <this repo> && cd shadowdiff
pip install -e ".[dev]" # once published: pip install traffic-diff
Run the demo shop: v1 on :8001, a buggy v2 on :8002, and the recorder on :8080 in front of v1.
docker compose up -d --build
python demo/traffic.py --requests 2000 # ~2,000 realistic GETs through the recorder
docker compose stop recorder # flush and close the recording database
shadowdiff replay --session demo --target http://localhost:8002 --db data/shadowdiff.db
echo $? # 1: breaking changes found
shadowdiff report --db data/shadowdiff.db --format html # re-render the latest run as HTML
docker compose down
Without Docker, run the same three processes locally:
VERSION=v1 uvicorn demo.app:app --port 8001 &
VERSION=v2 uvicorn demo.app:app --port 8002 &
shadowdiff record --target http://127.0.0.1:8001 --port 8080 --session demo &
python demo/traffic.py --base-url http://127.0.0.1:8080
kill %3 # stop the recorder
shadowdiff replay --session demo --target http://127.0.0.1:8002 --concurrency 50
Commands
| Command | Does |
|---|---|
shadowdiff record --target URL [--port 8080] [--session NAME] |
Run the recording proxy |
shadowdiff replay --session NAME --target URL [--concurrency 20] [--config shadowdiff.yaml] |
Replay, diff, write report.md, exit 1 on breaking diffs |
shadowdiff report [--run RUN_ID] [--format md|html] |
Re-render a run's report (default: latest run) |
All commands take --db (default shadowdiff.db). Run any command with --help for every option.
Ignore rules (shadowdiff.yaml)
ignore_fields: ["*_at", "id", "request_id"] # field-name globs; "$.meta.*" matches a full path
ignore_values: [uuid, iso_timestamp] # a UUID -> other UUID change is noise
unordered_lists:
"$.items": "id" # match list items by key, not position
Unordered-list paths can use [*] for nested lists ("$.orders[*].lines": "sku"), and a null
key compares the list as a multiset.
Sample report
From the demo: v1 traffic replayed against v2, which returns price as a string and renames
in_stock to available on /products/{id}. Each response also carries a fresh request_id
UUID and served_at timestamp, and /products returns its items in random order. None of that
noise shows up.
FAIL: 2 breaking change(s) across 844 request(s)
Replayed Passed Failed Breaking changes Warnings Duration 2001 1157 844 2 1 1.93s Breaking changes
Endpoint Path Change Requests Example GET /products/{id}$.in_stockfield removed 844 /products/20: wasfalseGET /products/{id}$.pricetype changed number → string 844 /products/20:128.85→"128.85"Warnings
Endpoint Path Change Requests Example GET /products/{id}$.availablefield added 844 /products/20: addedfalseBy endpoint
Endpoint Requests Passed Failed GET /health93 93 0 GET /orders/{id}388 388 0 GET /products605 605 0 GET /products/{id}914 70 844
Replaying the same recording against v1 reports 0 breaking changes and 0 warnings, and exits 0. That run is the check that noise filtering works.
Measured numbers
Measured on an Apple M3 laptop (8 cores) with everything on one machine, using
demo/traffic.py (2,000 requests) and demo/bench.py (6,000 requests per side, direct and proxied
rounds alternated). Other desktop apps were running, so treat these as indicative.
| Metric | Result | Target |
|---|---|---|
| Planted bugs caught | 2 / 2 | 2 / 2 |
| False positives from noise (v1 vs v1) | 0 | 0 |
| Replay throughput, 2,000 requests at concurrency 50 | ~1,050-1,300 req/s | ≥ 500 req/s |
| Proxy overhead, sequential requests | +1.0 ms p50, +1.2 ms p95 | < 5 ms p95 |
| Proxy overhead, 10 concurrent clients | +3.4-4.6 ms p50, +5.7-13.8 ms p95 |
The single-process demo API tops out around 1,100 req/s, so replay throughput is bounded by the target here, not by shadowdiff. At 10 concurrent clients the single-process proxy is close to saturation on one core, which is where the higher, noisier p95 comes from.
Reproduce:
python -m demo.bench --direct http://127.0.0.1:8001 --proxy http://127.0.0.1:8080 --concurrency 1
shadowdiff replay --session demo --target http://127.0.0.1:8001 --concurrency 50
Limitations
- Only
GETrequests are recorded and replayed. Safe replay of writes is the next big piece. - Recorded headers and bodies are stored as-is in SQLite, including
Authorizationheaders and cookies. Don't record production traffic with credentials or PII until redaction lands. value_changedis a warning, not breaking (per the PRD), so a changed value alone won't fail CI.- The proxy is a single process. Under heavy concurrent load its added latency grows.
- Bodies are compared fully in memory, and diffs for a run are computed after all requests return.
Development
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
ruff check . && ruff format --check . && pytest
License
MIT
Metadata
Release files for traffic-diff 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| traffic_diff-0.1.0.tar.gz | 26.8 kB | With provenance | Details for traffic_diff-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| traffic_diff-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for traffic_diff-0.1.0-py3-none-any.whl |
Total release size: 48.3 kB
Release files / traffic_diff-0.1.0.tar.gz
| Download URL | traffic_diff-0.1.0.tar.gz |
|---|---|
| Size | 26.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
003d7f9db2da9107b89426ddf81c73ff532b12d1b11170920b6ef68ca2db64c1
|
|
BLAKE2b-256 checksum How to use checksums |
8d0bbaba3e0aec7998b0503e286c7ce739b449cb711c73711b4a35108f1c9bb9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.
Transparency logRelease files / traffic_diff-0.1.0-py3-none-any.whl
| Download URL | traffic_diff-0.1.0-py3-none-any.whl |
|---|---|
| Size | 21.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
26d5f32c339c74f4e9d463ad531fc8ab10c5635fe460ec33d3e0ca70b16717d4
|
|
BLAKE2b-256 checksum How to use checksums |
113362c8756d16bf7cfabae13dddf74fe352fef56c50792d52b52655bffb6462
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.
Transparency logSecurity for traffic-diff 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for traffic_diff-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.