1
1
import hashlib
2
2
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
+ )
5
17
6
- from snuba_sdk import BooleanCondition , Column , Condition
7
18
from typing_extensions import NotRequired
8
19
20
+ from sentry .api import event_search
21
+ from sentry .api .event_search import ParenExpression , SearchFilter
22
+ from sentry .search .events import fields
9
23
from sentry .snuba .dataset import Dataset
10
24
from sentry .snuba .metrics .utils import MetricOperationType
11
25
from sentry .snuba .models import SnubaQuery
26
+ from sentry .utils .snuba import resolve_column
12
27
13
28
logger = logging .getLogger (__name__ )
14
29
15
30
# Name component of MRIs used for custom alert metrics.
16
- # TODO: Move to a common module.
17
31
CUSTOM_ALERT_METRIC_NAME = "transactions/on_demand"
18
32
QUERY_HASH_KEY = "query_hash"
19
33
20
34
# Base type for conditions to evaluate on payloads.
21
35
# TODO: Streamline with dynamic sampling.
22
36
RuleCondition = Union ["LogicalRuleCondition" , "ComparingRuleCondition" , "NotRuleCondition" ]
23
37
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
27
43
"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" ,
43
80
}
44
81
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 ]] = {
46
85
"count" : "sum" ,
47
86
"avg" : "avg" ,
48
- "quantile(0.50)" : "p50" ,
49
- "quantile(0.75)" : "p75" ,
50
- "quantile(0.95)" : "p95" ,
51
- "quantile(0.99)" : "p99" ,
52
87
"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.
57
93
}
58
94
59
- # TODO(ogi): support count_if
95
+ # Mapping to infer metric type from Discover function.
60
96
_AGGREGATE_TO_METRIC_TYPE = {
61
97
"count" : "c" ,
98
+ # TODO(ogi): support count_if
62
99
"avg" : "d" ,
63
- "quantile(0.50)" : "d" ,
64
- "quantile(0.75)" : "d" ,
65
- "quantile(0.95)" : "d" ,
66
- "quantile(0.99)" : "d" ,
67
100
"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" ,
72
105
}
73
106
107
+ # Operators used in ``ComparingRuleCondition``.
108
+ CompareOp = Literal ["eq" , "gt" , "gte" , "lt" , "lte" , "glob" ]
109
+
74
110
75
111
class ComparingRuleCondition (TypedDict ):
76
112
"""RuleCondition that compares a named field to a reference value."""
77
113
78
- op : Literal [ "eq" , "gt" , "gte" , "lt" , "lte" , "glob" ]
114
+ op : CompareOp
79
115
name : str
80
116
value : Any
81
117
@@ -162,9 +198,12 @@ def __init__(self, field: str, query: str):
162
198
2. Generation of rules for on-demand metric extraction.
163
199
164
200
"""
165
- relay_field , metric_type , op = _extract_field_info (field )
166
201
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 )
168
207
self .field = relay_field
169
208
self .metric_type = metric_type
170
209
self .mri = f"{ metric_type } :{ CUSTOM_ALERT_METRIC_NAME } @none"
@@ -180,69 +219,175 @@ def query_hash(self) -> str:
180
219
def condition (self ) -> RuleCondition :
181
220
"""Returns a condition that should be fulfilled for the on-demand metric to be extracted."""
182
221
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 ()
187
225
188
- where = [c for c in (_convert_condition (c ) for c in where ) if c is not None ]
189
226
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.
194
231
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 )
195
236
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
198
238
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 } "
202
242
203
243
if metric_type == "c" :
204
- assert not select . parameters , "Count should not have parameters "
244
+ assert not arguments , "`count()` does not support arguments "
205
245
return None , metric_type , metric_op
206
246
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
208
249
209
- name = select .parameters [0 ].name
210
- assert name in _SNUBA_TO_RELAY_FIELDS , f"Unsupported field { name } "
211
250
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.
213
254
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
214
260
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 } "
224
264
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 ]} "
226
270
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 } " )
230
272
231
- assert condition .lhs .name in _SNUBA_TO_RELAY_FIELDS , f"Unsupported field { condition .lhs .name } "
232
273
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" )
238
277
239
278
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.
243
282
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