1use relay_conventions::attributes::{
2 BROWSER__NAME, BROWSER__NAVIGATION__ID, BROWSER__NAVIGATION__TYPE, BROWSER__VERSION,
3 BROWSER__WEB_VITAL__CLS__VALUE, BROWSER__WEB_VITAL__FCP__VALUE,
4 BROWSER__WEB_VITAL__INP__INTERACTION_TYPE, BROWSER__WEB_VITAL__INP__TARGET,
5 BROWSER__WEB_VITAL__INP__VALUE, BROWSER__WEB_VITAL__LCP__ELEMENT, BROWSER__WEB_VITAL__LCP__ID,
6 BROWSER__WEB_VITAL__LCP__LOAD_TIME, BROWSER__WEB_VITAL__LCP__RENDER_TIME,
7 BROWSER__WEB_VITAL__LCP__SIZE, BROWSER__WEB_VITAL__LCP__URL, BROWSER__WEB_VITAL__LCP__VALUE,
8 BROWSER__WEB_VITAL__TTFB__REQUEST_TIME, BROWSER__WEB_VITAL__TTFB__VALUE, SENTRY__ENVIRONMENT,
9 SENTRY__METRIC__SOURCE, SENTRY__ORIGIN, SENTRY__PAGELOAD__SPAN_ID, SENTRY__PLATFORM,
10 SENTRY__RELEASE, SENTRY__SDK__NAME, SENTRY__SDK__VERSION, SENTRY__SEGMENT__NAME,
11 USER_AGENT__ORIGINAL,
12};
13use relay_conventions::interpolate::browser__web_vital__cls__source__key;
14use relay_event_schema::protocol::{Attributes, SpanV2, TraceMetric};
15use relay_metrics::MetricUnit;
16use relay_protocol::Value;
17use std::collections::BTreeMap;
18
19const MAX_CLS_SOURCES: u32 = 128;
20
21#[allow(
22 deprecated,
23 reason = "Spans converted from a transaction carry the browser name as a legacy span tag."
24)]
25const LEGACY_BROWSER_NAME: &str = relay_conventions::attributes::SENTRY__BROWSER__NAME;
26
27const WEB_VITAL_SPAN_NAMES: [&str; 7] = [
28 "pageload",
29 "ui.webvital.lcp",
30 "ui.webvital.cls",
31 "ui.interaction.click",
32 "ui.interaction.hover",
33 "ui.interaction.drag",
34 "ui.interaction.press",
35];
36
37const COMMON_ATTRIBUTES: [&str; 13] = [
38 BROWSER__NAME,
39 BROWSER__VERSION,
40 BROWSER__NAVIGATION__TYPE,
41 BROWSER__NAVIGATION__ID,
42 SENTRY__PAGELOAD__SPAN_ID,
43 SENTRY__ORIGIN,
44 SENTRY__SEGMENT__NAME,
45 USER_AGENT__ORIGINAL,
46 SENTRY__RELEASE,
47 SENTRY__ENVIRONMENT,
48 SENTRY__SDK__NAME,
49 SENTRY__SDK__VERSION,
50 SENTRY__PLATFORM,
51];
52
53const WEB_VITAL_LOOKUPS: [WebVital; 5] = [
54 WebVital {
55 attribute_value: BROWSER__WEB_VITAL__LCP__VALUE,
56 name: "browser.web_vital.lcp",
57 unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond),
58 attribute_keys: &[
59 BROWSER__WEB_VITAL__LCP__ELEMENT,
60 BROWSER__WEB_VITAL__LCP__ID,
61 BROWSER__WEB_VITAL__LCP__URL,
62 BROWSER__WEB_VITAL__LCP__SIZE,
63 BROWSER__WEB_VITAL__LCP__LOAD_TIME,
64 BROWSER__WEB_VITAL__LCP__RENDER_TIME,
65 "score.lcp",
66 "score.weight.lcp",
67 "score.ratio.lcp",
68 ],
69 },
70 WebVital {
71 attribute_value: BROWSER__WEB_VITAL__CLS__VALUE,
72 name: "browser.web_vital.cls",
73 unit: MetricUnit::None,
74 attribute_keys: &["score.cls", "score.weight.cls", "score.ratio.cls"],
75 },
76 WebVital {
77 attribute_value: BROWSER__WEB_VITAL__INP__VALUE,
78 name: "browser.web_vital.inp",
79 unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond),
80 attribute_keys: &[
81 BROWSER__WEB_VITAL__INP__TARGET,
82 BROWSER__WEB_VITAL__INP__INTERACTION_TYPE,
83 "score.inp",
84 "score.weight.inp",
85 "score.ratio.inp",
86 ],
87 },
88 WebVital {
89 attribute_value: BROWSER__WEB_VITAL__FCP__VALUE,
90 name: "browser.web_vital.fcp",
91 unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond),
92 attribute_keys: &["score.fcp", "score.weight.fcp", "score.ratio.fcp"],
93 },
94 WebVital {
95 attribute_value: BROWSER__WEB_VITAL__TTFB__VALUE,
96 name: "browser.web_vital.ttfb",
97 unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond),
98 attribute_keys: &[
99 BROWSER__WEB_VITAL__TTFB__REQUEST_TIME,
100 "score.ttfb",
101 "score.ratio.ttfb",
102 "score.weight.ttfb",
103 ],
104 },
105];
106
107struct WebVital {
108 name: &'static str,
109 attribute_value: &'static str,
110 unit: MetricUnit,
111 attribute_keys: &'static [&'static str],
112}
113
114pub fn extract_web_vital_metrics(span: &SpanV2) -> Option<Vec<TraceMetric>> {
117 let attrs = span.attributes.0.as_ref()?;
118
119 let op_name = attrs.get_value("sentry.op")?.as_str()?;
120
121 if !WEB_VITAL_SPAN_NAMES.contains(&op_name) {
122 return None;
123 }
124
125 let mut results = vec![];
126
127 for web_vital in &WEB_VITAL_LOOKUPS {
128 let Some(value) = attrs.get_value(web_vital.attribute_value) else {
129 continue;
130 };
131
132 let Some(value) = value.as_f64() else {
133 continue;
134 };
135
136 let mut attributes = Attributes::new();
137
138 if web_vital.attribute_value == BROWSER__WEB_VITAL__CLS__VALUE {
142 for i in 1..MAX_CLS_SOURCES {
143 let attr_key = browser__web_vital__cls__source__key(i.to_string());
144 match attrs.get_attribute(&attr_key) {
145 Some(v) => attributes.insert(attr_key, v.value.clone()),
146 None => break,
147 }
148 }
149 }
150
151 for attr_key in web_vital.attribute_keys {
152 if let Some(v) = attrs.get_attribute(*attr_key) {
153 attributes.insert(*attr_key, v.value.clone());
154 }
155 }
156
157 for attr_key in COMMON_ATTRIBUTES {
158 if let Some(v) = attrs.get_attribute(attr_key) {
159 attributes.insert(attr_key, v.value.clone());
160 }
161 }
162
163 if !attributes.contains_key(BROWSER__NAME)
164 && let Some(v) = attrs.get_attribute(LEGACY_BROWSER_NAME)
165 {
166 attributes.insert(BROWSER__NAME, v.value.clone());
167 }
168
169 attributes.insert(SENTRY__METRIC__SOURCE, "span");
171
172 let trace_metric = TraceMetric {
173 timestamp: span.start_timestamp.clone(),
174 trace_id: span.trace_id.clone(),
175 span_id: span.span_id.clone(),
176 name: web_vital.name.to_owned().into(),
177 ty: relay_event_schema::protocol::MetricType::Distribution.into(),
178 unit: web_vital.unit.into(),
179 value: Value::F64(value).into(),
180 attributes: attributes.into(),
181 other: BTreeMap::default(),
182 };
183
184 results.push(trace_metric);
185 }
186
187 if results.is_empty() {
188 None
189 } else {
190 Some(results)
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use relay_event_schema::protocol::Span as SpanV1;
197 use relay_protocol::{Annotated, SerializableAnnotated};
198
199 use super::*;
200
201 #[test]
202 fn test_browser_name_legacy_span() {
203 let span = Annotated::<SpanV1>::from_json(
204 r#"{
205 "trace_id": "a0fa8803753e40fd8124b21eeb2986b5",
206 "span_id": "968cff94913ebb07",
207 "op": "ui.interaction.press",
208 "origin": "auto.http.browser.inp",
209 "start_timestamp": 1742921669.25,
210 "timestamp": 1742921669.75,
211 "measurements": {"inp": {"value": 104.0, "unit": "millisecond"}},
212 "sentry_tags": {"browser.name": "Safari"}
213 }"#,
214 )
215 .unwrap()
216 .into_value()
217 .unwrap();
218
219 let span = crate::span_v1_to_span_v2(span, true);
220 let metrics = extract_web_vital_metrics(&span).unwrap();
221 assert_eq!(metrics.len(), 1);
222
223 let attributes = metrics[0].attributes.value().unwrap();
224 assert_eq!(
225 attributes.get_value(BROWSER__NAME).and_then(|v| v.as_str()),
226 Some("Safari")
227 );
228 }
229
230 #[test]
231 fn test_inp_attributes() {
232 let span = Annotated::<SpanV2>::from_json(
233 r#"{
234 "trace_id": "a0fa8803753e40fd8124b21eeb2986b5",
235 "span_id": "968cff94913ebb07",
236 "name": "Click",
237 "start_timestamp": 1742921669.25,
238 "end_timestamp": 1742921669.75,
239 "attributes": {
240 "sentry.op": {"type": "string", "value": "ui.interaction.click"},
241 "browser.web_vital.inp.value": {"type": "double", "value": 104.0},
242 "browser.web_vital.inp.target": {"type": "string", "value": "body > button#submit"},
243 "browser.web_vital.inp.interaction_type": {"type": "string", "value": "click"}
244 }
245 }"#,
246 )
247 .unwrap()
248 .into_value()
249 .unwrap();
250
251 let metrics = extract_web_vital_metrics(&span).unwrap();
252 assert_eq!(metrics.len(), 1);
253
254 insta::assert_json_snapshot!(SerializableAnnotated(&metrics[0].attributes), @r#"
255 {
256 "browser.web_vital.inp.interaction_type": {
257 "type": "string",
258 "value": "click"
259 },
260 "browser.web_vital.inp.target": {
261 "type": "string",
262 "value": "body > button#submit"
263 },
264 "sentry.metric.source": {
265 "type": "string",
266 "value": "span"
267 }
268 }
269 "#);
270 }
271
272 #[test]
273 fn test_lcp_attributes() {
274 let span = Annotated::<SpanV2>::from_json(
275 r#"{
276 "trace_id": "a0fa8803753e40fd8124b21eeb2986b5",
277 "span_id": "968cff94913ebb07",
278 "start_timestamp": 1742921669.25,
279 "end_timestamp": 1742921669.75,
280 "attributes": {
281 "sentry.op": {"type": "string", "value": "ui.webvital.lcp"},
282 "browser.web_vital.lcp.value": {"type": "double", "value": 400.0},
283 "browser.navigation.type": {"type": "string", "value": "soft-navigation"},
284 "browser.navigation.id": {"type": "integer", "value": 2},
285 "unrelated": {"type": "string", "value": "dropped"}
286 }
287 }"#,
288 )
289 .unwrap()
290 .into_value()
291 .unwrap();
292
293 let metrics = extract_web_vital_metrics(&span).unwrap();
294 assert_eq!(metrics.len(), 1);
295
296 insta::assert_json_snapshot!(SerializableAnnotated(&metrics[0].attributes), @r#"
297 {
298 "browser.navigation.id": {
299 "type": "integer",
300 "value": 2
301 },
302 "browser.navigation.type": {
303 "type": "string",
304 "value": "soft-navigation"
305 },
306 "sentry.metric.source": {
307 "type": "string",
308 "value": "span"
309 }
310 }
311 "#);
312 }
313}