Skip to content
This repository was archived by the owner on Aug 23, 2022. It is now read-only.

Commit e7da88e

Browse files
author
Anton Sapozhnikov
authored
Add non-digest batch mode (#39)
By default (digest mode), all batch events will be grouped by an owner and source_type. we email will look like: > here are your X new issues, and by the way, you have these Y old ones. This PR adds non-digest mode, so the router will receive only these events that are new or need a reminder.
1 parent 40a8bb1 commit e7da88e

File tree

6 files changed

+247
-24
lines changed

6 files changed

+247
-24
lines changed

comet_core/app.py

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -155,12 +155,25 @@ def __init__(self, database_uri="sqlite://"):
155155

156156
self.database_uri = database_uri
157157
self.batch_config = {
158-
"wait_for_more": timedelta(seconds=3),
158+
"communication_digest_mode": True,
159+
# By default (communication_digest_mode=True), all batch events will be grouped by an owner and source_type.
160+
# And email will look like:
161+
# here are your X new issues, and by the way, you have these Y old ones.
162+
# In case of the non-digest mode,
163+
# the router will receive only these events that are new or need a reminder.
164+
"escalation_reminder_cadence": timedelta(days=7),
165+
# `escalation_reminder_cadence` defines how often to send escalation reminders
166+
"escalation_time": timedelta(seconds=10),
167+
# `escalation_time` defines how soon event should be escalated (it takes ignore_fingerprints into account)
159168
"max_wait": timedelta(seconds=4),
169+
# `max_wait` defines the amount of time to wait since the earliest event in an attempt to catch whole batch
160170
"new_threshold": timedelta(days=7),
171+
# `new_threshold` defines amount of time to wait since the latest report of the given fingerprint to assume
172+
# it as a regression of the detected issue
161173
"owner_reminder_cadence": timedelta(days=7),
162-
"escalation_time": timedelta(seconds=10),
163-
"escalation_reminder_cadence": timedelta(days=7),
174+
# `owner_reminder_cadence` defines how often to send reminders
175+
"wait_for_more": timedelta(seconds=3),
176+
# `wait_for_more` defines the amount of time to wait since the latest event
164177
}
165178
self.specific_configs = {}
166179

@@ -377,7 +390,7 @@ def decorator(func):
377390

378391
self.escalators.add(source_types, func)
379392

380-
# pylint: disable=too-many-branches
393+
# pylint: disable=too-many-branches, too-many-locals, too-many-nested-blocks, too-many-statements
381394
def process_unprocessed_events(self):
382395
"""Checks the database for unprocessed events and processes them.
383396
@@ -387,20 +400,20 @@ def process_unprocessed_events(self):
387400
same escalation recipient recently. All ignored events will be skipped for the above, but marked as processed.
388401
389402
Config options we care about:
390-
source_type_config['owner_reminder_cadence']:
391-
source_type_config['notifications_send_emails']
403+
source_type_config['communication_digest_mode'],
404+
source_type_config['escalation_reminder_cadence'],
392405
source_type_config['escalation_time'],
393-
source_type_config['escalation_reminder_cadence']
394-
source_type_config['recipient_override']
395-
source_type_config['email_subject']:
396-
source_type_config['wait_for_more']:
397-
source_type_config['max_wait']:
406+
source_type_config['max_wait'],
407+
source_type_config['new_threshold'],
408+
source_type_config['owner_reminder_cadence'],
409+
source_type_config['wait_for_more']
398410
"""
411+
399412
LOG.debug("Processing unprocessed events")
400413

401414
# pylint: disable=consider-iterating-dictionary
402415
for source_type in self.parsers.keys():
403-
source_type_config = self.batch_config
416+
source_type_config = self.batch_config.copy()
404417
if source_type in self.specific_configs:
405418
source_type_config.update(self.specific_configs[source_type])
406419

@@ -451,14 +464,34 @@ def process_unprocessed_events(self):
451464
for owner, events in events_by_owner.items():
452465
owner_reminder_cadence = source_type_config["owner_reminder_cadence"]
453466

454-
if any([event.new for event in events]) or self.data_store.check_any_issue_needs_reminder(
455-
owner_reminder_cadence, events
456-
):
467+
events_to_remind = []
468+
if source_type_config["communication_digest_mode"]:
469+
if any([event.new for event in events]) or self.data_store.check_any_issue_needs_reminder(
470+
owner_reminder_cadence, events
471+
):
472+
events_to_remind = events
473+
else:
474+
fingerprints_to_remind = self.data_store.get_any_issues_need_reminder(
475+
owner_reminder_cadence, events
476+
)
477+
if fingerprints_to_remind:
478+
for e in events:
479+
if e.fingerprint in fingerprints_to_remind:
480+
e.reminder = True
481+
events_to_remind.append(e)
482+
483+
for e in events:
484+
if e.new and not e.fingerprint in fingerprints_to_remind:
485+
events_to_remind.append(e)
486+
487+
if events_to_remind:
457488
try:
458-
self._route_events(owner, events, source_type)
459-
self.data_store.update_processed_at_timestamp_to_now(events)
489+
self._route_events(owner, events_to_remind, source_type)
490+
self.data_store.update_processed_at_timestamp_to_now(events_to_remind)
460491
except CometCouldNotSendException:
461-
LOG.error(f"Could not send alert to {owner}: {events}")
492+
LOG.error(f"Could not send alert to {owner}: {events_to_remind}")
493+
494+
self.data_store.update_processed_at_timestamp_to_now([e for e in events if e not in events_to_remind])
462495

463496
LOG.info("events-processed", extra={"events": len(events), "source-type": source_type, "owner": owner})
464497

comet_core/data_store.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def remove_duplicate_events(event_record_list):
4343
return list(events_hash_table.values())
4444

4545

46-
class DataStore:
46+
class DataStore: # pylint: disable=too-many-public-methods
4747
"""Abstraction of the comet storage layer.
4848
4949
Args:
@@ -156,6 +156,32 @@ def check_any_issue_needs_reminder(self, search_timedelta, records):
156156
return max(timestamps)[0] <= datetime.utcnow() - search_timedelta
157157
return False
158158

159+
def get_any_issues_need_reminder(self, search_timedelta, records):
160+
"""Returns all the `fingerprints` having corresponding `event` table entries with the latest `sent_at`
161+
more then search_timedelta ago.
162+
NOTE: if all database records for a fingerprint given in the `records` list have the sent_at values set to Null,
163+
then this fingerprint will be treated as NOT needing a reminder, which might be unintuitive.
164+
Args:
165+
search_timedelta (datetime.timedelta): reminder interval
166+
records (list): list of EventRecord objects to check
167+
Returns:
168+
list: list of fingerprints that represent issues that need to be reminded about
169+
"""
170+
fingerprints = [record.fingerprint for record in records]
171+
fingerprints_to_remind = (
172+
self.session.query(func.max(EventRecord.sent_at).label("sent_at"), EventRecord.fingerprint)
173+
.filter(EventRecord.fingerprint.in_(fingerprints) & EventRecord.sent_at.isnot(None))
174+
.group_by(EventRecord.fingerprint)
175+
.all()
176+
)
177+
result = []
178+
deltat = datetime.utcnow() - search_timedelta
179+
for f in fingerprints_to_remind:
180+
if f.sent_at <= deltat:
181+
result.append(f.fingerprint)
182+
183+
return result
184+
159185
def update_timestamp_column_to_now(self, records, column_name):
160186
"""Update the `column_name` of the provided `EventRecord`s to datetime now
161187

comet_core/fingerprint.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414

1515
"""Helper function to compute the fingerprint of alerts."""
1616

17-
import collections
1817
import hmac
1918
import json
19+
from collections.abc import Iterable
2020
from copy import deepcopy
2121
from hashlib import sha256, shake_256
2222

@@ -62,7 +62,7 @@ def filter_dict(orig_dict, blacklist):
6262
for item in blacklist:
6363
if isinstance(item, str) and item in orig_dict:
6464
del orig_dict[item]
65-
elif isinstance(item, collections.Iterable):
65+
elif isinstance(item, Iterable):
6666
pointer = orig_dict
6767
for sub in item[:-1]:
6868
pointer = pointer.get(sub, {})

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
setuptools.setup(
1818
name="comet-core",
19-
version="2.5.1",
19+
version="2.6.0",
2020
url="https://github.com/spotify/comet-core",
2121
author="Spotify Platform Security",
2222
author_email="[email protected]",

tests/test_app.py

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@
2525
from comet_core.model import EventRecord, IgnoreFingerprintRecord
2626

2727

28-
@freeze_time("2018-05-09 09:00:00")
2928
# pylint: disable=missing-docstring
30-
def test_process_unprocessed_events():
29+
def test_process_unprocessed_events_digest_mode():
3130
app = Comet()
3231
app.register_parser("datastoretest", json)
3332
app.register_parser("datastoretest2", json)
3433
app.register_parser("datastoretest3", json)
34+
app.register_parser("datastoretest4", json)
3535

3636
app.set_config("datastoretest2", {})
3737

@@ -91,11 +91,120 @@ def test_process_unprocessed_events():
9191
)
9292

9393
app.process_unprocessed_events()
94+
95+
# it is expected to have specific_router called once for the datastoretest2
9496
assert specific_router.call_count == 1
97+
# it is expected to have two calls of the generic router for the source_type datastoretest2 and datastoretest3
9598
assert router.call_count == 2
99+
# and the user must be check_user
96100
assert router.call_args[0][2][0].owner == check_user
101+
# due to the default escalation_time=10seconds, all three events (id=2,3,4) must be escalated
97102
assert escalator.call_count == 3
98103

104+
app.data_store.add_record(
105+
EventRecord(
106+
id=5,
107+
received_at=datetime.utcnow() - timedelta(days=2),
108+
source_type="datastoretest",
109+
owner=check_user,
110+
data={},
111+
fingerprint="f1",
112+
)
113+
)
114+
app.process_unprocessed_events()
115+
116+
# f1 is expected to be processed, but not sent out
117+
assert app.data_store.get_latest_event_with_fingerprint("f1").processed_at
118+
assert not app.data_store.get_latest_event_with_fingerprint("f1").sent_at
119+
120+
121+
# pylint: disable=missing-docstring
122+
def test_process_unprocessed_events_non_digest_mode():
123+
app = Comet()
124+
app.register_parser("datastoretest4", json)
125+
126+
check_user = "an_owner"
127+
router = mock.Mock()
128+
escalator = mock.Mock()
129+
app.register_router(func=router)
130+
app.register_escalator(func=escalator)
131+
132+
app.set_config("datastoretest4", {"communication_digest_mode": False, "new_threshold": timedelta(days=14)})
133+
134+
app.data_store.add_record(
135+
EventRecord(
136+
id=6,
137+
received_at=datetime.utcnow() - timedelta(days=8),
138+
source_type="datastoretest4",
139+
sent_at=datetime.utcnow() - timedelta(days=8),
140+
processed_at=datetime.utcnow() - timedelta(days=8),
141+
owner=check_user,
142+
data={},
143+
fingerprint="f5",
144+
)
145+
)
146+
147+
app.data_store.add_record(
148+
EventRecord(
149+
id=7,
150+
received_at=datetime.utcnow() - timedelta(days=2),
151+
source_type="datastoretest4",
152+
owner=check_user,
153+
data={},
154+
fingerprint="f5",
155+
)
156+
)
157+
158+
app.data_store.add_record(
159+
EventRecord(
160+
id=8,
161+
received_at=datetime.utcnow(),
162+
source_type="datastoretest4",
163+
owner=check_user,
164+
data={},
165+
fingerprint="f6",
166+
)
167+
)
168+
169+
app.data_store.add_record(
170+
EventRecord(
171+
id=9,
172+
received_at=datetime.utcnow() - timedelta(days=2),
173+
source_type="datastoretest4",
174+
sent_at=datetime.utcnow() - timedelta(days=2),
175+
processed_at=datetime.utcnow() - timedelta(days=2),
176+
owner=check_user,
177+
data={},
178+
fingerprint="f7",
179+
)
180+
)
181+
182+
app.data_store.add_record(
183+
EventRecord(
184+
id=10,
185+
received_at=datetime.utcnow(),
186+
source_type="datastoretest4",
187+
owner=check_user,
188+
data={},
189+
fingerprint="f7",
190+
)
191+
)
192+
193+
# f5 is expected to be reminded
194+
# f6 is expected to be new and sent as well
195+
# f7 is NOT expected to be reminded
196+
before_calling = router.call_count
197+
app.process_unprocessed_events()
198+
assert app.data_store.get_latest_event_with_fingerprint("f5").processed_at
199+
assert app.data_store.get_latest_event_with_fingerprint("f6").processed_at
200+
assert app.data_store.get_latest_event_with_fingerprint("f7").processed_at
201+
assert router.call_count == before_calling + 1
202+
203+
sent_fingerprints = [e.fingerprint for e in router.call_args[0][2]]
204+
assert "f5" in sent_fingerprints
205+
assert "f6" in sent_fingerprints
206+
assert "f7" not in sent_fingerprints
207+
99208

100209
def test_event_container():
101210
container = EventContainer("test", {})

tests/test_data_store.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,61 @@ def test_update_processed_at_timestamp_to_now(data_store_with_test_events):
163163
assert isinstance(record.processed_at, datetime)
164164

165165

166+
def test_get_any_issues_need_reminder():
167+
data_store = comet_core.data_store.DataStore("sqlite://")
168+
169+
test_fingerprint1 = "f1"
170+
test_fingerprint2 = "f2"
171+
test_fingerprint3 = "f3"
172+
173+
one_a = EventRecord(sent_at=datetime.utcnow() - timedelta(days=9), source_type="datastoretest")
174+
one_a.fingerprint = test_fingerprint1
175+
one_b = EventRecord(sent_at=datetime.utcnow() - timedelta(days=3), source_type="datastoretest")
176+
one_b.fingerprint = test_fingerprint1
177+
178+
two_a = EventRecord(sent_at=datetime.utcnow() - timedelta(days=10), source_type="datastoretest")
179+
two_a.fingerprint = test_fingerprint2
180+
two_b = EventRecord(sent_at=datetime.utcnow() - timedelta(days=8), source_type="datastoretest")
181+
two_b.fingerprint = test_fingerprint2
182+
183+
two_c = EventRecord(source_type="datastoretest") # sent_at NULL
184+
two_c.fingerprint = test_fingerprint2
185+
186+
three_a = EventRecord(source_type="datastoretest") # sent_at NULL
187+
three_a.fingerprint = test_fingerprint3
188+
189+
data_store.add_record(one_a)
190+
data_store.add_record(two_a)
191+
data_store.add_record(two_b)
192+
data_store.add_record(two_c)
193+
data_store.add_record(three_a)
194+
195+
# issue \ time --->
196+
# 1 --------a------|-------------->
197+
# 2 ----a-------b--|--------------> (2c sent_at == NULL)
198+
# 3 ---------------|--------------> (3a sent_at == NULL)
199+
# ^
200+
# -7days
201+
202+
result = data_store.get_any_issues_need_reminder(timedelta(days=7), [one_a, two_a, three_a])
203+
assert len(result) == 2
204+
assert test_fingerprint2 in result
205+
assert test_fingerprint1 in result
206+
207+
data_store.add_record(one_b)
208+
209+
# issue \ time --->
210+
# 1 --------a------|-----b-------->
211+
# 2 ----a-------b--|--------------> (2c sent_at == NULL)
212+
# 3 ---------------|--------------> (3a sent_at == NULL)
213+
# ^
214+
# -7days
215+
216+
result = data_store.get_any_issues_need_reminder(timedelta(days=7), [one_a, two_a, three_a])
217+
assert len(result) == 1
218+
assert test_fingerprint2 in result
219+
220+
166221
def test_check_any_issue_needs_reminder():
167222
data_store = comet_core.data_store.DataStore("sqlite://")
168223

0 commit comments

Comments
 (0)