Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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 needs rustc 1.88.0 or later: some of them, such as cookie_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:

FeatureDefaultWhat it adds
cookiesonEach GooseUser keeps its own cookies; see Cookies. Build with default-features = false to leave it out.
dashboardoffThe live web dashboard, served with --dashboard.
pdf-reportsoffPDF reports, written with a .pdf --report-file.
rustls-tlsoffKept 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 use statements 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 check
  • TransactionError::InvalidMethod: unsupported HTTP methods
  • TransactionError::LoggerFailed: errors sending log messages to the logger thread; source holds the flume::SendError, boxed
  • TransactionError::MetricsFailed: errors sending metrics to the parent thread
  • TransactionError::RequestCanceled: requests canceled when throttling is enabled and the test ends
  • TransactionError::RequestFailed: a request marked failed with GooseUser::set_failure, or a failed request built with GooseRequest::builder().error_on_fail(); raw_request holds the request’s GooseRequestMetric, boxed
  • TransactionError::Reqwest: network and HTTP client errors, such as connection failures and timeouts
  • TransactionError::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.

MethodPasses when
status, not_statusthe response status code is, or is not, the one given
title, not_titlethe page title contains, or does not contain, the text given, ignoring case
text, not_textthe page contains, or does not contain, the text given
texts, not_textsthe page contains every text in the list, or none of them
header, not_headerthe response has, or does not have, the header given
header_value, not_header_valuethe header’s value contains, or does not contain, the text given
redirectthe 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_value checks the header’s value. In 0.6 it failed whenever the header was set.
  • validate_and_load_static_assets no longer loads protocol relative asset URLs on other hosts, such as //cdn.example.com/a.js.
  • header_is_set() and valid_header_value() take an http::HeaderMap in place of reqwest::header::HeaderMap. They are the same type, so code that compiled with 0.6 still compiles.
  • The default feature turns on Goose’s default features, which in Goose 0.19 is the cookies feature, and no longer picks a TLS backend. The rustls-tls feature turns on goose/rustls-tls and 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.

OptionsExplained
-H/--hostCommon Options
-u/--usersCommon Options
-t/--run-time, --iterationsCommon Options
-r/--increase-rate, -s/--increase-timeCommon Options
--decrease-rate, --decrease-timeCommon Options
--test-planTest Plan
--scenarios, --scenarios-listLimiting Scenarios
--throttle-requestsThrottle
-h/--help-h prints the summary and --help the full reference, both shown on this page.
-V/--versionPrints the Goose library’s name and version, then exits.
-l/--listPrints 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/--quietWithout 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-logWrites 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-levelAdds 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-metricsPrints 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-metricsCommon Options
--no-error-summaryLeaves 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-timeoutCommon Options
--baseline-fileCommon Options
--output-dir, --notesCommon Options, then Output Files
--report-graph-data, --render-reportCommon Options, then Output Files
--compressOutput Files
--no-granular-reportMetrics
--no-reset-metricsKeeps 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-metricsCollects 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-codesLeaves the count of each HTTP status code out of the console metrics and the reports.
--co-mitigationMitigation
--threshold, --no-threshold-abort, --threshold-abort-delayThresholds
-R/--request-log, --request-format, --request-bodyRequest Log
-T/--transaction-log, --transaction-formatTransaction Log
-S/--scenario-log, --scenario-formatScenario Log
-E/--error-log, --error-formatError Log
-D/--debug-log, --debug-format, --no-debug-bodyDebug Log
--dashboardCommon Options, then Live Dashboard
--dashboard-host, --dashboard-port, --dashboard-auth-token, --dashboard-control, --dashboard-no-auth, --dashboard-max-clients, --no-percentiles-over-timeDashboard Reference
--no-autostartControllers
--no-telnet, --telnet-host, --telnet-portTelnet Controller
--websocket, --websocket-host, --websocket-portWebSocket Controller
--timeoutTips
--no-gzipBy default requests ask for gzip compressed responses, which Goose decompresses; --no-gzip stops asking.
--sticky-followWhen a request is redirected to another host, the GooseUser sends its later requests to that host too.
--accept-invalid-certsSkips 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 length20 names, graph data20 names, writing HTML100 names, graph data100 names, writing HTML
10 minutes0.3 MB4 MB1.3 MB20 MB
1 hour1.1 MB24 MB5 MB114 MB
24 hours35 MB570 MB160 MB2.8 GB
7 days280 MB4 GB1.3 GB20 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>.

MetricMeasuresValue
pNNA response time percentile, with optional decimals, such as p50, p95 or p99.9, greater than 0 and less than 100A time: a number with us, ms or s; a bare number is milliseconds
avgThe average response timeA time
maxThe slowest response timeA time
error-rateFailed requests as a percent of all requestsA number with an optional %, at most 100
rpsRequests per second while the run holds its users steadyA 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: no GET /checkout takes 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 _1 and _2 are 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

