use super::{
per_second_calculations, GooseErrorMetricAggregate, GooseMetrics, GooseRequestMetricAggregate,
GooseRequestMetricTimingData, GooseTransactionErrorMetricAggregate, ScenarioMetricAggregate,
TransactionMetricAggregate,
};
use chrono::prelude::*;
use serde::Serialize;
use std::collections::HashSet;
pub(crate) const MAX_REQUEST_ROWS: usize = 100;
pub(crate) const MAX_ERROR_ROWS: usize = 50;
#[cfg_attr(not(feature = "dashboard"), allow(dead_code))]
pub(crate) const SERIES_WINDOW_SECS: u32 = 300;
pub(crate) const SNAPSHOT_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct DashboardSnapshot {
pub version: u32,
pub generated_at: DateTime<Utc>,
pub goose_version: String,
pub phase: String,
pub stopping: bool,
pub duration_secs: u64,
pub active_users: u64,
pub maximum_users: u64,
pub target_users: u64,
pub total_users: u64,
pub hosts: Vec<String>,
pub aggregate: AggregateMetrics,
pub requests: Vec<RequestRow>,
pub errors: Vec<ErrorRow>,
pub transaction_errors: Vec<TransactionErrorRow>,
pub scenarios: Vec<ScenarioRow>,
pub transactions: Vec<TransactionRow>,
pub series: SeriesWindow,
pub flags: SnapshotFlags,
pub report: SnapshotReport,
pub settings: SnapshotSettings,
#[serde(skip)]
pub build_time: std::time::Duration,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct SnapshotReport {
pub save: bool,
pub graph_data: bool,
pub folder: Option<String>,
pub paths: Vec<String>,
pub notes: Option<String>,
pub last_report: Option<String>,
pub last_report_name: Option<String>,
pub error: Option<String>,
pub writes: u64,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct SnapshotSettings {
pub host: Option<String>,
pub hosts: Vec<String>,
pub users: Option<u64>,
pub increase_rate: Option<f64>,
pub increase_time_secs: Option<u64>,
pub decrease_rate: Option<f64>,
pub decrease_time_secs: Option<u64>,
pub run_time_secs: Option<u64>,
pub test_plan: Option<String>,
pub hold_ends_in_secs: Option<u64>,
#[cfg_attr(
test,
ts(
type = "\"idle\" | \"ramp_up\" | \"hold\" | \"hold_until_stopped\" | \"ramping_down\" | \"test_plan\" | \"stopping\""
)
)]
pub stage: String,
}
impl SnapshotSettings {
#[cfg(test)]
pub(crate) fn idle() -> Self {
SnapshotSettings {
host: None,
hosts: Vec::new(),
users: None,
increase_rate: None,
increase_time_secs: None,
decrease_rate: None,
decrease_time_secs: None,
run_time_secs: None,
test_plan: None,
hold_ends_in_secs: None,
stage: "idle".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct AggregateMetrics {
pub total_requests: u64,
pub total_failures: u64,
pub requests_per_second: f64,
pub failures_per_second: f64,
pub failure_rate: f64,
pub response_time_avg_ms: f64,
pub response_time_min_ms: u64,
pub response_time_max_ms: u64,
pub percentile_ms: Percentiles,
pub response_time_us: Option<MicrosecondTimes>,
pub co_active: bool,
pub co_adjusted: Option<CoAdjusted>,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct RequestRow {
pub method: String,
pub name: String,
pub request_count: u64,
pub failure_count: u64,
pub requests_per_second: f64,
pub failures_per_second: f64,
pub response_time_avg_ms: f64,
pub response_time_min_ms: u64,
pub response_time_max_ms: u64,
pub percentile_ms: Percentiles,
pub response_time_us: Option<MicrosecondTimes>,
pub status_codes: Vec<(u16, u64)>,
pub co_adjusted: Option<CoAdjusted>,
pub has_series: bool,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct ScenarioRow {
pub scenario_index: u64,
pub scenario_name: String,
pub users: u64,
pub run_count: u64,
pub runs_per_second: f64,
pub response_time_avg_ms: f64,
pub response_time_min_ms: u64,
pub response_time_max_ms: u64,
pub percentile_ms: Percentiles,
pub response_time_us: MicrosecondTimes,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct TransactionRow {
pub scenario_index: u64,
pub scenario_name: String,
pub transaction_index: u64,
pub transaction_name: String,
pub run_count: u64,
pub failure_count: u64,
pub runs_per_second: f64,
pub failures_per_second: f64,
pub response_time_avg_ms: f64,
pub response_time_min_ms: u64,
pub response_time_max_ms: u64,
pub percentile_ms: Percentiles,
pub response_time_us: MicrosecondTimes,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct ErrorRow {
pub method: String,
pub name: String,
pub error: String,
pub occurrences: u64,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct TransactionErrorRow {
pub transaction: String,
pub scenario: String,
pub name: String,
pub error: String,
pub occurrences: u64,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct SeriesWindow {
pub start_second: u64,
pub rps: Vec<f64>,
pub fps: Vec<f64>,
pub users: Vec<u64>,
pub avg_latency_ms: Vec<f64>,
pub heatmap: Option<Heatmap>,
}
impl SeriesWindow {
pub(crate) fn empty() -> Self {
SeriesWindow {
start_second: 0,
rps: Vec::new(),
fps: Vec::new(),
users: Vec::new(),
avg_latency_ms: Vec::new(),
heatmap: None,
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct Heatmap {
pub generation: u64,
pub rev: u64,
pub since: u64,
pub start_second: u64,
pub end_second: u64,
pub hold_from: u64,
pub buckets_us: Vec<[u64; 3]>,
pub columns: Vec<HeatmapColumn>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct HeatmapColumn {
pub second: u64,
pub measured: Vec<u64>,
pub synthetic: Vec<u64>,
}
#[cfg(test)]
impl Heatmap {
pub(crate) fn column(&self, second: u64) -> Option<&HeatmapColumn> {
self.columns.iter().find(|column| column.second == second)
}
pub(crate) fn counts(&self, second: u64) -> (u64, u64) {
self.column(second).map_or((0, 0), |column| {
(
HeatmapColumn::total(&column.measured),
HeatmapColumn::total(&column.synthetic),
)
})
}
}
#[cfg(test)]
impl HeatmapColumn {
pub(crate) fn pairs(cells: &[u64]) -> Vec<(u64, u64)> {
cells.chunks(2).map(|pair| (pair[0], pair[1])).collect()
}
pub(crate) fn total(cells: &[u64]) -> u64 {
cells.chunks(2).map(|pair| pair[1]).sum()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct HeatmapCursor {
pub generation: u64,
pub rev: u64,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct RequestSeries {
pub method: String,
pub name: String,
pub heatmap: Heatmap,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct CoAdjusted {
pub response_time_avg_ms: f64,
pub response_time_max_ms: u64,
pub percentile_ms: Percentiles,
pub response_time_us: Option<MicrosecondTimes>,
}
impl CoAdjusted {
fn from_timing_data(data: &GooseRequestMetricTimingData) -> Self {
let response_time_us = MicrosecondTimes::of(data);
CoAdjusted {
response_time_avg_ms: data.average_ms_or_zero(),
response_time_max_ms: data.maximum_time_ms() as u64,
percentile_ms: Percentiles::of(response_time_us.as_ref()),
response_time_us,
}
}
}
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct MicrosecondTimes {
pub min: u64,
pub max: u64,
pub p50: u64,
pub p95: u64,
pub p99: u64,
}
impl MicrosecondTimes {
fn of(data: &GooseRequestMetricTimingData) -> Option<Self> {
(data.counter > 0).then(|| MicrosecondTimes {
min: data.minimum_time_us,
max: data.maximum_time_us,
p50: data.percentile_us(0.5),
p95: data.percentile_us(0.95),
p99: data.percentile_us(0.99),
})
}
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct Percentiles {
pub p50: u64,
pub p95: u64,
pub p99: u64,
}
impl Percentiles {
fn zero() -> Self {
Percentiles {
p50: 0,
p95: 0,
p99: 0,
}
}
fn of(times: Option<&MicrosecondTimes>) -> Self {
match times {
Some(times) => Percentiles {
p50: times.p50 / 1000,
p95: times.p95 / 1000,
p99: times.p99 / 1000,
},
None => Self::zero(),
}
}
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(test, derive(ts_rs::TS))]
pub(crate) struct SnapshotFlags {
pub metrics_disabled: bool,
pub transaction_metrics_disabled: bool,
pub scenario_metrics_disabled: bool,
pub requests_truncated: bool,
pub errors_truncated: bool,
pub transaction_errors_truncated: bool,
pub series_seconds: u32,
pub percentiles_over_time: bool,
}
pub(crate) struct DashboardSnapshotInput<'a> {
pub metrics: &'a GooseMetrics,
pub series: SeriesWindow,
pub active_users: usize,
pub maximum_users: usize,
pub target_users: usize,
pub total_users: usize,
pub phase: String,
pub stopping: bool,
pub series_window_secs: u32,
pub no_status_codes: bool,
pub metrics_disabled: bool,
pub no_transaction_metrics: bool,
pub no_scenario_metrics: bool,
pub report: SnapshotReport,
pub settings: SnapshotSettings,
pub percentiles_over_time: bool,
pub series_keys: HashSet<&'a str>,
}
pub(crate) fn build_dashboard_snapshot(input: DashboardSnapshotInput<'_>) -> DashboardSnapshot {
if input.metrics_disabled {
let mut hosts: Vec<String> = input.metrics.hosts.iter().cloned().collect();
hosts.sort();
return DashboardSnapshot {
version: SNAPSHOT_VERSION,
generated_at: Utc::now(),
goose_version: env!("CARGO_PKG_VERSION").to_string(),
phase: input.phase,
stopping: input.stopping,
duration_secs: input.metrics.duration as u64,
active_users: input.active_users as u64,
maximum_users: input.maximum_users as u64,
target_users: input.target_users as u64,
total_users: input.total_users as u64,
hosts,
aggregate: AggregateMetrics {
total_requests: 0,
total_failures: 0,
requests_per_second: 0.0,
failures_per_second: 0.0,
failure_rate: 0.0,
response_time_avg_ms: 0.0,
response_time_min_ms: 0,
response_time_max_ms: 0,
percentile_ms: Percentiles::zero(),
response_time_us: None,
co_active: false,
co_adjusted: None,
},
requests: Vec::new(),
errors: Vec::new(),
transaction_errors: Vec::new(),
scenarios: Vec::new(),
transactions: Vec::new(),
series: SeriesWindow::empty(),
flags: SnapshotFlags {
metrics_disabled: true,
transaction_metrics_disabled: true,
scenario_metrics_disabled: true,
requests_truncated: false,
errors_truncated: false,
transaction_errors_truncated: false,
series_seconds: input.series_window_secs,
percentiles_over_time: false,
},
report: input.report,
settings: input.settings,
build_time: std::time::Duration::ZERO,
};
}
let duration = input.metrics.duration;
let mut aggregate_total_count: usize = 0;
let mut aggregate_fail_count: usize = 0;
let mut aggregate = GooseRequestMetricTimingData::new(None);
let co_active = input
.metrics
.coordinated_omission_metrics
.as_ref()
.map(|m| m.has_events())
.unwrap_or(false);
let mut co_aggregate: Option<GooseRequestMetricTimingData> =
co_active.then(|| GooseRequestMetricTimingData::new(None));
let mut request_rank: Vec<(&str, &GooseRequestMetricAggregate, usize)> =
Vec::with_capacity(input.metrics.requests.len());
for (key, request) in &input.metrics.requests {
let total_count = request.success_count + request.fail_count;
aggregate_total_count += total_count;
aggregate_fail_count += request.fail_count;
aggregate.merge(&request.raw_data);
if let Some(co_aggregate) = co_aggregate.as_mut() {
co_aggregate.merge(
request
.coordinated_omission_data
.as_ref()
.unwrap_or(&request.raw_data),
);
}
request_rank.push((key.as_str(), request, total_count));
}
request_rank.sort_by(|(_, a, a_count), (_, b, b_count)| {
b_count
.cmp(a_count)
.then_with(|| a.method_label().cmp(b.method_label()))
.then_with(|| a.path.cmp(&b.path))
});
let requests_truncated = request_rank.len() > MAX_REQUEST_ROWS;
request_rank.truncate(MAX_REQUEST_ROWS);
let request_rows: Vec<RequestRow> = request_rank
.into_iter()
.map(|(key, request, _)| {
request_row_from_aggregate(
request,
duration,
input.no_status_codes,
co_active,
input.series_keys.contains(key),
)
})
.collect();
let mut error_rank: Vec<&GooseErrorMetricAggregate> = input.metrics.errors.values().collect();
error_rank.sort_by(|a, b| {
b.occurrences
.cmp(&a.occurrences)
.then_with(|| a.method_label().cmp(b.method_label()))
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.error.cmp(&b.error))
});
let errors_truncated = error_rank.len() > MAX_ERROR_ROWS;
error_rank.truncate(MAX_ERROR_ROWS);
let error_rows: Vec<ErrorRow> = error_rank
.into_iter()
.map(error_row_from_aggregate)
.collect();
let mut transaction_error_rank: Vec<&GooseTransactionErrorMetricAggregate> =
input.metrics.transaction_errors.values().collect();
transaction_error_rank.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))
});
let transaction_errors_truncated = transaction_error_rank.len() > MAX_ERROR_ROWS;
transaction_error_rank.truncate(MAX_ERROR_ROWS);
let transaction_error_rows: Vec<TransactionErrorRow> = transaction_error_rank
.into_iter()
.map(|error| TransactionErrorRow {
transaction: error.transaction_number(),
scenario: error.scenario_name.clone(),
name: error.transaction_name.clone(),
error: error.error.clone(),
occurrences: error.occurrences as u64,
})
.collect();
let scenario_rows: Vec<ScenarioRow> = if input.no_scenario_metrics {
Vec::new()
} else {
input
.metrics
.scenarios
.iter()
.map(|scenario| scenario_row_from_aggregate(scenario, duration))
.collect()
};
let transaction_rows: Vec<TransactionRow> = if input.no_transaction_metrics {
Vec::new()
} else {
input
.metrics
.transactions
.iter()
.flatten()
.map(|transaction| transaction_row_from_aggregate(transaction, duration))
.collect()
};
let (rps, fps) = per_second_calculations(duration, aggregate_total_count, aggregate_fail_count);
let failure_rate = if aggregate_total_count > 0 {
aggregate_fail_count as f64 / aggregate_total_count as f64
} else {
0.0
};
let response_time_avg_ms = aggregate.average_ms_or_zero();
let response_time_us = MicrosecondTimes::of(&aggregate);
let mut hosts: Vec<String> = input.metrics.hosts.iter().cloned().collect();
hosts.sort();
DashboardSnapshot {
version: SNAPSHOT_VERSION,
generated_at: Utc::now(),
goose_version: env!("CARGO_PKG_VERSION").to_string(),
phase: input.phase,
stopping: input.stopping,
duration_secs: duration as u64,
active_users: input.active_users as u64,
maximum_users: input.maximum_users as u64,
target_users: input.target_users as u64,
total_users: input.total_users as u64,
hosts,
aggregate: AggregateMetrics {
total_requests: aggregate_total_count as u64,
total_failures: aggregate_fail_count as u64,
requests_per_second: rps as f64,
failures_per_second: fps as f64,
failure_rate,
response_time_avg_ms,
response_time_min_ms: aggregate.minimum_time_ms() as u64,
response_time_max_ms: aggregate.maximum_time_ms() as u64,
percentile_ms: Percentiles::of(response_time_us.as_ref()),
response_time_us,
co_active,
co_adjusted: co_aggregate.as_ref().map(CoAdjusted::from_timing_data),
},
requests: request_rows,
errors: error_rows,
transaction_errors: transaction_error_rows,
scenarios: scenario_rows,
transactions: transaction_rows,
series: input.series,
flags: SnapshotFlags {
metrics_disabled: false,
transaction_metrics_disabled: input.no_transaction_metrics,
scenario_metrics_disabled: input.no_scenario_metrics,
requests_truncated,
errors_truncated,
transaction_errors_truncated,
series_seconds: input.series_window_secs,
percentiles_over_time: input.percentiles_over_time,
},
report: input.report,
settings: input.settings,
build_time: std::time::Duration::ZERO,
}
}
fn request_row_from_aggregate(
request: &GooseRequestMetricAggregate,
duration: usize,
no_status_codes: bool,
co_active: bool,
has_series: bool,
) -> RequestRow {
let total_count = request.success_count + request.fail_count;
let (rps, fps) = per_second_calculations(duration, total_count, request.fail_count);
let avg = request.raw_data.average_ms_or_zero();
let response_time_us = MicrosecondTimes::of(&request.raw_data);
let status_codes = if no_status_codes {
Vec::new()
} else {
let mut codes: Vec<(u16, u64)> = request
.status_code_counts
.iter()
.map(|(&code, &count)| (code, count as u64))
.collect();
codes.sort_by_key(|(code, _)| *code);
codes
};
RequestRow {
method: request.method_label().to_string(),
name: request.path.clone(),
request_count: total_count as u64,
failure_count: request.fail_count as u64,
requests_per_second: rps as f64,
failures_per_second: fps as f64,
response_time_avg_ms: avg,
response_time_min_ms: request.raw_data.minimum_time_ms() as u64,
response_time_max_ms: request.raw_data.maximum_time_ms() as u64,
percentile_ms: Percentiles::of(response_time_us.as_ref()),
response_time_us,
status_codes,
co_adjusted: if co_active {
request
.coordinated_omission_data
.as_ref()
.map(CoAdjusted::from_timing_data)
} else {
None
},
has_series,
}
}
fn scenario_row_from_aggregate(scenario: &ScenarioMetricAggregate, duration: usize) -> ScenarioRow {
let times = &scenario.run_times;
let (runs_per_second, _) = per_second_calculations(duration, times.counter, 0);
let response_time_us = MicrosecondTimes::of(times);
ScenarioRow {
scenario_index: scenario.index as u64,
scenario_name: scenario.name.to_string(),
users: scenario.users.len() as u64,
run_count: times.counter as u64,
runs_per_second: runs_per_second as f64,
response_time_avg_ms: times.average_ms_or_zero(),
response_time_min_ms: times.minimum_time_ms() as u64,
response_time_max_ms: times.maximum_time_ms() as u64,
percentile_ms: Percentiles::of(response_time_us.as_ref()),
response_time_us: response_time_us.unwrap_or_default(),
}
}
fn transaction_row_from_aggregate(
transaction: &TransactionMetricAggregate,
duration: usize,
) -> TransactionRow {
let run_count = transaction.success_count + transaction.fail_count;
let (runs_per_second, failures_per_second) =
per_second_calculations(duration, run_count, transaction.fail_count);
let response_time_us = MicrosecondTimes::of(&transaction.run_times);
TransactionRow {
scenario_index: transaction.scenario_index as u64,
scenario_name: transaction.scenario_name.to_string(),
transaction_index: transaction.transaction_index as u64,
transaction_name: transaction
.transaction_name
.name_for_transaction()
.to_string(),
run_count: run_count as u64,
failure_count: transaction.fail_count as u64,
runs_per_second: runs_per_second as f64,
failures_per_second: failures_per_second as f64,
response_time_avg_ms: transaction.run_times.average_ms_or_zero(),
response_time_min_ms: transaction.run_times.minimum_time_ms() as u64,
response_time_max_ms: transaction.run_times.maximum_time_ms() as u64,
percentile_ms: Percentiles::of(response_time_us.as_ref()),
response_time_us: response_time_us.unwrap_or_default(),
}
}
fn error_row_from_aggregate(error: &GooseErrorMetricAggregate) -> ErrorRow {
ErrorRow {
method: error.method_label().to_string(),
name: error.name.clone(),
error: error.error.clone(),
occurrences: error.occurrences as u64,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::goose::{GooseMethod, TransactionName};
use crate::metrics::coordinated_omission::CoordinatedOmissionMetrics;
use crate::metrics::GooseCoordinatedOmissionMitigation;
use crate::metrics::GooseRequestMetricAggregate;
use std::sync::Arc;
use std::time::Duration;
fn empty_metrics() -> GooseMetrics {
GooseMetrics::default()
}
fn make_request(
path: &str,
successes: usize,
failures: usize,
times: &[(usize, usize)],
) -> GooseRequestMetricAggregate {
let mut agg = GooseRequestMetricAggregate::new(path, GooseMethod::Get, 0, "");
agg.success_count = successes;
agg.fail_count = failures;
for &(time, count) in times {
for _ in 0..count {
agg.raw_data.record_time(time as u64);
}
}
agg
}
#[test]
fn builder_empty_metrics() {
let metrics = empty_metrics();
let snap = build_dashboard_snapshot(DashboardSnapshotInput {
metrics: &metrics,
series: SeriesWindow::empty(),
active_users: 0,
maximum_users: 10,
target_users: 10,
total_users: 10,
phase: "idle".to_string(),
stopping: false,
series_window_secs: SERIES_WINDOW_SECS,
no_status_codes: false,
metrics_disabled: false,
no_transaction_metrics: false,
no_scenario_metrics: false,
report: SnapshotReport::default(),
settings: SnapshotSettings::idle(),
percentiles_over_time: false,
series_keys: HashSet::new(),
});
assert_eq!(snap.version, 1);
assert_eq!(snap.phase, "idle");
assert_eq!(snap.maximum_users, 10);
assert_eq!(snap.target_users, 10);
assert_eq!(snap.aggregate.total_requests, 0);
assert!(!snap.aggregate.co_active);
assert!(snap.requests.is_empty());
assert!(snap.errors.is_empty());
assert!(!snap.flags.requests_truncated);
assert!(!snap.flags.errors_truncated);
assert_eq!(snap.flags.series_seconds, SERIES_WINDOW_SECS);
}
#[test]
fn builder_caps_request_and_error_rows() {
let mut metrics = empty_metrics();
metrics.duration = 10;
for i in 0..(MAX_REQUEST_ROWS + 25) {
let path = format!("/path-{i}");
let count = i + 1;
metrics.requests.insert(
format!("GET {path}"),
make_request(&path, count, 0, &[(10, count)]),
);
}
for i in 0..(MAX_ERROR_ROWS + 10) {
let key = format!("err-{i}");
let mut err = GooseErrorMetricAggregate::new(
GooseMethod::Get,
format!("/e{i}"),
format!("error {i}"),
"",
);
err.occurrences = i + 1;
metrics.errors.insert(key, err);
}
let snap = build_dashboard_snapshot(DashboardSnapshotInput {
metrics: &metrics,
series: SeriesWindow::empty(),
active_users: 5,
maximum_users: 5,
target_users: 5,
total_users: 5,
phase: "maintain".to_string(),
stopping: false,
series_window_secs: 60,
no_status_codes: true,
metrics_disabled: false,
no_transaction_metrics: false,
no_scenario_metrics: false,
report: SnapshotReport::default(),
settings: SnapshotSettings::idle(),
percentiles_over_time: false,
series_keys: HashSet::new(),
});
assert_eq!(snap.requests.len(), MAX_REQUEST_ROWS);
assert!(snap.flags.requests_truncated);
assert_eq!(
snap.requests[0].request_count,
(MAX_REQUEST_ROWS + 25) as u64
);
assert!(snap.requests[0].status_codes.is_empty());
assert_eq!(snap.errors.len(), MAX_ERROR_ROWS);
assert!(snap.flags.errors_truncated);
assert_eq!(snap.errors[0].occurrences, (MAX_ERROR_ROWS + 10) as u64);
}
#[test]
fn co_active_requires_events_not_just_enabled() {
let mut metrics = empty_metrics();
metrics.coordinated_omission_metrics = Some(CoordinatedOmissionMetrics::new(
GooseCoordinatedOmissionMitigation::Average,
));
let snap = build_dashboard_snapshot(DashboardSnapshotInput {
metrics: &metrics,
series: SeriesWindow::empty(),
active_users: 0,
maximum_users: 0,
target_users: 0,
total_users: 0,
phase: "maintain".to_string(),
stopping: false,
series_window_secs: 300,
no_status_codes: false,
metrics_disabled: false,
no_transaction_metrics: false,
no_scenario_metrics: false,
report: SnapshotReport::default(),
settings: SnapshotSettings::idle(),
percentiles_over_time: false,
series_keys: HashSet::new(),
});
assert!(!snap.aggregate.co_active);
metrics
.coordinated_omission_metrics
.as_mut()
.unwrap()
.record_co_event(
Duration::from_millis(100),
Duration::from_millis(1000),
5,
0,
"scenario".to_string(),
);
let snap = build_dashboard_snapshot(DashboardSnapshotInput {
metrics: &metrics,
series: SeriesWindow::empty(),
active_users: 0,
maximum_users: 0,
target_users: 0,
total_users: 0,
phase: "maintain".to_string(),
stopping: false,
series_window_secs: 300,
no_status_codes: false,
metrics_disabled: false,
no_transaction_metrics: false,
no_scenario_metrics: false,
report: SnapshotReport::default(),
settings: SnapshotSettings::idle(),
percentiles_over_time: false,
series_keys: HashSet::new(),
});
assert!(snap.aggregate.co_active);
}
fn co_input(metrics: &GooseMetrics) -> DashboardSnapshotInput<'_> {
DashboardSnapshotInput {
metrics,
series: SeriesWindow::empty(),
active_users: 0,
maximum_users: 0,
target_users: 0,
total_users: 0,
phase: "maintain".to_string(),
stopping: false,
series_window_secs: 300,
no_status_codes: false,
metrics_disabled: false,
no_transaction_metrics: false,
no_scenario_metrics: false,
report: SnapshotReport::default(),
settings: SnapshotSettings::idle(),
percentiles_over_time: false,
series_keys: HashSet::new(),
}
}
fn add_co_requests(metrics: &mut GooseMetrics) {
let mut a = GooseRequestMetricAggregate::new("/a", GooseMethod::Get, 0, "");
a.success_count = 20;
for _ in 0..20 {
a.record_time(10, false);
}
a.record_time(5000, true);
a.record_time(5000, true);
metrics.requests.insert("GET /a".to_string(), a);
metrics
.requests
.insert("GET /b".to_string(), make_request("/b", 10, 0, &[(20, 10)]));
}
fn record_one_co_event(metrics: &mut GooseMetrics) {
metrics
.coordinated_omission_metrics
.as_mut()
.unwrap()
.record_co_event(
Duration::from_millis(100),
Duration::from_millis(1000),
5,
0,
"scenario".to_string(),
);
}
#[test]
fn co_adjusted_carries_synthetic_times_and_percentile_ms_stays_measured() {
let mut metrics = empty_metrics();
metrics.coordinated_omission_metrics = Some(CoordinatedOmissionMetrics::new(
GooseCoordinatedOmissionMitigation::Average,
));
record_one_co_event(&mut metrics);
add_co_requests(&mut metrics);
let snap = build_dashboard_snapshot(co_input(&metrics));
assert!(snap.aggregate.co_active);
let a = snap.requests.iter().find(|r| r.name == "/a").unwrap();
assert_eq!(a.percentile_ms.p95, 10);
assert_eq!(a.percentile_ms.p99, 10);
assert_eq!(a.response_time_avg_ms, 10.0);
assert_eq!(a.response_time_max_ms, 10);
let a_co = a.co_adjusted.as_ref().expect("/a has adjusted data");
assert_eq!(a_co.percentile_ms.p50, 10);
assert_eq!(a_co.percentile_ms.p95, 5000);
assert_eq!(a_co.percentile_ms.p99, 5000);
assert_eq!(a_co.response_time_max_ms, 5000);
assert!((a_co.response_time_avg_ms - 10_200.0 / 22.0).abs() < 1e-9);
let b = snap.requests.iter().find(|r| r.name == "/b").unwrap();
assert!(b.co_adjusted.is_none());
assert_eq!(snap.aggregate.percentile_ms.p99, 20);
assert_eq!(snap.aggregate.response_time_max_ms, 20);
let agg_co = snap.aggregate.co_adjusted.as_ref().expect("adjusted");
assert_eq!(agg_co.percentile_ms.p50, 10);
assert_eq!(agg_co.percentile_ms.p95, 20);
assert_eq!(agg_co.percentile_ms.p99, 5000);
assert_eq!(agg_co.response_time_max_ms, 5000);
assert!((agg_co.response_time_avg_ms - 10_400.0 / 32.0).abs() < 1e-9);
}
#[test]
fn co_adjusted_absent_without_mitigation_events() {
let mut metrics = empty_metrics();
add_co_requests(&mut metrics);
let snap = build_dashboard_snapshot(co_input(&metrics));
assert!(snap.aggregate.co_adjusted.is_none());
assert!(snap.requests.iter().all(|r| r.co_adjusted.is_none()));
metrics.coordinated_omission_metrics = Some(CoordinatedOmissionMetrics::new(
GooseCoordinatedOmissionMitigation::Average,
));
let snap = build_dashboard_snapshot(co_input(&metrics));
assert!(!snap.aggregate.co_active);
assert!(snap.aggregate.co_adjusted.is_none());
assert!(snap.requests.iter().all(|r| r.co_adjusted.is_none()));
}
#[test]
fn builder_aggregates_request_counts_and_percentiles() {
let mut metrics = empty_metrics();
metrics.duration = 10;
metrics
.requests
.insert("GET /a".to_string(), make_request("/a", 8, 2, &[(20, 10)]));
metrics
.requests
.insert("GET /b".to_string(), make_request("/b", 5, 0, &[(40, 5)]));
let snap = build_dashboard_snapshot(DashboardSnapshotInput {
metrics: &metrics,
series: SeriesWindow::empty(),
active_users: 3,
maximum_users: 10,
target_users: 10,
total_users: 10,
phase: "increase".to_string(),
stopping: false,
series_window_secs: 300,
no_status_codes: false,
metrics_disabled: false,
no_transaction_metrics: false,
no_scenario_metrics: false,
report: SnapshotReport::default(),
settings: SnapshotSettings::idle(),
percentiles_over_time: false,
series_keys: HashSet::new(),
});
assert_eq!(snap.aggregate.total_requests, 15);
assert_eq!(snap.aggregate.total_failures, 2);
assert!((snap.aggregate.requests_per_second - 1.5).abs() < 0.001);
assert!((snap.aggregate.failure_rate - (2.0 / 15.0)).abs() < 0.0001);
assert_eq!(snap.requests.len(), 2);
assert_eq!(snap.requests[0].name, "/a");
assert_eq!(snap.requests[0].request_count, 10);
assert_eq!(snap.active_users, 3);
assert_eq!(snap.target_users, 10);
assert!(snap.aggregate.response_time_avg_ms > 0.0);
assert!(snap.aggregate.percentile_ms.p50 > 0);
}
#[test]
fn metrics_disabled_forces_empty_tables_and_series() {
let mut metrics = empty_metrics();
metrics.duration = 42;
metrics.hosts.insert("https://example.com".to_string());
metrics
.requests
.insert("GET /a".to_string(), make_request("/a", 8, 2, &[(20, 10)]));
let mut err = GooseErrorMetricAggregate::new(
GooseMethod::Get,
"/a".to_string(),
"boom".to_string(),
"",
);
err.occurrences = 3;
metrics.errors.insert("err".to_string(), err);
add_scenario_metrics(&mut metrics);
let series = SeriesWindow {
start_second: 10,
rps: vec![1.0, 2.0],
fps: vec![0.0, 0.5],
users: vec![1, 2],
avg_latency_ms: vec![10.0, 20.0],
heatmap: None,
};
let snap = build_dashboard_snapshot(DashboardSnapshotInput {
metrics: &metrics,
series,
active_users: 7,
maximum_users: 10,
target_users: 10,
total_users: 10,
phase: "maintain".to_string(),
stopping: true,
series_window_secs: 120,
no_status_codes: false,
metrics_disabled: true,
no_transaction_metrics: false,
no_scenario_metrics: false,
report: SnapshotReport::default(),
settings: SnapshotSettings::idle(),
percentiles_over_time: false,
series_keys: HashSet::new(),
});
assert!(snap.flags.metrics_disabled);
assert!(!snap.flags.requests_truncated);
assert!(!snap.flags.errors_truncated);
assert_eq!(snap.flags.series_seconds, 120);
assert!(snap.requests.is_empty());
assert!(snap.errors.is_empty());
assert!(snap.scenarios.is_empty());
assert!(snap.transactions.is_empty());
assert!(snap.flags.transaction_metrics_disabled);
assert!(snap.flags.scenario_metrics_disabled);
assert_eq!(snap.series, SeriesWindow::empty());
assert_eq!(snap.aggregate.total_requests, 0);
assert_eq!(snap.aggregate.total_failures, 0);
assert!(!snap.aggregate.co_active);
assert_eq!(snap.phase, "maintain");
assert!(snap.stopping);
assert_eq!(snap.duration_secs, 42);
assert_eq!(snap.active_users, 7);
assert_eq!(snap.target_users, 10);
assert_eq!(snap.hosts, vec!["https://example.com".to_string()]);
}
fn make_transaction(
scenario_index: usize,
scenario_name: &str,
transaction_index: usize,
transaction_name: &str,
times: &[(u64, bool)],
) -> TransactionMetricAggregate {
let mut agg = TransactionMetricAggregate::new(
scenario_index,
Arc::from(scenario_name),
transaction_index,
TransactionName::TransactionOnly(Arc::from(transaction_name)),
);
for &(time, success) in times {
agg.set_time(time, success);
}
agg
}
fn make_scenario(index: usize, name: &str, runs: &[(u64, usize)]) -> ScenarioMetricAggregate {
let mut agg = ScenarioMetricAggregate::new(index, Arc::from(name));
for &(time, user) in runs {
agg.update(time, user);
}
agg
}
fn add_scenario_metrics(metrics: &mut GooseMetrics) {
metrics.scenarios = vec![
make_scenario(0, "Alpha", &[(100, 0), (200, 1), (300, 0), (400, 1)]),
make_scenario(1, "Beta", &[(50, 2), (70, 2)]),
];
metrics.transactions = vec![
vec![
make_transaction(
0,
"Alpha",
0,
"login",
&[(10, true), (20, true), (30, true)],
),
make_transaction(
0,
"Alpha",
1,
"",
&[(40, true), (60, false), (80, false), (100, true)],
),
],
vec![
make_transaction(1, "Beta", 0, "front", &[(5, true)]),
make_transaction(1, "Beta", 1, "about", &[]),
],
];
}
fn scenario_input(metrics: &GooseMetrics) -> DashboardSnapshotInput<'_> {
DashboardSnapshotInput {
metrics,
series: SeriesWindow::empty(),
active_users: 3,
maximum_users: 3,
target_users: 3,
total_users: 3,
phase: "maintain".to_string(),
stopping: false,
series_window_secs: SERIES_WINDOW_SECS,
no_status_codes: false,
metrics_disabled: false,
no_transaction_metrics: false,
no_scenario_metrics: false,
report: SnapshotReport::default(),
settings: SnapshotSettings::idle(),
percentiles_over_time: false,
series_keys: HashSet::new(),
}
}
#[test]
fn builder_scenario_and_transaction_rows() {
let mut metrics = empty_metrics();
metrics.duration = 4;
add_scenario_metrics(&mut metrics);
let snap = build_dashboard_snapshot(scenario_input(&metrics));
assert!(!snap.flags.scenario_metrics_disabled);
assert!(!snap.flags.transaction_metrics_disabled);
let names: Vec<&str> = snap
.scenarios
.iter()
.map(|s| s.scenario_name.as_str())
.collect();
assert_eq!(names, vec!["Alpha", "Beta"]);
let alpha = &snap.scenarios[0];
assert_eq!(alpha.scenario_index, 0);
assert_eq!(alpha.users, 2);
assert_eq!(alpha.run_count, 4);
assert!((alpha.runs_per_second - 1.0).abs() < 1e-9);
assert!((alpha.response_time_avg_ms - 250.0).abs() < 1e-9);
assert_eq!(alpha.response_time_min_ms, 100);
assert_eq!(alpha.response_time_max_ms, 400);
assert_eq!(alpha.percentile_ms.p50, 199);
assert_eq!(alpha.response_time_us.p50, 199_680);
assert_eq!(
crate::metrics::format_micros(alpha.response_time_us.p50, true),
"200"
);
assert_eq!(alpha.percentile_ms.p95, 400);
assert_eq!(alpha.percentile_ms.p99, 400);
let beta = &snap.scenarios[1];
assert_eq!(beta.scenario_index, 1);
assert_eq!(beta.users, 1);
assert_eq!(beta.run_count, 2);
assert!((beta.response_time_avg_ms - 60.0).abs() < 1e-9);
let order: Vec<(u64, u64)> = snap
.transactions
.iter()
.map(|t| (t.scenario_index, t.transaction_index))
.collect();
assert_eq!(order, vec![(0, 0), (0, 1), (1, 0), (1, 1)]);
let login = &snap.transactions[0];
assert_eq!(login.scenario_name, "Alpha");
assert_eq!(login.transaction_name, "login");
assert_eq!(login.run_count, 3);
assert_eq!(login.failure_count, 0);
assert!((login.runs_per_second - 0.75).abs() < 1e-6);
assert_eq!(login.failures_per_second, 0.0);
assert!((login.response_time_avg_ms - 20.0).abs() < 1e-9);
assert_eq!(login.response_time_min_ms, 10);
assert_eq!(login.response_time_max_ms, 30);
assert_eq!(login.percentile_ms.p50, 20);
assert_eq!(login.percentile_ms.p99, 30);
let failing = &snap.transactions[1];
assert_eq!(failing.transaction_name, "");
assert_eq!(failing.run_count, 4);
assert_eq!(failing.failure_count, 2);
assert!((failing.runs_per_second - 1.0).abs() < 1e-6);
assert!((failing.failures_per_second - 0.5).abs() < 1e-6);
assert!((failing.response_time_avg_ms - 70.0).abs() < 1e-9);
assert_eq!(failing.response_time_min_ms, 40);
assert_eq!(failing.response_time_max_ms, 100);
assert_eq!(failing.percentile_ms.p50, 60);
assert_eq!(failing.percentile_ms.p95, 100);
let never_run = &snap.transactions[3];
assert_eq!(never_run.scenario_name, "Beta");
assert_eq!(never_run.transaction_name, "about");
assert_eq!(never_run.run_count, 0);
assert_eq!(never_run.response_time_avg_ms, 0.0);
assert_eq!(never_run.percentile_ms.p50, 0);
}
#[test]
fn never_run_scenario_serializes_without_null() {
let mut metrics = empty_metrics();
metrics.scenarios = vec![make_scenario(0, "Idle", &[])];
metrics.transactions = vec![vec![make_transaction(0, "Idle", 0, "t", &[])]];
let snap = build_dashboard_snapshot(scenario_input(&metrics));
let scenario = &snap.scenarios[0];
assert_eq!(scenario.run_count, 0);
assert_eq!(scenario.users, 0);
assert_eq!(scenario.runs_per_second, 0.0);
assert_eq!(scenario.response_time_avg_ms, 0.0);
assert_eq!(scenario.percentile_ms.p50, 0);
assert_eq!(snap.transactions[0].response_time_avg_ms, 0.0);
let value = serde_json::to_value(&snap).expect("serialize snapshot");
for rows in ["scenarios", "transactions"] {
let json = value[rows].to_string();
assert!(!json.contains("null"), "{}: {}", rows, json);
}
}
#[test]
fn no_transaction_metrics_empties_only_transactions() {
let mut metrics = empty_metrics();
metrics.duration = 4;
add_scenario_metrics(&mut metrics);
let mut input = scenario_input(&metrics);
input.no_transaction_metrics = true;
let snap = build_dashboard_snapshot(input);
assert!(snap.transactions.is_empty());
assert!(snap.flags.transaction_metrics_disabled);
assert!(!snap.flags.scenario_metrics_disabled);
assert!(!snap.flags.metrics_disabled);
assert_eq!(snap.scenarios.len(), 2);
}
#[test]
fn no_scenario_metrics_empties_only_scenarios() {
let mut metrics = empty_metrics();
metrics.duration = 4;
add_scenario_metrics(&mut metrics);
let mut input = scenario_input(&metrics);
input.no_scenario_metrics = true;
let snap = build_dashboard_snapshot(input);
assert!(snap.scenarios.is_empty());
assert!(snap.flags.scenario_metrics_disabled);
assert!(!snap.flags.transaction_metrics_disabled);
assert!(!snap.flags.metrics_disabled);
assert_eq!(snap.transactions.len(), 4);
}
const DASHBOARD_TYPES_PATH: &str = "src/dashboard/static/snapshot.d.ts";
const UPDATE_DASHBOARD_TYPES_ENV: &str = "GOOSE_UPDATE_DASHBOARD_TYPES";
fn dashboard_types_ts() -> String {
use std::any::TypeId;
use ts_rs::{TypeVisitor, TS};
struct Declarations {
cfg: ts_rs::Config,
seen: Vec<TypeId>,
decls: Vec<String>,
}
impl TypeVisitor for Declarations {
fn visit<T: TS + 'static + ?Sized>(&mut self) {
if T::output_path().is_none() || self.seen.contains(&TypeId::of::<T>()) {
return;
}
self.seen.push(TypeId::of::<T>());
self.decls.push(T::decl(&self.cfg));
T::visit_dependencies(self);
}
}
let mut declarations = Declarations {
cfg: ts_rs::Config::new().with_large_int("number"),
seen: Vec::new(),
decls: Vec::new(),
};
declarations.visit::<DashboardSnapshot>();
declarations.visit::<RequestSeries>();
declarations.decls.sort();
let mut out = format!(
"// Generated from src/metrics/dashboard_snapshot.rs by the unit test\n\
// `dashboard_types_match_rust`. Do not edit. To regenerate, run:\n\
// {UPDATE_DASHBOARD_TYPES_ENV}=1 cargo test --lib dashboard_types_match_rust\n\
\n\
/** The only `DashboardSnapshot.version` this client accepts. */\n\
type DashboardSnapshotVersion = {SNAPSHOT_VERSION};\n"
);
for decl in declarations.decls {
out.push('\n');
for line in decl.lines() {
out.push_str(line.trim_end());
out.push('\n');
}
}
out
}
#[test]
fn dashboard_types_match_rust() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(DASHBOARD_TYPES_PATH);
let generated = dashboard_types_ts();
if std::env::var_os(UPDATE_DASHBOARD_TYPES_ENV).is_some() {
assert!(
std::env::var_os("CI").is_none(),
"{} must not be set in CI",
UPDATE_DASHBOARD_TYPES_ENV
);
std::fs::write(&path, &generated).expect("write dashboard types");
return;
}
let committed = std::fs::read_to_string(&path).expect("read dashboard types");
assert!(
committed == generated,
"{} does not match the snapshot structs. Regenerate it with \
`{}=1 cargo test --lib dashboard_types_match_rust`, then type check \
the client (`npm run check` in src/dashboard/static).\n--- generated ---\n{}",
DASHBOARD_TYPES_PATH,
UPDATE_DASHBOARD_TYPES_ENV,
generated
);
}
#[test]
fn snapshot_json_comes_from_plain_derives() {
let source = include_str!("dashboard_snapshot.rs");
let structs = source
.split("\nmod tests {")
.next()
.expect("source")
.replace("#[serde(skip)]", "");
for pattern in ["serde(", "impl Serialize", "impl serde::Serialize"] {
assert!(
!structs.contains(pattern),
"`{}` in dashboard_snapshot.rs: snapshot.d.ts may no longer \
match the JSON; check the generated types by hand",
pattern
);
}
}
}