surgesurge

Scenario reference

Every field of a surge YAML scenario. Unknown fields are rejected with the line number, so a typo fails surge validate instead of silently doing nothing.

yaml
name: checkout            # report title (default: the filename)
type: load                # smoke|load|stress|spike|soak|capacity
base_url: https://api.example.com
http:      { … }          # client settings
headers:   { … }          # sent on every request
vars:      { … }          # scenario-wide values for ${…}
feeders:   { … }          # CSV data sources
load:      { … }          # how much traffic, for how long
thresholds: [ … ]         # pass/fail rules
setup:     [ … ]          # commands run before the load
teardown:  [ … ]          # commands run after it, always
on_start:  [ … ]          # once per virtual user
flow:      [ … ]          # what one user does
wait:      2s..5s         # pause between iterations
users:     [ … ]          # several roles instead of one flow
output:    { … }          # report files

on_start, flow and wait at the top level are the one-role shorthand — they describe a single population. With a users: list each population carries its own, and the top-level ones are refused: applying them to every role would invent a login nobody wrote.


http

FieldDefaultMeaning
timeout30sper-request timeout
insecure_skip_verifyfalseaccept any TLS certificate (staging with self-signed certs)
disable_keep_alivefalsenew connection per request — measures connection setup, costs throughput
disable_http2falseforce HTTP/1.1
disable_redirectsfalsetreat a 3xx as the final response instead of following it
max_conns_per_hostunlimitedcap connections to one host
proxy$HTTP_PROXYproxy URL
max_body_bytes10485760how much of a response body is read (the rest is drained)
yaml
http:
  timeout: 10s
  insecure_skip_verify: true

headers

Sent on every request; a step's own headers win on conflict. Values accept ${…}.

yaml
headers:
  User-Agent: surge
  X-Env: "${env.ENVIRONMENT}"

vars

Scenario-wide values, usable anywhere ${…} works. Override from the CLI with -var key=value.

yaml
vars:
  api: /api/v2
  tenant: acme

users — several roles in one test

Real traffic is not one journey repeated. It is mostly people browsing, some of them buying, and the occasional admin doing something expensive. A test made of one of those measures a mix that does not exist.

Give either a top-level flow: — the one-role shorthand — or a users: list, never both.

yaml
users:
  - name: browser
    weight: 7                 # ~78% of the virtual users
    wait: 2s..8s              # this role's own pace, between iterations
    flow:
      - name: browse
        get: /api/items

  - name: buyer
    weight: 2
    wait: 1s..3s
    on_start:                 # once per virtual user, not per iteration
      - name: login
        post: /auth/login
        json: { username: "${users.username}", password: "${users.password}" }
        extract:
          token: { json: data.token }
    flow:
      - name: checkout
        post: /api/checkout
        headers: { Authorization: "Bearer ${token}" }

  - name: admin
    count: 1                  # exactly one, however the test is scaled
    wait: 30s
    flow:
      - name: report
        get: /admin/report
FieldDefaultMeaning
nameuser1, user2…what the role is called in the report
weight1share of the virtual users, relative to the other weighted roles
count—an exact number of virtual users, filled before the weighted ones
waitnonepause between iterations: 2s or 1s..3s
on_start—steps run once when the virtual user is created
flowrequiredthe steps one iteration performs

on_start is where a login belongs. Put it in flow and you measure a login on every pass, which no real user does. Put it in on_start and whatever it extracts — a token, a tenant id — stays available to every iteration after.

count is for the role that must not scale: one admin running an expensive report stays one admin whether the test is 50 virtual users or 5,000.

Two variables are always available: ${__VU} is the virtual user's number, and ${__USER} is the name of its role.

surge validate prints the division, because a mistyped weight still runs:

text
    users      3 populations
      browser — weight 7 (~78% of the rest) · 200ms..600ms
      buyer — weight 2 (~22% of the rest) · 500ms..1s
        on_start (once per virtual user)
          1. POST /auth/login → token
      admin — exactly 1 · 2s

feeders

CSV files that feed data into iterations. The header row names the columns, and each column is available as ${feedername.column}.

