Skip to main content

saddle_observability/
event.rs

1use std::{collections::BTreeMap, error::Error, fmt};
2
3use serde::Serialize;
4
5/// Maximum UTF-8 byte length of a domain event name.
6pub const MAX_DOMAIN_EVENT_NAME_BYTES: usize = 128;
7/// Maximum number of scalar fields in one domain event.
8pub const MAX_DOMAIN_FIELDS: usize = 16;
9/// Maximum UTF-8 byte length of a domain field name.
10pub const MAX_DOMAIN_FIELD_NAME_BYTES: usize = 64;
11/// Maximum UTF-8 byte length of one domain string value.
12pub const MAX_DOMAIN_STRING_BYTES: usize = 512;
13/// Maximum encoded JSON bytes for the name and fields of one domain event.
14pub const MAX_DOMAIN_PAYLOAD_BYTES: usize = 4_096;
15
16/// Validation failures for bounded V1 domain events.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub enum DomainEventError {
19    EmptyEventName,
20    EventNameTooLong,
21    EmptyFieldName,
22    FieldNameTooLong,
23    TooManyFields,
24    StringValueTooLong,
25    PayloadTooLarge,
26}
27
28impl fmt::Display for DomainEventError {
29    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
30        formatter.write_str(match self {
31            Self::EmptyEventName => "domain event name must not be empty",
32            Self::EventNameTooLong => "domain event name exceeds its byte limit",
33            Self::EmptyFieldName => "domain event field name must not be empty",
34            Self::FieldNameTooLong => "domain event field name exceeds its byte limit",
35            Self::TooManyFields => "domain event has too many fields",
36            Self::StringValueTooLong => "domain event string value exceeds its byte limit",
37            Self::PayloadTooLarge => "domain event payload exceeds its encoded byte limit",
38        })
39    }
40}
41
42impl Error for DomainEventError {}
43
44/// Stable severity values emitted by Saddle's JSON logger.
45#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
46#[serde(rename_all = "lowercase")]
47pub enum EventLevel {
48    Debug,
49    Info,
50    Warn,
51    Error,
52}
53
54/// Scalar domain data that is safe and intentional to include in a log event.
55///
56/// Arbitrary object serialization is deliberately unsupported so callers do
57/// not accidentally log full requests, responses or domain entities.
58#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
59#[serde(untagged)]
60pub enum DomainValue {
61    String(String),
62    Signed(i64),
63    Unsigned(u64),
64    Bool(bool),
65}
66
67impl From<&str> for DomainValue {
68    fn from(value: &str) -> Self {
69        Self::String(value.to_owned())
70    }
71}
72
73impl From<String> for DomainValue {
74    fn from(value: String) -> Self {
75        Self::String(value)
76    }
77}
78
79impl From<i64> for DomainValue {
80    fn from(value: i64) -> Self {
81        Self::Signed(value)
82    }
83}
84
85macro_rules! signed_domain_values {
86    ($($type:ty),+ $(,)?) => {
87        $(
88            impl From<$type> for DomainValue {
89                fn from(value: $type) -> Self {
90                    Self::Signed(i64::from(value))
91                }
92            }
93        )+
94    };
95}
96
97signed_domain_values!(i8, i16, i32);
98
99impl From<u64> for DomainValue {
100    fn from(value: u64) -> Self {
101        Self::Unsigned(value)
102    }
103}
104
105macro_rules! unsigned_domain_values {
106    ($($type:ty),+ $(,)?) => {
107        $(
108            impl From<$type> for DomainValue {
109                fn from(value: $type) -> Self {
110                    Self::Unsigned(u64::from(value))
111                }
112            }
113        )+
114    };
115}
116
117unsigned_domain_values!(u8, u16, u32);
118
119impl From<bool> for DomainValue {
120    fn from(value: bool) -> Self {
121        Self::Bool(value)
122    }
123}
124
125impl DomainValue {
126    pub(crate) fn into_json(self) -> serde_json::Value {
127        match self {
128            Self::String(value) => serde_json::Value::String(value),
129            Self::Signed(value) => serde_json::Value::from(value),
130            Self::Unsigned(value) => serde_json::Value::from(value),
131            Self::Bool(value) => serde_json::Value::from(value),
132        }
133    }
134}
135
136/// An explicit business event associated with the current call context.
137#[derive(Clone, Debug, Eq, PartialEq)]
138pub struct DomainEvent {
139    pub(crate) name: String,
140    pub(crate) level: EventLevel,
141    pub(crate) fields: BTreeMap<String, DomainValue>,
142}
143
144impl DomainEvent {
145    pub fn new(name: impl Into<String>) -> Result<Self, DomainEventError> {
146        let name = name.into();
147        if name.is_empty() {
148            return Err(DomainEventError::EmptyEventName);
149        }
150        if name.len() > MAX_DOMAIN_EVENT_NAME_BYTES {
151            return Err(DomainEventError::EventNameTooLong);
152        }
153        let event = Self {
154            name,
155            level: EventLevel::Info,
156            fields: BTreeMap::new(),
157        };
158        event.validate_payload_size()?;
159        Ok(event)
160    }
161
162    pub fn level(mut self, level: EventLevel) -> Self {
163        self.level = level;
164        self
165    }
166
167    pub fn field(
168        mut self,
169        name: impl Into<String>,
170        value: impl Into<DomainValue>,
171    ) -> Result<Self, DomainEventError> {
172        let name = name.into();
173        let value = value.into();
174        if name.is_empty() {
175            return Err(DomainEventError::EmptyFieldName);
176        }
177        if name.len() > MAX_DOMAIN_FIELD_NAME_BYTES {
178            return Err(DomainEventError::FieldNameTooLong);
179        }
180        if matches!(&value, DomainValue::String(value) if value.len() > MAX_DOMAIN_STRING_BYTES) {
181            return Err(DomainEventError::StringValueTooLong);
182        }
183        if !self.fields.contains_key(&name) && self.fields.len() == MAX_DOMAIN_FIELDS {
184            return Err(DomainEventError::TooManyFields);
185        }
186        let previous = self.fields.insert(name.clone(), value);
187        if let Err(error) = self.validate_payload_size() {
188            match previous {
189                Some(previous) => {
190                    self.fields.insert(name, previous);
191                }
192                None => {
193                    self.fields.remove(&name);
194                }
195            }
196            return Err(error);
197        }
198        Ok(self)
199    }
200
201    fn validate_payload_size(&self) -> Result<(), DomainEventError> {
202        #[derive(Serialize)]
203        struct Payload<'a> {
204            name: &'a str,
205            fields: &'a BTreeMap<String, DomainValue>,
206        }
207
208        let size = serde_json::to_vec(&Payload {
209            name: &self.name,
210            fields: &self.fields,
211        })
212        .map_err(|_| DomainEventError::PayloadTooLarge)?
213        .len();
214        if size > MAX_DOMAIN_PAYLOAD_BYTES {
215            Err(DomainEventError::PayloadTooLarge)
216        } else {
217            Ok(())
218        }
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn rejects_oversized_names_values_and_field_counts() {
228        assert_eq!(DomainEvent::new(""), Err(DomainEventError::EmptyEventName));
229        assert_eq!(
230            DomainEvent::new("x".repeat(MAX_DOMAIN_EVENT_NAME_BYTES + 1)),
231            Err(DomainEventError::EventNameTooLong)
232        );
233        assert_eq!(
234            DomainEvent::new("event").unwrap().field("", true),
235            Err(DomainEventError::EmptyFieldName)
236        );
237        assert_eq!(
238            DomainEvent::new("event")
239                .unwrap()
240                .field("x".repeat(MAX_DOMAIN_FIELD_NAME_BYTES + 1), true),
241            Err(DomainEventError::FieldNameTooLong)
242        );
243        assert_eq!(
244            DomainEvent::new("event")
245                .unwrap()
246                .field("value", "x".repeat(MAX_DOMAIN_STRING_BYTES + 1)),
247            Err(DomainEventError::StringValueTooLong)
248        );
249
250        let mut event = DomainEvent::new("event").unwrap();
251        for index in 0..MAX_DOMAIN_FIELDS {
252            event = event.field(format!("field_{index}"), index as u64).unwrap();
253        }
254        assert_eq!(
255            event.field("one_too_many", true),
256            Err(DomainEventError::TooManyFields)
257        );
258    }
259
260    #[test]
261    fn encoded_payload_size_is_bounded() {
262        let mut event = DomainEvent::new("x".repeat(MAX_DOMAIN_EVENT_NAME_BYTES)).unwrap();
263        let mut index = 0;
264        let error = loop {
265            match event.field(
266                format!("field_{index}"),
267                "\\\"".repeat(MAX_DOMAIN_STRING_BYTES / 2),
268            ) {
269                Ok(next) => {
270                    event = next;
271                    index += 1;
272                }
273                Err(error) => break error,
274            }
275        };
276        assert_eq!(error, DomainEventError::PayloadTooLarge);
277    }
278}