relay_event_schema/processor/
attrs.rs

1use std::borrow::Cow;
2use std::fmt;
3use std::ops::{Deref, RangeInclusive};
4
5use enumset::{EnumSet, EnumSetType};
6use relay_protocol::Annotated;
7
8use crate::processor::ProcessValue;
9
10/// Error for unknown value types.
11#[derive(Debug)]
12pub struct UnknownValueTypeError;
13
14impl fmt::Display for UnknownValueTypeError {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        write!(f, "unknown value type")
17    }
18}
19
20impl std::error::Error for UnknownValueTypeError {}
21
22/// The (simplified) type of a value.
23#[derive(Debug, Ord, PartialOrd, EnumSetType)]
24pub enum ValueType {
25    // Basic types
26    String,
27    Binary,
28    Number,
29    Boolean,
30    DateTime,
31    Array,
32    Object,
33
34    // Roots
35    Event,
36    Attachments,
37    Replay,
38
39    // Protocol types
40    Exception,
41    Stacktrace,
42    Frame,
43    Request,
44    User,
45    LogEntry,
46    Message,
47    Thread,
48    Breadcrumb,
49    OurLog,
50    TraceMetric,
51    Span,
52    ClientSdkInfo,
53
54    // Attachments and Contents
55    Minidump,
56    HeapMemory,
57    StackMemory,
58}
59
60impl ValueType {
61    pub fn for_field<T: ProcessValue>(field: &Annotated<T>) -> EnumSet<Self> {
62        field
63            .value()
64            .map(ProcessValue::value_type)
65            .unwrap_or_else(EnumSet::empty)
66    }
67}
68
69relay_common::derive_fromstr_and_display!(ValueType, UnknownValueTypeError, {
70    ValueType::String => "string",
71    ValueType::Binary => "binary",
72    ValueType::Number => "number",
73    ValueType::Boolean => "boolean" | "bool",
74    ValueType::DateTime => "datetime",
75    ValueType::Array => "array" | "list",
76    ValueType::Object => "object",
77    ValueType::Event => "event",
78    ValueType::Attachments => "attachments",
79    ValueType::Replay => "replay",
80    ValueType::Exception => "error" | "exception",
81    ValueType::Stacktrace => "stack" | "stacktrace",
82    ValueType::Frame => "frame",
83    ValueType::Request => "http" | "request",
84    ValueType::User => "user",
85    ValueType::LogEntry => "logentry",
86    ValueType::Message => "message",
87    ValueType::Thread => "thread",
88    ValueType::Breadcrumb => "breadcrumb",
89    ValueType::OurLog => "log",
90    ValueType::TraceMetric => "trace_metric",
91
92    ValueType::Span => "span",
93    ValueType::ClientSdkInfo => "sdk",
94    ValueType::Minidump => "minidump",
95    ValueType::HeapMemory => "heap_memory",
96    ValueType::StackMemory => "stack_memory",
97});
98
99/// Whether an attribute should be PII-strippable/should be subject to datascrubbers
100#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
101pub enum Pii {
102    /// The field will be stripped by default
103    True,
104    /// The field cannot be stripped at all
105    False,
106    /// The field will only be stripped when addressed with a specific path selector, but generic
107    /// selectors such as `$string` do not apply.
108    Maybe,
109}
110
111/// A static or dynamic `Pii` value.
112#[derive(Debug, Clone, Copy)]
113pub enum PiiMode {
114    /// A static value.
115    Static(Pii),
116    /// A dynamic value, computed based on a `ProcessingState`.
117    Dynamic(fn(&ProcessingState) -> Pii),
118}
119
120/// A static or dynamic Option<`usize`> value.
121///
122/// Used for the fields `max_chars` and `max_bytes`.
123#[derive(Debug, Clone, Copy)]
124pub enum SizeMode {
125    Static(Option<usize>),
126    Dynamic(fn(&ProcessingState) -> Option<usize>),
127}
128
129/// Meta information about a field.
130#[derive(Debug, Clone, Copy)]
131pub struct FieldAttrs {
132    /// Optionally the name of the field.
133    pub name: Option<&'static str>,
134    /// If the field is required.
135    pub required: bool,
136    /// If the field should be non-empty.
137    pub nonempty: bool,
138    /// Whether to trim whitespace from this string.
139    pub trim_whitespace: bool,
140    /// A set of allowed or denied character ranges for this string.
141    pub characters: Option<CharacterSet>,
142    /// The maximum char length of this field.
143    pub max_chars: SizeMode,
144    /// The extra char length allowance on top of max_chars.
145    pub max_chars_allowance: usize,
146    /// The maximum depth of this field.
147    pub max_depth: Option<usize>,
148    /// The maximum number of bytes of this field.
149    pub max_bytes: SizeMode,
150    /// The type of PII on the field.
151    pub pii: PiiMode,
152    /// Whether additional properties should be retained during normalization.
153    pub retain: bool,
154    /// Whether the trimming processor is allowed to shorten or drop this field.
155    pub trim: bool,
156}
157
158/// A set of characters allowed or denied for a (string) field.
159///
160/// Note that this field is generated in the derive, it can't be constructed easily in tests.
161#[derive(Clone, Copy)]
162pub struct CharacterSet {
163    /// Generated in derive for performance. Can be left out when set is created manually.
164    pub char_is_valid: fn(char) -> bool,
165    /// A set of ranges that are allowed/denied within the character set
166    pub ranges: &'static [RangeInclusive<char>],
167    /// Whether the character set is inverted
168    pub is_negative: bool,
169}
170
171impl fmt::Debug for CharacterSet {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        f.debug_struct("CharacterSet")
174            .field("ranges", &self.ranges)
175            .field("is_negative", &self.is_negative)
176            .finish()
177    }
178}
179
180impl FieldAttrs {
181    /// Creates default `FieldAttrs`.
182    pub const fn new() -> Self {
183        FieldAttrs {
184            name: None,
185            required: false,
186            nonempty: false,
187            trim_whitespace: false,
188            characters: None,
189            max_chars: SizeMode::Static(None),
190            max_chars_allowance: 0,
191            max_depth: None,
192            max_bytes: SizeMode::Static(None),
193            pii: PiiMode::Static(Pii::False),
194            retain: false,
195            trim: true,
196        }
197    }
198
199    /// Sets whether a value in this field is required.
200    pub const fn required(mut self, required: bool) -> Self {
201        self.required = required;
202        self
203    }
204
205    /// Sets whether this field can have an empty value.
206    ///
207    /// This is distinct from `required`. An empty string (`""`) passes the "required" check but not the
208    /// "nonempty" one.
209    pub const fn nonempty(mut self, nonempty: bool) -> Self {
210        self.nonempty = nonempty;
211        self
212    }
213
214    /// Sets whether whitespace should be trimmed before validation.
215    pub const fn trim_whitespace(mut self, trim_whitespace: bool) -> Self {
216        self.trim_whitespace = trim_whitespace;
217        self
218    }
219
220    /// Sets whether this field contains PII.
221    pub const fn pii(mut self, pii: Pii) -> Self {
222        self.pii = PiiMode::Static(pii);
223        self
224    }
225
226    /// Sets whether this field contains PII dynamically based on the current state.
227    pub const fn pii_dynamic(mut self, pii: fn(&ProcessingState) -> Pii) -> Self {
228        self.pii = PiiMode::Dynamic(pii);
229        self
230    }
231
232    /// Sets the maximum number of characters allowed in the field.
233    pub const fn max_chars(mut self, max_chars: usize) -> Self {
234        self.max_chars = SizeMode::Static(Some(max_chars));
235        self
236    }
237
238    /// Sets the maximum number of characters allowed in the field dynamically based on the current state.
239    pub const fn max_chars_dynamic(
240        mut self,
241        max_chars: fn(&ProcessingState) -> Option<usize>,
242    ) -> Self {
243        self.max_chars = SizeMode::Dynamic(max_chars);
244        self
245    }
246
247    /// Sets whether additional properties should be retained during normalization.
248    pub const fn retain(mut self, retain: bool) -> Self {
249        self.retain = retain;
250        self
251    }
252}
253
254static DEFAULT_FIELD_ATTRS: FieldAttrs = FieldAttrs::new();
255static PII_TRUE_FIELD_ATTRS: FieldAttrs = FieldAttrs::new().pii(Pii::True);
256static PII_MAYBE_FIELD_ATTRS: FieldAttrs = FieldAttrs::new().pii(Pii::Maybe);
257
258impl Default for FieldAttrs {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264#[derive(Debug, Clone, Eq, Ord, PartialOrd)]
265enum PathItem<'a> {
266    StaticKey(&'a str),
267    OwnedKey(String),
268    Index(usize),
269}
270
271impl<'a> PartialEq for PathItem<'a> {
272    fn eq(&self, other: &PathItem<'a>) -> bool {
273        self.key() == other.key() && self.index() == other.index()
274    }
275}
276
277impl PathItem<'_> {
278    /// Returns the key if there is one
279    #[inline]
280    pub fn key(&self) -> Option<&str> {
281        match self {
282            PathItem::StaticKey(s) => Some(s),
283            PathItem::OwnedKey(s) => Some(s.as_str()),
284            PathItem::Index(_) => None,
285        }
286    }
287
288    /// Returns the index if there is one
289    #[inline]
290    pub fn index(&self) -> Option<usize> {
291        match self {
292            PathItem::StaticKey(_) | PathItem::OwnedKey(_) => None,
293            PathItem::Index(idx) => Some(*idx),
294        }
295    }
296}
297
298impl fmt::Display for PathItem<'_> {
299    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300        match self {
301            PathItem::StaticKey(s) => f.pad(s),
302            PathItem::OwnedKey(s) => f.pad(s.as_str()),
303            PathItem::Index(val) => write!(f, "{val}"),
304        }
305    }
306}
307
308/// Like [`std::borrow::Cow`], but with a boxed value.
309///
310/// This is useful for types that contain themselves, where otherwise the layout of the type
311/// cannot be computed, for example
312///
313/// ```rust,ignore
314/// struct Foo<'a>(Cow<'a, Foo<'a>>); // will not compile
315/// struct Bar<'a>(BoxCow<'a, Bar<'a>>); // will compile
316/// ```
317#[derive(Debug, Clone)]
318enum BoxCow<'a, T> {
319    Borrowed(&'a T),
320    Owned(Box<T>),
321}
322
323impl<T> Deref for BoxCow<'_, T> {
324    type Target = T;
325
326    fn deref(&self) -> &Self::Target {
327        match self {
328            BoxCow::Borrowed(inner) => inner,
329            BoxCow::Owned(inner) => inner.deref(),
330        }
331    }
332}
333
334/// An event's processing state.
335///
336/// The processing state describes an item in an event which is being processed, an example
337/// of processing might be scrubbing the event for PII.  The processing state itself
338/// describes the current item and it's parent, which allows you to follow all the items up
339/// to the root item.  You can think of processing an event as a visitor pattern visiting
340/// all items in the event and the processing state is a stack describing the currently
341/// visited item and all it's parents.
342#[derive(Debug, Clone)]
343pub struct ProcessingState<'a> {
344    // In event scrubbing, every state holds a reference to its parent.
345    // In Replay scrubbing, we do not call `process_*` recursively,
346    // but instead hold a single `ProcessingState` that represents the current item.
347    // This item owns its parent (plus ancestors) exclusively, which is why we use `BoxCow` here
348    // rather than `Rc` / `Arc`.
349    parent: Option<BoxCow<'a, ProcessingState<'a>>>,
350    path_item: Option<PathItem<'a>>,
351    attrs: Option<Cow<'a, FieldAttrs>>,
352    value_type: EnumSet<ValueType>,
353    depth: usize,
354}
355
356static ROOT_STATE: ProcessingState = ProcessingState {
357    parent: None,
358    path_item: None,
359    attrs: None,
360    value_type: enumset::enum_set!(),
361    depth: 0,
362};
363
364impl<'a> ProcessingState<'a> {
365    /// Returns the root processing state.
366    pub fn root() -> &'static ProcessingState<'static> {
367        &ROOT_STATE
368    }
369
370    /// Creates a new root state.
371    pub fn new_root(
372        attrs: Option<Cow<'static, FieldAttrs>>,
373        value_type: impl IntoIterator<Item = ValueType>,
374    ) -> ProcessingState<'static> {
375        ProcessingState {
376            parent: None,
377            path_item: None,
378            attrs,
379            value_type: value_type.into_iter().collect(),
380            depth: 0,
381        }
382    }
383
384    /// Derives a processing state by entering a borrowed key.
385    pub fn enter_borrowed(
386        &'a self,
387        key: &'a str,
388        attrs: Option<Cow<'a, FieldAttrs>>,
389        value_type: impl IntoIterator<Item = ValueType>,
390    ) -> Self {
391        ProcessingState {
392            parent: Some(BoxCow::Borrowed(self)),
393            path_item: Some(PathItem::StaticKey(key)),
394            attrs,
395            value_type: value_type.into_iter().collect(),
396            depth: self.depth + 1,
397        }
398    }
399
400    /// Derives a processing state by entering an owned key.
401    ///
402    /// The new (child) state takes ownership of the current (parent) state.
403    pub fn enter_owned(
404        self,
405        key: String,
406        attrs: Option<Cow<'a, FieldAttrs>>,
407        value_type: impl IntoIterator<Item = ValueType>,
408    ) -> Self {
409        let depth = self.depth + 1;
410        ProcessingState {
411            parent: Some(BoxCow::Owned(self.into())),
412            path_item: Some(PathItem::OwnedKey(key)),
413            attrs,
414            value_type: value_type.into_iter().collect(),
415            depth,
416        }
417    }
418
419    /// Derives a processing state by entering an index.
420    pub fn enter_index(
421        &'a self,
422        idx: usize,
423        attrs: Option<Cow<'a, FieldAttrs>>,
424        value_type: impl IntoIterator<Item = ValueType>,
425    ) -> Self {
426        ProcessingState {
427            parent: Some(BoxCow::Borrowed(self)),
428            path_item: Some(PathItem::Index(idx)),
429            attrs,
430            value_type: value_type.into_iter().collect(),
431            depth: self.depth + 1,
432        }
433    }
434
435    /// Derives a processing state without adding a path segment. Useful in newtype structs.
436    pub fn enter_nothing(&'a self, attrs: Option<Cow<'a, FieldAttrs>>) -> Self {
437        ProcessingState {
438            attrs,
439            path_item: None,
440            parent: Some(BoxCow::Borrowed(self)),
441            ..self.clone()
442        }
443    }
444
445    /// Returns the path in the processing state.
446    pub fn path(&'a self) -> Path<'a> {
447        Path(self)
448    }
449
450    pub fn value_type(&self) -> EnumSet<ValueType> {
451        self.value_type
452    }
453
454    /// Returns the field attributes.
455    pub fn attrs(&self) -> &FieldAttrs {
456        match self.attrs {
457            Some(ref cow) => cow,
458            None => &DEFAULT_FIELD_ATTRS,
459        }
460    }
461
462    /// Derives the attrs for recursion.
463    pub fn inner_attrs(&self) -> Option<Cow<'_, FieldAttrs>> {
464        match self.pii() {
465            Pii::True => Some(Cow::Borrowed(&PII_TRUE_FIELD_ATTRS)),
466            Pii::False => None,
467            Pii::Maybe => Some(Cow::Borrowed(&PII_MAYBE_FIELD_ATTRS)),
468        }
469    }
470
471    /// Returns the PII status for this state.
472    ///
473    /// If the state's `FieldAttrs` contain a fixed PII status,
474    /// it is returned. If they contain a dynamic PII status (a function),
475    /// it is applied to this state and the output returned.
476    pub fn pii(&self) -> Pii {
477        match self.attrs().pii {
478            PiiMode::Static(pii) => pii,
479            PiiMode::Dynamic(pii_fn) => pii_fn(self),
480        }
481    }
482
483    /// Returns the max bytes for this state.
484    ///
485    /// If the state's `FieldAttrs` contain a fixed `max_bytes` value,
486    /// it is returned. If they contain a dynamic `max_bytes` value (a function),
487    /// it is applied to this state and the output returned.
488    pub fn max_bytes(&self) -> Option<usize> {
489        match self.attrs().max_bytes {
490            SizeMode::Static(n) => n,
491            SizeMode::Dynamic(max_bytes_fn) => max_bytes_fn(self),
492        }
493    }
494
495    /// Returns the max chars for this state.
496    ///
497    /// If the state's `FieldAttrs` contain a fixed `max_chars` value,
498    /// it is returned. If they contain a dynamic `max_chars` value (a function),
499    /// it is applied to this state and the output returned.
500    pub fn max_chars(&self) -> Option<usize> {
501        match self.attrs().max_chars {
502            SizeMode::Static(n) => n,
503            SizeMode::Dynamic(max_chars_fn) => max_chars_fn(self),
504        }
505    }
506
507    /// Iterates through this state and all its ancestors up the hierarchy.
508    ///
509    /// This starts at the top of the stack of processing states and ends at the root.  Thus
510    /// the first item returned is the currently visited leaf of the event structure.
511    pub fn iter(&'a self) -> ProcessingStateIter<'a> {
512        ProcessingStateIter {
513            state: Some(self),
514            size: self.depth,
515        }
516    }
517
518    /// Returns the contained parent state.
519    ///
520    /// - Returns `Ok(None)` if the current state is the root.
521    /// - Returns `Err(self)` if the current state does not own the parent state.
522    #[expect(
523        clippy::result_large_err,
524        reason = "this method returns `self` in the error case"
525    )]
526    pub fn try_into_parent(self) -> Result<Option<Self>, Self> {
527        match self.parent {
528            Some(BoxCow::Borrowed(_)) => Err(self),
529            Some(BoxCow::Owned(parent)) => Ok(Some(*parent)),
530            None => Ok(None),
531        }
532    }
533
534    /// Return the depth (~ indentation level) of the currently processed value.
535    pub fn depth(&'a self) -> usize {
536        self.depth
537    }
538
539    /// Return whether the depth changed between parent and self.
540    ///
541    /// This is `false` when we entered a newtype struct.
542    pub fn entered_anything(&'a self) -> bool {
543        if let Some(parent) = &self.parent {
544            parent.depth() != self.depth()
545        } else {
546            true
547        }
548    }
549
550    /// Returns an iterator over the "keys" in this state,
551    /// in order from right to left (or innermost state to outermost).
552    pub fn keys(&self) -> impl Iterator<Item = &str> {
553        self.iter()
554            .filter_map(|state| state.path_item.as_ref())
555            .flat_map(|item| item.key())
556    }
557
558    /// Returns the last path item if there is one. Skips over "dummy" path segments that exist
559    /// because of newtypes.
560    #[inline]
561    fn path_item(&self) -> Option<&PathItem<'_>> {
562        for state in self.iter() {
563            if let Some(ref path_item) = state.path_item {
564                return Some(path_item);
565            }
566        }
567        None
568    }
569}
570
571pub struct ProcessingStateIter<'a> {
572    state: Option<&'a ProcessingState<'a>>,
573    size: usize,
574}
575
576impl<'a> Iterator for ProcessingStateIter<'a> {
577    type Item = &'a ProcessingState<'a>;
578
579    fn next(&mut self) -> Option<Self::Item> {
580        let current = self.state?;
581        self.state = current.parent.as_deref();
582        Some(current)
583    }
584
585    fn size_hint(&self) -> (usize, Option<usize>) {
586        (self.size, Some(self.size))
587    }
588}
589
590impl ExactSizeIterator for ProcessingStateIter<'_> {}
591
592impl Default for ProcessingState<'_> {
593    fn default() -> Self {
594        ProcessingState::root().clone()
595    }
596}
597
598/// Represents the [`ProcessingState`] as a path.
599///
600/// This is a view of a [`ProcessingState`] which treats the stack of states as a path.
601#[derive(Debug)]
602pub struct Path<'a>(&'a ProcessingState<'a>);
603
604impl Path<'_> {
605    /// Returns the current key if there is one
606    #[inline]
607    pub fn key(&self) -> Option<&str> {
608        PathItem::key(self.0.path_item()?)
609    }
610
611    /// Returns the current index if there is one
612    #[inline]
613    pub fn index(&self) -> Option<usize> {
614        PathItem::index(self.0.path_item()?)
615    }
616
617    /// Return the depth (~ indentation level) of the currently processed value.
618    pub fn depth(&self) -> usize {
619        self.0.depth()
620    }
621
622    /// Returns the field attributes of the current path item.
623    pub fn attrs(&self) -> &FieldAttrs {
624        self.0.attrs()
625    }
626
627    /// Returns the PII status for this path.
628    pub fn pii(&self) -> Pii {
629        self.0.pii()
630    }
631
632    /// Iterates through the states in this path.
633    pub fn iter(&self) -> ProcessingStateIter<'_> {
634        self.0.iter()
635    }
636}
637
638impl fmt::Display for Path<'_> {
639    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
640        let mut items = Vec::with_capacity(self.0.depth);
641        for state in self.0.iter() {
642            if let Some(ref path_item) = state.path_item {
643                items.push(path_item)
644            }
645        }
646
647        for (idx, item) in items.into_iter().rev().enumerate() {
648            if idx > 0 {
649                write!(f, ".")?;
650            }
651            write!(f, "{item}")?;
652        }
653        Ok(())
654    }
655}
656
657#[cfg(test)]
658mod tests {
659
660    use relay_protocol::{Annotated, Empty, FromValue, IntoValue, Object, SerializableAnnotated};
661
662    use crate::processor::attrs::ROOT_STATE;
663    use crate::processor::{Pii, ProcessValue, ProcessingState, Processor, process_value};
664
665    fn pii_from_item_name(state: &ProcessingState) -> Pii {
666        match state.path_item().and_then(|p| p.key()) {
667            Some("true_item") => Pii::True,
668            Some("false_item") => Pii::False,
669            _ => Pii::Maybe,
670        }
671    }
672
673    fn max_chars_from_item_name(state: &ProcessingState) -> Option<usize> {
674        match state.path_item().and_then(|p| p.key()) {
675            Some("short_item") => Some(10),
676            Some("long_item") => Some(20),
677            _ => None,
678        }
679    }
680
681    #[derive(Debug, Clone, Empty, IntoValue, FromValue, ProcessValue)]
682    #[metastructure(pii = "pii_from_item_name")]
683    struct TestValue(#[metastructure(max_chars = "max_chars_from_item_name")] String);
684
685    struct TestPiiProcessor;
686
687    impl Processor for TestPiiProcessor {
688        fn process_string(
689            &mut self,
690            value: &mut String,
691            _meta: &mut relay_protocol::Meta,
692            state: &ProcessingState<'_>,
693        ) -> crate::processor::ProcessingResult where {
694            match state.pii() {
695                Pii::True => *value = "true".to_owned(),
696                Pii::False => *value = "false".to_owned(),
697                Pii::Maybe => *value = "maybe".to_owned(),
698            }
699            Ok(())
700        }
701    }
702
703    struct TestTrimmingProcessor;
704
705    impl Processor for TestTrimmingProcessor {
706        fn process_string(
707            &mut self,
708            value: &mut String,
709            _meta: &mut relay_protocol::Meta,
710            state: &ProcessingState<'_>,
711        ) -> crate::processor::ProcessingResult where {
712            if let Some(n) = state.max_chars() {
713                value.truncate(n);
714            }
715            Ok(())
716        }
717    }
718
719    #[test]
720    fn test_dynamic_pii() {
721        let mut object: Annotated<Object<TestValue>> = Annotated::from_json(
722            r#"
723        {
724          "false_item": "replace me",
725          "other_item": "replace me",
726          "true_item": "replace me"
727        }
728        "#,
729        )
730        .unwrap();
731
732        process_value(&mut object, &mut TestPiiProcessor, &ROOT_STATE).unwrap();
733
734        insta::assert_json_snapshot!(SerializableAnnotated(&object), @r###"
735        {
736          "false_item": "false",
737          "other_item": "maybe",
738          "true_item": "true"
739        }
740        "###);
741    }
742
743    #[test]
744    fn test_dynamic_max_chars() {
745        let mut object: Annotated<Object<TestValue>> = Annotated::from_json(
746            r#"
747        {
748          "short_item": "Should be shortened to 10",
749          "long_item": "Should be shortened to 20",
750          "other_item": "Should not be shortened at all"
751        }
752        "#,
753        )
754        .unwrap();
755
756        process_value(&mut object, &mut TestTrimmingProcessor, &ROOT_STATE).unwrap();
757
758        insta::assert_json_snapshot!(SerializableAnnotated(&object), @r###"
759        {
760          "long_item": "Should be shortened ",
761          "other_item": "Should not be shortened at all",
762          "short_item": "Should be "
763        }
764        "###);
765    }
766}