Skip to content

Commit 3716b26

Browse files
feat(spans): Extract standalone CLS span metrics and performance score (#3988)
The JS SDK allows sending CLS as a standalone span to Relay, similar to INP standalone spans. This update enables the extraction of standalone CLS spans to indexed and metrics datasets, with CLS Performance score calculation. These metrics are still written to the `transaction` namespace to not break any product areas using CLS #skip-changelog
1 parent 6874ca5 commit 3716b26

File tree

5 files changed

+261
-37
lines changed

5 files changed

+261
-37
lines changed

relay-dynamic-config/src/defaults.rs

Lines changed: 114 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -960,39 +960,120 @@ pub fn hardcoded_span_metrics() -> Vec<(GroupKey, Vec<MetricSpec>, Vec<TagMappin
960960
),
961961
(
962962
GroupKey::SpanMetricsTx,
963-
vec![MetricSpec {
964-
category: DataCategory::Span,
965-
mri: "d:transactions/measurements.score.total@ratio".into(),
966-
field: Some("span.measurements.score.total.value".into()),
967-
condition: Some(
968-
// If transactions are extracted from spans, the transaction processing pipeline
969-
// will take care of this metric.
970-
is_allowed_browser.clone() & RuleCondition::eq("span.was_transaction", false),
971-
),
972-
tags: vec![
973-
Tag::with_key("span.op")
974-
.from_field("span.sentry_tags.op")
975-
.always(),
976-
Tag::with_key("transaction.op")
977-
.from_field("span.sentry_tags.transaction.op")
978-
.always(),
979-
Tag::with_key("transaction")
980-
.from_field("span.sentry_tags.transaction")
981-
.always(),
982-
Tag::with_key("environment")
983-
.from_field("span.sentry_tags.environment")
984-
.always(),
985-
Tag::with_key("release")
986-
.from_field("span.sentry_tags.release")
987-
.always(),
988-
Tag::with_key("browser.name")
989-
.from_field("span.browser.name")
990-
.always(), // already guarded by condition on metric
991-
Tag::with_key("user.geo.subregion")
992-
.from_field("span.sentry_tags.user.geo.subregion")
993-
.always(), // already guarded by condition on metric
994-
],
995-
}],
963+
vec![
964+
MetricSpec {
965+
category: DataCategory::Span,
966+
mri: "d:transactions/measurements.score.total@ratio".into(),
967+
field: Some("span.measurements.score.total.value".into()),
968+
condition: Some(
969+
// If transactions are extracted from spans, the transaction processing pipeline
970+
// will take care of this metric.
971+
is_allowed_browser.clone()
972+
& RuleCondition::eq("span.was_transaction", false),
973+
),
974+
tags: vec![
975+
Tag::with_key("span.op")
976+
.from_field("span.sentry_tags.op")
977+
.always(),
978+
Tag::with_key("transaction.op")
979+
.from_field("span.sentry_tags.transaction.op")
980+
.always(),
981+
Tag::with_key("transaction")
982+
.from_field("span.sentry_tags.transaction")
983+
.always(),
984+
Tag::with_key("environment")
985+
.from_field("span.sentry_tags.environment")
986+
.always(),
987+
Tag::with_key("release")
988+
.from_field("span.sentry_tags.release")
989+
.always(),
990+
Tag::with_key("browser.name")
991+
.from_field("span.browser.name")
992+
.always(), // already guarded by condition on metric
993+
Tag::with_key("user.geo.subregion")
994+
.from_field("span.sentry_tags.user.geo.subregion")
995+
.always(), // already guarded by condition on metric
996+
],
997+
},
998+
MetricSpec {
999+
category: DataCategory::Span,
1000+
mri: "d:transactions/measurements.score.cls@ratio".into(),
1001+
field: Some("span.measurements.score.cls.value".into()),
1002+
condition: Some(is_allowed_browser.clone()),
1003+
tags: vec![
1004+
Tag::with_key("span.op")
1005+
.from_field("span.sentry_tags.op")
1006+
.always(),
1007+
Tag::with_key("transaction")
1008+
.from_field("span.sentry_tags.transaction")
1009+
.always(),
1010+
Tag::with_key("environment")
1011+
.from_field("span.sentry_tags.environment")
1012+
.always(),
1013+
Tag::with_key("release")
1014+
.from_field("span.sentry_tags.release")
1015+
.always(),
1016+
Tag::with_key("browser.name")
1017+
.from_field("span.sentry_tags.browser.name")
1018+
.always(), // already guarded by condition on metric
1019+
Tag::with_key("user.geo.subregion")
1020+
.from_field("span.sentry_tags.user.geo.subregion")
1021+
.always(), // already guarded by condition on metric
1022+
],
1023+
},
1024+
MetricSpec {
1025+
category: DataCategory::Span,
1026+
mri: "d:transactions/measurements.score.weight.cls@ratio".into(),
1027+
field: Some("span.measurements.score.weight.cls.value".into()),
1028+
condition: Some(is_allowed_browser.clone()),
1029+
tags: vec![
1030+
Tag::with_key("span.op")
1031+
.from_field("span.sentry_tags.op")
1032+
.always(),
1033+
Tag::with_key("transaction")
1034+
.from_field("span.sentry_tags.transaction")
1035+
.always(),
1036+
Tag::with_key("environment")
1037+
.from_field("span.sentry_tags.environment")
1038+
.always(),
1039+
Tag::with_key("release")
1040+
.from_field("span.sentry_tags.release")
1041+
.always(),
1042+
Tag::with_key("browser.name")
1043+
.from_field("span.sentry_tags.browser.name")
1044+
.always(), // already guarded by condition on metric
1045+
Tag::with_key("user.geo.subregion")
1046+
.from_field("span.sentry_tags.user.geo.subregion")
1047+
.always(), // already guarded by condition on metric
1048+
],
1049+
},
1050+
MetricSpec {
1051+
category: DataCategory::Span,
1052+
mri: "d:transactions/measurements.cls@ratio".into(),
1053+
field: Some("span.measurements.cls.value".into()),
1054+
condition: Some(is_allowed_browser.clone()),
1055+
tags: vec![
1056+
Tag::with_key("span.op")
1057+
.from_field("span.sentry_tags.op")
1058+
.always(),
1059+
Tag::with_key("transaction")
1060+
.from_field("span.sentry_tags.transaction")
1061+
.always(),
1062+
Tag::with_key("environment")
1063+
.from_field("span.sentry_tags.environment")
1064+
.always(),
1065+
Tag::with_key("release")
1066+
.from_field("span.sentry_tags.release")
1067+
.always(),
1068+
Tag::with_key("browser.name")
1069+
.from_field("span.sentry_tags.browser.name")
1070+
.always(), // already guarded by condition on metric
1071+
Tag::with_key("user.geo.subregion")
1072+
.from_field("span.sentry_tags.user.geo.subregion")
1073+
.always(), // already guarded by condition on metric
1074+
],
1075+
},
1076+
],
9961077
vec![],
9971078
),
9981079
]

