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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/sentry/api/endpoints/organization_spans_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 6 additions & 1 deletion src/sentry/data_export/processors/issues_by_tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
"""
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/search/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('"', '\\"'))
Expand Down
15 changes: 12 additions & 3 deletions src/sentry/tagstore/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -107,7 +116,7 @@ def __init__(
self,
group_id: int,
key: str,
value,
value: str | None,
times_seen: int,
first_seen,
last_seen,
Expand Down
8 changes: 6 additions & 2 deletions src/sentry/utils/eventuser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -338,14 +338,18 @@ 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.
"""
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)
Expand Down
33 changes: 33 additions & 0 deletions tests/sentry/issues/endpoints/test_group_tagkey_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading