surgesurge

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

Pick one. All three give the same binary.

bash
# 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 version

Check it:

bash
surge version
# surge 0.5.0

There 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

bash
go install gitlab.com/agentic-fx/surge/cmd/surge@latest

Then your repository holds YAML and nothing else:

text
your-service/
  perf/
    checkout.yaml
    users.csv
bash
surge verify perf/checkout.yaml -base-url https://staging.example.com
surge run    perf/checkout.yaml -base-url https://staging.example.com

This 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:

bash
cd your-service
go get gitlab.com/agentic-fx/surge
text
your-service/
  go.mod            # your module, with surge as one dependency
  internal/...      # your application
  perf/
    main.go         # the load test, a normal Go program
go
package 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()
}
bash
go run ./perf -target https://staging.example.com -duration 5m

The 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

bash
docker run --rm -v "$PWD/perf:/work" \
  registry.gitlab.com/agentic-fx/surge run /work/checkout.yaml -base-url https://staging

Not published yet?

While surge lives only on disk, point your module at the checkout and delete the line when it is published:

text
require gitlab.com/agentic-fx/surge v0.8.0

replace gitlab.com/agentic-fx/surge => /path/to/surge

go 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:

bash
surge quick https://your-api.example.com/health -vus 20 -duration 30s

If 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:

bash
go run ./examples/target -addr :8099 &
surge quick http://localhost:8099/health -vus 20 -duration 5s -threshold "p95 < 200ms"
text
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)

PASS

The flags you will use most:

FlagMeans
-vus 2020 virtual users, each waiting for its own response
-rate 500500 arrivals per second regardless of how slow answers are
-duration 30show long to run
-threshold "p95 < 200ms"pass/fail rule — repeatable
-html report.htmlwrite a shareable report
-analyzehave surge explain the numbers
-web :8088watch 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:

bash
surge init checkout.yaml

Edit it down to what you need. The whole language is in scenario-reference.md; this is the shape of it:

yaml
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 failure

Check it before you send any traffic — this reads the file, the CSV, every regex and every threshold expression, and sends nothing:

bash
surge validate checkout.yaml

Then run it:

bash
surge run checkout.yaml
surge run checkout.yaml -base-url https://staging.example.com   # same file, any environment

One 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:

go
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 failure

go 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:

bash
surge import har session.har -o checkout.yaml
text
wrote 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/checkout

This 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:

text
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 identity

The 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:

text
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 enough

Which check, not how many. checks 97.09% is a number; the breakdown is a place to look:

text
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:

text
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 exceeded

That 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:

bash
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/loadtest

The 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:

text
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":

yaml
# .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:

bash
surge baseline save summary.json -name checkout

After 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:

text
  ✗ 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

bash
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."

ProtocolHow
WebSocketbuilt in — ws, err := c.Dial(url), then ws.Request(msg)
gRPCsurge/grpcx (a separate module, so HTTP-only tests stay lean)
Databasessurge/dbx — any database/sql driver, errors grouped by cause, pool counters
SAP OData / Fiori / S4HANAsurge/sapx — CSRF handshake, $batch changesets
Mainframe (TN3270)surge/tn3270 — telnet negotiation, EBCDIC, screen buffer, AID keys
The page itselfsurge/browser — real Chrome, Core Web Vitals (LCP, CLS, FCP)
A protocol surge cannot speakan exec: step runs the tool that can, and measures it like a request
Anything elsewrap 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:

Where to go next