Skip to content

Commit 2d5b397

Browse files
authored
feat(data-explore): Add support in EAP to filter Array by elements (#7898)
Current implementation of arrays in TraceItemTable endpoint transforms arrays as array of strings, and then only allows `LIKE/NOT_LIKE` filter on them. With this change, arrays will preserve their original type, and client can filter EAP items by element of an array attribute. To preserve the type, array is converted to JSON string, (clickhouse-driver doesn't support Array(JSON) type, as discussed in this [PR](#7551)), and post processing of results recovers the original array by decoding JSON string. To filter, predicate maps arrays to corresponding type and use array exists with element filter. Example Clickhouse query is: ``` SELECT ( cast(lower(leftPad(hex(item_id), if( greater(length(hex(item_id)), 16), 32, 16), '0')), 'String' ) AS `sentry.item_id_TYPE_STRING` ), (toJSONString(attributes_array.`tags`::Array( JSON)) AS tags_TYPE_ARRAY ) FROM eap_items_1_local WHERE in( project_id, [1, 2, 3] ) AND equals(organization_id, 1) AND less( timestamp, toDateTime('2026-04-24 14:00:00') ) AND greaterOrEquals(timestamp, toDateTime('2026-04-24 08:00:00')) AND arrayExists(x -> equals(lower(x), lower('error')), (arrayMap(x -> coalesce( x.`String`::Nullable(String), toString(x.`Int`::Nullable(Int64)), toString(x.`Double`::Nullable(Float64)), x.`Bool`::Nullable(String)), attributes_array.`tags`::Array( JSON)) AS tags_TYPE_ARRAY__array_members ) ) AND lessOrEquals(sampling_factor, 1) AND greater(sampling_factor, 0) AND equals(item_type, 1) LIMIT 10001 OFFSET 0 ```
1 parent 68aaf4a commit 2d5b397

7 files changed

Lines changed: 457 additions & 84 deletions

File tree

snuba/protos/common.py

Lines changed: 60 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,61 @@ def _generate_subscriptable_reference(
120120
)
121121

122122

123+
def type_array_to_membership_array_expression(attr_key: AttributeKey) -> FunctionCall:
124+
"""To be used only in WHERE clause, not SELECT"""
125+
if attr_key.type != AttributeKey.Type.TYPE_ARRAY:
126+
raise MalformedAttributeException(
127+
f"type_array_to_membership_array_expression expected TYPE_ARRAY, got "
128+
f"{AttributeKey.Type.Name(attr_key.type)}"
129+
)
130+
# We need different label than attribute_key_to_expression(TYPE_ARRAY) [toJSONString]
131+
alias = f"{_build_label_mapping_key(attr_key)}__array_members"
132+
x = Argument(None, "x")
133+
return FunctionCall(
134+
alias=alias,
135+
function_name="arrayMap",
136+
parameters=(
137+
Lambda(
138+
alias=None,
139+
parameters=("x",),
140+
transformation=FunctionCall(
141+
alias=None,
142+
function_name="coalesce",
143+
parameters=(
144+
JsonPath(None, x, "String", "Nullable(String)"),
145+
FunctionCall(
146+
None,
147+
"toString",
148+
(JsonPath(None, x, "Int", "Nullable(Int64)"),),
149+
),
150+
FunctionCall(
151+
None,
152+
"toString",
153+
(JsonPath(None, x, "Double", "Nullable(Float64)"),),
154+
),
155+
JsonPath(None, x, "Bool", "Nullable(String)"),
156+
),
157+
),
158+
),
159+
JsonPath(
160+
alias=None,
161+
base=column("attributes_array"),
162+
path=attr_key.name,
163+
return_type="Array(JSON)",
164+
),
165+
),
166+
)
167+
168+
169+
def type_array_to_stored_array_json_path(attr_key: AttributeKey) -> JsonPath:
170+
return JsonPath(
171+
alias=None,
172+
base=column("attributes_array"),
173+
path=attr_key.name,
174+
return_type="Array(JSON)",
175+
)
176+
177+
123178
def attribute_key_to_expression(attr_key: AttributeKey) -> Expression:
124179
"""Convert an AttributeKey proto to a Snuba Expression.
125180
@@ -178,45 +233,13 @@ def attribute_key_to_expression(attr_key: AttributeKey) -> Expression:
178233
)
179234

180235
if attr_key.type == AttributeKey.Type.TYPE_ARRAY:
181-
# Array values are stored as tagged variants (e.g. {"String": "alice"},
182-
# {"Int": "123"}) in the JSON column. Cast to Array(JSON), then extract
183-
# the value from whichever variant tag is present. We coalesce across
184-
# all supported types, converting non-string types via toString so the
185-
# result is always a string array.
186-
x = Argument(None, "x")
236+
# Tagged array under attributes_array.* as Array(JSON). Select toJSONString(...)
237+
# so the result column is String; callers decode in application code. Raw
238+
# Array(JSON) is not returned in the SELECT to avoid native client limits.
187239
return FunctionCall(
188240
alias=alias,
189-
function_name="arrayMap",
190-
parameters=(
191-
Lambda(
192-
alias=None,
193-
parameters=("x",),
194-
transformation=FunctionCall(
195-
alias=None,
196-
function_name="coalesce",
197-
parameters=(
198-
JsonPath(None, x, "String", "Nullable(String)"),
199-
FunctionCall(
200-
None,
201-
"toString",
202-
(JsonPath(None, x, "Int", "Nullable(Int64)"),),
203-
),
204-
FunctionCall(
205-
None,
206-
"toString",
207-
(JsonPath(None, x, "Double", "Nullable(Float64)"),),
208-
),
209-
JsonPath(None, x, "Bool", "Nullable(String)"),
210-
),
211-
),
212-
),
213-
JsonPath(
214-
alias=None,
215-
base=column("attributes_array"),
216-
path=attr_key.name,
217-
return_type="Array(JSON)",
218-
),
219-
),
241+
function_name="toJSONString",
242+
parameters=(type_array_to_stored_array_json_path(attr_key),),
220243
)
221244

222245
raise MalformedAttributeException(

snuba/web/rpc/common/common.py

Lines changed: 133 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
)
1313

1414
from snuba import settings, state
15-
from snuba.protos.common import MalformedAttributeException
15+
from snuba.protos.common import (
16+
MalformedAttributeException,
17+
type_array_to_membership_array_expression,
18+
)
1619
from snuba.protos.common import (
1720
attribute_key_to_expression as _attribute_key_to_expression,
1821
)
@@ -54,6 +57,21 @@ def attribute_key_to_expression(attr_key: AttributeKey) -> Expression:
5457
raise BadSnubaRPCRequestException(str(e)) from e
5558

5659

60+
def _trace_item_filter_key_expression(
61+
attr_to_key_expression_callable: Callable[[AttributeKey], Expression], key: AttributeKey
62+
) -> Expression:
63+
"""predicates must use the normalized
64+
``arrayMap`` (``type_array_to_membership_array_expression``) so
65+
``arrayExists`` compares per element. It is different from SELECT predicate.
66+
"""
67+
if key.type == AttributeKey.Type.TYPE_ARRAY:
68+
try:
69+
return type_array_to_membership_array_expression(key)
70+
except MalformedAttributeException as e:
71+
raise BadSnubaRPCRequestException(str(e)) from e
72+
return attr_to_key_expression_callable(key)
73+
74+
5775
Tin = TypeVar("Tin", bound=ProtobufMessage)
5876
Tout = TypeVar("Tout", bound=ProtobufMessage)
5977

@@ -265,6 +283,76 @@ def _attribute_value_to_expression(v: AttributeValue) -> Expression:
265283
}
266284

267285

286+
def _validate_comparison_filter_type_array(
287+
op: ComparisonFilter.Op.ValueType, v: AttributeValue
288+
) -> None:
289+
if op in (ComparisonFilter.OP_LIKE, ComparisonFilter.OP_NOT_LIKE):
290+
if v.WhichOneof("value") != "val_str":
291+
raise BadSnubaRPCRequestException(
292+
"LIKE/NOT_LIKE on array keys requires a string pattern"
293+
)
294+
return
295+
if op in (ComparisonFilter.OP_EQUALS, ComparisonFilter.OP_NOT_EQUALS):
296+
# Array can be empty or non-empty. It can never be null, or can never have null elements.
297+
vt = v.WhichOneof("value")
298+
if vt in (
299+
None,
300+
"val_null",
301+
"val_array",
302+
"val_str_array",
303+
"val_int_array",
304+
"val_float_array",
305+
"val_double_array",
306+
):
307+
raise BadSnubaRPCRequestException(
308+
"OP_EQUALS/OP_NOT_EQUALS on array keys require a scalar value "
309+
"(e.g. val_str, val_int) or null (is_null / val_null) to match null elements"
310+
)
311+
return
312+
raise BadSnubaRPCRequestException(
313+
f"{ComparisonFilter.Op.Name(op)} is not supported on array keys "
314+
"(supported: LIKE, NOT_LIKE, OP_EQUALS, OP_NOT_EQUALS)"
315+
)
316+
317+
318+
def _type_array_membership_rhs_expression(v: AttributeValue) -> Expression:
319+
"""RHS as String, comparable to TYPE_ARRAY arrayMap output (Array(String) in CH)."""
320+
value_type = v.WhichOneof("value")
321+
match value_type:
322+
case "val_str":
323+
return literal(v.val_str)
324+
case "val_int":
325+
return f.toString(literal(v.val_int))
326+
case "val_double":
327+
return f.toString(literal(v.val_double))
328+
case "val_float":
329+
return f.toString(literal(v.val_float))
330+
case "val_bool":
331+
return literal(str(v.val_bool).lower())
332+
case _:
333+
raise BadSnubaRPCRequestException(
334+
f"unsupported AttributeValue for array membership: {value_type}"
335+
)
336+
337+
338+
def _type_array_includes_scalar_expression(
339+
array_expr: Expression,
340+
v: AttributeValue,
341+
ignore_case: bool,
342+
) -> Expression:
343+
"""Any element equals scalar (includes / [*])"""
344+
if v.WhichOneof("value") == "val_null" or v.is_null:
345+
raise BadSnubaRPCRequestException("Arrays can't be NULL or cannot have NULL elements")
346+
x = Argument(None, "x")
347+
rhs = _type_array_membership_rhs_expression(v)
348+
if ignore_case and v.WhichOneof("value") == "val_str":
349+
return f.arrayExists(
350+
Lambda(None, ("x",), f.equals(f.lower(x), f.lower(rhs))),
351+
array_expr,
352+
)
353+
return f.arrayExists(Lambda(None, ("x",), f.equals(x, rhs)), array_expr)
354+
355+
268356
def _any_attribute_filter_to_expression(
269357
filt: AnyAttributeFilter,
270358
) -> Expression:
@@ -425,53 +513,61 @@ def trace_item_filters_to_expression(
425513
op = item_filter.comparison_filter.op
426514
v = item_filter.comparison_filter.value
427515

428-
# TYPE_ARRAY only supports LIKE/NOT_LIKE with string patterns — validate early.
429516
if k.type == AttributeKey.Type.TYPE_ARRAY:
430-
if op not in (
431-
ComparisonFilter.OP_LIKE,
432-
ComparisonFilter.OP_NOT_LIKE,
433-
):
434-
raise BadSnubaRPCRequestException(
435-
"only LIKE and NOT_LIKE comparisons are supported on array keys"
436-
)
437-
if v.WhichOneof("value") != "val_str":
438-
raise BadSnubaRPCRequestException(
439-
"LIKE/NOT_LIKE on array keys requires a string pattern"
440-
)
517+
_validate_comparison_filter_type_array(op, v)
441518

442-
k_expression = attribute_key_to_expression(k)
519+
k_expression = _trace_item_filter_key_expression(
520+
attr_to_key_expression_callable=attribute_key_to_expression, key=k
521+
)
443522

444523
value_type = v.WhichOneof("value")
445524
if value_type is None:
446525
raise BadSnubaRPCRequestException("comparison does not have a right hand side")
447526

448-
if v.is_null:
527+
if v.is_null or value_type == "val_null":
449528
v_expression: Expression = literal(None)
450529
else:
451530
v_expression = _attribute_value_to_expression(v)
452531

453532
if op == ComparisonFilter.OP_EQUALS:
454533
_check_non_string_values_cannot_ignore_case(item_filter.comparison_filter)
455-
expr = (
456-
f.equals(f.lower(k_expression), f.lower(v_expression))
457-
if item_filter.comparison_filter.ignore_case
458-
else f.equals(k_expression, v_expression)
459-
)
460-
# we redefine the way equals works for nulls
461-
# now null=null is true
462-
expr_with_null = or_cond(expr, and_cond(f.isNull(k_expression), f.isNull(v_expression)))
463-
return expr_with_null
534+
535+
if k.type == AttributeKey.Type.TYPE_ARRAY:
536+
return _type_array_includes_scalar_expression(
537+
k_expression, v, item_filter.comparison_filter.ignore_case
538+
)
539+
else:
540+
expr = (
541+
f.equals(f.lower(k_expression), f.lower(v_expression))
542+
if item_filter.comparison_filter.ignore_case
543+
else f.equals(k_expression, v_expression)
544+
)
545+
# we redefine the way equals works for nulls
546+
# now null=null is true
547+
expr_with_null = or_cond(
548+
expr, and_cond(f.isNull(k_expression), f.isNull(v_expression))
549+
)
550+
return expr_with_null
464551
if op == ComparisonFilter.OP_NOT_EQUALS:
465552
_check_non_string_values_cannot_ignore_case(item_filter.comparison_filter)
466-
expr = (
467-
f.notEquals(f.lower(k_expression), f.lower(v_expression))
468-
if item_filter.comparison_filter.ignore_case
469-
else f.notEquals(k_expression, v_expression)
470-
)
471-
# we redefine the way not equals works for nulls
472-
# now null!=null is true
473-
expr_with_null = or_cond(expr, f.xor(f.isNull(k_expression), f.isNull(v_expression)))
474-
return expr_with_null
553+
if k.type == AttributeKey.Type.TYPE_ARRAY:
554+
return not_cond(
555+
_type_array_includes_scalar_expression(
556+
k_expression, v, item_filter.comparison_filter.ignore_case
557+
)
558+
)
559+
else:
560+
expr = (
561+
f.notEquals(f.lower(k_expression), f.lower(v_expression))
562+
if item_filter.comparison_filter.ignore_case
563+
else f.notEquals(k_expression, v_expression)
564+
)
565+
# we redefine the way not equals works for nulls
566+
# now null!=null is true
567+
expr_with_null = or_cond(
568+
expr, f.xor(f.isNull(k_expression), f.isNull(v_expression))
569+
)
570+
return expr_with_null
475571
if op == ComparisonFilter.OP_LIKE:
476572
if k.type == AttributeKey.Type.TYPE_ARRAY:
477573
like_fn = f.ilike if item_filter.comparison_filter.ignore_case else f.like
@@ -578,7 +674,10 @@ def trace_item_filters_to_expression(
578674

579675
if item_filter.HasField("exists_filter"):
580676
return get_field_existence_expression(
581-
attribute_key_to_expression(item_filter.exists_filter.key)
677+
_trace_item_filter_key_expression(
678+
attr_to_key_expression_callable=attribute_key_to_expression,
679+
key=item_filter.exists_filter.key,
680+
)
582681
)
583682

584683
if item_filter.HasField("any_attribute_filter"):

snuba/web/rpc/v1/resolvers/common/aggregation.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
Reliability,
2020
)
2121

22+
from snuba.protos.common import type_array_to_stored_array_json_path
2223
from snuba.query.dsl import CurriedFunctions as cf
2324
from snuba.query.dsl import Functions as f
2425
from snuba.query.dsl import and_cond, column, literal
@@ -129,6 +130,9 @@ def _resolve_field_and_existence(
129130
else:
130131
raise RuntimeError("expected existence_checks to never be empty, but it is")
131132
return field, existence
133+
elif aggregation.key.type == AttributeKey.Type.TYPE_ARRAY:
134+
field = type_array_to_stored_array_json_path(aggregation.key)
135+
return field, f.notEmpty(field)
132136
else:
133137
field = attribute_key_to_expression(aggregation.key)
134138
return field, get_field_existence_expression(field)

0 commit comments

Comments
 (0)