relay_event_schema/protocol/
measurements.rs1use 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#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
10pub struct Measurement {
11 #[metastructure(required = true, skip_serialization = "never")]
13 pub value: Annotated<FiniteF64>,
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<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}