use crate::config::{GooseConfiguration, RUN_FOLDER};
use crate::graph::GraphData;
use crate::logger::{move_goose_log, GooseLogFormat};
#[cfg(feature = "dashboard")]
use crate::metrics::dashboard_snapshot::SnapshotReport;
#[cfg(feature = "dashboard")]
use crate::metrics::MetricsCommand;
use crate::metrics::{load_baseline_file, write_folder_report, GooseMetrics, LoadedReport};
#[cfg(feature = "dashboard")]
use crate::{ControlOutcome, GooseAttackRunState};
use crate::{GooseAttack, GooseError};
use chrono::{DateTime, Local, SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
#[cfg(feature = "dashboard")]
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
pub(crate) const DEFAULT_PARENT: &str = "goose-runs";
pub(crate) const REPORT_HTML: &str = "report.html";
pub(crate) const REPORT_JSON: &str = "report.json";
#[cfg(feature = "dashboard")]
pub(crate) const REPORT_MD: &str = "report.md";
pub(crate) const RESERVED: &str = ".goose-run";
pub(crate) const GOOSE_LOG: &str = "goose.log";
pub(crate) const LOG_NAMES: [&str; 5] =
["requests", "transactions", "scenarios", "errors", "debug"];
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum EndedBy {
Completed,
Stopped,
Canceled,
UsersExited,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub(crate) struct RunRecord {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default)]
pub goose_version: String,
#[serde(default)]
pub test: String,
#[serde(default)]
pub started: Option<String>,
#[serde(default)]
pub ended: Option<String>,
#[serde(default)]
pub ended_by: Option<EndedBy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canceled_reason: Option<String>,
#[serde(default)]
pub in_progress: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graphs_start: Option<GraphsStart>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct GraphsStart {
pub time: String,
pub reason: String,
}
impl GraphsStart {
pub(crate) fn describe(&self) -> String {
let time = DateTime::parse_from_rfc3339(&self.time)
.map(|time| {
time.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
.unwrap_or_else(|_| self.time.clone());
format!(
"The graphs start at {time}, not at the start of the run: {}",
self.reason
)
}
}
impl RunRecord {
pub(crate) fn intro(&self) -> Vec<String> {
let mut intro = Vec::new();
if let Some(notes) = self.notes.as_deref().filter(|notes| !notes.is_empty()) {
intro.push(notes.to_string());
}
if self.in_progress {
intro.push(IN_PROGRESS.to_string());
}
intro
}
}
pub(crate) const IN_PROGRESS: &str =
"This report was saved while the run was in progress. The report written when the run ends replaces it.";
pub(crate) fn without_gz(name: &str) -> (&str, bool) {
match name.strip_suffix(".gz") {
Some(inner) => (inner, true),
None => (name, false),
}
}
pub(crate) fn log_file_name(name: &str, format: Option<&GooseLogFormat>) -> String {
let extension = match format {
Some(GooseLogFormat::Csv) => "csv",
Some(GooseLogFormat::Raw | GooseLogFormat::Pretty) => "log",
Some(GooseLogFormat::Json) | None => "json",
};
format!("{name}.{extension}")
}
#[cfg(any(test, feature = "dashboard"))]
pub(crate) fn parent_folder(configuration: &GooseConfiguration) -> PathBuf {
match configuration.output_dir() {
Some(dir) => match Path::new(dir).parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => PathBuf::from("."),
},
None => PathBuf::from(DEFAULT_PARENT),
}
}
#[derive(Clone, Debug)]
pub(crate) struct RunFolder {
pub path: PathBuf,
}
impl RunFolder {
pub(crate) fn choose(
configuration: &GooseConfiguration,
started: DateTime<Local>,
) -> io::Result<RunFolder> {
Ok(RunFolder {
path: reserve(&Self::base(configuration, started))?,
})
}
fn base(configuration: &GooseConfiguration, started: DateTime<Local>) -> PathBuf {
match configuration.output_dir() {
Some(dir) => PathBuf::from(dir),
None => Path::new(DEFAULT_PARENT).join(started.format("%Y%m%d-%H%M%S").to_string()),
}
}
pub(crate) fn file(&self, name: &str) -> PathBuf {
self.path.join(name)
}
#[cfg(test)]
pub(crate) fn name(&self) -> String {
self.path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default()
}
pub(crate) fn error(&self, e: io::Error) -> GooseError {
folder_error(&self.path, e)
}
pub(crate) fn check_writable(&self) -> Result<(), GooseError> {
let mut dir = self.path.as_path();
loop {
match std::fs::metadata(dir) {
Ok(metadata) if metadata.is_dir() => break,
Ok(_) => {
return Err(self.error(io::Error::other(format!(
"{} is not a folder",
dir.display()
))))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
dir = match dir.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
};
}
Err(e) => return Err(self.error(e)),
}
}
let probe = dir.join(format!(".goose-write-check-{}", std::process::id()));
let _ = std::fs::remove_file(&probe);
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe)
.and_then(|_| std::fs::remove_file(&probe))
.map_err(|e| self.error(e))
}
}
pub(crate) fn folder_error(path: &Path, e: io::Error) -> GooseError {
GooseError::InvalidOption {
option: "--output-dir".to_string(),
value: path.to_string_lossy().into_owned(),
detail: format!("Can't write to {}: {e}", path.display()),
}
}
fn reserve(base: &Path) -> io::Result<PathBuf> {
if let Some(parent) = base
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)?;
}
let name = base
.file_name()
.map(|name| name.to_string_lossy().into_owned());
let mut n = 1u32;
let mut retries = 3;
loop {
let candidate = match (n, &name) {
(1, _) | (_, None) => base.to_path_buf(),
(n, Some(name)) => base.with_file_name(format!("{name}-{n}")),
};
let (created, free) = match std::fs::create_dir(&candidate) {
Ok(()) => (true, Ok(true)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
(false, is_empty_folder(&candidate))
}
Err(e) => return Err(e),
};
let taken = match free {
Ok(true) => std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(candidate.join(RESERVED))
.map(|_| true),
other => other,
};
match taken {
Ok(true) => return Ok(candidate),
Ok(false) => {}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
Err(e) if e.kind() == io::ErrorKind::NotFound && retries > 0 => {
retries -= 1;
continue;
}
Err(e) => {
if created {
let _ = std::fs::remove_dir(&candidate);
}
return Err(e);
}
}
if name.is_none() {
break;
}
n += 1;
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} already holds files", base.display()),
))
}
fn is_empty_folder(path: &Path) -> io::Result<bool> {
match std::fs::symlink_metadata(path) {
Ok(metadata) if metadata.is_dir() => Ok(std::fs::read_dir(path)?.next().is_none()),
Ok(_) => Ok(false),
Err(e) => Err(e),
}
}
#[derive(Debug, Default)]
pub(crate) struct Output {
pub folder: Option<RunFolder>,
reserved: Option<PathBuf>,
pub save_report: bool,
save_report_held: bool,
pub graph_data: bool,
pub started: Option<DateTime<Utc>>,
pub last_report: Option<String>,
error: Arc<Mutex<Option<String>>>,
writes: Arc<AtomicU64>,
pub saved_now: bool,
pub saving_now: Option<tokio::task::JoinHandle<()>>,
}
impl Output {
#[cfg(any(test, feature = "dashboard"))]
pub(crate) fn error(&self) -> Option<String> {
lock(&self.error).clone()
}
pub(crate) fn set_error(&self, error: Option<String>) {
*lock(&self.error) = error;
}
#[cfg(feature = "dashboard")]
fn no_folder_reason(&self) -> String {
self.error()
.unwrap_or_else(|| "this run has no folder".to_string())
}
pub(crate) fn end_save_report_hold(&mut self) {
if std::mem::take(&mut self.save_report_held) {
self.save_report = true;
}
}
pub(crate) fn release_folder(&mut self) {
release(self.reserved.take());
}
}
fn release(folder: Option<PathBuf>) {
if let Some(folder) = folder {
let _ = std::fs::remove_file(folder.join(RESERVED));
let _ = std::fs::remove_dir(&folder);
}
}
pub(crate) fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
impl Drop for Output {
fn drop(&mut self) {
self.release_folder();
}
}
pub(crate) const DASHBOARD_GRAPH_SECS: usize =
crate::metrics::dashboard_snapshot::SERIES_WINDOW_SECS as usize * 2;
impl GooseAttack {
pub(crate) fn set_ended_by(&mut self, ended_by: EndedBy, reason: Option<String>) {
if self.run_in_progress && self.ended_by.is_none() {
self.ended_by = Some((ended_by, reason));
}
}
pub(crate) fn keeps_full_graph(&self) -> bool {
self.configuration.keeps_full_graph(self.output.save_report)
}
pub(crate) fn records_latency_columns(&self) -> bool {
self.keeps_full_graph() && self.configuration.records_latency_series()
}
pub(crate) fn start_run_folder(&mut self) -> Result<(), GooseError> {
let previous = self.output.reserved.take();
let started = self.choose_run_folder();
release(previous);
started
}
pub(crate) fn goose_log_in_folder(&self) -> bool {
self.configuration.goose_log() == Some(RUN_FOLDER) && crate::logger::goose_log_installed()
}
fn choose_run_folder(&mut self) -> Result<(), GooseError> {
self.output.folder = None;
self.output.last_report = None;
self.output.set_error(None);
self.output.saved_now = false;
self.output.started = None;
self.output.end_save_report_hold();
if !self.configuration.uses_run_folder() {
return Ok(());
}
let started = Local::now();
let folder = RunFolder::choose(&self.configuration, started)
.map_err(|e| folder_error(&RunFolder::base(&self.configuration, started), e))
.and_then(|folder| {
self.output.reserved = Some(folder.path.clone());
folder.check_writable().map(|()| folder)
});
let folder = match folder {
Ok(folder) => folder,
Err(e) if self.configuration.folder_only_for_dashboard() => {
let reason = reason(&e);
warn!("{reason}. This run's report is not saved.");
self.output.release_folder();
self.output.set_error(Some(reason));
if self.output.save_report {
self.output.save_report = false;
self.output.save_report_held = true;
}
return Ok(());
}
Err(e) => return Err(e),
};
if self.configuration.goose_log() == Some(RUN_FOLDER) {
let name = match self.configuration.compress {
Some(_) => format!("{GOOSE_LOG}.gz"),
None => GOOSE_LOG.to_string(),
};
move_goose_log(&folder.file(&name)).map_err(|e| folder.error(e))?;
}
info!("writing this run's files to {}", folder.path.display());
self.output.folder = Some(folder);
Ok(())
}
pub(crate) fn report_record_for(&self, in_progress: bool, graph_data: &GraphData) -> RunRecord {
self.report_record(in_progress, graph_data.first_second())
}
pub(crate) fn report_record(&self, in_progress: bool, graphs_from: usize) -> RunRecord {
let (ended_by, canceled_reason) = match (in_progress, &self.ended_by) {
(true, _) => (None, None),
(false, Some((ended_by, reason))) => (Some(*ended_by), reason.clone()),
(false, None) => (Some(EndedBy::Completed), None),
};
RunRecord {
notes: self.configuration.notes().map(str::to_string),
goose_version: env!("CARGO_PKG_VERSION").to_string(),
test: test_name(),
started: self.output.started.map(rfc3339),
ended: (!in_progress).then(|| rfc3339(Utc::now())),
ended_by,
canceled_reason,
in_progress,
graphs_start: self.graphs_start(graphs_from),
}
}
fn graphs_start(&self, graphs_from: usize) -> Option<GraphsStart> {
let started = self.output.started.filter(|_| graphs_from > 0)?;
let reason = if self.keeps_full_graph() {
format!(
"Save report was off for part of the run, and Goose then keeps only the dashboard's last {} minutes of graphs.",
DASHBOARD_GRAPH_SECS / 60
)
} else {
format!(
"Save report is off, so Goose keeps only the dashboard's last {} minutes of graphs.",
DASHBOARD_GRAPH_SECS / 60
)
};
Some(GraphsStart {
time: rfc3339(started + chrono::Duration::seconds(graphs_from as i64)),
reason,
})
}
pub(crate) async fn write_end_of_run(
&mut self,
returns_to_idle: bool,
) -> Result<(), GooseError> {
if let Some(saving) = self.output.saving_now.take() {
let _ = saving.await;
}
let (baseline, baseline_error) = match &self.configuration.baseline_file {
Some(file) => match load_baseline_file(file) {
Ok(baseline) => (Some(baseline), None),
Err(e) => (None, Some(e)),
},
None => (None, None),
};
let record = self.report_record_for(false, &self.graph_data);
let save = self.output.save_report || self.output.saved_now;
let folder = match (save, self.output.folder.as_ref()) {
(true, Some(folder)) => Some(folder.path.clone()),
_ => None,
};
let mut html = None;
let source = self.report_source(&record);
let report_files = match baseline_error {
Some(e) => Err(e),
None => {
self.write_report_files(&source, baseline.as_ref(), &mut html)
.await
}
};
let written = match folder {
None => return report_files,
Some(folder) => write_folder_report(
&folder,
&source,
baseline.as_ref(),
&mut html,
self.configuration.compress,
)
.map(|files| (folder, files)),
};
match written {
Ok((folder, files)) => {
for file in files {
info!("report file written to: {}", file.display());
}
self.output.last_report = Some(folder.to_string_lossy().into_owned());
self.output.set_error(None);
self.output.writes.fetch_add(1, Ordering::Relaxed);
report_files
}
Err(e) => {
let reason = reason(&e);
error!("couldn't save the report: {reason}");
self.output.set_error(Some(reason));
if returns_to_idle {
report_files
} else {
report_files.and(Err(e))
}
}
}
}
}
impl GooseAttack {
pub(crate) async fn render_report(&self, path: &str) -> Result<GooseMetrics, GooseError> {
let loaded =
LoadedReport::read(Path::new(path)).map_err(|detail| GooseError::InvalidOption {
option: "--render-report".to_string(),
value: path.to_string(),
detail,
})?;
let baseline = match &self.configuration.baseline_file {
Some(file) => Some(load_baseline_file(file)?),
None => None,
};
let empty = GraphData::new();
let source = loaded.source(&self.configuration, &empty);
self.create_reports().await?;
self.write_report_files(&source, baseline.as_ref(), &mut None)
.await?;
Ok(loaded.data.raw_metrics.into_owned())
}
#[cfg(feature = "dashboard")]
pub(crate) fn report_snapshot(&self) -> SnapshotReport {
SnapshotReport {
save: self.output.save_report,
graph_data: self.output.graph_data,
folder: self
.output
.folder
.as_ref()
.map(|folder| folder.path.to_string_lossy().into_owned()),
paths: self.configuration.report_paths(),
notes: self.configuration.notes().map(str::to_string),
last_report: self.output.last_report.clone(),
last_report_name: self
.output
.last_report
.as_deref()
.and_then(|folder| Path::new(folder).file_name())
.map(|name| name.to_string_lossy().into_owned())
.filter(|name| is_report_name(name)),
error: self.output.error(),
writes: self.output.writes.load(Ordering::Relaxed),
}
}
}
#[cfg(feature = "dashboard")]
#[derive(Clone, Debug, Serialize)]
pub(crate) struct ListedReport {
pub name: String,
#[serde(flatten)]
pub record: RunRecord,
pub running: bool,
pub duration_secs: u64,
pub max_users: u64,
pub requests: u64,
pub failed_requests: u64,
#[serde(skip)]
sort_key: i64,
}
#[cfg(feature = "dashboard")]
pub(crate) type ListingCache = HashMap<PathBuf, (u64, std::time::SystemTime, ListedReport)>;
#[cfg(feature = "dashboard")]
pub(crate) fn is_report_name(name: &str) -> bool {
let mut chars = name.chars();
chars.next().is_some_and(|c| c.is_ascii_alphanumeric())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
#[cfg(feature = "dashboard")]
pub(crate) fn report_folder(parent: &Path, name: &str) -> Option<(PathBuf, PathBuf)> {
if !is_report_name(name) {
return None;
}
let folder = parent.join(name);
if !std::fs::symlink_metadata(&folder).is_ok_and(|m| m.file_type().is_dir()) {
return None;
}
let json = regular_file(&folder, REPORT_JSON)?;
Some((folder, json))
}
#[cfg(feature = "dashboard")]
pub(crate) fn regular_file(folder: &Path, name: &str) -> Option<PathBuf> {
[name.to_string(), format!("{name}.gz")]
.iter()
.map(|name| folder.join(name))
.find(|path| std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file()))
}
#[cfg(feature = "dashboard")]
pub(crate) fn list_reports(
parent: &Path,
running: Option<&Path>,
cache: &mut ListingCache,
) -> io::Result<Vec<ListedReport>> {
let entries = match std::fs::read_dir(parent) {
Ok(entries) => entries,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
cache.clear();
return Ok(Vec::new());
}
Err(e) => return Err(e),
};
let mut listed = Vec::new();
let mut seen = std::collections::HashSet::new();
for entry in entries.filter_map(Result::ok) {
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
let Some((folder, json)) = report_folder(parent, &name) else {
continue;
};
let Ok(metadata) = std::fs::metadata(&json) else {
continue;
};
let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH);
seen.insert(json.clone());
let cached = cache
.get(&json)
.filter(|(len, time, _)| *len == metadata.len() && *time == modified)
.map(|(_, _, report)| report.clone());
let mut report = match cached {
Some(report) => report,
None => match read_listed(&json, &name, modified) {
Some(report) => {
cache.insert(json.clone(), (metadata.len(), modified, report.clone()));
report
}
None => continue,
},
};
report.running = report.record.in_progress && running == Some(folder.as_path());
listed.push(report);
}
cache.retain(|path, _| seen.contains(path));
listed.sort_by(|a, b| {
b.sort_key
.cmp(&a.sort_key)
.then_with(|| b.name.cmp(&a.name))
});
Ok(listed)
}
#[cfg(feature = "dashboard")]
pub(crate) fn read_record(json: &Path) -> Option<RunRecord> {
let text = crate::metrics::read_report_text(json).ok()?;
serde_json::from_str(&text).ok()
}
#[cfg(feature = "dashboard")]
pub(crate) fn is_listed(json: &Path) -> bool {
read_listed(json, "", std::time::UNIX_EPOCH).is_some()
}
#[cfg(feature = "dashboard")]
fn read_listed(json: &Path, name: &str, modified: std::time::SystemTime) -> Option<ListedReport> {
#[derive(Deserialize)]
struct Listed {
raw_metrics: Metrics,
}
#[derive(Deserialize)]
struct Metrics {
#[serde(default)]
duration: u64,
#[serde(default)]
maximum_users: u64,
#[serde(default)]
requests: HashMap<String, Requests>,
}
#[derive(Deserialize)]
struct Requests {
#[serde(default)]
success_count: u64,
#[serde(default)]
fail_count: u64,
}
let text = crate::metrics::read_report_text(json).ok()?;
let record: RunRecord = serde_json::from_str(&text).ok()?;
let metrics = serde_json::from_str::<Listed>(&text).ok()?.raw_metrics;
let written = DateTime::<Utc>::from(modified).timestamp();
let sort_key = record
.started
.as_deref()
.and_then(|started| DateTime::parse_from_rfc3339(started).ok())
.map_or(written, |started| started.timestamp());
Some(ListedReport {
name: name.to_string(),
record,
running: false,
duration_secs: metrics.duration,
max_users: metrics.maximum_users,
requests: metrics
.requests
.values()
.map(|r| r.success_count + r.fail_count)
.sum(),
failed_requests: metrics.requests.values().map(|r| r.fail_count).sum(),
sort_key,
})
}
#[cfg(feature = "dashboard")]
pub(crate) fn deletable_files() -> Vec<String> {
let mut names = vec![
REPORT_JSON.to_string(),
REPORT_HTML.to_string(),
REPORT_MD.to_string(),
GOOSE_LOG.to_string(),
];
for name in LOG_NAMES {
for extension in ["json", "csv", "log"] {
names.push(format!("{name}.{extension}"));
}
}
let gzipped: Vec<String> = names.iter().map(|name| format!("{name}.gz")).collect();
names.extend(gzipped);
let temporary: Vec<String> = [REPORT_JSON, REPORT_HTML]
.iter()
.flat_map(|name| [format!(".{name}.tmp"), format!(".{name}.gz.tmp")])
.collect();
names.extend(temporary);
names
}
#[cfg(feature = "dashboard")]
pub(crate) fn is_reserved(folder: &Path) -> bool {
std::fs::symlink_metadata(folder.join(RESERVED)).is_ok()
}
#[cfg(feature = "dashboard")]
impl GooseAttack {
pub(crate) fn control_report(
&mut self,
goose_attack_run_state: &mut GooseAttackRunState,
save: Option<bool>,
graph_data: Option<bool>,
) -> ControlOutcome {
if save == Some(true) && self.configuration.no_metrics {
return self.control_outcome(
goose_attack_run_state,
false,
Some("invalid_report"),
"--no-metrics keeps no report to save",
None,
);
}
if save == Some(true) && self.run_in_progress && self.output.folder.is_none() {
let message = self.output.no_folder_reason();
return self.control_outcome(
goose_attack_run_state,
false,
Some("invalid_report"),
message,
None,
);
}
if let Some(graph_data) = graph_data {
self.output.graph_data = graph_data;
}
if save.is_some() {
self.output.save_report_held = false;
}
if let Some(save) = save.filter(|save| *save != self.output.save_report) {
let keeps_before = self.keeps_full_graph();
self.output.save_report = save;
let keep = self.keeps_full_graph();
if self.run_in_progress && keep != keeps_before {
let now = self.series_elapsed_secs();
let _ = goose_attack_run_state
.metrics_cmd_tx
.send(MetricsCommand::KeepGraph { keep, now });
}
}
let message = format!(
"save report {}, include graph data {}",
if self.output.save_report { "on" } else { "off" },
if self.output.graph_data { "on" } else { "off" }
);
self.control_outcome(goose_attack_run_state, true, None, message, None)
}
pub(crate) async fn control_save_report_now(
&mut self,
goose_attack_run_state: &mut GooseAttackRunState,
) -> Result<ControlOutcome, GooseError> {
let refuse = |attack: &Self, state: &GooseAttackRunState, error, message: &str| {
Ok(attack.control_outcome(state, false, Some(error), message.to_string(), None))
};
if self.configuration.no_metrics {
return refuse(
self,
goose_attack_run_state,
"invalid_report",
"--no-metrics keeps no report to save",
);
}
if !self.run_in_progress {
return refuse(
self,
goose_attack_run_state,
"invalid_phase",
"Save report now needs a run in progress",
);
}
let Some(folder) = self.output.folder.clone() else {
let message = self.output.no_folder_reason();
return refuse(self, goose_attack_run_state, "report_failed", &message);
};
if self
.output
.saving_now
.as_ref()
.is_some_and(|saving| !saving.is_finished())
{
return refuse(
self,
goose_attack_run_state,
"report_busy",
"a report is being saved; try again when it is written",
);
}
if let Err(e) = folder.check_writable() {
let reason = reason(&e);
self.output.set_error(Some(reason.clone()));
return refuse(self, goose_attack_run_state, "report_failed", &reason);
}
self.update_duration();
let (respond, report_data) = tokio::sync::oneshot::channel();
let sent = goose_attack_run_state
.metrics_cmd_tx
.send(MetricsCommand::GetReportData {
duration: self.metrics.duration,
total_users: self.metrics.total_users,
maximum_users: self.metrics.maximum_users,
history: self.metrics.history.clone(),
respond,
});
let (Ok(()), Ok((metrics, graph_data))) = (sent, report_data.await) else {
return refuse(
self,
goose_attack_run_state,
"report_failed",
"the metrics are not available",
);
};
let folder_path = folder.path.clone();
let record = self.report_record_for(true, &graph_data);
let configuration = self.configuration.clone();
let graph_data_in_json = self.output.graph_data;
let compress = self.configuration.compress;
let error = Arc::clone(&self.output.error);
let writes = Arc::clone(&self.output.writes);
self.output.saved_now = true;
self.output.saving_now = Some(tokio::task::spawn_blocking(move || {
let baseline = match configuration
.baseline_file
.as_ref()
.map(|file| load_baseline_file(file).map_err(|e| (file.clone(), reason(&e))))
{
Some(Ok(baseline)) => Some(baseline),
Some(Err((file, reason))) => {
warn!("the report saved now has no baseline, as {file} doesn't load: {reason}");
None
}
None => None,
};
let source = crate::metrics::ReportSource {
configuration: &configuration,
metrics: &metrics,
graph_data: &graph_data,
graphs: true,
heatmap: None,
loaded: None,
record: &record,
graph_data_in_json,
};
match write_folder_report(
&folder_path,
&source,
baseline.as_ref(),
&mut None,
compress,
) {
Ok(files) => {
for file in files {
info!("in progress report written to: {}", file.display());
}
*lock(&error) = None;
writes.fetch_add(1, Ordering::Relaxed);
}
Err(e) => {
let reason = reason(&e);
error!("couldn't save the report now: {reason}");
*lock(&error) = Some(reason);
}
}
}));
let message = format!("saving the report to {}", folder.path.display());
Ok(self.control_outcome(goose_attack_run_state, true, None, message, None))
}
}
pub(crate) fn reason(e: &GooseError) -> String {
match e {
GooseError::InvalidOption { detail, .. } => detail.clone(),
other => other.to_string(),
}
}
pub(crate) fn rfc3339(time: DateTime<Utc>) -> String {
time.to_rfc3339_opts(SecondsFormat::Secs, true)
}
pub(crate) fn test_name() -> String {
std::env::current_exe()
.ok()
.and_then(|exe| exe.file_stem().map(|s| s.to_string_lossy().into_owned()))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"goose-folder-test-{name}-{}-{}",
std::process::id(),
Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn configuration(output_dir: Option<&Path>) -> GooseConfiguration {
GooseConfiguration {
output_dir: output_dir.map(|dir| dir.to_string_lossy().into_owned()),
..Default::default()
}
}
#[test]
fn default_folder_is_named_for_the_start_time() {
let started = Local.with_ymd_and_hms(2026, 10, 9, 14, 12, 3).unwrap();
let base = RunFolder::base(&configuration(None), started);
assert_eq!(base, Path::new("goose-runs/20261009-141203"));
}
#[test]
fn folder_holding_files_is_never_reused() {
let parent = temp_dir("suffix");
let dir = parent.join("nightly");
let started = Local::now();
let choose = || RunFolder::choose(&configuration(Some(&dir)), started).unwrap();
let mut chosen = Vec::new();
for _ in 0..4 {
let folder = choose();
std::fs::write(folder.file("goose.log"), "x").unwrap();
std::fs::remove_file(folder.file(RESERVED)).unwrap();
chosen.push(folder.name());
}
assert_eq!(chosen, ["nightly", "nightly-2", "nightly-3", "nightly-4"]);
std::fs::remove_file(parent.join("nightly-3").join("goose.log")).unwrap();
assert_eq!(choose().name(), "nightly-3");
std::fs::write(parent.join("nightly-5"), "a file").unwrap();
std::fs::write(parent.join("nightly-3").join("goose.log"), "x").unwrap();
assert_eq!(choose().name(), "nightly-6");
std::fs::remove_dir_all(&parent).ok();
}
#[test]
fn a_chosen_folder_is_reserved_at_once() {
let parent = temp_dir("reserve");
let dir = parent.join("nightly");
let started = Local::now();
let first = RunFolder::choose(&configuration(Some(&dir)), started).unwrap();
let second = RunFolder::choose(&configuration(Some(&dir)), started).unwrap();
assert_eq!(
(first.name(), second.name()),
("nightly".into(), "nightly-2".into())
);
assert!(first.path.is_dir() && second.path.is_dir());
let empty = parent.join("empty");
std::fs::create_dir(&empty).unwrap();
let first = RunFolder::choose(&configuration(Some(&empty)), started).unwrap();
let second = RunFolder::choose(&configuration(Some(&empty)), started).unwrap();
assert_eq!(
(first.name(), second.name()),
("empty".into(), "empty-2".into())
);
std::fs::remove_dir_all(&parent).ok();
}
#[test]
fn writable_check_finds_the_nearest_folder() {
let parent = temp_dir("writable");
let folder = RunFolder {
path: parent.join("a").join("b"),
};
folder.check_writable().unwrap();
assert_eq!(std::fs::read_dir(&parent).unwrap().count(), 0);
std::fs::write(parent.join("a"), "a file").unwrap();
let error = folder.check_writable().unwrap_err();
match error {
GooseError::InvalidOption { option, value, .. } => {
assert_eq!(option, "--output-dir");
assert_eq!(value, folder.path.to_string_lossy());
}
other => panic!("{:?}", other),
}
std::fs::remove_dir_all(&parent).ok();
}
#[test]
fn graphs_start_when_the_graphs_do_not_cover_the_run() {
let mut attack = GooseAttack::initialize_with_config(configuration(None)).unwrap();
attack.output.started = Some(Utc.with_ymd_and_hms(2026, 10, 22, 10, 0, 0).unwrap());
assert_eq!(attack.report_record(false, 0).graphs_start, None);
attack.output.save_report = true;
let start = attack.report_record(false, 300).graphs_start.unwrap();
assert_eq!(start.time, "2026-10-22T10:05:00Z");
assert!(start
.reason
.contains("Save report was off for part of the run"));
attack.output.save_report = false;
let start = attack.report_record(false, 300).graphs_start.unwrap();
assert!(start.reason.starts_with("Save report is off"));
}
#[tokio::test]
async fn the_end_of_run_report_says_where_its_graphs_start() {
use crate::test_plan::{TestPlanHistory, TestPlanStepAction};
let parent = temp_dir("graphs-start");
let mut attack =
GooseAttack::initialize_with_config(configuration(Some(&parent.join("run")))).unwrap();
let started = Utc.with_ymd_and_hms(2026, 10, 22, 10, 0, 0).unwrap();
attack.run_in_progress = true;
attack.output.started = Some(started);
attack.output.save_report = true;
attack.output.folder =
Some(RunFolder::choose(&attack.configuration, Local::now()).unwrap());
for step in [TestPlanStepAction::Increasing, TestPlanStepAction::Finished] {
attack.metrics.history.push(TestPlanHistory::step(step, 0));
}
for second in 0..=900 {
attack.graph_data.record_users_per_second(1, second);
}
attack.graph_data.prune_to_window(600);
let origin = attack.graph_data.first_second();
assert!(origin > 0);
attack.write_end_of_run(false).await.unwrap();
let report: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(parent.join("run").join(REPORT_JSON)).unwrap(),
)
.unwrap();
assert_eq!(
report["graphs_start"]["time"],
rfc3339(started + chrono::Duration::seconds(origin as i64))
);
std::fs::remove_dir_all(&parent).ok();
}
#[test]
fn a_report_saved_now_is_marked_in_progress() {
let mut attack = GooseAttack::initialize_with_config(configuration(None)).unwrap();
attack.run_in_progress = true;
attack.output.started = Some(Utc::now());
let record = attack.report_record(true, 0);
assert!(record.in_progress);
assert_eq!((record.ended.as_deref(), record.ended_by), (None, None));
assert_eq!(record.intro(), vec![IN_PROGRESS.to_string()]);
let record = attack.report_record(false, 0);
assert!(!record.in_progress);
assert_eq!(record.ended_by, Some(EndedBy::Completed));
assert!(record.intro().is_empty());
}
#[test]
fn keeps_full_graph_table() {
for path in [false, true] {
for bare in [false, true] {
for dashboard in [false, true] {
let mut report_file = Vec::new();
if path {
report_file.push("report.html".to_string());
}
if bare {
report_file.push(RUN_FOLDER.to_string());
}
let mut attack = GooseAttack::initialize_with_config(GooseConfiguration {
report_file,
dashboard,
..Default::default()
})
.unwrap();
let save = attack.configuration.save_report_at_start();
assert_eq!(save, bare || dashboard);
for save in [save, !save] {
attack.output.save_report = save;
assert_eq!(
attack.keeps_full_graph(),
path || save,
"path={path} bare={bare} dashboard={dashboard} save={save}"
);
}
}
}
}
}
#[cfg(feature = "dashboard")]
#[tokio::test]
async fn save_report_off_keeps_the_graphs_a_path_needs() {
for (path, sent) in [(true, None), (false, Some(false))] {
let report_file = if path {
vec!["report.html".to_string()]
} else {
Vec::new()
};
let mut attack = GooseAttack::initialize_with_config(GooseConfiguration {
report_file,
no_telnet: true,
no_websocket: true,
..Default::default()
})
.unwrap();
let mut run_state = attack.initialize_attack().await.unwrap();
let (metrics_cmd_tx, metrics_cmd_rx) = flume::unbounded();
run_state.metrics_cmd_tx = metrics_cmd_tx;
attack.run_in_progress = true;
attack.output.save_report = true;
assert!(attack.control_report(&mut run_state, Some(false), None).ok);
let keep = metrics_cmd_rx.try_iter().find_map(|command| match command {
MetricsCommand::KeepGraph { keep, .. } => Some(keep),
_ => None,
});
assert_eq!(keep, sent, "path={path}");
}
}
#[test]
fn a_released_folder_goes_only_when_empty() {
let parent = temp_dir("release");
let started = Local::now();
let empty =
RunFolder::choose(&configuration(Some(&parent.join("empty"))), started).unwrap();
let mut output = Output::default();
output.reserved = Some(empty.path.clone());
output.release_folder();
assert!(!empty.path.exists());
let used = RunFolder::choose(&configuration(Some(&parent.join("used"))), started).unwrap();
std::fs::write(used.file("goose.log"), "x").unwrap();
output.reserved = Some(used.path.clone());
output.release_folder();
assert!(used.file("goose.log").exists());
assert!(!used.file(RESERVED).exists());
std::fs::remove_dir_all(&parent).ok();
}
#[tokio::test]
async fn a_failed_folder_report_is_an_error_unless_the_session_goes_on() {
let parent = temp_dir("end-of-run-error");
let run = |report_file: Vec<String>| {
let mut attack = GooseAttack::initialize_with_config(GooseConfiguration {
report_file,
..configuration(Some(&parent.join("run")))
})
.unwrap();
let folder = RunFolder::choose(&attack.configuration, Local::now()).unwrap();
std::fs::create_dir(folder.file(REPORT_HTML)).unwrap();
attack.run_in_progress = true;
attack.output.started = Some(Utc::now());
attack.output.save_report = true;
attack.output.folder = Some(folder);
attack
};
let mut attack = run(Vec::new());
assert!(attack.write_end_of_run(false).await.is_err());
let mut attack = run(Vec::new());
attack.write_end_of_run(true).await.unwrap();
let error = attack.output.error().unwrap_or_default();
assert!(error.contains(&*parent.to_string_lossy()), "{}", error);
let missing = parent.join("missing").join("report.html");
let mut attack = run(vec![missing.to_string_lossy().into_owned()]);
assert!(attack.write_end_of_run(true).await.is_err());
std::fs::remove_dir_all(&parent).ok();
}
#[cfg(feature = "dashboard")]
#[test]
fn last_report_name_is_only_a_name_the_routes_take() {
let mut attack =
GooseAttack::initialize_with_config(GooseConfiguration::default()).unwrap();
for (folder, name) in [
("goose-runs/nightly", Some("nightly")),
("goose-runs/nightly run", None),
] {
attack.output.last_report = Some(folder.to_string());
assert_eq!(
attack.report_snapshot().last_report_name.as_deref(),
name,
"{}",
folder
);
}
}
#[test]
fn a_record_without_its_fields_loads() {
let record: RunRecord = serde_json::from_str("{}").unwrap();
assert_eq!(record, RunRecord::default());
}
#[test]
fn parent_folder_holds_the_run_folder() {
assert_eq!(parent_folder(&configuration(None)), Path::new("goose-runs"));
let nested = configuration(Some(Path::new("goose-runs/nightly")));
assert_eq!(parent_folder(&nested), Path::new("goose-runs"));
let bare = configuration(Some(Path::new("nightly")));
assert_eq!(parent_folder(&bare), Path::new("."));
}
#[test]
fn a_gz_name_is_its_inner_format() {
assert_eq!(without_gz("report.json.gz"), ("report.json", true));
assert_eq!(without_gz("report.pdf.gz"), ("report.pdf", true));
assert_eq!(without_gz("report.json"), ("report.json", false));
assert_eq!(without_gz("x.gz.gz"), ("x.gz", true));
}
#[test]
fn gzipped_reports_are_read_whole_or_not_at_all() {
use std::io::Write;
let dir = temp_dir("gz-read");
let json = dir.join("report.json.gz");
let mut gzip = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gzip.write_all(br#"{"notes": "zipped"}"#).unwrap();
let bytes = gzip.finish().unwrap();
std::fs::write(&json, &bytes).unwrap();
assert_eq!(crate::metrics::json_report_path(&dir), json);
let text = crate::metrics::read_report_text(&json).unwrap();
assert_eq!(text, r#"{"notes": "zipped"}"#);
std::fs::write(dir.join("report.json"), "{}").unwrap();
assert_eq!(
crate::metrics::json_report_path(&dir),
dir.join("report.json")
);
std::fs::write(&json, &bytes[..bytes.len() - 6]).unwrap();
assert!(crate::metrics::read_report_text(&json).is_err());
std::fs::write(&json, b"plain text").unwrap();
assert!(crate::metrics::read_report_text(&json).is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "dashboard")]
#[test]
fn delete_takes_plain_and_gzipped_names() {
let names = deletable_files();
for name in [
"report.json",
"report.json.gz",
"goose.log.gz",
"requests.csv.gz",
] {
assert!(names.iter().any(|n| n == name), "{}", name);
}
assert!(!names.iter().any(|n| n.contains('/')));
}
#[cfg(feature = "dashboard")]
#[test]
fn a_changed_report_is_listed_again() {
let parent = temp_dir("listing");
let folder = parent.join("run");
std::fs::create_dir(&folder).unwrap();
let json = folder.join(REPORT_JSON);
let report = |in_progress: bool, requests: u64| {
serde_json::json!({
"in_progress": in_progress,
"started": "2026-10-22T10:00:00Z",
"raw_metrics": {"requests": {"GET /": {"success_count": requests, "fail_count": 0}}},
})
.to_string()
};
let mut cache = ListingCache::new();
std::fs::write(&json, report(true, 5)).unwrap();
let listed = list_reports(&parent, Some(&folder), &mut cache).unwrap();
assert!(listed[0].record.in_progress && listed[0].running);
assert_eq!(listed[0].requests, 5);
let listed = list_reports(&parent, None, &mut cache).unwrap();
assert!(listed[0].record.in_progress && !listed[0].running);
std::fs::write(&json, report(false, 12345)).unwrap();
let listed = list_reports(&parent, Some(&folder), &mut cache).unwrap();
assert!(!listed[0].record.in_progress && !listed[0].running);
assert_eq!(listed[0].requests, 12345);
std::fs::remove_dir_all(&parent).ok();
}
#[test]
fn log_names_follow_the_format() {
assert_eq!(log_file_name("requests", None), "requests.json");
assert_eq!(
log_file_name("errors", Some(&GooseLogFormat::Csv)),
"errors.csv"
);
assert_eq!(
log_file_name("debug", Some(&GooseLogFormat::Pretty)),
"debug.log"
);
assert_eq!(
log_file_name("scenarios", Some(&GooseLogFormat::Raw)),
"scenarios.log"
);
}
}