saddle-observability 0.3.37

Saddle structured logging and trace correlation
Documentation
//! One immutable event supplies full and independently configured summaries.
use std::io::{self, Write};
use serde::Serialize;
use saddle_core::{request_context::UnifiedContext, json_pointer::JsonPointer};
use crate::{SummaryProfile, SummaryFormat, MissingField};

/// Event payload selectors use the same immutable value as full serialization.
pub trait ContextRecordPayload: Serialize {
    fn contains(&self, path: &str) -> bool;
    fn write_selected<W: Write>(&self, path: &str, writer: &mut W) -> serde_json::Result<()>;
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ContextRecord<'a, P: ContextRecordPayload> {
    pub record_schema: &'static str,
    pub timestamp: u128,
    pub level: &'static str,
    pub event: &'static str,
    pub context: UnifiedContext<'a>,
    pub payload: P,
}
impl<P: ContextRecordPayload> ContextRecord<'_, P> {
    fn split<'a>(&self, path: &'a str) -> Option<(saddle_core::json_pointer::PointerSegment<'a>, &'a str)> {
        let key = JsonPointer::parse(path).ok()?.segments().next()?;
        let rest = &path[1..]; Some((key, rest.find('/').map_or("", |at| &rest[at..])))
    }
    pub fn contains(&self, path: &str) -> bool {
        if path.is_empty() { return true; }
        let Some((key, rest)) = self.split(path) else { return false; };
        if key.matches("context") { return self.context.with_selected(rest, |value| value.is_some()).unwrap_or(false); }
        if key.matches("payload") { return self.payload.contains(rest); }
        rest.is_empty() && ["recordSchema", "timestamp", "level", "event"].iter().any(|name| key.matches(name))
    }
    pub fn write_selected<W: Write>(&self, path: &str, writer: &mut W) -> serde_json::Result<()> {
        if path.is_empty() { return serde_json::to_writer(writer, self); }
        let Some((key, rest)) = self.split(path) else { return serde_json::to_writer(writer, &()); };
        if key.matches("context") {
            return self.context.with_selected(rest, |value| serde_json::to_writer(writer, &value))
                .map_err(|_| serde_json::Error::io(io::ErrorKind::InvalidInput.into()))?;
        }
        if key.matches("payload") { return self.payload.write_selected(rest, writer); }
        if !rest.is_empty() { return serde_json::to_writer(writer, &()); }
        if key.matches("recordSchema") { return serde_json::to_writer(writer, &self.record_schema); }
        if key.matches("timestamp") { return serde_json::to_writer(writer, &self.timestamp); }
        if key.matches("level") { return serde_json::to_writer(writer, &self.level); }
        if key.matches("event") { return serde_json::to_writer(writer, &self.event); }
        serde_json::to_writer(writer, &())
    }
    /// Values always use JSON literal escaping, including text interpolation.
    pub fn write_summary<W: Write>(&self, profile: &SummaryProfile, writer: &mut W) -> serde_json::Result<()> {
        match profile.format {
            SummaryFormat::Json => {
                writer.write_all(b"{").map_err(serde_json::Error::io)?;
                let mut first = true;
                for (alias, path) in &profile.fields {
                    if profile.missing == MissingField::Omit && !self.contains(path) { continue; }
                    if !first { writer.write_all(b",").map_err(serde_json::Error::io)?; }
                    first = false;
                    serde_json::to_writer(&mut *writer, alias)?;
                    writer.write_all(b":").map_err(serde_json::Error::io)?;
                    self.write_selected(path, writer)?;
                }
                writer.write_all(b"}").map_err(serde_json::Error::io)
            }
            SummaryFormat::Text => {
                let mut remainder = profile.template.as_deref().unwrap_or("");
                while let Some(start) = remainder.find("${") {
                    writer.write_all(remainder[..start].as_bytes()).map_err(serde_json::Error::io)?;
                    remainder = &remainder[start + 2..];
                    let end = remainder.find('}').expect("startup validated template");
                    let path = &profile.fields[&remainder[..end]];
                    if profile.missing != MissingField::Omit || self.contains(path) { self.write_selected(path, writer)?; }
                    remainder = &remainder[end + 1..];
                }
                writer.write_all(remainder.as_bytes()).map_err(serde_json::Error::io)
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeMap;
    #[derive(Serialize)]
    struct Payload { value: &'static str }
    impl ContextRecordPayload for Payload {
        fn contains(&self, path: &str) -> bool { matches!(path, "" | "/value") }
        fn write_selected<W: Write>(&self, path: &str, writer: &mut W) -> serde_json::Result<()> {
            match path { "" => serde_json::to_writer(writer, self), "/value" => serde_json::to_writer(writer, &self.value), _ => serde_json::to_writer(writer, &()) }
        }
    }
    #[test]
    fn text_values_cannot_inject_lines_and_json_missing_is_distinct_from_null() {
        let root = saddle_core::RequestRootPublisher::create(saddle_core::ContextLabel::checked("app").unwrap(), saddle_core::ContextFact::NotEstablished).unwrap();
        let view = root.reference().view(saddle_core::RequestLocalFacts::new(saddle_core::RequestViewPhase::Reading));
        let record = ContextRecord { record_schema: "1.0", timestamp: 1, level: "info", event: "stage",
            context: view.unified_context(), payload: Payload { value: "quote\"\n\r\u{0000}\\tail" } };
        let mut profile = SummaryProfile { name: "safe".into(), enabled: true, format: SummaryFormat::Text,
            fields: BTreeMap::from([("v".into(), "/payload/value".into()), ("m".into(), "/context/business/missing".into())]),
            missing: MissingField::Null, template: Some("value=${v} missing=${m}".into()) };
        let mut output = Vec::new(); record.write_summary(&profile, &mut output).unwrap();
        assert_eq!(std::str::from_utf8(&output).unwrap(), "value=\"quote\\\"\\n\\r\\u0000\\\\tail\" missing=null");
        assert!(!output.contains(&b'\n')); assert!(!output.contains(&b'\r')); assert!(!output.contains(&0));
        profile.format = SummaryFormat::Json; profile.template = None; profile.missing = MissingField::Omit;
        output.clear(); record.write_summary(&profile, &mut output).unwrap();
        let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
        assert_eq!(json["v"], record.payload.value); assert!(json.get("m").is_none());
        profile.missing = MissingField::Null; output.clear(); record.write_summary(&profile, &mut output).unwrap();
        let json: serde_json::Value = serde_json::from_slice(&output).unwrap(); assert_eq!(json.get("m"), Some(&serde_json::Value::Null));
    }
}

/// Self-contained terminal context encoded while the request is still live.
/// Holds only an exact process-output allocation, never an input or Core view.
#[doc(hidden)]
pub struct OwnedUnifiedContext {
    pub(crate) bytes: saddle_admission::ExactStored<Vec<u8>>,
}
impl Serialize for OwnedUnifiedContext {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        let raw: &serde_json::value::RawValue = serde_json::from_slice(self.bytes.get())
            .map_err(serde::ser::Error::custom)?;
        raw.serialize(serializer)
    }
}