Skip to content

Commit f1d8ce6

Browse files
authored
feat(eap): Support release filters on eap spans (#92281)
This support for the following filters - `release:latest` - `release.stage` - `release.version` - `release.package` - `release.build` Closes EXP-264
1 parent dc5165d commit f1d8ce6

File tree

7 files changed

+623
-1
lines changed

7 files changed

+623
-1
lines changed

src/sentry/search/eap/columns.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from collections.abc import Callable, Iterable
1+
from collections.abc import Callable, Iterable, Mapping
22
from dataclasses import dataclass, field
33
from datetime import datetime
44
from typing import Any, Literal, TypeAlias, TypedDict
@@ -18,6 +18,7 @@
1818
)
1919
from sentry_protos.snuba.v1.trace_item_filter_pb2 import TraceItemFilter
2020

21+
from sentry.api.event_search import SearchFilter
2122
from sentry.exceptions import InvalidSearchQuery
2223
from sentry.search.eap import constants
2324
from sentry.search.eap.types import EAPResponse
@@ -444,3 +445,4 @@ class ColumnDefinitions:
444445
columns: dict[str, ResolvedAttribute]
445446
contexts: dict[str, VirtualColumnDefinition]
446447
trace_item_type: TraceItemType.ValueType
448+
filter_aliases: Mapping[str, Callable[[SnubaParams, SearchFilter], SearchFilter]]

src/sentry/search/eap/ourlogs/definitions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@
1414
columns=OURLOG_ATTRIBUTE_DEFINITIONS,
1515
contexts=OURLOG_VIRTUAL_CONTEXTS,
1616
trace_item_type=TraceItemType.TRACE_ITEM_TYPE_LOG,
17+
filter_aliases={},
1718
)

src/sentry/search/eap/resolver.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,9 +400,20 @@ def resolve_virtual_context_term(
400400
)
401401
return final_raw_value
402402

403+
def convert_term(self, term: event_search.SearchFilter) -> event_search.SearchFilter:
404+
name = term.key.name
405+
406+
converter = self.definitions.filter_aliases.get(name)
407+
if converter is not None:
408+
term = converter(self.params, term)
409+
410+
return term
411+
403412
def resolve_term(
404413
self, term: event_search.SearchFilter
405414
) -> tuple[TraceItemFilter, VirtualColumnDefinition | None]:
415+
term = self.convert_term(term)
416+
406417
resolved_column, context_definition = self.resolve_column(term.key.name)
407418

408419
value = term.value.value

src/sentry/search/eap/spans/definitions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
SPAN_CONDITIONAL_AGGREGATE_DEFINITIONS,
77
)
88
from sentry.search.eap.spans.attributes import SPAN_ATTRIBUTE_DEFINITIONS, SPAN_VIRTUAL_CONTEXTS
9+
from sentry.search.eap.spans.filter_aliases import SPAN_FILTER_ALIAS_DEFINITIONS
910
from sentry.search.eap.spans.formulas import SPAN_FORMULA_DEFINITIONS
1011

