Getting started
For someone who has never run surge. Install it, measure something real in a minute, then write a scenario you can keep. Every command and every block of output below is from an actual run — nothing here is illustrative.
- Install
- Minute one: measure something
- Minute five: a scenario you can keep
- Already have the flow in a browser?
- Reading the result
- Putting it in CI
- What else it does
- Common mistakes it protects you from
Install
Pick one. All three give the same binary.
# 1. Go toolchain (Go 1.23+) — puts `surge` in $(go env GOPATH)/bin
go install gitlab.com/agentic-fx/surge/cmd/surge@latest
# 2. From source
git clone https://gitlab.com/agentic-fx/surge.git && cd surge
make build # ./surge
# 3. Container — 35MB, no Go needed
docker build -t surge -f deploy/docker/Dockerfile .
docker run --rm surge versionCheck it:
surge version
# surge 0.5.0There is nothing else to set up. No agent to register, no account, no server: surge is one static binary that exits 0 when the test passed and 1 when it did not.
Using it as a Go library instead? go get gitlab.com/agentic-fx/surge, then read library.md. The CLI and the library run the same engine — anything below applies to both.
You do not clone this repository
surge is a tool and a library. Your tests live in your own repository; this one is only for working on surge itself. Three ways to take it, and nothing about your project has to change to suit it.
1. The CLI, and no Go at all
go install gitlab.com/agentic-fx/surge/cmd/surge@latestThen your repository holds YAML and nothing else:
your-service/
perf/
checkout.yaml
users.csvsurge verify perf/checkout.yaml -base-url https://staging.example.com
surge run perf/checkout.yaml -base-url https://staging.example.comThis is the whole story for HTTP APIs. Nobody on the team needs to write Go.
2. The library, in your own module
For gRPC, WebSocket, a database, a browser, or anything else you can call from Go. Add it to the repository that already holds your code:
cd your-service
go get gitlab.com/agentic-fx/surgeyour-service/
go.mod # your module, with surge as one dependency
internal/... # your application
perf/
main.go # the load test, a normal Go programpackage main
import (
"time"
"gitlab.com/agentic-fx/surge"
)
func main() {
s := surge.New("shop-api")
// A scenario can take its own options. ParseFlags makes their values
// available while the scenario is still being built; without it they
// are not parsed until RunMain is already running.
target := s.Flags().String("target", "http://localhost:8080", "system under test")
s.ParseFlags()
s.BaseURL(*target)
s.Closed(20, 10*time.Second)
s.Thresholds("p95 < 500ms", "error_rate < 1%")
u := s.User("shopper", 1).Wait(100*time.Millisecond, 400*time.Millisecond)
u.OnStart(func(c *surge.Ctx) error {
r := c.POST("/auth/login", surge.Name("login"),
surge.JSON(map[string]string{"username": "u", "password": "p"}))
c.Set("token", r.JSON("data.token"))
return r.Err()
})
u.Task("browse", 1, func(c *surge.Ctx) error {
r := c.GET("/api/items", surge.Name("browse"), surge.Bearer(c.Str("token")))
c.Check("has items", r.JSON("count") != "")
return r.Err()
})
s.RunMain()
}go run ./perf -target https://staging.example.com -duration 5mThe binary takes the same flags as the CLI, exits 0 or 1 the same way, and can be an agent or a coordinator — so a scenario written in Go is not a second-class one.
3. The container
docker run --rm -v "$PWD/perf:/work" \
registry.gitlab.com/agentic-fx/surge run /work/checkout.yaml -base-url https://stagingNot published yet?
While surge lives only on disk, point your module at the checkout and delete the line when it is published:
require gitlab.com/agentic-fx/surge v0.8.0
replace gitlab.com/agentic-fx/surge => /path/to/surgego build ./perf then works exactly as it will afterwards.
Minute one: measure something
You do not need a scenario file to start. surge quick takes a URL:
surge quick https://your-api.example.com/health -vus 20 -duration 30sIf you have no system of your own to point at yet, this repository ships a demo target so you can try the whole tool offline:
go run ./examples/target -addr :8099 &
surge quick http://localhost:8099/health -vus 20 -duration 5s -threshold "p95 < 200ms"surge 0.5.0 · quick · closed model · 20 VUs over 0s → 20 VUs over 5s · total 5s
[ 00:02] vus 20 iters 130351 reqs 130351 errors 0 p95 0ms
[ 00:04] vus 20 iters 261815 reqs 261815 errors 0 p95 0ms
── quick ── closed model · 5s · 2026-11-07 08:59:22
request count err% rps avg p50 p95 p99 max
────────────────────────────────────────────────────────────────────────────────────────────────────
GET http://localhost:8099/... 326,254 0.00 65244.6 0.3 0.3 0.5 0.8 7.2
────────────────────────────────────────────────────────────────────────────────────────────────────
total 326,254 0.00 65244.6 0.3 0.3 0.5 0.8 7.2
iterations 326,254 · requests 326,254 · errors 0 · data in 5.0 MB · out 0 B
status 200 ×326,254
thresholds
✓ p95 < 200ms (actual 0.5ms)
PASSThe flags you will use most:
| Flag | Means |
|---|---|
-vus 20 | 20 virtual users, each waiting for its own response |
-rate 500 | 500 arrivals per second regardless of how slow answers are |
-duration 30s | how long to run |
-threshold "p95 < 200ms" | pass/fail rule — repeatable |
-html report.html | write a shareable report |
-analyze | have surge explain the numbers |
-web :8088 | watch it live in a browser |
Minute five: a scenario you can keep
A one-URL test is a smoke test. Real systems need a journey: log in, carry the token, do something, check the answer. Start from the commented template:
surge init checkout.yamlEdit it down to what you need. The whole language is in scenario-reference.md; this is the shape of it:
name: checkout
base_url: http://localhost:8099
feeders:
users: { path: users.csv, mode: circular } # one row per virtual user
load:
stages: # ramp up, hold, ramp down
- { duration: 10s, target: 50 }
- { duration: 20s, target: 50 }
- { duration: 5s, target: 0 }
warmup: 5s # kept out of the final stats
thresholds: # these decide pass or fail
- p95 < 400ms
- p99(checkout) < 900ms
- error_rate < 1%
flow:
- name: login
post: /auth/login
json: { username: "${users.username}", password: "${users.password}" }
extract:
token: { json: data.token } # carry it into later steps
think: 1s..2s # a real user pauses
- name: checkout
post: /api/checkout
headers: { Authorization: "Bearer ${token}" }
json: { item_id: "x", idempotency_key: "${uuid()}" }
check:
status: [200, 201]
json: { status: confirmed } # a 200 saying "failed" is a failureCheck it before you send any traffic — this reads the file, the CSV, every regex and every threshold expression, and sends nothing:
surge validate checkout.yamlThen run it:
surge run checkout.yaml
surge run checkout.yaml -base-url https://staging.example.com # same file, any environmentOne scenario file, every environment: -base-url and -var key=value override anything in the file, so you are never maintaining staging.yaml and prod.yaml side by side and wondering which one drifted.
Prefer code?
The same test as Go. You get a compiler, your IDE, your own helpers, and any library you already use for signing requests or building payloads:
s := surge.New("shop").BaseURL("http://localhost:8099").CookieJar()
s.ClosedStages(surge.Stage{Duration: 20 * time.Second, Target: 50})
s.Thresholds("p95 < 400ms", "error_rate < 1%")
buyer := s.User("buyer", 1).Wait(time.Second, 3*time.Second)
buyer.OnStart(func(c *surge.Ctx) error { // once per virtual user
r := c.POST("/auth/login", surge.JSON(map[string]string{"username": "u", "password": "p"}))
c.Set("token", r.JSON("data.token"))
return r.Err()
})
buyer.Task("browse", 4, func(c *surge.Ctx) error { // weight 4: runs 4× as often
r := c.GET("/api/items", surge.Bearer(c.Str("token")))
c.Check("20 items", r.JSON("count") == "20")
return nil
})
s.RunMain() // flags, run, exit 1 on failurego run . and it behaves exactly like the CLI. Full API: library.md.
Already have the flow in a browser?
Do not write the scenario by hand. Record the journey in your browser's network tab, save the HAR, and let surge write it:
surge import har session.har -o checkout.yamlwrote checkout.yaml — 4 steps, 11 requests skipped
correlated 2 dynamic value(s) — recorded values replaced with live ones:
${token}
from json: data.token in step "POST /auth/login"
used in: GET /api/items, POST /api/checkoutThis matters more than it sounds. Replaying a recording verbatim re-sends yesterday's session token, so you measure the login wall instead of the application and the numbers look great. surge finds the values the server minted and the client later sent back, wires them up, drops images and CSS, collapses ids in metric names so one row does not become ten thousand, and tells you plainly what it could not work out.
surge import openapi spec.yaml does the same from an API description.
Reading the result
Thresholds decide. Anything in thresholds: (or -threshold) is checked at the end; one failure means the run failed. The verdict says which ones, and what else it noticed:
thresholds
✓ p95 < 400ms (actual 62.0ms)
✗ error_rate < 1% (actual 2.01%)
✗ check_rate > 99% (actual 97.09%)
FAIL 2 of 3 thresholds breached · 4,126 requests · 83 failed (2.01%) · 213 checks failed
✗ threshold error_rate < 1% — actual 2.01%
✗ threshold check_rate > 99% — actual 97.09%
✗ 83 of 4,126 requests failed (2.01%)
⚠ feeder "accounts" has 80 rows for 110 virtual users, so 30 share an identityThe lines marked ⚠ do not change the verdict. They are the reasons to trust the numbers less: a run cut short, arrivals that were never applied, a generator that ran out of CPU, a warmup excluded, a feeder too small for the load. A true number read without them is still read wrong.
A pass can be worth less than it looks. A run with no thresholds has no way to fail, and the report says so rather than printing a green word:
PASS 4,126 requests · no errors · 7,330 checks passed
⚠ no thresholds were set, so this run had no way to fail — a pass here
means it finished, not that it was fast enoughWhich check, not how many. checks 97.09% is a number; the breakdown is a place to look:
failed checks
✗ json status = confirmed 113 of 799 failed (85.9% passed)
✗ status in 200 48 of 3,288 failed (98.5% passed)
✗ under 500ms 4 of 46 failed (91.3% passed)What actually came back. Every distinct failure keeps one real example — the role that hit it, the URL, the status, and what the server replied:
failures (one example of each)
POST api/checkout (buyer) check failed ×113
check: json status = confirmed
replied: {"order_id":"o-10826","status":"pending","total":30.5}
POST api/cart (shopper) HTTP 500 ×48
check: status in 200 · json has cart_id
Internal Server Error
replied: {"error":"pricing-service: upstream connect error…","trace_id":"7f3c9a21b4e0"}
GET api/export (back-office) timeout ×1
Get "http://shop/api/export": context deadline exceededThat second one is the case no error rate catches on its own: checkout answered 200 and answered it wrongly. The first example of each group is kept and the rest are counted, so a million failures cost the same memory as fifty.
The response body is the most useful thing in a failure and the most likely to hold something private. It is kept by default, capped at 512 bytes; set output.no_failure_bodies: true for a report that will be passed around.
Exceptions, for a task that raised rather than a request that failed — a panic, a body that would not marshal, an on_start that gave up — are grouped with their message and stack. A panicking virtual user no longer ends the run: it is recorded, that user backs off, and the rest keep going.
Exit codes, so a pipeline needs no parsing: 0 pass · 1 thresholds failed or a real regression · 2 setup error, reported before any traffic was sent.
Reports, any combination:
surge run checkout.yaml \
-html report.html \ # one self-contained file, light and dark, with charts
-json summary.json \ # everything, for your own tooling
-csv timeline.csv \ # per-second sec,reqs,errs,avg_ms,p50,p95,max,vus
-failures failures.csv \ # one row per distinct failure, with its example
-prometheus metrics.txt \ # for a Pushgateway or textfile collector → Grafana
-influx lines.txt \ # InfluxDB line protocol
-webhook https://hooks.example.com/loadtestThe HTML report opens with the same verdict, then the charts (throughput and failures, p50/p95/max, virtual users), the request table, the full percentile ladder out to p99.9, the failures with their replies, the checks by name, and whatever the target said about itself. It is one file with nothing external in it: no CDN, no fonts to fetch, no tracking. Mail it, archive it, open it in five years.
Ask it what happened. -analyze turns the table into conclusions:
analysis
The target stopped keeping up at around 180 concurrent users (42s in).
! The system stopped scaling
Throughput peaked at 1,240 req/s 42s in (180 VUs) and then fell to
1,050 req/s while average latency rose from 95ms to 410ms.
→ Treat roughly 1,240 req/s as this configuration's ceiling.You can also run surge analyze summary.json later, on a run from last week.
Watch it live. -web :8088 serves a dashboard (a compiled Go scenario takes the same flag) — tiles, the request table, throughput and latency charts, per-agent breakdown in a distributed run, and thresholds evaluated every second, so a budget that is already blown shows at 00:20 instead of at the end.
Putting it in CI
The whole integration is "run it and check the exit code":
# .gitlab-ci.yml
load-test:
image: golang:1.23
script:
- go install gitlab.com/agentic-fx/surge/cmd/surge@latest
- surge run tests/checkout.yaml -base-url $STAGING_URL -json summary.json -html report.html
- surge compare summary.json -baseline checkout # regression gate
artifacts:
paths: [report.html, summary.json]For the regression gate to mean anything, store a few reference runs first:
surge baseline save summary.json -name checkoutAfter three or more, surge compare judges a new run against the range this system normally produces — median and median absolute deviation — instead of against one previous run:
✗ p95
39.2% worse than the usual 204.00, which is 9.8 deviations out — larger
than this system's normal run-to-run spread.With fewer than three baselines it says the verdict cannot be trusted and does not fail your build, which is the difference between a gate the team keeps and a gate the team switches off. → regression.md
What else it does
Grouped by what you are trying to find out.
"How much can this system take?" — surge discover finds out instead of making you guess. It raises the arrival rate while the run is in flight, stops when your SLO breaks, bisects to the edge, then holds that level to confirm it is stable, and reports a confidence level with the answer. It also knows when the load generator is the bottleneck and refuses to report that as your system's capacity. → capacity.md
surge discover api.yaml -slo "p95 < 500ms, error_rate < 1%"
# CAPACITY 4320 iterations/s confidence: high"Why is it slow?" — -monitor scrapes the target's own Prometheus metrics during the run and lines the timestamps up against your latency, so the report says where to look first. -timings breaks each request into DNS, connect, TLS, time-to-first-byte and receive, which is how you discover you measured your own connection pool rather than the server.
"Is it getting worse release after release?" — surge trend catches what a regression gate cannot: a system 3% slower every release passes every check and is a quarter slower after eight of them, because the baseline drifts with it.
"Don't take production down with the test." — -abort-on "error_rate > 50% for 15s" stops a run that has already failed.
"One machine isn't enough." — surge agent -listen 0.0.0.0:7777 -token "$SURGE_TOKEN" on each generator, then surge run api.yaml -agents gen1:7777,gen2:7777. The raw histograms are merged, so the merged p99 is the true p99 and not an average of three p99s. If an agent is unreachable or on a different version the run stops before any traffic is sent. → distributed.md
"My system isn't plain HTTP."
| Protocol | How |
|---|---|
| WebSocket | built in — ws, err := c.Dial(url), then ws.Request(msg) |
| gRPC | surge/grpcx (a separate module, so HTTP-only tests stay lean) |
| Databases | surge/dbx — any database/sql driver, errors grouped by cause, pool counters |
| SAP OData / Fiori / S4HANA | surge/sapx — CSRF handshake, $batch changesets |
| Mainframe (TN3270) | surge/tn3270 — telnet negotiation, EBCDIC, screen buffer, AID keys |
| The page itself | surge/browser — real Chrome, Core Web Vitals (LCP, CLS, FCP) |
| A protocol surge cannot speak | an exec: step runs the tool that can, and measures it like a request |
| Anything else | wrap it in c.Time("name", fn) and it joins the same stats, thresholds and reports |
Worked example for each, and how to run it on one machine, across a fleet or in Kubernetes: recipes.md.
Kubernetes and Docker — deploy/ has both. A run is a Job that exits 0 or 1, which Kubernetes already models, so there is no operator to install.
Common mistakes it protects you from
Worth knowing, because they are the reason load-test numbers are so often wrong:
- A closed model hides overload. With
vus, when the system slows your test sends less load, so the graph flattens and the problem disappears. Useratefor traffic that arrives whether or not you are ready; when surge cannot start an arrival because every worker is busy, it counts it rather than quietly delaying it:dropped 4,187 iterations — max_vus reached. - A saturated generator measures itself. surge watches its own machine's CPU and marks the run untrustworthy above 85% instead of letting the number stand.
- A 200 is not a success.
check:lets a response that returned the right status and the wrong body count as a failure. - Checks are not errors. A 200 that was too slow fails a check without being counted as an error, so the two numbers keep their meaning.
- p99 is never invented. Percentiles are clamped to the observed min and max, so the report cannot show a latency that never happened.
- Warmup is excluded. The first
warmup:seconds appear on the timeline but stay out of the final statistics.
Where to go next
- recipes.md — a test for each protocol, and every way to run it
- scenario-reference.md — every YAML field
- library.md — the Go API
- capacity.md · regression.md · distributed.md