Skip to main content

saddle_observability/
context_record.rs

1//! One immutable event supplies full and independently configured summaries.
2use std::io::{self, Write};
3use serde::Serialize;
4use saddle_core::{request_context::UnifiedContext, json_pointer::JsonPointer};
5use crate::{SummaryProfile, SummaryFormat, MissingField};
6
7/// Event payload selectors use the same immutable value as full serialization.
8pub trait ContextRecordPayload: Serialize {
9    fn contains(&self, path: &str) -> bool;
10    fn write_selected<W: Write>(&self, path: &str, writer: &mut W) -> serde_json::Result<()>;
11}
12#[derive(Serialize)]
13#[serde(rename_all = "camelCase")]
14pub struct ContextRecord<'a, P: ContextRecordPayload> {
15    pub record_schema: &'static str,
16    pub timestamp: u128,
17    pub level: &'static str,
18    pub event: &'static str,
19    pub context: UnifiedContext<'a>,
20    pub payload: P,
21}
22impl<P: ContextRecordPayload> ContextRecord<'_, P> {
23    fn split<'a>(&self, path: &'a str) -> Option<(saddle_core::json_pointer::PointerSegment<'a>, &'a str)> {
24        let key = JsonPointer::parse(path).ok()?.segments().next()?;
25        let rest = &path[1..]; Some((key, rest.find('/').map_or("", |at| &rest[at..])))
26    }
27    pub fn contains(&self, path: &str) -> bool {
28        if path.is_empty() { return true; }
29        let Some((key, rest)) = self.split(path) else { return false; };
30        if key.matches("context") { return self.context.with_selected(rest, |value| value.is_some()).unwrap_or(false); }
31        if key.matches("payload") { return self.payload.contains(rest); }
32        rest.is_empty() && ["recordSchema", "timestamp", "level", "event"].iter().any(|name| key.matches(name))
33    }
34    pub fn write_selected<W: Write>(&self, path: &str, writer: &mut W) -> serde_json::Result<()> {
35        if path.is_empty() { return serde_json::to_writer(writer, self); }
36        let Some((key, rest)) = self.split(path) else { return serde_json::to_writer(writer, &()); };
37        if key.matches("context") {
38            return self.context.with_selected(rest, |value| serde_json::to_writer(writer, &value))
39                .map_err(|_| serde_json::Error::io(io::ErrorKind::InvalidInput.into()))?;
40        }
41        if key.matches("payload") { return self.payload.write_selected(rest, writer); }
42        if !rest.is_empty() { return serde_json::to_writer(writer, &()); }
43        if key.matches("recordSchema") { return serde_json::to_writer(writer, &self.record_schema); }
44        if key.matches("timestamp") { return serde_json::to_writer(writer, &self.timestamp); }
45        if key.matches("level") { return serde_json::to_writer(writer, &self.level); }
46        if key.matches("event") { return serde_json::to_writer(writer, &self.event); }
47        serde_json::to_writer(writer, &())
48    }
49    /// Values always use JSON literal escaping, including text interpolation.
50    pub fn write_summary<W: Write>(&self, profile: &SummaryProfile, writer: &mut W) -> serde_json::Result<()> {
51        match profile.format {
52            SummaryFormat::Json => {
53                writer.write_all(b"{").map_err(serde_json::Error::io)?;
54                let mut first = true;
55                for (alias, path) in &profile.fields {
56                    if profile.missing == MissingField::Omit && !self.contains(path) { continue; }
57                    if !first { writer.write_all(b",").map_err(serde_json::Error::io)?; }
58                    first = false;
59                    serde_json::to_writer(&mut *writer, alias)?;
60                    writer.write_all(b":").map_err(serde_json::Error::io)?;
61                    self.write_selected(path, writer)?;
62                }
63                writer.write_all(b"}").map_err(serde_json::Error::io)
64            }
65            SummaryFormat::Text => {
66                let mut remainder = profile.template.as_deref().unwrap_or("");
67                while let Some(start) = remainder.find("${") {
68                    writer.write_all(remainder[..start].as_bytes()).map_err(serde_json::Error::io)?;
69                    remainder = &remainder[start + 2..];
70                    let end = remainder.find('}').expect("startup validated template");
71                    let path = &profile.fields[&remainder[..end]];
72                    if profile.missing != MissingField::Omit || self.contains(path) { self.write_selected(path, writer)?; }
73                    remainder = &remainder[end + 1..];
74                }
75                writer.write_all(remainder.as_bytes()).map_err(serde_json::Error::io)
76            }
77        }
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use std::collections::BTreeMap;
85    #[derive(Serialize)]
86    struct Payload { value: &'static str }
87    impl ContextRecordPayload for Payload {
88        fn contains(&self, path: &str) -> bool { matches!(path, "" | "/value") }
89        fn write_selected<W: Write>(&self, path: &str, writer: &mut W) -> serde_json::Result<()> {
90            match path { "" => serde_json::to_writer(writer, self), "/value" => serde_json::to_writer(writer, &self.value), _ => serde_json::to_writer(writer, &()) }
91        }
92    }
93    #[test]
94    fn text_values_cannot_inject_lines_and_json_missing_is_distinct_from_null() {
95        let root = saddle_core::RequestRootPublisher::create(saddle_core::ContextLabel::checked("app").unwrap(), saddle_core::ContextFact::NotEstablished).unwrap();
96        let view = root.reference().view(saddle_core::RequestLocalFacts::new(saddle_core::RequestViewPhase::Reading));
97        let record = ContextRecord { record_schema: "1.0", timestamp: 1, level: "info", event: "stage",
98            context: view.unified_context(), payload: Payload { value: "quote\"\n\r\u{0000}\\tail" } };
99        let mut profile = SummaryProfile { name: "safe".into(), enabled: true, format: SummaryFormat::Text,
100            fields: BTreeMap::from([("v".into(), "/payload/value".into()), ("m".into(), "/context/business/missing".into())]),
101            missing: MissingField::Null, template: Some("value=${v} missing=${m}".into()) };
102        let mut output = Vec::new(); record.write_summary(&profile, &mut output).unwrap();
103        assert_eq!(std::str::from_utf8(&output).unwrap(), "value=\"quote\\\"\\n\\r\\u0000\\\\tail\" missing=null");
104        assert!(!output.contains(&b'\n')); assert!(!output.contains(&b'\r')); assert!(!output.contains(&0));
105        profile.format = SummaryFormat::Json; profile.template = None; profile.missing = MissingField::Omit;
106        output.clear(); record.write_summary(&profile, &mut output).unwrap();
107        let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
108        assert_eq!(json["v"], record.payload.value); assert!(json.get("m").is_none());
109        profile.missing = MissingField::Null; output.clear(); record.write_summary(&profile, &mut output).unwrap();
110        let json: serde_json::Value = serde_json::from_slice(&output).unwrap(); assert_eq!(json.get("m"), Some(&serde_json::Value::Null));
111    }
112}
113
114/// Self-contained terminal context encoded while the request is still live.
115/// Holds only an exact process-output allocation, never an input or Core view.
116#[doc(hidden)]
117pub struct OwnedUnifiedContext {
118    pub(crate) bytes: saddle_admission::ExactStored<Vec<u8>>,
119}
120impl Serialize for OwnedUnifiedContext {
121    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
122        let raw: &serde_json::value::RawValue = serde_json::from_slice(self.bytes.get())
123            .map_err(serde::ser::Error::custom)?;
124        raw.serialize(serializer)
125    }
126}