FieldDefaultMeaning
pathrequiredCSV file, relative to the scenario file
typecsvonly csv today
modecircularhow rows are handed out — see below
ModeA row is takenUse it for
circularevery iteration, in orderproduct ids, search terms, anything stateless
randomevery iteration, at randomthe same, where order would be a pattern the cache learns
onceevery iteration, walking the file, then repeating the last rowa finite queue of work
per_vuonce per virtual user, held for the whole runlogins: one account per user
yaml
feeders:
  users:    { path: users.csv, mode: per_vu }     # one account each
  products: { path: data/products.csv, mode: random }

per_vu: one account per virtual user

The other three modes draw a fresh row at the start of every iteration, so virtual user 1 logs in as alice, and on its next pass is bob — while still holding alice's token and alice's cookies. For anything stateless that is fine. For a login it is not a user at all.

per_vu binds one row to each virtual user for the length of the run:

text
mode: per_vu            mode: circular
VU 1  alice ×9          VU 1  alice ×3  bob ×2  carol ×4  dave ×1
VU 2  bob   ×11         VU 2  bob   ×3  dave ×7
VU 3  carol ×10         VU 3  bob   ×4  carol ×5  dave ×1
VU 4  dave  ×10         VU 4  alice ×7  bob  ×1  carol ×1  dave ×1

This matters beyond tidiness. Per-account rate limits, per-account data volumes, row locks and cache keys all behave differently when a hundred virtual users share three accounts, and the result is a measurement of contention on three rows rather than of the system.

Two rules the mode enforces rather than leaving to you:

Splitting the accounts across machines

Two machines each running mode: per_vu would both number their virtual users from 1 and both hand out the first row, so the same account would be logged in twice at once. surge divides the rows as well as the load: machine i of n takes rows i, i+n, i+2n, and no two machines can hand out the same one.

This happens by itself in a coordinated run (-agents). For machines with no coordinator, say which is which — decided from the shard number alone, with nothing to agree on:

bash
# on three machines, or three CI jobs
surge run shop.yaml -shard 1/3 -snapshot part1.json
surge run shop.yaml -shard 2/3 -snapshot part2.json
surge run shop.yaml -shard 3/3 -snapshot part3.json

surge merge part*.json -threshold "p95 < 500ms"

Each machine applies a third of the load and takes a third of the rows. No CSV needs splitting by hand, and a machine left with no rows at all says so rather than running empty.

Only per_vu and once are divided. circular and random mean "reuse freely", so every machine keeps the whole file — dividing those would quietly shrink the data each machine draws from.

Why not a shared database of accounts? It works, and it is what most teams reach for. The cost is that every iteration then makes a network round trip to claim an account, and that wait happens inside the measurement: the generator queues on its own coordination and reports the delay as the target's latency. Striping needs no round trip and no shared state, because the division is decided by arithmetic.

In a Go scenario the same thing is c.VU(), which is stable for the life of the virtual user, as an index into your own data.

csv
username,password,tier
alice,pw-alice,gold
bob,pw-bob,silver

→ ${users.username}, ${users.password}, ${users.tier}.

load

How much traffic, for how long. Give stages, or the shorthand duration + vus/rate, or iterations + vus.

FieldDefaultMeaning
modelclosedclosed = N concurrent users · open = N arrivals per second
stages—ramp points: { duration, target }, interpolated linearly from the previous target
duration—shorthand: hold one level this long
vus—closed model: number of virtual users
rate—open model: arrivals per second
max_vus1000open model: cap on concurrent iterations; arrivals beyond it are dropped and reported
iterations—run exactly N iterations and stop (closed model only)
warmup0leading period excluded from the final stats (still on the timeline)
grace_stop5show long in-flight iterations may finish after the run ends
yaml
# constant: 50 users for 2 minutes
load: { vus: 50, duration: 2m }

# ramp: 0 → 100 users, hold, down
load:
  stages:
    - { duration: 30s, target: 100 }
    - { duration: 2m,  target: 100 }
    - { duration: 30s, target: 0 }
  warmup: 30s

# open model: find the knee
load:
  model: open
  max_vus: 500
  stages:
    - { duration: 30s, target: 200 }
    - { duration: 60s, target: 1000 }

