diff --git a/src/sentry/api/endpoints/organization_spans_fields.py b/src/sentry/api/endpoints/organization_spans_fields.py index 259496d1d69f2a..6c55d1d1417b96 100644 --- a/src/sentry/api/endpoints/organization_spans_fields.py +++ b/src/sentry/api/endpoints/organization_spans_fields.py @@ -184,7 +184,7 @@ def get(self, request: Request, organization: Organization, key: str) -> Respons with handle_query_errors(): tag_values = executor.execute() - tag_values.sort(key=lambda tag: tag.value) + tag_values.sort(key=lambda tag: tag.value or "") paginator = ChainPaginator([tag_values], max_limit=max_span_tag_values) diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 3fc0061c4ff91b..856d13677a9964 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -375,7 +375,7 @@ def data_fn(offset: int, limit: int): with handle_query_errors(): tag_values = executor.execute() - tag_values.sort(key=lambda tag: tag.value) + tag_values.sort(key=lambda tag: tag.value or "") return tag_values return self.paginate( diff --git a/src/sentry/data_export/processors/issues_by_tag.py b/src/sentry/data_export/processors/issues_by_tag.py index c148127d9a09c1..35f279e13e0068 100644 --- a/src/sentry/data_export/processors/issues_by_tag.py +++ b/src/sentry/data_export/processors/issues_by_tag.py @@ -113,7 +113,12 @@ def get_raw_data(self, limit: int = 1000, offset: int = 0) -> list[GroupTagValue users = EventUser.for_tags(self.group.project_id, [i.value for i in items]) else: users = {} - return [GroupTagValueAndEventUser(item, users.get(item.value)) for item in items] + return [ + GroupTagValueAndEventUser( + item, users.get(item.value) if item.value is not None else None + ) + for item in items + ] def get_serialized_data(self, limit: int = 1000, offset: int = 0) -> list[dict[str, str]]: """ diff --git a/src/sentry/search/utils.py b/src/sentry/search/utils.py index 9146ca6fb99885..7dc29d8dfabeb7 100644 --- a/src/sentry/search/utils.py +++ b/src/sentry/search/utils.py @@ -831,7 +831,7 @@ def convert_user_tag_to_query(key: str, value: str) -> str | None: Converts a user tag to a query string that can be used to search for that user. Returns None if not a user tag. """ - if key == "user" and ":" in value: + if key == "user" and value is not None and ":" in value: sub_key, value = value.split(":", 1) if KEYWORD_MAP.get_key(sub_key, None): return 'user.{}:"{}"'.format(sub_key, value.replace('"', '\\"')) diff --git a/src/sentry/tagstore/types.py b/src/sentry/tagstore/types.py index a668dbae50bc7c..a348a3f9f97c68 100644 --- a/src/sentry/tagstore/types.py +++ b/src/sentry/tagstore/types.py @@ -29,7 +29,16 @@ def __eq__(self, other: object) -> bool: ) def __lt__(self, other: object) -> bool: - return getattr(self, self._sort_key) < getattr(other, self._sort_key) + self_val = getattr(self, self._sort_key) + other_val = getattr(other, self._sort_key) + # Handle None values in sorting - None sorts before any non-None value + if self_val is None and other_val is None: + return False + if self_val is None: + return True + if other_val is None: + return False + return self_val < other_val def __getstate__(self) -> dict[str, Any]: return {name: getattr(self, name) for name in self.__slots__} @@ -68,7 +77,7 @@ class TagValue(TagType): def __init__( self, key: str, - value, + value: str | None, times_seen: int | None, first_seen: datetime | None, last_seen: datetime | None, @@ -107,7 +116,7 @@ def __init__( self, group_id: int, key: str, - value, + value: str | None, times_seen: int, first_seen, last_seen, diff --git a/src/sentry/utils/eventuser.py b/src/sentry/utils/eventuser.py index 08650162179523..4cbbb57c761cea 100644 --- a/src/sentry/utils/eventuser.py +++ b/src/sentry/utils/eventuser.py @@ -314,7 +314,7 @@ def from_snuba(result: Mapping[str, Any]) -> EventUser: @classmethod def for_tags( - cls: type[EventUser], project_id: int, values: Sequence[str] + cls: type[EventUser], project_id: int, values: Sequence[str | None] ) -> dict[str, EventUser]: """ Finds matching EventUser objects from a list of tag values. @@ -338,7 +338,7 @@ def for_tags( @classmethod def _process_tag_batch( - cls, projects: QuerySet[Project], values: Sequence[str] + cls, projects: QuerySet[Project], values: Sequence[str | None] ) -> dict[str, EventUser]: """ Process a single batch of tag values and return the matching EventUser objects. @@ -346,6 +346,10 @@ def _process_tag_batch( result = {} keyword_filters: dict[str, Any] = {} for value in values: + # Skip None values - these represent events without the tag set. + # There's no EventUser to look up for these cases. + if value is None: + continue key, value = value.split(":", 1)[0], value.split(":", 1)[-1] if keyword_filters.get(key): keyword_filters[key].append(value) diff --git a/tests/sentry/issues/endpoints/test_group_tagkey_values.py b/tests/sentry/issues/endpoints/test_group_tagkey_values.py index 09225f35bf46fe..dc0f30a02027ff 100644 --- a/tests/sentry/issues/endpoints/test_group_tagkey_values.py +++ b/tests/sentry/issues/endpoints/test_group_tagkey_values.py @@ -210,6 +210,39 @@ def test_includes_empty_values_backend_helpers(self) -> None: assert values.get("bar") == 1 assert values.get("") == 1 + def test_user_tag_with_empty_values(self) -> None: + """Test that user tags with empty values don't cause AttributeError.""" + project = self.create_project() + + # Event with user data + self.store_event( + data={ + "user": {"id": "user123"}, + "timestamp": before_now(seconds=1).isoformat(), + }, + project_id=project.id, + ) + # Event without user data (will have empty user tag) + event = self.store_event( + data={ + "timestamp": before_now(seconds=2).isoformat(), + }, + project_id=project.id, + ) + + group = event.group + + self.login_as(user=self.user) + + url = f"/api/0/issues/{group.id}/tags/user/values/" + + # This should not crash with AttributeError: 'NoneType' object has no attribute 'split' + response = self.client.get(url) + + assert response.status_code == 200 + # Should return at least the user with id, empty values may or may not be included + assert len(response.data) >= 1 + def test_count_sort(self) -> None: project = self.create_project() project.date_added = timezone.now() - timedelta(minutes=10)