When you migrate an API to a new environment, refactor a backend service, or introduce a reverse proxy, you need confidence that the new stack responds ide
When you migrate an API to a new environment, refactor a backend service, or introduce a reverse proxy, you need confidence that the new stack responds identically to the old one. Unit and integration tests only cover the paths you thought to write — real production traffic covers the paths your users actually hit. Replaying recorded HTTP requests against a staging or canary target is one of the cheapest, highest-signal regression checks you can run.
This script parses an nginx/Apache-style access log (or a JSON-lines export), extracts the HTTP method, path, query string, and status code from each request, then re-issues those requests against a target host. It compares the new status code against the recorded one and reports drift, so you can catch broken routes, changed auth behavior, or 5xx regressions before they reach production.
The architecture is a straightforward pipeline: parse → filter → replay → compare → report. Each stage is isolated so it can be swapped or extended independently.
$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$referer" "$http_user_agent"). Lines that don't match are skipped silently (they're often health-check noise or malformed entries).ThreadPoolExecutor fans requests out concurrently. Each worker uses a shared requests.Session per thread for connection reuse. Timeouts and a max body read cap keep runaway responses from blowing up memory.Key design decisions: no external deps beyond requests; everything tunable via CLI flags or env vars; deterministic exit code (0 = clean, 1 = drift detected) so it drops into CI without wrappers.
#!/usr/bin/env python3
"""
API Replay Tester from Traffic Logs
Parses an HTTP access log (nginx Combined Log Format or JSON-lines),
replays each request against a target host, and reports status-code drift.
Usage:
python3 replay_test.py --traffic-log nginx_access.log --target http://staging:3000
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from typing import Iterable, Iterator, Optional
from urllib.parse import urljoin, urlparse
import requests
# ---------------------------------------------------------------------------
# Configuration (env vars provide defaults; CLI flags override them)
# ---------------------------------------------------------------------------
DEFAULT_TARGET = os.environ.get("REPLAY_TARGET", "http://localhost:3000")
DEFAULT_TIMEOUT = float(os.environ.get("REPLAY_TIMEOUT", "10"))
DEFAULT_CONCURRENCY = int(os.environ.get("REPLAY_CONCURRENCY", "8"))
DEFAULT_METHODS = os.environ.get("REPLAY_METHODS", "GET,POST").split(",")
DEFAULT_MAX_BODY = int(os.environ.get("REPLAY_MAX_BODY", str(64 * 1024))) # 64 KiB
USER_AGENT = os.environ.get("REPLAY_USER_AGENT", "api-replay-tester/1.0")
# nginx Combined Log Format:
# 127.0.0.1 - - [10/Oct/2024:13:55:36 +0000] "GET /api/v1/users?page=2 HTTP/1.1" 200 1234 "-" "curl/8.0"
COMBINED_RE = re.compile(
r'^(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] '
r'"(?P<method>[A-Z]+) (?P<path>\S+) (?P<proto>[^"]+)" '
r'(?P<status>\d{3}) (?P<bytes>\S+)'
)
@dataclass(frozen=True)
class LogEntry:
"""A single parsed request from the traffic log."""
method: str
path: str # includes query string
status: int # status code recorded in the log
source: str = "log"
@dataclass
class ReplayResult:
"""Outcome of replaying a single LogEntry."""
entry: LogEntry
new_status: Optional[int] = None
error: Optional[str] = None
body_hash: Optional[str] = None
elapsed_ms: float = 0.0
@property
def drifted(self) -> bool:
"""True if the replayed status differs from the recorded one."""
return self.error is not None or self.new_status != self.entry.status
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
def parse_combined_line(line: str) -> Optional[LogEntry]:
"""Parse one nginx Combined Log Format line. Returns None if unparseable."""
m = COMBINED_RE.match(line.strip())
if not m:
return None
try:
return LogEntry(
method=m.group("method"),
path=m.group("path"),
status=int(m.group("status")),
)
except ValueError:
return None
def parse_jsonl_line(line: str) -> Optional[LogEntry]:
"""Parse one JSON-lines record. Expects keys: method, path, status."""
try:
obj = json.loads(line)
return LogEntry(
method=str(obj["method"]).upper(),
path=str(obj["path"]),
status=int(obj["status"]),
)
except (json.JSONDecodeError, KeyError, ValueError, TypeError):
return None
def iter_log(path: str, fmt: str) -> Iterator[LogEntry]:
"""Yield LogEntry objects from a file, sniffing format if requested."""
parser = parse_jsonl_line if fmt == "jsonl" else parse_combined_line
with open(path, "r", encoding="utf-8", errors="replace") as fh:
for line in fh:
entry = parser(line)
if entry is not None:
yield entry
def filter_entries(
entries: Iterable[LogEntry],
methods: set[str],
skip_status_5xx: bool,
path_prefix: Optional[str],
limit: Optional[int],
) -> list[LogEntry]:
"""Apply method/status/prefix filters and optionally cap the list."""
out: list[LogEntry] = []
for e in entries:
if e.method not in methods:
continue
if skip_status_5xx and 500 <= e.status < 600:
# Source-side 5xx are usually transient — replaying them
# mostly produces noise. Skip unless explicitly kept.
continue
if path_prefix and not e.path.startswith(path_prefix):
continue
out.append(e)
if limit is not None and len(out) >= limit:
break
return out
# ---------------------------------------------------------------------------
# Replay
# ---------------------------------------------------------------------------
def replay_one(
session: requests.Session,
target: str,
entry: LogEntry,
timeout: float,
max_body: int,
) -> ReplayResult:
"""Re-issue one request and capture status/error/body hash."""
# urljoin handles the case where target has a path prefix like /v2.
url = urljoin(target.rstrip("/") + "/", entry.path.lstrip("/"))
result = ReplayResult(entry=entry)
try:
# POST bodies aren't in the access log, so we send an empty body.
# This is usually fine for read-only endpoints but will fail for
# strict validators — filter those out via --methods GET.
resp = session.request(
method=entry.method,
url=url,
timeout=timeout,
allow_redirects=False,
headers={"User-Agent": USER_AGENT},
data=b"" if entry.method in ("POST", "PUT", "PATCH") else None,
)
result.new_status = resp.status_code
result.elapsed_ms = resp.elapsed.total_seconds() * 1000.0
# Read a bounded chunk so huge downloads don't OOM the process.
chunk = resp.raw.read(max_body, decode_content=True) or b""
result.body_hash = hashlib.sha256(chunk).hexdigest()[:16]
except requests.Timeout:
result.error = "timeout"
except requests.ConnectionError as exc:
result.error = f"connection_error: {exc.__class__.__name__}"
except requests.RequestException as exc:
result.error = f"request_error: {exc.__class__.__name__}"
return result
def replay_all(
entries: list[LogEntry],
target: str,
timeout: float,
concurrency: int,
max_body: int,
) -> list[ReplayResult]:
"""Replay all entries using a thread pool. Order is not preserved."""
results: list[ReplayResult] = []
# One Session per worker thread is safe; requests.Session is not
# documented as thread-safe, so we create one per task via a thread-local.
import threading
local = threading.local()
def worker(entry: LogEntry) -> ReplayResult:
if not hasattr(local, "session"):
local.session = requests.Session()
return replay_one(local.session, target, entry, timeout, max_body)
with ThreadPoolExecutor(max_workers=max(1, concurrency)) as pool:
futures = {pool.submit(worker, e): e for e in entries}
for fut in as_completed(futures):
try:
results.append(fut.result())
except Exception as exc: # defensive: worker should never raise
entry = futures[fut]
results.append(ReplayResult(entry=entry, error=f"worker_crash: {exc}"))
return results
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def print_report(results: list[ReplayResult], show_ok: bool) -> None:
"""Print a compact per-request report and a summary."""
drift_count = 0
for r in sorted(results, key=lambda x: (x.entry.path, x.entry.method)):
if r.drifted:
drift_count += 1
if r.error:
print(f" [ERR] {r.entry.method:6s} {r.entry.path} "
f"expected={r.entry.status} -> {r.error}")
else:
print(f" [DRIFT] {r.entry.method:6s} {r.entry.path} "
f"expected={r.entry.status} got={r.new_status} "
f"({r.elapsed_ms:.0f}ms)")
elif show_ok:
print(f" [ OK ] {r.entry.method:6s} {r.entry.path} "
f"status={r.new_status} ({r.elapsed_ms:.0f}ms)")
status_counts = Counter(
r.new_status if r.new_status is not None else "error" for r in results
)
print()
print(f"Replayed: {len(results)} Drift: {drift_count} "
f"Clean: {len(results) - drift_count}")
print("Status distribution: " +
", ".join(f"{k}={v}" for k, v in sorted(status_counts.items(), key=str)))
def write_json_report(results: list[ReplayResult], path: str) -> None:
"""Write machine-readable results for CI artifact consumption."""
payload = {
"total": len(results),
"drift": sum(1 for r in results if r.drifted),
"results": [
{
"method": r.entry.method,
"path": r.entry.path,
"expected_status": r.entry.status,
"actual_status": r.new_status,
"error": r.error,
"elapsed_ms": round(r.elapsed_ms, 2),
"body_hash": r.body_hash,
}
for r in results
],
}
with open(path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="Replay HTTP requests from an access log against a target host.",
)
p.add_argument("--traffic-log", required=True,
help="Path to nginx Combined Log or JSON-lines file.")
p.add_argument("--target", default=DEFAULT_TARGET,
help=f"Base URL of the target (default: {DEFAULT_TARGET}).")
p.add_argument("--format", choices=("combined", "jsonl"), default="combined",
help="Log format (default: combined).")
p.add_argument("--methods", default=",".join(DEFAULT_METHODS),
help="Comma-separated HTTP methods to replay.")
p.add_argument("--path-prefix", default=None,
help="Only replay paths starting with this prefix.")
p.add_argument("--limit", type=int, default=None,
help="Cap the number of requests replayed.")
p.add_argument("--concurrency", type=int, default=DEFAULT_CONCURRENCY,
help=f"Parallel workers (default: {DEFAULT_CONCURRENCY}).")
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT,
help=f"Per-request timeout in seconds (default: {DEFAULT_TIMEOUT}).")
p.add_argument("--max-body", type=int, default=DEFAULT_MAX_BODY,
help=f"Max bytes to read per response (default: {DEFAULT_MAX_BODY}).")
p.add_argument("--keep-5xx", action="store_true",
help="Do not skip source-side 5xx requests.")
p.add_argument("--show-ok", action="store_true",
help="Print successful replays too (verbose).")
p.add_argument("--json-report", default=None,
help="Write a JSON report to this path.")
return p
def main(argv: Optional[list[str]] = None) -> int:
args = build_parser().parse_args(argv)
if not os.path.isfile(args.traffic_log):
print(f"error: traffic log not found: {args.traffic_log}", file=sys.stderr)
return 2
target_parsed = urlparse(args.target)
if target_parsed.scheme not in ("http", "https"):
print(f"error: --target must be http(s), got {args.target!r}", file=sys.stderr)
return 2
methods = {m.strip().upper() for m in args.methods.split(",") if m.strip()}
if not methods:
print("error: --methods cannot be empty", file=sys.stderr)
return 2
print(f"Parsing {args.traffic_log} ({args.format}) ...")
try:
raw = list(iter_log(args.traffic_log, args.format))
except OSError as exc:
print(f"error: cannot read log: {exc}", file=sys.stderr)
return 2
entries = filter_entries(
raw,
methods=methods,
skip_status_5xx=not args.keep_5xx,
path_prefix=args.path_prefix,
limit=args.limit,
)
print(f"Parsed {len(raw)} lines, selected {len(entries)} for replay.")
if not entries:
print("Nothing to replay — check --methods / --path-prefix filters.")
return 0
print(f"Replaying against {args.target} "
f"(concurrency={args.concurrency}, timeout={args.timeout}s) ...")
results = replay_all(
entries,
target=args.target,
timeout=args.timeout,
concurrency=args.concurrency,
max_body=args.max_body,
)
print_report(results, show_ok=args.show_ok)
if args.json_report:
try:
write_json_report(results, args.json_report)
print(f"JSON report written to {args.json_report}")
except OSError as exc:
print(f"warning: could not write JSON report: {exc}", file=sys.stderr)
return 1 if any(r.drifted for r in results) else 0
if __name__ == "__main__":
sys.exit(main())
All knobs are settable via CLI flags; the env vars below provide defaults so you can bake them into a container image or CI job without changing the invocation.
| Env var | Default | Purpose |
|---|---|---|
REPLAY_TARGET |
http://localhost:3000 |
Base URL of the service under test. CLI --target overrides. |
REPLAY_TIMEOUT |
10 |
Per-request timeout in seconds. |
REPLAY_CONCURRENCY |
8 |
Thread pool size. Bump for high-latency targets; lower if the target is fragile. |
REPLAY_METHODS |
GET,POST |
Methods replayed by default. PUT/PATCH/DELETE are excluded because their bodies aren't in the log. |
REPLAY_MAX_BODY |
65536 |
Bytes read per response before hashing. Prevents OOM on large downloads. |
REPLAY_USER_AGENT |
api-replay-tester/1.0 |
Sent on every request so the target can identify replay traffic. |
CLI flags that don't have env equivalents: --path-prefix, --limit, --keep-5xx, --show-ok, --json-report, --format.
1. Basic replay against staging, GETs only:
$ python3 replay_test.py --traffic-log nginx_access.log \
--target http://staging:3000 --methods GET
Parsing nginx_access.log (combined) ...
Parsed 14823 lines, selected 612 for replay.
Replaying against http://staging:3000 (concurrency=8, timeout=10.0s) ...
[DRIFT] GET /api/v1/users/42 expected=200 got=404 (12ms)
[DRIFT] GET /api/v1/orders?since=2024-10-01 expected=200 got=500 (88ms)
[ERR] GET /api/v1/reports/export expected=200 -> timeout
Replayed: 612 Drift: 3 Clean: 609
Status distribution: 200=580, 404=17, 500=15
2. Focused run with a JSON report for CI:
$ python3 replay_test.py --traffic-log traffic.jsonl --format jsonl \
--target https://canary.internal --path-prefix /api/v2 \
--limit 200 --json-report replay.json
...
Replayed: 200 Drift: 0 Clean: 200
JSON report written to replay.json
$ echo $?
0
3. Verbose run against a local dev server:
$ python3 replay_test.py --traffic-log access.log \
--target http://localhost:8000 --show-ok --concurrency 4
[ OK ] GET /healthz status=200 (3ms)
[ OK ] GET /api/items status=200 (14ms)
...
Replayed: 87 Drift: 0 Clean: 87
2 and a clear message rather than a traceback.urlparse; a ftp:// or empty scheme is rejected before any traffic is sent.requests.Timeout, ConnectionError, and other RequestException subclasses are caught per-request and recorded as error on the ReplayResult. One dead endpoint never aborts the batch.as_completed loop wraps fut.result() defensively so an unexpected exception in a worker becomes a single drift entry instead of killing the pool.resp.raw.read(max_body, ...) caps memory. The body hash will differ from a full-body hash, but for drift detection that's fine.0 means clean, 1 means at least one drift or error, 2 means a configuration/IO problem. CI can treat 1 as a soft failure and 2 as a hard failure.har parsing dependency.--record and diff JSON bodies structurally (ignoring volatile fields like id, timestamp, request_id). A small recursive diff function is enough.replay_one with a token-bucket limiter and retry on 429 / 503 with exponential backoff. Useful when replaying against a production-shaped canary that has real rate limits.--shard 3/8) and run each shard as a separate process or Kubernetes Job, then merge the JSON reports. The --json-report format is already merge-friendly.