Skip to content

Commit fd47c83

Browse files
authored
ref(metrics): Convert discover queries directly without QueryBuilder (#52430)
Instead of using QueryBuilder's internal methods, this switches the on-demand metric converter to using plain event search parsers. All mappings have been updated to map directly from UI language to the event protocol. The event search query parser does not resolve hierarchies of logical operations and instead emits a flat token stream. For this reason, conditions are now converted by a simple recursive descent utility.
1 parent 77e2ad5 commit fd47c83

File tree

2 files changed

+266
-97
lines changed

2 files changed

+266
-97
lines changed
+234-89
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,117 @@
11
import hashlib
22
import logging
3-
from datetime import datetime
4-
from typing import Any, Dict, Literal, Optional, Sequence, Tuple, TypedDict, Union, cast
3+
import re
4+
from typing import (
5+
Any,
6+
Dict,
7+
Literal,
8+
Optional,
9+
Sequence,
10+
Tuple,
11+
Type,
12+
TypedDict,
13+
TypeVar,
14+
Union,
15+
cast,
16+
)
517

6-
from snuba_sdk import BooleanCondition, Column, Condition
718
from typing_extensions import NotRequired
819

20+
from sentry.api import event_search
21+
from sentry.api.event_search import ParenExpression, SearchFilter
22+
from sentry.search.events import fields
923
from sentry.snuba.dataset import Dataset
1024
from sentry.snuba.metrics.utils import MetricOperationType
1125
from sentry.snuba.models import SnubaQuery
26+
from sentry.utils.snuba import resolve_column
1227

1328
logger = logging.getLogger(__name__)
1429

1530
# Name component of MRIs used for custom alert metrics.
16-
# TODO: Move to a common module.
1731
CUSTOM_ALERT_METRIC_NAME = "transactions/on_demand"
1832
QUERY_HASH_KEY = "query_hash"
1933

2034
# Base type for conditions to evaluate on payloads.
2135
# TODO: Streamline with dynamic sampling.
2236
RuleCondition = Union["LogicalRuleCondition", "ComparingRuleCondition", "NotRuleCondition"]
2337

24-
_SNUBA_TO_RELAY_FIELDS = {
25-
"contexts[geo.country_code]": "event.geo.country_code",
26-
"http_method": "event.http.method",
38+
39+
# Maps from Discover's field names to event protocol paths.
40+
# See Relay's ``FieldValueProvider`` for supported fields.
41+
_SEARCH_TO_PROTOCOL_FIELDS = {
42+
# Top-level fields
2743
"release": "event.release",
28-
"transaction_name": "event.transaction",
29-
"transaction_op": "event.transaction.op",
30-
"transaction_status": "event.transaction.status",
31-
"duration": "event.duration",
32-
"measurements[cls]": "event.measurements.cls",
33-
"measurements[fcp]": "event.measurements.fcp",
34-
"measurements[fid]": "event.measurements.fid",
35-
"measurements[fp]": "event.measurements.fp",
36-
"measurements[lcp]": "event.measurements.lcp",
37-
"measurements[ttfb]": "event.measurements.ttfb",
38-
"measurements[ttfb.requesttime]": "event.measurements.ttfb.requesttime",
39-
# TODO(ogi): Support fields whose resolution returns a function
40-
# "browser.name": "event.browser.name",
41-
# "http.status_code": "event.http.status_code",
42-
# "os.name": "event.os.name",
44+
"dist": "event.dist",
45+
"environment": "event.environment",
46+
"transaction": "event.transaction",
47+
"platform": "event.platform",
48+
# User
49+
"user.email": "event.user.email",
50+
"user.id": "event.user.id",
51+
"user.ip_address": "event.user.ip_address",
52+
"user.name": "event.user.name",
53+
"user.segment": "event.user.segment",
54+
# Subset of context fields
55+
"device.name": "event.contexts.device.name",
56+
"device.family": "event.contexts.device.family",
57+
"os.name": "event.contexts.os.name",
58+
"os.version": "event.contexts.os.version",
59+
"transaction.op": "event.contexts.trace.op",
60+
# Computed fields
61+
"transaction.duration": "event.duration",
62+
"release.build": "event.release.build",
63+
"release.package": "event.release.package",
64+
"release.version": "event.release.version.short",
65+
# Tags, measurements, and breakdowns are mapped by the converter
66+
# TODO: Required but yet unsupported by Relay
67+
# "geo.country_code": None,
68+
# "transaction.status": None,
69+
# "http.method": None,
70+
}
71+
72+
# Maps from Discover's syntax to Relay rule condition operators.
73+
_SEARCH_TO_RELAY_OPERATORS: Dict[str, "CompareOp"] = {
74+
"=": "eq",
75+
"!=": "eq", # combined with external negation
76+
"<": "lt",
77+
"<=": "lte",
78+
">": "gt",
79+
">=": "gte",
4380
}
4481

45-
_SNUBA_TO_METRIC_AGGREGATES: Dict[str, Optional[MetricOperationType]] = {
82+
# Maps plain Discover functions to metric aggregation functions. Derived metrics
83+
# are not part of this mapping.
84+
_SEARCH_TO_METRIC_AGGREGATES: Dict[str, Optional[MetricOperationType]] = {
4685
"count": "sum",
4786
"avg": "avg",
48-
"quantile(0.50)": "p50",
49-
"quantile(0.75)": "p75",
50-
"quantile(0.95)": "p95",
51-
"quantile(0.99)": "p99",
5287
"max": "max",
53-
# not supported yet
54-
"percentile": None,
55-
"failure_rate": None,
56-
"apdex": None,
88+
"p50": "p50",
89+
"p75": "p75",
90+
"p95": "p95",
91+
"p99": "p99",
92+
# generic percentile is not supported by metrics layer.
5793
}
5894

59-
# TODO(ogi): support count_if
95+
# Mapping to infer metric type from Discover function.
6096
_AGGREGATE_TO_METRIC_TYPE = {
6197
"count": "c",
98+
# TODO(ogi): support count_if
6299
"avg": "d",
63-
"quantile(0.50)": "d",
64-
"quantile(0.75)": "d",
65-
"quantile(0.95)": "d",
66-
"quantile(0.99)": "d",
67100
"max": "d",
68-
# not supported yet
69-
"percentile": None,
70-
"failure_rate": None,
71-
"apdex": None,
101+
"p50": "d",
102+
"p75": "d",
103+
"p95": "d",
104+
"p99": "d",
72105
}
73106

107+
# Operators used in ``ComparingRuleCondition``.
108+
CompareOp = Literal["eq", "gt", "gte", "lt", "lte", "glob"]
109+
74110

75111
class ComparingRuleCondition(TypedDict):
76112
"""RuleCondition that compares a named field to a reference value."""
77113

78-
op: Literal["eq", "gt", "gte", "lt", "lte", "glob"]
114+
op: CompareOp
79115
name: str
80116
value: Any
81117

@@ -162,9 +198,12 @@ def __init__(self, field: str, query: str):
162198
2. Generation of rules for on-demand metric extraction.
163199
164200
"""
165-
relay_field, metric_type, op = _extract_field_info(field)
166201

167-
self._query = query
202+
# On-demand metrics are implicitly transaction metrics. Remove the
203+
# filter from the query since it can't be translated to a RuleCondition.
204+
self._query = re.sub(r"event\.type:transaction\s*", "", query)
205+
206+
relay_field, metric_type, op = _extract_field_info(field)
168207
self.field = relay_field
169208
self.metric_type = metric_type
170209
self.mri = f"{metric_type}:{CUSTOM_ALERT_METRIC_NAME}@none"
@@ -180,69 +219,175 @@ def query_hash(self) -> str:
180219
def condition(self) -> RuleCondition:
181220
"""Returns a condition that should be fulfilled for the on-demand metric to be extracted."""
182221

183-
where, having = _get_query_builder().resolve_conditions(self._query, False)
184-
185-
assert where, "Query should not use on demand metrics"
186-
assert not having, "Having conditions are not supported"
222+
tokens = event_search.parse_search_query(self._query)
223+
assert tokens, "This query should not use on demand metrics"
224+
return SearchQueryConverter(tokens).convert()
187225

188-
where = [c for c in (_convert_condition(c) for c in where) if c is not None]
189226

190-
if len(where) == 1:
191-
return where[0]
192-
else:
193-
return {"op": "and", "inner": where}
227+
def _extract_field_info(aggregate: str) -> Tuple[Optional[str], str, MetricOperationType]:
228+
"""
229+
Extracts the field name, metric type and metric operation from a Discover
230+
function call.
194231
232+
This does not support derived metrics such as ``apdex`` and aggregates with
233+
filters (``count_if``).
234+
"""
235+
name, arguments, _alias = fields.parse_function(aggregate)
195236

196-
def _extract_field_info(aggregate: str) -> Tuple[Optional[str], str, MetricOperationType]:
197-
select = _get_query_builder().resolve_column(aggregate, False)
237+
# TODO: Add support for derived metrics: failure_rate, apdex, eps, epm, tps, tpm
198238

199-
metric_type = _AGGREGATE_TO_METRIC_TYPE.get(select.function)
200-
metric_op = _SNUBA_TO_METRIC_AGGREGATES.get(select.function)
201-
assert metric_type and metric_op, f"Unsupported aggregate function {select.function}"
239+
metric_type = _AGGREGATE_TO_METRIC_TYPE.get(name)
240+
metric_op = _SEARCH_TO_METRIC_AGGREGATES.get(name)
241+
assert metric_type and metric_op, f"Unsupported aggregate function {name}"
202242

203243
if metric_type == "c":
204-
assert not select.parameters, "Count should not have parameters"
244+
assert not arguments, "`count()` does not support arguments"
205245
return None, metric_type, metric_op
206246
else:
207-
assert len(select.parameters) == 1, "Only one parameter is supported"
247+
assert len(arguments) == 1, "Only one parameter is supported"
248+
return _map_field_name(arguments[0]), metric_type, metric_op
208249

209-
name = select.parameters[0].name
210-
assert name in _SNUBA_TO_RELAY_FIELDS, f"Unsupported field {name}"
211250

212-
return _SNUBA_TO_RELAY_FIELDS[name], metric_type, metric_op
251+
def _map_field_name(search_key: str) -> str:
252+
"""
253+
Maps a the name of a field in a search query to the event protocol path.
213254
255+
Raises an exception if the field is not supported.
256+
"""
257+
# Map known fields using a static mapping.
258+
if field := _SEARCH_TO_PROTOCOL_FIELDS.get(search_key):
259+
return field
214260

215-
def _convert_condition(condition: Union[Condition, BooleanCondition]) -> Optional[RuleCondition]:
216-
if isinstance(condition, BooleanCondition):
217-
return cast(
218-
RuleCondition,
219-
{
220-
"op": condition.op.name.lower(),
221-
"inner": [_convert_condition(c) for c in condition.conditions],
222-
},
223-
)
261+
# Measurements support generic access.
262+
if search_key.startswith("measurements."):
263+
return f"event.{search_key}"
224264

225-
assert isinstance(condition, Condition), f"Unsupported condition type {type(condition)}"
265+
# Run a schema-aware check for tags. Always use the resolver output,
266+
# since it accounts for passing `tags[foo]` as key.
267+
resolved = (resolve_column(Dataset.Transactions))(search_key)
268+
if resolved.startswith("tags["):
269+
return f"event.tags.{resolved[5:-1]}"
226270

227-
# TODO: Currently we do not support function conditions like count_if
228-
if not isinstance(condition.lhs, Column):
229-
return None
271+
raise ValueError(f"Unsupported query field {search_key}")
230272

231-
assert condition.lhs.name in _SNUBA_TO_RELAY_FIELDS, f"Unsupported field {condition.lhs.name}"
232273

233-
return {
234-
"op": condition.op.name.lower(),
235-
"name": _SNUBA_TO_RELAY_FIELDS[condition.lhs.name],
236-
"value": condition.rhs,
237-
}
274+
QueryOp = Literal["AND", "OR"]
275+
QueryToken = Union[SearchFilter, QueryOp, ParenExpression]
276+
T = TypeVar("T")
238277

239278

240-
def _get_query_builder():
241-
# TODO: Find a way to perform resolve_column and resolve_conditions without instantiating a QueryBuilder
242-
from sentry.search.events.builder import QueryBuilder
279+
class SearchQueryConverter:
280+
"""
281+
A converter from search query token stream to rule conditions.
243282
244-
return QueryBuilder(
245-
dataset=Dataset.Transactions,
246-
# start and end parameters are required, but not used
247-
params={"start": datetime.now(), "end": datetime.now()},
248-
)
283+
Pass a token stream obtained from `parse_search_query` to the constructor.
284+
The converter can be used exactly once.
285+
"""
286+
287+
def __init__(self, tokens: Sequence[QueryToken]):
288+
self._tokens = tokens
289+
self._position = 0
290+
291+
def convert(self) -> RuleCondition:
292+
"""
293+
Converts the token stream into a rule condition.
294+
295+
This function can raise an exception if the token stream is structurally
296+
invalid or contains fields that are not supported by the rule engine.
297+
"""
298+
299+
condition = self._expr()
300+
if self._position < len(self._tokens):
301+
raise ValueError("Unexpected trailing tokens")
302+
return condition
303+
304+
def _peek(self) -> Optional[QueryToken]:
305+
"""Returns the next token without consuming it."""
306+
307+
if self._position < len(self._tokens):
308+
return self._tokens[self._position]
309+
else:
310+
return None
311+
312+
def _consume(self, pattern: Union[str, Type[T]]) -> Optional[T]:
313+
"""
314+
Consumes the next token if it matches the given pattern.
315+
316+
The pattern can be:
317+
- a literal string, in which case the token must be equal to the string
318+
- a type, in which case the token must be an instance of the type
319+
320+
Returns the token if it matches, or ``None`` otherwise.
321+
"""
322+
token = self._peek()
323+
324+
if isinstance(pattern, str) and token != pattern:
325+
return None
326+
elif isinstance(pattern, type) and not isinstance(token, pattern):
327+
return None
328+
329+
self._position += 1
330+
return cast(T, token)
331+
332+
def _expr(self) -> RuleCondition:
333+
terms = [self._term()]
334+
335+
while self._consume("OR") is not None:
336+
terms.append(self._term())
337+
338+
if len(terms) == 1:
339+
return terms[0]
340+
else:
341+
return {"op": "or", "inner": terms}
342+
343+
def _term(self) -> RuleCondition:
344+
factors = [self._factor()]
345+
346+
while self._peek() not in ("OR", None):
347+
self._consume("AND") # AND is optional and implicit, ignore if present.
348+
factors.append(self._factor())
349+
350+
if len(factors) == 1:
351+
return factors[0]
352+
else:
353+
return {"op": "and", "inner": factors}
354+
355+
def _factor(self) -> RuleCondition:
356+
if filt := self._consume(SearchFilter):
357+
return self._filter(filt)
358+
elif paren := self._consume(ParenExpression):
359+
return SearchQueryConverter(paren.children).convert()
360+
elif token := self._peek():
361+
raise ValueError(f"Unexpected token {token}")
362+
else:
363+
raise ValueError("Unexpected end of query")
364+
365+
def _filter(self, token: SearchFilter) -> RuleCondition:
366+
operator = _SEARCH_TO_RELAY_OPERATORS.get(token.operator)
367+
if not operator:
368+
raise ValueError(f"Unsupported operator {token.operator}")
369+
370+
value: Any = token.value.raw_value
371+
if operator == "eq" and token.value.is_wildcard():
372+
condition: RuleCondition = {
373+
"op": "glob",
374+
"name": _map_field_name(token.key.name),
375+
"value": [value],
376+
}
377+
else:
378+
# Special case: `x != ""` is the result of a `has:x` query, which
379+
# needs to be translated as `not(x == null)`.
380+
if token.operator == "!=" and value == "":
381+
value = None
382+
if isinstance(value, str):
383+
value = event_search.translate_escape_sequences(value)
384+
condition = {
385+
"op": operator,
386+
"name": _map_field_name(token.key.name),
387+
"value": value,
388+
}
389+
390+
if token.operator == "!=":
391+
condition = {"op": "not", "inner": condition}
392+
393+
return condition

0 commit comments

Comments
 (0)