saddle_observability/
context_record.rs1use std::io::{self, Write};
3use serde::Serialize;
4use saddle_core::{request_context::UnifiedContext, json_pointer::JsonPointer};
5use crate::{SummaryProfile, SummaryFormat, MissingField};
6
7pub 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 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#[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}