use serde::{Deserialize, Serialize};
use crate::config::validation::{require_nonempty, require_nonzero};
use crate::errors::OrionError;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MetricsConfig {
pub enabled: bool,
pub bind_addr: Option<String>,
}
impl MetricsConfig {
pub(crate) fn validate(&self, server: &crate::config::ServerConfig) -> Result<(), OrionError> {
let Some(addr) = self.bind_addr.as_deref() else {
return Ok(());
};
let parsed = addr
.parse::<std::net::SocketAddr>()
.map_err(|e| OrionError::Config {
message: format!(
"metrics.bind_addr '{addr}' is not a valid host:port address: {e}"
),
})?;
if parsed.port() == server.port && Self::hosts_overlap(&server.host, parsed.ip()) {
return Err(OrionError::Config {
message: format!(
"metrics.bind_addr '{addr}' overlaps server.host/server.port \
('{}:{}') — the metrics listener needs an address of its own \
(leave it unset to keep /metrics on the main listener)",
server.host, server.port
),
});
}
Ok(())
}
fn hosts_overlap(server_host: &str, metrics_ip: std::net::IpAddr) -> bool {
let Ok(server_ip) = server_host.parse::<std::net::IpAddr>() else {
return true;
};
server_ip.is_unspecified() || metrics_ip.is_unspecified() || server_ip == metrics_ip
}
pub fn on_main_listener(&self) -> bool {
self.enabled && self.bind_addr.is_none()
}
pub fn dedicated_bind_addr(&self) -> Option<&str> {
self.enabled.then_some(self.bind_addr.as_deref()).flatten()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TracingConfig {
pub enabled: bool,
pub otlp_endpoint: String,
pub service_name: String,
pub sample_rate: f64,
pub debug_profile_enabled: bool,
}
impl Default for TracingConfig {
fn default() -> Self {
Self {
enabled: false,
otlp_endpoint: "http://localhost:4317".to_string(),
service_name: "orion".to_string(),
sample_rate: 1.0,
debug_profile_enabled: false,
}
}
}
impl TracingConfig {
pub(crate) fn validate(&self) -> Result<(), OrionError> {
if self.enabled {
require_nonempty(
&self.otlp_endpoint,
"tracing.otlp_endpoint (required when tracing is enabled)",
)?;
if !(0.0..=1.0).contains(&self.sample_rate) {
return Err(OrionError::Config {
message: "tracing.sample_rate must be between 0.0 and 1.0".to_string(),
});
}
}
Ok(())
}
}
impl TraceStorageConfig {
pub(crate) fn validate(&self) -> Result<(), OrionError> {
if !(0.0..=1.0).contains(&self.sample_rate) {
return Err(OrionError::Config {
message: "trace_storage.sample_rate must be between 0.0 and 1.0".to_string(),
});
}
match self.mode {
TraceStorageMode::Async => {
require_nonzero(self.max_pending as u64, "trace_storage.max_pending")?;
require_nonzero(self.async_workers as u64, "trace_storage.async_workers")?;
}
TraceStorageMode::Batch => {
require_nonzero(self.max_pending as u64, "trace_storage.max_pending")?;
require_nonzero(self.batch_size as u64, "trace_storage.batch_size")?;
if self.batch_size > 1000 {
return Err(OrionError::Config {
message: "trace_storage.batch_size must be <= 1000 (the batch \
INSERT binds ~11 parameters per row and SQLite caps a \
statement at 32 766 binds)"
.to_string(),
});
}
require_nonzero(
self.batch_flush_interval_ms,
"trace_storage.batch_flush_interval_ms",
)?;
require_nonzero(self.batch_workers as u64, "trace_storage.batch_workers")?;
}
TraceStorageMode::Sync | TraceStorageMode::Off => {}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum TraceStorageMode {
#[default]
Sync,
Async,
Batch,
Off,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum AsyncOnOverflow {
#[default]
Drop,
Block,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TraceStorageConfig {
pub mode: TraceStorageMode,
pub sample_rate: f64,
pub errors_only: bool,
pub max_pending: usize,
pub async_on_overflow: AsyncOnOverflow,
pub overflow_block_timeout_ms: u64,
pub async_workers: usize,
pub batch_size: usize,
pub batch_flush_interval_ms: u64,
pub batch_workers: usize,
}
impl Default for TraceStorageConfig {
fn default() -> Self {
Self {
mode: TraceStorageMode::Sync,
sample_rate: 1.0,
errors_only: false,
max_pending: 10_000,
async_on_overflow: AsyncOnOverflow::Drop,
overflow_block_timeout_ms: 100,
async_workers: 4,
batch_size: 1000,
batch_flush_interval_ms: 100,
batch_workers: 4,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CorsConfig {
pub allowed_origins: Vec<String>,
}
impl Default for CorsConfig {
fn default() -> Self {
Self {
allowed_origins: vec!["*".to_string()],
}
}
}
impl CorsConfig {
pub(crate) fn validate(&self, is_production: bool) -> Result<(), OrionError> {
if self.allowed_origins.len() > 1 && self.allowed_origins.iter().any(|o| o == "*") {
return Err(OrionError::Config {
message: "CORS allowed_origins cannot mix '*' with explicit origins. \
Use exactly [\"*\"] for permissive CORS, or list explicit origins only"
.to_string(),
});
}
if self.allowed_origins.len() == 1 && self.allowed_origins[0] == "*" {
if is_production {
return Err(OrionError::Config {
message:
"CORS wildcard '*' is not allowed when environment starts with 'prod'. \
Set explicit origins in [cors] allowed_origins"
.to_string(),
});
}
tracing::warn!(
"CORS is set to permissive ('*'). For production, configure specific origins in [cors] allowed_origins"
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ServerConfig;
fn metrics(bind_addr: Option<&str>) -> MetricsConfig {
MetricsConfig {
enabled: true,
bind_addr: bind_addr.map(str::to_string),
}
}
#[test]
fn bind_addr_must_be_a_host_port_pair() {
let err = metrics(Some("not-an-address"))
.validate(&ServerConfig::default())
.expect_err("a bad address must fail at startup, not at bind");
assert!(err.to_string().contains("metrics.bind_addr"), "{err}");
assert!(
metrics(Some("9090"))
.validate(&ServerConfig::default())
.is_err()
);
assert!(
metrics(Some("127.0.0.1:9090"))
.validate(&ServerConfig::default())
.is_ok()
);
}
#[test]
fn bind_addr_must_not_collide_with_the_main_listener() {
let server = ServerConfig {
host: "127.0.0.1".to_string(),
port: 8080,
..ServerConfig::default()
};
let err = metrics(Some("127.0.0.1:8080"))
.validate(&server)
.expect_err("two listeners on one address must be refused");
assert!(err.to_string().contains("overlaps"), "{err}");
assert!(metrics(Some("127.0.0.1:9090")).validate(&server).is_ok());
}
#[test]
fn a_wildcard_on_either_side_overlaps_the_same_port() {
let wildcard = |host: &str| ServerConfig {
host: host.to_string(),
port: 8080,
..ServerConfig::default()
};
for host in ["0.0.0.0", "::"] {
let err = metrics(Some("127.0.0.1:8080"))
.validate(&wildcard(host))
.expect_err("a wildcard server.host must overlap a specific metrics address");
assert!(err.to_string().contains("overlaps"), "{err} (host {host})");
}
assert!(
metrics(Some("0.0.0.0:8080"))
.validate(&ServerConfig {
host: "10.0.0.5".to_string(),
port: 8080,
..ServerConfig::default()
})
.is_err()
);
assert!(
metrics(Some("127.0.0.1:8080"))
.validate(&ServerConfig {
host: "10.0.0.5".to_string(),
port: 8080,
..ServerConfig::default()
})
.is_ok()
);
}
#[test]
fn an_unresolvable_server_host_on_the_same_port_is_refused() {
let server = ServerConfig {
host: "localhost".to_string(),
port: 8080,
..ServerConfig::default()
};
assert!(metrics(Some("127.0.0.1:8080")).validate(&server).is_err());
assert!(metrics(Some("127.0.0.1:9090")).validate(&server).is_ok());
}
#[test]
fn registration_follows_enabled_and_bind_addr() {
let off = MetricsConfig::default();
assert!(!off.on_main_listener());
assert_eq!(off.dedicated_bind_addr(), None);
let main_only = metrics(None);
assert!(main_only.on_main_listener());
assert_eq!(main_only.dedicated_bind_addr(), None);
let dedicated = metrics(Some("127.0.0.1:9090"));
assert!(
!dedicated.on_main_listener(),
"a dedicated listener moves the endpoint, it does not duplicate it"
);
assert_eq!(dedicated.dedicated_bind_addr(), Some("127.0.0.1:9090"));
let disabled_but_bound = MetricsConfig {
enabled: false,
bind_addr: Some("127.0.0.1:9090".to_string()),
};
assert_eq!(disabled_but_bound.dedicated_bind_addr(), None);
assert!(!disabled_but_bound.on_main_listener());
}
#[test]
fn trace_persistence_defaults_to_sync() {
assert_eq!(
TraceStorageConfig::default().mode,
TraceStorageMode::Sync,
"changing this default changes whether traces can be silently dropped"
);
assert_eq!(TraceStorageMode::default(), TraceStorageMode::Sync);
}
#[test]
fn batch_size_is_bounded_against_sqlite_bind_limit() {
let config = TraceStorageConfig {
mode: TraceStorageMode::Batch,
batch_size: 1001,
..Default::default()
};
assert!(config.validate().is_err());
let config = TraceStorageConfig {
mode: TraceStorageMode::Batch,
batch_size: 1000,
..Default::default()
};
assert!(config.validate().is_ok());
}
}