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 0a9897e3cc8..9867ab6b224 100644 --- a/relay-server/src/processing/trace_metrics/mod.rs +++ b/relay-server/src/processing/trace_metrics/mod.rs @@ -1,18 +1,23 @@ -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; mod filter; mod process; @@ -21,6 +26,41 @@ 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..60cadb8e8d7 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, }; @@ -69,7 +70,6 @@ impl Forward for TransactionOutput { } TransactionOutput::Indexed { spans, transaction } => (spans, transaction), }; - let performance_issues_spans = ctx .project_info .has_feature(Feature::PerformanceIssuesSpans); @@ -88,6 +88,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 b35cb8bdbba..1266821da59 100644 --- a/relay-server/src/services/store.rs +++ b/relay-server/src/services/store.rs @@ -35,6 +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::service::ServiceError; use crate::services::global_config::GlobalConfigHandle; use crate::services::objectstore::ObjectstoreKey; 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..f629b339c10 --- /dev/null +++ b/relay-spans/src/web_vitals.rs @@ -0,0 +1,342 @@ +use relay_event_schema::protocol::{Attributes, SpanV2, TraceMetric}; +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", + "ui.webvital.cls", + "ui.interaction.click", + "ui.interaction.hover", + "ui.interaction.drag", + "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", + unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), + attribute_keys: &[ + "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", + unit: MetricUnit::None, + attribute_keys: &["score.cls", "score.weight.cls", "score.ratio.cls"], + }, + WebVital { + attribute_value: "browser.web_vital.inp.value", + name: "browser.web_vital.inp", + unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), + 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", + unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), + attribute_keys: &["score.fcp", "score.weight.fcp", "score.ratio.fcp"], + }, + WebVital { + attribute_value: "browser.web_vital.ttfb.value", + name: "browser.web_vital.ttfb", + unit: MetricUnit::Duration(relay_metrics::DurationUnit::MilliSecond), + attribute_keys: &[ + "browser.web_vital.ttfb.request_time", + "score.ttfb", + "score.ratio.ttfb", + "score.weight.ttfb", + ], + }, +]; + +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(); + + // 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(), + 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) + } +} + +#[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 new file mode 100644 index 00000000000..7e33281150b --- /dev/null +++ b/tests/integration/test_webvital_metrics.py @@ -0,0 +1,864 @@ +import json +from datetime import datetime, timedelta, timezone + +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() + 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 + 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 + + envelope = Envelope() + envelope.add_item( + Item( + type="span", + payload=PayloadRef( + bytes=json.dumps( + { + "op": "ui.interaction.click", + "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( + { + "data": { + "transaction": "/page/with/click/interaction/jane/123", + "replay_id": "8477286c8e5148b386b71ade38374d58", + "user": "[email]", + }, + "profile_id": "3d9428087fda4ba0936788b70a7587d0", + "op": "ui.interaction.click", + "span_id": "cd429c44b67a3eb1", + "segment_id": "cd429c44b67a3eb1", + "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": { + "inp": {"value": 100}, + }, + }, + ).encode() + ), + ) + ) + relay.send_envelope(project_id, envelope) + + 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 expected_metrics == items + + +def test_v1_standalone_span(mini_sentry, relay_with_processing, items_consumer): + 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" + ) + + +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) + 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(), + "end_timestamp": ts.timestamp() + 0.5, + "trace_id": "5b8efff798038103d269b633813fc60c", + "span_id": "eee19b7ec3c1b175", + "is_segment": True, + "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"}, + "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": "ui.interaction.drag", + "status": "ok", + "attributes": { + "sentry.op": {"value": "ui.interaction.drag", "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" + ) + assert ( + items[1]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.fcp" + ) + assert ( + items[2]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.inp" + ) + assert ( + items[3]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.lcp" + ) + assert ( + items[4]["attributes"]["sentry.metric_name"]["stringValue"] + == "browser.web_vital.ttfb" + )