feat(web-vitals): Emit web-vitals as metrics#6118
Conversation
| Ok(()) | ||
| } | ||
|
|
||
| fn produce_web_vital_metrics( |
There was a problem hiding this comment.
I think we should do these things now-a-days in the respective Forward::forward_store implementation of the processing pipeline.
Even if that means we need a separate story for transactions and spans atm.
@jjbayer wdyt?
There was a problem hiding this comment.
Yeah IMO we should put data type specifics into the processing pipeline as much as possible.
| pub(crate) mod store; | ||
| pub(crate) mod utils; |
There was a problem hiding this comment.
These should stay private, if we have to change visibility (especially scoped visibility) it's usually a sign of bad code organization.
There was a problem hiding this comment.
Done--but more generally, even pub(crate)?
There was a problem hiding this comment.
I'd try to avoid it, generally it can either be public or not, with a good module hierarchy it's generally not necessary (as child modules can access private modules of parents).
In this case I am a bit behind it, because I want every processor to be independent and avoid having processor start sharing code that isn't explicitly moved into a shared component, that was a big mistake of the old processing code.
| name: "browser.web_vital.lcp.value", | ||
| unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), | ||
| attribute_keys: &[ | ||
| "browser.web_vital.lcp.value", |
There was a problem hiding this comment.
I think these are missing the performance scores?
| 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.
| name: web_vital.name.to_owned().into(), | ||
| ty: relay_event_schema::protocol::MetricType::Distribution.into(), | ||
| unit: web_vital.unit.into(), | ||
| value: Value::F64(value.as_f64().unwrap_or(0.0)).into(), |
There was a problem hiding this comment.
If the value is not a number, I would rather not emit a metric here at all. This is basically a product question: how do we want to surface unexpected data to the user? IMO silent failure is better than sending 0.0 because the latter skews the average, etc.
There was a problem hiding this comment.
Fair enough, I've moved it to filter it--I'll add some logging in case we see it in the wild
| Ok(()) | ||
| } | ||
|
|
||
| fn produce_web_vital_metrics( |
There was a problem hiding this comment.
Yeah IMO we should put data type specifics into the processing pipeline as much as possible.
| }; | ||
|
|
||
| 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
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2c157e7. Configure here.
| 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.


No description provided.