loadr

loadr is a load testing platform in a single binary. It combines the two dominant traditions in load testing:

  • k6's model: scriptable tests, open/closed load models with precise executors, a first-class metrics engine with thresholds as pass/fail criteria, and a great CLI experience.
  • JMeter's breadth: rich assertions (response code, body, JSONPath, XPath, size, duration), extractors (regex, boundary, CSS, XPath), timers (constant, uniform, gaussian, constant-throughput), CSV data sets, cookie management, and broad protocol coverage.

…and adds what both lack: declarative YAML test definitions validated by a JSON Schema, a plugin system (sandboxed WASM components and native libraries), built-in distributed execution with mathematically correct percentile aggregation, and a built-in management web UI.

A taste

name: smoke
defaults:
  http: { base_url: https://api.example.com }

scenarios:
  api:
    executor: constant-arrival-rate
    rate: 100
    duration: 5m
    pre_allocated_vus: 50
    flow:
      - request:
          url: /search?q=widgets
          extract: [ { type: jsonpath, name: first, expression: "$.results[0].id" } ]
          checks: [ { type: status, equals: 200 } ]
      - request: { url: "/items/${first}" }

thresholds:
  http_req_duration: [ "p(95)<300" ]
  http_req_failed: [ "rate<0.01" ]
loadr run smoke.yaml          # exit code 0 when thresholds pass, 99 when not

How the pieces fit

ComponentWhat it does
loadr runrun a test locally (standalone mode)
loadr controller + loadr agentdistribute one test across a fleet
loadr validatelint a test file with line/column diagnostics
loadr convertimport JMeter .jmx files and k6 scripts
loadr reportrender an HTML report from saved results
Web UIlive dashboards, test editing, fleet management
Pluginsnew protocols, outputs, extractors, assertions, services

Continue with Installation, or jump to the YAML reference, the JS API, or the migration guides.

Installation

Release binaries

Download the archive for your platform from the downloads page, unpack it and put loadr on your PATH:

curl -sSL https://loadr.io/download/loadr-x86_64-unknown-linux-gnu.tar.gz | tar xz
sudo mv loadr-*/loadr /usr/local/bin/
loadr version

Builds are published for Linux (x86_64, aarch64), macOS (Intel & Apple Silicon) and Windows.

Docker

docker run --rm -v "$PWD:/work" loadr/loadr run /work/test.yaml

The image is distroless (no shell), runs as a non-root user, and contains only the loadr binary.

From source

With the source tree on your machine:

cargo install --path crates/loadr-cli

Rust 1.85+ is required. There are no system dependencies — protobuf compilation happens in-process (protox), TLS is rustls, and the JS engine (QuickJS) is compiled in.

Shell completions

loadr completions bash | sudo tee /etc/bash_completion.d/loadr
loadr completions zsh > "${fpath[1]}/_loadr"
loadr completions fish > ~/.config/fish/completions/loadr.fish

Editor support for test files

Generate the JSON Schema once and point your editor at it for autocomplete and inline validation — see JSON Schema & editor setup:

loadr schema > loadr.schema.json

Your first test

Create first.yaml:

name: first-test
defaults:
  http:
    base_url: https://httpbin.org
    timeout: 10s

scenarios:
  smoke:
    executor: constant-vus
    vus: 5
    duration: 30s
    flow:
      - request:
          name: get anything
          url: /anything?hello=loadr
          checks:
            - { type: status, equals: 200 }
            - { type: jsonpath, name: echoed arg, expression: "$.args.hello", equals: loadr }
      - think_time: { type: uniform, min: 500ms, max: 1500ms }

thresholds:
  http_req_duration: [ "p(95)<2000" ]
  checks: [ "rate>0.99" ]

Validate it first — loadr's validator reports precise positions and suggests fixes for typos:

$ loadr validate first.yaml
✓ first.yaml is valid (1 scenario, 1 request)

Run it:

$ loadr run first.yaml

  first-test — 1 scenario(s), 30.0s

  checks.....................: 100.00% — ✓ 214 ✗ 0
    ✓ status is 200 (107 / 107)
    ✓ echoed arg (107 / 107)
  http_req_duration..........: avg=312.44ms min=287.12ms med=305.81ms max=512.20ms p(90)=341ms p(95)=367ms p(99)=489ms
  http_reqs..................: 107 (3/s)
  iterations.................: 107 (3/s)
  vus........................: value=5 min=5 max=5

  thresholds:
    ✓ http_req_duration: p(95)<2000 (observed: 367.21)
    ✓ checks: rate>0.99 (observed: 1.00)

The exit code is 0 when all thresholds pass and 99 when any fail — wire it straight into CI.

What just happened

  • constant-vus kept exactly 5 virtual users iterating for 30 seconds — a closed load model (new iterations start only when the previous finishes).
  • Each iteration ran the flow: one HTTP request, two checks, then a random pause between 500 ms and 1.5 s.
  • Checks record pass/fail into the checks metric without failing the request (use assert: for failures). The threshold over checks is what gates the run.

Next steps

  • Watch it live: loadr run --ui first.yaml then open http://127.0.0.1:6464.
  • Save machine-readable results: loadr run --summary-export results.json first.yaml.
  • Browse examples/ — 15 runnable tests covering every feature.

The CLI

loadr <COMMAND>

Commands:
  run          Run a test (standalone, or submit to a controller)
  validate     Validate test files and print diagnostics
  convert      Convert JMeter .jmx or k6 .js files to loadr YAML
  controller   Run the distributed-mode controller
  agent        Run a load-generating agent
  plugin       List, install, enable, disable and inspect plugins
  report       Render an HTML report from a summary JSON file
  schema       Print the JSON Schema for test definitions
  completions  Generate shell completions
  version      Print version information

Global flags: -q/--quiet (errors only), -v/--verbose (repeat for more), --no-color.

loadr run

loadr run test.yaml                         # run locally
loadr run -e staging test.yaml              # apply the env.staging overrides
loadr run --vus 50 --duration 2m test.yaml  # override single-scenario load
loadr run --ui test.yaml                    # serve the live web UI during the run
loadr run --summary-export out.json test.yaml
loadr run --output json=samples.jsonl test.yaml   # ad-hoc output (repeatable)
loadr run --quiet test.yaml                 # summary only, no live progress
loadr run --controller host:6464 test.yaml  # submit via the controller's API port
Exit codeMeaning
0run finished, all thresholds passed
1error (invalid test, I/O, ...)
99run finished but thresholds failed (k6-compatible)
130interrupted (Ctrl-C twice; first Ctrl-C stops gracefully)

Selecting scenarios by tag

Tag scenarios in YAML with the scenario-level tags map, then run only the ones you want with --tags / --exclude-tags. (These same tags are also attached to the scenario's metric samples.)

scenarios:
  smoke_read:
    executor: shared-iterations
    vus: 2
    iterations: 10
    tags: { suite: smoke, kind: read }     # name → value pairs
    flow: [ { request: { url: /api/v1/items } } ]
  full_write:
    executor: ramping-vus
    stages: [ { duration: 1m, target: 30 } ]
    tags: { suite: full, kind: write }
    flow: [ ... ]

The filter matches against tag values, not the tag names:

  • --tags a,b — keep a scenario if it carries at least one of these values (any-match / OR). Omit --tags to start from every scenario.
  • --exclude-tags a,b — drop a scenario if it carries any of these values.
  • Exclude always wins: a scenario matched by both --tags and --exclude-tags is dropped.
  • Both flags take a comma-separated list and may be repeated.
  • If the filter leaves no scenarios, the run fails with an error rather than running nothing.
loadr run --tags smoke test.yaml                 # only scenarios tagged `smoke`
loadr run --tags read,write test.yaml            # tagged `read` OR `write`
loadr run --tags full --exclude-tags write test.yaml  # full load, reads only
loadr run --exclude-tags smoke test.yaml         # everything except smoke

After filtering, loadr prints how many of the original scenarios remain (unless --quiet).

loadr validate

$ loadr validate broken.yaml
error at line 12, column 5 (scenarios.api.executor): `constant-arrival-rate` requires `pre_allocated_vus`
error at line 18, column 9 (scenarios.api.flow[0].request.url): `${vars.api_kye}` is not defined under `variables:` — did you mean `api_key`?
2 error(s), 0 warning(s)

--format json emits diagnostics as JSON for editor/CI integration.

loadr convert

loadr convert plan.jmx -o converted.yaml
loadr convert k6-script.js -o converted.yaml

Conversion warnings (unsupported constructs, things to review) print to stderr; the output always passes loadr validate.

loadr plugin

loadr plugin list                      # discovered plugins + enabled state
loadr plugin install ./my-plugin-dir  # copy into the plugins directory
loadr plugin info my-extractor
loadr plugin disable my-extractor
loadr plugin enable my-extractor

The plugins directory is ~/.loadr/plugins (override with LOADR_PLUGINS_DIR or --plugins-dir).

loadr report

loadr run --summary-export results.json test.yaml
loadr report results.json -o report.html

Produces a self-contained HTML file: metric tables, latency percentiles, check and threshold outcomes — shareable with people who don't run loadr.

Test definition overview

A loadr test is one YAML file. Every top-level key:

name: my-test                # display name (optional)
description: what it does    # free text (optional)

defaults: { ... }            # request defaults: base URL, headers, timeouts, TLS, tags
env: { ... }                 # named environment overlays (-e <name>)
variables: { ... }           # static values: ${vars.name}
secrets: { ... }             # values from env/file: ${secrets.name} (redacted)
data: { ... }                # CSV / inline data sources: ${data.source.column}
metrics: { ... }             # custom metric declarations
js: { ... }                  # embedded JavaScript module + limits

scenarios: { ... }           # REQUIRED: the workloads
thresholds: { ... }          # pass/fail criteria over metrics
outputs: [ ... ]             # exporters: jsonl, csv, prometheus, influxdb, otlp, statsd
plugins: [ ... ]             # plugins to load

Unknown keys are rejected with a did-you-mean suggestion. Durations are strings like 300ms, 30s, 1m30s, 1h (bare numbers mean seconds).

Defaults

defaults:
  http:
    base_url: https://api.example.com   # joined with relative request URLs
    headers: { User-Agent: loadr/0.1 }
    timeout: 30s                        # per request (default 30s)
    follow_redirects: true              # default true
    max_redirects: 10
    version: auto                       # auto | http1 | http2 | http2-prior-knowledge
    compression: true                   # Accept-Encoding + auto-decompress
    keep_alive: true                    # reuse connections within a VU
    proxy: http://proxy.internal:3128
    cookies: true                       # automatic per-VU cookie jar
    tls:
      insecure_skip_verify: false
      ca_file: ./ca.pem                 # extra trusted roots
      cert_file: ./client.pem           # mTLS client certificate
      key_file: ./client-key.pem
      server_name: override.sni.name
  tags: { team: payments }              # added to every sample
  think_time: { type: uniform, min: 1s, max: 2s }   # default pause after each request

Minimal complete test

scenarios:
  s:
    executor: constant-vus
    vus: 1
    duration: 10s
    flow:
      - request: { url: https://example.com/ }

Everything else is optional. See the following chapters for each block, or generate the JSON Schema (loadr schema) for the exhaustive picture.

Scenarios & executors

A test has one or more scenarios, all running concurrently (offset with start_time). Each scenario picks an executor — the algorithm that schedules iterations. loadr implements all seven k6 executors with identical semantics.

scenarios:
  my_scenario:
    executor: ramping-vus        # which scheduling model
    # ... executor-specific knobs ...
    start_time: 30s              # delay after test start (default 0)
    graceful_stop: 30s           # time for in-flight iterations to finish (default 30s)
    exec: myJsFunction           # JS function to run per iteration (optional)
    flow: [ ... ]                # declarative steps per iteration (optional; needs flow and/or exec)
    pacing: { iterations_per_second: 10 }   # constant-throughput governor
    think_time: { type: constant, duration: 1s }  # default pause after each request
    tags: { kind: api }          # tags on all samples from this scenario

The scenario tags map (name → value) is attached to every sample from the scenario, and its values also drive loadr run --tags / --exclude-tags scenario selection — see Selecting scenarios by tag.

Closed-model executors

New iterations start only when a VU finishes its previous one — throughput depends on response times (a coordinated-omission-prone model; use open models to control offered load).

constant-vus

executor: constant-vus
vus: 50
duration: 5m

ramping-vus

VU count follows linear ramps between stage targets.

executor: ramping-vus
start_vus: 0
stages:
  - { duration: 2m, target: 100 }   # ramp 0 → 100
  - { duration: 5m, target: 100 }   # hold
  - { duration: 1m, target: 0 }     # ramp down
graceful_ramp_down: 30s             # grace for iterations on de-allocated VUs

per-vu-iterations

Each VU runs exactly N iterations.

executor: per-vu-iterations
vus: 10
iterations: 100        # per VU → 1000 total
max_duration: 10m      # safety cap (default 10m)

shared-iterations

A pool of N iterations split dynamically among VUs (fast VUs do more).

executor: shared-iterations
vus: 10
iterations: 1000       # total
max_duration: 10m

Open-model executors

Iterations start on schedule regardless of completion — the offered load is what you configured, and saturation shows up as dropped_iterations instead of silently lower request rates.

constant-arrival-rate

executor: constant-arrival-rate
rate: 100              # iteration starts per time_unit
time_unit: 1s          # default 1s (rate: 6000 + time_unit: 1m ≡ 100/s)
duration: 10m
pre_allocated_vus: 50  # workers created up front
max_vus: 200           # pool may grow to this before dropping iterations

ramping-arrival-rate

executor: ramping-arrival-rate
start_rate: 10
time_unit: 1s
pre_allocated_vus: 50
max_vus: 500
stages:
  - { duration: 2m, target: 100 }   # linear rate ramp
  - { duration: 5m, target: 100 }

externally-controlled

VU count is set at runtime — from the web UI's run page, the controller API, or programmatically. Great for exploratory "turn the dial" testing.

executor: externally-controlled
max_vus: 500
duration: 30m          # optional; omit to run until stopped

Graceful stop semantics

When a scenario's schedule ends (or the run is stopped), no new iterations start; in-flight iterations get graceful_stop (default 30s) to finish before being cancelled. ramping-vus additionally applies graceful_ramp_down to VUs being de-allocated mid-iteration during a downward ramp.

Requests

The flow: of a scenario is a list of steps, each a single-key mapping: request, think_time, js, or group.

flow:
  - request:
      name: create order          # metric tag (defaults to the URL string)
      protocol: http              # inferred from URL scheme when omitted
      method: POST                # default GET (POST when a body is present)
      url: /orders                # absolute, or relative to defaults.http.base_url
      params: { source: loadtest }    # query string parameters
      headers:
        X-Idempotency-Key: "${js: crypto.uuidv4()}"
      body: ...                   # see below
      timeout: 10s                # per-request override
      follow_redirects: false     # per-request override
      tags: { endpoint: orders }  # extra metric tags
      extract: [ ... ]            # see Extraction
      assert: [ ... ]             # failures mark the request failed
      checks: [ ... ]             # recorded only
  - think_time: { type: uniform, min: 1s, max: 3s }
  - js: "session.counterAdd('orders_created', 1)"
  - group:
      name: checkout              # nested samples get group="::checkout"
      steps: [ ... ]

Bodies

body: 'raw string with ${interpolation}'
# or structured (exactly one key):
body: { json: { sku: "W-1", qty: 2, note: "${vars.note}" } }   # sets Content-Type
body: { file: ./payload.bin }                                  # loaded at start
body: { form: { user: alice, pass: "${secrets.pw}" } }         # urlencoded
body:
  multipart:
    - { name: meta, value: '{"kind":"avatar"}', content_type: application/json }
    - { name: file, file: ./avatar.png, filename: avatar.png }

JSON bodies interpolate every string leaf; a leaf that is only ${expr} keeps its JSON type when the value parses as JSON ("${count}"7, not "7").

Protocol-specific blocks

Non-HTTP requests use the same step with an extra options block — see the protocol chapters:

- request: { url: wss://x/ws, ws: { send: ["hi"], receive_count: 1 } }
- request: { url: grpc://x:50051, grpc: { service: pkg.Svc, method: M, reflection: true, message: {...} } }
- request: { url: /graphql, protocol: graphql, graphql: { query: "...", variables: {...} } }
- request: { url: tcp://x:7000, socket: { send_text: "PING\n", read_bytes: 64 } }

Cookies

With defaults.http.cookies: true (the default) every VU has its own cookie jar: Set-Cookie responses are stored (RFC 6265 domain/path/secure/expiry matching) and sent automatically. Manual control is available from JS: session.cookieSet(url, name, value), session.cookieGet(url, name), session.cookiesClear().

Flow control

Beyond a straight sequence of steps, a flow can loop, branch and choose at random — covering Gatling's repeat/during/asLongAs/doIf/randomSwitch and Locust's weighted-task model in declarative YAML.

repeat — a fixed number of times

flow:
  - repeat:
      times: 3
      counter: attempt          # 0-based loop index, readable from JS (default `index`)
      steps:
        - request: { url: /poll }
        - think_time: { type: constant, duration: 1s }

while — as long as a condition holds

The condition is a JavaScript expression evaluated in the VU's runtime before each pass. max_iterations (default 10000) prevents runaway loops.

flow:
  - js: "session.vars.page = 0"
  - while:
      condition: "Number(session.vars.page) < 5"
      max_iterations: 20
      steps:
        - request: { url: "/feed?page=${page}" }
        - js: "session.vars.page = Number(session.vars.page) + 1"

if / else — branch on a condition

flow:
  - if:
      condition: "response && JSON.parse(session.vars.cart||'{}').items > 0"
      then:
        - request: { method: POST, url: /checkout }
      else:
        - request: { url: /cart/empty }

(else is optional.)

random — weighted / uniform / round-robin branches

The headline Locust paradigm (@task(weight)) and Gatling's switches. Each branch's samples are tagged with the branch name (or branch-<n>).

flow:
  - random:
      strategy: weighted          # weighted (default) | uniform | round_robin
      choices:
        - weight: 70
          name: browse
          steps:
            - request: { url: /search?q=widget }
        - weight: 25
          name: add_to_cart
          steps:
            - request: { method: POST, url: /cart, body: { json: { sku: W-1 } } }
        - weight: 5
          name: checkout
          steps:
            - request: { method: POST, url: /checkout }
StrategyBehaviour
weightedpick proportional to weight (default 1.0 each) — Gatling randomSwitch, Locust task weights
uniformevery branch equally likely — Gatling uniformRandomSwitch
round_robincycle through branches in order — Gatling roundRobinSwitch

Nesting

Control-flow steps nest arbitrarily — a random branch can contain a while, a repeat can wrap an if, and group still tags everything inside. This is how you model realistic user journeys: browse 1–5 pages, then with some probability add to cart, then maybe check out, retrying the payment up to 3 times. See examples/16-flow-control.yaml.

Extraction & correlation

Extractors pull values out of a response into named variables, available to every later step in the iteration as ${name} and to JS as session.vars.name.

- request:
    url: /checkout/start
    extract:
      - { type: jsonpath, name: order_id, expression: "$.order.id" }
      - { type: regex,    name: csrf,     expression: 'csrf" value="([^"]+)', group: 1 }
      - { type: xpath,    name: total,    expression: "//order/total" }
      - { type: css,      name: token,    expression: "input[name=token]", attribute: value }
      - { type: boundary, name: trace,    left: 'trace="', right: '"' }
      - { type: header,   name: location, header: Location }
- request:
    url: /orders/${order_id}
    headers: { X-Trace: "${trace}" }
TypeSourceNotes
jsonpathJSON bodyfull JSONPath; result keeps its JSON type
regexbody textgroup selects the capture group (default 1, 0 = whole match)
xpathXML bodyXPath 1.0
cssHTML bodyCSS selector; attribute: reads an attribute, otherwise element text
boundarybody textJMeter-style left/right boundary
headerresponse headerscase-insensitive

Common options:

  • default: value — used when nothing matches. Without a default, a failed extraction marks the request failed (http_req_failed) and the variable stays unset.
  • index: first | last | random | all — which match to take (all produces a JSON array). Supported by jsonpath, regex, css and boundary.

Extracted values are per-VU and per-iteration scoped state — they persist across steps within the iteration and across iterations of the same VU until overwritten.

Assertions & checks

The same condition types power two blocks with different consequences:

  • assert: — JMeter-style assertions. A failure marks the request failed (http_req_failed) and can change control flow via on_failure.
  • checks: — k6-style checks. Results are recorded into the checks rate metric (per-check, via the check tag) and never fail the request. Gate the run with a threshold: checks: ["rate>0.99"].
- request:
    url: /orders
    assert:
      - { type: status, equals: 201 }
      - { type: jsonpath, expression: "$.order.id", exists: true, on_failure: abort_iteration }
    checks:
      - { type: duration, name: fast enough, max: 250ms }
      - { type: body_contains, value: '"status":"pending"' }

Condition types

TypeFieldsPasses when
statusequals, one_of: [..], matches: "2.."status code matches
body_containsvalue, negatebody contains (or not) the substring
body_matchespattern, negatebody matches the regex
jsonpathexpression, equals, existsmatch exists (default) / equals the JSON value
xpathexpression, equals, existsXPath 1.0 result
durationmaxresponse duration ≤ max
sizemin, max, equalsbody size in bounds
headerheader, equals, contains, existsheader present/matching
jsexpressionthe JS expression is truthy (response is in scope)

All take an optional name (used in reports; a sensible one is generated otherwise) and, in assert: blocks, on_failure:

on_failureEffect
continue (default)record the failure, keep going
abort_iterationskip the rest of this iteration
abort_scenariostop this scenario
abort_teststop the whole run (exit code reflects failure)

JS conditions

checks:
  - type: js
    name: balanced response
    expression: "response.json ? true : JSON.parse(response.body).items.length > 0"

The response object has status, body, headers (lower-cased), duration_ms, url, error, protocol.

Thresholds

Thresholds are the pass/fail contract of a test, evaluated continuously during the run and finally at the end. Any failing threshold makes loadr run exit with code 99 (k6-compatible).

thresholds:
  http_req_duration:
    - "p(95)<400"                    # plain expression
    - threshold: "p(99.9)<1500"      # object form
      abort_on_fail: true            # stop the test the moment it fails...
      delay_abort_eval: 30s          # ...but not in the first 30s (warm-up)
  http_req_failed: [ "rate<0.01" ]
  checks: [ "rate>0.99" ]
  my_custom_counter: [ "count>1000" ]
  "http_req_duration{scenario:api}": [ "p(99)<250" ]   # tag-filtered

Expression syntax

<aggregation> <op> <bound> where op< <= > >= == !=.

AggregationApplies toMeaning
avg, min, max, medtrendstatistics in milliseconds
p(N)trendany percentile, e.g. p(95), p(99.9) (HDR-exact)
rateratepass fraction 0..1; on counters: events/second
countcountertotal
valuegaugelast value

Bounds accept durations for time metrics: p(95)<400ms, avg<1.5s.

Tag selectors

metric{tag:value,tag2:value2} aggregates only samples whose tags include all listed pairs. Useful tags: scenario, name (request name), method, status, group, check, plus anything from tags: blocks.

thresholds:
  "http_req_duration{name:checkout}": [ "p(95)<800" ]
  "checks{scenario:browse}": [ "rate>0.95" ]

Semantics worth knowing

  • A threshold over a metric with no samples passes (matching k6) — but loadr validate warns when the metric name is unknown.
  • abort_on_fail triggers a graceful stop (in-flight iterations finish, summary still produced, exit code 99).
  • In distributed runs thresholds are evaluated centrally on merged histograms, so p(99) is the true fleet-wide percentile.

Data parameterization

Feed iterations from CSV files or inline rows. A row is consumed once per iteration per source (the first reference fetches it; later references in the same iteration see the same row).

data:
  users:
    type: csv
    path: data/users.csv     # relative to the test file
    mode: shared             # shared | per_vu
    on_eof: recycle          # recycle | stop
    delimiter: ","           # default ,
    has_header: true         # default true; otherwise columns are col0, col1, ...
  fixtures:
    type: inline
    rows:
      - { sku: W-1, qty: 1 }
      - { sku: W-2, qty: 3 }

scenarios:
  buy:
    executor: per-vu-iterations
    vus: 5
    iterations: 100
    flow:
      - request:
          method: POST
          url: /cart
          body: { form: { user: "${data.users.username}", sku: "${data.fixtures.sku}" } }

Modes

  • shared — one cursor for the whole run; VUs pull the next row atomically. Rows are spread across VUs (each row used once per lap).
  • per_vu — every VU iterates the full data set from the top independently.

End of data

  • recycle — wrap to the first row (default).
  • stop — the VU that hits EOF stops iterating (JMeter's "stop thread on EOF"). With shared mode this winds the test down as the data runs out — handy for "process each row exactly once" jobs.

From JS, fetch the current row with session.data('users'){username: "...", password: "..."}.

Feeder strategies & throttling

Two more features borrowed from Gatling: feeder strategies (how rows are chosen) and a throttle (a hard request-rate ceiling).

Pick strategies

Any CSV, JSON or inline data source takes a pick strategy alongside its mode (shared/per-VU) and on_eof (recycle/stop):

data:
  users:
    type: csv
    path: data/users.csv
    mode: per_vu
    pick: shuffle       # sequential (default) | random | shuffle
    on_eof: recycle
pickBehaviour
sequentialrows in file order; the cursor advances by one (default) — Gatling circular
randoma uniformly random row every time; never exhausts (on_eof ignored) — Gatling random
shufflethe full set shuffled once per VU, then read in that order — Gatling shuffle

JSON feeders

Besides CSV and inline rows, a data source can be a JSON file — an array of objects, each object a row:

data:
  skus:
    type: json
    path: data/skus.json    # [ { "sku": "W-1", "name": "Widget" }, ... ]
    pick: random

Reference fields the same way: ${data.skus.sku}.

Throttling (request-rate ceiling)

A scenario can cap its aggregate request rate regardless of how many VUs are running or how fast the target responds — Gatling's throttle / reachRps(...). Iterations block before each request until a slot frees up (a global token-bucket limiter shared across all the scenario's VUs).

scenarios:
  steady:
    executor: constant-vus
    vus: 50
    duration: 10m
    throttle: { requests_per_second: 200 }   # never exceed 200 req/s in total
    flow:
      - request: { url: /api/items }

This is distinct from the arrival-rate executors (which control iteration starts) and from pacing (which spaces iterations): throttle is a ceiling on requests that applies on top of whatever executor you choose. Use it to stay under a known rate limit, or to hold a flat load while a closed model would otherwise overshoot.

See examples/17-feeders-and-throttle.yaml.

Variables, secrets & interpolation

${...} placeholders work in URLs, headers, params, bodies (string leaves), request names, WebSocket frames, gRPC messages and GraphQL variables.

FormResolves to
${env.NAME}process environment variable
${vars.name}the variables: block
${secrets.name}the secrets: block (redacted from logs/reports)
${data.source.column}current data row
${name}extracted variable / JS-set session.vars.name
${vu} / ${iteration} / ${scenario}the running VU id / iteration index / scenario name
${js: expr}evaluate JS in the VU's runtime, e.g. ${js: Date.now()}

Escape a literal with $${${.

variables:
  tenant: acme
  api_base: "https://${env.REGION}.api.example.com"   # env resolved at startup

secrets:
  api_key: { env: API_KEY }          # from the environment
  db_pass: { file: ./secrets/db }    # from a file (trimmed)

scenarios:
  s:
    executor: constant-vus
    vus: 1
    duration: 1m
    flow:
      - request:
          url: /tenants/${vars.tenant}/ping
          headers:
            X-Api-Key: ${secrets.api_key}
            X-Request-Id: "${js: crypto.uuidv4()}"

Notes:

  • variables values may interpolate ${env.*} — resolved once at startup. Other namespaces resolve per use, inside the iteration.
  • Secrets never appear in console output, summaries or validation messages.
  • loadr validate errors on ${vars.*} / ${secrets.*} / ${data.*} references that don't exist (with did-you-mean), and warns on bare names no extractor produces.

Think time & pacing

Think time (JMeter-style timers)

A pause, either as an explicit step or as a default after every request:

flow:
  - request: { url: / }
  - think_time: { type: constant, duration: 2s }
  - request: { url: /next }
TypeFieldsBehaviour
constantdurationfixed pause
uniformmin, maxuniformly random in [min, max]
gaussianmean, std_devnormal distribution, truncated at 0

Scenario- or test-wide default (applied after each request step):

defaults:
  think_time: { type: uniform, min: 1s, max: 3s }
scenarios:
  fast_api:
    think_time: { type: constant, duration: 100ms }   # overrides the default

Pacing (constant throughput)

The JMeter "constant throughput timer" equivalent: space iteration starts so the scenario approaches a target rate, with VUs as the concurrency ceiling.

scenarios:
  steady:
    executor: constant-vus
    vus: 20
    duration: 10m
    pacing: { iterations_per_second: 10 }   # ~10 iterations/s across all 20 VUs
    flow: [ { request: { url: / } } ]

Prefer the arrival-rate executors when you need precise offered load; pacing is the right tool when porting JMeter plans or when you want a closed model with an upper rate bound.

Outputs

Outputs stream metrics out of a run — raw samples and/or one-second aggregates. Configure any number:

outputs:
  - { type: json, path: results.jsonl }             # newline-delimited JSON
  - { type: csv, path: samples.csv }
  - type: prometheus
    listen: 127.0.0.1:9091                          # scrape endpoint (GET /metrics)
    remote_write_url: http://prom:9090/api/v1/write # and/or push
    interval: 5s
  - type: influxdb
    url: http://influxdb:8086
    database: loadr                                  # bucket (v2) / db (v1)
    token: ${env.INFLUX_TOKEN}
    organization: my-org
  - type: otlp
    endpoint: http://otel-collector:4317
    protocol: grpc                                   # grpc | http
    headers: { x-tenant: load }
  - { type: statsd, address: 127.0.0.1:8125, prefix: loadr. }
  - { type: plugin, name: my-exporter, config: { mode: fast } }

Or ad hoc from the CLI: loadr run --output json=results.jsonl test.yaml.

OutputGranularityNotes
jsonevery sample + snapshots + final summaryone JSON object per line (type field discriminates)
csvevery sampletimestamp_ms,metric,kind,value,tags
prometheus1 s aggregatesmetrics prefixed loadr_; trends as quantile gauges; counters as _total
influxdbinterval aggregatesline protocol, v1 and v2 APIs
otlpinterval aggregatesOpenTelemetry metrics over gRPC or HTTP/protobuf
statsdevery sampleDogStatsD-style tags
pluginbothany installed output plugin

The Grafana dashboard in deploy/grafana/dashboards/ is pre-built against the Prometheus naming; docker compose -f deploy/docker-compose.yml up gives you the full Prometheus + Grafana stack.

For end-of-run results in CI, prefer --summary-export results.json + loadr report results.json -o report.html.

Environments

One test file, many targets. The env: block holds named overlays that deep-merge over the document when selected with -e:

defaults:
  http: { base_url: https://prod.example.com, timeout: 10s }

env:
  staging:
    defaults:
      http:
        base_url: https://staging.example.com    # only this key changes
        tls: { insecure_skip_verify: true }
  ci:
    scenarios:
      api: { vus: 1, duration: 10s }             # tiny load in CI
    thresholds:
      http_req_duration: [ "p(95)<5000" ]        # lax CI thresholds

scenarios:
  api:
    executor: constant-vus
    vus: 20
    duration: 5m
    flow: [ { request: { url: /health } } ]
loadr run test.yaml               # production values
loadr run -e staging test.yaml    # staging overlay
loadr run -e ci test.yaml         # CI overlay

Merge rules:

  • Mappings merge recursively — you only write the keys that differ.
  • Scalars and lists replace — an overlay outputs: list replaces the base list entirely.
  • The env: block itself is removed before the merge (overlays can't nest).
  • Unknown -e names fail fast, listing the available environments.

Combine with ${env.*} interpolation and secrets: for values that differ per machine rather than per environment.

Embedded JavaScript overview

loadr embeds a JavaScript engine (QuickJS — see ADR-001) so dynamic logic lives next to the declarative YAML. JS is usable three ways:

1. Inline expressions

Anywhere ${...} works, ${js: <expr>} evaluates in the VU's runtime:

headers:
  X-Request-Id: "${js: crypto.uuidv4()}"
params:
  page: "${js: Math.ceil(Math.random() * 10)}"

2. Inline script steps

flow:
  - js: "session.counterAdd('pages_viewed', 1)"
  - js:
      script: |
        const row = session.data('users');
        session.vars.greeting = `hello ${row.username}`;
  - js:
      call: warmCache        # an exported function from the module

3. A module (inline or file)

js:
  file: ./script.js          # or  script: |  (inline source)
  timeout: 10s               # per-call wall-clock limit (default 10s)
  memory_limit_mb: 64        # per-VU heap limit (default 64)

The module is an ES module with k6-compatible imports:

import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Counter, Trend } from 'k6/metrics';

export function setup() { /* once, before VUs start */ return {...}; }
export default function (data) { /* per iteration when exec/default used */ }
export function teardown(data) { /* once, after the run */ }
export function beforeRequest(req) { /* around every YAML request */ return req; }
export function afterRequest(res) { /* ... */ }

Isolation & limits

Every VU gets its own JS runtime and context — no shared mutable state between VUs (matching k6). Each runtime enforces:

  • a heap limit (memory_limit_mb) — exceeding it throws;
  • a wall-clock interrupt per call (timeout) — infinite loops are killed;
  • no filesystem or network access except through the provided APIs (open() is restricted to the test's directory).

Values flow both ways

  • Extracted YAML values appear in JS as session.vars.<name>.
  • Values set from JS (session.vars.x = ...) are usable in YAML as ${x}.
  • setup()'s return value is passed to every scenario function and is readable in hooks.

Lifecycle hooks

            ┌──────────┐
            │ setup()  │  once, before any VU; may make requests;
            └────┬─────┘  return value shared (read-only) with all VUs
                 │
   ┌─────────────┴──────────────┐
   │ per iteration, per VU:     │
   │   flow steps               │   beforeRequest(req) ─▶ request ─▶ afterRequest(res)
   │   then exec function       │   (around every YAML request step)
   └─────────────┬──────────────┘
                 │
            ┌────┴──────┐
            │ teardown()│  once, after the run (even on abort)
            └───────────┘

setup() / teardown(data)

export function setup() {
  const res = http.post('/auth/token', JSON.stringify({ id: __ENV.CLIENT_ID }));
  return { token: res.json().token };          // must be JSON-serializable
}
export function teardown(data) {
  http.post('/auth/revoke', JSON.stringify({ token: data.token }));
}

Scenario functions

A scenario runs its YAML flow first (if any), then its exec function (default export when exec: default):

scenarios:
  scripted: { executor: constant-vus, vus: 10, duration: 5m, exec: buyFlow }
export function buyFlow(data, ctx) {
  // data = setup() result; ctx = { vu, iteration, scenario }
  const res = http.get('/items', { headers: { Authorization: `Bearer ${data.token}` } });
  check(res, { 'ok': (r) => r.status === 200 });
  sleep(1);
}

beforeRequest(req) / afterRequest(res)

Fire around every YAML request: step (not around http.* calls made from JS). beforeRequest may mutate and return the request:

export function beforeRequest(req) {
  req.headers['X-Signature'] = crypto.hmac('sha256', __ENV.SIGNING_KEY, req.body || '', 'hex');
  return req;       // returning nothing keeps the request unchanged
}

export function afterRequest(res) {
  if (res.status === 429) console.warn(`rate limited on ${res.url}`);
}

The req object: {name, method, url, headers, body}url, method, headers and body may be overridden by the returned object.

Per-VU on_start / on_stop

A scenario can name an exported function to run once per VU, around that VU's stream of iterations (Locust's on_start / on_stop):

  • on_start runs once, just before the VU's first iteration.
  • on_stop runs once, when the VU retires (after its last iteration). It is skipped for a VU that never ran an iteration.

Use them for per-user setup and cleanup that should happen once per virtual user rather than once per iteration — e.g. log in on start, log out on stop. Both receive the setup() result as their single argument:

scenarios:
  users:
    executor: constant-vus
    vus: 50
    duration: 5m
    on_start: login        # exported from the JS module
    on_stop: logout
    exec: browse
export function login(data) {
  const res = http.post('/auth/login', JSON.stringify({ pw: __ENV.PW }));
  // Stash per-VU state on the VU's session for later iterations.
  session.vars.token = res.json().token;
}

export function browse(data) {
  http.get('/feed', { headers: { Authorization: `Bearer ${session.vars.token}` } });
}

export function logout(data) {
  http.post('/auth/logout', JSON.stringify({ token: session.vars.token }));
}

on_start runs per VU (so once per simulated user), whereas setup() runs once for the whole test. A failing on_start / on_stop is logged as a warning and does not abort the run.

handleSummary(data)

Export handleSummary to produce a custom end-of-run report. It runs once, after teardown(), with the run summary as its single argument. If it returns a string, that string replaces the default console summary; returning nothing (or null) leaves the default summary in place. This matches k6's handleSummary.

export function handleSummary(data) {
  const reqs = data.metrics.find((m) => m.metric === 'http_reqs');
  const dur  = data.metrics.find((m) => m.metric === 'http_req_duration');
  return [
    `run ${data.run_id} — ${data.duration_secs.toFixed(1)}s`,
    `requests: ${reqs ? reqs.agg.sum : 0}`,
    `p95 latency: ${dur ? dur.agg.p95.toFixed(1) : 0} ms`,
    `thresholds passed: ${data.thresholds_passed}`,
  ].join('\n');
}

data is the run summary (the same object written by the JSON output):

{
  name, run_id,
  started_ms, ended_ms, duration_secs,
  scenarios: ['users', ...],            // scenario names
  metrics: [ { metric, kind, agg: { avg, min, med, max, p90, p95, p99,
                                    sum, count, rate, per_second, last } }, ... ],
  checks:  [ { name, passes, fails }, ... ],
  thresholds: [ ... ],
  thresholds_passed: true,
  aborted: null,                        // abort reason, if any
}

Non-string return values are pretty-printed as JSON and used as the report.

JS API reference

All globals are also importable k6-style: import http from 'k6/http', import { check, sleep, group } from 'k6', import { Counter, Gauge, Rate, Trend } from 'k6/metrics'.

http

http.get(url, params?)
http.post(url, body?, params?)        // also put, patch, del, head, options
http.request(method, url, body?, params?)
  • body: string, or object (serialized as JSON with Content-Type: application/json).
  • params: { headers: {}, timeout: 5000 /* ms */, tags: {}, name: 'metric name' }.
  • Relative URLs join defaults.http.base_url. Requests use the VU's cookie jar, connection pool and TLS settings, and emit the full http_* metric family.

Response object:

{
  status: 200, status_text: 'OK',
  body: '...',            // string
  json(),                 // parsed body (or null)
  headers: { 'content-type': '...' },   // lower-cased keys
  duration_ms: 87.2,
  timings: { dns_ms, connect_ms, tls_ms, sending_ms, waiting_ms, receiving_ms, duration_ms, blocked_ms },
  error: null,            // transport error string, if any
  url: 'https://...',     // final URL after redirects
  protocol: 'HTTP/2'
}

check(value, conditions, tags?)

check(res, {
  'status 200': (r) => r.status === 200,
  'fast': (r) => r.duration_ms < 200,
  'flag set': someBoolean,
});

Each key records a pass/fail sample into the checks metric (tag check=<key>). Returns true when all passed. Never throws.

sleep(seconds) and group(name, fn)

sleep(1.5);
group('checkout', () => { http.post('/cart', ...); });

Groups nest; samples inside carry group="::checkout" tags.

Metrics

const errors = new Counter('business_errors');
const queue = new Gauge('queue_depth');
const hits = new Rate('cache_hits');
const renderTime = new Trend('render_time');

errors.add(1);
queue.add(42);
hits.add(true);                       // or 1/0
renderTime.add(16.6, { page: 'home' });   // value + extra tags

Metrics are registered on first use (or declare them in YAML metrics: to use them in thresholds with validation).

session — the VU bridge

session.vu              // VU id (number)
session.iteration       // current iteration (0-based)
session.scenario        // scenario name
session.vars.foo        // shared variable store: ${foo} in YAML sees this
session.vars.foo = 'x'
session.data('users')   // current data row for a source: {col: value}
session.cookieGet(url, name)
session.cookieSet(url, name, value)
session.cookiesClear()
// conveniences for YAML one-liners:
session.counterAdd(name, value, tags?)
session.gaugeSet(name, value, tags?)
session.rateAdd(name, pass, tags?)
session.trendAdd(name, value, tags?)

crypto

crypto.sha256('data', 'hex')       // or 'base64'
crypto.sha1('data', 'hex')
crypto.md5('data', 'hex')
crypto.hmac('sha256', 'secret', 'data', 'hex')
crypto.randomBytes(16)             // array of bytes
crypto.uuidv4()                    // string

encoding

encoding.b64encode('hello')        // 'aGVsbG8='
encoding.b64decode('aGVsbG8=')     // 'hello'

Environment & files

__ENV.MY_VAR                       // process environment (string | undefined)
open('./payload.json')             // file contents as string
open('./blob.bin', 'b')            // as bytes

open() resolves relative to the test file's directory and refuses to read outside it.

console

console.log/info/warn/error/debug route into loadr's structured logging (visible with -v, in the web UI log view, and in agent logs).

HTTP

The HTTP client is built directly on hyper with a custom connection layer so every phase of every request is measured — no averaged guesses:

MetricPhase
http_req_blockedwaiting for a connection (dns + connect + tls on cold connections; ~0 on reuse)
http_req_connectingTCP connect
http_req_tls_handshakingTLS handshake
http_req_sendingwriting the request
http_req_waitingtime to first byte (TTFB)
http_req_receivingreading the body
http_req_durationsending + waiting + receiving

Plus http_reqs, http_req_failed (transport error or status ≥ 400), data_sent, data_received. Samples carry name, method, status, scenario, group, proto tags.

Versions

defaults.http.version:

  • auto (default) — ALPN negotiation; HTTP/2 when the server offers it.
  • http1 — force HTTP/1.1.
  • http2 — offer only h2 over TLS.
  • http2-prior-knowledge — HTTP/2 without negotiation, including plaintext.

HTTP/2 connections are multiplexed; HTTP/1.1 connections are kept alive and reused per VU (a VU models one user agent: its own connections and cookie jar). keep_alive: false closes after each request.

TLS & mTLS

defaults:
  http:
    tls:
      ca_file: ./internal-ca.pem        # extra trust roots (PEM, may contain several)
      cert_file: ./client.pem           # client certificate (mTLS)
      key_file: ./client-key.pem
      server_name: api.internal         # SNI override
      insecure_skip_verify: false       # accept any cert (testing only!)
      min_version: "1.2"                # pin the lowest TLS version offered
      max_version: "1.3"                # pin the highest TLS version offered

Roots default to the bundled Mozilla store (webpki-roots). Everything is rustls — no OpenSSL dependency.

TLS version pinning

tls.min_version and tls.max_version constrain which TLS versions the handshake may negotiate. Both are strings and accept only "1.2" or "1.3" (the 1. prefix and a TLSv1. prefix are both tolerated, so "TLSv1.3" works too). When neither is set the client offers TLS 1.2 and 1.3 and lets the server pick the highest.

defaults:
  http:
    tls:
      min_version: "1.3"     # refuse anything older than TLS 1.3

Pinning is useful for proving a server has dropped legacy TLS, or for forcing a specific version while profiling. A configuration whose min_version is higher than its max_version (so no version remains) is rejected at startup.

Redirects, compression, proxies

  • Redirects followed by default (max_redirects: 10); 301/302/303 switch to GET, 307/308 preserve method and body. Timings accumulate across hops; the reported url is the final one.
  • compression: true sends Accept-Encoding: gzip, deflate, br and transparently decompresses. data_received counts wire (compressed) bytes.
  • proxy: http://host:3128 routes plaintext requests via absolute-form and HTTPS via CONNECT.

Cookies

Automatic per-VU jars (RFC 6265 domain/path/secure/expiry matching) — see Requests.

Response caching

cache: true gives each VU a browser-style HTTP cache, modelled on JMeter's HTTP Cache Manager. Only GET requests are cached, and only when the response says so:

defaults:
  http:
    cache: true

The cache key is the full request URL. Behaviour per GET:

  • Fresh hit — if a stored entry is still within its max-age, it is served straight from cache with no network round trip. Timings are zero and bytes_sent is 0.
  • Revalidation — if an entry has expired but carries a validator (ETag and/or Last-Modified), loadr re-requests it with If-None-Match / If-Modified-Since. A 304 Not Modified serves the cached body and refreshes its freshness window; the response timings/bytes reflect the conditional request.
  • Store — a 200 OK whose Cache-Control allows caching (a max-age=N and no no-store/private) is stored for next time.

Cache-Control: no-store or private are never cached. Responses without a max-age are not stored. The cache lives in the VU and is not shared between VUs, so the first iteration of each VU populates it.

Each served response carries a cache field in its extras set to hit, revalidated, or miss, which is handy when inspecting traffic with --http-debug.

Per-host connection overrides

hosts pins one or more hostnames to fixed addresses, bypassing DNS — the equivalent of curl's --resolve or k6's options.hosts. Use it to send traffic at a specific node behind a load balancer, to test before DNS has propagated, or to hit a staging box while keeping the real Host header.

defaults:
  http:
    hosts:
      api.example.com: 10.0.0.42          # host          -> ip
      api.example.com:443: 10.0.0.42:8443 # host:port      -> ip:port
      cdn.example.com: 10.0.0.43:8080     # host          -> ip:port

Keys are matched case-insensitively. A host:port key matches only requests to that exact port; a bare host key matches any port. When the mapped value omits a port, the request's original port is kept. Only connection routing changes — the URL, Host header, SNI and certificate validation all still use the original hostname.

Discarding response bodies

discard_response_bodies: true drops each response body as soon as it has been read and measured. This keeps memory flat during high-throughput or long soak runs where bodies would otherwise pile up.

defaults:
  http:
    discard_response_bodies: true

Discarding happens after the body is fully received and decompressed, so data_received and all phase timings stay accurate. Extractors and body assertions that run on a discarded response see an empty body, so only enable this when you are asserting on status/headers/timings rather than body content.

Distributed tracing

tracing: true injects a W3C Trace Context traceparent header on every request, so spans generated by loadr correlate with traces in your backend (Jaeger, Tempo, Honeycomb, ...) — like k6's tracing.

defaults:
  http:
    tracing: true

A fresh traceparent (00-<32-hex trace-id>-<16-hex span-id>-01) is generated per request. The trace ids only need to be unique, not cryptographically random, so they are produced from a fast per-VU PRNG. If a request already carries a traceparent header (set on the request or in defaults.http.headers), loadr leaves it untouched.

Wire-level debugging

For a verbose dump of every HTTP request and response — request line, all headers, and a preview of the response body (first 2000 chars) — enable HTTP debug. This is for diagnosing a single test interactively, not for load runs.

loadr run test.yaml --http-debug

The --http-debug flag sets the LOADR_HTTP_DEBUG environment variable, which the HTTP handler reads on startup; setting LOADR_HTTP_DEBUG directly has the same effect:

LOADR_HTTP_DEBUG=1 loadr run test.yaml

Output is logged under the loadr::http_debug target. Combined with cache: true, the logged responses also show the cache state (hit / revalidated / miss) for each GET.

WebSocket

A request with a ws:///wss:// URL (or protocol: ws) opens a WebSocket session: connect → send frames → receive until a condition → close.

- request:
    name: chat session
    url: wss://chat.example.com/ws
    headers: { Origin: https://chat.example.com }   # handshake headers
    ws:
      subprotocols: [ "chat.v2" ]
      send:
        - '{"type":"hello"}'                          # text frame
        - { text: '{"type":"msg","body":"hi ${vu}"}', delay: 500ms }
        - { binary_base64: "3q2+7w==", delay: 100ms } # binary frame
      receive_count: 2          # close after N received messages
      receive_until: '"done"'   # ...or when a text message contains this
      session_duration: 10s     # ...or after this long (request timeout still caps everything)
    checks:
      - { type: body_contains, value: '"type":"ack"' }   # runs on the LAST received message

Default receive behaviour (when neither receive_count nor receive_until is set): wait for one message per sent frame.

Metrics

MetricMeaning
ws_connectingTCP + TLS + upgrade handshake time
ws_session_durationopen → close
ws_msgs_sent / ws_msgs_receivedframe counters
data_sent / data_receivedpayload bytes

Extraction and conditions operate on the last received message as the response body; extras exposes msgs_sent, msgs_received and last_message for js conditions.

wss:// uses the same TLS configuration as HTTP (custom CAs, mTLS, insecure_skip_verify).

Server-Sent Events

A request with an sse:///sses:// URL opens a one-way Server-Sent Events stream: connect → GET with Accept: text/event-stream → read events frame-by-frame until a stop condition → close.

- request:
    name: order updates
    url: sse://events.example.com/orders/stream
    headers:
      Authorization: Bearer ${token}        # sent on the GET handshake
      Last-Event-ID: "${cursor}"
    checks:
      - { type: body_contains, value: '"status":"shipped"' }   # runs on the LAST event's data

The handler always issues a GET (any other method is an error) and adds Accept: text/event-stream, Cache-Control: no-cache and Connection: keep-alive for you. Caller headers and the VU's cookie jar are merged in. sse:// maps to http://; sses:// maps to https:// and uses the same TLS configuration as HTTP (custom CAs, mTLS, insecure_skip_verify, server_name).

Wire format

The stream is parsed per the SSE spec: event:, data:, id: and retry: fields are accumulated and an event is dispatched on each blank line. Multiple data: lines are joined with \n; a missing event: defaults to message; comment lines (starting with :) are ignored; retry: is recognised but not acted upon (reads are single-shot). A leading space after the field colon is stripped, and both \n and \r\n line endings are handled.

Stop conditions

By default the stream is read until the server closes it or the request timeout elapses. Three limits bound the read (whichever is hit first wins, and the request timeout always caps everything):

OptionMeaning
eventsStop after this many events have been dispatched.
untilStop on the first event whose data contains this substring.
durationStop after this wall-clock window (e.g. 10s, 500ms, 2m, or a bare number of seconds).

Metrics

MetricMeaning
plugin_reqsCount of completed SSE requests
plugin_req_durationsend + wait (TTFB) + receive time
data_sent / data_receivedrequest bytes / streamed event bytes
http_req_failedfailure rate (transport error or stream read error)

Samples are tagged proto=sse alongside the usual name, method and status. The reported status is the HTTP status of the stream response (e.g. 200); a connection or handshake failure reports status 0 with an error.

Extraction, checks and assertions

The data of the last received event becomes the response body, so every extractor and condition (body_contains, body_matches, regex, size, status, header…) operates on it. A js condition sees the response as response with status, status_text, body, headers, duration_ms, error, url and protocol in scope.

checks:
  - { type: body_contains, name: shipped, value: '"status":"shipped"' }
assert:
  - { type: status, equals: 200 }
  - { type: js, expression: 'response.body.length > 0' }

checks are recorded to the checks metric and never fail the request; assert failures mark the request failed.

Beyond the response body, the handler also reports events_received, last_event ({ "type", "data", "id" }) and the parsed events (capped at the first 100) as protocol extras, which surface in run reports.

gRPC

loadr calls gRPC services dynamically — no code generation, no protoc binary. Describe the service either with .proto files (compiled in-process by protox) or via server reflection.

- request:
    name: say hello
    url: grpc://greeter.example.com:50051       # grpcs:// for TLS
    grpc:
      proto_files: [ protos/helloworld.proto ]  # relative to the test file
      proto_includes: [ protos/ ]               # import search paths
      service: helloworld.Greeter
      method: SayHello
      message: { name: "vu-${vu}" }             # request message as JSON
      metadata: { x-api-key: "${secrets.key}" }
    assert:
      - { type: status, equals: 0 }             # gRPC code: 0 = OK
      - { type: jsonpath, expression: "$.message", exists: true }

With reflection instead of files:

grpc:
  reflection: true
  service: helloworld.Greeter
  method: SayHello
  message: { name: "world" }

Streaming

All four shapes are supported. Streaming requests provide messages (a list) instead of message:

grpc:
  reflection: true
  service: helloworld.Greeter
  method: LotsOfReplies          # server streaming: responses collected
  message: { name: "stream" }
---
grpc:
  service: pkg.Ingest
  method: Push                   # client streaming
  messages: [ { v: 1 }, { v: 2 }, { v: 3 } ]

The response body is the (last) response message rendered as JSON, so jsonpath extraction/assertions work naturally. extras.messages holds every streamed response; extras.message_count the count.

Semantics & metrics

  • status is the gRPC status code (0 = OK); non-zero marks the request failed. status_text carries the code name and message.
  • Metrics: grpc_reqs, grpc_req_duration, plus data_sent/data_received.
  • Channels are pooled per VU per endpoint; proto descriptor pools are compiled once and cached process-wide.
  • grpcs:// uses the standard TLS config (custom CAs, mTLS).

GraphQL

GraphQL rides on the HTTP client (protocol: graphql): loadr builds the standard {query, variables, operationName} POST envelope, then understands GraphQL's error semantics on top of HTTP's.

- request:
    name: search
    url: /graphql
    protocol: graphql
    graphql:
      query: |
        query Search($term: String!) {
          products(search: $term) { edges { node { id name } } totalCount }
        }
      variables: { term: "widget" }       # string leaves interpolate ${...}
      operation_name: Search
    extract:
      - { type: jsonpath, name: first_id, expression: "$.data.products.edges[0].node.id" }
    checks:
      - { type: jsonpath, name: no errors, expression: "$.errors", exists: false }

Failure semantics

A GraphQL response is marked failed when:

  • the HTTP layer failed (transport error or status ≥ 400), or
  • the body has a non-empty errors array and no data (total failure).

Partial errors (errors alongside data) do not fail the request — assert on them explicitly if they matter:

assert:
  - { type: jsonpath, expression: "$.errors", exists: false }

Metrics

GraphQL requests emit the full http_* family plus graphql_reqs and graphql_req_duration, so you can threshold GraphQL separately:

thresholds:
  graphql_req_duration: [ "p(95)<400" ]

extras.graphql_errors carries the error count for js conditions.

Redis

Drive a Redis server with real commands and measure the round trip. loadr speaks the RESP wire protocol directly over a raw TCP connection — no client library, no pipelining — so every request is one command in, one reply out, timed end to end.

- request:
    name: set greeting
    url: redis://cache.example.com:6379
    body: "SET greeting hello"        # one RESP command per request
    checks:
      - { type: status, equals: 0 }   # 0 = OK, non-zero = RESP error reply
      - { type: body_contains, value: OK }

When to use

Reach for this when the thing under test is Redis: cache warm-up storms, key-space contention, INCR hot keys, or simply checking that latency holds under a steady command rate. For anything that merely uses Redis behind an HTTP API, test the API with the http handler instead.

The target URL

redis://host[:port][/db]
  • scheme must be redis.
  • port defaults to 6379 when omitted.
  • db — an optional numeric path selects a database. On a freshly opened connection loadr issues SELECT <db> before the first command; a failing SELECT surfaces as a connection error. redis://host/3 selects db 3; redis://host leaves the default db 0.
url: redis://127.0.0.1:6379       # default db
url: redis://cache.internal/2     # SELECT 2 on connect

Expressing the command

The command is taken from the request body: a single line whose whitespace-separated tokens become the command and its arguments. loadr encodes them as a RESP array of bulk strings and sends exactly that.

- request: { name: ping,  url: redis://localhost, body: "PING" }
- request: { name: write, url: redis://localhost, body: "SET session:${vu} active" }
- request: { name: read,  url: redis://localhost, body: "GET session:${vu}" }
- request: { name: bump,  url: redis://localhost, body: "INCR page:views" }
- request: { name: ttl,   url: redis://localhost, body: "EXPIRE session:${vu} 60" }

${...} interpolation works in the body like anywhere else, so per-VU keys and data-feed values flow straight into the command.

Because the body is split on whitespace, arguments that themselves contain spaces cannot be expressed this way — use distinct keys/values, or a value that is a single token. An empty body is rejected ("no redis command provided").

Connection pooling

Connections are pooled per virtual user, keyed by host:port:

  • The first command from a VU to a given endpoint opens a TCP socket (TCP_NODELAY set), runs the optional SELECT, and keeps the socket.
  • Every later command from that VU to the same endpoint reuses the open socket — no reconnect, no re-SELECT. This shows up in the timings: the first request has a non-zero connect phase, reused ones do not.
  • If a pooled socket is found to be broken (the previous command left it in an error state, or the peer dropped it), loadr transparently discards it and dials a fresh one for that command.

Pools are per-VU, so N virtual users hold up to N live connections per endpoint — size your scenario vus with the server's connection limit in mind.

Replies, status, and body

A request succeeds at the transport level whenever loadr gets a well-formed RESP reply. Whether that reply is an error reply is reflected in status:

ReplystatusBodyextras.reply_type
+OK simple string0the string (OK)string
:42 integer0the number as text (42)integer
$5\r\nhello bulk string0the bytes (hello)bulk
*… array0the array rendered as JSONarray
$-1 / *-1 null0emptynil
-ERR … error replynon-zerothe error texterror

So a missing key (GET of an absent key → nil) is a success with an empty body, while -ERR unknown command is a failure (status ≠ 0, the message also lands in error). A connection failure or timeout is reported as status: 0 with error set and no reply.

extras carries the parsed reply for assertions and extraction:

  • extras.reply_type — one of string, integer, bulk, array, nil, error.
  • extras.value — the reply as JSON: a string for simple/bulk/error replies, a number for integers, an array for multi-bulk replies, null for nil.

Checks and assertions

Checks run against the same body and status every other protocol exposes:

- request:
    name: increment counter
    url: redis://localhost
    body: "INCR jobs:done"
    assert:
      - { type: status, equals: 0 }                  # not an error reply
    checks:
      - { type: body_matches, pattern: '^[0-9]+$' }  # integer came back
      - { type: duration, name: cache is fast, max: 5ms }

- request:
    name: read flag
    url: redis://localhost
    body: "GET feature:beta"
    checks:
      - { type: body_contains, value: "on" }
      - { type: size, name: non-empty, min: 1 }      # fail if key was nil
  • statusequals: 0 to require a non-error reply (or one_of/matches).
  • body_contains / body_matches — match against the reply value as text (the bulk value, the simple string, or the integer's digits).
  • size — bound the reply length; min: 1 is a handy "key existed" guard, since a nil reply has an empty body.
  • duration — cap the per-command round trip.

Checks are recorded to the checks metric and never fail the request; assert entries mark the request failed (and can abort via on_failure).

Extract reply values for later steps with the usual extractors, e.g. a boundary/body_matches extractor over the reply text.

Timings & metrics

The handler measures the command lifecycle: the TCP connect phase (first command only), sending while the command is written and flushed, and waiting while the reply is read. duration is their sum. bytes_sent counts the encoded command, bytes_received the reply body.

Standard request metrics apply (data_sent, data_received, and the request rate/duration series), so thresholds work as for any protocol:

thresholds:
  checks: [ "rate>0.99" ]

Browser

The browser protocol drives a real headless Chrome over the Chrome DevTools Protocol (CDP). A request navigates the page to a URL, waits for the load to settle, then reads Navigation Timing and Web Vitals straight out of the page — so the numbers reflect what a user's browser actually does: DNS/connect/TLS, time to first byte, the DOMContentLoaded and load events, first and largest contentful paint, and every subresource the page pulls in.

plugins:
  - name: browser            # register the browser protocol

scenarios:
  homepage:
    executor: constant-vus
    vus: 5
    duration: 1m
    flow:
      - request:
          name: load homepage
          protocol: browser   # required — there is no URL-scheme shorthand
          url: https://example.com
          timeout: 30s        # navigation timeout (default 30s)
          checks:
            - { type: status, equals: 200 }
            - { type: body_contains, value: "</html>" }

When to use it

Use browser when you need real client-side timing — paint metrics, JavaScript execution, and the cost of all the subresources a page fetches. Use the protocol-level http client for everything else: it is far cheaper per request and measures the transport precisely, but it does not render a page, run scripts, or fetch subresources.

Runtime requirement

A Chrome/Chromium binary must be installed on the runner (the handler launches /usr/bin/google-chrome with --headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage). Chrome is launched lazily — only on the first browser request — so tests that never reach a browser step pay nothing.

One Chrome process is shared per run. Each VU gets its own tab, reused across requests, so navigation within a VU keeps a warm cache and a single browsing session (a VU models one user). Navigation failures (DNS, connection refused, aborts) are recorded as a failed sample with status = 0 and an error, not as a crash; only a timeout aborts the step.

Request shape

FieldMeaning
protocol: browserRequired. The browser protocol has no URL-scheme alias, so it must be named explicitly and listed under plugins:.
urlAbsolute URL to navigate to (http:// or https://), passed verbatim to the page. Supports ${...}.
timeoutNavigation timeout; falls back to defaults.http.timeout, then 30s.
checks / assertRun against the navigation: status (the real HTTP status of the main document), body_contains / body_matches (the rendered HTML), duration, etc.

Only the navigation timeout is taken from defaults.http; other HTTP options (TLS, redirects, compression, cookies) do not apply to the browser protocol.

Metrics

Browser navigations record into the generic plugin_* metric family, plus the shared failure and byte counters:

MetricKindMeaning
plugin_reqsCounternavigations
plugin_req_durationTrendfull navigation time (ms)
http_req_failedRatenavigation error or status ≥ 400
data_receivedCounterbytes transferred for the document + subresources

The standard sample tags apply (name, method, status, proto = browser, scenario, group).

Web Vitals & timing extras

Each response carries the captured page metrics in extras, available to js conditions and JavaScript steps via response.extras:

KeyMeaning
fcp_msFirst Contentful Paint (may be null if unavailable)
lcp_msLargest Contentful Paint (captured via PerformanceObserver; may be null)
dcl_msDOMContentLoaded event end
load_msload event end
resourcesnumber of subresources fetched
transferred_bytestotal transfer size (document + subresources)
titlethe page's document.title

The Navigation Timing phases (DNS, connect, TLS, TTFB, receiving, total duration) are mapped onto loadr's standard request timings, so they appear in the trend breakdown alongside other protocols.

TCP & UDP

Raw socket round trips for protocols of your own: connect/bind, send a payload, read a response, measure.

- request:
    name: tcp ping
    url: tcp://gateway.example.com:7000
    socket:
      send_text: "PING ${vu}\r\n"     # UTF-8 payload with interpolation
      read_bytes: 64                  # read exactly N bytes...
      # read_until_close: true        # ...or until the server closes
      read_timeout: 2s                # default: the request timeout
    checks:
      - { type: body_contains, value: PONG }

- request:
    name: udp probe
    url: udp://stats.example.com:8125
    socket:
      send_hex: "deadbeef 0102"       # hex payload (whitespace ignored)
      read_timeout: 500ms             # waits for one datagram; absence = failure

Behaviour:

  • TCP — connect (timed), send, then read per the options: read_bytes for a fixed length, read_until_close until EOF, or (default) a single read of whatever arrives first.
  • UDP — bind an ephemeral port, send_to, then receive one datagram (or read_bytes worth) within read_timeout.

The received bytes become the response body, so every extractor and condition (regex, boundary, size, body_matches…) works on binary-ish payloads via their text forms.

Metrics: tcp_reqs/tcp_req_duration, udp_reqs/udp_req_duration, data_sent, data_received.

Distributed testing overview

One machine tops out. loadr's distributed mode runs one test across a fleet of agents with a single point of control and — crucially — correct aggregate statistics.

                    ┌──────────────────────────────┐
   loadr run ─────▶ │          controller          │ ◀───── web UI / API
   --controller     │  partitioning · aggregation  │
                    │  thresholds · run lifecycle  │
                    └──────┬───────┬───────┬───────┘
                     gRPC (mTLS)   │       │
                    ┌──────┴─┐ ┌───┴────┐ ┌┴───────┐
                    │ agent-1│ │ agent-2│ │ agent-3│   loadr agent --join ...
                    └────────┘ └────────┘ └────────┘
  • The controller accepts agents, distributes test definitions and data files, partitions load, coordinates a synchronized start, aggregates metrics centrally and evaluates thresholds fleet-wide.
  • Agents are dumb muscle: they receive an assignment, run their share with the ordinary engine, and stream metric deltas back every second.

Quick start

# 1. control plane (also serves the web UI)
loadr controller --bind 0.0.0.0:7625 --ui-bind 0.0.0.0:6464

# 2. on each load generator
loadr agent --join controller-host:7625 --name agent-$(hostname)

# 3. submit a test (to the controller's API/UI port)
loadr run --controller controller-host:6464 test.yaml

Or the batteries-included stack (controller + 3 agents + Prometheus + Grafana):

docker compose -f deploy/docker-compose.yml up --build

Kubernetes manifests and a Helm chart live in deploy/helm install loadr deploy/helm/loadr --set agents.replicas=10.

What gets partitioned

ExecutorSplit across N agents
constant-vus, ramping-vusVU counts (remainder to the lowest indices)
constant-arrival-rate, ramping-arrival-raterates divided exactly (N×rate/N = rate)
shared-iterationsthe iteration pool
per-vu-iterationsVUs split; iterations-per-VU unchanged
externally-controlledscale commands split like VU counts

Stage timings are identical everywhere — only magnitudes scale — so global ramps are exact. A 2-second start barrier puts every agent on the same clock.

Controller & agents

The coordination protocol

Controller and agents speak loadr.coordination.v1 — a single bidirectional gRPC stream per agent (see ADR-003):

agent ──▶ Register{agent_id, name, protocol_version, cores, labels}
      ◀── Registered{controller_id}
      ◀── Assignment{run_id, plan_yaml, partition i/n, data files}
      ◀── Start{run_id, start_unix_ms}          # synchronized barrier
      ──▶ MetricsBatch{run_id, delta}           # every second
      ──▶ Heartbeat{active_vus, run_state}      # every 2 seconds
      ◀── Control{stop|kill|pause|resume|scale}
      ──▶ RunEvent{started|finished|failed, summary}

The protocol is versioned; an agent with an incompatible protocol_version is rejected at registration.

TLS / mTLS

loadr controller --bind 0.0.0.0:7625 \
  --tls-cert server.pem --tls-key server-key.pem \
  --tls-client-ca clients-ca.pem          # require client certs (mTLS)

loadr agent --join ctrl:7625 \
  --tls-ca ca.pem \
  --tls-cert agent.pem --tls-key agent-key.pem

Without flags the channel is plaintext — fine on a private network, not on the internet.

Failure handling

  • Heartbeats every 2 s; an agent silent past the liveness window (default 6 s) is marked unhealthy.
  • Reconnection: agents reconnect with jittered exponential backoff and re-register, resuming their identity.
  • Agent loss during a run is policy-driven per submission:
    • continue (default) — remaining agents keep their share; the lost agent's portion of the load simply stops (the summary notes the reduced fleet).
    • abort — the controller stops the run everywhere.

Data files

CSV files, JS modules, proto files and body files referenced by the test are shipped inside the assignment and materialized in the agent's working directory. Paths are sanitized — anything containing .. or absolute paths is rejected.

Operating notes

  • Agents are stateless; scale them with your orchestrator (kubectl scale deploy/loadr-agent --replicas=20).
  • One controller handles many sequential/concurrent runs; each run records its agent set at submission time.
  • The web UI on the controller shows the fleet (health, VUs, labels, last heartbeat) and every run's live metrics.

Metric aggregation

The percentile trap

Most homegrown distributed setups report per-node percentiles and average them. That number is wrong — often wildly. If agent A's p99 is 100 ms and agent B's p99 is 1000 ms, the fleet's true p99 is not 550 ms; it depends on the full shape of both distributions.

loadr never averages percentiles:

  1. Every agent records trend metrics into HDR histograms (3 significant figures, auto-resizing).
  2. Each second, the agent serializes a delta histogram (HDR V2 encoding) and streams it to the controller.
  3. The controller merges histograms — a lossless operation — into a central aggregator per (metric, tag set).
  4. Percentiles, thresholds, the live UI and the final summary are computed from the merged histograms only.

Counters and rates merge as exact sums (passes/total); gauges keep the most recent value plus min/max envelopes.

This is verified by tests: two in-process agents record disjoint latency ranges (1–1000 ms and 1001–2000 ms); the merged p99 must equal the true p99 of the union (~1980 ms), where naive averaging would claim ~1485 ms.

Tags & per-agent visibility

Every sample an agent emits carries an instance: <agent-name> tag, so the fleet view can show per-agent breakdowns and you can threshold per instance:

thresholds:
  "http_req_duration{instance:agent-1}": [ "p(95)<500" ]

Threshold evaluation

Thresholds run centrally against the merged data — abort_on_fail decisions consider fleet-wide reality, then fan stop commands out to every agent. Local evaluation on agents is disabled in distributed runs to avoid split-brain aborts.

The management UI

A built-in, RabbitMQ-style management interface — shipped as a first-party service plugin, statically linked into the default binary.

loadr run --ui test.yaml                  # standalone: dashboard for this run
loadr controller --ui-bind 0.0.0.0:6464   # distributed: manage the whole fleet

Default address 127.0.0.1:6464 (loopback unless you bind otherwise — deliberate security default).

Pages

  • Overview — live stat cards (RPS, active VUs, error rate, p95) and streaming charts (request rate, latency percentiles, errors), per-scenario table, threshold pass/fail pills, live check rates. Updates once per second over SSE.
  • Runs — every run with state and outcome; a run page with live charts, the threshold table, scenario breakdown, and controls: Stop (graceful), Kill, Pause/Resume, and a VU dial for externally-controlled scenarios. Finished runs render the full summary (metric table, checks, thresholds).
  • Tests — a test library: upload/edit YAML in the browser with line-numbered editing and one-click Validate (the same diagnostics as loadr validate, inline), then Run.
  • Agents — the fleet: health, active VUs, cores, labels, last heartbeat.
  • Logs — live tail of engine logs.

Dark mode is the default (there's a toggle; it remembers). No CDNs, no trackers — the entire SPA is embedded in the binary.

Authentication

loadr controller --ui-user admin --ui-password s3cret      # HTTP Basic
loadr controller --ui-token "$(openssl rand -hex 24)"      # bearer token(s)

Both may be active at once; SSE/WebSocket connections accept ?token=. Without any auth flags the UI is open — bind it to loopback or put it behind your proxy.

API

Everything the UI does is a JSON API you can script against:

GET  /api/overview                 GET  /api/runs            POST /api/runs
GET  /api/runs/:id                 GET  /api/runs/:id/summary
GET  /api/runs/:id/stream (SSE)    POST /api/runs/:id/stop|pause|scale
GET  /api/agents                   GET/PUT/DELETE /api/tests[/:name]
POST /api/validate                 GET  /api/logs            GET /healthz

Plugin system overview

loadr extends through five plugin types over two mechanisms — without rebuilding the binary (unlike k6/xk6) and without a JVM (unlike JMeter).

Plugin typeExtendsTypical examples
protocolnew request kinds in flow:MQTT, Kafka, Redis, database drivers
outputmetric exportersproprietary APMs, custom data lakes
extractornew extract: typesHTML tables, protobuf bodies, JWT claims
assertionnew condition typesschema validation, image diffing
servicelong-running componentsthe web UI itself, webhook notifiers

Two mechanisms

  • WASM components (wasmtime, WIT-defined interface) — for extractors and assertions: portable (one .wasm runs on every platform), fully sandboxed (no filesystem/network unless granted), written in any language with component tooling.
  • Native libraries (abi_stable) — for protocols, outputs and services where raw performance or arbitrary system access matters. Layout-checked at load time: an ABI-incompatible plugin fails loudly with a useful error, not undefined behaviour.

Installing & using

loadr plugin list
loadr plugin install ./uppercase-extractor/   # dir with plugin.toml + artifact
loadr plugin info uppercase-extractor
loadr plugin disable uppercase-extractor

Plugins live in ~/.loadr/plugins/<name>/ (override: LOADR_PLUGINS_DIR or --plugins-dir), each with a manifest:

# plugin.toml
[plugin]
name = "uppercase-extractor"
version = "0.1.0"
kind = "extractor"            # protocol | output | extractor | assertion | service
type = "wasm"                 # wasm | native
entry = "uppercase.wasm"
description = "Boundary extractor that upper-cases the match"

Reference plugins from a test:

plugins:
  - { name: uppercase-extractor, config: { left: "id=", right: ";" } }
  - { name: kafka-protocol, path: ./libkafka_protocol.so }   # explicit path

scenarios:
  s:
    flow:
      - request:
          protocol: kafka-protocol          # protocol plugins by name
          url: kafka://broker:9092/topic

Working examples of every type ship in plugins/examples/ — start there, then read Developing a plugin.

WASM plugins

WASM plugins are component-model components against the WIT world in crates/loadr-plugin-api/wit/loadr.wit. The host runs them in wasmtime with no filesystem and no network — a malicious or buggy extractor can waste CPU, nothing else.

The interface (abridged):

package loadr:plugin;

interface meta {
  record info { name: string, version: string, kind: string, description: string }
  describe: func() -> info;
}

interface extractor {
  /// body + headers + the plugin's JSON config -> extracted value (or none)
  extract: func(body: list<u8>, headers: list<tuple<string,string>>, config: string) -> option<string>;
}

interface assertion {
  record verdict { pass: bool, detail: string }
  check: func(status: s64, body: list<u8>, headers: list<tuple<string,string>>,
              duration-ms: f64, config: string) -> verdict;
}

Writing one in Rust

cargo new --lib my-extractor && cd my-extractor
rustup target add wasm32-wasip2
# Cargo.toml
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.58"
#![allow(unused)]
fn main() {
wit_bindgen::generate!({ path: "wit", world: "loadr-plugin" });

struct Plugin;

impl exports::loadr::plugin::meta::Guest for Plugin {
    fn describe() -> exports::loadr::plugin::meta::Info { /* ... */ }
}

impl exports::loadr::plugin::extractor::Guest for Plugin {
    fn extract(body: Vec<u8>, _headers: Vec<(String, String)>, config: String) -> Option<String> {
        let cfg: serde_json::Value = serde_json::from_str(&config).ok()?;
        // ... your logic ...
    }
}

export!(Plugin);
}
cargo build --release --target wasm32-wasip2
# target/wasm32-wasip2/release/my_extractor.wasm is the component

Package it with a plugin.toml (type = "wasm") and loadr plugin install. Any language with component tooling (Go via TinyGo, Python via componentize-py, JS via jco) works the same way.

Using it

plugins: [ { name: my-extractor, config: { left: "id=", right: ";" } } ]
scenarios:
  s:
    flow:
      - request:
          url: /page
          extract:
            - { type: plugin, name: order_id, plugin: my-extractor }

(Plugin extractors/assertions are addressed by plugin name; their config from the plugins: entry is passed to every call.)

Native plugins

Native plugins are dynamic libraries (.so/.dylib/.dll) using abi_stable for a checked, versioned ABI: at load time the library's type layouts are validated against the host's, so mismatched versions fail with a clear error instead of undefined behaviour.

Data crosses the boundary as JSON strings — a deliberate trade: marshalling cost is negligible at plugin-call frequency, and it keeps the ABI surface tiny and forward-compatible.

The interface

loadr-plugin-api exposes #[sabi_trait] object types:

#![allow(unused)]
fn main() {
#[sabi_trait]
pub trait FfiOutput {
    fn name(&self) -> RString;
    fn start(&mut self, config_json: RString) -> RResult<(), RString>;
    fn on_samples(&mut self, samples_json: RString);
    fn on_snapshot(&mut self, snapshot_json: RString);
    fn finish(&mut self, summary_json: RString);
}

#[sabi_trait]
pub trait FfiProtocol {
    fn name(&self) -> RString;
    /// request JSON -> response JSON ({status, headers, body_base64, duration_ms, ...})
    fn execute(&self, request_json: RString) -> RString;
}

#[sabi_trait]
pub trait FfiService {
    fn name(&self) -> RString;
    fn start(&mut self, config_json: RString) -> RResult<RString, RString>;
    fn stop(&mut self);
}
}

A plugin exports one root module advertising what it provides:

#![allow(unused)]
fn main() {
use loadr_plugin_api::export_loadr_plugin;

export_loadr_plugin! {
    info: my_info_fn,
    output: make_my_output,      // any subset of output / protocol / service
}
}

Building

# Cargo.toml
[lib]
crate-type = ["cdylib"]
[dependencies]
loadr-plugin-api = "0.1"
abi_stable = "0.11"
cargo build --release
# package target/release/libmy_plugin.so with a plugin.toml (type = "native")

The two shipped examples are the best reference:

  • plugins/examples/native-output — an output plugin writing snapshot digests to a file;
  • plugins/examples/native-protocol — an echo-proto protocol handler, including how request.options.plugin config reaches your execute.

Safety notes

Native plugins run in-process with full privileges — treat them like any dependency you compile in. Prefer WASM for anything that doesn't strictly need native capability. loadr refuses to load a plugin whose abi_stable layout check fails, and loadr plugin info shows what a library exports before you enable it.

Developing a plugin

A practical walkthrough — we'll build, test and ship the uppercase-extractor WASM plugin (the same one in plugins/examples/wasm-extractor).

1. Scaffold

cargo new --lib uppercase-extractor && cd uppercase-extractor
mkdir wit && cp <loadr repo>/crates/loadr-plugin-api/wit/loadr.wit wit/
[package]
name = "uppercase-extractor"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wit-bindgen = "0.58"
serde_json = "1"

2. Implement

#![allow(unused)]
fn main() {
wit_bindgen::generate!({ path: "wit", world: "loadr-plugin" });

struct Plugin;

impl exports::loadr::plugin::meta::Guest for Plugin {
    fn describe() -> exports::loadr::plugin::meta::Info {
        exports::loadr::plugin::meta::Info {
            name: "uppercase-extractor".into(),
            version: env!("CARGO_PKG_VERSION").into(),
            kind: "extractor".into(),
            description: "boundary extractor that upper-cases the match".into(),
        }
    }
}

impl exports::loadr::plugin::extractor::Guest for Plugin {
    fn extract(body: Vec<u8>, _headers: Vec<(String, String)>, config: String) -> Option<String> {
        let cfg: serde_json::Value = serde_json::from_str(&config).ok()?;
        let (left, right) = (cfg["left"].as_str()?, cfg["right"].as_str()?);
        let text = String::from_utf8_lossy(&body);
        let start = text.find(left)? + left.len();
        let end = text[start..].find(right)? + start;
        Some(text[start..end].to_uppercase())
    }
}

export!(Plugin);
}

3. Build & package

rustup target add wasm32-wasip2
cargo build --release --target wasm32-wasip2

mkdir dist
cp target/wasm32-wasip2/release/uppercase_extractor.wasm dist/
cat > dist/plugin.toml <<'EOF'
[plugin]
name = "uppercase-extractor"
version = "0.1.0"
kind = "extractor"
type = "wasm"
entry = "uppercase_extractor.wasm"
description = "Boundary extractor that upper-cases the match"
EOF

4. Install & use

loadr plugin install ./dist
loadr plugin info uppercase-extractor
plugins: [ { name: uppercase-extractor, config: { left: "token=", right: ";" } } ]

Testing tips

  • Drive the component directly in a Rust test with loadr_plugin_api::WasmExtractor::load(path) — exactly what loadr's own test suite does for the examples.
  • For native plugins: build with cargo build, then NativePlugin::load("target/debug/libmy_plugin.so") in a test.
  • Keep configs JSON-serializable and document them in your README; loadr passes the config: value through verbatim.

Versioning rules

  • WASM: the WIT package version (loadr:plugin@0.1.0) is the contract.
  • Native: abi_stable layout checking is the contract; additionally the root module carries abi_version — bump on breaking changes and loadr will refuse mismatches with a clean message.

Migrating from k6

Two paths, freely mixed:

  1. Automatic: loadr convert script.js -o test.yaml translates options, scenarios, stages, thresholds, plain http.* calls, checks, sleeps and groups into YAML, and preserves anything it can't translate as embedded JS with warnings.
  2. Keep your script: loadr's JS API is deliberately k6-shaped — many scripts run nearly unchanged under a thin YAML wrapper:
js: { file: ./your-k6-script.js }
scenarios:
  default: { executor: constant-vus, vus: 10, duration: 5m, exec: default }

Concept map

k6loadr
export const options = { vus, duration }scenario with constant-vus
options.stagesramping-vus + stages:
options.scenarios.<name>scenarios.<name> (same executor names)
options.thresholdsthresholds: (same expression syntax)
import http from 'k6/http'works as-is
check(res, {...})works as-is; or YAML checks:
sleep(n)works as-is; or YAML think_time
group(name, fn)works as-is; or YAML group: step
Trend/Counter/Rate/Gaugework as-is; or YAML metrics:
__ENV.FOOworks as-is; or ${env.FOO} in YAML
open('data.csv') + papaparsedata: block (CSV native)
setup() / teardown()identical lifecycle
k6 run script.jsloadr run test.yaml
exit code 99 on threshold failureidentical
k6 Cloud / dashboardsbuilt-in web UI + Prometheus/Grafana outputs
xk6 extensionsWASM / native plugins (no rebuild)

What the converter handles

loadr convert covers the common 90%: vus/duration/stages/ iterations, the full options.scenarios matrix (camelCase → snake_case), thresholds incl. abortOnFail/delayAbortEval, http.get/post/ put/del/patch/head/options/request with literal URLs/bodies/headers, JSON.stringify bodies, check patterns (status equality, body.includes, duration comparisons — others become js conditions), sleep (constant and Math.random() uniform), group, custom metric declarations, and recognized imports.

Anything else — loops, conditionals, custom logic — is preserved verbatim in the js: block and listed as a warning, so the converted test always runs.

Differences to know

  • Trend values: loadr's res.duration_ms ≈ k6's res.timings.duration. The converter rewrites the common forms; review custom timing math.
  • Async: k6 scripts using top-level await/http.asyncRequest need restructuring into synchronous calls (QuickJS resolves returned promises, but the blocking API is the model).
  • Cookies: automatic jars per VU, same as k6; the http.cookieJar() API is replaced by session.cookieGet/Set/Clear.
  • handleSummary(): replaced by --summary-export + loadr report.

Migrating from JMeter

loadr convert test-plan.jmx -o converted.yaml
loadr validate converted.yaml
loadr run converted.yaml

The converter parses JMeter 5.x plans and emits clean YAML, with a warning for every element it couldn't translate (disabled elements, plugins, ${__functions}, complex controllers).

Concept map

JMeterloadr
Thread Group (threads, ramp-up, duration)scenario: constant-vus / ramping-vus
Thread Group with loop countper-vu-iterations
Multiple Thread Groupsmultiple scenarios (run concurrently)
HTTP Request samplerrequest: step
HTTP Header Managerheaders: (request- or defaults-level by scope)
HTTP Cookie Managerdefaults.http.cookies: true (default)
CSV Data Set Configdata: block
User Defined Variablesvariables:
Constant / Uniform / Gaussian Random Timerthink_time: (same three types)
Constant Throughput Timerpacing: (per-minute → per-second)
Response Assertionassert: status / body_contains / body_matches
Duration / Size Assertionassert: duration / size
JSON / XPath Assertionassert: jsonpath / xpath
Regular Expression Extractorextract: regex (incl. match no. → index)
JSON / XPath / Boundary Extractorextract: jsonpath / xpath / boundary
CSS Selector Extractorextract: css
Transaction Controllergroup: step
Loop Controllersteps replicated (≤10) or warning
Backend Listener (InfluxDB/Graphite)outputs: influxdb / prometheus / statsd
Aggregate Report / HTML dashboardconsole summary + loadr report + web UI
Distributed testing (RMI, jmeter-server)loadr controller / loadr agent (gRPC, mTLS)
BeanShell / JSR223 / Groovyembedded JavaScript

What changes for the better

  • Percentiles are exact (HDR histograms), including across the fleet — JMeter's distributed mode ships raw samples or averages, loadr merges histograms.
  • Open-model load: JMeter's thread-based model can't hold a target request rate when the system slows down; constant-arrival-rate can.
  • Code review-able tests: YAML diffs instead of 4000-line XML.
  • No JVM tuning, no plugin manager, one binary.

What needs hand-porting

  • JMeter plugins (custom samplers etc.) → loadr protocol plugins.
  • ${__time()}, ${__Random()}, ${__UUID()} and friends → ${js: ...} one-liners (Date.now(), Math.random(), crypto.uuidv4()). The converter flags each occurrence.
  • If/While/Switch controllers → JS scenario functions (exec:), where real control flow is natural.
  • Module/Include controllers → split scenarios across files and compose with environments or separate tests.

Built-in metrics

Kinds: Counter (sum), Gauge (last/min/max), Rate (pass fraction), Trend (HDR histogram: avg/min/med/max + any percentile).

Core

MetricKindMeaning
iterationsCountercompleted iterations
iteration_durationTrendfull iteration time (ms)
dropped_iterationsCounterarrival-rate starts skipped (no free VU at max_vus)
vusGaugeactive virtual users
vus_maxGaugepeak VUs
checksRatecheck pass rate (tag check = name)
data_sent / data_receivedCounterbytes on the wire

HTTP (and GraphQL)

MetricKind
http_reqsCounter
http_req_durationTrend (sending + waiting + receiving)
http_req_blockedTrend (connection acquisition)
http_req_connectingTrend (TCP)
http_req_tls_handshakingTrend
http_req_sending / http_req_waiting / http_req_receivingTrend
http_req_failedRate (transport error or status ≥ 400)

Other protocols

MetricKind
ws_connecting, ws_session_durationTrend
ws_msgs_sent, ws_msgs_receivedCounter
grpc_reqs / grpc_req_durationCounter / Trend
graphql_reqs / graphql_req_durationCounter / Trend
tcp_reqs / tcp_req_durationCounter / Trend
udp_reqs / udp_req_durationCounter / Trend

Standard tags

scenario, name (request name), method, status, proto, group (::outer::inner), check (on checks samples), instance (agent name in distributed runs), plus everything from defaults.tags, scenario tags: and request tags:.

Custom metrics

Declare in YAML for threshold validation, or create ad hoc from JS:

metrics:
  carts_created: { kind: counter }
  render_time: { kind: trend, time: true }
new Counter('carts_created').add(1);
session.trendAdd('render_time', 16.6);

Exit codes

CodeMeaningNotes
0successrun completed, every threshold passed
1errorinvalid test definition, I/O failure, connection to controller failed, ...
99thresholds failedrun completed (or was aborted by abort_on_fail); k6-compatible
130interruptedsecond Ctrl-C (the first triggers a graceful stop with summary)

CI example:

- name: Load test gate
  run: loadr run -e ci --summary-export results.json perf/checkout.yaml
  # job fails automatically on exit 99

- name: Publish report
  if: always()
  run: loadr report results.json -o report.html

JSON Schema & editor setup

loadr's YAML format ships as a JSON Schema generated from the same types the parser uses — autocomplete and inline validation can never drift from reality.

loadr schema > loadr.schema.json

VS Code (YAML extension)

// .vscode/settings.json
{
  "yaml.schemas": {
    "./loadr.schema.json": ["**/loadtests/**/*.yaml", "**/*.loadr.yaml"]
  }
}

Or per file:

# yaml-language-server: $schema=./loadr.schema.json
name: my-test

JetBrains IDEs

Settings → Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings → add loadr.schema.json with your test file pattern.

Neovim

require('lspconfig').yamlls.setup {
  settings = { yaml = { schemas = { ["./loadr.schema.json"] = "loadtests/**/*.yaml" } } }
}

CI validation without an editor

loadr validate --format json loadtests/*.yaml

gives you the same diagnostics (path, line, column, message, suggestion) as machine-readable JSON.

Credits & influences

loadr stands on the shoulders of the load-testing tools that came before it. It is not a fork of any of them — it's a fresh implementation in Rust — but its design borrows the best ideas from four projects, deliberately and gratefully.

k6 — the model

loadr's core execution model is k6's. The seven executors (constant-vus, ramping-vus, constant-arrival-rate, ramping-arrival-rate, per-vu-iterations, shared-iterations, externally-controlled), the open/closed load distinction, the four metric types (Counter, Gauge, Rate, Trend), thresholds as pass/fail criteria with abortOnFail and exit code 99, checks, groups, tags, and the embedded-JavaScript developer experience all follow k6's semantics — so much so that the JS API is import-compatible (import http from 'k6/http') and loadr convert imports k6 scripts directly.

Apache JMeter — the arsenal

JMeter's breadth of assertions, extractors and timers shaped loadr's request toolkit: response/duration/size/JSONPath/XPath assertions, the regular expression / boundary / CSS / XPath extractors, the constant / uniform / gaussian timers and the constant-throughput timer (loadr's pacing), CSV data sets with shared/per-thread cursors and recycle/stop-at-EOF, and cookie management. loadr convert reads .jmx plans so you can bring decades of existing tests with you.

Gatling — the DSL

Gatling contributed the flow control and injection vocabulary: the repeat / while / if-else loops and conditionals, the randomSwitch / uniformRandomSwitch / roundRobinSwitch branch selection (loadr's random step), the feeder strategies (sequential / random / shuffle), JSON feeders, and the request-rate throttle (reachRps). Gatling's rich, assertion-driven simulation reports also informed loadr's HTML report.

Locust — the behaviour model

Locust's weighted-task model — users that pick @task(weight) actions at random rather than running a fixed script — is exactly what loadr's weighted random step expresses. Locust's clean real-time web UI was a direct inspiration for loadr's built-in management UI, and its straightforward distributed master/worker model informed loadr's controller/agent design.

What loadr adds

The combination is the point — k6's model and JMeter's arsenal and Gatling's DSL and Locust's behaviour model in one binary — plus a few things none of them ship together: a single static binary with no runtime (no JVM, no Python, no Go toolchain, no protoc, no OpenSSL); mathematically correct distributed percentiles via HDR-histogram merging (not averaging); a sandboxed WASM + native plugin system that needs no rebuild; six protocols with per-phase timings; and a declarative, schema-validated YAML format you can code-review.

Trademarks and project names belong to their respective owners. loadr is an independent project and is not affiliated with or endorsed by k6/Grafana Labs, the Apache Software Foundation, Gatling Corp, or the Locust project.

Architecture overview

(See also ARCHITECTURE.md in the repository root.)

Crate layout

loadr-config     YAML schema (serde + schemars), validation, ${...} templates
loadr-core       engine: executors, VU pool, flow interpreter, metrics,
                 thresholds, checks, extraction, data feeds, cookies;
                 traits: ProtocolHandler, ScriptEngine, Output
loadr-protocols  HTTP/1.1+2 (hyper + custom timing connector), WS, gRPC,
                 GraphQL, TCP, UDP — implements ProtocolHandler
loadr-js         QuickJS runtime — implements ScriptEngine/VuScript
loadr-outputs    JSONL, CSV, Prometheus, InfluxDB, OTLP, StatsD — implement Output
loadr-plugin-api WASM (WIT) + native (abi_stable) plugin loading & SDK
loadr-agent      controller/agent gRPC coordination, HDR delta merging
loadr-convert    .jmx and k6 importers
loadr-cli        wiring + UX
loadr-plugin-webui  the management UI (axum + embedded SPA)

Dependency rule: everything depends down onto loadr-core/loadr-config; core knows nothing about concrete protocols, JS engines or outputs — only trait objects. That keeps each decision (QuickJS, hyper, tonic…) replaceable.

Execution data flow

YAML ──parse/validate──▶ TestPlan ──compile──▶ ScenarioPrograms
                                        │
       Scheduler ◀─ executors ─ VuPool ─┘     (per scenario)
           │ iteration
           ▼
  FlowInterpreter ── steps ──▶ ProtocolHandler (per-VU cookies, pools, vars)
           │ hooks                  │ samples
           ▼                        ▼
      VuScript (JS)            MetricsBus ─▶ Aggregator ─▶ thresholds
                                                │            outputs
                                                │            web UI (SSE)
                                                └─▶ controller stream (distributed)

The MetricsBus is an unbounded mpsc fan-in; the aggregator drains it, snapshots once per second for live consumers, evaluates thresholds continuously, and produces the final summary.

Individual decisions

ADR-001: JavaScript runtime — QuickJS (rquickjs)

Status: accepted

Context

Tests embed JavaScript three ways (inline ${js:}, script steps, full modules). Candidates: QuickJS (via rquickjs), embedded V8 (via deno_core/rusty_v8), JavaScriptCore, Bun, and pure-Rust engines (Boa).

Decision

QuickJS via rquickjs, behind the ScriptEngine/VuScript traits in loadr-core so the choice stays reversible.

Rationale

Per-VU isolation cost dominates. k6 semantics require an isolated JS context per VU — no shared mutable state. A load test may run thousands of VUs. QuickJS runtimes cost kilobytes and microseconds to create; V8 isolates cost megabytes and milliseconds. At 5 000 VUs that's the difference between "fine" and gigabytes of heap before the first request.

Execution model. Load scripts are straight-line blocking code (http.get(), sleep(), check()). QuickJS lets host calls block synchronously and bridge into Tokio (block_in_place + block_on). deno_core imposes its async ops/event-loop model, which fights deterministic pacing and per-request hooks.

Distribution. rusty_v8 means huge prebuilt artifacts, long CI builds and friction on musl/distroless/cross targets — directly against the single-small-binary goal. QuickJS is plain C, compiled by cargo everywhere.

The JIT doesn't pay here. Iterations are network-bound; script time is a few percent of iteration time. This mirrors k6's own choice of goja (a non-JIT Go interpreter) over embedding V8. Hot paths (crypto, encoding, HTTP, JSON) are native Rust functions exposed to JS.

Why not the others:

  • Bun is a runtime/toolkit on JavaScriptCore written in Zig — there is no supported way to embed it in a Rust process. Using it would mean shipping a separate bun executable and IPC, destroying the single-binary story, per-VU isolation and the platform matrix. JavaScriptCore itself has only immature Rust bindings with painful static linking on Linux.
  • Boa (pure Rust): attractive supply-chain-wise, but slower than QuickJS with less complete ES support at decision time.

Consequences

  • CPU-heavy user script code runs ~10–30× slower than V8 — documented; native stdlib mitigates the common cases.
  • Sandboxing is straightforward: per-runtime memory limits + interrupt handler for wall-clock budgets (js.timeout, js.memory_limit_mb).
  • A V8-backed ScriptEngine implementation can slot in later behind the same trait if benchmarks ever justify it.

ADR-002: Plugin system — WASM components + abi_stable natives

Status: accepted

Context

Plugins must cover five shapes (protocol, output, extractor, assertion, service) with very different risk/performance profiles. One mechanism can't serve both "run untrusted-ish pure functions safely" and "pump millions of samples with zero overhead".

Decision

Two first-class mechanisms:

  1. WASM components (wasmtime + a WIT-defined interface) for extractors and assertions — pure functions over response bytes.
  2. Native dynamic libraries (abi_stable) for outputs, protocols and services.

Rationale

  • Extractors/assertions are called per-response with untrusted test-author logic; the component model gives capability-safe sandboxing (no FS/network), cross-platform portability of a single artifact, and polyglot authorship. wasm32-wasip2 makes Rust guests produce components directly.
  • Protocols and outputs need real sockets, threads and throughput; native libraries are the honest answer. abi_stable removes the classic cdylib footgun: type layouts are validated at load time, so version mismatches are clean errors, not UB. JSON-over-FFI keeps the ABI minimal and evolvable — marshalling cost is irrelevant at plugin-boundary frequencies (outputs see batched samples, protocols see one call per request).
  • Rejected: dylib-only (no sandbox, no portability), WASM-only (WASI sockets are not mature enough for protocol throughput), subprocess plugins à la Terraform (operationally heavier; latency per extractor call).

Consequences

  • Two loaders to maintain; shared discovery/manifest/registry layer (plugin.toml, ~/.loadr/plugins).
  • The web UI ships as a service plugin (statically linked by default), proving the service interface with first-party code.
  • Worked examples of every type live in plugins/examples/ and are exercised by cargo test (including compiling the WASM guests).

ADR-003: Coordination protocol — gRPC bidirectional stream

Status: accepted

Context

Distributed mode needs: agent registration/health, shipping test definitions

  • data files, a synchronized start, live metric aggregation, control (stop/pause/scale), and resilience to agent loss — over one ops-friendly port, with optional mutual authentication.

Decision

A single bidirectional gRPC stream per agent (loadr.coordination.v1, tonic), protos compiled in-process with protox (no system protoc). Heartbeats ride the stream; reconnection is jittered exponential backoff with re-registration; the protocol carries an explicit version checked at registration. TLS and mTLS via rustls.

Metric transport: each agent keeps a local aggregator and ships deltas once per second — counters/rates as numeric deltas, trends as HDR-V2-encoded delta histograms. The controller merges histograms; percentiles are computed only after merging (see metric aggregation).

Rationale

  • One stream = one connection through firewalls/load balancers, natural ordering, cheap heartbeats, server push for control.
  • gRPC/tonic gives typed evolution (proto), TLS/mTLS, and flow control for free; hand-rolled TCP framing or HTTP polling would re-invent all of it.
  • Delta histograms bound bandwidth (KBs/second/agent regardless of request rate) while losing nothing statistically — vs raw sample shipping (unbounded) or pre-computed percentiles (mathematically wrong to combine).
  • Synchronized start via a controller-stamped start_unix_ms barrier keeps multi-agent ramps aligned to within clock skew, which is sufficient for load shaping (we are not coordinating transactions).

Consequences

  • Agents are stateless and trivially scalable; the controller is a single point of coordination (acceptable: a run's lifetime is minutes/hours, and agents fail-safe by stopping load when orphaned).
  • Data files travel in the assignment message — fine for the CSV/proto/script sizes tests actually use; huge corpora should live on the agents' disks.

ADR-004: HTTP stack — hyper with a hand-rolled timing connector

Status: accepted

Context

A load tool's HTTP numbers are its product. We need per-phase timings (DNS, connect, TLS, send, TTFB, receive), exact byte counts, HTTP/1.1 + HTTP/2 with version forcing, mTLS/custom CAs, redirects, decompression, proxies — and a connection model that matches what a "virtual user" means.

Decision

Build directly on hyper (client conn API) with our own connection establishment: tokio DNS lookup, TcpStream::connect, tokio-rustls handshake — each phase individually timed — then http1/http2 handshakes picked by ALPN or configuration. Connections are pooled per VU. No reqwest.

Rationale

  • reqwest (and most high-level clients) hide connection reuse and phase boundaries; you simply cannot report honest http_req_blocked/ http_req_tls_handshaking through it.
  • Per-VU pooling models reality: a VU is one user agent with its own keep-alive connections and cookie jar. Global pools (the reqwest default) understate connection-establishment cost dramatically and produce multiplexing patterns no real client population exhibits.
  • rustls everywhere: no OpenSSL build matrix, distroless-friendly, and one TLS config shared by HTTP, WebSocket and gRPC.

Consequences

  • We own redirects, decompression (gzip/deflate/br), proxy CONNECT, cookie injection and byte accounting — all unit-tested against the in-repo test server (including TLS with generated certs and forced HTTP/1.1 vs h2).
  • HTTP/3 is future work; the connector design (phase-timed dial + versioned handshake) has a clear slot for quinn.

ADR-005: Metrics engine — HDR histograms over tagged series

Status: accepted

Decision

Four k6-compatible metric kinds (Counter, Gauge, Rate, Trend). Samples carry an interned metric name + an immutable tag set (Arc<BTreeMap>); the aggregator keys series by (metric, tags). Trends record into HDR histograms — 3 significant figures, auto-resizing, values stored ×1000 (microsecond resolution for millisecond metrics).

VUs emit samples over an unbounded mpsc to a single aggregator task, which snapshots once per second (live UI/outputs/thresholds), supports tag-filtered merged views for threshold selectors (metric{tag:value}), and produces serializable deltas for distributed mode.

Rationale

  • HDR histograms give exact-enough percentiles (0.1% relative error) at fixed memory, O(1) record, and — the killer feature — lossless merging, which makes distributed percentiles correct and threshold evaluation on arbitrary p(N) cheap.
  • Tag-set series (rather than pre-aggregated names) let one recording answer every slicing question later: per scenario, per request name, per status, per agent.
  • A single aggregator task removes locking from the hot path; VUs only do an mpsc send with pre-interned Arc names/tags. At 1 Hz snapshot cadence the drain loop is far from saturation at realistic sample rates.

Consequences

  • Tag cardinality is the user's responsibility (request name defaults to the URL template, not the rendered URL, specifically to keep cardinality sane).
  • Trends assume non-negative values (durations); custom trend metrics share that constraint.

ADR-006: Executor model — k6's seven, open and closed

Status: accepted

Decision

Implement k6's full executor set with matching semantics: constant-vus, ramping-vus, per-vu-iterations, shared-iterations (closed models — VU loops drive iterations), constant-arrival-rate, ramping-arrival-rate (open models — an arrival clock starts iterations on schedule), and externally-controlled.

Open-model mechanics: a dispatcher integrates the (possibly ramping) rate function and fires iteration starts at idle workers; if none is idle it grows the pool up to max_vus, beyond which it records dropped_iterations instead of queueing. Closed-model ramping uses a watch channel of "allowed VUs" with linear interpolation at 100 ms resolution; de-allocated VUs get graceful_ramp_down to finish in-flight iterations.

Rationale

  • The open/closed distinction is the single most important correctness property in load generation: closed models suffer coordinated omission (a slow server reduces offered load, hiding the problem). Users must be able to choose, and k6's executor vocabulary is the de-facto standard.
  • Dropping, not queueing, when starved at max_vus keeps the offered rate honest and makes saturation visible as a first-class metric.
  • k6-identical names and parameters make migration mechanical (the converter maps options.scenarios 1:1) and documentation transferable.

Consequences

  • Every executor funnels through one run_iteration path (flow + exec + metrics + outcome handling), so features like pause, graceful stop, data exhaustion and abort actions behave identically everywhere.
  • Distributed partitioning is a pure function over executor specs (VUs/iterations split with remainders, rates divided exactly) — unit-tested invariant: partitions always sum to the original.