Skip to main content

saddle_observability/
context_logging.rs

1//! Startup-frozen context output profiles. No runtime expression evaluation.
2use std::collections::BTreeMap;
3use saddle_core::json_pointer::JsonPointer;
4use serde::Deserialize;
5
6#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
7#[serde(default, deny_unknown_fields)]
8pub struct ContextLoggingConfig {
9    pub config_version: u32,
10    pub context_schema_major: u32,
11    pub full: FullContextProfile,
12    pub summaries: Vec<SummaryProfile>,
13}
14impl Default for ContextLoggingConfig {
15    fn default() -> Self { Self { config_version: 1, context_schema_major: 1, full: FullContextProfile::default(), summaries: Vec::new() } }
16}
17#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
18#[serde(default, deny_unknown_fields)]
19pub struct FullContextProfile { pub enabled: bool, pub format: JsonFormat }
20impl Default for FullContextProfile {
21    fn default() -> Self { Self { enabled: true, format: JsonFormat::Json } }
22}
23#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum JsonFormat { #[default] Json }
26#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum SummaryFormat { #[default] Json, Text }
29#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum MissingField { #[default] Null, Omit }
32#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct SummaryProfile {
35    pub name: String,
36    #[serde(default)] pub enabled: bool,
37    #[serde(default)] pub format: SummaryFormat,
38    pub fields: BTreeMap<String, String>,
39    #[serde(default)] pub missing: MissingField,
40    pub template: Option<String>,
41}
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub enum ContextLoggingError { Version, ProfileCount, Name, DuplicateName, FieldCount, Path, Template }
44impl std::fmt::Display for ContextLoggingError {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "invalid context logging configuration: {self:?}") }
46}
47impl std::error::Error for ContextLoggingError {}
48impl ContextLoggingConfig {
49    pub fn validate(&self) -> Result<(), ContextLoggingError> {
50        use ContextLoggingError as E;
51        if self.config_version != 1 || self.context_schema_major != 1 { return Err(E::Version); }
52        if self.summaries.len() > 8 { return Err(E::ProfileCount); }
53        for (index, profile) in self.summaries.iter().enumerate() {
54            if profile.name.is_empty() || !profile.name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') { return Err(E::Name); }
55            if self.summaries[..index].iter().any(|old| old.name == profile.name) { return Err(E::DuplicateName); }
56            if profile.fields.len() > 64 { return Err(E::FieldCount); }
57            for path in profile.fields.values() {
58                if path.len() > 1024 || JsonPointer::parse(path).is_err() || !known_record_path(path) { return Err(E::Path); }
59            }
60            match (profile.format, profile.template.as_deref()) {
61                (SummaryFormat::Json, None) => {},
62                (SummaryFormat::Text, Some(template)) if template.len() <= 16 * 1024 => {
63                    let mut remainder = template;
64                    while let Some(start) = remainder.find("${") {
65                        remainder = &remainder[start + 2..];
66                        let end = remainder.find('}').ok_or(E::Template)?;
67                        if !profile.fields.contains_key(&remainder[..end]) { return Err(E::Template); }
68                        remainder = &remainder[end + 1..];
69                    }
70                },
71                _ => return Err(E::Template),
72            }
73        }
74        Ok(())
75    }
76}
77// Dynamic subtrees allow future keys; fixed framework groups reject typos.
78fn known_record_path(path: &str) -> bool {
79    saddle_core::request_context::is_known_context_record_path(path)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    fn profile(path: &str) -> SummaryProfile {
86        SummaryProfile { name: "business-summary".into(), enabled: true, format: SummaryFormat::Json,
87            fields: BTreeMap::from([("v".into(), path.into())]), missing: MissingField::Null, template: None }
88    }
89    #[test]
90    fn profile_bounds_apply_even_when_outputs_are_disabled() {
91        let mut config = ContextLoggingConfig::default();
92        config.full.enabled = false;
93        config.summaries = (0..8).map(|n| {
94            let mut p = profile("/context/business/future"); p.name = format!("profile_{n}"); p.enabled = false; p
95        }).collect();
96        assert_eq!(config.validate(), Ok(()));
97        config.summaries.push(profile("/context/business"));
98        assert_eq!(config.validate(), Err(ContextLoggingError::ProfileCount));
99        config.summaries.pop();
100        config.summaries[1].name = config.summaries[0].name.clone();
101        assert_eq!(config.validate(), Err(ContextLoggingError::DuplicateName));
102        config.summaries.truncate(1);
103        config.summaries[0].fields = (0..64).map(|n| (format!("field{n}"), "/context/business/future".into())).collect();
104        assert_eq!(config.validate(), Ok(()));
105        config.summaries[0].fields.insert("overflow".into(), "/context/business".into());
106        assert_eq!(config.validate(), Err(ContextLoggingError::FieldCount));
107        config.summaries[0].fields = BTreeMap::from([("v".into(), format!("/context/business/{}", "a".repeat(1024)))]);
108        assert_eq!(config.validate(), Err(ContextLoggingError::Path));
109    }
110    #[test]
111    fn profiles_reject_unsafe_names_fixed_typos_and_template_expressions() {
112        let mut config = ContextLoggingConfig::default();
113        assert!(config.full.enabled);
114        for path in ["/context/business/a~1b/0", "/context/inputInfo/requestData/unknown", "/context/interfaceInfo/module", "/context/profuseContext/future", "/context/_meta/redactions/~1business"] {
115            config.summaries = vec![profile(path)]; assert_eq!(config.validate(), Ok(()));
116        }
117        for path in ["/context/requestInfo/requestID", "/context/interfaceinfo", "/context/business/~2", "/context/executionInfo/taskId/unknown"] {
118            config.summaries = vec![profile(path)]; assert_eq!(config.validate(), Err(ContextLoggingError::Path));
119        }
120        config.summaries = vec![profile("/context/business")];
121        config.summaries[0].name = "../outside".into(); assert_eq!(config.validate(), Err(ContextLoggingError::Name));
122        config.summaries[0].name = "safe".into();
123        config.summaries[0].format = SummaryFormat::Text;
124        config.summaries[0].template = Some("value=${v}".into()); assert_eq!(config.validate(), Ok(()));
125        config.summaries[0].template = Some("${v.raw}".into()); assert_eq!(config.validate(), Err(ContextLoggingError::Template));
126    }
127}