Skip to content

Commit f820c40

Browse files
authored
feat(eap): Sets up double deletion of occurrences with EAP (#101385)
Sets up double deletion of occurrences with EAP, as described in [ID-997](https://linear.app/getsentry/issue/ID-997/set-up-double-deletions-with-eap). When deleting events from eventstore, we should also delete from EAP using the in-progress `DeleteTraceItems` endpoint ([protobuf definition](https://github.com/getsentry/sentry-protos/blob/main/proto/sentry_protos/snuba/v1/endpoint_delete_trace_items.proto)). Rolling out behind an organization allowlist feature flag (empty for foreseeable future), so this won't be running once merged.
1 parent 4020df1 commit f820c40

File tree

4 files changed

+224
-3
lines changed

4 files changed

+224
-3
lines changed

src/sentry/deletions/tasks/nodestore.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
import sentry_sdk
77
from snuba_sdk import DeleteQuery, Request
88

9-
from sentry import eventstream, nodestore
9+
from sentry import eventstream, nodestore, options
1010
from sentry.deletions.tasks.scheduled import MAX_RETRIES, logger
11+
from sentry.eventstream.eap import delete_groups_from_eap_rpc
1112
from sentry.exceptions import DeleteAborted
1213
from sentry.models.eventattachment import EventAttachment
1314
from sentry.models.userreport import UserReport
@@ -205,6 +206,51 @@ def delete_events_from_eventstore(
205206
eventstream_state = eventstream.backend.start_delete_groups(project_id, group_ids)
206207
eventstream.backend.end_delete_groups(eventstream_state)
207208

209+
delete_events_from_eap(organization_id, project_id, group_ids, dataset)
210+
211+
212+
def delete_events_from_eap(
213+
organization_id: int,
214+
project_id: int,
215+
group_ids: Sequence[int],
216+
dataset: Dataset,
217+
) -> None:
218+
eap_deletion_allowlist = options.get("eventstream.eap.deletion_enabled.organization_allowlist")
219+
if organization_id not in eap_deletion_allowlist:
220+
return
221+
222+
try:
223+
response = delete_groups_from_eap_rpc(
224+
organization_id=organization_id,
225+
project_id=project_id,
226+
group_ids=group_ids,
227+
referrer="deletions.group.eap",
228+
)
229+
logger.info(
230+
"eap.delete_groups.completed",
231+
extra={
232+
"organization_id": organization_id,
233+
"project_id": project_id,
234+
"group_count": len(group_ids),
235+
"matching_items_count": response.matching_items_count,
236+
},
237+
)
238+
except Exception as e:
239+
logger.exception(
240+
"eap.delete_groups.failed",
241+
extra={
242+
"organization_id": organization_id,
243+
"project_id": project_id,
244+
"group_ids": group_ids[:10],
245+
"error": str(e),
246+
},
247+
)
248+
metrics.incr(
249+
"deletions.eap.failed",
250+
tags={"dataset": dataset.value},
251+
sample_rate=1.0,
252+
)
253+
208254

209255
def delete_events_from_eventstore_issue_platform(
210256
organization_id: int, project_id: int, group_ids: Sequence[int], times_seen_list: Sequence[int]

src/sentry/eventstream/eap.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from collections.abc import Sequence
5+
6+
from sentry_protos.snuba.v1.endpoint_delete_trace_items_pb2 import (
7+
DeleteTraceItemsRequest,
8+
DeleteTraceItemsResponse,
9+
)
10+
from sentry_protos.snuba.v1.request_common_pb2 import (
11+
TRACE_ITEM_TYPE_OCCURRENCE,
12+
RequestMeta,
13+
TraceItemFilterWithType,
14+
)
15+
from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey, AttributeValue, IntArray
16+
from sentry_protos.snuba.v1.trace_item_filter_pb2 import (
17+
AndFilter,
18+
ComparisonFilter,
19+
TraceItemFilter,
20+
)
21+
22+
from sentry.utils import snuba_rpc
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
def delete_groups_from_eap_rpc(
28+
organization_id: int,
29+
project_id: int,
30+
group_ids: Sequence[int],
31+
referrer: str = "deletions.group",
32+
) -> DeleteTraceItemsResponse:
33+
"""
34+
Delete occurrences from EAP for the given group IDs.
35+
"""
36+
37+
if not group_ids:
38+
raise ValueError("group_ids must not be empty")
39+
40+
project_filter = _create_project_filter(project_id)
41+
group_id_filter = _create_group_id_filter(list(group_ids))
42+
combined_filter = TraceItemFilter(
43+
and_filter=AndFilter(filters=[project_filter, group_id_filter])
44+
)
45+
filter_with_type = TraceItemFilterWithType(
46+
item_type=TRACE_ITEM_TYPE_OCCURRENCE,
47+
filter=combined_filter,
48+
)
49+
50+
request = DeleteTraceItemsRequest(
51+
meta=RequestMeta(
52+
organization_id=organization_id,
53+
project_ids=[project_id],
54+
referrer=referrer,
55+
cogs_category="deletions",
56+
),
57+
filters=[filter_with_type],
58+
)
59+
response = snuba_rpc.rpc(request, DeleteTraceItemsResponse)
60+
61+
return response
62+
63+
64+
def _create_project_filter(project_id: int) -> TraceItemFilter:
65+
return TraceItemFilter(
66+
comparison_filter=ComparisonFilter(
67+
key=AttributeKey(
68+
type=AttributeKey.TYPE_INT,
69+
name="sentry.project_id",
70+
),
71+
op=ComparisonFilter.OP_EQUALS,
72+
value=AttributeValue(val_int=project_id),
73+
)
74+
)
75+
76+
77+
def _create_group_id_filter(group_ids: list[int]) -> TraceItemFilter:
78+
return TraceItemFilter(
79+
comparison_filter=ComparisonFilter(
80+
key=AttributeKey(
81+
type=AttributeKey.TYPE_INT,
82+
name="sentry.group_id",
83+
),
84+
op=ComparisonFilter.OP_IN,
85+
value=AttributeValue(val_int_array=IntArray(values=group_ids)),
86+
)
87+
)

src/sentry/options/defaults.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3583,9 +3583,9 @@
35833583
flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE,
35843584
)
35853585

3586-
# Allow list for projects with LLM issue detection enabled
3586+
# The allowlist of organization IDs for which deletion from EAP is enabled.
35873587
register(
3588-
"issue-detection.llm-detection.projects-allowlist",
3588+
"eventstream.eap.deletion_enabled.organization_allowlist",
35893589
type=Sequence,
35903590
default=[],
35913591
flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE,
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from unittest.mock import patch
2+
3+
import pytest
4+
from sentry_protos.snuba.v1.endpoint_delete_trace_items_pb2 import DeleteTraceItemsResponse
5+
from sentry_protos.snuba.v1.request_common_pb2 import TRACE_ITEM_TYPE_OCCURRENCE, ResponseMeta
6+
7+
from sentry.deletions.tasks.nodestore import delete_events_from_eap
8+
from sentry.eventstream.eap import delete_groups_from_eap_rpc
9+
from sentry.snuba.dataset import Dataset
10+
from sentry.testutils.cases import TestCase
11+
12+
13+
class TestEAPDeletion(TestCase):
14+
def setUp(self):
15+
self.organization_id = 1
16+
self.project_id = 123
17+
self.group_ids = [1, 2, 3]
18+
19+
@patch("sentry.eventstream.eap.snuba_rpc.rpc")
20+
def test_deletion_with_error_dataset(self, mock_rpc):
21+
mock_rpc.return_value = DeleteTraceItemsResponse(
22+
meta=ResponseMeta(),
23+
matching_items_count=150,
24+
)
25+
26+
with self.options(
27+
{"eventstream.eap.deletion_enabled.organization_allowlist": [self.organization_id]}
28+
):
29+
delete_events_from_eap(
30+
self.organization_id, self.project_id, self.group_ids, Dataset.Events
31+
)
32+
33+
assert mock_rpc.call_count == 1
34+
35+
request = mock_rpc.call_args[0][0]
36+
assert request.meta.organization_id == self.organization_id
37+
assert request.meta.project_ids == [self.project_id]
38+
assert request.meta.referrer == "deletions.group.eap"
39+
assert request.meta.cogs_category == "deletions"
40+
41+
assert len(request.filters) == 1
42+
assert request.filters[0].item_type == TRACE_ITEM_TYPE_OCCURRENCE
43+
44+
@patch("sentry.eventstream.eap.snuba_rpc.rpc")
45+
def test_multiple_group_ids(self, mock_rpc):
46+
mock_rpc.return_value = DeleteTraceItemsResponse(
47+
meta=ResponseMeta(),
48+
matching_items_count=500,
49+
)
50+
51+
many_group_ids = [10, 20, 30, 40, 50]
52+
53+
with self.options(
54+
{"eventstream.eap.deletion_enabled.organization_allowlist": [self.organization_id]}
55+
):
56+
delete_events_from_eap(
57+
self.organization_id, self.project_id, many_group_ids, Dataset.Events
58+
)
59+
60+
request = mock_rpc.call_args[0][0]
61+
group_filter = request.filters[0].filter.and_filter.filters[1]
62+
assert list(group_filter.comparison_filter.value.val_int_array.values) == many_group_ids
63+
64+
@patch("sentry.eventstream.eap.snuba_rpc.rpc")
65+
def test_organization_not_in_allowlist_skips_deletion(self, mock_rpc):
66+
with self.options({"eventstream.eap.deletion_enabled.organization_allowlist": [456, 789]}):
67+
delete_events_from_eap(
68+
self.organization_id, self.project_id, self.group_ids, Dataset.Events
69+
)
70+
71+
mock_rpc.assert_not_called()
72+
73+
@patch("sentry.eventstream.eap.snuba_rpc.rpc")
74+
def test_empty_allowlist_skips_deletion(self, mock_rpc):
75+
with self.options({"eventstream.eap.deletion_enabled.organization_allowlist": []}):
76+
delete_events_from_eap(
77+
self.organization_id, self.project_id, self.group_ids, Dataset.Events
78+
)
79+
80+
mock_rpc.assert_not_called()
81+
82+
def test_empty_group_ids_raises_error(self):
83+
with pytest.raises(ValueError, match="group_ids must not be empty"):
84+
delete_groups_from_eap_rpc(
85+
organization_id=self.organization_id,
86+
project_id=self.project_id,
87+
group_ids=[],
88+
)

0 commit comments

Comments
 (0)