use super::{
delta::*, iterations_per_user, per_second_calculations, prepare_status_codes, CoMetricsSummary,
GooseMetrics, GooseRequestMetricAggregate, GooseRequestMetricTimingData, Millis, MillisFloat,
NullableFloat,
};
use crate::{
report::{
get_response_metric, CORequestMetric, ErrorMetric, RequestMetric, ResponseMetric,
ScenarioMetric, StatusCodeMetric, TransactionErrorMetric, TransactionMetric,
},
util, GooseError,
};
use itertools::Itertools;
use serde::{Deserialize, Deserializer};
use std::borrow::Cow;
use std::collections::HashMap;
use std::fs::File;
use std::hash::Hash;
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};
fn deserialize_nullable_f64<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<f64> = Option::deserialize(deserializer)?;
Ok(opt.unwrap_or(f64::NAN))
}
fn serialize_without_breakdowns<S>(rows: &[RequestMetric], serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.collect_seq(rows.iter().filter(|row| !row.is_breakdown))
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct ReportData<'m> {
pub raw_metrics: Cow<'m, GooseMetrics>,
#[serde(serialize_with = "serialize_without_breakdowns")]
pub raw_request_metrics: Vec<RequestMetric>,
pub raw_response_metrics: Vec<ResponseMetric>,
pub co_request_metrics: Option<Vec<CORequestMetric>>,
pub co_response_metrics: Option<Vec<ResponseMetric>>,
pub scenario_metrics: Option<Vec<ScenarioMetric>>,
pub transaction_metrics: Option<Vec<TransactionMetric>>,
pub status_code_metrics: Option<Vec<StatusCodeMetric>>,
pub errors: Option<Vec<ErrorMetric>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transaction_errors: Option<Vec<TransactionErrorMetric>>,
pub coordinated_omission_metrics: Option<CoMetricsSummary>,
}
pub struct ReportOptions {
pub no_transaction_metrics: bool,
pub no_scenario_metrics: bool,
pub no_status_codes: bool,
}
pub fn prepare_data_with_baseline<'a>(
options: ReportOptions,
metrics: &'a GooseMetrics,
baseline: Option<&ReportData>,
) -> ReportData<'a> {
let prepare = Prepare::new(options, metrics);
let mut result = prepare.build();
if let Some(baseline) = baseline {
apply_baseline_deltas(&mut result, baseline);
}
result
}
#[derive(Default)]
pub(crate) struct RequestTotals {
pub(crate) times: GooseRequestMetricTimingData,
pub(crate) requests: usize,
pub(crate) failures: usize,
}
impl RequestTotals {
pub(crate) fn add(&mut self, request: &GooseRequestMetricAggregate) {
self.requests += request.success_count + request.fail_count;
self.failures += request.fail_count;
self.times.merge(&request.raw_data);
}
pub(crate) fn of<'a>(
requests: impl IntoIterator<Item = &'a GooseRequestMetricAggregate>,
) -> Self {
let mut totals = RequestTotals::default();
for request in requests {
totals.add(request);
}
totals
}
}
struct RawIntermediate {
raw_aggregate: GooseRequestMetricTimingData,
}
struct Prepare<'m> {
options: ReportOptions,
metrics: &'m GooseMetrics,
co_data: bool,
}
impl<'m> Prepare<'m> {
fn new(options: ReportOptions, metrics: &'m GooseMetrics) -> Self {
Self {
options,
metrics,
co_data: false,
}
}
fn build(mut self) -> ReportData<'m> {
self.co_data = self
.metrics
.requests
.values()
.any(|request| request.coordinated_omission_data.is_some());
let (raw_request_metrics, raw_response_metrics, intermediate) = self.build_raw();
let (co_request_metrics, co_response_metrics) = self.build_co(&intermediate);
ReportData {
raw_metrics: Cow::Borrowed(self.metrics),
raw_request_metrics,
raw_response_metrics,
co_request_metrics,
co_response_metrics,
scenario_metrics: self.build_scenario(),
transaction_metrics: self.build_transaction(),
status_code_metrics: self.build_status_code(),
errors: self.build_errors(),
transaction_errors: self.build_transaction_errors(),
coordinated_omission_metrics: self
.metrics
.coordinated_omission_metrics
.as_ref()
.map(|co| co.get_summary()),
}
}
fn build_raw(&self) -> (Vec<RequestMetric>, Vec<ResponseMetric>, RawIntermediate) {
let mut raw_request_metrics = vec![];
let mut raw_response_metrics = vec![];
let mut totals = RequestTotals::default();
for (request_key, request) in self.metrics.requests.iter().sorted() {
let method = request.method_label();
let name = request_key
.strip_prefix(&format!("{} ", method))
.unwrap()
.to_string();
let total_request_count = request.success_count + request.fail_count;
let (requests_per_second, failures_per_second) = per_second_calculations(
self.metrics.duration,
total_request_count,
request.fail_count,
);
raw_request_metrics.push(RequestMetric {
method: method.to_string(),
name: name.to_string(),
number_of_requests: total_request_count.into(),
number_of_failures: request.fail_count.into(),
response_time_average: request.raw_data.average_ms_or_zero().into(),
response_time_minimum: Millis(request.raw_data.minimum_time_us).into(),
response_time_maximum: Millis(request.raw_data.maximum_time_us).into(),
requests_per_second: requests_per_second.into(),
failures_per_second: failures_per_second.into(),
is_breakdown: false,
});
if let Some(breakdowns) = request.status_code_breakdowns() {
for b in breakdowns {
raw_request_metrics.push(RequestMetric {
method: format!("{} ({:.1}%)", b.status_code, b.percentage),
name: String::new(),
number_of_requests: b.count.into(),
number_of_failures: 0usize.into(),
response_time_average: b.average.into(),
response_time_minimum: Millis(b.min_time_us).into(),
response_time_maximum: Millis(b.max_time_us).into(),
requests_per_second: 0.0f32.into(),
failures_per_second: 0.0f32.into(),
is_breakdown: true,
});
}
}
raw_response_metrics.push(get_response_metric(method, &name, &request.raw_data));
totals.add(request);
}
let RequestTotals {
times: raw_aggregate,
requests: raw_aggregate_total_count,
failures: raw_aggregate_fail_count,
} = totals;
let (raw_aggregate_requests_per_second, raw_aggregate_failures_per_second) =
per_second_calculations(
self.metrics.duration,
raw_aggregate_total_count,
raw_aggregate_fail_count,
);
raw_request_metrics.push(RequestMetric {
method: "".to_string(),
name: "Aggregated".to_string(),
number_of_requests: raw_aggregate_total_count.into(),
number_of_failures: raw_aggregate_fail_count.into(),
response_time_average: raw_aggregate.average_ms_or_zero().into(),
response_time_minimum: Millis(raw_aggregate.minimum_time_us).into(),
response_time_maximum: Millis(raw_aggregate.maximum_time_us).into(),
requests_per_second: raw_aggregate_requests_per_second.into(),
failures_per_second: raw_aggregate_failures_per_second.into(),
is_breakdown: false,
});
raw_response_metrics.push(get_response_metric("", "Aggregated", &raw_aggregate));
(
raw_request_metrics,
raw_response_metrics,
RawIntermediate { raw_aggregate },
)
}
fn build_co(
&self,
intermediate: &RawIntermediate,
) -> (Option<Vec<CORequestMetric>>, Option<Vec<ResponseMetric>>) {
if !self.co_data {
return (None, None);
}
let mut co_request_metrics = vec![];
let mut co_response_metrics = vec![];
let mut co_aggregate = GooseRequestMetricTimingData::new(None);
for (request_key, request) in self.metrics.requests.iter().sorted() {
co_aggregate.merge(
request
.coordinated_omission_data
.as_ref()
.unwrap_or(&request.raw_data),
);
if let Some(coordinated_omission_data) = request.coordinated_omission_data.as_ref() {
let method = request.method_label();
let name = request_key
.strip_prefix(&format!("{} ", method))
.unwrap()
.to_string();
let raw_average = request.raw_data.average_ms_or_zero();
let co_average = coordinated_omission_data.average_ms_or_zero();
co_request_metrics.push(CORequestMetric {
method: method.to_string(),
name: name.to_string(),
response_time_average: co_average.into(),
response_time_standard_deviation: util::standard_deviation(
raw_average as f32,
co_average as f32,
)
.into(),
response_time_maximum: Millis(coordinated_omission_data.maximum_time_us).into(),
});
co_response_metrics.push(get_response_metric(
method,
&name,
coordinated_omission_data,
));
}
}
let co_average = co_aggregate.average_ms_or_zero();
let raw_average = intermediate.raw_aggregate.average_ms_or_zero();
co_request_metrics.push(CORequestMetric {
method: "".to_string(),
name: "Aggregated".to_string(),
response_time_average: co_average.into(),
response_time_standard_deviation: util::standard_deviation(
raw_average as f32,
co_average as f32,
)
.into(),
response_time_maximum: Millis(co_aggregate.maximum_time_us).into(),
});
co_response_metrics.push(get_response_metric("", "Aggregated", &co_aggregate));
(Some(co_request_metrics), Some(co_response_metrics))
}
fn build_transaction(&self) -> Option<Vec<TransactionMetric>> {
if self.options.no_transaction_metrics {
return None;
}
let mut transaction_metrics = Vec::new();
let mut aggregate_total_count = 0;
let mut aggregate_fail_count = 0;
let mut aggregate_times = GooseRequestMetricTimingData::default();
for (scenario_counter, scenario) in self.metrics.transactions.iter().enumerate() {
for (transaction_counter, transaction) in scenario.iter().enumerate() {
if transaction_counter == 0 {
transaction_metrics.push(TransactionMetric {
is_scenario: true,
transaction: "".to_string(),
name: transaction.scenario_name.to_string(),
number_of_requests: 0usize.into(),
number_of_failures: 0usize.into(),
response_time_average: None,
response_time_minimum: 0usize.into(),
response_time_maximum: 0usize.into(),
requests_per_second: None,
failures_per_second: None,
});
}
let total_run_count = transaction.success_count + transaction.fail_count;
let (requests_per_second, failures_per_second) = per_second_calculations(
self.metrics.duration,
total_run_count,
transaction.fail_count,
);
let times = &transaction.run_times;
transaction_metrics.push(TransactionMetric {
is_scenario: false,
transaction: format!("{scenario_counter}.{transaction_counter}"),
name: transaction
.transaction_name
.name_for_transaction()
.to_string(),
number_of_requests: total_run_count.into(),
number_of_failures: transaction.fail_count.into(),
response_time_average: Some(times.average_ms_or_zero().into()),
response_time_minimum: Millis(times.minimum_time_us).into(),
response_time_maximum: Millis(times.maximum_time_us).into(),
requests_per_second: Some(requests_per_second.into()),
failures_per_second: Some(failures_per_second.into()),
});
aggregate_total_count += total_run_count;
aggregate_fail_count += transaction.fail_count;
aggregate_times.merge(times);
}
}
let (aggregate_requests_per_second, aggregate_failures_per_second) =
per_second_calculations(
self.metrics.duration,
aggregate_total_count,
aggregate_fail_count,
);
transaction_metrics.push(TransactionMetric {
is_scenario: false,
transaction: "".to_string(),
name: "Aggregated".to_string(),
number_of_requests: aggregate_total_count.into(),
number_of_failures: aggregate_fail_count.into(),
response_time_average: Some(aggregate_times.average_ms_or_zero().into()),
response_time_minimum: Millis(aggregate_times.minimum_time_us).into(),
response_time_maximum: Millis(aggregate_times.maximum_time_us).into(),
requests_per_second: Some(aggregate_requests_per_second.into()),
failures_per_second: Some(aggregate_failures_per_second.into()),
});
Some(transaction_metrics)
}
fn build_scenario(&self) -> Option<Vec<ScenarioMetric>> {
if self.options.no_scenario_metrics {
return None;
}
let mut scenario_metrics = Vec::new();
let mut aggregate_users = 0;
let mut aggregate_times = GooseRequestMetricTimingData::default();
for scenario in &self.metrics.scenarios {
let times = &scenario.run_times;
let (count_per_second, _failures_per_second) =
per_second_calculations(self.metrics.duration, times.counter, 0);
let iterations = iterations_per_user(times.counter, scenario.users.len());
scenario_metrics.push(ScenarioMetric {
name: scenario.name.to_string(),
users: scenario.users.len().into(),
count: times.counter.into(),
response_time_average: times.average_ms_or_zero().into(),
response_time_minimum: Millis(times.minimum_time_us).into(),
response_time_maximum: Millis(times.maximum_time_us).into(),
count_per_second: count_per_second.into(),
iterations: iterations.into(),
});
aggregate_users += scenario.users.len();
aggregate_times.merge(times);
}
let (aggregate_count_per_second, _aggregate_failures_per_second) =
per_second_calculations(self.metrics.duration, aggregate_times.counter, 0);
scenario_metrics.push(ScenarioMetric {
name: "Aggregated".to_string(),
users: aggregate_users.into(),
count: aggregate_times.counter.into(),
response_time_average: aggregate_times.average_ms_or_zero().into(),
response_time_minimum: Millis(aggregate_times.minimum_time_us).into(),
response_time_maximum: Millis(aggregate_times.maximum_time_us).into(),
count_per_second: aggregate_count_per_second.into(),
iterations: iterations_per_user(aggregate_times.counter, aggregate_users).into(),
});
Some(scenario_metrics)
}
fn build_status_code(&self) -> Option<Vec<StatusCodeMetric>> {
if self.options.no_status_codes {
return None;
}
let mut status_code_metrics = Vec::new();
let mut aggregated_status_code_counts: HashMap<u16, usize> = HashMap::new();
for (request_key, request) in self.metrics.requests.iter().sorted() {
let method = request.method_label();
let name = request_key
.strip_prefix(&format!("{} ", method))
.unwrap()
.to_string();
let codes = prepare_status_codes(
&request.status_code_counts,
&mut Some(&mut aggregated_status_code_counts),
);
status_code_metrics.push(StatusCodeMetric {
method: method.to_string(),
name,
status_codes: codes,
});
}
let aggregated_codes = prepare_status_codes(&aggregated_status_code_counts, &mut None);
status_code_metrics.push(StatusCodeMetric {
method: "".to_string(),
name: "Aggregated".to_string(),
status_codes: aggregated_codes,
});
Some(status_code_metrics)
}
fn build_errors(&self) -> Option<Vec<ErrorMetric>> {
if self.metrics.errors.is_empty() {
return None;
}
let errors: Vec<ErrorMetric> = self
.metrics
.errors
.values()
.map(|error| ErrorMetric {
method: error.method_label().to_string(),
name: error.name.clone(),
error: error.error.clone(),
occurrences: error.occurrences.into(),
})
.collect();
Some(errors)
}
fn build_transaction_errors(&self) -> Option<Vec<TransactionErrorMetric>> {
if self.metrics.transaction_errors.is_empty() {
return None;
}
let mut errors: Vec<_> = self.metrics.transaction_errors.values().collect();
errors.sort_by(|a, b| {
b.occurrences
.cmp(&a.occurrences)
.then_with(|| a.scenario_index.cmp(&b.scenario_index))
.then_with(|| a.transaction_index.cmp(&b.transaction_index))
.then_with(|| a.error.cmp(&b.error))
});
Some(
errors
.into_iter()
.map(|error| TransactionErrorMetric {
transaction: error.transaction_number(),
scenario: error.scenario_name.clone(),
name: error.transaction_name.clone(),
error: error.error.clone(),
occurrences: error.occurrences.into(),
})
.collect(),
)
}
}
pub(crate) fn apply_baseline_deltas(current: &mut ReportData, baseline: &ReportData) {
correlate_deltas(
&mut current.raw_request_metrics,
&baseline.raw_request_metrics,
|r| (r.method.clone(), r.name.clone()),
);
correlate_deltas(
&mut current.raw_response_metrics,
&baseline.raw_response_metrics,
|r| (r.method.clone(), r.name.clone()),
);
if let (Some(current_co), Some(baseline_co)) = (
&mut current.co_request_metrics,
&baseline.co_request_metrics,
) {
correlate_deltas(current_co, baseline_co, |r| {
(r.method.clone(), r.name.clone())
});
}
if let (Some(current_co), Some(baseline_co)) = (
&mut current.co_response_metrics,
&baseline.co_response_metrics,
) {
correlate_deltas(current_co, baseline_co, |r| {
(r.method.clone(), r.name.clone())
});
}
if let (Some(current_transactions), Some(baseline_transactions)) = (
&mut current.transaction_metrics,
&baseline.transaction_metrics,
) {
correlate_deltas(current_transactions, baseline_transactions, |t| {
(t.transaction.clone(), t.name.clone())
});
}
if let (Some(current_scenarios), Some(baseline_scenarios)) =
(&mut current.scenario_metrics, &baseline.scenario_metrics)
{
correlate_deltas(current_scenarios, baseline_scenarios, |s| s.name.clone());
}
if let (Some(current_errors), Some(baseline_errors)) = (&mut current.errors, &baseline.errors) {
correlate_deltas(current_errors, baseline_errors, |e| {
(e.method.clone(), e.name.clone(), e.error.clone())
});
}
if let (Some(current_errors), Some(baseline_errors)) = (
&mut current.transaction_errors,
&baseline.transaction_errors,
) {
correlate_deltas(current_errors, baseline_errors, |e| {
(
e.scenario.clone(),
e.transaction.clone(),
e.name.clone(),
e.error.clone(),
)
});
}
}
pub(crate) fn json_report_path(path: &Path) -> PathBuf {
if !path.is_dir() {
return path.to_path_buf();
}
let json = path.join(crate::runs::REPORT_JSON);
if json.is_file() {
json
} else {
path.join(format!("{}.gz", crate::runs::REPORT_JSON))
}
}
pub(crate) fn open_report(path: &Path) -> std::io::Result<Box<dyn Read>> {
let file = BufReader::new(File::open(path)?);
if crate::runs::without_gz(&path.to_string_lossy()).1 {
Ok(Box::new(flate2::read::MultiGzDecoder::new(file)))
} else {
Ok(Box::new(file))
}
}
pub(crate) fn read_report_text(path: &Path) -> std::io::Result<String> {
let mut text = String::new();
open_report(path)?.read_to_string(&mut text)?;
Ok(text)
}
pub fn load_baseline_file<P: AsRef<Path>>(path: P) -> Result<ReportData<'static>, GooseError> {
let path_str = path.as_ref().to_string_lossy();
let file_path = json_report_path(path.as_ref());
let file = open_report(&file_path).map_err(|err| GooseError::InvalidOption {
option: "--baseline-file".to_string(),
value: path_str.to_string(),
detail: format!("Failed to open baseline file: {err}"),
})?;
let mut baseline = read_report(file).map_err(|err| GooseError::InvalidOption {
option: "--baseline-file".to_string(),
value: path_str.to_string(),
detail: format!("Failed to parse baseline file as JSON: {err}"),
})?;
validate_baseline_data(&baseline, &path_str)?;
baseline.raw_metrics = Cow::Owned(baseline.raw_metrics.into_owned());
Ok(baseline)
}
pub(crate) struct LoadedReport {
pub data: ReportData<'static>,
pub record: crate::runs::RunRecord,
pub graphs: Option<(crate::graph::GraphData, Option<serde_json::Value>)>,
}
impl LoadedReport {
pub(crate) fn read(path: &Path) -> Result<LoadedReport, String> {
let text =
read_report_text(path).map_err(|e| format!("Can't read {}: {e}", path.display()))?;
Self::parse(&text)
.map_err(|e| format!("{} is not a Goose JSON report: {e}", path.display()))
}
pub(crate) fn parse(text: &str) -> Result<LoadedReport, serde_json::Error> {
#[derive(Deserialize)]
struct Graphs {
graphs: Option<crate::graph::RecordedGraphs>,
}
let mut data = read_report(text.as_bytes())?;
data.raw_metrics = Cow::Owned(data.raw_metrics.into_owned());
restore_breakdown_rows(&mut data);
Ok(LoadedReport {
data,
record: serde_json::from_str(text)?,
graphs: serde_json::from_str::<Graphs>(text)?
.graphs
.map(crate::graph::RecordedGraphs::into_parts),
})
}
pub(crate) fn source<'a>(
&'a self,
configuration: &'a crate::GooseConfiguration,
empty: &'a crate::graph::GraphData,
) -> super::ReportSource<'a> {
let (graph_data, heatmap) = match &self.graphs {
Some((graph_data, heatmap)) => (graph_data, heatmap.as_ref()),
None => (empty, None),
};
super::ReportSource {
configuration,
metrics: &self.data.raw_metrics,
graph_data,
graphs: self.graphs.is_some(),
heatmap,
loaded: Some(&self.data),
record: &self.record,
graph_data_in_json: self.graphs.is_some(),
}
}
}
#[cfg(feature = "dashboard")]
pub(crate) fn compare_reports(
run: &Path,
run_id: &str,
baseline: &Path,
baseline_id: &str,
) -> Result<Vec<u8>, String> {
let load = |path: &Path, id: &str| -> Result<ReportData<'static>, String> {
let reader = open_report(path).map_err(|e| format!("Can't read run {id}: {e}"))?;
let data =
read_report(reader).map_err(|e| format!("Can't read the report of run {id}: {e}"))?;
let requests = data
.raw_request_metrics
.iter()
.filter(|r| !r.is_breakdown)
.count();
if requests == 0 {
return Err(format!("Run {id} has no requests to compare."));
}
if requests != data.raw_response_metrics.len() {
return Err(format!("The report of run {id} is inconsistent."));
}
Ok(data)
};
let mut current = load(run, run_id)?;
let previous = load(baseline, baseline_id)?;
apply_baseline_deltas(&mut current, &previous);
let mut out = Vec::new();
crate::report::write_markdown_report(&mut out, current, &[])
.map_err(|e| format!("Can't compare run {run_id} with run {baseline_id}: {e}"))?;
Ok(out)
}
fn read_report(mut reader: impl Read) -> Result<ReportData<'static>, serde_json::Error> {
let mut text = String::new();
reader
.read_to_string(&mut text)
.map_err(serde_json::Error::io)?;
let mut report = serde_json::from_str::<BaselineReportData>(&text)?.into_report_data();
if serde_json::from_str::<MicrosecondsProbe>(&text)?.has_microseconds() {
if let Some(key) = report
.raw_metrics
.requests
.iter()
.find_map(|(key, request)| {
(!key.starts_with(&format!("{} ", request.method_label()))).then_some(key)
})
{
return Err(serde::de::Error::custom(format!(
"request {key:?} does not start with its method"
)));
}
rebuild_timed_rows(&mut report);
}
Ok(report)
}
#[derive(Deserialize)]
struct MicrosecondsProbe {
raw_metrics: ProbeMetrics,
}
#[derive(Deserialize)]
struct ProbeMetrics {
requests: HashMap<String, ProbeRequest>,
#[serde(default)]
transactions: Vec<Vec<ProbeTimingData>>,
#[serde(default)]
scenarios: Vec<ProbeTimingData>,
}
#[derive(Deserialize)]
struct ProbeRequest {
raw_data: ProbeTimingData,
}
#[derive(Deserialize)]
struct ProbeTimingData {
times_us: Option<serde::de::IgnoredAny>,
}
impl MicrosecondsProbe {
fn has_microseconds(&self) -> bool {
let metrics = &self.raw_metrics;
metrics
.requests
.values()
.any(|request| request.raw_data.times_us.is_some())
|| metrics
.transactions
.iter()
.flatten()
.any(|transaction| transaction.times_us.is_some())
|| metrics
.scenarios
.iter()
.any(|scenario| scenario.times_us.is_some())
}
}
fn rebuild_timed_rows(report: &mut ReportData<'static>) {
let ReportData {
raw_request_metrics,
raw_response_metrics,
co_request_metrics,
co_response_metrics,
transaction_metrics,
scenario_metrics,
..
} = Prepare::new(
ReportOptions {
no_transaction_metrics: report.transaction_metrics.is_none(),
no_scenario_metrics: report.scenario_metrics.is_none(),
no_status_codes: true,
},
&report.raw_metrics,
)
.build();
let request_key = |r: &RequestMetric| (r.method.clone(), r.name.clone());
let response_key = |r: &ResponseMetric| (r.method.clone(), r.name.clone());
replace_rows(
&mut report.raw_request_metrics,
raw_request_metrics,
request_key,
);
replace_rows(
&mut report.raw_response_metrics,
raw_response_metrics,
response_key,
);
if let (Some(stored), Some(rebuilt)) = (&mut report.co_request_metrics, co_request_metrics) {
replace_rows(stored, rebuilt, |r| (r.method.clone(), r.name.clone()));
}
if let (Some(stored), Some(rebuilt)) = (&mut report.co_response_metrics, co_response_metrics) {
replace_rows(stored, rebuilt, response_key);
}
if let (Some(stored), Some(rebuilt)) = (&mut report.transaction_metrics, transaction_metrics) {
replace_rows(stored, rebuilt, |t| (t.transaction.clone(), t.name.clone()));
}
if let (Some(stored), Some(rebuilt)) = (&mut report.scenario_metrics, scenario_metrics) {
replace_rows(stored, rebuilt, |s| s.name.clone());
}
}
fn restore_breakdown_rows(report: &mut ReportData<'static>) {
let (rebuilt, _, _) = Prepare::new(
ReportOptions {
no_transaction_metrics: true,
no_scenario_metrics: true,
no_status_codes: true,
},
&report.raw_metrics,
)
.build_raw();
let mut breakdowns: HashMap<(String, String), Vec<RequestMetric>> = HashMap::new();
let mut parent = None;
for row in rebuilt {
if row.is_breakdown {
if let Some(key) = &parent {
breakdowns.entry(Clone::clone(key)).or_default().push(row);
}
} else {
parent = Some((row.method, row.name));
}
}
if breakdowns.is_empty() {
return;
}
let stored = std::mem::take(&mut report.raw_request_metrics);
for row in stored {
let following = breakdowns.remove(&(row.method.clone(), row.name.clone()));
report.raw_request_metrics.push(row);
report
.raw_request_metrics
.extend(following.into_iter().flatten());
}
}
fn replace_rows<T, K: Eq + Hash>(stored: &mut [T], rebuilt: Vec<T>, key: impl Fn(&T) -> K) {
let mut rebuilt: HashMap<K, T> = rebuilt.into_iter().map(|row| (key(&row), row)).collect();
for row in stored {
if let Some(new) = rebuilt.remove(&key(row)) {
*row = new;
}
}
}
#[derive(Debug, Deserialize)]
struct BaselineReportData {
pub raw_metrics: BaselineGooseMetrics,
pub raw_request_metrics: Vec<BaselineRequestMetric>,
pub raw_response_metrics: Vec<BaselineResponseMetric>,
pub co_request_metrics: Option<Vec<BaselineCORequestMetric>>,
pub co_response_metrics: Option<Vec<BaselineResponseMetric>>,
pub scenario_metrics: Option<Vec<BaselineScenarioMetric>>,
pub transaction_metrics: Option<Vec<BaselineTransactionMetric>>,
pub status_code_metrics: Option<Vec<StatusCodeMetric>>,
pub errors: Option<Vec<BaselineErrorMetric>>,
#[serde(default)]
pub transaction_errors: Option<Vec<BaselineTransactionErrorMetric>>,
pub coordinated_omission_metrics: Option<CoMetricsSummary>,
}
#[derive(Debug, Deserialize)]
struct BaselineGooseMetrics {
pub hash: u64,
#[serde(default)]
pub history: Vec<crate::test_plan::TestPlanHistory>,
pub duration: usize,
pub maximum_users: usize,
pub total_users: usize,
pub requests: super::GooseRequestMetrics,
pub transactions: super::TransactionMetrics,
#[serde(default)]
pub scenarios: super::ScenarioMetrics,
pub errors: super::GooseErrorMetrics,
#[serde(default)]
pub transaction_errors: super::GooseTransactionErrorMetrics,
#[serde(default)]
pub hosts: std::collections::HashSet<String>,
pub coordinated_omission_metrics: Option<super::CoordinatedOmissionMetrics>,
#[serde(default)]
pub final_metrics: bool,
#[serde(default)]
pub display_status_codes: bool,
#[serde(default)]
pub display_metrics: bool,
#[serde(default)]
pub thresholds: Vec<crate::threshold::ThresholdResult>,
}
impl From<BaselineGooseMetrics> for GooseMetrics {
fn from(baseline: BaselineGooseMetrics) -> Self {
GooseMetrics {
hash: baseline.hash,
history: baseline.history,
duration: baseline.duration,
maximum_users: baseline.maximum_users,
total_users: baseline.total_users,
requests: baseline.requests,
transactions: baseline.transactions,
scenarios: baseline.scenarios,
errors: baseline.errors,
transaction_errors: baseline.transaction_errors,
hosts: baseline.hosts,
coordinated_omission_metrics: baseline.coordinated_omission_metrics,
final_metrics: baseline.final_metrics,
display_status_codes: baseline.display_status_codes,
display_metrics: baseline.display_metrics,
thresholds: baseline.thresholds,
threshold_abort: None,
rps_window: Default::default(),
}
}
}
impl BaselineReportData {
fn into_report_data(self) -> ReportData<'static> {
ReportData {
raw_metrics: Cow::Owned(self.raw_metrics.into()),
raw_request_metrics: self
.raw_request_metrics
.into_iter()
.map(Into::into)
.collect(),
raw_response_metrics: self
.raw_response_metrics
.into_iter()
.map(Into::into)
.collect(),
co_request_metrics: self
.co_request_metrics
.map(|metrics| metrics.into_iter().map(Into::into).collect()),
co_response_metrics: self
.co_response_metrics
.map(|metrics| metrics.into_iter().map(Into::into).collect()),
scenario_metrics: self
.scenario_metrics
.map(|metrics| metrics.into_iter().map(Into::into).collect()),
transaction_metrics: self
.transaction_metrics
.map(|metrics| metrics.into_iter().map(Into::into).collect()),
status_code_metrics: self.status_code_metrics,
errors: self
.errors
.map(|errors| errors.into_iter().map(Into::into).collect()),
transaction_errors: self
.transaction_errors
.map(|errors| errors.into_iter().map(Into::into).collect()),
coordinated_omission_metrics: self.coordinated_omission_metrics,
}
}
}
#[derive(Debug, Deserialize)]
struct BaselineRequestMetric {
pub method: String,
pub name: String,
pub number_of_requests: usize,
pub number_of_failures: usize,
#[serde(deserialize_with = "deserialize_nullable_f64")]
pub response_time_average: f64,
pub response_time_minimum: usize,
pub response_time_maximum: usize,
#[serde(deserialize_with = "deserialize_nullable_f64")]
pub requests_per_second: f64,
#[serde(deserialize_with = "deserialize_nullable_f64")]
pub failures_per_second: f64,
#[serde(default)]
pub is_breakdown: bool,
}
impl From<BaselineRequestMetric> for RequestMetric {
fn from(baseline: BaselineRequestMetric) -> Self {
RequestMetric {
method: baseline.method,
name: baseline.name,
number_of_requests: Value::Plain(baseline.number_of_requests),
number_of_failures: Value::Plain(baseline.number_of_failures),
response_time_average: Value::Plain(MillisFloat(baseline.response_time_average)),
response_time_minimum: baseline.response_time_minimum.into(),
response_time_maximum: baseline.response_time_maximum.into(),
requests_per_second: Value::Plain(NullableFloat(baseline.requests_per_second)),
failures_per_second: Value::Plain(NullableFloat(baseline.failures_per_second)),
is_breakdown: baseline.is_breakdown,
}
}
}
#[derive(Debug, Deserialize)]
struct BaselineResponseMetric {
pub method: String,
pub name: String,
pub percentile_50: usize,
pub percentile_60: usize,
pub percentile_70: usize,
pub percentile_80: usize,
pub percentile_90: usize,
pub percentile_95: usize,
pub percentile_99: usize,
pub percentile_100: usize,
}
impl From<BaselineResponseMetric> for ResponseMetric {
fn from(baseline: BaselineResponseMetric) -> Self {
ResponseMetric {
method: baseline.method,
name: baseline.name,
percentile_50: baseline.percentile_50.into(),
percentile_60: baseline.percentile_60.into(),
percentile_70: baseline.percentile_70.into(),
percentile_80: baseline.percentile_80.into(),
percentile_90: baseline.percentile_90.into(),
percentile_95: baseline.percentile_95.into(),
percentile_99: baseline.percentile_99.into(),
percentile_100: baseline.percentile_100.into(),
}
}
}
#[derive(Debug, Deserialize)]
struct BaselineCORequestMetric {
pub method: String,
pub name: String,
pub response_time_average: f64,
pub response_time_standard_deviation: f64,
pub response_time_maximum: usize,
}
impl From<BaselineCORequestMetric> for CORequestMetric {
fn from(baseline: BaselineCORequestMetric) -> Self {
CORequestMetric {
method: baseline.method,
name: baseline.name,
response_time_average: Value::Plain(MillisFloat(baseline.response_time_average)),
response_time_standard_deviation: Value::Plain(MillisFloat(
baseline.response_time_standard_deviation,
)),
response_time_maximum: baseline.response_time_maximum.into(),
}
}
}
#[derive(Debug, Deserialize)]
struct BaselineTransactionMetric {
pub is_scenario: bool,
pub transaction: String,
pub name: String,
pub number_of_requests: usize,
pub number_of_failures: usize,
pub response_time_average: Option<f64>,
pub response_time_minimum: usize,
pub response_time_maximum: usize,
pub requests_per_second: Option<f64>,
pub failures_per_second: Option<f64>,
}
impl From<BaselineTransactionMetric> for TransactionMetric {
fn from(baseline: BaselineTransactionMetric) -> Self {
TransactionMetric {
is_scenario: baseline.is_scenario,
transaction: baseline.transaction,
name: baseline.name,
number_of_requests: Value::Plain(baseline.number_of_requests),
number_of_failures: Value::Plain(baseline.number_of_failures),
response_time_average: baseline
.response_time_average
.map(|v| Value::Plain(MillisFloat(v))),
response_time_minimum: baseline.response_time_minimum.into(),
response_time_maximum: baseline.response_time_maximum.into(),
requests_per_second: baseline
.requests_per_second
.map(|v| Value::Plain(NullableFloat(v))),
failures_per_second: baseline
.failures_per_second
.map(|v| Value::Plain(NullableFloat(v))),
}
}
}
#[derive(Debug, Deserialize)]
struct BaselineScenarioMetric {
pub name: String,
pub users: usize,
pub count: usize,
pub response_time_average: Option<f64>,
pub response_time_minimum: usize,
pub response_time_maximum: usize,
pub count_per_second: f64,
pub iterations: Option<f64>,
}
impl From<BaselineScenarioMetric> for ScenarioMetric {
fn from(baseline: BaselineScenarioMetric) -> Self {
ScenarioMetric {
name: baseline.name,
users: Value::Plain(baseline.users),
count: Value::Plain(baseline.count),
response_time_average: Value::Plain(MillisFloat(
baseline.response_time_average.unwrap_or(f64::NAN),
)),
response_time_minimum: baseline.response_time_minimum.into(),
response_time_maximum: baseline.response_time_maximum.into(),
count_per_second: Value::Plain(NullableFloat(baseline.count_per_second)),
iterations: Value::Plain(NullableFloat(baseline.iterations.unwrap_or(f64::NAN))),
}
}
}
#[derive(Debug, Deserialize)]
struct BaselineErrorMetric {
pub method: String,
pub name: String,
pub error: String,
pub occurrences: usize,
}
#[derive(Debug, Deserialize)]
struct BaselineTransactionErrorMetric {
pub transaction: String,
pub scenario: String,
pub name: String,
pub error: String,
pub occurrences: usize,
}
impl From<BaselineTransactionErrorMetric> for TransactionErrorMetric {
fn from(baseline: BaselineTransactionErrorMetric) -> Self {
TransactionErrorMetric {
transaction: baseline.transaction,
scenario: baseline.scenario,
name: baseline.name,
error: baseline.error,
occurrences: Value::Plain(baseline.occurrences),
}
}
}
impl From<BaselineErrorMetric> for ErrorMetric {
fn from(baseline: BaselineErrorMetric) -> Self {
ErrorMetric {
method: baseline.method,
name: baseline.name,
error: baseline.error,
occurrences: Value::Plain(baseline.occurrences),
}
}
}
fn validate_baseline_data(baseline: &ReportData, path_str: &str) -> Result<(), GooseError> {
let non_breakdown_request_count = baseline
.raw_request_metrics
.iter()
.filter(|r| !r.is_breakdown)
.count();
let response_count = baseline.raw_response_metrics.len();
if non_breakdown_request_count != response_count {
return Err(GooseError::InvalidOption {
option: "--baseline-file".to_string(),
value: path_str.to_string(),
detail: format!(
"Inconsistent baseline data: {} request metrics vs {} response metrics",
non_breakdown_request_count, response_count
),
});
}
if non_breakdown_request_count == 0 {
return Err(GooseError::InvalidOption {
option: "--baseline-file".to_string(),
value: path_str.to_string(),
detail: "Baseline file contains no request metrics".to_string(),
});
}
Ok(())
}
fn correlate_deltas<T, F, K>(current: &mut [T], baseline: &[T], f: F)
where
T: DeltaTo,
F: Fn(&T) -> K,
K: Eq + Hash,
{
let mut current = current
.iter_mut()
.map(|request| (f(request), request))
.collect::<HashMap<_, _>>();
let previous = baseline
.iter()
.map(|request| (f(request), request))
.collect::<HashMap<_, _>>();
for (k, v) in &mut current {
if let Some(previous) = previous.get(k) {
v.delta_to(previous);
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::metrics::millis::MillisDelta;
use crate::report::RequestMetric;
fn two_scenario_metrics() -> GooseMetrics {
use crate::metrics::ScenarioMetricAggregate;
let mut metrics = GooseMetrics {
display_metrics: true,
duration: 10,
..Default::default()
};
let mut first = ScenarioMetricAggregate::new(0, "First".into());
for _ in 0..10 {
first.update(10, 0);
}
let mut second = ScenarioMetricAggregate::new(1, "Second".into());
second.update(10, 1);
second.update(10, 2);
second.users.insert(3);
metrics.scenarios = vec![first, second];
metrics
}
#[test]
fn report_aggregated_iterations_match_the_console() {
let metrics = two_scenario_metrics();
let data = prepare_data_with_baseline(
ReportOptions {
no_transaction_metrics: false,
no_scenario_metrics: false,
no_status_codes: false,
},
&metrics,
None,
);
let scenarios = data.scenario_metrics.expect("scenario metrics");
let aggregated = scenarios.last().unwrap();
assert_eq!(aggregated.name, "Aggregated");
assert_eq!(*aggregated.iterations.value(), 3.0);
let console = metrics.to_string();
let row = console
.lines()
.find(|line| line.trim_start().starts_with("Aggregated") && line.contains(" 12 |"))
.unwrap_or_else(|| panic!("aggregated scenario row in {}", console));
assert_eq!(row.rsplit('|').next().unwrap().trim(), "3.00");
let mut markdown = Vec::new();
crate::report::write_markdown_report(
&mut markdown,
prepare_data_with_baseline(
ReportOptions {
no_transaction_metrics: false,
no_scenario_metrics: false,
no_status_codes: false,
},
&metrics,
None,
),
&[],
)
.unwrap();
let markdown = String::from_utf8(markdown).unwrap();
let table = &markdown[markdown.find("## Scenario Metrics").unwrap()..];
assert!(table.contains("\n| Scenario | # Users |"), "{}", table);
let row = table
.lines()
.find(|line| line.starts_with("| Aggregated |"))
.unwrap();
assert!(row.ends_with("| 3.00 |"), "{}", row);
}
#[test]
fn delta_value_usize() {
assert_eq!(100usize.delta(50usize), 50isize);
assert_eq!(usize::MAX.delta(usize::MAX), 0isize);
assert_eq!(usize::MAX.delta(0usize), isize::MAX);
assert_eq!(0usize.delta(usize::MAX), isize::MIN);
}
#[test]
fn correlate_deltas_matches_by_key() {
let mut current = vec![
RequestMetric {
method: "GET".into(),
name: "/".into(),
number_of_requests: 200usize.into(),
number_of_failures: 5usize.into(),
response_time_average: 50.0f32.into(),
response_time_minimum: 10usize.into(),
response_time_maximum: 500usize.into(),
requests_per_second: 20.0f32.into(),
failures_per_second: 0.5f32.into(),
is_breakdown: false,
},
RequestMetric {
method: "POST".into(),
name: "/api".into(),
number_of_requests: 100usize.into(),
number_of_failures: 2usize.into(),
response_time_average: 80.0f32.into(),
response_time_minimum: 20usize.into(),
response_time_maximum: 800usize.into(),
requests_per_second: 10.0f32.into(),
failures_per_second: 0.2f32.into(),
is_breakdown: false,
},
];
let baseline = vec![RequestMetric {
method: "GET".into(),
name: "/".into(),
number_of_requests: 150usize.into(),
number_of_failures: 3usize.into(),
response_time_average: 40.0f32.into(),
response_time_minimum: 8usize.into(),
response_time_maximum: 400usize.into(),
requests_per_second: 15.0f32.into(),
failures_per_second: 0.3f32.into(),
is_breakdown: false,
}];
correlate_deltas(&mut current, &baseline, |r| {
(r.method.clone(), r.name.clone())
});
assert_eq!(current[0].number_of_requests.value(), 200);
assert!(matches!(current[0].number_of_requests, Value::Delta { .. }));
assert_eq!(current[1].number_of_requests.value(), 100);
assert!(matches!(current[1].number_of_requests, Value::Plain(_)));
}
fn minimal_baseline_json(
requests: usize,
failures: usize,
avg_response: f32,
min_response: usize,
max_response: usize,
) -> String {
format!(
r#"{{
"raw_metrics": {{
"hash": 0,
"duration": 60,
"maximum_users": 10,
"total_users": 10,
"requests": {{}},
"transactions": [],
"errors": {{}}
}},
"raw_request_metrics": [
{{
"method": "GET",
"name": "/",
"number_of_requests": {requests},
"number_of_failures": {failures},
"response_time_average": {avg_response},
"response_time_minimum": {min_response},
"response_time_maximum": {max_response},
"requests_per_second": 10.0,
"failures_per_second": 0.5
}}
],
"raw_response_metrics": [
{{
"method": "GET",
"name": "/",
"percentile_50": 50,
"percentile_60": 60,
"percentile_70": 70,
"percentile_80": 80,
"percentile_90": 90,
"percentile_95": 95,
"percentile_99": 99,
"percentile_100": 100
}}
]
}}"#,
)
}
#[test]
fn load_baseline_file_valid() {
let path = std::env::temp_dir().join("goose_test_valid_baseline.json");
std::fs::write(&path, minimal_baseline_json(500, 10, 45.5, 5, 200)).unwrap();
let result = load_baseline_file(&path);
std::fs::remove_file(&path).ok();
let data = result.expect("should load valid baseline");
assert_eq!(data.raw_request_metrics.len(), 1);
assert_eq!(data.raw_response_metrics.len(), 1);
assert_eq!(data.raw_request_metrics[0].number_of_requests.value(), 500);
assert_eq!(data.raw_request_metrics[0].number_of_failures.value(), 10);
assert_eq!(data.raw_response_metrics[0].percentile_50.value(), 50);
assert_eq!(data.raw_response_metrics[0].percentile_100.value(), 100);
assert!(matches!(
data.raw_request_metrics[0].number_of_requests,
Value::Plain(500)
));
}
#[test]
fn apply_baseline_deltas_all_metric_types() {
use crate::report::{
CORequestMetric, ErrorMetric, ResponseMetric, ScenarioMetric, TransactionMetric,
};
let mut current = ReportData {
raw_metrics: Cow::Owned(GooseMetrics::default()),
raw_request_metrics: vec![RequestMetric {
method: "GET".into(),
name: "/".into(),
number_of_requests: 200usize.into(),
number_of_failures: 5usize.into(),
response_time_average: 50.0f32.into(),
response_time_minimum: 10usize.into(),
response_time_maximum: 500usize.into(),
requests_per_second: 20.0f32.into(),
failures_per_second: 0.5f32.into(),
is_breakdown: false,
}],
raw_response_metrics: vec![ResponseMetric {
method: "GET".into(),
name: "/".into(),
percentile_50: 50usize.into(),
percentile_60: 60usize.into(),
percentile_70: 70usize.into(),
percentile_80: 80usize.into(),
percentile_90: 90usize.into(),
percentile_95: 95usize.into(),
percentile_99: 99usize.into(),
percentile_100: 100usize.into(),
}],
co_request_metrics: Some(vec![CORequestMetric {
method: "GET".into(),
name: "/".into(),
response_time_average: 55.0f32.into(),
response_time_standard_deviation: 5.0f32.into(),
response_time_maximum: 600usize.into(),
}]),
co_response_metrics: Some(vec![ResponseMetric {
method: "GET".into(),
name: "/".into(),
percentile_50: 55usize.into(),
percentile_60: 65usize.into(),
percentile_70: 75usize.into(),
percentile_80: 85usize.into(),
percentile_90: 95usize.into(),
percentile_95: 100usize.into(),
percentile_99: 110usize.into(),
percentile_100: 120usize.into(),
}]),
transaction_metrics: Some(vec![TransactionMetric {
is_scenario: false,
transaction: "LoadTest".into(),
name: "front page".into(),
number_of_requests: 200usize.into(),
number_of_failures: 5usize.into(),
response_time_average: Some(50.0f32.into()),
response_time_minimum: 10usize.into(),
response_time_maximum: 500usize.into(),
requests_per_second: Some(20.0f32.into()),
failures_per_second: Some(0.5f32.into()),
}]),
scenario_metrics: Some(vec![ScenarioMetric {
name: "LoadTest".into(),
users: 10usize.into(),
count: 200usize.into(),
response_time_average: 50.0f32.into(),
response_time_minimum: 10usize.into(),
response_time_maximum: 500usize.into(),
count_per_second: 20.0f32.into(),
iterations: 5.0f32.into(),
}]),
status_code_metrics: None,
errors: Some(vec![ErrorMetric {
method: "GET".into(),
name: "/".into(),
error: "503 Service Unavailable".into(),
occurrences: 5usize.into(),
}]),
transaction_errors: None,
coordinated_omission_metrics: None,
};
let baseline = ReportData {
raw_metrics: Cow::Owned(GooseMetrics::default()),
raw_request_metrics: vec![RequestMetric {
method: "GET".into(),
name: "/".into(),
number_of_requests: 150usize.into(),
number_of_failures: 3usize.into(),
response_time_average: 40.0f32.into(),
response_time_minimum: 8usize.into(),
response_time_maximum: 400usize.into(),
requests_per_second: 15.0f32.into(),
failures_per_second: 0.3f32.into(),
is_breakdown: false,
}],
raw_response_metrics: vec![ResponseMetric {
method: "GET".into(),
name: "/".into(),
percentile_50: 40usize.into(),
percentile_60: 50usize.into(),
percentile_70: 60usize.into(),
percentile_80: 70usize.into(),
percentile_90: 80usize.into(),
percentile_95: 85usize.into(),
percentile_99: 90usize.into(),
percentile_100: 95usize.into(),
}],
co_request_metrics: Some(vec![CORequestMetric {
method: "GET".into(),
name: "/".into(),
response_time_average: 45.0f32.into(),
response_time_standard_deviation: 4.0f32.into(),
response_time_maximum: 500usize.into(),
}]),
co_response_metrics: Some(vec![ResponseMetric {
method: "GET".into(),
name: "/".into(),
percentile_50: 45usize.into(),
percentile_60: 55usize.into(),
percentile_70: 65usize.into(),
percentile_80: 75usize.into(),
percentile_90: 85usize.into(),
percentile_95: 90usize.into(),
percentile_99: 100usize.into(),
percentile_100: 110usize.into(),
}]),
transaction_metrics: Some(vec![TransactionMetric {
is_scenario: false,
transaction: "LoadTest".into(),
name: "front page".into(),
number_of_requests: 150usize.into(),
number_of_failures: 3usize.into(),
response_time_average: Some(40.0f32.into()),
response_time_minimum: 8usize.into(),
response_time_maximum: 400usize.into(),
requests_per_second: Some(15.0f32.into()),
failures_per_second: Some(0.3f32.into()),
}]),
scenario_metrics: Some(vec![ScenarioMetric {
name: "LoadTest".into(),
users: 8usize.into(),
count: 150usize.into(),
response_time_average: 40.0f32.into(),
response_time_minimum: 8usize.into(),
response_time_maximum: 400usize.into(),
count_per_second: 15.0f32.into(),
iterations: 4.0f32.into(),
}]),
status_code_metrics: None,
errors: Some(vec![ErrorMetric {
method: "GET".into(),
name: "/".into(),
error: "503 Service Unavailable".into(),
occurrences: 3usize.into(),
}]),
transaction_errors: None,
coordinated_omission_metrics: None,
};
apply_baseline_deltas(&mut current, &baseline);
assert!(matches!(
current.raw_request_metrics[0].number_of_requests,
Value::Delta {
value: 200,
delta: 50
}
));
assert!(matches!(
current.raw_response_metrics[0].percentile_50,
Value::Delta {
value: Millis(50_000),
delta: MillisDelta(10_000)
}
));
assert!(matches!(
current.co_request_metrics.as_ref().unwrap()[0].response_time_maximum,
Value::Delta {
value: Millis(600_000),
delta: MillisDelta(100_000)
}
));
assert!(matches!(
current.co_response_metrics.as_ref().unwrap()[0].percentile_50,
Value::Delta {
value: Millis(55_000),
delta: MillisDelta(10_000)
}
));
assert!(matches!(
current.transaction_metrics.as_ref().unwrap()[0].number_of_requests,
Value::Delta {
value: 200,
delta: 50
}
));
assert!(matches!(
current.scenario_metrics.as_ref().unwrap()[0].users,
Value::Delta {
value: 10,
delta: 2
}
));
assert!(matches!(
current.errors.as_ref().unwrap()[0].occurrences,
Value::Delta { value: 5, delta: 2 }
));
}
#[test]
fn round_trip_serialize_then_load_as_baseline() {
use crate::report::ResponseMetric;
let mut metrics = GooseMetrics::default();
metrics
.history
.push(crate::test_plan::TestPlanHistory::step(
crate::test_plan::TestPlanStepAction::Increasing,
0,
));
let original = ReportData {
raw_metrics: Cow::Owned(metrics),
raw_request_metrics: vec![RequestMetric {
method: "POST".into(),
name: "/api/submit".into(),
number_of_requests: 1000usize.into(),
number_of_failures: 25usize.into(),
response_time_average: 120.5f32.into(),
response_time_minimum: 15usize.into(),
response_time_maximum: 3000usize.into(),
requests_per_second: 16.7f32.into(),
failures_per_second: 0.42f32.into(),
is_breakdown: false,
}],
raw_response_metrics: vec![ResponseMetric {
method: "POST".into(),
name: "/api/submit".into(),
percentile_50: 100usize.into(),
percentile_60: 110usize.into(),
percentile_70: 130usize.into(),
percentile_80: 150usize.into(),
percentile_90: 200usize.into(),
percentile_95: 500usize.into(),
percentile_99: 1500usize.into(),
percentile_100: 3000usize.into(),
}],
co_request_metrics: None,
co_response_metrics: None,
scenario_metrics: None,
transaction_metrics: None,
status_code_metrics: None,
errors: None,
transaction_errors: None,
coordinated_omission_metrics: None,
};
let json = serde_json::to_string_pretty(&original).unwrap();
let path = std::env::temp_dir().join("goose_test_roundtrip.json");
std::fs::write(&path, &json).unwrap();
let loaded = load_baseline_file(&path);
std::fs::remove_file(&path).ok();
let data = loaded.expect("round-trip should succeed");
assert_eq!(data.raw_request_metrics.len(), 1);
assert_eq!(data.raw_request_metrics[0].method, "POST");
assert_eq!(data.raw_request_metrics[0].name, "/api/submit");
assert_eq!(data.raw_request_metrics[0].number_of_requests.value(), 1000);
assert_eq!(data.raw_response_metrics[0].percentile_99.value(), 1500);
assert_eq!(data.raw_metrics.history.len(), 1);
}
#[test]
fn load_baseline_file_from_run_directory() {
let dir = std::env::temp_dir().join(format!(
"goose_test_baseline_run_dir_{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(crate::runs::REPORT_JSON),
minimal_baseline_json(500, 10, 45.5, 5, 200),
)
.unwrap();
let result = load_baseline_file(&dir);
std::fs::remove_dir_all(&dir).ok();
let data = result.expect("should load report.json from the run directory");
assert_eq!(data.raw_request_metrics[0].number_of_requests.value(), 500);
}
#[test]
fn load_baseline_file_invalid_path() {
let result = load_baseline_file("/nonexistent/path.json");
assert!(result.is_err());
}
#[test]
fn load_baseline_file_invalid_json() {
let path = std::env::temp_dir().join("goose_test_invalid.json");
std::fs::write(&path, "not valid json").unwrap();
let result = load_baseline_file(&path);
std::fs::remove_file(&path).ok();
assert!(result.is_err());
}
#[test]
fn load_baseline_file_empty_metrics() {
let path = std::env::temp_dir().join("goose_test_empty.json");
std::fs::write(
&path,
r#"{
"raw_metrics": {
"hash": 0,
"duration": 10,
"maximum_users": 1,
"total_users": 1,
"requests": {},
"transactions": [],
"errors": {}
},
"raw_request_metrics": [],
"raw_response_metrics": []
}"#,
)
.unwrap();
let result = load_baseline_file(&path);
std::fs::remove_file(&path).ok();
match result {
Err(GooseError::InvalidOption { detail, .. }) => {
assert!(
detail.contains("no request metrics"),
"unexpected detail: {}",
detail
);
}
other => panic!("expected InvalidOption error, got: {:?}", other),
}
}
}