The Goose Book
Have you ever been attacked by a goose?
What Is Goose?
Goose is a load testing tool written in Rust and inspired by Locust. A load test is a small Rust program that depends on the goose crate: you build it with Cargo and run it against a host, choosing the users and the run time on the command line.
Where Locust has a user class with tasks, Goose has a scenario with transactions. Requests go through the Reqwest HTTP client. A load test of another protocol, such as raw TCP, times each operation itself and records it in Goose’s metrics.
What It Looks Like
The live dashboard of a 50 user run whose users start one a second: the requests per second climb with them and level off, the tiles other than Users cover the whole run, and each column of the heatmap is one second of response times.
That run came from the Umami example in a checkout of Goose, against a copy of the Drupal Umami demo site. --no-reset-metrics keeps the ramp up in its numbers; without it, Goose clears the metrics when the ramp up ends:
cargo run --release --features dashboard --example umami -- \
--dashboard -H http://umami.example.com -u 50 -r 1 -t 3m --no-reset-metrics
Why Use Goose
- One process uses every CPU core. A Goose load test runs its users as async tasks on Tokio’s multi-threaded runtime, which spreads them across a server’s cores. Locust needs a process per core to do the same.
- Measured throughput. In Tag1’s A Goose In The Clouds (2021, an earlier Goose release), one 8 core server simulated 2,000 users making over 6,500 requests per second and sustained 2.5 Gbps; one 32 core server made 22,900 requests per second, limited by its 9 Gbps uplink.
- Load tests are Rust code. A load test can use any crate and builds to one binary, to run in CI or a container. See Creating A Load Test.
- Results a pipeline can act on. Thresholds fail a run on its metrics, and with a baseline the PDF, HTML and Markdown reports show each number’s change since an earlier run.
Watching A Run And Its Reports
The live dashboard above needs the dashboard feature, which is off by default; Watching a run shows how to turn it on. Goose then serves it at http://127.0.0.1:5118/ while a load test runs with --dashboard, and saves the run’s report when it ends. With --dashboard-control and a dashboard token, the dashboard can also start and stop the test and change its users and settings; Turning it on explains the token, and --dashboard-no-auth, which turns authentication off.
When the run ends, each --report-file <FILE> writes one report, its type set by the file’s extension: PDF (from a build with the pdf-reports feature), HTML, Markdown or JSON. Writing reports shows each. Goose also prints its metrics to the console.
Goose 0.19 runs on one server: Gaggle, the distributed load test, was removed in Goose 0.17.0 and will be restored in Goose 0.20, before the 1.0 release. For a distributed load test today, use Goose 0.16.4.
Brought To You By
Goose is made by Tag1 Consulting and led by Jeremy Andrews, Tag1’s CEO, with many community contributions. Tag1 is a member of the Rust Foundation.
More Documentation
- Getting Started, to build and run a first load test
- Developer documentation
- Blog posts at Tag1:
- Real life Goose load testing
- The golden Goose egg, a compile-time adventure
- A working Gaggle, from 2020, with an earlier Goose
Coming from Goose 0.18? See Upgrading From 0.18.
Requirements
-
In order to write load tests, you must first install Rust.
-
Goose load tests are managed with Cargo, the Rust package manager.
-
Goose sets no minimum Rust version in its
Cargo.toml. With the newest releases of its dependencies, Goose 0.19 needsrustc1.88.0 or later: some of them, such ascookie_store, which Reqwest uses for cookies, require it. Use a current stable Rust. -
Building Goose compiles
aws-lc-rs, the cryptography library behind its TLS support, which needs a C compiler. See its build requirements.
TLS
Goose makes requests with Reqwest 0.13, whose default TLS backend is rustls, so a load test needs no OpenSSL. Reqwest checks certificates with rustls-platform-verifier, which uses the operating system’s certificate store, so a CA you have added to the system is trusted. --accept-invalid-certs turns certificate checks off, for test servers with self signed or expired certificates.
Goose before 0.19 used Reqwest 0.12 and native TLS by default, with rustls through the rustls-tls feature. In 0.19 the rustls-tls feature still exists, so a Cargo.toml that enables it keeps building, but HTTP requests use rustls with or without it.
Goose does not enable Reqwest’s http2 feature, so requests use HTTP/1.1.
Features
Goose has these Cargo features:
| Feature | Default | What it adds |
|---|---|---|
cookies | on | Each GooseUser keeps its own cookies; see Cookies. Build with default-features = false to leave it out. |
dashboard | off | The live web dashboard, served with --dashboard. |
pdf-reports | off | PDF reports, written with a .pdf --report-file. |
rustls-tls | off | Kept so that older Cargo.toml files build; see TLS. |
For example, to build a load test with the dashboard:
[dependencies]
goose = { version = "^0.19", features = ["dashboard"] }
Chrome for PDF reports
A .pdf --report-file needs the pdf-reports feature and a Chrome or Chromium browser to render the report. Goose uses the browser the CHROME environment variable names, then an installed Chrome, Chromium or Edge, and otherwise downloads a Chrome for Testing headless shell once into the user cache directory, which needs unzip (tar on Windows) and, on Linux, glibc. See PDF report system requirements.
--pdf-print-html writes a print-ready HTML report to turn into a PDF in any browser, and needs neither Chrome nor the pdf-reports feature.
Glossary
These are the words Goose uses in a sense of its own, each with what it means in Goose and the page that explains it.
Adjusted Metrics
The metrics Goose shows with coordinated omission mitigation on, once mitigation has added any synthetic requests. Goose labels them “adjusted” and shows them beside the “raw” metrics of the requests actually sent. See Coordinated Omission Metrics.
Attack Phase
The stage a GooseAttack is in, which Goose logs as entering GooseAttack phase: and the live dashboard shows: Idle, Increase while users launch, Maintain once all are running, Decrease while they stop, and Shutdown as Goose exits. Idle is Goose waiting for a start from the dashboard or a Controller, before a run or after one. The plan overview lists Decrease as Canceling, not Decreasing, when every user finishes its iterations or when ctrl-c, the dashboard, a Controller, a killswitch or a failed threshold stops the run. See Running A Load Test.
Baseline
An earlier run’s JSON report, or the run folder that holds it, given with --baseline-file. The PDF, HTML and Markdown reports then show each number’s change from it, such as 1,234 (+200). See Comparing Against A Baseline.
Cadence
How long a GooseUser takes to run once through its scenario, leaving out any wait time: its average, fastest or slowest time, as --co-mitigation chooses. Coordinated omission mitigation uses it to tell how many requests a user missed while the server stalled. See Mitigation.
CO Event
A request made after a stall, for which coordinated omission mitigation added synthetic requests. The CO event count counts these requests, not the stalls. See CO Event Tracking.
Controller
The telnet or WebSocket interface that starts and stops a load test and changes its settings, such as its users, host, ramps, run time or test plan. The live dashboard can do the same with dashboard control. See Controllers.
Coordinated Omission
The error a load test makes when its users wait on a stalled server and so send fewer requests, which hides the stall from the metrics. Goose can correct for it with --co-mitigation. See Coordinated Omission.
Dashboard Control
What the live dashboard can do with --dashboard-control: start and stop the test, change its users and settings, turn Save report off and on, delete saved runs, and quit Goose once the test is stopped. It also needs a dashboard token or --dashboard-no-auth. Without control the dashboard is read-only: it shows the run, saves its report and lists saved runs in its runs panel, but cannot change the test. See Turning it on.
Dashboard Token
A secret set with --dashboard-auth-token. The live dashboard then requires it, and a browser gives it by opening the dashboard’s address with ?token=<TOKEN> added. See Turning it on.
Decrease Rate
How many GooseUsers stop each second at the end of a load test, set with --decrease-rate; --decrease-time spreads the stops over a time instead. With neither, all users stop at once. See Controlling the ramp-down rate.
Gaggle
Goose’s distributed load test, in which one Manager and one or more Workers ran a single load test across several servers. It was removed in Goose 0.17.0 and will be restored in Goose 0.20, before the 1.0 release; for a distributed load test today, use Goose 0.16.4. See Gaggle: Distributed Load Test.
Goose Eggs
A companion crate of helpers for validating responses and for load testing Drupal sites, with its own version of the Umami example. See Goose Eggs.
Goose Log
Goose’s own log of what it is doing, written with -G (--goose-log), apart from the logs of requests and transactions. It holds only warnings and errors unless -g (--log-level) adds info messages. See Output Files.
GooseAttack
The load test itself: the scenarios it runs and its configuration, built with GooseAttack::initialize() and run with execute(). See Creating A Load Test.
GooseConfiguration
The structure that holds every run-time option of a load test, parsed from the command line, with defaults the load test can set in code with GooseDefault. See Custom Options.
GooseDefault
The options a load test sets in its code with set_default(), such as a host or a number of users. An option given on the command line wins. See Defaults.
GooseError
The error returned by GooseAttack methods such as initialize(), set_default() and execute(), for example when a run-time option is invalid or no host is configured. A transaction returns a TransactionError instead. See Creating A Load Test.
GooseMetrics
The metrics a load test collects. execute() returns them when the run ends, and the JSON report holds them. See JSON report.
GooseRequest
A request built in a transaction with GooseRequest::builder(), for what the shortcuts such as user.get() and user.post() do not set, such as headers or the status code to expect. See GooseRequest and the Basic Authentication example.
GooseUser
A simulated user: an async task that runs one scenario over and over until the load test ends. Unless its scenario sets a wait time, it runs its transactions back to back, so it makes far more requests than one person would. --users sets how many start. See How many users to simulate.
Graph Data
The per second numbers behind a report’s graphs. When Goose will write a report at the end of the run, it keeps them in memory for every second of the run; without one, it keeps only the last 10 minutes for the live dashboard, and none when there is no dashboard. --report-graph-data also adds them to the JSON report. See Graph data and memory.
Heatmap
A chart of response times over time, on the live dashboard and in the PDF and HTML reports. Each column is a span of time and each row a range of response times; a cell’s color shows how many requests took that long, and lines show each column’s p50, p95 and p99. See The response time heatmap.
Hold
A time when the number of users stays steady: the run time after a ramp up, or a test plan step that keeps the users it has. An rps threshold, on requests per second, counts only the holds. See Thresholds.
Host
The base URL a load test sends its requests to, given with -H (--host). A request’s path, such as /about, is added to it, so one load test can run against any site. See Host to load test.
Increase Rate
How many GooseUsers start each second at the start of a load test, set with --increase-rate, one a second by default; --increase-time sets how long starting them all takes instead. See Specifying the increase rate.
Iteration
One run of a GooseUser through its whole scenario. --iterations stops each user after that many. See Specifying how long the load test will run.
Killswitch
A way to stop a running load test from your own code, by calling goose::trigger_killswitch with a reason. Goose stops the run as it does for ctrl-c, writes every report, and records the run as canceled with that reason. See Killswitch.
Live Dashboard
The web page Goose serves while a load test runs with --dashboard. Goose must be built with the dashboard feature, which is off by default. The page shows users, requests, response times and errors second by second. See Live Dashboard.
Logs
Files that record what happens during a run, each turned on with its own option, such as --request-log. The request, transaction, scenario and error logs record each one as it ends. The debug log holds what the load test writes with log_debug(), such as a failed request and its response. See Logging.
Machine Name
A scenario’s name in lower case letters and digits, with _1, _2 added when two names would be the same. --scenarios uses it to pick the scenarios that run, and --scenarios-list prints it. See Limiting Scenarios.
Metrics Reset
When the ramp up ends, Goose prints the metrics so far and then clears them, so the totals that follow leave out the ramp up. --no-reset-metrics turns the reset off, so the totals include the ramp up. A run limited by --iterations (see Iteration) or shaped by a test plan never resets. See Specifying how long the load test will run.
Plan Overview
A table of each step the run went through, such as Increasing, Maintaining, Decreasing or Canceling, with when it started and ended and how many users ran. The PDF, HTML and Markdown reports show it under their header, and the console prints it last. See Running A Load Test.
Request
One HTTP request a GooseUser makes, or an operation of another protocol recorded with GooseUser::record_custom_request, as in the TCP example. See Creating A Load Test.
Request Name
The name a request is counted under in the metrics: its path, unless the load test names the request, or its transaction names its requests with set_name_for_transaction_and_requests(). See Naming Transactions.
Run Folder
The folder a run writes into when an output flag such as --report-file is given without a path, or when Save report writes the run’s report. It is the folder --output-dir names, or else goose-runs/<YYYYMMDD-HHMMSS>, named for the run’s local start time. See Run folders.
Run Time
How long every user keeps running once all have started, set with --run-time. Starting and stopping the users takes extra time, so a run lasts longer than its run time. See Specifying how long the load test will run.
Running Metrics
The metrics so far, printed to the console every N seconds during a run with --running-metrics N, and during the ramp up also each time a user starts. See Run-Time Options.
Runs Panel
The live dashboard’s list of runs: this run’s run folder and the others beside it that hold a report, to download, compare or, with dashboard control, delete. See Save, compare and delete runs.
Save Report
A run setting that writes the run’s report into its run folder when the run ends. A --report-file given without a path turns it on, and so does --dashboard unless --no-metrics is set; the live dashboard shows it as a switch that dashboard control can turn off and on. See Save, compare and delete runs.
Scenario
A list of transactions one GooseUser runs, such as browsing anonymously or logging in to post. A load test can have many, each with a weight. See Creating A Load Test.
Scheduler
The order in which Goose assigns weighted scenarios to new users and runs a scenario’s transactions: round robin, serial or random. See Scheduling Scenarios And Transactions.
Session Data
Data a GooseUser keeps between its requests, such as a token from a login, stored with set_session_data. See Session.
Snapshot
The JSON state the live dashboard serves at /api/v1/snapshot and streams from /api/v1/events, about once a second. Its version field numbers the snapshot’s format, not the Goose release. See Dashboard Reference.
Start And Stop Transactions
Transactions that run once, outside a scenario’s loop. One marked with set_on_start() runs when each user starts, and one marked with set_on_stop() when it stops. GooseAttack::test_start() registers one that runs once before the first user starts, and test_stop() one that runs once after the last user stops. See set_on_start and test_start.
Synthetic Request
A response time that coordinated omission mitigation adds for a request a user would have sent at its usual cadence had the server not stalled. See Coordinated Omission Metrics.
Test Plan
A list of steps that shapes a load test, given with --test-plan. Each step is a number of users and the time to reach it, written users,time;users,time, to build a ramp up, a hold, a spike or a ramp down. See Test Plan.
Threshold
A pass or fail rule on a load test’s metrics, such as p95<500ms or error-rate<1%, given with --threshold. A rule that fails stops the run by default and always makes it fail. See Thresholds.
Throttle
A limit on how many requests per second all GooseUsers together send, set with --throttle-requests. See Throttle.
Transaction
A function that makes one or more requests and checks the responses, such as loading a page and its static assets, or logging in. See Creating A Load Test.
TransactionError
The error a transaction returns when it fails. A failed request is counted once, with the request errors. A custom message, a bad URL, or a response body that cannot be read or decoded is counted as a transaction error. See Transaction Error Types.
TransactionResult
The type a transaction function returns, Result<(), Box<TransactionError>>: Ok(()) on success, or a boxed TransactionError on failure. See Validating Requests.
Users Ramp
A run shaped by its users, increase rate, run time and ramp down (decrease rate), as opposed to a test plan. When the live dashboard or a Controller changes its users mid run, Goose ramps to the new count and keeps the run time and ramp down where it can. See Change users and settings.
Weight
How often Goose picks a transaction within its scenario, or what share of users a scenario gets. A transaction of weight 3 runs 3 times as often as one of weight 1. See Scheduling Scenarios And Transactions.
Getting Started
This first chapter of the Goose Book provides a high-level overview of writing and running Goose load tests. If you’re new to Goose, this is the place to start.
If you have load tests written for Goose 0.18, Upgrading From 0.18 lists the changes that can break them.
The Importance Of Load Testing
Load testing can help prevent website outages, stress test code changes, and identify bottlenecks. It can also quickly perform functional regression testing. The ability to run the same test repeatedly gives critical insight into the impact of changes to the code and/or systems.
When to Use Goose
Goose is particularly well-suited for:
- Complex User Workflows: Testing multi-step processes like checkout flows, user registration, or content management workflows
- API Load Testing: Validating REST APIs, GraphQL endpoints, or microservice interactions under load
- Performance Regression Testing: Integrating into CI/CD pipelines to catch performance regressions before deployment
- Capacity Planning: Understanding how your infrastructure scales and where bottlenecks occur
- Coordinated Omission Detection: Identifying when server slowdowns affect more users than simple metrics suggest
Goose vs Other Load Testing Tools
Unlike tools that focus purely on HTTP request volume, Goose excels at:
- Stateful Testing: Maintaining sessions, cookies, and authentication across requests
- Realistic Load Patterns: Simulating actual user behavior rather than just hammering endpoints
- Developer-Friendly: Written in Rust with type safety and excellent error handling
- Detailed Analysis: Advanced metrics that reveal hidden performance issues
- Flexibility: Custom logic, data-driven tests, and complex scenarios
Prerequisites
Before diving into Goose, you should have:
- Basic Rust Knowledge: Familiarity with Rust syntax, async/await, and error handling
- HTTP Understanding: Knowledge of HTTP methods, status codes, and web application architecture
- Testing Mindset: Understanding of what you want to test and what constitutes success
If you are new to load testing, start with Creating a Load Test.
Creating A Load Test
Cargo
Cargo is the Rust package manager. To create a new load test, use Cargo to create a new application. You can give it any name; this page uses loadtest:
$ cargo new loadtest
Creating binary (application) `loadtest` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
$ cd loadtest/
This creates a new directory named loadtest/ containing loadtest/Cargo.toml and loadtest/src/main.rs. Edit Cargo.toml and add Goose and Tokio under the [dependencies] heading, so the file reads:
[package]
name = "loadtest"
version = "0.1.0"
edition = "2024"
[dependencies]
goose = "^0.19"
tokio = "^1"
The edition line is the one your version of Cargo wrote; leave it as it is.
You can now compile all dependencies, though the program still only prints “Hello, world!”. The first build downloads and compiles every crate Goose depends on, so it takes a while:
$ cargo run
Updating crates.io index
Locking 253 packages to highest Rust 1.99.0 compatible versions
Adding generic-array v0.14.7 (available: v0.14.9)
Downloading crates ...
Downloaded goose v0.19.0
...
Compiling goose v0.19.0
Compiling loadtest v0.1.0 (/home/you/loadtest)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 27.70s
Running `target/debug/loadtest`
Hello, world!
The package count, the Rust version and the build time depend on your toolchain and your machine. This debug build only checks that everything compiles; Running A Load Test builds with --release, which compiles the dependencies once more.
Creating the load test
To create an actual load test, first add the following line to the top of src/main.rs to make Goose’s functionality available to your code:
use goose::prelude::*;
Note: The prelude adds the following
usestatements for you, so you don’t need to write them out:use goose::config::{GooseDefault, GooseDefaultType}; use goose::goose::{ GooseMethod, GooseRequest, GooseUser, Scenario, Transaction, TransactionError, TransactionFunction, TransactionResult, }; use goose::metrics::{GooseCoordinatedOmissionMitigation, GooseMetrics}; use goose::{scenario, transaction, GooseAttack, GooseError, GooseScheduler};
Then create a transaction function: an async function that makes requests, which Goose runs as a transaction. Goose passes every transaction function a mutable reference to a GooseUser, which makes the web requests and records their metrics. Each GooseUser has its own Reqwest client, and with Goose’s default features that client keeps the user’s cookies from one request to the next (see Cookies). A transaction function usually does not set the host: you give the host when you run the load test, so the same load test runs against different environments without recompiling.
Below the use line, add the following loadtest_index function, which loads the front page:
async fn loadtest_index(user: &mut GooseUser) -> TransactionResult {
let _goose_metrics = user.get("").await?;
Ok(())
}
The function is declared async, so a user waiting for a page does not block a CPU core. It returns a TransactionResult, which is either an empty Ok(()) on success or a boxed TransactionError on failure. Here the GooseUser makes a GET request for the front page, given as the empty path "". The .await lets other users run while the server responds. The trailing ? returns early in two cases: Goose could not build or record the request, for example because the URL is invalid, or the run ended while the request waited on a throttle. If the request fails because the server answers with a 404 or because Goose cannot connect to it, the ? does not return. Goose records a failed request, and the function carries on.
The request returns a GooseResponse, which holds Goose’s record of the request and the server’s response. This function stores it in _goose_metrics, and the leading underscore (_) tells the compiler the variable is unused on purpose. The function then returns Ok(()), so Goose counts the transaction as a success even if its request failed. Validating Requests shows how to fail a transaction.
Replace the main() function with the following, which gives it a return type and registers loadtest_index with Goose:
#[tokio::main]
async fn main() -> Result<(), GooseError> {
GooseAttack::initialize()?
.register_scenario(scenario!("LoadtestTransactions")
.register_transaction(transaction!(loadtest_index))
)
.execute()
.await?;
Ok(())
}
scenario! creates a scenario, the list of transactions each user runs over and over, and register_transaction adds loadtest_index to it.
#[tokio::main] is a Tokio macro. Goose is an asynchronous library, and the macro lets you declare main() as async.
If you’re new to Rust, the return type Result<(), GooseError> says that main returns nothing (()) on success and a GooseError on failure. initialize() and execute() can each fail, and the ? after each one ends the program with its error. .execute() is asynchronous, so .await comes before its ?. The final line, Ok(()), returns the empty result expected on success.
The whole src/main.rs now reads:
use goose::prelude::*;
async fn loadtest_index(user: &mut GooseUser) -> TransactionResult {
let _goose_metrics = user.get("").await?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), GooseError> {
GooseAttack::initialize()?
.register_scenario(scenario!("LoadtestTransactions")
.register_transaction(transaction!(loadtest_index))
)
.execute()
.await?;
Ok(())
}
That is your first load test.
Paths and the host
Goose joins each path to the host the way a browser resolves a link. With a host that has no path, such as http://local.dev/, about/ and /about/ both request http://local.dev/about/. With a host that has a path, use paths that do not start with a / so they stay under it: with --host http://local.dev/site/, about/ requests http://local.dev/site/about/, while /about/ requests http://local.dev/about/. End such a host with a /. Without it, --host http://local.dev/site drops site, and about/ requests http://local.dev/about/.
Naming Transactions
A transaction without a name is listed by its number alone in the transaction metrics. Goose has two methods for naming a transaction, which you call on the transaction!(...) you register. They differ in what they do to the names of the transaction’s requests.
set_name()
Names the transaction only. Each request the transaction makes keeps its own name, which is its path unless the request was given a name.
.register_transaction(transaction!(loadtest_index).set_name("Homepage Load"))
Use when: you want a readable transaction name and still want each request listed by its own path.
set_name_for_transaction_and_requests()
Names the transaction, and every request it makes that has no name of its own. A request named with GooseRequest::builder().name() keeps its own name; every other request in the transaction is listed under the transaction’s name in the request metrics, in place of its path.
Here checkout_flow stands for a transaction function of your own that makes several requests:
.register_transaction(transaction!(checkout_flow).set_name_for_transaction_and_requests("Checkout"))
Use when: a transaction is a multistep flow, such as a login or a checkout, and you want its requests counted together under one name.
Running A Load Test runs the load test; to watch a run live in your browser, turn on the dashboard feature and add --dashboard (see Live Dashboard).
Validating Requests
Error Handling
A transaction reports an error as a TransactionError. A transaction function returns a TransactionResult, which is Result<(), Box<TransactionError>>: the ? operator and .into() box the error for you.
Transaction Error Types
TransactionError has the following variants. Only some of them are counted as transaction errors, as the next sections explain:
TransactionError::Custom: user defined errors, such as a failed business logic checkTransactionError::InvalidMethod: unsupported HTTP methodsTransactionError::LoggerFailed: errors sending log messages to the logger thread;sourceholds theflume::SendError, boxedTransactionError::MetricsFailed: errors sending metrics to the parent threadTransactionError::RequestCanceled: requests canceled when throttling is enabled and the test endsTransactionError::RequestFailed: a request marked failed withGooseUser::set_failure, or a failed request built withGooseRequest::builder().error_on_fail();raw_requestholds the request’sGooseRequestMetric, boxedTransactionError::Reqwest: network and HTTP client errors, such as connection failures and timeoutsTransactionError::Url: URL parsing errors when building requests
Custom Error Handling
The TransactionError::Custom variant lets a transaction function return its own error, from a string literal or a String.
Using String Literals
Return a custom error from a string literal with .into():
#![allow(unused)]
fn main() {
use goose::prelude::*;
async fn validate_response(user: &mut GooseUser) -> TransactionResult {
let response = user.get("/api/data").await?;
let response_data = response.response?;
let text = response_data.text().await?;
if !text.contains("expected_content") {
return Err("Missing expected content in response".into());
}
Ok(())
}
}
response.response is the server’s response, or the Reqwest error Goose got instead, such as a connection failure; its ? returns that error. .text().await? returns an error if the body cannot be read.
Using Formatted Strings
For an error message built at run time, use format!:
#![allow(unused)]
fn main() {
use goose::prelude::*;
async fn business_logic_check(user: &mut GooseUser) -> TransactionResult {
let response = user.post("/login", "").await?;
let response_data = response.response?;
if !response_data.status().is_success() {
return Err(format!("Login failed with status: {}", response_data.status()).into());
}
Ok(())
}
}
Each example starts with its own use line so it stands alone; in your src/main.rs, keep one use goose::prelude::*; at the top. Register each function in main() as in Creating A Load Test, for example with .register_transaction(transaction!(validate_response)), and run the load test with --host.
A transaction that returns any error is counted as failed in the transaction metrics. With --transaction-log, the transaction log records it with "success":false and, unless the request was canceled, the error in its error field.
A TransactionError::Custom or Url error, or a Reqwest error from building the request or from reading or decoding the response body, is counted as a transaction error. With --error-log, Goose writes each one to the error log. The dashboard, the PDF, HTML, Markdown and JSON reports, and the console summary each count them in a transaction errors table, grouped by scenario, transaction and message, most frequent first. For example, take a scenario named ErrUser whose third transaction is registered as transaction!(flaky).set_name("flaky"). If flaky returns Err("every third call fails".into()) on one call in three, the console summary shows:
=== TRANSACTION ERRORS ===
------------------------------------------------------------------------------
Count | Transaction: error
------------------------------------------------------------------------------
21,320 1.3 ErrUser flaky: TransactionError: custom error (every third call fails)
------------------------------------------------------------------------------
When a request fails, Goose counts it once, when it is made: as a request error in the Errors table, and in the error log with --error-log. If the transaction then returns RequestFailed, or a Reqwest error from sending the request, such as a connection failure or too many redirects, Goose does not count it again; only the transaction log names it. A transaction that returns its own error after a failed request is in both tables: business_logic_check against a host that answers 401 puts the POST /login request in the Errors table and its Custom error in the transaction errors table.
A transaction that returns RequestCanceled, because the run ended while its request waited on the throttle, is counted as failed and records no error.
--no-error-summary stops counting errors for the Errors and transaction errors tables: the dashboard shows both tables empty, and the reports and the console summary leave them out. The logs still get every error.
Goose Eggs
Goose Eggs is a library of helpers for writing Goose load tests. Each goose-eggs release works with one Goose release, and goose-eggs 0.7 is the release for Goose 0.19. Add it to the dependencies in your Cargo.toml:
[dependencies]
goose = "^0.19"
goose-eggs = "0.7"
tokio = "^1"
goose-eggs turns on Goose’s default features, which in Goose 0.19 is the cookies feature. To build without cookies, set default-features = false on both goose and goose-eggs.
To validate a page, bring the Validate structure and either the validate_page or the validate_and_load_static_assets function into scope:
use goose_eggs::{validate_and_load_static_assets, Validate};
The following transaction requests /goose/, checks that the response has a 200 status code and that the text Gander is on the page, and, if both checks pass, loads the page’s static assets:
async fn goose_page(user: &mut GooseUser) -> TransactionResult {
let goose = user.get("/goose/").await?;
let validate = Validate::builder()
.status(200)
.text("Gander")
.build();
validate_and_load_static_assets(user, goose, &validate).await?;
Ok(())
}
After its checks pass, validate_and_load_static_assets requests every src="..." link and every href="..." link to a .css file on the page, when the link is a relative path or points at the load test host. The request metrics list them as GET static asset: js for a URL containing .js, GET static asset: img for any other src link, and GET static asset: css for a stylesheet. validate_page runs the same checks without loading assets. When every check passes, both return the page’s HTML as a String.
A page that fails a check is a failed request. It is counted in the request metrics and in the Errors table with the reason, and the transaction returns RequestFailed. If /goose/ answered 200 without the text Gander, the console summary would show:
=== ERRORS ===
------------------------------------------------------------------------------
Count | Error
------------------------------------------------------------------------------
51,013 GET /goose/: http://127.0.0.1:8001/goose/: text not found on page: Gander
------------------------------------------------------------------------------
While the run goes on, Goose also prints an INFO line, set_failure: ..., for each failed check; -q hides them and keeps the tables.
With --debug-log, the debug log records the check that failed with the response’s headers and body.
The Validate builder
Validate::builder() builds a Validate from any of the following checks, and build() finishes it. Text and header checks add up: every one you call must pass. There is one status check, one title check and one redirect check, so a second call to status or not_status, to title or not_title, or to redirect replaces the first:
let validate = Validate::builder()
.status(200)
.title("Goose")
.text("Gander")
.not_text("Error")
.header("content-type")
.header_value("content-type", "text/html")
.redirect(false)
.build();
Use it in place of the validate in goose_page above.
| Method | Passes when |
|---|---|
status, not_status | the response status code is, or is not, the one given |
title, not_title | the page title contains, or does not contain, the text given, ignoring case |
text, not_text | the page contains, or does not contain, the text given |
texts, not_texts | the page contains every text in the list, or none of them |
header, not_header | the response has, or does not have, the header given |
header_value, not_header_value | the header’s value contains, or does not contain, the text given |
redirect | the request was redirected (true) or was not (false) |
Validate::none() checks nothing.
New in goose-eggs 0.7
For validation, goose-eggs 0.7 changes the following from 0.6:
not_header_valuechecks the header’s value. In 0.6 it failed whenever the header was set.validate_and_load_static_assetsno longer loads protocol relative asset URLs on other hosts, such as//cdn.example.com/a.js.header_is_set()andvalid_header_value()take anhttp::HeaderMapin place ofreqwest::header::HeaderMap. They are the same type, so code that compiled with 0.6 still compiles.- The
defaultfeature turns on Goose’s default features, which in Goose 0.19 is thecookiesfeature, and no longer picks a TLS backend. Therustls-tlsfeature turns ongoose/rustls-tlsand nothing else.
goose-eggs 0.7 also adds wordpress and woocommerce modules, with helpers for logging in, searching, commenting, posting through the REST API, and filling a WooCommerce cart and checking out. Its CHANGELOG lists every change.
Read the goose-eggs documentation for its other helpers, including the Drupal ones.
Running A Load Test
Run the load test from Creating A Load Test with Cargo. Pass the --release option whenever you compile or run a load test: a debug build is much slower and can’t generate as much load.
$ cargo run --release
Finished `release` profile [optimized] target(s) in 0.07s
Running `target/release/loadtest`
17:13:37 [INFO] Output verbosity level: INFO
17:13:37 [INFO] Logfile verbosity level: WARN
17:13:37 [INFO] users defaulted to number of CPUs = 18
Error: InvalidOption { option: "--host", value: "", detail: "A host must be defined via the --host option, the GooseAttack.set_default() function, or the Scenario.set_host() function (no host defined for LoadtestTransactions)." }
The load test fails because it has not been told which host to load test.
Run it again, this time passing the --host option with the address of a site you run or are allowed to load test. The -- passes the options after it to the load test rather than to Cargo. Without -u, Goose starts one user for each CPU; without -t, it runs until you stop it. This load test sets no wait time, so each user sends its next request as soon as the last one is answered. Against your own site, start small (see How many users to simulate and Specifying how long the load test will run):
$ cargo run --release -- --host https://your-site.example -u 2 -t 30s --report-file=report.html --no-reset-metrics
--report-file with a .html file extension writes an HTML report, and --no-reset-metrics keeps the ramp up in that report (explained below). Stop a run without -t with one Ctrl-C. The run below is the same command against a test server on port 19060 of this machine, without -u and -t. It ran until Ctrl-C was pressed once, about 8 seconds after entering GooseAttack phase: Maintain:
$ cargo run --release -- --host http://localhost:19060 --report-file=report.html --no-reset-metrics
Finished `release` profile [optimized] target(s) in 0.06s
Running `target/release/loadtest --host 'http://localhost:19060' --report-file=report.html --no-reset-metrics`
17:13:38 [INFO] Output verbosity level: INFO
17:13:38 [INFO] Logfile verbosity level: WARN
17:13:38 [INFO] users defaulted to number of CPUs = 18
17:13:38 [INFO] no_reset_metrics = true
17:13:38 [INFO] report_file = ["report.html"]
17:13:38 [INFO] global host configured: http://localhost:19060
17:13:38 [INFO] [scheduler]: allocating transactions and scenarios with RoundRobin scheduler
17:13:38 [INFO] [scheduler]: initializing 18 user states...
17:13:38 [INFO] [controller]: Telnet listening on: 127.0.0.1:5116
17:13:38 [INFO] entering GooseAttack phase: Increase
17:13:38 [INFO] [user 1]: launching user from LoadtestTransactions
17:13:39 [INFO] [user 2]: launching user from LoadtestTransactions
17:13:40 [INFO] [user 3]: launching user from LoadtestTransactions
...
17:13:54 [INFO] [user 17]: launching user from LoadtestTransactions
17:13:55 [INFO] [user 18]: launching user from LoadtestTransactions
All 18 users launched.
17:13:56 [INFO] entering GooseAttack phase: Maintain
17:14:04 [WARN] caught ctrl-c, stopping...
17:14:04 [INFO] Killswitch triggered: SIGINT received
17:14:05 [INFO] entering GooseAttack phase: Decrease
17:14:05 [INFO] [user 14]: exiting from LoadtestTransactions
17:14:05 [INFO] [user 5]: exiting from LoadtestTransactions
...
17:14:05 [INFO] html report file written to: report.html
17:14:05 [INFO] entering GooseAttack phase: Shutdown
17:14:05 [INFO] printing final metrics after 27 seconds...
All INFO and higher-level log messages are displayed on the console while the load test runs. The entering GooseAttack phase: lines name each phase the load test enters. -q (--quiet) hides the INFO lines and leaves warnings and errors, and -v (--verbose) adds low-level debug messages. In Goose 0.19, -v against a fast server can hang the run at the metrics reset; add --no-reset-metrics with it, and see When a run never ends.
Goose launches one user a second, so this machine’s 18 users took 18 seconds to ramp up; the overview below shows this as the Increasing step. With no wait time, this run’s console metrics, not shown here, report over 9,000 requests a second against the local test server.
By default, once every user has started, Goose prints the metrics so far, resets them, and prints All 18 users launched, resetting metrics (disable with --no-reset-metrics). The results then describe the run at full load. --no-reset-metrics, used here, skips that early print of the metrics and the reset, and Goose prints only All 18 users launched. The tables and graphs in report.html then include the ramp up; the User Metrics graph shows the ramp up with or without the reset.
What the run produced
Once every user has exited, Goose writes report.html in the directory you ran it from; open it in a browser. Then Goose enters Shutdown and prints its final console metrics, which end with the plan overview. The overview lists each step of the run, when it started and stopped, how long it took, and how many users were running:
=== OVERVIEW ===
------------------------------------------------------------------------------
Action Started Stopped Elapsed Users
------------------------------------------------------------------------------
Increasing: 2026-10-28 18:13:38 - 2026-10-28 18:13:56 (00:00:18, 0 -> 18)
Maintaining: 2026-10-28 18:13:56 - 2026-10-28 18:14:05 (00:00:09, 18)
Canceling: 2026-10-28 18:14:05 - 2026-10-28 18:14:05 (00:00:00, 0 <- 18)
Target host: http://localhost:19060/
goose v0.19.0
------------------------------------------------------------------------------
Because Ctrl-C stopped this run, the overview lists its Decrease phase as Canceling; a run that ends at its -t time lists it as Decreasing. The log lines in the run above carry UTC times; the plan overview uses local time, an hour ahead on this machine.
To watch a run live, change the goose line in Cargo.toml to goose = { version = "^0.19", features = ["dashboard"] } and run with --dashboard (see Watching a run). Give --report-file once for each report you want; its extension sets the type (see Writing reports). A .pdf report also needs the pdf-reports feature, as in features = ["dashboard", "pdf-reports"], and Chrome, which Goose downloads if it finds none (see Requirements).
Metrics shows every view of a run and when to use each, from the live dashboard through the PDF, HTML, Markdown and JSON reports to the console metrics. Its HTML report section walks through the report you just opened, and its console metrics section explains the tables printed before the plan overview.
The examples included with Goose show larger load tests.
Run-Time Options
Every Goose load test takes the run-time options below. Three of them need a crate feature: --pdf-scale and --pdf-timeout exist only when Goose is built with pdf-reports, and --dashboard is refused unless Goose is built with dashboard. -h prints a summary of each one, and --help prints the full reference: what each option does, its default, the values it takes, the options it works with or excludes, an example, and the GooseDefault that sets it in code, where they apply. To see the summary for your own load test, run cargo run --release -- -h in its folder, or cargo run --release --example simple -- -h in a checkout of Goose.
The help on this page is from Goose 0.19.0. When you run -h yourself, its first line names the version of Goose your load test was built with, and -V (--version) prints the library’s name and version, such as goose 0.19.0. The output below comes from a build with the pdf-reports feature:
Goose 0.19.0: an HTTP(S) load testing tool written in Rust
Usage: loadtest [OPTIONS]
Use -h for a summary of each option, --help for details, defaults and examples.
Load test:
-H, --host <URL> Target host, such as http://10.21.32.33
-u, --users <N> Concurrent users [default: one per CPU]
-r, --increase-rate <RATE> Users started per second [default: 1]
-s, --increase-time <TIME> Time to start all users, such as 30s
-t, --run-time <TIME> Run this long after all users start
--decrease-rate <RATE> Users stopped per second
--decrease-time <TIME> Time to stop all users, such as 30s
--iterations <N> Each user runs its scenario N times
--test-plan <PLAN> Load shape, such as "10,60s;0,30s"
--scenarios <LIST> Only run the named scenarios
--throttle-requests <N> Cap total requests per second
Information:
-h, --help Print help (--help for more)
-V, --version Print the Goose library version
-l, --list List scenarios and their transactions
--scenarios-list List scenario names, then exit
Console and Goose log:
-v, --verbose... More console output (-v, -vv)
-q, --quiet... Less console output, warnings only
-G, --goose-log [<FILE>] Write the Goose log to FILE
-g, --log-level... More Goose log detail (-g, -gg)
--running-metrics <N> Print running metrics every N seconds
--no-print-metrics Don't print metrics at the end
--no-error-summary Don't collect the error summary
Reports:
--report-file [<FILE>] Write a report; repeatable
--output-dir <DIR> Run folder, or goose-runs/<start time>
--compress [<LEVEL>] Gzip files in the run's folder, 1 to 9
--notes <TEXT> Label the run in its reports
--report-graph-data Add graph data to the JSON report
--render-report <FILE> Write reports from a JSON report, exit
--baseline-file <PATH> Compare against an earlier run
--no-granular-report Graph only totals in reports
--pdf-print-html <FILE> Write print-ready HTML for PDF
--pdf-scale <SCALE> PDF scale, 0.1 to 2.0 [default: 0.8]
--pdf-timeout <SECS> PDF timeout, 10 to 600 s [default: 60]
Metrics:
--no-reset-metrics Keep metrics from the ramp up
--no-metrics Don't collect any metrics
--no-transaction-metrics Don't collect transaction metrics
--no-scenario-metrics Don't collect scenario metrics
--no-status-codes Don't collect status code metrics
--co-mitigation <MODE> Coordinated omission mitigation
--threshold <RULE> Pass/fail rule, such as p95<500ms
--no-threshold-abort Judge thresholds only at the end
--threshold-abort-delay <TIME> Wait before thresholds can stop the run
Logs:
-R, --request-log [<FILE>] Log every request to FILE
--request-format <FMT> Request log format
--request-body Add request bodies to the request log
-T, --transaction-log [<FILE>] Log every transaction to FILE
--transaction-format <FMT> Transaction log format
-S, --scenario-log [<FILE>] Log every scenario to FILE
--scenario-format <FMT> Scenario log format
-E, --error-log [<FILE>] Log every error to FILE
--error-format <FMT> Error log format
-D, --debug-log [<FILE>] Write the debug log to FILE
--debug-format <FMT> Debug log format
--no-debug-body Leave response bodies out of debug log
Dashboard:
--dashboard Serve the live web dashboard
--dashboard-host <HOST> Bind host [default: 127.0.0.1]
--dashboard-port <PORT> Bind port [default: 5118]
--dashboard-auth-token <TOKEN> Auth token
--dashboard-control Allow control from the dashboard
--dashboard-no-auth Dashboard without a token
--dashboard-max-clients <N> Max live clients [default: 32]
--no-percentiles-over-time Don't track per-second response times
Controllers:
--no-autostart Wait for a Controller to start the test
--no-telnet Disable the telnet Controller
--telnet-host <HOST> Telnet host [default: 127.0.0.1]
--telnet-port <PORT> Telnet port [default: 5116]
--websocket Enable the WebSocket Controller
--websocket-host <HOST> WebSocket host [default: 127.0.0.1]
--websocket-port <PORT> WebSocket port [default: 5117]
HTTP client:
--timeout <SECS> Request timeout in seconds [default: 60]
--no-gzip Don't send Accept-Encoding: gzip
--sticky-follow Keep following the host's redirect
--accept-invalid-certs Accept invalid TLS certificates
Examples:
loadtest -H http://localhost:8080 -u 10 -r 2 -t 5m
loadtest -H http://localhost:8080 -u 10 -r 2 -t 5m --report-file report.html
Run with --help for details, defaults and examples.
https://book.goose.rs/getting-started/runtime-options.html
Help wraps to the terminal’s width, up to 120 columns. When the output is not a terminal, it wraps to COLUMNS if that is set, also up to 120, and to 80 columns otherwise. Both outputs on this page are the help at 80 columns.
Where each option is explained
The options a load test needs first are on Common Options. Every other option is explained on the page linked beside it, or here in the table.
| Options | Explained |
|---|---|
-H/--host | Common Options |
-u/--users | Common Options |
-t/--run-time, --iterations | Common Options |
-r/--increase-rate, -s/--increase-time | Common Options |
--decrease-rate, --decrease-time | Common Options |
--test-plan | Test Plan |
--scenarios, --scenarios-list | Limiting Scenarios |
--throttle-requests | Throttle |
-h/--help | -h prints the summary and --help the full reference, both shown on this page. |
-V/--version | Prints the Goose library’s name and version, then exits. |
-l/--list | Prints each scenario by its display name, with its transactions and their weights, then exits without load testing; --scenarios-list prints the names --scenarios takes. |
-v/--verbose, -q/--quiet | Without either, the console shows info, warning and error messages while the load test runs. -v adds debug messages and -vv trace messages; -q leaves out the info messages. |
-G/--goose-log | Writes Goose’s own log of the run to a file, or into the run folder when given no path; Output Files names the file. |
-g/--log-level | Adds info messages to the Goose log that --goose-log writes, debug messages with -gg and trace messages with -ggg. Without -g, that log holds only warnings and errors. |
--running-metrics | Prints the metrics so far to the console every N seconds, and during the ramp up also each time a user starts. The metrics at the end are printed too. With --no-print-metrics, neither is printed. |
--no-print-metrics | Common Options |
--no-error-summary | Leaves the errors and transaction errors tables out of the console metrics and the reports. The error log still records every error. |
--report-file, --pdf-print-html, --pdf-scale, --pdf-timeout | Common Options |
--baseline-file | Common Options |
--output-dir, --notes | Common Options, then Output Files |
--report-graph-data, --render-report | Common Options, then Output Files |
--compress | Output Files |
--no-granular-report | Metrics |
--no-reset-metrics | Keeps the metrics from the ramp up, which Goose otherwise clears when the ramp up ends. A test plan or --iterations run never clears them, and the option is refused with either. |
--no-metrics | Collects no metrics, so Goose writes no report. It cannot be used with --report-file, --pdf-print-html, --threshold, --co-mitigation, or the request, transaction, scenario and error logs. |
--no-transaction-metrics, --no-scenario-metrics | --no-transaction-metrics leaves each transaction’s metrics out of the console metrics and the reports, and the transaction log stays empty. --no-scenario-metrics does the same for scenarios and the scenario log. |
--no-status-codes | Leaves the count of each HTTP status code out of the console metrics and the reports. |
--co-mitigation | Mitigation |
--threshold, --no-threshold-abort, --threshold-abort-delay | Thresholds |
-R/--request-log, --request-format, --request-body | Request Log |
-T/--transaction-log, --transaction-format | Transaction Log |
-S/--scenario-log, --scenario-format | Scenario Log |
-E/--error-log, --error-format | Error Log |
-D/--debug-log, --debug-format, --no-debug-body | Debug Log |
--dashboard | Common Options, then Live Dashboard |
--dashboard-host, --dashboard-port, --dashboard-auth-token, --dashboard-control, --dashboard-no-auth, --dashboard-max-clients, --no-percentiles-over-time | Dashboard Reference |
--no-autostart | Controllers |
--no-telnet, --telnet-host, --telnet-port | Telnet Controller |
--websocket, --websocket-host, --websocket-port | WebSocket Controller |
--timeout | Tips |
--no-gzip | By default requests ask for gzip compressed responses, which Goose decompresses; --no-gzip stops asking. |
--sticky-follow | When a request is redirected to another host, the GooseUser sends its later requests to that host too. |
--accept-invalid-certs | Skips TLS certificate validation, for test servers with self-signed or expired certificates. |
--no-websocket is not in the help: the WebSocket Controller is off unless --websocket turns it on, and a command line from Goose 0.18 that still passes --no-websocket runs with a warning. Upgrading From 0.18 lists every option that was removed or now behaves differently.
Full reference
cargo run --release -- --help prints:
Goose 0.19.0: an HTTP(S) load testing tool written in Rust
Usage: loadtest [OPTIONS]
Use -h for a summary of each option, --help for details, defaults and examples.
Load test:
-H, --host <URL>
Target host, such as http://10.21.32.33
The scheme, host and optional port each request's path is joined to.
It overrides a host set with Scenario::set_host or GooseDefault::Host,
and is required unless one of those gives every scenario a host. A
request with a full URL ignores it.
Example: -H http://localhost:8080. Set in code with
GooseDefault::Host.
-u, --users <N>
Concurrent users [default: one per CPU]
How many users to start. Without it, Goose starts one user for each
CPU available to it. At least 1, and at least 2 with --increase-time
or --decrease-time. Not allowed with --test-plan, which sets its own
user counts.
Example: -u 100. Set in code with GooseDefault::Users.
-r, --increase-rate <RATE>
Users started per second [default: 1]
How fast to start users. A decimal is allowed: 0.5 starts one user
every two seconds. The slowest rate is one user a day. Not allowed
with --increase-time or --test-plan.
Example: -r 10. Set in code with GooseDefault::IncreaseRate.
-s, --increase-time <TIME>
Time to start all users, such as 30s
Starts the users evenly over this time, in place of --increase-rate.
TIME is a whole number of seconds, or hours, minutes and seconds in
that order, such as 1h30m or 2m30s. Needs at least 2 users. Not
allowed with --increase-rate or --test-plan.
Example: -s 2m. Set in code with GooseDefault::IncreaseTime, in
seconds.
-t, --run-time <TIME>
Run this long after all users start
How long to keep every user running once all have started; starting
and stopping them takes extra time. Without it, the load test runs
until it is stopped, or until --iterations completes. TIME is as for
--increase-time. When it ends, users stop as --decrease-rate or
--decrease-time set, otherwise all at once. Not allowed with
--iterations or --test-plan.
Example: -t 20m. Set in code with GooseDefault::RunTime, in seconds.
--decrease-rate <RATE>
Users stopped per second
How fast to stop users at the end of the load test. A decimal is
allowed, as for --increase-rate. Without it or --decrease-time, every
user stops at once. Not allowed with --decrease-time, --iterations or
--test-plan.
Example: --decrease-rate 5. Set in code with
GooseDefault::DecreaseRate.
--decrease-time <TIME>
Time to stop all users, such as 30s
Stops the users evenly over this time at the end of the load test, in
place of --decrease-rate. TIME is as for --increase-time. Needs at
least 2 users. Not allowed with --decrease-rate, --iterations or
--test-plan.
Example: --decrease-time 1m. Set in code with
GooseDefault::DecreaseTime, in seconds.
--iterations <N>
Each user runs its scenario N times
Each user stops after running its scenario N times, and the load test
ends once every user has stopped. 0, the default, means no limit. Not
allowed with --run-time, --decrease-rate, --decrease-time, --test-plan
or --no-reset-metrics.
Example: --iterations 5. Set in code with GooseDefault::Iterations.
--test-plan <PLAN>
Load shape, such as "10,60s;0,30s"
Steps separated by semicolons, each a number of users and the time to
reach it: "10,60s;0,30s" starts 10 users over 60 seconds, then stops
them over 30 seconds. A step that keeps the number of users holds them
for its time. TIME is as for --increase-time. Not allowed with
--users, --increase-rate, --increase-time, --decrease-rate,
--decrease-time, --run-time, --iterations or --no-reset-metrics.
Example: --test-plan "10,1m;10,5m;0,30s". Set in code with
GooseDefault::TestPlan.
--scenarios <LIST>
Only run the named scenarios
A comma separated list of scenario machine names, as --scenarios-list
prints them. Matching ignores case, and * matches any run of
characters. Without it, every scenario runs.
Example: --scenarios "checkout,browse*". Set in code with
GooseDefault::Scenarios.
--throttle-requests <N>
Cap total requests per second
The most requests per second all users together may make. 0, the
default, means no cap.
Example: --throttle-requests 100. Set in code with
GooseDefault::ThrottleRequests.
Information:
-h, --help
Print help: -h prints a summary of each option, --help this full
reference.
-V, --version
Print the Goose library version
Prints the name and version of the Goose library the load test was
built with, then exits.
-l, --list
List scenarios and their transactions
Prints each scenario and its transactions, with their weights, then
exits without load testing.
--scenarios-list
List scenario names, then exit
Prints the machine name of each scenario, which --scenarios matches,
and its full name, then exits without load testing.
Console and Goose log:
-v, --verbose...
More console output (-v, -vv)
The console shows info messages by default. -v adds debug messages and
-vv adds trace messages. Not allowed with --quiet.
Example: -vv. Set in code with GooseDefault::Verbose.
-q, --quiet...
Less console output, warnings only
The console shows only warnings and errors, leaving out info messages;
-qq is the same as -q. Not allowed with --verbose.
Example: -q. Set in code with GooseDefault::Quiet.
-G, --goose-log [<FILE>]
Write the Goose log to FILE
Writes Goose's own log messages to FILE, at the detail --log-level
sets. Without FILE, writes goose.log into the run's folder. Without
the option, there is no Goose log.
Example: -G goose.log. Set in code with GooseDefault::GooseLog.
-g, --log-level...
More Goose log detail (-g, -gg)
The Goose log that --goose-log writes holds warnings and errors by
default. -g adds info messages, -gg debug messages and -ggg trace
messages.
Example: -G goose.log -gg. Set in code with GooseDefault::LogLevel.
--running-metrics <N>
Print running metrics every N seconds
Prints the metrics so far to the console every N seconds while the
load test runs. Without it, metrics are printed only at the end.
Example: --running-metrics 30. Set in code with
GooseDefault::RunningMetrics.
--no-print-metrics
Don't print metrics at the end
Leaves the metrics out of the console when the load test ends. Reports
are still written.
Set in code with GooseDefault::NoPrintMetrics.
--no-error-summary
Don't collect the error summary
Leaves the summary of errors out of the metrics, on the console and in
reports. The error log, --error-log, is still written.
Set in code with GooseDefault::NoErrorSummary.
Reports:
--report-file [<FILE>]
Write a report; repeatable
Writes a report of the load test to FILE when it ends. The extension
picks the type: .html or .htm, .md, .json, or .pdf with the
pdf-reports feature. Without FILE, writes report.json and report.html
into the run's folder. Repeat the option to write several. Not allowed
with --no-metrics.
Example: --report-file report.html --report-file report.json. Set in
code with GooseDefault::ReportFile.
--output-dir <DIR>
Run folder, or goose-runs/<start time>
Sets the run's folder for output flags given without a path. Goose
creates it as the run starts, and removes it at the end of a run that
wrote nothing into it. When the folder already holds files, the run
uses DIR-2, DIR-3 and so on. Needs an output flag given without a
path, or --dashboard, which counts except under --no-metrics.
Example: --output-dir goose-runs/nightly --report-file. Set in code
with GooseDefault::OutputDir.
--compress [<LEVEL>]
Gzip files in the run's folder, 1 to 9
Gzips every file Goose writes into the run's folder, each named with
.gz added. A path given to an output flag keeps exactly that name; a
name that ends in .gz is gzipped without this option. LEVEL is 1
(fastest) to 9 (smallest), default 6, for every gzipped file.
Example: --report-file -R --compress 9. Set in code with
GooseDefault::Compress.
--notes <TEXT>
Label the run in its reports
Stores TEXT in the JSON report and shows it at the top of the HTML and
Markdown reports and in the dashboard. Needs --report-file,
--pdf-print-html or --dashboard.
Example: --notes "baseline before cache change". Set in code with
GooseDefault::Notes.
--report-graph-data
Add graph data to the JSON report
Adds the per second data behind the HTML report's graphs to the JSON
report, so a report rendered from it with --render-report has its
graphs. Needs --report-file or --dashboard.
Set in code with GooseDefault::ReportGraphData.
--render-report <FILE>
Write reports from a JSON report, exit
Writes the reports named by --report-file paths from FILE, a JSON
report, then exits without running a load test.
Example: --render-report report.json --report-file report.html.
--baseline-file <PATH>
Compare against an earlier run
PATH is a JSON report, plain or gzipped, or a folder that holds
report.json or report.json.gz. Every report then shows how each metric
changed from that run.
Example: --baseline-file baseline.json. Set in code with
GooseDefault::BaselineFile.
--no-granular-report
Graph only totals in reports
Report graphs show a line for the total and, by default, one more for
each request, transaction or scenario. This leaves out the extra
lines. Needs --report-file, --pdf-print-html or --dashboard.
Set in code with GooseDefault::NoGranularData.
--pdf-print-html <FILE>
Write print-ready HTML for PDF
Writes the HTML report, laid out for printing, to FILE, to turn into a
PDF in any browser. Needs neither Chrome nor the pdf-reports feature.
Not allowed with --no-metrics.
Example: --pdf-print-html report-print.html.
--pdf-scale <SCALE>
PDF scale, 0.1 to 2.0 [default: 0.8]
How much Chrome scales the pages of a .pdf --report-file. Only with
the pdf-reports feature.
Example: --pdf-scale 1.2. Set in code with GooseDefault::PdfScale.
--pdf-timeout <SECS>
PDF timeout, 10 to 600 s [default: 60]
Seconds to wait for Chrome to start and to render a .pdf
--report-file. Only with the pdf-reports feature.
Example: --pdf-timeout 120. Set in code with GooseDefault::PdfTimeout.
Metrics:
--no-reset-metrics
Keep metrics from the ramp up
By default, Goose resets the metrics once every user has started, so
they cover only the full load. This keeps what was gathered while
users were starting. Not allowed with --test-plan or --iterations.
Set in code with GooseDefault::NoResetMetrics.
--no-metrics
Don't collect any metrics
Turns off every metric, and with them every report. Not allowed with
--report-file, --request-log, --transaction-log, --scenario-log,
--error-log or --co-mitigation.
Set in code with GooseDefault::NoMetrics.
--no-transaction-metrics
Don't collect transaction metrics
Leaves out the metrics for each transaction, keeping those for
requests and scenarios.
Set in code with GooseDefault::NoTransactionMetrics.
--no-scenario-metrics
Don't collect scenario metrics
Leaves out the metrics for each scenario, keeping those for requests
and transactions.
Set in code with GooseDefault::NoScenarioMetrics.
--no-status-codes
Don't collect status code metrics
Leaves out the count of each HTTP status code.
Set in code with GooseDefault::NoStatusCodes.
--co-mitigation <MODE>
Coordinated omission mitigation
Experimental. While the server stalls, users send fewer requests, so
the stall is under counted. Mitigation backfills the requests each
user would have sent at its usual cadence. MODE is average, maximum or
minimum, the cadence to backfill at, or disabled, the default. Not
allowed with --no-metrics.
Example: --co-mitigation average. Set in code with
GooseDefault::CoordinatedOmissionMitigation.
--threshold <RULE>
Pass/fail rule, such as p95<500ms
RULE is <metric><op><value>, with an optional " for <request>". The
metric is pNN with optional decimals (a response time percentile, such
as p95 or p99.9), avg, max, error-rate (failed requests as a percent
of all) or rps (requests per second while the run holds its users
steady, in Maintain). The op is <, <=, > or >=. A time takes us, ms or
s, a bare number being ms; an error-rate takes an optional %. A rule
ending " for <request>", named as the HTML or Markdown report names
it, judges only that request, as in "p99<2s for GET /checkout"; the
console cuts long names short. Without it, the rule judges all
requests together. A rule that can never pass is refused. A failing
rule stops the run, unless --no-threshold-abort is set, and always
fails it: execute() returns GooseError::ThresholdsFailed, so a main
that passes it up with ? exits nonzero. Repeat the option for several
rules; they replace any set in code. Not allowed with --no-metrics.
Example: --threshold "p95<500ms" --threshold "error-rate<1%". Set in
code with GooseDefault::Threshold, once for each rule.
--no-threshold-abort
Judge thresholds only at the end
By default, Goose checks every --threshold once a second from when the
run first enters Maintain, and stops the run when one fails. With this
set, Goose judges them only when the run ends, and a failing rule
still fails the run. An rps rule counts only the time spent holding
users steady, as it does without this.
Set in code with GooseDefault::NoThresholdAbort.
--threshold-abort-delay <TIME>
Wait before thresholds can stop the run
Checking starts when the run first enters Maintain, holding its users
steady, and this waits TIME more before the first check, to let a cold
cache warm up. TIME is as for --run-time; the default is 0. It never
changes the judgement at the end of the run. Needs --threshold, and
not allowed with --no-threshold-abort.
Example: --threshold-abort-delay 30s. Set in code with
GooseDefault::ThresholdAbortDelay, in seconds.
Logs:
-R, --request-log [<FILE>]
Log every request to FILE
Writes a line for each request, in --request-format. Without FILE,
writes requests.json, .csv or .log, as --request-format sets, into the
run's folder. Not allowed with --no-metrics.
Example: -R requests.log. Set in code with GooseDefault::RequestLog.
--request-format <FMT>
Request log format
FMT is json, the default, csv, raw or pretty.
Example: --request-format csv. Set in code with
GooseDefault::RequestFormat.
--request-body
Add request bodies to the request log
Includes the body of each request in --request-log.
Set in code with GooseDefault::RequestBody.
-T, --transaction-log [<FILE>]
Log every transaction to FILE
Writes a line for each transaction, in --transaction-format. Without
FILE, writes transactions.json, .csv or .log, as --transaction-format
sets, into the run's folder. Not allowed with --no-metrics.
Example: -T transactions.log. Set in code with
GooseDefault::TransactionLog.
--transaction-format <FMT>
Transaction log format
FMT is json, the default, csv, raw or pretty.
Example: --transaction-format csv. Set in code with
GooseDefault::TransactionFormat.
-S, --scenario-log [<FILE>]
Log every scenario to FILE
Writes a line for each run of a scenario, in --scenario-format.
Without FILE, writes scenarios.json, .csv or .log, as
--scenario-format sets, into the run's folder. Not allowed with
--no-metrics.
Example: -S scenarios.log. Set in code with GooseDefault::ScenarioLog.
--scenario-format <FMT>
Scenario log format
FMT is json, the default, csv, raw or pretty.
Example: --scenario-format csv. Set in code with
GooseDefault::ScenarioFormat.
-E, --error-log [<FILE>]
Log every error to FILE
Writes a line for each failed request, in --error-format. Without
FILE, writes errors.json, .csv or .log, as --error-format sets, into
the run's folder. Not allowed with --no-metrics.
Example: -E errors.log. Set in code with GooseDefault::ErrorLog.
--error-format <FMT>
Error log format
FMT is json, the default, csv, raw or pretty.
Example: --error-format csv. Set in code with
GooseDefault::ErrorFormat.
-D, --debug-log [<FILE>]
Write the debug log to FILE
Writes what the load test logs with GooseUser::log_debug and
GooseUser::set_failure, with response bodies unless --no-debug-body,
in --debug-format. Without FILE, writes debug.json, .csv or .log, as
--debug-format sets, into the run's folder.
Example: -D debug.log. Set in code with GooseDefault::DebugLog.
--debug-format <FMT>
Debug log format
FMT is json, the default, csv, raw or pretty.
Example: --debug-format pretty. Set in code with
GooseDefault::DebugFormat.
--no-debug-body
Leave response bodies out of debug log
Writes --debug-log without response bodies, which keeps it small.
Set in code with GooseDefault::NoDebugBody.
Dashboard:
--dashboard
Serve the live web dashboard
Serves a page that charts the load test as it runs, on 127.0.0.1 port
5118 unless --dashboard-host or --dashboard-port say otherwise. Needs
Goose built with the dashboard feature.
Set in code with GooseDefault::Dashboard.
--dashboard-host <HOST>
Bind host [default: 127.0.0.1]
The address the dashboard listens on, with --dashboard. An address
that is not a loopback address needs --dashboard-auth-token or
--dashboard-no-auth.
Example: --dashboard-host 0.0.0.0. Set in code with
GooseDefault::DashboardHost.
--dashboard-port <PORT>
Bind port [default: 5118]
The TCP port the dashboard listens on, with --dashboard. 0 picks a
free port, which the log shows.
Example: --dashboard-port 8088. Set in code with
GooseDefault::DashboardPort.
--dashboard-auth-token <TOKEN>
Auth token
The token a dashboard client must send, as an Authorization: Bearer
header or a token query parameter. Needed when --dashboard-host is not
a loopback address, and with --dashboard-control, unless
--dashboard-no-auth is set.
Set in code with GooseDefault::DashboardAuthToken.
--dashboard-control
Allow control from the dashboard
Lets the dashboard start, stop and quit the load test and change its
users. Needs --dashboard, and --dashboard-auth-token or
--dashboard-no-auth.
Set in code with GooseDefault::DashboardControl.
--dashboard-no-auth
Dashboard without a token
Allow dashboard access and control without a token. Anyone who can
reach the port can watch the test, and with --dashboard-control start,
stop and redirect it; on anything but loopback Goose warns at startup.
Needs --dashboard, and can't be used with --dashboard-auth-token.
Set in code with GooseDefault::DashboardNoAuth.
--dashboard-max-clients <N>
Max live clients [default: 32]
The most dashboard pages that may follow the live stream at once.
Example: --dashboard-max-clients 4. Set in code with
GooseDefault::DashboardMaxClients.
--no-percentiles-over-time
Don't track per-second response times
Leaves out the response time percentiles for each second behind the
dashboard's response time heatmap, which saves memory. Also turns off
the report's response time heatmap.
Set in code with GooseDefault::NoPercentilesOverTime.
Controllers:
--no-autostart
Wait for a Controller to start the test
Starts idle and waits for a start command from the telnet Controller,
the WebSocket Controller with --websocket, or the dashboard with
--dashboard-control. Needs one of them enabled.
Set in code with GooseDefault::NoAutoStart.
--no-telnet
Disable the telnet Controller
By default, Goose accepts Controller commands over telnet, on
--telnet-host and --telnet-port.
Set in code with GooseDefault::NoTelnet.
--telnet-host <HOST>
Telnet host [default: 127.0.0.1]
The address the telnet Controller listens on. It has no
authentication: any other address, such as 0.0.0.0, prints a warning
at startup, as anyone who can reach the port can start, stop and
reconfigure the load test.
Example: --telnet-host 0.0.0.0. Set in code with
GooseDefault::TelnetHost.
--telnet-port <PORT>
Telnet port [default: 5116]
The TCP port the telnet Controller listens on.
Example: --telnet-port 6116. Set in code with
GooseDefault::TelnetPort.
--websocket
Enable the WebSocket Controller
Accepts Controller commands over WebSocket, on --websocket-host and
--websocket-port. Off by default: the WebSocket Controller has no
authentication and accepts a connection from any web page, so any
browser that can reach the port can drive the test.
Set in code with GooseDefault::WebSocket.
--websocket-host <HOST>
WebSocket host [default: 127.0.0.1]
The address the WebSocket Controller listens on, with --websocket. It
has no authentication: any other address, such as 0.0.0.0, prints a
warning at startup, as anyone who can reach the port can start, stop
and reconfigure the load test.
Example: --websocket-host 0.0.0.0. Set in code with
GooseDefault::WebSocketHost.
--websocket-port <PORT>
WebSocket port [default: 5117]
The TCP port the WebSocket Controller listens on, with --websocket.
Example: --websocket-port 6117. Set in code with
GooseDefault::WebSocketPort.
HTTP client:
--timeout <SECS>
Request timeout in seconds [default: 60]
How long each request may take before it fails. A decimal is allowed:
0.5 is half a second. Rounded to the millisecond, so at least 0.0005.
Example: --timeout 15. Set in code with GooseDefault::Timeout.
--no-gzip
Don't send Accept-Encoding: gzip
By default, requests ask for gzip compressed responses, which Goose
decompresses.
Set in code with GooseDefault::NoGzip.
--sticky-follow
Keep following the host's redirect
When a request is redirected to another host, the user sends its later
requests to that host too.
Set in code with GooseDefault::StickyFollow.
--accept-invalid-certs
Accept invalid TLS certificates
Skips TLS certificate validation, for test servers with self signed or
expired certificates.
Set in code with GooseDefault::AcceptInvalidCerts.
Examples:
loadtest -H http://localhost:8080 -u 10 -r 2 -t 5m
loadtest -H http://localhost:8080 -u 10 -r 2 -t 5m --report-file report.html
Most options can also be set in code with GooseDefault; the command line wins. A
value that begins with a hyphen goes after =, as in --goose-log=-run.log.
https://book.goose.rs/getting-started/runtime-options.html
The developer documentation describes GooseConfiguration, the structure that holds these options.
Common Run Time Options
These are the options you need in your first week of load testing: where to send the load, how many users, how fast they start, how long to run, how to watch the run, and which reports to keep. Combine them as you need. Run-Time Options lists every option and the page that explains it.
Each example other than the host example also needs --host, unless the load test sets a host itself with GooseDefault::Host or Scenario::set_host. Options that take a time, such as --run-time and --increase-time, take a whole number of seconds, or hours, minutes and seconds in that order, such as 1h30m or 2m30s.
Host to load test
A load test’s requests usually name only a path, such as /about, so Goose must be told which host to send them to. The same load test can then run against http://local.example.com, https://qa.example.com or https://www.example.com.
Host example
Load test the https://www.example.com domain.
cargo run --release -- -H https://www.example.com
How many users to simulate
By default, Goose will launch one user per available CPU core. Often you will want to simulate considerably more users than this, and this can be done by setting the --users run time option.
(Alternatively, you can use --test-plan to build both simple and more complex traffic patterns that can include a varying number of users.)
Users example
Launch 1,000 GooseUsers.
cargo run --release -- -u 1000
At the default rate of one user a second, starting 1,000 users takes over 16 minutes; the next section shows how to start them faster.
Controlling how long it takes Goose to launch all users
Goose starts users at a rate, with --increase-rate, or over a time, with --increase-time, but not both together. (These replace --hatch-rate and --startup-time from Goose 0.18 and earlier, which no longer exist; see Upgrading From 0.18.)
Both are best effort limits: Goose will not start users faster than you configure, but the machine running Goose may not be able to start them that fast.
Specifying the increase rate
By default, Goose starts one GooseUser per second. So if you configure --users to 10 it will take ten seconds to fully start the load test. If you set --increase-rate 5 then Goose will start 5 users every second, taking two seconds to start up. If you set --increase-rate 0.5 then Goose will start 1 user every 2 seconds, taking twenty seconds to start all 10 users. The slowest rate Goose accepts is one user a day, about 0.0000116; a slower rate is an error.
Goose does not round a rate that is not a whole number: --increase-rate 1.5 starts a user every 667 milliseconds, so the increase phase of 9 users lasts 6 seconds. --decrease-rate, --increase-time, --decrease-time and the steps of a test plan are not rounded either.
Increase rate example
Launch one user every two seconds.
cargo run --release -- -r .5
Specifying the total increase time
Alternatively, you can tell Goose how long you would like it to take to start all GooseUsers. So, if you configure --users to 10 and set --increase-time 10 it will launch 1 user every second. If you set --increase-time 1m it will start 1 user every 6 seconds, starting all users over one minute. And if you set --increase-time 2s it will launch five users per second, launching all users in two seconds.
Increase time example
Launch all users in 5 seconds.
cargo run --release -- -s 5
Specifying how long the load test will run
The --run-time option is not affected by how long Goose takes to start up. Thus, if you configure a load test with --users 100 --increase-time 30m --run-time 5m Goose will run for a total of 35 minutes, first ramping up for 30 minutes and then running at full load for 5 minutes. If you want Goose to exit immediately after all users start, you can set a very small run time, for example --users 100 --increase-rate .25 --run-time 1s.
Instead of a run time, --iterations sets how many times each GooseUser runs through its scenario before it stops, and a --test-plan sets how long each of its steps lasts.
Goose resets its metrics when the ramp up ends. Its reports then leave the ramp up out of the totals and out of every graph but the active users graph. --no-reset-metrics keeps the ramp up in. A run limited by --iterations or shaped by a test plan never resets, and refuses --no-reset-metrics.
If you do not configure a run time, Goose runs until it is canceled with ctrl-c.
Run time example
Run the load test for 30 minutes.
cargo run --release -- -t 30m
Iterations example
Each GooseUser will take as long as it takes to fully run its assigned Scenario 5 times and then stop.
cargo run --release -- --iterations 5
Watching a run
With the dashboard feature turned on in your load test’s Cargo.toml, --dashboard serves a live web dashboard while the load test runs. Open http://127.0.0.1:5118/ in a browser to see the users, requests per second, response times and errors as they change.
A read-only dashboard 2 minutes 14 seconds into a run of 50 users with --no-reset-metrics: the users chart climbs by one user a second to 50 and holds, and the requests chart climbs with it.
By default the dashboard only watches: it cannot start, stop or change the test, and its Control card says how to turn control on. With Save report on, as it is by default, Goose writes the run’s report.html and report.json into its run folder when the run ends. --dashboard-control lets the dashboard start and stop the test, change its users and settings, and, once the test is stopped, quit Goose. It also needs a token, set with --dashboard-auth-token <TOKEN> and given by opening http://127.0.0.1:5118/?token=<TOKEN>, or --dashboard-no-auth, which lets anyone who can reach the port control the test. Live Dashboard tours every part of the page, including the truncation notice.
Dashboard example
Turn on the dashboard feature, then watch a load test in the browser.
goose = { version = "^0.19", features = ["dashboard"] }
cargo run --release -- --dashboard
Writing reports
--report-file <FILE> writes a report when the load test finishes. The file’s extension sets its type: .pdf for a PDF report, when Goose is built with the pdf-reports feature; .html or .htm for HTML; .md for Markdown; and .json for JSON. Give --report-file once for each report you want. A --report-file given without a path writes report.html and report.json into the run folder. A file that already exists at the path is overwritten. Report Formats shows what each report holds. The HTML report is the easiest to start with.
PDF report example
Build with the pdf-reports feature and write a PDF report, waiting up to two minutes for Chrome.
cargo run --release --features goose/pdf-reports -- --report-file report.pdf --pdf-timeout 120
A PDF report needs Chrome or Chromium; see Requirements. --pdf-timeout is how many seconds Goose waits for Chrome to start and to render the PDF, from 10 to 600, default 60. --pdf-scale, from 0.1 to 2.0, default 0.8, sets how much Chrome scales the pages. Both exist only with the pdf-reports feature.
HTML report example
Write an HTML report to report.html when the load test finishes.
cargo run --release -- --report-file report.html
The HTML report’s requests per second graph, from a run of 50 users with --no-reset-metrics: the total climbs through the shaded ramp up and levels off once every user is running.
Printer-friendly HTML report example
Write an HTML report laid out for printing to PDF.
cargo run --release -- --pdf-print-html print-report.html
--pdf-print-html writes the HTML report laid out for printing, to turn into a PDF with your browser’s “Print to PDF” or to print on paper. It needs neither Chrome nor the pdf-reports feature, has the same graphs and response time heatmap as the HTML report, and cannot be used with --no-metrics. It can also be written beside a --report-file:
cargo run --release -- --report-file report.html --pdf-print-html print-report.html
HTML and Markdown report example
Write an HTML and a Markdown report when the load test finishes.
cargo run --release -- --report-file report.html --report-file report.md
JSON report example
Write a JSON report, which a later run can compare against with --baseline-file.
cargo run --release -- --report-file report.json
Console output
Goose prints ASCII metrics to the console when a load test finishes, whether or not it writes a report. --no-print-metrics leaves them out, and every requested report is still written.
Run folders
An output flag given without a path, such as a bare --report-file or --goose-log, writes into the run folder: goose-runs/<YYYYMMDD-HHMMSS> from the run’s local start time, unless --output-dir names one. A run watched with --dashboard saves its report there too. --notes labels the run in its reports and on the dashboard. Output Files gives the rules for every file Goose writes.
cargo run --release -- --report-file --notes "after the cache change"
When running Goose in Docker, mount a folder for the runs:
docker run -v "$PWD/goose-runs:/goose-runs" my-load-test --host https://example.com --output-dir /goose-runs/nightly --report-file
In CI, upload goose-runs/ as a build artifact to keep every run’s reports.
Comparing Against A Baseline
To see what changed between two runs, such as before and after a deployment, give the later run an earlier run’s JSON report with --baseline-file. A run folder that holds a report.json works too, such as the one a --dashboard run or a bare --report-file writes:
cargo run --release -- --report-file --baseline-file goose-runs/20261028-120502
The numeric metrics in the PDF, HTML and Markdown reports then include a delta showing the difference from the baseline. For example, instead of just showing 1,234 requests, the HTML report shows 1,234 (+200) if the current run had 200 more requests than the baseline, or 1,234 (-50) if it had 50 fewer; the Markdown report shows its request counts without thousands separators, 1234 (+200). The JSON report holds only the current run’s values, so it can be the next run’s baseline. The console metrics show no deltas.
Deltas are calculated for:
- Requests: number of requests, failures, average/min/max response time, requests/failures per second
- Response time percentiles: p50, p60, p70, p80, p90, p95, p99, p100
- Transactions: request count, failures, response times, rates
- Scenarios: user count, iteration count, response times, rates
- Errors: occurrence count
- Transaction errors: occurrence count
- Coordinated Omission: adjusted response times and standard deviation
Metrics are matched between runs by their key (HTTP method + endpoint name for requests, transaction + name for transactions, etc.). A request in the current run but not in the baseline is shown without a delta, and one only in the baseline is not shown.
The baseline must be a JSON report generated by Goose, plain or gzipped (.json.gz), or a run folder, whose report.json or report.json.gz is then used. If the file does not exist, contains invalid JSON, or has no request metrics, Goose will report an error. A load test can also set a default baseline in code with GooseDefault::BaselineFile, which --baseline-file overrides.
Baseline comparison example
Run a load test comparing against a previous run, generating both HTML and JSON reports.
cargo run --release -- --report-file report.html --report-file latest.json --baseline-file baseline.json
The resulting latest.json can then be used as the baseline for the next comparison, enabling a chain of run-over-run comparisons.
Baselines from older Goose releases
A JSON report from Goose 0.18.1 or later loads as a baseline, including one from a run that received more than one status code. A baseline without transaction errors, such as one from Goose 0.18.1, still loads, and the run’s transaction errors then show no delta.
A time’s delta is the current time minus the baseline time, each as the report prints it. A 0.19 baseline’s request, transaction and scenario times come from its microsecond timing data. A report from an older Goose holds whole milliseconds: its minimums and maximums are truncated, and its percentiles above 100 ms are rounded to 10 ms, 100 ms or whole seconds. So an unchanged time can read differently against it, such as 38 (+1).
Controlling the ramp-down rate
You can configure how quickly Goose stops users at the end of a load test with --decrease-rate or --decrease-time. These are mutually exclusive, just like their increase counterparts, and --decrease-rate has the same slowest rate of one user a day. If neither is set, Goose stops all users at once.
Decrease rate example
Stop one user every two seconds.
cargo run --release -- --decrease-rate .5
Decrease time example
Spread the stops over 30 seconds.
cargo run --release -- --decrease-time 30s
Goose divides the time by the number of users and stops one user each interval, the first at once, so with 4 users it stops one every 7.5 seconds and the last 22.5 seconds in.
Graph data and memory
Goose keeps per second graph data in memory until the run ends whenever it will write a report then: for a --report-file path, for --pdf-print-html, and while Save report is on, as it is with --dashboard or a bare --report-file. It holds every second of the run, the ramp up included. After the metrics reset, Goose keeps zeros in memory for the ramp up seconds of the request, error, response time, transaction and scenario data, and the report leaves those seconds out of its graphs. When Save report is turned on mid run, the data starts up to 10 minutes before that moment. The graph data costs about 12 bytes per request name per second, and 4 bytes more per second for each name that has failed. Goose holds it in buffers that grow by doubling, so it can take up to twice that.
Writing an HTML report costs far more while it is written: about 300 bytes more per request name for each second of the run. --pdf-print-html builds the same HTML and costs the same. A PDF report costs about three times as much in Goose, about 950 bytes per request name for each second of the run. Chrome needs memory of its own to print it, from about 400 MB for a small report to about 750 MB for a 20 minute run with 100 request names. A Markdown or JSON report adds almost nothing. With 20 and 100 request names and none failing, counting the doubling:
| run length | 20 names, graph data | 20 names, writing HTML | 100 names, graph data | 100 names, writing HTML |
|---|---|---|---|---|
| 10 minutes | 0.3 MB | 4 MB | 1.3 MB | 20 MB |
| 1 hour | 1.1 MB | 24 MB | 5 MB | 114 MB |
| 24 hours | 35 MB | 570 MB | 160 MB | 2.8 GB |
| 7 days | 280 MB | 4 GB | 1.3 GB | 20 GB |
The per second costs were measured on 10 to 35 minute runs of Goose 0.19, and every row is computed from them. The table leaves out the response time heatmap, which stops growing once its columns fill, after about 10 minutes. At 100 request names it takes about 5 MB when response times are fast and even, 10 MB when they are typical, and up to about 40 MB when they spread widely, or more with a --timeout over 60 seconds; --no-percentiles-over-time turns it off.
To keep the HTML cost out of a long soak test, write a JSON report with its graph data, --report-file report.json --report-graph-data, and turn it into an HTML report after the run with --render-report report.json --report-file report.html. The render needs more memory than writing the HTML during the run would, about 1.3 times as much for a 10 minute run and more for shorter ones, but it no longer competes with the load test, and it can run later or on another machine. Output Files gives the rules.
Without a report, as on a --dashboard run with Save report off, Goose keeps only the last 10 minutes of per second graph data in memory; the dashboard’s charts show the last 5 minutes of it. On a long soak test with --dashboard-control, no --report-file path and no --pdf-print-html, turn Save report off to keep memory flat, and turn it on before the part of the run you want graphed.
Other options
When you need more than this page, these come next:
- Test Plan: a load shape of steps, such as a ramp up, a hold and a spike.
- Throttle: a cap on requests per second.
- Thresholds: pass and fail rules on the metrics.
- Limiting Scenarios: running only some of a load test’s scenarios.
- Logging: the request, transaction, scenario, error and debug logs.
Test Plan
A load test that ramps up to full strength and then runs for a set amount of time can be configured by combining the --increase-time or --increase-rate options together with the --users and --run-time options. To also configure a ramp-down, use --decrease-time or --decrease-rate. For more complex load patterns you must instead use the --test-plan option.
A test plan is defined as a series of numerical pairs that each defines a number of users, and the amount of time to ramp to this number of users. For example, 10,60s means “launch 10 users over 60 seconds”. By stringing together multiple pairs separated by a semicolon you can define more complex test plans. For example, 10,1m;10,5m;0,0s means “launch 10 users over 1 minute, continue with 10 users for 5 minutes, then shut down the load test as quickly as possible”.
The amount of time can be defined in seconds (e.g. 10,5s), minutes (e.g. 10,15m) or hours (e.g. 10,1h), or a mix of them in that order (e.g. 10,1h30m). A bare number is seconds (e.g. 10,90), and a step with no time (e.g. 0,) takes 0 seconds. The units take either case, and nothing else is allowed, so 10,15mins is refused. White space around each number is ignored.
A test plan sets every user count and time itself, so --test-plan is not allowed with --users, --increase-rate, --increase-time, --decrease-rate, --decrease-time, --run-time or --iterations. A test plan never resets metrics, so --no-reset-metrics is refused with it too. Goose refuses a test plan before any user starts when a step does not parse, when the plan never starts a user (such as 0,10s), when its users add up to more than fit in a usize, or when a time is too long to count in milliseconds. This sample runs the simple example from a Goose checkout:
% cargo run --release --example simple -- --test-plan "0,10s"
ERROR: invalid `configuration.test_plan` value: '0,10s': `configuration.test_plan` must start at least one user, and no more than fit in a usize.
error: invalid value '0,10s' for '--test-plan <PLAN>': GooseError: invalid option or value specified
For more information, try '--help'.
A test plan can also be set in code with GooseDefault::TestPlan, which refuses the same plans. Thresholds are checked from the first step that holds its users steady, then in every later step except a ramp down.
Running A Test Plan From The Dashboard Or A Controller
The live dashboard and both Controllers show a running test plan and can set one: the dashboard through its control API, the Controllers with a command.
The live dashboard
With --dashboard, the Active users chart draws each step of the plan as it runs. This run used --test-plan "20,20s;20,40s;60,20s;60,40s;20,20s;20,60s;0,0s":
Each step with users shows as a ramp or a hold: to 20 over 20 seconds, 20 for 40, to 60 over 20, 60 for 40, down to 20 over 20, then 20 for 60, the step still running when the image was taken at 190 seconds; the plan’s last step, 0,0s, would stop every user at 200 seconds.
With control off, the header names the plan: test plan: 7 steps, peak 60 users, 3 min 20 s total.
With control on, the Control card’s summary names the host, then the plan, and the Target row is read only while the plan runs, with the reason The test plan sets users; edit the plan, or stop and switch mode. To switch mode, stop the run and set a user count: that replaces the plan with a users ramp. The settings form has no test plan field. To set a plan, send it as test_plan to POST /api/v1/control/settings, or to POST /api/v1/control/start to set it and start in one step. If Goose was started with -H http://localhost:18888 --no-autostart --dashboard --dashboard-control --dashboard-no-auth, this sets a plan and starts it:
curl -X POST -H 'Content-Type: application/json' \
-d '{"test_plan":"10,10s;10,20s;0,0s"}' \
http://127.0.0.1:5118/api/v1/control/start
{"ok":true,"command":"start","message":"load test started","phase":"increase","active_users":0,"target_users":10,"settings":{"host":"http://localhost:18888","hosts":["http://localhost:18888/"],"users":null,"increase_rate":null,"increase_time_secs":null,"decrease_rate":null,"decrease_time_secs":null,"run_time_secs":null,"test_plan":"10,10;10,20;0,0","hold_ends_in_secs":null,"stage":"test_plan"}}
The reply names the plan in canonical form, each step’s time in whole seconds. Run settings gives the rules for a plan sent to a running test.
Telnet and WebSocket
The test-plan command sets a plan, and start runs it. Here Goose runs with -H http://localhost:18888 --no-autostart, -H naming the target, which a start needs when the load test sets no default host. users is refused while the plan runs:
% telnet localhost 5116
goose> test-plan 10,10s;10,20s;0,0s
test-plan configured
goose> start
load test started
goose> users 20
failed to reconfigure users, the test plan sets users: edit the plan, or stop and switch mode
goose> stop
load test stopped
goose> shutdown
load test shut down
The WebSocket Controller takes the same command as JSON:
{"request":"test-plan 10,10s;10,20s;0,0s"}
{"response":"test-plan configured","success":true}
config and config-json show the plan Goose is running, each step’s time in milliseconds: on telnet config in Rust’s debug form, TestPlan { steps: [(10, 10000), (10, 20000), (0, 0)], current: 0 } over several lines, and config-json, and both commands on WebSocket, as "test_plan":{"steps":[[10,10000],[10,20000],[0,0]],"current":0}. A plan sent to a running test replaces the one running, starting from its first step at the current user count. The Controller commands give the bounds a plan must keep.
Simple Example
The following command tells Goose to start 10 users over 60 seconds and then to run for 5 minutes before shutting down:
$ cargo run --release -- -H http://local.dev/ --increase-time 1m --users 10 --run-time 5m --no-reset-metrics
The exact same behaviour can be defined with the following test plan:
$ cargo run --release -- -H http://local.dev/ --test-plan "10,1m;10,5m;0,0s"
Ramp Down Example
Unless --decrease-rate or --decrease-time is set, Goose stops a load test as quickly as it can when the specified --run-time completes. A test plan can also ramp down slowly. In the following example, Goose starts 1000 users in 2 minutes and then slowly stops them over 500 seconds (stopping 2 users per second):
$ cargo run --release -- -H http://local.dev/ --test-plan "1000,2m;0,500s"
Load Spike Example
Another possibility when specifying a test plan is to add load spikes into otherwise steady load. For example, in the following example Goose starts 500 users over 5 minutes and lets it run with a couple of traffic spikes to 2,500 users:
$ cargo run --release -- -H http://local.dev/ --test-plan "500,5m;500,5m;2500,45s;500,45s;500,5m;2500,45s;500,45s;500,5m;0,0s"
This graph comes from the simple example run with that test plan against a local server. Each step took the time it names: the first step started a user every 600 milliseconds and reached 500 users after 5 minutes (see Specifying the increase rate).
Internals
Internally, Goose converts the test plan into a vector of usize tuples, Vec<(usize, usize)>, where the first integer reflects the number of users to be running and the second integer reflects the time in milliseconds. You can see the internal representation when you start a load test, for example as follows. With --no-autostart the load test waits to be started, so press ctrl-c once the line is printed:
% cargo run --release --example simple -- --no-autostart --test-plan "100,30s;100,1h" | grep test_plan
21:00:10 [INFO] test_plan = TestPlan { steps: [(100, 30000), (100, 3600000)], current: 0 }
Throttling Requests
By default, Goose will generate as much load as it can. If this is not desirable, the throttle allows optionally limiting the maximum number of requests per second made during a load test. This can be helpful to ensure consistency when running a load test from multiple different servers with different available resources.
The throttle is specified as an integer and imposes a maximum number of requests, not a minimum number of requests. 0, the default, means no throttle. Set it in code with GooseDefault::ThrottleRequests.
An rps threshold that the throttle keeps the run from reaching is refused before the load test starts. --throttle-requests 0 turns the throttle off, so it refuses none.
Example
In this example, Goose will launch 100 GooseUser threads, but the throttle will prevent them from generating a combined total of more than 5 requests per second.
$ cargo run --release -- -H http://local.dev/ -u100 -r20 --throttle-requests 5
Thresholds
A threshold is a pass/fail rule on a load test’s metrics, such as “the 95th percentile response time stays under 500 ms” or “under 1% of requests fail”. Give one with --threshold, as often as needed. A rule that fails stops the run by default, and always makes the run fail: GooseAttack::execute() returns an error once every report is written (see Exit code in CI). Every report says which rules passed and which failed.
cargo run --release -- -H http://local.dev/ -u 50 -r 10 -t 10m \
--threshold "p95<500ms" \
--threshold "error-rate<1%" \
--threshold "p99<2s for GET /checkout"
Quote each rule: < and > redirect in a shell.
You can set as many thresholds as you like. Goose checks them all once a second while the run goes, stops the run as soon as one fails, and checks them all again at the end; see Stopping the run. Each threshold covers the whole run, not just part of it. Thresholds are meant for a simple load test, one that ramps up, holds a single user count, and ramps down. To check several user counts, run a separate load test for each one. For how rps is counted, see rps counts only the time users hold steady.
Syntax
A rule is <metric><op><value>, optionally followed by for <request>.
| Metric | Measures | Value |
|---|---|---|
pNN | A response time percentile, with optional decimals, such as p50, p95 or p99.9, greater than 0 and less than 100 | A time: a number with us, ms or s; a bare number is milliseconds |
avg | The average response time | A time |
max | The slowest response time | A time |
error-rate | Failed requests as a percent of all requests | A number with an optional %, at most 100 |
rps | Requests per second while the run holds its users steady | A number |
The operator is <, <=, > or >=. A rule passes when the comparison holds for what Goose observed: p95<500ms passes at 499 ms and fails at 500 ms, while p95<=500ms passes at 500 ms too.
Without for <request>, a rule judges all requests together, as the Aggregated row of the report’s tables does. With it, the rule judges only the request with that name: its full name as the HTML or Markdown report shows it, method and name together, such as GET /login, where the name is the path or the request’s custom name, as in POST login. The console’s tables cut long names short, so copy a name from a report. When the run ends, Goose warns about a rule for a request the run never made, suggesting the name it most likely means:
threshold p95<1s for get /: no request named "get /"; did you mean "GET /"?
Some rules:
p95<500ms: 95% of all responses take less than 500 ms.p99.9<=2s: 99.9% of all responses take at most 2 seconds.avg<150: the average response is under 150 ms.error-rate<1%: fewer than 1% of requests fail.rps>=200: the run makes at least 200 requests a second while it holds its users steady.max<5s for GET /checkout: noGET /checkouttakes 5 seconds or more.
A rule that does not parse is refused before any user starts, with a GooseError::InvalidOption whose detail says what is wrong with it:
threshold "p100<1s": the percentile must be greater than 0 and less than 100, such as p95 or p99.9.
So is a rule that can never pass: any rule with <0, such as p95<0 or error-rate<0, as well as rps<=0 and error-rate>100 (with or without %). With --throttle-requests N, which keeps the run at N requests a second or fewer, rps>M is refused when M is N or more, and rps>=M when M is more than N:
threshold "rps>=200": it can never pass; `configuration.throttle_requests` caps the run at 100 requests a second.
--throttle-requests 0 means no throttle, so it refuses no rps rule.
Thresholds judge metrics, so --threshold is not allowed with --no-metrics.
A percentile comes from the same histogram buckets as every report’s percentiles, so it can be up to 0.78% away from the exact time, while the average and the slowest time are exact.
Stopping the run
By default Goose checks every rule once a second while the load test runs, and the first rule that fails stops the run through the killswitch, with a reason that names the rule and what was observed. In this run of the simple example, a local test server, not part of Goose, answered each request in 1.5 seconds until 12 seconds after the first request it received, then failed every request:
cargo run --release --example simple -- -H http://127.0.0.1:19862 -u 50 -r 50 -t 60s \
--threshold "p95<2s" --threshold "error-rate<1%" --no-telnet
Once 1% or more of requests had failed, the next check stopped the run, 12 seconds after the run entered Maintain:
19:44:39 [INFO] Killswitch triggered: threshold error-rate<1% failed: observed 2.54%
A threshold failure is a killswitch stop, and works as any other killswitch does. The run then fails, as below.
With --no-threshold-abort, Goose never stops the run for a threshold. It runs its full time, and judges every rule only at the end.
When checking starts and stops
Checking starts when the run first enters Maintain, the first time it holds its users steady, in every mode. A ramp up therefore never fails a rule while the run goes. A users ramp resets its metrics as it enters Maintain. Under --test-plan, --iterations or --no-reset-metrics they are not reset, so the ramp up’s requests still count in the first check, except in rps, which counts only the hold.
Once checking starts, every later Increase or Maintain step of a test plan is checked too. Checking pauses while the run stops users, in Decrease, and ends at Shutdown. The judgement at the end still counts every request since the metrics reset, or since the start of the run when there is none, including those made in Decrease. rps is the exception, below.
A test plan that never holds its users steady, such as 10,1m;0,1m, never enters Maintain, so its rules are judged only at the end, and its rps rules can not pass. Goose warns about this at startup, unless --no-threshold-abort is set and no rule is an rps rule.
A rule whose metric has no samples yet, such as a rule for a request that has not run, is skipped while the run goes. The first check can judge only a few requests, so one early error can fail error-rate. The first seconds of the first hold can also fail an rps>= rule that the whole hold meets. --threshold-abort-delay gives the run time to make more requests before the first check.
rps counts only the time users hold steady
An rps rule counts only the time the run spends holding its users steady, in Maintain. This is true in every mode, both in the judgement at the end and in any checks while the run goes. It counts the requests made while the run holds its users, over the seconds it holds them, added up over every hold of a test plan. Holds at different user counts therefore give one rate, and an rps>= rule judges that one rate. An rps rule’s row gives the rate followed by from Maintain. A ramp up, a ramp down, and an Increase step between two holds add nothing, so none of them pulls the rate down. The report’s Aggregated requests per second can differ: it covers every second since the metrics reset. A run under --test-plan, --iterations or --no-reset-metrics has no reset, so there the ramp up’s lower rate pulls that figure down, and an rps>= rule judged on it would fail although the steady load meets it.
A test plan step that stays at 0 users after a ramp down and before a later ramp up is a hold too: it adds seconds and no requests, so it pulls the rate down. In 10,10s;10,20s;0,5s;0,20s;10,10s;10,20s;0,5s, that step is 0,20s. Leave such a step out of a test plan with an rps rule: without 0,20s, the ramp down leads straight into the next ramp up, and the rate covers only the two holds at 10 users.
--iterations is refused at startup with --decrease-rate or --decrease-time, as a ramp down would stop users before they finish their iterations: the error reads `configuration.decrease_rate` can not be set with `configuration.iterations`., or the same for decrease_time. With --iterations, the run enters Maintain when the ramp up ends and stays there until the last user finishes its iterations. A run whose users all finish before the ramp up ends never enters Maintain. Once the first user finishes, the rest of the hold has fewer users making requests. That time still counts, so it pulls the rate down. A run with --run-time in place of --iterations keeps every user making requests until the hold ends, so use --run-time with an rps rule.
Until the run has held its users for a whole second there is no rate to judge. A run that never enters Maintain has no time to count, so its rps rules fail at the end as no samples.
--threshold-abort-delay
--threshold-abort-delay holds the first check back by a time after the run first enters Maintain, given as for --run-time: 30s, 2m, or a bare number of seconds. It never changes the judgement at the end of the run.
Use it when the first seconds of a run are slow for a reason you accept, such as a cold cache. With --threshold-abort-delay 1m, a warm up spike in the first minute after the run enters Maintain can not stop the run, but it still counts in the final percentiles, so a rule it breaks in the final metrics still fails at the end:
cargo run --release -- -H http://local.dev/ -u 50 -r 50 -t 10m \
--threshold "p95<500ms" --threshold-abort-delay 1m
It needs a --threshold, and is not allowed with --no-threshold-abort, which never stops the run.
The end of the run
When the run ends, Goose judges every rule once more on the final metrics. Here a rule whose metric has no samples fails, observed as no samples: a rule for a request that never ran says nothing about that request, so it can not pass.
The rule that stopped the run always fails. Requests that finish after it stopped, and any the check had not counted yet, are counted afterwards, so by the end its value can be higher or lower than when it stopped the run. Its row shows the value at the stop and at the end, such as 2.54% at abort, 6.45% at end in the run under Stopping the run, or one value when the two are the same.
When every rule passes, execute() returns Ok(GooseMetrics) as usual. When any fails, it returns GooseError::ThresholdsFailed once every report is written. The error holds the rules that failed and the run’s metrics, as a ThresholdMetrics that reads as the GooseMetrics it holds:
match goose_attack.execute().await {
Ok(metrics) => println!("all {} thresholds passed", metrics.thresholds.len()),
Err(GooseError::ThresholdsFailed { failed, metrics }) => {
for result in &failed {
eprintln!("{} failed: observed {}", result.rule, result.observed);
}
eprintln!("the run lasted {} seconds", metrics.duration);
std::process::exit(1);
}
Err(e) => return Err(e),
}
GooseMetrics::thresholds holds every rule’s ThresholdResult in either case: its rule, what was observed, as text and as a number, whether it passed, and whether it stopped the run.
Exit code in CI
A load test whose main returns Result<(), GooseError> and uses ? on execute(), as every example does, exits with code 1 when a threshold fails, so a CI step fails with it. This step runs the simple example from a Goose checkout; in your own load test crate, use cargo run --release -- instead:
- name: Load test
run: |
cargo run --release --example simple -- \
--host http://staging.example.com --users 20 --increase-rate 5 --run-time 5m \
--threshold "p95<500ms" --threshold "error-rate<1%" \
--report-file report.html
The console output of the run under Stopping the run ends with the rule that failed, then the error main returns, before Goose exits with code 1:
19:44:40 [ERROR] threshold error-rate<1% failed: observed 2.54% at abort, 6.45% at end
Error: ThresholdsFailed { failed: [ThresholdResult { rule: "error-rate<1%", observed: "2.54% at abort, 6.45% at end", value: Some(6.451612903225806), passed: false, aborted: true }], metrics: ThresholdMetrics { .. } }
Reports
The PDF, HTML and Markdown reports have a Thresholds section with a row for each rule: the rule, what was observed, and the result. A failing row says FAIL, and FAIL, stopped the run for the rule that stopped it, so the result never rests on color alone. The JSON report keeps each result as an object in raw_metrics.thresholds, with rule, observed, value, passed and aborted, so a report rendered from it with --render-report shows the same section. Without thresholds there is no section. The console’s final tables have the same section; the run under Stopping the run printed:
=== THRESHOLDS ===
------------------------------------------------------------------------------
Rule | Observed | Result
------------------------------------------------------------------------------
p95<2s | 1499ms | PASS
error-rate<1% | 2.54% at abort, 6.45% at end | FAIL, stopped the run
------------------------------------------------------------------------------
Setting thresholds in code
Set rules in code with GooseDefault::Threshold. Each call adds one rule, so code can set several. A rule that does not parse is refused by set_default itself:
use goose::prelude::*;
#[tokio::main]
async fn main() -> Result<(), GooseError> {
GooseAttack::initialize()?
.set_default(GooseDefault::Threshold, "p95<500ms")?
.set_default(GooseDefault::Threshold, "error-rate<1%")?
// Judge them only at the end of the run.
.set_default(GooseDefault::NoThresholdAbort, true)?;
Ok(())
}
Rules given with --threshold replace every rule set in code; they are not added to them. GooseDefault::ThresholdAbortDelay sets the delay, in seconds.
Overhead
Thresholds do no work per request: Goose judges each rule against the metrics it already keeps, once a second while it checks (see When checking starts and stops) and once at the end, or only at the end under --no-threshold-abort.
Killswitch
Goose provides a killswitch mechanism to programmatically stop a load test when specific conditions are met. This is useful for protecting your systems and ensuring tests stop automatically when problems are detected.
To stop a run when a metric crosses a limit, such as a percentile response time or the error rate, use a threshold instead of writing the check.
When the killswitch is triggered, Goose logs Killswitch triggered: with the reason and stops the run as for Ctrl-C: users stop, test_stop runs, every report is written, and the run is recorded as canceled with the reason given. Under --no-autostart too, a killswitch stop then shuts Goose down rather than returning it to Idle, though a connected dashboard can keep it up until Quit, as at the end of an autostarted run. Only the first reason is kept: later calls change nothing until Goose clears the killswitch as the next run starts.
A killswitch fired by your own code, as below, is not an error in itself: execute() returns Ok with the metrics of the run so far, unless a threshold fails when the run ends.
Common Use Cases
- Health Check Integration: Monitor system health endpoints and stop on failure
- Resource Exhaustion Detection: Stop when detecting connection pool or memory issues
- Sitemap Traversal Completion: Stop after fully crawling a site’s pages
- Data Set Processing: Stop when all test data has been consumed
- External Signal Integration: Stop based on monitoring system alerts
Example: Service Unavailable Detection
#![allow(unused)]
fn main() {
use goose::prelude::*;
async fn check_availability(user: &mut GooseUser) -> TransactionResult {
let goose = user.get("/api/endpoint").await?;
// Stop the test if server returns 503 Service Unavailable
if let Ok(response) = goose.response {
if response.status() == 503 {
goose::trigger_killswitch("Server returned 503: Service Unavailable");
}
}
Ok(())
}
}
You can also check if the killswitch has been triggered using goose::is_killswitch_triggered() to conditionally execute cleanup code or skip certain operations.
goose::reset_killswitch() clears it. Goose calls it itself as each run starts. A test suite that runs several load tests in one process can call it after a test that triggered the killswitch, so the tests after it are not canceled.
Limiting Which Scenarios Run
It can often be useful to run only a subset of the Scenarios defined by a load test. Instead of commenting them out in the source code and recompiling, the --scenarios run-time option allows you to dynamically control which Scenarios are running.
Listing Scenarios By Machine Name
To ensure that each scenario has a unique name, you must use the machine name of the scenario when filtering which are running. For example, using the Umami example enable the --scenarios-list flag:
% cargo run --release --example umami -- --scenarios-list
Finished `release` profile [optimized] target(s) in 0.22s
Running `target/release/examples/umami --scenarios-list`
21:02:23 [INFO] Output verbosity level: INFO
21:02:23 [INFO] Logfile verbosity level: WARN
21:02:23 [INFO] users defaulted to number of CPUs = 18
Scenarios:
- adminuser: ("Admin user")
- anonymousenglishuser: ("Anonymous English user")
- anonymousspanishuser: ("Anonymous Spanish user")
What Is A Machine Name: A Scenario can have any name, and several Scenarios can share one. A machine name ensures that you can still identify each Scenario uniquely, and without any special characters that can be difficult or insecure to pass through the command line. A machine name is made up of only the ASCII letters and digits found in your Scenario’s full name, in lower case, and optionally with
_and a number appended to differentiate between multiple Scenarios that would otherwise have the same name.In the following example, we have three very similarly named Scenarios. One simply has an extra white space between words. The second has an airplane emoticon in the name. Both the extra space and the airplane symbol are stripped away from the machine name as they are not alphanumerics, and instead
_1and_2are appended to the end to differentiate:Scenarios: - loadtesttransactions: ("LoadtestTransactions") - loadtesttransactions_1: ("Loadtest Transactions") - loadtesttransactions_2: ("LoadtestTransactions ✈️")
Running Scenarios By Machine Name
To run a subset of the above scenarios, pass a comma separated list of machine names with the --scenarios run time option. Goose uses exact matching by default: admin does not run adminuser. To match multiple scenarios with a single pattern, use the * wildcard character which matches zero or more characters. Matching ignores case and the white space around each name, so AdminUser runs adminuser. Set the list in code with GooseDefault::Scenarios.
For example, to run only the two anonymous Scenarios, you could add --scenarios "anon*":
% cargo run --release --example umami -- --increase-rate 10 --scenarios "anon*"
Finished `release` profile [optimized] target(s) in 0.11s
Running `target/release/examples/umami --increase-rate 10 --scenarios 'anon*'`
21:02:35 [INFO] Output verbosity level: INFO
21:02:35 [INFO] Logfile verbosity level: WARN
21:02:35 [INFO] users defaulted to number of CPUs = 18
21:02:35 [INFO] increase_rate = 10.0
21:02:35 [INFO] scenarios = Scenarios { active: ["anon*"] }
Or, to run only the “Anonymous Spanish user” and “Admin user” Scenarios using their exact machine names, you could add --scenarios "anonymousspanishuser,adminuser":
% cargo run --release --example umami -- --increase-rate 10 --scenarios "anonymousspanishuser,adminuser"
21:02:26 [INFO] scenarios = Scenarios { active: ["anonymousspanishuser", "adminuser"] }
You can also mix exact and wildcard matching: --scenarios "adminuser,anon*" would run the exact adminuser scenario plus all scenarios starting with anon.
A name may hold only letters, digits, _ and *. Anything else is refused before the load test starts, with the reason:
% cargo run --release --example umami -- --scenarios "admin-user"
error: invalid value 'admin-user' for '--scenarios <LIST>': 'admin-user' is not a scenario name: use letters, digits and _, with * as a wildcard; --scenarios-list prints the names.
For more information, try '--help'.
A machine name with a _ suffix, such as loadtesttransactions_1 above, selects that one scenario. For example, a load test that registers two scenarios both named Visitor lists them as visitor and visitor_1, and --scenarios visitor_1 runs only the second:
% cargo run --release -- --host http://127.0.0.1:8080 -u2 -r2 -t2s --scenarios visitor_1
...
=== PER SCENARIO METRICS ===
------------------------------------------------------------------------------
Name | # users | # times run | scenarios/s | iterations
------------------------------------------------------------------------------
1: Visitor | 0 | 0 | 0.00 | 0.00
2: Visitor | 2 | 115156 | 57578 | 57578
-------------------------+----------+--------------+-------------+------------
Aggregated | 2 | 115156 | 57578 | 57578
The final summary still lists the first Visitor scenario, with no users.
A name that matches no scenario logs a warning, as it is most likely mistyped, and is otherwise ignored as long as another name in the list matches one. When nothing in the list matches any scenario, Goose prints the scenarios and stops with an error:
% cargo run --release --example umami -- --scenarios "nosuch"
19:55:15 [INFO] scenarios = Scenarios { active: ["nosuch"] }
19:55:15 [WARN] --scenarios "nosuch" matches no scenario; --scenarios-list prints the names
Scenarios:
- adminuser: ("Admin user")
- anonymousenglishuser: ("Anonymous English user")
- anonymousspanishuser: ("Anonymous Spanish user")
Error: NoScenarios { detail: "No scenarios are enabled." }
When the load test completes, you can refer to the Scenario metrics to confirm which Scenarios were enabled, and which were not.
Custom Run Time Options
A load test can add run-time options of its own. GooseAttack::initialize() parses the command line itself, with clap, and exits with an error when it receives an option it does not know. There are two alternatives here: environment variables, or parsing the command line yourself and handing Goose its part.
Environment Variables
One option is to use environment variables. An example of this can be found in the Umami example which uses environment variables to allow the configuration of a custom username and password.
Alternatively, you can use this method to set configurable custom defaults. The earlier example can be enhanced to use an environment variable to set a custom default hostname:
use goose::prelude::*;
async fn loadtest_index(user: &mut GooseUser) -> TransactionResult {
let _goose_metrics = user.get("").await?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), GooseError> {
// Get optional custom default hostname from `HOST` environment variable.
let custom_host = match std::env::var("HOST") {
Ok(host) => host,
Err(_) => "".to_string(),
};
GooseAttack::initialize()?
.register_scenario(scenario!("LoadtestTransactions")
.register_transaction(transaction!(loadtest_index))
)
// Set optional custom default hostname.
.set_default(GooseDefault::Host, custom_host.as_str())?
.execute()
.await?;
Ok(())
}
This can now be used to set a custom default for the scenario, in this example with no --host set Goose will execute a load test against the hostname defined in HOST:
% HOST="https://local.dev/" cargo run --release
Finished `release` profile [optimized] target(s) in 0.07s
Running `target/release/loadtest`
21:04:16 [INFO] Output verbosity level: INFO
21:04:16 [INFO] Logfile verbosity level: WARN
21:04:16 [INFO] users defaulted to number of CPUs = 18
21:04:16 [INFO] host for LoadtestTransactions configured: https://local.dev/
It’s still possible to override this custom default from the command line with standard Goose options, for example here the load test will run against the hostname configured by the --host option:
% HOST="http://local.dev/" cargo run --release -- --host https://example.com/
Finished `release` profile [optimized] target(s) in 0.07s
Running `target/release/loadtest --host 'https://example.com/'`
21:04:19 [INFO] Output verbosity level: INFO
21:04:19 [INFO] Logfile verbosity level: WARN
21:04:19 [INFO] users defaulted to number of CPUs = 18
21:04:19 [INFO] global host configured: https://example.com/
If the HOST variable and the --host option are not set, Goose will display the expected error:
% cargo run --release
Running `target/release/loadtest`
21:04:11 [INFO] Output verbosity level: INFO
21:04:11 [INFO] Logfile verbosity level: WARN
21:04:11 [INFO] users defaulted to number of CPUs = 18
Error: InvalidOption { option: "--host", value: "", detail: "A host must be defined via the --host option, the GooseAttack.set_default() function, or the Scenario.set_host() function (no host defined for LoadtestTransactions)." }
Command Line Arguments
To add command line arguments of your own, parse the command line yourself and give Goose its options with GooseAttack::initialize_with_config in place of GooseAttack::initialize(). initialize_with_config does not read the command line; it takes a GooseConfiguration.
Adding options to Goose’s
GooseConfiguration derives clap::Args, so a load test can flatten it into a clap::Parser of its own and add its options beside Goose’s. Every Goose option keeps working, -h and --help list both, and an option neither knows is still an error. The command line is parsed with your parser’s settings, not Goose’s, so an option given twice, such as --host a --host b, is refused where Goose keeps the last, and a negative number such as --users -1 gets no tip. Add clap to your Cargo.toml with its derive feature, such as clap = { version = "4.5", features = ["derive"] }:
use clap::Parser;
use goose::config::GooseConfiguration;
use goose::prelude::*;
/// Every Goose option, plus the load test's own.
#[derive(Parser)]
struct Options {
#[command(flatten)]
goose: GooseConfiguration,
/// The ID of the product the load test buys.
#[arg(long, default_value_t = 1)]
product_id: u32,
}
async fn loadtest_index(user: &mut GooseUser) -> TransactionResult {
let _goose_metrics = user.get("").await?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), GooseError> {
let options = Options::parse();
println!("product_id = {}", options.product_id);
GooseAttack::initialize_with_config(options.goose)?
.register_scenario(
scenario!("User").register_transaction(transaction!(loadtest_index)),
)
.execute()
.await?;
Ok(())
}
The program above can be invoked with a command such as:
cargo run -- --host http://localhost:8080 --product-id 42
Your options must not reuse the names or short letters Goose’s take; --help lists them.
Building the configuration yourself
You can also build a GooseConfiguration without reading the command line: GooseConfiguration::default() leaves every option unset, and GooseConfiguration::parse_args_default(&["--host", "http://localhost:8080"]) parses a list of Goose options, leaving every option you do not pass unset, and returns a Result. Given -h or --help, it prints nothing and returns GooseError::InvalidOption, with the flag as option and the detail help was requested. GooseConfiguration also implements clap::Parser, so GooseConfiguration::try_parse_from(["loadtest", "--host", "http://localhost:8080"]) parses the same options, with the program name first. Each option that takes a value is an Option, None when it is not set, so a value is set with Some. Goose then applies any GooseDefault and its own defaults to every option left unset, as it does for the command line.
Options you do not set this way are not read from the command line. In particular, a host is required, from the configuration, GooseDefault::Host or Scenario::set_host:
use goose::config::GooseConfiguration;
use goose::prelude::*;
async fn loadtest_index(user: &mut GooseUser) -> TransactionResult {
let _goose_metrics = user.get("").await?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), GooseError> {
let mut config = GooseConfiguration::default();
config.host = Some("http://localhost:8080".to_string());
config.users = Some(10);
GooseAttack::initialize_with_config(config)?
.register_scenario(
scenario!("User").register_transaction(transaction!(loadtest_index)),
)
.execute()
.await?;
Ok(())
}
Before 0.19, Goose parsed its command line with gumdrop, and a load test that called parse_args_default imported use gumdrop::Options;. Remove that import: parse_args_default and parse_args_default_or_exit are methods of GooseConfiguration itself now.
Output files
Goose writes reports and logs only when you ask for them. This page gives the rules for every file Goose writes, then three recipes. A relative path, and the goose-runs folder, are relative to the working directory, so the commands below, run with cargo run in a Goose checkout, write into the checkout.
Files
1. A run writes nothing unless a flag or the dashboard asks for it.
cargo run --release --example simple -- --host http://localhost:8080 -t 30s
This run prints its metrics and writes no file and no folder.
2. An output flag given a path writes exactly that file and nothing more.
cargo run --release --example simple -- --host http://localhost:8080 -t 30s --report-file results.html
This writes results.html, as Goose 0.18 did.
3. An output flag given without a path writes into the run’s folder. --report-file writes report.html and report.json. --goose-log, --request-log, --transaction-log, --scenario-log, --error-log and --debug-log write goose.log, requests, transactions, scenarios, errors and debug, each with its format’s extension (.json, .csv, or .log for raw and pretty), plus .gz under rule 15. An output flag followed by another option, as --report-file -R below, is given without a path. A path that begins with a hyphen goes after =, as in --goose-log=-run.log: given as its own argument it is read as options, so --goose-log -run.log is read as --goose-log -r un.log and refused because un.log is not a number. When the command line itself does not parse, as with --goose-log -x.log, the error ends with a tip to use =.
cargo run --release --example simple -- --host http://localhost:8080 -t 30s --report-file -R --request-format csv
This writes report.html, report.json and requests.csv into the run’s folder.
4. The run’s folder is --output-dir DIR, or goose-runs/<YYYYMMDD-HHMMSS> from the run’s local start time. Goose creates it as the run starts and prints its path. If the run writes nothing into it, Goose removes it when the run ends. Goose never writes a run into a folder that already exists and is not empty when the run starts. It uses DIR-2, DIR-3, DIR-4 and so on instead, always numbered from the original name (never DIR-2-2). It takes the first number whose folder is missing or empty, and prints the name it chose. Two processes that start at the same moment still get a folder each. The numbering also gives each run under --no-autostart, and each run started from the dashboard, its own folder.
While the run lasts, the folder holds a .goose-run file, which reserves it so no other run takes it. A bare --goose-log keeps the file there until the next run starts or the load test ends. A process that is killed leaves the file behind; remove it by hand to free the folder. Before the first run starts, a bare --goose-log keeps its lines in memory, up to 1 MiB, and writes them into the first run’s folder. Lines past 1 MiB are dropped, and if no run starts, none are written.
cargo run --release --example simple -- --host http://localhost:8080 -t 30s --output-dir goose-runs/nightly --report-file
The first run writes into goose-runs/nightly, the next into goose-runs/nightly-2. --output-dir needs an output flag given without a path, or --dashboard without --no-metrics; otherwise it is an error, as the folder would hold nothing. A folder Goose can’t write to stops the run before any load, with an error naming the folder. The one exception is in rule 9.
What a report holds
5. A report’s tables always count every request since the metrics reset, or from the start of the run when there is none. Its graphs, all but User Metrics, also start at the metrics reset, and cover the seconds Goose still kept when the report was written; when Save report’s window cut them shorter, the report says when its graphs start and why.
While Save report is off and no --report-file path or --pdf-print-html is given, Goose keeps only the last 10 minutes of graph data and 5 minutes of heatmap (rule 10). A report written later, after Save report is turned on or by Save report now, still has every request in its tables, and says when its graphs start and why.
6. --notes "text" is shown in the dashboard and at the top of the PDF, HTML and Markdown reports, and stored in the JSON report. It needs --report-file, --pdf-print-html or --dashboard.
cargo run --release --example simple -- --host http://localhost:8080 -t 30s --report-file --notes "baseline before cache change"
7. --report-graph-data adds the per second graph data to the JSON report. Off by default, and it needs --report-file or --dashboard. With it, a report rendered from the JSON has its graphs; without it, the rendered report says the graphs were not recorded.
cargo run --release --example simple -- --host http://localhost:8080 -t 30s --report-file results.json --report-graph-data
8. --render-report FILE.json writes the reports named by --report-file paths from an existing JSON report, then exits without running a load test. It refuses --notes and --report-graph-data, as the rendered report keeps the JSON report’s own.
cargo run --release --example simple -- --render-report results.json --report-file results.html --report-file results.md
Formats
A report’s type comes from its extension. --report-file writes PDF for .pdf, HTML for .html or .htm, Markdown for .md, and JSON for .json; PDF needs the load test built with the pdf-reports feature. Any other extension is refused before the load test starts, as in Unknown report file type: docx. Reports are written even with --no-print-metrics. Metrics shows what each format holds.
cargo run --release --example simple -- --host http://localhost:8080 -t 30s \
--report-file results.html --report-file results.md --report-file results.json
The JSON report also holds the run’s test plan history, so a Markdown report rendered from it with --render-report has its plan overview.
--pdf-print-html FILE writes the HTML report laid out for printing, to turn into a PDF in any browser, without Chrome or the pdf-reports feature. It has the HTML report’s graphs and heatmap, with or without a --report-file, counts as a report for --notes and --no-granular-report, and is refused with --no-metrics. --pdf-scale (0.1 to 2.0, default 0.8) and --pdf-timeout (10 to 600 seconds, default 60) apply to a .pdf report. See PDF reports.
--baseline-file PATH compares the run with an earlier one in every report. PATH is a JSON report, plain or gzipped, or a run’s folder that holds report.json or report.json.gz. See Comparing against a baseline.
Each log has a format option: --request-format, --transaction-format, --scenario-format, --error-format and --debug-format each take json, the default, csv, raw or pretty. A log given without a path is named with .json, .csv, or .log for raw and pretty (rule 3). Logging describes each log.
The dashboard
The live dashboard describes the report bar and the Runs panel; Control endpoints lists the report bar’s routes, and Report endpoints lists the Runs panel’s.
9. The dashboard’s report bar, at the top of the page, shows a Save report switch: whether this run’s report.html and report.json are written to its folder. With --dashboard it starts on whether or not --report-file paths are given, except under --no-metrics. When they are given, turn it off to write only those paths. For a run without the dashboard, a bare --report-file does what the switch does. The bar also lists any --report-file paths from the command line, which are always written.
When only --dashboard asks for the run’s folder, with no output flag given without a path and no --output-dir, a folder Goose can’t write to does not stop the run. Goose prints a warning naming the folder and the reason before any load, and runs with Save report off. A banner at the top of the dashboard says why, and stays for the whole run.
cargo run --release --features dashboard --example simple -- --host http://localhost:8080 --dashboard
10. Save report can be turned on or off at any time. While it is on, Goose keeps all of the run’s graph data and heatmap since the metrics reset. While it is off and no --report-file path or --pdf-print-html is given, Goose keeps only the last 10 minutes of graph data and the last 5 minutes of heatmap. Either way, the dashboard’s charts show only the last 5 minutes. Turning it on during a run keeps graph data and heatmap from that point on, plus up to 10 minutes of graph data and 5 minutes of heatmap before it, and rule 5 labels the report.
On a long soak test with --dashboard-control, no --report-file path and no --pdf-print-html, turn Save report off to keep memory flat, and turn it on before the part of the run you want graphed. Without --dashboard-control, Save report stays on, and memory grows slowly. In a 60 minute run, the process’s resident memory grew by 544 KiB, from 35,264 KiB at 5 minutes to 35,808 KiB at 60. The run was examples/simple with 300 users against a local test server that answered / after 10 ms and every other request at once: each user logged in once during the ramp up, then the users made about 30 requests a second over 2 request names. Its command:
cargo run --release --example simple --features dashboard,pdf-reports -- -H http://127.0.0.1:19870 \
-u 300 -r 50 -t 60m --dashboard --dashboard-port 19871 --no-telnet
11. Save report now writes the run’s report as it stands into its folder while the run continues, and marks it as in progress. It needs a run in progress. The Runs panel lists the report once it is written. If it can’t be written, the Runs panel says why. The report at the end of the run replaces it, even when Save report is off. A second switch in the bar, Include graph data, is rule 7’s flag and can also be changed at any time.
12. The Runs panel lists the folders beside the run’s folder that hold a report.json or report.json.gz. It looks in the folder that holds the run’s folder: the parent of the --output-dir folder, or goose-runs by default. Rows are newest first and show each run’s notes. A report still marked in progress shows as running for the run in progress and as incomplete for any other (a run that ended without its final report). Each row offers HTML, Markdown and JSON, rendering any format the folder lacks from its JSON. A report stored gzipped downloads as plain HTML, Markdown or JSON. With control, each row also has a Delete button. It deletes the folder’s reports and the logs Goose names there, then the folder if that leaves it empty. The folder of the run in progress can’t be deleted. Nor can the folder a bare --goose-log is open in: Goose keeps that log open in the last run’s folder until the next run starts. Nor can a folder another Goose process is writing to, which holds .goose-run (rule 4).
With --output-dir goose-runs/nightly, the panel lists the folders in goose-runs. It lists only folders whose name is a letter or digit followed by any mix of letters, digits, ., _ and -, so a run written to goose-runs/my run is not listed. For such a folder, the banner at the end of the run says where the report was saved and offers no downloads.
13. Save report now, Delete, and changing either switch need --dashboard-control and the control token, or --dashboard-no-auth (see control without a token), like Start and Stop. Without control the switches are shown read only. Save report then stays on, except under --no-metrics or with a folder Goose can’t write to (rule 9): every --dashboard run writes report.html and report.json into its folder, and Goose keeps all of the run’s graph data (rule 10).
TOKEN=$(openssl rand -hex 16)
cargo run --release --features dashboard --example simple -- --host http://localhost:8080 \
--dashboard --dashboard-control --dashboard-auth-token "$TOKEN"
Compression
14. Any report or log whose name ends in .gz is written gzipped. A report’s format comes from the name before .gz. A log keeps the format its --request-format or other format option sets. Goose reads a .json.gz report anywhere it reads a JSON report: --baseline-file, --render-report and the Runs panel. --pdf-print-html writes plain HTML whatever its name.
cargo run --release --example simple -- --host http://localhost:8080 -t 30s --report-file results.json.gz -R requests.csv.gz --request-format csv
15. --compress gzips every file Goose writes into the run’s folder under rules 3 and 9, each named with .gz added. A path given to an output flag keeps exactly that name, and is gzipped only when that name ends in .gz (rule 14). --compress LEVEL sets the level from 1 (fastest) to 9 (smallest), default 6, for every gzipped file, including rule 14’s.
cargo run --release --example simple -- --host http://localhost:8080 -t 30s --report-file -R --compress 9
This writes report.html.gz, report.json.gz and requests.json.gz into the run’s folder.
Recipes
One report file
cargo run --release --example simple -- --host http://localhost:8080 -t 5m --report-file results.html
Goose writes results.html and nothing else.
See a run made without the dashboard in the dashboard
Write the run’s report into a folder under goose-runs:
cargo run --release --example simple -- --host http://localhost:8080 -t 5m \
--output-dir goose-runs/nightly --report-file
Later, start the dashboard from the same working directory:
cargo run --release --features dashboard --example simple -- --host http://localhost:8080 --dashboard --no-autostart
Its Runs panel lists nightly. With --no-autostart, Goose runs no new load test until a Controller starts one, because without --dashboard-control the dashboard has no Start button.
Keep files small
Add --compress to gzip every file in the run’s folder, or name a file with .gz:
cargo run --release --example simple -- --host http://localhost:8080 -t 10m \
--report-file --report-file summary.html -R --compress
summary.html stays plain: a path keeps its name. To read the gzipped files:
gunzip -k goose-runs/*/report.html.gz # then open report.html in a browser
gzip -dc goose-runs/*/report.json.gz | jq .
gzip -dc goose-runs/*/requests.json.gz | head
The dashboard’s Runs panel also opens a gzipped report. A gzipped log is whole once its run ends. A gzipped Goose log is also whole between runs, so gzip -dc reads it while the dashboard waits for the next run. During a run, and after a process killed with SIGKILL, gzip -dc reads a gzipped log up to the last part written, then reports an unexpected end of file. To follow a run as it goes, use a plain log.
Metrics
Goose can show a load test’s results six ways. The dashboard and the PDF need a Cargo feature as well as an option; the HTML, Markdown and JSON reports take one option each; the console metrics, like the log lines, need no flag.
Choosing a view
| View | What it holds | Who it is for | How to get it |
|---|---|---|---|
| Live dashboard | Live charts and tables | You, while the run is going | The dashboard feature, then --dashboard, and open http://127.0.0.1:5118/ |
| PDF report | The HTML report’s sections in one tall, fixed page; its line graphs can stop short in this release | Someone who reads a finished run on screen or as an attachment | The pdf-reports feature and Chrome, which Goose downloads if it finds none, then --report-file report.pdf; for paper, or without the feature or Chrome, --pdf-print-html print.html and a browser’s Print, which breaks the report into pages |
| HTML report | The tables, graphs to zoom and filter, and the response time heatmap; the graphs load from a CDN when the report is opened | An engineer studying the run | --report-file report.html |
| Markdown report | Tables only | An issue, a pull request or a wiki page | --report-file report.md |
| JSON report | Every table’s numbers, for programs | A script, a CI job, or a later run’s --baseline-file | --report-file report.json |
| Console metrics | Text tables | A quick look in the terminal, or a CI log | Printed at the end of every run, unless --no-print-metrics or --no-metrics |
A feature is turned on for the goose dependency in your load test’s Cargo.toml, as in goose = { version = "^0.19", features = ["dashboard", "pdf-reports"] }; the command below runs an example in the Goose repository, so it uses --features. A report’s type comes from the extension of its --report-file, so repeat the option for each report you want. If you keep the JSON report, --render-report can write any of the other reports from it later, without running the load test, as in --render-report report.json --report-file report.pdf (see What a report holds). Without a JSON report, another report means running the load test again. So add --report-file report.json --report-graph-data to any run you may want to report on later: a report rendered from a JSON report has graphs only when the run had --report-graph-data or the dashboard’s Include graph data switch on. Where reports go when no path is given is in Output Files.
Every image and excerpt on this page comes from one run of the Umami example against a Drupal 11 site installed with the Umami demo profile, reached at http://localhost:18888. The site was changed as the example needs: search and contact enabled, a Website feedback contact form added, an alias added for one renamed article, image styles generated ahead of the run, and CSS and JS aggregation off, so each CSS and JS file is a static asset request of its own:
cargo run --release --features dashboard,pdf-reports --example umami -- \
-H http://localhost:18888 --dashboard -u 50 -r 5 -t 3m \
--report-file report.pdf --report-file report.html \
--report-file report.md --report-file report.json
The run behind the images also moved the dashboard and telnet ports, with --dashboard-port 5128 --telnet-port 5126, which changes nothing shown here. No request failed. Goose launched 5 users a second up to 50, reset its metrics once all 50 were running, then ran for 3 minutes. The tables, and every graph but User Metrics, count from that reset.
The live dashboard
The dashboard shows the run as it happens: users, requests and failures per second, the response time heatmap, and tables of scenarios, transactions, requests and errors, updated every second.
The chart reads zero for the seconds the users were launching, because Goose clears it at the metrics reset; the summary under the chart gives the mean of the last 10 seconds, 502 requests a second.
With --dashboard the dashboard’s Save report switch starts on, so the run also writes its own report.html and report.json into the run’s folder, named for the run’s local start time, such as goose-runs/20261028-175916/ (see Output Files). The --report-file reports go to the paths given, relative to where you ran the command. With its report saved and a dashboard tab open when the run ends on its own, Goose stays up so you can download its HTML, Markdown and JSON reports from the Runs panel; it exits on Ctrl-C, a Controller shutdown, or the dashboard’s Quit button, which needs --dashboard-control. A threshold, the killswitch or Ctrl-C that stops the run ends Goose without waiting for the dashboard (see When the run ends). Live Dashboard tours every panel, and the Dashboard Reference covers each in detail.
PDF report
The PDF report is the HTML report with print styling, rendered by Chrome into one tall page. It has the same sections as the HTML report, controls included, though they do nothing in a PDF. Chrome loads the graphs from the HTML report’s CDN when it writes the PDF. In this release the line graphs, such as requests per second, can stop partway along their time axis, because Chrome prints them before they finish drawing; send the HTML report when the graphs matter.
The PDF is laid out as the HTML report is, in one tall page: the header, then the plan overview, then each section below them.
Name a .pdf file with --report-file report.pdf. --pdf-scale and --pdf-timeout tune how Chrome renders it; see the PDF report example. Without Chrome, --pdf-print-html writes the report laid out for printing from any browser; see the printer-friendly HTML report example.
Chrome
Requirements gives the order in which Goose looks for a browser; a Chrome for Testing headless shell it downloaded earlier is used before a new download. When the CHROME environment variable is set and not empty, Goose uses that binary and never downloads one. It must name an executable file: if it names a macOS app bundle such as /Applications/Google Chrome.app, Goose stops with an error that names the binary inside it, Contents/MacOS/Google Chrome.
Before the load test starts, Goose finds the browser, downloading it if needed, and renders a small test page, which must finish within --pdf-timeout. If that fails, the load test does not start, so a Chrome problem never costs a finished run. The log names the browser it will use:
16:59:19 [INFO] PDF reports will use Chrome 156.0.8078.48 at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome (installed); checked in 3.0s
A downloaded browser is kept in goose/chrome under the user cache directory: ~/.cache (or $XDG_CACHE_HOME) on Linux, ~/Library/Caches on macOS, and %LOCALAPPDATA% on Windows. Once a week Goose checks for a newer stable Chrome for Testing; when there is one, it downloads it and removes the older ones, and when the check fails it logs a warning and uses the one it has. Goose never updates or removes an installed browser or the one CHROME names. Goose built for musl, such as on Alpine, never downloads Chrome, because Chrome for Testing needs glibc: install Chromium (apk add chromium) or set CHROME.
Troubleshooting PDF reports
- Without a browser, Goose needs network access to download one, and
unziponPATH(taron Windows). If a firewall blocks the download, orunzipis missing, install Chrome or Chromium or setCHROME. - On Linux, a downloaded headless shell needs the shared libraries Chrome uses, such as
libnss3. Installing Chromium from your distribution brings them in. - When Chrome won’t start, the error names the binary and how Goose found it. On Linux it also says when the binary’s ELF interpreter is missing, usually a glibc binary on a musl system, and lists the shared libraries
lddcan’t find. An “Exec format error” means the binary was built for another architecture. - If the PDF fails at the end of a run, the other reports are still written, and the empty PDF file is removed.
HTML report
--report-file report.html writes the HTML report, one file of about 730 KB for this run. Its graphs use the ECharts library, loaded from a CDN when the report is opened, so they appear only where the CDN can be reached. Click a name in a graph’s legend to hide or show its line, and drag the handles under a graph to zoom.
Header and plan overview. The report opens with any --notes, then the number of users, the target host and the Goose version, then the plan overview: each step of the run, when it started and stopped, how long it took, and how many users were running. The PDF image above shows both.
Sections that depend on the run and its options. Thresholds follows the plan overview when --threshold is set. When coordinated omission mitigation added synthetic times, an adjusted table follows Request Metrics and another follows Response Time Metrics. With mitigation on, the report ends with Coordinated Omission Event Metrics. Errors and Transaction Errors follow User Metrics when a request failed or a transaction returned an error that is not a request error. --no-error-summary leaves them out, and --no-status-codes, --no-transaction-metrics and --no-scenario-metrics leave out Status Code, Transaction and Scenario Metrics. With --baseline-file, each number in the request, response time, transaction, scenario and error tables is followed by its change from the earlier run. With mitigation on, the request counts and rates and the status code counts include the synthetic requests (see Reading the event summary).
Request Metrics
A graph of requests per second, with a line for all requests and one for each request, then a table of each request: how many were made and failed, the average, minimum and maximum response time, and requests and failures per second. --no-granular-report draws only the totals, in this graph and in every other line graph of the report; the heatmap keeps its menu of request names.
Most of the 500 requests a second were static assets, the line just under the total; the page requests are the thin lines near zero.
Response time breakdowns by status code
When one request name gets more than one status code, such as some 200 and some 500 responses, a row for each status code follows its row, starting └─. In the PDF, HTML and Markdown reports the row gives that code’s count, share of the responses, and average, minimum and maximum times; in the console metrics it follows the request in the table of times, with the share in its name and - for Median, because Goose keeps only those figures for each code. A request that got no response, such as one that could not connect, counts under status code 0. The rows hold measured times only, never coordinated omission synthetic ones. They are in the PDF, HTML and Markdown reports, in the JSON report as each request’s status_code_timings, and in the console metrics.
Response Time Metrics
A graph of the average response time each second, then the response time heatmap of the run since the metrics reset, then a table of percentiles from 50% to 100%: the 95%ile column is the slowest of the fastest 95% of each request’s responses. The console metrics use other percentiles, from 50% to 99.99%.
The p50 line holds near 9 ms throughout; the p95 line sits near 80 ms for the first half minute, then near 25 ms with spikes to 80 ms and more.
The heatmap uses the dashboard’s rows and percentiles, with p50, p95 and p99 drawn as colored lines, but covers the whole run since the metrics reset, while the dashboard’s heatmap shows the last 5 minutes. A column starts one second wide. Once a series holds 600 columns for all requests, or 60 for one request name, adjacent pairs merge, and the heatmap says how wide each column is. This 3 minute run kept one second columns for all requests, while each request name’s columns merged to 4 seconds. A menu picks all requests or one of the first 100 request names Goose recorded. --no-percentiles-over-time leaves the heatmap out.
Status Code Metrics
A table of every status code each request received.
Every request in this run got 200; a request that also got other codes lists each with its count.
Transaction Metrics
A graph of transactions per second and a table of each transaction, grouped by scenario. A transaction fails only when its code returns an error, so a transaction can succeed while a request it made failed; Transaction Log explains which errors are counted where. Scenarios and transactions are numbered from 1 on the dashboard, in every report’s transaction errors table (the JSON report’s transaction_errors too) and in the console metrics. The transaction tables of the PDF, HTML, Markdown and JSON reports number them from 0, as 0.0. So transaction 0.2 in those tables is 1.3 on the dashboard and in the transaction errors tables, and transaction 3 under scenario 1 in the console metrics.
Each transaction loads a page and its static assets, and each form transaction also loads a second page, so about 30 transactions a second carried the 500 requests a second above.
Scenario Metrics
A graph of scenarios per second and a table of each scenario. # Users is how many users ran the scenario since the metrics reset, or since the start in a run with no reset, whether or not they finished an iteration. # Times Run counts finished iterations, and the times are how long one iteration took. Iterations is # Times Run divided by # Users, in the Aggregated row too. A scenario no user ran shows an average and Iterations of 0.
The one Admin user took 34 to 43 seconds for each iteration, so it finished 4 in the 3 minutes, while the 40 Anonymous English users finished 254.
User Metrics
A graph of how many users were running.
This is the only graph that shows the ramp up; the others count from the metrics reset.
Markdown report
--report-file report.md writes the Markdown report: the HTML report’s tables, as Markdown tables, in the same order. It has no graphs or heatmap, no header lines for the user count, host or version, and no coordinated omission adjusted tables. Its error sections are headed Error Metrics and Transaction Error Metrics. Its coordinated omission section, Coordinated Omission Metrics, adds tables of events by user and by scenario when there are events; see Coordinated Omission metrics. Its scenario table’s first column is headed Scenario, as in the HTML report. The start of its Request Metrics:
## Request Metrics
| Method | Name | # Requests | # Fails | Average (ms) | Min (ms) | Max (ms) | RPS | Failures/s |
| ------ | ---- | ---------: | ------: | -----------: | -------: | -------: | --: | ---------: |
| GET | / | 528 | 0 | 32 | 8.55 | 267 | 2.93 | 0.00 |
| GET | /en/about-umami | 254 | 0 | 27 | 9.15 | 107 | 1.41 | 0.00 |
| GET | /en/articles/ | 265 | 0 | 39 | 10 | 256 | 1.47 | 0.00 |
JSON report
--report-file report.json writes the JSON report: every table the other reports show, as indented JSON (383 KB for this run), with the graph data only when asked for (below). Its top level, with each array and object shown by its size:
jq 'map_values(if type == "array" then "\(length) rows" elif type == "object" then "{\(keys | length) keys}" else . end)' report.json
{
"goose_version": "0.19.0",
"test": "umami",
"started": "2026-10-28T16:59:19Z",
"ended": "2026-10-28T17:02:30Z",
"ended_by": "completed",
"in_progress": false,
"raw_metrics": "{14 keys}",
"raw_request_metrics": "143 rows",
"raw_response_metrics": "143 rows",
"co_request_metrics": null,
"co_response_metrics": null,
"scenario_metrics": "4 rows",
"transaction_metrics": "27 rows",
"status_code_metrics": "143 rows",
"errors": null,
"coordinated_omission_metrics": null
}
The JSON report’s times, which end Z, are UTC, as are the log’s; the run’s folder and the plan overview in the other reports and the console metrics use local time, an hour ahead here, as in Running A Load Test.
raw_metricsis Goose’s own record of the run,GooseMetrics, with the test plan steps the run went through, inhistory, and each--thresholdresult. Each transaction’stransaction_namethere is an object that says how it was named, such as{"TransactionOnly":"anon /"}.raw_request_metrics,raw_response_metrics,scenario_metrics,transaction_metricsandstatus_code_metricsare the tables the other reports show;raw_request_metricshas a row for each request and the Aggregated row, and each request inraw_metricsholdsstatus_code_timingsin place of the└─breakdown rows.errorsisnullwithout errors, andtransaction_errorsappears only when a transaction returned an error that is not a request error.- The
co_keys arenulluntil coordinated omission mitigation adds synthetic times, andcoordinated_omission_metricsisnullwith mitigation off. graphs, with the per second data behind the graphs and the heatmap, appears only when the run had--report-graph-dataor the dashboard’s Include graph data switch on.
Minimums, maximums and percentiles in raw_request_metrics, raw_response_metrics and the other tables are whole milliseconds, truncated, so a 37.999 ms time is 37; averages keep their fraction of a millisecond, as 31.861234848484848 for GET /. A request or scenario average with no times to average is 0, and each scenario’s heading row in transaction_metrics has null averages and rates. The raw timing data keeps microseconds, in fields ending _us. A JSON report loads as a baseline with --baseline-file (see Baselines from older Goose releases), and --render-report writes the other reports from it.
Console metrics
While the load test runs, Goose logs what it is doing; Running A Load Test walks through those log lines. Once every user has started, it prints the tables so far and resets its metrics, after which every table, errors included, counts from zero. A run with --iterations or a test plan never resets, and --no-reset-metrics keeps the ramp up in the tables. With --running-metrics N it prints the tables again every N seconds, ramp up included. During the ramp up it also prints them each time a user starts at least N milliseconds after the last print, because in Goose 0.19 the user launch code reads N as milliseconds. When the run ends, it prints the final metrics in this order: scenarios, with their times; transactions, with their times; requests, with their response times, percentiles and status codes; errors, transaction errors and thresholds, when there are any; the coordinated omission event summary, whenever mitigation is on; and last the plan overview, as Running A Load Test shows. From this run, up to the plan overview, with ... for the rows and tables left out:
=== PER SCENARIO METRICS ===
------------------------------------------------------------------------------
Name | # users | # times run | scenarios/s | iterations
------------------------------------------------------------------------------
1: Anonymous English u.. | 40 | 254 | 1.41 | 6.35
2: Anonymous Spanish u.. | 9 | 54 | 0.30 | 6.00
3: Admin user | 1 | 4 | 0.02 | 4.00
-------------------------+----------+--------------+-------------+------------
Aggregated | 50 | 312 | 1.73 | 6.24
...
=== PER TRANSACTION METRICS ===
------------------------------------------------------------------------------
Name | # times run | # fails | trans/s | fail/s
------------------------------------------------------------------------------
1: Anonymous English user
1: anon / | 519 | 0 (0%) | 2.88 | 0.00
2: anon /en/basicpage | 254 | 0 (0%) | 1.41 | 0.00
...
-------------------------+---------------+----------------+----------+--------
Aggregated | 5,124 | 0 (0%) | 28.47 | 0.00
...
=== PER REQUEST METRICS ===
------------------------------------------------------------------------------
Name | # reqs | # fails | req/s | fail/s
------------------------------------------------------------------------------
GET / | 528 | 0 (0%) | 2.93 | 0.00
...
GET static asset | 85,825 | 0 (0%) | 476.81 | 0.00
...
-------------------------+---------------+----------------+----------+--------
Aggregated | 91,625 | 0 (0%) | 509.03 | 0.00
------------------------------------------------------------------------------
Name | Avg (ms) | Min | Max | Median
------------------------------------------------------------------------------
GET / | 32 | 8.55 | 267 | 20
...
GET static asset | 11 | 3.81 | 401 | 8.26
...
-------------------------+-------------+------------+-------------+-----------
Aggregated | 14 | 3.81 | 438 | 8.51
------------------------------------------------------------------------------
Slowest page load within specified percentile of requests (in ms):
------------------------------------------------------------------------------
Name | 50% | 75% | 98% | 99% | 99.9% | 99.99%
------------------------------------------------------------------------------
GET / | 20 | 26 | 148 | 163 | 210 | 267
...
GET static asset | 8.26 | 10 | 74 | 78 | 92 | 281
...
-------------------------+--------+--------+--------+--------+--------+-------
Aggregated | 8.51 | 11 | 87 | 138 | 230 | 338
------------------------------------------------------------------------------
Name | Status codes
------------------------------------------------------------------------------
GET / | 528 [200]
...
GET static asset | 85,825 [200]
...
-------------------------+----------------------------------------------------
Aggregated | 91,625 [200]
--no-print-metrics leaves out every table the console prints, at the reset, with --running-metrics and at the end; the reports are still written.
How Goose measures response times
Goose measures time to first byte: from sending a request until its response headers arrive, including connecting and following redirects, but not reading the body. Every request Goose sends is timed this way. A load test that needs the time to the last byte reads the body and times it; see Measuring complete download time.
Goose keeps request, transaction and scenario times to the microsecond. The tables of the dashboard, the PDF, HTML and Markdown reports and the console metrics show times under 10 ms to the hundredth of a millisecond and longer times in whole milliseconds, rounded half up. The heatmap labels truncate instead, as the Dashboard Reference explains, and so do the minimums, maximums and percentiles of the JSON report. Percentiles come from buckets never more than 0.78% off the times they hold, so 2.2 ms and 2.5 ms are told apart, and the fastest and slowest times are kept exactly.
Measuring complete download time
To measure the time to the last byte, read the body yourself and record the time it took with record_custom_request_duration, which records it as a request of its own:
#![allow(unused)]
fn main() {
use goose::prelude::*;
use std::time::Instant;
async fn measure_full_download(user: &mut GooseUser) -> TransactionResult {
let started = Instant::now();
let goose = user.get("/large-file").await?;
// Read the entire response body.
let success = match goose.response {
Ok(response) => response.bytes().await.is_ok(),
Err(_) => false,
};
// Record the time to the last byte as a request of its own.
user.record_custom_request_duration(
"DOWNLOAD",
"/large-file",
started.elapsed(),
success,
None,
None,
)
.await?;
Ok(())
}
}
The GET /large-file row then holds the time to the first byte, and the DOWNLOAD /large-file row the time to the last. A custom request is counted like any other, in its own row and in the Aggregated row; with no status code given, the status code table shows it under [0], as in the TCP example. Reading whole bodies uses more memory and can lower the load one Goose process generates.
Developer documentation on how metrics are collected and stored is in the goose::metrics module.
Tips
The tips follow a load test’s life: writing it, checking it, the first runs, scaling up, reading the results and debugging. If you are back with a problem, start here:
- A request that should fail passes: Check the page, not only the status code.
- The machine running Goose is the limit: Check that Goose is not the bottleneck.
- Requests fail to connect once you add users: Raise the open file limit.
- The reports are missing: Write the reports, and keep each run.
- A CI job should fail: Make a CI job fail on slow responses.
- Response times look too good: When response times look too good.
- Requests time out: When requests time out.
- A run never ends: When a run never ends.
- A command from an older post fails: Options renamed since the older posts.
Writing a load test
Handle every error
A request method such as get returns Ok even when the request fails, with the failure inside the response, so ? on the call does not stop the transaction. Goose still counts the request as failed; see A failed request does not fail its transaction. Under load, an unwrap() on goose.response panics the first time a request gets no response, as when it times out, and one on its body panics when the body cannot be read. Handle those with if let or match, and return other errors with ?. Goose records an error a transaction returns and keeps the user running.
async fn website_index(user: &mut GooseUser) -> TransactionResult {
let goose = user.get("/").await?;
if let Ok(response) = goose.response {
let _text = response.text().await;
}
Ok(())
}
Name requests that carry an ID
Goose groups requests by name, and a request’s name is its path unless you give one. A test that requests /node/1, /node/2 and so on gets a row for every node; one name for them all gives one row whose numbers cover every node.
let path = format!("/node/{nid}");
let _goose = user.get_named(&path, "/node/%nid").await?;
Give users a wait time
A scenario without a wait time sends each user’s next request as soon as the last one ends, so each user loads the server as hard as it can. set_wait_time pauses each user for a random time in a range after each transaction, as a person would. With a wait time, the same load needs more users.
scenario!("WebsiteUser")
.set_wait_time(Duration::from_secs(5), Duration::from_secs(15))?
Stop the run from your own code
goose::trigger_killswitch stops a run as Ctrl-C does, with every report written; see Killswitch. A threshold, set on the command line or in code with GooseDefault::Threshold, stops the run when a metric such as the error rate crosses a limit.
goose::trigger_killswitch("Server returned 503: Service Unavailable");
Checking a load test
Check the page, not only the status code
A 200 with an error page is still a 200. Check the response for the content you expect, and mark the request failed with set_failure when it is not there. Pass the page to set_failure so the debug log keeps it; Request failures explains set_failure, and Validating Requests shows other checks. In Goose 0.19 the request log still shows such a request as a success, with "success":true; see List every failed request.
let mut goose = user.get("/").await?;
if let Ok(response) = goose.response {
let text = response.text().await.unwrap_or_default();
if !text.contains("<title>Home</title>") {
return user.set_failure("title not found", &mut goose.request, None, Some(&text));
}
}
Start with one user and a debug log
Run one user for a short time with a debug log before adding load, so each request is easy to follow. Goose writes an entry to the debug log each time the load test calls log_debug and each time set_failure marks a request as failed; a test that does neither gets an empty debug log. Add -R for a line for every request as well. Without file names, -D writes debug.json and -R writes requests.json into the run’s folder, goose-runs/<start time>/; see Output Files.
cargo run --release -- -H http://localhost:8080 -u 1 -t 30s -D -R
First runs
Build with --release
cargo run without --release builds a debug binary, which sends far less load: Tag1 measured about 13 times the traffic from a release build in The golden Goose egg. Every cargo run on this page uses --release.
cargo run --release -- -H http://localhost:8080
Always name the host
A load test can set a default host in its code, as the Umami example does, and a run without -H (--host) then loads that host. A load test with no default host refuses to start without -H. Pass it every time.
cargo run --release -- -H https://staging.example.com
Stop with one Ctrl-C
Ctrl-C stops the run cleanly: users stop, every report is written and the final metrics print. A second Ctrl-C exits at once and writes none of them, so wait for the first to finish. The run in Running A Load Test shows the line Goose prints:
17:14:04 [WARN] caught ctrl-c, stopping...
Start the run from the dashboard
With the dashboard feature, --no-autostart starts Goose idle, so you can check the settings in the dashboard, at http://127.0.0.1:5118/, before you press Start. Pressing Start needs --dashboard-control, which in turn needs --dashboard-auth-token or --dashboard-no-auth; the command below uses --dashboard-no-auth, which suits a test on your own machine only. See Live Dashboard.
cargo run --release --features goose/dashboard -- -H http://localhost:8080 \
--dashboard --dashboard-control --dashboard-no-auth --no-autostart
Scaling up
Check that Goose is not the bottleneck
When the machine running Goose runs out of CPU, the results measure Goose, not the server: response times grow and requests per second stop rising as you add users. Watch the Goose process in top or htop while the test runs; replace loadtest below with your binary’s name, which is the crate’s name unless you set another. Goose uses every core, so top can show it at up to 100% times the number of cores; near that, or with every core busy in htop, Goose is the limit. Build with --release, send less load from this machine with fewer users or --throttle-requests, or use a larger machine.
top -p $(pgrep -nf target/release/loadtest) # Linux
top -pid $(pgrep -nf target/release/loadtest) # macOS
Raise the open file limit
By default each user holds its own connections, and each connection is an open file. On Linux and macOS the default limit is often 1,024 or lower, and Goose does not raise it. Past the limit, requests fail with status code 0 and client error (Connect), and nothing in the output names open files. A test with no wait time can also run on past -t, and its first Ctrl-C does not stop it; a second ends it, with no reports. Raise the limit in the shell that runs Goose; A Goose in the clouds mentions raising it for a large run.
ulimit -n 100000
Ramp up slowly
Goose starts one user a second by default. A high -r (--increase-rate, users a second) starts many users at once, which can overwhelm a cold server and measure its warm up, not its steady state. For a large run, spread the start over time with -s (--increase-time), or give the run a shape with a test plan.
cargo run --release -- -H http://localhost:8080 -u 500 --increase-time 5m -t 30m
Cap the request rate
--throttle-requests caps the requests Goose sends each second, whatever the number of users. Use it to stay under a rate limit, or to hold a steady load while you change something else; see Throttle.
cargo run --release -- -H http://localhost:8080 -u 100 --throttle-requests 200
Use every core of one machine
One Goose process runs its users across every CPU core; set -u for the load you need rather than starting more processes. Distributed load tests with Gaggles, which the older posts describe, return in Goose 0.20; see Gaggle.
cargo run --release -- -H http://localhost:8080 -u 1000 -s 5m
Reading the results
Write the reports, and keep each run
A run writes no report files unless you ask for them. A --report-file without a file name, or --dashboard, writes report.html and report.json into the run’s folder, goose-runs/<start time>/ under the directory you ran Goose from; Goose logs the folder as the run starts (see Output Files). A file name you give is written where you name it, relative to the directory you ran Goose from. Reports are also missing after a second Ctrl-C (see Stop with one Ctrl-C), and --report-file is refused with --no-metrics. --notes labels the run, and --baseline-file compares a later run with an earlier one; see Comparing against a baseline.
cargo run --release -- -H http://localhost:8080 --report-file --notes "before the cache change"
The tables start once every user is running
By default Goose resets its metrics once every user has started, so the tables describe the run at full load. --no-reset-metrics keeps the ramp up in them. A run with a test plan or --iterations never resets. See Console metrics.
cargo run --release -- -H http://localhost:8080 --no-reset-metrics
A failed request does not fail its transaction
A request fails on an error status code, when no response comes back, or when your code calls set_failure. A transaction fails only when its function returns an error. This run of the simple example, with a 1 second timeout, loaded a local server that answers / after 3 seconds. Both requests for / timed out, but transaction 0.1, which requests /, did not fail. The example’s transactions have no names, so its Markdown report numbers them by scenario and transaction, from 0:
cargo run --release --example simple -- -H http://127.0.0.1:19050 \
-u 2 -r 2 -t 15s --timeout 1 --no-telnet --report-file report.md
## Request Metrics
| Method | Name | # Requests | # Fails | Average (ms) | Min (ms) | Max (ms) | RPS | Failures/s |
| ------ | ---- | ---------: | ------: | -----------: | -------: | -------: | --: | ---------: |
| GET | / | 2 | 2 | 1001 | 1001 | 1001 | 0.13 | 0.13 |
...
## Transaction Metrics
| Transaction | # Times Run | # Fails | Average (ms) | Min (ms) | Max (ms) | RPS | Failures/s |
| ----------- | ----------: | ------: | -----------: | -------: | -------: | --: | ---------: |
| **WebsiteUser** |
...
| 0.1 | 2 | 0 | 1001 | 1001 | 1001 | 0.13 | 0.00 |
To make a failed request fail its transaction, build it with GooseRequest::builder().error_on_fail() and send it with user.request, or return what set_failure returns, as Check the page does; see Validating Requests.
Make a CI job fail on slow responses
--threshold judges the run against a rule. A rule that fails makes the run fail and, by default, stops it while it runs; with --no-threshold-abort the run goes to the end and is judged then. Either way execute() returns an error, so a main that returns it with ? exits with a nonzero status; see Exit code in CI.
cargo run --release -- -H http://localhost:8080 -t 5m --threshold "p95<500ms" --threshold "error-rate<1%"
When response times look too good
First check # Fails in the request table, and that the pages are the ones you expect (see Check the page): fast failures look like fast responses. When a server stalls a request, its user waits instead of sending the requests it would have sent, so the wait shows in one response time and the requests it never sent are missing from the measured times. --co-mitigation average adds synthetic response times for those missing requests, without sending more load; Was my run affected? shows how to read the result.
cargo run --release -- -H http://localhost:8080 --co-mitigation average --report-file
Debugging
When requests time out
Goose times out a request after 60 seconds. A timeout is usually the finding, since the server could not answer in time at this load. Raise the limit only when you expect slow answers: the requests that would have timed out then count as slow responses, not failures. A timed-out request counts at the limit in the response times. In the run above, with --timeout 1, GET / shows 1,001 ms as its average, minimum and maximum. --timeout sets the limit in seconds, decimals allowed.
cargo run --release -- -H http://localhost:8080 --timeout 120
To remove the limit, build a client of your own with GooseUser::set_client_builder; for a limit no request will reach, set a very long timeout, such as --timeout 86400 for a day.
See the page a request got
Run with -D (--debug-log), as in Start with one user and a debug log: each entry in debug.json is a JSON object, and body holds the page your code passed to set_failure or log_debug, as Check the page does. jq or jaq can pull it out to open in a browser; you may need to turn off JavaScript to view it. The request log below records every request, but not the page it got.
head -n 1 goose-runs/<start time>/debug.json | jq -r .body > page.html
List every failed request
The error table, in the reports and in the console’s final metrics, says why requests failed, as in this Markdown report of the timeout run:
## Error Metrics
| Method | Name | # | Error |
| ------ | ---- | --: | ----- |
| GET | / | 2 | error sending request /: operation timed out |
-R (--request-log) writes a line for every request; without a file name it writes requests.json into the run’s folder, and jq can list the ones that failed. The simple example with -R, run again against the slow server for 10 seconds, gave these two lines:
cargo run --release --example simple -- -H http://127.0.0.1:19052 \
-u 2 -r 2 -t 10s --timeout 1 --no-telnet -R -q
jq -c 'select(.success == false) | {name, status_code, error}' goose-runs/<start time>/requests.json
{"name":"/","status_code":0,"error":"error sending request /: operation timed out"}
{"name":"/","status_code":0,"error":"error sending request /: operation timed out"}
A status code of 0 means no response came back; see Response time breakdowns by status code. A request your code marks failed with set_failure still shows "success":true in the request log, so the jq command above skips it; -E (--error-log) writes a line for each of them, as for every other failed request.
When a run never ends
A run without -t or --iterations, or with a test plan whose last step still has users, goes on until Ctrl-C (end a plan with a step to 0 users, such as 0,0s), and a run past the open file limit can too (see Raise the open file limit). When -t runs out, or at the first Ctrl-C, each user first finishes its transaction: after entering GooseAttack phase: Decrease, each request left in it can wait up to the timeout, 60 seconds by default, against a stalled server, before the reports are written. Unless a threshold or the killswitch stops it, a run started from the dashboard or a Controller leaves Goose up when it ends, and so does a --dashboard run that ends on its own with its report saved and a dashboard tab open. Goose then stays up, even after the tab is closed, until Ctrl-C, a Controller shutdown, or the dashboard’s Quit button, which needs --dashboard-control (see When the run ends). In Goose 0.19, -v can hang a run partway through printing its metrics, where not even two Ctrl-Cs stop it: leave out --running-metrics when you use -v, and add --no-reset-metrics against a fast server. From another terminal, end a hung run with kill and its process ID, which writes no reports; replace loadtest with your binary’s name:
kill $(pgrep -nf target/release/loadtest)
Options renamed since the older posts
Tag1’s blog posts about Goose from 2020 and 2021, such as Using Gaggles to distribute your load testing, use options that have been renamed or removed. Their code also uses the names from before Goose 0.16: taskset! is now scenario!, register_taskset is register_scenario, task! is transaction!, register_task is register_transaction, GooseTaskSet is Scenario, GooseTaskError is TransactionError, and GooseTaskResult is TransactionResult.
| In the posts | Now |
|---|---|
-c, --clients | -u, --users |
--hatch-rate | -r, --increase-rate |
--startup-time | -s, --increase-time |
--log-file | -G, --goose-log |
--debug-file, --debug-log-file | -D, --debug-log |
--requests-file, --metrics-file, --stats-log-file | -R, --request-log |
--metrics-format, --requests-format | --request-format |
--task-log | -T, --transaction-log |
--task-format | --transaction-format |
--print-stats | the default; --no-print-metrics turns the printing off, --no-metrics the collecting |
--only-summary | the default; --running-metrics N adds running metrics |
--reset-stats | the default |
--no-stats | --no-metrics |
--status-codes | the default; --no-status-codes turns it off |
--no-task-metrics | --no-transaction-metrics |
--manager, --worker and the other Gaggle options | none in Goose 0.19; Gaggles return in 0.20, see Gaggle |
Logging
With logging, it’s possible to record all Goose activity. This can be useful for debugging errors, for validating the load test, and for creating graphs.
When logging is enabled, a central logging thread maintains a buffer to minimize the IO overhead, and controls the writing to ensure that multiple threads don’t corrupt each other’s messages. All log messages are sent through a channel to the logging thread and written asynchronously, minimizing the impact on the load test.
Each log option takes a file name, such as --request-log requests.log, and writes that file. Given without one, as --request-log, it writes a file named for the log and its format into the run’s folder: requests.json, requests.csv, or requests.log for raw and pretty. A name ending in .gz writes the log gzipped, and --compress gzips every log in the run’s folder. See Output files.
Every log takes a format option: --request-format, --transaction-format, --scenario-format, --error-format and --debug-format. Each accepts json (the default), csv, raw or pretty. A JSON log writes one object per line, with its keys in the order of the logged structure’s fields. A CSV log starts with a header row. The elapsed field of the request, transaction, scenario and error logs counts milliseconds since the load test started, a clock every user shares. The raw format writes Rust’s debug output of each entry on one line, and pretty spreads the same output over several indented lines.
The request, transaction, scenario and error logs are built from Goose metrics, so they can’t be combined with --no-metrics. The debug log can.
What logging costs
With Goose on two cores running examples/simple with 1,000 users and no waits, the JSON request log added about 3.4 µs of CPU to each request, a tenth of what Goose spent per request. Gzip at the default level 6 roughly doubles that. Each request writes about 500 bytes, so at 25,000 requests a second the log grows by about 45 GB an hour. Write it uncompressed unless the disk can’t keep up or space is short; then gzip it at the default level.
Request Log
Goose can optionally log details about all the requests made during the load test to a file. This log file contains the running metrics Goose generates as the load test runs. To enable, add the --request-log <request.log> command line option, where <request.log> is either a relative or absolute path of the log file to create. Any existing file that may already exist will be overwritten.
If --request-body is also enabled, the request log will include the entire body of any client requests, in the body field of the raw request.
Logs include the entire GooseRequestMetric object which also includes the entire GooseRawRequest object, both created for all client requests.
The headers field of the GooseRawRequest lists the headers set on the individual request (for example by .header(), .bearer_auth(), .json() or .form()); headers that Reqwest adds when it sends the request are not included: the client’s default headers (those set with .default_headers(), the User-Agent and Accept: */*), the Cookie header from the cookie store, Accept-Encoding when compression is enabled, and the Host and Content-Length headers. Reqwest offers no way to read them: https://github.com/tag1consulting/goose/issues/336. Request headers are only captured when the request log, the debug log or the error log is enabled, otherwise headers is empty.
Log Format
By default, logs are written in JSON Lines format. For example, from the simple example with --request-body also enabled:
{"elapsed":505,"scenario_index":0,"scenario_name":"websiteuser","transaction_index":0,"transaction_name":{"TransactionOnly":""},"raw":{"method":"Post","url":"http://127.0.0.1:8080/login","headers":["(\"content-type\", \"application/x-www-form-urlencoded\")"],"body":"username=test_user&password=","custom_method":""},"name":"/login","final_url":"http://127.0.0.1:8080/login","redirected":false,"response_time":1,"status_code":200,"success":true,"update":false,"user":3,"error":"","coordinated_omission_elapsed":0,"user_cadence":0,"response_time_fraction_us":221}
{"elapsed":506,"scenario_index":0,"scenario_name":"websiteuser","transaction_index":1,"transaction_name":{"TransactionOnly":""},"raw":{"method":"Get","url":"http://127.0.0.1:8080/","headers":[],"body":"","custom_method":""},"name":"/","final_url":"http://127.0.0.1:8080/","redirected":false,"response_time":1,"status_code":200,"success":true,"update":false,"user":3,"error":"","coordinated_omission_elapsed":0,"user_cadence":0,"response_time_fraction_us":202}
{"elapsed":6875,"scenario_index":0,"scenario_name":"websiteuser","transaction_index":2,"transaction_name":{"TransactionOnly":""},"raw":{"method":"Get","url":"http://127.0.0.1:8080/about/","headers":[],"body":"","custom_method":""},"name":"/about/","final_url":"http://127.0.0.1:8080/about/","redirected":false,"response_time":0,"status_code":200,"success":true,"update":false,"user":2,"error":"","coordinated_omission_elapsed":0,"user_cadence":0,"response_time_fraction_us":632}
The --request-format option can be used to log in csv, json (default), raw or pretty format. The raw format is Rust’s debug output of the entire GooseRequestMetric object, one per line, and pretty is the same output spread over several indented lines per request.
The csv format starts with a header row. Its transaction_name column holds the transaction’s name only when it was set with set_name_for_transaction_and_requests; a name set with set_name, or no name as in this example, leaves it empty, and its raw column holds the debug output of the GooseRawRequest. The first two requests of the same example look like:
elapsed,scenario_index,scenario_name,transaction_index,transaction_name,raw,name,final_url,redirected,response_time,status_code,success,update,user,error,coordinated_omission_elapsed,user_cadence,response_time_us
0,0,websiteuser,0,,"GooseRawRequest { method: Post, url: ""http://127.0.0.1:8080/login"", headers: [""(\""content-type\"", \""application/x-www-form-urlencoded\"")""], body: ""username=test_user&password="", custom_method: """" }",/login,http://127.0.0.1:8080/login,false,0,200,true,false,0,,0,0,756
0,0,websiteuser,1,,"GooseRawRequest { method: Get, url: ""http://127.0.0.1:8080/"", headers: [], body: """", custom_method: """" }",/,http://127.0.0.1:8080/,false,0,200,true,false,0,,0,0,188
The transaction_name field is an object naming how the transaction was named: {"TransactionOnly":"..."} for a name set with set_name, which only the transaction carries, or {"InheritNameByRequests":"..."} for a name set with set_name_for_transaction_and_requests, which its requests take too. Goose 0.18 wrote it as a plain string, and wrote the transaction’s name in the CSV column however it was set.
response_time is in whole milliseconds, truncated. The json, raw and pretty formats also carry response_time_fraction_us, the microseconds past those milliseconds (0 to 999), and the csv format ends with a response_time_us column holding the whole time in microseconds. In the first JSON line above the /login request took 1.221 milliseconds, and in the third the /about/ request took 632 microseconds. In the first CSV row the /login request took 756 microseconds.
Transaction Log
Goose can optionally log details about each time a transaction is run during a load test. To enable, add the --transaction-log <transaction.log> command line option, where <transaction.log> is either a relative or absolute path of the log file to create. Any existing file that may already exist will be overwritten.
Logs include the entire TransactionMetric object which is created each time any transaction is run.
Log Format
By default, logs are written in JSON Lines format. For example:
{"elapsed":0,"scenario_index":0,"transaction_index":0,"name":"","run_time":0,"success":true,"user":0,"run_time_fraction_us":334,"error":""}
{"elapsed":0,"scenario_index":0,"transaction_index":1,"name":"","run_time":0,"success":true,"user":0,"run_time_fraction_us":92,"error":""}
{"elapsed":6172,"scenario_index":0,"transaction_index":2,"name":"","run_time":1,"success":true,"user":0,"run_time_fraction_us":41,"error":""}
{"elapsed":8600,"scenario_index":0,"transaction_index":2,"name":"","run_time":0,"success":true,"user":2,"run_time_fraction_us":257,"error":""}
These lines are from the simple example, whose transactions have no names, run with -u 4 -r 4 -t 12s against a local server. In the first line, GooseUser thread 0 successfully ran transaction 0, the login, in 334 microseconds. In the third line, the same thread successfully ran transaction 2, the /about/ page, in 1.041 milliseconds.
Each line ends with an error field. It is empty when the transaction succeeded. When the transaction returned an error, success is false and error holds the error as its Display writes it. This line is from a load test whose flaky transaction returns TransactionError::Custom("every third call fails") one time in three:
{"elapsed":0,"scenario_index":0,"transaction_index":2,"name":"flaky","run_time":0,"success":false,"user":0,"run_time_fraction_us":68,"error":"TransactionError: custom error (every third call fails)"}
A transaction that failed because a request made with error_on_fail, or marked failed with set_failure, failed names the request error in error, as request failed: <method> <name>: <error>; the request error itself is counted in the errors table and the error log. A transaction canceled as the run ended has no error. A transaction that returns a TransactionError::Custom or Url error, or a Reqwest error from reading the response body or building the request, is counted, by scenario, transaction and error, in the Transaction Errors tables of the metrics and reports, and is written to the error log. Any other error, such as a Reqwest error from sending a request or from an error status, is named only in this log.
The --transaction-format option can be used to log in csv, json (default), raw or pretty format. The raw format is Rust’s debug output of the entire TransactionMetric object, one per line, and pretty is the same output spread over several indented lines per transaction. In each of them the error field comes last:
TransactionMetric { elapsed: 2, scenario_index: 0, transaction_index: 2, name: "flaky", run_time: 0, success: false, user: 0, run_time_fraction_us: 101, error: "TransactionError: custom error (every third call fails)" }
For example, csv output of the simple example starts with a header row and looks like:
elapsed,scenario_index,transaction_index,name,run_time,success,user,run_time_us,error
0,0,0,,0,true,0,455,
0,0,1,,0,true,0,143,
251,0,0,,0,true,1,471,
251,0,1,,0,true,1,163,
7872,0,2,,0,true,1,220,
The error column is last, and is empty for a transaction that succeeded. A failed transaction fills it:
0,0,2,flaky,0,false,0,77,TransactionError: custom error (every third call fails)
run_time is in whole milliseconds, truncated. The json, raw and pretty formats also carry run_time_fraction_us, the microseconds past those milliseconds (0 to 999), and the csv format has a run_time_us column, before error, holding the whole time in microseconds.
Scenario Log
Goose can optionally log details about each time a scenario is run during a load test. To enable, add the --scenario-log <scenario.log> command line option, where <scenario.log> is either a relative or absolute path of the log file to create. Any existing file that may already exist will be overwritten.
Logs include the entire ScenarioMetric object which is created each time any scenario is run.
Log Format
By default, logs are written in JSON Lines format. For example:
{"elapsed":20926,"name":"WebsiteUser","index":0,"run_time":20756,"user":1,"run_time_fraction_us":471}
{"elapsed":21402,"name":"WebsiteUser","index":0,"run_time":21064,"user":2,"run_time_fraction_us":471}
{"elapsed":22791,"name":"WebsiteUser","index":0,"run_time":22285,"user":3,"run_time_fraction_us":334}
{"elapsed":24811,"name":"WebsiteUser","index":0,"run_time":24810,"user":0,"run_time_fraction_us":432}
These lines are from the simple example. In the first line, GooseUser thread 1 ran the complete WebsiteUser scenario in 20,756.471 milliseconds, which includes the wait after each transaction. In the fourth line, GooseUser thread 0 ran it in 24,810.432 milliseconds.
The --scenario-format option can be used to log in csv, json (default), raw or pretty format. The raw format is Rust’s debug output of the entire ScenarioMetric object, one per line, and pretty is the same output spread over several indented lines per scenario run.
For example, csv output of the same example starts with a header row and looks like:
elapsed,name,index,run_time,user,run_time_us
19283,WebsiteUser,0,18945,2,18945361
19724,WebsiteUser,0,18882,5,18882239
20468,WebsiteUser,0,20467,0,20467323
24027,WebsiteUser,0,23857,1,23857874
run_time is in whole milliseconds, truncated. The json, raw and pretty formats also carry run_time_fraction_us, the microseconds past those milliseconds (0 to 999), and the csv format ends with a run_time_us column holding the whole time in microseconds.
Error Log
Goose can optionally log details about all load test errors to a file. To enable, add the --error-log=<error.log> command line option, where <error.log> is either a relative or absolute path of the log file to create. Any existing file that may already exist will be overwritten.
The log holds two kinds of entries:
- a request error: the entire
GooseErrorMetricobject, created any time a request results in an error, including a request the load test marks as failed withset_failure; - a transaction error: a
GooseTransactionErrorMetric, created each time a transaction returns an error that is not already a request error: aTransactionError::CustomorUrl, or aReqwesterror from reading the response body or building the request. A transaction that fails because one of its requests failed, as withset_failure, gets no entry of its own: the request error has one. It haselapsed,scenario_index,scenario_name(the machine name, as in the request log),transaction_index,transaction_name,run_time,useranderror, the error as itsDisplaywrites it. Errors fromtest_startandtest_stopare not logged.
The raw.headers field of a request error follows the same rule as in the request log: it lists only the headers set on the individual request.
Log Format
By default, logs are written in JSON Lines format. For example:
{"elapsed":0,"scenario_index":0,"scenario_name":"erruser","transaction_index":2,"transaction_name":"flaky","run_time":0,"user":0,"error":"TransactionError: custom error (every third call fails)"}
{"elapsed":0,"raw":{"method":"Get","url":"http://127.0.0.1:8080/about/","headers":[],"body":"","custom_method":""},"name":"/about/","final_url":"http://127.0.0.1:8080/about/","redirected":false,"response_time":0,"status_code":404,"user":0,"error":"404 Not Found: /about/"}
These lines are from a load test run against a local server that answers /about/ with a 404. Its ErrUser scenario requests / and /about/, and its flaky transaction returns TransactionError::Custom("every third call fails") one time in three. The first line is a transaction error, the second a request error.
The --error-format option can be used to change the log format to csv, json (default), raw or pretty format. The raw format is Rust’s debug output of the entire GooseErrorMetric or GooseTransactionErrorMetric object, one per line, and pretty is the same output spread over several indented lines per error.
The csv format starts with a header row. Its first nine columns are a request error’s fields, and its raw column holds the debug output of the GooseRawRequest. The last five columns, scenario_index, scenario_name, transaction_index, transaction_name and run_time, are a transaction error’s. A transaction error fills elapsed, user, error and those five, leaving the other request columns empty, and a request error leaves the last five empty, so every row has every column:
elapsed,raw,name,final_url,redirected,response_time,status_code,user,error,scenario_index,scenario_name,transaction_index,transaction_name,run_time
0,,,,,,,0,TransactionError: custom error (every third call fails),0,erruser,2,flaky,0
0,"GooseRawRequest { method: Get, url: ""http://127.0.0.1:8080/about/"", headers: [], body: """", custom_method: """" }",/about/,http://127.0.0.1:8080/about/,false,0,404,0,404 Not Found: /about/,,,,,
--no-error-summary leaves errors out of the errors tables of the metrics and reports, not out of this log.
Debug Log
Goose can optionally and efficiently log arbitrary details, and specifics about requests and responses for debug purposes.
To enable, add the --debug-log <debug.log> command line option, where <debug.log> is either a relative or absolute path of the log file to create. Any existing file that may already exist will be overwritten.
If --debug-log <foo> is not specified at run time, nothing will be logged and there is no measurable overhead in your load test.
Response bodies are included unless --no-debug-body is also given, which keeps the debug log small.
To write to the debug log, you must invoke log_debug from your load test transaction functions. The tag parameter allows you to record any arbitrary string: it can also identify where in the load test the log was generated, and/or why debug is being written, and/or other details such as the contents of a form the load test posts. Other parameters that can be included in the debug log are the complete Request that was made, as well as the Headers and Body of the Response.
(The recorded request headers are only those set on the individual request: client default headers, the User-Agent, cookies, Accept-Encoding, Host and Content-Length are added later by Reqwest and are not recorded, see the request log and https://github.com/tag1consulting/goose/issues/336.)
See examples/drupal_memcache for an example of how you might invoke log_debug from a load test.
Request Failures
Calls to set_failure can be used to tell Goose that a request failed even though the server returned a successful status code, and will automatically invoke log_debug for you. See examples/drupal_memcache and examples/umami for an example of how you might use set_failure to generate useful debug logs.
Log Format
By default, logs are written in JSON Lines format. For example:
{"tag":"login: no form_build_id on page: /user page","request":{"elapsed":333,"scenario_index":1,"scenario_name":"authbrowsinguser","transaction_index":0,"transaction_name":{"TransactionOnly":"(Auth) login"},"raw":{"method":"Get","url":"http://127.0.0.1:8080/user","headers":[],"body":"","custom_method":""},"name":"/user","final_url":"http://127.0.0.1:8080/user","redirected":false,"response_time":0,"status_code":200,"success":false,"update":true,"user":1,"error":"login: no form_build_id on page: /user page","coordinated_omission_elapsed":0,"user_cadence":0,"response_time_fraction_us":625},"header":"{\"server\": \"BaseHTTP Python/3.15.0\", \"date\": \"Sun, 25 Oct 2026 20:57:26 GMT\", \"content-type\": \"text/html; charset=utf-8\", \"content-length\": \"86\"}","body":"<!doctype html><html><head><title>user</title></head><body><h1>user</h1></body></html>"}
This line is from the drupal_memcache example run against a server whose login page has no form_build_id, so the login transaction called set_failure. The header field is the response headers as one string, Rust’s debug output of the header map, and header, request and body are null when the call passed none.
The --debug-format option can be used to log in csv, json (default), raw or pretty format. The raw format is Rust’s debug output of the entire GooseDebug object, one per line, and pretty is the same output spread over several indented lines per entry. The csv format starts with the header row tag,request,header,body; its request, header and body columns hold Rust’s debug output of each optional value, such as Some(...) or None.
Controlling A Running Goose Load Test
There are three ways to watch or change a load test while it runs: the Live Dashboard and two Controllers.
- The Live Dashboard is a page in your browser that shows the test as it runs and saves its report, and, with control turned on, starts and stops it and changes its users and settings. Pick it to watch a test, or to steer one by hand.
- The telnet Controller takes typed commands. Pick it for
configandmetricsat a prompt, a test plan typed by hand, or shutting Goose down mid run. - The WebSocket Controller takes the same commands as JSON. Pick it to drive Goose from a program.
What each can do
| Live Dashboard | Telnet | WebSocket | |
|---|---|---|---|
| Observe | Live charts, KPIs and tables | metrics, metrics-json | metrics, metrics-json |
| Start, stop | With control on | start, stop | start, stop |
| Users | With control on | users | users |
| Host and ramps | With control on, in the Edit settings form | host, increaserate, increase-time, decreaserate, decrease-time | the same commands |
| Test plan | With control on, through POST /api/v1/control/settings or POST /api/v1/control/start; the form has no plan field | test-plan | test-plan |
| Run time | With control on, in the Edit settings form | runtime | runtime |
| Save report | On by default with --dashboard; turning it off and Save report now need control | No | No |
config | No | config, config-json | config, config-json |
| Shut Goose down | With control on: Quit, from Idle only | shutdown, from any phase | shutdown, from any phase |
| Default | Off; --dashboard turns it on, on 127.0.0.1:5118 | On, on 127.0.0.1:5116 | Off; --websocket turns it on, on 127.0.0.1:5117 |
| Authentication | Read-only on loopback: none, unless a token is set. Control, or any bind off loopback: a token, or --dashboard-no-auth | None | None |
The dashboard is read-only unless control is turned on with --dashboard-control and a token or --dashboard-no-auth; see Turning it on. It needs Goose built with the dashboard feature.
The telnet and WebSocket Controllers
By default, Goose launches a telnet Controller thread that listens on 127.0.0.1:5116. With --websocket it also launches a WebSocket Controller thread that listens on 127.0.0.1:5117; that one is off by default, as any web page a browser opens could reach it. Neither takes any authentication, so anyone who can reach their ports can start, stop and reconfigure the load test, which is why both listen only on loopback unless told otherwise. To control the load test from another machine, forward the port over ssh, or name a host with --telnet-host or --websocket-host, such as 0.0.0.0; a Controller listening on any address that is not loopback prints a warning at startup. Turn the telnet Controller off with --no-telnet.
Goose can be started with the --no-autostart option to keep the load test from starting on its own. A Controller’s start command then starts it, as does the dashboard’s Start under --dashboard-control. Goose refuses --no-autostart when neither is available. When Goose is started this way, a host is not required and can instead be configured through a Controller or the dashboard. A load test that a Controller or the dashboard starts again after it stopped counts its requests from zero.
With --telnet-host 0.0.0.0, Goose starts and writes this warning straight to standard error, where neither -q nor the log level hides it, and to the Goose log at warn level; the WebSocket Controller’s warning names --websocket-host and port 5117:
==============================================================================
WARNING: the Telnet Controller on 0.0.0.0:5116 has no authentication.
Anyone who can reach this port can start, stop and reconfigure the load test.
Safer: leave --telnet-host unset to listen on 127.0.0.1 only, and use an
ssh forward (ssh -L 5116:127.0.0.1:5116 host).
==============================================================================
A Controller that can’t listen on its port, because another process already holds it, logs an error and the load test runs without it:
22:10:32 [ERROR] [controller]: the Telnet Controller couldn't listen on 127.0.0.1:5116: Address already in use (os error 48); the load test runs without it
With --no-autostart, if no Controller could listen and dashboard control is off, nothing could start the load test, so Goose refuses to run:
Error: InvalidOption { option: "`configuration.no_autostart`", value: "true", detail: "`configuration.no_autostart` requires a Controller or dashboard control, and no Controller could listen on its port." }
Live Dashboard
The live dashboard is a web page Goose serves while a load test runs. It shows the test’s users, requests, response times and errors second by second. It saves each run’s report, and lists saved runs to download and compare. With control turned on it can also start and stop the test, and change its users, host, ramps and run time.
The first screen of a run with control on, explained section by section below, with the truncation notice under The tables.
Build Goose with the dashboard feature, add --dashboard, and open http://127.0.0.1:5118/. In a checkout of Goose, against your own Umami site:
cargo run --release --features dashboard --example umami -- \
--dashboard -H http://umami.example.com -u 100 -r 1
Most images on this page come from one run of the command above, against a local Umami site at http://localhost:18888: the first image, the charts, the heatmap, the Requests table, the Control card with Edit settings open, and the Runs panel. That run added --dashboard-control --dashboard-no-auth for control, -t 10m for a 10 minute hold, --no-reset-metrics so the tables include the ramp up, --notes "100, then 150 users", which the header shows, and -q to keep the console quiet. The read-only and Errors images come from runs of their own, and their captions say what changed.
Turning it on
The dashboard is an opt in crate feature. In your own load test crate, turn it on in Cargo.toml, as Requirements shows:
goose = { version = "^0.19", features = ["dashboard"] }
Then run your load test with --dashboard, like any other option:
cargo run --release -- --dashboard -H https://staging.example.com
A build without the feature refuses --dashboard at startup. The dashboard listens on 127.0.0.1:5118; Dashboard Reference lists the flags that change that.
--dashboard alone gives a read-only dashboard. Its Control card says so, names the two ways to get control, and points to the telnet Controller, which Goose starts by default and which takes no authentication:
With --dashboard alone the card can only point elsewhere: to a restart with control, or to the telnet Controller listening on 127.0.0.1:5116.
To control the test from the page as well, add --dashboard-control and one of:
--dashboard-auth-token <token>, then openhttp://127.0.0.1:5118/?token=<token>. See Browser bootstrap with?token=.--dashboard-no-auth, for control with no token, described next.
Control without a token
With --dashboard-control --dashboard-no-auth the page reads Live dashboard · control, no token. Anyone who can reach the dashboard’s port can then start and stop the test, point the load at any host, and delete saved reports. On a shared machine use a token instead, set in the load test’s code so ps does not show it; On a shared machine shows how, and what else is open there. Control without a token in the reference gives its rules, among them the warning Goose prints off loopback.
What changes, and what doesn’t
- The dashboard saves each run’s
report.htmlandreport.jsonin a run folder,goose-runs/<start time>under the working directory by default; see Saving the report. Goose still writes the reports it is asked for (PDF, HTML, Markdown and JSON), and prints the same metrics to the console, with a few lines more about the dashboard. - Watching adds no requests to the target.
--dashboardcosts Goose memory for the per second graph data, whether or not a page is open, and a little CPU for each snapshot, built about once a second while a page is open; Memory cost gives the numbers.
Watching from another machine
Keep the dashboard on loopback and use an SSH tunnel, or bind another address with --dashboard-host and a token. On a shared machine, set a token either way, in the load test’s code: a loopback dashboard with no token is open to every user of that machine. With a token set, the token goes in the URL whether or not control is on: http://127.0.0.1:5118/?token=<token> through the tunnel, or http://<address>:5118/?token=<token> on a bind off loopback. Bind and token rules says what each choice exposes, and to whom.
What it shows
The header shows the phase (idle, increase, maintain, decrease or shutdown), the hosts under load, how long the test has run, its --notes, and the connection, SSE with a green dot while the page is streaming. With control off it also shows the run settings, such as the test plan. Under the header, the report bar holds the report switches, described under Save, compare and delete runs. Below it and the Control card, the KPI row shows the active users against the target, then requests per second, the failure rate, the p95 and the average response time; all but the users cover the whole run, or the time since metrics were last reset, as The tables explains.
Active users
Users ramp to 100 at one a second, hold, then ramp to 150 after Target was set to 150 at 120 seconds, and hold again; the axis counts seconds since the test started.
Each point is one second. The charts keep the last 300 seconds of the run, so these, taken 200 seconds in, show all of it; on a longer run the start scrolls off, and the reports keep the whole run. The summary under each line chart is the mean of its last 10 seconds (the requests per second chart leaves out the newest second, still filling), with rising or falling when that is more than 10 percent above or below the 10 seconds before, and steady otherwise, once there are 20 seconds of data. So in the first image, taken as the ramp ended, the summary under the users chart reads Users 96 of 100 while the Users tile already reads 100 / 100. The step to 150 came from the Control card while the test ran, with no restart.
Requests per second
Requests per second climb with the users to about 1,000 by 100 seconds, then stop climbing; the dashed Failures/s line stays at zero.
Against the users chart, this shows the site at capacity: from 120 to 170 seconds users went from 100 to 150 and requests per second did not rise. The summary under the chart reads RPS 950.50.
The response time heatmap
From about 124 seconds, as users pass about 104, the p95 line climbs from about 20 ms to several hundred and the p99 rises to mostly between 500 ms and 1 second, while the p50 stays near 8 ms.
The card, titled Response times by second, draws each second as a column, oldest on the left, and each row as a range of response times on a logarithmic axis; a darker cell holds more requests. Three lines cross it: each second’s p50, p95 and p99. The newest two seconds are not drawn yet, as their slowest requests are still arriving. The labels at the right edge show the newest drawn second, here p95: 335 ms, and the summary under the chart the mean of the last 10 drawn seconds, p95 638 ms. Hover a cell for its second, its range, its count and that second’s percentiles, and pick a request at the top of the card to see it alone. The response time heatmap in the reference gives the rows, the color scale and how each percentile is worked out.
The tables
Below the charts, Summary gives the phase, how long the test has run, the users and total users, the Goose version, the hosts and the series window (the 300 seconds the charts keep), and Whole run the totals and response time percentiles. Scenarios lists every scenario with its users, how many times it ran and how long it took. Transactions lists every transaction with its runs, failures and times, and Requests each request with its counts, times and status codes:
Sorted by p95 with one click on its heading, the table puts the Spanish search, POST /es/search/node, first at 212 ms over the whole run, and scrolls right for p99, max and the status codes.
The table’s p95 covers the whole run, so the fast first two minutes weigh in; the heatmap’s lines are each one second.
Click a heading to sort by it. Every figure covers the whole run, or the time since metrics were last reset: as in a console run, Goose resets them when the ramp up ends, unless --no-reset-metrics or --iterations is given, or a test plan runs (see Metrics). Goose sends the dashboard only the 100 busiest request names. A site with more, such as Umami, shows the notice request rows truncated (showing top rows only). above the Control card, and the table lists those 100, so a slow request that is rarely made may not be among them. In the run above, the admin user’s login and article edits, a few requests each, are left out, though the login and the saved edits are slower than the Spanish search. The reports list every request.
Errors
From a run with ADMIN_PASSWORD set to a wrong password: the admin user’s login fails, its row names the cause, and each article the admin then tries to edit answers 403.
Each row is one error, with its request and its count, and rows appear while the run is going; the table holds the 50 most frequent, and a notice at the top of the page says when there are more. Transaction errors, below it, counts errors that a transaction’s own code returned, other than failed requests.
What you can do with it
Stop, Start, Quit, changing users or settings, the report switches, Save report now and Delete need control on. Saved runs can be downloaded and compared without it.
Stop a run that is erroring
Errors show first in the Fail % tile and as the dashed red Failures/s line in the requests per second chart, at zero in the run behind the charts above. The Errors table, below Requests, says what they are. When a run is failing, press Stop rather than waiting for it to end: the users stop at once, and Goose writes the run’s reports, as at the end of any run. See Start and Stop semantics.
Change users and settings
Type a count in Target and press Apply, or press − or + to send a change of one step at once. Goose ramps up to a higher count at the increase rate, as the change to 150 above shows, and down to a lower one at the decrease rate, or the increase rate when no decrease rate is set. An increase with no increase rate set, and a decrease with neither rate set, go at 1 user a second. A change of users never uses --increase-time or --decrease-time. A change made during the ramp up starts the hold once the new count is reached, for the full run time; a change made during the hold leaves its end where it was. The planned ramp down follows, unless the new ramp outlasts the hold, or the new count is the one the run already has: then the run holds that count until stopped. Once the ramp down has started, a new count abandons it, and Goose holds the new count until stopped. See Users on a running test.
In Idle after the charts’ run was stopped, and with a new host, ramp up and run time pending: Start reads Start with changes, the new host waits for Confirm, and the note under Target says a change of users would go with them.
Edit settings opens a form for the host, the ramp up and ramp down, and the run time. An edit stays pending until you press Apply settings, or until Start with changes starts the next run with it. The first change of host in a page load asks for confirmation before anything is sent. While a test runs, the form takes only what the run can still change, such as the run time, and says why for the rest. In Idle with settings edits pending, Target’s Apply sends nothing on its own, and − and + only change the Target count: the count goes with those edits when you press Apply settings or Start with changes. See Run settings.
Start a run
Start Goose with --no-autostart and it waits in Idle for Start; after a Stop it waits the same way. With settings edits pending, the button reads Start with changes and sends them with the start, as in the Control card above. The card shows Quit, which shuts Goose down, only in Idle, so stop a running test first.
Save, compare and delete runs
The report bar at the top of the page holds two switches. Save report sets whether this run’s report is written to its folder, and Include graph data adds the per second data to the JSON report. With control on, the switches can be changed, and the bar also holds Save report now, which writes the report mid run; without control the switches are shown read only. --report-graph-data turns Include graph data on from the command line. Without control, Save report stays on, except under --no-metrics or when the run’s folder can’t be written to.
The Runs panel at the foot of the page lists the saved reports in goose-runs, the newest 50 first, with Show all for the rest. With --output-dir <dir>, which names the run’s own folder, it lists the folder that holds <dir> instead. The list also includes console runs made without the dashboard, when they wrote to a run folder: a run given a bare --report-file, with no file name, writes report.html and report.json there (see Output Files).
Three saved runs: two from console runs given a bare --report-file, and the newest from the dashboard; the two newest are checked, so Download comparison appears.
Each row downloads its HTML, Markdown or JSON report; a format the run did not save is rendered from its JSON report. Check Compare on two rows and Download comparison gives a Markdown report of the newer run against the older. With control on, Delete removes a run’s reports and logs. See Saving the report and The runs panel.
When the run ends
When a run Goose started on its own (without --no-autostart) reaches its end, Goose exits. That is when its run time and any ramp down are over, its test plan finishes, or every user exits. If a page is connected at that moment and the report was saved, Goose stays up instead, so the report can still be downloaded, and prints:
Run finished. Download it from the dashboard at http://127.0.0.1:5118. Press Ctrl-C to exit.
With control on, it ends Press Quit in the dashboard or Ctrl-C to exit. Closing the tab does not end this wait; Ctrl-C, a Controller shutdown and Quit do.
A Stop leaves Goose in Idle with the page up, ready for the next run, and a run started from the dashboard or a Controller goes back to Idle when it ends. During a run, Ctrl-C, the killswitch, a Controller shutdown or a threshold that aborts the run ends both the run and Goose, with no wait. Once Goose exits, the page says Goose has exited. See Waiting after an autostarted run.
Reference
Dashboard Reference lists every flag and default, the bind and token rules, the control endpoints, the snapshot and health endpoints, and how to build the page from its TypeScript sources.
Dashboard Reference
This page holds every flag, rule and endpoint of the Live Dashboard, and the snapshot fields a client most needs. Start there for what the dashboard shows and how to use it.
The dashboard is an opt in crate feature: build Goose with
--features dashboard. Stability says what the dashboard and its/api/v1endpoints promise from one release to the next.
Goose can optionally serve a live web dashboard while a load test runs. The dashboard streams compact metric snapshots (including trailing RPS, users, and latency series) over Server-Sent Events (SSE), with a short poll fallback if SSE is unavailable.
Enabling the dashboard
The dashboard is off by default. Compile Goose with the dashboard crate feature, then enable observation with --dashboard:
cargo run --release --features dashboard --example simple -- \
--dashboard \
-H https://staging.example.com \
-u 50 -t 10m
Then open:
http://127.0.0.1:5118/
The dashboard port opens when the Controllers’ ports do, once Goose has built a client for every user, which takes longer with more users and fewer cores. With --no-autostart users are built on Start, so the port opens at once.
Related flags
| Flag | Default | Purpose |
|---|---|---|
--dashboard | off | Enable the live dashboard HTTP server (observe) |
--dashboard-control | off | Enable the control endpoints and UI: Start, Stop, users, the run settings (which can point the load at any host), Quit, the report switches, Save report now and Delete of saved reports and logs (requires --dashboard; does not auto-enable the dashboard) |
--dashboard-host HOST | 127.0.0.1 | Bind address. A loopback address needs no token for metrics; any other needs a token or --dashboard-no-auth; see Bind and token rules |
--dashboard-port PORT | 5118 | Bind port. 0 picks a free port, which the startup log names: [dashboard]: listening on http://127.0.0.1:60096 (read-only). |
--dashboard-auth-token TOKEN | empty | Shared secret for metric APIs when configured; required when --dashboard-control is set (even on loopback), and for any bind off loopback, unless --dashboard-no-auth is. Visible in process listings (ps) like any CLI flag; set it in the load test’s code with GooseDefault::DashboardAuthToken to keep it out of them. |
--dashboard-no-auth | off | Allow dashboard access and control without a token. Anyone who can reach the port can watch the test and download saved reports, and with --dashboard-control start, stop and point the load at any host, and delete saved reports and logs; Goose warns at startup with control on loopback, and on anything but loopback. Needs --dashboard; refused with --dashboard-auth-token. See Control without a token. |
--dashboard-max-clients N | 32 | Max concurrent SSE clients (GET /api/v1/events); further clients receive HTTP 503. 0 is refused at startup (must be at least 1). The server also holds at most twice this many connections plus 16 (80 by default); a connection beyond that is closed as soon as it is accepted. |
--no-percentiles-over-time | off | Turn off the response time heatmap and the histograms behind it; the card shows the average latency chart instead. It also turns off the HTML report’s response time heatmap, and works without --dashboard. See The response time heatmap. |
Defaults can also be set programmatically with GooseDefault::Dashboard, GooseDefault::DashboardControl, GooseDefault::DashboardNoAuth, GooseDefault::DashboardHost, GooseDefault::DashboardPort, GooseDefault::DashboardAuthToken, GooseDefault::DashboardMaxClients, and GooseDefault::NoPercentilesOverTime.
Feature flag: the HTTP server is compiled behind the opt-in
dashboardcrate feature (not in default features, same pattern aspdf-reports). Enable it with--features dashboard. Builds without that feature reject--dashboardat startup with a clear rebuild hint.
The server lives as long as the load test, with one exception: see Waiting after an autostarted run. When GooseAttack::execute() returns, whether the run finished or failed, connected browsers receive event: closed, open connections get up to one second to finish, and the listener and every connection are closed before execute() returns, so a program can run another GooseAttack on the same port right away.
Observe vs control at a glance
| Capability | Live Dashboard (observe) | Live Dashboard + control | Controllers (telnet / WebSocket) |
|---|---|---|---|
| Live rates, percentiles, errors | Yes | Yes | metrics / metrics-json |
| Trailing RPS / users / latency charts | Yes | Yes | No |
| Start / stop / set users | No | Yes | Yes |
| Host / ramps / run time | No | Yes | Yes |
| Test plan | No | Through POST /api/v1/control/settings or POST /api/v1/control/start only | Yes (test-plan) |
config / metrics | No | No | Yes |
| Process shutdown | No | Quit, from Idle only | Yes (shutdown, any phase) |
| Default bind | 127.0.0.1:5118 | 127.0.0.1:5118 | 127.0.0.1:5116 / :5117, WebSocket only with --websocket |
| Auth | Token on metric APIs when configured | Token for control unless --dashboard-no-auth; metrics follow observe rules | None (see Controller docs) |
When control is off, POST /api/v1/control/* returns 404 (routes are not registered). Prefer Controllers for config, metrics and shutting down a running test; use dashboard control for Start, Stop, users and the run settings from a browser.
Bind and token rules
On a shared machine
Every user of the machine running Goose can reach what Goose serves on loopback. This is what each of them can do, and how to close it:
| Who | What they can reach | To close it |
|---|---|---|
| Any local process | The telnet Controller, on by default on 127.0.0.1:5116 with no authentication: start, stop, change users, the host, the ramps, the run time and the test plan, read config and metrics, and shut Goose down | --no-telnet |
| Any local process, or any page in a local browser | The WebSocket Controller, when --websocket turns it on; it takes no authentication and accepts a connection from any web page | Leave --websocket off |
| Any local user, with no token set | The dashboard’s metrics (request names, hosts, error strings, rates) and the run’s details (its notes, its --report-file paths and its run settings: host, ramps, run time and test plan): snapshots, the event stream, the per request heatmap, and every saved report in the runs folder, to list and download. By holding the event stream open when an autostarted run ends, they also keep Goose and its port up until it is told to quit (Waiting after an autostarted run) | A token, set in code as below |
Any local user, under --dashboard-no-auth with --dashboard-control | Everything in the row above, and Start, Stop, users, the run settings (including the host the load goes to), Quit, the report switches, Save report now, and Delete of saved reports and logs | A token in place of --dashboard-no-auth |
| Any local user, with a token set | The page’s HTML and static files, and GET /api/v1/health: among other fields, the Goose version, whether control is on, the Controllers’ addresses and how many pages are watching. They can also fill the connection cap, which every client shares, token or not: a user who keeps 2 × --dashboard-max-clients + 16 connections open, sending a request on each at least every 10 seconds, keeps your browser out until they stop | The metrics are already closed. No setting closes the lockout: run where you trust the other users, or behind a firewall when the bind is off loopback |
| Any local user, through the file system | The run folders under goose-runs (reports and logs), which Goose writes as any file, with the process’s umask, so a usual umask lets every local user read them | Run Goose under umask 077, or keep the runs under a folder only you can read, such as --output-dir ~/private/nightly with ~/private at mode 700: a second run with the same --output-dir, once that folder holds files, writes a new folder beside it, nightly-2 |
| Any local user, with the token on the command line | The token itself, in process listings (ps) | Set it in the load test’s code with GooseDefault::DashboardAuthToken, as below |
With the dashboard off loopback, “local” in the dashboard rows of this table becomes everyone who can reach its port (the telnet and WebSocket Controllers keep their own binds), and the token and the metrics cross the network as unencrypted http.
To keep the token off the command line, read it in the load test’s code, for example from an environment variable, and set it as a default. Make the token long and random, such as the output of openssl rand -hex 32: Goose does not limit wrong guesses on any route, and logs them only on the control routes and Delete. An empty token counts as none, so refuse one, as this does:
#![allow(unused)]
fn main() {
let token = std::env::var("DASHBOARD_TOKEN")
.ok()
.filter(|token| !token.is_empty())
.expect("set DASHBOARD_TOKEN to a non-empty value");
GooseAttack::initialize()?
.set_default(GooseDefault::DashboardAuthToken, token.as_str())?
// register scenarios and run as before
}
A token written into the source instead is readable by anyone who can read the source or the binary. The token can also turn up in other places: in a reverse proxy’s access log, as ?token= on the first page load (/?token=) and on the page’s metric URLs (see Security notes); in a browser tab duplicated or restored from the one that opened the dashboard (see Browser bootstrap with ?token=); and in curl command lines, which ps shows like any other (see curl examples). An export DASHBOARD_TOKEN=... typed at a shell also lands in its history; read -rs DASHBOARD_TOKEN && export DASHBOARD_TOKEN in bash or zsh sets it without echoing it or writing it there. On Linux a process’s environment, unlike its arguments, is readable only by its own user and root.
The token also goes to whatever listens on the dashboard’s port. If another user holds that port when Goose binds it, after building its users or at once with --no-autostart, Goose logs [dashboard]: failed to bind ...: ... (is the port already in use?) and exits, and the other process keeps the port: a tunnel or a reloaded tab then sends your token to it. So does a tab left open after Goose stops without a clean exit (killed or crashed): the page keeps retrying the event stream and polling, with the token, until it gets through. Send the token only while your own Goose is up, as its listening on line shows. That is an info line, which -q hides; under -q, check that lsof -iTCP:5118 -sTCP:LISTEN names your own Goose process before you send the token. Close the dashboard tab once Goose is gone, and pick another port with --dashboard-port when the default is taken.
So on a shared machine: keep the dashboard on loopback, set a token in the load test’s code, add --no-telnet unless you need the telnet Controller, leave --websocket off, run under umask 077 or keep the run folders under a folder only you can read, reach the dashboard from your own machine over an SSH tunnel, and send the token only once your own Goose holds the port, as above.
Loopback default and auth policy
By default the dashboard binds to loopback only (127.0.0.1). That is intentional: metrics snapshots include request names, error strings, hostnames and rates, and the run’s notes, --report-file paths and run settings; none of it should be world-readable.
Goose treats a bind host as loopback when it is:
localhost(case-insensitive, no DNS lookup)- any IPv4 address in
127.0.0.0/8(for example127.0.0.1,127.0.0.2) - IPv6 loopback
::1(with or without brackets:[::1])
Non-loopback binds, including unspecified addresses that listen on all interfaces (0.0.0.0, ::) and LAN addresses and hostnames, require a token at startup (--dashboard-auth-token or GooseDefault::DashboardAuthToken), unless --dashboard-no-auth is set. Goose refuses to start, with an error, if neither is given.
If a token is configured, it is enforced, even on loopback, on the metric APIs: every metric and report route that What requires a token lists.
On a loopback bind every request must carry a loopback Host header (localhost, an address in 127.0.0.0/8 or [::1], with any port), whatever the token setting; anything else, a missing Host included, gets 403 with the plain text body host not allowed. This covers every route, the page, its static files and /api/v1/health included. This stops DNS rebinding: a web page that points its own domain at 127.0.0.1 sends its own domain as Host, so it cannot read snapshots from a dashboard that has no token. A reverse proxy in front of a loopback bind must pass a loopback Host (nginx proxy_pass http://127.0.0.1:5118 does by default). Whether a bind is loopback is decided by the --dashboard-host value as written, by the list above. A hostname that resolves to a loopback address does not count as loopback: this Host check does not apply to it, and it needs a token like any other non-loopback bind.
Security matrix (observe vs control)
| Bind | Observe (--dashboard) | Control (--dashboard-control) |
|---|---|---|
| Loopback, no token | Metrics OK without auth | Startup error (control needs a token or --dashboard-no-auth) |
| Loopback, token set | Metrics require token | Control requires token |
Loopback, --dashboard-no-auth | Metrics OK without auth | Control open without a token; cross site requests refused |
| Non-loopback, no token | Startup error | Startup error |
| Non-loopback, token set | Metrics require token | Control requires token |
Non-loopback, --dashboard-no-auth | Metrics open; startup warning, page banner, Host rule | Control open without a token; startup warning, page banner, Host rule, cross site requests refused |
Any bind, --dashboard-no-auth with --dashboard-auth-token | Startup error | Startup error |
“Metrics” in this table means every route the token guards when one is set: the snapshot, the event stream, the per request heatmap and the saved reports, which can be listed and downloaded. What requires a token lists them. Every loopback bind also checks Host on every route, as Loopback default and auth policy explains; the “Host rule” in the table is the check off loopback under --dashboard-no-auth, in Off loopback, and “cross site requests refused” is the check in Control without a token.
Additional control rules:
--dashboard-controlwithout--dashboard→ startup hard-fail (control does not auto-enable the dashboard). The same goes for--dashboard-no-authwithout--dashboard.- Without
--dashboard-no-auth, control endpoints always require a non-empty--dashboard-auth-token, even on loopback, so other local processes cannot POST start/stop/users without the secret. The telnet Controller, on by default, takes the same commands from any local process with no authentication; see On a shared machine. - Startup log shows capability at a glance:
(read-only),(read-only, no token),(control enabled)or(control enabled, no token); see Startup lines.
Startup lines
Goose says at startup what the dashboard allows and how to get control. Read-only:
[dashboard]: listening on http://127.0.0.1:5118 (read-only)
[dashboard]: read-only; for control add --dashboard-control and either --dashboard-no-auth (no token) or --dashboard-auth-token <token>
With a token already set, the second line reads read-only; for control add --dashboard-control, as --dashboard-no-auth would then be refused.
Control with a token, which is never printed; <token> is the literal placeholder:
[dashboard]: listening on http://127.0.0.1:5118 (control enabled)
[dashboard]: open http://127.0.0.1:5118/?token=<token> to control the test
On an unspecified bind, --dashboard-host 0.0.0.0 or ::, the listening line names the bind address, and the line to open names 127.0.0.1 or [::1], which a browser on the same machine can open: open http://127.0.0.1:5118/?token=<token> to control the test. From another machine, use this machine’s address instead.
Control with --dashboard-no-auth on loopback, the second line at warn level:
[dashboard]: listening on http://127.0.0.1:5118 (control enabled, no token)
[dashboard]: --dashboard-no-auth: anyone on this machine can start, stop and redirect the test
Read-only with --dashboard-no-auth, the listening line reads (read-only, no token) and the second line read-only; for control add --dashboard-control. Off loopback, --dashboard-no-auth adds the warning in Control without a token.
Control without a token
--dashboard-no-auth lets the dashboard start, stop, quit and change users, the run settings and the report switches, save the report now, and delete saved reports and logs, with no token and no ?token= URL:
cargo run --release --features dashboard --example simple -- \
--dashboard --dashboard-control --dashboard-no-auth \
-H https://staging.example.com \
-u 50 -t 10m
Open http://127.0.0.1:5118/. The header reads Live dashboard · control, no token, and the Control card works as with a token. On loopback anyone on the machine can use it, as with a read-only dashboard with no token; with control on, Goose warns at startup.
--dashboard-no-auth can’t be given with --dashboard-auth-token, and needs --dashboard. Without --dashboard-control it keeps the dashboard read-only and lifts the token rule for a non-loopback bind.
With no token, nothing but the browser’s own rules keeps another web page from sending the dashboard a request, so the control routes (POST /api/v1/control/* and DELETE /api/v1/reports/{name}) refuse what another site’s page can send. A request with an Origin other than http:// followed by its own Host, or without Content-Type: application/json, gets 403 with { "ok": false, "reason": "cross_origin" }. A form, a link and a plain fetch from another site can’t send that content type without the browser first asking the server, and Goose sends no CORS headers, so the browser never lets them. The dashboard page sends Content-Type: application/json on every control request, with {} as the body of a POST with nothing else to send; its Delete sends the header with no body. Behind a reverse proxy that serves the page over https, or that rewrites Host, the page’s Origin no longer matches, and every control request is refused this way.
Under --dashboard-no-auth, control works through a proxy only when the page’s Origin still matches the Host Goose sees. Off loopback Goose takes any IP address as Host, so a proxy reached by an IP address only has to serve plain http and forward Host unchanged. A proxy reached by a name takes three things:
- the proxy serves plain http, under a name that resolves to the machine Goose runs on;
- it forwards that
Hostunchanged; - Goose runs with
--dashboard-hostset to that name, and binds an address it resolves to.
Such a proxy adds no encryption. For https at the proxy, use a token instead. Behind a proxy to a loopback bind, use a token too: that bind takes only a loopback Host, which never matches the page’s Origin once the proxy is reached by any other name.
curl and scripts send no Origin, so they need only the content type:
curl -X POST -H 'Content-Type: application/json' -d '{}' \
http://127.0.0.1:5118/api/v1/control/start
Off loopback
On a non-loopback bind, such as --dashboard-host 0.0.0.0, anyone who can reach the port can watch the test and, with --dashboard-control, start, stop and point the load at any host. Goose starts, and writes this warning straight to standard error, where neither -q nor the log level hides it, and to the Goose log at warn level:
==============================================================================
WARNING: the dashboard on http://0.0.0.0:5118 has no token (--dashboard-no-auth).
Anyone who can reach this port can watch the test, start and stop it,
and point the load at any host.
Safer: bind 127.0.0.1 and use an ssh forward (ssh -L 5118:127.0.0.1:5118 host),
or use --dashboard-auth-token in place of --dashboard-no-auth. Only IP addresses,
localhost and the --dashboard-host name are accepted as Host; set --dashboard-host
to the DNS name you browse to.
==============================================================================
Without --dashboard-control the second sentence reads Anyone who can reach this port can watch the test. The page shows the first two sentences in a warning banner that names the address the page was opened at and stays for as long as the page is open.
The safer ways are an SSH tunnel to a loopback bind, or a token.
Under --dashboard-no-auth, off loopback the server also checks Host on every route: it takes an IP address (10.0.0.5:5118, [fe80::1]:5118), localhost, or the --dashboard-host value, with any port, and refuses any other name with 403 and { "ok": false, "reason": "host_not_allowed" }. A page that points its own domain at the load generator (DNS rebinding) sends that domain as both Host and Origin, so the Origin rule alone would let it through. To browse to the dashboard by a DNS name, set --dashboard-host to that name; Goose then binds an address that name resolves to.
What requires a token
| Path | Auth when token configured | Notes |
|---|---|---|
GET / (the page’s HTML shell; the page is a single page app, SPA) | Public | |
GET /static/app.js, GET /static/app.css | Public | |
GET /api/v1/health | Public | Liveness and timing counters; includes control_enabled and the Controller addresses (no load-test metrics); see Health endpoint exception |
GET /api/v1/snapshot | Required | |
GET /api/v1/events (SSE) | Required | |
GET /api/v1/series | Required | One request’s response time heatmap; see Request heatmap endpoint |
GET /api/v1/reports and /api/v1/reports/* | Required | The runs panel’s reports; see Report endpoints |
POST /api/v1/control/* | Required when control is enabled, unless --dashboard-no-auth | Even on loopback; 404 when control is off |
DELETE /api/v1/reports/{name} | Required when control is enabled, unless --dashboard-no-auth | Control token, as for POST /api/v1/control/*; 404 when control is off |
Under --dashboard-no-auth no token is configured, so no route needs one: the metric and report routes are open as on a loopback bind with no token, and the control routes take a request without a token and refuse a cross site one instead; see Control without a token.
The shell and static assets stay public because classic <script src> loads cannot forward a document ?token= query (or an in-memory secret). Sensitive data lives only in the snapshot, event stream, series and report APIs; mutation lives only on the control routes (the control POSTs and DELETE /api/v1/reports/{name}).
Browser bootstrap with ?token=
Browsers’ EventSource cannot set Authorization headers. For remote or token-protected dashboards, open:
http://{host}:{port}/?token=SECRET
The embedded UI reads token from the URL on load, strips it from the address bar with history.replaceState, and attaches ?token= to /api/v1/snapshot, /api/v1/events and /api/v1/series requests.
The UI keeps the token in the tab’s sessionStorage, so reloading the page keeps it. A new tab or window opened on the bare URL does not get it and needs ?token= again, and the token goes away with the tab. A tab duplicated from the browser menu gets a copy, since browsers copy sessionStorage when duplicating a tab, and a browser that restores closed tabs may restore it with the tab. It is never written to localStorage. A token in the URL replaces a stored one. When the server answers 401 to a snapshot, event stream or series request, or to a start, stop, users, quit or settings request, the UI removes the stored token (a 401 on the report switches, Save report now, a Delete or a report route only shows the error), so a stale token (for example after Goose restarts with a new one) is not sent again on the next reload.
Control POSTs from the SPA use Authorization: Bearer only (never ?token= on control routes), to avoid putting the secret on mutating URLs that may appear in proxy logs.
If the shell loads without a token when one is required, the page still renders, but metric calls return 401 and the UI shows:
Open this dashboard as http://host:port/?token=… (token required for metrics).
Non-browser clients may use either for metrics:
curl -H "Authorization: Bearer SECRET" http://host:5118/api/v1/snapshot
# or
curl "http://host:5118/api/v1/snapshot?token=SECRET"
GET /api/v1/snapshot returns 503 with an empty body when no snapshot arrives within five seconds, which happens while the main loop (the Goose task that runs the load test’s phases) or the metrics processor is busy (for example building the users of a large users change), or when the dashboard is shutting down. Retry after a short wait.
For control, the server accepts Authorization: Bearer only (query ?token= is rejected on control routes):
curl -H "Authorization: Bearer SECRET" \
-H "Content-Type: application/json" \
-d '{}' \
-X POST http://host:5118/api/v1/control/start
Example: bind on all interfaces
read -rs DASHBOARD_TOKEN && export DASHBOARD_TOKEN
cargo run --release --features dashboard --example simple -- \
--dashboard \
--dashboard-host 0.0.0.0 \
--dashboard-auth-token "$DASHBOARD_TOKEN" \
-H https://staging.example.com \
-u 200 -t 1h
From another machine, open http://<this machine's address>:5118/?token=<token>, with the token’s value in place of <token>. On a shared machine, set the token in the load test’s code instead of on the command line, where ps shows it to every user; see On a shared machine. Goose serves plain http, so the token and the metrics cross the network unencrypted; SSH tunnels avoid that.
Security notes (summary)
- Default off, default loopback bind; control is a separate opt-in (
--dashboard-control). - Non-loopback without a configured token → startup hard-fail, unless
--dashboard-no-authis set. - Control without a token → startup hard-fail, even on loopback, unless
--dashboard-no-authis set. - Token protects metric APIs when configured; control POSTs require the token when control is on, unless
--dashboard-no-authis set. --dashboard-no-authwith--dashboard-auth-token→ startup hard-fail. Under--dashboard-no-auththe control routes refuse a cross site request (403cross_origin), and off loopback Goose warns at startup, the page shows a warning banner, and only an IP address,localhostor the--dashboard-hostname is taken asHost(403host_not_allowed).- Shell/static/health stay public and contain no load-test metrics.
- Prefer
Authorization: Bearerfor scripts; browsers use?token=for metrics (EventSource limits) and Bearer only for control POSTs (with a token set, the server rejects query tokens on/api/v1/control/*). - Query tokens on the first page load and on metric URLs can appear in reverse-proxy access logs; prefer an SSH tunnel, or a reverse proxy whose access log only you can read or that does not log query strings, when that matters. Every response sends
Referrer-Policy: no-referrer, so the page does not pass its?token=URL on as aReferer. --dashboard-auth-tokenis visible in process listings (ps) like any CLI argument; on a shared machine, set the token in code instead, as On a shared machine shows.- Goose never logs the token value; the startup lines name the
<token>placeholder, never the secret. See What Goose logs. - The UI renders metric fields with
textContentonly (noinnerHTML) and serves a strict Content-Security-Policy without'unsafe-inline'scripts. - No session cookies and no CORS headers; classic cross-site cookie CSRF does not apply. With no token, under
--dashboard-no-auth, the control routes checkOriginandContent-Typeinstead. - Every response sends
Cache-Control: no-store(the first page load carries?token=and the APIs carry metrics) andX-Content-Type-Options: nosniff. - On a loopback bind, requests whose
Hostis not loopback get 403 (DNS rebinding). - A client gets 10 seconds to send a request’s headers, on a new connection and between requests on a keep-alive one, and the server holds at most
2 × --dashboard-max-clients + 16connections, so a client that sends nothing is closed after 10 seconds and the number of open connections is bounded. The cap is shared by every client, with a token or without one, so a client that holds that many connections open keeps every other client out; Goose logs each connection it closes this way only at debug level. Long-lived SSE streams are not cut: the timeout covers headers only.
What Goose logs
The main loop is the Goose task that runs the load test’s phases and applies control commands. Goose logs at info each control request the main loop answers, as [dashboard]: control <name> ok or rejected, with the phase, and logs at warn each one it can’t answer (control admission full, control timed out, control unavailable). Goose does not log a control request it refuses before the main loop sees it: a malformed body, an out of range value, a body over 16 KiB, or one that fails the cross site or Host checks (see Control without a token and Loopback default and auth policy). A missing or wrong token on a control POST (report and report/now among them) or on a Delete is logged at warn, and each deleted report at info. A missing or wrong token on a metric route or a report download is not logged, and no line at info or warn names the client’s address. Goose never logs the token value: the startup lines name the <token> placeholder, and a Controller’s config and config-json show a set token as (set).
Info lines go to the console, where -q hides them. Without --goose-log (-G) there is no log file, and the file -G writes holds warn and error lines only unless -g adds info. For a record of control requests and Deletes on disk, run with -G and -g.
SSH tunnels
Prefer keeping Goose on loopback and tunneling from your laptop. On a shared machine, open the tunnel only once your own Goose holds the port (On a shared machine says how to check):
ssh -L 5118:127.0.0.1:5118 loadgen.example.com
# open http://127.0.0.1:5118/ locally
With a loopback bind and no auth token (observe-only, or control under --dashboard-no-auth), no ?token= is required over the tunnel. If you set a token (required when control is enabled without --dashboard-no-auth), pass it in the browser URL; see Browser bootstrap with ?token=. On a shared machine a loopback bind with no token is open to every local user, so set a token there as well.
TLS termination is not provided by Goose; use an SSH tunnel or reverse proxy when you need encryption in transit. A reverse proxy must pass a loopback Host to a loopback bind (Loopback default and auth policy); under --dashboard-no-auth one that serves https or rewrites Host gets every control request refused (Control without a token); and its access log records the ?token= on the first page load and on metric URLs (Security notes).
Control endpoints
Controlling a load test from the dashboard
Control is off by default. Enable it with --dashboard-control and a token, as here, or with --dashboard-no-auth instead of the token; see Control without a token:
read -rs DASHBOARD_TOKEN && export DASHBOARD_TOKEN
cargo run --release --features dashboard --example simple -- \
--dashboard \
--dashboard-control \
--dashboard-auth-token "$DASHBOARD_TOKEN" \
-H https://staging.example.com \
-u 50 -t 10m
Open http://127.0.0.1:5118/?token=<token>, with the token’s value in place of <token>; on a shared machine, set the token in code, as On a shared machine says. The header shows Live dashboard · control, and a control panel appears with Start, Stop, a target-users input (Apply, − / + step buttons), Edit settings, which opens a form for the host, the ramps and the run time, and, in Idle, Quit.
--no-autostart recipe
--no-autostart keeps Goose in Idle until something starts the test. It is allowed when any Controller is enabled (the telnet Controller is on by default, the WebSocket Controller only with --websocket) or when --dashboard-control is set (with dashboard, and a token or --dashboard-no-auth):
read -rs DASHBOARD_TOKEN && export DASHBOARD_TOKEN
cargo run --release --features dashboard --example simple -- \
--no-autostart \
--no-telnet \
--dashboard \
--dashboard-control \
--dashboard-auth-token "$DASHBOARD_TOKEN" \
-H https://staging.example.com \
-u 50
Then Start from the UI or via POST /api/v1/control/start. On a shared machine, set the token in code instead, as On a shared machine says, so ps does not show it. Without Controllers and without dashboard control, --no-autostart is a startup hard-fail (there would be no way to leave Idle). The same goes, with dashboard control off, when the Controllers are enabled but none could listen on its port.
Endpoints
| Method | Path | Body | Description |
|---|---|---|---|
POST | /api/v1/control/start | empty, {}, or run settings | Start an idle load test → enters Increase; with run settings, apply them and start in one step, all or none |
POST | /api/v1/control/settings | run settings | Apply run settings without starting, all or none; see Run settings |
POST | /api/v1/control/stop | empty or {} | Stop all users at once → enters Decrease (eventual Idle); accepted only in Increase and Maintain, else "ok": false with invalid_phase |
POST | /api/v1/control/users | {"users": N} | Set absolute target user count (N an integer from 1 to 100000) |
POST | /api/v1/control/quit | empty or {} | Shut Goose down; accepted only in Idle |
POST | /api/v1/control/report | {"save": B, "graph_data": B}, either or both, each B a boolean | Set Save report and Include graph data; see Saving the report |
POST | /api/v1/control/report/now | empty or {} | Save report now; accepted only while a run is in progress, else invalid_phase (invalid_report under --no-metrics) |
DELETE | /api/v1/reports/{name} | none | Delete a folder’s reports and logs; see Report endpoints |
Auth: Authorization: Bearer <token> only (query ?token= → 401). Missing/wrong token → 401. Control disabled → 404. Under --dashboard-no-auth no token is needed, and a cross site request gets 403; see Control without a token.
curl examples
These pass the token on curl’s command line, which ps shows to every user. On a shared machine, put the header in a file only you can read and pass it with -H @<file> (curl 7.55 or later) instead.
read -rs DASHBOARD_TOKEN && export DASHBOARD_TOKEN
BASE="http://127.0.0.1:5118"
# Start (success means the phase entered Increase, not that test_start finished)
curl -sS -X POST \
-H "Authorization: Bearer $DASHBOARD_TOKEN" \
-H "Content-Type: application/json" \
-d '{}' \
"$BASE/api/v1/control/start"
# Stop (success means the cancel began: the phase is decrease, not idle)
curl -sS -X POST \
-H "Authorization: Bearer $DASHBOARD_TOKEN" \
-H "Content-Type: application/json" \
-d '{}' \
"$BASE/api/v1/control/stop"
# Start against another host at 20 users per second, holding 10 minutes
curl -sS -X POST \
-H "Authorization: Bearer $DASHBOARD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"host":"https://staging.example.com","increase_rate":20,"run_time_secs":600}' \
"$BASE/api/v1/control/start"
# Hold the running test 5 more minutes than it started with
curl -sS -X POST \
-H "Authorization: Bearer $DASHBOARD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"run_time_secs":900}' \
"$BASE/api/v1/control/settings"
# Set absolute user count
curl -sS -X POST \
-H "Authorization: Bearer $DASHBOARD_TOKEN" \
-H "Content-Type: application/json" \
-d '{"users":100}' \
"$BASE/api/v1/control/users"
# Quit (only while idle: Goose shuts down)
curl -sS -X POST \
-H "Authorization: Bearer $DASHBOARD_TOKEN" \
-H "Content-Type: application/json" \
-d '{}' \
"$BASE/api/v1/control/quit"
Example success bodies:
{
"ok": true,
"command": "start",
"message": "load test started",
"phase": "increase",
"active_users": 0,
"target_users": 50
}
{
"ok": true,
"command": "stop",
"message": "load test stopped",
"phase": "decrease",
"active_users": 50,
"target_users": 0
}
Logical rejections return HTTP 200 with "ok": false and a stable error code: invalid_phase when the phase cannot take the command, prepare_failed when a Start cannot prepare the load test, conflicting_settings when the command conflicts with other settings, such as users 1 while an increase time is set, no_host when clearing the host would leave something with no host, and internal when applying the command fails inside Goose, which then ends the load test with that error. The report control routes add three: invalid_report when Save report is turned on under --no-metrics or during a run with no folder, and for Save report now under --no-metrics; report_busy while Save report now is still writing the report it was last asked for; and report_failed when Save report now can’t start writing the report, as during a run with no folder or when its folder can’t be written to, with the reason in message. A write that fails after an ok: true answer shows in the snapshot’s report.error. When one setting is at fault the body also names it in field (for example "field": "users", or "field": "host" for a Start with no host); field is left out otherwise. A malformed body returns 400 with error set to bad_request, and a users value that is not an integer from 1 to 100000 returns 400 with invalid_users; those two 400 bodies, and every 503 body below, have "field": null. A run settings body has 400 refusals of its own, which name the field; see Run settings. A body larger than 16 KiB gets 413 with a plain text body, before the token is checked.
A Start is refused with prepare_failed and "field": "host" before it replies when some scenario has no host from --host, the scenario itself or the default host, and also when a test_start or test_stop transaction is registered and neither --host nor the default host is set, since those run with no scenario host. An autostarted run with the same configuration fails at launch instead.
When the main loop does not answer, a control POST returns 503 with "ok": false, an error code from this table, and phase, active_users, target_users and field set to null:
error | When | Client action |
|---|---|---|
busy | Four control requests are already waiting for the main loop | Wait and retry |
unavailable | The main loop no longer takes control requests, for example because the load test is exiting | Do not retry |
timeout | The main loop did not answer within 15 seconds | Re-read phase and target_users from a snapshot before retrying: a command still queued at the timeout is skipped, but one the main loop had started may still apply |
Start, stop, users and run settings
Start and Stop semantics
Start success means the test entered Increase, not that test_start transactions finished. Goose replies as soon as the phase transitions (same timing as Controllers), then runs reset_run_state / user test_start on the main loop. A long test_start does not block the HTTP 200.
Stop is accepted only in Increase and Maintain. In any other phase, a planned ramp down or a users decrease among them, it replies "ok": false with invalid_phase; to end a ramp down early, use a Controller’s shutdown or Ctrl-C, both of which also exit Goose.
Stop is not instantaneous idle. A successful Stop begins a cancel through AttackPhase::Decrease (Canceling), which stops all users at once, whatever the decrease rate or time: those shape a users ramp’s own ramp down, never a Stop. The process returns to Idle only after decrease finishes. While a Stop’s cancel runs (phase decrease with stopping true):
- Stop is disabled (a Stop is already under way)
- Start stays disabled until phase is
idleagain - Users is refused until phase is
idleagain (the dashboard returns"ok": falsewithinvalid_phase, a Controller replies false), so Users cannot turn a Stop back into a running load test
The snapshot’s stopping field is true from the Stop until idle; a Controller shutdown and Ctrl-C set it the same way. A users ramp’s own ramp down is also phase decrease, but stopping stays false and Users is accepted there: the ramp down is abandoned and the run holds the new count until stopped. While a test plan runs, Users is refused with invalid_phase and "field": "users", as the plan sets users: edit the plan, or stop and set users to switch to a users ramp.
Users on a running test
A users ramp keeps its shape when the user count changes mid run (Target, the users command, or POST /api/v1/control/users): Goose takes the new count and keeps the hold (the run time after the ramp up) and the ramp down, and regenerates the steps still ahead from them.
| When Users arrives | What happens |
|---|---|
| During the ramp up | Ramp to the new count, then hold for the full run time, then ramp down for the new count; a new count that is the one the run already has holds that count until stopped |
| During the hold | Ramp to the new count; the hold keeps its end. If the ramp outlasts it, or the new count is the one the run already has, the run holds that count until stopped, with no ramp down |
| During the ramp down | The ramp down is abandoned, and the run holds the new count until stopped |
| While holding until stopped | Ramp to the new count, and keep holding |
Adding users ramps at the increase rate; removing them at the decrease rate, else the increase rate; 1 per second for an increase with no increase rate, and for a decrease with neither rate. Increase and decrease times are not used for these changes. A decrease rate sent while a users ramp runs regenerates the ramp down still ahead at the new rate; on a run with a run time and no ramp down, where all users would stop at once when the hold ends, it adds a ramp down at that rate; on a run holding until stopped it only sets the rate for the next Users decrease, a later run time, and the next Start. Clearing the decrease rate (null) on a running test removes a ramp down this run added, so all users stop at once again, and is refused with conflicting_settings while a ramp down the run started with is still ahead. An increase rate sent while running is for the next Users increase, and never re-paces a ramp in progress. While running, either rate is refused with conflicting_settings when its own time is set, since a time changes only in Idle, and both are refused during the ramp down, while stopping, and while a test plan runs.
Run settings
POST /api/v1/control/settings applies run settings, and POST /api/v1/control/start applies them and starts in one step, so no other client can change a setting in between. Both take the token (the one --dashboard-auth-token or GooseDefault::DashboardAuthToken sets; there is no second token for control) and the 16 KiB body limit, and go to the main loop as one command, checked as a whole: a refusal, a Start’s prepare_failed among them, leaves every setting, the test plan, the running test and target_users as they were.
{
"host": "https://staging.example.com",
"users": 100,
"increase_rate": 10,
"increase_time_secs": null,
"decrease_rate": 5,
"decrease_time_secs": null,
"run_time_secs": 600,
"test_plan": null
}
- A field left out is left alone, and
nullclears it back to Goose’s default. Clearinghostgoes back to each scenario’s host, or the default host. A field that is not one of these is refused. - Values are bounded as the Controllers bound them:
hostanhttporhttpsurl with no user name or password;users1 to 100000; the rates 0.01 to 100000 users per second; the times whole seconds from 1 to 30 days (0is refused: sendnullto clear);test_planin the--test-planformat, within the bounds of the Controller commands: at most 100 steps of up to 30 days each, in at most 4096 bytes, starting 1 to 100000 users in total. test_planswitches to a test plan and clears users, the ramp settings and the run time;usersswitches back and clears the test plan.test_plan: nullis accepted only withusers. While a test plan is configured, a ramp setting withoutusersis refused with"field": "users".- In Idle, a rate and its own time each replace the other, so a rate alone clears its time, and a time alone clears its rate; on a running test a rate is refused while its own time is set. The two cannot both be set in one body. A test plan cannot be sent with
usersor a ramp setting, though clearing a ramp setting beside it withnullis accepted. users: nullis refused. On a running testusersis refused in a settings body: change users on its own with/api/v1/control/users.
Refusals are checked in this order, and the first one is answered:
| Status | error | When |
|---|---|---|
| 400 | bad_request | The body is not JSON, not an object, or has a field Goose does not know |
| 400 | invalid_value | A value of the wrong type or out of bounds, and users: null |
| 400 | conflicting_settings | Settings that never go together, whatever the state |
| 200 | invalid_phase | A setting the phase cannot change, set or cleared |
| 200 | conflicting_settings | A ramp setting while a test plan is configured ("field": "users"); a rate on a running test while its own time is set; clearing the decrease rate or the run time on a running test where that is refused; the command line’s rules (run time, a decrease rate or time, and a test plan under --iterations, a test plan under --no-reset-metrics, and in Idle a time with fewer than 2 users) |
| 200 | no_host | host: null would leave a scenario, or a test_start or test_stop transaction, with no host |
| 200 | prepare_failed | A Start that cannot prepare the load test, as without settings |
When one setting is at fault, the refusal names it in field (host, users, increase_rate, increase_time, decrease_rate, decrease_time, run_time or test_plan); a bad_request has "field": null, and a Start refused for its phase, or a prepare_failed not caused by a missing host, has no field. A reply to new settings, sent to settings or with a Start, carries settings as the snapshot does, whether accepted or refused by the main loop, so the page that sent them has the settings before the next snapshot; after a 503 timeout, read them from the next snapshot.
Which settings each phase takes, by settings.stage: ramp_up, hold and hold_until_stopped are a running users ramp, test_plan a running test plan, ramping_down the configured ramp down, and stopping a Stop, shutdown or Ctrl-C:
| Setting | Idle | Running, users ramp | Running, test plan | Ramping down | Stopping |
|---|---|---|---|---|---|
host | yes | no | no | no | no |
users (/api/v1/control/users) | yes | yes | no | yes, abandons the ramp down and holds | no |
increase_rate | yes | yes, for the next Users increase; refused while the increase time is set | no | no | no |
decrease_rate | yes | yes, regenerates the ramp down ahead, or only sets the rate on a run holding until stopped; refused while the decrease time is set | no | no | no |
increase_time_secs, decrease_time_secs | yes | no | no | no | no |
run_time_secs | yes | yes | no, the plan sets how long it runs | no | no |
test_plan | yes | yes, replaces the ramp | yes, restarts from its first step | yes, replaces the ramp down | no |
A test plan sent to a running test starts from its first step, ramping from the current user count.
Run time on a running test
The run time is the hold at full users after the ramp up, on a running test as in Idle. A new value is always written to the configuration, which settings.run_time_secs and the next Start read, and changes this run:
| When the run time changes | What happens |
|---|---|
| During the ramp up, before the hold | The hold ahead takes the new run time; with no run time before, a hold is added, then the run ramps down as configured, or stops all users at once |
| During the hold | The hold ends at its start plus the new run time; if that has passed, the ramp down starts at once. If the new run time puts the end of the hold earlier than the end of the ramp from a users change made during the hold, the run holds until stopped |
| While holding until stopped | The run holds from now for the new run time, then ramps down as configured, or stops all users at once |
Clearing it (null, or runtime 0 from a Controller) on a running test holds until stopped, and is refused with conflicting_settings while a ramp down is configured. The run time is refused during the ramp down, while stopping and while a test plan runs. Telnet and WebSocket take the same rules for runtime.
Run settings in the snapshot
Every snapshot carries settings: what the next Start runs, and where the running test is. It reaches every tab within a tick of a change from any front end.
"settings": {
"host": "https://example.com",
"hosts": ["https://example.com/"],
"users": 100,
"increase_rate": 10.0,
"increase_time_secs": null,
"decrease_rate": null,
"decrease_time_secs": null,
"run_time_secs": 600,
"test_plan": null,
"hold_ends_in_secs": 412,
"stage": "hold"
}
Names are the command line’s flags in snake case, durations are whole seconds with a _secs suffix, and null means unset, so Goose’s default applies.
| Field | Meaning |
|---|---|
host | The --host override, or null |
hosts | The hosts the next Start will load test: per scenario, --host, else the scenario’s host, else the default host, sorted and deduplicated, written as the top level hosts writes them (http://localhost/, with the slash). A scenario with no host from any source adds nothing, so it is right in Idle, after a host change, and under --no-autostart with no host yet. The top level hosts keeps meaning the current or last run’s hosts |
users | The configured user count, null only with a test plan. target_users is different: the count the current step ramps toward |
increase_rate, decrease_rate | Users per second |
increase_time_secs, decrease_time_secs | The ramp up or ramp down as a time |
run_time_secs | The hold at full users after the ramp up, not the total run time |
test_plan | The test plan as --test-plan takes it, in canonical form: each step’s duration in whole seconds, so 10,1h30m reads 10,5400 |
hold_ends_in_secs | Seconds until the hold ends once it has a fixed end, 0 while a ramp runs past the hold’s end, and still 0 once a users ramp outlasts the hold and the run holds until stopped (see Users on a running test); null at any other time |
stage | Where the run is: idle, ramp_up, hold, hold_until_stopped, ramping_down or stopping for a users ramp; idle, test_plan or stopping for a test plan. When a users ramp outlasts the hold and the run holds until stopped, stage still reads hold |
phase alone cannot say this: decrease with stopping false is a Users decrease, the configured ramp down, or a test plan step to fewer users. After Users during a ramp down, run_time_secs is unchanged while stage reads hold_until_stopped.
Health endpoint exception
GET /api/v1/health is intentionally unauthenticated so external monitors can check that the dashboard process is up without holding the metrics secret. Example response:
{
"ok": true,
"version": "<goose package version>",
"last_build_ms": 2,
"last_snapshot_ms": 180,
"snapshot_timeouts": 0,
"build_count": 42,
"active_sse_clients": 1,
"control_enabled": false,
"auth_required": true,
"loopback": true,
"controllers": { "telnet": "127.0.0.1:5116", "websocket": null }
}
| Field | Meaning |
|---|---|
ok | Always true when the handler runs |
version | Goose crate package version (CARGO_PKG_VERSION) |
last_build_ms | Time the metrics processor spent on the last published snapshot, draining pending metrics and building it, in milliseconds (0 if none yet) |
last_snapshot_ms | Round trip of the last published snapshot in milliseconds, from the dashboard’s request to the published JSON (0 if none yet) |
snapshot_timeouts | Snapshot requests not answered within 4 seconds since process start; a late answer is dropped, never published |
build_count | Successful hub snapshot builds since process start |
active_sse_clients | Concurrent SSE clients currently holding a slot |
control_enabled | Whether the POST /api/v1/control/* routes and Delete are registered (--dashboard-control) |
auth_required | Whether control needs a token: false only under --dashboard-no-auth, when the page sends no token; true otherwise. It does not say whether metrics need one: a loopback dashboard with no token reads true and serves its metrics to anyone on the machine |
loopback | Whether the dashboard is bound to a loopback address; with auth_required false and this false, the page shows the no token warning |
controllers | The host:port the telnet and WebSocket Controllers listen on, after defaults (127.0.0.1:5116 and 127.0.0.1:5117 unless set), or null for one turned off, the telnet Controller with --no-telnet and the WebSocket Controller unless --websocket turns it on, or one that couldn’t listen on its port; the page footer names them |
These counters are ops-safe (timing and client counts only). The response does not include rates, hosts under test, request names, or error strings. controllers names this process’s own listeners, which take no authentication; it reveals only where they listen. last_snapshot_ms minus last_build_ms is time spent waiting on the main loop and the metrics processor’s queue, plus serializing the JSON. The main loop answers the dashboard between steps and sleeps up to 500 ms between them, so this difference can reach about 500 ms by design; a large last_build_ms is the one that points at the metrics processor. Both durations are also logged at debug with a [dashboard] prefix when a build completes, and a late answer to a timed out request is logged with its round trip.
The page in detail
What the UI shows
- Phase badge:
idle(gray),increase(blue),maintain(green),decrease(orange),shutdown(red) - Connection indicator: green for SSE, yellow for poll fallback, red when disconnected, gray once Goose has exited
- KPI strip: users, RPS, fail %, and the whole run p95 and average latency. The tiles share the row, and each value is sized to its tile, so it stays on one line. The users tile shows the active users, then the target, smaller. RPS shows whole requests a second at 100 and above, and two decimals below 100; hover the tile for the two decimals the tables show.
- Charts: trailing series window (300 seconds) for RPS and failures per second and active users, and the response time heatmap; see The response time heatmap. The RPS and failures chart and the average latency chart, and their data tables, end at the last complete second: the newest second is still filling, so it is not drawn. Their colors follow the system’s light or dark theme, and change with it while the page is open.
- Whole run card: requests, failures, RPS, failures per second, fail %, average, p50, p95 and p99, and minimum and maximum response times
- Sortable tables: Scenarios, Transactions, Requests, Errors and Transaction errors. The scenario and transaction tables list every registered scenario and transaction and are never truncated; the request, error and transaction error tables show the top rows, 100 requests ranked by count and 50 of each kind of error ranked by occurrences (truncated server-side for large runs). The Transaction errors table counts each error a transaction returned that is not a request error, by transaction (numbered from 1), scenario, name and error. The Transactions and Requests tables show failures per second and minimum and maximum response times, and the Requests table each request’s status codes, most frequent first: the top three and how many more, such as
200 9,812 · 503 44 · 500 40 · +2, with every code on hover (empty with--no-status-codes) - Hosts in the header: the current or last run’s hosts. In Idle, when the next Start would load test other hosts, they read
Last run: …. - Run settings in the header, with control off: the same summary line the control panel shows, leaving out the hosts while running, and including the next Start’s hosts in Idle when they differ from the last run’s; see Run settings in the snapshot
- Control panel: with
--dashboard-control, the run settings summary, Start / Stop / Quit / target users; see Controlling a load test from the dashboard. Without it, the card says the dashboard is read-only and how to get control; see The read-only card - Notes in the header: the run’s
--notes - Report bar at the top of the page: the Save report and Include graph data switches, Save report now with control on, and the
--report-filepaths; see Saving the report - Runs panel: the reports in the folder that holds the run folders, with downloads and comparisons, and with
--dashboard-controla Delete button for each
Every figure in the KPI strip except the users, and every figure in the Whole run card and the tables, covers the whole run, since the test started or since metrics were last reset; the p95 and average tiles and the Whole run card say so in their labels, and hovering a table’s avg ms, min ms, p50, p95, p99, max ms or status codes heading says “Whole run”. Only the charts and the heatmap are by second. Goose measures response times to the microsecond. It shows times under 10 ms to the hundredth of a millisecond and from 10 ms in whole milliseconds, rounded half up, so a 0.418 ms response reads 0.42 and a 37.6 ms one 38. Averages follow the same rule.
Transaction times cover the whole transaction function, so they include --throttle-requests delays and any sleep inside it, and scenario times include the wait after each transaction. Every registered scenario and transaction has a row, so a scenario excluded by --scenarios, or one no user was assigned to, stays at 0 runs.
When control is off there are no control buttons. Use the Controllers (or enable dashboard control) to change the running test. The page footer names the address each Controller listens on, as GET /api/v1/health reports it, or says none is running.
When coordinated omission mitigation is on (--co-mitigation) and has recorded at least one event, the KPI strip, the Whole run card and the requests table show adjusted response times, which include the synthetic times mitigation adds for requests a stalled user could not send. The KPI tiles and the Whole run card label them “adjusted”, the requests table says so in a note above it, and hovering a value shows the measured one. The Whole run card shows both. The maximum is adjusted too; the minimum has no adjusted value and is always the measured one. The adjusted aggregate includes every request, using measured times for requests that never needed backfill, as the printed metrics and the reports do.
UI walkthrough (control panel)
When health.control_enabled is true and the SPA has a token, or health.auth_required is false:
- Phase badge still shows
idle/increase/maintain/decrease/shutdown. - Start is enabled only in
idle. - Stop is enabled in
increaseandmaintainonly. - Summary line: one line above the buttons says what the next Start runs, for example
https://example.com/ · 100 users at 10/s · hold 10 min · then 10/s down, and while running where the run is:ramping to 100 users at 10/s,ramp down in 6 min 52 sduring the hold,holding until stopped,ramping down at 10/s,stopping, or for a test plantest plan: 3 steps, peak 100 users, 1 h 1 min total. Its wording comes fromsettings.stage. It is not a live region, so the countdown is not announced. - Target users: enter an absolute count and Apply, or use − / + with a configurable step (default 10). Active user count is a read-only label from the latest snapshot. In Idle the field shows the configured users the next Start runs (empty with a test plan, where applying a count replaces the plan with a users ramp); while running, the count the run ramps toward. It follows the server unless you are editing it: an edit holds until its Apply succeeds and a snapshot reports the value, or until another client changes the count, when the field shows the new count and the status line says it changed elsewhere. − and + step from what the field shows. An unapplied edit is discarded, and the status line says so once, when the run starts stopping or a test plan starts. The field and step are read only, never disabled, with the reason under them, while
stoppingis set, while a test plan runs, and without a token. - Edit settings opens a form under the summary line for Host, Ramp up and Ramp down (each a number with users per second, or a total time in s, min or h), and Run time. It starts open in Idle and closed while running, and this browser remembers whether you opened or closed it, which then decides how it starts. Each field follows the server unless you are editing it, as Target does. An unapplied edit is pending: the form reads
Edit settings (N unapplied), Apply settings sends the pending edits, Discard edits drops them, and in Idle Start reads Start with changes and sends them with the start, the Target count among them. An emptied field goes out asnull, and a field you did not touch is left out. A number the browser cannot read is refused on the page, naming the field, rather than sent as a clear. While edits are pending in Idle, Target’s own Apply waits, and − / + only change the field. The first change of host in a page load asks first:Load will go to …under Host, with Confirm and Cancel, before anything is sent. Editing Host while the confirmation is open changes the destination it names; the confirmation closes, sending nothing, once the edits no longer change the host, or once the button that opened it can no longer act. A time shows in the largest unit that divides it evenly, so an applied120 sreads2 min, the same setting. While a users ramp runs, a ramp set by rate changes only its rate, a ramp set by time is read only (Set by time: 5 min; change it when idle), and the run time stays live, withheld 3 min 8 s of 10 minunder it during the hold. Fields the stage cannot change are read only, with the reason under them, and an edit the stage can no longer take is discarded, the status line saying so once. On a refusal that names a setting, the form opens, marks the field and moves focus to it. A test plan is set from a Controller or through/api/v1/control/settingsor/api/v1/control/start. - The status line shows the server’s
message, in red for a refusal; a 503 reads that control is busy or timed out, or gives the server’s message; a 401 also puts a banner at the top of the page saying to reopen with?token=. A 403 from the cross site guard readsRefused: the request's Origin doesn't match this dashboard's address (another site, or a proxy), and one from the Host ruleRefused: open the dashboard by its IP address or its --dashboard-host name. - Quit shows only in
idle, and shuts Goose down; the status line then readsGoose is shutting down.
Quit is refused outside idle ("ok": false with invalid_phase), so stop a running test first. A Controller shutdown command exits the Goose process from any phase.
The header subtitle says which dashboard it is: Live dashboard · control with a token, Live dashboard · control, no token under --dashboard-no-auth, and Live dashboard · read-only without control; it stays Live dashboard when GET /api/v1/health fails.
The read-only card
Without --dashboard-control the Control card stays, and says in place of its controls that the dashboard is read-only and how to get control:
Read-only: this dashboard can't start, stop or change the test.
To control it from here, restart Goose with --dashboard-control --dashboard-no-auth (no token), or with --dashboard-control --dashboard-auth-token <token> and open http://127.0.0.1:5118/?token=<token>.
Control this test via the telnet controller listening on 127.0.0.1:5116.
The address is the one the page was opened at. When the page has a token, or the dashboard runs with --dashboard-no-auth, only control is missing, and the second sentence reads To control it from here, restart Goose with --dashboard-control added. The last sentence names the telnet and WebSocket Controllers that listen, and is left out when neither does. The notice is a note, named Read-only dashboard for screen readers, and holds nothing that takes focus. When GET /api/v1/health fails, the card says Couldn't check whether control is on (/api/v1/health failed). Reload to retry. instead.
The response time heatmap
The response time card shows how long requests took, second by second, over the trailing window (300 seconds). Each column is one second, the oldest on the left. Each row is a range of response times: 100 microseconds each under a millisecond, 1 ms each up to 64 ms, 2 ms up to 100 ms, 10 ms up to 500 ms, 50 ms up to 1 second, 100 ms up to 5 seconds and 1 second up to 61 seconds, 237 rows in all. The bottom row holds responses under 100 microseconds, the row from 100 ms holds 100 to 109 ms, and the row from 1.4 s holds 1,400 to 1,499 ms. Past 61 seconds, which a longer --timeout allows, each row is one of the buckets Goose keeps a run’s times in, 0.8% to 1.6% as wide as the times it holds. Each row takes whole buckets, and a bucket that crosses a row’s edge is drawn in the row where it ends, so a time just under a row’s upper edge may be drawn in the row above it. The rows sit on a logarithmic latency axis, so each row is as tall as the range of times it holds, and the axis is marked at 0.1, 0.2 and 0.5 ms, 1, 2, 5, 10, 20, 50, 100, 200 and 500 ms and 1, 2, 5, 10, 20 and 60 s, as far as the data reaches. Seconds are labelled along the bottom at round steps of 1, 2, 5, 10, 15, 30, 60, 120 or 300 seconds from the start of the test, the smallest that gives at most eight labels, so a label stays on its second as the window slides.
The color of a cell shows how many requests in that second took that long, on a logarithmic scale running from the sparsest cell drawn to the busiest: the legend under the chart shows the sparsest cell’s count at its left end and the busiest’s at its right. In the light theme more requests are darker, in the dark theme brighter, and the legend marks the scale (log scale). Even the sparsest cell stands out from the card, so a cell of one request is visible. A second with no requests is empty. Hover a cell for its second, its row’s range of times, how many requests it holds and their share of the second’s requests, followed by that second’s p50, p95 and p99. A row under a millisecond reads as its range, such as 0.9 to 1 ms.
Three lines cross the heatmap: each second’s p50, p95 and p99, in one color and one width, p50 solid, p95 dashed and p99 dotted. Each is labelled at the right edge with its newest value, such as p95: 81 ms, or one label for lines with the same newest value, such as p95, p99: 4 ms, and the legend shows a sample of each stroke. A second’s percentile is worked out from the same counts its cells show, by the rule Goose uses for its whole run figures: the rank is the second’s request count times the percentile, rounded, and the percentile is the time of the first row, from the fastest, at which the running count of requests reaches that rank. A heatmap percentile is the middle of its row, where its point sits. A line breaks at a second with no requests. The labels, the tooltip, the summary and the data table show it truncated, to the tenth of a millisecond under 1 ms and in whole milliseconds from 1 ms, so the value shown always falls inside its row. In the Adjusted view the lines include the synthetic times, as the cells do.
Each column covers only the requests that started in that second. The KPI strip, the Whole run card and the tables cover the whole run instead, since the test started or since metrics were last reset, and say so. While a test runs, the two newest seconds are not drawn yet, because their slowest requests are still arriving.
Choose a request in the select at the top of the card to see that request alone, and All requests to go back. Response times are kept by second for each of the first 100 request names Goose records a response time for, in the order it first records one, counting again from each metrics reset. Those are the names in the select, as far as they are among the rows the Requests table receives; a name seen later counts toward all requests but is not in the select. The select lists them by method, then name, whatever the Requests table’s sort, and is not rebuilt while it has focus: a request that appears then is added when focus leaves it.
When coordinated omission mitigation is on and has recorded an event, two buttons choose what the heatmap counts: Adjusted, the default, as in the KPI strip and the tables, adds the synthetic times mitigation adds for requests a stalled user could not send, and Measured leaves them out. Synthetic times belong to the seconds of the stall, which Goose only learns about when it ends, so cells in seconds already drawn can change.
--no-percentiles-over-time turns the heatmap and the histograms behind it off, and the card shows the average latency chart instead. The same flag turns off the HTML report’s heatmap.
Snapshot data
Heatmap data
The snapshot’s series.heatmap carries the heatmap, or null when percentiles over time are off:
| Field | Meaning |
|---|---|
generation | Which set of histograms the columns come from. It changes when they are cleared, by a metrics reset or a new test, and a client then starts over. |
rev | The newest change the columns include. |
since | The rev these columns follow on from, or 0 for every column of the window. |
start_second, end_second | The window, in seconds from the start of the test. Columns before start_second are dropped. |
hold_from | Seconds from this one on are still filling and not drawn. |
buckets_us | [time, lowest, highest] in microseconds for each row index: the time the row stands for, which is its middle, then the lowest and highest time the row holds. A longer table replaces a shorter one; entries never change. |
columns | The columns that changed: { "second", "measured": [index, count, ...], "synthetic": [index, count, ...] }, each non empty bucket as an index and a count, synthetic empty unless mitigation has recorded an event. |
Only the columns that changed are sent. Every SSE client gets the same snapshot, whose heatmap holds the columns changed since the previous one; a client holding another rev, for example after it reconnects, asks GET /api/v1/snapshot?heatmap_generation=<generation>&heatmap_since=<rev> for the columns it lacks. Without those parameters GET /api/v1/snapshot returns every column of the window. The dashboard does all of this itself.
How big this is depends on how widely response times spread. In a test of 300 users at about 26 requests a second over 3 request names, each SSE snapshot’s heatmap took about 6.7 KB (7.1 KB at most), 5.4 KB of it the row table buckets_us, and a whole window of 92 seconds took 12.4 KB. Times spread over every row are the worst case: then each column holds an index and a count for up to 237 rows (more past 61 seconds with a longer --timeout) instead of a few. In that worst case the columns in each snapshot grow many times over, but the snapshot as a whole grows far less, as the row table it always carries stays most of it; a whole window, which is all columns, grows many times over.
Request heatmap endpoint
GET /api/v1/series?method=<method>&name=<request name> returns one request’s heatmap over the same window, as the card uses it when a request is chosen, in the same shape: { "method", "name", "heatmap" }. method is the method as the Requests table shows it (GET, POST, …) and name is the request name; URL encode both. Add heatmap_generation and heatmap_since to get only the columns changed since the heatmap you hold. It needs the token when one is configured, as GET /api/v1/snapshot does.
| Status | Body |
|---|---|
200 | { "method", "name", "heatmap": {...} }, the heatmap in the shape of the snapshot’s series.heatmap |
400 | {"error":"bad_request"}: method or name is missing, or the two together are longer than 2,048 bytes |
401 | Empty: the token is missing or wrong, as for GET /api/v1/snapshot |
404 | {"error":"not_found"}: the request has no heatmap of its own (not one of the first 100 names, or --no-percentiles-over-time) |
503 | {"error":"busy"} with four series requests already in flight, {"error":"timeout"} when no answer arrives within 15 seconds, or {"error":"unavailable"} when Goose is no longer taking dashboard requests (shutting down) |
Answers are cached for one second for each request and heatmap held, so several viewers polling one request cost about one export a second.
curl -s -H "Authorization: Bearer $DASHBOARD_TOKEN" \
"http://127.0.0.1:5118/api/v1/series?method=GET&name=%2F"
Metrics disabled
If Goose is started with --no-metrics, the dashboard still serves the shell and health endpoint, but snapshots report flags.metrics_disabled: true with empty request/error tables and empty series. The UI surfaces an honest empty state rather than fabricating data.
Scenario and transaction metrics can also be turned off on their own:
--no-transaction-metricssetsflags.transaction_metrics_disabled: true;transactionsis empty and the Transactions table says transaction metrics are disabled.--no-scenario-metricssetsflags.scenario_metrics_disabled: true;scenariosis empty and the Scenarios table says scenario metrics are disabled.
Both flags are also true under --no-metrics, where both tables say metrics are disabled.
Saving the report
Output files gives the rules for every file Goose writes. The dashboard adds rules 9 to 13.
The report bar at the top of the page shows the Save report switch: whether this run’s report.html and report.json are written to its folder, goose-runs/<YYYYMMDD-HHMMSS> unless --output-dir names one. It starts on with --dashboard, except under --no-metrics. Without --dashboard, it starts on with a bare --report-file, which writes into the run’s folder, so such a console run is listed in the runs panel too; --report-file paths alone do not turn it on. The bar lists the --report-file paths, which are always written.
Save report can be turned on or off at any time. While it is on, Goose keeps the run’s graph data and heatmap for the whole run. While it is off and no --report-file path or --pdf-print-html is given, Goose keeps only the dashboard’s last 10 minutes of graphs and 5 minutes of heatmap, so memory stays flat on a long soak test. Turning it on during a run keeps graphs from then on, with that window before it, and the report says when its graphs start and why.
Save report now writes the run’s report as it stands into its folder while the run continues, marked in_progress. The report at the end of the run replaces it, also when Save report is off. The second switch, Include graph data, adds the per second graph data to the JSON report, as --report-graph-data does, and can also be changed at any time.
Save report now, Delete, and changing either switch need --dashboard-control and the token, or --dashboard-no-auth, as Start and Stop do. Without control the switches are shown read only, so Save report stays on and every --dashboard run, except under --no-metrics or with a folder it can’t write to, writes report.html and report.json into its folder. A 60 minute run of examples/simple without control, with 300 users at about 28 requests a second over 2 request names, grew by about 0.8 MiB an hour. With --dashboard-control you can turn Save report off to keep memory flat. --no-metrics keeps no report, so Save report stays off.
When a run ends, a banner reads Report saved in <folder>. Download HTML, JSON or Markdown., naming the run’s folder, with a button for each report, or says why the report couldn’t be saved. For a folder whose name the report routes don’t take (see Report endpoints), it reads Report saved in <folder>. with no buttons. A Save report now that fails while the run goes on is shown at the foot of the runs panel, with the reason.
When only --dashboard asks for the run’s folder, with no output flag given without a path and no --output-dir, a folder Goose can’t write to does not stop the run. Goose prints a warning before any load, and runs with Save report off. A banner at the top of the page names the folder and the reason, and stays for the whole run. The Save report switch says why when you hover over it.
The runs panel
The Runs panel looks in the folder that holds each run’s folder: the parent of the --output-dir folder, or goose-runs by default. It lists each folder there that holds a report.json or report.json.gz it can read; a folder whose JSON report can’t be read is left out. It lists only folders with a name the report routes take; see Report endpoints. It lists them while Goose is Idle too, before any run has a folder, and it lists folders written by runs without the dashboard. Rows are sorted newest first, by start time, or by when the report was written if it records no start time. Each row shows the folder’s name, when the run started (in the viewer’s local time), its notes, how long it ran, its users, requests and failures, how it ended (Finished, Stopped, Ctrl-C, Canceled: <reason> or Users exited), and the test. A report still marked in progress shows as Running for the run in progress and as Incomplete for any other: a run that ended without its final report. The first 50 rows are shown; Show all lists the rest. The list is read when the page opens, when a run starts, when Goose writes a report into a run’s folder, and after a Delete. Goose writes a report there at the end of a run and on Save report now, and the snapshot’s report.writes counts those writes.
Each row offers HTML, Markdown and JSON. A format the folder lacks is rendered from its JSON report; without graph data in it, the rendered HTML report says the graphs were not recorded.
With --dashboard-control, each row also has a Delete button. It asks Delete the reports and logs in <folder>?, then deletes the folder’s reports and the logs Goose names there, and the folder if that leaves it empty. The folder of the run in progress can’t be deleted. Nor can the folder a bare --goose-log is open in: Goose keeps that log open in the last run’s folder until the next run starts. Nor can a folder that holds .goose-run. A Goose process keeps that file in its run’s folder while the run lasts, and after the run while a bare --goose-log is open there, so a dashboard never deletes what another process is writing. A process that is killed leaves the file behind; remove it by hand to free the folder.
Check Compare on exactly two rows and Download comparison downloads a Markdown report of the newer run with the older as its baseline: every number shows its change, as with --baseline-file. Runs with no requests can’t be compared. When the two runs are from different tests the panel warns that the comparison may not mean much.
Downloads are fetched with the token in an Authorization header and saved from the response, so the token never goes into a URL. Reports are always downloaded, never shown in the browser.
Report endpoints
The GET routes need the token when one is configured, as GET /api/v1/snapshot does. DELETE is registered only with --dashboard-control and needs the token as Authorization: Bearer (none under --dashboard-no-auth), as the control POSTs do. Each reads the disk only on request. Goose keeps what it parsed of each JSON report in memory, keyed by path, size and modification time, and writes no index.
| Endpoint | Returns |
|---|---|
GET /api/v1/reports | { "dir": "goose-runs", "reports": [ ... ] }: for each folder holding a regular report.json or report.json.gz that can be read, its name, the run’s record from its JSON report (notes, goose_version, test, started, ended, ended_by, canceled_reason, in_progress and graphs_start, with notes, canceled_reason and graphs_start left out when unset), running, duration_secs, max_users, requests and failed_requests, newest first |
GET /api/v1/reports/{name}/{file} | report.html, report.md or report.json of folder {name}, as an attachment named goose-<test>-<name>.<ext>; a format the folder lacks is rendered from its JSON report, or answers 422 with a one line reason when it can’t be |
GET /api/v1/reports/{name}/compare.md?baseline={name} | Report {name} compared to the baseline, as Markdown, named goose-<test>-<name>-vs-<baseline>.md, starting with a line naming both and when each started. 422 with a one line reason when a JSON report can’t be read or is inconsistent; a run with no requests compares as zeros |
DELETE /api/v1/reports/{name} | 204 once the folder’s report.html, report.md, report.json, goose.log and the request, transaction, scenario, error and debug logs Goose names there, each plain or gzipped, and any temporary report file a stopped process left, are deleted, then the folder if that leaves it empty; 409 for the folder of the run in progress once it holds a report, the folder a bare --goose-log is open in, or a folder that holds .goose-run; 404 for a folder the panel does not list |
A name is an ASCII letter or digit, then any mix of ASCII letters, digits, ., _ and -. A folder with any other name is not listed. A name of any other form, a folder that is not one level down, a symlink, or a folder without a regular JSON report gets a plain 404. A folder whose JSON report is not yet on disk is not listed: Goose writes the JSON report last, through a temporary file and a rename. Reports and comparisons are sent with Content-Disposition: attachment, X-Content-Type-Options: nosniff, Cache-Control: no-store and Content-Security-Policy: sandbox, so a report opened anyway runs no script with the dashboard’s origin.
curl -s -H "Authorization: Bearer $DASHBOARD_TOKEN" http://127.0.0.1:5118/api/v1/reports
curl -s -OJ -H "Authorization: Bearer $DASHBOARD_TOKEN" \
http://127.0.0.1:5118/api/v1/reports/nightly/report.html
curl -s -OJ -H "Authorization: Bearer $DASHBOARD_TOKEN" \
"http://127.0.0.1:5118/api/v1/reports/nightly-2/compare.md?baseline=nightly"
curl -s -X POST -H "Authorization: Bearer $DASHBOARD_TOKEN" -H 'Content-Type: application/json' \
-d '{"save": false}' http://127.0.0.1:5118/api/v1/control/report
With no token, which only a loopback bind or --dashboard-no-auth allows, every user on this machine (and off loopback, everyone who can reach the port) can list and download every report in that folder while the dashboard is up, even if the files themselves are readable only by their owner. On a shared machine, set a token in the load test’s code, as On a shared machine shows.
Waiting after an autostarted run
When an autostarted --dashboard run (one without --no-autostart) ends, Goose stays up instead of exiting if Save report wrote its report and any client is connected to the event stream, so the report can still be downloaded. With --dashboard-control it prints:
Run finished. Download it from the dashboard at http://127.0.0.1:5118. Press Quit in the dashboard or Ctrl-C to exit.
and without it:
Run finished. Download it from the dashboard at http://127.0.0.1:5118. Press Ctrl-C to exit.
The phase goes back to idle, and the test does not start again. Goose exits on Quit in the dashboard, on Ctrl-C, or on a Controller shutdown; there is no exit timer, so closing the tab does not end the wait. Nothing is written or printed again, and test_stop does not run again. Any client on /api/v1/events counts, not only browser tabs: a script tailing the event stream when the run ends keeps Goose up until Goose is told to quit. With no client connected when the run ends, Goose exits at once, as it does without the dashboard. A run stopped from the dashboard or a Controller prints no Run finished line; Goose stays up in Idle, as after any Stop. A run canceled with Ctrl-C or aborted by a --threshold exits with no wait.
GraphData memory cost
Enabling --dashboard turns on the same per second GraphData series collection used for HTML report graphs (also enabled by --report-file and --pdf-print-html). Goose keeps every second of the whole run while a report will be written at the end of it: to a --report-file or --pdf-print-html path, or by Save report, which is on by default with --dashboard. With Save report off and no --report-file or --pdf-print-html path, the dashboard keeps only twice its chart window (10 minutes). Without --dashboard, --report-file or --pdf-print-html nothing is recorded. The recording costs memory even when no browser is connected:
- Per-second counters for requests, errors, users, and average latency are kept for the whole run while a report will be written, and for the last 10 minutes otherwise.
- While a report will be written, memory grows with unique request names × run length, the same cost as generating HTML graphs; otherwise it stays flat.
- Request, error and transaction error tables truncate to the top rows (
flags.requests_truncated/flags.errors_truncated/flags.transaction_errors_truncatedwhen capped); series charts use a fixed trailing window (5 minutes).
With no SSE client connected and no GET /api/v1/snapshot in the last two seconds (/api/v1/health does not count), the dashboard issues no snapshot builds (no extra metrics-processor work beyond GraphData recording). When clients are connected, snapshots are coalesced to about 1 Hz for all viewers. Concurrent SSE clients are capped (default 32, tunable with --dashboard-max-clients); further clients receive HTTP 503.
Each snapshot build (while clients are connected or polling) runs on the metrics processor task: it drains pending metrics, exports the trailing series window, and builds percentile maps for the aggregate, up to 100 request rows, and one row per scenario and transaction, a number fixed by the load test’s code. That cost scales with unique request names and the timing histograms Goose already maintains; the attack main loop does not await the build. Under extreme cardinality, prefer fewer unique request names, a shorter test, or disable the dashboard if you need absolute minimal overhead (same trade-off as --report-file).
With --dashboard, and also for a --report-file or --pdf-print-html, Goose keeps one response time histogram per second for the last 300 seconds, for all requests together and for each of the first 100 request names, plus the same for synthetic times while coordinated omission mitigation is on. It does not grow with run length. Its size depends on how many request names there are and how many distinct response times occur: about 0.2 MB for all requests and 10 MB for 100 names with typical response times, and up to 30 MB in the worst case at the default 60 second timeout, more with a longer --timeout. Filling it costs little. In a test of 1,000 users at about 21,800 requests a second over 25 request names, the heatmap added no measurable CPU or instructions per request: the difference from the dashboard without it was within the runs’ spread of about 1%. It added 3.1 MiB of peak memory. Each user’s batch of metrics is still sent every 100 requests or 250 ms, as without the heatmap; a batch keeps each request name’s response times for the second it is filling and the next one, and a request from any other second sends it early. For the HTML report’s heatmap, while a report will be written, each second is also added once to whole run columns: up to 600 for all requests and 60 for each of those 100 names. The columns merge in pairs as they fill, so they too stop growing. --no-percentiles-over-time turns all of it off, along with the small per request cost of filling it.
For extreme runs with tens of thousands of unique request names, expect higher GraphData memory (same as --report-file). Disable the dashboard for the least overhead, just as you can turn off the telnet Controller with --no-telnet (the WebSocket Controller is off unless --websocket turns it on).
Relationship to Controllers
| Need | Prefer |
|---|---|
| Watch live charts and tables | Live Dashboard (--dashboard) |
| Start / Stop / set users, host, ramps and run time from a browser | Dashboard control (--dashboard-control + token, or --dashboard-no-auth) |
| Test plan from a script | Dashboard control (/api/v1/control/settings or /api/v1/control/start) or the Controllers |
Full command set, config and metrics | Telnet / WebSocket Controllers |
| Shut Goose down from Idle | Dashboard control (Quit) or the Controllers (shutdown) |
| Shut down a running test | Controllers (shutdown) |
Dashboard control reuses the same phase-transition logic as Controllers (shared helpers), and Controllers remain available alongside the dashboard.
For full Controller documentation, see Controlling A Running Goose Load Test.
Stability
Building it in
In 0.19 and 0.20 the dashboard is an opt in crate feature: build with --features dashboard. The goal is to make it a default feature in 1.0, a decision to be made on the feedback from 0.19 and 0.20.
/api/v1 in 0.19 and 0.20
Changes are additive where possible: new fields, new endpoints and new error codes, under snapshot version: 1. A client must ignore fields it does not know and must not fail on an error code it does not know. Every change to /api/v1 is listed in the CHANGELOG. A change that is not additive, such as a field or endpoint removed, renamed or given a different meaning, raises the snapshot version, and the CHANGELOG says so. 0.20 brings back distributed load testing (Gaggle) and may make such changes so the API can describe more than one worker.
/api/v1 from 1.0
Semver covers it: within 1.x no field, endpoint or error code is removed, renamed or given a different meaning. New ones may be added, so a client must ignore fields it does not know and must not fail on an error code it does not know.
The routes and the snapshot
In 0.19 /api/v1 serves GET /api/v1/health, GET /api/v1/snapshot, GET /api/v1/events, GET /api/v1/series, GET /api/v1/reports, GET /api/v1/reports/{name}/{file} and GET /api/v1/reports/{name}/compare.md, and with --dashboard-control POST /api/v1/control/start, settings, stop, users, quit, report and report/now, and DELETE /api/v1/reports/{name}. A snapshot’s top level fields are version, generated_at, goose_version, phase, stopping, duration_secs, active_users, maximum_users, target_users, total_users, hosts, aggregate, requests, errors, transaction_errors, scenarios, transactions, series, flags, report and settings. The SSE closed event’s data is 1.
What the contract is
The contract is the JSON served under /api/v1 and the snapshot version, a top level field in every snapshot. The Rust types behind it are internal and are not part of the public crate API. The bundled client is built for one version and refuses any other, and a client of your own should check version the same way.
Building the dashboard UI (TypeScript)
The browser SPA lives under src/dashboard/static/. Edit the TypeScript modules in src/, not the bundled JavaScript:
| File | Role |
|---|---|
src/main.ts | Entry point: renders each snapshot and wires the modules together |
src/connection.ts | Access token handling, SSE stream, poll fallback, snapshot fetch and version check |
src/control.ts | Control panel, its POSTs, the phase badge and the run settings summary |
src/settings-form.ts | The Edit settings form: Host, Ramp up, Ramp down and Run time, and their pending edits |
src/run-settings.ts | The run settings summary line, worded from settings.stage |
src/follow.ts | Fields that follow the server unless being edited (Target and the settings form) |
src/charts.ts | Chart.js setup and the line charts: RPS and failures, active users, and average latency |
src/chart-data.ts | Each line chart’s one sentence summary and its data table |
src/heatmap.ts | The response time heatmap: data shaping and drawing |
src/heatmap-card.ts | The response time card: its request select, the Adjusted and Measured toggle, and the average latency chart when percentiles over time are off |
src/coadjust.ts | Which response time to show: coordinated omission adjusted or measured |
src/runs.ts | The report bar, Save report now, the report banners and the runs panel |
src/tables.ts | The shared sortable table (sorting, filtering, empty states) and the scenarios, transactions, requests, errors and transaction errors tables |
src/format.ts | Number and duration formatting, and the small DOM builders the summary and tables share |
src/status.ts | Banner and connection indicator |
src/dom.ts | Element lookup that checks each element’s type |
snapshot.d.ts | Generated types of the snapshot JSON, from the Rust structs in src/metrics/dashboard_snapshot.rs |
idle-snapshot.mjs | A complete idle snapshot for the client tests, type checked against snapshot.d.ts by npm run check |
tsconfig.json / package.json | TypeScript type check and esbuild bundle config |
*.test.mjs | Client tests, run with npm test: token, tables, runs, control, settings, coadjust, format, heatmap, a11y, chrome and monitor. Most run in jsdom against the bundled app.js; npm test first builds coadjust.ts, format.ts, heatmap.ts and charts.ts into test-build/ for the tests that load a module on its own |
test-harness.mjs | The jsdom page loader and stub server the client tests share |
app.js | Bundled output (the modules plus Chart.js), embedded by the Rust server via include_str! |
index.html, app.css | Shell and styles (not generated) |
npm run build bundles src/main.ts, the modules it imports and Chart.js into one minified app.js with esbuild. Chart.js comes from npm at a pinned version (see package.json), with its own TypeScript types, so the chart configuration is type checked; its license comment is kept in the bundle.
Cargo builds do not run npm. The committed app.js is what gets embedded, so a normal cargo build works without Node.js. After changing a module in src/ (or the related type declarations), regenerate app.js and commit it with the sources.
Prerequisites
- Node.js (with npm)
- Network access once to install the dev dependencies (esbuild, Chart.js, TypeScript, and jsdom for the client tests)
Rebuild
From the repository root:
cd src/dashboard/static
npm ci
npm run build
That runs esbuild and overwrites app.js in the same directory. Then rebuild Goose so the new script is embedded:
cargo build --features dashboard
# or exercise the dashboard:
cargo run --release --features dashboard --example simple -- --dashboard ...
Snapshot types
snapshot.d.ts declares DashboardSnapshot and every struct it holds, and RequestSeries, the /api/v1/series body, generated by ts-rs from the Rust structs, so tsc rejects a read of a field the structs do not have. Every u64 is declared as number, which is what JSON.parse returns. After changing a snapshot struct, regenerate the file, then type check the client (and run npm run build if a module in src/ changed). tsconfig.json sets skipLibCheck to false so tsc also checks the generated file:
GOOSE_UPDATE_DASHBOARD_TYPES=1 cargo test --lib dashboard_types_match_rust
cd src/dashboard/static
npm run check
ts-rs is a dev dependency used only by unit tests, so crates that depend on Goose never pull it in. Without the environment variable, dashboard_types_match_rust compares the committed file with the structs and fails when they differ (a reordered field counts as a difference), so a struct change without a regenerated snapshot.d.ts fails cargo test in CI, and a client that reads a removed or renamed field then fails npm run check. A second test fails on any serde attribute other than serde(skip), which ts-rs honours, or on a hand-written Serialize in the snapshot module, because ts-rs cannot see either, so the JSON could change without the types changing.
ts-rs is pinned to an exact version in Cargo.toml. The committed file is compared byte for byte with its output, and Goose commits no Cargo.lock, so a new release that formats differently would otherwise fail CI with no change in Goose. Dependabot opens a pull request when a new ts-rs is released; if dashboard_types_match_rust fails on it, regenerate the file on that branch as above and review the diff.
The client accepts only the snapshot version named in snapshot.d.ts (SNAPSHOT_VERSION in the Rust module) and shows an error banner for any other.
Type check
esbuild strips the TypeScript types without checking them, so npm run build succeeds on code that does not type check. npm run check is the type check: it runs tsc over the modules and snapshot.d.ts (tsconfig.json sets noEmit, so tsc writes nothing), then checks idle-snapshot.mjs against the snapshot types.
cd src/dashboard/static
npm run check
Client tests
cd src/dashboard/static
npm run build
npm test
Most tests load the bundled app.js into a jsdom page with a stubbed fetch, so rebuild it first.
Notes
- Do not hand-edit
app.js; changes there will be lost on the nextnpm run build. node_modules/is gitignored; onlypackage.json/package-lock.jsonare committed so installs stay reproducible.- Chart.js is bundled into
app.js; the server does not serve it as a separate file. - CI runs
npm run check, rebuildsapp.jswithnpm ci && npm run build(failing if the committed file differs from the esbuild bundle), and runsnpm test.
Telnet Controller
The telnet Controller listens on 127.0.0.1:5116 by default, so only this machine can reach it. It has no authentication: anyone who can reach its port can start, stop and reconfigure the load test. To reach it from another machine, either forward the port over ssh to the default bind (ssh -L 5116:127.0.0.1:5116 loadtest, then telnet localhost 5116), or start Goose with an explicit host such as --telnet-host 0.0.0.0, which prints a warning at startup that the Controller has no authentication.
The host and port that the telnet Controller listens on can be configured at start time with --telnet-host and --telnet-port. The telnet Controller can be completely disabled with the --no-telnet command line option. The defaults can be changed with GooseDefault::TelnetHost, GooseDefault::TelnetPort, and GooseDefault::NoTelnet.
Controller Commands
To learn about all available commands, telnet into the Controller thread and enter help (or ?). For example:
% telnet localhost 5116
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
goose> ?
goose 0.19.0 controller commands:
help this help
exit exit controller
start start an idle load test
stop stop a running load test and return to idle state
shutdown shutdown load test and exit controller
host HOST set host to load test, (ie https://web.site/)
increaserate FLOAT set per-second rate users increase (0.01 to 100000)
increase-time TIME set total time to take increasing users (0 clears)
decreaserate FLOAT set per-second rate users decrease (0.01 to 100000)
decrease-time TIME set total time to take decreasing users (0 clears)
users INT set number of simulated users (1 to 100000)
runtime TIME set how long to hold users after the ramp up, (ie 1h30m5s, 0 clears)
test-plan PLAN define or replace test-plan, (ie 10,5m;10,1h;0,30s)
config display load test configuration
config-json display load test configuration in json format
metrics display metrics for current load test
metrics-json display metrics for current load test in json format
goose> q
goodbye!
goose> Connection closed by foreign host.
exit, quit and q end the session, and help and ? print this list. Commands and values are not case sensitive, and most commands take aliases: increase_rate and increase-rate for increaserate, increasetime for increase-time, run_time for runtime, testplan and plan for test-plan, stats for metrics, and the like. A command takes its value after one space.
config prints the configuration, and config-json prints it as JSON, with an option that is not set shown as null and an output flag given without a path shown as (the run's folder). Neither shows the dashboard auth token: one that is set reads (set). metrics prints the metrics tables, and metrics-json prints the metrics as JSON. In that JSON each request’s raw_data holds its response times in microsecond buckets, times_us, beside minimum_time_us, maximum_time_us and total_time_us, and the whole millisecond minimum_time, maximum_time, total_time and counter; each transaction and scenario holds the same as times_us, min_time_us, max_time_us and total_time_us, beside min_time, max_time, total_time and counter. Each request also holds status_code_counts, how many responses got each status code, and status_code_timings, each status code’s count, total_time_us, min_time_us and max_time_us.
A command whose value can’t be used is refused with what the value must be, and changes nothing:
goose> users 0
users must be an integer between 1 and 100000
goose> increaserate 0
increase_rate must be a number from 0.01 to 100000
goose> runtime 31d
run_time must be a time span such as 1h30m5s, from 1 second to 30 days, or 0 to clear
Telnet, WebSocket and the dashboard share these bounds:
| Setting | Accepted |
|---|---|
host | An http:// or https:// url with no user name or password, which would show to observers and in reports |
users | 1 to 100000 |
increaserate, decreaserate | 0.01 to 100000 users per second |
increase-time, decrease-time, runtime | 1 second to 30 days; 0 clears the setting, so Goose’s default applies (the dashboard takes null to clear, and refuses 0) |
test-plan | At most 100 steps of 0 seconds to 30 days each, in at most 4096 bytes, starting 1 to 100000 users in total |
host, increase-time and decrease-time are accepted only while the load test is idle, as they are read when it starts. runtime, users and test-plan also change a running load test. A runtime on a running users ramp moves the end of the hold in progress to its start plus the new run time, and starts the ramp down at once if that has passed. If the new run time puts the end of the hold earlier than the end of the ramp from a users change made during the hold, the run holds until stopped. Before the hold starts, a runtime replaces the hold ahead, and on a run holding until stopped it holds from now for the new run time, then ramps down. runtime 0 on a running test holds until stopped, and is refused while a ramp down is configured. runtime is refused during the ramp down, while stopping and while a test plan runs, as the plan sets how long it runs. See Run time on a running test. A users change on a running users ramp keeps its run time and ramp down: it ramps to the new count, holds, and ramps down from the new count. A change during the ramp up holds for the full run time; a change during the hold keeps the hold’s end. If the new ramp outlasts the hold, or the new count is the one the run already has, it holds that count until stopped. A users change during the ramp down abandons it and holds the new count until stopped. users is refused while a test plan runs, as the plan sets users. See Users on a running test.
The rates are accepted in Idle and on a running users ramp, and are refused during its ramp down, while stopping and while a test plan runs. On a running test, a rate is refused while its own time is set, since a time changes only in Idle; in Idle a rate clears its own time and a time clears its own rate. A decrease rate on a running users ramp regenerates the ramp down still ahead, or adds one where all users would stop at once when the hold ends; an increase rate is for the next users increase.
A test plan sets users, so a rate or a time is refused in Idle with set users first to switch to a users ramp, and, while the plan runs, with a test plan is running. While one is configured, users switches to a users ramp and clears the plan, and test-plan switches back and clears users, the rates and the times. The command line’s rules also apply: runtime, decreaserate, decrease-time and test-plan are refused under --iterations, test-plan under --no-reset-metrics, and in Idle increase-time or decrease-time with fewer than 2 users, or users 1 while either is set. users and test-plan are refused while a stop, shutdown or Ctrl-C is ramping the load test down. A command that gets no answer within 15 seconds is answered control timed out, it may still apply: check before retrying: check the configuration or metrics before sending it again.
Example
One possible use-case for the controller is to dynamically reconfigure the number of users being simulated by the load test. In the following example, the load test was launched with the following parameters:
% cargo run --release --example umami -- --no-autostart --telnet-host 0.0.0.0 --host https://umami.ddev.site/ --increase-rate 50 --report-file report.html --report-file report.md
The --telnet-host 0.0.0.0 lets the telnet Controller be reached from another machine, here one that reaches the load test as loadtest, and prints the warning that it has no authentication.
Then the telnet controller is invoked as follows:
% telnet loadtest 5116
Trying loadtest...
Connected to loadtest.
Escape character is '^]'.
goose> start
load test started
goose> users 20
users configured
goose> users 40
users configured
goose> users 80
users configured
goose> users 40
users configured
goose> users 20
users configured
goose> users 160
users configured
goose> users 20
users configured
goose> increase_rate 5
increase_rate configured
goose> users 80
users configured
goose> users 20
users configured
goose> shutdown
load test shut down
goose> Connection closed by foreign host.
Initially the load test is configured with an increase rate of 50, so goose increases and decreases the user count by 50 user threads per second: with no decrease rate set, Goose lowers the user count at the increase rate. Later we reconfigure the increase rate to 5, slowing down the rate that goose alters the number of user threads. This graph, from the end of that load test, shows the result:
The above commands are also summarized in the plan overview of the Markdown report, report.md, here from a run of the same commands against a local server (the HTML report has the same table):
## Plan Overview
| Action | Started | Stopped | Elapsed | Users |
| ------ | ------- | ------- | ------- | ----: |
| Increasing | 26-10-25 22:12:31 | 26-10-25 22:12:31 | 00:00:00 | 0 → 18 |
| Maintaining | 26-10-25 22:12:31 | 26-10-25 22:12:43 | 00:00:12 | 18 |
| Increasing | 26-10-25 22:12:43 | 26-10-25 22:12:43 | 00:00:00 | 18 → 20 |
| Maintaining | 26-10-25 22:12:43 | 26-10-25 22:12:55 | 00:00:12 | 20 |
| Increasing | 26-10-25 22:12:55 | 26-10-25 22:12:56 | 00:00:01 | 20 → 40 |
| Maintaining | 26-10-25 22:12:56 | 26-10-25 22:13:07 | 00:00:11 | 40 |
| Increasing | 26-10-25 22:13:07 | 26-10-25 22:13:08 | 00:00:01 | 40 → 80 |
| Maintaining | 26-10-25 22:13:08 | 26-10-25 22:13:22 | 00:00:14 | 80 |
| Decreasing | 26-10-25 22:13:22 | 26-10-25 22:13:23 | 00:00:01 | 40 ← 80 |
| Maintaining | 26-10-25 22:13:23 | 26-10-25 22:13:34 | 00:00:11 | 40 |
| Decreasing | 26-10-25 22:13:34 | 26-10-25 22:13:34 | 00:00:00 | 20 ← 40 |
| Maintaining | 26-10-25 22:13:34 | 26-10-25 22:13:46 | 00:00:12 | 20 |
| Increasing | 26-10-25 22:13:46 | 26-10-25 22:13:49 | 00:00:03 | 20 → 160 |
| Maintaining | 26-10-25 22:13:49 | 26-10-25 22:14:01 | 00:00:12 | 160 |
| Decreasing | 26-10-25 22:14:01 | 26-10-25 22:14:04 | 00:00:03 | 20 ← 160 |
| Maintaining | 26-10-25 22:14:04 | 26-10-25 22:14:17 | 00:00:13 | 20 |
| Increasing | 26-10-25 22:14:17 | 26-10-25 22:14:22 | 00:00:05 | 20 → 43 |
| Decreasing | 26-10-25 22:14:22 | 26-10-25 22:14:27 | 00:00:05 | 20 ← 43 |
| Maintaining | 26-10-25 22:14:27 | 26-10-25 22:14:47 | 00:00:20 | 20 |
| Canceling | 26-10-25 22:14:47 | 26-10-25 22:14:48 | 00:00:01 | 0 ← 20 |
The run starts with the default users, one per CPU core (18 here). At 5 users a second, the users 80 that follows increase_rate 5 had reached 43 users when users 20 arrived about 5 seconds later, so Goose turned back from 43.
WebSocket Controller
The WebSocket Controller is off unless Goose is started with --websocket, or a load test sets GooseDefault::WebSocket. It takes no authentication and accepts a connection from any web page, so a page open in any browser that can reach its port can start, stop and reconfigure the load test.
When on, it listens on 127.0.0.1:5117 by default, so only this machine can reach it. To reach it from another machine, either forward the port over ssh to the default bind (ssh -L 5117:127.0.0.1:5117 loadtest, then connect to ws://127.0.0.1:5117), or start Goose with an explicit host such as --websocket-host 0.0.0.0, which prints a warning at startup that the Controller has no authentication.
The host and port that the WebSocket Controller listens on can be configured at start time with --websocket-host and --websocket-port, and their defaults changed with GooseDefault::WebSocketHost and GooseDefault::WebSocketPort. --no-websocket and GooseDefault::NoWebSocket, which turned it off in Goose 0.18, are still accepted and only log a warning; either one with --websocket or GooseDefault::WebSocket is refused.
Details
The WebSocket Controller supports the same commands listed in the telnet controller. Requests and Responses are in JSON format.
Requests must be made in the following format:
{"request":String}
For example, a client should send the following JSON to request the current load test metrics:
{"request":"metrics"}
A request may be at most 131,072 bytes (128 KiB), the longest single command line argument Linux takes with 4 KiB pages. A longer message is answered with a close frame, code 1009 (message too big), with the reason message too big, at most 131072 bytes, and the connection ends. A test plan sent to a Controller is at most 4096 bytes and 100 steps, as from the dashboard; see the bounds every front end shares.
Responses will always be in the following format:
{"response":String,"success":Boolean}
For example, from a load test built with the pdf-reports feature, so its configuration includes pdf_scale and pdf_timeout, which a build without that feature leaves out:
% websocat ws://127.0.0.1:5117
foo
{"response":"invalid json, see Goose book https://book.goose.rs/controller/websocket.html","success":false}
{"request":"foo"}
{"response":"unrecognized command, see Goose book https://book.goose.rs/controller/websocket.html","success":false}
{"request":"config"}
{"response":"{\"host\":null,\"users\":10,\"increase_rate\":null,\"increase_time\":null,\"run_time\":null,\"decrease_rate\":null,\"decrease_time\":null,\"iterations\":null,\"test_plan\":null,\"scenarios\":null,\"throttle_requests\":null,\"version\":false,\"list\":false,\"scenarios_list\":false,\"verbose\":0,\"quiet\":0,\"goose_log\":null,\"log_level\":0,\"running_metrics\":null,\"no_print_metrics\":false,\"no_error_summary\":false,\"report_file\":[],\"output_dir\":null,\"compress\":null,\"notes\":null,\"report_graph_data\":false,\"render_report\":null,\"baseline_file\":null,\"no_granular_report\":false,\"pdf_print_html\":null,\"pdf_scale\":0.8,\"pdf_timeout\":60,\"no_reset_metrics\":false,\"no_metrics\":false,\"no_transaction_metrics\":false,\"no_scenario_metrics\":false,\"no_status_codes\":false,\"co_mitigation\":\"Disabled\",\"threshold\":[],\"no_threshold_abort\":false,\"threshold_abort_delay\":null,\"request_log\":null,\"request_format\":\"Json\",\"request_body\":false,\"transaction_log\":null,\"transaction_format\":\"Json\",\"scenario_log\":null,\"scenario_format\":\"Json\",\"error_log\":null,\"error_format\":\"Json\",\"debug_log\":null,\"debug_format\":\"Json\",\"no_debug_body\":false,\"dashboard\":false,\"dashboard_host\":null,\"dashboard_port\":null,\"dashboard_auth_token\":null,\"dashboard_control\":false,\"dashboard_no_auth\":false,\"dashboard_max_clients\":null,\"no_percentiles_over_time\":false,\"no_autostart\":true,\"no_telnet\":false,\"telnet_host\":\"127.0.0.1\",\"telnet_port\":5116,\"websocket\":true,\"no_websocket\":false,\"websocket_host\":\"127.0.0.1\",\"websocket_port\":5117,\"timeout\":null,\"no_gzip\":false,\"sticky_follow\":false,\"accept_invalid_certs\":false}","success":true}
{"request":"stop"}
{"response":"load test not running, failed to stop","success":false}
{"request":"shutdown"}
{"response":"load test shut down","success":true}
This session is Goose started with --websocket --no-autostart -u 10. config sends the configuration as JSON, with an option that is not set shown as null, as the telnet Controller’s config-json does. A dashboard auth token, when one is set, is shown as "(set)", never the token itself. metrics sends the metrics as the telnet Controller’s metrics-json does, with each request’s response times in microsecond buckets, times_us; see Controller Commands.
After exit (or quit or q) the Controller answers goodbye!, and after shutdown it answers load test shut down; either way it then sends a close frame, code 1000, and ends the connection. A request that gets no answer from Goose within 15 seconds is answered {"response":"control timed out, it may still apply: check before retrying","success":false}.
Gaggle: Distributed Load Test
NOTE: Goose 0.19 cannot run a Gaggle. Gaggle support was removed in Goose 0.17.0 (see PR 529) and will be restored in Goose 0.20, before the 1.0 release. The gaggle feature, the --manager and --worker flags and the other options in this section do not exist in 0.19. This section describes how Gaggles worked in Goose 0.16.4, the last release with them; use that release if you need a distributed load test today.
Goose 0.16 also supported distributed load testing. A Gaggle is one Goose process running in Manager mode, and 1 or more Goose processes running in Worker mode. The Manager coordinates starting and stopping the Workers, and collects aggregated metrics. Gaggle support is a cargo feature that must be enabled at compile-time. To launch a Gaggle, you must copy your load test application to all servers from which you wish to generate load.
It is strongly recommended that the same load test application be copied to all servers involved in a Gaggle. By default, Goose will verify that the load test is identical by comparing a hash of all load test rules. Telling it to skip this check can cause the load test to panic (for example, if a Worker defines a different number of transactions or scenarios than the Manager).
Load Testing At Scale
Experimenting with running Goose load tests from AWS, Goose used all available system resources, so it was generally limited only by network speed. A smaller server instance was able to simulate 2,000 users generating over 6,500 requests per second and sustaining 2.5 Gbps. As more uplink speed was added, Goose was able to scale linearly: by distributing the test across two servers with faster uplinks, it simulated 12,000 active users generating over 41,000 requests per second and saturating 16 Gbps.
Generating this much traffic in and of itself is not fundamentally difficult, but with Goose each request is fully analyzed and validated. It not only confirms the response code for each response the server returns, but also inspects the returned HTML to confirm it contains all expected elements. Links to static elements such as images and CSS are extracted from each response and also loaded, with each simulated user behaving similar to how a real user would. Goose excels at providing consistent and repeatable load testing.
For full details and graphs, refer to the blog A Goose In The Clouds: Load Testing At Scale.
Gaggle Manager
NOTE: Goose 0.19 cannot run a Gaggle. Gaggle support was removed in Goose 0.17.0 and will be restored in Goose 0.20, before the 1.0 release. This page describes Goose 0.16.4, the last release with it; see the Gaggle overview.
To launch a Gaggle, you first must start a Goose application in Manager mode. All configuration happens in the Manager. To start, add the --manager flag and --expect-workers option, the latter necessary to tell the Manager process how many Worker processes it will be coordinating.
Example
Configure a Goose Manager to listen on all interfaces on the default port (0.0.0.0:5115), waiting for 2 Goose Worker processes.
cargo run --features gaggle --example simple -- --manager --expect-workers 2 --host http://local.dev/
Gaggle Worker
NOTE: Goose 0.19 cannot run a Gaggle. Gaggle support was removed in Goose 0.17.0 and will be restored in Goose 0.20, before the 1.0 release. This page describes Goose 0.16.4, the last release with it; see the Gaggle overview.
At this time, a Goose process can be either a Manager or a Worker, not both. Therefore, it usually makes sense to launch your first Worker on the same server that the Manager is running on. If not otherwise configured, a Goose Worker will try to connect to the Manager on the localhost.
Examples
Starting a Worker that connects to a Manager running on the same server:
cargo run --features gaggle --example simple -- --worker -v
In our earlier example, we expected 2 Workers. The second Goose process should be started on a different server. This will require telling it the host where the Goose Manager process is running. For example:
cargo run --example simple -- --worker --manager-host 192.168.1.55 -v
Once all expected Workers are running, the distributed load test will automatically start. We set the -v flag so Goose provides verbose output indicating what is happening. In our example, the load test will run until it is canceled. You can cancel the Manager or either of the Worker processes, and the test will stop on all servers.
Run-time Flags
NOTE: Goose 0.19 cannot run a Gaggle. Gaggle support was removed in Goose 0.17.0 and will be restored in Goose 0.20, before the 1.0 release. This page describes Goose 0.16.4, the last release with it; see the Gaggle overview.
--manager: starts a Goose process in Manager mode. There currently can only be one Manager per Gaggle.--worker: starts a Goose process in Worker mode. How many Workers are in a given Gaggle is defined by the--expect-workersoption, documented below.--no-hash-check: tells Goose to ignore if the load test application doesn’t match between Worker(s) and the Manager. This is not recommended, and can cause the application to panic.
The --no-metrics, --no-reset-metrics, --no-status-codes, and --no-hash-check flags must be set on the Manager. Workers inherit these flags from the Manager
Run-time Options
--manager-bind-host <manager-bind-host>: configures the host that the Manager listens on. By default Goose will listen on all interfaces, or0.0.0.0.--manager-bind-port <manager-bind-port>: configures the port that the Manager listens on. By default Goose will listen on port5115.--manager-host <manager-host>: configures the host that the Worker will talk to the Manager on. By default, a Goose Worker will connect to the localhost, or127.0.0.1. In a distributed load test, this must be set to the IP of the Goose Manager.--manager-port <manager-port>: configures the port that a Worker will talk to the Manager on. By default, a Goose Worker will connect to port5115.
The --users, --increase-time, --increase-rate, --host, and --run-time options must be set on the Manager. Workers inherit these options from the Manager.
The --throttle-requests option must be configured on each Worker, and can be set to a different value on each Worker if desired.
Gaggle Technical Details
NOTE: Goose 0.19 cannot run a Gaggle. Gaggle support was removed in Goose 0.17.0 and will be restored in Goose 0.20, before the 1.0 release. This page describes Goose 0.16.4, the last release with it; see the Gaggle overview.
Goose uses nng to send network messages between the Manager and all Workers. Serde and Serde CBOR are used to serialize messages into Concise Binary Object Representation.
Workers initiate all network connections, and push metrics to the Manager process.
Compile-time Feature
Gaggle support is a compile-time Cargo feature that must be enabled. Goose uses the nng library to manage network connections, and compiling nng requires that cmake be available.
The gaggle feature can be enabled from the command line by adding --features gaggle to your cargo command.
With Goose 0.16, a load test application could default to compiling in the Gaggle feature in the dependencies section of its Cargo.toml. This only works with Goose 0.16.4 or earlier: Goose 0.18 accepts the feature and ignores it, and Goose 0.17 and 0.19 have no gaggle feature, so Cargo refuses to build a dependency line that asks for it. For example, pinned to 0.16.4:
[dependencies]
goose = { version = "=0.16.4", features = ["gaggle"] }
What is Coordinated Omission?
Coordinated Omission is a measurement problem that accidentally hides how many people are actually affected by server slowdowns during load testing.
The Race Timer Problem
Imagine you’re timing runners in a race:
- You’re supposed to time a runner every 10 seconds
- But sometimes a runner gets stuck in mud for 30 seconds
- Your timer waits for that stuck runner before timing the next one
- So instead of timing 6 runners per minute, you only time 2
Result: Your data says “average time: 15 seconds” when reality is most runners take 10 seconds, but some get stuck for 30+ seconds!
The issue: You’re only measuring the runners who actually finish, missing all the runners who would have started during the delay.
How This Affects Load Testing
The Problem: Missing the Full Impact
Timeline of Goose User Thread:
Time 0s: Send request ✅ (1 second response)
Time 1s: Send request ✅ (1 second response)
Time 2s: Send request ✅ (1 second response)
Time 3s: Send request... ⏳ (server freezes!)
Time 33s: Finally get response ❌ (30 seconds late!)
Time 34s: Send request ✅ (1 second response)
What traditional load testing records:
- 4 requests total
- Average response time: 8.25 seconds
- “Looks like mostly good performance”
What really happened: During that 30-second freeze, 29 more requests should have been made but couldn’t because the thread was stuck waiting. So instead of 4 requests, there should have been 33 requests affected by the server problem.
Why This Matters
When your server freezes for 30 seconds, EVERY user trying to access it during those 30 seconds is affected. Traditional load testing makes it look like only 1 user had problems, when really 30+ users experienced the issue.
This leads to dangerously optimistic reports:
- ❌ “99% of requests were fast” (hiding the freeze)
- ✅ “Server had a 30-second outage affecting 87% of traffic” (reality)
How Goose Fixes This
Coordinated omission mitigation is off by default; --co-mitigation average, minimum or maximum turns it on. See Mitigation Strategies.
1. Detects Missing Requests
Goose tracks how long each user takes to run through all of its transactions. When a run takes more than twice the user’s usual cadence, as one held up by a 30 second freeze does, Goose recognizes: “During that run, this user should have made more requests but couldn’t.”
2. Synthetic Request Injection
Goose adds “synthetic requests” to represent the requests that would have been made if the server hadn’t frozen, giving you a complete picture of impact.
3. Clear Reporting
Goose shows the measured and the adjusted response times side by side, and sums up the events. Here is a real run like the freeze above: one user of a load test whose only transaction requests /, against a local server that answers in 1 second, with --co-mitigation minimum so the cadence is the user’s fastest run. 3.5 seconds into the run the server froze for 30 seconds:
cargo run --release -- --host http://127.0.0.1:8080/ -u1 -r1 -t36s --co-mitigation minimum --no-telnet
=== COORDINATED OMISSION METRICS ===
Duration: 37 seconds
Total CO Events: 1
Events per minute: 1.62
Request Breakdown:
Actual requests: 8
Synthetic requests: 28 (77.8%)
Severity Distribution:
Critical: 1
The user measured 8 requests, one of them held for 30 seconds, and Goose added 28 synthetic requests for those it could not send during the freeze, so the GET / row counts 36 requests. This tells you: “77.8% of your expected traffic was affected by server problems!”
Visual Comparison
Before (Traditional Load Testing):
Timeline: |--1s--|--1s--|--------30s--------|--1s--|
Requests: ✅ ✅ ❌ ✅
Result: "4 requests, looks mostly fine" ❌ MISLEADING
After (Goose with CO Mitigation):
Timeline: |--1s--|--1s--|--------30s--------|--1s--|
Real: ✅ ✅ ❌ ✅
Synthetic: ❌❌❌❌❌...❌❌❌ (about one for each second of the freeze)
Result: "30-second freeze affected most of the traffic" ✅ ACCURATE
The Key Insight
A server problem affects every request that should have been sent while it lasted, not only the one that was slow. With mitigation enabled, Goose counts those requests too.
Mitigation Strategies
Overview
Goose records the time of every request it makes. That alone does not prevent coordinated omission: each GooseUser waits for a response before it sends its next request, so while the server stalls, users send fewer requests and the stall is under counted. Coordinated omission mitigation, which is off by default, backfills the requests each user would have sent at its usual cadence.
What Goose Records
Complete Timing Capture
- Recording Every Request: Goose records the response time of every request it makes, however long it takes, up to the request timeout
- No Sampling: every response time is counted; none is sampled away
- One Request at a Time per User: a
GooseUserwaits for each response before it goes on, so a slow response delays that user’s next requests; this is the source of coordinated omission that mitigation corrects for
#![allow(unused)]
fn main() {
// Example: Goose times this request however long it takes, up to --timeout.
async fn user_function(user: &mut GooseUser) -> TransactionResult {
// Even if this takes 30 seconds, Goose records the 30 seconds,
// and this user sends nothing else while it waits.
let _goose = user.get("/slow-endpoint").await?;
Ok(())
}
}
Accurate Percentile Calculation
Goose keeps response times in its own buckets:
- Microsecond precision: times are taken from a
Durationand kept to the microsecond - Bounded error: each bucket is never more than 1% off any time it holds, so percentiles are never more than 1% off
- Exact extremes: the fastest and slowest times are kept exactly
Configuration Options
Request Timeout Settings
A request that times out fails, and its response time is about the timeout, so a timeout shorter than the server’s slowest responses cuts them off. Set one long enough to capture the slow responses you want measured:
# A 60 second request timeout (the default is 60)
cargo run --release -- --timeout 60
# For extremely slow endpoints, increase further
cargo run --release -- --timeout 300
Coordinated Omission Mitigation Mode
Mitigation is disabled by default. Enable it by naming the cadence to backfill at:
# Backfill at each user's average cadence
cargo run --release -- --co-mitigation average
--co-mitigation takes average, maximum, minimum or disabled, ignoring case. In code, set it with GooseDefault::CoordinatedOmissionMitigation and a GooseCoordinatedOmissionMitigation value. Any value but disabled is refused with --no-metrics. Mitigation has no other options.
When enabled, this mode:
- Tracks how long each user takes to run through all of its transactions
- Backfills synthetic response times for the requests a stalled user did not send
- Logs an INFO message for each request that took longer than its user’s usual cadence
Understanding Your Results
With mitigation enabled and once it has backfilled a request, Goose shows two sets of metrics:
- Raw Metrics: the response times Goose measured
- Adjusted Metrics: the measured times together with the synthetic ones
A large gap between these metrics means a stall held users back during your test. The Metrics page shows where each appears.
Choosing Your Mitigation Strategy
Goose offers four values for the --co-mitigation flag:
| Mode | Use Case | Behavior |
|---|---|---|
disabled (default) | Custom analysis, external CO handling | No adjustment, raw data only |
average | General performance testing | Expected cadence is the user’s average time to run through its transactions |
minimum | Most sensitive | Expected cadence is the user’s fastest time to run through its transactions |
maximum | Least sensitive | Expected cadence is the user’s slowest time to run through its transactions |
When to Use Each Mode
Use minimum when:
- Testing services with strict timing requirements
- You want any slowdown against the fastest run through the transactions backfilled
- Testing in controlled environments with little variance
Use average when:
- Simulating realistic user behavior
- Testing public-facing websites
- You want balanced synthetic data generation
- General performance regression testing
Use maximum when:
- Response times vary widely and you only want the largest stalls backfilled
Use disabled when:
- Implementing custom CO mitigation
- Performing specialized statistical analysis
- You need only actual measurements
- Comparing with other tools’ raw output
Best Practices
1. Use Realistic User Counts
Avoid overwhelming your system with too few users:
# Better: More users with think time
cargo run --release -- --users 1000 --increase-rate 10
# Worse: Few users hammering the system
cargo run --release -- --users 10 --increase-rate 10
2. Monitor Response Time Distributions
Read the full distribution as well as the averages. The percentile tables of the console, HTML and Markdown reports show where the long tail starts: a median that looks good can sit beside a 99th percentile many times higher.
3. Set Appropriate Timeouts
Balance between capturing slow responses and test duration. --timeout sets the timeout for every request; to give one request its own, set it on the request builder:
#![allow(unused)]
fn main() {
use goose::prelude::*;
use std::time::Duration;
async fn slow_endpoint(user: &mut GooseUser) -> TransactionResult {
// Give this one request 30 seconds, whatever --timeout says.
let request_builder = user
.get_request_builder(&GooseMethod::Get, "/endpoint")?
.timeout(Duration::from_secs(30));
// The path names the request in the metrics, as the RequestBuilder
// already has its URL.
let goose_request = GooseRequest::builder()
.path("/endpoint")
.set_request_builder(request_builder)
.build();
let _goose = user.request(goose_request).await?;
Ok(())
}
}
4. Use Test Plans for Controlled Load
Test plans help maintain consistent request rates:
# Gradual ramp up prevents overwhelming the system: 100 users over 30 seconds,
# hold for 30 seconds, 200 users over 90 seconds, hold for 150 seconds, then
# stop every user over a minute.
cargo run --release -- --test-plan "100,30s;100,30s;200,1m30s;200,2m30s;0,1m"
How It Works
Each time a GooseUser starts another run through all the Transactions of its Scenario, Goose measures how long the previous run took, leaving out the time the user slept between transactions because of set_wait_time. It keeps each user’s fastest, slowest and average run. From the user’s fourth run on, the expected cadence is the average, slowest or fastest run, as --co-mitigation says; the average includes the run just measured. When the previous run took more than twice the expected cadence, Goose treats it as a stall.
Every request the user then makes during the run that follows backfills the stall. For each one, Goose records synthetic response times: the length of the stalled run, minus the cadence, minus the request’s own response time, then that value minus the cadence again, and so on for as long as the value is greater than the request’s own response time. These are the response_times the requests the user would have sent during the stall would have seen. These synthetic times estimate the effect of the slowdown.
Independently, from a user’s fourth run on, Goose logs an INFO message for each request whose response time is longer than the user’s expected cadence, its user_cadence. Such a request will likely trigger mitigation. INFO messages are shown on the console by default (-q hides them), and are written to the Goose log when it is configured with --goose-log and the log level is raised with -g.
Verification Techniques
1. Compare with Expected Throughput
Calculate theoretical vs actual request rates:
expected_rps = users * (1000 / average_ms_per_request_including_think_time)
actual_rps = total_requests / test_duration_seconds
co_factor = expected_rps / actual_rps # well above 1 suggests users were held back
2. Analyze Response Time Variance
Look for these warning signs in the measured metrics:
- A 99th percentile more than ten times the median
- A maximum response time orders of magnitude higher than the median
- A large gap between the measured and adjusted tables when mitigation is enabled
Examples
The following run is contrived. The drupal_memcache example ran with five users for 20 seconds against a local test server that answers each request in 5 to 25 ms, and that held every request for 3 seconds, 11 seconds into the run:
cargo run --release --example drupal_memcache -- --host http://127.0.0.1:8080/ \
-u5 -r5 -t20s --co-mitigation average --no-telnet \
--request-log requests.json --request-format json \
--report-file report.html --report-file report.json --report-file report.md
Requests that took longer than their user’s cadence were logged:
19:44:45 [INFO] 11.004s into goose attack: "GET http://127.0.0.1:8080/misc/drupal.js" [200] took abnormally long (3026 ms), transaction name: "(Anon) front page"
19:44:45 [INFO] 10.993s into goose attack: "GET http://127.0.0.1:8080/themes/bartik/logo.png" [200] took abnormally long (3036 ms), transaction name: "(Anon) front page"
19:44:45 [INFO] 10.984s into goose attack: "GET http://127.0.0.1:8080/misc/drupal.js" [200] took abnormally long (3046 ms), transaction name: "(Auth) front page"
19:44:45 [INFO] 11.005s into goose attack: "GET http://127.0.0.1:8080/misc/drupal.js" [200] took abnormally long (3024 ms), transaction name: "(Anon) front page"
19:44:45 [INFO] 11.004s into goose attack: "GET http://127.0.0.1:8080/" [200] took abnormally long (3026 ms), transaction name: "(Anon) front page"
The time before each message is when the request started, in seconds since the load test started, and the number in parentheses is its response time. With --request-log enabled, you can find each of these requests in the request log by its elapsed, the same start time in milliseconds, and its response_time. The first three, which started 11,004, 10,993 and 10,984 ms in and took 3,026, 3,036 and 3,046 ms:
{"elapsed":11004,"scenario_index":0,"scenario_name":"anonbrowsinguser","transaction_index":0,"transaction_name":{"TransactionOnly":"(Anon) front page"},"raw":{"method":"Get","url":"http://127.0.0.1:8080/misc/drupal.js","headers":[],"body":"","custom_method":""},"name":"static asset","final_url":"http://127.0.0.1:8080/misc/drupal.js","redirected":false,"response_time":3026,"status_code":200,"success":true,"update":false,"user":1,"error":"","coordinated_omission_elapsed":0,"user_cadence":930,"response_time_fraction_us":360}
{"elapsed":10993,"scenario_index":0,"scenario_name":"anonbrowsinguser","transaction_index":0,"transaction_name":{"TransactionOnly":"(Anon) front page"},"raw":{"method":"Get","url":"http://127.0.0.1:8080/themes/bartik/logo.png","headers":[],"body":"","custom_method":""},"name":"static asset","final_url":"http://127.0.0.1:8080/themes/bartik/logo.png","redirected":false,"response_time":3036,"status_code":200,"success":true,"update":false,"user":0,"error":"","coordinated_omission_elapsed":0,"user_cadence":896,"response_time_fraction_us":748}
{"elapsed":10984,"scenario_index":1,"scenario_name":"authbrowsinguser","transaction_index":1,"transaction_name":{"TransactionOnly":"(Auth) front page"},"raw":{"method":"Get","url":"http://127.0.0.1:8080/misc/drupal.js","headers":[],"body":"","custom_method":""},"name":"static asset","final_url":"http://127.0.0.1:8080/misc/drupal.js","redirected":false,"response_time":3046,"status_code":200,"success":true,"update":false,"user":3,"error":"","coordinated_omission_elapsed":0,"user_cadence":1046,"response_time_fraction_us":50}
All five user threads were held up by the same stall, each loading the front page or one of its static assets: users 0, 1, 2 and 4 the anonymous front page, and user 3 the authenticated one. The front page is the transaction each user runs most often, with a weight of 15 against the node page’s 10 and the user page’s 3, and each front page load also requests its static assets, so most requests caught by a stall belong to it. The user_cadence of each line is that user’s expected cadence: user 1 took on average 930 ms to run through its transactions, user 0 896 ms and user 3 1,046 ms, so a 3 second stall is more than twice any of them.
The request log also holds the requests that carry a backfill. During the run after a stall, each request a user makes appears twice: once as measured, with a coordinated_omission_elapsed of 0, and once more with coordinated_omission_elapsed set to how long the stalled run took, in milliseconds. Here is the line with the backfill for the first request of user 1’s run after the stall, a front page request that took 12 ms, after a run that took 3,946 ms against a cadence of 1,182 ms, the average now that it includes the stalled run:
{"elapsed":14191,"scenario_index":0,"scenario_name":"anonbrowsinguser","transaction_index":0,"transaction_name":{"TransactionOnly":"(Anon) front page"},"raw":{"method":"Get","url":"http://127.0.0.1:8080/","headers":[],"body":"","custom_method":""},"name":"/","final_url":"http://127.0.0.1:8080/","redirected":false,"response_time":12,"status_code":200,"success":true,"update":false,"user":1,"error":"","coordinated_omission_elapsed":3946,"user_cadence":1182,"response_time_fraction_us":534}
From this line Goose recorded three synthetic times for GET /: 2,752 ms (3,946 less 1,182 less 12), then 1,570 and 388 ms, each 1,182 less than the one before, stopping before the next would be 12 or less. Each synthetic time also counts as a request in # reqs; the line carrying the backfill does not, as the same request is counted once as measured. The synthetic times themselves are not written to the request log; normal requests have a coordinated_omission_elapsed of 0.
Advanced Techniques
Custom Metrics Collection
Log slow responses for your own analysis:
#![allow(unused)]
fn main() {
use goose::prelude::*;
async fn monitored_request(user: &mut GooseUser) -> TransactionResult {
let goose = user.get("/endpoint").await?;
// Write slow responses to the debug log, if --debug-log is set.
if goose.request.response_time > 100 {
user.log_debug(
&format!("slow response: {} ms", goose.request.response_time),
Some(&goose.request),
None,
None,
)?;
}
Ok(())
}
}
Real-time Monitoring
Use Goose’s controllers or the live dashboard to watch a test while it runs. The Telnet controller is on by default, the WebSocket controller with --websocket, and their metrics command returns the metrics so far:
# Turn the WebSocket controller on, and keep it on this machine
cargo run --release -- --websocket --websocket-host 127.0.0.1 --websocket-port 5117
# Monitor for:
# - Sudden drops in request rate
# - Spikes in response times
Statistical Analysis Note
While Goose provides comprehensive data for analysis, determining statistical significance of performance changes requires additional tools and expertise. Goose produces the raw data you need, but interpretation remains your responsibility.
For detailed analysis, consider:
- Kolmogorov-Smirnov or Anderson-Darling tests for distribution comparison
- Note that CO-adjusted data is derived from raw data (not statistically independent)
- Export data via
--request-logfor external analysis
Summary
Goose accounts for coordinated omission with:
- Every request timed: Goose times every request it makes
- Percentiles within 1%: no bucket is more than 1% off the times it holds
- Configuration: a request timeout and three mitigation modes
- Live metrics: the Controllers and the dashboard show the metrics while the test runs
Enable mitigation where stalls are likely, so the results show how the system behaves under load.
Metrics
With coordinated omission mitigation on, Goose keeps two sets of response times for each request name. The measured times, which other pages call raw, are the ones Goose timed. The adjusted times are the measured times together with the synthetic times that mitigation adds for the requests that a delayed user did not make. Goose also counts CO events. This page shows where each view reports them; Was my run affected? says how to read them for your own run.
The run on this page
Every image and excerpt on this page comes from runs against a small local server that answers each request after a random delay of about 5 to 15 ms, and stalls every 1,000th request, counted across all connections, for one second. The GraphQL example ran 10 users for two minutes against it, with no wait between requests:
cargo run --release --features dashboard --example graphql_loadtest -- \
-H http://127.0.0.1:18900 --dashboard -u 10 -r 10 -t 2m --co-mitigation average \
--report-file report.html --report-file report.md --report-file report.json
Each user runs the example’s four requests in a loop, and one time through the loop is an iteration. The time a user’s iteration is expected to take is its cadence: with --co-mitigation average, the average time of the user’s iterations. A stalled request delays the rest of its user’s iteration. Of about 111,000 requests in two minutes, the server stalled 111 for a second each, and the console logged each of them as taking abnormally long.
The live dashboard
Once mitigation records its first event, the dashboard shows adjusted times where it shows response times:
- the p95 and average KPI cards say
(whole run, adjusted); - the Whole run card shows adjusted and measured times side by side and, once there is an event, lists Coordinated omission as active;
- the requests table shows adjusted times, with the measured time on hover;
- the response time heatmap opens on Adjusted, with a Measured button beside it.
The RPS card, and the Requests and RPS figures on the Whole run card, then count the synthetic requests too, so the RPS figures read higher than the requests per second chart: 1,001 against 922.70 in the image below. Before the first event, and with mitigation off, every time on the dashboard is measured. The dashboard shows no count of CO events; the reports and the console do.
The adjusted p95 on the KPI card is 391 ms, while the heatmap’s densest band sits near 10 ms: almost everything above that band is synthetic, spread from about 20 ms up to the one second stall, for requests that the delayed users did not make.
Two seconds later, the card shows that stalling one request in 1,000 for a second lifts the adjusted p99 to 889 ms, while the measured p99 stays at 15 ms.
PDF and HTML reports
The PDF and HTML reports add two adjusted tables once mitigation has added synthetic times, and an events section whenever mitigation is on. Request Metrics With Coordinated Omission Mitigation follows Request Metrics: the adjusted average, a standard deviation and the maximum for each request name that has synthetic times, then the aggregate. Mitigation never changes the minimum, so this table shows in its place the standard deviation of two numbers, the measured and the adjusted average, which is their difference divided by the square root of 2.
The adjusted averages are 50 to 51 ms; a standard deviation of 28 puts the measured average about 40 ms lower, at the 11 ms the Whole run card shows as Avg ms (measured).
Response Time Metrics With Coordinated Omission Mitigation follows Response Time Metrics, with the same percentile columns.
The measured table, untitled here, on top and the adjusted one below stay within a millisecond of each other up to the 90th percentile; above it the adjusted times climb toward the one second stall.
Coordinated Omission Event Metrics ends the report whenever mitigation is on, even with no events: the events, the events per minute, the actual and synthetic requests, and a count of each severity that had an event.
The 440 events are four for each stalled request that had an iteration after it, as Reading the event summary explains.
Markdown report
The Markdown report has no adjusted tables. It has the event summary, with all four severities, and, when there are events, tables of events by user and by scenario. From this run, without the per user and per scenario tables:
## Coordinated Omission Metrics
**Overall Metrics:**
- Total CO Events: 440
- Synthetic Requests Generated: 9,770
- Actual Requests: 111,106
- Synthetic Percentage: 8.1%
**Severity Breakdown:**
- Minor (2-5x): 0
- Moderate (5-10x): 0
- Severe (10-20x): 24
- Critical (>20x): 416
JSON report
co_request_metrics and co_response_metrics hold the two adjusted tables, beside the measured raw_request_metrics and raw_response_metrics. Each request name in raw_metrics.requests holds its adjusted timing data in coordinated_omission_data, null when it has none. coordinated_omission_metrics holds the event summary, shown here without its per_user_events and per_scenario_events:
{
"total_co_events": 440,
"actual_requests": 111106,
"synthetic_requests": 9770,
"synthetic_percentage": 8.082664,
"severity_breakdown": {
"Critical": 416,
"Severe": 24
},
"duration_secs": 121,
"events_per_minute": 218.1818181818182,
"minor_count": 0,
"moderate_count": 0,
"severe_count": 24,
"critical_count": 416
}
Console metrics
With mitigation on, Goose logs each request that took longer than the user’s cadence, starting with the user’s fourth iteration; with mitigation off, it logs none:
16:24:37 [INFO] 1.447s into goose attack: "POST http://127.0.0.1:18900/graphql" [200] took abnormally long (1000 ms), transaction name: "Simple Query"
16:24:38 [INFO] 2.546s into goose attack: "POST http://127.0.0.1:18900/graphql" [200] took abnormally long (999 ms), transaction name: "Complex Query"
16:24:39 [INFO] 3.654s into goose attack: "POST http://127.0.0.1:18900/graphql" [200] took abnormally long (1001 ms), transaction name: "Mutation"
In the final metrics, an Adjusted for Coordinated Omission table follows the table of averages (Avg, Min, Max, Median) and another follows the percentile table, as the two adjusted tables do in the HTML report:
------------------------------------------------------------------------------
Name | Avg (ms) | Min | Max | Median
------------------------------------------------------------------------------
POST /graphql | 11 | 4.12 | 1,001 | 9.92
POST complex user post.. | 11 | 4.14 | 1,001 | 9.79
POST create post | 11 | 4.14 | 1,001 | 9.92
POST get user by id | 11 | 4.2 | 1,001 | 9.92
-------------------------+-------------+------------+-------------+-----------
Aggregated | 11 | 4.12 | 1,001 | 9.92
------------------------------------------------------------------------------
Adjusted for Coordinated Omission:
------------------------------------------------------------------------------
Name | Avg (ms) | Std Dev | Max | Median
------------------------------------------------------------------------------
POST /graphql | 50 | 28 | 1,001 | 10
POST complex user post.. | 51 | 28 | 1,001 | 10
POST create post | 51 | 28 | 1,001 | 10
POST get user by id | 51 | 28 | 1,001 | 10
-------------------------+-------------+------------+-------------+-----------
Aggregated | 51 | 28 | 1,001 | 10
------------------------------------------------------------------------------
Slowest page load within specified percentile of requests (in ms):
------------------------------------------------------------------------------
Name | 50% | 75% | 98% | 99% | 99.9% | 99.99%
------------------------------------------------------------------------------
POST /graphql | 9.92 | 12 | 15 | 15 | 17 | 1001
POST complex user post.. | 9.79 | 12 | 15 | 15 | 25 | 1001
POST create post | 9.92 | 12 | 15 | 15 | 1001 | 1001
POST get user by id | 9.92 | 12 | 15 | 15 | 1001 | 1001
-------------------------+--------+--------+--------+--------+--------+-------
Aggregated | 9.92 | 12 | 15 | 15 | 25 | 1001
------------------------------------------------------------------------------
Adjusted for Coordinated Omission:
------------------------------------------------------------------------------
Name | 50% | 75% | 98% | 99% | 99.9% | 99.99%
------------------------------------------------------------------------------
POST /graphql | 10 | 13 | 766 | 889 | 987 | 1001
POST complex user post.. | 10 | 13 | 766 | 889 | 987 | 1001
POST create post | 10 | 13 | 766 | 889 | 1001 | 1001
POST get user by id | 10 | 13 | 766 | 889 | 995 | 1001
-------------------------+--------+--------+--------+--------+--------+-------
Aggregated | 10 | 13 | 766 | 889 | 987 | 1001
The event summary comes just before === OVERVIEW === in the final metrics. A copy is also printed at the metrics reset, when every user has started; it covers only the ramp up, so read the final one:
=== COORDINATED OMISSION METRICS ===
Duration: 121 seconds
Total CO Events: 440
Events per minute: 218.18
Request Breakdown:
Actual requests: 111106
Synthetic requests: 9770 (8.1%)
Severity Distribution:
Critical: 416
Severe: 24
Reading the event summary
- Total CO Events counts requests, not stalled requests. When an iteration takes more than twice the user’s cadence (here, one with a stalled request), each request of the user’s next iteration that mitigation adds synthetic times for is one event. Here 110 of the 111 stalled requests led to four events each, one for each request of the user’s next iteration; the last came as the run ended, with no iteration after it.
- Duration is the time in whole seconds since the counts started, and Events per minute is Total CO Events divided by that time in minutes: 440 in 121 seconds is 218.18.
- Actual requests are the requests Goose timed; synthetic requests are the times mitigation added. Together they are the Aggregated
# reqsof the request table in the reports and the console, 120,876 here. The percentage is the synthetic requests’ share of that total: 9,770 of 120,876 is 8.1%. - The counts start again at the metrics reset after the users start, as the other metrics do; in a run with no reset (
--no-reset-metrics,--iterationsor a test plan) they cover the whole run. - Severity is the delayed iteration’s length as a multiple of the user’s cadence: Minor below 5 times, Moderate from 5 to 10, Severe from 10 to 20, and Critical from 20 times. Mitigation starts only above 2 times. The PDF and HTML reports and the console list only the severities with events, in no fixed order.
- In the Markdown report’s table of events by user, users are numbered from 0 and listed in no fixed order; the console’s
[user 1]: launching userlines number them from 1.
Was my run affected?
If your run had mitigation off, as it is by default (--co-mitigation disabled), look at its measured times: a maximum or a 99.99th percentile many times the median is the sign that requests were stalled. The disabled run in the table below had a maximum of 1,002 ms against a median of 9.92 ms in its console. Mitigation adds nothing to the load Goose sends, so run again with --co-mitigation average to see what the measured times leave out.
With mitigation on, check for events first. On the live dashboard, Coordinated omission active on the Whole run card means at least one event. In the reports, and in the console’s final event summary (not the copy printed at the metrics reset), read Total CO Events. When it is 0, mitigation added no synthetic times and Goose shows no adjusted tables. With average or minimum, that means no iteration judged since the metrics reset ran far enough over twice its user’s cadence for mitigation to add a synthetic time. A user’s first two iterations are never judged, so a stall in them, as on a cold start, counts no events. A user’s last iteration is not judged either, so a stall as the run ends counts none. With maximum, which counts no events in Goose 0.19, a 0 says nothing; run it again with average.
When there are events, compare the measured and adjusted times from the 95th percentile up:
- the dashboard’s Whole run card: p95 and p99, adjusted beside measured;
- the PDF or HTML report: its two percentile tables;
- the JSON report:
raw_response_metricsagainstco_response_metrics; - the console: its two percentile tables, from the 98% column.
The Markdown report has no adjusted tables. If the adjusted times climb, as from 15 to 889 ms at the 99th percentile here, stalled requests kept users from sending requests that the measured times leave out. With only a few events, only the highest percentiles change, but the run was still affected; the synthetic percentage (8.1% here) says by how much. If your run used minimum, read its note below the table first.
The setting decides what counts. The average row is the run above; the others ran the same command against a second copy of the server on port 18901, with --no-telnet in place of --dashboard:
--co-mitigation | CO events | Synthetic requests | Measured p99 | Adjusted p99 |
|---|---|---|---|---|
disabled (the default) | not counted | none | 15 ms | none |
average | 440 | 9,770 (8.1%) | 15 ms | 889 ms |
minimum | 18,258 | 38,288 (25.7%) | 15 ms | 938 ms |
maximum | 0 | 0 (0.0%) | 15 ms | none |
averagecompares each iteration with the user’s average iteration; here it counted events after every stalled request but the last.minimumcompares with the fastest iteration. Iterations of ordinary length led to events too. The fastest iteration in the run took 20 ms and the median 40 ms, so an iteration of ordinary length could run over twice the cadence of a user whose fastest iteration was near 20 ms; 17,818 of the 18,258 events were Minor and 440 Critical. Withminimum, judge a run by its events above Minor (Moderate, Severe and Critical), or run it again withaverage.maximumcompares with the slowest iteration Goose has recorded. Goose records a slowest iteration only once an iteration is no faster than the fastest before it; until then the user’s cadence is 0, and Goose logs every request but adds no synthetic times. Once there is a slowest iteration, a longer iteration becomes the slowest before Goose judges it, so no iteration is ever more than twice the slowest, and in Goose 0.19maximumcounts no events. Once one of a user’s iterations included a stalled request, that user’s cadence was over a second, and Goose stopped logging the user’s stalled requests. It logged 10 of that run’s 111 stalled requests, all in the first 18 seconds. It also logged 12 ordinary requests in the run’s first 0.6 seconds, from users whose every iteration so far had been faster than the one before it.
Goose does not test whether a change in the distribution is statistically significant; see the statistical analysis note.
Practical Examples
Each example shows when to use a Coordinated Omission mitigation strategy. Each example includes the command to run against a load test of your own, an illustration of the summary Goose prints when mitigation is enabled, and interpretation guidance. The figures are illustrations, not results of real runs; the Metrics page explains each line.
Example 1: Microservice SLA Validation
Scenario: You’re testing a payment processing microservice that must respond within 100ms for 99% of requests.
Goal: Detect any stall, no matter how brief.
Strategy: Use minimum cadence to catch even momentary slowdowns.
# Test command
cargo run --release -- \
--host https://payments.api.company.com \
--users 20 \
--run-time 5m \
--co-mitigation minimum \
--report-file payment_test.html
# Expected healthy output:
# === COORDINATED OMISSION METRICS ===
# Duration: 300 seconds
# Total CO Events: 2
# Events per minute: 0.40
#
# Request Breakdown:
# Actual requests: 18450
# Synthetic requests: 12 (0.1%)
#
# Severity Distribution:
# Minor: 2
Interpretation:
- ✅ Excellent: Only 2 minor CO events in 5 minutes
- ✅ Minimal Impact: only 0.1% of the response times are synthetic
- ✅ No Critical Issues: No severe or critical events
Red Flag Example:
# Problematic output:
# === COORDINATED OMISSION METRICS ===
# Duration: 300 seconds
# Total CO Events: 45
# Events per minute: 9.00
#
# Request Breakdown:
# Actual requests: 18450
# Synthetic requests: 892 (4.6%)
#
# Severity Distribution:
# Minor: 38
# Moderate: 6
# Severe: 1
Action Required: 4.6% synthetic requests and frequent CO events indicate the service stalls often enough to check its response time percentiles against the SLA.
Example 2: E-commerce Website Load Testing
Scenario: Testing an e-commerce site during Black Friday preparation. Users can tolerate some variability, but you want to understand overall performance.
Goal: Simulate realistic user behavior while detecting significant performance issues.
Strategy: Use average cadence for balanced detection.
# Test command
cargo run --release -- \
--host https://shop.company.com \
--users 500 \
--run-time 15m \
--co-mitigation average \
--report-file blackfriday_test.html
# Expected healthy output:
# === COORDINATED OMISSION METRICS ===
# Duration: 900 seconds
# Total CO Events: 12
# Events per minute: 0.80
#
# Request Breakdown:
# Actual requests: 145230
# Synthetic requests: 234 (0.2%)
#
# Severity Distribution:
# Minor: 8
# Moderate: 3
# Severe: 1
Interpretation:
- ✅ Good Performance: Low CO event rate (0.8/minute)
- ✅ Minimal Impact: Only 0.2% synthetic requests
- ⚠️ Monitor: One severe event warrants investigation
Concerning Example:
# Problematic output:
# === COORDINATED OMISSION METRICS ===
# Duration: 900 seconds
# Total CO Events: 156
# Events per minute: 10.40
#
# Request Breakdown:
# Actual requests: 145230
# Synthetic requests: 12450 (7.9%)
#
# Severity Distribution:
# Minor: 89
# Moderate: 45
# Severe: 18
# Critical: 4
Action Required: High CO event rate and 7.9% synthetic requests indicate the site will struggle under Black Friday load. Scale up resources or optimize performance.
Example 3: API Gateway Performance Testing
Scenario: Testing an API gateway that routes requests to multiple backend services. You want to understand how backend slowdowns affect the gateway.
Goal: Detect when backend issues cause gateway performance degradation.
Strategy: Use average cadence with longer test duration to capture intermittent issues.
# Test command
cargo run --release -- \
--host https://gateway.api.company.com \
--users 100 \
--run-time 30m \
--co-mitigation average \
--report-file gateway_test.html
# Healthy distributed system output:
# === COORDINATED OMISSION METRICS ===
# Duration: 1800 seconds
# Total CO Events: 23
# Events per minute: 0.77
#
# Request Breakdown:
# Actual requests: 324500
# Synthetic requests: 445 (0.1%)
#
# Severity Distribution:
# Minor: 18
# Moderate: 4
# Severe: 1
Interpretation:
- ✅ Stable Gateway: Low synthetic percentage indicates good overall performance
- ✅ Resilient: Minor events suggest the gateway handles backend hiccups well
- ✅ Scalable: Consistent performance over 30 minutes
Example 4: Database Connection Pool Testing
Scenario: Testing an application’s database connection pool under load to ensure it doesn’t become a bottleneck.
Goal: Detect connection pool exhaustion or database slowdowns.
Strategy: Use minimum cadence to catch any database-related delays immediately.
# Test command
cargo run --release -- \
--host https://app.company.com \
--users 200 \
--run-time 10m \
--co-mitigation minimum \
--report-file db_pool_test.html
# Healthy connection pool output:
# === COORDINATED OMISSION METRICS ===
# Duration: 600 seconds
# Total CO Events: 8
# Events per minute: 0.80
#
# Request Breakdown:
# Actual requests: 89450
# Synthetic requests: 67 (0.1%)
#
# Severity Distribution:
# Minor: 6
# Moderate: 2
Pool Exhaustion Example:
# Connection pool exhaustion:
# === COORDINATED OMISSION METRICS ===
# Duration: 600 seconds
# Total CO Events: 234
# Events per minute: 23.40
#
# Request Breakdown:
# Actual requests: 89450
# Synthetic requests: 8920 (9.1%)
#
# Severity Distribution:
# Minor: 45
# Moderate: 123
# Severe: 56
# Critical: 10
Action Required: High CO event rate and 9.1% synthetic requests are consistent with connection pool exhaustion. Increase pool size or optimize database queries.
Example 5: CDN Performance Validation
Scenario: Testing how your application performs when the CDN is slow or unavailable.
Goal: Understand the impact of CDN issues on user experience.
Strategy: Use average cadence to simulate realistic user tolerance.
# Test command with CDN issues simulated
cargo run --release -- \
--host https://app.company.com \
--users 150 \
--run-time 10m \
--co-mitigation average \
--report-file cdn_impact_test.html
# CDN issues detected:
# === COORDINATED OMISSION METRICS ===
# Duration: 600 seconds
# Total CO Events: 89
# Events per minute: 8.90
#
# Request Breakdown:
# Actual requests: 67230
# Synthetic requests: 2340 (3.4%)
#
# Severity Distribution:
# Minor: 34
# Moderate: 38
# Severe: 15
# Critical: 2
Interpretation:
- ⚠️ CDN Impact: 3.4% synthetic requests show CDN issues affect user experience
- ⚠️ User Frustration: Moderate and severe events indicate noticeable delays
- 📊 Business Impact: Use this data to justify CDN redundancy or optimization
Example 6: Baseline Testing (No CO Expected)
Scenario: Testing a well-optimized system under normal load to establish performance baselines.
Goal: Confirm the system performs consistently without CO events.
Strategy: Use disabled to get pure measurements, then compare with average mode.
# First, test with CO disabled for baseline
cargo run --release -- \
--host https://optimized.company.com \
--users 100 \
--run-time 10m \
--co-mitigation disabled \
--report-file baseline_raw.html
# Then test with CO detection enabled
cargo run --release -- \
--host https://optimized.company.com \
--users 100 \
--run-time 10m \
--co-mitigation average \
--report-file baseline_co.html
# Expected output (CO enabled):
# === COORDINATED OMISSION METRICS ===
# Duration: 600 seconds
# Total CO Events: 0
# Events per minute: 0.00
#
# Request Breakdown:
# Actual requests: 45670
# Synthetic requests: 0 (0.0%)
Perfect Baseline: Zero CO events and 0% synthetic requests indicate the system performs consistently under this load level.
Interpreting Results Across Examples
Green Flags (Healthy System)
- CO events per minute < 2
- Synthetic request percentage < 1%
- Mostly Minor severity events
- Consistent performance across test duration
Yellow Flags (Monitor Closely)
- CO events per minute 2-10
- Synthetic request percentage 1-5%
- Some Moderate severity events
- Occasional performance dips
Red Flags (Action Required)
- CO events per minute > 10
- Synthetic request percentage > 5%
- Frequent Severe or any Critical events
- Degrading performance over time
Using CO Metrics for Capacity Planning
- Find Breaking Point: Gradually increase load until CO events spike
- Set Alerts: Monitor CO metrics in production to detect issues early
- Compare Environments: Use CO metrics to validate staging vs production performance
- Track Trends: Monitor CO metrics over time to detect performance regression
Best Practices Summary
-
Choose the Right Mode:
minimumfor the most sensitive detectionaveragefor realistic user simulationdisabledfor baseline measurements
-
Set Appropriate Test Duration:
- Short tests (5-10 min) for quick validation
- Long tests (30+ min) for stability assessment
-
Monitor Key Metrics:
- Events per minute rate
- Synthetic request percentage
- Severity distribution
- Trends over time
-
Take Action Based on Results:
- < 1% synthetic: System healthy
- 1-5% synthetic: Monitor and investigate
-
5% synthetic: Performance issues need attention
These examples provide a foundation for understanding how CO metrics help identify and quantify performance issues in different scenarios. Use them as templates for your own testing strategies.
Configuration
Configuration of Goose load tests is done in Rust code within the load test plan. Complete documentation of all load test configuration can be found in the developer documentation.
Defaults
All run-time options can be configured with custom defaults. For example, you may want to default to the host name of your local development environment, only requiring that --host be set when running against a production environment. Assuming your local development environment is at “http://local.dev/” you can do this as follows:
GooseAttack::initialize()?
.register_scenario(scenario!("LoadtestTransactions")
.register_transaction(transaction!(loadtest_index))
)
.set_default(GooseDefault::Host, "http://local.dev/")?
.execute()
.await?;
Ok(())
The following defaults can be configured with a &str:
- host:
GooseDefault::Host - per-request timeout, in seconds:
GooseDefault::Timeout - users to start per second:
GooseDefault::IncreaseRate - users to stop per second:
GooseDefault::DecreaseRate - test plan:
GooseDefault::TestPlan - only run the named scenarios:
GooseDefault::Scenarios - a pass/fail threshold rule, one rule for each call:
GooseDefault::Threshold - report file name:
GooseDefault::ReportFile - earlier report to compare against:
GooseDefault::BaselineFile - the run’s folder:
GooseDefault::OutputDir - notes stored with the run’s reports:
GooseDefault::Notes - goose log file name:
GooseDefault::GooseLog - request log file name:
GooseDefault::RequestLog - transaction log file name:
GooseDefault::TransactionLog - scenario log file name:
GooseDefault::ScenarioLog - error log file name:
GooseDefault::ErrorLog - debug log file name:
GooseDefault::DebugLog - host to bind telnet Controller to,
127.0.0.1unless set:GooseDefault::TelnetHost - host to bind WebSocket Controller to,
127.0.0.1unless set:GooseDefault::WebSocketHost - host to bind the dashboard to:
GooseDefault::DashboardHost - dashboard auth token:
GooseDefault::DashboardAuthToken
The following defaults can be configured with a usize integer:
- total users to start:
GooseDefault::Users - seconds to start all users:
GooseDefault::IncreaseTime - seconds to stop all users:
GooseDefault::DecreaseTime - seconds for the test to run:
GooseDefault::RunTime - times each user runs its scenario:
GooseDefault::Iterations - how often to print running metrics, in seconds:
GooseDefault::RunningMetrics - log level:
GooseDefault::LogLevel - quiet:
GooseDefault::Quiet - verbosity:
GooseDefault::Verbose - maximum requests per second:
GooseDefault::ThrottleRequests - seconds to wait before a threshold can stop the run:
GooseDefault::ThresholdAbortDelay - port to bind telnet Controller to:
GooseDefault::TelnetPort - port to bind WebSocket Controller to:
GooseDefault::WebSocketPort - port to bind the dashboard to:
GooseDefault::DashboardPort - maximum live dashboard clients:
GooseDefault::DashboardMaxClients - gzip every file in the run’s folder, at a level from 1 to 9:
GooseDefault::Compress - seconds to wait for a PDF report, with the
pdf-reportsfeature:GooseDefault::PdfTimeout
The following default can be configured with an f64 float:
- PDF report scale, with the
pdf-reportsfeature:GooseDefault::PdfScale
The following defaults can be configured with a bool:
- do not reset metrics after all users start:
GooseDefault::NoResetMetrics - do not print metrics:
GooseDefault::NoPrintMetrics - do not track metrics:
GooseDefault::NoMetrics - do not track transaction metrics:
GooseDefault::NoTransactionMetrics - do not track scenario metrics:
GooseDefault::NoScenarioMetrics - do not track status codes:
GooseDefault::NoStatusCodes - do not display the error summary:
GooseDefault::NoErrorSummary - judge thresholds only at the end:
GooseDefault::NoThresholdAbort - add request bodies to the request log:
GooseDefault::RequestBody - do not log the response body in the debug log:
GooseDefault::NoDebugBody - do not start telnet Controller thread:
GooseDefault::NoTelnet - start the WebSocket Controller thread, which is off by default:
GooseDefault::WebSocket - do not start WebSocket Controller thread, which only warns now that it is off by default, and is refused with
GooseDefault::WebSocket:GooseDefault::NoWebSocket - do not autostart load test, wait instead for a Controller to start:
GooseDefault::NoAutoStart - do not ask for gzip compressed responses:
GooseDefault::NoGzip - follow redirect of base_url:
GooseDefault::StickyFollow - accept invalid TLS certificates:
GooseDefault::AcceptInvalidCerts - graph only totals in reports:
GooseDefault::NoGranularData - add graph data to the JSON report:
GooseDefault::ReportGraphData - serve the live web dashboard:
GooseDefault::Dashboard - allow control from the dashboard:
GooseDefault::DashboardControl - allow dashboard access and control without a token:
GooseDefault::DashboardNoAuth - do not track per-second response times:
GooseDefault::NoPercentilesOverTime
A default names a path, never the run’s folder. GooseDefault::OutputDir needs the dashboard, or an output flag given without a path on the command line. Without either, Goose refuses it with an error before the load test starts.
The following defaults can be configured with a GooseLogFormat:
- request log file format:
GooseDefault::RequestFormat - transaction log file format:
GooseDefault::TransactionFormat - scenario log file format:
GooseDefault::ScenarioFormat - error log file format:
GooseDefault::ErrorFormat - debug log file format:
GooseDefault::DebugFormat
The following defaults can be configured with a GooseCoordinatedOmissionMitigation:
- default Coordinated Omission Mitigation strategy:
GooseDefault::CoordinatedOmissionMitigation
Setting a default with a value of the wrong type returns an error that names the type the default expects.
An option given on the command line takes the place of the default. A value of 0 undoes a default too: --run-time 0, --increase-time 0, --decrease-time 0, --iterations 0 and --throttle-requests 0 undo GooseDefault::RunTime, IncreaseTime, DecreaseTime, Iterations and ThrottleRequests, so the load test can, for example, run --iterations where the default set a run time. An empty file name turns off a log a default set: --request-log "" turns off the request log GooseDefault::RequestLog names, and the same goes for --goose-log, --transaction-log, --scenario-log, --error-log and --debug-log.
For example, without any run-time options the following load test would automatically run against http://local.dev/, logging requests to goose-requests.log and debug to goose-debug.log. It will automatically launch 20 users at 4 users per second, so in 5 seconds, and run the load test for 15 minutes. Metrics will be displayed every minute during the test, and the status code table will be disabled. The order the defaults are set is not important.
GooseAttack::initialize()?
.register_scenario(scenario!("LoadtestTransactions")
.register_transaction(transaction!(loadtest_index))
)
.set_default(GooseDefault::Host, "http://local.dev/")?
.set_default(GooseDefault::RequestLog, "goose-requests.log")?
.set_default(GooseDefault::DebugLog, "goose-debug.log")?
.set_default(GooseDefault::Users, 20)?
.set_default(GooseDefault::IncreaseRate, "4")?
.set_default(GooseDefault::RunTime, 900)?
.set_default(GooseDefault::RunningMetrics, 60)?
.set_default(GooseDefault::NoStatusCodes, true)?
.execute()
.await?;
Ok(())
Find a complete list of all configuration options that can be configured with custom defaults in the developer documentation, as well as complete details on how to configure defaults.
Scheduling Scenarios And Transactions
When starting a load test, Goose assigns one Scenario to each GooseUser thread. By default, it assigns Scenario (and then Transaction within the scenario) in a round robin order. As new GooseUser threads are launched, the first will be assigned the first defined Scenario, the next will be assigned the next defined Scenario, and so on, looping through all available Scenario. Weighting is respected during this process, so if one Scenario is weighted heavier than others, that Scenario will get assigned to GooseUser more at the end of the launching process.
The GooseScheduler can be configured to instead launch Scenario and Transaction in a Serial or a Random order. When configured to allocate in a Serial order, Scenario and Transaction are launched in the exact order they are defined in the load test (see below for more detail on how this works). When configured to allocate in a Random order, Scenarios are assigned to users at random and each Scenario’s transactions run in a random order, so running the same load test multiple times can lead to different amounts of load being generated.
Prior to Goose 0.10.6 Scenario were allocated in a serial order. Prior to Goose 0.11.1 Transaction were allocated in a serial order. To restore the old behavior, you can use the GooseAttack::set_scheduler() method as follows:
GooseAttack::initialize()?
.set_scheduler(GooseScheduler::Serial);
To instead assign Scenario to users at random, and to run each Scenario’s transactions in a random order, you can instead configure as follows:
GooseAttack::initialize()?
.set_scheduler(GooseScheduler::Random);
The following configuration is possible but superfluous because it is the scheduling default, and is therefore how Goose behaves even if the .set_scheduler() method is not called at all:
GooseAttack::initialize()?
.set_scheduler(GooseScheduler::RoundRobin);
Scheduling Example
The following simple example helps illustrate how the different schedulers work.
use goose::prelude::*;
#[tokio::main]
async fn main() -> Result<(), GooseError> {
GooseAttack::initialize()?
.register_scenario(scenario!("Scenario1")
.register_transaction(transaction!(transaction1).set_weight(2)?)
.register_transaction(transaction!(transaction2))
.set_weight(2)?
)
.register_scenario(scenario!("Scenario2")
.register_transaction(transaction!(transaction1))
.register_transaction(transaction!(transaction2).set_weight(2)?)
)
.execute()
.await?;
Ok(())
}
Round Robin Scheduler
This first example assumes the default of .set_scheduler(GooseScheduler::RoundRobin).
If Goose is told to launch only two users, one GooseUser will run Scenario1 and the other will run Scenario2. Even though Scenario1 has a weight of 2 GooseUser are allocated round-robin so with only two users the second instance of Scenario1 is never launched. Goose launches the allocated users from the end of the list, so the first user launched runs Scenario2 and the second runs Scenario1. The first user launched is "user":0 in the request, transaction and scenario logs, and [user 1]: launching user in the console.
The GooseUser running Scenario1 will then launch transactions repeatedly in the following order: transaction1, transaction2, transaction1. If it runs through twice, then it runs all of the following transactions in the following order: transaction1, transaction2, transaction1, transaction1, transaction2, transaction1.
Serial Scheduler
This second example assumes the manual configuration of .set_scheduler(GooseScheduler::Serial).
If Goose is told to launch only two users, then both GooseUser will launch Scenario1 as it has a weight of 2. Scenario2 will not get assigned to either of the users.
Both GooseUser running Scenario1 will then launch transactions repeatedly in the following order: transaction1, transaction1, transaction2. If it runs through twice, then it runs all of the following transactions in the following order: transaction1, transaction1, transaction2, transaction1, transaction1, transaction2.
Random Scheduler
This third example assumes the manual configuration of .set_scheduler(GooseScheduler::Random).
Goose fills its list of users by picking a Scenario at random and taking one of that Scenario’s weighted places, until every place is taken. With weights of 2 and 1 the list holds Scenario1 twice and Scenario2 once, in a random order. If Goose is told to launch only two users, they may both run Scenario1, or one may run each, but never both Scenario2. If the load test is stopped and run again, the users are randomly re-assigned; there is no consistency between load test runs.
Goose builds each Scenario’s list of transactions as the Serial scheduler does, then shuffles the whole list once when the load test starts. Every GooseUser running that Scenario runs the same shuffled order, over and over, and the next run of the load test gets a new order. With Scenario1 the order is one of transaction1, transaction1, transaction2; transaction1, transaction2, transaction1; or transaction2, transaction1, transaction1.
Sequenced transactions are shuffled only within their sequence, so every transaction of a lower sequence still runs before any of a higher one. Transactions set with set_on_start() or set_on_stop() keep the Serial order.
Proxy Configuration
Goose supports routing traffic through HTTP, HTTPS, and SOCKS proxies by leveraging reqwest’s proxy capabilities. This is useful for testing applications through corporate proxies, debugging with tools like Burp Suite or OWASP ZAP, or routing traffic through specific network paths.
HTTP/HTTPS Proxies (No Setup Required)
HTTP and HTTPS proxies work out of the box with Goose - no compile-time changes needed. Simply set environment variables:
Important: HTTP_PROXY controls proxying for http:// URLs and HTTPS_PROXY for https:// URLs, whatever port they use. ALL_PROXY covers both, and HTTP_PROXY or HTTPS_PROXY wins over it when both are set. The proxy server itself typically runs on HTTP regardless of which requests it proxies.
# Set both to handle HTTP and HTTPS traffic
export HTTPS_PROXY=http://proxy.example.com:8080
export HTTP_PROXY=http://proxy.example.com:8080
# With authentication
export HTTPS_PROXY=http://username:[email protected]:8080
export HTTP_PROXY=http://username:[email protected]:8080
# Run your load test (no Cargo.toml changes needed)
cargo run --example simple -- --host https://example.com
SOCKS Proxy Support (Requires Setup)
To use SOCKS proxies with Goose, you need to enable SOCKS support in your project’s reqwest dependency.
Dependencies Setup
Add reqwest with the socks feature to your Cargo.toml:
[dependencies]
goose = "^0.19"
tokio = "^1"
reqwest = { version = "0.13", default-features = false, features = ["socks"] }
Note: Cargo automatically unifies features across all dependencies. Goose turns off reqwest’s default features and enables default-tls, form, gzip and json (and cookies with Goose’s default cookies feature), so adding socks in your own dependency results in reqwest being compiled with those features plus socks. Keep default-features = false on your reqwest line if you don’t want reqwest’s own defaults, such as http2 and system-proxy, added to the build.
Environment Variables
Configure SOCKS proxy using environment variables:
# Set both to handle HTTP and HTTPS traffic
export HTTPS_PROXY=socks5h://127.0.0.1:1080
export HTTP_PROXY=socks5h://127.0.0.1:1080
# Run your load test
cargo run --example simple -- --host https://example.com
SOCKS5 vs SOCKS5h
socks5://- DNS resolution happens locally, only network traffic goes through proxysocks5h://- Both DNS resolution and network traffic go through the proxy (recommended)
Use socks5h:// when you need DNS queries to be resolved through the SOCKS proxy, which is typically the case for accessing internal networks or when you want all traffic routed through the proxy.
Common Use Cases
Security Testing with Burp Suite
export HTTPS_PROXY=http://127.0.0.1:8080
export HTTP_PROXY=http://127.0.0.1:8080
cargo run --example simple -- --host https://target.example.com
SSH SOCKS Tunnel
# Set up SSH tunnel with SOCKS proxy
ssh -D 1080 -N [email protected]
# In another terminal, run load test through tunnel
export HTTPS_PROXY=socks5h://127.0.0.1:1080
export HTTP_PROXY=socks5h://127.0.0.1:1080
cargo run --example simple -- --host https://internal-app.example.com
Corporate Proxy
# Set both to proxy all traffic through corporate proxy
export HTTPS_PROXY=http://corporate-proxy.example.com:8080
export HTTP_PROXY=http://corporate-proxy.example.com:8080
cargo run --example simple -- --host https://external-site.com
Example Load Test
HTTP/HTTPS Proxy Example (Simple)
Cargo.toml (no special dependencies needed):
[package]
name = "my-loadtest"
version = "0.1.0"
edition = "2021"
[dependencies]
goose = "^0.19"
tokio = { version = "^1", features = ["macros", "rt-multi-thread"] }
# No special reqwest configuration needed for HTTP/HTTPS proxies
src/main.rs:
use goose::prelude::*;
#[tokio::main]
async fn main() -> Result<(), GooseError> {
GooseAttack::initialize()?
.register_scenario(scenario!("LoadTest")
.register_transaction(transaction!(homepage))
)
.set_default(GooseDefault::Host, "https://example.com")?
.execute()
.await?;
Ok(())
}
async fn homepage(user: &mut GooseUser) -> TransactionResult {
let _goose_metrics = user.get("/").await?;
Ok(())
}
Running with HTTP proxy:
export HTTPS_PROXY=http://proxy.example.com:8080
export HTTP_PROXY=http://proxy.example.com:8080
cargo run
SOCKS Proxy Example (Requires Setup)
Cargo.toml (with socks feature):
[package]
name = "my-loadtest"
version = "0.1.0"
edition = "2021"
[dependencies]
goose = "^0.19"
tokio = { version = "^1", features = ["macros", "rt-multi-thread"] }
# Add the socks feature; Cargo unifies it with the features Goose enables
reqwest = { version = "0.13", default-features = false, features = ["socks"] }
src/main.rs (same as above):
use goose::prelude::*;
#[tokio::main]
async fn main() -> Result<(), GooseError> {
GooseAttack::initialize()?
.register_scenario(scenario!("LoadTest")
.register_transaction(transaction!(homepage))
)
.set_default(GooseDefault::Host, "https://example.com")?
.execute()
.await?;
Ok(())
}
async fn homepage(user: &mut GooseUser) -> TransactionResult {
let _goose_metrics = user.get("/").await?;
Ok(())
}
Running with SOCKS proxy:
export HTTPS_PROXY=socks5h://127.0.0.1:1080
export HTTP_PROXY=socks5h://127.0.0.1:1080
cargo run
Troubleshooting
DNS Resolution Issues
If you see errors like “failed to lookup address information: No address associated with hostname”, try:
- Use
socks5h://instead ofsocks5://to route DNS through the proxy - Verify your SOCKS proxy is running and accessible
- Test with curl:
curl --proxy socks5h://127.0.0.1:1080 https://example.com
Authentication Issues
For proxies requiring authentication:
- Include credentials in the proxy URL:
socks5h://username:password@proxy:1080 - Ensure special characters in credentials are URL-encoded
Feature Not Available
If you get compilation errors about SOCKS features:
- Verify
reqwest = { version = "0.13", default-features = false, features = ["socks"] }is in your Cargo.toml - Run
cargo cleanand rebuild your project
Additional Resources
For more detailed proxy configuration options, see the official reqwest documentation:
- Reqwest Proxy Documentation - Complete guide to proxy configuration in reqwest
- Environment Variables:
HTTP_PROXY,HTTPS_PROXY,ALL_PROXY,NO_PROXY(and lowercase variants) - Programmatic Configuration: Using
reqwest::Proxyfor advanced proxy setups
Notes
- All GooseUsers in your load test will route through the configured proxy, including users given their own client with
GooseUser::set_client_builder, unless that client calls.no_proxy()or sets its ownreqwest::Proxy - The proxy configuration applies to the entire load test
- Both HTTP and HTTPS requests will use the proxy when configured
- Environment variables (
HTTP_PROXY,HTTPS_PROXY,NO_PROXY) are standard system proxy variables automatically recognized by reqwest, the HTTP client library that Goose uses internally - Goose does not define or manage these environment variables - they are set by you in your shell environment and consumed by reqwest
- Proxies from these environment variables need no reqwest feature: reqwest reads them for every client unless the client calls
.no_proxy(). Reqwest’s separatesystem-proxyfeature, which reads the macOS and Windows system proxy settings, is one of reqwest’s default features and Goose turns it off, so those system settings are not used unless your own reqwest dependency enablessystem-proxy - SOCKS proxy support requires the
socksfeature to be explicitly enabled in your project’s reqwest dependency
Cookies
Goose’s cookies feature is one of its default features. With it, each GooseUser gets its own Reqwest client with its own cookie store, so a cookie the server sets for one user, such as a session cookie after a login, is sent back with that user’s later requests and never with another user’s.
Default Behavior
No configuration is needed:
use goose::prelude::*;
#[tokio::main]
async fn main() -> Result<(), GooseError> {
// No configuration needed: each user has its own cookie store.
GooseAttack::initialize()?
.register_scenario(
scenario!("WebsiteUser")
.register_transaction(transaction!(login).set_on_start())
.register_transaction(transaction!(load_homepage)),
)
.execute()
.await?;
Ok(())
}
async fn login(user: &mut GooseUser) -> TransactionResult {
let params = [("username", "test_user"), ("password", "")];
// A session cookie the server sets here is sent with this user's later requests.
let _goose = user.post_form("/login", ¶ms).await?;
Ok(())
}
async fn load_homepage(user: &mut GooseUser) -> TransactionResult {
let _goose = user.get("/").await?;
Ok(())
}
Goose builds each user’s client with the user agent goose/0.19.0, the --timeout, gzip unless --no-gzip, invalid certificates accepted only with --accept-invalid-certs, and a cookie store when the cookies feature is enabled. Every user has a client of its own whether or not it keeps cookies. In a run of examples/simple, 1,000 users used about 65 MB more memory than 10 users.
A Client Without Cookies
To change the client of a user, build a reqwest::ClientBuilder and pass it to GooseUser::set_client_builder, usually in a transaction run once with set_on_start(). Goose does not re-export Reqwest, so the load test needs its own reqwest = { version = "0.13", default-features = false } dependency. A client built without .cookie_store(true) keeps no cookies:
use goose::prelude::*;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), GooseError> {
GooseAttack::initialize()?
.register_scenario(
scenario!("ApiUser")
.register_transaction(transaction!(setup_client).set_on_start())
.register_transaction(transaction!(api_call)),
)
.execute()
.await?;
Ok(())
}
async fn setup_client(user: &mut GooseUser) -> TransactionResult {
// Without .cookie_store(true) this user's client keeps no cookies.
let builder = reqwest::Client::builder()
.user_agent("my-loadtest/1.0")
.timeout(Duration::from_secs(10));
user.set_client_builder(builder).await?;
Ok(())
}
async fn api_call(user: &mut GooseUser) -> TransactionResult {
let _goose = user.get("/api/status").await?;
Ok(())
}
The new client replaces all of Goose’s defaults for that user, so set again any of them you need, such as the timeout. See the Basic Authentication example for a custom client that keeps cookies and adds default headers.
Building Without Cookie Support
To leave cookie support out of the build, turn off Goose’s default features:
[dependencies]
goose = { version = "^0.19", default-features = false }
Reqwest is then built without its cookies feature, unless another dependency turns it on, and no user’s client keeps cookies. ClientBuilder::cookie_store and cookie_provider only exist with Reqwest’s cookies feature, so a load test that calls them needs it, for example through its own reqwest = { version = "0.13", default-features = false, features = ["cookies"] } dependency. Leave Reqwest’s default features off: they include http2, which would let HTTPS requests use HTTP/2 where Goose keeps to HTTP/1.1.
The Type-Safe Client Builder
The goose::client module, which is not part of the prelude, has a GooseClientBuilder that records its cookie choice in its type: it starts in the CookiesEnabled state, without_cookies() moves it to CookiesDisabled and with_cookies() moves it back. timeout, user_agent, gzip and accept_invalid_certs are available in both states. GooseAttack::set_client_builder_with_cookies takes a CookiesEnabled builder and set_client_builder_without_cookies a CookiesDisabled one, and both return a Result:
use goose::client::GooseClientBuilder;
use goose::prelude::*;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), GooseError> {
// Starts in the CookiesEnabled state: each user gets its own client and cookie jar.
let builder = GooseClientBuilder::new()
.timeout(Duration::from_secs(30))
.user_agent("my-loadtest/1.0");
GooseAttack::initialize()?
.set_client_builder_with_cookies(builder)?
.register_scenario(
scenario!("WebsiteUser").register_transaction(transaction!(load_homepage)),
)
.execute()
.await?;
Ok(())
}
async fn load_homepage(user: &mut GooseUser) -> TransactionResult {
let _goose = user.get("/").await?;
Ok(())
}
set_client_builder_with_cookies gives every user a client of its own, built from the builder, with its own cookie jar. Without the cookies feature it returns GooseError::FeatureNotEnabled, as the clients could not keep cookies.
set_client_builder_without_cookies builds one client without a cookie store before any user starts, and every user shares it. Clones of a Reqwest client share one connection pool, so this suits a load test with many users that needs no cookies. If the client can’t be built, the load test fails before any user starts.
use goose::client::GooseClientBuilder;
use goose::prelude::*;
use std::time::Duration;
fn main() -> Result<(), GooseError> {
// without_cookies() moves the builder to the CookiesDisabled state.
let builder = GooseClientBuilder::new()
.without_cookies()
.timeout(Duration::from_secs(15))
.user_agent("my-loadtest/1.0");
let _attack = GooseAttack::initialize()?.set_client_builder_without_cookies(builder)?;
Ok(())
}
With either setter:
- The builder’s user agent replaces
goose/0.19.0. - The timeout, gzip and invalid certificate settings each come from the first of: the option given on the command line (
--timeout,--no-gzip,--accept-invalid-certs), the builder, aGooseDefaultfor the option, and Goose’s built in default. A setting the builder was not given keeps itsGooseDefaultor built in default. - The users that run the
test_startandtest_stoptransactions, and users a Controller adds, get the builder’s clients too. - The two setters replace each other, so the last call wins.
- A transaction can still give its user a different client with
GooseUser::set_client_builder, as shown above. Underset_client_builder_without_cookiesthat moves the user off the shared client.
Creating a GooseUser Directly
Load tests don’t normally create a GooseUser, but code that does, such as a test of a transaction function, passes the scenario’s name to GooseUser::new as an Arc<str>; it was a String in 0.18. The last argument is an optional reqwest::Client; with None the user gets the default client.
use goose::prelude::*;
use goose::config::GooseConfiguration;
use std::sync::Arc;
fn main() -> Result<(), GooseError> {
let configuration = GooseConfiguration::default();
let base_url = reqwest::Url::parse("http://127.0.0.1:8080/").unwrap();
let _user = GooseUser::new(0, Arc::from("websiteuser"), base_url, &configuration, 0, None)?;
Ok(())
}
Examples
Goose ships eight example load tests. Each entry below says what the example demonstrates and what it needs, and, except for Drupal Memcache, shows a piece of a real run; select the picture to open the example’s page. Simple is the smallest place to start, and Umami is the closest to a real site.
Simple
GET and POST requests, a login that runs once per user, and a wait after each transaction except the login. Needs: any web server that answers POST /login, / and /about/ with 200. Source: examples/simple.rs
Closure
Transactions built in a loop, one closure per path. Needs: any web server that answers /, /about and /our-team with 200. Source: examples/closure.rs
Session
A token from a signup request, kept per user with set_session_data and sent as a bearer token. Needs: a server whose POST /signup answers JSON with a jwtToken, and whose / answers 200 to a request that carries it. Source: examples/session.rs
Basic Authentication
Three ways to send HTTP Basic auth, one scenario each, and which of them covers static assets. Needs: the httpbin.org paths, or a server that answers them. Source: examples/basic_auth.rs
GraphQL
GraphQL queries and a mutation sent with post_graphql and post_graphql_named, and how each names its request. Needs: a server at POST /graphql that answers the example’s four operations with 200; a real GraphQL server needs the user, posts and createPost fields the queries ask for. Source: examples/graphql_loadtest.rs
TCP Load Test
A raw TCP round trip timed by the transaction and recorded in Goose’s metrics with record_custom_request_duration. Needs: a TCP echo server. Source: examples/tcp_loadtest.rs
Drupal Memcache
A Locust load test ported to Goose: anonymous and logged in users, form tokens read from pages, and comments posted. Needs: a Drupal 7 site prepared as the drupal-loadtest repository describes, since the example posts Drupal 7 forms. Its page has no picture of a run: against the Drupal 11 Umami site that the Umami example uses, nearly all of the example’s node and profile requests answered 404. Source: examples/drupal_memcache.rs
Umami
Three kinds of visitor (English, Spanish and admin), most of the site’s page types, forms, and each page’s images and scripts. Needs: a Drupal site installed with the Umami demo profile, with CSS and JS aggregation off and an admin account the example can log in with. Source: examples/umami/
Simple Example
The examples/simple.rs example copies the simple load test documented on the locust.io web page, rewritten in Rust for Goose. It shows how to GET and POST pages, with one scenario.
cargo run --release --features dashboard --example simple -- --host http://127.0.0.1:8080 -u 1024 -r 64 -t 3m --no-reset-metrics --dashboard -q, against a local nginx server that answers POST /login, / and /about/ with 200, 120 seconds in.
Each user logs in once with POST /login, then loads / and /about/ in turn, pausing 5 to 15 seconds after each of those two but not after the login. During the ramp each new user sends its login and its first page before its first pause, which is why the chart starts near 128 a second (64 new users a second, each sending two requests). From about 5 seconds in, the first users come back from their pauses and add to that, so it climbs to about 200 until all 1,024 users have started, then drops when the new users stop. After that the pause holds 1,024 users to about 100 requests a second.
A separate run measured Goose itself: -u 1024 -r 64 -t 120s --no-telnet -q, without the dashboard or the telnet Controller, against the same server on an Apple M5 Max. While all 1,024 users ran, Goose held at 77 MB of memory and used under 3% of one core. It made 12,245 requests in the 120 seconds after the last user started, and none of them failed.
With -q Goose does not print the dashboard’s address when it starts: open http://127.0.0.1:5118 while the test runs. If that tab is still open when the run ends, Goose prints the address and waits for Ctrl-C.
Complete Source Code
//! Simple Goose load test example. Duplicates the simple example on the
//! Locust project page (<https://locust.io/>).
//!
//! ## License
//!
//! Copyright 2020-2022 Jeremy Andrews
//!
//! Licensed under the Apache License, Version 2.0 (the "License");
//! you may not use this file except in compliance with the License.
//! You may obtain a copy of the License at
//!
//! <http://www.apache.org/licenses/LICENSE-2.0>
//!
//! Unless required by applicable law or agreed to in writing, software
//! distributed under the License is distributed on an "AS IS" BASIS,
//! WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//! See the License for the specific language governing permissions and
//! limitations under the License.
use goose::prelude::*;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), GooseError> {
GooseAttack::initialize()?
// In this example, we only create a single scenario, named "WebsiteUser".
.register_scenario(
scenario!("WebsiteUser")
// After each transactions runs, sleep randomly from 5 to 15 seconds.
.set_wait_time(Duration::from_secs(5), Duration::from_secs(15))?
// This transaction only runs one time when the user first starts.
.register_transaction(transaction!(website_login).set_on_start())
// These next two transactions run repeatedly as long as the load test is running.
.register_transaction(transaction!(website_index))
.register_transaction(transaction!(website_about)),
)
.execute()
.await?;
Ok(())
}
/// Demonstrates how to log in when a user starts. We flag this transaction as an
/// on_start transaction when registering it above. This means it only runs one time
/// per user, when the user thread first starts.
async fn website_login(user: &mut GooseUser) -> TransactionResult {
let params = [("username", "test_user"), ("password", "")];
let _goose = user.post_form("/login", ¶ms).await?;
Ok(())
}
/// A very simple transaction that simply loads the front page.
async fn website_index(user: &mut GooseUser) -> TransactionResult {
let _goose = user.get("/").await?;
Ok(())
}
/// A very simple transaction that simply loads the about page.
async fn website_about(user: &mut GooseUser) -> TransactionResult {
let _goose = user.get("/about/").await?;
Ok(())
}
Closure Example
The examples/closure.rs example loads three different pages on a web site. Instead of defining a hard coded Transaction function for each, the paths are passed in via a vector and the TransactionFunction is dynamically created in a closure.
cargo run --release --features dashboard --example closure -- --host http://127.0.0.1:8080 -u 200 -r 50 -t 3m --no-reset-metrics --dashboard -q, against a local nginx server that answers /, /about and /our-team with 200, 100 seconds in: one request row for each path in the vector.
Each user pauses 5 to 15 seconds after each transaction, so 200 users make about 20 requests a second; each user loads the three paths in turn, so the counts fall in the vector’s order. The closures carry no names, so the dashboard’s Transactions table lists them by number alone, 1.1 to 1.3; the request names are the paths. With -q Goose does not print the dashboard’s address when it starts: open http://127.0.0.1:5118 while the test runs. If that tab is still open when the run ends, Goose prints the address and waits for Ctrl-C.
Details
The paths to be loaded are first defined in a vector:
#![allow(unused)]
fn main() {
let paths = vec!["/", "/about", "/our-team"];
}
A transaction function for each path is then dynamically created as a closure:
for request_path in paths {
let path = request_path;
let closure: TransactionFunction = Arc::new(move |user| {
Box::pin(async move {
let _goose = user.get(path).await?;
Ok(())
})
});
Complete Source Code
//! Simple Goose load test example using closures.
//!
//! ## License
//!
//! Copyright 2020 Fabian Franz
//!
//! Licensed under the Apache License, Version 2.0 (the "License");
//! you may not use this file except in compliance with the License.
//! You may obtain a copy of the License at
//!
//! <http://www.apache.org/licenses/LICENSE-2.0>
//!
//! Unless required by applicable law or agreed to in writing, software
//! distributed under the License is distributed on an "AS IS" BASIS,
//! WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//! See the License for the specific language governing permissions and
//! limitations under the License.
use goose::prelude::*;
use std::boxed::Box;
use std::sync::Arc;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), GooseError> {
let mut scenario = scenario!("WebsiteUser")
// After each transaction runs, sleep randomly from 5 to 15 seconds.
.set_wait_time(Duration::from_secs(5), Duration::from_secs(15))?;
let paths = vec!["/", "/about", "/our-team"];
for request_path in paths {
let path = request_path;
let closure: TransactionFunction = Arc::new(move |user| {
Box::pin(async move {
let _goose = user.get(path).await?;
Ok(())
})
});
let transaction = Transaction::new(closure);
// We need to do the variable dance as scenario.register_transaction returns self and hence moves
// self out of `scenario`. By storing it in a new local variable and then moving it over
// we can avoid that error.
let new_scenario = scenario.register_transaction(transaction);
scenario = new_scenario;
}
GooseAttack::initialize()?
// In this example, we only create a single scenario, named "WebsiteUser".
.register_scenario(scenario)
.execute()
.await?;
Ok(())
}
Session Example
The examples/session.rs example demonstrates how you can add JWT authentication support to your load test, making use of the GooseUserData trait. In this example, the session is recorded in the GooseUser object with set_session_data, and retrieved with get_session_data_unchecked.
The HTML report’s Request Metrics, from cargo run --release --features dashboard --example session -- --host http://127.0.0.1:8081 -u 100 -r 20 -t 2m --no-reset-metrics --report-file --dashboard -q against a local nginx server whose POST /signup answers {"jwtToken":"goose-example-token"}; the run writes report.html and report.json to goose-runs/<YYYYMMDD-HHMMSS>/.
POST /signup ran 100 times, once for each of the 100 users, because it is the on start transaction. The server answers / with 401 to any request without the token, and Goose counts a 401 as a failed request, so 0 fails on GET / means every one of its 1,285 requests carried the token the user stored. With -q Goose does not print the dashboard’s address when it starts: open http://127.0.0.1:5118 while the test runs. If that tab is still open when the run ends, Goose prints the address and waits for Ctrl-C.
Details
In this example, the GooseUserData is a simple struct containing a string. Goose implements GooseUserData for every type that is Clone, Send, Sync and 'static, so the struct only needs to derive Clone:
#![allow(unused)]
fn main() {
#[derive(Clone)]
struct Session {
jwt_token: String,
}
}
Passing set_session_data a type that is not Clone does not compile.
Each user builds the struct from the JSON its signup request returns, and stores it in its own GooseUser:
user.set_session_data(Session {
jwt_token: response.jwt_token,
});
The session data is retrieved from the GooseUser object with each subsequent request. The example does no validation:
async fn authenticated_index(user: &mut GooseUser) -> TransactionResult {
// This will panic if the session is missing or if the session is not of the right type.
// Use `get_session_data` to handle a missing session.
let session = user.get_session_data_unchecked::<Session>();
// Create a Reqwest RequestBuilder object and configure bearer authentication when making
// a GET request for the index.
let reqwest_request_builder = user
.get_request_builder(&GooseMethod::Get, "/")?
.bearer_auth(&session.jwt_token);
// Add the manually created RequestBuilder and build a GooseRequest object. The
// path names the request in the metrics, as the RequestBuilder already has its URL.
let goose_request = GooseRequest::builder()
.path("/")
.set_request_builder(reqwest_request_builder)
.build();
// Make the actual request.
user.request(goose_request).await?;
Ok(())
}
If POST /signup does not answer with JSON holding a jwtToken, the user stores no session data, and get_session_data_unchecked panics with Missing session data!.
Complete Source Code
//! Goose load test example, leveraging the per-GooseUser `GooseUserData` field
// to store a per-user session JWT authentication token.
//!
//! ## License
//!
//! Copyright 2020-2022 Jeremy Andrews
//!
//! Licensed under the Apache License, Version 2.0 (the "License");
//! you may not use this file except in compliance with the License.
//! You may obtain a copy of the License at
//!
//! <http://www.apache.org/licenses/LICENSE-2.0>
//!
//! Unless required by applicable law or agreed to in writing, software
//! distributed under the License is distributed on an "AS IS" BASIS,
//! WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//! See the License for the specific language governing permissions and
//! limitations under the License.
use goose::prelude::*;
use serde::Deserialize;
use std::time::Duration;
#[derive(Clone)]
struct Session {
jwt_token: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthenticationResponse {
jwt_token: String,
}
#[tokio::main]
async fn main() -> Result<(), GooseError> {
GooseAttack::initialize()?
// In this example, we only create a single scenario, named "WebsiteUser".
.register_scenario(
scenario!("WebsiteUser")
// After each transaction runs, sleep randomly from 5 to 15 seconds.
.set_wait_time(Duration::from_secs(5), Duration::from_secs(15))?
// This transaction only runs one time when the user first starts.
.register_transaction(transaction!(website_signup).set_on_start())
// These next two transactions run repeatedly as long as the load test is running.
.register_transaction(transaction!(authenticated_index)),
)
.execute()
.await?;
Ok(())
}
/// Demonstrates how to log in and set a session when a user starts. We flag this transaction as an
/// on_start transaction when registering it above. This means it only runs one time
/// per user, when the user thread first starts.
async fn website_signup(user: &mut GooseUser) -> TransactionResult {
let params = [("username", "test_user"), ("password", "")];
let response = match user.post_form("/signup", ¶ms).await?.response {
Ok(r) => match r.json::<AuthenticationResponse>().await {
Ok(j) => j,
Err(e) => return Err(Box::new(e.into())),
},
Err(e) => return Err(Box::new(e.into())),
};
user.set_session_data(Session {
jwt_token: response.jwt_token,
});
Ok(())
}
/// A very simple transaction that simply loads the front page.
async fn authenticated_index(user: &mut GooseUser) -> TransactionResult {
// This will panic if the session is missing or if the session is not of the right type.
// Use `get_session_data` to handle a missing session.
let session = user.get_session_data_unchecked::<Session>();
// Create a Reqwest RequestBuilder object and configure bearer authentication when making
// a GET request for the index.
let reqwest_request_builder = user
.get_request_builder(&GooseMethod::Get, "/")?
.bearer_auth(&session.jwt_token);
// Add the manually created RequestBuilder and build a GooseRequest object. The
// path names the request in the metrics, as the RequestBuilder already has its URL.
let goose_request = GooseRequest::builder()
.path("/")
.set_request_builder(reqwest_request_builder)
.build();
// Make the actual request.
user.request(goose_request).await?;
Ok(())
}
Basic Authentication Example
The basic_auth.rs example demonstrates different approaches to handling HTTP Basic Authentication in Goose load tests. It answers issue #608, how to test a site behind Basic Auth, including when static assets (CSS, JS, images) need the credentials too.
The HTML report’s Request Metrics, from cargo run --release --features dashboard --example basic_auth -- --host http://127.0.0.1:8080 -u 60 -r 20 -t 2m --no-reset-metrics --report-file --dashboard -q against a local nginx server that answers the example’s four httpbin.org paths; the run writes report.html and report.json to goose-runs/<YYYYMMDD-HHMMSS>/.
The server answers /basic-auth/user/passwd with 401 unless the request has Basic auth for the user user with the password passwd, and Goose counts a 401 as a failed request. No request failed, including all 1,854 to the protected path, so all three approaches below sent the credentials. With -q Goose does not print the dashboard’s address when it starts: open http://127.0.0.1:5118 while the test runs. If that tab is still open when the run ends, Goose prints the address and waits for Ctrl-C.
The same run’s Transaction Metrics: one group for each approach.
The example names no transaction, so the report lists each by number alone, scenario then transaction, from 0; the dashboard and the console count from 1. Row 0.0 is the on start setup_basic_auth_client, which sends no request of its own. It ran 30 times, once for each CustomClientAuth user: the weights 3, 2 and 1 give 30, 20 and 10 of the 60. The other rows follow the order of the table under Solutions Demonstrated.
Problem Statement
When load testing a site protected by HTTP Basic Authentication:
- Static assets: credentials added to one request do not reach the CSS, JavaScript and image requests its page leads to, so those fail when they need Basic Auth too
- Code duplication: adding the credentials to every request by hand repeats the same code
Solutions Demonstrated
The example shows three different approaches to handle Basic Auth, one per scenario:
| Scenario | Weight | Transactions | Requests |
|---|---|---|---|
CustomClientAuth | 3 | setup_basic_auth_client (on start), test_basic_auth_endpoint, test_user_agent_endpoint, test_json_endpoint | GET /basic-auth/user/passwd, GET /user-agent, GET /json |
HelperFunctionAuth | 2 | test_with_helper_function, test_post_with_helper | GET /basic-auth/user/passwd, POST /post |
ManualPerRequestAuth | 1 | test_manual_basic_auth, test_manual_with_validation | GET /basic-auth/user/passwd, twice |
Every scenario waits 1 to 3 seconds after each transaction except the on start one. The paths are those of httpbin.org, where /basic-auth/user/passwd answers 200 only to the user user with the password passwd.
The excerpts below are taken from the example, where they sit inside a module compiled only with the cookies feature. That feature is on by default; built without it, the example prints an error and exits.
1. Custom Client with Default Headers (Recommended)
This approach sets up the HTTP client once per user with default Basic Auth headers. All subsequent requests automatically include the authentication headers, including requests for static assets.
async fn setup_basic_auth_client(user: &mut GooseUser) -> TransactionResult {
// Get credentials from environment variables
let (username, password) = get_basic_auth_credentials()?;
// Use reqwest's built-in basic_auth() to generate a properly encoded
// Authorization header, then extract it for use as a default header.
let temp_request = reqwest::Client::new()
.get("http://localhost")
.basic_auth(&username, Some(&password))
.build()
.map_err(TransactionError::Reqwest)?;
let mut headers = header::HeaderMap::new();
if let Some(auth_header) = temp_request.headers().get(header::AUTHORIZATION) {
headers.insert(header::AUTHORIZATION, auth_header.clone());
}
// Set up the client builder with default headers and other common settings
// Enable cookie store for maintaining session state with Basic Auth
let builder = Client::builder()
.user_agent("Goose/1.0 BasicAuth Example")
.default_headers(headers)
.timeout(Duration::from_secs(30))
.cookie_store(true);
// Apply the custom client to this user
user.set_client_builder(builder).await?;
Ok(())
}
Advantages:
- Set up once per user
- Applies to every request the user makes, static assets included
- Works with helpers that load a page’s static assets, such as
validate_and_load_static_assetsin the Umami example
2. Helper Function Approach
This approach uses a helper function that adds Basic Auth to each request.
async fn send_authenticated_request(
user: &mut GooseUser,
method: &GooseMethod,
path: &str,
) -> Result<GooseResponse, Box<TransactionError>> {
let mut reqwest_request_builder = user.get_request_builder(method, path)?;
// Check for Basic Auth credentials in environment
if let Ok((username, password)) = get_basic_auth_credentials() {
reqwest_request_builder = reqwest_request_builder.basic_auth(username, Some(password));
}
// Build the GooseRequest and make the request. The path names the request in
// the metrics, as the RequestBuilder already has its URL.
let goose_request = GooseRequest::builder()
.path(path)
.set_request_builder(reqwest_request_builder)
.build();
let goose_response = user.request(goose_request).await?;
Ok(goose_response)
}
Advantages:
- Control over each request
- Can apply authentication to some requests and not others
Disadvantages:
- Must be called for every request
- Does not reach static asset requests
The request is built from a Reqwest RequestBuilder, which already holds the URL, so the helper also calls .path(path) on the GooseRequest builder: the path names the request in the metrics, as GET /basic-auth/user/passwd and POST /post. Without .path() or .name("..."), Goose would name it by its method alone, as GET or POST.
3. Manual Per-Request Authentication
This approach manually adds Basic Auth to each individual request using the request builder.
async fn test_manual_basic_auth(user: &mut GooseUser) -> TransactionResult {
let (username, password) = get_basic_auth_credentials()?;
let reqwest_request_builder = user
.get_request_builder(&GooseMethod::Get, "/basic-auth/user/passwd")?
.basic_auth(username, Some(password));
let goose_request = GooseRequest::builder()
.path("/basic-auth/user/passwd")
.set_request_builder(reqwest_request_builder)
.build();
let _goose_metrics = user.request(goose_request).await?;
Ok(())
}
Disadvantages:
- Repeats the most code of the three approaches
- Does not reach static asset requests, the problem issue #608 describes
- Must name each request with
.path()or.name(), as in the helper function approach, or it is named by its method alone in the metrics
Configuration
Environment Variables
Separate variables:
export BASIC_AUTH_USERNAME="your_username"
export BASIC_AUTH_PASSWORD="your_password"
Combined format:
export BASIC_AUTH="username:password"
BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD are used when both are set; otherwise BASIC_AUTH is used, split at its first colon, so the password may contain colons. A BASIC_AUTH without a colon is an error. The manual approach’s transactions then fail immediately, because each returns the error, while the helper function sends its requests without credentials. The custom client’s on start transaction fails too, but Goose goes on with its standard client for that user. Those requests also go out without credentials, and the protected path answers them with 401.
Default Credentials
If neither the pair nor BASIC_AUTH is set, the example uses the credentials user:passwd, which httpbin.org accepts. Each time it reads them it logs three warning lines, even with -q, and it reads them once per helper function request, once per manual transaction and once per custom client user. Setting the variables quiets those; test_manual_with_validation still warns after every 200 that static assets would fail.
Running the Example
# Using httpbin.org for testing (uses default credentials)
cargo run --example basic_auth -- --host http://httpbin.org --users 5 --run-time 30s
# With custom credentials
BASIC_AUTH_USERNAME="myuser" BASIC_AUTH_PASSWORD="mypass" \
cargo run --example basic_auth -- --host https://your-protected-site.com --users 10 --run-time 60s
# Using combined format
BASIC_AUTH="myuser:mypass" \
cargo run --example basic_auth -- --host https://your-protected-site.com --users 10 --run-time 60s
Against another site the example still requests the httpbin.org paths in the table above, so change them to paths on your site first.
Key Takeaways
- Use the custom client approach for most sites: it sends credentials with every request the user makes, CSS, JS and image requests included
- Set up authentication once per user, in an on start transaction, rather than in every request
- Use environment variables for credentials to keep them out of your code
- Try your approach against httpbin.org before pointing it at your own site
Related Issues
This example addresses GitHub issue #608: “How can I test a site behind Basic Auth”.
GraphQL
Goose 0.19 adds two helper methods that send GraphQL queries and mutations.
cargo run --release --features dashboard --example graphql_loadtest -- --host http://127.0.0.1:4400 -u 5 -r 5 -t 3m --no-reset-metrics --dashboard -q, against a local Node server that answers each of the example’s four operations at POST /graphql with a 200 and a JSON data body without delay, 100 seconds in.
The transactions are named with set_name(), and three of their requests with post_graphql_named(). Simple Query’s request is sent with post_graphql(), which names it by its path, POST /graphql. No transaction waits, and this server answers without delay, so 5 users send about 72,000 requests a second; the Sample Output below comes from a slower server. With -q Goose does not print the dashboard’s address when it starts: open http://127.0.0.1:5118 while the test runs. If that tab is still open when the run ends, Goose prints the address and waits for Ctrl-C.
GraphQL Helper Methods
post_graphql(): POST a GraphQL query as JSON to a path; the metrics name the request by its path, for examplePOST /graphqlpost_graphql_named(): the same, with a name for the request in the metrics, for examplePOST get user by id
Both take the request body as anything that implements serde::Serialize, usually a serde_json::Value built with json!(), and return a GooseResponse, like the other request helpers.
GraphQL Endpoint Configuration
There is no configured GraphQL endpoint: the path is the first argument of each call, and Goose prepends the host as it does for every request. By convention, most GraphQL APIs use /graphql, but you can specify any path. In this fragment query is a serde_json::Value:
// Using the conventional /graphql endpoint
let _goose = user.post_graphql("/graphql", &query).await?;
// Using a custom endpoint
let _goose = user.post_graphql("/api/graphql", &query).await?;
Example: GraphQL Load Test
The examples/graphql_loadtest.rs example defines one scenario, GraphQL Load Test, with four transactions. No transaction sets a weight, and the scenario sets no wait time, so each user runs the four in turn, without pausing, for as long as the load test runs. Each one POSTs to /graphql:
| Transaction | Sends | Request name in the metrics |
|---|---|---|
Simple Query | the query { __typename }, with post_graphql() | POST /graphql |
User Query | a GetUser query with the variable id | POST get user by id |
Complex Query | a GetUserWithPosts query for a user’s posts and their comments, with the variables userId and first | POST complex user posts query |
Mutation | a CreatePost mutation with an input variable | POST create post |
Key Features
JSON Query Construction
Each query is a serde_json::Value built with json!().
Variables
Variables go in the JSON value’s variables field, beside its query field, as User Query, Complex Query and Mutation show.
Named Requests
post_graphql_named() gives each operation its own row in the metrics. Every request made with post_graphql() to the same path shares one row.
Weighted Transactions
set_weight() makes one operation run more often than another, such as more reads than writes. The example leaves every transaction at the default weight of 1.
Advanced Usage
Error Handling
Goose judges a GraphQL request by its HTTP status code, like any other request, so a GraphQL error returned with a 200 counts as a success. To catch those, read the response body and call set_failure:
async fn validated_graphql_transaction(user: &mut GooseUser) -> TransactionResult {
let query = json!({
"query": "query GetUser($id: ID!) { user(id: $id) { id name } }",
"variables": { "id": "invalid-id" }
});
let mut goose = user
.post_graphql_named("/graphql", &query, "get user with validation")
.await?;
if let Ok(response) = goose.response {
match response.json::<serde_json::Value>().await {
Ok(json) => {
if json.get("errors").is_some() {
return user.set_failure("GraphQL errors returned", &mut goose.request, None, None);
}
}
Err(_) => {
return user.set_failure("Invalid JSON response", &mut goose.request, None, None);
}
}
}
Ok(())
}
Custom Headers
If your GraphQL API requires authentication or custom headers, you can use the lower-level request building approach:
async fn authenticated_graphql_transaction(user: &mut GooseUser) -> TransactionResult {
let query = json!({
"query": "query GetPrivateData { privateData { id value } }"
});
// Build custom request with authentication header
let request_builder = user
.get_request_builder(&GooseMethod::Post, "/graphql")?
.header("Authorization", "Bearer your-token-here")
.json(&query);
let goose_request = GooseRequest::builder()
.set_request_builder(request_builder)
.name("authenticated query")
.build();
let _goose = user.request(goose_request).await?;
Ok(())
}
Both are fragments: they need use goose::prelude::*; and use serde_json::json;, and a scenario that registers them.
Sample Output
Running the example for 30 seconds with 5 users, against a small local server that answers every POST to /graphql, after 5 to 25 ms, with a 200 and a JSON body:
$ cargo run --release --example graphql_loadtest -- --host http://localhost:8080 --users 5 --run-time 30s
19:53:20 [INFO] Output verbosity level: INFO
19:53:20 [INFO] Logfile verbosity level: WARN
19:53:20 [INFO] users = 5
19:53:20 [INFO] run_time = 30
19:53:20 [INFO] global host configured: http://localhost:8080
19:53:20 [INFO] [scheduler]: allocating transactions and scenarios with RoundRobin scheduler
19:53:20 [INFO] [scheduler]: initializing 5 user states...
19:53:20 [INFO] [controller]: Telnet listening on: 127.0.0.1:5116
19:53:20 [INFO] entering GooseAttack phase: Increase
19:53:20 [INFO] [user 1]: launching user from GraphQL Load Test
19:53:21 [INFO] [user 2]: launching user from GraphQL Load Test
19:53:22 [INFO] [user 3]: launching user from GraphQL Load Test
19:53:23 [INFO] [user 4]: launching user from GraphQL Load Test
19:53:24 [INFO] [user 5]: launching user from GraphQL Load Test
19:53:25 [INFO] printing running metrics after 5 seconds...
Goose starts one user a second, and when all 5 have launched it prints the metrics so far and resets them. Those tables are left out here.
All 5 users launched, resetting metrics (disable with --no-reset-metrics).
19:53:25 [INFO] entering GooseAttack phase: Maintain
19:53:55 [INFO] entering GooseAttack phase: Decrease
19:53:55 [INFO] [user 2]: exiting from GraphQL Load Test
19:53:55 [INFO] [user 4]: exiting from GraphQL Load Test
19:53:55 [INFO] [user 1]: exiting from GraphQL Load Test
19:53:55 [INFO] [user 5]: exiting from GraphQL Load Test
19:53:55 [INFO] [user 3]: exiting from GraphQL Load Test
19:53:55 [INFO] entering GooseAttack phase: Shutdown
19:53:55 [INFO] printing final metrics after 30 seconds...
=== PER SCENARIO METRICS ===
------------------------------------------------------------------------------
Name | # users | # times run | scenarios/s | iterations
------------------------------------------------------------------------------
1: GraphQL Load Test | 5 | 2254 | 75.13 | 450.80
------------------------------------------------------------------------------
Name | Avg (ms) | Min | Max | Median
------------------------------------------------------------------------------
1: GraphQL Load Test | 67 | 27 | 103 | 67
=== PER TRANSACTION METRICS ===
------------------------------------------------------------------------------
Name | # times run | # fails | trans/s | fail/s
------------------------------------------------------------------------------
1: GraphQL Load Test
1: Simple Query | 2,256 | 0 (0%) | 75.20 | 0.00
2: User Query | 2,256 | 0 (0%) | 75.20 | 0.00
3: Complex Query | 2,256 | 0 (0%) | 75.20 | 0.00
4: Mutation | 2,256 | 0 (0%) | 75.20 | 0.00
-------------------------+---------------+----------------+----------+--------
Aggregated | 9,024 | 0 (0%) | 300.80 | 0.00
------------------------------------------------------------------------------
Name | Avg (ms) | Min | Max | Median
------------------------------------------------------------------------------
1: GraphQL Load Test
1: Simple Query | 17 | 5.28 | 29 | 17
2: User Query | 17 | 5.25 | 29 | 17
3: Complex Query | 17 | 5.29 | 28 | 17
4: Mutation | 17 | 5.41 | 31 | 17
-------------------------+-------------+------------+-------------+-----------
Aggregated | 17 | 5.25 | 31 | 17
=== PER REQUEST METRICS ===
------------------------------------------------------------------------------
Name | # reqs | # fails | req/s | fail/s
------------------------------------------------------------------------------
POST /graphql | 2,258 | 0 (0%) | 75.27 | 0.00
POST complex user post.. | 2,256 | 0 (0%) | 75.20 | 0.00
POST create post | 2,256 | 0 (0%) | 75.20 | 0.00
POST get user by id | 2,257 | 0 (0%) | 75.23 | 0.00
-------------------------+---------------+----------------+----------+--------
Aggregated | 9,027 | 0 (0%) | 300.90 | 0.00
------------------------------------------------------------------------------
Name | Avg (ms) | Min | Max | Median
------------------------------------------------------------------------------
POST /graphql | 17 | 5.28 | 29 | 17
POST complex user post.. | 17 | 5.27 | 28 | 17
POST create post | 17 | 5.39 | 31 | 17
POST get user by id | 17 | 5.24 | 29 | 17
-------------------------+-------------+------------+-------------+-----------
Aggregated | 17 | 5.24 | 31 | 17
------------------------------------------------------------------------------
Slowest page load within specified percentile of requests (in ms):
------------------------------------------------------------------------------
Name | 50% | 75% | 98% | 99% | 99.9% | 99.99%
------------------------------------------------------------------------------
POST /graphql | 17 | 22 | 27 | 27 | 28 | 29
POST complex user post.. | 17 | 22 | 27 | 27 | 28 | 28
POST create post | 17 | 22 | 27 | 27 | 28 | 31
POST get user by id | 17 | 22 | 27 | 27 | 28 | 29
-------------------------+--------+--------+--------+--------+--------+-------
Aggregated | 17 | 22 | 27 | 27 | 28 | 29
------------------------------------------------------------------------------
Name | Status codes
------------------------------------------------------------------------------
POST /graphql | 2,258 [200]
POST complex user post.. | 2,256 [200]
POST create post | 2,256 [200]
POST get user by id | 2,257 [200]
-------------------------+----------------------------------------------------
Aggregated | 9,027 [200]
=== OVERVIEW ===
------------------------------------------------------------------------------
Action Started Stopped Elapsed Users
------------------------------------------------------------------------------
Increasing: 2026-10-26 20:53:20 - 2026-10-26 20:53:25 (00:00:05, 0 -> 5)
Maintaining: 2026-10-26 20:53:25 - 2026-10-26 20:53:55 (00:00:30, 5)
Decreasing: 2026-10-26 20:53:55 - 2026-10-26 20:53:55 (00:00:00, 0 <- 5)
Target host: http://localhost:8080/
goose v0.19.0
------------------------------------------------------------------------------
Key Observations
Each request made with post_graphql_named() appears under its own name, such as POST get user by id, and the one made with post_graphql() under its path, POST /graphql. With no weights set, each transaction runs about as often as the others, so the four requests have about the same counts. Every request returned 200.
Complete Source Code
//! Example demonstrating GraphQL load testing with Goose.
//!
//! This example shows how to use Goose's GraphQL helper methods to load test
//! a GraphQL API. The GraphQL helpers use the existing HTTP infrastructure
//! and provide convenient methods for making GraphQL queries.
//!
//! ## Usage
//!
//! To compile and run this example:
//!
//! ```bash
//! cargo run --example graphql_loadtest -- --host https://api.example.com
//! ```
//!
//! ## Configuration Options
//!
//! - `--host`: Target host to load test
//! - `--users`: Number of concurrent users (default: one per CPU)
//! - `--run-time`: Duration to run the test (e.g., "30s", "5m")
//!
//! ## GraphQL Helper Methods
//!
//! Goose provides two GraphQL helper methods:
//! - `post_graphql()`: Basic GraphQL queries
//! - `post_graphql_named()`: Named GraphQL queries for better metrics tracking
//!
//! ## Example Commands
//!
//! Basic GraphQL load test:
//! ```bash
//! cargo run --example graphql_loadtest -- --host https://api.github.com --users 5 --run-time 30s
//! ```
//!
//! Using a custom GraphQL endpoint path:
//! ```bash
//! cargo run --example graphql_loadtest -- --host https://api.example.com --users 10 --run-time 1m
//! ```
use goose::prelude::*;
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), GooseError> {
GooseAttack::initialize()?
.register_scenario(
scenario!("GraphQL Load Test")
.set_weight(1)?
.register_transaction(transaction!(simple_query).set_name("Simple Query"))
.register_transaction(transaction!(user_query).set_name("User Query"))
.register_transaction(transaction!(complex_query).set_name("Complex Query"))
.register_transaction(transaction!(mutation_example).set_name("Mutation")),
)
.execute()
.await?;
Ok(())
}
/// Simple GraphQL query example
async fn simple_query(user: &mut GooseUser) -> TransactionResult {
let query = json!({
"query": "{ __typename }"
});
let _goose = user.post_graphql("/graphql", &query).await?;
Ok(())
}
/// User query with variables
async fn user_query(user: &mut GooseUser) -> TransactionResult {
let query = json!({
"query": "query GetUser($id: ID!) { user(id: $id) { id name email } }",
"variables": {
"id": "123"
}
});
// Use named query for better metrics tracking
let _goose = user
.post_graphql_named("/graphql", &query, "get user by id")
.await?;
Ok(())
}
/// Complex query with multiple fields and nested data
async fn complex_query(user: &mut GooseUser) -> TransactionResult {
let query = json!({
"query": r#"
query GetUserWithPosts($userId: ID!, $first: Int!) {
user(id: $userId) {
id
name
email
posts(first: $first) {
edges {
node {
id
title
content
createdAt
comments(first: 5) {
edges {
node {
id
content
author {
name
}
}
}
}
}
}
}
}
}
"#,
"variables": {
"userId": "456",
"first": 10
}
});
let _goose = user
.post_graphql_named("/graphql", &query, "complex user posts query")
.await?;
Ok(())
}
/// GraphQL mutation example
async fn mutation_example(user: &mut GooseUser) -> TransactionResult {
let mutation = json!({
"query": r#"
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
content
author {
id
name
}
}
}
"#,
"variables": {
"input": {
"title": "Load Test Post",
"content": "This post was created during a load test",
"authorId": "789"
}
}
});
let _goose = user
.post_graphql_named("/graphql", &mutation, "create post")
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_graphql_transactions() {
// Test that transactions can be created without errors
let simple_transaction = transaction!(simple_query).set_name("Simple Query");
let user_transaction = transaction!(user_query).set_name("User Query");
let complex_transaction = transaction!(complex_query).set_name("Complex Query");
let mutation_transaction = transaction!(mutation_example).set_name("Mutation");
assert_eq!(simple_transaction.name, "Simple Query");
assert_eq!(user_transaction.name, "User Query");
assert_eq!(complex_transaction.name, "Complex Query");
assert_eq!(mutation_transaction.name, "Mutation");
}
#[test]
fn test_scenario_creation() {
// Test that the scenario can be created properly
let scenario = scenario!("GraphQL Load Test")
.set_weight(1)
.unwrap()
.register_transaction(transaction!(simple_query).set_name("Simple Query"))
.register_transaction(transaction!(user_query).set_name("User Query"))
.register_transaction(transaction!(complex_query).set_name("Complex Query"))
.register_transaction(transaction!(mutation_example).set_name("Mutation"));
assert_eq!(scenario.name, "GraphQL Load Test");
assert_eq!(scenario.weight, 1);
assert_eq!(scenario.transactions.len(), 4);
}
#[test]
fn test_json_query_creation() {
// Test that JSON queries can be created properly
let query = json!({
"query": "{ __typename }"
});
assert!(query.is_object());
assert!(query.get("query").is_some());
assert_eq!(query["query"], "{ __typename }");
}
#[test]
fn test_complex_json_query() {
// Test complex query with variables
let query = json!({
"query": "query GetUser($id: ID!) { user(id: $id) { id name } }",
"variables": {
"id": "123"
}
});
assert!(query.is_object());
assert!(query.get("query").is_some());
assert!(query.get("variables").is_some());
assert_eq!(query["variables"]["id"], "123");
}
}
TCP Load Test
The examples/tcp_loadtest.rs example demonstrates how to use Goose to load test a raw TCP server. While Goose is primarily an HTTP load testing framework, its metrics pipeline supports any protocol through GooseUser::record_custom_request and GooseUser::record_custom_request_duration, both new in 0.19. The example uses record_custom_request_duration.
cargo run --release --features dashboard --example tcp_loadtest -- --host tcp://127.0.0.1:9000 -u 10 -r 10 -t 3m --no-reset-metrics --dashboard -q, against a small local Node echo server that writes each connection’s bytes back to it, about 100 seconds in.
The latency image shows the left part of the dashboard’s Response times by second card, the run’s first 77 seconds; the summary under its chart is the mean of the last 10 drawn seconds, which fall to the right of that part. Each user pauses 100 to 500 ms after every round trip, so 10 users make about 33 round trips a second. The example records each round trip as a Duration, so Goose keeps the fraction of a millisecond: most fall between 0.2 and 0.5 ms, and they average 0.31 ms. as_millis() truncates, so with record_custom_request every round trip under 1 ms would have been recorded as 0 ms. With -q Goose does not print the dashboard’s address when it starts: open http://127.0.0.1:5118 while the test runs. If that tab is still open when the run ends, Goose prints the address and waits for Ctrl-C.
Load testing other protocols
Some services communicate over raw TCP, UDP, WebSocket, gRPC, or other protocols rather than HTTP. Before 0.19, the only way to record metrics for these operations was to construct fake HTTP GooseRequest objects, which produced misleading results (spurious 404 errors). With record_custom_request, you time the operation yourself and hand the result to Goose.
The API
user.record_custom_request(
method, // &str: protocol label shown in metrics (e.g. "TCP", "GRPC", "WS")
name, // &str: identifies this operation in metrics output
response_time, // u64: how long the operation took, in milliseconds
success, // bool: whether the operation succeeded
status_code, // Option<u16>: protocol-specific status code, or None if not applicable
error, // Option<&str>: failure reason; ignored when success is true
).await?;
This is a fragment: it runs inside a transaction function, with the six values already set.
Recorded metrics appear in all Goose reports under the method label you provide. These are the final request tables of the command under Running the example, a different run from the dashboard run above, against a different echo server, so their times differ:
=== PER REQUEST METRICS ===
------------------------------------------------------------------------------
Name | # reqs | # fails | req/s | fail/s
------------------------------------------------------------------------------
TCP tcp_echo | 1,194 | 0 (0%) | 29.85 | 0.00
------------------------------------------------------------------------------
Name | Avg (ms) | Min | Max | Median
------------------------------------------------------------------------------
TCP tcp_echo | 0.95 | 0.29 | 15 | 0.91
------------------------------------------------------------------------------
Slowest page load within specified percentile of requests (in ms):
------------------------------------------------------------------------------
Name | 50% | 75% | 98% | 99% | 99.9% | 99.99%
------------------------------------------------------------------------------
TCP tcp_echo | 0.91 | 1.05 | 1.64 | 1.8 | 4.9 | 15
------------------------------------------------------------------------------
Name | Status codes
------------------------------------------------------------------------------
TCP tcp_echo | 1,194 [0]
-------------------------+----------------------------------------------------
Aggregated | 1,194 [0]
A custom request is counted like an HTTP request: in the # reqs, # fails, req/s and fail/s columns, in the Aggregated rows (the request tables print one only when there is more than one request name; the status code table always does), and in its response times and status codes.
Parameters
method: A short label for the protocol (e.g."TCP","GRPC","WS","MQTT"). Must not be empty or contain whitespace. Displayed in place of the HTTP method in all metrics output.name: Identifies the specific operation within that protocol. Must not be empty or only whitespace. Combined withmethodto form the metrics key ("TCP tcp_echo").response_time: The measured duration in milliseconds.as_millis()truncates to whole milliseconds, so a sub-millisecond operation is recorded as0 ms; to keep its microseconds, as Goose does for HTTP requests, callrecord_custom_request_duration(below).success: Whether the operation succeeded. Unlike HTTP requests where Goose infers success from the status code, for custom protocols you control this directly.status_code:Nonewhen the protocol has no concept of status codes (e.g. raw TCP);Some(code)to record a protocol-specific code (e.g. a gRPC status code). Internally,Noneis stored as0, so status code tables show[0]for these requests unless--no-status-codesturns those tables off.error: An optional error description for failed operations. WhensuccessisfalseanderrorisNone, Goose records<method> <name>: request failed.
record_custom_request_duration takes the same arguments, with the time as a Duration such as started.elapsed(), and keeps its microseconds.
Both return an error, and record nothing, when method or name breaks the rules above. They return Ok(()) even when success is false: recording a failure does not fail the transaction. To fail it as well, return an error from the transaction function after recording, for example Err(Box::new(TransactionError::Custom(message))). With --no-metrics nothing is recorded.
Coordinated Omission Mitigation applies to custom requests the same way it does to HTTP requests.
Goose records a custom request with the method GooseMethod::Custom and keeps the label you passed in raw.custom_method. GooseMethod::Custom is new in 0.19, so a match on GooseMethod with no wildcard arm needs an arm for it. It is not an HTTP method: get_request_builder(&GooseMethod::Custom, path) returns TransactionError::Custom with the text GooseMethod::Custom cannot be used with HTTP request builders; use record_custom_request() instead.
Running the example
Start a local TCP echo server:
ncat -l 9000 -k -e /bin/cat
Then run the load test, pointing --host at the server. Goose requires a URL with a scheme for validation, and any scheme works (e.g. tcp://, grpc://, ws://). The example reads the host and port from it for the TCP connection, and fails each transaction when the URL has no port:
cargo run --example tcp_loadtest -- \
--host tcp://localhost:9000 \
--users 10 \
--run-time 30s \
--no-reset-metrics
Adapting to other protocols
The same pattern works for any protocol: replace the TcpStream logic with UDP sockets, a WebSocket client, a gRPC stub, or any other I/O:
async fn my_custom_operation(user: &mut GooseUser) -> TransactionResult {
let started = std::time::Instant::now();
// Perform the actual operation here (TCP, UDP, gRPC, etc.)
let result: Result<_, _> = do_my_protocol_operation().await;
let response_time = started.elapsed();
match result {
Ok(_) => {
user.record_custom_request_duration(
"GRPC",
"operation_name",
response_time,
true,
None,
None,
)
.await?;
}
Err(e) => {
user.record_custom_request_duration(
"GRPC",
"operation_name",
response_time,
false,
None,
Some(&e.to_string()),
)
.await?;
}
}
Ok(())
}
This is a fragment: do_my_protocol_operation() stands for your own code.
Complete Source Code
//! Example showing how to load test a raw TCP server with Goose.
//!
//! Goose is primarily an HTTP load testing framework, but its metrics system can
//! track any protocol. This example demonstrates using
//! [`GooseUser::record_custom_request_duration`] to record timing and
//! success/failure information for TCP operations.
//!
//! # How it works
//!
//! 1. The target host and port are read from `--host` (e.g. `tcp://localhost:9000`).
//! 2. Each simulated user opens a TCP connection, sends a payload, and reads the echo.
//! 3. Timing is measured manually with [`std::time::Instant`].
//! 4. [`GooseUser::record_custom_request_duration`] records the result, to the
//! microsecond. It appears in the Goose metrics output under the method
//! label you provide (e.g. `TCP`).
//!
//! # Running the example
//!
//! Start a local TCP echo server (e.g. `ncat -l 9000 -k -e /bin/cat`), then:
//!
//! ```text
//! cargo run --example tcp_loadtest -- --host tcp://localhost:9000 --users 10 --run-time 30s --no-reset-metrics
//! ```
//!
//! The `--host` flag controls which server is targeted. Goose requires a URL with a
//! scheme for validation — any scheme works (e.g. `tcp://`, `grpc://`, `ws://`). The
//! host and port are extracted and used directly for the TCP connection.
//!
//! ## License
//!
//! Copyright 2020-2026 Jeremy Andrews
//!
//! Licensed under the Apache License, Version 2.0 (the "License");
//! you may not use this file except in compliance with the License.
//! You may obtain a copy of the License at
//!
//! <http://www.apache.org/licenses/LICENSE-2.0>
//!
//! Unless required by applicable law or agreed to in writing, software
//! distributed under the License is distributed on an "AS IS" BASIS,
//! WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//! See the License for the specific language governing permissions and
//! limitations under the License.
use goose::prelude::*;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
#[tokio::main]
async fn main() -> Result<(), GooseError> {
GooseAttack::initialize()?
.register_scenario(
scenario!("TcpUser")
.set_wait_time(Duration::from_millis(100), Duration::from_millis(500))?
.register_transaction(transaction!(tcp_echo).set_name("tcp_echo")),
)
.execute()
.await?;
Ok(())
}
/// Connects to the TCP server specified by `--host`, sends a payload, reads the
/// echo response, and records the round-trip as a Goose `TCP` metric.
///
/// # Persistent connections
///
/// This example opens a new TCP connection for each transaction. For production
/// load tests you may want to keep connections persistent across transactions
/// using Goose session data. Because `TcpStream` is not `Clone`, wrap it in
/// `Arc<tokio::sync::Mutex<TcpStream>>`:
///
/// ```ignore
/// // In an on_start transaction:
/// user.set_session_data(Arc::new(tokio::sync::Mutex::new(stream)));
///
/// // In subsequent transactions:
/// let stream = user.get_session_data_unchecked::<Arc<tokio::sync::Mutex<TcpStream>>>();
/// let mut guard = stream.lock().await;
/// ```
///
/// See `examples/session.rs` for the full session data pattern.
async fn tcp_echo(user: &mut GooseUser) -> TransactionResult {
let host = user.base_url.host_str().unwrap_or("127.0.0.1");
let port = user.base_url.port().ok_or_else(|| {
Box::new(TransactionError::Custom(
"--host must include a port number (e.g. --host tcp://127.0.0.1:9000)".to_string(),
))
})?;
let addr = format!("{}:{}", host, port);
let payload = b"Hello, TCP!\n";
let started = Instant::now();
let result = async {
let mut stream = TcpStream::connect(&addr).await?;
stream.write_all(payload).await?;
// Read echoed response (same length as what we sent).
let mut buf = vec![0u8; payload.len()];
stream.read_exact(&mut buf).await?;
Ok::<_, std::io::Error>(buf)
}
.await;
// A Duration keeps the round trip's microseconds, as Goose keeps an HTTP
// request's.
let response_time = started.elapsed();
match result {
Ok(_response) => {
// Pass None for status_code — TCP has no HTTP-style status codes.
user.record_custom_request_duration("TCP", "tcp_echo", response_time, true, None, None)
.await?;
}
Err(e) => {
let error_msg = e.to_string();
user.record_custom_request_duration(
"TCP",
"tcp_echo",
response_time,
false,
None, // TCP has no status codes
Some(&error_msg),
)
.await?;
}
}
Ok(())
}
Drupal Memcache Example
The examples/drupal_memcache.rs example is used to validate the performance of each release of the Drupal Memcache Module.
This page has no picture of a run, because the example needs its own test site to run cleanly. It requests random nodes up to /node/9999 and profiles up to /user/5000, and logs in as user3 to user5001 with the password 12345. It also posts Drupal 7 forms: the login form user_login, and comments with the field comment_body[und][0][value]. It needs a Drupal 7 site prepared as the drupal-loadtest repository describes. A 30 second run with 10 users against the Drupal 11 Umami site that the Umami page uses, a site with 19 nodes, made 17,083 requests. Of those, 1,435 failed with 404s, nearly all of its node and profile requests; the front page and all 14,076 static asset requests succeeded.
Background
Prior to every release of the Drupal Memcache Module, Tag1 Consulting has run a load test to check that the release performs as well as the last; the module runs on tens of thousands of Drupal websites.
The load test began as a JMeter testplan, was converted to a Locust testplan, and is now a Goose testplan.
The Goose port is kept here as an example of a real load test.
Details
The load test defines two scenarios. AnonBrowsingUser has a weight of 4 and AuthBrowsingUser a weight of 1, so four of every five users browse anonymously. Each scenario loads the front page (weight 15), a random node from /node/1 to /node/9999 (weight 10) and a random user profile from /user/2 to /user/5000 (weight 3); the authenticated scenario also posts comments (weight 3). The front page transaction then loads each image and script the page references whose path contains /misc or /themes, as requests named static asset. Transactions are named (Anon) front page, (Auth) node page and so on.
Users of the AuthBrowsingUser scenario log in once, when each starts:
scenario!("AuthBrowsingUser")
.set_weight(1)?
.register_transaction(
transaction!(drupal_memcache_login)
.set_on_start()
.set_name("(Auth) login"),
)
Each AuthBrowsingUser logs in as a random user from user3 to user5001, accounts that exist only on a site prepared for this test:
let uid: usize = rand::rng().random_range(3..5_002);
let username = format!("user{uid}");
let params = [
("name", username.as_str()),
("pass", "12345"),
("form_build_id", &form_build_id[1]),
("form_id", "user_login"),
("op", "Log+in"),
];
let _goose = user.post_form("/user", ¶ms).await?;
// @TODO: verify that we actually logged in.
The test also includes an example of how to post a comment during a load test, after reading the form’s form_build_id, form_token and form_id from the node page:
let comment_body = "this is a test comment body";
let params = [
("subject", "this is a test comment subject"),
("comment_body[und][0][value]", comment_body),
("comment_body[und][0][format]", "filtered_html"),
("form_build_id", &form_build_id[1]),
("form_token", &form_token[1]),
("form_id", &form_id[1]),
("op", "Save"),
];
// Post the comment.
let mut goose = user.post_form(&comment_path, ¶ms).await?;
The Goose Eggs library has Drupal helpers that shorten much of this.
Complete Source Code
//! Conversion of Locust load test used for the Drupal memcache module, from
//! <https://github.com/tag1consulting/drupal-loadtest/>
//!
//! To run, you must set up the load test environment as described in the above
//! repository, and then run the example. You'll need to set --host and may want
//! to set other command line options as well, starting with:
//! cargo run --release --example drupal_memcache --
//!
//! ## License
//!
//! Copyright 2020-2022 Jeremy Andrews
//!
//! Licensed under the Apache License, Version 2.0 (the "License");
//! you may not use this file except in compliance with the License.
//! You may obtain a copy of the License at
//!
//! <http://www.apache.org/licenses/LICENSE-2.0>
//!
//! Unless required by applicable law or agreed to in writing, software
//! distributed under the License is distributed on an "AS IS" BASIS,
//! WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//! See the License for the specific language governing permissions and
//! limitations under the License.
use goose::prelude::*;
use rand::Rng;
use regex::Regex;
#[tokio::main]
async fn main() -> Result<(), GooseError> {
GooseAttack::initialize()?
.register_scenario(
scenario!("AnonBrowsingUser")
.set_weight(4)?
.register_transaction(
transaction!(drupal_memcache_front_page)
.set_weight(15)?
.set_name("(Anon) front page"),
)
.register_transaction(
transaction!(drupal_memcache_node_page)
.set_weight(10)?
.set_name("(Anon) node page"),
)
.register_transaction(
transaction!(drupal_memcache_profile_page)
.set_weight(3)?
.set_name("(Anon) user page"),
),
)
.register_scenario(
scenario!("AuthBrowsingUser")
.set_weight(1)?
.register_transaction(
transaction!(drupal_memcache_login)
.set_on_start()
.set_name("(Auth) login"),
)
.register_transaction(
transaction!(drupal_memcache_front_page)
.set_weight(15)?
.set_name("(Auth) front page"),
)
.register_transaction(
transaction!(drupal_memcache_node_page)
.set_weight(10)?
.set_name("(Auth) node page"),
)
.register_transaction(
transaction!(drupal_memcache_profile_page)
.set_weight(3)?
.set_name("(Auth) user page"),
)
.register_transaction(
transaction!(drupal_memcache_post_comment)
.set_weight(3)?
.set_name("(Auth) comment form"),
),
)
.execute()
.await?;
Ok(())
}
/// View the front page.
async fn drupal_memcache_front_page(user: &mut GooseUser) -> TransactionResult {
let mut goose = user.get("/").await?;
match goose.response {
Ok(response) => {
// Copy the headers so we have them for logging if there are errors.
let headers = &response.headers().clone();
match response.text().await {
Ok(t) => {
let re = Regex::new(r#"src="(.*?)""#).unwrap();
// Collect copy of URLs to run them async
let mut urls = Vec::new();
for url in re.captures_iter(&t) {
if url[1].contains("/misc") || url[1].contains("/themes") {
urls.push(url[1].to_string());
}
}
for asset in &urls {
let _ = user.get_named(asset, "static asset").await;
}
}
Err(e) => {
// This will automatically get written to the error log if enabled, and will
// be displayed to stdout if `-v` is enabled when running the load test.
return user.set_failure(
&format!("front_page: failed to parse page: {e}"),
&mut goose.request,
Some(headers),
None,
);
}
}
}
Err(e) => {
// This will automatically get written to the error log if enabled, and will
// be displayed to stdout if `-v` is enabled when running the load test.
return user.set_failure(
&format!("front_page: no response from server: {e}"),
&mut goose.request,
None,
None,
);
}
}
Ok(())
}
/// View a node from 1 to 10,000, created by preptest.sh.
async fn drupal_memcache_node_page(user: &mut GooseUser) -> TransactionResult {
let nid = rand::rng().random_range(1..10_000);
let _goose = user.get(format!("/node/{}", nid).as_str()).await?;
Ok(())
}
/// View a profile from 2 to 5,001, created by preptest.sh.
async fn drupal_memcache_profile_page(user: &mut GooseUser) -> TransactionResult {
let uid = rand::rng().random_range(2..5_001);
let _goose = user.get(format!("/user/{}", uid).as_str()).await?;
Ok(())
}
/// Log in.
async fn drupal_memcache_login(user: &mut GooseUser) -> TransactionResult {
let mut goose = user.get("/user").await?;
match goose.response {
Ok(response) => {
// Copy the headers so we have them for logging if there are errors.
let headers = &response.headers().clone();
match response.text().await {
Ok(html) => {
let re = Regex::new(r#"name="form_build_id" value=['"](.*?)['"]"#).unwrap();
let form_build_id = match re.captures(&html) {
Some(f) => f,
None => {
// This will automatically get written to the error log if enabled, and will
// be displayed to stdout if `-v` is enabled when running the load test.
return user.set_failure(
"login: no form_build_id on page: /user page",
&mut goose.request,
Some(headers),
Some(&html),
);
}
};
// Log the user in.
let uid: usize = rand::rng().random_range(3..5_002);
let username = format!("user{uid}");
let params = [
("name", username.as_str()),
("pass", "12345"),
("form_build_id", &form_build_id[1]),
("form_id", "user_login"),
("op", "Log+in"),
];
let _goose = user.post_form("/user", ¶ms).await?;
// @TODO: verify that we actually logged in.
}
Err(e) => {
// This will automatically get written to the error log if enabled, and will
// be displayed to stdout if `-v` is enabled when running the load test.
return user.set_failure(
&format!("login: unexpected error when loading /user page: {e}"),
&mut goose.request,
Some(headers),
None,
);
}
}
}
// Goose will catch this error.
Err(e) => {
// This will automatically get written to the error log if enabled, and will
// be displayed to stdout if `-v` is enabled when running the load test.
return user.set_failure(
&format!("login: no response from server: {e}"),
&mut goose.request,
None,
None,
);
}
}
Ok(())
}
/// Post a comment.
async fn drupal_memcache_post_comment(user: &mut GooseUser) -> TransactionResult {
let nid: i32 = rand::rng().random_range(1..10_000);
let node_path = format!("node/{}", nid);
let comment_path = format!("/comment/reply/{}", nid);
let mut goose = user.get(&node_path).await?;
match goose.response {
Ok(response) => {
// Copy the headers so we have them for logging if there are errors.
let headers = &response.headers().clone();
match response.text().await {
Ok(html) => {
// Extract the form_build_id from the user login form.
let re = Regex::new(r#"name="form_build_id" value=['"](.*?)['"]"#).unwrap();
let form_build_id = match re.captures(&html) {
Some(f) => f,
None => {
// This will automatically get written to the error log if enabled, and will
// be displayed to stdout if `-v` is enabled when running the load test.
return user.set_failure(
&format!("post_comment: no form_build_id found on {}", node_path),
&mut goose.request,
Some(headers),
Some(&html),
);
}
};
let re = Regex::new(r#"name="form_token" value=['"](.*?)['"]"#).unwrap();
let form_token = match re.captures(&html) {
Some(f) => f,
None => {
// This will automatically get written to the error log if enabled, and will
// be displayed to stdout if `-v` is enabled when running the load test.
return user.set_failure(
&format!("post_comment: no form_token found on {}", node_path),
&mut goose.request,
Some(headers),
Some(&html),
);
}
};
let re = Regex::new(r#"name="form_id" value=['"](.*?)['"]"#).unwrap();
let form_id = match re.captures(&html) {
Some(f) => f,
None => {
// This will automatically get written to the error log if enabled, and will
// be displayed to stdout if `-v` is enabled when running the load test.
return user.set_failure(
&format!("post_comment: no form_id found on {}", node_path),
&mut goose.request,
Some(headers),
Some(&html),
);
}
};
// Optionally uncomment to log form_id, form_build_id, and form_token, together with
// the full body of the page. This is useful when modifying the load test.
/*
user.log_debug(
&format!(
"form_id: {}, form_build_id: {}, form_token: {}",
&form_id[1], &form_build_id[1], &form_token[1]
),
Some(&goose.request),
Some(headers),
Some(&html),
);
*/
let comment_body = "this is a test comment body";
let params = [
("subject", "this is a test comment subject"),
("comment_body[und][0][value]", comment_body),
("comment_body[und][0][format]", "filtered_html"),
("form_build_id", &form_build_id[1]),
("form_token", &form_token[1]),
("form_id", &form_id[1]),
("op", "Save"),
];
// Post the comment.
let mut goose = user.post_form(&comment_path, ¶ms).await?;
// Verify that the comment posted.
match goose.response {
Ok(response) => {
// Copy the headers so we have them for logging if there are errors.
let headers = &response.headers().clone();
match response.text().await {
Ok(html) => {
if !html.contains(comment_body) {
// This will automatically get written to the error log if enabled, and will
// be displayed to stdout if `-v` is enabled when running the load test.
return user.set_failure(
&format!("post_comment: no comment showed up after posting to {}", comment_path),
&mut goose.request,
Some(headers),
Some(&html),
);
}
}
Err(e) => {
// This will automatically get written to the error log if enabled, and will
// be displayed to stdout if `-v` is enabled when running the load test.
return user.set_failure(
&format!(
"post_comment: unexpected error when posting to {}: {}",
comment_path, e
),
&mut goose.request,
Some(headers),
None,
);
}
}
}
Err(e) => {
// This will automatically get written to the error log if enabled, and will
// be displayed to stdout if `-v` is enabled when running the load test.
return user.set_failure(
&format!(
"post_comment: no response when posting to {}: {}",
comment_path, e
),
&mut goose.request,
None,
None,
);
}
}
}
Err(e) => {
// This will automatically get written to the error log if enabled, and will
// be displayed to stdout if `-v` is enabled when running the load test.
return user.set_failure(
&format!("post_comment: no text when loading {}: {}", node_path, e),
&mut goose.request,
None,
None,
);
}
}
}
Err(e) => {
// This will automatically get written to the error log if enabled, and will
// be displayed to stdout if `-v` is enabled when running the load test.
return user.set_failure(
&format!(
"post_comment: no response when loading {}: {}",
node_path, e
),
&mut goose.request,
None,
None,
);
}
}
Ok(())
}
Umami Example
The examples/umami example load tests the Umami demonstration profile included with Drupal core.
cargo run --release --features dashboard --example umami -- --host http://localhost:18888 -u 50 -r 5 -t 3m --no-reset-metrics --dashboard -q, against a Drupal 11 site installed with the Umami demo profile, with CSS and JS aggregation turned off and the admin account admin with the password P@ssw0rd1234, 120 seconds in.
The Scenarios table shows that with 50 users the weights 40, 9 and 1 give exactly 40, 9 and 1 users. The Transactions table goes on past the six English rows shown here to 23 rows in all: the rest of the English user’s, the Spanish user’s and the admin user’s. In about the first two minutes of the run, the 50 users made 61,652 requests with none failed; 57,772 of them were the images and scripts the pages name, which the example requests under the one name static asset. With -q Goose does not print the dashboard’s address when it starts: open http://127.0.0.1:5118 while the test runs. If that tab is still open when the run ends, Goose prints the address and waits for Ctrl-C.
Turn CSS and JS aggregation off on the site: the example does not decode & in the aggregated asset links it parses, so the URLs it requests still hold & where the page meant &. With aggregation off it loads no CSS, because it looks for stylesheets only under the aggregated path.
Overview
The Umami profile installs a sample food magazine site in English and Spanish, with articles, recipes, basic pages, several taxonomies, search and a contact form.
The included example simulates three different types of users: an anonymous user browsing the site in English (scenario Anonymous English user, weight 40), an anonymous user browsing the site in Spanish (Anonymous Spanish user, weight 9), and an administrative user that logs into the site (Admin user, weight 1). The anonymous users load most of the site’s page types. The anonymous user browsing the site in English loads the front page, the About Umami basic page, the article listing and a random article, the recipe listing and a random recipe, a random node by node id, a random term listing, runs searches using words from the site’s content, and fills out the site’s contact form. The Spanish user does the same in Spanish, except loading a node by node id. The admin user logs in once, then loads the front page, the article listing, and edits and saves a random article. Goose counts any response that is not a 2xx as a failure. On each page the example also checks the HTML for the expected title and loads the images and scripts the page references.
The example load tests https://drupal-9.ddev.site/ unless --host names another site. The admin user logs in as admin with the password P@ssw0rd1234; set the ADMIN_USERNAME or ADMIN_PASSWORD environment variable to change either.
Read the blog A Goose In The Clouds: Load Testing At Scale for a demonstration of using this example, and learn more about the testplan from the README.
Alternative
The Goose Eggs library contains a variation of the Umami example.
Complete Source Code
This example is more complex than the other examples, and is split into multiple files, all of which can be found within examples/umami.
Upgrading From 0.18
Change the dependency to goose = "^0.19". The changes below can break a load test written for Goose 0.18, or change what it does.
Removed options
| 0.18 | 0.19 |
|---|---|
--hatch-rate (-r) | --increase-rate (-r), see launching users |
--startup-time (-s) | --increase-time (-s), see launching users |
GooseDefault::HatchRate | GooseDefault::IncreaseRate, see Defaults |
GooseDefault::StartupTime | GooseDefault::IncreaseTime, see Defaults |
GooseConfiguration fields hatch_rate and startup_time | increase_rate and increase_time, see Custom options |
goose::util::get_hatch_rate() | goose::util::parse_rate() |
Controller commands hatchrate and startuptime | increaserate and increasetime, see Controller commands |
Crate feature gaggle | None: Gaggle was removed in 0.17.0 and will be restored in 0.20, before the 1.0 release. For a distributed load test today, use 0.16.4; see Gaggle. |
The new --decrease-rate and --decrease-time control the ramp down; without them Goose stops all users at once, as 0.18 did. See the ramp down rate. Neither is allowed with --iterations: the run fails at startup with `configuration.decrease_rate` can not be set with `configuration.iterations`., or the same for decrease_time. Drop the decrease option, as each user stops after its iterations anyway.
API changes
- API change: the command line is parsed with
clap. Removeuse gumdrop::Options;:GooseConfiguration::parse_args_default()is now a method of its own, andGooseConfigurationimplementsclap::Parser. See Custom options. - API change: every
GooseConfigurationfield for an option that takes a single value, such ashost,run_time,increase_timeoriterations, is anOption,Nonewhen not given; set one withSome, as inconfiguration.run_time = Some("30s".to_string()). Thehelpfield is gone. See Custom options. - API change: session data must implement
Clone; add#[derive(Clone)]to the struct. See the Session example. - API change:
GooseConfiguration::parse_args_default()returnsGooseError::InvalidOptionfor-hand--help, with the flag asoptionand the detailhelp was requested, where 0.18 returned the configuration with itshelpfield set totrue. See Building the configuration yourself. - API change:
TransactionError::RequestFailed { raw_request }holds aBox<GooseRequestMetric>,TransactionError::LoggerFailed { source }a boxedSendError, and the newTransactionError::Custom(String)lets a transaction returnErr("reason".into()). See Transaction error types. - API change: a
matchwithout a wildcard arm needs new arms forTransactionError::Custom(see Transaction error types),GooseError::ThresholdsFailed(see The end of the run),GooseMethod::Custom(see The API) and the newGooseDefaultvariants (see Defaults). - API change: scenario and transaction names are
Arc<str>in place ofString:Scenario.name,Scenario.machine_name, thenameofScenarioMetric,TransactionMetricandScenarioMetricAggregate,TransactionMetricAggregate.scenario_name,GooseRequestMetric.nameandGooseRequestMetric.scenario_name, and thescenario_nameargument ofGooseUser::new(). Reading one as&strworks unchanged. See the API documentation. - API change:
Transaction.nameand thetransaction_nameofGooseRequestMetricandTransactionMetricAggregateare agoose::goose::TransactionNamein place of aString:TransactionOnly(Arc<str>)for a name set withset_name(), orInheritNameByRequests(Arc<str>)for one set withset_name_for_transaction_and_requests(). Read the name withname_for_transaction().GooseRequestMetric.transaction_indexis ausizein place of aString. See Naming Transactions and the API documentation. - API change: times are kept in microseconds.
GooseRequestMetricTimingDatahastimes_us,minimum_time_us,maximum_time_usandtotal_time_usin place of the millisecondtimes,minimum_time,maximum_timeandtotal_time;TransactionMetricAggregateandScenarioMetricAggregatekeep their times inrun_times; a struct literal ofGooseRequestMetric,TransactionMetricorScenarioMetricmust set the newresponse_time_fraction_usorrun_time_fraction_us. See Response times and the API documentation ofGooseRequestMetricTimingData,TransactionMetricAggregateandScenarioMetricAggregate. - goose-eggs 0.6 is built for Goose 0.18. A load test that uses goose-eggs moves to Goose 0.19 with goose-eggs 0.7: set
goose-eggs = "0.7". See Goose Eggs. - API change: the default crate features are now
cookies, sodefault-features = falseturns off cookie support, where in 0.18 it turned off TLS, and therustls-tlsfeature no longer changes the TLS that load test requests use. - API change:
controller::ControllerCommandcarries each command’s parsed value, andutil::parse_timespan()returnsOption<usize>. See Controller commands and the API documentation ofControllerCommandandparse_timespan(). - API change:
TestPlan::total_users()stops atusize::MAXin place of overflowing, andGooseError::InvalidControllerCommandis no longer returned, though the variant is kept. See the API documentation ofTestPlan. - API change:
TransactionDetailhas no lifetime parameter: itsscenario_nameis anArc<str>, itstransaction_indexanOption<usize>and itstransaction_nameaTransactionName, where 0.18 held&strs.GooseRequestMetric.transaction_indexisusize::MAXfor a request made outside a transaction. See the API documentation ofTransactionDetail. - API change: a transaction error is recorded.
GooseMetricshas the new public fieldtransaction_errors, aGooseTransactionErrorMetricsofGooseTransactionErrorMetricAggregates, andGooseTransactionErrorMetricis an error log entry.GooseMetrichas the new variantsAll,BatchandTransactionError, andGooseLogthe new variantsTransactionErrorandFailedTransaction, so amatchon either without a wildcard arm needs new arms. See Error log. - API change:
GooseMetricshas the new public fieldthresholds, each rule’sThresholdResult, and the newgoose::thresholdmodule holdsThresholdandThresholdResult. See Thresholds. - API change:
GooseConfigurationhas new public fields:threshold,no_threshold_abort,threshold_abort_delay,output_dir,compress,notes,report_graph_data,render_reportandwebsocket, among others, and, with thepdf-reportsfeature,pdf_scaleandpdf_timeout, anOption<f64>and anOption<u64>set in code withGooseDefault::PdfScaleandGooseDefault::PdfTimeout. See Custom options and Output files. - API change:
config::RUN_FOLDERandreset_killswitch()are public. See Output files and Killswitch.
Behavior changes
- behavior change:
Transaction::set_name()names only the transaction; its requests keep their own names. Useset_name_for_transaction_and_requests()for the 0.18 behavior. See Naming transactions. - behavior change:
--scenariosmatches machine names exactly, where 0.18 matched any part of the name; use*as a wildcard, as in--scenarios "anon*". See Limiting scenarios. - behavior change: HTTPS uses rustls in place of the system’s native TLS library. Certificates are still checked by the operating system’s verifier, so a CA the system trusts is trusted, and
--accept-invalid-certsskips the check in every build. See TLS. - behavior change: an option value that begins with a hyphen goes after
=, as in--goose-log=-run.log. Given as its own argument it is read as options, so--goose-log -run.logreads as--goose-logand-r un.log, and fails becauseun.logis not a number. When the command line itself does not parse, as with--goose-log -x.log, the error ends with a tip to use=. A negative number is still read as a value:--timeout -5reaches the timeout check and is refused there, and--users -1is refused with a tip that--userstakes no negative number. An output option followed by another option, as in--goose-log --quiet, is given without a path and writesgoose.loginto the run’s folder. See Output files. - behavior change: a report or log file whose name ends in
.gzis written gzipped. See Compression. - behavior change:
--report-filereports are written even with--no-print-metrics. See Output files. - behavior change:
-hprints a short summary and--helpthe full reference, both to stdout. See Run-time options. - behavior change:
--run-time 0,--increase-time 0,--decrease-time 0and--throttle-requests 0undo the matchingGooseDefault, and an empty log path, as in--request-log "", turns off a log aGooseDefaultsets;GooseDefault::IterationsandGooseDefault::NoGranularDatanow take effect. See Defaults. - behavior change: values that started no users or broke Goose are refused before the load test starts: a test plan with no users, a time span too large to hold, an increase or decrease rate below one user a day, a rate or
--timeoutthat is not a finite number, and a--timeoutthat rounds to less than 1 ms, including a negative one. A fractional--timeoutis rounded to the nearest millisecond, so0.0005runs as 1 ms and anything below it is refused. See Test plan and Run-time options. - behavior change: a time span, as given to
--run-time,--increase-time,--decrease-time,--threshold-abort-delay, a test plan step or a Controller command, is only hours, minutes and seconds written ash,mands, as in1h30mor45s, in either case. 0.18 read15mins,5hoursand2h3minby their leading part, as 15 minutes, 5 hours and 2 hours 3 minutes; 0.19 refuses them, so write15m,5hand2h3m. See Run-time options. - behavior change: times under 10 ms are shown to the hundredth of a millisecond, and percentiles are never more than 1% off, so figures differ from the whole milliseconds 0.18 printed. See Response times.
- behavior change: the JSON report’s timing data holds
times_usin place oftimes, its averages are means of the microseconds, it includes the test plan history, and each transaction’stransaction_nameis an object such as{"TransactionOnly":"login"}, where 0.18 wrote a string. See JSON report. - behavior change: the CSV request log ends with a
response_time_uscolumn, the scenario log withrun_time_us, and the transaction log withrun_time_usand thenerror; the JSON, raw and pretty formats addresponse_time_fraction_usorrun_time_fraction_us. In JSON logs the keys follow the order of the fields instead of being sorted,transaction_nameis an object such as{"TransactionOnly":"login"}, where 0.18 wrote a string (the error log’s new transaction error entries write it as a string); the CSV request log’stransaction_namecolumn is empty unless the name was set withset_name_for_transaction_and_requests(); and the request, debug and error logs writetransaction_indexas a number, such as0, where 0.18 wrote the string"0". See Logging and Request log. - behavior change:
elapsedin the request, error, transaction and scenario logs counts milliseconds since the load test started, where 0.18 counted from when each user was built;GooseUser.startedis likewise the time the load test started, the same for every user, and the HTML report’s active users graph shows the increase ramp. See Logging, Request log and Users. - behavior change:
GooseRawRequest.headers, as read throughgoose.request.raw.headersor aTransactionError::RequestFailed, is empty unless the request log, debug log or error log is enabled. See Request log. - behavior change: Controller
userstakes 1 to 100000, and on a running load test keeps its run time and ramp down;runtimechanges a running users ramp; a value that can’t be used is answered with what it must be;config-json(and the WebSocket Controller’sconfig) shows an unset option asnulland has nohelpkey, and telnetconfigshowsNonefor an unset option andSome(...)around a set one. See Controller commands. - behavior change: the telnet Controller’s
metrics-jsonand the WebSocket Controller’smetricsreplies holdtimes_usin place oftimesand write each transaction’stransaction_nameas an object, as the JSON report does, and a WebSocket message over 131,072 bytes closes the connection. See WebSocket Controller. - behavior change: the killswitch keeps the first reason it is given, and is cleared when a run starts. See Killswitch.
- behavior change: users start and stop on time at any rate. 0.18 rounded the time between two users up to the next half second when it was over 500 ms, so
--increase-rate 1.5started 1 user a second and a test plan step of 500 users in 5 minutes took 8 minutes 20 seconds. In 0.19--increase-rate 1.5starts a user every 667 ms, and that step takes 5 minutes. This applies to--increase-rate,--decrease-rate, test plan steps and users changed by a Controller. See Specifying the increase rate. - behavior change:
GooseScheduler::Randomshuffles transactions. 0.18 ran them in Serial order. 0.19 shuffles each scenario’s whole list once, so every user of a scenario runs the same random order; sequenced transactions are shuffled only within their sequence, andon_startandon_stoptransactions run as before. See Random Scheduler. - behavior change: a transaction’s error is recorded. Each line of the transaction log, in every format, ends with an
errorfield, empty for a transaction that succeeded. The error log gets an entry for each transaction error, and its CSV header gains the columnsscenario_index,scenario_name,transaction_index,transaction_nameandrun_timeaftererror; a request error row leaves them empty. The console summary and the HTML, Markdown, PDF and JSON reports add a Transaction Errors table when there are any, which--no-error-summaryleaves out. See Transaction log and Error log. - behavior change: the JSON report keeps each request’s times by status code in
status_code_timings, underraw_metrics.requests, and holds transaction errors intransaction_errors. Itsraw_request_metricsrows are each request and the Aggregated row. See JSON report. - behavior change: with
--co-mitigation, the Aggregated# reqsis the “Actual requests” plus the “Synthetic requests” of the coordinated omission summary, and those counts start again when the metrics are reset after the ramp up. See Coordinated omission metrics. - behavior change: the errors and transaction errors tables start again when the metrics are reset after the ramp up, as the other tables do, where 0.18 kept the ramp up’s errors in them.
--no-reset-metricskeeps them, and the error log still records every error. See Metrics. - behavior change: a scenario counts every user that ran it in the measured time, including users that had not completed an iteration, and the HTML, Markdown and JSON reports’ Aggregated scenario Iterations is all runs divided by all users, as on the console, where 0.18’s reports added up each scenario’s iterations. See Scenarios.
- behavior change: an average with no times to average, such as a scenario no user ran, is 0, where it was NaN (
nullin the JSON report). See Metrics. - behavior change:
--scenariosaccepts_, soname_1selects a second scenario registered asname, and a pattern that matches no scenario logs a warning. See Limiting scenarios. - behavior change: a telnet or WebSocket Controller that can’t listen on its port logs an error, and the load test runs without it;
--no-autostartis then refused when no Controller listens and dashboard control is off, as nothing could start the run. See Controlling a running load test and Printer-friendly HTML report example. - behavior change: a load test that a Controller starts again after it stopped counts its requests from zero. See Controlling a running load test.
- behavior change: the first column of the Markdown report’s scenario table is headed
Scenario, where 0.18 headed itTransaction. See Markdown report. - behavior change:
--telnet-port 0and--websocket-port 0are refused, where 0.18 read them as the default ports 5116 and 5117. Leave the option out for the default. See Controlling a running load test. - behavior change: the WebSocket Controller is off by default, as it takes no authentication and any web page a browser opens could reach it. A load test that used it adds
--websocketorGooseDefault::WebSocket;--no-websocketandGooseDefault::NoWebSocketstill run, with a warning, and are refused with--websocket.--no-autostart --no-telnetnow needs--websocketor--dashboard-control. See WebSocket Controller. - behavior change: the telnet and WebSocket Controllers listen on
127.0.0.1by default, where 0.18 listened on0.0.0.0, as neither has authentication. A load test controlled over telnet or WebSocket from another machine, or from outside its container, now needs--telnet-host 0.0.0.0or--websocket-host 0.0.0.0(orGooseDefault::TelnetHostorGooseDefault::WebSocketHost), or an ssh tunnel to127.0.0.1:5116; a Controller listening on any address that is not loopback prints a warning at startup. See Controlling a running load test. - behavior change: a Controller’s
configandconfig-jsonshow a dashboard auth token that is set as(set), never the token, as does{:?}of aGooseConfiguration. See Controller commands.