jotaro

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 25 Imported by: 0

README

jotaro

English | 日本語

CI Go Reference

A Go library for load-testing HTTP servers with scenarios (a flow such as login → list → order). Results can be written to the console, JSON or HTML, and the CLI can check them against thresholds.

  • Scenarios are plain Go code (if and variables just work)
  • Two ways to apply load
    • Closed: a fixed number of users repeat the scenario (Users)
    • Open: scenarios start at a fixed rate (Rate / Ramp / Stages). Arrivals keep their pace even when the server slows down, and latency is measured from the scheduled start time (Coordinated Omission correction)
  • Measures per-step latency (p50 / p90 / p95 / p99 / max), error breakdown, throughput, TTFB, DNS / TCP / TLS time, and connection reuse rate
  • Also records a per-second time series and the health of the load generator itself (CPU, goroutine count)

For the design rationale see DESIGN.md; for the result format and the threshold spec see docs/result.md.

Status: v0.x. The API may still change between minor versions. Changes are listed in CHANGELOG.md.

Try it

Runnable samples that start their own mock server are in examples/.

go run ./examples/checkout       # a short flow: login → product list → order
go run ./examples/marketplace    # a deeper scenario with five kinds of visitors

Use as a library

go get github.com/RIRUa/jotaro

Requires Go 1.25 or later.

1. Write a scenario
var checkout = jotaro.NewScenario("checkout", func(ctx context.Context, u *jotaro.User) error {
	var login struct {
		Token string `json:"token"`
	}
	if err := u.Step("login", func(s *jotaro.Step) error {
		return s.Post("/login").JSON(creds).Do().Decode(&login)
	}); err != nil {
		return err // return err to stop; ignore it to continue
	}

	u.Think(50 * time.Millisecond) // waiting time; not counted in latency

	return u.Step("order", func(s *jotaro.Step) error {
		return s.Post("/orders").Bearer(login.Token).
			JSON(map[string]int{"item": 1}).
			Do().ExpectStatus(http.StatusCreated)
	})
})
  • Step is the unit of measurement. Results are aggregated per step
  • Build a request with Get / Post / Put / Patch / Delete and send it with Do()
  • The following can be chained onto a request before Do()
    • Headers: Header(k, v) / Headers(map) / Bearer(token) / Basic(user, pass)
    • Query: Query(k, v) / Queries(url.Values) (values are escaped)
    • Body: JSON(v) / Form(url.Values) / Body(v) ([]byte / string / io.Reader are sent as is, url.Values as a form, anything else as JSON. Set Content-Type with Header when you need it)
  • After Do(), chain Decode(&v) (read JSON) or ExpectStatus(code) (verify the status)
  • Think is think time. Scenario time is reported both without and with Think
  • Even if a step fails, the iteration counts as successful as long as the scenario function returns nil. Step failures show up in the per-step stats
  • Cookies are fresh for each iteration. The connection pool is shared by all users

Use a Feeder to hand out different data (accounts, etc.) to each user. LoadCSV reads one from a CSV.

var accounts = jotaro.NewFeeder([]map[string]string{
	{"user": "alice"}, {"user": "bob"},
}, jotaro.Circular)

creds, ok := accounts.Next()
2. Choose the load and run
rep, err := jotaro.Run(ctx, jotaro.Plan{
	Scenario: checkout,
	BaseURL:  "http://localhost:8080",
	Duration: time.Minute,
	Users:    20, // closed model. For the open model, use Arrival instead of Users
})

Specify exactly one of Users and Arrival.

Setting Meaning
Users: 20 20 users repeat the scenario for Duration
Arrival: jotaro.Rate(50, time.Second) Start 50 scenarios per second
Arrival: jotaro.Ramp(0, 100, time.Second, 10*time.Second) Increase from 0 to 100 per second over 10 seconds
Arrival: jotaro.Stages(...) Switch Arrival stage by stage (ramp up → hold → ramp down, etc.)

