Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
o introduces `TransactionName` enum with `TransactionOnly` and `InheritNameByRequests` variants
- [#578](https://github.com/tag1consulting/goose/pull/578) add type-safe client builder for cookie configuration, optimize startup with shared clients
- [#629](https://github.com/tag1consulting/goose/pull/629) add `--pdf-print-html` option to generate printer-friendly HTML optimized for PDF conversion; provides two-step PDF workflow without requiring Chromium dependencies; add `--pdf-timeout` option for configurable Chrome timeout in direct PDF generation (10-300s, default: 60)
- [#663](https://github.com/tag1consulting/goose/pull/663) add response time breakdowns grouped by HTTP status code in CLI and HTML reports

## 0.18.1 August 14, 2025
- [#634](https://github.com/tag1consulting/goose/pull/634) add killswitch mechanism for programmatic test termination
Expand Down
18 changes: 18 additions & 0 deletions src/docs/goose-book/src/getting-started/metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,14 @@ All 9 users hatched.
-------------------------+-------------+------------+-------------+-----------
Aggregated | 11.41 | 2 | 304 | 5
------------------------------------------------------------------------------
Name | Avg (ms) | Min | Max | Median
------------------------------------------------------------------------------
GET static asset | 5.11 | 2 | 38 | 5
└─ 200 (94.2%) | 4.98 | 2 | 35 | 5
└─ 404 (5.8%) | 7.24 | 5 | 38 | 7
-------------------------+-------------+------------+-------------+-----------
Aggregated | 11.41 | 2 | 304 | 5
------------------------------------------------------------------------------
Slowest page load within specified percentile of requests (in ms):
------------------------------------------------------------------------------
Name | 50% | 75% | 98% | 99% | 99.9% | 99.99%
Expand Down Expand Up @@ -338,6 +346,16 @@ In the following graph, it's apparent that POST requests had the slowest respons
Below the graph is a table that shows per-request details:
![Response time metrics](metrics-response-time.jpg)

**Status Code Response Time Breakdowns:** When a request returns multiple different HTTP status codes during a load test, Goose automatically provides response time breakdowns grouped by status code. In the CLI output above, you can see this feature demonstrated with the `GET static asset` request, which received both `200` (successful) and `404` (not found) responses:

```
GET static asset | 5.11 | 2 | 38 | 5
└─ 200 (94.2%) | 4.98 | 2 | 35 | 5
└─ 404 (5.8%) | 7.24 | 5 | 38 | 7
```

This breakdown reveals important performance insights - in this example, the `404` responses took longer on average (7.24ms) than successful `200` responses (4.98ms), helping identify performance patterns related to error handling. The percentages show the distribution of each status code, and the tree-like formatting with `└─` clearly indicates these are sub-metrics of the main request. This feature helps identify performance differences between successful and failed requests, enabling more targeted optimization efforts.

#### Status codes
All status codes returned by the server are displayed in a table, per-request and in aggregate. In our simple test, we received only `200 OK` responses.
![Status code metrics](metrics-status-codes.jpg)
Expand Down
73 changes: 73 additions & 0 deletions src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,8 @@ pub struct GooseRequestMetricAggregate {
pub coordinated_omission_data: Option<GooseRequestMetricTimingData>,
/// Per-status-code counters, tracking how often each response code was returned for this request.
pub status_code_counts: HashMap<u16, usize>,
/// Per-status-code response time data, tracking response times grouped by status code.
pub status_code_response_times: HashMap<u16, GooseRequestMetricTimingData>,
/// Total number of times this path-method request resulted in a successful (2xx) status code.
///
/// A count of how many requests resulted in a 2xx status code.
Expand All @@ -485,6 +487,7 @@ impl GooseRequestMetricAggregate {
raw_data: GooseRequestMetricTimingData::new(None),
coordinated_omission_data: None,
status_code_counts: HashMap::new(),
status_code_response_times: HashMap::new(),
success_count: 0,
fail_count: 0,
load_test_hash,
Expand Down Expand Up @@ -529,6 +532,26 @@ impl GooseRequestMetricAggregate {
self.status_code_counts.insert(status_code, counter);
debug!("[metrics]: incremented {status_code} counter: {counter}");
}

/// Record response time grouped by status code.
pub(crate) fn record_status_code_response_time(
&mut self,
status_code: u16,
time_elapsed: u64,
coordinated_omission_mitigation: bool,
) {
// Get or create the timing data for this status code
let timing_data = self
.status_code_response_times
.entry(status_code)
.or_insert_with(|| GooseRequestMetricTimingData::new(None));

// Only add time_elapsed to raw data if the time wasn't generated by Coordinated
// Omission Mitigation.
if !coordinated_omission_mitigation {
timing_data.record_time(time_elapsed);
}
}
}

/// Implement equality for GooseRequestMetricAggregate. We can't simply derive since
Expand Down Expand Up @@ -1845,6 +1868,50 @@ impl GooseMetrics {
)),
raw_avg_precision = raw_average_precision,
)?;

// Display status code breakdown only if multiple status codes exist for this request
if request.status_code_response_times.len() > 1 {
// Sort status codes for consistent display order
let mut status_codes: Vec<_> = request.status_code_response_times.keys().collect();
status_codes.sort();

for &status_code in status_codes {
if let Some(timing_data) = request.status_code_response_times.get(&status_code)
{
// Calculate percentage of requests with this status code
let status_count =
request.status_code_counts.get(&status_code).unwrap_or(&0);
let total_count = request.success_count + request.fail_count;
let percentage = if total_count > 0 {
(*status_count as f32 / total_count as f32) * 100.0
} else {
0.0
};

let status_average = match timing_data.counter {
0 => 0.0,
_ => timing_data.total_time as f32 / timing_data.counter as f32,
};
let status_average_precision = determine_precision(status_average);

writeln!(
fmt,
" {:<24} | {:>11.status_avg_precision$} | {:>10} | {:>11} | {:>10}",
format!(" └─ {} ({:.1}%)", status_code, percentage),
status_average,
format_number(timing_data.minimum_time),
format_number(timing_data.maximum_time),
format_number(util::median(
&timing_data.times,
timing_data.counter,
timing_data.minimum_time,
timing_data.maximum_time,
)),
status_avg_precision = status_average_precision,
)?;
}
}
}
}

let raw_average = match aggregate_raw_counter {
Expand Down Expand Up @@ -2836,6 +2903,12 @@ impl GooseAttack {
);
if !self.configuration.no_status_codes {
merge_request.set_status_code(request_metric.status_code);
// Also track response time per status code
merge_request.record_status_code_response_time(
request_metric.status_code,
request_metric.response_time,
request_metric.coordinated_omission_elapsed > 0,
);
}
if request_metric.success {
merge_request.success_count += 1;
Expand Down
32 changes: 32 additions & 0 deletions src/metrics/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,38 @@ pub fn prepare_data(options: ReportOptions, metrics: &GooseMetrics) -> ReportDat
request.raw_data.maximum_time,
));

// Add status code response time breakdowns if multiple status codes exist
if request.status_code_response_times.len() > 1 {
let mut status_codes: Vec<_> = request.status_code_response_times.keys().collect();
status_codes.sort();

for &status_code in status_codes {
if let Some(timing_data) = request.status_code_response_times.get(&status_code) {
// Calculate percentage of requests with this status code
let status_count = request
.status_code_counts
.get(&status_code)
.copied()
.unwrap_or(0);
let total_count = request.success_count + request.fail_count;
let percentage = if total_count > 0 {
(status_count as f32 / total_count as f32) * 100.0
} else {
0.0
};

raw_response_metrics.push(report::get_response_metric(
&format!("└─ {} ({:.1}%)", status_code, percentage),
&name,
&timing_data.times,
timing_data.counter,
timing_data.minimum_time,
timing_data.maximum_time,
));
}
}
}

// Collect aggregated request and response metrics.
raw_aggregate_total_count += total_request_count;
raw_aggregate_fail_count += request.fail_count;
Expand Down
56 changes: 43 additions & 13 deletions src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub(crate) struct ResponseMetric {
pub percentile_95: usize,
pub percentile_99: usize,
pub percentile_100: usize,
pub is_breakdown: bool,
}

/// Defines the metrics reported about transactions.
Expand Down Expand Up @@ -140,6 +141,7 @@ pub(crate) fn get_response_metric(
percentile_95: percentiles[5],
percentile_99: percentiles[6],
percentile_100: percentiles[7],
is_breakdown: method.starts_with("└─"),
}
}

Expand Down Expand Up @@ -171,8 +173,35 @@ pub(crate) fn raw_request_metrics_row(metric: RequestMetric) -> String {

/// Build an individual row of response metrics in the html report.
pub(crate) fn response_metrics_row(metric: ResponseMetric) -> String {
format!(
r#"<tr>
// Check if this is a status code breakdown
if metric.is_breakdown {
// Status code breakdown row - merge first two columns and use increased indentation
format!(
r#"<tr style="background-color: #f8f9fa;">
<td colspan="2" style="padding-left: 50px; font-style: italic; text-align: left;">{method}</td>
<td>{percentile_50}</td>
<td>{percentile_60}</td>
<td>{percentile_70}</td>
<td>{percentile_80}</td>
<td>{percentile_90}</td>
<td>{percentile_95}</td>
<td>{percentile_99}</td>
<td>{percentile_100}</td>
</tr>"#,
method = metric.method,
percentile_50 = format_number(metric.percentile_50),
percentile_60 = format_number(metric.percentile_60),
percentile_70 = format_number(metric.percentile_70),
percentile_80 = format_number(metric.percentile_80),
percentile_90 = format_number(metric.percentile_90),
percentile_95 = format_number(metric.percentile_95),
percentile_99 = format_number(metric.percentile_99),
percentile_100 = format_number(metric.percentile_100),
)
} else {
// Regular response metrics row
format!(
r#"<tr>
<td>{method}</td>
<td>{name}</td>
<td>{percentile_50}</td>
Expand All @@ -184,17 +213,18 @@ pub(crate) fn response_metrics_row(metric: ResponseMetric) -> String {
<td>{percentile_99}</td>
<td>{percentile_100}</td>
</tr>"#,
method = metric.method,
name = metric.name,
percentile_50 = format_number(metric.percentile_50),
percentile_60 = format_number(metric.percentile_60),
percentile_70 = format_number(metric.percentile_70),
percentile_80 = format_number(metric.percentile_80),
percentile_90 = format_number(metric.percentile_90),
percentile_95 = format_number(metric.percentile_95),
percentile_99 = format_number(metric.percentile_99),
percentile_100 = format_number(metric.percentile_100),
)
method = metric.method,
name = metric.name,
percentile_50 = format_number(metric.percentile_50),
percentile_60 = format_number(metric.percentile_60),
percentile_70 = format_number(metric.percentile_70),
percentile_80 = format_number(metric.percentile_80),
percentile_90 = format_number(metric.percentile_90),
percentile_95 = format_number(metric.percentile_95),
percentile_99 = format_number(metric.percentile_99),
percentile_100 = format_number(metric.percentile_100),
)
}
}

/// If Coordinated Omission Mitigation is triggered, add a relevant request table to the
Expand Down
1 change: 1 addition & 0 deletions src/report/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ impl<W: Write> Markdown<'_, '_, W> {
percentile_95,
percentile_99,
percentile_100,
is_breakdown: _,
} in &self.data.raw_response_metrics
{
writeln!(
Expand Down
Loading
Loading