Skip to content

feat(web-vitals): Emit web-vitals as metrics#6118

Open
klochek wants to merge 3 commits into
masterfrom
christopherklochek/ingest-962-implement-double-writes-for-web-vitals
Open

feat(web-vitals): Emit web-vitals as metrics#6118
klochek wants to merge 3 commits into
masterfrom
christopherklochek/ingest-962-implement-double-writes-for-web-vitals

Conversation

@klochek

@klochek klochek commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@linear-code

linear-code Bot commented Jun 23, 2026

Copy link
Copy Markdown

INGEST-962

Comment thread relay-server/src/services/store.rs Outdated
Ok(())
}

fn produce_web_vital_metrics(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah IMO we should put data type specifics into the processing pipeline as much as possible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved

Comment on lines +20 to +21
pub(crate) mod store;
pub(crate) mod utils;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should stay private, if we have to change visibility (especially scoped visibility) it's usually a sign of bad code organization.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done--but more generally, even pub(crate)?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread relay-server/src/services/store.rs Outdated
Comment thread tests/integration/test_webvital_metrics.py Outdated
Comment thread relay-server/src/services/store.rs Outdated
name: "browser.web_vital.lcp.value",
unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond),
attribute_keys: &[
"browser.web_vital.lcp.value",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these are missing the performance scores?

Comment thread relay-server/src/services/store.rs Outdated
name: "browser.web_vital.cls.value",
unit: MetricUnit::None,
attribute_keys: &[
"browser.web_vital.cls.value",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second inspection, why do we add the metric value as an attribute? Isn't that redundant?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll be double-checking with the web-vitals folks about this, but this is as outlined in their rfc.

Comment thread relay-server/src/services/store.rs Outdated
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(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough, I've moved it to filter it--I'll add some logging in case we see it in the wild

Comment thread relay-server/src/services/store.rs Outdated
Ok(())
}

fn produce_web_vital_metrics(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah IMO we should put data type specifics into the processing pipeline as much as possible.

Comment thread relay-server/src/services/store.rs Outdated
};

message.try_accept(|span| {
self.produce_web_vital_metrics(scoping, received_at, &span)?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this call need to be guarded by a global option or feature flag in any way?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it should--once I've got the rest of the pinned down.

@klochek klochek force-pushed the christopherklochek/ingest-962-implement-double-writes-for-web-vitals branch from 4eb2c66 to 2c157e7 Compare July 6, 2026 19:15
@klochek klochek marked this pull request as ready for review July 6, 2026 20:05
@klochek klochek requested a review from a team as a code owner July 6, 2026 20:05
@klochek klochek requested a review from logaretm July 6, 2026 20:05

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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));
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2c157e7. Configure here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe can move it into mod store and just pub use here?

s.send_to_store(span.wrap(item));
}
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let attrs = span.attributes.0.as_ref()? should do

Comment on lines +98 to +100
let op_name = attrs.get_value("sentry.op")?;

if !WEB_VITAL_SPAN_NAMES.contains(&op_name.as_str().unwrap_or_default()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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}");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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).

Comment on lines +123 to +127
if let Some(v) = attrs.get_attribute(&attr_key) {
attributes.insert(attr_key, v.value.clone());
} else {
break;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a small style nit, in Relay we often use match statements for small if-else constructs like this

Comment on lines +19 to +27
"sentry.pageload.span_id",
"sentry.origin",
"sentry.transaction",
"user_agent.original",
"sentry.release",
"sentry.environment",
"sentry.sdk.name",
"sentry.sdk.version",
"sentry.platform",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 loewenheim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 logaretm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants