feat(web-vitals): Emit web-vitals as metrics#6118
Conversation
| name: "browser.web_vital.cls.value", | ||
| unit: MetricUnit::None, | ||
| attribute_keys: &[ | ||
| "browser.web_vital.cls.value", |
There was a problem hiding this comment.
Is attribute_value always also part of attribute_keys? In that case I would rename attribute_keys to something like additional_attributes and omit the value-holding attribute from it.
There was a problem hiding this comment.
On second inspection, why do we add the metric value as an attribute? Isn't that redundant?
There was a problem hiding this comment.
I'll be double-checking with the web-vitals folks about this, but this is as outlined in their rfc.
| }; | ||
|
|
||
| message.try_accept(|span| { | ||
| self.produce_web_vital_metrics(scoping, received_at, &span)?; |
There was a problem hiding this comment.
Does this call need to be guarded by a global option or feature flag in any way?
There was a problem hiding this comment.
Yes, it should--once I've got the rest of the pinned down.
4eb2c66 to
2c157e7
Compare
| s.send_to_store(span.wrap(item)); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Skips trace metrics feature flag
Medium Severity
Derived web-vital trace metrics are written straight to the store via produce_webvitals_metrics, so they never pass the trace-metrics processor’s Feature::TraceMetricsIngestion gate or its inbound filtering. Projects with trace metric ingestion disabled can still get these Kafka trace items.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 2c157e7. Configure here.
There was a problem hiding this comment.
This we'll have to check with Ben, but seems okay?
| span: &Managed<Box<crate::services::store::StoreSpanV2>>, | ||
| metrics: Vec<TraceMetric>, | ||
| ) { | ||
| use crate::processing::trace_metrics; |
There was a problem hiding this comment.
This doesn't seem necessary, the code is already in this module
|
|
||
| #[cfg(feature = "processing")] | ||
| /// Produce the supplied webvital trace metrics to kafka. | ||
| pub fn produce_webvitals_metrics( |
There was a problem hiding this comment.
Maybe can move it into mod store and just pub use here?
| s.send_to_store(span.wrap(item)); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
This we'll have to check with Ben, but seems okay?
| /// Extract any web vitals metrics for the supplied v2 span. Bad or missing metrics will be | ||
| /// silently dropped. | ||
| pub fn extract_web_vital_metrics(span: &SpanV2) -> Option<Vec<TraceMetric>> { | ||
| let Some(attrs) = &span.attributes.0 else { |
There was a problem hiding this comment.
let attrs = span.attributes.0.as_ref()? should do
| let op_name = attrs.get_value("sentry.op")?; | ||
|
|
||
| if !WEB_VITAL_SPAN_NAMES.contains(&op_name.as_str().unwrap_or_default()) { |
There was a problem hiding this comment.
| let op_name = attrs.get_value("sentry.op")?; | |
| if !WEB_VITAL_SPAN_NAMES.contains(&op_name.as_str().unwrap_or_default()) { | |
| let op_name = attrs.get_value("sentry.op")?.as_str()?; | |
| if !WEB_VITAL_SPAN_NAMES.contains(&op_name) { | |
| return None; | |
| } |
| } | ||
|
|
||
| // This is for attribution: we'll be able to tell on a metric if it came from relay. | ||
| attributes.insert("sentry.metric.source", "span"); |
There was a problem hiding this comment.
This (sentry.metric.source) needs to be defined sentry-conventions if it isn't already.
| // CLS webvitals are a little weird, in that they can have an arbitrary number of | ||
| // "source" attributes (with a .N postfix, 0-based, monotonically increasing), so we | ||
| // exhaustively look for them here. | ||
| if web_vital.attribute_value == "browser.web_vital.cls.value" { |
There was a problem hiding this comment.
| if web_vital.attribute_value == "browser.web_vital.cls.value" { | |
| if web_vital.attribute_value == BROWSER__WEB_VITAL__CLS__VALUE { |
| // exhaustively look for them here. | ||
| if web_vital.attribute_value == "browser.web_vital.cls.value" { | ||
| for i in 0..MAX_CLS_SOURCES { | ||
| let attr_key = format!("browser.web_vital.cls.source.{i}"); |
There was a problem hiding this comment.
| let attr_key = format!("browser.web_vital.cls.source.{i}"); | |
| let attr_key = browser__web_vital__cls__source__key(i); |
(Needs a small change first that the function accepts impl Display).
| if let Some(v) = attrs.get_attribute(&attr_key) { | ||
| attributes.insert(attr_key, v.value.clone()); | ||
| } else { | ||
| break; | ||
| } |
There was a problem hiding this comment.
Just a small style nit, in Relay we often use match statements for small if-else constructs like this
| "sentry.pageload.span_id", | ||
| "sentry.origin", | ||
| "sentry.transaction", | ||
| "user_agent.original", | ||
| "sentry.release", | ||
| "sentry.environment", | ||
| "sentry.sdk.name", | ||
| "sentry.sdk.version", | ||
| "sentry.platform", |
There was a problem hiding this comment.
These also all need to come from conventions
|
|
||
|
|
||
| # Test standalone spans (just lcp) | ||
| def test_v1_standalone_span(mini_sentry, relay_with_processing, items_consumer): |
There was a problem hiding this comment.
You could parametrize this test with mode (like some tests in test_spans_standalone) to see whether the legacy and experimental standalone span pipeline work alike.
loewenheim
left a comment
There was a problem hiding this comment.
Looks good, the main concern is around using attribute constants from relay-conventions (and possibly defining things you need in sentry-conventions) as @Dav1dde mentioned.
logaretm
left a comment
There was a problem hiding this comment.
Just caught a small issue with the CLS source starting index, otherwise LGTM.
| if let Some(v) = attrs.get_attribute(&attr_key) { | ||
| attributes.insert(attr_key, v.value.clone()); | ||
| } else { | ||
| break; |
There was a problem hiding this comment.
The SDK is sending the CLS starting from 1 rather than 0 so this would actually skip all incoming sources from an actual SDK because there is no 0 index.
While this looked weird to me at first, it is according to spec tho. So we should start the loop at 1 instead. Maybe let's throw in a test as well for this.
| attributes.insert("sentry.metric.source", "span"); | ||
|
|
||
| let trace_metric = TraceMetric { | ||
| timestamp: span.start_timestamp.clone(), |
There was a problem hiding this comment.
I thought about usingend_timestamp instead but both aren't really accurate so it doesn't matter, both are equally bad at approximating the actual metric timestamp. Just wanted to note this down.
2c157e7 to
9b164ee
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9b164ee. Configure here.
| ]; | ||
|
|
||
| const COMMON_ATTRIBUTES: [&str; 9] = [ | ||
| "sentry.pageload.span_id", |
| "with-serde", | ||
| "trace", | ||
| ] } | ||
| relay-metrics = { workspace = true } |
There was a problem hiding this comment.
Urgh we should change this, trace metrics should not depend on our metrics (doesn't need to be this PR).
I guess a simple "fix" is to re-export it from the trace metric protocol, or just actually define it there as it is independent.
|
|
||
| #[cfg(feature = "processing")] | ||
| /// Produce the supplied webvital trace metrics to kafka. | ||
| pub fn produce_webvitals_metrics( |
There was a problem hiding this comment.
If you move this to store.rs and then pub import it, you don't need as many conditional imports and it keeps this module clean(er).
There was a problem hiding this comment.
Which store.rs did you mean?
There was a problem hiding this comment.
Sorry this is already in store.rs I got tricked because of the cfg(feature = "processing") flags - the entire module is already guarded behind the processing feature.
| } | ||
|
|
||
| #[cfg(feature = "processing")] | ||
| /// Produce the supplied webvital trace metrics to kafka. |
There was a problem hiding this comment.
Some context here would be good why this is needed and what the intentions are. E.g. for now we need this for double writing and in the future the plan is to remove this and replace it by converting spans to metrics instead.
57fe071 to
032738b
Compare
Dav1dde
left a comment
There was a problem hiding this comment.
Changelog entry makes sense here also a quick PR description describing intent + linking to the RFC would be useful to have (will get materialized in the commit message).
|
Oops, the rare double approve. |
| let attr_key = browser__web_vital__cls__source__key(i.to_string()); | ||
| match attrs.get_attribute(&attr_key) { | ||
| Some(v) => attributes.insert(attr_key, v.value.clone()), | ||
| None => break, | ||
| } | ||
| } |
There was a problem hiding this comment.
Bug: The function extract_web_vital_metrics only checks for new CLS attribute keys (browser.web_vital.cls.source.N), causing attributes from legacy spans with old keys (cls.source.N) to be dropped.
Severity: MEDIUM
Suggested Fix
Modify extract_web_vital_metrics to handle both old and new CLS source attribute key formats. The function should first attempt to look up the new key (browser.web_vital.cls.source.N) and, if not found, fall back to checking for the old key (cls.source.N) before breaking the loop. This ensures that CLS source data from legacy spans is correctly processed.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: relay-spans/src/web_vitals.rs#L131-L136
Potential issue: When processing legacy spans, the `extract_web_vital_metrics` function
fails to extract Cumulative Layout Shift (CLS) source attributes. The function iterates
looking for attributes with the new key format, such as
`browser.web_vital.cls.source.1`. However, legacy spans still use the old format, like
`cls.source.1`. Because `attrs.get_attribute` returns `None` when searching for the new
key in a legacy span, the loop terminates immediately via a `break` statement. This
results in all CLS source attributes being silently dropped from the generated web vital
metrics for these spans.
Did we get this right? 👍 / 👎 to inform future reviews.
761cd6d to
11135f8
Compare
| if let Some(metrics) = relay_spans::extract_web_vital_metrics(&span.item) { | ||
| processing::trace_metrics::produce_webvitals_metrics(s, &span, metrics); | ||
| } |
There was a problem hiding this comment.
Bug: The legacy span processing path calls extract_web_vital_metrics before running full attribute normalization, which can cause web vital metrics to be silently dropped.
Severity: MEDIUM
Suggested Fix
Run the full attribute normalization pipeline, including normalize_attribute_names, on the converted SpanV2 object before calling extract_web_vital_metrics. This will align the legacy path with other transaction processing paths and ensure web vitals are extracted reliably.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: relay-server/src/processing/legacy_spans/mod.rs#L206-L208
Potential issue: In the legacy span processing path, `extract_web_vital_metrics` is
invoked on a `SpanV2` object immediately after its conversion from `SpanV1`. This
converted span does not undergo the full attribute normalization pipeline, specifically
missing a call to `normalize_attribute_names`. The `extract_web_vital_metrics` function
requires normalized attribute keys to correctly identify and extract web vital data.
Because this normalization step is skipped, any spans with deprecated or non-standard
attribute names will not be processed correctly, leading to the silent dropping of web
vital metrics.
There was a problem hiding this comment.
That legacy pipeline is gone within a week (hopefully). So doesn't matter.


No description provided.