# smoke test: 100 iterations, then stop
load: { iterations: 100, vus: 5 }

Closed vs open. Closed keeps N users busy: if the target slows down, your offered load drops with it and the problem hides. Open keeps the arrival rate fixed, which is how real traffic behaves. Use open to find a breaking point, closed to model a known population.

thresholds

Pass/fail rules. All must pass, or the run exits 1. Expressions that do not parse fail before any traffic is sent.

text
<metric>[(<request or action name>)] <op> <value>
MetricUnitExample
p50 p90 p95 p99durationp95 < 500ms
avg min maxdurationmax < 5s
error_ratepercenterror_rate < 1%
check_ratepercent (not scopeable)check_rate > 99%
rpsnumberrps > 500
countnumbercount > 10000

Operators: < <= > >=. Durations accept ms/s/m; a bare number means milliseconds. Without (name) the rule applies to all requests together.

yaml
thresholds:
  - p95 < 500ms
  - p99(api/checkout) < 2s  # just the step named "api/checkout"
  - p95(checkout) < 1s      # the whole `group: checkout` action
  - error_rate < 1%
  - check_rate > 99%
  - rps > 200

A scope names a request or a group: action, which is what makes group: worth writing: "checkout under one second" becomes a rule the run can fail on rather than a number in a table. A name that is both a request and an action is refused rather than guessed — rename one. A scope that never ran fails the threshold with nothing named X was measured, so a typo costs the run rather than passing quietly.

The same expressions scope inside abort_on: and a capacity-search SLO, and surge compare reports per request and per action.

flow

What one virtual user does, start to finish. One pass = one iteration. A step is a request, a pause, or a request followed by a pause.

FieldMeaning
namereport label (default METHOD url) — always set this when the URL contains an id, or every id becomes its own row
get post put patch delete head optionsthe URL, and the method
url + methodthe same thing, spelled out
headersper-step headers
bodyraw string body
jsonany YAML structure, sent as JSON (Content-Type set for you)
formkey/value pairs, sent urlencoded
extractpull values out of the response into ${…}
checkassertions that do not stop the flow
thinkpause after this step: 1s or 500ms..3s
continue_on_errorkeep going when this step fails (default: end the iteration)
yaml
flow:
  - name: login
    post: /auth/login
    json: { username: "${users.username}", password: "${users.password}" }
    extract:
      token: { json: data.token }
    think: 1s..2s

  - name: item detail
    get: /api/items/${item_id}      # the name keeps stats on one row
    headers: { Authorization: "Bearer ${token}" }

  - think: 2s..5s                   # a pause on its own

extract

Capture a value for later steps. Exactly one source per variable.

SourceExampleNotes
json{ json: data.token }gjson path: items.0.id, items.#, items.#(price>10).id
regex{ regex: 'name="csrf" value="([^"]+)"' }first capture group, else the whole match
header{ header: X-Request-Id }response header

A failed extraction counts as an extract failure and ends the iteration (unless continue_on_error: true), because the next step would send an empty value and lie about the result.

check

Assertions counted separately from errors — a slow 200 can fail a check without being an "error". They feed check_rate.

FieldExample
status200 or [200, 201, 204]
body_contains"order confirmed"
json_exists[data.token, user.id]
json{ status: confirmed, count: "20" } — "*" means "exists"
max_duration800ms
yaml
check:
  status: [200, 201]
  json_exists: [order_id]
  json: { status: confirmed }
  max_duration: 800ms

Declaring status also redefines failure for that step: with check: { status: 404 }, a 404 is a success and a 200 is an error. That is how you load-test error paths.

group — one user action across several calls

A checkout that is three API calls is one thing the user did, and "checkout took 480ms" is the sentence an SLA is written in. Three rows of per-request latency is not that sentence, and adding three p95s together is wrong twice over: percentiles do not add, and the user also waited for whatever happened between the calls.

Consecutive steps that share a group name are timed end to end:

yaml
flow:
  - { name: api/cart,    post: /api/cart,    group: checkout }
  - { name: api/pay,     post: /api/pay,     group: checkout }
  - { name: api/confirm, post: /api/confirm, group: checkout }
