relay_event_schema/protocol/
measurements.rs

1use std::ops::{Deref, DerefMut};
2
3use relay_base_schema::metrics::MetricUnit;
4use relay_protocol::{Annotated, Empty, Error, FromValue, IntoValue, Object, Value};
5
6use crate::processor::ProcessValue;
7
8/// An individual observed measurement.
9#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
10pub struct Measurement {
11    /// Value of observed measurement value.
12    #[metastructure(required = true, skip_serialization = "never")]
13    pub value: Annotated<f64>,
14
15    /// The unit of this measurement, defaulting to no unit.
16    pub unit: Annotated<MetricUnit>,
17}
18
19/// A map of observed measurement values.
20///
21/// They contain measurement values of observed values such as Largest Contentful Paint (LCP).
22#[derive(Clone, Debug, Default, PartialEq, Empty, IntoValue, ProcessValue)]
23pub struct Measurements(pub Object<Measurement>);
24
25impl Measurements {
26    /// Returns the underlying object of measurements.
27    pub fn into_inner(self) -> Object<Measurement> {
28        self.0
29    }
30
31    /// Return the value of the measurement with the given name, if it exists.
32    pub fn get_value(&self, key: &str) -> Option<f64> {
33        self.get(key)
34            .and_then(Annotated::value)
35            .and_then(|x| x.value.value())
36            .copied()
37    }
38}
39
40impl FromValue for Measurements {
41    fn from_value(value: Annotated<Value>) -> Annotated<Self> {
42        let mut processing_errors = Vec::new();
43
44        let mut measurements = Object::from_value(value).map_value(|measurements| {
45            let measurements = measurements
46                .into_iter()
47                .filter_map(|(name, object)| {
48                    let name = name.trim();
49
50                    if name.is_empty() {
51                        processing_errors.push(Error::invalid(format!(
52                            "measurement name '{name}' cannot be empty"
53                        )));
54                    } else if is_valid_measurement_name(name) {
55                        return Some((name.to_lowercase(), object));
56                    } else {
57                        processing_errors.push(Error::invalid(format!(
58                            "measurement name '{name}' can contain only characters a-z0-9._"
59                        )));
60                    }
61
62                    None
63                })
64                .collect();
65
66            Self(measurements)
67        });
68
69        for error in processing_errors {
70            measurements.meta_mut().add_error(error);
71        }
72
73        measurements
74    }
75}
76
77impl Deref for Measurements {
78    type Target = Object<Measurement>;
79
80    fn deref(&self) -> &Self::Target {
81        &self.0
82    }
83}
84
85impl DerefMut for Measurements {
86    fn deref_mut(&mut self) -> &mut Self::Target {
87        &mut self.0
88    }
89}
90
91fn is_valid_measurement_name(name: &str) -> bool {
92    name.starts_with(|c: char| c.is_ascii_alphabetic())
93        && name
94            .chars()
95            .all(|c| matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '.'))
96}
97
98#[cfg(test)]
99mod tests {
100    use relay_base_schema::metrics::DurationUnit;
101    use similar_asserts::assert_eq;
102
103    use super::*;
104    use crate::protocol::Event;
105
106    #[test]
107    fn test_measurements_serialization() {
108        let input = r#"{
109    "measurements": {
110        "LCP": {"value": 420.69, "unit": "millisecond"},
111        "   lcp_final.element-Size123  ": {"value": 1},
112        "fid": {"value": 2020},
113        "inp": {"value": 100.14},
114        "cls": {"value": null},
115        "fp": {"value": "im a first paint"},
116        "Total Blocking Time": {"value": 3.14159},
117        "missing_value": "string",
118        "": {"value": 2.71828}
119    }
120}"#;
121
122        let output = r#"{
123  "measurements": {
124    "cls": {
125      "value": null
126    },
127    "fid": {
128      "value": 2020.0
129    },
130    "fp": {
131      "value": null
132    },
133    "inp": {
134      "value": 100.14
135    },
136    "lcp": {
137      "value": 420.69,
138      "unit": "millisecond"
139    },
140    "missing_value": null
141  },
142  "_meta": {
143    "measurements": {
144      "": {
145        "err": [
146          [
147            "invalid_data",
148            {
149              "reason": "measurement name '' cannot be empty"
150            }
151          ],
152          [
153            "invalid_data",
154            {
155              "reason": "measurement name 'lcp_final.element-Size123' can contain only characters a-z0-9._"
156            }
157          ],
158          [
159            "invalid_data",
160            {
161              "reason": "measurement name 'Total Blocking Time' can contain only characters a-z0-9._"
162            }
163          ]
164        ]
165      },
166      "fp": {
167        "value": {
168          "": {
169            "err": [
170              [
171                "invalid_data",
172                {
173                  "reason": "expected a floating point number"
174                }
175              ]
176            ],
177            "val": "im a first paint"
178          }
179        }
180      },
181      "missing_value": {
182        "": {
183          "err": [
184            [
185              "invalid_data",
186              {
187                "reason": "expected measurement"
188              }
189            ]
190          ],
191          "val": "string"
192        }
193      }
194    }
195  }
196}"#;
197
198        let mut measurements = Annotated::new(Measurements({
199            let mut measurements = Object::new();
200            measurements.insert(
201                "cls".to_owned(),
202                Annotated::new(Measurement {
203                    value: Annotated::empty(),
204                    unit: Annotated::empty(),
205                }),
206            );
207            measurements.insert(
208                "lcp".to_owned(),
209                Annotated::new(Measurement {
210                    value: Annotated::new(420.69),
211                    unit: Annotated::new(MetricUnit::Duration(DurationUnit::MilliSecond)),
212                }),
213            );
214            measurements.insert(
215                "fid".to_owned(),
216                Annotated::new(Measurement {
217                    value: Annotated::new(2020f64),
218                    unit: Annotated::empty(),
219                }),
220            );
221            measurements.insert(
222                "inp".to_owned(),
223                Annotated::new(Measurement {
224                    value: Annotated::new(100.14),
225                    unit: Annotated::empty(),
226                }),
227            );
228            measurements.insert(
229                "fp".to_owned(),
230                Annotated::new(Measurement {
231                    value: Annotated::from_error(
232                        Error::expected("a floating point number"),
233                        Some("im a first paint".into()),
234                    ),
235                    unit: Annotated::empty(),
236                }),
237            );
238
239            measurements.insert(
240                "missing_value".to_owned(),
241                Annotated::from_error(Error::expected("measurement"), Some("string".into())),
242            );
243
244            measurements
245        }));
246
247        let measurements_meta = measurements.meta_mut();
248
249        measurements_meta.add_error(Error::invalid("measurement name '' cannot be empty"));
250
251        measurements_meta.add_error(Error::invalid(
252            "measurement name 'lcp_final.element-Size123' can contain only characters a-z0-9._",
253        ));
254
255        measurements_meta.add_error(Error::invalid(
256            "measurement name 'Total Blocking Time' can contain only characters a-z0-9._",
257        ));
258
259        let event = Annotated::new(Event {
260            measurements,
261            ..Default::default()
262        });
263
264        assert_eq!(event, Annotated::from_json(input).unwrap());
265        assert_eq!(event.to_json_pretty().unwrap(), output);
266    }
267}