use crate::observability::config::ObservabilityConfig;
use crate::observability::config_file::{
reload_observability_filters, validate_observability_config,
};
use crate::observability::init::{init_observability, ObservabilityGuards};
use crate::observability::reload::ObservabilityReloadHandle;
use anyhow::{bail, Context, Result};
use pi_config::{Config, Value};
pub fn observability_config_from_pi_config(
config: &mut Config,
path: &str,
) -> Result<ObservabilityConfig> {
let value = config
.sub_value(path)
.with_context(|| format!("failed to read pi_config subtree `{path}`"))?;
let value = pi_value_to_toml(value, path)?;
let config: ObservabilityConfig = value
.try_into()
.with_context(|| format!("failed to parse pi_config subtree `{path}`"))?;
validate_observability_config(&config)?;
Ok(config)
}
pub fn init_observability_from_pi_config(
config: &mut Config,
path: &str,
) -> Result<(ObservabilityReloadHandle, ObservabilityGuards)> {
init_observability(observability_config_from_pi_config(config, path)?)
}
pub fn reload_observability_filters_from_pi_config(
reload: &ObservabilityReloadHandle,
config: &mut Config,
path: &str,
) -> Result<()> {
let config = observability_config_from_pi_config(config, path)?;
reload_observability_filters(reload, &config)
}
fn pi_value_to_toml(value: Value, path: &str) -> Result<toml::Value> {
Ok(match value {
Value::Bool(value) => toml::Value::Boolean(value),
Value::Int(value) => toml::Value::Integer(value),
Value::Float(value) => toml::Value::Float(value),
Value::Str(value) => toml::Value::String(value),
Value::Array(values) => toml::Value::Array(
values
.into_iter()
.enumerate()
.map(|(index, value)| pi_value_to_toml(value, &format!("{path}[{index}]")))
.collect::<Result<Vec<_>>>()?,
),
Value::Table(values) => toml::Value::Table(
values
.into_iter()
.map(|(key, value)| {
let child_path = format!("{path}.{key}");
Ok((key, pi_value_to_toml(value, &child_path)?))
})
.collect::<Result<toml::map::Map<_, _>>>()?,
),
other => bail!(
"unsupported pi_config value at `{path}` for observability configuration: {other:?}"
),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::observability::filter::SharedOrderedFilter;
use crate::observability::reload::FilterReloadHandle;
use pi_config::ConfigBuilder;
use std::collections::BTreeMap;
#[test]
fn parses_complete_observability_subtree() {
let mut config = config_with_observability("info", Some("off"));
let parsed = observability_config_from_pi_config(&mut config, "observability").unwrap();
assert_eq!(parsed.log.filter.default_level, "info");
assert_eq!(
parsed.log.remote.filter.as_ref().unwrap().default_level,
"off"
);
}
#[test]
fn missing_subtree_reports_path() {
let mut config = ConfigBuilder::new().build().unwrap();
let error = observability_config_from_pi_config(&mut config, "observability").unwrap_err();
assert!(format!("{error:#}").contains("observability"));
}
#[test]
fn reloads_filters_from_updated_pi_config_subtree() {
let local = reload_scope("local", "info");
let remote = reload_scope("remote", "off");
let trace = reload_scope("trace", "warn");
let reload = ObservabilityReloadHandle::new(local, Some(remote), Some(trace), false);
let mut config = config_with_observability("debug", Some("error"));
let Value::Table(root) = config.sub_value("observability").unwrap() else {
panic!("observability must be a table");
};
let mut root = root;
let Value::Table(trace) = root.get_mut("trace").unwrap() else {
panic!("trace must be a table");
};
trace.insert("filter".to_string(), filter("trace"));
let mut config =
ConfigBuilder::from_base_value(table([("observability", Value::Table(root))]))
.build()
.unwrap();
reload_observability_filters_from_pi_config(&reload, &mut config, "observability").unwrap();
assert_eq!(
reload.local().current_filter_config().default_level,
"debug"
);
assert_eq!(
reload
.remote()
.unwrap()
.current_filter_config()
.default_level,
"error"
);
assert_eq!(
reload
.trace()
.unwrap()
.current_filter_config()
.default_level,
"trace"
);
}
#[test]
fn invalid_pi_config_update_keeps_active_filters() {
let reload =
ObservabilityReloadHandle::new(reload_scope("local", "info"), None, None, false);
let mut config = config_with_observability("verbose", None);
assert!(
reload_observability_filters_from_pi_config(&reload, &mut config, "observability")
.is_err()
);
assert_eq!(reload.local().current_filter_config().default_level, "info");
}
fn reload_scope(scope: &'static str, level: &str) -> FilterReloadHandle {
FilterReloadHandle::new(
true,
scope,
SharedOrderedFilter::new(crate::observability::LogFilterConfig {
default_level: level.to_string(),
overrides: Vec::new(),
})
.unwrap(),
)
}
fn config_with_observability(local_level: &str, remote_level: Option<&str>) -> Config {
let remote_filter = remote_level.map(filter);
let remote = table([
("enabled", Value::Bool(false)),
("endpoint", Value::Str(String::new())),
]);
let Value::Table(mut remote) = remote else {
unreachable!("remote test configuration must be a table");
};
if let Some(filter) = remote_filter {
remote.insert("filter".to_string(), filter);
}
let remote = Value::Table(remote);
let log = table([
("filter", filter(local_level)),
(
"local",
table([
("enabled", Value::Bool(false)),
("file_dir", Value::Str("logs".to_string())),
("file_name", Value::Str("app.log".to_string())),
]),
),
("remote", remote),
("dynamic", table([("enabled", Value::Bool(true))])),
]);
let trace = table([
("enabled", Value::Bool(false)),
("exporter", Value::Str("otlp".to_string())),
("endpoint", Value::Str(String::new())),
("service_name", Value::Str("test".to_string())),
]);
let root = table([("observability", table([("log", log), ("trace", trace)]))]);
ConfigBuilder::from_base_value(root).build().unwrap()
}
fn filter(level: &str) -> Value {
table([
("default_level", Value::Str(level.to_string())),
("overrides", Value::Array(Vec::new())),
])
}
fn table<const N: usize>(entries: [(&str, Value); N]) -> Value {
Value::Table(
entries
.into_iter()
.map(|(key, value)| (key.to_string(), value))
.collect::<BTreeMap<_, _>>(),
)
}
}