saddle-observability 0.3.36

Saddle structured logging and trace correlation
Documentation
//! Startup-frozen context output profiles. No runtime expression evaluation.
use std::collections::BTreeMap;
use saddle_core::json_pointer::JsonPointer;
use serde::Deserialize;

#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ContextLoggingConfig {
    pub config_version: u32,
    pub context_schema_major: u32,
    pub full: FullContextProfile,
    pub summaries: Vec<SummaryProfile>,
}
impl Default for ContextLoggingConfig {
    fn default() -> Self { Self { config_version: 1, context_schema_major: 1, full: FullContextProfile::default(), summaries: Vec::new() } }
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FullContextProfile { pub enabled: bool, pub format: JsonFormat }
impl Default for FullContextProfile {
    fn default() -> Self { Self { enabled: true, format: JsonFormat::Json } }
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JsonFormat { #[default] Json }
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SummaryFormat { #[default] Json, Text }
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MissingField { #[default] Null, Omit }
#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SummaryProfile {
    pub name: String,
    #[serde(default)] pub enabled: bool,
    #[serde(default)] pub format: SummaryFormat,
    pub fields: BTreeMap<String, String>,
    #[serde(default)] pub missing: MissingField,
    pub template: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ContextLoggingError { Version, ProfileCount, Name, DuplicateName, FieldCount, Path, Template }
impl std::fmt::Display for ContextLoggingError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "invalid context logging configuration: {self:?}") }
}
impl std::error::Error for ContextLoggingError {}
impl ContextLoggingConfig {
    pub fn validate(&self) -> Result<(), ContextLoggingError> {
        use ContextLoggingError as E;
        if self.config_version != 1 || self.context_schema_major != 1 { return Err(E::Version); }
        if self.summaries.len() > 8 { return Err(E::ProfileCount); }
        for (index, profile) in self.summaries.iter().enumerate() {
            if profile.name.is_empty() || !profile.name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') { return Err(E::Name); }
            if self.summaries[..index].iter().any(|old| old.name == profile.name) { return Err(E::DuplicateName); }
            if profile.fields.len() > 64 { return Err(E::FieldCount); }
            for path in profile.fields.values() {
                if path.len() > 1024 || JsonPointer::parse(path).is_err() || !known_record_path(path) { return Err(E::Path); }
            }
            match (profile.format, profile.template.as_deref()) {
                (SummaryFormat::Json, None) => {},
                (SummaryFormat::Text, Some(template)) if template.len() <= 16 * 1024 => {
                    let mut remainder = template;
                    while let Some(start) = remainder.find("${") {
                        remainder = &remainder[start + 2..];
                        let end = remainder.find('}').ok_or(E::Template)?;
                        if !profile.fields.contains_key(&remainder[..end]) { return Err(E::Template); }
                        remainder = &remainder[end + 1..];
                    }
                },
                _ => return Err(E::Template),
            }
        }
        Ok(())
    }
}
// Dynamic subtrees allow future keys; fixed framework groups reject typos.
fn known_record_path(path: &str) -> bool {
    saddle_core::request_context::is_known_context_record_path(path)
}

#[cfg(test)]
mod tests {
    use super::*;
    fn profile(path: &str) -> SummaryProfile {
        SummaryProfile { name: "business-summary".into(), enabled: true, format: SummaryFormat::Json,
            fields: BTreeMap::from([("v".into(), path.into())]), missing: MissingField::Null, template: None }
    }
    #[test]
    fn profile_bounds_apply_even_when_outputs_are_disabled() {
        let mut config = ContextLoggingConfig::default();
        config.full.enabled = false;
        config.summaries = (0..8).map(|n| {
            let mut p = profile("/context/business/future"); p.name = format!("profile_{n}"); p.enabled = false; p
        }).collect();
        assert_eq!(config.validate(), Ok(()));
        config.summaries.push(profile("/context/business"));
        assert_eq!(config.validate(), Err(ContextLoggingError::ProfileCount));
        config.summaries.pop();
        config.summaries[1].name = config.summaries[0].name.clone();
        assert_eq!(config.validate(), Err(ContextLoggingError::DuplicateName));
        config.summaries.truncate(1);
        config.summaries[0].fields = (0..64).map(|n| (format!("field{n}"), "/context/business/future".into())).collect();
        assert_eq!(config.validate(), Ok(()));
        config.summaries[0].fields.insert("overflow".into(), "/context/business".into());
        assert_eq!(config.validate(), Err(ContextLoggingError::FieldCount));
        config.summaries[0].fields = BTreeMap::from([("v".into(), format!("/context/business/{}", "a".repeat(1024)))]);
        assert_eq!(config.validate(), Err(ContextLoggingError::Path));
    }
    #[test]
    fn profiles_reject_unsafe_names_fixed_typos_and_template_expressions() {
        let mut config = ContextLoggingConfig::default();
        assert!(config.full.enabled);
        for path in ["/context/business/a~1b/0", "/context/inputInfo/requestData/unknown", "/context/interfaceInfo/module", "/context/profuseContext/future", "/context/_meta/redactions/~1business"] {
            config.summaries = vec![profile(path)]; assert_eq!(config.validate(), Ok(()));
        }
        for path in ["/context/requestInfo/requestID", "/context/interfaceinfo", "/context/business/~2", "/context/executionInfo/taskId/unknown"] {
            config.summaries = vec![profile(path)]; assert_eq!(config.validate(), Err(ContextLoggingError::Path));
        }
        config.summaries = vec![profile("/context/business")];
        config.summaries[0].name = "../outside".into(); assert_eq!(config.validate(), Err(ContextLoggingError::Name));
        config.summaries[0].name = "safe".into();
        config.summaries[0].format = SummaryFormat::Text;
        config.summaries[0].template = Some("value=${v}".into()); assert_eq!(config.validate(), Ok(()));
        config.summaries[0].template = Some("${v.raw}".into()); assert_eq!(config.validate(), Err(ContextLoggingError::Template));
    }
}