use std::collections::{BTreeMap, HashMap};
use crate::compiler::compile_after::{CompiledEntry, CompiledFile};
use crate::config::{
BaseScheduleConfig, HistogramScenarioConfig, LogScenarioConfig, ScenarioConfig, ScenarioEntry,
SummaryScenarioConfig,
};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PrepareError {
#[error("entry '{entry_label}': unknown signal_type '{signal_type}'")]
UnknownSignalType {
entry_label: String,
signal_type: String,
},
#[error("entry '{entry_label}' (signal_type: metrics): missing required field 'generator'")]
MissingGenerator {
entry_label: String,
},
#[error("entry '{entry_label}' (signal_type: logs): missing required field 'log_generator'")]
MissingLogGenerator {
entry_label: String,
},
#[error(
"entry '{entry_label}' (signal_type: {signal_type}): missing required field 'distribution'"
)]
MissingDistribution {
entry_label: String,
signal_type: String,
},
#[error("unsupported compiled file version: expected 2, got {version}")]
UnsupportedVersion {
version: u32,
},
}
pub fn prepare(file: CompiledFile) -> Result<Vec<ScenarioEntry>, PrepareError> {
let CompiledFile { version, entries } = file;
if version != 2 {
return Err(PrepareError::UnsupportedVersion { version });
}
let mut out = Vec::with_capacity(entries.len());
for entry in entries {
out.push(translate_entry(entry)?);
}
Ok(out)
}
pub fn translate_entry(entry: CompiledEntry) -> Result<ScenarioEntry, PrepareError> {
match entry.signal_type.as_str() {
"metrics" => metrics_entry(entry).map(ScenarioEntry::Metrics),
"logs" => logs_entry(entry).map(ScenarioEntry::Logs),
"histogram" => histogram_entry(entry).map(ScenarioEntry::Histogram),
"summary" => summary_entry(entry).map(ScenarioEntry::Summary),
_ => Err(PrepareError::UnknownSignalType {
entry_label: describe(&entry),
signal_type: entry.signal_type,
}),
}
}
fn describe(entry: &CompiledEntry) -> String {
entry.id.clone().unwrap_or_else(|| entry.name.clone())
}
fn build_base(entry: &mut CompiledEntry) -> BaseScheduleConfig {
let labels = entry.labels.take().map(btree_to_hash);
let clock_group = entry.clock_group.take();
let clock_group_is_auto = clock_group.as_ref().map(|_| entry.clock_group_is_auto);
BaseScheduleConfig {
name: std::mem::take(&mut entry.name),
rate: entry.rate,
duration: entry.duration.take(),
gaps: entry.gaps.take(),
bursts: entry.bursts.take(),
cardinality_spikes: entry.cardinality_spikes.take(),
dynamic_labels: entry.dynamic_labels.take(),
labels,
sink: std::mem::replace(&mut entry.sink, crate::sink::SinkConfig::Stdout),
phase_offset: entry.phase_offset.take(),
clock_group,
clock_group_is_auto,
jitter: entry.jitter,
jitter_seed: entry.jitter_seed,
on_sink_error: entry.on_sink_error,
}
}
fn btree_to_hash(m: BTreeMap<String, String>) -> HashMap<String, String> {
let mut hm = HashMap::with_capacity(m.len());
hm.extend(m);
hm
}
fn metrics_entry(mut entry: CompiledEntry) -> Result<ScenarioConfig, PrepareError> {
let generator = entry
.generator
.take()
.ok_or_else(|| PrepareError::MissingGenerator {
entry_label: describe(&entry),
})?;
let encoder = std::mem::replace(
&mut entry.encoder,
crate::encoder::EncoderConfig::PrometheusText { precision: None },
);
let base = build_base(&mut entry);
Ok(ScenarioConfig {
base,
generator,
encoder,
})
}
fn logs_entry(mut entry: CompiledEntry) -> Result<LogScenarioConfig, PrepareError> {
let generator =
entry
.log_generator
.take()
.ok_or_else(|| PrepareError::MissingLogGenerator {
entry_label: describe(&entry),
})?;
let encoder = std::mem::replace(
&mut entry.encoder,
crate::encoder::EncoderConfig::JsonLines { precision: None },
);
let base = build_base(&mut entry);
Ok(LogScenarioConfig {
base,
generator,
encoder,
})
}
fn histogram_entry(mut entry: CompiledEntry) -> Result<HistogramScenarioConfig, PrepareError> {
let distribution =
entry
.distribution
.take()
.ok_or_else(|| PrepareError::MissingDistribution {
entry_label: describe(&entry),
signal_type: "histogram".to_string(),
})?;
let buckets = entry.buckets.take();
let observations_per_tick = entry.observations_per_tick.map(u64::from);
let mean_shift_per_sec = entry.mean_shift_per_sec;
let seed = entry.seed;
let encoder = std::mem::replace(
&mut entry.encoder,
crate::encoder::EncoderConfig::PrometheusText { precision: None },
);
let base = build_base(&mut entry);
Ok(HistogramScenarioConfig {
base,
buckets,
distribution,
observations_per_tick,
mean_shift_per_sec,
seed,
encoder,
})
}
fn summary_entry(mut entry: CompiledEntry) -> Result<SummaryScenarioConfig, PrepareError> {
let distribution =
entry
.distribution
.take()
.ok_or_else(|| PrepareError::MissingDistribution {
entry_label: describe(&entry),
signal_type: "summary".to_string(),
})?;
let quantiles = entry.quantiles.take();
let observations_per_tick = entry.observations_per_tick.map(u64::from);
let mean_shift_per_sec = entry.mean_shift_per_sec;
let seed = entry.seed;
let encoder = std::mem::replace(
&mut entry.encoder,
crate::encoder::EncoderConfig::PrometheusText { precision: None },
);
let base = build_base(&mut entry);
Ok(SummaryScenarioConfig {
base,
quantiles,
distribution,
observations_per_tick,
mean_shift_per_sec,
seed,
encoder,
})
}
#[cfg(all(test, feature = "config"))]
mod tests {
use std::collections::BTreeMap;
use rstest::rstest;
use super::*;
use crate::config::DistributionConfig;
use crate::encoder::EncoderConfig;
use crate::generator::{GeneratorConfig, LogGeneratorConfig, TemplateConfig};
use crate::sink::SinkConfig;
fn bare(signal_type: &str, name: &str) -> CompiledEntry {
CompiledEntry {
id: None,
signal_type: signal_type.to_string(),
name: name.to_string(),
rate: 10.0,
duration: Some("1s".to_string()),
generator: None,
log_generator: None,
labels: None,
dynamic_labels: None,
encoder: EncoderConfig::PrometheusText { precision: None },
sink: SinkConfig::Stdout,
jitter: None,
jitter_seed: None,
gaps: None,
bursts: None,
cardinality_spikes: None,
phase_offset: None,
clock_group: None,
clock_group_is_auto: false,
distribution: None,
buckets: None,
quantiles: None,
observations_per_tick: None,
mean_shift_per_sec: None,
seed: None,
on_sink_error: crate::OnSinkError::Warn,
}
}
fn metrics_compiled(name: &str) -> CompiledEntry {
let mut e = bare("metrics", name);
e.generator = Some(GeneratorConfig::Constant { value: 1.0 });
e
}
fn logs_compiled(name: &str) -> CompiledEntry {
let mut e = bare("logs", name);
e.log_generator = Some(LogGeneratorConfig::Template {
templates: vec![TemplateConfig {
message: "hi".to_string(),
field_pools: BTreeMap::new(),
}],
severity_weights: None,
seed: Some(0),
});
e.encoder = EncoderConfig::JsonLines { precision: None };
e
}
fn histogram_compiled(name: &str) -> CompiledEntry {
let mut e = bare("histogram", name);
e.distribution = Some(DistributionConfig::Exponential { rate: 10.0 });
e.buckets = Some(vec![0.1, 1.0, 10.0]);
e
}
fn summary_compiled(name: &str) -> CompiledEntry {
let mut e = bare("summary", name);
e.distribution = Some(DistributionConfig::Normal {
mean: 0.1,
stddev: 0.02,
});
e.quantiles = Some(vec![0.5, 0.9, 0.99]);
e
}
fn file_with(entry: CompiledEntry) -> CompiledFile {
CompiledFile {
version: 2,
entries: vec![entry],
}
}
#[test]
fn metrics_entry_translates_to_scenario_entry_metrics() {
let file = file_with(metrics_compiled("cpu_usage"));
let out = prepare(file).expect("translate must succeed");
assert_eq!(out.len(), 1);
match &out[0] {
ScenarioEntry::Metrics(c) => {
assert_eq!(c.base.name, "cpu_usage");
assert_eq!(c.base.rate, 10.0);
assert!(matches!(c.generator, GeneratorConfig::Constant { .. }));
}
other => panic!("expected Metrics, got {other:?}"),
}
}
#[test]
fn logs_entry_translates_to_scenario_entry_logs() {
let file = file_with(logs_compiled("app_logs"));
let out = prepare(file).expect("translate must succeed");
match &out[0] {
ScenarioEntry::Logs(c) => {
assert_eq!(c.base.name, "app_logs");
assert!(matches!(c.generator, LogGeneratorConfig::Template { .. }));
}
other => panic!("expected Logs, got {other:?}"),
}
}
#[test]
fn histogram_entry_translates_with_distribution_and_buckets() {
let file = file_with(histogram_compiled("http_request_duration"));
let out = prepare(file).expect("translate must succeed");
match &out[0] {
ScenarioEntry::Histogram(c) => {
assert_eq!(c.base.name, "http_request_duration");
assert_eq!(c.buckets.as_deref(), Some(&[0.1, 1.0, 10.0][..]));
assert!(matches!(
c.distribution,
DistributionConfig::Exponential { .. }
));
}
other => panic!("expected Histogram, got {other:?}"),
}
}
#[test]
fn summary_entry_translates_with_distribution_and_quantiles() {
let file = file_with(summary_compiled("rpc_duration"));
let out = prepare(file).expect("translate must succeed");
match &out[0] {
ScenarioEntry::Summary(c) => {
assert_eq!(c.base.name, "rpc_duration");
assert_eq!(c.quantiles.as_deref(), Some(&[0.5, 0.9, 0.99][..]));
assert!(matches!(c.distribution, DistributionConfig::Normal { .. }));
}
other => panic!("expected Summary, got {other:?}"),
}
}
#[test]
fn prepare_preserves_entry_order() {
let file = CompiledFile {
version: 2,
entries: vec![
metrics_compiled("first"),
logs_compiled("second"),
histogram_compiled("third"),
summary_compiled("fourth"),
],
};
let out = prepare(file).expect("translate must succeed");
assert_eq!(out.len(), 4);
assert_eq!(out[0].base().name, "first");
assert_eq!(out[1].base().name, "second");
assert_eq!(out[2].base().name, "third");
assert_eq!(out[3].base().name, "fourth");
}
#[test]
fn prepare_empty_file_returns_empty_vec() {
let file = CompiledFile {
version: 2,
entries: vec![],
};
let out = prepare(file).expect("empty file must translate cleanly");
assert!(out.is_empty());
}
#[test]
fn phase_offset_string_is_passed_through_verbatim() {
let mut entry = metrics_compiled("delayed");
entry.phase_offset = Some("152.308s".to_string());
let out = prepare(file_with(entry)).expect("translate");
assert_eq!(out[0].phase_offset(), Some("152.308s"));
}
#[test]
fn clock_group_is_passed_through_on_all_variants() {
for factory in [
metrics_compiled as fn(&str) -> CompiledEntry,
logs_compiled,
histogram_compiled,
summary_compiled,
] {
let mut entry = factory("any");
entry.clock_group = Some("chain_alpha".to_string());
let out = prepare(file_with(entry)).expect("translate");
assert_eq!(out[0].clock_group(), Some("chain_alpha"));
}
}
#[test]
fn labels_btree_to_hash_preserves_all_pairs() {
let mut labels = BTreeMap::new();
labels.insert("k1".to_string(), "v1".to_string());
labels.insert("k2".to_string(), "v2".to_string());
labels.insert("k3".to_string(), "v3".to_string());
let mut entry = metrics_compiled("labeled");
entry.labels = Some(labels.clone());
let out = prepare(file_with(entry)).expect("translate");
let hm = out[0]
.base()
.labels
.as_ref()
.expect("labels must carry through");
assert_eq!(hm.len(), labels.len());
for (k, v) in &labels {
assert_eq!(hm.get(k).map(String::as_str), Some(v.as_str()));
}
}
#[test]
fn labels_empty_btree_maps_to_empty_hash() {
let mut entry = metrics_compiled("empty_labels");
entry.labels = Some(BTreeMap::new());
let out = prepare(file_with(entry)).expect("translate");
let hm = out[0].base().labels.as_ref().expect("Some stays Some");
assert!(hm.is_empty());
}
#[test]
fn labels_none_stays_none() {
let entry = metrics_compiled("no_labels");
let out = prepare(file_with(entry)).expect("translate");
assert!(out[0].base().labels.is_none());
}
#[test]
fn histogram_observations_per_tick_widens_zero_correctly() {
let mut entry = histogram_compiled("zero_obs");
entry.observations_per_tick = Some(0);
let out = prepare(file_with(entry)).expect("translate");
match &out[0] {
ScenarioEntry::Histogram(c) => {
assert_eq!(c.observations_per_tick, Some(0u64));
}
_ => panic!("expected Histogram"),
}
}
#[test]
fn histogram_observations_per_tick_widens_u32_max_correctly() {
let mut entry = histogram_compiled("max_obs");
entry.observations_per_tick = Some(u32::MAX);
let out = prepare(file_with(entry)).expect("translate");
match &out[0] {
ScenarioEntry::Histogram(c) => {
assert_eq!(c.observations_per_tick, Some(u64::from(u32::MAX)));
assert_eq!(c.observations_per_tick, Some(4_294_967_295_u64));
}
_ => panic!("expected Histogram"),
}
}
#[test]
fn summary_observations_per_tick_widens_u32_max_correctly() {
let mut entry = summary_compiled("max_obs_summary");
entry.observations_per_tick = Some(u32::MAX);
let out = prepare(file_with(entry)).expect("translate");
match &out[0] {
ScenarioEntry::Summary(c) => {
assert_eq!(c.observations_per_tick, Some(u64::from(u32::MAX)));
}
_ => panic!("expected Summary"),
}
}
#[test]
fn unknown_signal_type_produces_unknown_signal_type_error() {
let mut entry = bare("traces", "bad");
entry.id = Some("bad".to_string());
let err = prepare(file_with(entry)).expect_err("unknown signal_type must fail");
match err {
PrepareError::UnknownSignalType {
entry_label,
signal_type,
} => {
assert_eq!(entry_label, "bad");
assert_eq!(signal_type, "traces");
}
other => panic!("expected UnknownSignalType, got {other:?}"),
}
}
#[test]
fn unknown_signal_type_falls_back_to_name_when_id_absent() {
let entry = bare("traces", "bad_by_name");
let err = prepare(file_with(entry)).expect_err("unknown signal_type must fail");
match err {
PrepareError::UnknownSignalType { entry_label, .. } => {
assert_eq!(entry_label, "bad_by_name");
}
other => panic!("expected UnknownSignalType, got {other:?}"),
}
}
#[test]
fn metrics_without_generator_produces_missing_generator_error() {
let mut entry = bare("metrics", "no_gen");
entry.id = Some("no_gen".to_string());
let err = prepare(file_with(entry)).expect_err("missing generator must fail");
match err {
PrepareError::MissingGenerator { entry_label } => {
assert_eq!(entry_label, "no_gen");
}
other => panic!("expected MissingGenerator, got {other:?}"),
}
}
#[test]
fn logs_without_log_generator_produces_missing_log_generator_error() {
let entry = bare("logs", "no_log_gen");
let err = prepare(file_with(entry)).expect_err("missing log_generator must fail");
match err {
PrepareError::MissingLogGenerator { entry_label } => {
assert_eq!(entry_label, "no_log_gen");
}
other => panic!("expected MissingLogGenerator, got {other:?}"),
}
}
#[test]
fn histogram_without_distribution_produces_missing_distribution_error() {
let entry = bare("histogram", "no_dist_hist");
let err = prepare(file_with(entry)).expect_err("missing distribution must fail");
match err {
PrepareError::MissingDistribution {
entry_label,
signal_type,
} => {
assert_eq!(entry_label, "no_dist_hist");
assert_eq!(signal_type, "histogram");
}
other => panic!("expected MissingDistribution, got {other:?}"),
}
}
#[test]
fn summary_without_distribution_produces_missing_distribution_error() {
let entry = bare("summary", "no_dist_summary");
let err = prepare(file_with(entry)).expect_err("missing distribution must fail");
match err {
PrepareError::MissingDistribution {
entry_label,
signal_type,
} => {
assert_eq!(entry_label, "no_dist_summary");
assert_eq!(signal_type, "summary");
}
other => panic!("expected MissingDistribution, got {other:?}"),
}
}
#[derive(Debug, Clone, Copy)]
enum ExpectedMissing {
Generator,
LogGenerator,
Distribution,
}
#[rustfmt::skip]
#[rstest]
#[case::metrics("metrics", ExpectedMissing::Generator)]
#[case::logs("logs", ExpectedMissing::LogGenerator)]
#[case::histogram("histogram", ExpectedMissing::Distribution)]
#[case::summary("summary", ExpectedMissing::Distribution)]
fn missing_required_field_fails_per_signal_type(
#[case] signal_type: &str,
#[case] expected: ExpectedMissing,
) {
let entry = bare(signal_type, "empty_shape");
let err = prepare(file_with(entry)).err().unwrap_or_else(|| {
panic!("signal_type '{signal_type}' missing required field must error")
});
let matched = match expected {
ExpectedMissing::Generator => {
matches!(err, PrepareError::MissingGenerator { ref entry_label } if entry_label == "empty_shape")
}
ExpectedMissing::LogGenerator => {
matches!(err, PrepareError::MissingLogGenerator { ref entry_label } if entry_label == "empty_shape")
}
ExpectedMissing::Distribution => matches!(
err,
PrepareError::MissingDistribution { ref entry_label, signal_type: ref st }
if entry_label == "empty_shape" && st == signal_type
),
};
assert!(
matched,
"signal_type '{signal_type}': expected {expected:?}, got {err:?}"
);
}
#[test]
fn prepare_fails_fast_on_first_bad_entry() {
let file = CompiledFile {
version: 2,
entries: vec![
metrics_compiled("ok_1"),
bare("traces", "bad"),
metrics_compiled("ok_2"),
],
};
let err = prepare(file).expect_err("bad entry in middle must fail");
assert!(
matches!(err, PrepareError::UnknownSignalType { .. }),
"middle bad entry must produce UnknownSignalType, got {err:?}"
);
}
#[test]
fn prepare_error_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<PrepareError>();
}
#[test]
fn prepare_rejects_non_v2_version() {
let file = CompiledFile {
version: 3,
entries: vec![metrics_compiled("never_translated")],
};
let err = prepare(file).expect_err("version != 2 must fail");
match err {
PrepareError::UnsupportedVersion { version } => assert_eq!(version, 3),
other => panic!("expected UnsupportedVersion, got {other:?}"),
}
}
#[test]
fn prepare_version_check_precedes_entry_translation() {
let file = CompiledFile {
version: 0,
entries: vec![bare("traces", "would_fail_if_translated")],
};
let err = prepare(file).expect_err("version 0 must fail");
assert!(
matches!(err, PrepareError::UnsupportedVersion { version: 0 }),
"expected UnsupportedVersion {{ version: 0 }}, got {err:?}"
);
}
}