feat(discover): Add SnQL support for error.unhandled
diff --git a/src/sentry/search/events/base.py b/src/sentry/search/events/base.py
index 5e92baf8b81d3..868ebaa149d14 100644
--- a/src/sentry/search/events/base.py
+++ b/src/sentry/search/events/base.py
@@ -7,6 +7,7 @@
 
 from sentry.models import Project
 from sentry.search.events.constants import (
+    ERROR_HANDLED_ALIAS,
     ERROR_UNHANDLED_ALIAS,
     ISSUE_ALIAS,
     ISSUE_ID_ALIAS,
@@ -53,8 +54,9 @@ def __init__(self, dataset: Dataset, params: ParamsType):
             TIMESTAMP_TO_DAY_ALIAS: self._resolve_timestamp_to_day_alias,
             USER_DISPLAY_ALIAS: self._resolve_user_display_alias,
             TRANSACTION_STATUS_ALIAS: self._resolve_transaction_status,
+            ERROR_UNHANDLED_ALIAS: self._resolve_error_unhandled_alias,
+            ERROR_HANDLED_ALIAS: self._resolve_error_handled_alias,
             # TODO: implement these
-            ERROR_UNHANDLED_ALIAS: self._resolve_unimplemented_alias,
             KEY_TRANSACTION_ALIAS: self._resolve_unimplemented_alias,
             TEAM_KEY_TRANSACTION_ALIAS: self._resolve_unimplemented_alias,
             PROJECT_THRESHOLD_CONFIG_ALIAS: self._resolve_unimplemented_alias,
@@ -131,6 +133,16 @@ def _resolve_transaction_status(self, _: str) -> SelectType:
             "toUInt8", [self.column(TRANSACTION_STATUS_ALIAS)], TRANSACTION_STATUS_ALIAS
         )
 
+    def _resolve_error_unhandled_alias(self, _: str) -> SelectType:
+        return Function("notHandled", [], ERROR_UNHANDLED_ALIAS)
+
+    def _resolve_error_handled_alias(self, _: str) -> SelectType:
+        # Columns in snuba doesn't support aliasing right now like Function does.
+        # Adding a no-op here to get the alias.
+        return Function(
+            "cast", [self.column("error.handled"), "Array(Nullable(UInt8))"], ERROR_HANDLED_ALIAS
+        )
+
     def _resolve_unimplemented_alias(self, alias: str) -> SelectType:
         """Used in the interim as a stub for ones that have not be implemented in SnQL yet.
         Can be deleted once all field aliases have been implemented.
diff --git a/src/sentry/search/events/constants.py b/src/sentry/search/events/constants.py
index 1e703b3f515b2..4c1b53e4bfcbc 100644
--- a/src/sentry/search/events/constants.py
+++ b/src/sentry/search/events/constants.py
@@ -9,6 +9,7 @@
 PROJECT_THRESHOLD_CONFIG_ALIAS = "project_threshold_config"
 TEAM_KEY_TRANSACTION_ALIAS = "team_key_transaction"
 ERROR_UNHANDLED_ALIAS = "error.unhandled"
+ERROR_HANDLED_ALIAS = "error.handled"
 USER_DISPLAY_ALIAS = "user.display"
 PROJECT_ALIAS = "project"
 PROJECT_NAME_ALIAS = "project.name"
@@ -97,6 +98,7 @@
     TIMESTAMP_TO_HOUR_ALIAS,
     TIMESTAMP_TO_DAY_ALIAS,
     TRANSACTION_STATUS_ALIAS,
+    ERROR_UNHANDLED_ALIAS,
 }
 
 OPERATOR_NEGATION_MAP = {
diff --git a/src/sentry/search/events/filter.py b/src/sentry/search/events/filter.py
index 60dc8388f859c..13df565402dc4 100644
--- a/src/sentry/search/events/filter.py
+++ b/src/sentry/search/events/filter.py
@@ -25,6 +25,7 @@
 from sentry.search.events.constants import (
     ARRAY_FIELDS,
     EQUALITY_OPERATORS,
+    ERROR_HANDLED_ALIAS,
     ERROR_UNHANDLED_ALIAS,
     ISSUE_ALIAS,
     ISSUE_ID_ALIAS,
@@ -1021,6 +1022,8 @@ def __init__(self, dataset: Dataset, params: ParamsType):
             ISSUE_ALIAS: self._issue_filter_converter,
             TRANSACTION_STATUS_ALIAS: self._transaction_status_filter_converter,
             ISSUE_ID_ALIAS: self._issue_id_filter_converter,
+            ERROR_HANDLED_ALIAS: self._error_handled_filter_converter,
+            ERROR_UNHANDLED_ALIAS: self._error_unhandled_filter_converter,
         }
 
     def resolve_where(self, query: Optional[str]) -> List[WhereType]:
@@ -1270,3 +1273,37 @@ def _issue_id_filter_converter(self, search_filter: SearchFilter) -> Optional[Wh
         # Skip isNull check on group_id value as we want to
         # allow snuba's prewhere optimizer to find this condition.
         return Condition(lhs, Op(search_filter.operator), rhs)
+
+    def _error_unhandled_filter_converter(
+        self,
+        search_filter: SearchFilter,
+    ) -> Optional[WhereType]:
+        value = search_filter.value.value
+        # Treat has filter as equivalent to handled
+        if search_filter.value.raw_value == "":
+            output = 0 if search_filter.operator == "!=" else 1
+            return Condition(Function("isHandled", []), Op.EQ, output)
+        if value in ("1", 1):
+            return Condition(Function("notHandled", []), Op.EQ, 1)
+        if value in ("0", 0):
+            return Condition(Function("isHandled", []), Op.EQ, 1)
+        raise InvalidSearchQuery(
+            "Invalid value for error.unhandled condition. Accepted values are 1, 0"
+        )
+
+    def _error_handled_filter_converter(
+        self,
+        search_filter: SearchFilter,
+    ) -> Optional[WhereType]:
+        value = search_filter.value.value
+        # Treat has filter as equivalent to handled
+        if search_filter.value.raw_value == "":
+            output = 1 if search_filter.operator == "!=" else 0
+            return Condition(Function("isHandled", []), Op.EQ, output)
+        if value in ("1", 1):
+            return Condition(Function("isHandled", []), Op.EQ, 1)
+        if value in ("0", 0):
+            return Condition(Function("notHandled", []), Op.EQ, 1)
+        raise InvalidSearchQuery(
+            "Invalid value for error.handled condition. Accepted values are 1, 0"
+        )
diff --git a/tests/sentry/snuba/test_discover.py b/tests/sentry/snuba/test_discover.py
index 7afba21a75bb7..4f47d96fc17a2 100644
--- a/tests/sentry/snuba/test_discover.py
+++ b/tests/sentry/snuba/test_discover.py
@@ -497,6 +497,92 @@ def run_query(query, expected_statuses, message):
         )
         run_query("!has:transaction.status", [], "status nonexistant")
 
+    def test_error_handled_alias(self):
+        data = load_data("android-ndk", timestamp=before_now(minutes=10))
+        events = (
+            ("a" * 32, "not handled", False),
+            ("b" * 32, "is handled", True),
+            ("c" * 32, "undefined", None),
+        )
+        for event in events:
+            data["event_id"] = event[0]
+            data["message"] = event[1]
+            data["exception"]["values"][0]["value"] = event[1]
+            data["exception"]["values"][0]["mechanism"]["handled"] = event[2]
+            self.store_event(data=data, project_id=self.project.id)
+
+        queries = [
+            ("", [[0], [1], [None]]),
+            ("error.handled:true", [[1], [None]]),
+            ("!error.handled:true", [[0]]),
+            ("has:error.handled", [[1], [None]]),
+            ("has:error.handled error.handled:true", [[1], [None]]),
+            ("error.handled:false", [[0]]),
+            ("has:error.handled error.handled:false", []),
+        ]
+
+        for query, expected_data in queries:
+            for query_fn in [discover.query, discover.wip_snql_query]:
+                result = query_fn(
+                    selected_columns=["error.handled"],
+                    query=query,
+                    params={
+                        "organization_id": self.organization.id,
+                        "project_id": [self.project.id],
+                        "start": before_now(minutes=12),
+                        "end": before_now(minutes=8),
+                    },
+                )
+
+                data = result["data"]
+                data = sorted(
+                    data, key=lambda k: (k["error.handled"][0] is None, k["error.handled"][0])
+                )
+
+                assert len(data) == len(expected_data), query_fn
+                assert [item["error.handled"] for item in data] == expected_data
+
+    def test_error_unhandled_alias(self):
+        data = load_data("android-ndk", timestamp=before_now(minutes=10))
+        events = (
+            ("a" * 32, "not handled", False),
+            ("b" * 32, "is handled", True),
+            ("c" * 32, "undefined", None),
+        )
+        for event in events:
+            data["event_id"] = event[0]
+            data["message"] = event[1]
+            data["exception"]["values"][0]["value"] = event[1]
+            data["exception"]["values"][0]["mechanism"]["handled"] = event[2]
+            self.store_event(data=data, project_id=self.project.id)
+
+        queries = [
+            ("error.unhandled:true", ["a" * 32], [1]),
+            ("!error.unhandled:true", ["b" * 32, "c" * 32], [0, 0]),
+            ("has:error.unhandled", ["a" * 32], [1]),
+            ("!has:error.unhandled", ["b" * 32, "c" * 32], [0, 0]),
+            ("has:error.unhandled error.unhandled:true", ["a" * 32], [1]),
+            ("error.unhandled:false", ["b" * 32, "c" * 32], [0, 0]),
+            ("has:error.unhandled error.unhandled:false", [], []),
+        ]
+
+        for query, expected_events, error_handled in queries:
+            for query_fn in [discover.query, discover.wip_snql_query]:
+                result = query_fn(
+                    selected_columns=["error.unhandled"],
+                    query=query,
+                    params={
+                        "organization_id": self.organization.id,
+                        "project_id": [self.project.id],
+                        "start": before_now(minutes=12),
+                        "end": before_now(minutes=8),
+                    },
+                )
+                data = result["data"]
+
+                assert len(data) == len(expected_events), query_fn
+                assert [item["error.unhandled"] for item in data] == error_handled
+
     def test_field_aliasing_in_selected_columns(self):
         result = discover.query(
             selected_columns=["project.id", "user", "release", "timestamp.to_hour"],
diff --git a/tests/snuba/api/endpoints/test_organization_events_stats.py b/tests/snuba/api/endpoints/test_organization_events_stats.py
index a3b0c7fc68a81..74cb0816f4824 100644
--- a/tests/snuba/api/endpoints/test_organization_events_stats.py
+++ b/tests/snuba/api/endpoints/test_organization_events_stats.py
@@ -1494,6 +1494,7 @@ def test_top_events_with_error_handled(self):
 
         assert response.status_code == 200, response.content
         data = response.data
+
         assert len(data) == 3
 
         results = data[""]

Add support for error.unhandled
in SnQL. Fields and Filter.