use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct Config {
pub n_plus_one_sql_critical_max: u32,
pub n_plus_one_http_warning_max: u32,
pub io_waste_ratio_max: f64,
pub n_plus_one_threshold: u32,
pub window_duration_ms: u64,
pub slow_query_threshold_ms: u64,
pub slow_query_min_occurrences: u32,
pub max_fanout: u32,
pub green_enabled: bool,
pub green_region: Option<String>,
pub listen_addr: String,
pub listen_port: u16,
pub listen_port_grpc: u16,
pub json_socket: String,
pub max_active_traces: usize,
pub trace_ttl_ms: u64,
pub sampling_rate: f64,
pub max_events_per_trace: usize,
pub max_payload_size: usize,
}
impl Default for Config {
fn default() -> Self {
Self {
n_plus_one_sql_critical_max: 0,
n_plus_one_http_warning_max: 3,
io_waste_ratio_max: 0.30,
n_plus_one_threshold: 5,
window_duration_ms: 500,
slow_query_threshold_ms: 500,
slow_query_min_occurrences: 3,
max_fanout: 20,
green_enabled: true,
green_region: None,
listen_addr: "127.0.0.1".to_string(),
listen_port: 4318,
listen_port_grpc: 4317,
json_socket: "/tmp/perf-sentinel.sock".to_string(),
max_active_traces: 10_000,
trace_ttl_ms: 30_000,
sampling_rate: 1.0,
max_events_per_trace: 1_000,
max_payload_size: 1_048_576, }
}
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawConfig {
thresholds: ThresholdsSection,
detection: DetectionSection,
green: GreenSection,
daemon: DaemonSection,
max_payload_size: Option<usize>,
n_plus_one_threshold: Option<u32>,
listen_addr: Option<String>,
listen_port: Option<u16>,
window_duration_ms: Option<u64>,
trace_ttl_ms: Option<u64>,
max_active_traces: Option<usize>,
max_events_per_trace: Option<usize>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
#[allow(clippy::struct_field_names)] struct ThresholdsSection {
n_plus_one_sql_critical_max: Option<u32>,
n_plus_one_http_warning_max: Option<u32>,
io_waste_ratio_max: Option<f64>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct DetectionSection {
window_duration_ms: Option<u64>,
n_plus_one_min_occurrences: Option<u32>,
slow_query_threshold_ms: Option<u64>,
slow_query_min_occurrences: Option<u32>,
max_fanout: Option<u32>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct GreenSection {
enabled: Option<bool>,
region: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct DaemonSection {
listen_address: Option<String>,
listen_port_http: Option<u16>,
listen_port_grpc: Option<u16>,
json_socket: Option<String>,
max_active_traces: Option<usize>,
trace_ttl_ms: Option<u64>,
sampling_rate: Option<f64>,
max_events_per_trace: Option<usize>,
max_payload_size: Option<usize>,
}
impl From<RawConfig> for Config {
fn from(raw: RawConfig) -> Self {
let defaults = Self::default();
Self {
n_plus_one_sql_critical_max: raw
.thresholds
.n_plus_one_sql_critical_max
.unwrap_or(defaults.n_plus_one_sql_critical_max),
n_plus_one_http_warning_max: raw
.thresholds
.n_plus_one_http_warning_max
.unwrap_or(defaults.n_plus_one_http_warning_max),
io_waste_ratio_max: raw
.thresholds
.io_waste_ratio_max
.unwrap_or(defaults.io_waste_ratio_max),
n_plus_one_threshold: raw
.detection
.n_plus_one_min_occurrences
.or(raw.n_plus_one_threshold)
.unwrap_or(defaults.n_plus_one_threshold),
window_duration_ms: raw
.detection
.window_duration_ms
.or(raw.window_duration_ms)
.unwrap_or(defaults.window_duration_ms),
slow_query_threshold_ms: raw
.detection
.slow_query_threshold_ms
.unwrap_or(defaults.slow_query_threshold_ms),
slow_query_min_occurrences: raw
.detection
.slow_query_min_occurrences
.unwrap_or(defaults.slow_query_min_occurrences),
max_fanout: raw.detection.max_fanout.unwrap_or(defaults.max_fanout),
green_enabled: raw.green.enabled.unwrap_or(defaults.green_enabled),
green_region: raw.green.region.or(defaults.green_region),
listen_addr: raw
.daemon
.listen_address
.or(raw.listen_addr)
.unwrap_or(defaults.listen_addr),
listen_port: raw
.daemon
.listen_port_http
.or(raw.listen_port)
.unwrap_or(defaults.listen_port),
listen_port_grpc: raw
.daemon
.listen_port_grpc
.unwrap_or(defaults.listen_port_grpc),
json_socket: raw.daemon.json_socket.unwrap_or(defaults.json_socket),
max_active_traces: raw
.daemon
.max_active_traces
.or(raw.max_active_traces)
.unwrap_or(defaults.max_active_traces),
trace_ttl_ms: raw
.daemon
.trace_ttl_ms
.or(raw.trace_ttl_ms)
.unwrap_or(defaults.trace_ttl_ms),
sampling_rate: raw.daemon.sampling_rate.unwrap_or(defaults.sampling_rate),
max_events_per_trace: raw
.daemon
.max_events_per_trace
.or(raw.max_events_per_trace)
.unwrap_or(defaults.max_events_per_trace),
max_payload_size: raw
.daemon
.max_payload_size
.or(raw.max_payload_size)
.unwrap_or(defaults.max_payload_size),
}
}
}
fn check_range<T: PartialOrd + std::fmt::Display>(
name: &str,
val: &T,
min: &T,
max: &T,
) -> Result<(), String> {
if val < min {
return Err(format!("{name} must be >= {min}, got {val}"));
}
if val > max {
return Err(format!("{name} must be <= {max}, got {val}"));
}
Ok(())
}
fn check_min<T: PartialOrd + std::fmt::Display>(
name: &str,
val: &T,
min: &T,
) -> Result<(), String> {
if val < min {
return Err(format!("{name} must be >= {min}, got {val}"));
}
Ok(())
}
impl Config {
pub fn validate(&self) -> Result<(), String> {
self.validate_daemon_limits()?;
self.validate_detection_params()?;
self.validate_rates()?;
self.validate_listen_addr()?;
Ok(())
}
fn validate_daemon_limits(&self) -> Result<(), String> {
check_range(
"max_payload_size",
&self.max_payload_size,
&1024,
&(100 * 1024 * 1024),
)?;
check_range("max_active_traces", &self.max_active_traces, &1, &1_000_000)?;
check_range(
"max_events_per_trace",
&self.max_events_per_trace,
&1,
&100_000,
)?;
check_range("trace_ttl_ms", &self.trace_ttl_ms, &100, &3_600_000)?;
check_range("listen_port_http", &self.listen_port, &1, &65535)?;
check_range("listen_port_grpc", &self.listen_port_grpc, &1, &65535)?;
Ok(())
}
fn validate_detection_params(&self) -> Result<(), String> {
check_min("n_plus_one_threshold", &self.n_plus_one_threshold, &1)?;
check_min("window_duration_ms", &self.window_duration_ms, &1)?;
check_min("slow_query_threshold_ms", &self.slow_query_threshold_ms, &1)?;
check_min(
"slow_query_min_occurrences",
&self.slow_query_min_occurrences,
&1,
)?;
check_range("max_fanout", &self.max_fanout, &1, &100_000)?;
Ok(())
}
fn validate_rates(&self) -> Result<(), String> {
if !(0.0..=1.0).contains(&self.sampling_rate) {
return Err(format!(
"sampling_rate must be in [0.0, 1.0], got {}",
self.sampling_rate
));
}
if !(0.0..=1.0).contains(&self.io_waste_ratio_max) {
return Err(format!(
"io_waste_ratio_max must be in [0.0, 1.0], got {}",
self.io_waste_ratio_max
));
}
Ok(())
}
#[allow(clippy::unnecessary_wraps)]
fn validate_listen_addr(&self) -> Result<(), String> {
if self.listen_addr != "127.0.0.1" && self.listen_addr != "::1" {
tracing::warn!(
"Daemon configured to listen on non-loopback address: {}. \
Endpoints have no authentication, use a reverse proxy or \
network policy for security.",
self.listen_addr
);
}
Ok(())
}
}
pub fn load_from_str(content: &str) -> Result<Config, ConfigError> {
let raw: RawConfig = toml::from_str(content).map_err(ConfigError::Parse)?;
let config = Config::from(raw);
config.validate().map_err(ConfigError::Validation)?;
Ok(config)
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("config parse error: {0}")]
Parse(#[from] toml::de::Error),
#[error("config validation error: {0}")]
Validation(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_has_safe_defaults() {
let config = Config::default();
assert_eq!(config.max_payload_size, 1_048_576);
assert_eq!(config.listen_addr, "127.0.0.1");
assert_eq!(config.n_plus_one_threshold, 5);
assert_eq!(config.window_duration_ms, 500);
assert_eq!(config.trace_ttl_ms, 30_000);
assert_eq!(config.max_active_traces, 10_000);
assert_eq!(config.max_events_per_trace, 1_000);
}
#[test]
fn parse_empty_toml_gives_defaults() {
let config = load_from_str("").unwrap();
assert_eq!(config.max_payload_size, 1_048_576);
}
#[test]
fn parse_partial_toml() {
let config = load_from_str("n_plus_one_threshold = 10").unwrap();
assert_eq!(config.n_plus_one_threshold, 10);
assert_eq!(config.max_payload_size, 1_048_576); }
#[test]
fn parse_window_config() {
let config = load_from_str(
"window_duration_ms = 1000\ntrace_ttl_ms = 60000\nmax_active_traces = 5000",
)
.unwrap();
assert_eq!(config.window_duration_ms, 1000);
assert_eq!(config.trace_ttl_ms, 60_000);
assert_eq!(config.max_active_traces, 5000);
}
#[test]
fn parse_sectioned_format() {
let toml = r#"
[thresholds]
n_plus_one_sql_critical_max = 2
n_plus_one_http_warning_max = 5
io_waste_ratio_max = 0.50
[detection]
window_duration_ms = 1000
n_plus_one_min_occurrences = 10
[green]
enabled = false
[daemon]
listen_address = "0.0.0.0"
listen_port_http = 9418
listen_port_grpc = 9417
json_socket = "/var/run/perf-sentinel.sock"
max_active_traces = 20000
trace_ttl_ms = 60000
sampling_rate = 0.5
max_events_per_trace = 500
max_payload_size = 2097152
"#;
let config = load_from_str(toml).unwrap();
assert_eq!(config.n_plus_one_sql_critical_max, 2);
assert_eq!(config.n_plus_one_http_warning_max, 5);
assert!((config.io_waste_ratio_max - 0.50).abs() < f64::EPSILON);
assert_eq!(config.n_plus_one_threshold, 10);
assert_eq!(config.window_duration_ms, 1000);
assert!(!config.green_enabled);
assert_eq!(config.listen_addr, "0.0.0.0");
assert_eq!(config.listen_port, 9418);
assert_eq!(config.listen_port_grpc, 9417);
assert_eq!(config.json_socket, "/var/run/perf-sentinel.sock");
assert_eq!(config.max_active_traces, 20_000);
assert_eq!(config.trace_ttl_ms, 60_000);
assert!((config.sampling_rate - 0.5).abs() < f64::EPSILON);
assert_eq!(config.max_events_per_trace, 500);
assert_eq!(config.max_payload_size, 2_097_152);
}
#[test]
fn section_overrides_flat_field() {
let toml = r"
n_plus_one_threshold = 7
window_duration_ms = 800
[detection]
n_plus_one_min_occurrences = 12
";
let config = load_from_str(toml).unwrap();
assert_eq!(config.n_plus_one_threshold, 12);
assert_eq!(config.window_duration_ms, 800);
}
#[test]
fn new_fields_have_correct_defaults() {
let config = Config::default();
assert_eq!(config.n_plus_one_sql_critical_max, 0);
assert_eq!(config.n_plus_one_http_warning_max, 3);
assert!((config.io_waste_ratio_max - 0.30).abs() < f64::EPSILON);
assert!(config.green_enabled);
assert_eq!(config.listen_port_grpc, 4317);
assert_eq!(config.json_socket, "/tmp/perf-sentinel.sock");
assert!((config.sampling_rate - 1.0).abs() < f64::EPSILON);
}
#[test]
fn default_config_validates() {
let config = Config::default();
assert!(config.validate().is_ok());
}
#[test]
fn rejects_sampling_rate_above_one() {
let result = load_from_str("[daemon]\nsampling_rate = 5.0");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("sampling_rate"), "got: {err}");
}
#[test]
fn rejects_negative_sampling_rate() {
let result = load_from_str("[daemon]\nsampling_rate = -0.1");
assert!(result.is_err());
}
#[test]
fn rejects_io_waste_ratio_max_above_one() {
let result = load_from_str("[thresholds]\nio_waste_ratio_max = 1.5");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("io_waste_ratio_max"), "got: {err}");
}
#[test]
fn rejects_zero_max_payload_size() {
let result = load_from_str("[daemon]\nmax_payload_size = 0");
assert!(result.is_err());
}
#[test]
fn rejects_zero_n_plus_one_threshold() {
let result = load_from_str("n_plus_one_threshold = 0");
assert!(result.is_err());
}
#[test]
fn rejects_zero_max_active_traces() {
let result = load_from_str("max_active_traces = 0");
assert!(result.is_err());
}
#[test]
fn rejects_zero_max_events_per_trace() {
let result = load_from_str("max_events_per_trace = 0");
assert!(result.is_err());
}
#[test]
fn slow_query_defaults() {
let config = Config::default();
assert_eq!(config.slow_query_threshold_ms, 500);
assert_eq!(config.slow_query_min_occurrences, 3);
assert!(config.green_region.is_none());
}
#[test]
fn parse_slow_query_config() {
let toml = r"
[detection]
slow_query_threshold_ms = 1000
slow_query_min_occurrences = 5
";
let config = load_from_str(toml).unwrap();
assert_eq!(config.slow_query_threshold_ms, 1000);
assert_eq!(config.slow_query_min_occurrences, 5);
}
#[test]
fn parse_green_region() {
let toml = r#"
[green]
enabled = true
region = "eu-west-3"
"#;
let config = load_from_str(toml).unwrap();
assert_eq!(config.green_region.as_deref(), Some("eu-west-3"));
}
#[test]
fn rejects_zero_slow_query_threshold() {
let result = load_from_str("[detection]\nslow_query_threshold_ms = 0");
assert!(result.is_err());
}
#[test]
fn rejects_zero_slow_query_min_occurrences() {
let result = load_from_str("[detection]\nslow_query_min_occurrences = 0");
assert!(result.is_err());
}
#[test]
fn rejects_zero_max_fanout() {
let result = load_from_str("[detection]\nmax_fanout = 0");
assert!(result.is_err());
}
#[test]
fn rejects_max_fanout_over_100k() {
let result = load_from_str("[detection]\nmax_fanout = 100001");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("max_fanout"), "got: {err}");
}
#[test]
fn accepts_max_fanout_at_100k() {
let result = load_from_str("[detection]\nmax_fanout = 100000");
assert!(result.is_ok());
}
#[test]
fn rejects_max_payload_size_over_100mb() {
let result = load_from_str("[daemon]\nmax_payload_size = 104857601");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("max_payload_size"), "got: {err}");
}
#[test]
fn accepts_max_payload_size_at_100mb() {
let result = load_from_str("[daemon]\nmax_payload_size = 104857600");
assert!(result.is_ok());
}
#[test]
fn rejects_max_active_traces_over_1m() {
let result = load_from_str("[daemon]\nmax_active_traces = 1000001");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("max_active_traces"), "got: {err}");
}
#[test]
fn accepts_max_active_traces_at_1m() {
let result = load_from_str("[daemon]\nmax_active_traces = 1000000");
assert!(result.is_ok());
}
#[test]
fn rejects_max_events_per_trace_over_100k() {
let result = load_from_str("[daemon]\nmax_events_per_trace = 100001");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("max_events_per_trace"), "got: {err}");
}
#[test]
fn accepts_max_events_per_trace_at_100k() {
let result = load_from_str("[daemon]\nmax_events_per_trace = 100000");
assert!(result.is_ok());
}
#[test]
fn rejects_trace_ttl_below_100() {
let result = load_from_str("[daemon]\ntrace_ttl_ms = 50");
assert!(result.is_err());
}
#[test]
fn rejects_zero_window_duration() {
let result = load_from_str("[detection]\nwindow_duration_ms = 0");
assert!(result.is_err());
}
#[test]
fn green_disabled_parses() {
let config = load_from_str("[green]\nenabled = false").unwrap();
assert!(!config.green_enabled);
}
#[test]
fn rejects_port_zero() {
let result = load_from_str("[daemon]\nlisten_port_http = 0");
assert!(result.is_err());
}
#[test]
fn accepts_port_one() {
let config = load_from_str("[daemon]\nlisten_port_http = 1").unwrap();
assert_eq!(config.listen_port, 1);
}
#[test]
fn accepts_port_65535() {
let config = load_from_str("[daemon]\nlisten_port_http = 65535").unwrap();
assert_eq!(config.listen_port, 65535);
}
#[test]
fn rejects_grpc_port_zero() {
let result = load_from_str("[daemon]\nlisten_port_grpc = 0");
assert!(result.is_err());
}
#[test]
fn rejects_trace_ttl_above_1h() {
let result = load_from_str("[daemon]\ntrace_ttl_ms = 3600001");
assert!(result.is_err());
}
#[test]
fn accepts_trace_ttl_at_1h() {
let config = load_from_str("[daemon]\ntrace_ttl_ms = 3600000").unwrap();
assert_eq!(config.trace_ttl_ms, 3_600_000);
}
#[test]
fn accepts_trace_ttl_at_100ms() {
let config = load_from_str("[daemon]\ntrace_ttl_ms = 100").unwrap();
assert_eq!(config.trace_ttl_ms, 100);
}
#[test]
fn accepts_sampling_rate_zero() {
let config = load_from_str("[daemon]\nsampling_rate = 0.0").unwrap();
assert!((config.sampling_rate - 0.0).abs() < f64::EPSILON);
}
#[test]
fn accepts_sampling_rate_one() {
let config = load_from_str("[daemon]\nsampling_rate = 1.0").unwrap();
assert!((config.sampling_rate - 1.0).abs() < f64::EPSILON);
}
}