saddle-observability 0.1.0

Saddle structured logging and trace correlation
Documentation
use std::{collections::BTreeMap, error::Error, fmt};

use serde::Serialize;

/// Maximum UTF-8 byte length of a domain event name.
pub const MAX_DOMAIN_EVENT_NAME_BYTES: usize = 128;
/// Maximum number of scalar fields in one domain event.
pub const MAX_DOMAIN_FIELDS: usize = 16;
/// Maximum UTF-8 byte length of a domain field name.
pub const MAX_DOMAIN_FIELD_NAME_BYTES: usize = 64;
/// Maximum UTF-8 byte length of one domain string value.
pub const MAX_DOMAIN_STRING_BYTES: usize = 512;
/// Maximum encoded JSON bytes for the name and fields of one domain event.
pub const MAX_DOMAIN_PAYLOAD_BYTES: usize = 4_096;

/// Validation failures for bounded V1 domain events.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DomainEventError {
    EmptyEventName,
    EventNameTooLong,
    EmptyFieldName,
    FieldNameTooLong,
    TooManyFields,
    StringValueTooLong,
    PayloadTooLarge,
}

impl fmt::Display for DomainEventError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::EmptyEventName => "domain event name must not be empty",
            Self::EventNameTooLong => "domain event name exceeds its byte limit",
            Self::EmptyFieldName => "domain event field name must not be empty",
            Self::FieldNameTooLong => "domain event field name exceeds its byte limit",
            Self::TooManyFields => "domain event has too many fields",
            Self::StringValueTooLong => "domain event string value exceeds its byte limit",
            Self::PayloadTooLarge => "domain event payload exceeds its encoded byte limit",
        })
    }
}

impl Error for DomainEventError {}

/// Stable severity values emitted by Saddle's JSON logger.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum EventLevel {
    Debug,
    Info,
    Warn,
    Error,
}

/// Scalar domain data that is safe and intentional to include in a log event.
///
/// Arbitrary object serialization is deliberately unsupported so callers do
/// not accidentally log full requests, responses or domain entities.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(untagged)]
pub enum DomainValue {
    String(String),
    Signed(i64),
    Unsigned(u64),
    Bool(bool),
}

impl From<&str> for DomainValue {
    fn from(value: &str) -> Self {
        Self::String(value.to_owned())
    }
}

impl From<String> for DomainValue {
    fn from(value: String) -> Self {
        Self::String(value)
    }
}

impl From<i64> for DomainValue {
    fn from(value: i64) -> Self {
        Self::Signed(value)
    }
}

macro_rules! signed_domain_values {
    ($($type:ty),+ $(,)?) => {
        $(
            impl From<$type> for DomainValue {
                fn from(value: $type) -> Self {
                    Self::Signed(i64::from(value))
                }
            }
        )+
    };
}

signed_domain_values!(i8, i16, i32);

impl From<u64> for DomainValue {
    fn from(value: u64) -> Self {
        Self::Unsigned(value)
    }
}

macro_rules! unsigned_domain_values {
    ($($type:ty),+ $(,)?) => {
        $(
            impl From<$type> for DomainValue {
                fn from(value: $type) -> Self {
                    Self::Unsigned(u64::from(value))
                }
            }
        )+
    };
}

unsigned_domain_values!(u8, u16, u32);

impl From<bool> for DomainValue {
    fn from(value: bool) -> Self {
        Self::Bool(value)
    }
}

impl DomainValue {
    pub(crate) fn into_json(self) -> serde_json::Value {
        match self {
            Self::String(value) => serde_json::Value::String(value),
            Self::Signed(value) => serde_json::Value::from(value),
            Self::Unsigned(value) => serde_json::Value::from(value),
            Self::Bool(value) => serde_json::Value::from(value),
        }
    }
}

/// An explicit business event associated with the current call context.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DomainEvent {
    pub(crate) name: String,
    pub(crate) level: EventLevel,
    pub(crate) fields: BTreeMap<String, DomainValue>,
}

