Skip to main content

relay_spans/
web_vitals.rs

1use relay_conventions::attributes::{
2    BROWSER__WEB_VITAL__CLS__VALUE, BROWSER__WEB_VITAL__FCP__VALUE, BROWSER__WEB_VITAL__INP__VALUE,
3    BROWSER__WEB_VITAL__LCP__ELEMENT, BROWSER__WEB_VITAL__LCP__ID,
4    BROWSER__WEB_VITAL__LCP__LOAD_TIME, BROWSER__WEB_VITAL__LCP__RENDER_TIME,
5    BROWSER__WEB_VITAL__LCP__SIZE, BROWSER__WEB_VITAL__LCP__URL, BROWSER__WEB_VITAL__LCP__VALUE,
6    BROWSER__WEB_VITAL__TTFB__REQUEST_TIME, BROWSER__WEB_VITAL__TTFB__VALUE, SENTRY__ENVIRONMENT,
7    SENTRY__METRIC__SOURCE, SENTRY__ORIGIN, SENTRY__PAGELOAD__SPAN_ID, SENTRY__PLATFORM,
8    SENTRY__RELEASE, SENTRY__SDK__NAME, SENTRY__SDK__VERSION, SENTRY__SEGMENT__NAME,
9    USER_AGENT__ORIGINAL,
10};
11use relay_conventions::interpolate::browser__web_vital__cls__source__key;
12use relay_event_schema::protocol::{Attributes, SpanV2, TraceMetric};
13use relay_metrics::MetricUnit;
14use relay_protocol::Value;
15use std::collections::BTreeMap;
16
17const MAX_CLS_SOURCES: u32 = 128;
18
19const WEB_VITAL_SPAN_NAMES: [&str; 7] = [
20    "pageload",
21    "ui.webvital.lcp",
22    "ui.webvital.cls",
23    "ui.interaction.click",
24    "ui.interaction.hover",
25    "ui.interaction.drag",
26    "ui.interaction.press",
27];
28
29const COMMON_ATTRIBUTES: [&str; 9] = [
30    SENTRY__PAGELOAD__SPAN_ID,
31    SENTRY__ORIGIN,
32    SENTRY__SEGMENT__NAME,
33    USER_AGENT__ORIGINAL,
34    SENTRY__RELEASE,
35    SENTRY__ENVIRONMENT,
36    SENTRY__SDK__NAME,
37    SENTRY__SDK__VERSION,
38    SENTRY__PLATFORM,
39];
40
41const WEB_VITAL_LOOKUPS: [WebVital; 5] = [
42    WebVital {
43        attribute_value: BROWSER__WEB_VITAL__LCP__VALUE,
44        name: "browser.web_vital.lcp",
45        unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond),
46        attribute_keys: &[
47            BROWSER__WEB_VITAL__LCP__ELEMENT,
48            BROWSER__WEB_VITAL__LCP__ID,
49            BROWSER__WEB_VITAL__LCP__URL,
50            BROWSER__WEB_VITAL__LCP__SIZE,
51            BROWSER__WEB_VITAL__LCP__LOAD_TIME,
52            BROWSER__WEB_VITAL__LCP__RENDER_TIME,
53            "score.lcp",
54            "score.weight.lcp",
55            "score.ratio.lcp",
56        ],
57    },
58    WebVital {
59        attribute_value: BROWSER__WEB_VITAL__CLS__VALUE,
60        name: "browser.web_vital.cls",
61        unit: MetricUnit::None,
62        attribute_keys: &["score.cls", "score.weight.cls", "score.ratio.cls"],
63    },
64    WebVital {
65        attribute_value: BROWSER__WEB_VITAL__INP__VALUE,
66        name: "browser.web_vital.inp",
67        unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond),
68        attribute_keys: &[
69            "browser.web_vital.inp.target",
70            "browser.web_vital.inp.type",
71            "score.inp",
72            "score.weight.inp",
73            "score.ratio.inp",
74        ],
75    },
76    WebVital {
77        attribute_value: BROWSER__WEB_VITAL__FCP__VALUE,
78        name: "browser.web_vital.fcp",
79        unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond),
80        attribute_keys: &["score.fcp", "score.weight.fcp", "score.ratio.fcp"],
81    },
82    WebVital {
83        attribute_value: BROWSER__WEB_VITAL__TTFB__VALUE,
84        name: "browser.web_vital.ttfb",
85        unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond),
86        attribute_keys: &[
87            BROWSER__WEB_VITAL__TTFB__REQUEST_TIME,
88            "score.ttfb",
89            "score.ratio.ttfb",
90            "score.weight.ttfb",
91        ],
92    },
93];
94
95struct WebVital {
96    name: &'static str,
97    attribute_value: &'static str,
98    unit: MetricUnit,
99    attribute_keys: &'static [&'static str],
100}
101
102/// Extract any web vitals metrics for the supplied v2 span.  Bad or missing metrics will be
103/// silently dropped.
104pub fn extract_web_vital_metrics(span: &SpanV2) -> Option<Vec<TraceMetric>> {
105    let attrs = span.attributes.0.as_ref()?;
106
107    let op_name = attrs.get_value("sentry.op")?.as_str()?;
108
109    if !WEB_VITAL_SPAN_NAMES.contains(&op_name) {
110        return None;
111    }
112
113    let mut results = vec![];
114
115    for web_vital in &WEB_VITAL_LOOKUPS {
116        let Some(value) = attrs.get_value(web_vital.attribute_value) else {
117            continue;
118        };
119
120        let Some(value) = value.as_f64() else {
121            continue;
122        };
123
124        let mut attributes = Attributes::new();
125
126        // CLS webvitals are a little weird, in that they can have an arbitrary number of
127        // "source" attributes (with a .N postfix, 1-based, monotonically increasing), so we
128        // exhaustively look for them here.
129        if web_vital.attribute_value == BROWSER__WEB_VITAL__CLS__VALUE {
130            for i in 1..MAX_CLS_SOURCES {
131                let attr_key = browser__web_vital__cls__source__key(i.to_string());
132                match attrs.get_attribute(&attr_key) {
133                    Some(v) => attributes.insert(attr_key, v.value.clone()),
134                    None => break,
135                }
136            }
137        }
138
139        for attr_key in web_vital.attribute_keys {
140            if let Some(v) = attrs.get_attribute(*attr_key) {
141                attributes.insert(*attr_key, v.value.clone());
142            }
143        }
144
145        for attr_key in COMMON_ATTRIBUTES {
146            if let Some(v) = attrs.get_attribute(attr_key) {
147                attributes.insert(attr_key, v.value.clone());
148            }
149        }
150
151        // This is for attribution: we'll be able to tell on a metric if it came from relay.
152        attributes.insert(SENTRY__METRIC__SOURCE, "span");
153
154        let trace_metric = TraceMetric {
155            timestamp: span.start_timestamp.clone(),
156            trace_id: span.trace_id.clone(),
157            span_id: span.span_id.clone(),
158            name: web_vital.name.to_owned().into(),
159            ty: relay_event_schema::protocol::MetricType::Distribution.into(),
160            unit: web_vital.unit.into(),
161            value: Value::F64(value).into(),
162            attributes: attributes.into(),
163            other: BTreeMap::default(),
164        };
165
166        results.push(trace_metric);
167    }
168
169    if results.is_empty() {
170        None
171    } else {
172        Some(results)
173    }
174}