relay_event_schema/protocol/
measurements.rs1use 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#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
10pub struct Measurement {
11 #[metastructure(required = true, skip_serialization = "never")]
13 pub value: Annotated<f64>,
14
15 pub unit: Annotated<MetricUnit>,
17}
18
19#[derive(Clone, Debug, Default, PartialEq, Empty, IntoValue, ProcessValue)]
23pub struct Measurements(pub Object<Measurement>);
24
25impl Measurements {
26 pub fn into_inner(self) -> Object<Measurement> {
28 self.0
29 }
30
31 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}