From 6d5c0cd7852b340fbb474ee242b889eaa94f50a8 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Tue, 14 Apr 2026 14:50:23 -0700 Subject: [PATCH 1/7] Parse issue feed search filters into EAP query string --- .../search/eap/occurrences/search_executor.py | 158 +++++++++++ .../sentry/search/eap/test_search_executor.py | 266 ++++++++++++++++++ 2 files changed, 424 insertions(+) create mode 100644 src/sentry/search/eap/occurrences/search_executor.py create mode 100644 tests/sentry/search/eap/test_search_executor.py diff --git a/src/sentry/search/eap/occurrences/search_executor.py b/src/sentry/search/eap/occurrences/search_executor.py new file mode 100644 index 00000000000000..b4debe3dd79695 --- /dev/null +++ b/src/sentry/search/eap/occurrences/search_executor.py @@ -0,0 +1,158 @@ +import logging +from collections.abc import Sequence +from datetime import datetime + +from sentry.api.event_search import SearchFilter +from sentry.utils import metrics + +logger = logging.getLogger(__name__) + + +# Filters that must be skipped because they have no EAP equivalent. +# These would silently become dynamic tag lookups in the EAP SearchResolver +# (resolver.py:1026-1060) and produce incorrect results. +SKIP_FILTERS: frozenset[str] = frozenset( + { + # Aggregation fields — legacy routes these to HAVING clauses. + # Not EAP attributes; would silently become tag lookups. + "times_seen", + "last_seen", + "user_count", + # event.type is added internally by _query_params_for_error(), not from user filters. + # EAP occurrences don't use event.type — they're pre-typed. + "event.type", + # Require Postgres Release table lookups (semver matching, stage resolution). + "release.stage", + "release.version", + "release.package", + "release.build", + # Virtual alias that expands to coalesce(user.email, user.username, ...). + # No EAP equivalent. + "user.display", + # Requires team context lookup. + "team_key_transaction", + # Requires Snuba-specific status code translation. + "transaction.status", + } +) + +# Filters that need key name translation from legacy Snuba names to EAP attribute names. +TRANSLATE_KEYS: dict[str, str] = { + "error.main_thread": "exception_main_thread", +} + + +def search_filters_to_query_string( + search_filters: Sequence[SearchFilter], +) -> str: + """Convert Snuba-relevant SearchFilter objects to an EAP query string. + + Expects filters that have already been stripped of postgres-only fields + (status, assigned_to, bookmarked_by, etc.) by the caller. + + Returns a query string like: 'level:error platform:python message:"foo bar"' + compatible with the EAP SearchResolver's parse_search_query(). + """ + parts: list[str] = [] + for sf in search_filters: + part = _convert_single_filter(sf) + if part is not None: + parts.append(part) + return " ".join(parts) + + +def _convert_single_filter(sf: SearchFilter) -> str | None: + key = sf.key.name + op = sf.operator + raw_value = sf.value.raw_value + + if key in SKIP_FILTERS: + metrics.incr( + "eap.search_executor.filter_skipped", + tags={"key": key}, + ) + return None + + # error.unhandled requires special inversion logic. + # Legacy uses notHandled() Snuba function; EAP has error.handled attribute. + if key == "error.unhandled": + return _convert_error_unhandled(sf) + + if key in TRANSLATE_KEYS: + key = TRANSLATE_KEYS[key] + + # has / !has filters: empty string value with = or != + if raw_value == "" and op in ("=", "!="): + if op == "!=": + return f"has:{key}" + else: + return f"!has:{key}" + + formatted_value = _format_value(raw_value) + + if op == "=": + return f"{key}:{formatted_value}" + elif op == "!=": + return f"!{key}:{formatted_value}" + elif op in (">", ">=", "<", "<="): + return f"{key}:{op}{formatted_value}" + elif op == "IN": + return f"{key}:{formatted_value}" + elif op == "NOT IN": + return f"!{key}:{formatted_value}" + + logger.warning( + "eap.search_executor.unknown_operator", + extra={"key": key, "operator": op}, + ) + return None + + +def _convert_error_unhandled(sf: SearchFilter) -> str | None: + """Convert error.unhandled filter to the EAP error.handled attribute. + + error.unhandled:1 (or true) → !error.handled:1 + error.unhandled:0 (or false) → error.handled:1 + !error.unhandled:1 → error.handled:1 + """ + raw_value = sf.value.raw_value + op = sf.operator + + # Determine if the user is looking for unhandled errors + is_looking_for_unhandled = (op == "=" and raw_value in ("1", 1, True, "true")) or ( + op == "!=" and raw_value in ("0", 0, False, "false") + ) + + if is_looking_for_unhandled: + return "!error.handled:1" + else: + return "error.handled:1" + + +def _format_value( + raw_value: str | int | float | datetime | Sequence[str] | Sequence[float], +) -> str: + if isinstance(raw_value, (list, tuple)): + parts = ", ".join(_format_single_value(v) for v in raw_value) + return f"[{parts}]" + return _format_single_value(raw_value) + + +def _format_single_value(value: str | int | float | datetime) -> str: + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, (int, float)): + return str(value) + + s = str(value) + + # Wildcard values pass through as-is for the SearchResolver to handle + if "*" in s: + return s + + # Quote strings containing spaces or special characters + if " " in s or '"' in s or "," in s or "(" in s or ")" in s: + escaped = s.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + return s diff --git a/tests/sentry/search/eap/test_search_executor.py b/tests/sentry/search/eap/test_search_executor.py new file mode 100644 index 00000000000000..786f9e90a5b0c3 --- /dev/null +++ b/tests/sentry/search/eap/test_search_executor.py @@ -0,0 +1,266 @@ +from datetime import datetime, timezone + +from sentry.api.event_search import SearchFilter, SearchKey, SearchValue +from sentry.search.eap.occurrences.search_executor import search_filters_to_query_string + + +class TestSearchFiltersToQueryString: + def test_simple_equality(self): + filters = [SearchFilter(SearchKey("level"), "=", SearchValue("error"))] + assert search_filters_to_query_string(filters) == "level:error" + + def test_negation(self): + filters = [SearchFilter(SearchKey("level"), "!=", SearchValue("error"))] + assert search_filters_to_query_string(filters) == "!level:error" + + def test_greater_than(self): + filters = [SearchFilter(SearchKey("exception_count"), ">", SearchValue("5"))] + assert search_filters_to_query_string(filters) == "exception_count:>5" + + def test_greater_than_or_equal(self): + filters = [SearchFilter(SearchKey("exception_count"), ">=", SearchValue("5"))] + assert search_filters_to_query_string(filters) == "exception_count:>=5" + + def test_less_than(self): + filters = [SearchFilter(SearchKey("exception_count"), "<", SearchValue("5"))] + assert search_filters_to_query_string(filters) == "exception_count:<5" + + def test_less_than_or_equal(self): + filters = [SearchFilter(SearchKey("exception_count"), "<=", SearchValue("5"))] + assert search_filters_to_query_string(filters) == "exception_count:<=5" + + def test_in_list(self): + filters = [SearchFilter(SearchKey("level"), "IN", SearchValue(["error", "warning"]))] + assert search_filters_to_query_string(filters) == "level:[error, warning]" + + def test_not_in_list(self): + filters = [SearchFilter(SearchKey("level"), "NOT IN", SearchValue(["error", "warning"]))] + assert search_filters_to_query_string(filters) == "!level:[error, warning]" + + def test_wildcard_value(self): + filters = [SearchFilter(SearchKey("message"), "=", SearchValue("*foo*"))] + assert search_filters_to_query_string(filters) == "message:*foo*" + + def test_wildcard_prefix(self): + filters = [SearchFilter(SearchKey("message"), "=", SearchValue("foo*"))] + assert search_filters_to_query_string(filters) == "message:foo*" + + def test_wildcard_suffix(self): + filters = [SearchFilter(SearchKey("message"), "=", SearchValue("*foo"))] + assert search_filters_to_query_string(filters) == "message:*foo" + + def test_has_filter(self): + # has:user.email is parsed as key=user.email, op=!=, value="" + filters = [SearchFilter(SearchKey("user.email"), "!=", SearchValue(""))] + assert search_filters_to_query_string(filters) == "has:user.email" + + def test_not_has_filter(self): + # !has:user.email is parsed as key=user.email, op==, value="" + filters = [SearchFilter(SearchKey("user.email"), "=", SearchValue(""))] + assert search_filters_to_query_string(filters) == "!has:user.email" + + def test_tag_filter(self): + filters = [SearchFilter(SearchKey("tags[browser]"), "=", SearchValue("chrome"))] + assert search_filters_to_query_string(filters) == "tags[browser]:chrome" + + def test_value_with_spaces(self): + filters = [SearchFilter(SearchKey("message"), "=", SearchValue("foo bar baz"))] + assert search_filters_to_query_string(filters) == 'message:"foo bar baz"' + + def test_value_with_quotes(self): + filters = [SearchFilter(SearchKey("message"), "=", SearchValue('foo "bar"'))] + assert search_filters_to_query_string(filters) == 'message:"foo \\"bar\\""' + + def test_value_with_commas(self): + filters = [SearchFilter(SearchKey("message"), "=", SearchValue("a,b,c"))] + assert search_filters_to_query_string(filters) == 'message:"a,b,c"' + + def test_numeric_value(self): + filters = [SearchFilter(SearchKey("exception_count"), "=", SearchValue(42))] + assert search_filters_to_query_string(filters) == "exception_count:42" + + def test_float_value(self): + filters = [SearchFilter(SearchKey("exception_count"), ">", SearchValue(3.14))] + assert search_filters_to_query_string(filters) == "exception_count:>3.14" + + def test_datetime_value(self): + dt = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + filters = [SearchFilter(SearchKey("timestamp"), ">", SearchValue(dt))] + result = search_filters_to_query_string(filters) + assert result == "timestamp:>2024-01-15T12:00:00+00:00" + + def test_multiple_filters_joined(self): + filters = [ + SearchFilter(SearchKey("level"), "=", SearchValue("error")), + SearchFilter(SearchKey("platform"), "=", SearchValue("python")), + SearchFilter(SearchKey("message"), "=", SearchValue("fail")), + ] + result = search_filters_to_query_string(filters) + assert result == "level:error platform:python message:fail" + + def test_empty_filters(self): + assert search_filters_to_query_string([]) == "" + + # --- Skip filters --- + + def test_event_type_skipped(self): + filters = [SearchFilter(SearchKey("event.type"), "=", SearchValue("error"))] + assert search_filters_to_query_string(filters) == "" + + def test_times_seen_skipped(self): + filters = [SearchFilter(SearchKey("times_seen"), ">", SearchValue("100"))] + assert search_filters_to_query_string(filters) == "" + + def test_last_seen_as_filter_skipped(self): + dt = datetime(2024, 1, 1, tzinfo=timezone.utc) + filters = [SearchFilter(SearchKey("last_seen"), ">", SearchValue(dt))] + assert search_filters_to_query_string(filters) == "" + + def test_user_count_skipped(self): + filters = [SearchFilter(SearchKey("user_count"), ">", SearchValue("5"))] + assert search_filters_to_query_string(filters) == "" + + def test_release_stage_skipped(self): + filters = [SearchFilter(SearchKey("release.stage"), "=", SearchValue("adopted"))] + assert search_filters_to_query_string(filters) == "" + + def test_release_version_skipped(self): + filters = [SearchFilter(SearchKey("release.version"), ">", SearchValue("1.0.0"))] + assert search_filters_to_query_string(filters) == "" + + def test_user_display_skipped(self): + filters = [SearchFilter(SearchKey("user.display"), "=", SearchValue("john"))] + assert search_filters_to_query_string(filters) == "" + + def test_team_key_transaction_skipped(self): + filters = [SearchFilter(SearchKey("team_key_transaction"), "=", SearchValue("1"))] + assert search_filters_to_query_string(filters) == "" + + def test_transaction_status_skipped(self): + filters = [SearchFilter(SearchKey("transaction.status"), "=", SearchValue("ok"))] + assert search_filters_to_query_string(filters) == "" + + def test_skipped_filters_dont_affect_other_filters(self): + filters = [ + SearchFilter(SearchKey("level"), "=", SearchValue("error")), + SearchFilter(SearchKey("times_seen"), ">", SearchValue("100")), + SearchFilter(SearchKey("platform"), "=", SearchValue("python")), + ] + result = search_filters_to_query_string(filters) + assert result == "level:error platform:python" + + # --- Translated filters --- + + def test_error_unhandled_true(self): + filters = [SearchFilter(SearchKey("error.unhandled"), "=", SearchValue("1"))] + assert search_filters_to_query_string(filters) == "!error.handled:1" + + def test_error_unhandled_true_bool(self): + filters = [SearchFilter(SearchKey("error.unhandled"), "=", SearchValue("true"))] + assert search_filters_to_query_string(filters) == "!error.handled:1" + + def test_error_unhandled_false(self): + filters = [SearchFilter(SearchKey("error.unhandled"), "=", SearchValue("0"))] + assert search_filters_to_query_string(filters) == "error.handled:1" + + def test_error_unhandled_negated(self): + # !error.unhandled:1 → looking for handled errors + filters = [SearchFilter(SearchKey("error.unhandled"), "!=", SearchValue("1"))] + assert search_filters_to_query_string(filters) == "error.handled:1" + + def test_error_main_thread_translated(self): + filters = [SearchFilter(SearchKey("error.main_thread"), "=", SearchValue("1"))] + assert search_filters_to_query_string(filters) == "exception_main_thread:1" + + def test_error_main_thread_negated(self): + filters = [SearchFilter(SearchKey("error.main_thread"), "!=", SearchValue("1"))] + assert search_filters_to_query_string(filters) == "!exception_main_thread:1" + + # --- Pass-through filters (EAP attributes exist) --- + + def test_level_passthrough(self): + filters = [SearchFilter(SearchKey("level"), "=", SearchValue("warning"))] + assert search_filters_to_query_string(filters) == "level:warning" + + def test_message_passthrough(self): + filters = [SearchFilter(SearchKey("message"), "=", SearchValue("connection reset"))] + assert search_filters_to_query_string(filters) == 'message:"connection reset"' + + def test_platform_passthrough(self): + filters = [SearchFilter(SearchKey("platform"), "=", SearchValue("javascript"))] + assert search_filters_to_query_string(filters) == "platform:javascript" + + def test_release_passthrough(self): + filters = [SearchFilter(SearchKey("release"), "=", SearchValue("1.0.0"))] + assert search_filters_to_query_string(filters) == "release:1.0.0" + + def test_environment_passthrough(self): + filters = [SearchFilter(SearchKey("environment"), "=", SearchValue("production"))] + assert search_filters_to_query_string(filters) == "environment:production" + + def test_error_type_passthrough(self): + filters = [SearchFilter(SearchKey("error.type"), "=", SearchValue("ValueError"))] + assert search_filters_to_query_string(filters) == "error.type:ValueError" + + def test_error_handled_passthrough(self): + filters = [SearchFilter(SearchKey("error.handled"), "=", SearchValue("1"))] + assert search_filters_to_query_string(filters) == "error.handled:1" + + def test_stack_filename_passthrough(self): + filters = [SearchFilter(SearchKey("stack.filename"), "=", SearchValue("app.py"))] + assert search_filters_to_query_string(filters) == "stack.filename:app.py" + + def test_user_email_passthrough(self): + filters = [SearchFilter(SearchKey("user.email"), "=", SearchValue("foo@bar.com"))] + assert search_filters_to_query_string(filters) == "user.email:foo@bar.com" + + def test_sdk_name_passthrough(self): + filters = [SearchFilter(SearchKey("sdk.name"), "=", SearchValue("sentry.python"))] + assert search_filters_to_query_string(filters) == "sdk.name:sentry.python" + + def test_http_url_passthrough(self): + filters = [SearchFilter(SearchKey("http.url"), "=", SearchValue("https://example.com"))] + assert search_filters_to_query_string(filters) == "http.url:https://example.com" + + def test_trace_passthrough(self): + filters = [ + SearchFilter(SearchKey("trace"), "=", SearchValue("abcdef1234567890abcdef1234567890")) + ] + assert search_filters_to_query_string(filters) == "trace:abcdef1234567890abcdef1234567890" + + def test_transaction_passthrough(self): + filters = [SearchFilter(SearchKey("transaction"), "=", SearchValue("/api/users"))] + assert search_filters_to_query_string(filters) == "transaction:/api/users" + + def test_dist_passthrough(self): + filters = [SearchFilter(SearchKey("dist"), "=", SearchValue("abc123"))] + assert search_filters_to_query_string(filters) == "dist:abc123" + + # --- Complex scenarios --- + + def test_mixed_supported_and_skipped(self): + """A realistic query mixing supported, skipped, and translated filters.""" + filters = [ + SearchFilter(SearchKey("level"), "=", SearchValue("error")), + SearchFilter(SearchKey("error.unhandled"), "=", SearchValue("1")), + SearchFilter(SearchKey("times_seen"), ">", SearchValue("50")), + SearchFilter(SearchKey("platform"), "IN", SearchValue(["python", "javascript"])), + SearchFilter(SearchKey("release.version"), ">", SearchValue("2.0.0")), + SearchFilter(SearchKey("tags[browser]"), "=", SearchValue("chrome")), + ] + result = search_filters_to_query_string(filters) + assert result == ( + "level:error !error.handled:1 platform:[python, javascript] tags[browser]:chrome" + ) + + def test_in_list_with_single_value(self): + filters = [SearchFilter(SearchKey("level"), "IN", SearchValue(["error"]))] + assert search_filters_to_query_string(filters) == "level:[error]" + + def test_negated_tag_filter(self): + filters = [SearchFilter(SearchKey("tags[device]"), "!=", SearchValue("iPhone"))] + assert search_filters_to_query_string(filters) == "!tags[device]:iPhone" + + def test_wildcard_in_tag(self): + filters = [SearchFilter(SearchKey("tags[url]"), "=", SearchValue("*example*"))] + assert search_filters_to_query_string(filters) == "tags[url]:*example*" From 1cf3785cfc58de97a3c47367882c219c7b19889f Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Tue, 14 Apr 2026 15:13:56 -0700 Subject: [PATCH 2/7] Typing fix & test improvements --- .../search/eap/occurrences/search_executor.py | 9 +- .../sentry/search/eap/test_search_executor.py | 338 ++++++------------ 2 files changed, 111 insertions(+), 236 deletions(-) diff --git a/src/sentry/search/eap/occurrences/search_executor.py b/src/sentry/search/eap/occurrences/search_executor.py index b4debe3dd79695..ed93e374005b9c 100644 --- a/src/sentry/search/eap/occurrences/search_executor.py +++ b/src/sentry/search/eap/occurrences/search_executor.py @@ -135,7 +135,11 @@ def _format_value( if isinstance(raw_value, (list, tuple)): parts = ", ".join(_format_single_value(v) for v in raw_value) return f"[{parts}]" - return _format_single_value(raw_value) + if isinstance(raw_value, datetime): + return raw_value.isoformat() + if isinstance(raw_value, (int, float)): + return str(raw_value) + return _format_string_value(str(raw_value)) def _format_single_value(value: str | int | float | datetime) -> str: @@ -143,9 +147,10 @@ def _format_single_value(value: str | int | float | datetime) -> str: return value.isoformat() if isinstance(value, (int, float)): return str(value) + return _format_string_value(str(value)) - s = str(value) +def _format_string_value(s: str) -> str: # Wildcard values pass through as-is for the SearchResolver to handle if "*" in s: return s diff --git a/tests/sentry/search/eap/test_search_executor.py b/tests/sentry/search/eap/test_search_executor.py index 786f9e90a5b0c3..1bd9347891b7cc 100644 --- a/tests/sentry/search/eap/test_search_executor.py +++ b/tests/sentry/search/eap/test_search_executor.py @@ -5,241 +5,123 @@ class TestSearchFiltersToQueryString: - def test_simple_equality(self): - filters = [SearchFilter(SearchKey("level"), "=", SearchValue("error"))] - assert search_filters_to_query_string(filters) == "level:error" - - def test_negation(self): - filters = [SearchFilter(SearchKey("level"), "!=", SearchValue("error"))] - assert search_filters_to_query_string(filters) == "!level:error" - - def test_greater_than(self): - filters = [SearchFilter(SearchKey("exception_count"), ">", SearchValue("5"))] - assert search_filters_to_query_string(filters) == "exception_count:>5" - - def test_greater_than_or_equal(self): - filters = [SearchFilter(SearchKey("exception_count"), ">=", SearchValue("5"))] - assert search_filters_to_query_string(filters) == "exception_count:>=5" - - def test_less_than(self): - filters = [SearchFilter(SearchKey("exception_count"), "<", SearchValue("5"))] - assert search_filters_to_query_string(filters) == "exception_count:<5" - - def test_less_than_or_equal(self): - filters = [SearchFilter(SearchKey("exception_count"), "<=", SearchValue("5"))] - assert search_filters_to_query_string(filters) == "exception_count:<=5" - - def test_in_list(self): - filters = [SearchFilter(SearchKey("level"), "IN", SearchValue(["error", "warning"]))] - assert search_filters_to_query_string(filters) == "level:[error, warning]" - - def test_not_in_list(self): - filters = [SearchFilter(SearchKey("level"), "NOT IN", SearchValue(["error", "warning"]))] - assert search_filters_to_query_string(filters) == "!level:[error, warning]" - - def test_wildcard_value(self): - filters = [SearchFilter(SearchKey("message"), "=", SearchValue("*foo*"))] - assert search_filters_to_query_string(filters) == "message:*foo*" - - def test_wildcard_prefix(self): - filters = [SearchFilter(SearchKey("message"), "=", SearchValue("foo*"))] - assert search_filters_to_query_string(filters) == "message:foo*" - - def test_wildcard_suffix(self): - filters = [SearchFilter(SearchKey("message"), "=", SearchValue("*foo"))] - assert search_filters_to_query_string(filters) == "message:*foo" - - def test_has_filter(self): - # has:user.email is parsed as key=user.email, op=!=, value="" - filters = [SearchFilter(SearchKey("user.email"), "!=", SearchValue(""))] - assert search_filters_to_query_string(filters) == "has:user.email" - - def test_not_has_filter(self): - # !has:user.email is parsed as key=user.email, op==, value="" - filters = [SearchFilter(SearchKey("user.email"), "=", SearchValue(""))] - assert search_filters_to_query_string(filters) == "!has:user.email" - - def test_tag_filter(self): - filters = [SearchFilter(SearchKey("tags[browser]"), "=", SearchValue("chrome"))] - assert search_filters_to_query_string(filters) == "tags[browser]:chrome" - - def test_value_with_spaces(self): - filters = [SearchFilter(SearchKey("message"), "=", SearchValue("foo bar baz"))] - assert search_filters_to_query_string(filters) == 'message:"foo bar baz"' - - def test_value_with_quotes(self): - filters = [SearchFilter(SearchKey("message"), "=", SearchValue('foo "bar"'))] - assert search_filters_to_query_string(filters) == 'message:"foo \\"bar\\""' - - def test_value_with_commas(self): - filters = [SearchFilter(SearchKey("message"), "=", SearchValue("a,b,c"))] - assert search_filters_to_query_string(filters) == 'message:"a,b,c"' - - def test_numeric_value(self): - filters = [SearchFilter(SearchKey("exception_count"), "=", SearchValue(42))] - assert search_filters_to_query_string(filters) == "exception_count:42" - - def test_float_value(self): - filters = [SearchFilter(SearchKey("exception_count"), ">", SearchValue(3.14))] - assert search_filters_to_query_string(filters) == "exception_count:>3.14" - - def test_datetime_value(self): + def test_all_operator_types(self): + """Each operator type produces the correct EAP query syntax.""" + cases = [ + (SearchFilter(SearchKey("level"), "=", SearchValue("error")), "level:error"), + (SearchFilter(SearchKey("level"), "!=", SearchValue("error")), "!level:error"), + (SearchFilter(SearchKey("count"), ">", SearchValue("5")), "count:>5"), + (SearchFilter(SearchKey("count"), ">=", SearchValue("5")), "count:>=5"), + (SearchFilter(SearchKey("count"), "<", SearchValue("5")), "count:<5"), + (SearchFilter(SearchKey("count"), "<=", SearchValue("5")), "count:<=5"), + ( + SearchFilter(SearchKey("level"), "IN", SearchValue(["error", "warning"])), + "level:[error, warning]", + ), + ( + SearchFilter(SearchKey("level"), "NOT IN", SearchValue(["error", "warning"])), + "!level:[error, warning]", + ), + ] + for sf, expected in cases: + assert search_filters_to_query_string([sf]) == expected, ( + f"Failed for operator {sf.operator}" + ) + + def test_value_formatting(self): + """Values with special characters, wildcards, numerics, and datetimes + are formatted correctly.""" dt = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) - filters = [SearchFilter(SearchKey("timestamp"), ">", SearchValue(dt))] - result = search_filters_to_query_string(filters) - assert result == "timestamp:>2024-01-15T12:00:00+00:00" - - def test_multiple_filters_joined(self): - filters = [ - SearchFilter(SearchKey("level"), "=", SearchValue("error")), - SearchFilter(SearchKey("platform"), "=", SearchValue("python")), - SearchFilter(SearchKey("message"), "=", SearchValue("fail")), + cases = [ + # Wildcards pass through as-is + (SearchFilter(SearchKey("message"), "=", SearchValue("*foo*")), "message:*foo*"), + # Spaces trigger quoting + ( + SearchFilter(SearchKey("message"), "=", SearchValue("foo bar")), + 'message:"foo bar"', + ), + # Embedded quotes are escaped + ( + SearchFilter(SearchKey("message"), "=", SearchValue('foo "bar"')), + 'message:"foo \\"bar\\""', + ), + # Numeric values + (SearchFilter(SearchKey("count"), "=", SearchValue(42)), "count:42"), + (SearchFilter(SearchKey("count"), ">", SearchValue(3.14)), "count:>3.14"), + # Datetime values + ( + SearchFilter(SearchKey("timestamp"), ">", SearchValue(dt)), + "timestamp:>2024-01-15T12:00:00+00:00", + ), + # Tags pass through + ( + SearchFilter(SearchKey("tags[browser]"), "=", SearchValue("chrome")), + "tags[browser]:chrome", + ), ] - result = search_filters_to_query_string(filters) - assert result == "level:error platform:python message:fail" - - def test_empty_filters(self): - assert search_filters_to_query_string([]) == "" + for sf, expected in cases: + assert search_filters_to_query_string([sf]) == expected - # --- Skip filters --- + def test_has_and_not_has_filters(self): + """Empty-value filters are converted to has:/!has: syntax.""" + # has:user.email → parsed as op=!=, value="" + has_filter = SearchFilter(SearchKey("user.email"), "!=", SearchValue("")) + assert search_filters_to_query_string([has_filter]) == "has:user.email" - def test_event_type_skipped(self): - filters = [SearchFilter(SearchKey("event.type"), "=", SearchValue("error"))] - assert search_filters_to_query_string(filters) == "" - - def test_times_seen_skipped(self): - filters = [SearchFilter(SearchKey("times_seen"), ">", SearchValue("100"))] - assert search_filters_to_query_string(filters) == "" + # !has:user.email → parsed as op==, value="" + not_has_filter = SearchFilter(SearchKey("user.email"), "=", SearchValue("")) + assert search_filters_to_query_string([not_has_filter]) == "!has:user.email" - def test_last_seen_as_filter_skipped(self): - dt = datetime(2024, 1, 1, tzinfo=timezone.utc) - filters = [SearchFilter(SearchKey("last_seen"), ">", SearchValue(dt))] - assert search_filters_to_query_string(filters) == "" - - def test_user_count_skipped(self): - filters = [SearchFilter(SearchKey("user_count"), ">", SearchValue("5"))] - assert search_filters_to_query_string(filters) == "" - - def test_release_stage_skipped(self): - filters = [SearchFilter(SearchKey("release.stage"), "=", SearchValue("adopted"))] - assert search_filters_to_query_string(filters) == "" - - def test_release_version_skipped(self): - filters = [SearchFilter(SearchKey("release.version"), ">", SearchValue("1.0.0"))] - assert search_filters_to_query_string(filters) == "" - - def test_user_display_skipped(self): - filters = [SearchFilter(SearchKey("user.display"), "=", SearchValue("john"))] - assert search_filters_to_query_string(filters) == "" - - def test_team_key_transaction_skipped(self): - filters = [SearchFilter(SearchKey("team_key_transaction"), "=", SearchValue("1"))] - assert search_filters_to_query_string(filters) == "" - - def test_transaction_status_skipped(self): - filters = [SearchFilter(SearchKey("transaction.status"), "=", SearchValue("ok"))] - assert search_filters_to_query_string(filters) == "" - - def test_skipped_filters_dont_affect_other_filters(self): + def test_skipped_filters_are_dropped(self): + """All filters with no EAP equivalent are silently dropped.""" filters = [ - SearchFilter(SearchKey("level"), "=", SearchValue("error")), + SearchFilter(SearchKey("event.type"), "=", SearchValue("error")), SearchFilter(SearchKey("times_seen"), ">", SearchValue("100")), - SearchFilter(SearchKey("platform"), "=", SearchValue("python")), + SearchFilter(SearchKey("last_seen"), ">", SearchValue("2024-01-01")), + SearchFilter(SearchKey("user_count"), ">", SearchValue("5")), + SearchFilter(SearchKey("release.stage"), "=", SearchValue("adopted")), + SearchFilter(SearchKey("release.version"), ">", SearchValue("1.0.0")), + SearchFilter(SearchKey("release.package"), "=", SearchValue("com.example")), + SearchFilter(SearchKey("release.build"), "=", SearchValue("123")), + SearchFilter(SearchKey("user.display"), "=", SearchValue("john")), + SearchFilter(SearchKey("team_key_transaction"), "=", SearchValue("1")), + SearchFilter(SearchKey("transaction.status"), "=", SearchValue("ok")), ] - result = search_filters_to_query_string(filters) - assert result == "level:error platform:python" - - # --- Translated filters --- - - def test_error_unhandled_true(self): - filters = [SearchFilter(SearchKey("error.unhandled"), "=", SearchValue("1"))] - assert search_filters_to_query_string(filters) == "!error.handled:1" - - def test_error_unhandled_true_bool(self): - filters = [SearchFilter(SearchKey("error.unhandled"), "=", SearchValue("true"))] - assert search_filters_to_query_string(filters) == "!error.handled:1" - - def test_error_unhandled_false(self): - filters = [SearchFilter(SearchKey("error.unhandled"), "=", SearchValue("0"))] - assert search_filters_to_query_string(filters) == "error.handled:1" + assert search_filters_to_query_string(filters) == "" - def test_error_unhandled_negated(self): - # !error.unhandled:1 → looking for handled errors - filters = [SearchFilter(SearchKey("error.unhandled"), "!=", SearchValue("1"))] - assert search_filters_to_query_string(filters) == "error.handled:1" + def test_error_unhandled_translation(self): + """error.unhandled is inverted to use the EAP error.handled attribute.""" + # error.unhandled:1 → looking for unhandled → !error.handled:1 + assert ( + search_filters_to_query_string( + [SearchFilter(SearchKey("error.unhandled"), "=", SearchValue("1"))] + ) + == "!error.handled:1" + ) + # error.unhandled:0 → looking for handled → error.handled:1 + assert ( + search_filters_to_query_string( + [SearchFilter(SearchKey("error.unhandled"), "=", SearchValue("0"))] + ) + == "error.handled:1" + ) + # !error.unhandled:1 → looking for handled → error.handled:1 + assert ( + search_filters_to_query_string( + [SearchFilter(SearchKey("error.unhandled"), "!=", SearchValue("1"))] + ) + == "error.handled:1" + ) - def test_error_main_thread_translated(self): + def test_error_main_thread_key_translated(self): + """error.main_thread is renamed to the EAP attribute name.""" filters = [SearchFilter(SearchKey("error.main_thread"), "=", SearchValue("1"))] assert search_filters_to_query_string(filters) == "exception_main_thread:1" - def test_error_main_thread_negated(self): - filters = [SearchFilter(SearchKey("error.main_thread"), "!=", SearchValue("1"))] - assert search_filters_to_query_string(filters) == "!exception_main_thread:1" - - # --- Pass-through filters (EAP attributes exist) --- - - def test_level_passthrough(self): - filters = [SearchFilter(SearchKey("level"), "=", SearchValue("warning"))] - assert search_filters_to_query_string(filters) == "level:warning" - - def test_message_passthrough(self): - filters = [SearchFilter(SearchKey("message"), "=", SearchValue("connection reset"))] - assert search_filters_to_query_string(filters) == 'message:"connection reset"' - - def test_platform_passthrough(self): - filters = [SearchFilter(SearchKey("platform"), "=", SearchValue("javascript"))] - assert search_filters_to_query_string(filters) == "platform:javascript" - - def test_release_passthrough(self): - filters = [SearchFilter(SearchKey("release"), "=", SearchValue("1.0.0"))] - assert search_filters_to_query_string(filters) == "release:1.0.0" - - def test_environment_passthrough(self): - filters = [SearchFilter(SearchKey("environment"), "=", SearchValue("production"))] - assert search_filters_to_query_string(filters) == "environment:production" - - def test_error_type_passthrough(self): - filters = [SearchFilter(SearchKey("error.type"), "=", SearchValue("ValueError"))] - assert search_filters_to_query_string(filters) == "error.type:ValueError" - - def test_error_handled_passthrough(self): - filters = [SearchFilter(SearchKey("error.handled"), "=", SearchValue("1"))] - assert search_filters_to_query_string(filters) == "error.handled:1" - - def test_stack_filename_passthrough(self): - filters = [SearchFilter(SearchKey("stack.filename"), "=", SearchValue("app.py"))] - assert search_filters_to_query_string(filters) == "stack.filename:app.py" - - def test_user_email_passthrough(self): - filters = [SearchFilter(SearchKey("user.email"), "=", SearchValue("foo@bar.com"))] - assert search_filters_to_query_string(filters) == "user.email:foo@bar.com" - - def test_sdk_name_passthrough(self): - filters = [SearchFilter(SearchKey("sdk.name"), "=", SearchValue("sentry.python"))] - assert search_filters_to_query_string(filters) == "sdk.name:sentry.python" - - def test_http_url_passthrough(self): - filters = [SearchFilter(SearchKey("http.url"), "=", SearchValue("https://example.com"))] - assert search_filters_to_query_string(filters) == "http.url:https://example.com" - - def test_trace_passthrough(self): - filters = [ - SearchFilter(SearchKey("trace"), "=", SearchValue("abcdef1234567890abcdef1234567890")) - ] - assert search_filters_to_query_string(filters) == "trace:abcdef1234567890abcdef1234567890" - - def test_transaction_passthrough(self): - filters = [SearchFilter(SearchKey("transaction"), "=", SearchValue("/api/users"))] - assert search_filters_to_query_string(filters) == "transaction:/api/users" - - def test_dist_passthrough(self): - filters = [SearchFilter(SearchKey("dist"), "=", SearchValue("abc123"))] - assert search_filters_to_query_string(filters) == "dist:abc123" - - # --- Complex scenarios --- - - def test_mixed_supported_and_skipped(self): - """A realistic query mixing supported, skipped, and translated filters.""" + def test_realistic_mixed_query(self): + """A realistic issue feed query mixing supported, skipped, and translated filters. + Verifies that supported filters are converted, skipped filters are dropped, + and translated filters are rewritten — all in a single query string.""" filters = [ SearchFilter(SearchKey("level"), "=", SearchValue("error")), SearchFilter(SearchKey("error.unhandled"), "=", SearchValue("1")), @@ -252,15 +134,3 @@ def test_mixed_supported_and_skipped(self): assert result == ( "level:error !error.handled:1 platform:[python, javascript] tags[browser]:chrome" ) - - def test_in_list_with_single_value(self): - filters = [SearchFilter(SearchKey("level"), "IN", SearchValue(["error"]))] - assert search_filters_to_query_string(filters) == "level:[error]" - - def test_negated_tag_filter(self): - filters = [SearchFilter(SearchKey("tags[device]"), "!=", SearchValue("iPhone"))] - assert search_filters_to_query_string(filters) == "!tags[device]:iPhone" - - def test_wildcard_in_tag(self): - filters = [SearchFilter(SearchKey("tags[url]"), "=", SearchValue("*example*"))] - assert search_filters_to_query_string(filters) == "tags[url]:*example*" From efb49fc7b6f48f370d5506580f174b473ed4c7d3 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Wed, 15 Apr 2026 10:36:21 -0700 Subject: [PATCH 3/7] Implement parsing of aggregation filters --- .../search/eap/occurrences/search_executor.py | 39 ++++++++++++++++--- .../sentry/search/eap/test_search_executor.py | 35 ++++++++++++++--- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/sentry/search/eap/occurrences/search_executor.py b/src/sentry/search/eap/occurrences/search_executor.py index ed93e374005b9c..3c018acf647670 100644 --- a/src/sentry/search/eap/occurrences/search_executor.py +++ b/src/sentry/search/eap/occurrences/search_executor.py @@ -13,11 +13,6 @@ # (resolver.py:1026-1060) and produce incorrect results. SKIP_FILTERS: frozenset[str] = frozenset( { - # Aggregation fields — legacy routes these to HAVING clauses. - # Not EAP attributes; would silently become tag lookups. - "times_seen", - "last_seen", - "user_count", # event.type is added internally by _query_params_for_error(), not from user filters. # EAP occurrences don't use event.type — they're pre-typed. "event.type", @@ -37,10 +32,21 @@ ) # Filters that need key name translation from legacy Snuba names to EAP attribute names. +# TODO: instead of translating this key, maybe we should just set the public alias for this attribute to "error.main_thread"? TRANSLATE_KEYS: dict[str, str] = { "error.main_thread": "exception_main_thread", } +# Legacy aggregation field names → EAP aggregate function syntax. +# In the legacy path these become HAVING clauses (e.g. times_seen:>100 → HAVING count() > 100). +# The EAP SearchResolver parses function syntax like count():>100 as AggregateFilter objects +# and routes them to the aggregation_filter field on the RPC request. +AGGREGATION_FIELD_TO_EAP_FUNCTION: dict[str, str] = { + "times_seen": "count()", + "last_seen": "last_seen()", + "user_count": "count_unique(user.id)", +} + def search_filters_to_query_string( search_filters: Sequence[SearchFilter], @@ -66,6 +72,9 @@ def _convert_single_filter(sf: SearchFilter) -> str | None: op = sf.operator raw_value = sf.value.raw_value + if key in AGGREGATION_FIELD_TO_EAP_FUNCTION: + return _convert_aggregation_filter(sf) + if key in SKIP_FILTERS: metrics.incr( "eap.search_executor.filter_skipped", @@ -129,6 +138,26 @@ def _convert_error_unhandled(sf: SearchFilter) -> str | None: return "error.handled:1" +def _convert_aggregation_filter(sf: SearchFilter) -> str | None: + """Convert a legacy aggregation field filter to EAP function syntax. + + e.g. times_seen:>100 → count():>100 + last_seen:>2024-01-01 → last_seen():>2024-01-01T00:00:00+00:00 + user_count:>5 → count_unique(user.id):>5 + """ + eap_function = AGGREGATION_FIELD_TO_EAP_FUNCTION[sf.key.name] + formatted_value = _format_value(sf.value.raw_value) + + if sf.operator in (">", ">=", "<", "<="): + return f"{eap_function}:{sf.operator}{formatted_value}" + elif sf.operator == "=": + return f"{eap_function}:{formatted_value}" + elif sf.operator == "!=": + return f"!{eap_function}:{formatted_value}" + + return None + + def _format_value( raw_value: str | int | float | datetime | Sequence[str] | Sequence[float], ) -> str: diff --git a/tests/sentry/search/eap/test_search_executor.py b/tests/sentry/search/eap/test_search_executor.py index 1bd9347891b7cc..8f67ebaafa738c 100644 --- a/tests/sentry/search/eap/test_search_executor.py +++ b/tests/sentry/search/eap/test_search_executor.py @@ -76,9 +76,6 @@ def test_skipped_filters_are_dropped(self): """All filters with no EAP equivalent are silently dropped.""" filters = [ SearchFilter(SearchKey("event.type"), "=", SearchValue("error")), - SearchFilter(SearchKey("times_seen"), ">", SearchValue("100")), - SearchFilter(SearchKey("last_seen"), ">", SearchValue("2024-01-01")), - SearchFilter(SearchKey("user_count"), ">", SearchValue("5")), SearchFilter(SearchKey("release.stage"), "=", SearchValue("adopted")), SearchFilter(SearchKey("release.version"), ">", SearchValue("1.0.0")), SearchFilter(SearchKey("release.package"), "=", SearchValue("com.example")), @@ -89,6 +86,33 @@ def test_skipped_filters_are_dropped(self): ] assert search_filters_to_query_string(filters) == "" + def test_aggregation_filters_translated(self): + """Legacy aggregation field names are translated to EAP function syntax + so the SearchResolver parses them as AggregateFilter (HAVING) conditions.""" + dt = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + cases = [ + ( + SearchFilter(SearchKey("times_seen"), ">", SearchValue("100")), + "count():>100", + ), + ( + SearchFilter(SearchKey("times_seen"), "<=", SearchValue("50")), + "count():<=50", + ), + ( + SearchFilter(SearchKey("last_seen"), ">", SearchValue(dt)), + "last_seen():>2024-01-15T12:00:00+00:00", + ), + ( + SearchFilter(SearchKey("user_count"), ">", SearchValue("5")), + "count_unique(user.id):>5", + ), + ] + for sf, expected in cases: + assert search_filters_to_query_string([sf]) == expected, ( + f"Failed for {sf.key.name}:{sf.operator}{sf.value.raw_value}" + ) + def test_error_unhandled_translation(self): """error.unhandled is inverted to use the EAP error.handled attribute.""" # error.unhandled:1 → looking for unhandled → !error.handled:1 @@ -121,7 +145,7 @@ def test_error_main_thread_key_translated(self): def test_realistic_mixed_query(self): """A realistic issue feed query mixing supported, skipped, and translated filters. Verifies that supported filters are converted, skipped filters are dropped, - and translated filters are rewritten — all in a single query string.""" + aggregation filters are translated, and special filters are rewritten.""" filters = [ SearchFilter(SearchKey("level"), "=", SearchValue("error")), SearchFilter(SearchKey("error.unhandled"), "=", SearchValue("1")), @@ -132,5 +156,6 @@ def test_realistic_mixed_query(self): ] result = search_filters_to_query_string(filters) assert result == ( - "level:error !error.handled:1 platform:[python, javascript] tags[browser]:chrome" + "level:error !error.handled:1 count():>50" + " platform:[python, javascript] tags[browser]:chrome" ) From c5849268e70b1253a8b76cb3281b6d03cc6beb10 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Wed, 15 Apr 2026 11:31:59 -0700 Subject: [PATCH 4/7] feat(issues): Implement EAP group search for issue feed queries (#112987) WIP PR 2 for implementing issue feed search with EAP queries --- .../search/eap/occurrences/aggregates.py | 18 ++ .../search/eap/occurrences/search_executor.py | 121 +++++++++- .../sentry/search/eap/test_search_executor.py | 221 +++++++++++++++++- 3 files changed, 355 insertions(+), 5 deletions(-) diff --git a/src/sentry/search/eap/occurrences/aggregates.py b/src/sentry/search/eap/occurrences/aggregates.py index fca83112b34712..f75ae1bced4faf 100644 --- a/src/sentry/search/eap/occurrences/aggregates.py +++ b/src/sentry/search/eap/occurrences/aggregates.py @@ -113,6 +113,24 @@ ], ), "count_unique": count_unique_aggregate_definition(default_arg="group_id"), + "first_seen": AggregateDefinition( + internal_function=Function.FUNCTION_MIN, + default_search_type="integer", + infer_search_type_from_arguments=False, + arguments=[ + AttributeArgumentDefinition( + attribute_types={ + "duration", + "number", + "integer", + "string", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="timestamp", + ) + ], + ), "last_seen": AggregateDefinition( internal_function=Function.FUNCTION_MAX, default_search_type="integer", diff --git a/src/sentry/search/eap/occurrences/search_executor.py b/src/sentry/search/eap/occurrences/search_executor.py index 3c018acf647670..e596363365889f 100644 --- a/src/sentry/search/eap/occurrences/search_executor.py +++ b/src/sentry/search/eap/occurrences/search_executor.py @@ -1,8 +1,16 @@ import logging from collections.abc import Sequence from datetime import datetime +from typing import Any from sentry.api.event_search import SearchFilter +from sentry.models.environment import Environment +from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.search.eap.occurrences.query_utils import build_group_id_in_filter +from sentry.search.eap.types import SearchResolverConfig +from sentry.search.events.types import SnubaParams +from sentry.snuba.occurrences_rpc import Occurrences from sentry.utils import metrics logger = logging.getLogger(__name__) @@ -11,6 +19,7 @@ # Filters that must be skipped because they have no EAP equivalent. # These would silently become dynamic tag lookups in the EAP SearchResolver # (resolver.py:1026-1060) and produce incorrect results. +# TODO: these are potentially gaps between existing issue feed search behavior and EAP search behavior. May need to adddress. SKIP_FILTERS: frozenset[str] = frozenset( { # event.type is added internally by _query_params_for_error(), not from user filters. @@ -44,7 +53,7 @@ AGGREGATION_FIELD_TO_EAP_FUNCTION: dict[str, str] = { "times_seen": "count()", "last_seen": "last_seen()", - "user_count": "count_unique(user.id)", + "user_count": "count_unique(user)", } @@ -143,7 +152,7 @@ def _convert_aggregation_filter(sf: SearchFilter) -> str | None: e.g. times_seen:>100 → count():>100 last_seen:>2024-01-01 → last_seen():>2024-01-01T00:00:00+00:00 - user_count:>5 → count_unique(user.id):>5 + user_count:>5 → count_unique(user):>5 """ eap_function = AGGREGATION_FIELD_TO_EAP_FUNCTION[sf.key.name] formatted_value = _format_value(sf.value.raw_value) @@ -158,6 +167,114 @@ def _convert_aggregation_filter(sf: SearchFilter) -> str | None: return None +# Maps legacy sort_field names (from PostgresSnubaQueryExecutor.sort_strategies values) +# to (selected_columns, orderby) for EAP queries. +# +# Reference — legacy sort_strategies in executors.py: +# "date" → "last_seen" → max(timestamp) * 1000 +# "freq" → "times_seen" → count() +# "new" → "first_seen" → min(coalesce(group_first_seen, timestamp)) * 1000 +# "user" → "user_count" → uniq(tags[sentry:user]) +# "trends" → "trends" → complex ClickHouse expression (not supported) +# "recommended" → "recommended" → complex ClickHouse expression (not supported) +# "inbox" → "" → Postgres only (not supported) +EAP_SORT_STRATEGIES: dict[str, tuple[list[str], list[str]]] = { + "last_seen": (["group_id", "last_seen()"], ["-last_seen()"]), + "times_seen": (["group_id", "count()"], ["-count()"]), + "first_seen": (["group_id", "first_seen()"], ["-first_seen()"]), + "user_count": (["group_id", "count_unique(user)"], ["-count_unique(user)"]), +} + + +def run_eap_group_search( + start: datetime, + end: datetime, + project_ids: Sequence[int], + environment_ids: Sequence[int] | None, + sort_field: str, + organization: Organization, + group_ids: Sequence[int] | None = None, + limit: int | None = None, + offset: int = 0, + search_filters: Sequence[SearchFilter] | None = None, + referrer: str = "", +) -> tuple[list[tuple[int, Any]], int]: + """EAP equivalent of PostgresSnubaQueryExecutor.snuba_search(). + + Returns a tuple of: + * a list of (group_id, sort_score) tuples, + * total count (0 during double-reading; legacy provides the real total). + + This matches the return signature of snuba_search() so it can be used + as the experimental branch in check_and_choose(). + """ + if sort_field not in EAP_SORT_STRATEGIES: + return ([], 0) + + selected_columns, orderby = EAP_SORT_STRATEGIES[sort_field] + score_column = selected_columns[1] # e.g. "last_seen()" or "count()" + + projects = list(Project.objects.filter(id__in=project_ids)) + if not projects: + return ([], 0) + + environments: list[Environment] = [] + if environment_ids: + environments = list( + Environment.objects.filter(organization_id=organization.id, id__in=environment_ids) + ) + + snuba_params = SnubaParams( + start=start, + end=end, + organization=organization, + projects=projects, + environments=environments, + ) + + query_string = search_filters_to_query_string(search_filters or []) + + extra_conditions = None + if group_ids: + extra_conditions = build_group_id_in_filter(group_ids) + + try: + result = Occurrences.run_table_query( + params=snuba_params, + query_string=query_string, + selected_columns=selected_columns, + orderby=orderby, + offset=offset, + limit=limit or 100, + referrer=referrer, + config=SearchResolverConfig(), + extra_conditions=extra_conditions, + ) + except Exception: + logger.exception( + "eap.search_executor.run_table_query_failed", + extra={ + "organization_id": organization.id, + "project_ids": project_ids, + "sort_field": sort_field, + "referrer": referrer, + }, + ) + return ([], 0) + + tuples: list[tuple[int, Any]] = [] + for row in result.get("data", []): + group_id = row.get("group_id") + score = row.get(score_column) + if group_id is not None: + tuples.append((int(group_id), score)) + + # The EAP RPC TraceItemTableResponse does not include a total count + # (unlike Snuba's totals=True). During double-reading the legacy result + # provides the real total, so we return 0 here. + return (tuples, 0) + + def _format_value( raw_value: str | int | float | datetime | Sequence[str] | Sequence[float], ) -> str: diff --git a/tests/sentry/search/eap/test_search_executor.py b/tests/sentry/search/eap/test_search_executor.py index 8f67ebaafa738c..273e8b55e0e2c9 100644 --- a/tests/sentry/search/eap/test_search_executor.py +++ b/tests/sentry/search/eap/test_search_executor.py @@ -1,7 +1,11 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from sentry.api.event_search import SearchFilter, SearchKey, SearchValue -from sentry.search.eap.occurrences.search_executor import search_filters_to_query_string +from sentry.search.eap.occurrences.search_executor import ( + run_eap_group_search, + search_filters_to_query_string, +) +from sentry.testutils.cases import OccurrenceTestCase, SnubaTestCase, TestCase class TestSearchFiltersToQueryString: @@ -105,7 +109,7 @@ def test_aggregation_filters_translated(self): ), ( SearchFilter(SearchKey("user_count"), ">", SearchValue("5")), - "count_unique(user.id):>5", + "count_unique(user):>5", ), ] for sf, expected in cases: @@ -159,3 +163,214 @@ def test_realistic_mixed_query(self): "level:error !error.handled:1 count():>50" " platform:[python, javascript] tags[browser]:chrome" ) + + +class TestRunEAPGroupSearch(TestCase, SnubaTestCase, OccurrenceTestCase): + def setUp(self) -> None: + super().setUp() + self.now = datetime.now(timezone.utc) + self.start = self.now - timedelta(hours=1) + self.end = self.now + timedelta(hours=1) + + self.group1 = self.create_group(project=self.project) + self.group2 = self.create_group(project=self.project) + + # Store 3 error occurrences for group1, 1 warning for group2 + for _ in range(3): + occ = self.create_eap_occurrence( + group_id=self.group1.id, + level="error", + timestamp=self.now - timedelta(minutes=5), + ) + self.store_eap_items([occ]) + + occ = self.create_eap_occurrence( + group_id=self.group2.id, + level="warning", + timestamp=self.now - timedelta(minutes=10), + ) + self.store_eap_items([occ]) + + def test_last_seen_sort(self) -> None: + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="last_seen", + organization=self.organization, + referrer="test", + ) + group_ids = [gid for gid, _ in result] + assert len(group_ids) == 2 + assert group_ids[0] == self.group1.id + assert group_ids[1] == self.group2.id + + def test_times_seen_sort(self) -> None: + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="times_seen", + organization=self.organization, + referrer="test", + ) + group_ids = [gid for gid, _ in result] + assert len(group_ids) == 2 + assert group_ids[0] == self.group1.id + assert group_ids[1] == self.group2.id + + def test_first_seen_sort(self) -> None: + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="first_seen", + organization=self.organization, + referrer="test", + ) + group_ids = [gid for gid, _ in result] + assert len(group_ids) == 2 + assert group_ids[0] == self.group1.id + assert group_ids[1] == self.group2.id + + def test_user_count_sort(self) -> None: + group3 = self.create_group(project=self.project) + for i in range(3): + occ = self.create_eap_occurrence( + group_id=group3.id, + level="error", + timestamp=self.now - timedelta(minutes=3), + tags={"sentry:user": f"user-{i}@example.com"}, + ) + self.store_eap_items([occ]) + + occ = self.create_eap_occurrence( + group_id=self.group1.id, + level="error", + timestamp=self.now - timedelta(minutes=3), + tags={"sentry:user": "only-user@example.com"}, + ) + self.store_eap_items([occ]) + + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="user_count", + organization=self.organization, + referrer="test", + ) + group_ids = [gid for gid, _ in result] + assert len(group_ids) == 2 + assert group_ids[0] == group3.id + assert group_ids[1] == self.group1.id + + def test_unsupported_sort_returns_empty(self) -> None: + """Unsupported sort strategies (trends, recommended) return empty + so the caller can fall back to the legacy result.""" + result, total = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="trends", + organization=self.organization, + referrer="test", + ) + assert result == [] + assert total == 0 + + def test_filter_narrows_results(self) -> None: + """A level filter excludes groups that don't match.""" + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="last_seen", + organization=self.organization, + search_filters=[SearchFilter(SearchKey("level"), "=", SearchValue("error"))], + referrer="test", + ) + group_ids = {gid for gid, _ in result} + assert group_ids == {self.group1.id} + + def test_group_id_pre_filter(self) -> None: + """Pre-filtered group_ids are passed as extra_conditions, narrowing results.""" + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="last_seen", + organization=self.organization, + group_ids=[self.group1.id], + referrer="test", + ) + assert {gid for gid, _ in result} == {self.group1.id} + + def test_environment_filter(self) -> None: + """Environment IDs are applied via SnubaParams to narrow results.""" + env = self.create_environment(project=self.project, name="production") + occ = self.create_eap_occurrence( + group_id=self.group1.id, + level="error", + environment="production", + timestamp=self.now - timedelta(minutes=2), + ) + self.store_eap_items([occ]) + + occ2 = self.create_eap_occurrence( + group_id=self.group2.id, + level="warning", + environment="staging", + timestamp=self.now - timedelta(minutes=2), + ) + self.store_eap_items([occ2]) + + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=[env.id], + sort_field="last_seen", + organization=self.organization, + referrer="test", + ) + group_ids = {gid for gid, _ in result} + assert self.group1.id in group_ids + assert self.group2.id not in group_ids + + def test_sort_and_filter(self) -> None: + """Combines sorting, filtering, and group_id pre-filtering in one query. + Creates 3 groups across 2 levels, pre-filters to 2 of them, filters by + level, and verifies the remaining group is returned with correct sort order.""" + group3 = self.create_group(project=self.project) + for i in range(5): + occ = self.create_eap_occurrence( + group_id=group3.id, + level="error", + timestamp=self.now - timedelta(minutes=1 + i), + ) + self.store_eap_items([occ]) + + result, _ = run_eap_group_search( + start=self.start, + end=self.end, + project_ids=[self.project.id], + environment_ids=None, + sort_field="times_seen", + organization=self.organization, + group_ids=[self.group1.id, group3.id], + search_filters=[SearchFilter(SearchKey("level"), "=", SearchValue("error"))], + referrer="test", + ) + group_ids = [gid for gid, _ in result] + assert len(group_ids) == 2 + assert group_ids[0] == group3.id + assert group_ids[1] == self.group1.id + assert self.group2.id not in group_ids From c584fcfe4cac4e82c38d1d1af1b504acce35df78 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Wed, 15 Apr 2026 12:43:15 -0700 Subject: [PATCH 5/7] Minor changes & cleanup --- .../search/eap/occurrences/search_executor.py | 119 ++++++++---------- .../sentry/search/eap/test_search_executor.py | 20 --- 2 files changed, 50 insertions(+), 89 deletions(-) diff --git a/src/sentry/search/eap/occurrences/search_executor.py b/src/sentry/search/eap/occurrences/search_executor.py index e596363365889f..2afa8bf1b726f4 100644 --- a/src/sentry/search/eap/occurrences/search_executor.py +++ b/src/sentry/search/eap/occurrences/search_executor.py @@ -11,14 +11,13 @@ from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import SnubaParams from sentry.snuba.occurrences_rpc import Occurrences -from sentry.utils import metrics logger = logging.getLogger(__name__) # Filters that must be skipped because they have no EAP equivalent. # These would silently become dynamic tag lookups in the EAP SearchResolver -# (resolver.py:1026-1060) and produce incorrect results. +# and produce incorrect results. # TODO: these are potentially gaps between existing issue feed search behavior and EAP search behavior. May need to adddress. SKIP_FILTERS: frozenset[str] = frozenset( { @@ -60,7 +59,8 @@ def search_filters_to_query_string( search_filters: Sequence[SearchFilter], ) -> str: - """Convert Snuba-relevant SearchFilter objects to an EAP query string. + """ + Convert Snuba-relevant SearchFilter objects to an EAP query string. Expects filters that have already been stripped of postgres-only fields (status, assigned_to, bookmarked_by, etc.) by the caller. @@ -85,10 +85,6 @@ def _convert_single_filter(sf: SearchFilter) -> str | None: return _convert_aggregation_filter(sf) if key in SKIP_FILTERS: - metrics.incr( - "eap.search_executor.filter_skipped", - tags={"key": key}, - ) return None # error.unhandled requires special inversion logic. @@ -126,17 +122,24 @@ def _convert_single_filter(sf: SearchFilter) -> str | None: return None -def _convert_error_unhandled(sf: SearchFilter) -> str | None: - """Convert error.unhandled filter to the EAP error.handled attribute. +def _convert_aggregation_filter(sf: SearchFilter) -> str | None: + eap_function = AGGREGATION_FIELD_TO_EAP_FUNCTION[sf.key.name] + formatted_value = _format_value(sf.value.raw_value) - error.unhandled:1 (or true) → !error.handled:1 - error.unhandled:0 (or false) → error.handled:1 - !error.unhandled:1 → error.handled:1 - """ + if sf.operator in (">", ">=", "<", "<="): + return f"{eap_function}:{sf.operator}{formatted_value}" + elif sf.operator == "=": + return f"{eap_function}:{formatted_value}" + elif sf.operator == "!=": + return f"!{eap_function}:{formatted_value}" + + return None + + +def _convert_error_unhandled(sf: SearchFilter) -> str | None: raw_value = sf.value.raw_value op = sf.operator - # Determine if the user is looking for unhandled errors is_looking_for_unhandled = (op == "=" and raw_value in ("1", 1, True, "true")) or ( op == "!=" and raw_value in ("0", 0, False, "false") ) @@ -147,24 +150,38 @@ def _convert_error_unhandled(sf: SearchFilter) -> str | None: return "error.handled:1" -def _convert_aggregation_filter(sf: SearchFilter) -> str | None: - """Convert a legacy aggregation field filter to EAP function syntax. +def _format_value( + raw_value: str | int | float | datetime | Sequence[str] | Sequence[float], +) -> str: + if isinstance(raw_value, (list, tuple)): + parts = ", ".join(_format_single_value(v) for v in raw_value) + return f"[{parts}]" + if isinstance(raw_value, datetime): + return raw_value.isoformat() + if isinstance(raw_value, (int, float)): + return str(raw_value) + return _format_string_value(str(raw_value)) - e.g. times_seen:>100 → count():>100 - last_seen:>2024-01-01 → last_seen():>2024-01-01T00:00:00+00:00 - user_count:>5 → count_unique(user):>5 - """ - eap_function = AGGREGATION_FIELD_TO_EAP_FUNCTION[sf.key.name] - formatted_value = _format_value(sf.value.raw_value) - if sf.operator in (">", ">=", "<", "<="): - return f"{eap_function}:{sf.operator}{formatted_value}" - elif sf.operator == "=": - return f"{eap_function}:{formatted_value}" - elif sf.operator == "!=": - return f"!{eap_function}:{formatted_value}" +def _format_single_value(value: str | int | float | datetime) -> str: + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, (int, float)): + return str(value) + return _format_string_value(str(value)) - return None + +def _format_string_value(s: str) -> str: + # Wildcard values pass through as-is for the SearchResolver to handle + if "*" in s: + return s + + # Quote strings containing spaces or special characters + if " " in s or '"' in s or "," in s or "(" in s or ")" in s: + escaped = s.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + return s # Maps legacy sort_field names (from PostgresSnubaQueryExecutor.sort_strategies values) @@ -177,7 +194,7 @@ def _convert_aggregation_filter(sf: SearchFilter) -> str | None: # "user" → "user_count" → uniq(tags[sentry:user]) # "trends" → "trends" → complex ClickHouse expression (not supported) # "recommended" → "recommended" → complex ClickHouse expression (not supported) -# "inbox" → "" → Postgres only (not supported) +# "inbox" → "" → Postgres only (not supported) EAP_SORT_STRATEGIES: dict[str, tuple[list[str], list[str]]] = { "last_seen": (["group_id", "last_seen()"], ["-last_seen()"]), "times_seen": (["group_id", "count()"], ["-count()"]), @@ -199,14 +216,12 @@ def run_eap_group_search( search_filters: Sequence[SearchFilter] | None = None, referrer: str = "", ) -> tuple[list[tuple[int, Any]], int]: - """EAP equivalent of PostgresSnubaQueryExecutor.snuba_search(). + """ + EAP equivalent of PostgresSnubaQueryExecutor.snuba_search(). Returns a tuple of: * a list of (group_id, sort_score) tuples, * total count (0 during double-reading; legacy provides the real total). - - This matches the return signature of snuba_search() so it can be used - as the experimental branch in check_and_choose(). """ if sort_field not in EAP_SORT_STRATEGIES: return ([], 0) @@ -269,41 +284,7 @@ def run_eap_group_search( if group_id is not None: tuples.append((int(group_id), score)) - # The EAP RPC TraceItemTableResponse does not include a total count + # TODO: the EAP RPC TraceItemTableResponse does not include a total count # (unlike Snuba's totals=True). During double-reading the legacy result # provides the real total, so we return 0 here. return (tuples, 0) - - -def _format_value( - raw_value: str | int | float | datetime | Sequence[str] | Sequence[float], -) -> str: - if isinstance(raw_value, (list, tuple)): - parts = ", ".join(_format_single_value(v) for v in raw_value) - return f"[{parts}]" - if isinstance(raw_value, datetime): - return raw_value.isoformat() - if isinstance(raw_value, (int, float)): - return str(raw_value) - return _format_string_value(str(raw_value)) - - -def _format_single_value(value: str | int | float | datetime) -> str: - if isinstance(value, datetime): - return value.isoformat() - if isinstance(value, (int, float)): - return str(value) - return _format_string_value(str(value)) - - -def _format_string_value(s: str) -> str: - # Wildcard values pass through as-is for the SearchResolver to handle - if "*" in s: - return s - - # Quote strings containing spaces or special characters - if " " in s or '"' in s or "," in s or "(" in s or ")" in s: - escaped = s.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - return s diff --git a/tests/sentry/search/eap/test_search_executor.py b/tests/sentry/search/eap/test_search_executor.py index 273e8b55e0e2c9..4a9d181c0eebe3 100644 --- a/tests/sentry/search/eap/test_search_executor.py +++ b/tests/sentry/search/eap/test_search_executor.py @@ -10,7 +10,6 @@ class TestSearchFiltersToQueryString: def test_all_operator_types(self): - """Each operator type produces the correct EAP query syntax.""" cases = [ (SearchFilter(SearchKey("level"), "=", SearchValue("error")), "level:error"), (SearchFilter(SearchKey("level"), "!=", SearchValue("error")), "!level:error"), @@ -33,8 +32,6 @@ def test_all_operator_types(self): ) def test_value_formatting(self): - """Values with special characters, wildcards, numerics, and datetimes - are formatted correctly.""" dt = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) cases = [ # Wildcards pass through as-is @@ -67,7 +64,6 @@ def test_value_formatting(self): assert search_filters_to_query_string([sf]) == expected def test_has_and_not_has_filters(self): - """Empty-value filters are converted to has:/!has: syntax.""" # has:user.email → parsed as op=!=, value="" has_filter = SearchFilter(SearchKey("user.email"), "!=", SearchValue("")) assert search_filters_to_query_string([has_filter]) == "has:user.email" @@ -77,7 +73,6 @@ def test_has_and_not_has_filters(self): assert search_filters_to_query_string([not_has_filter]) == "!has:user.email" def test_skipped_filters_are_dropped(self): - """All filters with no EAP equivalent are silently dropped.""" filters = [ SearchFilter(SearchKey("event.type"), "=", SearchValue("error")), SearchFilter(SearchKey("release.stage"), "=", SearchValue("adopted")), @@ -91,8 +86,6 @@ def test_skipped_filters_are_dropped(self): assert search_filters_to_query_string(filters) == "" def test_aggregation_filters_translated(self): - """Legacy aggregation field names are translated to EAP function syntax - so the SearchResolver parses them as AggregateFilter (HAVING) conditions.""" dt = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc) cases = [ ( @@ -118,7 +111,6 @@ def test_aggregation_filters_translated(self): ) def test_error_unhandled_translation(self): - """error.unhandled is inverted to use the EAP error.handled attribute.""" # error.unhandled:1 → looking for unhandled → !error.handled:1 assert ( search_filters_to_query_string( @@ -142,14 +134,10 @@ def test_error_unhandled_translation(self): ) def test_error_main_thread_key_translated(self): - """error.main_thread is renamed to the EAP attribute name.""" filters = [SearchFilter(SearchKey("error.main_thread"), "=", SearchValue("1"))] assert search_filters_to_query_string(filters) == "exception_main_thread:1" def test_realistic_mixed_query(self): - """A realistic issue feed query mixing supported, skipped, and translated filters. - Verifies that supported filters are converted, skipped filters are dropped, - aggregation filters are translated, and special filters are rewritten.""" filters = [ SearchFilter(SearchKey("level"), "=", SearchValue("error")), SearchFilter(SearchKey("error.unhandled"), "=", SearchValue("1")), @@ -270,8 +258,6 @@ def test_user_count_sort(self) -> None: assert group_ids[1] == self.group1.id def test_unsupported_sort_returns_empty(self) -> None: - """Unsupported sort strategies (trends, recommended) return empty - so the caller can fall back to the legacy result.""" result, total = run_eap_group_search( start=self.start, end=self.end, @@ -285,7 +271,6 @@ def test_unsupported_sort_returns_empty(self) -> None: assert total == 0 def test_filter_narrows_results(self) -> None: - """A level filter excludes groups that don't match.""" result, _ = run_eap_group_search( start=self.start, end=self.end, @@ -300,7 +285,6 @@ def test_filter_narrows_results(self) -> None: assert group_ids == {self.group1.id} def test_group_id_pre_filter(self) -> None: - """Pre-filtered group_ids are passed as extra_conditions, narrowing results.""" result, _ = run_eap_group_search( start=self.start, end=self.end, @@ -314,7 +298,6 @@ def test_group_id_pre_filter(self) -> None: assert {gid for gid, _ in result} == {self.group1.id} def test_environment_filter(self) -> None: - """Environment IDs are applied via SnubaParams to narrow results.""" env = self.create_environment(project=self.project, name="production") occ = self.create_eap_occurrence( group_id=self.group1.id, @@ -346,9 +329,6 @@ def test_environment_filter(self) -> None: assert self.group2.id not in group_ids def test_sort_and_filter(self) -> None: - """Combines sorting, filtering, and group_id pre-filtering in one query. - Creates 3 groups across 2 levels, pre-filters to 2 of them, filters by - level, and verifies the remaining group is returned with correct sort order.""" group3 = self.create_group(project=self.project) for i in range(5): occ = self.create_eap_occurrence( From 50f0c86f33a813d2a8968212e2af10b7ad6ef48e Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Wed, 15 Apr 2026 15:20:01 -0700 Subject: [PATCH 6/7] Include `first_seen` in aggregation filter mapping --- src/sentry/search/eap/occurrences/search_executor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sentry/search/eap/occurrences/search_executor.py b/src/sentry/search/eap/occurrences/search_executor.py index 2afa8bf1b726f4..6c5a0d352ad967 100644 --- a/src/sentry/search/eap/occurrences/search_executor.py +++ b/src/sentry/search/eap/occurrences/search_executor.py @@ -50,8 +50,9 @@ # The EAP SearchResolver parses function syntax like count():>100 as AggregateFilter objects # and routes them to the aggregation_filter field on the RPC request. AGGREGATION_FIELD_TO_EAP_FUNCTION: dict[str, str] = { - "times_seen": "count()", "last_seen": "last_seen()", + "times_seen": "count()", + "first_seen": "first_seen()", "user_count": "count_unique(user)", } From 01f8433a374226c19eb52edf8ed7b0604357b075 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Wed, 15 Apr 2026 15:34:49 -0700 Subject: [PATCH 7/7] Fix string value formatting for query string --- src/sentry/search/eap/occurrences/search_executor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/sentry/search/eap/occurrences/search_executor.py b/src/sentry/search/eap/occurrences/search_executor.py index 6c5a0d352ad967..d067efe80121a3 100644 --- a/src/sentry/search/eap/occurrences/search_executor.py +++ b/src/sentry/search/eap/occurrences/search_executor.py @@ -173,11 +173,7 @@ def _format_single_value(value: str | int | float | datetime) -> str: def _format_string_value(s: str) -> str: - # Wildcard values pass through as-is for the SearchResolver to handle - if "*" in s: - return s - - # Quote strings containing spaces or special characters + # Quote strings containing spaces or special characters. if " " in s or '"' in s or "," in s or "(" in s or ")" in s: escaped = s.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"'