You can also set MaxUsers (the cap on concurrency in the open model; arrivals beyond it are counted as dropped), Timeout (the limit for a single request), Grace (how long to wait for running iterations after Duration), and Raw (where to write raw data).

3. Output the results
rep.Write(os.Stdout, report.FormatText) // FormatText / FormatJSON / FormatHTML

You can also judge the results against thresholds.

th, _ := report.ParseThreshold("p95<200ms")
checks := rep.Check(th)
checks.Print(os.Stdout)
if !checks.Passed() {
	os.Exit(1)
}

CLI

A CLI for processing and judging saved results afterwards.

go install github.com/RIRUa/jotaro/cmd/jotaro@latest

Prebuilt binaries (Linux / macOS / Windows) are attached to each GitHub Release. jotaro version prints the version (please include it in bug reports).

Put options before the file names.

# Build an HTML report from a JSON result (default format is html)
jotaro report -o result.html result.json

# Merge several results (e.g. from different machines) into one (default format is json)
jotaro merge -o merged.json a.json b.json

# Check the thresholds. Exit code 1 if any of them is not met
jotaro check -t 'p95<200ms' -t 'error_rate<1%' -t 'p99{step=login}<100ms' result.json
  • Pass - as the input to read from standard input (report / check)
  • With -raw, the input is read as raw JSON Lines data (the output of Plan.Raw) and re-aggregated
  • Choose the output format with -format text|json|html
Threshold syntax

metric[{scenario=name,step=name}] operator value. Operators are < <= > >=.

Metric Meaning
min mean p50 p90 p95 p99 max Request latency (200ms, 1.5s)
The above prefixed with corrected_ ttfb_ dns_ connect_ tls_ After CO correction / TTFB / DNS / TCP / TLS time
error_rate Share of network errors and ExpectStatus mismatches (1% or 0.01)
http_4xx_rate http_5xx_rate Share of HTTP 4xx / 5xx
iteration_fail_rate Share of failed iterations
requests rps dropped Number of requests / requests per second / number of dropped arrivals

When the target or the data is missing, the threshold counts as not met, so that a mistyped step name cannot pass silently. See chapter 5 of docs/result.md for details.

MCP server (scenario authoring)

jotaro mcp starts an MCP server that helps an AI assistant write scenarios. It talks over stdio and never runs the scenario it checks.

claude mcp add jotaro -- jotaro mcp     # Claude Code; other clients take the same command
Kind Name Purpose
Resource jotaro://guide How to write a scenario: skeleton, API overview, load models, checklist
Resource jotaro://thresholds Threshold syntax and metrics
Prompt write_scenario Write a scenario for a described flow, then check it
Tool check_scenario Check a scenario file: Go syntax, jotaro lint (Users xor Arrival, unchecked responses, duplicate step names, ...) and a type check by compiling. Pass dir (a module that requires jotaro) to check against its jotaro version; without it a temporary module with the latest jotaro is used, which needs network access
Tool validate_threshold Parse a threshold such as p99{step=login}<100ms

Packages

Package Contents
jotaro Scenarios (Scenario / User / Step) and load models (Plan / Run / Rate / Ramp / Stages). The entry point for users
report Aggregation of results (Report), output (text / JSON / HTML), merging, and threshold checks
record Per-request records and reading/writing raw data (JSON Lines)
cmd/jotaro CLI (report / merge / check / mcp)

Caveats

  • Only HTTP can be measured
  • Percentiles such as p50 are currently computed by sorting all records, which uses memory on long, high-load runs
  • There is no statistical comparison (whether two results differ significantly). check works by setting criteria and judging pass or fail

License

MIT

Documentation

Overview

Package jotaro is a library for load-testing HTTP servers with scenarios: a flow such as login → list → order, written as plain Go code.

This package is the entry point. It handles scenarios (Scenario / User / Step) and how load is applied (Plan / Run / Rate / Ramp / Stages). The result is returned as a report.Report. Writing it to the console, JSON or HTML, and checking it against thresholds, is done by the report package; raw per-request records are handled by the record package.

sc := jotaro.NewScenario("checkout", func(ctx context.Context, u *jotaro.User) error {
	return u.Step("login", func(s *jotaro.Step) error {
		return s.Post("/login").JSON(creds).Do().ExpectStatus(200)
	})
})
rep, err := jotaro.Run(ctx, jotaro.Plan{Scenario: sc, BaseURL: url, Users: 20, Duration: time.Minute})
rep.Write(os.Stdout, report.FormatText)
Example

A scenario is plain Go code. Run it with a Plan, here against a local test server.

package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"

	"github.com/RIRUa/jotaro"
)

func main() {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/login":
			fmt.Fprint(w, `{"token":"secret"}`)
		default:
			w.WriteHeader(http.StatusCreated)
		}
	}))
	defer srv.Close()

	checkout := jotaro.NewScenario("checkout", func(ctx context.Context, u *jotaro.User) error {
		var login struct {
			Token string `json:"token"`
		}
		if err := u.Step("login", func(s *jotaro.Step) error {
			return s.Post("/login").JSON(map[string]string{"user": "alice"}).Do().Decode(&login)
		}); err != nil {
			return err // return err to stop; ignore it to continue
		}

		u.Think(10 * time.Millisecond) // waiting time; not counted in latency

		return u.Step("order", func(s *jotaro.Step) error {
			return s.Post("/orders").Bearer(login.Token).
				JSON(map[string]int{"item": 1}).
				Do().ExpectStatus(http.StatusCreated)
		})
	})

	rep, err := jotaro.Run(context.Background(), jotaro.Plan{
		Scenario: checkout,
		BaseURL:  srv.URL,
		Duration: 200 * time.Millisecond,
		Users:    2,
	})
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println("ran:", rep.Requests.Count > 0)
	fmt.Println("errors:", len(rep.Requests.Errors))
}
Output:
ran: true
errors: 0

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func LoadCSV

func LoadCSV(r io.Reader) ([]map[string]string, error)

LoadCSV reads a CSV whose first line is the header into rows keyed by column name.

Example

LoadCSV reads rows keyed by the column names of the header line.

package main

import (
	"fmt"
	"strings"

	"github.com/RIRUa/jotaro"
)

func main() {
	rows, err := jotaro.LoadCSV(strings.NewReader("user,password\nalice,a1\nbob,b2\n"))
	if err != nil {
		fmt.Println(err)
		return
	}
	feeder := jotaro.NewFeeder(rows, jotaro.Once)
	row, _ := feeder.Next()
	fmt.Println(feeder.Len(), row["user"], row["password"])
}
Output:
2 alice a1

func Run

func Run(ctx context.Context, p Plan) (*report.Report, error)

Run applies load according to the Plan, then aggregates and returns the result.

Flow of a run:

  1. validate the Plan, and clone the default Transport so connections are not shared with other code
  2. start the health sampler, then run the closed (Users) or open (Arrival) loop until Duration ends
  3. wait up to Grace for running iterations, then cancel them
  4. aggregate the collected records into a report, and write the raw data if Plan.Raw is set

When ctx is done, everything stops at once, including running iterations, and the results so far are returned. Report.Elapsed is the real time until all users have stopped.

Types

type Arrival

type Arrival interface {
	// contains filtered or unexported methods
}

Arrival is how scenarios arrive in the open model. Arrival times are decided independently of how long a scenario takes.

func Ramp

func Ramp(from, to int, per, over time.Duration) Arrival

Ramp changes the arrival rate linearly from from to to over over. The rate is a count per per; after over has passed it stays at to (if to is 0, arrivals stop there). For example, Ramp(0, 50, time.Second, 30*time.Second) raises the rate from 0 to 50 per second over 30 seconds.

The length of the whole run is set by Plan.Duration. If over is longer than Duration, to is never reached.

func Rate

func Rate(n int, per time.Duration) Arrival

Rate starts scenarios at a rate of n per per. For example, Rate(10, time.Second) is 10 per second.

func Stages

func Stages(stages ...Stage) Arrival

Stages runs several stages in order. The Arrival of each stage runs with the start of that stage as 0 (a Ramp's slope also starts there). For example, ramp up to 50/s over 30 seconds, hold for 60 seconds, then ramp down to 0 over 10 seconds:

Stages(
	Stage{Ramp(0, 50, time.Second, 30*time.Second), 30 * time.Second},
	Stage{Rate(50, time.Second), 60 * time.Second},
	Stage{Ramp(50, 0, time.Second, 10*time.Second), 10 * time.Second},
)

Arrivals stop when all stages are over. The length of the whole run is set by Plan.Duration, so make it match the sum of the For of every stage.

type ExpectError

type ExpectError struct {
	// Want is the status code that was expected.
	Want int
	// Got is the status code that came back.
	Got int
}

ExpectError reports that the status code did not match what ExpectStatus expected.

func (*ExpectError) Error

func (e *ExpectError) Error() string

Error implements the error interface.

type FeedMode

type FeedMode int

FeedMode is the order in which a Feeder hands out rows.

const (
	// Circular hands out rows from the first one and wraps around after the last.
	Circular FeedMode = iota
	// Random picks a row at random each time. The same row can be picked twice in a row.
	Random
	// Once hands out rows from the first one and returns false once they are used up.
	// It is meant for data such as accounts that can be used only once.
	Once
)

type Feeder

type Feeder[T any] struct {
	// contains filtered or unexported fields
}

Feeder hands out different data (accounts, input values, ...) to each user. Next may be called from all users at the same time.

Example

A Feeder hands out different data to each user.

package main

import (
	"fmt"

	"github.com/RIRUa/jotaro"
)

func main() {
	accounts := jotaro.NewFeeder([]map[string]string{
		{"user": "alice"}, {"user": "bob"},
	}, jotaro.Circular)

	for range 3 {
		row, _ := accounts.Next()
		fmt.Println(row["user"])
	}
}
Output:
alice
bob
alice
Example (Once)

With Once, rows are handed out once and then Next returns false.

package main

import (
	"fmt"

	"github.com/RIRUa/jotaro"
)

func main() {
	tokens := jotaro.NewFeeder([]string{"t1", "t2"}, jotaro.Once)

	for {
		tok, ok := tokens.Next()
		if !ok {
			break
		}
		fmt.Println(tok)
	}
}
Output:
t1
t2

func NewFeeder

func NewFeeder[T any](rows []T, mode FeedMode) *Feeder[T]

NewFeeder creates a Feeder that hands out rows in the order given by mode.

func (*Feeder[T]) Len

func (f *Feeder[T]) Len() int

Len returns the number of rows.

func (*Feeder[T]) Next

func (f *Feeder[T]) Next() (T, bool)

Next returns the next row. It returns false when there are no rows or when Once has run out.

type Plan

type Plan struct {
	// Scenario is the flow every virtual user runs.
	Scenario *Scenario
	// BaseURL is prepended to the relative paths of requests (for example "http://localhost:8080").
	BaseURL string

	// Users is the number of virtual users running at the same time.
	Users int
	// Arrival is how scenarios arrive (e.g. Rate(10, time.Second)). It cannot be set together with Users.
	Arrival Arrival
	// MaxUsers is the cap on concurrency with Arrival. 0 means 1000.
	// Arrivals that come in while the cap is reached are not run and are counted as dropped.
	MaxUsers int
	// After Duration has passed, no new iterations are started.
	Duration time.Duration

	// Timeout is the limit for a single request. 0 means 30 seconds.
	Timeout time.Duration
	// Grace is how long to wait for running iterations to finish after Duration.
	// Those that exceed it are aborted and recorded as record.ErrCanceled. 0 means 30 seconds.
	Grace time.Duration

	// If Raw is not nil, the raw data is written as JSON Lines when Run finishes (record.WriteRaw).
	// Even if writing fails, the aggregated Report is returned (together with an error).
	Raw io.Writer
}

Plan describes how load is applied.

Specify exactly one of Users (closed model) and Arrival (open model).

  • Users: a fixed number of users repeat the scenario until Duration has passed.
  • Arrival: scenarios start at a fixed rate, regardless of how long a scenario takes.
Example (Open)

In the open model, scenarios start at a fixed rate whatever the server does. Stages switches the arrival stage by stage: ramp up, hold, ramp down.

package main

import (
	"time"

	"github.com/RIRUa/jotaro"
)

func main() {
	var scenario *jotaro.Scenario // see the Example above

	plan := jotaro.Plan{
		Scenario: scenario,
		BaseURL:  "http://localhost:8080",
		Duration: 100 * time.Second,
		Arrival: jotaro.Stages(
			jotaro.Stage{Arrival: jotaro.Ramp(0, 50, time.Second, 30*time.Second), For: 30 * time.Second},
			jotaro.Stage{Arrival: jotaro.Rate(50, time.Second), For: 60 * time.Second},
			jotaro.Stage{Arrival: jotaro.Ramp(50, 0, time.Second, 10*time.Second), For: 10 * time.Second},
		),
		MaxUsers: 500, // arrivals beyond this are counted as dropped
	}
	_ = plan // jotaro.Run(ctx, plan)
}

type Request

type Request struct {
	// Spec is the data form of the request. It can be edited directly before Do.
	Spec RequestSpec
	// contains filtered or unexported fields
}

Request is a request builder. Send it with Do.

Builder methods return the Request so calls can be chained. A failure while building (for example JSON that cannot be encoded) is stored and returned later by the Response, which keeps the chain free of error checks.

func (*Request) Basic

func (r *Request) Basic(user, pass string) *Request

Basic sets Basic authentication in the Authorization header.

func (*Request) Bearer

func (r *Request) Bearer(token string) *Request

Bearer sets a Bearer token in the Authorization header.

func (*Request) Body

func (r *Request) Body(v any) *Request

Body sets the body according to its type.

[]byte / string  sent as is. Content-Type is not set; add it with Header if you need it
io.Reader        read to the end and sent as is. Content-Type is not set
url.Values       same as Form
anything else    same as JSON (structs, maps, slices, ...)

nil sets nothing. Use JSON when you want to make it explicit that the body is JSON (also when you want to send a string as a JSON string).

func (*Request) Do

func (r *Request) Do() *Response

Do sends the request. A network error is kept in the response and returned by Decode or ExpectStatus.

func (*Request) Form

func (r *Request) Form(v url.Values) *Request

Form sets the body as application/x-www-form-urlencoded.

func (*Request) Header

func (r *Request) Header(key, value string) *Request

Header sets a header.

func (*Request) Headers

func (r *Request) Headers(h map[string]string) *Request

Headers sets several headers at once. An existing key is overwritten.

func (*Request) JSON

func (r *Request) JSON(v any) *Request

JSON sets the body as JSON.

func (*Request) Queries

func (r *Request) Queries(v url.Values) *Request

Queries adds several query parameters at once.

func (*Request) Query

func (r *Request) Query(key, value string) *Request

Query adds a query parameter. Repeating a key gives multiple values. Unlike writing it directly into Path, the value is escaped.

type RequestSpec

type RequestSpec struct {
	Method string
	Path   string
	Query  url.Values // added to the query of Path
	Header http.Header
	Body   []byte
}

RequestSpec describes a request. It is kept as data separate from execution so that it can later be built from YAML and the like.

type Response

type Response struct {
	// Code is the HTTP status code. It is 0 when no response was received.
	Code int
	// Header holds the response headers. It is nil when no response was received.
	Header http.Header
	// Body is the whole body, already read.
	Body []byte
	// contains filtered or unexported fields
}

Response is the result of a single request. HTTP 4xx/5xx are not network errors, so Err stays nil and the status goes into Code.

func (*Response) Decode

func (r *Response) Decode(v any) error

Decode reads the body into v as JSON.

func (*Response) Err

func (r *Response) Err() error

Err returns the network error, if any. It does not look at the HTTP status code.

func (*Response) ExpectStatus

func (r *Response) ExpectStatus(code int) error

ExpectStatus checks that the status code equals code.

type Scenario

type Scenario struct {
	// Name labels the scenario in every record and in the report.
	Name string
	// Fn is the flow itself. Returning an error marks the iteration as failed.
	Fn func(ctx context.Context, u *User) error
}

Scenario is the flow that a virtual user runs once.

func NewScenario

func NewScenario(name string, fn func(ctx context.Context, u *User) error) *Scenario

NewScenario creates a scenario.

type Stage

type Stage struct {
	// Arrival decides when scenarios start within this stage, counted from the stage's own start.
	Arrival Arrival
	// For is how long the stage lasts. Arrivals that would fall at or after For are dropped.
	For time.Duration
}

Stage is one stage of Stages. It keeps Arrival going for For.

type Step

type Step struct {
	// contains filtered or unexported fields
}

Step is the entry point for building requests inside u.Step. Every request built from it is recorded under this step's name.

func (*Step) Delete

func (s *Step) Delete(path string) *Request

Delete builds a DELETE request.

func (*Step) Get

func (s *Step) Get(path string) *Request

Get builds a GET request.

func (*Step) Name

func (s *Step) Name() string

Name returns the step name.

func (*Step) Patch

func (s *Step) Patch(path string) *Request

Patch builds a PATCH request.

func (*Step) Post

func (s *Step) Post(path string) *Request

Post builds a POST request.

func (*Step) Put

func (s *Step) Put(path string) *Request

Put builds a PUT request.

func (*Step) Request

func (s *Step) Request(method, path string) *Request

Request builds a request with an arbitrary method.

type User

type User struct {
	// ID identifies the virtual user. In the open model every arrival gets its own ID.
	ID int
	// Iteration counts how many times this user has run the scenario (0-based).
	Iteration int
	// Client sends the requests. Use it directly for anything the Step builders do not cover,
	// but note that such requests are not recorded.
	Client *http.Client
	// contains filtered or unexported fields
}

User is a virtual user that runs the scenario once. ID is the number of the virtual user and does not change across iterations. Client has its own cookie jar per iteration, so every iteration starts a fresh session.

func (*User) Step

func (u *User) Step(name string, fn func(s *Step) error) error

Step runs one section of the scenario and records its duration and outcome. The error returned by fn is returned as is, so the caller decides whether to continue or stop.

func (*User) Think

func (u *User) Think(d time.Duration)

Think waits for d. The time waited is counted in neither the step nor Active. It returns early when ctx is done.

Directories

Path Synopsis
cmd
jotaro command
Command jotaro is a CLI that works with result files saved by the jotaro library.
Command jotaro is a CLI that works with result files saved by the jotaro library.
examples
checkout command
Command checkout is a sample that shows how to use jotaro.
Command checkout is a sample that shows how to use jotaro.
marketplace command
Command marketplace is a deeper sample than examples/checkout.
Command marketplace is a deeper sample than examples/checkout.
internal
histogram
Package histogram is the log histogram (DDSketch style) that Latency holds for merging.
Package histogram is the log histogram (DDSketch style) that Latency holds for merging.
mcpserver
Package mcpserver is the MCP server behind "jotaro mcp".
Package mcpserver is the MCP server behind "jotaro mcp".
Package record holds the raw data of a load test (records of requests, steps and iterations), and its input and output.
Package record holds the raw data of a load test (records of requests, steps and iterations), and its input and output.
Package report aggregates and outputs the results (Report) of a load test.
Package report aggregates and outputs the results (Report) of a load test.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL