CLI · commit 8668c11

loadsmith

Scenario-based HTTP load testing with correct latency statistics

Download loadsmith.zip
Quickstart
unzip loadsmith.zip && cd loadsmith
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/python -m pytest -q

loadsmith

Scenario-based HTTP load testing that tells the truth about latency. You describe realistic multi-step user journeys in YAML, drive them with an open (arrival-rate) or closed (virtual users) model, and get percentiles that are corrected for coordinated omission: every request is timed both from when it was actually sent and from when it should have been sent. SLOs turn the run into a pass/fail exit code for CI.

Features

Install

uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'     # add ',http2' for HTTP/2
source .venv/bin/activate

Quickstart

python examples/target_server.py --port 8146 &        # demo API that stalls for 2s out of every 15s
loadsmith run examples/shop.yaml --html report.html -o results.json

Exit code: 0 all SLOs pass, 1 an SLO failed, 2 invalid scenario/arguments, 3 runtime failure.

Usage

Run

loadsmith run SCENARIO [-w WORKERS] [--rate N | --users N] [--duration 15s] [--max-concurrency N]
                       [--base-url URL] [--slo 'p99 < 500ms']... [--seed N] [-o results.json] [--html report.html]
                       [--no-live]

Real output of loadsmith run examples/shop.yaml --no-live --seed 1 against the example server (the stall falls inside the run):

Running shop: open model, 15s, 1 worker(s), target http://127.0.0.1:8146

shop  1294 requests in 15s (86.3 req/s), 1 errors (0.08%), 495 journeys completed, 0 dropped
                                  Latency (ms)
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━┓
┃                           ┃    p50 ┃    p90 ┃    p95 ┃   p99 ┃ p99.9 ┃   max ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━┩
│ corrected (from intended  │ 32.6ms │  143ms │  1.48s │ 1.89s │ 2.03s │ 2.05s │
│ start)                    │        │        │        │       │       │       │
│ service (from actual      │ 29.9ms │ 65.0ms │ 79.1ms │  162ms │ 2.03s │ 2.05s │
│ send)                     │        │        │        │       │       │       │
└───────────────────────────┴────────┴────────┴────────┴───────┴───────┴───────┘
                                     Steps
┏━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┓
┃ scenario / step          ┃ reqs ┃ errs ┃    p50 ┃    p95 ┃    p99 ┃ p99 svc ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━━┩
│ browse / list products   │  343 │    0 │ 17.7ms │ 31.2ms │  114ms │   114ms │
│ browse / login           │  343 │    0 │ 66.9ms │  1.85s │  1.90s │   215ms │
│ purchase / add to cart   │  152 │    0 │ 22.7ms │ 40.5ms │ 58.5ms │  58.5ms │
│ purchase / checkout      │  152 │    1 │ 62.9ms │  112ms │  280ms │   280ms │
│ purchase / list products │  152 │    0 │ 16.9ms │ 28.7ms │ 42.2ms │  42.2ms │
│ purchase / login         │  152 │    0 │ 60.8ms │  1.83s │  1.95s │   1.95s │
└──────────────────────────┴──────┴──────┴────────┴────────┴────────┴─────────┘
Errors by type: check=1
  latency >= 400ms: 1
                  SLOs
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┓
┃ objective           ┃ actual ┃ result ┃
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━┩
│ p99 < 500ms         │  1.89s │ FAIL   │
│ p99:service < 500ms │  162ms │ PASS   │
│ error_rate < 2%     │  0.08% │ PASS   │
│ rps > 80            │   86.3 │ PASS   │
└─────────────────────┴────────┴────────┘
SLO check failed

The naive p99 is 162 ms; the corrected p99 is 1.89 s. That gap is the stall that a closed-loop tool would never show you. See docs/coordinated-omission.md. The matching reports are in examples/sample-report.html and examples/sample-results.json.

When stdout is a terminal, a live dashboard shows elapsed time, active VUs, RPS, total requests/errors, corrected and service percentiles and sparklines of RPS and errors. --no-live (or a non-TTY) prints a progress line every 5 s instead.

Compare two runs

loadsmith compare a.json b.json [--max-regression 20]

Prints each metric for A and B with absolute and percentage change (red = worse, green = better) and a per-step table of corrected p95/p99. --max-regression PCT exits 1 if overall corrected p95 or p99 grew by more than PCT percent.

Record a scenario from a browser HAR

loadsmith record --har examples/sample.har [-o scenario.yaml] [--base-url URL] [--include-static]

Static assets and other hosts are dropped; cookies, user-agent and similar headers are removed; JSON bodies become json:; gaps between requests become think:; recorded 2xx/3xx statuses become status checks. Output (abridged):

name: recorded-journey
base_url: http://127.0.0.1:8146
load: {model: open, rate: 2, duration: 30s, max_concurrency: 50}
steps:
- name: POST /login
  method: POST
  url: /login
  json: {email: alice@example.com, password: secret}
  checks: [{status: 200}]
  think: 1.8
- name: GET /products
  ...
slos: [p95 < 500ms, error_rate < 1%]

Review the result: replace hard-coded values with templates and add extract:.

Scenario file reference

name: shop
base_url: http://127.0.0.1:8146
timeout: 10s                 # per request
http2: false
headers: {X-Env: load}       # default headers
variables: {qty: 2}          # available as {{ qty }}
precision: 3                 # histogram significant digits (1-5)

feeders:                     # CSV with header row; row bound to each virtual user
  user: {file: users.csv, mode: circular}   # circular | random | unique

load:
  model: open                # open (default) | closed
  stages:                    # linear ramp from the previous target; first stage starts at `start` (default 0)
    - {duration: 2s, target: 40}     # open: arrivals/s, closed: virtual users
    - {duration: 12s, target: 40}
    - {duration: 1s, target: 0}
  # shorthand instead of stages:  rate: 40 / users: 10  +  duration: 15s
  max_concurrency: 5         # cap on active journeys (open model)
  arrivals: poisson          # poisson | constant
  pace: 500ms                # closed model only: fixed iteration schedule (enables correction)
  grace: 10s                 # time allowed to finish after the schedule ends

slos: [p99 < 500ms, error_rate < 2%, rps > 80]

scenarios:                   # or put before/steps/after at the top level for a single scenario
  - name: purchase
    weight: 3
    before: [...]            # once per virtual user
    steps:
      - name: add to cart
        method: POST
        url: /cart
        headers: {Authorization: "Bearer {{ token }}"}
        json: {product_id: "{{ product_id }}", qty: "{{ qty }}"}   # or body: "raw text"
        extract:
          product_id: "$.items[3].id"                  # JSONPath shorthand
          sess: {header: X-Session}
          oid: {regex: 'order=(\w+)', from: body}      # from: body | a header name
        checks:
          - status: 200                                # or a list: [200, 201]
          - {json: $.items, equals: 1}                 # also: exists: true|false, contains: x
          - {body_contains: order_id}
          - {latency_lt: 400ms}                        # service latency; bare numbers are ms
        think: {uniform: [100ms, 400ms]}               # 0.5 | constant | uniform | exponential (mean)
    after: [...]

Templates. {{ var }}, {{ user.email }} (feeder row), {{ prev.status }}, {{ prev.json.items[0].id }}, {{ prev.headers.location }}, {{ prev.body }}, {{ vu.id }}, {{ iteration }}, {{ env.NAME }}, {{ timestamp }}, {{ random.uuid }}, {{ random.int(1, 9) }}, {{ random.float(0, 1) }}, {{ random.choice('a', 'b') }}, {{ random.string(8) }}, {{ random.email }}. Filters: | default('x'), | urlencode, | lower, | upper. In json: bodies a value that is a single expression keeps its type ("{{ qty }}" becomes a number).

JSONPath subset. $, .key, ['key'], [n] (negative allowed), [*], .*, ..key.

SLO syntax. metric[:corrected|:service][[step]] op value; metrics p50..p99.9 (any pNN), min, mean, max, error_rate, rps, requests, errors; operators < <= > >= ==. Examples: p95 < 300ms, p99:service < 1s, p95[checkout] < 800ms, error_rate < 1%. Latency SLOs use corrected latency by default.

Semantics worth knowing. In the open model each arrival is one virtual user running before, the steps once, then after. In the closed model each virtual user runs before once, loops the steps until the end, then after. HTTP status >= 400 is an error unless a status check lists it. Transport errors, status errors and failed extractions end the current journey; failed checks do not.

Configuration

Everything is in the scenario file; run options override base_url, rate/users, duration, concurrency, and add SLOs. --workers N spawns N processes, each taking 1/N of the arrival rate and concurrency cap. --seed makes arrival times, mix choices and random.* reproducible per worker.

Architecture

src/loadsmith/
  scenario.py   YAML -> dataclasses, feeders, overrides      engine.py     virtual users, open/closed loops, checks
  template.py   {{ }} rendering                             schedule.py   Poisson / ramp arrival times
  jsonpath.py   JSONPath subset                             runner.py     thread or process workers, aggregation
  hist.py       HDR-style histogram                         slo.py        SLO parse/evaluate
  stats.py      per-step + per-second recorder              results.py    JSON results
  dashboard.py  rich live view                              report_html.py  HTML + SVG
  compare.py    result diffing                              har.py        HAR -> scenario
  cli.py        click commands
examples/       target_server.py (port 8146), shop.yaml, users.csv, sample.har, sample report/results
docs/           coordinated-omission.md, design.md

See docs/design.md for the algorithms (histogram, arrival inversion, error semantics).

Tests

.venv/bin/pytest -q          # 93 tests, ~25 s (includes short end-to-end runs and a 2-process run)

Covers templating and extraction, histogram accuracy against numpy and merging, Poisson arrival statistics, coordinated omission on a simulated stall with a deterministic fake clock, SLOs, HAR conversion, scenario validation, engine semantics, end-to-end runs against the example server, multi-process merge, CLI exit codes and compare.

Limitations