relay-event-normalization/src/event.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3386,6 +3386,107 @@ mod tests {
33863386
"###);
33873387
}
33883388

3389+
#[test]
3390+
fn test_computes_standalone_cls_performance_score() {
3391+
let json = r#"
3392+
{
3393+
"type": "transaction",
3394+
"timestamp": "2021-04-26T08:00:05+0100",
3395+
"start_timestamp": "2021-04-26T08:00:00+0100",
3396+
"measurements": {
3397+
"cls": {"value": 0.5}
3398+
}
3399+
}
3400+
"#;
3401+
3402+
let mut event = Annotated::<Event>::from_json(json).unwrap().0.unwrap();
3403+
3404+
let performance_score: PerformanceScoreConfig = serde_json::from_value(json!({
3405+
"profiles": [
3406+
{
3407+
"name": "Default",
3408+
"scoreComponents": [
3409+
{
3410+
"measurement": "fcp",
3411+
"weight": 0.15,
3412+
"p10": 900.0,
3413+
"p50": 1600.0,
3414+
"optional": true,
3415+
},
3416+
{
3417+
"measurement": "lcp",
3418+
"weight": 0.30,
3419+
"p10": 1200.0,
3420+
"p50": 2400.0,
3421+
"optional": true,
3422+
},
3423+
{
3424+
"measurement": "cls",
3425+
"weight": 0.15,
3426+
"p10": 0.1,
3427+
"p50": 0.25,
3428+
"optional": true,
3429+
},
3430+
{
3431+
"measurement": "ttfb",
3432+
"weight": 0.10,
3433+
"p10": 200.0,
3434+
"p50": 400.0,
3435+
"optional": true,
3436+
},
3437+
],
3438+
"condition": {
3439+
"op": "and",
3440+
"inner": [],
3441+
},
3442+
}
3443+
]
3444+
}))
3445+
.unwrap();
3446+
3447+
normalize(
3448+
&mut event,
3449+
&mut Meta::default(),
3450+
&NormalizationConfig {
3451+
performance_score: Some(&performance_score),
3452+
..Default::default()
3453+
},
3454+
);
3455+
3456+
insta::assert_ron_snapshot!(SerializableAnnotated(&event.measurements), {}, @r###"
3457+
{
3458+
"cls": {
3459+
"value": 0.5,
3460+
"unit": "none",
3461+
},
3462+
"score.cls": {
3463+
"value": 0.16615877613713903,
3464+
"unit": "ratio",
3465+
},
3466+
"score.total": {
3467+
"value": 0.16615877613713903,
3468+
"unit": "ratio",
3469+
},
3470+
"score.weight.cls": {
3471+
"value": 1.0,
3472+
"unit": "ratio",
3473+
},
3474+
"score.weight.fcp": {
3475+
"value": 0.0,
3476+
"unit": "ratio",
3477+
},
3478+
"score.weight.lcp": {
3479+
"value": 0.0,
3480+
"unit": "ratio",
3481+
},
3482+
"score.weight.ttfb": {
3483+
"value": 0.0,
3484+
"unit": "ratio",
3485+
},
3486+
}
3487+
"###);
3488+
}
3489+
33893490
#[test]
33903491
fn test_computed_performance_score_uses_first_matching_profile() {
33913492
let json = r#"

relay-event-normalization/src/normalize/span/tag_extraction.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,7 +777,9 @@ pub fn extract_tags(
777777
}
778778
}
779779
if let Some(measurements) = span.measurements.value() {
780-
if span_op.starts_with("ui.interaction.") && measurements.contains_key("inp") {
780+
if (span_op.starts_with("ui.interaction.") && measurements.contains_key("inp"))
781+
|| span_op.starts_with("ui.webvital.")
782+
{
781783
if let Some(transaction) = span
782784
.data
783785
.value()

relay-server/src/metrics_extraction/event.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1659,7 +1659,7 @@ mod tests {
16591659
}
16601660

16611661
#[test]
1662-
fn test_extract_span_metrics_performance_score() {
1662+
fn test_extract_span_metrics_inp_performance_score() {
16631663
let json = r#"
16641664
{
16651665
"op": "ui.interaction.click",
@@ -1699,6 +1699,46 @@ mod tests {
16991699
}
17001700
}
17011701

1702+
#[test]
1703+
fn test_extract_span_metrics_cls_performance_score() {
1704+
let json = r#"
1705+
{
1706+
"op": "ui.webvital.cls",
1707+
"span_id": "bd429c44b67a3eb4",
1708+
"start_timestamp": 1597976302.0000000,
1709+
"timestamp": 1597976302.0000000,
1710+
"exclusive_time": 0,
1711+
"trace_id": "ff62a8b040f340bda5d830223def1d81",
1712+
"sentry_tags": {
1713+
"browser.name": "Chrome",
1714+
"op": "ui.webvital.cls"
1715+
},
1716+
"measurements": {
1717+
"score.total": {"value": 1.0},
1718+
"score.cls": {"value": 1.0},
1719+
"score.weight.cls": {"value": 1.0},
1720+
"cls": {"value": 1.0}
1721+
}
1722+
}
1723+
"#;
1724+
let span = Annotated::<Span>::from_json(json).unwrap();
1725+
let metrics = generic::extract_metrics(
1726+
span.value().unwrap(),
1727+
combined_config([Feature::ExtractCommonSpanMetricsFromEvent], None).combined(),
1728+
);
1729+
1730+
for mri in [
1731+
"d:transactions/measurements.cls@ratio",
1732+
"d:transactions/measurements.score.cls@ratio",
1733+
"d:transactions/measurements.score.total@ratio",
1734+
"d:transactions/measurements.score.weight.cls@ratio",
1735+
] {
1736+
assert!(metrics.iter().any(|b| &*b.name == mri));
1737+
assert!(metrics.iter().any(|b| b.tags.contains_key("browser.name")));
1738+
assert!(metrics.iter().any(|b| b.tags.contains_key("span.op")));
1739+
}
1740+
}
1741+
17021742
#[test]
17031743
fn extracts_span_metrics_from_transaction() {
17041744
let event = r#"

relay-server/src/services/processor/span/processing.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,9 +404,9 @@ impl<'a> NormalizeSpanConfig<'a> {
404404
fn set_segment_attributes(span: &mut Annotated<Span>) {
405405
let Some(span) = span.value_mut() else { return };
406406

407-
// Identify INP spans and make sure they are not wrapped in a segment.
407+
// Identify INP spans or other WebVital spans and make sure they are not wrapped in a segment.
408408
if let Some(span_op) = span.op.value() {
409-
if span_op.starts_with("ui.interaction.") {
409+
if span_op.starts_with("ui.interaction.") || span_op.starts_with("ui.webvital.") {
410410
span.is_segment = None.into();
411411
span.parent_span_id = None.into();
412412
span.segment_id = None.into();

0 commit comments

Comments
 (0)