text
user actions  (end to end, think time excluded)

action                              ran   failed      avg      p50      p95
checkout                            826      112    480.0    410.0    910.0

ws — WebSocket

A game, a feed or a chat opens one connection per user and keeps it for the session. So the dial goes in on_start, where it is paid once, and the messages go in the flow:

yaml
on_start:
  - name: ws/connect
    ws: /game                    # relative to base_url; http→ws, https→wss
    as: game                     # the handle later steps use
    headers: { Authorization: "Bearer ${token}" }
    subprotocol: [json]          # optional
    read_limit: 1048576          # one message, default 1MB

flow:
  - name: ws/join
    on: game
    send: '{"op":"join","room":"${room}"}'
    receive: true                # send + receive = one round trip
    check:
      body_contains: '"ok":true'
    extract:
      player: { json: player.id }

  - name: ws/tick
    on: game
    receive: true                # on its own: the wait, which is the push
    repeat: 5                    # read five messages, measuring each
    check:
      body_contains: '"type":"tick"'

  - name: ws/leave
    on: game
    close: true                  # only to test what the server does

Putting the dial in the flow measures a handshake on every iteration. That is a different test, and rarely the one meant.

There is no ping. A pong is a control frame, processed only while a read is in flight, so pinging an idle connection waits for a reply nobody is listening for. Measure something the server actually answers.

setup and teardown — the commands around the load

The second run is the one that matters. A load test is a measurement only if the thing it measures starts in the same state every time, and "remember to seed the accounts and clear the carts" written in a wiki is not a state anyone can reproduce. The commands that do it live in the scenario, and they end up in the report, so a run read six months later says what was done to the environment and not only what the environment did.

yaml
setup:
  - ./scripts/seed-accounts.sh 150        # a string: a shell runs it
  - ["psql", "-f", "seed.sql"]            # a list: argv, no shell at all
teardown:
  - ./scripts/clean.sh
hook_timeout: 10m                         # per command, default 5m

A scenario that can run commands is as powerful as a shell script. Read one before you run it, as you would a script.

output

yaml
output:
  json:     reports/summary.json    # machine-readable summary
  html:     reports/report.html     # self-contained page, no network
  csv:      reports/timeline.csv    # per-second sec,reqs,errs,avg,p50,p95,max,vus
  failures: reports/failures.csv    # one row per distinct failure, with its example
  no_failure_bodies: false          # true leaves response bodies out of the report

Directories are created as needed. CLI flags -json, -html, -csv and -failures override.

failures is the error log in a form you can grep and diff: request, role, method, error class, status, count, share, first and last second, which checks failed, the message, the URL, and the first 512 bytes the server replied.

no_failure_bodies turns the last of those off. The reply is the most useful thing in a failure and the most likely to hold a customer's data, so it is kept by default and withheld on request — not the other way round, and not silently.


Templating: ${…}

Usable in URLs, headers, bodies, JSON values, form fields, check values and think. Resolution order: extracted variables and feeder columns → vars → env. → functions. An unknown expression is left as-is, so a typo is visible in the request instead of silently empty.

ExpressionGives
${token}a value from extract
${users.username}a feeder column
${api}a vars entry
${env.API_KEY}an environment variable
${__VU}the virtual user's number
${uuid()}a random UUID v4
${randint(1,100)}a random integer, inclusive
${randstr(12)}random lowercase letters and digits
${now()}Unix milliseconds
${now_iso()}RFC 3339 UTC
yaml
json:
  order_id: "${uuid()}"
  qty: "${randint(1,5)}"
  user: "${users.username}"
  at: "${now_iso()}"
  key: "${env.API_KEY}"

CLI overrides

Any of these override the file, so one scenario covers every environment:

bash
surge run checkout.yaml \
  -base-url https://staging.example.com \
  -var tenant=acme \
  -vus 100 -duration 5m -warmup 30s \
  -threshold "p99 < 1s" \
  -json out.json -html out.html

surge validate checkout.yaml parses everything, checks that feeder files exist and that regexes and thresholds compile, and prints the plan — without sending a single request.