Skip to content

Commit 3edac08

Browse files
wmakphacops
andauthored
fix(attribute-values): Properly support boolean attributes (#8000)
## Summary The `TraceItemAttributeValues` RPC enumerates the distinct values an attribute takes (used to populate filter-value autocomplete). It previously assumed every attribute was a string, so querying a **boolean** attribute broke. This makes the endpoint properly support booleans. ## Changes - **Existence check on the right column.** Booleans live in `attributes_bool`, not `attributes_string`. A small `_ATTRIBUTE_TYPE_TO_COLUMN` map drives the `has(...)` check by attribute type; unsupported types raise a clear `BadSnubaRPCRequestException`. Adding more types later is a one-line map entry. - **Substring match guarded to strings.** `value_substring_match` compiles to a SQL `LIKE`, which only makes sense for strings — it now raises on non-string keys instead of producing a broken query. - **Lowercase boolean values.** The response proto's `values` field is `repeated string`, so booleans must be serialized as strings. They're returned as lowercase `"true"`/`"false"` — matching how booleans are stringified across the EAP filter API — so the returned values round-trip as filter inputs. Serializing via `bool(value)` also guards against ClickHouse returning `0`/`1`. ## Tests - `test_boolean_case` — enumerating a boolean attribute returns `["true", "false"]` with the expected counts. - `test_substring_match_on_boolean_rejected` — substring match on a boolean is rejected. - `test_unsupported_attribute_type_rejected` — non-string/bool types are rejected. --------- Co-authored-by: Pierre Massat <pierre.massat@sentry.io>
1 parent 8e4cfa3 commit 3edac08

2 files changed

Lines changed: 75 additions & 2 deletions

File tree

snuba/web/rpc/v1/trace_item_attribute_values.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,29 @@ def _map_key_names_for_existence_check(request_key: AttributeKey) -> list[str]:
4848
return names
4949

5050

51+
# Attribute value types this endpoint can enumerate, mapped to the ClickHouse
52+
# attribute map they live in. The response proto only carries strings, so every
53+
# supported type must also have a well-defined string form (see _execute).
54+
_ATTRIBUTE_TYPE_TO_COLUMN: dict["AttributeKey.Type.ValueType", str] = {
55+
AttributeKey.TYPE_STRING: "attributes_string",
56+
AttributeKey.TYPE_BOOLEAN: "attributes_bool",
57+
}
58+
59+
5160
def _build_conditions(request: TraceItemAttributeValuesRequest) -> Expression:
5261
attribute_key = attribute_key_to_expression(request.key)
5362

63+
try:
64+
attributes_column = _ATTRIBUTE_TYPE_TO_COLUMN[request.key.type]
65+
except KeyError:
66+
raise BadSnubaRPCRequestException("Only string and boolean attributes can be used")
67+
68+
# Use mapContains (not has) for key existence: it's the correct ClickHouse
69+
# function for Map columns and is handled by HashBucketFunctionTransformer
70+
# for the bucketed string/float maps as well as the un-bucketed bool map.
5471
key_existence = combine_or_conditions(
5572
[
56-
f.has(column("attributes_string"), name)
73+
f.mapContains(column(attributes_column), name)
5774
for name in _map_key_names_for_existence_check(request.key)
5875
]
5976
)
@@ -63,6 +80,10 @@ def _build_conditions(request: TraceItemAttributeValuesRequest) -> Expression:
6380
conditions.append(f.equals(column("item_type"), request.meta.trace_item_type))
6481

6582
if request.value_substring_match:
83+
if request.key.type != AttributeKey.TYPE_STRING:
84+
raise BadSnubaRPCRequestException(
85+
"substring matches can only be used on string attributes"
86+
)
6687
conditions.append(
6788
f.like(
6889
attribute_key,
@@ -200,9 +221,14 @@ def _execute(self, in_msg: TraceItemAttributeValuesRequest) -> TraceItemAttribut
200221
request=snuba_request,
201222
timer=self._timer,
202223
)
224+
# The response proto only carries strings. Boolean values are serialized
225+
# to the lowercase "true"/"false" form used across the EAP filter API so
226+
# that returned values round-trip as filter inputs.
227+
is_boolean = in_msg.key.type == AttributeKey.TYPE_BOOLEAN
203228
values, counts = [], []
204229
for row in res.result.get("data", []):
205-
values.append(row["attr_value"])
230+
value = row["attr_value"]
231+
values.append(str(bool(value)).lower() if is_boolean else value)
206232
counts.append(row.get("count()", 0))
207233
if len(values) == 0:
208234
return TraceItemAttributeValuesResponse(

tests/web/rpc/v1/test_trace_item_attribute_values_v1.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from snuba.datasets.storages.factory import get_storage
1616
from snuba.datasets.storages.storage_key import StorageKey
17+
from snuba.web.rpc.common.exceptions import BadSnubaRPCRequestException
1718
from snuba.web.rpc.v1.trace_item_attribute_values import AttributeValuesRequest
1819
from tests.base import BaseApiTest
1920
from tests.helpers import write_raw_unprocessed_events
@@ -63,13 +64,15 @@ def setup_teardown(eap: None, redis_db: None) -> Generator[List[bytes], None, No
6364
attributes={
6465
"tag1": AnyValue(string_value="herp"),
6566
"tag2": AnyValue(string_value="herp"),
67+
"custom_flag": AnyValue(bool_value=True),
6668
},
6769
),
6870
gen_item_message(
6971
start_timestamp=start_timestamp,
7072
attributes={
7173
"tag1": AnyValue(string_value="herpderp"),
7274
"tag2": AnyValue(string_value="herp"),
75+
"custom_flag": AnyValue(bool_value=False),
7376
},
7477
),
7578
gen_item_message(
@@ -108,6 +111,7 @@ def setup_teardown(eap: None, redis_db: None) -> Generator[List[bytes], None, No
108111
start_timestamp=start_timestamp,
109112
attributes={
110113
"tag1": AnyValue(string_value="some_last_value"),
114+
"sentry.is_segment": AnyValue(bool_value=False),
111115
},
112116
),
113117
gen_item_message(
@@ -251,3 +255,46 @@ def test_pagination(self, setup_teardown: Any) -> None:
251255
response = AttributeValuesRequest().execute(message)
252256
assert response.values == [expected]
253257
assert response.counts == [1]
258+
259+
def test_boolean_case(self, setup_teardown: Any) -> None:
260+
message = TraceItemAttributeValuesRequest(
261+
meta=COMMON_META,
262+
limit=5,
263+
key=AttributeKey(name="sentry.is_segment", type=AttributeKey.TYPE_BOOLEAN),
264+
)
265+
response = AttributeValuesRequest().execute(message)
266+
assert response.values == ["true", "false"]
267+
assert response.counts == [8, 1]
268+
269+
def test_boolean_existence_check(self, setup_teardown: Any) -> None:
270+
# `custom_flag` is set on exactly two items (one True, one False); the
271+
# other items do not have the key. The existence check must exclude the
272+
# items missing the key, otherwise they'd be miscounted as "false".
273+
message = TraceItemAttributeValuesRequest(
274+
meta=COMMON_META,
275+
limit=5,
276+
key=AttributeKey(name="custom_flag", type=AttributeKey.TYPE_BOOLEAN),
277+
)
278+
response = AttributeValuesRequest().execute(message)
279+
# Equal counts, so ties break on attr_value ASC ("false" before "true").
280+
assert response.values == ["false", "true"]
281+
assert response.counts == [1, 1]
282+
283+
def test_substring_match_on_boolean_rejected(self, setup_teardown: Any) -> None:
284+
message = TraceItemAttributeValuesRequest(
285+
meta=COMMON_META,
286+
limit=5,
287+
key=AttributeKey(name="sentry.is_segment", type=AttributeKey.TYPE_BOOLEAN),
288+
value_substring_match="tru",
289+
)
290+
with pytest.raises(BadSnubaRPCRequestException):
291+
AttributeValuesRequest().execute(message)
292+
293+
def test_unsupported_attribute_type_rejected(self, setup_teardown: Any) -> None:
294+
message = TraceItemAttributeValuesRequest(
295+
meta=COMMON_META,
296+
limit=5,
297+
key=AttributeKey(name="sentry.duration_ms", type=AttributeKey.TYPE_DOUBLE),
298+
)
299+
with pytest.raises(BadSnubaRPCRequestException):
300+
AttributeValuesRequest().execute(message)

0 commit comments

Comments
 (0)