Library guide
Writing a load test in Go. If you have written one before, the mapping is at the bottom.
go get gitlab.com/agentic-fx/surgeThe whole shape
package main
import (
"time"
"gitlab.com/agentic-fx/surge"
)
func main() {
s := surge.New("shop") // a scenario
s.BaseURL("http://localhost:8080").
Closed(50, 2*time.Minute). // 50 users for 2 minutes
Thresholds("p95 < 500ms", "error_rate < 1%")
buyer := s.User("buyer", 3). // a population, weight 3
Wait(time.Second, 3*time.Second) // think time between tasks
buyer.OnStart(func(c *surge.Ctx) error { // once per virtual user
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()
})
buyer.Task("browse", 4, func(c *surge.Ctx) error { // weight 4
c.GET("/api/items", surge.Name("browse"), surge.Bearer(c.Str("token")))
return nil
})
s.RunMain() // flags, run, exit code
}go run . # the plan in the code
go run . -vus 200 -duration 5m # override it
go run . -rate 500 -duration 1m -html report.htmlScenario
| Method | Purpose |
|---|---|
surge.New(name) | create a scenario |
.BaseURL(u) | base for relative URLs |
.Header(k, v) | header on every request |
.Timeout(d) | per-request timeout (default 30s) |
.InsecureSkipVerify() | accept any TLS certificate |
.Closed(vus, dur) | N concurrent users for a duration |
.Open(rate, dur) | N arrivals per second for a duration |
.ClosedStages(…) / .OpenStages(…) | ramp through surge.Stage{Duration, Target} |
.Iterations(n, vus) | exactly n iterations, then stop |
.MaxVUs(n) | open model: cap on concurrent iterations (default 1000) |
.Warmup(d) | exclude the first d from the final stats |
.Thresholds(…) | pass/fail rules (expressions) |
.OutputJSON/HTML/CSV(path) | write reports |
.Quiet() | no console output |
.User(name, weight) | add a population |
.Run(ctx) | run, return *surge.Summary |
.RunMain() | parse flags, run, exit 1 on threshold failure, 2 on setup error |
s.ClosedStages(
surge.Stage{Duration: 30 * time.Second, Target: 100}, // ramp
surge.Stage{Duration: 2 * time.Minute, Target: 100}, // hold
surge.Stage{Duration: 30 * time.Second, Target: 0}, // down
)
s.OpenStages( // arrival rate
surge.Stage{Duration: 30 * time.Second, Target: 200},
surge.Stage{Duration: time.Minute, Target: 1000}, // find the knee
)Setup and teardown commands
Setup and Teardown are Go functions and run once per agent, because agents do not share memory. For the shared kind — seed a database, run a migration, archive the artefacts — use the command hooks, which run once, on the machine that starts the test:
s.SetupCommand("./scripts/seed.sh 150").
TeardownCommand("./scripts/clean.sh").
HookTimeout(10 * time.Minute)A single string goes through a shell, so pipes and redirects work. Several arguments are an argv, executed directly, where an argument containing a space stays one argument:
s.SetupCommand("psql", "-f", "seed.sql")A failing setup stops the run and sends no load. Teardown runs whatever happened — a failed run, a breached threshold, a cancelled context — and carries on past its own failures, because the step that puts a feature flag back should still run when the step that drops a table did not.
User
A population of virtual users. When several exist, each spawned VU picks one, weighted-random: a task with weight 4 runs four times as often as one with weight 1.
| Method | Purpose |
|---|---|
.OnStart(fn) | once per VU: log in, open a connection. An error is retried on the next iteration after a short pause |
.OnStop(fn) | when the VU is descheduled: log out, close the connection (gets a fresh 5s context) |
.Task(name, weight, fn) | one unit of work; each iteration picks one task, weighted |
.Sequential() | run tasks in declaration order instead of weighted-random |
.Wait(min, max) | think time between tasks, uniform random |
api := s.User("api-client", 7) // 70% of VUs
ui := s.User("ui-user", 3) // 30%
api.Task("items", 1, func(c *surge.Ctx) error {
return c.GET("/api/items", surge.Name("items")).Err()
})
ui.Sequential() // a fixed journey, step per iteration
ui.Task("home", 1, func(c *surge.Ctx) error { return c.GET("/").Err() })
ui.Task("search", 1, func(c *surge.Ctx) error { return c.GET("/search?q=hat").Err() })
ui.Task("checkout", 1, func(c *surge.Ctx) error { return c.POST("/cart/checkout").Err() })Ctx
Passed to every task. Carries per-VU state and the HTTP client, and records every request.
Requests
| Call | Returns |
|---|---|
c.GET(url string, opts ...surge.ReqOpt) | *surge.Response |
c.POST(url string, opts ...surge.ReqOpt) | *surge.Response |
c.PUT(url string, opts ...surge.ReqOpt) | *surge.Response |
c.PATCH(url string, opts ...surge.ReqOpt) | *surge.Response |
c.DELETE(url string, opts ...surge.ReqOpt) | *surge.Response |
c.Do(method, url string, opts ...surge.ReqOpt) | *surge.Response |
Options:
| Option | Does |
|---|---|
surge.Name("checkout") | the metric name. Always set it when the URL has an id, or stats fragment per URL |
surge.Header(k, v) | one header |
surge.Bearer(token) | Authorization: Bearer … |
surge.JSON(v) | marshal v as the body, set Content-Type |
surge.Body(contentType, b) | raw body |
surge.Form(map[string]string{…}) | urlencoded body |
surge.Expect(200, 201) | which statuses are success (also counts as a check) |
surge.MaxDuration(800*time.Millisecond) | latency check — fails the check, not the request |
Response
r := c.GET("/api/items", surge.Name("items"))
if r.OK() && r.JSONExists("items.0.id") {
c.Set("first", r.JSON("items.0.id"))
}| Call | Gives |
|---|---|
r.OK() | transport fine and status accepted |
r.Status() | 200 |
r.JSON(path) | a gjson path into the body |
r.JSONExists(path) | whether that path is there |
r.Header(name) | one response header |
r.Text() · r.Body() | the body, as a string or bytes |
r.Duration() | how long it took |
r.Err() | transport error — nil on an HTTP error status |
Every request is recorded whether or not you look at the result, so a failure always shows up in the report.
State, checks, think
c.Set("token", r.JSON("data.token")) // per-VU state
c.Str("token") // read it back as a string
c.Value("conn") // read any value
c.Check("order confirmed", r.JSON("status") == "confirmed") // feeds check_rate
c.Think(time.Second, 3*time.Second) // interruptible pause
c.VU() // this VU's number
c.UserName() // which population
c.Context() // the run contextAnything that is not HTTP
c.Time measures a function and records it under a name, into the same stats, thresholds and reports as HTTP requests. This is how you load-test gRPC, WebSocket, a database, a queue — anything callable from Go.
u.OnStart(func(c *surge.Ctx) error {
conn, err := grpc.Dial(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return err
}
c.Set("client", pb.NewSearchClient(conn))
c.Set("conn", conn)
return nil
})
u.OnStop(func(c *surge.Ctx) {
c.Value("conn").(*grpc.ClientConn).Close()
})
u.Task("search", 1, func(c *surge.Ctx) error {
client := c.Value("client").(pb.SearchClient)
return c.Time("grpc.Search", func() error {
_, err := client.Search(c.Context(), &pb.Query{Term: "widget"})
return err
})
})Then s.Thresholds("p95(grpc.Search) < 100ms") works exactly as it would for HTTP. Runnable example: examples/custom-protocol.
Reading the result
Run returns the same structure the JSON report contains.
| Field | Is |
|---|---|
sum.Passed | every threshold passed |
sum.Total.P95Ms · .P99Ms · .AvgMs | aggregate percentiles |
sum.Total.RPS | requests a second |
sum.Total.ErrorRate | percent |
sum.Total.TopErrors | {"timeout": 12, "HTTP 500": 3} |
sum.Dropped | open model: arrivals that found no free VU |
sum.ChecksPass · sum.ChecksFail | assertion tallies |
sum.Requests · sum.Actions | per request name, and per group: action |
sum.Failures · sum.Checks · sum.Thresholds | the evidence behind the verdict |
sum, err := s.Run(context.Background())
if err != nil {
log.Fatal(err) // setup problem; a failed threshold is not an error
}
for _, r := range sum.Requests { // per request name
fmt.Printf("%s p95=%.1fms errors=%d\n", r.Name, r.P95Ms, r.Errors)
}
for _, t := range sum.Thresholds {
fmt.Printf("%v %s (actual %s)\n", t.OK, t.Expr, t.Actual)
}In a Go test
Because a scenario is ordinary Go, a performance budget can live in your test suite and fail the build like any other test.
func TestCheckoutPerformance(t *testing.T) {
if testing.Short() {
t.Skip("load test skipped in -short mode")
}
staging := "https://staging.example.com"
s := surge.New("checkout-budget").BaseURL(staging).Quiet().
Closed(20, 30*time.Second).
Thresholds("p95 < 400ms", "error_rate < 1%")
s.User("u", 1).Task("checkout", 1, func(c *surge.Ctx) error {
return c.POST("/api/checkout", surge.Name("checkout")).Err()
})
sum, err := s.Run(context.Background())
if err != nil {
t.Fatal(err)
}
if !sum.Passed {
for _, th := range sum.Thresholds {
if !th.OK {
t.Errorf("threshold failed: %s (actual %s)", th.Expr, th.Actual)
}
}
}
}Two behaviours worth knowing
- An iteration ends when a step fails. surge stops the iteration, because the next request would use a value that was never extracted. Opt out per step with
continue_on_error(YAML) or by ignoringr.OK()and carrying on (Go). - Checks are not errors.
MaxDurationandCheckfeedcheck_rate, while errors are transport failures and unexpected statuses. A slow 200 is a failed check, not a failed request.