Skip to main content

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 (shadowdiff is too close to an existing project there). The command and the Python package are still shadowdiff.

Status: MVP (v0.1.0). GET requests 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
  1. Record. shadowdiff record runs a reverse proxy in front of the current version. It forwards every request unchanged and records GET request/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.
  2. Replay. shadowdiff replay sends 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.
  3. Smart diff. Each JSON response is walked against the recording. Every difference gets a JSON path and a kind: type_changed, field_removed and status_changed are breaking; field_added and value_changed are warnings. Ignore rules in shadowdiff.yaml drop the noise.
  4. Report. Identical diffs are grouped across requests ("$.price type changed number → string on 844 requests to GET /products/{id}") and written to report.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_stock field removed 844 /products/20: was false
GET /products/{id} $.price type changed number → string 844 /products/20: 128.85 → "128.85"

Warnings

Endpoint Path Change Requests Example
GET /products/{id} $.available field added 844 /products/20: added false

By endpoint

Endpoint Requests Passed Failed
GET /health 93 93 0
GET /orders/{id} 388 388 0
GET /products 605 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 GET requests are recorded and replayed. Safe replay of writes is the next big piece.
  • Recorded headers and bodies are stored as-is in SQLite, including Authorization headers and cookies. Don't record production traffic with credentials or PII until redaction lands.
  • value_changed is 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)

Source distribution for traffic-diff 0.1.0
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)

Table of built distributions (wheels) for traffic-diff 0.1.0
File Interpreter ABI Platform Actions
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 log

Release 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 log

Security 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.

Select from 2 files

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 log

A 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.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page