From 43ec90102532c8d4beec02ceacae14455c82d664 Mon Sep 17 00:00:00 2001 From: Chris Klochek Date: Mon, 22 Jun 2026 21:00:58 -0400 Subject: [PATCH 1/3] feat(web-vitals): Emit web-vitals as metrics --- .../src/processing/trace_metrics/mod.rs | 4 +- relay-server/src/services/store.rs | 139 ++++++++- tests/integration/test_webvital_metrics.py | 280 ++++++++++++++++++ 3 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 tests/integration/test_webvital_metrics.py diff --git a/relay-server/src/processing/trace_metrics/mod.rs b/relay-server/src/processing/trace_metrics/mod.rs index 0a9897e3cc8..0ea5bff6639 100644 --- a/relay-server/src/processing/trace_metrics/mod.rs +++ b/relay-server/src/processing/trace_metrics/mod.rs @@ -17,8 +17,8 @@ use smallvec::smallvec; mod filter; mod process; #[cfg(feature = "processing")] -mod store; -mod utils; +pub(crate) mod store; +pub(crate) mod utils; mod validate; pub use self::utils::get_calculated_byte_size; diff --git a/relay-server/src/services/store.rs b/relay-server/src/services/store.rs index b35cb8bdbba..4419b79d89e 100644 --- a/relay-server/src/services/store.rs +++ b/relay-server/src/services/store.rs @@ -20,13 +20,15 @@ use relay_base_schema::organization::OrganizationId; use relay_base_schema::project::ProjectId; use relay_common::time::UnixTimestamp; use relay_config::Config; -use relay_event_schema::protocol::{EventId, SpanV2, datetime_to_timestamp}; +use relay_event_schema::protocol::{ + Attributes, EventId, SpanV2, TraceMetric, TraceMetricHeader, datetime_to_timestamp, +}; use relay_kafka::{ClientError, KafkaClient, KafkaTopic, Message, SerializationOutput}; use relay_metrics::{ Bucket, BucketView, BucketViewValue, BucketsView, ByNamespace, GaugeValue, MetricName, - MetricNamespace, SetView, + MetricNamespace, MetricUnit, SetView, }; -use relay_protocol::{Annotated, FiniteF64, SerializableAnnotated}; +use relay_protocol::{Annotated, FiniteF64, SerializableAnnotated, Value}; use relay_quotas::Scoping; use relay_statsd::metric; use relay_system::{Addr, FromMessage, Interface, NoResponse, Service}; @@ -35,6 +37,8 @@ use relay_threading::AsyncPool; use crate::envelope::{AttachmentPlaceholder, AttachmentType, ContentType, Item, ItemType}; use crate::managed::{Counted, Managed, ManagedEnvelope, OutcomeError, Quantities, Rejected}; use crate::metrics::{ArrayEncoding, BucketEncoder, MetricOutcomes}; +use crate::processing::Retention; +use crate::processing::trace_metrics::store::{Context, convert}; use crate::service::ServiceError; use crate::services::global_config::GlobalConfigHandle; use crate::services::objectstore::ObjectstoreKey; @@ -48,6 +52,61 @@ mod sessions; /// Fallback name used for attachment items without a `filename` header. const UNNAMED_ATTACHMENT: &str = "Unnamed Attachment"; +const WEB_VITAL_LOOKUPS: [WebVital; 5] = [ + WebVital { + attribute_value: "browser.web_vital.lcp.value", + name: "browser.web_vital.lcp.value", + unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), + attribute_keys: &[ + "browser.web_vital.lcp.value", + "browser.web_vital.lcp.element", + "browser.web_vital.lcp.id", + "browser.web_vital.lcp.url", + "browser.web_vital.lcp.size", + "browser.web_vital.lcp.load_time", + "browser.web_vital.lcp.render_time", + ], + }, + WebVital { + attribute_value: "browser.web_vital.cls.value", + name: "browser.web_vital.cls.value", + unit: MetricUnit::None, + attribute_keys: &[ + "browser.web_vital.cls.value", + "browser.web_vital.cls.source.1", + "browser.web_vital.cls.source.2", + ], + }, + WebVital { + attribute_value: "browser.web_vital.inp.value", + name: "browser.web_vital.inp.value", + unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), + attribute_keys: &["browser.web_vital.inp.value"], + }, + WebVital { + attribute_value: "browser.web_vital.fcp.value", + name: "browser.web_vital.fcp.value", + unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), + attribute_keys: &["browser.web_vital.fcp.value"], + }, + WebVital { + attribute_value: "browser.web_vital.ttfb.value", + name: "browser.web_vital.ttfb.value", + unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), + attribute_keys: &[ + "browser.web_vital.ttfb.value", + "browser.web_vital.ttfb.request_time", + ], + }, +]; + +struct WebVital { + name: &'static str, + attribute_value: &'static str, + unit: MetricUnit, + attribute_keys: &'static [&'static str], +} + #[derive(Debug, thiserror::Error)] pub enum StoreError { #[error("failed to send the message to kafka: {0}")] @@ -789,6 +848,7 @@ impl StoreService { }; message.try_accept(|span| { + self.produce_web_vital_metrics(scoping, received_at, &span)?; let item = Annotated::new(span.item); let message = KafkaMessage::SpanV2 { routing_key: span.routing_key, @@ -828,6 +888,79 @@ impl StoreService { Ok(()) } + fn produce_web_vital_metrics( + &self, + scoping: Scoping, + received_at: DateTime, + span: &Box, + ) -> Result<(), StoreError> { + let Some(attrs) = &span.item.attributes.0 else { + return Ok(()); + }; + + for web_vital in &WEB_VITAL_LOOKUPS { + let Some(value) = attrs.get_value(web_vital.attribute_value) else { + continue; + }; + + let mut attributes = Attributes::new(); + for attr_key in web_vital.attribute_keys { + if let Some(v) = attrs.get_attribute(*attr_key) { + attributes.insert(*attr_key, v.value.clone()); + } + } + let trace_metric = TraceMetric { + timestamp: span.item.start_timestamp.clone(), + trace_id: span.item.trace_id.clone(), + span_id: span.item.span_id.clone(), + 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(), + attributes: attributes.into(), + other: BTreeMap::default(), + }; + + let trace_metric_headers = TraceMetricHeader { + byte_size: Some(crate::processing::trace_metrics::utils::calculate_size( + &trace_metric, + )), + other: BTreeMap::default(), + }; + + if let Ok(mut trace_item) = convert( + crate::envelope::WithHeader { + header: trace_metric_headers.into(), + value: trace_metric.into(), + }, + &Context { + received_at, + scoping, + retention: Retention { + standard: 30, + downsampled: 30, + }, + }, + ) { + let kafka_headers = + BTreeMap::from([("namespace".to_owned(), MetricNamespace::Spans.to_string())]); + + // Remove all outcomes, as we don't want to double-bill. + trace_item.trace_item.outcomes = None; + self.produce( + KafkaTopic::Items, + KafkaMessage::Item { + headers: kafka_headers, + item_type: TraceItemType::Metric, + message: trace_item.trace_item, + }, + )?; + } + } + + Ok(()) + } + fn handle_store_profile_chunk( &self, message: Managed, diff --git a/tests/integration/test_webvital_metrics.py b/tests/integration/test_webvital_metrics.py new file mode 100644 index 00000000000..ecbc347cb02 --- /dev/null +++ b/tests/integration/test_webvital_metrics.py @@ -0,0 +1,280 @@ +import json +from datetime import datetime, timedelta, timezone + +from sentry_sdk.envelope import Envelope, Item, PayloadRef + +TEST_CONFIG = { + "aggregator": { + "bucket_interval": 1, + "initial_delay": 0, + "shift_key": "none", + } +} + + +def v1_envelope_with_spans(*payloads: dict, trace_info=None) -> Envelope: + envelope = Envelope() + for payload in payloads: + item = Item( + type="span", + payload=PayloadRef(json=payload), + content_type="application/json", + ) + envelope.add_item(item) + envelope.headers["trace"] = trace_info + return envelope + + +def v2_envelope_with_spans(*payloads: dict, trace_info=None, metadata=None) -> Envelope: + envelope = Envelope() + envelope.add_item( + Item( + type="span", + payload=PayloadRef(json={"items": payloads, **(metadata or {})}), + content_type="application/vnd.sentry.items.span.v2+json", + headers={"item_count": len(payloads)}, + ) + ) + envelope.headers["trace"] = trace_info + return envelope + + +def test_v1_transaction( + mini_sentry, + relay_with_processing, + items_consumer, +): + relay = relay_with_processing() + items_consumer = items_consumer() + + project_id = 42 + mini_sentry.add_full_project_config(project_id) + duration = timedelta(milliseconds=500) + end = datetime.now(timezone.utc) - timedelta(seconds=1) + start = end - duration + + envelope = Envelope() + envelope.add_item( + Item( + type="span", + payload=PayloadRef( + bytes=json.dumps( + { + "op": "pageload", + "span_id": "bd429c44b67a3eb1", + "segment_id": "bd429c44b67a3eb1", + "start_timestamp": start.timestamp(), + "timestamp": end.timestamp() + 1, + "exclusive_time": 345.0, # The SDK knows that this span has a lower exclusive time + "trace_id": "ff62a8b040f340bda5d830223def1d81", + "measurements": { + "cls": {"value": 100}, + "fcp": {"value": 200}, + "lcp": {"value": 400}, + "ttfb": {"value": 500}, + }, + }, + ).encode() + ), + ) + ) + envelope.add_item( + Item( + type="span", + payload=PayloadRef( + bytes=json.dumps( + { + "description": "", + "op": "ui.interaction.click", + "parent_span_id": "bd429c44b67a3eb1", + "span_id": "a6f029fbe0e2389a", + "start_timestamp": start.timestamp(), + "timestamp": end.timestamp() + 1, + "trace_id": "d3d20f000885466b8c8f947c9b92b8d3", + "origin": "auto.http.browser.inp", + "exclusive_time": 104, + "measurements": {"inp": {"value": 104, "unit": "millisecond"}}, + "segment_id": "bd429c44b67a3eb1", + }, + ).encode() + ), + ) + ) + + relay.send_envelope(project_id, envelope) + + items = items_consumer.get_items() + items.sort(key=lambda item: item["attributes"]["sentry.metric_name"]["stringValue"]) + assert len(items) == 5 + assert ( + items[0]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.cls.value" + ) + assert ( + items[1]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.fcp.value" + ) + assert ( + items[2]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.inp.value" + ) + assert ( + items[3]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.lcp.value" + ) + assert ( + items[4]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.ttfb.value" + ) + + +def test_v1_standalone_span(mini_sentry, relay_with_processing, items_consumer): + """ + Test verifies LCP spans processed via the SpanV2 and legacy standalone processing pipeline are equally processed. + + Some differences between the pipelines exist and are noted in the test. + """ + items_consumer = items_consumer() + + project_id = 42 + project_config = mini_sentry.add_full_project_config(project_id) + + relay = relay_with_processing() + + ts = datetime.now(timezone.utc) + + envelope = v1_envelope_with_spans( + { + "data": { + "sentry.op": "ui.webvital.lcp", + "release": "frontend@488531b11e6401fa530ac25554d44426e6ef0f0b", + "environment": "prod", + "replay_id": "3d76a6311de149b9b3f560827ea0ecf9", + "transaction": "/insights/projects/", + "user_agent.original": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/42.0", + "client.address": "{{auto}}", + "sentry.exclusive_time": 0, + "sentry.pageload.span_id": "8a6626cc9bdd5d9b", + "sentry.report_event": "navigation", + "lcp.url": "https://s1.sentry-cdn.com/../sentry-loader.svg", + "lcp.loadTime": 527.5, + "lcp.renderTime": 548, + "lcp.size": 8100, + }, + "description": "", + "op": "ui.webvital.lcp", + "parent_span_id": "8a6626cc9bdd5d9b", + "span_id": "9fd17741416e8e4e", + "start_timestamp": ts.timestamp() - 0.5, + "timestamp": ts.timestamp(), + "trace_id": "d3d20f000885466b8c8f947c9b92b8d3", + "origin": "auto.http.browser.lcp", + "exclusive_time": 0, + "measurements": {"lcp": {"value": 548, "unit": "millisecond"}}, + "segment_id": "8a6626cc9bdd5d9b", + }, + trace_info={ + "trace_id": "d3d20f000885466b8c8f947c9b92b8d3", + "public_key": project_config["publicKeys"][0]["publicKey"], + "transaction": "/insights/projects/", + }, + ) + + relay.send_envelope(project_id, envelope) + + items = items_consumer.get_items() + + assert len(items) == 1 + assert ( + items[0]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.lcp.value" + ) + + +def test_v2( + mini_sentry, + relay_with_processing, + items_consumer, +): + relay = relay_with_processing() + items_consumer = items_consumer() + + project_id = 42 + project_config = mini_sentry.add_full_project_config(project_id) + + ts = datetime.now(timezone.utc) + + envelope = v2_envelope_with_spans( + { + "start_timestamp": ts.timestamp(), + "end_timestamp": ts.timestamp() + 0.5, + "trace_id": "5b8efff798038103d269b633813fc60c", + "span_id": "eee19b7ec3c1b175", + "is_segment": True, + "name": "some op", + "status": "ok", + "attributes": { + "sentry.segment.id": {"value": "bd429c44b67a3eb1", "type": "string"}, + "cls": {"value": 100.0, "type": "double"}, + "fcp": {"value": 200.0, "type": "double"}, + "lcp": {"value": 400.0, "type": "double"}, + "ttfb": {"value": 500.0, "type": "double"}, + }, + }, + { + "start_timestamp": ts.timestamp(), + "end_timestamp": ts.timestamp() + 0.5, + "trace_id": "5b8efff798038103d269b633813fc60c", + "span_id": "eee19b7ec3c1b176", + "is_segment": True, + "name": "some op", + "status": "ok", + "attributes": { + "sentry.op": {"value": "ui.interaction.click", "type": "string"}, + "sentry.profile_id": { + "value": "3d9428087fda4ba0936788b70a7587d0", + "type": "string", + }, + "sentry.segment.id": {"value": "cd429c44b67a3eb1", "type": "string"}, + "inp": {"value": 100.0, "type": "double"}, + }, + }, + metadata={ + "version": 2, + "ingest_settings": { + "infer_user_agent": "auto", + }, + }, + trace_info={ + "trace_id": "5b8efff798038103d269b633813fc60c", + "public_key": project_config["publicKeys"][0]["publicKey"], + "release": "foo@1.0", + "environment": "prod", + "transaction": "/my/fancy/endpoint", + }, + ) + relay.send_envelope(project_id, envelope) + + items = items_consumer.get_items() + items.sort(key=lambda item: item["attributes"]["sentry.metric_name"]["stringValue"]) + assert len(items) == 5 + assert ( + items[0]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.cls.value" + ) + assert ( + items[1]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.fcp.value" + ) + assert ( + items[2]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.inp.value" + ) + assert ( + items[3]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.lcp.value" + ) + assert ( + items[4]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.ttfb.value" + ) From d681a77eba0cf4ccfd481d0534a11befeff2b8a7 Mon Sep 17 00:00:00 2001 From: Chris Klochek Date: Tue, 23 Jun 2026 16:35:11 -0400 Subject: [PATCH 2/3] feedback --- Cargo.lock | 1 + .../src/processing/legacy_spans/mod.rs | 4 + relay-server/src/processing/spans/mod.rs | 4 + .../src/processing/trace_metrics/mod.rs | 53 ++++++- .../processing/transactions/types/output.rs | 6 + relay-server/src/services/store.rs | 140 +----------------- relay-spans/Cargo.toml | 1 + relay-spans/src/lib.rs | 2 + relay-spans/src/web_vitals.rs | 121 +++++++++++++++ tests/integration/test_webvital_metrics.py | 15 +- 10 files changed, 194 insertions(+), 153 deletions(-) create mode 100644 relay-spans/src/web_vitals.rs diff --git a/Cargo.lock b/Cargo.lock index e7873da132f..280b225e1b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5015,6 +5015,7 @@ dependencies = [ "opentelemetry-proto", "relay-conventions", "relay-event-schema", + "relay-metrics", "relay-otel", "relay-protocol", "serde", diff --git a/relay-server/src/processing/legacy_spans/mod.rs b/relay-server/src/processing/legacy_spans/mod.rs index 7e0afa1efdf..3d14a95869d 100644 --- a/relay-server/src/processing/legacy_spans/mod.rs +++ b/relay-server/src/processing/legacy_spans/mod.rs @@ -11,6 +11,7 @@ use crate::Envelope; use crate::envelope::{EnvelopeHeaders, Item, ItemContainer, ItemType, Items}; use crate::managed::{Counted, Managed, ManagedEnvelope, OutcomeError, Quantities, Rejected}; use crate::metrics_extraction::ExtractedMetrics; +use crate::processing::trace_metrics::produce_webvitals_metrics; use crate::processing::{ self, Context, CountRateLimited, Forward, Output, QuotaRateLimiter, RateLimited, }; @@ -202,6 +203,9 @@ impl Forward for LegacySpanOutput { for span in spans.split(|spans| spans.spans.into_iter().map(IndexedOnly)) { if let Ok(span) = span.try_map(|span, _| store::convert(span.0, retention)) { + if let Some(metrics) = relay_spans::extract_web_vital_metrics(&span.item) { + produce_webvitals_metrics(s, &span, metrics); + } s.send_to_store(span) }; } diff --git a/relay-server/src/processing/spans/mod.rs b/relay-server/src/processing/spans/mod.rs index 9c921514dcc..f4f2d9f9581 100644 --- a/relay-server/src/processing/spans/mod.rs +++ b/relay-server/src/processing/spans/mod.rs @@ -19,6 +19,7 @@ use crate::metrics_extraction::ExtractedMetrics; use crate::processing::trace_attachments::forward::attachment_to_item; use crate::processing::trace_attachments::process::ScrubAttachmentError; use crate::processing::trace_attachments::types::ExpandedAttachment; +use crate::processing::trace_metrics::produce_webvitals_metrics; use crate::processing::{self, Context, Forward, Output, QuotaRateLimiter, RateLimited}; use crate::services::outcome::{DiscardReason, Outcome}; @@ -275,6 +276,9 @@ impl Forward for SpanOutput { match either.transpose() { Either::Left(span) => { if let Ok(span) = span.try_map(|span, _| store::convert(span, &ctx)) { + if let Some(metrics) = relay_spans::extract_web_vital_metrics(&span.item) { + produce_webvitals_metrics(s, &span, metrics); + } s.send_to_store(span); } } diff --git a/relay-server/src/processing/trace_metrics/mod.rs b/relay-server/src/processing/trace_metrics/mod.rs index 0ea5bff6639..79fb55045f5 100644 --- a/relay-server/src/processing/trace_metrics/mod.rs +++ b/relay-server/src/processing/trace_metrics/mod.rs @@ -1,26 +1,67 @@ -use std::sync::Arc; - use relay_cogs::{AppFeature, FeatureWeights}; use relay_event_schema::processor::ProcessingAction; +use relay_event_schema::protocol::TraceMetricHeader; use relay_event_schema::protocol::{TraceMetric, trace_metric}; use relay_filter::FilterStatKey; use relay_quotas::{DataCategory, RateLimits}; +use smallvec::smallvec; +use std::collections::BTreeMap; +use std::sync::Arc; use crate::Envelope; use crate::envelope::{ContainerItems, EnvelopeHeaders, Item, ItemType, Items}; use crate::envelope::{ContainerWriteError, ItemContainer}; use crate::managed::{Counted, Managed, ManagedEnvelope, ManagedResult as _, Quantities, Rejected}; -use crate::processing::{self, Context, CountRateLimited, Forward, Output, QuotaRateLimiter}; +use crate::processing::trace_metrics; +use crate::processing::{ + self, Context, CountRateLimited, Forward, Output, QuotaRateLimiter, Retention, StoreHandle, +}; use crate::services::outcome::{DiscardItemType, DiscardReason, Outcome}; -use smallvec::smallvec; +use crate::services::store::StoreSpanV2; +use crate::services::store::StoreTraceItem; mod filter; mod process; #[cfg(feature = "processing")] -pub(crate) mod store; -pub(crate) mod utils; +mod store; +mod utils; mod validate; +/// Produce the supplied webvital trace metrics to kafka. +pub fn produce_webvitals_metrics( + s: StoreHandle<'_>, + span: &Managed>, + metrics: Vec, +) { + for metric in metrics { + let trace_metric_headers = TraceMetricHeader { + byte_size: Some(trace_metrics::utils::calculate_size(&metric)), + other: BTreeMap::default(), + }; + + let wheader = crate::envelope::WithHeader { + header: trace_metric_headers.into(), + value: metric.into(), + }; + + if let Ok(mut item) = trace_metrics::store::convert( + wheader, + &trace_metrics::store::Context { + received_at: span.received_at(), + scoping: span.scoping(), + retention: Retention { + standard: span.retention_days, + downsampled: span.downsampled_retention_days, + }, + }, + ) { + // Clear outcomes for these metrics, as we don't want them billed. + item.trace_item.outcomes = None; + s.send_to_store(span.wrap(item)); + } + } +} + pub use self::utils::get_calculated_byte_size; pub type Result = std::result::Result; diff --git a/relay-server/src/processing/transactions/types/output.rs b/relay-server/src/processing/transactions/types/output.rs index bd945d9768d..f1e49e4a99e 100644 --- a/relay-server/src/processing/transactions/types/output.rs +++ b/relay-server/src/processing/transactions/types/output.rs @@ -8,6 +8,7 @@ use crate::managed::{Managed, ManagedResult, Rejected}; #[cfg(feature = "processing")] use crate::processing::StoreHandle; use crate::processing::spans::Indexed; +use crate::processing::trace_metrics::produce_webvitals_metrics; use crate::processing::transactions::types::{ ExpandedTransaction, ExtractedIndexedSpans, StandaloneProfile, }; @@ -88,6 +89,11 @@ impl Forward for TransactionOutput { span.performance_issues_spans = true; }); } + + if let Some(metrics) = relay_spans::extract_web_vital_metrics(&span.item) { + produce_webvitals_metrics(s, &span, metrics); + } + s.send_to_store(span) }; } diff --git a/relay-server/src/services/store.rs b/relay-server/src/services/store.rs index 4419b79d89e..1266821da59 100644 --- a/relay-server/src/services/store.rs +++ b/relay-server/src/services/store.rs @@ -20,15 +20,13 @@ use relay_base_schema::organization::OrganizationId; use relay_base_schema::project::ProjectId; use relay_common::time::UnixTimestamp; use relay_config::Config; -use relay_event_schema::protocol::{ - Attributes, EventId, SpanV2, TraceMetric, TraceMetricHeader, datetime_to_timestamp, -}; +use relay_event_schema::protocol::{EventId, SpanV2, datetime_to_timestamp}; use relay_kafka::{ClientError, KafkaClient, KafkaTopic, Message, SerializationOutput}; use relay_metrics::{ Bucket, BucketView, BucketViewValue, BucketsView, ByNamespace, GaugeValue, MetricName, - MetricNamespace, MetricUnit, SetView, + MetricNamespace, SetView, }; -use relay_protocol::{Annotated, FiniteF64, SerializableAnnotated, Value}; +use relay_protocol::{Annotated, FiniteF64, SerializableAnnotated}; use relay_quotas::Scoping; use relay_statsd::metric; use relay_system::{Addr, FromMessage, Interface, NoResponse, Service}; @@ -37,8 +35,7 @@ use relay_threading::AsyncPool; use crate::envelope::{AttachmentPlaceholder, AttachmentType, ContentType, Item, ItemType}; use crate::managed::{Counted, Managed, ManagedEnvelope, OutcomeError, Quantities, Rejected}; use crate::metrics::{ArrayEncoding, BucketEncoder, MetricOutcomes}; -use crate::processing::Retention; -use crate::processing::trace_metrics::store::{Context, convert}; + use crate::service::ServiceError; use crate::services::global_config::GlobalConfigHandle; use crate::services::objectstore::ObjectstoreKey; @@ -52,61 +49,6 @@ mod sessions; /// Fallback name used for attachment items without a `filename` header. const UNNAMED_ATTACHMENT: &str = "Unnamed Attachment"; -const WEB_VITAL_LOOKUPS: [WebVital; 5] = [ - WebVital { - attribute_value: "browser.web_vital.lcp.value", - name: "browser.web_vital.lcp.value", - unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), - attribute_keys: &[ - "browser.web_vital.lcp.value", - "browser.web_vital.lcp.element", - "browser.web_vital.lcp.id", - "browser.web_vital.lcp.url", - "browser.web_vital.lcp.size", - "browser.web_vital.lcp.load_time", - "browser.web_vital.lcp.render_time", - ], - }, - WebVital { - attribute_value: "browser.web_vital.cls.value", - name: "browser.web_vital.cls.value", - unit: MetricUnit::None, - attribute_keys: &[ - "browser.web_vital.cls.value", - "browser.web_vital.cls.source.1", - "browser.web_vital.cls.source.2", - ], - }, - WebVital { - attribute_value: "browser.web_vital.inp.value", - name: "browser.web_vital.inp.value", - unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), - attribute_keys: &["browser.web_vital.inp.value"], - }, - WebVital { - attribute_value: "browser.web_vital.fcp.value", - name: "browser.web_vital.fcp.value", - unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), - attribute_keys: &["browser.web_vital.fcp.value"], - }, - WebVital { - attribute_value: "browser.web_vital.ttfb.value", - name: "browser.web_vital.ttfb.value", - unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), - attribute_keys: &[ - "browser.web_vital.ttfb.value", - "browser.web_vital.ttfb.request_time", - ], - }, -]; - -struct WebVital { - name: &'static str, - attribute_value: &'static str, - unit: MetricUnit, - attribute_keys: &'static [&'static str], -} - #[derive(Debug, thiserror::Error)] pub enum StoreError { #[error("failed to send the message to kafka: {0}")] @@ -848,7 +790,6 @@ impl StoreService { }; message.try_accept(|span| { - self.produce_web_vital_metrics(scoping, received_at, &span)?; let item = Annotated::new(span.item); let message = KafkaMessage::SpanV2 { routing_key: span.routing_key, @@ -888,79 +829,6 @@ impl StoreService { Ok(()) } - fn produce_web_vital_metrics( - &self, - scoping: Scoping, - received_at: DateTime, - span: &Box, - ) -> Result<(), StoreError> { - let Some(attrs) = &span.item.attributes.0 else { - return Ok(()); - }; - - for web_vital in &WEB_VITAL_LOOKUPS { - let Some(value) = attrs.get_value(web_vital.attribute_value) else { - continue; - }; - - let mut attributes = Attributes::new(); - for attr_key in web_vital.attribute_keys { - if let Some(v) = attrs.get_attribute(*attr_key) { - attributes.insert(*attr_key, v.value.clone()); - } - } - let trace_metric = TraceMetric { - timestamp: span.item.start_timestamp.clone(), - trace_id: span.item.trace_id.clone(), - span_id: span.item.span_id.clone(), - 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(), - attributes: attributes.into(), - other: BTreeMap::default(), - }; - - let trace_metric_headers = TraceMetricHeader { - byte_size: Some(crate::processing::trace_metrics::utils::calculate_size( - &trace_metric, - )), - other: BTreeMap::default(), - }; - - if let Ok(mut trace_item) = convert( - crate::envelope::WithHeader { - header: trace_metric_headers.into(), - value: trace_metric.into(), - }, - &Context { - received_at, - scoping, - retention: Retention { - standard: 30, - downsampled: 30, - }, - }, - ) { - let kafka_headers = - BTreeMap::from([("namespace".to_owned(), MetricNamespace::Spans.to_string())]); - - // Remove all outcomes, as we don't want to double-bill. - trace_item.trace_item.outcomes = None; - self.produce( - KafkaTopic::Items, - KafkaMessage::Item { - headers: kafka_headers, - item_type: TraceItemType::Metric, - message: trace_item.trace_item, - }, - )?; - } - } - - Ok(()) - } - fn handle_store_profile_chunk( &self, message: Managed, diff --git a/relay-spans/Cargo.toml b/relay-spans/Cargo.toml index 752ad5478f4..155165fafd9 100644 --- a/relay-spans/Cargo.toml +++ b/relay-spans/Cargo.toml @@ -20,6 +20,7 @@ opentelemetry-proto = { workspace = true, features = [ "with-serde", "trace", ] } +relay-metrics = { workspace = true } relay-conventions = { workspace = true } relay-event-schema = { workspace = true } relay-otel = { workspace = true } diff --git a/relay-spans/src/lib.rs b/relay-spans/src/lib.rs index 1ff31d8689a..b6bc358b5f9 100644 --- a/relay-spans/src/lib.rs +++ b/relay-spans/src/lib.rs @@ -10,6 +10,7 @@ pub use crate::description::derive_description_for_v2_span; pub use crate::op::derive_op_for_v2_span; pub use crate::otel_to_sentry_v2::otel_to_sentry_span as otel_to_sentry_span_v2; pub use crate::v1_to_v2::span_v1_to_span_v2; +pub use crate::web_vitals::extract_web_vital_metrics; pub use opentelemetry_proto::tonic::trace::v1 as otel_trace; @@ -18,3 +19,4 @@ mod name; mod op; mod otel_to_sentry_v2; mod v1_to_v2; +mod web_vitals; diff --git a/relay-spans/src/web_vitals.rs b/relay-spans/src/web_vitals.rs new file mode 100644 index 00000000000..f5476a49d46 --- /dev/null +++ b/relay-spans/src/web_vitals.rs @@ -0,0 +1,121 @@ +use relay_event_schema::protocol::{Attributes, SpanV2, TraceMetric}; +use relay_metrics::MetricUnit; +use relay_protocol::Value; +use std::collections::BTreeMap; + +const WEB_VITAL_SPAN_NAMES: [&'static str; 7] = [ + "pageload", + "ui.webvital.lcp", + "ui.webvital.cls", + "ui.interaction.click", + "ui.interaction.hover", + "ui.interaction.drag", + "ui.interaction.press", +]; + +const WEB_VITAL_LOOKUPS: [WebVital; 5] = [ + WebVital { + attribute_value: "browser.web_vital.lcp.value", + name: "browser.web_vital.lcp.value", + unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), + attribute_keys: &[ + "browser.web_vital.lcp.value", + "browser.web_vital.lcp.element", + "browser.web_vital.lcp.id", + "browser.web_vital.lcp.url", + "browser.web_vital.lcp.size", + "browser.web_vital.lcp.load_time", + "browser.web_vital.lcp.render_time", + ], + }, + WebVital { + attribute_value: "browser.web_vital.cls.value", + name: "browser.web_vital.cls.value", + unit: MetricUnit::None, + attribute_keys: &[ + "browser.web_vital.cls.value", + "browser.web_vital.cls.source.1", + "browser.web_vital.cls.source.2", + ], + }, + WebVital { + attribute_value: "browser.web_vital.inp.value", + name: "browser.web_vital.inp.value", + unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), + attribute_keys: &["browser.web_vital.inp.value"], + }, + WebVital { + attribute_value: "browser.web_vital.fcp.value", + name: "browser.web_vital.fcp.value", + unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), + attribute_keys: &["browser.web_vital.fcp.value"], + }, + WebVital { + attribute_value: "browser.web_vital.ttfb.value", + name: "browser.web_vital.ttfb.value", + unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), + attribute_keys: &[ + "browser.web_vital.ttfb.value", + "browser.web_vital.ttfb.request_time", + ], + }, +]; + +struct WebVital { + name: &'static str, + attribute_value: &'static str, + unit: MetricUnit, + attribute_keys: &'static [&'static str], +} + +/// 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> { + if let Some(name) = span.name.value() { + if !WEB_VITAL_SPAN_NAMES.contains(&name.as_str()) { + return None; + } + } + + let Some(attrs) = &span.attributes.0 else { + return None; + }; + + let mut results = vec![]; + + for web_vital in &WEB_VITAL_LOOKUPS { + let Some(value) = attrs.get_value(web_vital.attribute_value) else { + continue; + }; + + let Some(value) = value.as_f64() else { + continue; + }; + + let mut attributes = Attributes::new(); + for attr_key in web_vital.attribute_keys { + if let Some(v) = attrs.get_attribute(*attr_key) { + attributes.insert(*attr_key, v.value.clone()); + } + } + let trace_metric = TraceMetric { + timestamp: span.start_timestamp.clone(), + trace_id: span.trace_id.clone(), + span_id: span.span_id.clone(), + name: web_vital.name.to_owned().into(), + ty: relay_event_schema::protocol::MetricType::Distribution.into(), + unit: web_vital.unit.into(), + value: Value::F64(value).into(), + attributes: attributes.into(), + other: BTreeMap::default(), + }; + + results.push(trace_metric); + } + + if results.is_empty() { + None + } else { + Some(results) + } +} diff --git a/tests/integration/test_webvital_metrics.py b/tests/integration/test_webvital_metrics.py index ecbc347cb02..e65ed3b2c4c 100644 --- a/tests/integration/test_webvital_metrics.py +++ b/tests/integration/test_webvital_metrics.py @@ -3,14 +3,6 @@ from sentry_sdk.envelope import Envelope, Item, PayloadRef -TEST_CONFIG = { - "aggregator": { - "bucket_interval": 1, - "initial_delay": 0, - "shift_key": "none", - } -} - def v1_envelope_with_spans(*payloads: dict, trace_info=None) -> Envelope: envelope = Envelope() @@ -211,9 +203,10 @@ def test_v2( "trace_id": "5b8efff798038103d269b633813fc60c", "span_id": "eee19b7ec3c1b175", "is_segment": True, - "name": "some op", + "name": "pageload", "status": "ok", "attributes": { + "sentry.op": {"value": "pageload", "type": "string"}, "sentry.segment.id": {"value": "bd429c44b67a3eb1", "type": "string"}, "cls": {"value": 100.0, "type": "double"}, "fcp": {"value": 200.0, "type": "double"}, @@ -227,10 +220,10 @@ def test_v2( "trace_id": "5b8efff798038103d269b633813fc60c", "span_id": "eee19b7ec3c1b176", "is_segment": True, - "name": "some op", + "name": "ui.interaction.drag", "status": "ok", "attributes": { - "sentry.op": {"value": "ui.interaction.click", "type": "string"}, + "sentry.op": {"value": "ui.interaction.drag", "type": "string"}, "sentry.profile_id": { "value": "3d9428087fda4ba0936788b70a7587d0", "type": "string", From 4eb2c66fd602ea6ad0d5617f48dd764f5e767b4e Mon Sep 17 00:00:00 2001 From: Chris Klochek Date: Fri, 3 Jul 2026 11:10:45 -0400 Subject: [PATCH 3/3] more attributes, more testing --- .../src/processing/trace_metrics/mod.rs | 1 - .../processing/transactions/types/output.rs | 1 - relay-spans/src/web_vitals.rs | 249 ++++++- tests/integration/test_webvital_metrics.py | 681 ++++++++++++++++-- 4 files changed, 871 insertions(+), 61 deletions(-) diff --git a/relay-server/src/processing/trace_metrics/mod.rs b/relay-server/src/processing/trace_metrics/mod.rs index 79fb55045f5..9867ab6b224 100644 --- a/relay-server/src/processing/trace_metrics/mod.rs +++ b/relay-server/src/processing/trace_metrics/mod.rs @@ -18,7 +18,6 @@ use crate::processing::{ }; use crate::services::outcome::{DiscardItemType, DiscardReason, Outcome}; use crate::services::store::StoreSpanV2; -use crate::services::store::StoreTraceItem; mod filter; mod process; diff --git a/relay-server/src/processing/transactions/types/output.rs b/relay-server/src/processing/transactions/types/output.rs index f1e49e4a99e..60cadb8e8d7 100644 --- a/relay-server/src/processing/transactions/types/output.rs +++ b/relay-server/src/processing/transactions/types/output.rs @@ -70,7 +70,6 @@ impl Forward for TransactionOutput { } TransactionOutput::Indexed { spans, transaction } => (spans, transaction), }; - let performance_issues_spans = ctx .project_info .has_feature(Feature::PerformanceIssuesSpans); diff --git a/relay-spans/src/web_vitals.rs b/relay-spans/src/web_vitals.rs index f5476a49d46..f629b339c10 100644 --- a/relay-spans/src/web_vitals.rs +++ b/relay-spans/src/web_vitals.rs @@ -3,6 +3,8 @@ use relay_metrics::MetricUnit; use relay_protocol::Value; use std::collections::BTreeMap; +const MAX_CLS_SOURCES: u32 = 128; + const WEB_VITAL_SPAN_NAMES: [&'static str; 7] = [ "pageload", "ui.webvital.lcp", @@ -13,50 +15,68 @@ const WEB_VITAL_SPAN_NAMES: [&'static str; 7] = [ "ui.interaction.press", ]; +const COMMON_ATTRIBUTES: [&'static str; 9] = [ + "sentry.pageload.span_id", + "sentry.origin", + "sentry.transaction", + "user_agent.original", + "sentry.release", + "sentry.environment", + "sentry.sdk.name", + "sentry.sdk.version", + "sentry.platform", +]; + const WEB_VITAL_LOOKUPS: [WebVital; 5] = [ WebVital { attribute_value: "browser.web_vital.lcp.value", - name: "browser.web_vital.lcp.value", + name: "browser.web_vital.lcp", unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), attribute_keys: &[ - "browser.web_vital.lcp.value", "browser.web_vital.lcp.element", "browser.web_vital.lcp.id", "browser.web_vital.lcp.url", "browser.web_vital.lcp.size", "browser.web_vital.lcp.load_time", "browser.web_vital.lcp.render_time", + "score.lcp", + "score.weight.lcp", + "score.ratio.lcp", ], }, WebVital { attribute_value: "browser.web_vital.cls.value", - name: "browser.web_vital.cls.value", + name: "browser.web_vital.cls", unit: MetricUnit::None, - attribute_keys: &[ - "browser.web_vital.cls.value", - "browser.web_vital.cls.source.1", - "browser.web_vital.cls.source.2", - ], + attribute_keys: &["score.cls", "score.weight.cls", "score.ratio.cls"], }, WebVital { attribute_value: "browser.web_vital.inp.value", - name: "browser.web_vital.inp.value", + name: "browser.web_vital.inp", unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), - attribute_keys: &["browser.web_vital.inp.value"], + attribute_keys: &[ + "browser.web_vital.inp.target", + "browser.web_vital.inp.type", + "score.inp", + "score.weight.inp", + "score.ratio.inp", + ], }, WebVital { attribute_value: "browser.web_vital.fcp.value", - name: "browser.web_vital.fcp.value", + name: "browser.web_vital.fcp", unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), - attribute_keys: &["browser.web_vital.fcp.value"], + attribute_keys: &["score.fcp", "score.weight.fcp", "score.ratio.fcp"], }, WebVital { attribute_value: "browser.web_vital.ttfb.value", - name: "browser.web_vital.ttfb.value", + name: "browser.web_vital.ttfb", unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), attribute_keys: &[ - "browser.web_vital.ttfb.value", "browser.web_vital.ttfb.request_time", + "score.ttfb", + "score.ratio.ttfb", + "score.weight.ttfb", ], }, ]; @@ -93,11 +113,36 @@ pub fn extract_web_vital_metrics(span: &SpanV2) -> Option> { }; let mut attributes = Attributes::new(); + + // 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" { + for i in 0..MAX_CLS_SOURCES { + let attr_key = format!("browser.web_vital.cls.source.{i}"); + if let Some(v) = attrs.get_attribute(&attr_key) { + attributes.insert(attr_key, v.value.clone()); + } else { + break; + } + } + } + for attr_key in web_vital.attribute_keys { if let Some(v) = attrs.get_attribute(*attr_key) { attributes.insert(*attr_key, v.value.clone()); } } + + for attr_key in COMMON_ATTRIBUTES { + if let Some(v) = attrs.get_attribute(attr_key) { + attributes.insert(attr_key, v.value.clone()); + } + } + + // This is for attribution: we'll be able to tell on a metric if it came from relay. + attributes.insert("sentry.metric.source", "span"); + let trace_metric = TraceMetric { timestamp: span.start_timestamp.clone(), trace_id: span.trace_id.clone(), @@ -119,3 +164,179 @@ pub fn extract_web_vital_metrics(span: &SpanV2) -> Option> { Some(results) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::span_v1_to_span_v2; + use relay_event_schema::protocol::{Event, Span}; + use relay_protocol::Annotated; + + /// Returns the string value of attribute `key` on the metric, if present. + fn attr<'a>(metric: &'a TraceMetric, key: &str) -> Option<&'a str> { + metric + .attributes + .value()? + .get_value(key) + .and_then(Value::as_str) + } + + /// Finds the LCP web vital metric in the produced set. + fn lcp_metric(metrics: &[TraceMetric]) -> &TraceMetric { + metrics + .iter() + .find(|m| m.name.value().map(String::as_str) == Some("browser.web_vital.lcp")) + .expect("expected an LCP web vital metric") + } + + /// Span V2 ingest path (`spans/mod.rs`): a browser SDK sends the SDK attributes directly on + /// the span, so extraction must copy them onto the produced metric. + #[test] + fn native_v2_span_carries_sdk_attributes() { + let json = r#"{ + "trace_id": "5b8efff798038103d269b633813fc60c", + "span_id": "eee19b7ec3c1b174", + "start_timestamp": 1544719859.0, + "end_timestamp": 1544719860.0, + "name": "pageload", + "attributes": { + "browser.web_vital.lcp.value": {"type": "double", "value": 2500.0}, + "sentry.release": {"type": "string", "value": "myapp@1.0.0"}, + "sentry.environment": {"type": "string", "value": "prod"}, + "sentry.sdk.name": {"type": "string", "value": "sentry.javascript.react"}, + "sentry.sdk.version": {"type": "string", "value": "9.1.0"} + } + }"#; + let span = Annotated::::from_json(json) + .unwrap() + .into_value() + .unwrap(); + + let metrics = extract_web_vital_metrics(&span).expect("expected metrics"); + let lcp = lcp_metric(&metrics); + + assert_eq!(attr(lcp, "sentry.release"), Some("myapp@1.0.0")); + assert_eq!(attr(lcp, "sentry.environment"), Some("prod")); + assert_eq!( + attr(lcp, "sentry.sdk.name"), + Some("sentry.javascript.react") + ); + assert_eq!(attr(lcp, "sentry.sdk.version"), Some("9.1.0")); + } + + /// If the SDK does not send the attributes, extraction cannot invent them. This documents the + /// honest caveat that the Span V2 path only carries SDK info when the client provides it. + #[test] + fn native_v2_span_without_sdk_attributes_omits_them() { + let json = r#"{ + "trace_id": "5b8efff798038103d269b633813fc60c", + "span_id": "eee19b7ec3c1b174", + "start_timestamp": 1544719859.0, + "end_timestamp": 1544719860.0, + "name": "pageload", + "attributes": { + "browser.web_vital.lcp.value": {"type": "double", "value": 2500.0} + } + }"#; + let span = Annotated::::from_json(json) + .unwrap() + .into_value() + .unwrap(); + + let metrics = extract_web_vital_metrics(&span).expect("expected metrics"); + let lcp = lcp_metric(&metrics); + + assert_eq!(attr(lcp, "sentry.release"), None); + assert_eq!(attr(lcp, "sentry.environment"), None); + assert_eq!(attr(lcp, "sentry.sdk.name"), None); + assert_eq!(attr(lcp, "sentry.sdk.version"), None); + } + + /// Standalone legacy span path (`legacy_spans/mod.rs`): a v1 span arrives on its own carrying + /// the SDK info in its `data` bag. `span_v1_to_span_v2` maps those `data` fields to `sentry.*` + /// attributes, which extraction must copy onto the metric. + #[test] + fn standalone_legacy_span_carries_sdk_attributes() { + let json = r#"{ + "trace_id": "5b8efff798038103d269b633813fc60c", + "span_id": "eee19b7ec3c1b174", + "start_timestamp": 1544719859.0, + "timestamp": 1544719860.0, + "data": { + "sentry.name": "pageload", + "browser.web_vital.lcp.value": 2500.0, + "sentry.release": "myapp@1.0.0", + "sentry.environment": "prod", + "sentry.sdk.name": "sentry.javascript.react", + "sentry.sdk.version": "9.1.0" + } + }"#; + let span_v1 = Annotated::::from_json(json) + .unwrap() + .into_value() + .unwrap(); + let span_v2 = span_v1_to_span_v2(span_v1); + + assert_eq!(span_v2.name.value().map(String::as_str), Some("pageload")); + + let metrics = extract_web_vital_metrics(&span_v2).expect("expected metrics"); + let lcp = lcp_metric(&metrics); + + assert_eq!(attr(lcp, "sentry.release"), Some("myapp@1.0.0")); + assert_eq!(attr(lcp, "sentry.environment"), Some("prod")); + assert_eq!( + attr(lcp, "sentry.sdk.name"), + Some("sentry.javascript.react") + ); + assert_eq!(attr(lcp, "sentry.sdk.version"), Some("9.1.0")); + } + + /// Transaction path (`transactions/types/output.rs`): a transaction event carries + /// release/environment/sdk at the *event* level. `Span::from(&Event)` copies them into + /// `SpanData`, and `span_v1_to_span_v2` maps those into `sentry.*` attributes. This drives the + /// real conversion chain and asserts the attributes reach the produced metric. + #[test] + fn transaction_derived_span_carries_sdk_attributes() { + let event = Annotated::::from_json( + r#"{ + "type": "transaction", + "platform": "javascript", + "sdk": {"name": "sentry.javascript.react", "version": "9.1.0"}, + "release": "myapp@1.0.0", + "environment": "prod", + "transaction": "pageload", + "contexts": { + "trace": { + "trace_id": "4c79f60c11214eb38604f4ae0781bfb2", + "span_id": "fa90fdead5f74052", + "type": "trace", + "op": "pageload", + "data": { + "browser.web_vital.lcp.value": 2500.0 + } + } + } + }"#, + ) + .unwrap() + .into_value() + .unwrap(); + + let span_v1 = Span::from(&event); + let span_v2 = span_v1_to_span_v2(span_v1); + + // The transaction name becomes the span name; "pageload" marks this as a web vital span. + assert_eq!(span_v2.name.value().map(String::as_str), Some("pageload")); + + let metrics = extract_web_vital_metrics(&span_v2).expect("expected metrics"); + let lcp = lcp_metric(&metrics); + + assert_eq!(attr(lcp, "sentry.release"), Some("myapp@1.0.0")); + assert_eq!(attr(lcp, "sentry.environment"), Some("prod")); + assert_eq!( + attr(lcp, "sentry.sdk.name"), + Some("sentry.javascript.react") + ); + assert_eq!(attr(lcp, "sentry.sdk.version"), Some("9.1.0")); + } +} diff --git a/tests/integration/test_webvital_metrics.py b/tests/integration/test_webvital_metrics.py index e65ed3b2c4c..7e33281150b 100644 --- a/tests/integration/test_webvital_metrics.py +++ b/tests/integration/test_webvital_metrics.py @@ -3,6 +3,43 @@ from sentry_sdk.envelope import Envelope, Item, PayloadRef +from .asserts import matches_any, time_within_delta + + +def v1_transaction_envelope(*payloads: dict) -> Envelope: + envelope = Envelope() + + spans = [payload for payload in payloads] + + envelope.add_item( + Item( + type="transaction", + payload=PayloadRef( + bytes=json.dumps( + { + "type": "transaction", + "timestamp": spans[0]["timestamp"], + "start_timestamp": spans[0]["start_timestamp"], + "spans": spans, + "contexts": { + "trace": { + "op": "hi", + "trace_id": "a0fa8803753e40fd8124b21eeb2986b5", + "span_id": "968cff94913ebb07", + "sentry.origin": "manual", + } + }, + "transaction": "my_transaction", + "environment": "production", + "platform": "node", + }, + ).encode() + ), + ) + ) + + return envelope + def v1_envelope_with_spans(*payloads: dict, trace_info=None) -> Envelope: envelope = Envelope() @@ -40,7 +77,303 @@ def test_v1_transaction( items_consumer = items_consumer() project_id = 42 - mini_sentry.add_full_project_config(project_id) + project_config = mini_sentry.add_full_project_config(project_id) + project_config["config"]["performanceScore"] = { + "profiles": [ + { + "name": "Desktop", + "scoreComponents": [ + {"measurement": "fcp", "weight": 0.15, "p10": 900, "p50": 1600}, + {"measurement": "lcp", "weight": 0.30, "p10": 1200, "p50": 2400}, + {"measurement": "cls", "weight": 0.25, "p10": 0.1, "p50": 0.25}, + {"measurement": "ttfb", "weight": 0.30, "p10": 0.2, "p50": 0.4}, + ], + "condition": { + "op": "eq", + "name": "event.contexts.browser.name", + "value": "Firefox", + }, + }, + { + "name": "Desktop INP", + "scoreComponents": [ + {"measurement": "inp", "weight": 1.0, "p10": 200, "p50": 400}, + ], + "condition": { + "op": "eq", + "name": "event.contexts.browser.name", + "value": "Firefox", + }, + }, + ], + } + duration = timedelta(milliseconds=500) + end = datetime.now(timezone.utc) - timedelta(seconds=1) + start = end - duration + + envelope = v1_transaction_envelope( + { + "op": "pageload", + "span_id": "bd429c44b67a3eb1", + "segment_id": "bd429c44b67a3eb1", + "start_timestamp": start.timestamp(), + "timestamp": end.timestamp() + 1, + "exclusive_time": 345.0, + "trace_id": "ff62a8b040f340bda5d830223def1d81", + "measurements": { + "cls": {"value": 100}, + "fcp": {"value": 200}, + "lcp": {"value": 400}, + "ttfb": {"value": 500}, + }, + }, + { + "description": "", + "op": "ui.interaction.click", + "parent_span_id": "bd429c44b67a3eb1", + "span_id": "a6f029fbe0e2389a", + "start_timestamp": start.timestamp(), + "timestamp": end.timestamp() + 1, + "trace_id": "d3d20f000885466b8c8f947c9b92b8d3", + "origin": "auto.http.browser.inp", + "exclusive_time": 104, + "measurements": {"inp": {"value": 104}}, + "segment_id": "bd429c44b67a3eb1", + }, + ) + + relay.send_envelope(project_id, envelope) + + expected = [ + { + "organizationId": "1", + "projectId": "42", + "traceId": "ff62a8b040f340bda5d830223def1d81", + "itemId": matches_any(), + "itemType": "TRACE_ITEM_TYPE_METRIC", + "timestamp": time_within_delta(), + "attributes": { + "sentry.metric_name": {"stringValue": "browser.web_vital.cls"}, + "sentry.sdk.name": {"stringValue": "raven-node"}, + "sentry._internal.cooccuring.unit.none": {"boolValue": True}, + "sentry.metric_unit": {"stringValue": "none"}, + "sentry.sdk.version": {"stringValue": "2.6.3"}, + "sentry._internal.cooccuring.name.browser.web_vital.cls": { + "boolValue": True + }, + "sentry.value": {"doubleValue": 100.0}, + "sentry.metric.source": {"stringValue": "span"}, + "sentry.timestamp_precise": { + "intValue": time_within_delta(expect_resolution="ns") + }, + "sentry.span_id": {"stringValue": "bd429c44b67a3eb1"}, + "sentry.payload_size_bytes": {"intValue": "180"}, + "sentry._internal.cooccuring.type.distribution": {"boolValue": True}, + "sentry.metric_type": {"stringValue": "distribution"}, + "sentry.platform": {"stringValue": "node"}, + "sentry.transaction": {"stringValue": "my_transaction"}, + "sentry.environment": {"stringValue": "production"}, + }, + "clientSampleRate": 1.0, + "serverSampleRate": 1.0, + "retentionDays": 90, + "received": time_within_delta(), + "downsampledRetentionDays": 90, + }, + { + "organizationId": "1", + "projectId": "42", + "traceId": "ff62a8b040f340bda5d830223def1d81", + "itemId": matches_any(), + "itemType": "TRACE_ITEM_TYPE_METRIC", + "timestamp": time_within_delta(), + "attributes": { + "sentry.metric_name": {"stringValue": "browser.web_vital.fcp"}, + "sentry.sdk.name": {"stringValue": "raven-node"}, + "sentry._internal.cooccuring.name.browser.web_vital.fcp": { + "boolValue": True + }, + "sentry.metric_unit": {"stringValue": "millisecond"}, + "sentry.sdk.version": {"stringValue": "2.6.3"}, + "sentry.value": {"doubleValue": 200.0}, + "sentry.metric.source": {"stringValue": "span"}, + "sentry.timestamp_precise": { + "intValue": time_within_delta(expect_resolution="ns") + }, + "sentry.span_id": {"stringValue": "bd429c44b67a3eb1"}, + "sentry.payload_size_bytes": {"intValue": "180"}, + "sentry.metric_type": {"stringValue": "distribution"}, + "sentry.platform": {"stringValue": "node"}, + "sentry._internal.cooccuring.type.distribution": {"boolValue": True}, + "sentry._internal.cooccuring.unit.millisecond": {"boolValue": True}, + "sentry.transaction": {"stringValue": "my_transaction"}, + "sentry.environment": {"stringValue": "production"}, + }, + "clientSampleRate": 1.0, + "serverSampleRate": 1.0, + "retentionDays": 90, + "received": time_within_delta(), + "downsampledRetentionDays": 90, + }, + { + "organizationId": "1", + "projectId": "42", + "traceId": "d3d20f000885466b8c8f947c9b92b8d3", + "itemId": matches_any(), + "itemType": "TRACE_ITEM_TYPE_METRIC", + "timestamp": time_within_delta(), + "attributes": { + "sentry.metric_name": {"stringValue": "browser.web_vital.inp"}, + "sentry.sdk.name": {"stringValue": "raven-node"}, + "sentry.origin": {"stringValue": "auto.http.browser.inp"}, + "sentry.metric_unit": {"stringValue": "millisecond"}, + "sentry.sdk.version": {"stringValue": "2.6.3"}, + "sentry.value": {"doubleValue": 104.0}, + "sentry._internal.cooccuring.name.browser.web_vital.inp": { + "boolValue": True + }, + "sentry.metric.source": {"stringValue": "span"}, + "sentry.timestamp_precise": { + "intValue": time_within_delta(expect_resolution="ns") + }, + "sentry.span_id": {"stringValue": "a6f029fbe0e2389a"}, + "sentry.payload_size_bytes": {"intValue": "214"}, + "sentry._internal.cooccuring.type.distribution": {"boolValue": True}, + "sentry.platform": {"stringValue": "node"}, + "sentry.metric_type": {"stringValue": "distribution"}, + "sentry._internal.cooccuring.unit.millisecond": {"boolValue": True}, + "sentry.transaction": {"stringValue": "my_transaction"}, + "sentry.environment": {"stringValue": "production"}, + }, + "clientSampleRate": 1.0, + "serverSampleRate": 1.0, + "retentionDays": 90, + "received": time_within_delta(), + "downsampledRetentionDays": 90, + }, + { + "organizationId": "1", + "projectId": "42", + "traceId": "ff62a8b040f340bda5d830223def1d81", + "itemId": matches_any(), + "itemType": "TRACE_ITEM_TYPE_METRIC", + "timestamp": time_within_delta(), + "attributes": { + "sentry.metric_name": {"stringValue": "browser.web_vital.lcp"}, + "sentry.sdk.name": {"stringValue": "raven-node"}, + "sentry._internal.cooccuring.name.browser.web_vital.lcp": { + "boolValue": True + }, + "sentry.metric_unit": {"stringValue": "millisecond"}, + "sentry.sdk.version": {"stringValue": "2.6.3"}, + "sentry.value": {"doubleValue": 400.0}, + "sentry.metric.source": {"stringValue": "span"}, + "sentry.metric_type": {"stringValue": "distribution"}, + "sentry.span_id": {"stringValue": "bd429c44b67a3eb1"}, + "sentry.payload_size_bytes": {"intValue": "180"}, + "sentry._internal.cooccuring.type.distribution": {"boolValue": True}, + "sentry.timestamp_precise": { + "intValue": time_within_delta(expect_resolution="ns") + }, + "sentry.platform": {"stringValue": "node"}, + "sentry._internal.cooccuring.unit.millisecond": {"boolValue": True}, + "sentry.transaction": {"stringValue": "my_transaction"}, + "sentry.environment": {"stringValue": "production"}, + }, + "clientSampleRate": 1.0, + "serverSampleRate": 1.0, + "retentionDays": 90, + "received": time_within_delta(), + "downsampledRetentionDays": 90, + }, + { + "organizationId": "1", + "projectId": "42", + "traceId": "ff62a8b040f340bda5d830223def1d81", + "itemId": matches_any(), + "itemType": "TRACE_ITEM_TYPE_METRIC", + "timestamp": time_within_delta(), + "attributes": { + "sentry.metric_name": {"stringValue": "browser.web_vital.ttfb"}, + "sentry.sdk.name": {"stringValue": "raven-node"}, + "sentry._internal.cooccuring.name.browser.web_vital.ttfb": { + "boolValue": True + }, + "sentry.metric_unit": {"stringValue": "millisecond"}, + "sentry.sdk.version": {"stringValue": "2.6.3"}, + "sentry.value": {"doubleValue": 500.0}, + "sentry.metric.source": {"stringValue": "span"}, + "sentry._internal.cooccuring.type.distribution": {"boolValue": True}, + "sentry.span_id": {"stringValue": "bd429c44b67a3eb1"}, + "sentry.payload_size_bytes": {"intValue": "181"}, + "sentry.platform": {"stringValue": "node"}, + "sentry.metric_type": {"stringValue": "distribution"}, + "sentry.timestamp_precise": { + "intValue": time_within_delta(expect_resolution="ns") + }, + "sentry._internal.cooccuring.unit.millisecond": {"boolValue": True}, + "sentry.transaction": {"stringValue": "my_transaction"}, + "sentry.environment": {"stringValue": "production"}, + }, + "clientSampleRate": 1.0, + "serverSampleRate": 1.0, + "retentionDays": 90, + "received": time_within_delta(), + "downsampledRetentionDays": 90, + }, + ] + + items = items_consumer.get_items() + items.sort(key=lambda item: item["attributes"]["sentry.metric_name"]["stringValue"]) + + assert expected == items + + +def test_v1_spans(mini_sentry, relay_with_processing, items_consumer, spans_consumer): + + spans_consumer = spans_consumer() + items_consumer = items_consumer() + relay = relay_with_processing() + + project_id = 42 + project_config = mini_sentry.add_full_project_config(project_id) + project_config["config"]["performanceScore"] = { + "profiles": [ + { + "name": "Desktop", + "scoreComponents": [ + {"measurement": "fcp", "weight": 0.15, "p10": 900, "p50": 1600}, + {"measurement": "lcp", "weight": 0.30, "p10": 1200, "p50": 2400}, + {"measurement": "cls", "weight": 0.25, "p10": 0.1, "p50": 0.25}, + {"measurement": "ttfb", "weight": 0.30, "p10": 0.2, "p50": 0.4}, + ], + "condition": { + "op": "eq", + "name": "event.contexts.browser.name", + "value": "Firefox", + }, + }, + { + "name": "Desktop INP", + "scoreComponents": [ + {"measurement": "inp", "weight": 1.0, "p10": 200, "p50": 400}, + ], + "condition": { + "op": "eq", + "name": "event.contexts.browser.name", + "value": "Firefox", + }, + }, + ], + } + project_config["config"]["txNameRules"] = [ + { + "pattern": "**/interaction/*/**", + "expiry": "3022-11-30T00:00:00.000000Z", + "redaction": {"method": "replace", "substitution": "*"}, + } + ] + duration = timedelta(milliseconds=500) end = datetime.now(timezone.utc) - timedelta(seconds=1) start = end - duration @@ -52,7 +385,7 @@ def test_v1_transaction( payload=PayloadRef( bytes=json.dumps( { - "op": "pageload", + "op": "ui.interaction.click", "span_id": "bd429c44b67a3eb1", "segment_id": "bd429c44b67a3eb1", "start_timestamp": start.timestamp(), @@ -76,56 +409,266 @@ def test_v1_transaction( payload=PayloadRef( bytes=json.dumps( { - "description": "", + "data": { + "transaction": "/page/with/click/interaction/jane/123", + "replay_id": "8477286c8e5148b386b71ade38374d58", + "user": "[email]", + }, + "profile_id": "3d9428087fda4ba0936788b70a7587d0", "op": "ui.interaction.click", - "parent_span_id": "bd429c44b67a3eb1", - "span_id": "a6f029fbe0e2389a", + "span_id": "cd429c44b67a3eb1", + "segment_id": "cd429c44b67a3eb1", "start_timestamp": start.timestamp(), "timestamp": end.timestamp() + 1, - "trace_id": "d3d20f000885466b8c8f947c9b92b8d3", - "origin": "auto.http.browser.inp", - "exclusive_time": 104, - "measurements": {"inp": {"value": 104, "unit": "millisecond"}}, - "segment_id": "bd429c44b67a3eb1", + "exclusive_time": 345.0, # The SDK knows that this span has a lower exclusive time + "trace_id": "ff62a8b040f340bda5d830223def1d81", + "measurements": { + "inp": {"value": 100}, + }, }, ).encode() ), ) ) - relay.send_envelope(project_id, envelope) - items = items_consumer.get_items() + spans = spans_consumer.get_spans(timeout=10.0, n=2) + + for span in spans: + span.pop("received", None) + + # endpoint might overtake envelope + spans.sort(key=lambda msg: msg["span_id"]) + + expected_scores = [ + { + "score.fcp": 0.14999972769539766, + "score.lcp": 0.29986141375718806, + "score.ratio.cls": 0.0, + "score.ratio.fcp": 0.9999981846359844, + "score.ratio.lcp": 0.9995380458572936, + "score.ratio.ttfb": 0.0, + "score.total": 0.4498611414525857, + "score.ttfb": 0.0, + "score.weight.cls": 0.25, + "score.weight.fcp": 0.15, + "score.weight.lcp": 0.3, + "score.weight.ttfb": 0.3, + "browser.web_vital.cls.value": 100.0, + "browser.web_vital.fcp.value": 200.0, + "browser.web_vital.lcp.value": 400.0, + "browser.web_vital.ttfb.value": 500.0, + "score.cls": 0.0, + }, + { + "browser.web_vital.inp.value": 100.0, + "score.inp": 0.9948129113413748, + "score.ratio.inp": 0.9948129113413748, + "score.total": 0.9948129113413748, + "score.weight.inp": 1.0, + }, + ] + + # Confirm that we're emitting spans that contain the expected scores + assert len(spans) == len(expected_scores) + for span, scores in zip(spans, expected_scores): + for key, score in scores.items(): + assert span["attributes"][key]["value"] == score + + expected_metrics = [ + { + "organizationId": "1", + "projectId": "42", + "traceId": "ff62a8b040f340bda5d830223def1d81", + "itemId": matches_any(), + "itemType": "TRACE_ITEM_TYPE_METRIC", + "timestamp": time_within_delta(), + "attributes": { + "sentry.metric_name": {"stringValue": "browser.web_vital.cls"}, + "sentry._internal.cooccuring.unit.none": {"boolValue": True}, + "sentry.metric_unit": {"stringValue": "none"}, + "score.cls": {"doubleValue": 0.0}, + "sentry._internal.cooccuring.name.browser.web_vital.cls": { + "boolValue": True + }, + "sentry.value": {"doubleValue": 100.0}, + "user_agent.original": { + "stringValue": "RelayIntegrationTests/1.0.0 Firefox/42.0" + }, + "score.weight.cls": {"doubleValue": 0.25}, + "sentry.metric.source": {"stringValue": "span"}, + "sentry._internal.cooccuring.type.distribution": {"boolValue": True}, + "sentry.span_id": {"stringValue": "bd429c44b67a3eb1"}, + "sentry.payload_size_bytes": {"intValue": "176"}, + "sentry.timestamp_precise": { + "intValue": time_within_delta(expect_resolution="ns") + }, + "sentry.metric_type": {"stringValue": "distribution"}, + "score.ratio.cls": {"doubleValue": 0.0}, + }, + "clientSampleRate": 1.0, + "serverSampleRate": 1.0, + "retentionDays": 90, + "received": time_within_delta(), + "downsampledRetentionDays": 90, + }, + { + "organizationId": "1", + "projectId": "42", + "traceId": "ff62a8b040f340bda5d830223def1d81", + "itemId": matches_any(), + "itemType": "TRACE_ITEM_TYPE_METRIC", + "timestamp": time_within_delta(), + "attributes": { + "sentry.metric_name": {"stringValue": "browser.web_vital.fcp"}, + "sentry._internal.cooccuring.name.browser.web_vital.fcp": { + "boolValue": True + }, + "score.ratio.fcp": {"doubleValue": 0.9999981846359844}, + "sentry.metric_unit": {"stringValue": "millisecond"}, + "score.fcp": {"doubleValue": 0.14999972769539766}, + "sentry.value": {"doubleValue": 200.0}, + "score.weight.fcp": {"doubleValue": 0.15}, + "user_agent.original": { + "stringValue": "RelayIntegrationTests/1.0.0 Firefox/42.0" + }, + "sentry.metric.source": {"stringValue": "span"}, + "sentry._internal.cooccuring.type.distribution": {"boolValue": True}, + "sentry.span_id": {"stringValue": "bd429c44b67a3eb1"}, + "sentry.payload_size_bytes": {"intValue": "176"}, + "sentry.timestamp_precise": { + "intValue": time_within_delta(expect_resolution="ns") + }, + "sentry.metric_type": {"stringValue": "distribution"}, + "sentry._internal.cooccuring.unit.millisecond": {"boolValue": True}, + }, + "clientSampleRate": 1.0, + "serverSampleRate": 1.0, + "retentionDays": 90, + "received": time_within_delta(), + "downsampledRetentionDays": 90, + }, + { + "organizationId": "1", + "projectId": "42", + "traceId": "ff62a8b040f340bda5d830223def1d81", + "itemId": matches_any(), + "itemType": "TRACE_ITEM_TYPE_METRIC", + "timestamp": time_within_delta(), + "attributes": { + "sentry.metric_name": {"stringValue": "browser.web_vital.inp"}, + "score.inp": {"doubleValue": 0.9948129113413748}, + "score.ratio.inp": {"doubleValue": 0.9948129113413748}, + "sentry.metric_unit": {"stringValue": "millisecond"}, + "sentry.value": {"doubleValue": 100.0}, + "user_agent.original": { + "stringValue": "RelayIntegrationTests/1.0.0 Firefox/42.0" + }, + "sentry._internal.cooccuring.name.browser.web_vital.inp": { + "boolValue": True + }, + "sentry.metric.source": {"stringValue": "span"}, + "sentry.timestamp_precise": { + "intValue": time_within_delta(expect_resolution="ns") + }, + "sentry.span_id": {"stringValue": "cd429c44b67a3eb1"}, + "sentry.payload_size_bytes": {"intValue": "226"}, + "sentry.metric_type": {"stringValue": "distribution"}, + "sentry._internal.cooccuring.type.distribution": {"boolValue": True}, + "score.weight.inp": {"doubleValue": 1.0}, + "sentry._internal.cooccuring.unit.millisecond": {"boolValue": True}, + "sentry.transaction": { + "stringValue": "/page/with/click/interaction/*/*" + }, + }, + "clientSampleRate": 1.0, + "serverSampleRate": 1.0, + "retentionDays": 90, + "received": time_within_delta(), + "downsampledRetentionDays": 90, + }, + { + "organizationId": "1", + "projectId": "42", + "traceId": "ff62a8b040f340bda5d830223def1d81", + "itemId": matches_any(), + "itemType": "TRACE_ITEM_TYPE_METRIC", + "timestamp": time_within_delta(), + "attributes": { + "score.ratio.lcp": {"doubleValue": 0.9995380458572936}, + "sentry.metric_name": {"stringValue": "browser.web_vital.lcp"}, + "sentry._internal.cooccuring.name.browser.web_vital.lcp": { + "boolValue": True + }, + "sentry.metric_unit": {"stringValue": "millisecond"}, + "score.weight.lcp": {"doubleValue": 0.3}, + "user_agent.original": { + "stringValue": "RelayIntegrationTests/1.0.0 Firefox/42.0" + }, + "sentry.value": {"doubleValue": 400.0}, + "score.lcp": {"doubleValue": 0.29986141375718806}, + "sentry.metric.source": {"stringValue": "span"}, + "sentry.timestamp_precise": { + "intValue": time_within_delta(expect_resolution="ns") + }, + "sentry.span_id": {"stringValue": "bd429c44b67a3eb1"}, + "sentry.payload_size_bytes": {"intValue": "176"}, + "sentry._internal.cooccuring.type.distribution": {"boolValue": True}, + "sentry.metric_type": {"stringValue": "distribution"}, + "sentry._internal.cooccuring.unit.millisecond": {"boolValue": True}, + }, + "clientSampleRate": 1.0, + "serverSampleRate": 1.0, + "retentionDays": 90, + "received": time_within_delta(), + "downsampledRetentionDays": 90, + }, + { + "organizationId": "1", + "projectId": "42", + "traceId": "ff62a8b040f340bda5d830223def1d81", + "itemId": matches_any(), + "itemType": "TRACE_ITEM_TYPE_METRIC", + "timestamp": time_within_delta(), + "attributes": { + "sentry.metric_name": {"stringValue": "browser.web_vital.ttfb"}, + "sentry._internal.cooccuring.name.browser.web_vital.ttfb": { + "boolValue": True + }, + "score.ttfb": {"doubleValue": 0.0}, + "sentry.metric_unit": {"stringValue": "millisecond"}, + "score.weight.ttfb": {"doubleValue": 0.3}, + "score.ratio.ttfb": {"doubleValue": 0.0}, + "sentry.value": {"doubleValue": 500.0}, + "user_agent.original": { + "stringValue": "RelayIntegrationTests/1.0.0 Firefox/42.0" + }, + "sentry.metric.source": {"stringValue": "span"}, + "sentry.timestamp_precise": { + "intValue": time_within_delta(expect_resolution="ns") + }, + "sentry.span_id": {"stringValue": "bd429c44b67a3eb1"}, + "sentry.payload_size_bytes": {"intValue": "180"}, + "sentry._internal.cooccuring.type.distribution": {"boolValue": True}, + "sentry.metric_type": {"stringValue": "distribution"}, + "sentry._internal.cooccuring.unit.millisecond": {"boolValue": True}, + }, + "clientSampleRate": 1.0, + "serverSampleRate": 1.0, + "retentionDays": 90, + "received": time_within_delta(), + "downsampledRetentionDays": 90, + }, + ] + expected_metrics = [dict(sorted(item.items())) for item in expected_metrics] + + items = [dict(sorted(item.items())) for item in items_consumer.get_items()] items.sort(key=lambda item: item["attributes"]["sentry.metric_name"]["stringValue"]) - assert len(items) == 5 - assert ( - items[0]["attributes"]["sentry.metric_name"]["stringValue"] - == "browser.web_vital.cls.value" - ) - assert ( - items[1]["attributes"]["sentry.metric_name"]["stringValue"] - == "browser.web_vital.fcp.value" - ) - assert ( - items[2]["attributes"]["sentry.metric_name"]["stringValue"] - == "browser.web_vital.inp.value" - ) - assert ( - items[3]["attributes"]["sentry.metric_name"]["stringValue"] - == "browser.web_vital.lcp.value" - ) - assert ( - items[4]["attributes"]["sentry.metric_name"]["stringValue"] - == "browser.web_vital.ttfb.value" - ) + assert expected_metrics == items -def test_v1_standalone_span(mini_sentry, relay_with_processing, items_consumer): - """ - Test verifies LCP spans processed via the SpanV2 and legacy standalone processing pipeline are equally processed. - Some differences between the pipelines exist and are noted in the test. - """ +def test_v1_standalone_span(mini_sentry, relay_with_processing, items_consumer): items_consumer = items_consumer() project_id = 42 @@ -179,7 +722,7 @@ def test_v1_standalone_span(mini_sentry, relay_with_processing, items_consumer): assert len(items) == 1 assert ( items[0]["attributes"]["sentry.metric_name"]["stringValue"] - == "browser.web_vital.lcp.value" + == "browser.web_vital.lcp" ) @@ -193,9 +736,57 @@ def test_v2( project_id = 42 project_config = mini_sentry.add_full_project_config(project_id) + project_config["config"]["performanceScore"] = { + "profiles": [ + { + "name": "Desktop", + "scoreComponents": [ + {"measurement": "fcp", "weight": 0.15, "p10": 900, "p50": 1600}, + {"measurement": "lcp", "weight": 0.30, "p10": 1200, "p50": 2400}, + {"measurement": "cls", "weight": 0.25, "p10": 0.1, "p50": 0.25}, + {"measurement": "ttfb", "weight": 0.30, "p10": 0.2, "p50": 0.4}, + ], + "condition": { + "op": "or", + "inner": [ + { + "op": "eq", + "name": "event.contexts.browser.name", + "value": "Firefox", + }, + { + "op": "eq", + "name": "span.attributes.browser.name.value", + "value": "Firefox", + }, + ], + }, + }, + { + "name": "Desktop INP", + "scoreComponents": [ + {"measurement": "inp", "weight": 1.0, "p10": 200, "p50": 400}, + ], + "condition": { + "op": "or", + "inner": [ + { + "op": "eq", + "name": "event.contexts.browser.name", + "value": "Firefox", + }, + { + "op": "eq", + "name": "span.attributes.browser.name.value", + "value": "Firefox", + }, + ], + }, + }, + ], + } ts = datetime.now(timezone.utc) - envelope = v2_envelope_with_spans( { "start_timestamp": ts.timestamp(), @@ -253,21 +844,21 @@ def test_v2( assert len(items) == 5 assert ( items[0]["attributes"]["sentry.metric_name"]["stringValue"] - == "browser.web_vital.cls.value" + == "browser.web_vital.cls" ) assert ( items[1]["attributes"]["sentry.metric_name"]["stringValue"] - == "browser.web_vital.fcp.value" + == "browser.web_vital.fcp" ) assert ( items[2]["attributes"]["sentry.metric_name"]["stringValue"] - == "browser.web_vital.inp.value" + == "browser.web_vital.inp" ) assert ( items[3]["attributes"]["sentry.metric_name"]["stringValue"] - == "browser.web_vital.lcp.value" + == "browser.web_vital.lcp" ) assert ( items[4]["attributes"]["sentry.metric_name"]["stringValue"] - == "browser.web_vital.ttfb.value" + == "browser.web_vital.ttfb" )