1112
SPAN_DEFINITIONS = ColumnDefinitions(
@@ -15,4 +16,5 @@
1516
columns=SPAN_ATTRIBUTE_DEFINITIONS,
1617
contexts=SPAN_VIRTUAL_CONTEXTS,
1718
trace_item_type=TraceItemType.TRACE_ITEM_TYPE_SPAN,
19+
filter_aliases=SPAN_FILTER_ALIAS_DEFINITIONS,
1820
)
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
from typing import Literal
2+
3+
from sentry.api.event_search import SearchFilter, SearchKey, SearchValue
4+
from sentry.exceptions import InvalidSearchQuery
5+
from sentry.models.release import Release
6+
from sentry.models.releases.util import SemverFilter
7+
from sentry.search.events import constants
8+
from sentry.search.events.filter import (
9+
_flip_field_sort,
10+
handle_operator_negation,
11+
parse_semver,
12+
to_list,
13+
)
14+
from sentry.search.events.types import SnubaParams
15+
from sentry.search.utils import parse_release, validate_snuba_array_parameter
16+
17+
18+
def release_filter_converter(params: SnubaParams, search_filter: SearchFilter) -> SearchFilter:
19+
if search_filter.value.is_wildcard():
20+
operator = search_filter.operator
21+
value = search_filter.value
22+
else:
23+
operator_conversions = {"=": "IN", "!=": "NOT IN"}
24+
operator = operator_conversions.get(search_filter.operator, search_filter.operator)
25+
26+
if params.environments:
27+
environments = [env for env in params.environments if env is not None]
28+
else:
29+
environments = []
30+
31+
value = SearchValue(
32+
[
33+
part
34+
for v in to_list(search_filter.value.value)
35+
for part in parse_release(
36+
v,
37+
params.project_ids,
38+
environments,
39+
params.organization.id if params.organization else None,
40+
)
41+
]
42+
)
43+
return SearchFilter(search_filter.key, operator, value)
44+
45+
46+
def release_stage_filter_converter(
47+
params: SnubaParams, search_filter: SearchFilter
48+
) -> SearchFilter:
49+
organization_id = params.organization_id
50+
if organization_id is None:
51+
raise ValueError("organization is a required param")
52+
53+
# TODO: Filter by project here as well. It's done elsewhere, but could critically limit versions
54+
# for orgs with thousands of projects, each with their own releases (potentially drowning out ones we care about)
55+
qs = (
56+
Release.objects.filter_by_stage(
57+
organization_id,
58+
search_filter.operator,
59+
search_filter.value.value,
60+
project_ids=params.project_ids,
61+
environments=params.environment_names,
62+
)
63+
.values_list("version", flat=True)
64+
.order_by("date_added")[: constants.MAX_SEARCH_RELEASES]
65+
)
66+
versions = list(qs)
67+
68+
if not versions:
69+
# XXX: Just return a filter that will return no results if we have no versions
70+
versions = [constants.SEMVER_EMPTY_RELEASE]
71+
72+
if not validate_snuba_array_parameter(versions):
73+
raise InvalidSearchQuery(
74+
"There are too many releases that match your release.stage filter, please try again with a narrower range"
75+
)
76+
77+
return SearchFilter(SearchKey(constants.RELEASE_ALIAS), "IN", SearchValue(versions))
78+
79+
80+
def semver_filter_converter(params: SnubaParams, search_filter: SearchFilter) -> SearchFilter:
81+
organization_id = params.organization_id
82+
if organization_id is None:
83+
raise ValueError("organization is a required param")
84+
# We explicitly use `raw_value` here to avoid converting wildcards to shell values
85+
if not isinstance(search_filter.value.raw_value, str):
86+
raise InvalidSearchQuery(
87+
f"{search_filter.key.name}: Invalid value: {search_filter.value.raw_value}. Expected a semver version."
88+
)
89+
version: str = search_filter.value.raw_value
90+
operator: str = search_filter.operator
91+
92+
# Note that we sort this such that if we end up fetching more than
93+
# MAX_SEMVER_SEARCH_RELEASES, we will return the releases that are closest to
94+
# the passed filter.
95+
order_by = Release.SEMVER_COLS
96+
if operator.startswith("<"):
97+
order_by = list(map(_flip_field_sort, order_by))
98+
qs = (
99+
Release.objects.filter_by_semver(
100+
organization_id,
101+
parse_semver(version, operator),
102+
project_ids=params.project_ids,
103+
)
104+
.values_list("version", flat=True)
105+
.order_by(*order_by)[: constants.MAX_SEARCH_RELEASES]
106+
)
107+
versions = list(qs)
108+
final_operator: Literal["IN", "NOT IN"] = "IN"
109+
if len(versions) == constants.MAX_SEARCH_RELEASES:
110+
# We want to limit how many versions we pass through to Snuba. If we've hit
111+
# the limit, make an extra query and see whether the inverse has fewer ids.
112+
# If so, we can do a NOT IN query with these ids instead. Otherwise, we just
113+
# do our best.
114+
operator = constants.OPERATOR_NEGATION_MAP[operator]
115+
# Note that the `order_by` here is important for index usage. Postgres seems
116+
# to seq scan with this query if the `order_by` isn't included, so we
117+
# include it even though we don't really care about order for this query
118+
qs_flipped = (
119+
Release.objects.filter_by_semver(organization_id, parse_semver(version, operator))
120+
.order_by(*map(_flip_field_sort, order_by))
121+
.values_list("version", flat=True)[: constants.MAX_SEARCH_RELEASES]
122+
)
123+
124+
exclude_versions = list(qs_flipped)
125+
if exclude_versions and len(exclude_versions) < len(versions):
126+
# Do a negative search instead
127+
final_operator = "NOT IN"
128+
versions = exclude_versions
129+
130+
if not validate_snuba_array_parameter(versions):
131+
raise InvalidSearchQuery(
132+
"There are too many releases that match your release.version filter, please try again with a narrower range"
133+
)
134+
135+
if not versions:
136+
# XXX: Just return a filter that will return no results if we have no versions
137+
versions = [constants.SEMVER_EMPTY_RELEASE]
138+
139+
return SearchFilter(SearchKey(constants.RELEASE_ALIAS), final_operator, SearchValue(versions))
140+
141+
142+
def semver_package_filter_converter(
143+
params: SnubaParams, search_filter: SearchFilter
144+
) -> SearchFilter:
145+
organization_id = params.organization_id
146+
if organization_id is None:
147+
raise ValueError("organization is a required param")
148+
149+
if not isinstance(search_filter.value.raw_value, str):
150+
raise InvalidSearchQuery(
151+
f"{search_filter.key.name}: Invalid value: {search_filter.value.raw_value}. Expected a semver package."
152+
)
153+
package: str = search_filter.value.raw_value
154+
155+
versions = list(
156+
Release.objects.filter_by_semver(
157+
organization_id,
158+
SemverFilter("exact", [], package),
159+
project_ids=params.project_ids,
160+
).values_list("version", flat=True)[: constants.MAX_SEARCH_RELEASES]
161+
)
162+
163+
if not versions:
164+
# XXX: Just return a filter that will return no results if we have no versions
165+
versions = [constants.SEMVER_EMPTY_RELEASE]
166+
167+
if not validate_snuba_array_parameter(versions):
168+
raise InvalidSearchQuery(
169+
"There are too many releases that match your release.package filter, please try again with a narrower range"
170+
)
171+
172+
return SearchFilter(SearchKey(constants.RELEASE_ALIAS), "IN", SearchValue(versions))
173+
174+
175+
def semver_build_filter_converter(params: SnubaParams, search_filter: SearchFilter) -> SearchFilter:
176+
organization_id = params.organization_id
177+
if organization_id is None:
178+
raise ValueError("organization is a required param")
179+
180+
if not isinstance(search_filter.value.raw_value, str):
181+
raise InvalidSearchQuery(
182+
f"{search_filter.key.name}: Invalid value: {search_filter.value.raw_value}. Expected a semver build."
183+
)
184+
build: str = search_filter.value.raw_value
185+
186+
operator, negated = handle_operator_negation(search_filter.operator)
187+
try:
188+
django_op = constants.OPERATOR_TO_DJANGO[operator]
189+
except KeyError:
190+
raise InvalidSearchQuery("Invalid operation 'IN' for semantic version filter.")
191+
versions = list(
192+
Release.objects.filter_by_semver_build(
193+
organization_id,
194+
django_op,
195+
build,
196+
project_ids=params.project_ids,
197+
negated=negated,
198+
).values_list("version", flat=True)[: constants.MAX_SEARCH_RELEASES]
199+
)
200+
201+
if not validate_snuba_array_parameter(versions):
202+
raise InvalidSearchQuery(
203+
"There are too many releases that match your release.build filter, please try again with a narrower range"
204+
)
205+
206+
if not versions:
207+
# XXX: Just return a filter that will return no results if we have no versions
208+
versions = [constants.SEMVER_EMPTY_RELEASE]
209+
210+
return SearchFilter(SearchKey(constants.RELEASE_ALIAS), "IN", SearchValue(versions))
211+
212+
213+
SPAN_FILTER_ALIAS_DEFINITIONS = {
214+
constants.RELEASE_ALIAS: release_filter_converter,
215+
constants.RELEASE_STAGE_ALIAS: release_stage_filter_converter,
216+
constants.SEMVER_ALIAS: semver_filter_converter,
217+
constants.SEMVER_PACKAGE_ALIAS: semver_package_filter_converter,
218+
constants.SEMVER_BUILD_ALIAS: semver_build_filter_converter,
219+
}

src/sentry/search/eap/uptime_checks/definitions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@
1313
columns=UPTIME_CHECK_ATTRIBUTE_DEFINITIONS,
1414
contexts=UPTIME_CHECK_VIRTUAL_CONTEXTS,
1515
trace_item_type=TraceItemType.TRACE_ITEM_TYPE_UPTIME_CHECK,
16+
filter_aliases={},
1617
)

0 commit comments

Comments
 (0)