Skip to content

Commit 9eaa6a7

Browse files
authored
Merge branch 'shashjar/issue-feed-search-eap-parse-search-filters-into-query-string' into shashjar/issue-feed-search-eap-implement-eap-double-read
2 parents 1aee64f + c584fcf commit 9eaa6a7

File tree

2 files changed

+50
-89
lines changed

2 files changed

+50
-89
lines changed

src/sentry/search/eap/occurrences/search_executor.py

Lines changed: 50 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,13 @@
1111
from sentry.search.eap.types import SearchResolverConfig
1212
from sentry.search.events.types import SnubaParams
1313
from sentry.snuba.occurrences_rpc import Occurrences
14-
from sentry.utils import metrics
1514

1615
logger = logging.getLogger(__name__)
1716

1817

1918
# Filters that must be skipped because they have no EAP equivalent.
2019
# These would silently become dynamic tag lookups in the EAP SearchResolver
21-
# (resolver.py:1026-1060) and produce incorrect results.
20+
# and produce incorrect results.
2221
# TODO: these are potentially gaps between existing issue feed search behavior and EAP search behavior. May need to adddress.
2322
SKIP_FILTERS: frozenset[str] = frozenset(
2423
{
@@ -60,7 +59,8 @@
6059
def search_filters_to_query_string(
6160
search_filters: Sequence[SearchFilter],
6261
) -> str:
63-
"""Convert Snuba-relevant SearchFilter objects to an EAP query string.
62+
"""
63+
Convert Snuba-relevant SearchFilter objects to an EAP query string.
6464
6565
Expects filters that have already been stripped of postgres-only fields
6666
(status, assigned_to, bookmarked_by, etc.) by the caller.
@@ -85,10 +85,6 @@ def _convert_single_filter(sf: SearchFilter) -> str | None:
8585
return _convert_aggregation_filter(sf)
8686

8787
if key in SKIP_FILTERS:
88-
metrics.incr(
89-
"eap.search_executor.filter_skipped",
90-
tags={"key": key},
91-
)
9288
return None
9389

9490
# error.unhandled requires special inversion logic.
@@ -126,17 +122,24 @@ def _convert_single_filter(sf: SearchFilter) -> str | None:
126122
return None
127123

128124

129-
def _convert_error_unhandled(sf: SearchFilter) -> str | None:
130-
"""Convert error.unhandled filter to the EAP error.handled attribute.
125+
def _convert_aggregation_filter(sf: SearchFilter) -> str | None:
126+
eap_function = AGGREGATION_FIELD_TO_EAP_FUNCTION[sf.key.name]
127+
formatted_value = _format_value(sf.value.raw_value)
131128

132-
error.unhandled:1 (or true) → !error.handled:1
133-
error.unhandled:0 (or false) → error.handled:1
134-
!error.unhandled:1 → error.handled:1
135-
"""
129+
if sf.operator in (">", ">=", "<", "<="):
130+
return f"{eap_function}:{sf.operator}{formatted_value}"
131+
elif sf.operator == "=":
132+
return f"{eap_function}:{formatted_value}"
133+
elif sf.operator == "!=":
134+
return f"!{eap_function}:{formatted_value}"
135+
136+
return None
137+
138+
139+
def _convert_error_unhandled(sf: SearchFilter) -> str | None:
136140
raw_value = sf.value.raw_value
137141
op = sf.operator
138142

139-
# Determine if the user is looking for unhandled errors
140143
is_looking_for_unhandled = (op == "=" and raw_value in ("1", 1, True, "true")) or (
141144
op == "!=" and raw_value in ("0", 0, False, "false")
142145
)
@@ -147,24 +150,38 @@ def _convert_error_unhandled(sf: SearchFilter) -> str | None:
147150
return "error.handled:1"
148151

149152

150-
def _convert_aggregation_filter(sf: SearchFilter) -> str | None:
151-
"""Convert a legacy aggregation field filter to EAP function syntax.
153+
def _format_value(
154+
raw_value: str | int | float | datetime | Sequence[str] | Sequence[float],
155+
) -> str:
156+
if isinstance(raw_value, (list, tuple)):
157+
parts = ", ".join(_format_single_value(v) for v in raw_value)
158+
return f"[{parts}]"
159+
if isinstance(raw_value, datetime):
160+
return raw_value.isoformat()
161+
if isinstance(raw_value, (int, float)):
162+
return str(raw_value)
163+
return _format_string_value(str(raw_value))
152164

153-
e.g. times_seen:>100 → count():>100
154-
last_seen:>2024-01-01 → last_seen():>2024-01-01T00:00:00+00:00
155-
user_count:>5 → count_unique(user):>5
156-
"""
157-
eap_function = AGGREGATION_FIELD_TO_EAP_FUNCTION[sf.key.name]
158-
formatted_value = _format_value(sf.value.raw_value)
159165

160-
if sf.operator in (">", ">=", "<", "<="):
161-
return f"{eap_function}:{sf.operator}{formatted_value}"
162-
elif sf.operator == "=":
163-
return f"{eap_function}:{formatted_value}"
164-
elif sf.operator == "!=":
165-
return f"!{eap_function}:{formatted_value}"
166+
def _format_single_value(value: str | int | float | datetime) -> str:
167+
if isinstance(value, datetime):
168+
return value.isoformat()
169+
if isinstance(value, (int, float)):
170+
return str(value)
171+
return _format_string_value(str(value))
166172

167-
return None
173+
174+
def _format_string_value(s: str) -> str:
175+
# Wildcard values pass through as-is for the SearchResolver to handle
176+
if "*" in s:
177+
return s
178+
179+
# Quote strings containing spaces or special characters
180+
if " " in s or '"' in s or "," in s or "(" in s or ")" in s:
181+
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
182+
return f'"{escaped}"'
183+
184+
return s
168185

169186

170187
# Maps legacy sort_field names (from PostgresSnubaQueryExecutor.sort_strategies values)
@@ -177,7 +194,7 @@ def _convert_aggregation_filter(sf: SearchFilter) -> str | None:
177194
# "user" → "user_count" → uniq(tags[sentry:user])
178195
# "trends" → "trends" → complex ClickHouse expression (not supported)
179196
# "recommended" → "recommended" → complex ClickHouse expression (not supported)
180-
# "inbox" → "" → Postgres only (not supported)
197+
# "inbox" → "" → Postgres only (not supported)
181198
EAP_SORT_STRATEGIES: dict[str, tuple[list[str], list[str]]] = {
182199
"last_seen": (["group_id", "last_seen()"], ["-last_seen()"]),
183200
"times_seen": (["group_id", "count()"], ["-count()"]),
@@ -199,14 +216,12 @@ def run_eap_group_search(
199216
search_filters: Sequence[SearchFilter] | None = None,
200217
referrer: str = "",
201218
) -> tuple[list[tuple[int, Any]], int]:
202-
"""EAP equivalent of PostgresSnubaQueryExecutor.snuba_search().
219+
"""
220+
EAP equivalent of PostgresSnubaQueryExecutor.snuba_search().
203221
204222
Returns a tuple of:
205223
* a list of (group_id, sort_score) tuples,
206224
* total count (0 during double-reading; legacy provides the real total).
207-
208-
This matches the return signature of snuba_search() so it can be used
209-
as the experimental branch in check_and_choose().
210225
"""
211226
if sort_field not in EAP_SORT_STRATEGIES:
212227
return ([], 0)
@@ -269,41 +284,7 @@ def run_eap_group_search(
269284
if group_id is not None:
270285
tuples.append((int(group_id), score))
271286

272-
# The EAP RPC TraceItemTableResponse does not include a total count
287+
# TODO: the EAP RPC TraceItemTableResponse does not include a total count
273288
# (unlike Snuba's totals=True). During double-reading the legacy result
274289
# provides the real total, so we return 0 here.
275290
return (tuples, 0)
276-
277-
278-
def _format_value(
279-
raw_value: str | int | float | datetime | Sequence[str] | Sequence[float],
280-
) -> str:
281-
if isinstance(raw_value, (list, tuple)):
282-
parts = ", ".join(_format_single_value(v) for v in raw_value)
283-
return f"[{parts}]"
284-
if isinstance(raw_value, datetime):
285-
return raw_value.isoformat()
286-
if isinstance(raw_value, (int, float)):
287-
return str(raw_value)
288-
return _format_string_value(str(raw_value))
289-
290-
291-
def _format_single_value(value: str | int | float | datetime) -> str:
292-
if isinstance(value, datetime):
293-
return value.isoformat()
294-
if isinstance(value, (int, float)):
295-
return str(value)
296-
return _format_string_value(str(value))
297-
298-
299-
def _format_string_value(s: str) -> str:
300-
# Wildcard values pass through as-is for the SearchResolver to handle
301-
if "*" in s:
302-
return s
303-
304-
# Quote strings containing spaces or special characters
305-
if " " in s or '"' in s or "," in s or "(" in s or ")" in s:
306-
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
307-
return f'"{escaped}"'
308-
309-
return s

tests/sentry/search/eap/test_search_executor.py

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
class TestSearchFiltersToQueryString:
1212
def test_all_operator_types(self):
13-
"""Each operator type produces the correct EAP query syntax."""
1413
cases = [
1514
(SearchFilter(SearchKey("level"), "=", SearchValue("error")), "level:error"),
1615
(SearchFilter(SearchKey("level"), "!=", SearchValue("error")), "!level:error"),
@@ -33,8 +32,6 @@ def test_all_operator_types(self):
3332
)
3433

3534
def test_value_formatting(self):
36-
"""Values with special characters, wildcards, numerics, and datetimes
37-
are formatted correctly."""
3835
dt = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
3936
cases = [
4037
# Wildcards pass through as-is
@@ -67,7 +64,6 @@ def test_value_formatting(self):
6764
assert search_filters_to_query_string([sf]) == expected
6865

6966
def test_has_and_not_has_filters(self):
70-
"""Empty-value filters are converted to has:/!has: syntax."""
7167
# has:user.email → parsed as op=!=, value=""
7268
has_filter = SearchFilter(SearchKey("user.email"), "!=", SearchValue(""))
7369
assert search_filters_to_query_string([has_filter]) == "has:user.email"
@@ -77,7 +73,6 @@ def test_has_and_not_has_filters(self):
7773
assert search_filters_to_query_string([not_has_filter]) == "!has:user.email"
7874

7975
def test_skipped_filters_are_dropped(self):
80-
"""All filters with no EAP equivalent are silently dropped."""
8176
filters = [
8277
SearchFilter(SearchKey("event.type"), "=", SearchValue("error")),
8378
SearchFilter(SearchKey("release.stage"), "=", SearchValue("adopted")),
@@ -91,8 +86,6 @@ def test_skipped_filters_are_dropped(self):
9186
assert search_filters_to_query_string(filters) == ""
9287

9388
def test_aggregation_filters_translated(self):
94-
"""Legacy aggregation field names are translated to EAP function syntax
95-
so the SearchResolver parses them as AggregateFilter (HAVING) conditions."""
9689
dt = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
9790
cases = [
9891
(
@@ -118,7 +111,6 @@ def test_aggregation_filters_translated(self):
118111
)
119112

120113
def test_error_unhandled_translation(self):
121-
"""error.unhandled is inverted to use the EAP error.handled attribute."""
122114
# error.unhandled:1 → looking for unhandled → !error.handled:1
123115
assert (
124116
search_filters_to_query_string(
@@ -142,14 +134,10 @@ def test_error_unhandled_translation(self):
142134
)
143135

144136
def test_error_main_thread_key_translated(self):
145-
"""error.main_thread is renamed to the EAP attribute name."""
146137
filters = [SearchFilter(SearchKey("error.main_thread"), "=", SearchValue("1"))]
147138
assert search_filters_to_query_string(filters) == "exception_main_thread:1"
148139

149140
def test_realistic_mixed_query(self):
150-
"""A realistic issue feed query mixing supported, skipped, and translated filters.
151-
Verifies that supported filters are converted, skipped filters are dropped,
152-
aggregation filters are translated, and special filters are rewritten."""
153141
filters = [
154142
SearchFilter(SearchKey("level"), "=", SearchValue("error")),
155143
SearchFilter(SearchKey("error.unhandled"), "=", SearchValue("1")),
@@ -270,8 +258,6 @@ def test_user_count_sort(self) -> None:
270258
assert group_ids[1] == self.group1.id
271259

272260
def test_unsupported_sort_returns_empty(self) -> None:
273-
"""Unsupported sort strategies (trends, recommended) return empty
274-
so the caller can fall back to the legacy result."""
275261
result, total = run_eap_group_search(
276262
start=self.start,
277263
end=self.end,
@@ -285,7 +271,6 @@ def test_unsupported_sort_returns_empty(self) -> None:
285271
assert total == 0
286272

287273
def test_filter_narrows_results(self) -> None:
288-
"""A level filter excludes groups that don't match."""
289274
result, _ = run_eap_group_search(
290275
start=self.start,
291276
end=self.end,
@@ -300,7 +285,6 @@ def test_filter_narrows_results(self) -> None:
300285
assert group_ids == {self.group1.id}
301286

302287
def test_group_id_pre_filter(self) -> None:
303-
"""Pre-filtered group_ids are passed as extra_conditions, narrowing results."""
304288
result, _ = run_eap_group_search(
305289
start=self.start,
306290
end=self.end,
@@ -314,7 +298,6 @@ def test_group_id_pre_filter(self) -> None:
314298
assert {gid for gid, _ in result} == {self.group1.id}
315299

316300
def test_environment_filter(self) -> None:
317-
"""Environment IDs are applied via SnubaParams to narrow results."""
318301
env = self.create_environment(project=self.project, name="production")
319302
occ = self.create_eap_occurrence(
320303
group_id=self.group1.id,
@@ -346,9 +329,6 @@ def test_environment_filter(self) -> None:
346329
assert self.group2.id not in group_ids
347330

348331
def test_sort_and_filter(self) -> None:
349-
"""Combines sorting, filtering, and group_id pre-filtering in one query.
350-
Creates 3 groups across 2 levels, pre-filters to 2 of them, filters by
351-
level, and verifies the remaining group is returned with correct sort order."""
352332
group3 = self.create_group(project=self.project)
353333
for i in range(5):
354334
occ = self.create_eap_occurrence(

0 commit comments

Comments
 (0)