Repository: getsentry/sentry

Issue:
Update formula
Add the v2 (via a query param like `v2=True` or something) [formula](https://www.notion.so/sentry/Better-Priority-Sort-7bc3b056786b4540ad325586064a7b51#d7e7001f2108455ca9b8ea3d84512266) so we can test out both. 


Discussion:

Resolving patch:
diff --git a/src/sentry/api/endpoints/organization_group_index.py b/src/sentry/api/endpoints/organization_group_index.py
--- a/src/sentry/api/endpoints/organization_group_index.py
+++ b/src/sentry/api/endpoints/organization_group_index.py
@@ -38,7 +38,12 @@
 )
 from sentry.search.events.constants import EQUALITY_OPERATORS
 from sentry.search.snuba.backend import assigned_or_suggested_filter
-from sentry.search.snuba.executors import DEFAULT_PRIORITY_WEIGHTS, get_search_filter
+from sentry.search.snuba.executors import (
+    DEFAULT_PRIORITY_WEIGHTS,
+    V2_DEFAULT_PRIORITY_WEIGHTS,
+    PrioritySortWeights,
+    get_search_filter,
+)
 from sentry.snuba import discover
 from sentry.types.ratelimit import RateLimit, RateLimitCategory
 from sentry.utils.cursors import Cursor, CursorResult
@@ -160,20 +165,51 @@ class OrganizationGroupIndexEndpoint(OrganizationEventsEndpointBase):
         },
     }
 
