use std::collections::BTreeMap;
pub use crate::yaml_helpers::ParamValue;
use crate::yaml_helpers::{escape_yaml_double_quoted, format_float, format_rate, needs_quoting};
#[derive(Debug, Clone)]
pub struct MetricAnswers {
pub name: String,
pub situation: String,
pub situation_params: Vec<(String, ParamValue)>,
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct PackAnswers {
pub pack_name: String,
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct LogAnswers {
pub name: String,
pub message_template: String,
pub severity_weights: Vec<(String, f64)>,
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct HistogramAnswers {
pub name: String,
pub distribution_type: String,
pub distribution_params: Vec<(String, ParamValue)>,
pub observations_per_tick: u64,
pub buckets: Option<Vec<f64>>,
pub seed: u64,
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct SummaryAnswers {
pub name: String,
pub distribution_type: String,
pub distribution_params: Vec<(String, ParamValue)>,
pub observations_per_tick: u64,
pub quantiles: Option<Vec<f64>>,
pub seed: u64,
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct DeliveryAnswers {
pub domain: String,
pub rate: f64,
pub duration: String,
pub encoder: String,
pub sink: String,
pub endpoint: Option<String>,
pub sink_extra: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
pub enum ScenarioKind {
SingleMetric(MetricAnswers),
Pack(PackAnswers),
Logs(LogAnswers),
Histogram(HistogramAnswers),
Summary(SummaryAnswers),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InitScenarioType {
SingleMetric,
Pack,
Logs,
Histogram,
Summary,
}
impl ScenarioKind {
pub fn scenario_type(&self) -> InitScenarioType {
match self {
ScenarioKind::SingleMetric(_) => InitScenarioType::SingleMetric,
ScenarioKind::Pack(_) => InitScenarioType::Pack,
ScenarioKind::Logs(_) => InitScenarioType::Logs,
ScenarioKind::Histogram(_) => InitScenarioType::Histogram,
ScenarioKind::Summary(_) => InitScenarioType::Summary,
}
}
}
pub fn required_encoder_for_sink(sink: &str) -> Option<&'static str> {
match sink {
"remote_write" => Some("remote_write"),
"otlp_grpc" => Some("otlp"),
_ => None,
}
}
pub fn render_scenario_yaml(kind: &ScenarioKind, delivery: &DeliveryAnswers) -> String {
match kind {
ScenarioKind::SingleMetric(answers) => render_single_metric(answers, delivery),
ScenarioKind::Pack(answers) => render_pack_scenario(answers, delivery),
ScenarioKind::Logs(answers) => render_logs_scenario(answers, delivery),
ScenarioKind::Histogram(answers) => render_histogram_scenario(answers, delivery),
ScenarioKind::Summary(answers) => render_summary_scenario(answers, delivery),
}
}
pub fn suggest_filename(kind: &ScenarioKind) -> String {
match kind {
ScenarioKind::SingleMetric(answers) => {
format!("{}.yaml", answers.name.replace('_', "-"))
}
ScenarioKind::Pack(answers) => {
format!("{}.yaml", answers.pack_name.replace('_', "-"))
}
ScenarioKind::Logs(answers) => {
format!("{}.yaml", answers.name.replace('_', "-"))
}
ScenarioKind::Histogram(answers) => {
format!("{}.yaml", answers.name.replace('_', "-"))
}
ScenarioKind::Summary(answers) => {
format!("{}.yaml", answers.name.replace('_', "-"))
}
}
}
fn write_header(out: &mut String, title: &str, detail: &[&str]) {
out.push_str(&format!("# {title}\n"));
out.push_str("#\n");
out.push_str("# Generated by `sonda init`. Run with:\n");
out.push_str("# sonda run --scenario <this-file>\n");
for line in detail {
out.push_str(&format!("# {line}\n"));
}
out.push('\n');
}
fn write_defaults_block(
out: &mut String,
delivery: &DeliveryAnswers,
default_labels: &BTreeMap<String, String>,
) {
out.push_str("# Defaults inherited by every entry in scenarios: below.\n");
out.push_str("defaults:\n");
out.push_str(&format!(" rate: {}\n", format_rate(delivery.rate)));
out.push_str(&format!(" duration: {}\n", delivery.duration));
out.push_str(" encoder:\n");
out.push_str(&format!(" type: {}\n", delivery.encoder));
render_sink(out, delivery, 2);
if !default_labels.is_empty() {
out.push_str(" labels:\n");
for (key, value) in default_labels {
write_label_line(out, 4, key, value);
}
}
out.push('\n');
}
fn write_label_line(out: &mut String, indent: usize, key: &str, value: &str) {
let pad = " ".repeat(indent);
if needs_quoting(value) {
out.push_str(&format!(
"{pad}{key}: \"{}\"\n",
escape_yaml_double_quoted(value)
));
} else {
out.push_str(&format!("{pad}{key}: {value}\n"));
}
}
fn render_single_metric(answers: &MetricAnswers, delivery: &DeliveryAnswers) -> String {
let mut out = String::with_capacity(1024);
write_header(
&mut out,
&format!(
"{}: {} scenario using the '{}' pattern.",
answers.name, delivery.domain, answers.situation
),
&[],
);
out.push_str("version: 2\n\n");
write_defaults_block(&mut out, delivery, &BTreeMap::new());
out.push_str("scenarios:\n");
out.push_str(" - signal_type: metrics\n");
out.push_str(&format!(" name: {}\n", answers.name));
out.push_str(" generator:\n");
out.push_str(&format!(" type: {}\n", answers.situation));
for (key, value) in &answers.situation_params {
match value {
ParamValue::Float(v) => {
out.push_str(&format!(" {key}: {}\n", format_float(*v)));
}
ParamValue::String(s) => {
out.push_str(&format!(
" {key}: \"{}\"\n",
escape_yaml_double_quoted(s)
));
}
}
}
if !answers.labels.is_empty() {
out.push_str(" labels:\n");
for (key, value) in &answers.labels {
write_label_line(&mut out, 6, key, value);
}
}
out
}
fn render_pack_scenario(answers: &PackAnswers, delivery: &DeliveryAnswers) -> String {
let mut out = String::with_capacity(512);
write_header(
&mut out,
&format!(
"Pack-based scenario using '{}' metric pack.",
answers.pack_name
),
&[],
);
out.push_str("version: 2\n\n");
write_defaults_block(&mut out, delivery, &BTreeMap::new());
out.push_str("scenarios:\n");
out.push_str(" - signal_type: metrics\n");
out.push_str(&format!(" pack: {}\n", answers.pack_name));
if !answers.labels.is_empty() {
out.push_str(" labels:\n");
for (key, value) in &answers.labels {
write_label_line(&mut out, 6, key, value);
}
}
out
}
fn render_logs_scenario(answers: &LogAnswers, delivery: &DeliveryAnswers) -> String {
let mut out = String::with_capacity(1024);
write_header(
&mut out,
&format!("Log scenario: {}.", answers.name.replace('_', " ")),
&[],
);
out.push_str("version: 2\n\n");
write_defaults_block(&mut out, delivery, &BTreeMap::new());
out.push_str("scenarios:\n");
out.push_str(" - signal_type: logs\n");
out.push_str(&format!(" name: {}\n", answers.name));
out.push_str(" log_generator:\n");
out.push_str(" type: template\n");
out.push_str(" templates:\n");
out.push_str(&format!(
" - message: \"{}\"\n",
escape_yaml_double_quoted(&answers.message_template)
));
out.push_str(" field_pools: {}\n");
if !answers.severity_weights.is_empty() {
out.push_str(" severity_weights:\n");
for (sev, weight) in &answers.severity_weights {
out.push_str(&format!(" {sev}: {weight}\n"));
}
}
out.push_str(" seed: 42\n");
if !answers.labels.is_empty() {
out.push_str(" labels:\n");
for (key, value) in &answers.labels {
write_label_line(&mut out, 6, key, value);
}
}
out
}
const PROMETHEUS_DEFAULT_BUCKETS: &[f64] = &[
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
];
const DEFAULT_QUANTILES: &[f64] = &[0.5, 0.9, 0.95, 0.99];
fn render_histogram_scenario(answers: &HistogramAnswers, delivery: &DeliveryAnswers) -> String {
let mut out = String::with_capacity(1024);
write_header(
&mut out,
&format!("{}: {} histogram scenario.", answers.name, delivery.domain),
&[],
);
out.push_str("version: 2\n\n");
write_defaults_block(&mut out, delivery, &BTreeMap::new());
out.push_str("scenarios:\n");
out.push_str(" - signal_type: histogram\n");
out.push_str(&format!(" name: {}\n", answers.name));
out.push_str(" distribution:\n");
out.push_str(&format!(" type: {}\n", answers.distribution_type));
for (key, value) in &answers.distribution_params {
match value {
ParamValue::Float(v) => {
out.push_str(&format!(" {key}: {}\n", format_float(*v)));
}
ParamValue::String(s) => {
out.push_str(&format!(
" {key}: \"{}\"\n",
escape_yaml_double_quoted(s)
));
}
}
}
out.push_str(&format!(
" observations_per_tick: {}\n",
answers.observations_per_tick
));
out.push_str(&format!(" seed: {}\n", answers.seed));
match &answers.buckets {
Some(custom) => {
let formatted: Vec<String> = custom.iter().map(|v| format_float(*v)).collect();
out.push_str(&format!(" buckets: [{}]\n", formatted.join(", ")));
}
None => {
let formatted: Vec<String> = PROMETHEUS_DEFAULT_BUCKETS
.iter()
.map(|v| format_float(*v))
.collect();
out.push_str(&format!(
" # buckets: [{}] # (Prometheus defaults; omit to use built-in)\n",
formatted.join(", ")
));
}
}
if !answers.labels.is_empty() {
out.push_str(" labels:\n");
for (key, value) in &answers.labels {
write_label_line(&mut out, 6, key, value);
}
}
out
}
fn render_summary_scenario(answers: &SummaryAnswers, delivery: &DeliveryAnswers) -> String {
let mut out = String::with_capacity(1024);
write_header(
&mut out,
&format!("{}: {} summary scenario.", answers.name, delivery.domain),
&[],
);
out.push_str("version: 2\n\n");
write_defaults_block(&mut out, delivery, &BTreeMap::new());
out.push_str("scenarios:\n");
out.push_str(" - signal_type: summary\n");
out.push_str(&format!(" name: {}\n", answers.name));
out.push_str(" distribution:\n");
out.push_str(&format!(" type: {}\n", answers.distribution_type));
for (key, value) in &answers.distribution_params {
match value {
ParamValue::Float(v) => {
out.push_str(&format!(" {key}: {}\n", format_float(*v)));
}
ParamValue::String(s) => {
out.push_str(&format!(
" {key}: \"{}\"\n",
escape_yaml_double_quoted(s)
));
}
}
}
out.push_str(&format!(
" observations_per_tick: {}\n",
answers.observations_per_tick
));
out.push_str(&format!(" seed: {}\n", answers.seed));
match &answers.quantiles {
Some(custom) => {
let formatted: Vec<String> = custom.iter().map(|v| format_float(*v)).collect();
out.push_str(&format!(" quantiles: [{}]\n", formatted.join(", ")));
}
None => {
let formatted: Vec<String> =
DEFAULT_QUANTILES.iter().map(|v| format_float(*v)).collect();
out.push_str(&format!(
" # quantiles: [{}] # (standard defaults; omit to use built-in)\n",
formatted.join(", ")
));
}
}
if !answers.labels.is_empty() {
out.push_str(" labels:\n");
for (key, value) in &answers.labels {
write_label_line(&mut out, 6, key, value);
}
}
out
}
fn render_sink(out: &mut String, delivery: &DeliveryAnswers, indent: usize) {
let pad = " ".repeat(indent);
let sink = &delivery.sink;
let endpoint = &delivery.endpoint;
let extra = &delivery.sink_extra;
out.push_str(&format!("{pad}sink:\n"));
out.push_str(&format!("{pad} type: {sink}\n"));
let endpoint_field = match sink.as_str() {
"http_push" | "remote_write" | "loki" => Some("url"),
"file" => Some("path"),
"otlp_grpc" => Some("endpoint"),
"tcp" | "udp" => Some("address"),
_ => None,
};
if let (Some(field), Some(ref ep)) = (endpoint_field, endpoint) {
out.push_str(&format!(
"{pad} {field}: \"{}\"\n",
escape_yaml_double_quoted(ep)
));
}
match sink.as_str() {
"otlp_grpc" => {
if let Some(signal_type) = extra.get("signal_type") {
out.push_str(&format!("{pad} signal_type: {signal_type}\n"));
}
}
"kafka" => {
if let Some(brokers) = extra.get("brokers") {
out.push_str(&format!(
"{pad} brokers: \"{}\"\n",
escape_yaml_double_quoted(brokers)
));
}
if let Some(topic) = extra.get("topic") {
out.push_str(&format!(
"{pad} topic: \"{}\"\n",
escape_yaml_double_quoted(topic)
));
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stdout_delivery() -> DeliveryAnswers {
DeliveryAnswers {
domain: "infrastructure".to_string(),
rate: 1.0,
duration: "60s".to_string(),
encoder: "prometheus_text".to_string(),
sink: "stdout".to_string(),
endpoint: None,
sink_extra: BTreeMap::new(),
}
}
#[test]
fn suggest_filename_for_single_metric() {
let kind = ScenarioKind::SingleMetric(MetricAnswers {
name: "node_cpu_usage".to_string(),
situation: "steady".to_string(),
situation_params: vec![],
labels: BTreeMap::new(),
});
assert_eq!(suggest_filename(&kind), "node-cpu-usage.yaml");
}
#[test]
fn suggest_filename_for_pack() {
let kind = ScenarioKind::Pack(PackAnswers {
pack_name: "telegraf_snmp_interface".to_string(),
labels: BTreeMap::new(),
});
assert_eq!(suggest_filename(&kind), "telegraf-snmp-interface.yaml");
}
#[test]
fn suggest_filename_for_logs() {
let kind = ScenarioKind::Logs(LogAnswers {
name: "app_error_logs".to_string(),
message_template: "test".to_string(),
severity_weights: vec![],
labels: BTreeMap::new(),
});
assert_eq!(suggest_filename(&kind), "app-error-logs.yaml");
}
#[test]
fn required_encoder_for_remote_write_sink() {
assert_eq!(
required_encoder_for_sink("remote_write"),
Some("remote_write")
);
}
#[test]
fn required_encoder_for_otlp_sink() {
assert_eq!(required_encoder_for_sink("otlp_grpc"), Some("otlp"));
}
#[test]
fn required_encoder_for_stdout_is_none() {
assert_eq!(required_encoder_for_sink("stdout"), None);
}
fn assert_v2_shape(yaml: &str) {
assert!(
yaml.contains("version: 2"),
"missing `version: 2`, got:\n{yaml}"
);
assert!(
yaml.contains("defaults:"),
"missing `defaults:`, got:\n{yaml}"
);
assert!(
yaml.contains("scenarios:"),
"missing `scenarios:`, got:\n{yaml}"
);
let stripped: String = yaml
.lines()
.filter(|l| !l.trim_start().starts_with('#'))
.collect::<Vec<_>>()
.join("\n");
let version_pos = stripped.find("version: 2").expect("has version");
let defaults_pos = stripped.find("defaults:").expect("has defaults");
let scenarios_pos = stripped.find("scenarios:").expect("has scenarios");
assert!(
version_pos < defaults_pos && defaults_pos < scenarios_pos,
"ordering violated: version/defaults/scenarios in:\n{stripped}"
);
}
#[test]
fn single_metric_emits_v2_shape() {
let kind = ScenarioKind::SingleMetric(MetricAnswers {
name: "cpu_usage".to_string(),
situation: "steady".to_string(),
situation_params: vec![
("center".to_string(), ParamValue::Float(50.0)),
("amplitude".to_string(), ParamValue::Float(10.0)),
],
labels: BTreeMap::from([("instance".to_string(), "web-01".to_string())]),
});
let yaml = render_scenario_yaml(&kind, &stdout_delivery());
assert_v2_shape(&yaml);
assert!(yaml.contains("signal_type: metrics"));
assert!(yaml.contains("name: cpu_usage"));
assert!(yaml.contains("type: steady"));
assert!(yaml.contains("center: 50.0"));
assert!(yaml.contains("instance: web-01"));
}
#[test]
fn pack_emits_v2_shape() {
let kind = ScenarioKind::Pack(PackAnswers {
pack_name: "telegraf_snmp_interface".to_string(),
labels: BTreeMap::from([("device".to_string(), "rtr-01".to_string())]),
});
let yaml = render_scenario_yaml(&kind, &stdout_delivery());
assert_v2_shape(&yaml);
assert!(yaml.contains("pack: telegraf_snmp_interface"));
assert!(yaml.contains("device: rtr-01"));
}
#[test]
fn logs_emits_v2_shape() {
let kind = ScenarioKind::Logs(LogAnswers {
name: "app_logs".to_string(),
message_template: "event happened".to_string(),
severity_weights: vec![("info".to_string(), 1.0)],
labels: BTreeMap::new(),
});
let delivery = DeliveryAnswers {
encoder: "json_lines".to_string(),
..stdout_delivery()
};
let yaml = render_scenario_yaml(&kind, &delivery);
assert_v2_shape(&yaml);
assert!(yaml.contains("signal_type: logs"));
assert!(yaml.contains("log_generator:"));
assert!(yaml.contains("type: template"));
assert!(yaml.contains("event happened"));
}
#[test]
fn histogram_emits_v2_shape_with_buckets_comment() {
let kind = ScenarioKind::Histogram(HistogramAnswers {
name: "latency".to_string(),
distribution_type: "normal".to_string(),
distribution_params: vec![
("mean".to_string(), ParamValue::Float(0.2)),
("stddev".to_string(), ParamValue::Float(0.05)),
],
observations_per_tick: 100,
buckets: None,
seed: 0,
labels: BTreeMap::new(),
});
let yaml = render_scenario_yaml(&kind, &stdout_delivery());
assert_v2_shape(&yaml);
assert!(yaml.contains("signal_type: histogram"));
assert!(yaml.contains("distribution:"));
assert!(yaml.contains("# buckets:"));
}
#[test]
fn summary_emits_v2_shape_with_quantiles_comment() {
let kind = ScenarioKind::Summary(SummaryAnswers {
name: "rpc_latency".to_string(),
distribution_type: "exponential".to_string(),
distribution_params: vec![("rate".to_string(), ParamValue::Float(5.0))],
observations_per_tick: 50,
quantiles: None,
seed: 7,
labels: BTreeMap::new(),
});
let yaml = render_scenario_yaml(&kind, &stdout_delivery());
assert_v2_shape(&yaml);
assert!(yaml.contains("signal_type: summary"));
assert!(yaml.contains("# quantiles:"));
assert!(yaml.contains("seed: 7"));
}
#[test]
fn single_metric_output_compiles_via_compile_scenario_file() {
use sonda_core::compile_scenario_file;
use sonda_core::compiler::expand::InMemoryPackResolver;
let kind = ScenarioKind::SingleMetric(MetricAnswers {
name: "cpu_usage".to_string(),
situation: "steady".to_string(),
situation_params: vec![
("center".to_string(), ParamValue::Float(50.0)),
("amplitude".to_string(), ParamValue::Float(10.0)),
("period".to_string(), ParamValue::String("60s".to_string())),
],
labels: BTreeMap::from([("instance".to_string(), "web-01".to_string())]),
});
let yaml = render_scenario_yaml(&kind, &stdout_delivery());
let entries = compile_scenario_file(&yaml, &InMemoryPackResolver::new())
.unwrap_or_else(|e| panic!("emitted YAML must compile, got: {e}\n---\n{yaml}"));
assert_eq!(entries.len(), 1);
}
#[test]
fn logs_output_compiles_via_compile_scenario_file() {
use sonda_core::compile_scenario_file;
use sonda_core::compiler::expand::InMemoryPackResolver;
let kind = ScenarioKind::Logs(LogAnswers {
name: "app_logs".to_string(),
message_template: "event {id}".to_string(),
severity_weights: vec![("info".to_string(), 0.8), ("error".to_string(), 0.2)],
labels: BTreeMap::new(),
});
let delivery = DeliveryAnswers {
encoder: "json_lines".to_string(),
..stdout_delivery()
};
let yaml = render_scenario_yaml(&kind, &delivery);
compile_scenario_file(&yaml, &InMemoryPackResolver::new())
.unwrap_or_else(|e| panic!("emitted logs YAML must compile, got: {e}\n---\n{yaml}"));
}
#[test]
fn histogram_output_compiles_via_compile_scenario_file() {
use sonda_core::compile_scenario_file;
use sonda_core::compiler::expand::InMemoryPackResolver;
let kind = ScenarioKind::Histogram(HistogramAnswers {
name: "latency".to_string(),
distribution_type: "normal".to_string(),
distribution_params: vec![
("mean".to_string(), ParamValue::Float(0.2)),
("stddev".to_string(), ParamValue::Float(0.05)),
],
observations_per_tick: 100,
buckets: Some(vec![0.1, 0.5, 1.0]),
seed: 0,
labels: BTreeMap::new(),
});
let yaml = render_scenario_yaml(&kind, &stdout_delivery());
compile_scenario_file(&yaml, &InMemoryPackResolver::new()).unwrap_or_else(|e| {
panic!("emitted histogram YAML must compile, got: {e}\n---\n{yaml}")
});
}
#[test]
fn summary_output_compiles_via_compile_scenario_file() {
use sonda_core::compile_scenario_file;
use sonda_core::compiler::expand::InMemoryPackResolver;
let kind = ScenarioKind::Summary(SummaryAnswers {
name: "rpc_latency".to_string(),
distribution_type: "exponential".to_string(),
distribution_params: vec![("rate".to_string(), ParamValue::Float(5.0))],
observations_per_tick: 50,
quantiles: Some(vec![0.5, 0.9, 0.99]),
seed: 7,
labels: BTreeMap::new(),
});
let yaml = render_scenario_yaml(&kind, &stdout_delivery());
compile_scenario_file(&yaml, &InMemoryPackResolver::new())
.unwrap_or_else(|e| panic!("emitted summary YAML must compile, got: {e}\n---\n{yaml}"));
}
}