Skip to main content

relay_spans/
web_vitals.rs

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
114/// Extract any web vitals metrics for the supplied v2 span.  Bad or missing metrics will be
115/// silently dropped.
116pub 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        // CLS webvitals are a little weird, in that they can have an arbitrary number of
139        // "source" attributes (with a .N postfix, 1-based, monotonically increasing), so we
140        // exhaustively look for them here.
141        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        // This is for attribution: we'll be able to tell on a metric if it came from relay.
170        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}