use super::super::{RunWithArgsDependencies, locale_resolution, localization, run_with_args};
use anyhow::{Context, Result, bail, ensure};
use cap_std::ambient_authority;
use cap_std::fs::Dir;
use metrics::Label;
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
use monotony::{MonotonicClock, StdMonotonicClock, test_util::QueuedMonotonicClock};
use proptest::{prelude::*, test_runner::TestCaseError};
use rstest::rstest;
use std::ffi::OsString;
use std::process::ExitCode;
use std::time::Duration;
use tempfile::tempdir;
fn captured_metrics(body: impl FnOnce()) -> Vec<(String, Vec<Label>, DebugValue)> {
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
metrics::with_local_recorder(&recorder, body);
snapshotter
.snapshot()
.into_vec()
.into_iter()
.map(|(composite, _unit, _description, value)| {
let key = composite.key();
(
key.name().to_owned(),
key.labels().cloned().collect::<Vec<_>>(),
value,
)
})
.collect()
}
fn verify_config_load_metrics(
metrics: &[(String, Vec<Label>, DebugValue)],
scenario: ConfigurationLoadScenario,
phase_duration: Duration,
) -> Result<()> {
verify_startup_outcome_counter(metrics, scenario.expected_outcome())?;
verify_startup_duration_histogram(
metrics,
scenario.total_duration(phase_duration).as_secs_f64(),
)?;
verify_phase_metrics(
metrics,
scenario.expected_phase_outcomes(),
phase_duration.as_secs_f64(),
)?;
Ok(())
}
fn verify_startup_outcome_counter(
metrics: &[(String, Vec<Label>, DebugValue)],
expected_outcome: &str,
) -> Result<()> {
let counters = metrics
.iter()
.filter(|(name, _, _)| name == "netsuke_config_load_total")
.collect::<Vec<_>>();
ensure!(
counters.len() == 1,
"one config-load counter should be recorded, found {}",
counters.len()
);
let [counter] = counters.as_slice() else {
bail!("one config-load counter should be recorded");
};
let [label] = counter.1.as_slice() else {
bail!("counter should have exactly one label");
};
ensure!(
label.key() == "outcome",
"counter label key must be outcome"
);
ensure!(
label.value() == expected_outcome,
"counter outcome must be {expected_outcome}"
);
ensure!(
counter.2 == DebugValue::Counter(1),
"counter must record exactly one attempt"
);
Ok(())
}
fn verify_startup_duration_histogram(
metrics: &[(String, Vec<Label>, DebugValue)],
expected_duration: f64,
) -> Result<()> {
let histograms = metrics
.iter()
.filter(|(name, _, _)| name == "netsuke_config_load_duration_seconds")
.collect::<Vec<_>>();
ensure!(
histograms.len() == 1,
"one config-load histogram should be recorded, found {}",
histograms.len()
);
let [histogram] = histograms.as_slice() else {
bail!("one config-load histogram should be recorded");
};
let DebugValue::Histogram(samples) = &histogram.2 else {
bail!("expected a histogram value, got {:?}", histogram.2);
};
ensure!(histogram.1.is_empty(), "histogram must have no labels");
ensure!(samples.len() == 1, "exactly one duration sample expected");
let [sample] = samples.as_slice() else {
bail!("exactly one duration sample expected");
};
ensure!(
*sample == expected_duration,
"duration sample must be {expected_duration}"
);
Ok(())
}
fn verify_phase_metrics(
metrics: &[(String, Vec<Label>, DebugValue)],
expected_phase_outcomes: &[(&str, &str)],
expected_duration: f64,
) -> Result<()> {
let counters = metrics
.iter()
.filter(|(name, _, _)| name == "config_load_total")
.collect::<Vec<_>>();
ensure!(
counters.len() == expected_phase_outcomes.len(),
"expected one phase counter per configuration path"
);
let histograms = metrics
.iter()
.filter(|(name, _, _)| name == "config_load_duration_seconds")
.collect::<Vec<_>>();
ensure!(
histograms.len() == expected_phase_outcomes.len(),
"expected one phase histogram per configuration path"
);
for (phase, outcome) in expected_phase_outcomes {
let counter_count = counters
.iter()
.filter(|counter| {
has_exact_labels(&counter.1, &[("phase", phase), ("outcome", outcome)])
&& counter.2 == DebugValue::Counter(1)
})
.count();
ensure!(
counter_count == 1,
"expected one phase counter for phase={phase}, outcome={outcome}"
);
let histogram_count = histograms
.iter()
.filter(|histogram| {
has_exact_labels(&histogram.1, &[("phase", phase)])
&& matches!(
&histogram.2,
DebugValue::Histogram(samples)
if samples.as_slice() == [expected_duration]
)
})
.count();
ensure!(
histogram_count == 1,
"expected one phase histogram for phase={phase}"
);
}
Ok(())
}
fn has_exact_labels(labels: &[Label], expected: &[(&str, &str)]) -> bool {
labels.len() == expected.len()
&& expected.iter().all(|(key, value)| {
labels
.iter()
.any(|label| label.key() == *key && label.value() == *value)
})
}
struct EmptyEnv;
impl locale_resolution::LocaleEnvProvider for EmptyEnv {
fn var(&self, _key: &str) -> Option<String> {
None
}
}
struct NoSystemLocale;
impl locale_resolution::SystemLocale for NoSystemLocale {
fn system_locale(&self) -> Option<String> {
None
}
}
#[derive(Clone, Copy)]
enum ConfigurationLoadScenario {
JsonResolutionFailure,
MergeFailure,
SuccessfulMerge,
}
impl ConfigurationLoadScenario {
const ALL: [Self; 3] = [
Self::JsonResolutionFailure,
Self::MergeFailure,
Self::SuccessfulMerge,
];
const fn config_contents(self) -> Option<&'static str> {
match self {
Self::JsonResolutionFailure => None,
Self::MergeFailure => Some("jobs = 0\n"),
Self::SuccessfulMerge => Some(""),
}
}
const fn expected_outcome(self) -> &'static str {
match self {
Self::JsonResolutionFailure | Self::MergeFailure => "failure",
Self::SuccessfulMerge => "success",
}
}
const fn expected_phase_outcomes(self) -> &'static [(&'static str, &'static str)] {
match self {
Self::JsonResolutionFailure => &[("diag_mode", "failure")],
Self::MergeFailure => &[("diag_mode", "success"), ("merge", "failure")],
Self::SuccessfulMerge => &[("diag_mode", "success"), ("merge", "success")],
}
}
fn total_duration(self, duration: Duration) -> Duration {
match self {
Self::JsonResolutionFailure => duration,
Self::MergeFailure | Self::SuccessfulMerge => duration.saturating_add(duration),
}
}
}
fn configuration_clock(
scenario: ConfigurationLoadScenario,
phase_duration: Duration,
) -> Result<QueuedMonotonicClock> {
let started_at = StdMonotonicClock.now();
let diag_finished_at = started_at
.checked_add(phase_duration)
.context("diagnostic-mode phase duration must fit in an Instant")?;
let instants = match scenario {
ConfigurationLoadScenario::JsonResolutionFailure => {
vec![
started_at,
started_at,
started_at,
diag_finished_at,
diag_finished_at,
diag_finished_at,
]
}
ConfigurationLoadScenario::MergeFailure | ConfigurationLoadScenario::SuccessfulMerge => {
let merge_finished_at = diag_finished_at
.checked_add(phase_duration)
.context("merge phase duration must fit in an Instant")?;
vec![
started_at,
started_at,
started_at,
diag_finished_at,
diag_finished_at,
diag_finished_at,
merge_finished_at,
merge_finished_at,
]
}
};
Ok(QueuedMonotonicClock::from_instants(instants))
}
fn run_with_config_metrics(
scenario: ConfigurationLoadScenario,
duration: Duration,
) -> Result<Vec<(String, Vec<Label>, DebugValue)>> {
let temp = tempdir()?;
let config_path = temp.path().join("netsuke.toml");
if let Some(contents) = scenario.config_contents() {
Dir::open_ambient_dir(temp.path(), ambient_authority())?.write("netsuke.toml", contents)?;
}
let missing_manifest = temp.path().join("missing.Netsukefile");
let args: Vec<OsString> = [
"netsuke".into(),
"--config".into(),
config_path.into_os_string(),
"--file".into(),
missing_manifest.into_os_string(),
"--json".into(),
]
.into();
let _lock = test_support::localizer_test_lock()
.map_err(|error| anyhow::anyhow!("localizer test lock poisoned: {error}"))?;
let _restore = localization::set_localizer_for_tests(localization::localizer());
let clock = configuration_clock(scenario, duration)?;
let dependencies = RunWithArgsDependencies {
locale_env: &EmptyEnv,
system_locale: &NoSystemLocale,
configuration_clock: &clock,
config_env: &context_env_tests::EmptyConfigEnv,
};
let mut exit = None;
let metrics = captured_metrics(|| {
exit = Some(run_with_args(args, &dependencies));
});
ensure!(
exit == Some(ExitCode::FAILURE),
"configuration scenario should fail after recording its metric"
);
Ok(metrics)
}
#[rstest]
#[case::json_resolution_failure(ConfigurationLoadScenario::JsonResolutionFailure)]
#[case::merge_failure(ConfigurationLoadScenario::MergeFailure)]
fn configuration_failures_record_metrics_for_their_exit_path(
#[case] scenario: ConfigurationLoadScenario,
) -> Result<()> {
let duration = Duration::from_millis(7);
let metrics = run_with_config_metrics(scenario, duration)?;
verify_config_load_metrics(&metrics, scenario, duration)?;
Ok(())
}
#[test]
fn a_successful_configuration_merge_records_metrics_before_runner_failure() -> Result<()> {
let duration = Duration::from_millis(11);
let scenario = ConfigurationLoadScenario::SuccessfulMerge;
let metrics = run_with_config_metrics(scenario, duration)?;
verify_config_load_metrics(&metrics, scenario, duration)?;
Ok(())
}
proptest! {
#![proptest_config(ProptestConfig { cases: 8, .. ProptestConfig::default() })]
#[test]
fn configuration_load_outcomes_preserve_the_metrics_contract(milliseconds in 0_u64..=1_000) {
let duration = Duration::from_millis(milliseconds);
for scenario in ConfigurationLoadScenario::ALL {
let metrics = run_with_config_metrics(scenario, duration)
.map_err(|error| TestCaseError::fail(error.to_string()))?;
verify_config_load_metrics(&metrics, scenario, duration)
.map_err(|error| TestCaseError::fail(error.to_string()))?;
}
}
}
#[path = "config_load_context_env_tests.rs"]
mod context_env_tests;