From ef178bdd64a208c8e108e9edfc65b6df0425fa9a Mon Sep 17 00:00:00 2001 From: Jens Reimann Date: Tue, 30 Jul 2024 11:47:11 +0200 Subject: [PATCH] feat: implement markdown report --- src/metrics.rs | 23 ++- src/metrics/common.rs | 2 +- src/report.rs | 3 + src/report/markdown.rs | 310 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 335 insertions(+), 3 deletions(-) create mode 100644 src/report/markdown.rs diff --git a/src/metrics.rs b/src/metrics.rs index 1110a32d..76ee4491 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -10,10 +10,12 @@ mod common; +pub(crate) use common::ReportData; + use crate::config::GooseDefaults; use crate::goose::{get_base_url, GooseMethod, Scenario}; use crate::logger::GooseLog; -use crate::metrics::common::{ReportData, ReportOptions}; +use crate::metrics::common::ReportOptions; use crate::report; use crate::test_plan::{TestPlanHistory, TestPlanStepAction}; use crate::util; @@ -2426,7 +2428,7 @@ impl GooseMetrics { } // Determine the seconds, minutes and hours between two chrono:DateTimes. - fn get_seconds_minutes_hours( + pub(crate) fn get_seconds_minutes_hours( &self, start: &chrono::DateTime, end: &chrono::DateTime, @@ -3022,6 +3024,9 @@ impl GooseAttack { Some("json") => { self.write_json_report(create(path).await?).await?; } + Some("md") => { + self.write_markdown_report(create(path).await?).await?; + } None => { return Err(GooseError::InvalidOption { option: "--report-file".to_string(), @@ -3058,6 +3063,20 @@ impl GooseAttack { Ok(()) } + /// Write a Markdown report. + pub(crate) async fn write_markdown_report(&self, report_file: File) -> Result<(), GooseError> { + let data = common::prepare_data( + ReportOptions { + no_transaction_metrics: self.configuration.no_transaction_metrics, + no_scenario_metrics: self.configuration.no_scenario_metrics, + no_status_codes: self.configuration.no_status_codes, + }, + &self.metrics, + ); + + report::write_markdown_report(&mut BufWriter::new(report_file.into_std().await), data) + } + // Write an HTML-formatted report. pub(crate) async fn write_html_report( &self, diff --git a/src/metrics/common.rs b/src/metrics/common.rs index e6a89dd2..b337c111 100644 --- a/src/metrics/common.rs +++ b/src/metrics/common.rs @@ -13,7 +13,7 @@ use itertools::Itertools; use std::collections::{BTreeMap, HashMap}; #[derive(Debug, serde::Serialize)] -pub struct ReportData<'m> { +pub(crate) struct ReportData<'m> { pub raw_metrics: &'m GooseMetrics, pub raw_request_metrics: Vec, diff --git a/src/report.rs b/src/report.rs index c89ee9a1..9a5ceae8 100644 --- a/src/report.rs +++ b/src/report.rs @@ -1,5 +1,8 @@ //! Optionally writes an html-formatted summary report after running a load test. mod common; +mod markdown; + +pub(crate) use markdown::write_markdown_report; use crate::{ metrics::{self, format_number}, diff --git a/src/report/markdown.rs b/src/report/markdown.rs new file mode 100644 index 00000000..3c5c1068 --- /dev/null +++ b/src/report/markdown.rs @@ -0,0 +1,310 @@ +use crate::{ + metrics::{format_number, GooseErrorMetricAggregate, ReportData}, + report::{ + common::OrEmpty, RequestMetric, ResponseMetric, ScenarioMetric, StatusCodeMetric, + TransactionMetric, + }, + test_plan::TestPlanStepAction, + GooseError, +}; +use chrono::{Local, TimeZone}; +use std::io::Write; + +struct Markdown<'m, 'w, W: Write> { + w: &'w mut W, + data: ReportData<'m>, +} + +pub(crate) fn write_markdown_report( + w: &mut W, + data: ReportData, +) -> Result<(), GooseError> { + Markdown { w, data }.write() +} + +impl<'m, 'w, W: Write> Markdown<'m, 'w, W> { + pub fn write(mut self) -> Result<(), GooseError> { + self.write_header()?; + self.write_plan_overview()?; + self.write_request_metrics()?; + self.write_response_metrics()?; + self.write_status_code_metrics()?; + self.write_transaction_metrics()?; + self.write_scenario_metrics()?; + self.write_error_metrics()?; + + Ok(()) + } + + fn write_header(&mut self) -> Result<(), GooseError> { + writeln!( + self.w, + r#" +# Goose Attack Report +"# + )?; + + Ok(()) + } + + fn write_plan_overview(&mut self) -> Result<(), GooseError> { + write!( + self.w, + r#" +## Plan Overview + +| Action | Started | Stopped | Elapsed | Users | +| ------ | ------- | ------- | ------- | ----: | +"# + )?; + + for step in self.data.raw_metrics.history.windows(2) { + let (seconds, minutes, hours) = self + .data + .raw_metrics + .get_seconds_minutes_hours(&step[0].timestamp, &step[1].timestamp); + let started = Local + .timestamp_opt(step[0].timestamp.timestamp(), 0) + // @TODO: error handling + .unwrap() + .format("%y-%m-%d %H:%M:%S"); + let stopped = Local + .timestamp_opt(step[1].timestamp.timestamp(), 0) + // @TODO: error handling + .unwrap() + .format("%y-%m-%d %H:%M:%S"); + + let users = match &step[0].action { + // For maintaining just show the current number of users. + TestPlanStepAction::Maintaining => { + format!("{}", step[0].users) + } + // For increasing show the current number of users to the new number of users. + TestPlanStepAction::Increasing => { + format!("{} → {}", step[0].users, step[1].users) + } + // For decreasing show the new number of users from the current number of users. + TestPlanStepAction::Decreasing | TestPlanStepAction::Canceling => { + format!("{} ← {}", step[1].users, step[0].users,) + } + TestPlanStepAction::Finished => { + unreachable!("there shouldn't be a step after finished"); + } + }; + + writeln!( + self.w, + r#"| {action:?} | {started} | {stopped} | {hours:02}:{minutes:02}:{seconds:02} | {users} |"#, + action = step[0].action, + )?; + } + + Ok(()) + } + + fn write_request_metrics(&mut self) -> Result<(), GooseError> { + write!( + self.w, + r#" +## Request Metrics + +| Method | Name | # Requests | # Fails | Average (ms) | Min (ms) | Max (ms) | RPS | Failures/s | +| ------ | ---- | ---------: | ------: | -----------: | -------: | -------: | --: | ---------: | +"# + )?; + + for RequestMetric { + method, + name, + number_of_requests, + number_of_failures, + response_time_average, + response_time_minimum, + response_time_maximum, + requests_per_second, + failures_per_second, + } in &self.data.raw_request_metrics + { + writeln!( + self.w, + r#"| {method} | {name} | {number_of_requests} | {number_of_failures } | {response_time_average:.2 } | {response_time_minimum} | {response_time_maximum} | {requests_per_second:.2} | {failures_per_second:.2} |"#, + )?; + } + + Ok(()) + } + + fn write_response_metrics(&mut self) -> Result<(), GooseError> { + write!( + self.w, + r#" +## Response Time Metrics + +| Method | Name | 50%ile (ms) | 50%ile (ms) | 60%ile (ms) | 70%ile (ms) | 70%ile (ms) | 80%ile (ms) | 90%ile (ms) | 95%ile (ms) | 99%ile (ms) | 100%ile (ms) | +| ------ | ---- | ----------: | ----------: | ----------: | ----------: | ----------: | ----------: | ----------: | ----------: | ----------: | -----------: | +"# + )?; + + for ResponseMetric { + method, + name, + percentile_50, + percentile_60, + percentile_70, + percentile_80, + percentile_90, + percentile_95, + percentile_99, + percentile_100, + } in &self.data.raw_response_metrics + { + writeln!( + self.w, + r#"| {method} | {name} | {percentile_50} | {percentile_60 } | {percentile_70 } | {percentile_80} | {percentile_90} | {percentile_95} | {percentile_99} | {percentile_100} |"#, + percentile_50 = format_number(*percentile_50), + percentile_60 = format_number(*percentile_60), + percentile_70 = format_number(*percentile_70), + percentile_80 = format_number(*percentile_80), + percentile_90 = format_number(*percentile_90), + percentile_95 = format_number(*percentile_95), + percentile_99 = format_number(*percentile_99), + percentile_100 = format_number(*percentile_100), + )?; + } + + Ok(()) + } + + fn write_status_code_metrics(&mut self) -> Result<(), GooseError> { + let Some(status_code_metrics) = &self.data.status_code_metrics else { + return Ok(()); + }; + + write!( + self.w, + r#" +## Status Code Metrics + +| Method | Name | Status Codes | +| ------ | ---- | ------------ | +"# + )?; + + for StatusCodeMetric { + method, + name, + status_codes, + } in status_code_metrics + { + writeln!(self.w, r#"| {method} | {name} | {status_codes} |"#)?; + } + + Ok(()) + } + + fn write_transaction_metrics(&mut self) -> Result<(), GooseError> { + let Some(transaction_metrics) = &self.data.transaction_metrics else { + return Ok(()); + }; + + write!( + self.w, + r#" +## Transaction Metrics + +| Transaction | # Times Run | # Fails | Average (ms) | Min (ms) | Max (ms) | RPS | Failures/s | +| ----------- | ----------: | ------: | -----------: | -------: | -------: | --: | ---------: | +"# + )?; + + for TransactionMetric { + is_scenario, + transaction, + name, + number_of_requests, + number_of_failures, + response_time_average, + response_time_minimum, + response_time_maximum, + requests_per_second, + failures_per_second, + } in transaction_metrics + { + match is_scenario { + true => writeln!(self.w, r#"| **{name}** |"#)?, + false => writeln!( + self.w, + r#"| {transaction} {name} | {number_of_requests} | {number_of_failures} | {response_time_average:.2} | {response_time_minimum} | {response_time_maximum} | {requests_per_second:.2} | {failures_per_second:.2} |"#, + response_time_average = OrEmpty(*response_time_average), + requests_per_second = OrEmpty(*requests_per_second), + failures_per_second = OrEmpty(*failures_per_second), + )?, + } + } + + Ok(()) + } + + fn write_scenario_metrics(&mut self) -> Result<(), GooseError> { + let Some(scenario_metrics) = &self.data.scenario_metrics else { + return Ok(()); + }; + + write!( + self.w, + r#" +## Scenario Metrics + +| Transaction | # Users | # Times Run | Average (ms) | Min (ms) | Max (ms) | Scenarios/s | Iterations | +| ----------- | ------: | ----------: | -----------: | -------: | -------: | ----------: | ---------: | +"# + )?; + + for ScenarioMetric { + name, + users, + count, + response_time_average, + response_time_minimum, + response_time_maximum, + count_per_second, + iterations, + } in scenario_metrics + { + writeln!( + self.w, + r#"| {name} | {users} | {count} | {response_time_average:.2} | {response_time_minimum} | {response_time_maximum} | {count_per_second:.2} | {iterations:.2} |"# + )?; + } + + Ok(()) + } + + fn write_error_metrics(&mut self) -> Result<(), GooseError> { + let Some(errors) = &self.data.errors else { + return Ok(()); + }; + + write!( + self.w, + r#" +## Error Metrics + +| Method | Name | # | Error | +| ------ | ---- | --: | ----- | +"# + )?; + + for GooseErrorMetricAggregate { + method, + name, + error, + occurrences, + } in errors + { + writeln!(self.w, r#"| {method} | {name} | {occurrences} | {error} |"#)?; + } + + Ok(()) + } +}