Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 0 additions & 20 deletions relay-event-normalization/src/eap/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -800,26 +800,6 @@ fn normalize_http_attributes(
}
}

/// Makes sure web vital spans are not identified with segments.
///
/// This was ported from the legacy pipeline for behavior parity.
/// At some point in the future, web vital spans will become metrics,
/// and this will become academic.
pub fn normalize_web_vital_span_segment(span: &mut SpanV2) {
let Some(attributes) = span.attributes.value_mut() else {
return;
};

if let Some(op) = attributes.get_value(SENTRY__OP)
&& let Some(op_name) = op.as_str()
&& (op_name.starts_with("ui.interaction.") || op_name.starts_with("ui.webvital."))
{
span.is_segment = None.into();
span.parent_span_id = None.into();
attributes.remove(SENTRY__SEGMENT__ID);
}
}

/// Double writes sentry conventions attributes into legacy attributes.
///
/// This achieves backwards compatibility as it allows products to continue using legacy attributes
Expand Down
30 changes: 2 additions & 28 deletions relay-server/src/processing/legacy_spans/normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use relay_event_normalization::{
use relay_event_schema::processor::{ProcessingState, process_value};
use relay_event_schema::protocol::{BrowserContext, EventId, IpAddr, Span, SpanData};
use relay_metrics::UnixTimestamp;
use relay_protocol::{Annotated, Empty, Value};
use relay_protocol::{Annotated, Value};
use relay_sampling::DynamicSamplingContext;

/// Config needed to normalize a standalone span.
Expand Down Expand Up @@ -62,33 +62,7 @@ pub struct NormalizeSpanConfig<'a> {

fn set_segment_attributes(span: &mut Annotated<Span>) {
let Some(span) = span.value_mut() else { return };

// Identify INP spans or other WebVital spans and make sure they are not wrapped in a segment.
if let Some(span_op) = span.op.value()
&& (span_op.starts_with("ui.interaction.") || span_op.starts_with("ui.webvital."))
{
span.is_segment = None.into();
span.parent_span_id = None.into();
span.segment_id = None.into();
return;
}

let Some(span_id) = span.span_id.value() else {
return;
};

if let Some(segment_id) = span.segment_id.value() {
// The span is a segment if and only if the segment_id matches the span_id.
span.is_segment = (segment_id == span_id).into();
} else if span.parent_span_id.is_empty() {
// If the span has no parent, it is automatically a segment:
span.is_segment = true.into();
}

// If the span is a segment, always set the segment_id to the current span_id:
if span.is_segment.value() == Some(&true) {
span.segment_id = span.span_id.clone();
}
relay_spans::set_segment_attributes(span);
}

/// Normalizes a standalone span.
Expand Down
8 changes: 6 additions & 2 deletions relay-server/src/processing/spans/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,12 @@ fn expand_legacy_span(item: &Item) -> Result<WithHeader<SpanV2>> {
// We can't enable `infer_name` here:
// These spans haven't been PII scrubbed yet, so inferring a name would risk
// leaking PII.
.map_value(|span| relay_spans::span_v1_to_span_v2(span, false));
.map_value(|mut span| {
// Set some segment-related attributes before converting to V2.
// This exists for parity with the legacy standalone span pipeline.
relay_spans::set_segment_attributes(&mut span);
relay_spans::span_v1_to_span_v2(span, false)
});
Comment thread
cursor[bot] marked this conversation as resolved.
Comment on lines +171 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: For legacy web vital spans, sentry.segment.id from the V1 data field can leak into V2 attributes because the explicit removal logic was removed.
Severity: LOW

Suggested Fix

Explicitly remove the SENTRY__SEGMENT__ID attribute from the V2 attributes of web vital spans after the initial conversion from the V1 data field. This ensures the attribute is stripped regardless of its source, restoring the intended behavior.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: relay-server/src/processing/spans/process.rs#L171-L175

Potential issue: The logic for converting legacy (V1) web vital spans to V2 format no
longer explicitly removes the `sentry.segment.id` attribute. If a V1 web vital span
contains `sentry.segment.id` within its `data` payload, this attribute is converted and
added to the V2 attributes. The new logic attempts to clear the top-level `segment_id`,
but this does not affect the attribute already present from the `data` field. As a
result, the `sentry.segment.id` attribute incorrectly remains on the V2 span, which
contradicts the intended behavior of stripping it from web vital spans.

Also affects:

  • relay-spans/src/segment.rs:1~37


Ok(WithHeader::new(span))
}
Expand Down Expand Up @@ -234,7 +239,6 @@ fn normalize_span(
// because category derivation depends on having the sentry.op attribute
// available.
eap::normalize_sentry_op(&mut span.attributes);
eap::normalize_web_vital_span_segment(span);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Native V2 web vital segments

Medium Severity

Removing normalize_web_vital_span_segment from shared V2 normalize_span leaves native Span V2 ingest (container items and similar paths) with no step that clears segment fields for ui.webvital.* / ui.interaction.* ops, because set_segment_attributes only runs when expanding legacy V1 JSON.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3e2bd9a. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is intentional. In V2 we simply require that SDKs set the is_segment field correctly.

eap::normalize_span_category(&mut span.attributes);
Comment thread
sentry[bot] marked this conversation as resolved.
eap::normalize_received(&mut span.attributes, meta.received_at());
eap::normalize_client_address(&mut span.attributes, meta.client_addr());
Expand Down
2 changes: 2 additions & 0 deletions relay-spans/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub use crate::description::derive_description_for_v2_span;
pub use crate::name::name_for_attributes;
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::segment::set_segment_attributes;
pub use crate::v1_to_v2::span_v1_to_span_v2;

pub use opentelemetry_proto::tonic::trace::v1 as otel_trace;
Expand All @@ -18,4 +19,5 @@ mod description;
mod name;
mod op;
mod otel_to_sentry_v2;
mod segment;
mod v1_to_v2;
37 changes: 37 additions & 0 deletions relay-spans/src/segment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use relay_event_schema::protocol::Span;
use relay_protocol::Empty;

/// Normalizes various segment-related fields on a span:
///
/// * Web vital spans have `is_segment`, `parent_span_id`, and `segment_id` erased.
/// * Spans whose `span_id` is equal to the `segment_id` or which don't have a `parent_span_id` have
/// `is_segment` set to `true`.
/// * Finally, segment spans have the `segment_id` set to their own `span_id`.
pub fn set_segment_attributes(span: &mut Span) {
// Identify INP spans or other WebVital spans and make sure they are not wrapped in a segment.
if let Some(span_op) = span.op.value()
&& (span_op.starts_with("ui.interaction.") || span_op.starts_with("ui.webvital."))
{
span.is_segment = None.into();
span.parent_span_id = None.into();
span.segment_id = None.into();
return;
}

let Some(span_id) = span.span_id.value() else {
return;
};

if let Some(segment_id) = span.segment_id.value() {
// The span is a segment if and only if the segment_id matches the span_id.
span.is_segment = (segment_id == span_id).into();
} else if span.parent_span_id.is_empty() {
// If the span has no parent, it is automatically a segment:
span.is_segment = true.into();
}

// If the span is a segment, always set the segment_id to the current span_id:
if span.is_segment.value() == Some(&true) {
span.segment_id = span.span_id.clone();
}
Comment on lines +25 to +36

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These normalizations would previously happen in the legacy standalone span pipeline, but not the V2 one.

}
135 changes: 135 additions & 0 deletions tests/integration/test_spans_standalone.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from datetime import datetime, timezone

from sentry_sdk.envelope import Envelope, Item, PayloadRef
from sentry_relay.consts import DataCategory

from .asserts import time_within_delta, time_within

from .test_dynamic_sampling import add_sampling_config

import pytest

# Some profiles from Sentry
Expand Down Expand Up @@ -1192,5 +1195,137 @@ def test_name_inference(
}


@pytest.mark.parametrize("sampled", [False, True])
def test_inp_span_is_segment_ds(
mini_sentry,
relay,
relay_with_processing,
metrics_consumer,
outcomes_consumer,
sampled,
):
"""
Verifies that the `is_segment` tag on metrics is correctly unset
regardless of whether the span is sampled.

This is specifically related to https://github.com/getsentry/relay/pull/6042.
and https://github.com/getsentry/relay/pull/6126.
If we ever decide to get rid of the functionality introduced in those PRs, this
test becomes obsolete.
"""
metrics_consumer = metrics_consumer()
outcomes_consumer = outcomes_consumer()

project_id = 42
project_config = mini_sentry.add_full_project_config(project_id)
project_config["config"]["performanceScore"] = {
"profiles": performance_score_profiles
}
project_config["config"].setdefault("features", []).append(
"organizations:relay-generate-billing-outcome"
)
project_config["config"].setdefault("features", []).append(
"projects:span-v2-experimental-processing"
)

add_sampling_config(
project_config, sample_rate=1.0 if sampled else 0.0, rule_type="project"
)

relay = relay_with_processing()

ts = datetime.now(timezone.utc)

envelope = envelope_with_spans(
{
"data": {
"sentry.op": "ui.interaction.click",
"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": 104,
},
"description": "<unknown>",
"op": "ui.interaction.click",
"parent_span_id": "8a6626cc9bdd5d9b",
"span_id": "a6f029fbe0e2389a",
"start_timestamp": ts.timestamp() - 0.5,
"timestamp": ts.timestamp(),
"trace_id": "d3d20f000885466b8c8f947c9b92b8d3",
"origin": "auto.http.browser.inp",
"exclusive_time": 104,
"measurements": {"inp": {"value": 104, "unit": "millisecond"}},
"segment_id": "8a6626cc9bdd5d9b",
"is_segment": True,
},
trace_info={
"trace_id": "d3d20f000885466b8c8f947c9b92b8d3",
"public_key": project_config["publicKeys"][0]["publicKey"],
"transaction": "/insights/projects/",
},
)

relay.send_envelope(project_id, envelope)

assert metrics_consumer.get_metrics(with_headers=False) == [
{
"org_id": 1,
"project_id": 42,
"name": "c:spans/count_per_root_project@none",
"type": "c",
"value": 1.0,
"timestamp": time_within_delta(ts),
"tags": {
"decision": "keep" if sampled else "drop",
"is_segment": "false",
"target_project_id": "42",
"transaction": "/insights/projects/",
},
"retention_days": 90,
"received_at": time_within(ts, precision="s"),
},
{
"org_id": 1,
"project_id": 42,
"name": "c:spans/usage@none",
"type": "c",
"value": 1.0,
"timestamp": time_within_delta(ts),
"tags": {"is_segment": "false", "billing_outcome_emitted": "true"},
"retention_days": 90,
"received_at": time_within(ts, precision="s"),
},
]

expected_outcomes = [
{
"category": DataCategory.SPAN.value,
"key_id": 123,
"org_id": 1,
"outcome": 0,
"project_id": 42,
"quantity": 1,
}
]

if not sampled:
expected_outcomes.append(
{
"category": DataCategory.SPAN_INDEXED.value,
"key_id": 123,
"org_id": 1,
"outcome": 1,
"project_id": 42,
"quantity": 1,
"reason": "Sampled:0",
}
)

assert outcomes_consumer.get_aggregated_outcomes() == expected_outcomes


def _if_dict(cond, then):
return then if cond else {}
1 change: 1 addition & 0 deletions tests/integration/test_spansv2.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ def test_spansv2_ds_drop(mini_sentry, relay, span, rule_type):
"timestamp": ts.timestamp() + 0.5,
"trace_id": "5b8efff798038103d269b633813fc60c",
"span_id": "eee19b7ec3c1b176",
"parent_span_id": "eee19b7ec3c1b177",
"op": "some op",
"description": "some description",
"data": {"foo": "bar"},
Expand Down
Loading