Skip to main content

relay_event_schema/protocol/
measurements.rs

1use std::ops::{Deref, DerefMut};
2
3use relay_base_schema::metrics::MetricUnit;
4use relay_protocol::{Annotated, Empty, Error, FiniteF64, 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<FiniteF64>,
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<FiniteF64> {
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 relay_conventions::measurements::*;
102    use similar_asserts::assert_eq;
103
104    use super::*;
105    use crate::protocol::Event;
106
107    #[test]
108    fn test_measurements_serialization() {
109        let input = r#"{
110    "measurements": {
111        "LCP": {"value": 420.69, "unit": "millisecond"},
112        "   lcp_final.element-Size123  ": {"value": 1},
113        "fid": {"value": 2020},
114        "inp": {"value": 100.14},
115        "cls": {"value": null},
116        "fp": {"value": "im a first paint"},
117        "Total Blocking Time": {"value": 3.14159},
118        "missing_value": "string",
119        "": {"value": 2.71828}
120    }
121}"#;
122
123        let output = r#"{
124  "measurements": {
125    "cls": {
126      "value": null
127    },
128    "fid": {
129      "value": 2020.0
130    },
131    "fp": {
132      "value": null
133    },
134    "inp": {
135      "value": 100.14
136    },
137    "lcp": {
138      "value": 420.69,
139      "unit": "millisecond"
140    },
141    "missing_value": null
142  },
143  "_meta": {
144    "measurements": {
145      "": {
146        "err": [
147          [
148            "invalid_data",
149            {
150              "reason": "measurement name '' cannot be empty"
151            }
152          ],
153          [
154            "invalid_data",
155            {
156              "reason": "measurement name 'lcp_final.element-Size123' can contain only characters a-z0-9._"
157            }
158          ],
159          [
160            "invalid_data",
161            {
162              "reason": "measurement name 'Total Blocking Time' can contain only characters a-z0-9._"
163            }
164          ]
165        ]
166      },
167      "fp": {
168        "value": {
169          "": {
170            "err": [
171              [
172                "invalid_data",
173                {
174                  "reason": "expected a finite floating point number"
175                }
176              ]
177            ],
178            "val": "im a first paint"
179          }
180        }
181      },
182      "missing_value": {
183        "": {
184          "err": [
185            [
186              "invalid_data",
187              {
188                "reason": "expected measurement"
189              }
190            ]
191          ],
192          "val": "string"
193        }
194      }
195    }
196  }
197}"#;
198
199        let mut measurements = Annotated::new(Measurements({
200            let mut measurements = Object::new();
201            measurements.insert(
202                CLS.to_owned(),
203                Annotated::new(Measurement {
204                    value: Annotated::empty(),
205                    unit: Annotated::empty(),
206                }),
207            );
208            measurements.insert(
209                LCP.to_owned(),
210                Annotated::new(Measurement {
211                    value: Annotated::new(420.69.try_into().unwrap()),
212                    unit: Annotated::new(MetricUnit::Duration(DurationUnit::MilliSecond)),
213                }),
214            );
215            measurements.insert(
216                FID.to_owned(),
217                Annotated::new(Measurement {
218                    value: Annotated::new(2020f64.try_into().unwrap()),
219                    unit: Annotated::empty(),
220                }),
221            );
222            measurements.insert(
223                INP.to_owned(),
224                Annotated::new(Measurement {
225                    value: Annotated::new(100.14.try_into().unwrap()),
226                    unit: Annotated::empty(),
227                }),
228            );
229            measurements.insert(
230                FP.to_owned(),
231                Annotated::new(Measurement {
232                    value: Annotated::from_error(
233                        Error::expected("a finite floating point number"),
234                        Some("im a first paint".into()),
235                    ),
236                    unit: Annotated::empty(),
237                }),
238            );
239
240            measurements.insert(
241                "missing_value".to_owned(),
242                Annotated::from_error(Error::expected("measurement"), Some("string".into())),
243            );
244
245            measurements
246        }));
247
248        let measurements_meta = measurements.meta_mut();
249
250        measurements_meta.add_error(Error::invalid("measurement name '' cannot be empty"));
251
252        measurements_meta.add_error(Error::invalid(
253            "measurement name 'lcp_final.element-Size123' can contain only characters a-z0-9._",
254        ));
255
256        measurements_meta.add_error(Error::invalid(
257            "measurement name 'Total Blocking Time' can contain only characters a-z0-9._",
258        ));
259
260        let event = Annotated::new(Event {
261            measurements,
262            ..Default::default()
263        });
264
265        assert_eq!(event, Annotated::from_json(input).unwrap());
266        assert_eq!(event.to_json_pretty().unwrap(), output);
267    }
268}