surgesurge

Library guide

Writing a load test in Go. If you have written one before, the mapping is at the bottom.

bash
go get gitlab.com/agentic-fx/surge

The whole shape

go
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
}
bash
go run .                               # the plan in the code
go run . -vus 200 -duration 5m         # override it
go run . -rate 500 -duration 1m -html report.html

Scenario

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

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

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

MethodPurpose
.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
go
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

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

OptionDoes
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

go
r := c.GET("/api/items", surge.Name("items"))
if r.OK() && r.JSONExists("items.0.id") {
	c.Set("first", r.JSON("items.0.id"))
}
CallGives
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

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

Anything 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.

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.

FieldIs
sum.Passedevery threshold passed
sum.Total.P95Ms · .P99Ms · .AvgMsaggregate percentiles
sum.Total.RPSrequests a second
sum.Total.ErrorRatepercent
sum.Total.TopErrors{"timeout": 12, "HTTP 500": 3}
sum.Droppedopen model: arrivals that found no free VU
sum.ChecksPass · sum.ChecksFailassertion tallies
sum.Requests · sum.Actionsper request name, and per group: action
sum.Failures · sum.Checks · sum.Thresholdsthe evidence behind the verdict
go
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.

go
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

  1. 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 ignoring r.OK() and carrying on (Go).
  2. Checks are not errors. MaxDuration and Check feed check_rate, while errors are transport failures and unexpected statuses. A slow 200 is a failed check, not a failed request.