-    def build_better_priority_sort_kwargs(self, request: Request):
-        """Temporary function to be used while developing the new priority sort"""
-        return {
-            "better_priority": {
-                "log_level": request.GET.get("logLevel", DEFAULT_PRIORITY_WEIGHTS["log_level"]),
-                "frequency": request.GET.get("frequency", DEFAULT_PRIORITY_WEIGHTS["frequency"]),
-                "has_stacktrace": request.GET.get(
-                    "hasStacktrace", DEFAULT_PRIORITY_WEIGHTS["has_stacktrace"]
-                ),
-                "event_halflife_hours": request.GET.get(
-                    "eventHalflifeHours", DEFAULT_PRIORITY_WEIGHTS["event_halflife_hours"]
-                ),
+    def build_better_priority_sort_kwargs(
+        self, request: Request
+    ) -> Mapping[str, PrioritySortWeights]:
+        """
+        Temporary function to be used while developing the new priority sort. Parses the query params in the request.
+
+        :param logLevel: the weight (number from 0 to 10) to apply for events
+        :param hasStacktrace: the weight (number from 0 to 3) to apply for error events with stacktraces or not
+        :param eventHalflifeHours: each multiple of eventHalflifeHours halves the contribution score of an event
+        :param v2: boolean to switch between using v1 or v2 priority sort
+        :param norm: boolean to switch between normalizing the individual contribution scores to [0, 1] or not
+        """
+
+        if request.GET.get("v2", False):
+            return {
+                "better_priority": {
+                    "log_level": request.GET.get(
+                        "logLevel", V2_DEFAULT_PRIORITY_WEIGHTS["log_level"]
+                    ),
+                    "has_stacktrace": request.GET.get(
+                        "hasStacktrace", V2_DEFAULT_PRIORITY_WEIGHTS["has_stacktrace"]
+                    ),
+                    "event_halflife_hours": request.GET.get(
+                        "eventHalflifeHours", V2_DEFAULT_PRIORITY_WEIGHTS["event_halflife_hours"]
+                    ),
+                    "issue_halflife_hours": request.GET.get(
+                        "issueHalflifeHours", V2_DEFAULT_PRIORITY_WEIGHTS["issue_halflife_hours"]
+                    ),
+                    "v2": True,
+                    "norm": request.GET.get("norm", V2_DEFAULT_PRIORITY_WEIGHTS["norm"]),
+                }
+            }
+        else:
+            return {
+                "better_priority": {
+                    "log_level": request.GET.get("logLevel", DEFAULT_PRIORITY_WEIGHTS["log_level"]),
+                    "has_stacktrace": request.GET.get(
+                        "hasStacktrace", DEFAULT_PRIORITY_WEIGHTS["has_stacktrace"]
+                    ),
+                    "event_halflife_hours": DEFAULT_PRIORITY_WEIGHTS["event_halflife_hours"],
+                    "issue_halflife_hours": DEFAULT_PRIORITY_WEIGHTS["issue_halflife_hours"],
+                    "v2": False,
+                    "norm": False,
+                }
             }
-        }
 
     def _search(
         self, request: Request, organization, projects, environments, extra_query_kwargs=None
diff --git a/src/sentry/search/snuba/executors.py b/src/sentry/search/snuba/executors.py
--- a/src/sentry/search/snuba/executors.py
+++ b/src/sentry/search/snuba/executors.py
@@ -54,16 +54,29 @@
 
 class PrioritySortWeights(TypedDict):
     log_level: int
-    frequency: int
     has_stacktrace: int
     event_halflife_hours: int
+    issue_halflife_hours: int
+    v2: bool
+    norm: bool
 
 
 DEFAULT_PRIORITY_WEIGHTS: PrioritySortWeights = {
     "log_level": 0,
-    "frequency": 0,
     "has_stacktrace": 0,
     "event_halflife_hours": 4,
+    "issue_halflife_hours": 24 * 7,
+    "v2": False,
+    "norm": False,
+}
+
+V2_DEFAULT_PRIORITY_WEIGHTS: PrioritySortWeights = {
+    "log_level": 0,
+    "has_stacktrace": 0,
+    "event_halflife_hours": 12,
+    "issue_halflife_hours": 4,
+    "v2": False,
+    "norm": False,
 }
 
 
@@ -465,34 +478,112 @@ def better_priority_aggregation(
     end: datetime,
     aggregate_kwargs: PrioritySortWeights,
 ) -> Sequence[str]:
-    issue_age_weight = 1  # [0, 5]
-    event_age_weight = 1  # [0, 5]
+    issue_age_weight = 1  # [1, 5]
+    event_age_weight = 1  # [1, 5]
     log_level_weight = aggregate_kwargs["log_level"]  # [0, 10]
     stacktrace_weight = aggregate_kwargs["has_stacktrace"]  # [0, 3]
+    # (event or issue age_hours) / (event or issue halflife hours)
+    # any event or issue age that is greater than max_pow times the half-life hours will get clipped
     max_pow = 16
     min_score = 0.01
     event_age_hours = "divide(now() - timestamp, 3600)"
-    event_halflife_hours = aggregate_kwargs["event_halflife_hours"]  # halves score every 4 hours
+    event_halflife_hours = aggregate_kwargs["event_halflife_hours"]  # halves score every x hours
+    issue_halflife_hours = aggregate_kwargs["issue_halflife_hours"]  # halves score every x hours
     issue_age_hours = "divide(now() - min(timestamp), 3600)"
-    issue_halflife_hours = 24 * 7  # issues half in value every week
-
-    # TODO: we have no way of aggregating to account for frequency:
-    #  (errors within group) / (total events grouped by project)
-    # frequency_weight = aggregate_kwargs["frequency"]  # [0, 5]
-    # frequency_score = f"countIf(and(greater(toDateTime('{start_str}'), timestamp), lessOrEquals(toDateTime('{end_str}'), timestamp))) / count()"
 
     log_level_score = "multiIf(equals(level, 'fatal'), 1.0, equals(level, 'error'), 0.66, equals(level, 'warning'), 0.33, 0.0)"
     stacktrace_score = "if(notEmpty(exception_stacks.type), 1.0, 0.0)"
+    # event_agg_rank:
+    #   ls = log_level_score    {1.0, 0.66, 0.33, 0}
+    #   lw = log_level_weight   [0, 10]
+    #   ss = stacktrace_score   {1.0, 0.0}
+    #   sw = stacktrace_weight  [0, 3]
+    #   as = event_age_score    [1, 0]
+    #   aw = event_age_weight   [1, 5]
+    #
+    #        (ls * lw) + (ss * sw) + (as * aw)     min(f(x)  = 0, when individual scores are all 0
+    # f(x) = ---------------------------------  ,  max(f(x)) = 1, when individual scores are all 1
+    #                  lw + sw + aw
+    #
     event_agg_numerator = f"plus(plus(multiply({log_level_score}, {log_level_weight}), multiply({stacktrace_score}, {stacktrace_weight})), {event_age_weight})"
     event_agg_denominator = (
         f"plus(plus({log_level_weight}, {stacktrace_weight}), {event_age_weight})"
     )
-    event_agg_rank = f"divide({event_agg_numerator}, {event_agg_denominator})"
+    event_agg_rank = f"divide({event_agg_numerator}, {event_agg_denominator})"  # values from [0, 1]
+
+    v2 = aggregate_kwargs["v2"]
+
+    if not v2:
+        aggregate_event_score = f"greatest({min_score}, sum(divide({event_agg_rank}, pow(2, least({max_pow}, divide({event_age_hours}, {event_halflife_hours}))))))"
+        aggregate_issue_score = f"greatest({min_score}, divide({issue_age_weight}, pow(2, least({max_pow}, divide({issue_age_hours}, {issue_halflife_hours})))))"
+        return [f"multiply({aggregate_event_score}, {aggregate_issue_score})", ""]
+    else:
+        #  * apply log to event score summation to clamp the contribution of event scores to a reasonable maximum
+        #  * add an extra 'relative volume score' (# of events in past 60 mins / # of events in the past 7 days)
+        #    to factor in the volume of events that recently were fired versus the past. This will up-rank issues
+        #    that are more recently active as a function of the overall amount of events grouped to that issue
+        #  * conditionally normalize all the scores so the range of values sweeps from 0.0 to 1.0 -
+
+        # aggregate_event_score:
+        #
+        # ------------------------------------------------------------------------------
+        # part 1 (summation over all events in group)
+        #   x = event_age_hours
+        #   k = event_halflife_hours (fixed to a constant)
+        #      1
+        # Σ ------- = Σ ([1, 0), [1, 0), [1, 0), ...) ~= [0, +inf] = g(x)
+        #   2^(x/k)
+        #
+        # ------------------------------------------------------------------------------
+        # part 2a (offset by 1 to remove possibility of ln(0))
+        # g(x) + 1 = [1, +inf] = h(x)
+        #
+        # ------------------------------------------------------------------------------
+        # part 2b (apply ln to clamp exponential growth and apply a 'fixed' maximum)
+        #                            x = 1, e,    10,  1000, 1000000, 1000000000, ...
+        # ln(h(x)) = [ln(1), ln(+inf)] = 0, 1, ~2.30, ~6.09,  ~13.81,     ~20.72, +inf
+        aggregate_event_score = f"log(plus(1, sum(divide({event_agg_rank}, pow(2, divide({event_age_hours}, {event_halflife_hours}))))))"
+
+        event_count_60_mins = "countIf(lessOrEquals(minus(now(), timestamp), 3600))"
+        avg_hourly_event_count_last_7_days = (
+            "countIf(lessOrEquals(minus(now(), timestamp), 604800))"  # 604800 = 3600 * 24 * 7
+        )
+        relative_volume_score = (
+            f"divide(plus({event_count_60_mins}, 1), plus({avg_hourly_event_count_last_7_days}, 1))"
+        )
+        aggregate_issue_score = f"greatest({min_score}, divide({issue_age_weight}, pow(2, least({max_pow}, divide({issue_age_hours}, {issue_halflife_hours})))))"
 
-    aggregate_event_score = f"greatest({min_score}, sum(divide({event_agg_rank}, pow(2, least({max_pow}, divide({event_age_hours}, {event_halflife_hours}))))))"
-    aggregate_issue_score = f"greatest({min_score}, divide({issue_age_weight}, pow(2, least({max_pow}, divide({issue_age_hours}, {issue_halflife_hours})))))"
+        normalize = aggregate_kwargs["norm"]
 
-    return [f"multiply({aggregate_event_score}, {aggregate_issue_score})", ""]
+        if not normalize:
+            return [
+                f"multiply(multiply({aggregate_issue_score}, {aggregate_event_score}), {relative_volume_score})",
+                "",
+            ]
+        else:
+            # aggregate_issue_score:
+            #   x = issue_age_hours
+            #   k = issue_halflife_hours (fixed to a constant)
+            #                          k = 4
+            # lim           1          x = 0,     1,     10,  100000000
+            # x -> inf   -------    f(x) = 1, ~0.84,  ~0.16,  ~0
+            #            2^(x/k)
+            normalized_aggregate_issue_score = aggregate_issue_score  # already ranges from 1 to 0
+            normalized_relative_volume_score = (
+                relative_volume_score  # already normalized since it's a percentage
+            )
+
+            # aggregate_event_score ranges from [0, +inf], as the amount of events grouped to this issue
+            # increases. we apply an upper bound of 21 to the log of the summation of the event scores
+            # and then divide by 21 so the normalized score sweeps from [0, 1]
+            # In practice, itll take a degenerate issue with an absurd amount of events for the
+            # aggregate_event_score to reach to upper limit of ~21 (and normalized score of 1)
+            normalized_aggregate_event_score = f"divide(least({aggregate_event_score}, 21), 21)"
+
+            return [
+                f"plus(plus({normalized_aggregate_issue_score}, {normalized_aggregate_event_score}), {normalized_relative_volume_score})",
+                "",
+            ]
 
 
 class PostgresSnubaQueryExecutor(AbstractQueryExecutor):


Test patch:
diff --git a/tests/snuba/api/endpoints/test_organization_group_index.py b/tests/snuba/api/endpoints/test_organization_group_index.py
--- a/tests/snuba/api/endpoints/test_organization_group_index.py
+++ b/tests/snuba/api/endpoints/test_organization_group_index.py
@@ -156,9 +156,11 @@ def test_sort_by_better_priority(self):
 
         aggregate_kwargs: PrioritySortWeights = {
             "log_level": 3,
-            "frequency": 5,
             "has_stacktrace": 5,
-            "eventHalflifeHours": 4,
+            "event_halflife_hours": 4,
+            "issue_halflife_hours": 4,
+            "v2": False,
+            "norm": False,
         }
 
         response = self.get_success_response(
diff --git a/tests/snuba/search/test_backend.py b/tests/snuba/search/test_backend.py
--- a/tests/snuba/search/test_backend.py
+++ b/tests/snuba/search/test_backend.py
@@ -364,9 +364,11 @@ def test_better_priority_sort(self):
         with self.feature("organizations:issue-list-better-priority-sort"):
             weights: PrioritySortWeights = {
                 "log_level": 5,
-                "frequency": 5,
                 "has_stacktrace": 5,
                 "event_halflife_hours": 4,
+                "issue_halflife_hours": 24 * 7,
+                "v2": False,
+                "norm": False,
             }
             results = self.make_query(
                 sort_by="betterPriority",
@@ -2103,10 +2105,61 @@ def test_better_priority_sort_old_and_new_events(self):
 
         with self.feature("organizations:issue-list-better-priority-sort"):
             weights: PrioritySortWeights = {
-                "log_level": 5,
-                "frequency": 5,
-                "has_stacktrace": 5,
+                "log_level": 0,
+                "has_stacktrace": 0,
+                "event_halflife_hours": 4,
+                "issue_halflife_hours": 24 * 7,
+                "v2": False,
+                "norm": False,
+            }
+            results = self.make_query(
+                sort_by="betterPriority",
+                projects=[new_project],
+                aggregate_kwargs=weights,
+            )
+        recent_group = Group.objects.get(id=recent_event.group.id)
+        old_group = Group.objects.get(id=old_event.group.id)
+        assert list(results) == [recent_group, old_group]
+
+    def test_better_priority_sort_v2(self):
+        """Test that the v2 formula works."""
+        new_project = self.create_project(organization=self.project.organization)
+        base_datetime = (datetime.utcnow() - timedelta(days=3)).replace(tzinfo=pytz.utc)
+
+        recent_event = self.store_event(
+            data={
+                "fingerprint": ["put-me-in-recent-group"],
+                "event_id": "c" * 32,
+                "message": "group1",
+                "environment": "production",
+                "tags": {"server": "example.com", "sentry:user": "event3@example.com"},
+                "timestamp": iso_format(base_datetime),
+                "stacktrace": {"frames": [{"module": "group1"}]},
+            },
+            project_id=new_project.id,
+        )
+        old_event = self.store_event(
+            data={
+                "fingerprint": ["put-me-in-old-group"],
+                "event_id": "a" * 32,
+                "message": "foo. Also, this message is intended to be greater than 256 characters so that we can put some unique string identifier after that point in the string. The purpose of this is in order to verify we are using snuba to search messages instead of Postgres (postgres truncates at 256 characters and clickhouse does not). santryrox.",
+                "environment": "production",
+                "tags": {"server": "example.com", "sentry:user": "old_event@example.com"},
+                "timestamp": iso_format(base_datetime - timedelta(days=20)),
+                "stacktrace": {"frames": [{"module": "group1"}]},
+            },
+            project_id=new_project.id,
+        )
+        old_event.data["timestamp"] = 1504656000.0  # datetime(2017, 9, 6, 0, 0)
+
+        with self.feature("organizations:issue-list-better-priority-sort"):
+            weights: PrioritySortWeights = {
+                "log_level": 0,
+                "has_stacktrace": 0,
                 "event_halflife_hours": 4,
+                "issue_halflife_hours": 24 * 7,
+                "v2": True,
+                "norm": False,
             }
             results = self.make_query(
                 sort_by="betterPriority",
@@ -2150,9 +2203,11 @@ def test_better_priority_log_level_results(self):
         agg_kwargs = {
             "better_priority": {
                 "log_level": 0,
-                "frequency": 0,
                 "has_stacktrace": 0,
                 "event_halflife_hours": 4,
+                "issue_halflife_hours": 24 * 7,
+                "v2": False,
+                "norm": False,
             }
         }
         query_executor = self.backend._get_query_executor()
@@ -2172,15 +2227,7 @@ def test_better_priority_log_level_results(self):
         # initially group 2's score is higher since it has a more recent event
         assert group2_score_before > group1_score_before
 
-        # agg_kwargs["better_priority"].update({"log_level": 5})
-        agg_kwargs_after = {
-            "better_priority": {
-                "log_level": 10,
-                "frequency": 0,
-                "has_stacktrace": 0,
-                "event_halflife_hours": 4,
-            }
-        }
+        agg_kwargs["better_priority"].update({"log_level": 5})
 
         results2 = query_executor.snuba_search(
             start=None,
@@ -2191,7 +2238,7 @@ def test_better_priority_log_level_results(self):
             organization=self.organization,
             group_ids=[group1.id, group2.id],
             limit=150,
-            aggregate_kwargs=agg_kwargs_after,
+            aggregate_kwargs=agg_kwargs,
         )[0]
         group1_score_after = results2[0][1]
         group2_score_after = results2[1][1]
@@ -2204,9 +2251,11 @@ def test_better_priority_has_stacktrace_results(self):
         agg_kwargs = {
             "better_priority": {
                 "log_level": 0,
-                "frequency": 0,
                 "has_stacktrace": 0,
                 "event_halflife_hours": 4,
+                "issue_halflife_hours": 24 * 7,
+                "v2": False,
+                "norm": False,
             }
         }
         query_executor = self.backend._get_query_executor()
@@ -2310,9 +2359,11 @@ def test_better_priority_event_halflife_results(self):
         agg_kwargs = {
             "better_priority": {
                 "log_level": 0,
-                "frequency": 0,
                 "has_stacktrace": 0,
                 "event_halflife_hours": 4,
+                "issue_halflife_hours": 24 * 7,
+                "v2": False,
+                "norm": False,
             }
         }
         query_executor = self.backend._get_query_executor()