ViewWhat it holdsWho it is forHow to get it
Live dashboardLive charts and tablesYou, while the run is goingThe dashboard feature, then --dashboard, and open http://127.0.0.1:5118/
PDF reportThe HTML report’s sections in one tall, fixed page; its line graphs can stop short in this releaseSomeone who reads a finished run on screen or as an attachmentThe 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 reportThe tables, graphs to zoom and filter, and the response time heatmap; the graphs load from a CDN when the report is openedAn engineer studying the run--report-file report.html
Markdown reportTables onlyAn issue, a pull request or a wiki page--report-file report.md
JSON reportEvery table’s numbers, for programsA script, a CI job, or a later run’s --baseline-file--report-file report.json
Console metricsText tablesA quick look in the terminal, or a CI logPrinted 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 unzip on PATH (tar on Windows). If a firewall blocks the download, or unzip is missing, install Chrome or Chromium or set CHROME.
  • 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 ldd can’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_metrics is Goose’s own record of the run, GooseMetrics, with the test plan steps the run went through, in history, and each --threshold result. Each transaction’s transaction_name there is an object that says how it was named, such as {"TransactionOnly":"anon /"}.
  • raw_request_metrics, raw_response_metrics, scenario_metrics, transaction_metrics and status_code_metrics are the tables the other reports show; raw_request_metrics has a row for each request and the Aggregated row, and each request in raw_metrics holds status_code_timings in place of the └─ breakdown rows.
  • errors is null without errors, and transaction_errors appears only when a transaction returned an error that is not a request error.
  • The co_ keys are null until coordinated omission mitigation adds synthetic times, and coordinated_omission_metrics is null with mitigation off.
  • graphs, with the per second data behind the graphs and the heatmap, appears only when the run had --report-graph-data or 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:

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 postsNow
-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-statsthe default; --no-print-metrics turns the printing off, --no-metrics the collecting
--only-summarythe default; --running-metrics N adds running metrics
--reset-statsthe default
--no-stats--no-metrics
--status-codesthe default; --no-status-codes turns it off
--no-task-metrics--no-transaction-metrics
--manager, --worker and the other Gaggle optionsnone 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 GooseErrorMetric object, created any time a request results in an error, including a request the load test marks as failed with set_failure;
  • a transaction error: a GooseTransactionErrorMetric, created each time a transaction returns an error that is not already a request error: a TransactionError::Custom or Url, or a Reqwest error from reading the response body or building the request. A transaction that fails because one of its requests failed, as with set_failure, gets no entry of its own: the request error has one. It has elapsed, scenario_index, scenario_name (the machine name, as in the request log), transaction_index, transaction_name, run_time, user and error, the error as its Display writes it. Errors from test_start and test_stop are 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 config and metrics at 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 DashboardTelnetWebSocket
ObserveLive charts, KPIs and tablesmetrics, metrics-jsonmetrics, metrics-json
Start, stopWith control onstart, stopstart, stop
UsersWith control onusersusers
Host and rampsWith control on, in the Edit settings formhost, increaserate, increase-time, decreaserate, decrease-timethe same commands
Test planWith control on, through POST /api/v1/control/settings or POST /api/v1/control/start; the form has no plan fieldtest-plantest-plan
Run timeWith control on, in the Edit settings formruntimeruntime
Save reportOn by default with --dashboard; turning it off and Save report now need controlNoNo
configNoconfig, config-jsonconfig, config-json
Shut Goose downWith control on: Quit, from Idle onlyshutdown, from any phaseshutdown, from any phase
DefaultOff; --dashboard turns it on, on 127.0.0.1:5118On, on 127.0.0.1:5116Off; --websocket turns it on, on 127.0.0.1:5117
AuthenticationRead-only on loopback: none, unless a token is set. Control, or any bind off loopback: a token, or --dashboard-no-authNoneNone

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 open http://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.html and report.json in 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. --dashboard costs 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/v1 endpoints 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.

FlagDefaultPurpose
--dashboardoffEnable the live dashboard HTTP server (observe)
--dashboard-controloffEnable 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 HOST127.0.0.1Bind 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 PORT5118Bind 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 TOKENemptyShared 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-authoffAllow 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 N32Max 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-timeoffTurn 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 dashboard crate feature (not in default features, same pattern as pdf-reports). Enable it with --features dashboard. Builds without that feature reject --dashboard at 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

