diff --git a/pyproject.toml b/pyproject.toml index 565ff1b31d76fd..9067970274ba26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ dependencies = [ # [end] jsonschema format validators "sentry-arroyo>=2.25.5", "sentry-forked-email-reply-parser>=0.5.12.post1", - "sentry-kafka-schemas>=2.1.3", + "sentry-kafka-schemas>=2.1.6", "sentry-ophio>=1.1.3", "sentry-protos>=0.4.0", "sentry-redis-tools>=0.5.0", diff --git a/src/sentry/insights/__init__.py b/src/sentry/insights/__init__.py index 5c5749e4a1821f..634fe190cf1c43 100644 --- a/src/sentry/insights/__init__.py +++ b/src/sentry/insights/__init__.py @@ -26,15 +26,13 @@ def from_span_v1(cls, span: dict[str, Any]) -> "FilterSpan": ) @classmethod - def from_span_data(cls, data: dict[str, Any]) -> "FilterSpan": - """Get relevant fields from `span.data`. - - This will later be replaced by `from_span_attributes` or `from_span_v2`.""" + def from_span_attributes(cls, attributes: dict[str, Any]) -> "FilterSpan": + """Get relevant fields from `span.attributes`.""" return cls( - op=data.get("sentry.op"), - category=data.get("sentry.category"), - description=data.get("sentry.description"), - transaction_op=data.get("sentry.transaction_op"), + op=(attributes.get("sentry.op") or {}).get("value"), + category=(attributes.get("sentry.category") or {}).get("value"), + description=(attributes.get("sentry.description") or {}).get("value"), + transaction_op=(attributes.get("sentry.transaction_op") or {}).get("value"), ) diff --git a/src/sentry/spans/buffer.py b/src/sentry/spans/buffer.py index a8bebf7d14c0de..d6048b1853fe92 100644 --- a/src/sentry/spans/buffer.py +++ b/src/sentry/spans/buffer.py @@ -77,6 +77,7 @@ from sentry import options from sentry.processing.backpressure.memory import ServiceMemory, iter_cluster_memory_usage +from sentry.spans.consumers.process_segments.types import attribute_value from sentry.utils import metrics, redis # SegmentKey is an internal identifier used by the redis buffer that is also @@ -129,7 +130,7 @@ class Span(NamedTuple): segment_id: str | None project_id: int payload: bytes - end_timestamp_precise: float + end_timestamp: float is_segment_span: bool = False def effective_parent_id(self): @@ -339,7 +340,7 @@ def _group_by_parent(self, spans: Sequence[Span]) -> dict[tuple[str, str], list[ def _prepare_payloads(self, spans: list[Span]) -> dict[str | bytes, float]: if self._zstd_compressor is None: - return {span.payload: span.end_timestamp_precise for span in spans} + return {span.payload: span.end_timestamp for span in spans} combined = b"\x00".join(span.payload for span in spans) original_size = len(combined) @@ -354,7 +355,7 @@ def _prepare_payloads(self, spans: list[Span]) -> dict[str | bytes, float]: metrics.timing("spans.buffer.compression.compressed_size", compressed_size) metrics.timing("spans.buffer.compression.compression_ratio", compression_ratio) - min_timestamp = min(span.end_timestamp_precise for span in spans) + min_timestamp = min(span.end_timestamp for span in spans) return {compressed: min_timestamp} def _decompress_batch(self, compressed_data: bytes) -> list[bytes]: @@ -428,17 +429,23 @@ def flush_segments(self, now: int) -> dict[SegmentKey, FlushedSegment]: has_root_span = False metrics.timing("spans.buffer.flush_segments.num_spans_per_segment", len(segment)) for payload in segment: - val = orjson.loads(payload) - - if not val.get("segment_id"): - val["segment_id"] = segment_span_id - - is_segment = segment_span_id == val["span_id"] - val["is_segment"] = is_segment + span = orjson.loads(payload) + + if not attribute_value(span, "sentry.segment.id"): + span.setdefault("attributes", {})["sentry.segment.id"] = { + "type": "string", + "value": segment_span_id, + } + + is_segment = segment_span_id == span["span_id"] + span.setdefault("attributes", {})["sentry.is_segment"] = { + "type": "boolean", + "value": is_segment, + } if is_segment: has_root_span = True - output_spans.append(OutputSpan(payload=val)) + output_spans.append(OutputSpan(payload=span)) metrics.incr( "spans.buffer.flush_segments.num_segments_per_shard", tags={"shard_i": shard} diff --git a/src/sentry/spans/consumers/process/factory.py b/src/sentry/spans/consumers/process/factory.py index 620db57b4a4dc6..7e9f7311f1cf18 100644 --- a/src/sentry/spans/consumers/process/factory.py +++ b/src/sentry/spans/consumers/process/factory.py @@ -18,6 +18,7 @@ from sentry import killswitches from sentry.spans.buffer import Span, SpansBuffer from sentry.spans.consumers.process.flusher import SpanFlusher +from sentry.spans.consumers.process_segments.types import attribute_value from sentry.utils import metrics from sentry.utils.arroyo import MultiprocessingPool, SetJoinTimeout, run_task_with_multiprocessing @@ -182,10 +183,10 @@ def process_batch( trace_id=val["trace_id"], span_id=val["span_id"], parent_span_id=val.get("parent_span_id"), - segment_id=val.get("segment_id"), + segment_id=cast(str | None, attribute_value(val, "sentry.segment.id")), project_id=val["project_id"], payload=payload.value, - end_timestamp_precise=val["end_timestamp_precise"], + end_timestamp=val["end_timestamp"], is_segment_span=bool(val.get("parent_span_id") is None or val.get("is_remote")), ) spans.append(span) diff --git a/src/sentry/spans/consumers/process_segments/convert.py b/src/sentry/spans/consumers/process_segments/convert.py index e308539e5aa9fa..9d4b94e960c30f 100644 --- a/src/sentry/spans/consumers/process_segments/convert.py +++ b/src/sentry/spans/consumers/process_segments/convert.py @@ -1,4 +1,3 @@ -from collections.abc import MutableMapping from typing import Any, cast import orjson @@ -13,58 +12,69 @@ I64_MAX = 2**63 - 1 FIELD_TO_ATTRIBUTE = { - "description": "sentry.raw_description", - "duration_ms": "sentry.duration_ms", - "is_segment": "sentry.is_segment", - "exclusive_time_ms": "sentry.exclusive_time_ms", - "start_timestamp_precise": "sentry.start_timestamp_precise", - "end_timestamp_precise": "sentry.end_timestamp_precise", + "end_timestamp": "sentry.end_timestamp_precise", + "event_id": "sentry.event_id", + "hash": "sentry.hash", "is_remote": "sentry.is_remote", + "kind": "sentry.kind", + "name": "sentry.name", "parent_span_id": "sentry.parent_span_id", - "profile_id": "sentry.profile_id", - "segment_id": "sentry.segment_id", "received": "sentry.received", - "origin": "sentry.origin", - "kind": "sentry.kind", - "hash": "sentry.hash", - "event_id": "sentry.event_id", + "start_timestamp": "sentry.start_timestamp_precise", +} + +RENAME_ATTRIBUTES = { + "sentry.description": "sentry.raw_description", + "sentry.segment.id": "sentry.segment_id", } def convert_span_to_item(span: CompatibleSpan) -> TraceItem: - attributes: MutableMapping[str, AnyValue] = {} # TODO + attributes: dict[str, AnyValue] = {} client_sample_rate = 1.0 server_sample_rate = 1.0 - # This key is ambiguous. sentry-conventions and relay interpret it as "raw description", - # sentry interprets it as normalized_description. - # See https://github.com/getsentry/sentry/blob/7f2ccd1d03e8845a833fe1ee6784bce0c7f0b935/src/sentry/search/eap/spans/attributes.py#L596. - # Delete it and relay on top-level `description` for now. - (span.get("data") or {}).pop("sentry.description", None) - - for k, v in (span.get("data") or {}).items(): - if v is not None: - try: - attributes[k] = _anyvalue(v) - except Exception: - sentry_sdk.capture_exception() - else: - if k == "sentry.client_sample_rate": - try: - client_sample_rate = float(v) - except ValueError: - pass - elif k == "sentry.server_sample_rate": - try: - server_sample_rate = float(v) - except ValueError: - pass + for k, attribute in (span.get("attributes") or {}).items(): + if attribute is None: + continue + if (value := attribute.get("value")) is None: + continue + try: + # NOTE: This ignores the `type` field of the attribute itself + attributes[k] = _anyvalue(value) + except Exception: + sentry_sdk.capture_exception() + else: + if k == "sentry.client_sample_rate": + try: + client_sample_rate = float(value) # type:ignore[arg-type] + except ValueError: + pass + elif k == "sentry.server_sample_rate": + try: + server_sample_rate = float(value) # type:ignore[arg-type] + except ValueError: + pass for field_name, attribute_name in FIELD_TO_ATTRIBUTE.items(): - v = span.get(field_name) - if v is not None: - attributes[attribute_name] = _anyvalue(v) + attribute = span.get(field_name) # type:ignore[assignment] + if attribute is not None: + attributes[attribute_name] = _anyvalue(attribute) + + # Rename some attributes from their sentry-conventions name to what the product currently expects. + # Eventually this should all be handled by deprecation policies in sentry-conventions. + for convention_name, eap_name in RENAME_ATTRIBUTES.items(): + if convention_name in attributes: + attributes[eap_name] = attributes.pop(convention_name) + + try: + # TODO: Move this to Relay + attributes["sentry.duration_ms"] = AnyValue( + int_value=int(1000 * (span["end_timestamp"] - span["start_timestamp"])) + ) + except Exception: + sentry_sdk.capture_exception() if links := span.get("links"): try: @@ -80,7 +90,7 @@ def convert_span_to_item(span: CompatibleSpan) -> TraceItem: trace_id=span["trace_id"], item_id=int(span["span_id"], 16).to_bytes(16, "little"), item_type=TraceItemType.TRACE_ITEM_TYPE_SPAN, - timestamp=_timestamp(span["start_timestamp_precise"]), + timestamp=_timestamp(span["start_timestamp"]), attributes=attributes, client_sample_rate=client_sample_rate, server_sample_rate=server_sample_rate, @@ -132,7 +142,10 @@ def _sanitize_span_link(link: SpanLink) -> SpanLink: # might be an intermediary state where there is a pre-existing dropped # attributes count. Respect that count, if it's present. It should always be # an integer. - dropped_attributes_count = attributes.get("sentry.dropped_attributes_count", 0) + try: + dropped_attributes_count = int(attributes["sentry.dropped_attributes_count"]["value"]) # type: ignore[arg-type,index] + except (KeyError, ValueError, TypeError): + dropped_attributes_count = 0 for key, value in attributes.items(): if key in ALLOWED_LINK_ATTRIBUTE_KEYS: @@ -141,7 +154,10 @@ def _sanitize_span_link(link: SpanLink) -> SpanLink: dropped_attributes_count += 1 if dropped_attributes_count > 0: - allowed_attributes["sentry.dropped_attributes_count"] = dropped_attributes_count + allowed_attributes["sentry.dropped_attributes_count"] = { + "type": "integer", + "value": dropped_attributes_count, + } # Only include the `attributes` key if the key was present in the original # link, don't create a an empty object, since there is a semantic difference diff --git a/src/sentry/spans/consumers/process_segments/enrichment.py b/src/sentry/spans/consumers/process_segments/enrichment.py index 91b358fc06f692..874a614f44ad1a 100644 --- a/src/sentry/spans/consumers/process_segments/enrichment.py +++ b/src/sentry/spans/consumers/process_segments/enrichment.py @@ -2,10 +2,9 @@ from collections.abc import Sequence from typing import Any -from sentry_kafka_schemas.schema_types.buffered_segments_v1 import SegmentSpan +from sentry_kafka_schemas.schema_types.ingest_spans_v1 import SpanEvent -from sentry.performance_issues.types import SentryTags as PerformanceIssuesSentryTags -from sentry.spans.consumers.process_segments.types import EnrichedSpan, get_span_op +from sentry.spans.consumers.process_segments.types import attribute_value, get_span_op # Keys of shared sentry attributes that are shared across all spans in a segment. This list # is taken from `extract_shared_tags` in Relay. @@ -45,7 +44,7 @@ DEFAULT_SPAN_OP = "default" -def _find_segment_span(spans: list[SegmentSpan]) -> SegmentSpan | None: +def _find_segment_span(spans: list[SpanEvent]) -> SpanEvent | None: """ Finds the segment in the span in the list that has ``is_segment`` set to ``True``. @@ -58,7 +57,7 @@ def _find_segment_span(spans: list[SegmentSpan]) -> SegmentSpan | None: # Iterate backwards since we usually expect the segment span to be at the end. for span in reversed(spans): - if span.get("is_segment"): + if attribute_value(span, "sentry.is_segment"): return span return None @@ -67,7 +66,7 @@ def _find_segment_span(spans: list[SegmentSpan]) -> SegmentSpan | None: class TreeEnricher: """Enriches spans with information from their parent, child and sibling spans.""" - def __init__(self, spans: list[SegmentSpan]) -> None: + def __init__(self, spans: list[SpanEvent]) -> None: self._segment_span = _find_segment_span(spans) self._ttid_ts = _timestamp_by_op(spans, "ui.load.initial_display") @@ -79,55 +78,52 @@ def __init__(self, spans: list[SegmentSpan]) -> None: interval = _span_interval(span) self._span_map.setdefault(parent_span_id, []).append(interval) - def _data(self, span: SegmentSpan) -> dict[str, Any]: - ret = {**span.get("data", {})} + def _attributes(self, span: SpanEvent) -> dict[str, Any]: + attributes: dict[str, Any] = {**(span.get("attributes") or {})} + + def get_value(key: str) -> Any: + attr: dict[str, Any] = attributes.get(key) or {} + return attr.get("value") + if self._segment_span is not None: # Assume that Relay has extracted the shared tags into `data` on the # root span. Once `sentry_tags` is removed, the logic from # `extract_shared_tags` should be moved here. - segment_fields = self._segment_span.get("data", {}) - shared_tags = {k: v for k, v in segment_fields.items() if k in SHARED_SENTRY_ATTRIBUTES} + segment_attrs = self._segment_span.get("attributes", {}) + shared_attrs = {k: v for k, v in segment_attrs.items() if k in SHARED_SENTRY_ATTRIBUTES} - is_mobile = segment_fields.get("sentry.mobile") == "true" + is_mobile = attribute_value(self._segment_span, "sentry.mobile") == "true" mobile_start_type = _get_mobile_start_type(self._segment_span) if is_mobile: # NOTE: Like in Relay's implementation, shared tags are added at the # very end. This does not have access to the shared tag value. We # keep behavior consistent, although this should be revisited. - if ret.get("sentry.thread.name") == MOBILE_MAIN_THREAD_NAME: - ret["sentry.main_thread"] = "true" - if not ret.get("sentry.app_start_type") and mobile_start_type: - ret["sentry.app_start_type"] = mobile_start_type - - if self._ttid_ts is not None and span["end_timestamp_precise"] <= self._ttid_ts: - ret["sentry.ttid"] = "ttid" - if self._ttfd_ts is not None and span["end_timestamp_precise"] <= self._ttfd_ts: - ret["sentry.ttfd"] = "ttfd" - - for key, value in shared_tags.items(): - if ret.get(key) is None: - ret[key] = value - - return ret + if get_value("sentry.thread.name") == MOBILE_MAIN_THREAD_NAME: + attributes["sentry.main_thread"] = {"type": "string", "value": "true"} + if not get_value("sentry.app_start_type") and mobile_start_type: + attributes["sentry.app_start_type"] = { + "type": "string", + "value": mobile_start_type, + } + + if self._ttid_ts is not None and span["end_timestamp"] <= self._ttid_ts: + attributes["sentry.ttid"] = {"type": "string", "value": "ttid"} + if self._ttfd_ts is not None and span["end_timestamp"] <= self._ttfd_ts: + attributes["sentry.ttfd"] = {"type": "string", "value": "ttfd"} + + for key, value in shared_attrs.items(): + if attributes.get(key) is None: + attributes[key] = value + + attributes["sentry.exclusive_time_ms"] = { + "type": "double", + "value": self._exclusive_time(span), + } - def _sentry_tags(self, data: dict[str, Any]) -> dict[str, str]: - """Backfill sentry tags used in performance issue detection. + return attributes - Once performance issue detection is only called from process_segments, - (not from event_manager), the performance issues code can be refactored to access - span attributes instead of sentry_tags. - """ - sentry_tags = {} - for tag_key in PerformanceIssuesSentryTags.__mutable_keys__: - data_key = ( - "sentry.normalized_description" if tag_key == "description" else f"sentry.{tag_key}" - ) - if data_key in data: - sentry_tags[tag_key] = data[data_key] - return sentry_tags - - def _exclusive_time(self, span: SegmentSpan) -> float: + def _exclusive_time(self, span: SpanEvent) -> float: """ Sets the exclusive time on all spans in the list. @@ -155,17 +151,15 @@ def _exclusive_time(self, span: SegmentSpan) -> float: return exclusive_time_us / 1_000 - def enrich_span(self, span: SegmentSpan) -> EnrichedSpan: - exclusive_time = self._exclusive_time(span) - data = self._data(span) + def enrich_span(self, span: SpanEvent) -> SpanEvent: + attributes = self._attributes(span) return { **span, - "data": data, - "exclusive_time_ms": exclusive_time, + "attributes": attributes, } @classmethod - def enrich_spans(cls, spans: list[SegmentSpan]) -> tuple[int | None, list[EnrichedSpan]]: + def enrich_spans(cls, spans: list[SpanEvent]) -> tuple[int | None, list[SpanEvent]]: inst = cls(spans) ret = [] segment_idx = None @@ -179,31 +173,31 @@ def enrich_spans(cls, spans: list[SegmentSpan]) -> tuple[int | None, list[Enrich return segment_idx, ret -def _get_mobile_start_type(segment: SegmentSpan) -> str | None: +def _get_mobile_start_type(segment: SpanEvent) -> str | None: """ Check the measurements on the span to determine what kind of start type the event is. """ - data = segment.get("data") or {} + attributes = segment.get("attributes") or {} - if "app_start_cold" in data: + if "app_start_cold" in attributes: return "cold" - if "app_start_warm" in data: + if "app_start_warm" in attributes: return "warm" return None -def _timestamp_by_op(spans: list[SegmentSpan], op: str) -> float | None: +def _timestamp_by_op(spans: list[SpanEvent], op: str) -> float | None: for span in spans: if get_span_op(span) == op: - return span["end_timestamp_precise"] + return span["end_timestamp"] return None -def _span_interval(span: SegmentSpan | EnrichedSpan) -> tuple[int, int]: +def _span_interval(span: SpanEvent) -> tuple[int, int]: """Get the start and end timestamps of a span in microseconds.""" - return _us(span["start_timestamp_precise"]), _us(span["end_timestamp_precise"]) + return _us(span["start_timestamp"]), _us(span["end_timestamp"]) def _us(timestamp: float) -> int: @@ -213,9 +207,9 @@ def _us(timestamp: float) -> int: def compute_breakdowns( - spans: Sequence[SegmentSpan], + spans: Sequence[SpanEvent], breakdowns_config: dict[str, dict[str, Any]], -) -> dict[str, float]: +) -> dict[str, Any]: """ Computes breakdowns from all spans and writes them to the segment span. @@ -234,12 +228,12 @@ def compute_breakdowns( continue for key, value in breakdowns.items(): - ret[f"{breakdown_name}.{key}"] = value + ret[f"{breakdown_name}.{key}"] = {"value": value} return ret -def _compute_span_ops(spans: Sequence[SegmentSpan], config: Any) -> dict[str, float]: +def _compute_span_ops(spans: Sequence[SpanEvent], config: Any) -> dict[str, float]: matches = config.get("matches") if not matches: return {} diff --git a/src/sentry/spans/consumers/process_segments/message.py b/src/sentry/spans/consumers/process_segments/message.py index 568b26b2dde5ca..d5d4baa959a56a 100644 --- a/src/sentry/spans/consumers/process_segments/message.py +++ b/src/sentry/spans/consumers/process_segments/message.py @@ -6,7 +6,7 @@ import sentry_sdk from django.core.exceptions import ValidationError -from sentry_kafka_schemas.schema_types.buffered_segments_v1 import SegmentSpan +from sentry_kafka_schemas.schema_types.ingest_spans_v1 import SpanEvent from sentry import options from sentry.constants import DataCategory @@ -29,7 +29,7 @@ from sentry.signals import first_insight_span_received, first_transaction_received from sentry.spans.consumers.process_segments.enrichment import TreeEnricher, compute_breakdowns from sentry.spans.consumers.process_segments.shim import build_shim_event_data, make_compatible -from sentry.spans.consumers.process_segments.types import CompatibleSpan +from sentry.spans.consumers.process_segments.types import CompatibleSpan, attribute_value from sentry.spans.grouping.api import load_span_grouping_config from sentry.utils import metrics from sentry.utils.dates import to_datetime @@ -43,7 +43,7 @@ @metrics.wraps("spans.consumers.process_segments.process_segment") def process_segment( - unprocessed_spans: list[SegmentSpan], skip_produce: bool = False + unprocessed_spans: list[SpanEvent], skip_produce: bool = False ) -> list[CompatibleSpan]: _verify_compatibility(unprocessed_spans) segment_span, spans = _enrich_spans(unprocessed_spans) @@ -113,7 +113,7 @@ def _redact(data: Any) -> Any: @metrics.wraps("spans.consumers.process_segments.enrich_spans") def _enrich_spans( - unprocessed_spans: list[SegmentSpan], + unprocessed_spans: list[SpanEvent], ) -> tuple[CompatibleSpan | None, list[CompatibleSpan]]: """ Enriches all spans with data derived from the span tree and the segment. @@ -145,7 +145,7 @@ def _compute_breakdowns( ) -> None: config = project.get_option("sentry:breakdowns") breakdowns = compute_breakdowns(spans, config) - segment.setdefault("data", {}).update(breakdowns) + segment.setdefault("attributes", {}).update(breakdowns) @metrics.wraps("spans.consumers.process_segments.create_models") @@ -154,11 +154,10 @@ def _create_models(segment: CompatibleSpan, project: Project) -> None: Creates the Environment and Release models, along with the necessary relationships between them and the Project model. """ - - environment_name = segment["data"].get("sentry.environment") - release_name = segment["data"].get("sentry.release") - dist_name = segment["data"].get("sentry.dist") - date = to_datetime(segment["end_timestamp_precise"]) + environment_name = attribute_value(segment, "sentry.environment") + release_name = attribute_value(segment, "sentry.release") + dist_name = attribute_value(segment, "sentry.dist") + date = to_datetime(segment["end_timestamp"]) environment = Environment.get_or_create(project=project, name=environment_name) @@ -232,7 +231,7 @@ def _detect_performance_problems( culprit=event_data["transaction"], evidence_data=problem.evidence_data or {}, evidence_display=problem.evidence_display, - detection_time=to_datetime(segment_span["end_timestamp_precise"]), + detection_time=to_datetime(segment_span["end_timestamp"]), level="info", ) @@ -248,17 +247,15 @@ def _detect_performance_problems( def _record_signals( segment_span: CompatibleSpan, spans: list[CompatibleSpan], project: Project ) -> None: - data = segment_span.get("data", {}) - record_generic_event_processed( project, - platform=data.get("sentry.platform"), - release=data.get("sentry.release"), - environment=data.get("sentry.environment"), + platform=attribute_value(segment_span, "sentry.platform"), + release=attribute_value(segment_span, "sentry.release"), + environment=attribute_value(segment_span, "sentry.environment"), ) # signal expects an event like object with a datetime attribute - event_like = types.SimpleNamespace(datetime=to_datetime(segment_span["end_timestamp_precise"])) + event_like = types.SimpleNamespace(datetime=to_datetime(segment_span["end_timestamp"])) set_project_flag_and_signal( project, @@ -268,7 +265,7 @@ def _record_signals( ) for module in insights_modules( - [FilterSpan.from_span_data(span.get("data", {})) for span in spans] + [FilterSpan.from_span_attributes(span.get("attributes", {})) for span in spans] ): set_project_flag_and_signal( project, diff --git a/src/sentry/spans/consumers/process_segments/shim.py b/src/sentry/spans/consumers/process_segments/shim.py index 822efe657a7d6c..113f303d7344fd 100644 --- a/src/sentry/spans/consumers/process_segments/shim.py +++ b/src/sentry/spans/consumers/process_segments/shim.py @@ -8,47 +8,54 @@ from copy import deepcopy from typing import Any, cast -from sentry_kafka_schemas.schema_types.buffered_segments_v1 import _SentryExtractedTags +import sentry_sdk +from sentry_kafka_schemas.schema_types.ingest_spans_v1 import SpanEvent from sentry.performance_issues.types import SentryTags as PerformanceIssuesSentryTags -from sentry.spans.consumers.process_segments.types import CompatibleSpan, EnrichedSpan, get_span_op +from sentry.spans.consumers.process_segments.types import ( + CompatibleSpan, + attribute_value, + get_span_op, +) from sentry.utils.dates import to_datetime -def make_compatible(span: EnrichedSpan) -> CompatibleSpan: +def make_compatible(span: SpanEvent) -> CompatibleSpan: # Creates attributes for EAP spans that are required by logic shared with the # event pipeline. # - # Spans in the transaction event protocol had a slightly different schema + # Spans in the transaction event protocol had a different schema # compared to raw spans on the EAP topic. This function adds the missing # attributes to the spans to make them compatible with the event pipeline # logic. ret: CompatibleSpan = { **span, - "sentry_tags": _sentry_tags(span.get("data") or {}), + "sentry_tags": _sentry_tags(span.get("attributes") or {}), "op": get_span_op(span), - # Note: Event protocol spans expect `exclusive_time` while EAP expects - # `exclusive_time_ms`. Both are the same value in milliseconds - "exclusive_time": span["exclusive_time_ms"], + "exclusive_time": attribute_value(span, "sentry.exclusive_time_ms"), + "is_segment": bool(attribute_value(span, "sentry.is_segment")), } return ret -def _sentry_tags(data: dict[str, Any]) -> _SentryExtractedTags: +def _sentry_tags(attributes: dict[str, Any]) -> dict[str, str]: """Backfill sentry tags used in performance issue detection. Once performance issue detection is only called from process_segments, (not from event_manager), the performance issues code can be refactored to access span attributes instead of sentry_tags. """ - sentry_tags: _SentryExtractedTags = {} + sentry_tags = {} for tag_key in PerformanceIssuesSentryTags.__mutable_keys__: - data_key = ( + attribute_key = ( "sentry.normalized_description" if tag_key == "description" else f"sentry.{tag_key}" ) - if data_key in data: - sentry_tags[tag_key] = data[data_key] # type: ignore[literal-required] + if attribute_key in attributes: + try: + sentry_tags[tag_key] = str((attributes[attribute_key] or {}).get("value")) + except Exception: + sentry_sdk.capture_exception() return sentry_tags @@ -57,7 +64,6 @@ def build_shim_event_data( segment_span: CompatibleSpan, spans: list[CompatibleSpan] ) -> dict[str, Any]: """Create a shimmed event payload for performance issue detection.""" - data = segment_span.get("data", {}) event: dict[str, Any] = { "type": "transaction", @@ -66,29 +72,27 @@ def build_shim_event_data( "trace": { "trace_id": segment_span["trace_id"], "type": "trace", - "op": data.get("sentry.transaction.op"), + "op": attribute_value(segment_span, "sentry.transaction.op"), "span_id": segment_span["span_id"], "hash": segment_span["hash"], }, }, "event_id": uuid.uuid4().hex, "project_id": segment_span["project_id"], - "transaction": data.get("sentry.transaction"), - "release": data.get("sentry.release"), - "dist": data.get("sentry.dist"), - "environment": data.get("sentry.environment"), - "platform": data.get("sentry.platform"), - "tags": [["environment", data.get("sentry.environment")]], + "transaction": attribute_value(segment_span, "sentry.transaction"), + "release": attribute_value(segment_span, "sentry.release"), + "dist": attribute_value(segment_span, "sentry.dist"), + "environment": attribute_value(segment_span, "sentry.environment"), + "platform": attribute_value(segment_span, "sentry.platform"), + "tags": [["environment", attribute_value(segment_span, "sentry.environment")]], "received": segment_span["received"], - "timestamp": segment_span["end_timestamp_precise"], - "start_timestamp": segment_span["start_timestamp_precise"], - "datetime": to_datetime(segment_span["end_timestamp_precise"]).strftime( - "%Y-%m-%dT%H:%M:%SZ" - ), + "timestamp": segment_span["end_timestamp"], + "start_timestamp": segment_span["start_timestamp"], + "datetime": to_datetime(segment_span["end_timestamp"]).strftime("%Y-%m-%dT%H:%M:%SZ"), "spans": [], } - if (profile_id := segment_span.get("profile_id")) is not None: + if (profile_id := attribute_value(segment_span, "sentry.profile_id")) is not None: event["contexts"]["profile"] = {"profile_id": profile_id, "type": "profile"} # Add legacy span attributes required only by issue detectors. As opposed to @@ -96,8 +100,8 @@ def build_shim_event_data( # topological sorting on the span tree. for span in spans: event_span = cast(dict[str, Any], deepcopy(span)) - event_span["start_timestamp"] = span["start_timestamp_precise"] - event_span["timestamp"] = span["end_timestamp_precise"] + event_span["start_timestamp"] = span["start_timestamp"] + event_span["timestamp"] = span["end_timestamp"] event["spans"].append(event_span) return event diff --git a/src/sentry/spans/consumers/process_segments/types.py b/src/sentry/spans/consumers/process_segments/types.py index ba4d8864c8bca6..629e00869e62d8 100644 --- a/src/sentry/spans/consumers/process_segments/types.py +++ b/src/sentry/spans/consumers/process_segments/types.py @@ -1,6 +1,17 @@ -from typing import NotRequired +from collections.abc import Mapping +from typing import Any, NotRequired + +from sentry_kafka_schemas.schema_types.ingest_spans_v1 import ( + SpanEvent, + _FileColonIngestSpansFullStopV1FullStopSchemaFullStopJsonNumberSignDefinitionsAttributevalueObject, +) + +Attributes = dict[ + str, + None + | _FileColonIngestSpansFullStopV1FullStopSchemaFullStopJsonNumberSignDefinitionsAttributevalueObject, +] -from sentry_kafka_schemas.schema_types.buffered_segments_v1 import SegmentSpan # The default span.op to assume if it is missing on the span. This should be # normalized by Relay, but we defensively apply the same fallback as the op is @@ -8,26 +19,25 @@ DEFAULT_SPAN_OP = "default" -def get_span_op(span: SegmentSpan) -> str: - return span.get("data", {}).get("sentry.op") or DEFAULT_SPAN_OP - - -class EnrichedSpan(SegmentSpan, total=True): - """ - Enriched version of the incoming span payload that has additional attributes - extracted from its child spans and/or inherited from its parent span. - """ +def get_span_op(span: SpanEvent) -> str: + return attribute_value(span, "sentry.op") or DEFAULT_SPAN_OP - exclusive_time_ms: float - -class CompatibleSpan(EnrichedSpan, total=True): +class CompatibleSpan(SpanEvent, total=True): """A span that has the same fields as a kafka span, plus shimming for logic shared with the event pipeline. This type will be removed eventually.""" exclusive_time: float op: str + sentry_tags: dict[str, str] + is_segment: bool # Added by `SpanGroupingResults.write_to_spans` in `_enrich_spans` hash: NotRequired[str] + + +def attribute_value(span: Mapping[str, Any], key: str) -> Any: + attributes = span.get("attributes") or {} + attr: dict[str, Any] = attributes.get(key) or {} + return attr.get("value") diff --git a/src/sentry/spans/grouping/strategy/base.py b/src/sentry/spans/grouping/strategy/base.py index 11db6068536d77..3e6549fd2c8359 100644 --- a/src/sentry/spans/grouping/strategy/base.py +++ b/src/sentry/spans/grouping/strategy/base.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from typing import Any, NotRequired, Optional, TypedDict +from sentry.spans.consumers.process_segments.types import attribute_value from sentry.spans.grouping.utils import Hash, parse_fingerprint_var from sentry.utils import urls @@ -55,8 +56,10 @@ def get_standalone_span_group(self, span: Span) -> str: # Treat the segment span like get_transaction_span_group for backwards # compatibility with transaction events, but fall back to default # fingerprinting if the span doesn't have a transaction. - data = span.get("data") or {} - if span.get("is_segment") and (transaction := data.get("sentry.transaction")) is not None: + if ( + attribute_value(span, "sentry.is_segment") + and (transaction := attribute_value(span, "sentry.transaction")) is not None + ): result = Hash() result.update(transaction) return result.hexdigest() @@ -117,7 +120,11 @@ def raw_description_strategy(span: Span) -> Sequence[str]: strategy is only effective if the span description is a fixed string. Otherwise, this strategy will produce a large number of span groups. """ - return [span.get("description") or ""] + return [raw_description(span)] + + +def raw_description(span: Span) -> str: + return span.get("description") or attribute_value(span, "sentry.description") or "" IN_CONDITION_PATTERN = re.compile(r" IN \(%s(\s*,\s*%s)*\)") @@ -140,7 +147,7 @@ def normalized_db_span_in_condition_strategy(span: Span) -> Sequence[str] | None seen as different spans. We want these spans to be seen as similar spans, so we normalize the right hand side of `IN` conditions to `(%s) to use in the fingerprint.""" - description = span.get("description") or "" + description = raw_description(span) cleaned, count = IN_CONDITION_PATTERN.subn(" IN (%s)", description) if count == 0: return None @@ -165,7 +172,7 @@ def loose_normalized_db_span_in_condition_strategy(span: Span) -> Sequence[str] """This is identical to the above `normalized_db_span_in_condition_strategy` but it uses a looser regular expression that catches database spans that come from Laravel and Rails""" - description = span.get("description") or "" + description = raw_description(span) cleaned, count = LOOSE_IN_CONDITION_PATTERN.subn(" IN (%s)", description) if count == 0: return None @@ -209,7 +216,7 @@ def parametrize_db_span_strategy(span: Span) -> Sequence[str] | None: conservative with the literals we target. Currently, only single-quoted strings are parametrized even though MySQL supports double-quoted strings as well, because PG uses double-quoted strings for identifiers.""" - query = span.get("description") or "" + query = raw_description(span) query, in_count = LOOSE_IN_CONDITION_PATTERN.subn(" IN (%s)", query) query, savepoint_count = DB_SAVEPOINT_PATTERN.subn("SAVEPOINT %s", query) query, param_count = DB_PARAMETRIZATION_PATTERN.subn("%s", query) @@ -255,7 +262,7 @@ def remove_http_client_query_string_strategy(span: Span) -> Sequence[str] | None """ # Check the description is of the form ` ` - description = span.get("description") or "" + description = raw_description(span) parts = description.split(" ", 1) if len(parts) != 2: return None @@ -276,7 +283,7 @@ def remove_redis_command_arguments_strategy(span: Span) -> Sequence[str] | None: The arguments to the redis command is highly variable and therefore not used as a part of the fingerprint. """ - description = span.get("description") or "" + description = raw_description(span) parts = description.split(" ", 1) # the redis command name is the first word in the description diff --git a/src/sentry/testutils/performance_issues/span_builder.py b/src/sentry/testutils/performance_issues/span_builder.py index 5e0ff051ad262f..b731e8a06a5d06 100644 --- a/src/sentry/testutils/performance_issues/span_builder.py +++ b/src/sentry/testutils/performance_issues/span_builder.py @@ -72,3 +72,21 @@ def build(self) -> Span: if self.hash is not None: span["hash"] = self.hash return span + + def build_v2(self) -> dict[str, Any]: + """Return a Span V2""" + return { + "trace_id": self.trace_id, + "parent_span_id": self.parent_span_id, + "span_id": self.span_id, + "start_timestamp": self.start_timestamp, + "timestamp": self.timestamp, + "same_process_as_parent": self.same_process_as_parent, + "attributes": { + "sentry.is_segment": {"value": self.is_segment}, + "sentry.op": {"value": self.op}, + "sentry.description": {"value": self.description}, + **{k: {"value": v} for (k, v) in (self.tags or {}).items()}, + **{k: {"value": v} for (k, v) in (self.data or {}).items()}, + }, + } diff --git a/tests/sentry/spans/consumers/process/__init__.py b/tests/sentry/spans/consumers/process/__init__.py index bb9b7365a51e0a..0c5dbc7344f8d9 100644 --- a/tests/sentry/spans/consumers/process/__init__.py +++ b/tests/sentry/spans/consumers/process/__init__.py @@ -1,29 +1,35 @@ -def build_mock_span(project_id, span_op=None, is_segment=False, data=None, **kwargs): - span = { - "description": "OrganizationNPlusOne", - "duration_ms": 107, - "is_segment": is_segment, +from sentry_kafka_schemas.schema_types.ingest_spans_v1 import SpanEvent + + +def build_mock_span(project_id, *, span_op=None, is_segment=False, attributes=None, **kwargs): + span: SpanEvent = { "is_remote": is_segment, "parent_span_id": None, - "profile_id": "dbae2b82559649a1a34a2878134a007b", "project_id": project_id, "organization_id": 1, "received": 1707953019.044972, "retention_days": 90, - "segment_id": "a49b42af9fb69da0", - "data": { - "sentry.environment": "development", - "sentry.release": "backend@24.2.0.dev0+699ce0cd1281cc3c7275d0a474a595375c769ae8", - "sentry.platform": "python", - "sentry.op": span_op or "base.dispatch.sleep", - **(data or {}), + "attributes": { + "sentry.is_segment": {"value": is_segment, "type": "boolean"}, + "sentry.duration": {"value": 0.107, "type": "double"}, + "sentry.environment": {"value": "development", "type": "string"}, + "sentry.release": { + "value": "backend@24.2.0.dev0+699ce0cd1281cc3c7275d0a474a595375c769ae8", + "type": "string", + }, + "sentry.platform": {"value": "python", "type": "string"}, + "sentry.op": {"value": span_op or "base.dispatch.sleep", "type": "string"}, + "sentry.segment.id": {"value": "a49b42af9fb69da0", "type": "string"}, + "sentry.profile_id": {"type": "string", "value": "dbae2b82559649a1a34a2878134a007b"}, + **(attributes or {}), }, "span_id": "a49b42af9fb69da0", - "start_timestamp_ms": 1707953018865, - "start_timestamp_precise": 1707953018.865, - "end_timestamp_precise": 1707953018.972, + "start_timestamp": 1707953018.865, + "end_timestamp": 1707953018.972, "trace_id": "94576097f3a64b68b85a59c7d4e3ee2a", + "name": "OrganizationNPlusOne", + "status": "ok", } - span.update(**kwargs) + span.update(**kwargs) # type:ignore[call-arg] return span diff --git a/tests/sentry/spans/consumers/process/test_consumer.py b/tests/sentry/spans/consumers/process/test_consumer.py index 95d13499635a14..b0487fb31cc651 100644 --- a/tests/sentry/spans/consumers/process/test_consumer.py +++ b/tests/sentry/spans/consumers/process/test_consumer.py @@ -50,7 +50,7 @@ def add_commit(offsets, force=False): "project_id": 12, "span_id": "a" * 16, "trace_id": "b" * 32, - "end_timestamp_precise": 1700000000.0, + "end_timestamp": 1700000000.0, } ), [], @@ -76,12 +76,14 @@ def add_commit(offsets, force=False): assert orjson.loads(msg.value) == { "spans": [ { - "is_segment": True, + "attributes": { + "sentry.is_segment": {"type": "boolean", "value": True}, + "sentry.segment.id": {"type": "string", "value": "aaaaaaaaaaaaaaaa"}, + }, "project_id": 12, - "segment_id": "aaaaaaaaaaaaaaaa", "span_id": "aaaaaaaaaaaaaaaa", "trace_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "end_timestamp_precise": 1700000000.0, + "end_timestamp": 1700000000.0, }, ], } diff --git a/tests/sentry/spans/consumers/process/test_flusher.py b/tests/sentry/spans/consumers/process/test_flusher.py index 50b156148225d2..3fa01c36f05b52 100644 --- a/tests/sentry/spans/consumers/process/test_flusher.py +++ b/tests/sentry/spans/consumers/process/test_flusher.py @@ -50,7 +50,7 @@ def append(msg): parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=now, + end_timestamp=now, ), Span( payload=_payload("d" * 16), @@ -59,7 +59,7 @@ def append(msg): parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=now, + end_timestamp=now, ), Span( payload=_payload("c" * 16), @@ -68,7 +68,7 @@ def append(msg): parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=now, + end_timestamp=now, ), Span( payload=_payload("b" * 16), @@ -78,7 +78,7 @@ def append(msg): is_segment_span=True, segment_id=None, project_id=1, - end_timestamp_precise=now, + end_timestamp=now, ), ] diff --git a/tests/sentry/spans/consumers/process_segments/test_convert.py b/tests/sentry/spans/consumers/process_segments/test_convert.py index 5b8cd689b9e085..679e0b8bbcc38e 100644 --- a/tests/sentry/spans/consumers/process_segments/test_convert.py +++ b/tests/sentry/spans/consumers/process_segments/test_convert.py @@ -1,6 +1,7 @@ from typing import cast from google.protobuf.timestamp_pb2 import Timestamp +from sentry_kafka_schemas.schema_types.ingest_spans_v1 import SpanEvent from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType from sentry_protos.snuba.v1.trace_item_pb2 import AnyValue @@ -11,73 +12,73 @@ # Test ported from Snuba's `eap_items_span`. # ############################################### -SPAN_KAFKA_MESSAGE = { - "description": "/api/0/relays/projectconfigs/", - "duration_ms": 152, - "exclusive_time_ms": 0.228, - "is_segment": True, - "data": { - "http.status_code": "200", - "my.array.field": [1, 2, ["nested", "array"]], - "my.dict.field": {"id": 42, "name": "test"}, - "my.false.bool.field": False, - "my.float.field": 101.2, - "my.int.field": 2000, - "my.neg.field": -100, - "my.neg.float.field": -101.2, - "my.true.bool.field": True, - "my.u64.field": 9447000002305251000, - "num_of_spans": 50.0, - "relay_endpoint_version": "3", - "relay_id": "88888888-4444-4444-8444-cccccccccccc", - "relay_no_cache": "False", - "relay_protocol_version": "3", - "relay_use_post_or_schedule": "True", - "relay_use_post_or_schedule_rejected": "version", - "sentry.category": "http", - "sentry.client_sample_rate": 0.1, - "sentry.environment": "development", - "sentry.description": "/api/0/relays/projectconfigs/", - "sentry.normalized_description": "normalized_description", - "sentry.op": "http.server", - "sentry.platform": "python", - "sentry.release": "backend@24.7.0.dev0+c45b49caed1e5fcbf70097ab3f434b487c359b6b", - "sentry.sdk.name": "sentry.python.django", - "sentry.sdk.version": "2.7.0", - "sentry.segment.name": "/api/0/relays/projectconfigs/", - "sentry.server_sample_rate": 0.2, - "sentry.status": "ok", - "sentry.status_code": "200", - "sentry.thread.id": "8522009600", - "sentry.thread.name": "uWSGIWorker1Core0", - "sentry.trace.status": "ok", - "sentry.transaction": "/api/0/relays/projectconfigs/", - "sentry.transaction.method": "POST", - "sentry.transaction.op": "http.server", - "sentry.user": "ip:127.0.0.1", - "server_name": "D23CXQ4GK2.local", - "spans_over_limit": "False", - "thread.id": "8522009600", - "thread.name": "uWSGIWorker1Core0", +SPAN_KAFKA_MESSAGE: SpanEvent = { + "is_remote": True, + "attributes": { + "http.status_code": {"value": "200", "type": "string"}, + "my.array.field": {"value": [1, 2, ["nested", "array"]], "type": "array"}, + "my.dict.field": {"value": {"id": 42, "name": "test"}, "type": "object"}, + "my.false.bool.field": {"value": False, "type": "boolean"}, + "my.float.field": {"value": 101.2, "type": "double"}, + "my.int.field": {"value": 2000, "type": "integer"}, + "my.neg.field": {"value": -100, "type": "integer"}, + "my.neg.float.field": {"value": -101.2, "type": "double"}, + "my.true.bool.field": {"value": True, "type": "boolean"}, + "my.u64.field": {"value": 9447000002305251000, "type": "integer"}, + "num_of_spans": {"value": 50.0, "type": "string"}, + "relay_endpoint_version": {"value": "3", "type": "string"}, + "relay_id": {"value": "88888888-4444-4444-8444-cccccccccccc", "type": "string"}, + "relay_no_cache": {"value": "False", "type": "string"}, + "relay_protocol_version": {"value": "3", "type": "string"}, + "relay_use_post_or_schedule": {"value": "True", "type": "string"}, + "relay_use_post_or_schedule_rejected": {"value": "version", "type": "string"}, + "sentry.category": {"value": "http", "type": "string"}, + "sentry.client_sample_rate": {"value": 0.1, "type": "string"}, + "sentry.description": {"value": "/api/0/relays/projectconfigs/", "type": "string"}, + "sentry.environment": {"value": "development", "type": "string"}, + "sentry.is_segment": {"value": True, "type": "boolean"}, + "sentry.normalized_description": {"value": "normalized_description", "type": "string"}, + "sentry.op": {"value": "http.server", "type": "string"}, + "sentry.origin": {"value": "auto.http.django", "type": "string"}, + "sentry.platform": {"value": "python", "type": "string"}, + "sentry.profile_id": {"value": "56c7d1401ea14ad7b4ac86de46baebae", "type": "string"}, + "sentry.release": { + "value": "backend@24.7.0.dev0+c45b49caed1e5fcbf70097ab3f434b487c359b6b", + "type": "string", + }, + "sentry.sdk.name": {"value": "sentry.python.django", "type": "string"}, + "sentry.sdk.version": {"value": "2.7.0", "type": "string"}, + "sentry.segment.id": {"type": "string", "value": "8873a98879faf06d"}, + "sentry.segment.name": {"value": "/api/0/relays/projectconfigs/", "type": "string"}, + "sentry.server_sample_rate": {"value": 0.2, "type": "string"}, + "sentry.status": {"value": "ok", "type": "string"}, + "sentry.status_code": {"value": "200", "type": "string"}, + "sentry.thread.id": {"value": "8522009600", "type": "string"}, + "sentry.thread.name": {"value": "uWSGIWorker1Core0", "type": "string"}, + "sentry.trace.status": {"value": "ok", "type": "string"}, + "sentry.transaction": {"value": "/api/0/relays/projectconfigs/", "type": "string"}, + "sentry.transaction.method": {"value": "POST", "type": "string"}, + "sentry.transaction.op": {"value": "http.server", "type": "string"}, + "sentry.user": {"value": "ip:127.0.0.1", "type": "string"}, + "server_name": {"value": "D23CXQ4GK2.local", "type": "string"}, + "spans_over_limit": {"value": "False", "type": "string"}, + "thread.id": {"value": "8522009600", "type": "string"}, + "thread.name": {"value": "uWSGIWorker1Core0", "type": "string"}, }, - "sentry_tags": {"ignored": "tags"}, - "profile_id": "56c7d1401ea14ad7b4ac86de46baebae", "organization_id": 1, - "origin": "auto.http.django", "project_id": 1, "received": 1721319572.877828, "retention_days": 90, - "segment_id": "8873a98879faf06d", "span_id": "8873a98879faf06d", "trace_id": "d099bf9ad5a143cf8f83a98081d0ed3b", - "start_timestamp_ms": 1721319572616, - "start_timestamp_precise": 1721319572.616648, - "end_timestamp_precise": 1721319572.768806, + "start_timestamp": 1721319572.616648, + "end_timestamp": 1721319572.768806, + "name": "endpoint", + "status": "ok", } def test_convert_span_to_item() -> None: - # Cast since the above payload does not conform to the strict schema item = convert_span_to_item(cast(CompatibleSpan, SPAN_KAFKA_MESSAGE)) assert item.organization_id == 1 @@ -91,74 +92,78 @@ def test_convert_span_to_item() -> None: assert item.retention_days == 90 assert item.received == Timestamp(seconds=1721319572, nanos=877828000) - assert item.attributes == { + # Sort for easier comparison: + attrs = {k: v for (k, v) in sorted(item.attributes.items())} + + assert attrs == { + "http.status_code": AnyValue(string_value="200"), + "my.array.field": AnyValue(string_value=r"""[1,2,["nested","array"]]"""), + "my.dict.field": AnyValue(string_value=r"""{"id":42,"name":"test"}"""), "my.false.bool.field": AnyValue(bool_value=False), - "my.true.bool.field": AnyValue(bool_value=True), - "sentry.is_segment": AnyValue(bool_value=True), "my.float.field": AnyValue(double_value=101.2), - "my.neg.float.field": AnyValue(double_value=-101.2), - "sentry.exclusive_time_ms": AnyValue(double_value=0.228), - "sentry.start_timestamp_precise": AnyValue(double_value=1721319572.616648), - "num_of_spans": AnyValue(double_value=50.0), - "sentry.end_timestamp_precise": AnyValue(double_value=1721319572.768806), - "sentry.duration_ms": AnyValue(int_value=152), - "sentry.received": AnyValue(double_value=1721319572.877828), "my.int.field": AnyValue(int_value=2000), "my.neg.field": AnyValue(int_value=-100), - "relay_protocol_version": AnyValue(string_value="3"), - "sentry.raw_description": AnyValue(string_value="/api/0/relays/projectconfigs/"), - "sentry.segment_id": AnyValue(string_value="8873a98879faf06d"), - "sentry.transaction.method": AnyValue(string_value="POST"), - "server_name": AnyValue(string_value="D23CXQ4GK2.local"), - "sentry.status": AnyValue(string_value="ok"), + "my.neg.float.field": AnyValue(double_value=-101.2), + "my.true.bool.field": AnyValue(bool_value=True), + "my.u64.field": AnyValue(double_value=9447000002305251000.0), + "num_of_spans": AnyValue(double_value=50.0), "relay_endpoint_version": AnyValue(string_value="3"), + "relay_id": AnyValue(string_value="88888888-4444-4444-8444-cccccccccccc"), "relay_no_cache": AnyValue(string_value="False"), + "relay_protocol_version": AnyValue(string_value="3"), + "relay_use_post_or_schedule_rejected": AnyValue(string_value="version"), "relay_use_post_or_schedule": AnyValue(string_value="True"), - "spans_over_limit": AnyValue(string_value="False"), - "sentry.segment.name": AnyValue(string_value="/api/0/relays/projectconfigs/"), - "sentry.status_code": AnyValue(string_value="200"), + "sentry.category": AnyValue(string_value="http"), + "sentry.client_sample_rate": AnyValue(double_value=0.1), + "sentry.duration_ms": AnyValue(int_value=152), + "sentry.end_timestamp_precise": AnyValue(double_value=1721319572.768806), + "sentry.environment": AnyValue(string_value="development"), + "sentry.is_remote": AnyValue(bool_value=True), + "sentry.is_segment": AnyValue(bool_value=True), + "sentry.name": AnyValue(string_value="endpoint"), + "sentry.normalized_description": AnyValue(string_value="normalized_description"), "sentry.op": AnyValue(string_value="http.server"), "sentry.origin": AnyValue(string_value="auto.http.django"), - "sentry.transaction": AnyValue(string_value="/api/0/relays/projectconfigs/"), - "sentry.thread.name": AnyValue(string_value="uWSGIWorker1Core0"), + "sentry.platform": AnyValue(string_value="python"), "sentry.profile_id": AnyValue(string_value="56c7d1401ea14ad7b4ac86de46baebae"), - "thread.id": AnyValue(string_value="8522009600"), - "http.status_code": AnyValue(string_value="200"), + "sentry.raw_description": AnyValue(string_value="/api/0/relays/projectconfigs/"), + "sentry.received": AnyValue(double_value=1721319572.877828), "sentry.release": AnyValue( string_value="backend@24.7.0.dev0+c45b49caed1e5fcbf70097ab3f434b487c359b6b" ), "sentry.sdk.name": AnyValue(string_value="sentry.python.django"), - "sentry.transaction.op": AnyValue(string_value="http.server"), - "relay_id": AnyValue(string_value="88888888-4444-4444-8444-cccccccccccc"), - "sentry.trace.status": AnyValue(string_value="ok"), - "sentry.category": AnyValue(string_value="http"), - "sentry.environment": AnyValue(string_value="development"), - "sentry.thread.id": AnyValue(string_value="8522009600"), "sentry.sdk.version": AnyValue(string_value="2.7.0"), - "sentry.platform": AnyValue(string_value="python"), - "sentry.client_sample_rate": AnyValue(double_value=0.1), + "sentry.segment_id": AnyValue(string_value="8873a98879faf06d"), + "sentry.segment.name": AnyValue(string_value="/api/0/relays/projectconfigs/"), "sentry.server_sample_rate": AnyValue(double_value=0.2), + "sentry.start_timestamp_precise": AnyValue(double_value=1721319572.616648), + "sentry.status_code": AnyValue(string_value="200"), + "sentry.status": AnyValue(string_value="ok"), + "sentry.thread.id": AnyValue(string_value="8522009600"), + "sentry.thread.name": AnyValue(string_value="uWSGIWorker1Core0"), + "sentry.trace.status": AnyValue(string_value="ok"), + "sentry.transaction.method": AnyValue(string_value="POST"), + "sentry.transaction.op": AnyValue(string_value="http.server"), + "sentry.transaction": AnyValue(string_value="/api/0/relays/projectconfigs/"), "sentry.user": AnyValue(string_value="ip:127.0.0.1"), - "relay_use_post_or_schedule_rejected": AnyValue(string_value="version"), - "sentry.normalized_description": AnyValue(string_value="normalized_description"), + "server_name": AnyValue(string_value="D23CXQ4GK2.local"), + "spans_over_limit": AnyValue(string_value="False"), + "thread.id": AnyValue(string_value="8522009600"), "thread.name": AnyValue(string_value="uWSGIWorker1Core0"), - "my.dict.field": AnyValue(string_value=r"""{"id":42,"name":"test"}"""), - "my.u64.field": AnyValue(double_value=9447000002305251000.0), - "my.array.field": AnyValue(string_value=r"""[1,2,["nested","array"]]"""), } def test_convert_falsy_fields() -> None: - message = {**SPAN_KAFKA_MESSAGE, "duration_ms": 0, "is_segment": False} + message: SpanEvent = {**SPAN_KAFKA_MESSAGE} + message["attributes"]["sentry.is_segment"] = {"type": "boolean", "value": False} item = convert_span_to_item(cast(CompatibleSpan, message)) - assert item.attributes.get("sentry.duration_ms") == AnyValue(int_value=0) assert item.attributes.get("sentry.is_segment") == AnyValue(bool_value=False) def test_convert_span_links_to_json() -> None: - message = { + message: SpanEvent = { **SPAN_KAFKA_MESSAGE, "links": [ # A link with all properties @@ -167,10 +172,10 @@ def test_convert_span_links_to_json() -> None: "span_id": "8873a98879faf06d", "sampled": True, "attributes": { - "sentry.link.type": "parent", - "sentry.dropped_attributes_count": 2, - "parent_depth": 17, - "confidence": "high", + "sentry.link.type": {"type": "string", "value": "parent"}, + "sentry.dropped_attributes_count": {"type": "integer", "value": 2}, + "parent_depth": {"type": "integer", "value": 17}, + "confidence": {"type": "string", "value": "high"}, }, }, # A link with missing optional properties @@ -184,5 +189,5 @@ def test_convert_span_links_to_json() -> None: item = convert_span_to_item(cast(CompatibleSpan, message)) assert item.attributes.get("sentry.links") == AnyValue( - string_value='[{"trace_id":"d099bf9ad5a143cf8f83a98081d0ed3b","span_id":"8873a98879faf06d","sampled":true,"attributes":{"sentry.link.type":"parent","sentry.dropped_attributes_count":4}},{"trace_id":"d099bf9ad5a143cf8f83a98081d0ed3b","span_id":"873a988879faf06d"}]' + string_value='[{"trace_id":"d099bf9ad5a143cf8f83a98081d0ed3b","span_id":"8873a98879faf06d","sampled":true,"attributes":{"sentry.link.type":{"type":"string","value":"parent"},"sentry.dropped_attributes_count":{"type":"integer","value":4}}},{"trace_id":"d099bf9ad5a143cf8f83a98081d0ed3b","span_id":"873a988879faf06d"}]' ) diff --git a/tests/sentry/spans/consumers/process_segments/test_enrichment.py b/tests/sentry/spans/consumers/process_segments/test_enrichment.py index f11b5cb5a1f662..f9bdcba29fab35 100644 --- a/tests/sentry/spans/consumers/process_segments/test_enrichment.py +++ b/tests/sentry/spans/consumers/process_segments/test_enrichment.py @@ -1,5 +1,10 @@ +from typing import cast + +from sentry_kafka_schemas.schema_types.ingest_spans_v1 import SpanEvent + from sentry.spans.consumers.process_segments.enrichment import TreeEnricher, compute_breakdowns from sentry.spans.consumers.process_segments.shim import make_compatible +from sentry.spans.consumers.process_segments.types import CompatibleSpan, attribute_value from tests.sentry.spans.consumers.process import build_mock_span # Tests ported from Relay @@ -10,37 +15,39 @@ def test_childless_spans() -> None: build_mock_span( project_id=1, is_segment=True, - start_timestamp_precise=1609455600.0, - end_timestamp_precise=1609455605.0, + start_timestamp=1609455600.0, + end_timestamp=1609455605.0, span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.0, - end_timestamp_precise=1609455604.0, + start_timestamp=1609455601.0, + end_timestamp=1609455604.0, span_id="bbbbbbbbbbbbbbbb", parent_span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.0, - end_timestamp_precise=1609455603.5, + start_timestamp=1609455601.0, + end_timestamp=1609455603.5, span_id="cccccccccccccccc", parent_span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455603.0, - end_timestamp_precise=1609455604.877, + start_timestamp=1609455603.0, + end_timestamp=1609455604.877, span_id="dddddddddddddddd", parent_span_id="aaaaaaaaaaaaaaaa", ), ] - _, enriched = TreeEnricher.enrich_spans(spans) - enriched = [make_compatible(span) for span in enriched] + _, spans = TreeEnricher.enrich_spans(spans) + spans = [make_compatible(span) for span in spans] - exclusive_times = {span["span_id"]: span["exclusive_time_ms"] for span in enriched} + exclusive_times = { + span["span_id"]: attribute_value(span, "sentry.exclusive_time_ms") for span in spans + } assert exclusive_times == { "aaaaaaaaaaaaaaaa": 1123.0, "bbbbbbbbbbbbbbbb": 3000.0, @@ -54,36 +61,38 @@ def test_nested_spans() -> None: build_mock_span( project_id=1, is_segment=True, - start_timestamp_precise=1609455600.0, - end_timestamp_precise=1609455605.0, + start_timestamp=1609455600.0, + end_timestamp=1609455605.0, span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.0, - end_timestamp_precise=1609455602.0, + start_timestamp=1609455601.0, + end_timestamp=1609455602.0, span_id="bbbbbbbbbbbbbbbb", parent_span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.2, - end_timestamp_precise=1609455601.8, + start_timestamp=1609455601.2, + end_timestamp=1609455601.8, span_id="cccccccccccccccc", parent_span_id="bbbbbbbbbbbbbbbb", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.4, - end_timestamp_precise=1609455601.6, + start_timestamp=1609455601.4, + end_timestamp=1609455601.6, span_id="dddddddddddddddd", parent_span_id="cccccccccccccccc", ), ] - _, enriched = TreeEnricher.enrich_spans(spans) + _, spans = TreeEnricher.enrich_spans(spans) - exclusive_times = {span["span_id"]: span["exclusive_time_ms"] for span in enriched} + exclusive_times = { + span["span_id"]: attribute_value(span, "sentry.exclusive_time_ms") for span in spans + } assert exclusive_times == { "aaaaaaaaaaaaaaaa": 4000.0, "bbbbbbbbbbbbbbbb": 400.0, @@ -97,36 +106,38 @@ def test_overlapping_child_spans() -> None: build_mock_span( project_id=1, is_segment=True, - start_timestamp_precise=1609455600.0, - end_timestamp_precise=1609455605.0, + start_timestamp=1609455600.0, + end_timestamp=1609455605.0, span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.0, - end_timestamp_precise=1609455602.0, + start_timestamp=1609455601.0, + end_timestamp=1609455602.0, span_id="bbbbbbbbbbbbbbbb", parent_span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.2, - end_timestamp_precise=1609455601.6, + start_timestamp=1609455601.2, + end_timestamp=1609455601.6, span_id="cccccccccccccccc", parent_span_id="bbbbbbbbbbbbbbbb", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.4, - end_timestamp_precise=1609455601.8, + start_timestamp=1609455601.4, + end_timestamp=1609455601.8, span_id="dddddddddddddddd", parent_span_id="bbbbbbbbbbbbbbbb", ), ] - _, enriched = TreeEnricher.enrich_spans(spans) + _, spans = TreeEnricher.enrich_spans(spans) - exclusive_times = {span["span_id"]: span["exclusive_time_ms"] for span in enriched} + exclusive_times = { + span["span_id"]: attribute_value(span, "sentry.exclusive_time_ms") for span in spans + } assert exclusive_times == { "aaaaaaaaaaaaaaaa": 4000.0, "bbbbbbbbbbbbbbbb": 400.0, @@ -140,36 +151,38 @@ def test_child_spans_dont_intersect_parent() -> None: build_mock_span( project_id=1, is_segment=True, - start_timestamp_precise=1609455600.0, - end_timestamp_precise=1609455605.0, + start_timestamp=1609455600.0, + end_timestamp=1609455605.0, span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.0, - end_timestamp_precise=1609455602.0, + start_timestamp=1609455601.0, + end_timestamp=1609455602.0, span_id="bbbbbbbbbbbbbbbb", parent_span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455600.4, - end_timestamp_precise=1609455600.8, + start_timestamp=1609455600.4, + end_timestamp=1609455600.8, span_id="cccccccccccccccc", parent_span_id="bbbbbbbbbbbbbbbb", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455602.2, - end_timestamp_precise=1609455602.6, + start_timestamp=1609455602.2, + end_timestamp=1609455602.6, span_id="dddddddddddddddd", parent_span_id="bbbbbbbbbbbbbbbb", ), ] - _, enriched = TreeEnricher.enrich_spans(spans) + _, spans = TreeEnricher.enrich_spans(spans) - exclusive_times = {span["span_id"]: span["exclusive_time_ms"] for span in enriched} + exclusive_times = { + span["span_id"]: attribute_value(span, "sentry.exclusive_time_ms") for span in spans + } assert exclusive_times == { "aaaaaaaaaaaaaaaa": 4000.0, "bbbbbbbbbbbbbbbb": 1000.0, @@ -183,36 +196,38 @@ def test_child_spans_extend_beyond_parent() -> None: build_mock_span( project_id=1, is_segment=True, - start_timestamp_precise=1609455600.0, - end_timestamp_precise=1609455605.0, + start_timestamp=1609455600.0, + end_timestamp=1609455605.0, span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.0, - end_timestamp_precise=1609455602.0, + start_timestamp=1609455601.0, + end_timestamp=1609455602.0, span_id="bbbbbbbbbbbbbbbb", parent_span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455600.8, - end_timestamp_precise=1609455601.4, + start_timestamp=1609455600.8, + end_timestamp=1609455601.4, span_id="cccccccccccccccc", parent_span_id="bbbbbbbbbbbbbbbb", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.6, - end_timestamp_precise=1609455602.2, + start_timestamp=1609455601.6, + end_timestamp=1609455602.2, span_id="dddddddddddddddd", parent_span_id="bbbbbbbbbbbbbbbb", ), ] - _, enriched = TreeEnricher.enrich_spans(spans) + _, spans = TreeEnricher.enrich_spans(spans) - exclusive_times = {span["span_id"]: span["exclusive_time_ms"] for span in enriched} + exclusive_times = { + span["span_id"]: attribute_value(span, "sentry.exclusive_time_ms") for span in spans + } assert exclusive_times == { "aaaaaaaaaaaaaaaa": 4000.0, "bbbbbbbbbbbbbbbb": 200.0, @@ -226,36 +241,38 @@ def test_child_spans_consumes_all_of_parent() -> None: build_mock_span( project_id=1, is_segment=True, - start_timestamp_precise=1609455600.0, - end_timestamp_precise=1609455605.0, + start_timestamp=1609455600.0, + end_timestamp=1609455605.0, span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.0, - end_timestamp_precise=1609455602.0, + start_timestamp=1609455601.0, + end_timestamp=1609455602.0, span_id="bbbbbbbbbbbbbbbb", parent_span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455600.8, - end_timestamp_precise=1609455601.6, + start_timestamp=1609455600.8, + end_timestamp=1609455601.6, span_id="cccccccccccccccc", parent_span_id="bbbbbbbbbbbbbbbb", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.4, - end_timestamp_precise=1609455602.2, + start_timestamp=1609455601.4, + end_timestamp=1609455602.2, span_id="dddddddddddddddd", parent_span_id="bbbbbbbbbbbbbbbb", ), ] - _, enriched = TreeEnricher.enrich_spans(spans) + _, spans = TreeEnricher.enrich_spans(spans) - exclusive_times = {span["span_id"]: span["exclusive_time_ms"] for span in enriched} + exclusive_times = { + span["span_id"]: attribute_value(span, "sentry.exclusive_time_ms") for span in spans + } assert exclusive_times == { "aaaaaaaaaaaaaaaa": 4000.0, "bbbbbbbbbbbbbbbb": 0.0, @@ -269,36 +286,38 @@ def test_only_immediate_child_spans_affect_calculation() -> None: build_mock_span( project_id=1, is_segment=True, - start_timestamp_precise=1609455600.0, - end_timestamp_precise=1609455605.0, + start_timestamp=1609455600.0, + end_timestamp=1609455605.0, span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.0, - end_timestamp_precise=1609455602.0, + start_timestamp=1609455601.0, + end_timestamp=1609455602.0, span_id="bbbbbbbbbbbbbbbb", parent_span_id="aaaaaaaaaaaaaaaa", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.6, - end_timestamp_precise=1609455602.2, + start_timestamp=1609455601.6, + end_timestamp=1609455602.2, span_id="cccccccccccccccc", parent_span_id="bbbbbbbbbbbbbbbb", ), build_mock_span( project_id=1, - start_timestamp_precise=1609455601.4, - end_timestamp_precise=1609455601.8, + start_timestamp=1609455601.4, + end_timestamp=1609455601.8, span_id="dddddddddddddddd", parent_span_id="cccccccccccccccc", ), ] - _, enriched = TreeEnricher.enrich_spans(spans) + _, spans = TreeEnricher.enrich_spans(spans) - exclusive_times = {span["span_id"]: span["exclusive_time_ms"] for span in enriched} + exclusive_times = { + span["span_id"]: attribute_value(span, "sentry.exclusive_time_ms") for span in spans + } assert exclusive_times == { "aaaaaaaaaaaaaaaa": 4000.0, "bbbbbbbbbbbbbbbb": 600.0, @@ -311,48 +330,48 @@ def test_emit_ops_breakdown() -> None: segment_span = build_mock_span( project_id=1, is_segment=True, - start_timestamp_precise=1577836800.0, - end_timestamp_precise=1577858400.01, + start_timestamp=1577836800.0, + end_timestamp=1577858400.01, span_id="ffffffffffffffff", ) spans = [ build_mock_span( project_id=1, - start_timestamp_precise=1577836800.0, # 2020-01-01 00:00:00 - end_timestamp_precise=1577840400.0, # 2020-01-01 01:00:00 + start_timestamp=1577836800.0, # 2020-01-01 00:00:00 + end_timestamp=1577840400.0, # 2020-01-01 01:00:00 span_id="fa90fdead5f74052", parent_span_id=segment_span["span_id"], span_op="http", ), build_mock_span( project_id=1, - start_timestamp_precise=1577844000.0, # 2020-01-01 02:00:00 - end_timestamp_precise=1577847600.0, # 2020-01-01 03:00:00 + start_timestamp=1577844000.0, # 2020-01-01 02:00:00 + end_timestamp=1577847600.0, # 2020-01-01 03:00:00 span_id="bbbbbbbbbbbbbbbb", parent_span_id=segment_span["span_id"], span_op="db", ), build_mock_span( project_id=1, - start_timestamp_precise=1577845800.0, # 2020-01-01 02:30:00 - end_timestamp_precise=1577849400.0, # 2020-01-01 03:30:00 + start_timestamp=1577845800.0, # 2020-01-01 02:30:00 + end_timestamp=1577849400.0, # 2020-01-01 03:30:00 span_id="cccccccccccccccc", parent_span_id=segment_span["span_id"], span_op="db.postgres", ), build_mock_span( project_id=1, - start_timestamp_precise=1577851200.0, # 2020-01-01 04:00:00 - end_timestamp_precise=1577853000.0, # 2020-01-01 04:30:00 + start_timestamp=1577851200.0, # 2020-01-01 04:00:00 + end_timestamp=1577853000.0, # 2020-01-01 04:30:00 span_id="dddddddddddddddd", parent_span_id=segment_span["span_id"], span_op="db.mongo", ), build_mock_span( project_id=1, - start_timestamp_precise=1577854800.0, # 2020-01-01 05:00:00 - end_timestamp_precise=1577858400.01, # 2020-01-01 06:00:00.01 + start_timestamp=1577854800.0, # 2020-01-01 05:00:00 + end_timestamp=1577858400.01, # 2020-01-01 06:00:00.01 span_id="eeeeeeeeeeeeeeee", parent_span_id=segment_span["span_id"], span_op="browser", @@ -366,13 +385,13 @@ def test_emit_ops_breakdown() -> None: } # Compute breakdowns for the segment span - _ = TreeEnricher.enrich_spans(spans) + _, spans = TreeEnricher.enrich_spans(spans) updates = compute_breakdowns(spans, breakdowns_config) - assert updates["span_ops.ops.http"] == 3600000.0 - assert updates["span_ops.ops.db"] == 7200000.0 - assert updates["span_ops_2.ops.http"] == 3600000.0 - assert updates["span_ops_2.ops.db"] == 7200000.0 + assert updates["span_ops.ops.http"]["value"] == 3600000.0 + assert updates["span_ops.ops.db"]["value"] == 7200000.0 + assert updates["span_ops_2.ops.http"]["value"] == 3600000.0 + assert updates["span_ops_2.ops.db"]["value"] == 7200000.0 # NOTE: Relay used to extract a total.time breakdown, which is no longer # included in span breakdowns. @@ -384,29 +403,31 @@ def test_write_tags_for_performance_issue_detection(): segment_span = _mock_performance_issue_span( is_segment=True, span_id="ffffffffffffffff", - data={ - "sentry.sdk.name": "sentry.php.laravel", - "sentry.environment": "production", - "sentry.release": "1.0.0", - "sentry.platform": "php", + attributes={ + "sentry.sdk.name": {"value": "sentry.php.laravel"}, + "sentry.environment": {"value": "production"}, + "sentry.release": {"value": "1.0.0"}, + "sentry.platform": {"value": "php"}, }, ) spans = [ _mock_performance_issue_span( is_segment=False, - data={ - "sentry.system": "mongodb", - "sentry.normalized_description": '{"filter":{"productid":{"buffer":"?"}},"find":"reviews"}', + attributes={ + "sentry.system": {"value": "mongodb"}, + "sentry.normalized_description": { + "value": '{"filter":{"productid":{"buffer":"?"}},"find":"reviews"}' + }, }, ), segment_span, ] - _, spans = TreeEnricher.enrich_spans(spans) - spans = [make_compatible(span) for span in spans] + _, enriched_spans = TreeEnricher.enrich_spans(spans) + compatible_spans: list[CompatibleSpan] = [make_compatible(span) for span in enriched_spans] - child_span, segment_span = spans + child_span, segment_span = compatible_spans assert segment_span["sentry_tags"] == { "sdk.name": "sentry.php.laravel", @@ -425,24 +446,28 @@ def test_write_tags_for_performance_issue_detection(): } -def _mock_performance_issue_span(is_segment, data, **fields): - return { - "description": "OrganizationNPlusOne", - "duration_ms": 107, - "is_segment": is_segment, - "is_remote": is_segment, - "parent_span_id": None, - "profile_id": "dbae2b82559649a1a34a2878134a007b", - "project_id": 1, - "organization_id": 1, - "received": 1707953019.044972, - "retention_days": 90, - "segment_id": "a49b42af9fb69da0", - "data": data, - "span_id": "a49b42af9fb69da0", - "start_timestamp_ms": 1707953018865, - "start_timestamp_precise": 1707953018.865, - "end_timestamp_precise": 1707953018.972, - "trace_id": "94576097f3a64b68b85a59c7d4e3ee2a", - **fields, - } +def _mock_performance_issue_span(is_segment, attributes, **fields) -> SpanEvent: + return cast( + SpanEvent, + { + "duration_ms": 107, + "parent_span_id": None, + "profile_id": "dbae2b82559649a1a34a2878134a007b", + "project_id": 1, + "organization_id": 1, + "received": 1707953019.044972, + "retention_days": 90, + "segment_id": "a49b42af9fb69da0", + "attributes": { + **attributes, + "sentry.is_segment": {"type": "boolean", "value": is_segment}, + "sentry.description": {"type": "string", "value": "OrganizationNPlusOne"}, + }, + "span_id": "a49b42af9fb69da0", + "start_timestamp_ms": 1707953018865, + "start_timestamp": 1707953018.865, + "end_timestamp": 1707953018.972, + "trace_id": "94576097f3a64b68b85a59c7d4e3ee2a", + **fields, + }, + ) diff --git a/tests/sentry/spans/consumers/process_segments/test_message.py b/tests/sentry/spans/consumers/process_segments/test_message.py index d1914c064b0854..20574a3b0c873d 100644 --- a/tests/sentry/spans/consumers/process_segments/test_message.py +++ b/tests/sentry/spans/consumers/process_segments/test_message.py @@ -24,12 +24,14 @@ def generate_basic_spans(self): segment_span = build_mock_span( project_id=self.project.id, is_segment=True, - data={ - "sentry.browser.name": "Google Chrome", - "sentry.transaction": "/api/0/organizations/{organization_id_or_slug}/n-plus-one/", - "sentry.transaction.method": "GET", - "sentry.transaction.op": "http.server", - "sentry.user": "id:1", + attributes={ + "sentry.browser.name": {"value": "Google Chrome"}, + "sentry.transaction": { + "value": "/api/0/organizations/{organization_id_or_slug}/n-plus-one/" + }, + "sentry.transaction.method": {"value": "GET"}, + "sentry.transaction.op": {"value": "http.server"}, + "sentry.user": {"value": "id:1"}, }, ) child_span = build_mock_span( @@ -38,7 +40,7 @@ def generate_basic_spans(self): parent_span_id=segment_span["span_id"], span_id="940ce942561548b5", start_timestamp_ms=1707953018867, - start_timestamp_precise=1707953018.867, + start_timestamp=1707953018.867, ) return [child_span, segment_span] @@ -55,7 +57,7 @@ def generate_n_plus_one_spans(self): parent_span_id=segment_span["span_id"], span_id="940ce942561548b5", start_timestamp_ms=1707953018867, - start_timestamp_precise=1707953018.867, + start_timestamp=1707953018.867, ) cause_span = build_mock_span( project_id=self.project.id, @@ -64,7 +66,7 @@ def generate_n_plus_one_spans(self): parent_span_id="940ce942561548b5", span_id="a974da4671bc3857", start_timestamp_ms=1707953018867, - start_timestamp_precise=1707953018.867, + start_timestamp=1707953018.867, ) repeating_span_description = 'SELECT "sentry_organization"."id", "sentry_organization"."name", "sentry_organization"."slug", "sentry_organization"."status", "sentry_organization"."date_added", "sentry_organization"."default_role", "sentry_organization"."is_test", "sentry_organization"."flags" FROM "sentry_organization" WHERE "sentry_organization"."id" = %s LIMIT 21' @@ -76,7 +78,7 @@ def repeating_span(): parent_span_id="940ce942561548b5", span_id=uuid.uuid4().hex[:16], start_timestamp_ms=1707953018869, - start_timestamp_precise=1707953018.869, + start_timestamp=1707953018.869, ) repeating_spans = [repeating_span() for _ in range(7)] @@ -90,19 +92,19 @@ def test_enrich_spans(self) -> None: assert len(processed_spans) == len(spans) child_span, segment_span = processed_spans - child_data = child_span["data"] - segment_data = segment_span["data"] + child_attrs = child_span["attributes"] + segment_data = segment_span["attributes"] - assert child_data["sentry.transaction"] == segment_data["sentry.transaction"] - assert child_data["sentry.transaction.method"] == segment_data["sentry.transaction.method"] - assert child_data["sentry.transaction.op"] == segment_data["sentry.transaction.op"] - assert child_data["sentry.user"] == segment_data["sentry.user"] + assert child_attrs["sentry.transaction"] == segment_data["sentry.transaction"] + assert child_attrs["sentry.transaction.method"] == segment_data["sentry.transaction.method"] + assert child_attrs["sentry.transaction.op"] == segment_data["sentry.transaction.op"] + assert child_attrs["sentry.user"] == segment_data["sentry.user"] def test_enrich_spans_no_segment(self) -> None: spans = self.generate_basic_spans() for span in spans: span["is_segment"] = False - del span["data"] + del span["attributes"] processed_spans = process_segment(spans) assert len(processed_spans) == len(spans) @@ -124,7 +126,7 @@ def test_create_models(self) -> None: organization_id=self.organization.id, version="backend@24.2.0.dev0+699ce0cd1281cc3c7275d0a474a595375c769ae8", ) - assert release.date_added.timestamp() == spans[0]["end_timestamp_precise"] + assert release.date_added.timestamp() == spans[0]["end_timestamp"] @override_options({"spans.process-segments.detect-performance-problems.enable": True}) @mock.patch("sentry.issues.ingest.send_issue_occurrence_to_eventstream") @@ -160,7 +162,7 @@ def test_n_plus_one_issue_detection_without_segment_span( parent_span_id="b35b839c02985f33", span_id="940ce942561548b5", start_timestamp_ms=1707953018867, - start_timestamp_precise=1707953018.867, + start_timestamp=1707953018.867, ) cause_span = build_mock_span( project_id=self.project.id, @@ -170,7 +172,7 @@ def test_n_plus_one_issue_detection_without_segment_span( parent_span_id="940ce942561548b5", span_id="a974da4671bc3857", start_timestamp_ms=1707953018867, - start_timestamp_precise=1707953018.867, + start_timestamp=1707953018.867, ) repeating_span_description = 'SELECT "sentry_organization"."id", "sentry_organization"."name", "sentry_organization"."slug", "sentry_organization"."status", "sentry_organization"."date_added", "sentry_organization"."default_role", "sentry_organization"."is_test", "sentry_organization"."flags" FROM "sentry_organization" WHERE "sentry_organization"."id" = %s LIMIT 21' @@ -183,7 +185,7 @@ def repeating_span(): parent_span_id="940ce942561548b5", span_id=uuid.uuid4().hex[:16], start_timestamp_ms=1707953018869, - start_timestamp_precise=1707953018.869, + start_timestamp=1707953018.869, ) repeating_spans = [repeating_span() for _ in range(7)] @@ -227,9 +229,9 @@ def test_record_signals(self, mock_track): project_id=self.project.id, is_segment=True, span_op="http.client", - data={ - "sentry.op": "http.client", - "sentry.category": "http", + attributes={ + "sentry.op": {"value": "http.client"}, + "sentry.category": {"value": "http"}, }, ) spans = process_segment([span]) diff --git a/tests/sentry/spans/consumers/process_segments/test_shim.py b/tests/sentry/spans/consumers/process_segments/test_shim.py index b923bda992d2d8..77404ab0fe0a73 100644 --- a/tests/sentry/spans/consumers/process_segments/test_shim.py +++ b/tests/sentry/spans/consumers/process_segments/test_shim.py @@ -1,15 +1,22 @@ from typing import cast +from sentry_kafka_schemas.schema_types.ingest_spans_v1 import SpanEvent + from sentry.spans.consumers.process_segments.shim import make_compatible -from sentry.spans.consumers.process_segments.types import EnrichedSpan +from sentry.spans.consumers.process_segments.types import Attributes from tests.sentry.spans.consumers.process_segments.test_convert import SPAN_KAFKA_MESSAGE def test_make_compatible(): - message = cast(EnrichedSpan, {**SPAN_KAFKA_MESSAGE, "sentry_tags": {"ignored": "tags"}}) - compatible = make_compatible(message) - assert compatible["exclusive_time"] == message["exclusive_time_ms"] - assert compatible["op"] == message["data"]["sentry.op"] + message = {**SPAN_KAFKA_MESSAGE} + attributes: Attributes = { + "sentry.exclusive_time_ms": {"type": "double", "value": 100.0}, + **message["attributes"], # type:ignore[dict-item] + } + message["attributes"] = attributes + compatible = make_compatible(cast(SpanEvent, message)) + assert compatible["exclusive_time"] == 100.0 + assert compatible["op"] == message["attributes"]["sentry.op"]["value"] # type: ignore[index] # Pre-existing tags got overwritten: assert compatible["sentry_tags"] == { diff --git a/tests/sentry/spans/grouping/test_strategy.py b/tests/sentry/spans/grouping/test_strategy.py index 74565818493baf..4d398ce6d908f6 100644 --- a/tests/sentry/spans/grouping/test_strategy.py +++ b/tests/sentry/spans/grouping/test_strategy.py @@ -588,11 +588,16 @@ def test_default_2022_10_27_strategy(spans: list[Span], expected: Mapping[str, l def test_standalone_spans_compat() -> None: - spans = [ + spans_v1 = [ SpanBuilder().with_span_id("b" * 16).with_description("b" * 16).build(), SpanBuilder().with_span_id("c" * 16).with_description("c" * 16).build(), SpanBuilder().with_span_id("d" * 16).with_description("d" * 16).build(), ] + spans_v2 = [ + SpanBuilder().with_span_id("b" * 16).with_description("b" * 16).build_v2(), + SpanBuilder().with_span_id("c" * 16).with_description("c" * 16).build_v2(), + SpanBuilder().with_span_id("d" * 16).with_description("d" * 16).build_v2(), + ] event = { "transaction": "transaction name", @@ -601,15 +606,15 @@ def test_standalone_spans_compat() -> None: "span_id": "a" * 16, }, }, - "spans": spans, + "spans": spans_v1, } - standalone_spans = spans + [ + standalone_spans = spans_v2 + [ SpanBuilder() .with_span_id("a" * 16) .segment() .with_data({"sentry.transaction": "transaction name"}) - .build() + .build_v2() ] cfg = CONFIGURATIONS[DEFAULT_CONFIG_ID] diff --git a/tests/sentry/spans/test_buffer.py b/tests/sentry/spans/test_buffer.py index 7a22a4e8f3bb9d..e55463733a6d92 100644 --- a/tests/sentry/spans/test_buffer.py +++ b/tests/sentry/spans/test_buffer.py @@ -46,8 +46,10 @@ def _output_segment(span_id: bytes, segment_id: bytes, is_segment: bool) -> Outp return OutputSpan( payload={ "span_id": span_id.decode("ascii"), - "segment_id": segment_id.decode("ascii"), - "is_segment": is_segment, + "attributes": { + "sentry.segment.id": {"type": "string", "value": segment_id.decode("ascii")}, + "sentry.is_segment": {"type": "boolean", "value": is_segment}, + }, } ) @@ -137,7 +139,7 @@ def process_spans(spans: Sequence[Span | _SplitBatch], buffer: SpansBuffer, now) parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("d" * 16), @@ -146,7 +148,7 @@ def process_spans(spans: Sequence[Span | _SplitBatch], buffer: SpansBuffer, now) parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("c" * 16), @@ -155,7 +157,7 @@ def process_spans(spans: Sequence[Span | _SplitBatch], buffer: SpansBuffer, now) parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("b" * 16), @@ -165,7 +167,7 @@ def process_spans(spans: Sequence[Span | _SplitBatch], buffer: SpansBuffer, now) segment_id=None, is_segment_span=True, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), ] ) @@ -210,7 +212,7 @@ def test_basic(buffer: SpansBuffer, spans) -> None: parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), _SplitBatch(), Span( @@ -220,7 +222,7 @@ def test_basic(buffer: SpansBuffer, spans) -> None: parent_span_id="a" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("a" * 16), @@ -230,7 +232,7 @@ def test_basic(buffer: SpansBuffer, spans) -> None: is_segment_span=True, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("c" * 16), @@ -239,7 +241,7 @@ def test_basic(buffer: SpansBuffer, spans) -> None: parent_span_id="a" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), ] ) @@ -284,7 +286,7 @@ def test_deep(buffer: SpansBuffer, spans) -> None: parent_span_id="d" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("d" * 16), @@ -293,7 +295,7 @@ def test_deep(buffer: SpansBuffer, spans) -> None: parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("b" * 16), @@ -302,7 +304,7 @@ def test_deep(buffer: SpansBuffer, spans) -> None: parent_span_id="c" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("c" * 16), @@ -311,7 +313,7 @@ def test_deep(buffer: SpansBuffer, spans) -> None: parent_span_id="a" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("a" * 16), @@ -321,7 +323,7 @@ def test_deep(buffer: SpansBuffer, spans) -> None: is_segment_span=True, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), ] ) @@ -367,7 +369,7 @@ def test_deep2(buffer: SpansBuffer, spans) -> None: parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("d" * 16), @@ -376,7 +378,7 @@ def test_deep2(buffer: SpansBuffer, spans) -> None: parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("e" * 16), @@ -385,7 +387,7 @@ def test_deep2(buffer: SpansBuffer, spans) -> None: parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("b" * 16), @@ -395,7 +397,7 @@ def test_deep2(buffer: SpansBuffer, spans) -> None: is_segment_span=True, segment_id=None, project_id=2, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), ] ) @@ -448,7 +450,7 @@ def test_parent_in_other_project(buffer: SpansBuffer, spans) -> None: project_id=1, segment_id=None, is_segment_span=True, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("d" * 16), @@ -457,7 +459,7 @@ def test_parent_in_other_project(buffer: SpansBuffer, spans) -> None: parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("e" * 16), @@ -466,7 +468,7 @@ def test_parent_in_other_project(buffer: SpansBuffer, spans) -> None: parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("b" * 16), @@ -476,7 +478,7 @@ def test_parent_in_other_project(buffer: SpansBuffer, spans) -> None: is_segment_span=True, segment_id=None, project_id=2, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), ] ), @@ -532,7 +534,7 @@ def test_flush_rebalance(buffer: SpansBuffer) -> None: segment_id=None, project_id=1, is_segment_span=True, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ) ] @@ -582,7 +584,7 @@ def make_payload(span_id: str): project_id=1, segment_id=None, is_segment_span=True, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=make_payload("a" * 16), @@ -591,7 +593,7 @@ def make_payload(span_id: str): parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=make_payload("c" * 16), @@ -600,7 +602,7 @@ def make_payload(span_id: str): parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), ] @@ -640,7 +642,7 @@ def test_max_segment_spans_limit(buffer: SpansBuffer) -> None: parent_span_id="b" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000001.0, + end_timestamp=1700000001.0, ), Span( payload=_payload("b" * 16), @@ -649,7 +651,7 @@ def test_max_segment_spans_limit(buffer: SpansBuffer) -> None: parent_span_id="a" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000002.0, + end_timestamp=1700000002.0, ), ] batch2 = [ @@ -660,7 +662,7 @@ def test_max_segment_spans_limit(buffer: SpansBuffer) -> None: parent_span_id="a" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000003.0, + end_timestamp=1700000003.0, ), Span( payload=_payload("e" * 16), @@ -669,7 +671,7 @@ def test_max_segment_spans_limit(buffer: SpansBuffer) -> None: parent_span_id="a" * 16, segment_id=None, project_id=1, - end_timestamp_precise=1700000004.0, + end_timestamp=1700000004.0, ), Span( payload=_payload("a" * 16), @@ -679,7 +681,7 @@ def test_max_segment_spans_limit(buffer: SpansBuffer) -> None: project_id=1, segment_id=None, is_segment_span=True, - end_timestamp_precise=1700000005.0, + end_timestamp=1700000005.0, ), ] @@ -716,7 +718,7 @@ def test_kafka_slice_id(buffer: SpansBuffer) -> None: project_id=1, segment_id=None, is_segment_span=True, - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ) ] @@ -742,7 +744,7 @@ def test_preassigned_disconnected_segment(buffer: SpansBuffer) -> None: parent_span_id="c" * 16, # does not exist in this segment project_id=1, segment_id="a" * 16, # refers to the correct span below - end_timestamp_precise=1700000000.0, + end_timestamp=1700000000.0, ), Span( payload=_payload("a" * 16), @@ -752,7 +754,7 @@ def test_preassigned_disconnected_segment(buffer: SpansBuffer) -> None: project_id=1, segment_id="a" * 16, is_segment_span=True, - end_timestamp_precise=1700000001.0, + end_timestamp=1700000001.0, ), ] diff --git a/uv.lock b/uv.lock index 3d6b38fb8acf91..13c7984cdabe15 100644 --- a/uv.lock +++ b/uv.lock @@ -2069,11 +2069,11 @@ requires-dist = [ { name = "rfc3986-validator", specifier = ">=0.1.1" }, { name = "sentry-arroyo", specifier = ">=2.25.5" }, { name = "sentry-forked-email-reply-parser", specifier = ">=0.5.12.post1" }, - { name = "sentry-kafka-schemas", specifier = ">=2.1.3" }, + { name = "sentry-kafka-schemas", specifier = ">=2.1.6" }, { name = "sentry-ophio", specifier = ">=1.1.3" }, { name = "sentry-protos", specifier = ">=0.4.0" }, { name = "sentry-redis-tools", specifier = ">=0.5.0" }, - { name = "sentry-relay", specifier = ">=0.9.15" }, + { name = "sentry-relay", specifier = ">=0.9.16" }, { name = "sentry-sdk", extras = ["http2"], specifier = ">=2.35.1" }, { name = "sentry-usage-accountant", specifier = ">=0.0.10" }, { name = "setuptools", specifier = ">=70.0.0" }, @@ -2240,7 +2240,7 @@ wheels = [ [[package]] name = "sentry-kafka-schemas" -version = "2.1.3" +version = "2.1.6" source = { registry = "https://pypi.devinfra.sentry.io/simple" } dependencies = [ { name = "fastjsonschema", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2251,7 +2251,7 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] wheels = [ - { url = "https://pypi.devinfra.sentry.io/wheels/sentry_kafka_schemas-2.1.3-py2.py3-none-any.whl", hash = "sha256:bf294c727d66fef81d24602600495933dccdefa625430f7938f99b9a252e5fbb" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_kafka_schemas-2.1.6-py2.py3-none-any.whl", hash = "sha256:385e43b268c81a822fd88fc797f6143d79e3b4c9de86ce67a974b07ddbdcb25f" }, ] [[package]] @@ -2291,16 +2291,16 @@ wheels = [ [[package]] name = "sentry-relay" -version = "0.9.15" +version = "0.9.16" source = { registry = "https://pypi.devinfra.sentry.io/simple" } dependencies = [ { name = "milksnake", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] wheels = [ - { url = "https://pypi.devinfra.sentry.io/wheels/sentry_relay-0.9.15-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:034cd4ea3549fad77bd1743e9327af85ca1d6daf8289cbf2f46263921587dfc5" }, - { url = "https://pypi.devinfra.sentry.io/wheels/sentry_relay-0.9.15-py2.py3-none-macosx_14_0_arm64.whl", hash = "sha256:a736413c89784f48f589b8ec0c5611c90d3e4b479c7b6200956a720a8f51f64d" }, - { url = "https://pypi.devinfra.sentry.io/wheels/sentry_relay-0.9.15-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3bd2959e8496a7bddfd72a4722ba268768a09e8d1493d3e541318cc23cb89bc3" }, - { url = "https://pypi.devinfra.sentry.io/wheels/sentry_relay-0.9.15-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:b3cdf49c23cebd6fd3f821cb4f099abc269e38a387ebaed29b8bf721487291cc" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_relay-0.9.16-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:141b0b5ffdfec5194a1bc7851e0f4d9304a090589ed3c181bac6b85b9c7db142" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_relay-0.9.16-py2.py3-none-macosx_14_0_arm64.whl", hash = "sha256:59e6ff9cd52b6b9976c866adb33a1b4ffc9b1e545816010169601d76953c3cb7" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_relay-0.9.16-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:005c3dc3a97c49fda0b481a08664d02e8e9ade1cac99c816febca3f836a1856c" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_relay-0.9.16-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:255c5f40c7b480df42e4899e0ddaf4fccd1edfd3450b78f237cbe3e473e7b4ff" }, ] [[package]]