Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
63 changes: 50 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 class="status-breakdown">
<td colspan="2">{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 Expand Up @@ -623,6 +653,13 @@ pub(crate) fn build_report(
color: #00ca5a;
}}

.status-breakdown {{
background-color: #f8f9fa;
padding-left: 50px;
font-style: italic;
text-align: left;
}}

.graph {{
margin-bottom: 1em;
}}
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
5 changes: 5 additions & 0 deletions src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,11 @@ pub fn median(
min: usize,
max: usize,
) -> usize {
// Guard against empty data structures
if total_elements == 0 || btree.is_empty() {
return 0;
}

let mut total_count: usize = 0;
let half_elements: usize = (total_elements as f64 / 2.0).round() as usize;
for (value, counter) in btree {
Expand Down
Loading
Loading