CapabilityLive Dashboard (observe)Live Dashboard + controlControllers (telnet / WebSocket)
Live rates, percentiles, errorsYesYesmetrics / metrics-json
Trailing RPS / users / latency chartsYesYesNo
Start / stop / set usersNoYesYes
Host / ramps / run timeNoYesYes
Test planNoThrough POST /api/v1/control/settings or POST /api/v1/control/start onlyYes (test-plan)
config / metricsNoNoYes
Process shutdownNoQuit, from Idle onlyYes (shutdown, any phase)
Default bind127.0.0.1:5118127.0.0.1:5118127.0.0.1:5116 / :5117, WebSocket only with --websocket
AuthToken on metric APIs when configuredToken for control unless --dashboard-no-auth; metrics follow observe rulesNone (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:

WhoWhat they can reachTo close it
Any local processThe 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 browserThe WebSocket Controller, when --websocket turns it on; it takes no authentication and accepts a connection from any web pageLeave --websocket off
Any local user, with no token setThe 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-controlEverything 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 logsA token in place of --dashboard-no-auth
Any local user, with a token setThe 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 stopThe 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 systemThe 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 themRun 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 lineThe 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 example 127.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)

BindObserve (--dashboard)Control (--dashboard-control)
Loopback, no tokenMetrics OK without authStartup error (control needs a token or --dashboard-no-auth)
Loopback, token setMetrics require tokenControl requires token
Loopback, --dashboard-no-authMetrics OK without authControl open without a token; cross site requests refused
Non-loopback, no tokenStartup errorStartup error
Non-loopback, token setMetrics require tokenControl requires token
Non-loopback, --dashboard-no-authMetrics open; startup warning, page banner, Host ruleControl open without a token; startup warning, page banner, Host rule, cross site requests refused
Any bind, --dashboard-no-auth with --dashboard-auth-tokenStartup errorStartup 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-control without --dashboard → startup hard-fail (control does not auto-enable the dashboard). The same goes for --dashboard-no-auth without --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 Host unchanged;
  • Goose runs with --dashboard-host set 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

PathAuth when token configuredNotes
GET / (the page’s HTML shell; the page is a single page app, SPA)Public
GET /static/app.js, GET /static/app.cssPublic
GET /api/v1/healthPublicLiveness and timing counters; includes control_enabled and the Controller addresses (no load-test metrics); see Health endpoint exception
GET /api/v1/snapshotRequired
GET /api/v1/events (SSE)Required
GET /api/v1/seriesRequiredOne request’s response time heatmap; see Request heatmap endpoint
GET /api/v1/reports and /api/v1/reports/*RequiredThe runs panel’s reports; see Report endpoints
POST /api/v1/control/*Required when control is enabled, unless --dashboard-no-authEven on loopback; 404 when control is off
DELETE /api/v1/reports/{name}Required when control is enabled, unless --dashboard-no-authControl 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-auth is set.
  • Control without a token → startup hard-fail, even on loopback, unless --dashboard-no-auth is set.
  • Token protects metric APIs when configured; control POSTs require the token when control is on, unless --dashboard-no-auth is set.
  • --dashboard-no-auth with --dashboard-auth-token → startup hard-fail. Under --dashboard-no-auth the control routes refuse a cross site request (403 cross_origin), and off loopback Goose warns at startup, the page shows a warning banner, and only an IP address, localhost or the --dashboard-host name is taken as Host (403 host_not_allowed).
  • Shell/static/health stay public and contain no load-test metrics.
  • Prefer Authorization: Bearer for 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 a Referer.
  • --dashboard-auth-token is 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 textContent only (no innerHTML) 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 check Origin and Content-Type instead.
  • Every response sends Cache-Control: no-store (the first page load carries ?token= and the APIs carry metrics) and X-Content-Type-Options: nosniff.
  • On a loopback bind, requests whose Host is 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 + 16 connections, 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

MethodPathBodyDescription
POST/api/v1/control/startempty, {}, or run settingsStart an idle load test → enters Increase; with run settings, apply them and start in one step, all or none
POST/api/v1/control/settingsrun settingsApply run settings without starting, all or none; see Run settings
POST/api/v1/control/stopempty 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/quitempty or {}Shut Goose down; accepted only in Idle
POST/api/v1/control/report{"save": B, "graph_data": B}, either or both, each B a booleanSet Save report and Include graph data; see Saving the report
POST/api/v1/control/report/nowempty 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}noneDelete 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:

errorWhenClient action
busyFour control requests are already waiting for the main loopWait and retry
unavailableThe main loop no longer takes control requests, for example because the load test is exitingDo not retry
timeoutThe main loop did not answer within 15 secondsRe-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 idle again
  • Users is refused until phase is idle again (the dashboard returns "ok": false with invalid_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 arrivesWhat happens
During the ramp upRamp 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 holdRamp 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 downThe ramp down is abandoned, and the run holds the new count until stopped
While holding until stoppedRamp 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 null clears it back to Goose’s default. Clearing host goes 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: host an http or https url with no user name or password; users 1 to 100000; the rates 0.01 to 100000 users per second; the times whole seconds from 1 to 30 days (0 is refused: send null to clear); test_plan in the --test-plan format, 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_plan switches to a test plan and clears users, the ramp settings and the run time; users switches back and clears the test plan. test_plan: null is accepted only with users. While a test plan is configured, a ramp setting without users is 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 users or a ramp setting, though clearing a ramp setting beside it with null is accepted.
  • users: null is refused. On a running test users is 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:

StatuserrorWhen
400bad_requestThe body is not JSON, not an object, or has a field Goose does not know
400invalid_valueA value of the wrong type or out of bounds, and users: null
400conflicting_settingsSettings that never go together, whatever the state
200invalid_phaseA setting the phase cannot change, set or cleared
200conflicting_settingsA 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)
200no_hosthost: null would leave a scenario, or a test_start or test_stop transaction, with no host
200prepare_failedA 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:

SettingIdleRunning, users rampRunning, test planRamping downStopping
hostyesnononono
users (/api/v1/control/users)yesyesnoyes, abandons the ramp down and holdsno
increase_rateyesyes, for the next Users increase; refused while the increase time is setnonono
decrease_rateyesyes, regenerates the ramp down ahead, or only sets the rate on a run holding until stopped; refused while the decrease time is setnonono
increase_time_secs, decrease_time_secsyesnononono
run_time_secsyesyesno, the plan sets how long it runsnono
test_planyesyes, replaces the rampyes, restarts from its first stepyes, replaces the ramp downno

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 changesWhat happens
During the ramp up, before the holdThe 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 holdThe 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 stoppedThe 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.

FieldMeaning
hostThe --host override, or null
hostsThe 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
usersThe configured user count, null only with a test plan. target_users is different: the count the current step ramps toward
increase_rate, decrease_rateUsers per second
increase_time_secs, decrease_time_secsThe ramp up or ramp down as a time
run_time_secsThe hold at full users after the ramp up, not the total run time
test_planThe 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_secsSeconds 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
stageWhere 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 }
}
FieldMeaning
okAlways true when the handler runs
versionGoose crate package version (CARGO_PKG_VERSION)
last_build_msTime the metrics processor spent on the last published snapshot, draining pending metrics and building it, in milliseconds (0 if none yet)
last_snapshot_msRound trip of the last published snapshot in milliseconds, from the dashboard’s request to the published JSON (0 if none yet)
snapshot_timeoutsSnapshot requests not answered within 4 seconds since process start; a late answer is dropped, never published
build_countSuccessful hub snapshot builds since process start
active_sse_clientsConcurrent SSE clients currently holding a slot
control_enabledWhether the POST /api/v1/control/* routes and Delete are registered (--dashboard-control)
auth_requiredWhether 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
loopbackWhether the dashboard is bound to a loopback address; with auth_required false and this false, the page shows the no token warning
controllersThe 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-file paths; see Saving the report
  • Runs panel: the reports in the folder that holds the run folders, with downloads and comparisons, and with --dashboard-control a 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:

  1. Phase badge still shows idle / increase / maintain / decrease / shutdown.
  2. Start is enabled only in idle.
  3. Stop is enabled in increase and maintain only.
  4. 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 s during the hold, holding until stopped, ramping down at 10/s, stopping, or for a test plan test plan: 3 steps, peak 100 users, 1 h 1 min total. Its wording comes from settings.stage. It is not a live region, so the countdown is not announced.
  5. 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 stopping is set, while a test plan runs, and without a token.
  6. 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 as null, 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 applied 120 s reads 2 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, with held 3 min 8 s of 10 min under 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/settings or /api/v1/control/start.
  7. 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 reads Refused: the request's Origin doesn't match this dashboard's address (another site, or a proxy), and one from the Host rule Refused: open the dashboard by its IP address or its --dashboard-host name.
  8. Quit shows only in idle, and shuts Goose down; the status line then reads Goose 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:

FieldMeaning
generationWhich 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.
revThe newest change the columns include.
sinceThe rev these columns follow on from, or 0 for every column of the window.
start_second, end_secondThe window, in seconds from the start of the test. Columns before start_second are dropped.
hold_fromSeconds 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.
columnsThe 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.

StatusBody
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
401Empty: 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-metrics sets flags.transaction_metrics_disabled: true; transactions is empty and the Transactions table says transaction metrics are disabled.
  • --no-scenario-metrics sets flags.scenario_metrics_disabled: true; scenarios is 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.

EndpointReturns
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_truncated when 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

NeedPrefer
Watch live charts and tablesLive Dashboard (--dashboard)
Start / Stop / set users, host, ramps and run time from a browserDashboard control (--dashboard-control + token, or --dashboard-no-auth)
Test plan from a scriptDashboard control (/api/v1/control/settings or /api/v1/control/start) or the Controllers
Full command set, config and metricsTelnet / WebSocket Controllers
Shut Goose down from IdleDashboard control (Quit) or the Controllers (shutdown)
Shut down a running testControllers (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:

FileRole
src/main.tsEntry point: renders each snapshot and wires the modules together
src/connection.tsAccess token handling, SSE stream, poll fallback, snapshot fetch and version check
src/control.tsControl panel, its POSTs, the phase badge and the run settings summary
src/settings-form.tsThe Edit settings form: Host, Ramp up, Ramp down and Run time, and their pending edits
src/run-settings.tsThe run settings summary line, worded from settings.stage
src/follow.tsFields that follow the server unless being edited (Target and the settings form)
src/charts.tsChart.js setup and the line charts: RPS and failures, active users, and average latency
src/chart-data.tsEach line chart’s one sentence summary and its data table
src/heatmap.tsThe response time heatmap: data shaping and drawing
src/heatmap-card.tsThe response time card: its request select, the Adjusted and Measured toggle, and the average latency chart when percentiles over time are off
src/coadjust.tsWhich response time to show: coordinated omission adjusted or measured
src/runs.tsThe report bar, Save report now, the report banners and the runs panel
src/tables.tsThe shared sortable table (sorting, filtering, empty states) and the scenarios, transactions, requests, errors and transaction errors tables
src/format.tsNumber and duration formatting, and the small DOM builders the summary and tables share
src/status.tsBanner and connection indicator
src/dom.tsElement lookup that checks each element’s type
snapshot.d.tsGenerated types of the snapshot JSON, from the Rust structs in src/metrics/dashboard_snapshot.rs
idle-snapshot.mjsA complete idle snapshot for the client tests, type checked against snapshot.d.ts by npm run check
tsconfig.json / package.jsonTypeScript type check and esbuild bundle config
*.test.mjsClient 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.mjsThe jsdom page loader and stub server the client tests share
app.jsBundled output (the modules plus Chart.js), embedded by the Rust server via include_str!
index.html, app.cssShell 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 next npm run build.
  • node_modules/ is gitignored; only package.json / package-lock.json are 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, rebuilds app.js with npm ci && npm run build (failing if the committed file differs from the esbuild bundle), and runs npm 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:

SettingAccepted
hostAn http:// or https:// url with no user name or password, which would show to observers and in reports
users1 to 100000
increaserate, decreaserate0.01 to 100000 users per second
increase-time, decrease-time, runtime1 second to 30 days; 0 clears the setting, so Goose’s default applies (the dashboard takes null to clear, and refuses 0)
test-planAt 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 &rarr; 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 &rarr; 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 &rarr; 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 &rarr; 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 &larr; 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 &larr; 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 &rarr; 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 &larr; 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 &rarr; 43 |
| Decreasing | 26-10-25 22:14:22 | 26-10-25 22:14:27 | 00:00:05 | 20 &larr; 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 &larr; 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-workers option, 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, or 0.0.0.0.
  • --manager-bind-port <manager-bind-port>: configures the port that the Manager listens on. By default Goose will listen on port 5115.
  • --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, or 127.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 port 5115.

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

  1. Recording Every Request: Goose records the response time of every request it makes, however long it takes, up to the request timeout
  2. No Sampling: every response time is counted; none is sampled away
  3. One Request at a Time per User: a GooseUser waits 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 Duration and 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:

ModeUse CaseBehavior
disabled (default)Custom analysis, external CO handlingNo adjustment, raw data only
averageGeneral performance testingExpected cadence is the user’s average time to run through its transactions
minimumMost sensitiveExpected cadence is the user’s fastest time to run through its transactions
maximumLeast sensitiveExpected 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-log for external analysis

Summary

Goose accounts for coordinated omission with:

  1. Every request timed: Goose times every request it makes
  2. Percentiles within 1%: no bucket is more than 1% off the times it holds
  3. Configuration: a request timeout and three mitigation modes
  4. 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 # reqs of 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, --iterations or 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 user lines 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_metrics against co_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-mitigationCO eventsSynthetic requestsMeasured p99Adjusted p99
disabled (the default)not countednone15 msnone
average4409,770 (8.1%)15 ms889 ms
minimum18,25838,288 (25.7%)15 ms938 ms
maximum00 (0.0%)15 msnone
  • average compares each iteration with the user’s average iteration; here it counted events after every stalled request but the last.
  • minimum compares 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. With minimum, judge a run by its events above Minor (Moderate, Severe and Critical), or run it again with average.
  • maximum compares 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.19 maximum counts 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

  1. Find Breaking Point: Gradually increase load until CO events spike
  2. Set Alerts: Monitor CO metrics in production to detect issues early
  3. Compare Environments: Use CO metrics to validate staging vs production performance
  4. Track Trends: Monitor CO metrics over time to detect performance regression

Best Practices Summary

  1. Choose the Right Mode:

    • minimum for the most sensitive detection
    • average for realistic user simulation
    • disabled for baseline measurements
  2. Set Appropriate Test Duration:

    • Short tests (5-10 min) for quick validation
    • Long tests (30+ min) for stability assessment
  3. Monitor Key Metrics:

    • Events per minute rate
    • Synthetic request percentage
    • Severity distribution
    • Trends over time
  4. 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.1 unless set: GooseDefault::TelnetHost
  • host to bind WebSocket Controller to, 127.0.0.1 unless 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-reports feature: GooseDefault::PdfTimeout

The following default can be configured with an f64 float:

  • PDF report scale, with the pdf-reports feature: 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 proxy
  • socks5h:// - 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 of socks5:// 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 clean and 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::Proxy for 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 own reqwest::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 separate system-proxy feature, 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 enables system-proxy
  • SOCKS proxy support requires the socks feature 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", &params).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.

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, a GooseDefault for the option, and Goose’s built in default. A setting the builder was not given keeps its GooseDefault or built in default.
  • The users that run the test_start and test_stop transactions, 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. Under set_client_builder_without_cookies that 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

The dashboard's Users tile at 1,024 of 1,024 and RPS tile at 109

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

The dashboard's Requests table: GET /, GET /about and GET /our-team, each with 0 fails

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

The dashboard's Requests table: GET / with 1,020 requests and POST /signup with 100, both with 0 fails

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

The dashboard's Requests table: GET /basic-auth/user/passwd with 1,498 requests, then GET /user-agent, GET /json and POST /post, all with 0 fails

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

The dashboard's Transactions table: Simple Query, User Query, Complex Query and Mutation, each run about 1,828,975 times

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

The dashboard's Requests table with one row: method TCP, name tcp_echo, with 0 fails

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

Open the Drupal Memcache page

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/

The dashboard's Scenarios table: Anonymous English user with 40 users, Anonymous Spanish user with 9 and Admin user with 1

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.

The dashboard's KPI row and its requests and failures per second chart: Users reads 1,024 of 1,024, RPS reads 108, and the Fail % tile, cut at the image's right edge, reads 0.00%; the chart rises to about 200 requests a second during the 16 second ramp, falls back, and holds near 100 with no failures

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", &params).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.

The dashboard's Requests table cut after its avg ms column, with three rows, GET /, GET /about and GET /our-team, at 770, 682 and 619 requests, all with 0 fails and average times from 0.4 to 0.6 ms

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 table cut after its # Fails column: GET / with 1,285 requests and POST /signup with 100, both with 0 fails, and 1,385 in all

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", &params).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 table cut after its # Fails column: GET /basic-auth/user/passwd with 1,854 requests, GET /json 607, GET /user-agent 621 and POST /post 612, all with 0 fails, and 3,694 in all

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 HTML report's Transaction Metrics table cut after its # Fails column, grouped under CustomClientAuth with rows 0.0 to 0.3, HelperFunctionAuth with 1.0 and 1.1, and ManualPerRequestAuth with 2.0 and 2.1; row 0.0 ran 30 times, the rest between 301 and 629 times, all with 0 fails, and 3,724 in all

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:

  1. 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
  2. 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:

ScenarioWeightTransactionsRequests
CustomClientAuth3setup_basic_auth_client (on start), test_basic_auth_endpoint, test_user_agent_endpoint, test_json_endpointGET /basic-auth/user/passwd, GET /user-agent, GET /json
HelperFunctionAuth2test_with_helper_function, test_post_with_helperGET /basic-auth/user/passwd, POST /post
ManualPerRequestAuth1test_manual_basic_auth, test_manual_with_validationGET /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.

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_assets in 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

  1. Use the custom client approach for most sites: it sends credentials with every request the user makes, CSS, JS and image requests included
  2. Set up authentication once per user, in an on start transaction, rather than in every request
  3. Use environment variables for credentials to keep them out of your code
  4. Try your approach against httpbin.org before pointing it at your own site

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.

The dashboard's Transactions table cut after its avg ms column: Simple Query, User Query, Complex Query and Mutation in the GraphQL Load Test scenario, each run about 1,809,875 times at about 18,099 a second, with 0 fails The dashboard's Requests table cut after its avg ms column: POST /graphql, POST get user by id, POST complex user posts query and POST create post, each about 1,809,936 requests at about 18,099 a second, with 0 fails

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 example POST /graphql
  • post_graphql_named(): the same, with a name for the request in the metrics, for example POST 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:

TransactionSendsRequest name in the metrics
Simple Querythe query { __typename }, with post_graphql()POST /graphql
User Querya GetUser query with the variable idPOST get user by id
Complex Querya GetUserWithPosts query for a user’s posts and their comments, with the variables userId and firstPOST complex user posts query
Mutationa CreatePost mutation with an input variablePOST 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.

The left part of the dashboard's Response times by second card with TCP tcp_echo picked: the first 77 seconds of round trips, most between 0.2 and 0.5 ms The dashboard's Requests table cut after its avg ms column, with one row: method TCP, name tcp_echo, 3,271 requests, 0 fails, 33.04 a second, 0.31 ms on average

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 with method to 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 as 0 ms; to keep its microseconds, as Goose does for HTTP requests, call record_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: None when 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, None is stored as 0, so status code tables show [0] for these requests unless --no-status-codes turns those tables off.
  • error: An optional error description for failed operations. When success is false and error is None, 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", &params).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, &params).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", &params).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, &params).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.

The dashboard's Scenarios table with Anonymous English user at 40 users, Anonymous Spanish user at 9 and Admin user at 1, cut after its runs/s column, above the Transactions table's first six rows, anon / through anon /en/recipes/%, all for the English user, with 0 fails, cut after its #fails column

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 &amp; in the aggregated asset links it parses, so the URLs it requests still hold &amp; 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.180.19
--hatch-rate (-r)--increase-rate (-r), see launching users
--startup-time (-s)--increase-time (-s), see launching users
GooseDefault::HatchRateGooseDefault::IncreaseRate, see Defaults
GooseDefault::StartupTimeGooseDefault::IncreaseTime, see Defaults
GooseConfiguration fields hatch_rate and startup_timeincrease_rate and increase_time, see Custom options
goose::util::get_hatch_rate()goose::util::parse_rate()
Controller commands hatchrate and startuptimeincreaserate and increasetime, see Controller commands
Crate feature gaggleNone: 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. Remove use gumdrop::Options;: GooseConfiguration::parse_args_default() is now a method of its own, and GooseConfiguration implements clap::Parser. See Custom options.
  • API change: every GooseConfiguration field for an option that takes a single value, such as host, run_time, increase_time or iterations, is an Option, None when not given; set one with Some, as in configuration.run_time = Some("30s".to_string()). The help field 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() returns GooseError::InvalidOption for -h and --help, with the flag as option and the detail help was requested, where 0.18 returned the configuration with its help field set to true. See Building the configuration yourself.
  • API change: TransactionError::RequestFailed { raw_request } holds a Box<GooseRequestMetric>, TransactionError::LoggerFailed { source } a boxed SendError, and the new TransactionError::Custom(String) lets a transaction return Err("reason".into()). See Transaction error types.
  • API change: a match without a wildcard arm needs new arms for TransactionError::Custom (see Transaction error types), GooseError::ThresholdsFailed (see The end of the run), GooseMethod::Custom (see The API) and the new GooseDefault variants (see Defaults).
  • API change: scenario and transaction names are Arc<str> in place of String: Scenario.name, Scenario.machine_name, the name of ScenarioMetric, TransactionMetric and ScenarioMetricAggregate, TransactionMetricAggregate.scenario_name, GooseRequestMetric.name and GooseRequestMetric.scenario_name, and the scenario_name argument of GooseUser::new(). Reading one as &str works unchanged. See the API documentation.
  • API change: Transaction.name and the transaction_name of GooseRequestMetric and TransactionMetricAggregate are a goose::goose::TransactionName in place of a String: TransactionOnly(Arc<str>) for a name set with set_name(), or InheritNameByRequests(Arc<str>) for one set with set_name_for_transaction_and_requests(). Read the name with name_for_transaction(). GooseRequestMetric.transaction_index is a usize in place of a String. See Naming Transactions and the API documentation.
  • API change: times are kept in microseconds. GooseRequestMetricTimingData has times_us, minimum_time_us, maximum_time_us and total_time_us in place of the millisecond times, minimum_time, maximum_time and total_time; TransactionMetricAggregate and ScenarioMetricAggregate keep their times in run_times; a struct literal of GooseRequestMetric, TransactionMetric or ScenarioMetric must set the new response_time_fraction_us or run_time_fraction_us. See Response times and the API documentation of GooseRequestMetricTimingData, TransactionMetricAggregate and ScenarioMetricAggregate.
  • 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, so default-features = false turns off cookie support, where in 0.18 it turned off TLS, and the rustls-tls feature no longer changes the TLS that load test requests use.
  • API change: controller::ControllerCommand carries each command’s parsed value, and util::parse_timespan() returns Option<usize>. See Controller commands and the API documentation of ControllerCommand and parse_timespan().
  • API change: TestPlan::total_users() stops at usize::MAX in place of overflowing, and GooseError::InvalidControllerCommand is no longer returned, though the variant is kept. See the API documentation of TestPlan.
  • API change: TransactionDetail has no lifetime parameter: its scenario_name is an Arc<str>, its transaction_index an Option<usize> and its transaction_name a TransactionName, where 0.18 held &strs. GooseRequestMetric.transaction_index is usize::MAX for a request made outside a transaction. See the API documentation of TransactionDetail.
  • API change: a transaction error is recorded. GooseMetrics has the new public field transaction_errors, a GooseTransactionErrorMetrics of GooseTransactionErrorMetricAggregates, and GooseTransactionErrorMetric is an error log entry. GooseMetric has the new variants All, Batch and TransactionError, and GooseLog the new variants TransactionError and FailedTransaction, so a match on either without a wildcard arm needs new arms. See Error log.
  • API change: GooseMetrics has the new public field thresholds, each rule’s ThresholdResult, and the new goose::threshold module holds Threshold and ThresholdResult. See Thresholds.
  • API change: GooseConfiguration has new public fields: threshold, no_threshold_abort, threshold_abort_delay, output_dir, compress, notes, report_graph_data, render_report and websocket, among others, and, with the pdf-reports feature, pdf_scale and pdf_timeout, an Option<f64> and an Option<u64> set in code with GooseDefault::PdfScale and GooseDefault::PdfTimeout. See Custom options and Output files.
  • API change: config::RUN_FOLDER and reset_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. Use set_name_for_transaction_and_requests() for the 0.18 behavior. See Naming transactions.
  • behavior change: --scenarios matches 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-certs skips 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.log reads as --goose-log and -r un.log, and fails 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 =. A negative number is still read as a value: --timeout -5 reaches the timeout check and is refused there, and --users -1 is refused with a tip that --users takes no negative number. An output option followed by another option, as in --goose-log --quiet, is given without a path and writes goose.log into the run’s folder. See Output files.
  • behavior change: a report or log file whose name ends in .gz is written gzipped. See Compression.
  • behavior change: --report-file reports are written even with --no-print-metrics. See Output files.
  • behavior change: -h prints a short summary and --help the full reference, both to stdout. See Run-time options.
  • behavior change: --run-time 0, --increase-time 0, --decrease-time 0 and --throttle-requests 0 undo the matching GooseDefault, and an empty log path, as in --request-log "", turns off a log a GooseDefault sets; GooseDefault::Iterations and GooseDefault::NoGranularData now 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 --timeout that is not a finite number, and a --timeout that rounds to less than 1 ms, including a negative one. A fractional --timeout is rounded to the nearest millisecond, so 0.0005 runs 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 as h, m and s, as in 1h30m or 45s, in either case. 0.18 read 15mins, 5hours and 2h3min by their leading part, as 15 minutes, 5 hours and 2 hours 3 minutes; 0.19 refuses them, so write 15m, 5h and 2h3m. 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_us in place of times, its averages are means of the microseconds, it includes the test plan history, and each transaction’s transaction_name is 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_us column, the scenario log with run_time_us, and the transaction log with run_time_us and then error; the JSON, raw and pretty formats add response_time_fraction_us or run_time_fraction_us. In JSON logs the keys follow the order of the fields instead of being sorted, transaction_name is 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’s transaction_name column is empty unless the name was set with set_name_for_transaction_and_requests(); and the request, debug and error logs write transaction_index as a number, such as 0, where 0.18 wrote the string "0". See Logging and Request log.
  • behavior change: elapsed in 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.started is 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 through goose.request.raw.headers or a TransactionError::RequestFailed, is empty unless the request log, debug log or error log is enabled. See Request log.
  • behavior change: Controller users takes 1 to 100000, and on a running load test keeps its run time and ramp down; runtime changes a running users ramp; a value that can’t be used is answered with what it must be; config-json (and the WebSocket Controller’s config) shows an unset option as null and has no help key, and telnet config shows None for an unset option and Some(...) around a set one. See Controller commands.
  • behavior change: the telnet Controller’s metrics-json and the WebSocket Controller’s metrics replies hold times_us in place of times and write each transaction’s transaction_name as 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.5 started 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.5 starts 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::Random shuffles 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, and on_start and on_stop transactions 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 error field, empty for a transaction that succeeded. The error log gets an entry for each transaction error, and its CSV header gains the columns scenario_index, scenario_name, transaction_index, transaction_name and run_time after error; 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-summary leaves out. See Transaction log and Error log.
  • behavior change: the JSON report keeps each request’s times by status code in status_code_timings, under raw_metrics.requests, and holds transaction errors in transaction_errors. Its raw_request_metrics rows are each request and the Aggregated row. See JSON report.
  • behavior change: with --co-mitigation, the Aggregated # reqs is 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-metrics keeps 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 (null in the JSON report). See Metrics.
  • behavior change: --scenarios accepts _, so name_1 selects a second scenario registered as name, 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-autostart is 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 it Transaction. See Markdown report.
  • behavior change: --telnet-port 0 and --websocket-port 0 are 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 --websocket or GooseDefault::WebSocket; --no-websocket and GooseDefault::NoWebSocket still run, with a warning, and are refused with --websocket. --no-autostart --no-telnet now needs --websocket or --dashboard-control. See WebSocket Controller.
  • behavior change: the telnet and WebSocket Controllers listen on 127.0.0.1 by default, where 0.18 listened on 0.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.0 or --websocket-host 0.0.0.0 (or GooseDefault::TelnetHost or GooseDefault::WebSocketHost), or an ssh tunnel to 127.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 config and config-json show a dashboard auth token that is set as (set), never the token, as does {:?} of a GooseConfiguration. See Controller commands.