Skip to content

Commit 8943e88

Browse files
authored
feat(deletes): support TraceItemFilter[s] in DeleteTraceItems API (#7535)
Wire DeleteTraceEndpoint TraceItemFilter[s] into bulk_delete_query arguments. This allows product to test API-level integration with the delete endpoint, without blowing up the size of this change. This validates the incoming fields and passes them through in a new `AttributeConditions` object when not deleting by trace_id Since we don't support delete by attributes, and it's unsafe to only use the column values provided, we short-circuit requests that have attribute conditions (and do no delete at all). Next steps: 1. Wire these values into the DeleteQueryMessage (undecided at the moment if we should resolve the attributes to columns when we create that message, or if attribute_conditions should be a new field that gets resolved when the consumer executes the delete) 2. Run a test? Depends on #7534
1 parent c59803b commit 8943e88

File tree

4 files changed

+456
-13
lines changed

4 files changed

+456
-13
lines changed

snuba/web/bulk_delete_query.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import logging
22
import time
3+
from dataclasses import dataclass
34
from threading import Thread
4-
from typing import Any, Dict, Mapping, MutableMapping, Optional, Sequence, TypedDict
5+
from typing import (
6+
Any,
7+
Dict,
8+
List,
9+
Mapping,
10+
MutableMapping,
11+
Optional,
12+
Sequence,
13+
TypedDict,
14+
)
515

616
import rapidjson
717
from confluent_kafka import KafkaError
@@ -12,6 +22,7 @@
1222
from snuba.attribution.attribution_info import AttributionInfo
1323
from snuba.clickhouse.columns import ColumnSet
1424
from snuba.clickhouse.query import Query
25+
from snuba.datasets.deletion_settings import DeletionSettings
1526
from snuba.datasets.storage import WritableTableStorage
1627
from snuba.datasets.storages.storage_key import StorageKey
1728
from snuba.query.conditions import combine_or_conditions
@@ -39,6 +50,12 @@
3950
logger = logging.getLogger(__name__)
4051

4152

53+
@dataclass
54+
class AttributeConditions:
55+
item_type: int
56+
attributes: Dict[str, List[Any]]
57+
58+
4259
class DeleteQueryMessage(TypedDict):
4360
rows_to_delete: int
4461
storage_name: str
@@ -123,11 +140,63 @@ def produce_delete_query(delete_query: DeleteQueryMessage) -> None:
123140
logger.exception("Could not produce delete query due to error: %r", ex)
124141

125142

143+
def _validate_attribute_conditions(
144+
attribute_conditions: AttributeConditions,
145+
delete_settings: DeletionSettings,
146+
) -> None:
147+
"""
148+
Validates that the attribute_conditions are allowed for the configured item_type.
149+
150+
Args:
151+
attribute_conditions: AttributeConditions containing item_type and attribute mappings
152+
delete_settings: The deletion settings for the storage
153+
154+
Raises:
155+
InvalidQueryException: If no attributes are configured for the item_type,
156+
or if any requested attributes are not allowed
157+
"""
158+
# Get the string name for the item_type from the configuration
159+
# The configuration uses string names (e.g., "occurrence") as keys
160+
allowed_attrs_config = delete_settings.allowed_attributes_by_item_type
161+
162+
if not allowed_attrs_config:
163+
raise InvalidQueryException("No attribute-based deletions configured for this storage")
164+
165+
# Check if the item_type has any allowed attributes configured
166+
# Since the config uses string names and we're given an integer, we need to find the matching config
167+
# For now, we'll check all configured item types and validate against any that match
168+
169+
# Try to find a matching configuration by item_type name
170+
matching_allowed_attrs = None
171+
for configured_item_type, allowed_attrs in allowed_attrs_config.items():
172+
# For this initial implementation, we'll use the string key directly
173+
# In the future, we might need a mapping from item_type int to string name
174+
matching_allowed_attrs = allowed_attrs
175+
break # For now, assume the first/only configured type
176+
177+
if matching_allowed_attrs is None:
178+
raise InvalidQueryException(
179+
f"No attribute-based deletions configured for item_type {attribute_conditions.item_type}"
180+
)
181+
182+
# Validate that all requested attributes are allowed
183+
requested_attrs = set(attribute_conditions.attributes.keys())
184+
allowed_attrs_set = set(matching_allowed_attrs)
185+
invalid_attrs = requested_attrs - allowed_attrs_set
186+
187+
if invalid_attrs:
188+
raise InvalidQueryException(
189+
f"Invalid attributes for deletion: {invalid_attrs}. "
190+
f"Allowed attributes: {allowed_attrs_set}"
191+
)
192+
193+
126194
@with_span()
127195
def delete_from_storage(
128196
storage: WritableTableStorage,
129197
conditions: Dict[str, list[Any]],
130198
attribution_info: Mapping[str, Any],
199+
attribute_conditions: Optional[AttributeConditions] = None,
131200
) -> dict[str, Result]:
132201
"""
133202
This method does a series of validation checks (outline below),
@@ -138,6 +207,7 @@ def delete_from_storage(
138207
* storage validation that deletes are enabled
139208
* column names are valid (allowed_columns storage setting)
140209
* column types are valid
210+
* attribute names are valid (allowed_attributes_by_item_type storage setting)
141211
"""
142212
if not deletes_are_enabled():
143213
raise DeletesNotEnabledError("Deletes not enabled in this region")
@@ -161,6 +231,16 @@ def delete_from_storage(
161231
except InvalidColumnType as e:
162232
raise InvalidQueryException(e.message)
163233

234+
# validate attribute conditions if provided
235+
if attribute_conditions:
236+
_validate_attribute_conditions(attribute_conditions, delete_settings)
237+
logger.error(
238+
"valid attribute_conditions passed to delete_from_storage, but delete will be ignored "
239+
"as functionality is not yet implemented"
240+
)
241+
# deleting by just conditions and ignoring attribute_conditions would be dangerous
242+
return {}
243+
164244
attr_info = _get_attribution_info(attribution_info)
165245
return delete_from_tables(storage, delete_settings.tables, conditions, attr_info)
166246

snuba/web/rpc/v1/endpoint_delete_trace_items.py

Lines changed: 111 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,99 @@
1-
from typing import Type
1+
from typing import Any, Dict, List, Optional, Sequence, Type
22

33
from sentry_protos.snuba.v1.endpoint_delete_trace_items_pb2 import (
44
DeleteTraceItemsRequest,
55
DeleteTraceItemsResponse,
66
)
7+
from sentry_protos.snuba.v1.request_common_pb2 import TraceItemFilterWithType
8+
from sentry_protos.snuba.v1.trace_item_filter_pb2 import ComparisonFilter
79

810
from snuba.attribution.appid import AppID
911
from snuba.datasets.storages.factory import get_writable_storage
1012
from snuba.datasets.storages.storage_key import StorageKey
11-
from snuba.web.bulk_delete_query import delete_from_storage
13+
from snuba.web.bulk_delete_query import AttributeConditions, delete_from_storage
1214
from snuba.web.rpc import RPCEndpoint
1315
from snuba.web.rpc.common.exceptions import BadSnubaRPCRequestException
1416

1517

18+
def _extract_attribute_value(comparison_filter: ComparisonFilter) -> Any:
19+
"""Extract the value from a ComparisonFilter's AttributeValue."""
20+
value_field = comparison_filter.value.WhichOneof("value")
21+
if value_field == "val_str":
22+
return comparison_filter.value.val_str
23+
elif value_field == "val_int":
24+
return comparison_filter.value.val_int
25+
elif value_field == "val_double":
26+
return comparison_filter.value.val_double
27+
elif value_field == "val_bool":
28+
return comparison_filter.value.val_bool
29+
elif value_field == "val_str_array":
30+
return list(comparison_filter.value.val_str_array.values)
31+
elif value_field == "val_int_array":
32+
return list(comparison_filter.value.val_int_array.values)
33+
elif value_field == "val_double_array":
34+
return list(comparison_filter.value.val_double_array.values)
35+
else:
36+
raise BadSnubaRPCRequestException(f"Unsupported attribute value type: {value_field}")
37+
38+
39+
def _trace_item_filters_to_attribute_conditions(
40+
item_type: int,
41+
filters: Sequence[TraceItemFilterWithType],
42+
) -> AttributeConditions:
43+
"""
44+
Convert TraceItemFilters to AttributeConditions for deletion.
45+
46+
Only supports ComparisonFilter with OP_EQUALS or OP_IN operations.
47+
All filters are combined with AND logic.
48+
49+
Args:
50+
item_type: The trace item type (e.g., occurrence, span)
51+
filters: List of TraceItemFilterWithType from the request
52+
53+
Returns:
54+
AttributeConditions object containing item_type and attribute mappings
55+
56+
Raises:
57+
BadSnubaRPCRequestException: If unsupported filter types or operations are encountered
58+
"""
59+
attributes: Dict[str, List[Any]] = {}
60+
61+
for filter_with_type in filters:
62+
# Extract the actual filter from TraceItemFilterWithType
63+
trace_filter = filter_with_type.filter
64+
# Only support comparison filters for deletion
65+
if not trace_filter.HasField("comparison_filter"):
66+
raise BadSnubaRPCRequestException(
67+
"Only comparison filters are supported for deletion. "
68+
"AND, OR, and NOT filters are not supported."
69+
)
70+
71+
comparison_filter = trace_filter.comparison_filter
72+
op = comparison_filter.op
73+
74+
# Only support equality operations for deletion
75+
if op not in (ComparisonFilter.OP_EQUALS, ComparisonFilter.OP_IN):
76+
op_name = ComparisonFilter.Op.Name(op)
77+
raise BadSnubaRPCRequestException(
78+
f"Only OP_EQUALS and OP_IN operations are supported for deletion. Got: {op_name}"
79+
)
80+
81+
attribute_name = comparison_filter.key.name
82+
value = _extract_attribute_value(comparison_filter)
83+
84+
# Convert single values to lists for consistency
85+
if not isinstance(value, list):
86+
value = [value]
87+
88+
# If the attribute already exists, extend the list (OR logic within same attribute)
89+
if attribute_name in attributes:
90+
attributes[attribute_name].extend(value)
91+
else:
92+
attributes[attribute_name] = value
93+
94+
return AttributeConditions(item_type=item_type, attributes=attributes)
95+
96+
1697
class EndpointDeleteTraceItems(RPCEndpoint[DeleteTraceItemsRequest, DeleteTraceItemsResponse]):
1798
@classmethod
1899
def version(cls) -> str:
@@ -36,9 +117,6 @@ def _execute(self, request: DeleteTraceItemsRequest) -> DeleteTraceItemsResponse
36117
if has_trace_ids and has_filters:
37118
raise BadSnubaRPCRequestException("Provide only one of trace_ids or filters, not both.")
38119

39-
if has_filters:
40-
raise NotImplementedError("Currently, only delete by trace_ids is supported")
41-
42120
attribution_info = {
43121
"app_id": AppID("eap"),
44122
"referrer": request.meta.referrer,
@@ -51,14 +129,37 @@ def _execute(self, request: DeleteTraceItemsRequest) -> DeleteTraceItemsResponse
51129
"parent_api": "eap_delete_trace_items",
52130
}
53131

132+
# Build base conditions that apply to all deletions
133+
conditions: Dict[str, List[Any]] = {
134+
"organization_id": [request.meta.organization_id],
135+
"project_id": list(request.meta.project_ids),
136+
}
137+
138+
attribute_conditions: Optional[AttributeConditions] = None
139+
140+
if has_trace_ids:
141+
# Delete by trace_ids (no attribute filtering)
142+
conditions["trace_id"] = [str(tid) for tid in request.trace_ids]
143+
else:
144+
# Delete by filters (with attribute filtering)
145+
# item_type must be specified in the request metadata for attribute-based deletion
146+
if request.meta.trace_item_type == 0: # TRACE_ITEM_TYPE_UNSPECIFIED
147+
raise BadSnubaRPCRequestException(
148+
"trace_item_type must be specified in metadata when using filters"
149+
)
150+
151+
attribute_conditions = _trace_item_filters_to_attribute_conditions(
152+
request.meta.trace_item_type,
153+
request.filters,
154+
)
155+
# Add item_type to conditions for the delete query
156+
conditions["item_type"] = [attribute_conditions.item_type]
157+
54158
delete_result = delete_from_storage(
55159
get_writable_storage(StorageKey.EAP_ITEMS),
56-
{
57-
"organization_id": [request.meta.organization_id],
58-
"trace_id": list(request.trace_ids),
59-
"project_id": list(request.meta.project_ids),
60-
},
160+
conditions,
61161
attribution_info,
162+
attribute_conditions,
62163
)
63164

64165
response = DeleteTraceItemsResponse()

0 commit comments

Comments
 (0)