impl DomainEvent {
    pub fn new(name: impl Into<String>) -> Result<Self, DomainEventError> {
        let name = name.into();
        if name.is_empty() {
            return Err(DomainEventError::EmptyEventName);
        }
        if name.len() > MAX_DOMAIN_EVENT_NAME_BYTES {
            return Err(DomainEventError::EventNameTooLong);
        }
        let event = Self {
            name,
            level: EventLevel::Info,
            fields: BTreeMap::new(),
        };
        event.validate_payload_size()?;
        Ok(event)
    }

    pub fn level(mut self, level: EventLevel) -> Self {
        self.level = level;
        self
    }

    pub fn field(
        mut self,
        name: impl Into<String>,
        value: impl Into<DomainValue>,
    ) -> Result<Self, DomainEventError> {
        let name = name.into();
        let value = value.into();
        if name.is_empty() {
            return Err(DomainEventError::EmptyFieldName);
        }
        if name.len() > MAX_DOMAIN_FIELD_NAME_BYTES {
            return Err(DomainEventError::FieldNameTooLong);
        }
        if matches!(&value, DomainValue::String(value) if value.len() > MAX_DOMAIN_STRING_BYTES) {
            return Err(DomainEventError::StringValueTooLong);
        }
        if !self.fields.contains_key(&name) && self.fields.len() == MAX_DOMAIN_FIELDS {
            return Err(DomainEventError::TooManyFields);
        }
        let previous = self.fields.insert(name.clone(), value);
        if let Err(error) = self.validate_payload_size() {
            match previous {
                Some(previous) => {
                    self.fields.insert(name, previous);
                }
                None => {
                    self.fields.remove(&name);
                }
            }
            return Err(error);
        }
        Ok(self)
    }

    fn validate_payload_size(&self) -> Result<(), DomainEventError> {
        #[derive(Serialize)]
        struct Payload<'a> {
            name: &'a str,
            fields: &'a BTreeMap<String, DomainValue>,
        }

        let size = serde_json::to_vec(&Payload {
            name: &self.name,
            fields: &self.fields,
        })
        .map_err(|_| DomainEventError::PayloadTooLarge)?
        .len();
        if size > MAX_DOMAIN_PAYLOAD_BYTES {
            Err(DomainEventError::PayloadTooLarge)
        } else {
            Ok(())
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rejects_oversized_names_values_and_field_counts() {
        assert_eq!(DomainEvent::new(""), Err(DomainEventError::EmptyEventName));
        assert_eq!(
            DomainEvent::new("x".repeat(MAX_DOMAIN_EVENT_NAME_BYTES + 1)),
            Err(DomainEventError::EventNameTooLong)
        );
        assert_eq!(
            DomainEvent::new("event").unwrap().field("", true),
            Err(DomainEventError::EmptyFieldName)
        );
        assert_eq!(
            DomainEvent::new("event")
                .unwrap()
                .field("x".repeat(MAX_DOMAIN_FIELD_NAME_BYTES + 1), true),
            Err(DomainEventError::FieldNameTooLong)
        );
        assert_eq!(
            DomainEvent::new("event")
                .unwrap()
                .field("value", "x".repeat(MAX_DOMAIN_STRING_BYTES + 1)),
            Err(DomainEventError::StringValueTooLong)
        );

        let mut event = DomainEvent::new("event").unwrap();
        for index in 0..MAX_DOMAIN_FIELDS {
            event = event.field(format!("field_{index}"), index as u64).unwrap();
        }
        assert_eq!(
            event.field("one_too_many", true),
            Err(DomainEventError::TooManyFields)
        );
    }

    #[test]
    fn encoded_payload_size_is_bounded() {
        let mut event = DomainEvent::new("x".repeat(MAX_DOMAIN_EVENT_NAME_BYTES)).unwrap();
        let mut index = 0;
        let error = loop {
            match event.field(
                format!("field_{index}"),
                "\\\"".repeat(MAX_DOMAIN_STRING_BYTES / 2),
            ) {
                Ok(next) => {
                    event = next;
                    index += 1;
                }
                Err(error) => break error,
            }
        };
        assert_eq!(error, DomainEventError::PayloadTooLarge);
    }
}