Browse Source

feat(semver): Support `release.package` search key (#27218)

This allows users to filter releases/events/transactions by release package via the `package`
keyword.
Dan Fuller 3 years ago
parent
commit
fd10bc58bf

+ 9 - 1
src/sentry/api/endpoints/organization_releases.py

@@ -26,8 +26,9 @@ from sentry.models import (
     ReleaseCommitError,
     ReleaseProject,
     ReleaseStatus,
+    SemverFilter,
 )
-from sentry.search.events.constants import SEMVER_ALIAS
+from sentry.search.events.constants import SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS
 from sentry.search.events.filter import parse_semver
 from sentry.signals import release_created
 from sentry.snuba.sessions import (
@@ -82,6 +83,13 @@ def _filter_releases_by_query(queryset, organization, query):
                 organization.id,
                 parse_semver(search_filter.value.raw_value, search_filter.operator),
             )
+
+        if search_filter.key.name == SEMVER_PACKAGE_ALIAS:
+            queryset = queryset.filter_by_semver(
+                organization.id,
+                SemverFilter("exact", [], search_filter.value.raw_value),
+            )
+
     return queryset
 
 

+ 2 - 2
src/sentry/api/release_search.py

@@ -1,13 +1,13 @@
 from functools import partial
 
 from sentry.api.event_search import SearchConfig, default_config, parse_search_query
-from sentry.search.events.constants import SEMVER_ALIAS
+from sentry.search.events.constants import SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS
 
 RELEASE_FREE_TEXT_KEY = "release"
 
 release_search_config = SearchConfig.create_from(
     default_config,
-    allowed_keys={SEMVER_ALIAS},
+    allowed_keys={SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS},
     allow_boolean=False,
     free_text_key=RELEASE_FREE_TEXT_KEY,
 )

+ 5 - 1
src/sentry/api/serializers/models/group.py

@@ -49,7 +49,7 @@ from sentry.notifications.helpers import (
 )
 from sentry.notifications.types import NotificationSettingOptionValues, NotificationSettingTypes
 from sentry.reprocessing2 import get_progress
-from sentry.search.events.constants import SEMVER_ALIAS
+from sentry.search.events.constants import SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS
 from sentry.search.events.filter import convert_search_filter_to_snuba_query
 from sentry.tagstore.snuba.backend import fix_tag_value_data
 from sentry.tsdb.snuba import SnubaTSDB
@@ -755,7 +755,11 @@ class GroupSerializerSnuba(GroupSerializerBase):
         # We don't need to filter by the semver query again here since we're
         # filtering to specific groups. Saves us making a second query to
         # postgres for no reason
+        # TODO: Above comment is wrong, we do need to filter by at least `SEMVER_ALIAS` here since
+        # groups can appear across releases. Probably unnecessary for the `SEMVER_PACKAGE_ALIAS`
+        # though, since package tends to be tied to a specific project.
         SEMVER_ALIAS,
+        SEMVER_PACKAGE_ALIAS,
     }
 
     def __init__(

+ 1 - 0
src/sentry/search/events/constants.py

@@ -14,6 +14,7 @@ ISSUE_ALIAS = "issue"
 ISSUE_ID_ALIAS = "issue.id"
 RELEASE_ALIAS = "release"
 SEMVER_ALIAS = "sentry.semver"
+SEMVER_PACKAGE_ALIAS = "release.package"
 TIMESTAMP_TO_HOUR_ALIAS = "timestamp.to_hour"
 TIMESTAMP_TO_DAY_ALIAS = "timestamp.to_day"
 

+ 30 - 0
src/sentry/search/events/filter.py

@@ -38,6 +38,7 @@ from sentry.search.events.constants import (
     SEMVER_EMPTY_RELEASE,
     SEMVER_FAKE_PACKAGE,
     SEMVER_MAX_SEARCH_RELEASES,
+    SEMVER_PACKAGE_ALIAS,
     SEMVER_WILDCARDS,
     TEAM_KEY_TRANSACTION_ALIAS,
     USER_DISPLAY_ALIAS,
@@ -408,6 +409,34 @@ def _semver_filter_converter(
     return ["release", final_operator, versions]
 
 
+def _semver_package_filter_converter(
+    search_filter: SearchFilter,
+    name: str,
+    params: Optional[Mapping[str, Union[int, str, datetime]]],
+) -> Tuple[str, str, Sequence[str]]:
+    """
+    Applies a semver package filter to the search. Note that if the query returns more than
+    `SEMVER_MAX_SEARCH_RELEASES` here we arbitrarily return a subset of the releases.
+    """
+    if not params or "organization_id" not in params:
+        raise ValueError("organization_id is a required param")
+
+    organization_id: int = params["organization_id"]
+    package: str = search_filter.value.raw_value
+
+    versions = list(
+        Release.objects.filter_by_semver(
+            organization_id, SemverFilter("exact", [], package)
+        ).values_list("version", flat=True)[:SEMVER_MAX_SEARCH_RELEASES]
+    )
+
+    if not versions:
+        # XXX: Just return a filter that will return no results if we have no versions
+        versions = [SEMVER_EMPTY_RELEASE]
+
+    return ["release", "IN", versions]
+
+
 def parse_semver(version, operator) -> Optional[SemverFilter]:
     """
     Attempts to parse a release version using our semver syntax. version should be in
@@ -474,6 +503,7 @@ key_conversion_map: Mapping[
     KEY_TRANSACTION_ALIAS: _key_transaction_filter_converter,
     TEAM_KEY_TRANSACTION_ALIAS: _team_key_transaction_filter_converter,
     SEMVER_ALIAS: _semver_filter_converter,
+    SEMVER_PACKAGE_ALIAS: _semver_package_filter_converter,
 }
 
 

+ 45 - 15
src/sentry/tagstore/snuba/backend.py

@@ -18,6 +18,7 @@ from sentry.models import (
 from sentry.search.events.constants import (
     PROJECT_ALIAS,
     SEMVER_ALIAS,
+    SEMVER_PACKAGE_ALIAS,
     SEMVER_WILDCARDS,
     USER_DISPLAY_ALIAS,
 )
@@ -689,6 +690,44 @@ class SnubaTagStorage(TagStorage):
             order_by=order_by,
         )
 
+    def _get_semver_versions_for_package(self, projects, organization_id, package):
+        packages = (
+            Release.objects.filter(organization_id=organization_id, package__startswith=package)
+            .values_list("package")
+            .distinct()
+        )
+
+        return Release.objects.filter(
+            organization_id=organization_id,
+            package__in=packages,
+            id__in=ReleaseProject.objects.filter(project_id__in=projects).values_list(
+                "release_id", flat=True
+            ),
+        ).annotate_prerelease_column()
+
+    def _get_tag_values_for_package(self, projects, environments, package):
+        from sentry.api.paginator import SequencePaginator
+
+        package = package if package else ""
+
+        organization_id = Project.objects.filter(id=projects[0]).values_list(
+            "organization_id", flat=True
+        )[0]
+        versions = self._get_semver_versions_for_package(projects, organization_id, package)
+        if environments:
+            versions = versions.filter(
+                id__in=ReleaseEnvironment.objects.filter(
+                    environment_id__in=environments
+                ).values_list("release_id", flat=True)
+            )
+        packages = versions.values_list("package", flat=True).distinct().order_by("package")[:1000]
+        return SequencePaginator(
+            [
+                (i, TagValue(SEMVER_PACKAGE_ALIAS, v, None, None, None))
+                for i, v in enumerate(packages)
+            ]
+        )
+
     def get_tag_value_paginator_for_projects(
         self,
         projects,
@@ -756,7 +795,11 @@ class SnubaTagStorage(TagStorage):
                 ]
             )
 
+        if key == SEMVER_PACKAGE_ALIAS:
+            return self._get_tag_values_for_package(projects, environments, query)
+
         if key == SEMVER_ALIAS:
+            # TODO: Split into function
             # If doing a search on semver, we want to hit postgres to query the releases
             query = query if query else ""
             organization_id = Project.objects.filter(id=projects[0]).values_list(
@@ -764,22 +807,9 @@ class SnubaTagStorage(TagStorage):
             )[0]
 
             if query and "@" not in query and re.search(r"[^\d.\*]", query):
-                include_package = True
                 # Handle searching just on package
-                packages = (
-                    Release.objects.filter(
-                        organization_id=organization_id, package__startswith=query
-                    )
-                    .values_list("package")
-                    .distinct()
-                )
-                versions = Release.objects.filter(
-                    organization_id=organization_id,
-                    package__in=packages,
-                    id__in=ReleaseProject.objects.filter(project_id__in=projects).values_list(
-                        "release_id", flat=True
-                    ),
-                ).annotate_prerelease_column()
+                include_package = True
+                versions = self._get_semver_versions_for_package(projects, organization_id, query)
             else:
                 include_package = not query or "@" in query
                 if not query:

+ 49 - 7
tests/sentry/api/endpoints/test_organization_releases.py

@@ -27,7 +27,7 @@ from sentry.models import (
     Repository,
 )
 from sentry.plugins.providers.dummy.repository import DummyRepositoryProvider
-from sentry.search.events.constants import SEMVER_ALIAS
+from sentry.search.events.constants import SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS
 from sentry.testutils import APITestCase, ReleaseCommitPatchTest, SetRefsTestCase, TestCase
 from sentry.utils.compat.mock import patch
 
@@ -253,21 +253,44 @@ class OrganizationReleaseListTest(APITestCase):
 
         release_1 = self.create_release(version="test@1.2.4")
         release_2 = self.create_release(version="test@1.2.3")
+        release_3 = self.create_release(version="test2@1.2.5")
         self.create_release(version="some.release")
 
         response = self.get_valid_response(self.organization.slug, query=f"{SEMVER_ALIAS}:>1.2.3")
-        assert [r["version"] for r in response.data] == [release_1.version]
+        assert [r["version"] for r in response.data] == [release_3.version, release_1.version]
 
         response = self.get_valid_response(self.organization.slug, query=f"{SEMVER_ALIAS}:>=1.2.3")
-        assert [r["version"] for r in response.data] == [release_2.version, release_1.version]
+        assert [r["version"] for r in response.data] == [
+            release_3.version,
+            release_2.version,
+            release_1.version,
+        ]
 
         response = self.get_valid_response(self.organization.slug, query=f"{SEMVER_ALIAS}:1.2.*")
+        assert [r["version"] for r in response.data] == [
+            release_3.version,
+            release_2.version,
+            release_1.version,
+        ]
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{SEMVER_PACKAGE_ALIAS}:test2"
+        )
+        assert [r["version"] for r in response.data] == [release_3.version]
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{SEMVER_PACKAGE_ALIAS}:test"
+        )
         assert [r["version"] for r in response.data] == [release_2.version, release_1.version]
 
         response = self.get_valid_response(
             self.organization.slug, query=f"{SEMVER_ALIAS}:>=1.2.3", sort="semver"
         )
-        assert [r["version"] for r in response.data] == [release_1.version, release_2.version]
+        assert [r["version"] for r in response.data] == [
+            release_3.version,
+            release_1.version,
+            release_2.version,
+        ]
 
         response = self.get_valid_response(self.organization.slug, query=f"{SEMVER_ALIAS}:2.2.1")
         assert [r["version"] for r in response.data] == []
@@ -540,20 +563,39 @@ class OrganizationReleaseStatsTest(APITestCase):
 
         release_1 = self.create_release(version="test@1.2.4")
         release_2 = self.create_release(version="test@1.2.3")
+        release_3 = self.create_release(version="test2@1.2.5")
         self.create_release(version="some.release")
 
         response = self.get_valid_response(self.organization.slug, query=f"{SEMVER_ALIAS}:>1.2.3")
-        assert [r["version"] for r in response.data] == [release_1.version]
+        assert [r["version"] for r in response.data] == [release_3.version, release_1.version]
 
         response = self.get_valid_response(self.organization.slug, query=f"{SEMVER_ALIAS}:>=1.2.3")
-        assert [r["version"] for r in response.data] == [release_2.version, release_1.version]
+        assert [r["version"] for r in response.data] == [
+            release_3.version,
+            release_2.version,
+            release_1.version,
+        ]
 
         response = self.get_valid_response(self.organization.slug, query=f"{SEMVER_ALIAS}:1.2.*")
-        assert [r["version"] for r in response.data] == [release_2.version, release_1.version]
+        assert [r["version"] for r in response.data] == [
+            release_3.version,
+            release_2.version,
+            release_1.version,
+        ]
 
         response = self.get_valid_response(self.organization.slug, query=f"{SEMVER_ALIAS}:2.2.1")
         assert [r["version"] for r in response.data] == []
 
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{SEMVER_PACKAGE_ALIAS}:test2"
+        )
+        assert [r["version"] for r in response.data] == [release_3.version]
+
+        response = self.get_valid_response(
+            self.organization.slug, query=f"{SEMVER_PACKAGE_ALIAS}:test"
+        )
+        assert [r["version"] for r in response.data] == [release_2.version, release_1.version]
+
     def test_query_filter(self):
         self.login_as(user=self.user)
 

+ 45 - 6
tests/sentry/search/events/test_filter.py

@@ -9,9 +9,14 @@ from sentry_relay.consts import SPAN_STATUS_CODE_TO_NAME
 
 from sentry.api.event_search import SearchFilter, SearchKey, SearchValue
 from sentry.models.release import SemverFilter
-from sentry.search.events.constants import SEMVER_ALIAS, SEMVER_EMPTY_RELEASE
+from sentry.search.events.constants import SEMVER_ALIAS, SEMVER_EMPTY_RELEASE, SEMVER_PACKAGE_ALIAS
 from sentry.search.events.fields import Function, FunctionArg, InvalidSearchQuery, with_default
-from sentry.search.events.filter import _semver_filter_converter, get_filter, parse_semver
+from sentry.search.events.filter import (
+    _semver_filter_converter,
+    _semver_package_filter_converter,
+    get_filter,
+    parse_semver,
+)
 from sentry.testutils.cases import TestCase
 from sentry.testutils.helpers.datetime import before_now
 from sentry.utils.snuba import OPERATOR_TO_FUNCTION
@@ -1433,7 +1438,7 @@ class FunctionTest(unittest.TestCase):
 
 class SemverFilterConverterTest(TestCase):
     def test_invalid_params(self):
-        key = "semver"
+        key = SEMVER_ALIAS
         filter = SearchFilter(SearchKey(key), ">", SearchValue("1.2.3"))
         with pytest.raises(ValueError, match="organization_id is a required param"):
             _semver_filter_converter(filter, key, None)
@@ -1441,7 +1446,7 @@ class SemverFilterConverterTest(TestCase):
             _semver_filter_converter(filter, key, {"something": 1})
 
     def test_invalid_query(self):
-        key = "semver"
+        key = SEMVER_ALIAS
         filter = SearchFilter(SearchKey(key), ">", SearchValue("1.2.hi"))
         with pytest.raises(InvalidSearchQuery, match="Invalid format for semver query"):
             _semver_filter_converter(filter, key, {"organization_id": self.organization.id})
@@ -1450,7 +1455,7 @@ class SemverFilterConverterTest(TestCase):
         self, operator, version, expected_operator, expected_releases, organization_id=None
     ):
         organization_id = organization_id if organization_id else self.organization.id
-        key = "semver"
+        key = SEMVER_ALIAS
         filter = SearchFilter(SearchKey(key), operator, SearchValue(version))
         assert _semver_filter_converter(filter, key, {"organization_id": organization_id}) == [
             "release",
@@ -1553,7 +1558,7 @@ class SemverFilterConverterTest(TestCase):
         self.run_test("=", "1.2.3.4", "IN", [release_4.version])
         self.run_test("=", "2.*", "IN", [release_5.version])
 
-    def test_multi_packagae(self):
+    def test_multi_package(self):
         release_1 = self.create_release(version="test@1.0.0.0")
         release_2 = self.create_release(version="test@1.2.0.0")
         release_3 = self.create_release(version="test_2@1.2.3.0")
@@ -1562,6 +1567,40 @@ class SemverFilterConverterTest(TestCase):
         self.run_test(">", "test_2@1.0", "IN", [release_3.version])
 
 
+class SemverPackageFilterConverterTest(TestCase):
+    def run_test(
+        self, operator, version, expected_operator, expected_releases, organization_id=None
+    ):
+        organization_id = organization_id if organization_id else self.organization.id
+        key = SEMVER_ALIAS
+        filter = SearchFilter(SearchKey(key), operator, SearchValue(version))
+        converted = _semver_package_filter_converter(
+            filter, key, {"organization_id": organization_id}
+        )
+        assert converted[0] == "release"
+        assert converted[1] == expected_operator
+        assert set(converted[2]) == set(expected_releases)
+
+    def test_invalid_params(self):
+        key = SEMVER_PACKAGE_ALIAS
+        filter = SearchFilter(SearchKey(key), "=", SearchValue("sentry"))
+        with pytest.raises(ValueError, match="organization_id is a required param"):
+            _semver_filter_converter(filter, key, None)
+        with pytest.raises(ValueError, match="organization_id is a required param"):
+            _semver_filter_converter(filter, key, {"something": 1})
+
+    def test_empty(self):
+        self.run_test("=", "test", "IN", [SEMVER_EMPTY_RELEASE])
+
+    def test(self):
+        release = self.create_release(version="test@1.2.3")
+        release_2 = self.create_release(version="test@1.2.4")
+        release_3 = self.create_release(version="test2@1.2.4")
+        self.run_test("=", "test", "IN", [release.version, release_2.version])
+        self.run_test("=", "test2", "IN", [release_3.version])
+        self.run_test("=", "test3", "IN", [SEMVER_EMPTY_RELEASE])
+
+
 class ParseSemverTest(unittest.TestCase):
     def run_test(self, version: str, operator: str, expected: SemverFilter):
         semver_filter = parse_semver(version, operator)

+ 36 - 1
tests/sentry/snuba/test_discover.py

@@ -9,7 +9,7 @@ from sentry.models.transaction_threshold import (
     ProjectTransactionThresholdOverride,
     TransactionMetric,
 )
-from sentry.search.events.constants import SEMVER_ALIAS
+from sentry.search.events.constants import SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS
 from sentry.snuba import discover
 from sentry.testutils import SnubaTestCase, TestCase
 from sentry.testutils.helpers.datetime import before_now, iso_format
@@ -558,6 +558,41 @@ class QueryIntegrationTest(SnubaTestCase, TestCase):
         )
         assert {r["id"] for r in result["data"]} == {release_1_e_1, release_1_e_2}
 
+    def test_semver_package_condition(self):
+        release_1 = self.create_release(version="test@1.2.3")
+        release_2 = self.create_release(version="test2@1.2.4")
+
+        release_1_e_1 = self.store_event(
+            data={"release": release_1.version},
+            project_id=self.project.id,
+        ).event_id
+        release_1_e_2 = self.store_event(
+            data={"release": release_1.version},
+            project_id=self.project.id,
+        ).event_id
+        release_2_e_1 = self.store_event(
+            data={"release": release_2.version},
+            project_id=self.project.id,
+        ).event_id
+
+        result = discover.query(
+            selected_columns=["id"],
+            query=f"{SEMVER_PACKAGE_ALIAS}:test",
+            params={"project_id": [self.project.id], "organization_id": self.organization.id},
+        )
+        assert {r["id"] for r in result["data"]} == {
+            release_1_e_1,
+            release_1_e_2,
+        }
+        result = discover.query(
+            selected_columns=["id"],
+            query=f"{SEMVER_PACKAGE_ALIAS}:test2",
+            params={"project_id": [self.project.id], "organization_id": self.organization.id},
+        )
+        assert {r["id"] for r in result["data"]} == {
+            release_2_e_1,
+        }
+
     def test_latest_release_condition(self):
         result = discover.query(
             selected_columns=["id", "message"],

+ 33 - 1
tests/snuba/api/endpoints/test_organization_events_v2.py

@@ -10,7 +10,7 @@ from sentry.models.transaction_threshold import (
     ProjectTransactionThresholdOverride,
     TransactionMetric,
 )
-from sentry.search.events.constants import SEMVER_ALIAS
+from sentry.search.events.constants import SEMVER_ALIAS, SEMVER_PACKAGE_ALIAS
 from sentry.testutils import APITestCase, SnubaTestCase
 from sentry.testutils.helpers import parse_link_header
 from sentry.testutils.helpers.datetime import before_now, iso_format
@@ -817,6 +817,38 @@ class OrganizationEventsV2EndpointTest(APITestCase, SnubaTestCase):
             release_1_e_2,
         }
 
+    def test_semver_package(self):
+        release_1 = self.create_release(version="test@1.2.3")
+        release_2 = self.create_release(version="test2@1.2.4")
+
+        release_1_e_1 = self.store_event(
+            data={"release": release_1.version, "timestamp": self.min_ago},
+            project_id=self.project.id,
+        ).event_id
+        release_1_e_2 = self.store_event(
+            data={"release": release_1.version, "timestamp": self.min_ago},
+            project_id=self.project.id,
+        ).event_id
+        release_2_e_1 = self.store_event(
+            data={"release": release_2.version, "timestamp": self.min_ago},
+            project_id=self.project.id,
+        ).event_id
+
+        query = {"field": ["id"], "query": f"{SEMVER_PACKAGE_ALIAS}:test"}
+        response = self.do_request(query)
+        assert response.status_code == 200, response.content
+        assert {r["id"] for r in response.data["data"]} == {
+            release_1_e_1,
+            release_1_e_2,
+        }
+
+        query = {"field": ["id"], "query": f"{SEMVER_PACKAGE_ALIAS}:test2"}
+        response = self.do_request(query)
+        assert response.status_code == 200, response.content
+        assert {r["id"] for r in response.data["data"]} == {
+            release_2_e_1,
+        }
+
     def test_aliased_fields(self):
         project = self.create_project()
         event1 = self.store_event(

Some files were not shown because too many files changed in this diff