test_event_frequency.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. import time
  2. from copy import deepcopy
  3. from datetime import timedelta
  4. from unittest.mock import patch
  5. from uuid import uuid4
  6. import pytz
  7. from django.utils.timezone import now
  8. from freezegun import freeze_time
  9. from sentry.issues.grouptype import PerformanceNPlusOneGroupType
  10. from sentry.models import Rule
  11. from sentry.rules.conditions.event_frequency import (
  12. EventFrequencyCondition,
  13. EventFrequencyPercentCondition,
  14. EventUniqueUserFrequencyCondition,
  15. )
  16. from sentry.testutils.cases import RuleTestCase, SnubaTestCase
  17. from sentry.testutils.helpers.datetime import before_now, iso_format
  18. from sentry.testutils.performance_issues.store_transaction import PerfIssueTransactionTestMixin
  19. from sentry.testutils.silo import region_silo_test
  20. class FrequencyConditionMixin:
  21. def increment(self, event, count, environment=None, timestamp=None):
  22. raise NotImplementedError
  23. def _run_test(self, minutes, data, passes, add_events=False):
  24. if not self.environment:
  25. self.environment = self.create_environment(name="prod")
  26. rule = self.get_rule(data=data, rule=Rule(environment_id=None))
  27. environment_rule = self.get_rule(data=data, rule=Rule(environment_id=self.environment.id))
  28. event = self.add_event(
  29. data={
  30. "fingerprint": ["something_random"],
  31. "user": {"id": uuid4().hex},
  32. },
  33. project_id=self.project.id,
  34. timestamp=before_now(minutes=minutes),
  35. )
  36. if add_events:
  37. self.increment(
  38. event,
  39. data["value"] + 1,
  40. environment=self.environment.name,
  41. timestamp=now() - timedelta(minutes=minutes),
  42. )
  43. self.increment(
  44. event,
  45. data["value"] + 1,
  46. timestamp=now() - timedelta(minutes=minutes),
  47. )
  48. if passes:
  49. self.assertPasses(rule, event)
  50. self.assertPasses(environment_rule, event)
  51. else:
  52. self.assertDoesNotPass(rule, event)
  53. self.assertDoesNotPass(environment_rule, event)
  54. class ErrorEventMixin:
  55. def add_event(self, data, project_id, timestamp):
  56. data["timestamp"] = iso_format(timestamp)
  57. # Store an error event
  58. event = self.store_event(
  59. data=data,
  60. project_id=project_id,
  61. )
  62. return event.for_group(event.group)
  63. class PerfEventMixin(PerfIssueTransactionTestMixin):
  64. def add_event(self, data, project_id, timestamp):
  65. fingerprint = data["fingerprint"][0]
  66. fingerprint = (
  67. fingerprint
  68. if "-" in fingerprint
  69. else f"{PerformanceNPlusOneGroupType.type_id}-{data['fingerprint'][0]}"
  70. )
  71. # Store a performance event
  72. event = self.store_transaction(
  73. environment=data.get("environment"),
  74. project_id=project_id,
  75. user_id=data.get("user", uuid4().hex),
  76. fingerprint=[fingerprint],
  77. timestamp=timestamp.replace(tzinfo=pytz.utc),
  78. )
  79. return event.for_group(event.groups[0])
  80. class StandardIntervalMixin:
  81. def test_one_minute_with_events(self):
  82. data = {"interval": "1m", "value": 6}
  83. self._run_test(data=data, minutes=1, passes=True, add_events=True)
  84. data = {"interval": "1m", "value": 16}
  85. self._run_test(data=data, minutes=1, passes=False)
  86. def test_one_hour_with_events(self):
  87. data = {"interval": "1h", "value": 6}
  88. self._run_test(data=data, minutes=60, passes=True, add_events=True)
  89. data = {"interval": "1h", "value": 16}
  90. self._run_test(data=data, minutes=60, passes=False)
  91. def test_one_day_with_events(self):
  92. data = {"interval": "1d", "value": 6}
  93. self._run_test(data=data, minutes=1440, passes=True, add_events=True)
  94. data = {"interval": "1d", "value": 16}
  95. self._run_test(data=data, minutes=1440, passes=False)
  96. def test_one_week_with_events(self):
  97. data = {"interval": "1w", "value": 6}
  98. self._run_test(data=data, minutes=10080, passes=True, add_events=True)
  99. data = {"interval": "1w", "value": 16}
  100. self._run_test(data=data, minutes=10080, passes=False)
  101. def test_one_minute_no_events(self):
  102. data = {"interval": "1m", "value": 6}
  103. self._run_test(data=data, minutes=1, passes=False)
  104. def test_one_hour_no_events(self):
  105. data = {"interval": "1h", "value": 6}
  106. self._run_test(data=data, minutes=60, passes=False)
  107. def test_one_day_no_events(self):
  108. data = {"interval": "1d", "value": 6}
  109. self._run_test(data=data, minutes=1440, passes=False)
  110. def test_one_week_no_events(self):
  111. data = {"interval": "1w", "value": 6}
  112. self._run_test(data=data, minutes=10080, passes=False)
  113. def test_comparison(self):
  114. # Test data is 4 events in the current period and 2 events in the comparison period, so
  115. # a 100% increase.
  116. event = self.add_event(
  117. data={
  118. "fingerprint": ["something_random"],
  119. "user": {"id": uuid4().hex},
  120. },
  121. project_id=self.project.id,
  122. timestamp=before_now(minutes=1),
  123. )
  124. self.increment(
  125. event,
  126. 3,
  127. timestamp=now() - timedelta(minutes=1),
  128. )
  129. self.increment(
  130. event,
  131. 2,
  132. timestamp=now() - timedelta(days=1, minutes=20),
  133. )
  134. data = {
  135. "interval": "1h",
  136. "value": 99,
  137. "comparisonType": "percent",
  138. "comparisonInterval": "1d",
  139. }
  140. rule = self.get_rule(data=data, rule=Rule(environment_id=None))
  141. self.assertPasses(rule, event)
  142. data = {
  143. "interval": "1h",
  144. "value": 101,
  145. "comparisonType": "percent",
  146. "comparisonInterval": "1d",
  147. }
  148. rule = self.get_rule(data=data, rule=Rule(environment_id=None))
  149. self.assertDoesNotPass(rule, event)
  150. def test_comparison_empty_comparison_period(self):
  151. # Test data is 1 event in the current period and 0 events in the comparison period. This
  152. # should always result in 0 and never fire.
  153. event = self.add_event(
  154. data={
  155. "fingerprint": ["something_random"],
  156. "user": {"id": uuid4().hex},
  157. },
  158. project_id=self.project.id,
  159. timestamp=before_now(minutes=1),
  160. )
  161. data = {
  162. "interval": "1h",
  163. "value": 0,
  164. "comparisonType": "percent",
  165. "comparisonInterval": "1d",
  166. }
  167. rule = self.get_rule(data=data, rule=Rule(environment_id=None))
  168. self.assertDoesNotPass(rule, event)
  169. data = {
  170. "interval": "1h",
  171. "value": 100,
  172. "comparisonType": "percent",
  173. "comparisonInterval": "1d",
  174. }
  175. rule = self.get_rule(data=data, rule=Rule(environment_id=None))
  176. self.assertDoesNotPass(rule, event)
  177. class EventFrequencyConditionTestCase(
  178. FrequencyConditionMixin, StandardIntervalMixin, SnubaTestCase
  179. ):
  180. rule_cls = EventFrequencyCondition
  181. def increment(self, event, count, environment=None, timestamp=None):
  182. timestamp = timestamp if timestamp else before_now(minutes=1)
  183. data = {"fingerprint": event.data["fingerprint"]}
  184. if environment:
  185. data["environment"] = environment
  186. for _ in range(count):
  187. self.add_event(
  188. data=data,
  189. project_id=self.project.id,
  190. timestamp=timestamp,
  191. )
  192. class EventUniqueUserFrequencyConditionTestCase(
  193. FrequencyConditionMixin,
  194. StandardIntervalMixin,
  195. SnubaTestCase,
  196. ):
  197. rule_cls = EventUniqueUserFrequencyCondition
  198. def increment(self, event, count, environment=None, timestamp=None):
  199. timestamp = timestamp if timestamp else before_now(minutes=1)
  200. data = {"fingerprint": event.data["fingerprint"]}
  201. if environment:
  202. data["environment"] = environment
  203. for _ in range(count):
  204. event_data = deepcopy(data)
  205. event_data["user"] = {"id": uuid4().hex}
  206. self.add_event(
  207. data=event_data,
  208. project_id=self.project.id,
  209. timestamp=timestamp,
  210. )
  211. class EventFrequencyPercentConditionTestCase(SnubaTestCase):
  212. rule_cls = EventFrequencyPercentCondition
  213. def _make_sessions(self, num):
  214. received = time.time()
  215. def make_session(i):
  216. return dict(
  217. distinct_id=uuid4().hex,
  218. session_id=uuid4().hex,
  219. org_id=self.project.organization_id,
  220. project_id=self.project.id,
  221. status="ok",
  222. seq=0,
  223. release="foo@1.0.0",
  224. environment="prod",
  225. retention_days=90,
  226. duration=None,
  227. errors=0,
  228. # The line below is crucial to spread sessions throughout the time period.
  229. started=received - i,
  230. received=received,
  231. )
  232. self.bulk_store_sessions([make_session(i) for i in range(num)])
  233. def _run_test(self, minutes, data, passes, add_events=False):
  234. if not self.environment or self.environment.name != "prod":
  235. self.environment = self.create_environment(name="prod")
  236. if not hasattr(self, "test_event"):
  237. self.test_event = self.add_event(
  238. data={
  239. "fingerprint": ["something_random"],
  240. "user": {"id": uuid4().hex},
  241. "environment": self.environment.name,
  242. },
  243. project_id=self.project.id,
  244. timestamp=before_now(minutes=minutes),
  245. )
  246. if add_events:
  247. self.increment(
  248. self.test_event,
  249. max(1, int(minutes / 2)) - 1,
  250. environment=self.environment.name,
  251. timestamp=now() - timedelta(minutes=minutes),
  252. )
  253. rule = self.get_rule(data=data, rule=Rule(environment_id=None))
  254. environment_rule = self.get_rule(data=data, rule=Rule(environment_id=self.environment.id))
  255. if passes:
  256. self.assertPasses(rule, self.test_event)
  257. self.assertPasses(environment_rule, self.test_event)
  258. else:
  259. self.assertDoesNotPass(rule, self.test_event)
  260. self.assertDoesNotPass(environment_rule, self.test_event)
  261. def increment(self, event, count, environment=None, timestamp=None):
  262. data = {
  263. "fingerprint": event.data["fingerprint"],
  264. }
  265. timestamp = timestamp if timestamp else before_now(minutes=1)
  266. if environment:
  267. data["environment"] = environment
  268. for _ in range(count):
  269. event_data = deepcopy(data)
  270. event_data["user"] = {"id": uuid4().hex}
  271. self.add_event(
  272. data=event_data,
  273. project_id=self.project.id,
  274. timestamp=timestamp,
  275. )
  276. @patch("sentry.rules.conditions.event_frequency.MIN_SESSIONS_TO_FIRE", 1)
  277. def test_five_minutes_with_events(self):
  278. self._make_sessions(60)
  279. data = {"interval": "5m", "value": 39}
  280. self._run_test(data=data, minutes=5, passes=True, add_events=True)
  281. data = {"interval": "5m", "value": 41}
  282. self._run_test(data=data, minutes=5, passes=False)
  283. @patch("sentry.rules.conditions.event_frequency.MIN_SESSIONS_TO_FIRE", 1)
  284. def test_ten_minutes_with_events(self):
  285. self._make_sessions(60)
  286. data = {"interval": "10m", "value": 49}
  287. self._run_test(data=data, minutes=10, passes=True, add_events=True)
  288. data = {"interval": "10m", "value": 51}
  289. self._run_test(data=data, minutes=10, passes=False)
  290. @patch("sentry.rules.conditions.event_frequency.MIN_SESSIONS_TO_FIRE", 1)
  291. def test_thirty_minutes_with_events(self):
  292. self._make_sessions(60)
  293. data = {"interval": "30m", "value": 49}
  294. self._run_test(data=data, minutes=30, passes=True, add_events=True)
  295. data = {"interval": "30m", "value": 51}
  296. self._run_test(data=data, minutes=30, passes=False)
  297. @patch("sentry.rules.conditions.event_frequency.MIN_SESSIONS_TO_FIRE", 1)
  298. def test_one_hour_with_events(self):
  299. self._make_sessions(60)
  300. data = {"interval": "1h", "value": 49}
  301. self._run_test(data=data, minutes=60, add_events=True, passes=True)
  302. data = {"interval": "1h", "value": 51}
  303. self._run_test(data=data, minutes=60, passes=False)
  304. @patch("sentry.rules.conditions.event_frequency.MIN_SESSIONS_TO_FIRE", 1)
  305. def test_five_minutes_no_events(self):
  306. self._make_sessions(60)
  307. data = {"interval": "5m", "value": 39}
  308. self._run_test(data=data, minutes=5, passes=True, add_events=True)
  309. @patch("sentry.rules.conditions.event_frequency.MIN_SESSIONS_TO_FIRE", 1)
  310. def test_ten_minutes_no_events(self):
  311. self._make_sessions(60)
  312. data = {"interval": "10m", "value": 49}
  313. self._run_test(data=data, minutes=10, passes=True, add_events=True)
  314. @patch("sentry.rules.conditions.event_frequency.MIN_SESSIONS_TO_FIRE", 1)
  315. def test_thirty_minutes_no_events(self):
  316. self._make_sessions(60)
  317. data = {"interval": "30m", "value": 49}
  318. self._run_test(data=data, minutes=30, passes=True, add_events=True)
  319. @patch("sentry.rules.conditions.event_frequency.MIN_SESSIONS_TO_FIRE", 1)
  320. def test_one_hour_no_events(self):
  321. self._make_sessions(60)
  322. data = {"interval": "1h", "value": 49}
  323. self._run_test(data=data, minutes=60, passes=False)
  324. @patch("sentry.rules.conditions.event_frequency.MIN_SESSIONS_TO_FIRE", 1)
  325. def test_comparison(self):
  326. self._make_sessions(10)
  327. # Create sessions for previous period
  328. self._make_sessions(10)
  329. # Test data is 2 events in the current period and 1 events in the comparison period.
  330. # Number of sessions is 20 in each period, so current period is 20% of sessions, prev
  331. # is 10%. Overall a 100% increase comparitively.
  332. event = self.add_event(
  333. data={"fingerprint": ["something_random"]},
  334. project_id=self.project.id,
  335. timestamp=before_now(minutes=1),
  336. )
  337. self.increment(
  338. event,
  339. 1,
  340. timestamp=now() - timedelta(minutes=1),
  341. )
  342. self.increment(
  343. event,
  344. 1,
  345. timestamp=now() - timedelta(days=1, minutes=20),
  346. )
  347. data = {
  348. "interval": "1h",
  349. "value": 99,
  350. "comparisonType": "percent",
  351. "comparisonInterval": "1d",
  352. }
  353. rule = self.get_rule(data=data, rule=Rule(environment_id=None))
  354. self.assertPasses(rule, event)
  355. data = {
  356. "interval": "1h",
  357. "value": 101,
  358. "comparisonType": "percent",
  359. "comparisonInterval": "1d",
  360. }
  361. rule = self.get_rule(data=data, rule=Rule(environment_id=None))
  362. self.assertDoesNotPass(rule, event)
  363. @freeze_time((now() - timedelta(days=2)).replace(hour=12, minute=40, second=0, microsecond=0))
  364. @region_silo_test
  365. class ErrorIssueFrequencyConditionTestCase(
  366. EventFrequencyConditionTestCase, RuleTestCase, ErrorEventMixin
  367. ):
  368. pass
  369. @freeze_time((now() - timedelta(days=2)).replace(hour=12, minute=40, second=0, microsecond=0))
  370. @region_silo_test
  371. class PerfIssueFrequencyConditionTestCase(
  372. EventFrequencyConditionTestCase, RuleTestCase, PerfEventMixin
  373. ):
  374. pass
  375. @freeze_time((now() - timedelta(days=2)).replace(hour=12, minute=40, second=0, microsecond=0))
  376. @region_silo_test
  377. class ErrorIssueUniqueUserFrequencyConditionTestCase(
  378. EventUniqueUserFrequencyConditionTestCase, RuleTestCase, ErrorEventMixin
  379. ):
  380. pass
  381. @freeze_time((now() - timedelta(days=2)).replace(hour=12, minute=40, second=0, microsecond=0))
  382. @region_silo_test
  383. class PerfIssueUniqueUserFrequencyConditionTestCase(
  384. EventUniqueUserFrequencyConditionTestCase, RuleTestCase, PerfEventMixin
  385. ):
  386. pass
  387. @freeze_time((now() - timedelta(days=2)).replace(hour=12, minute=40, second=0, microsecond=0))
  388. @region_silo_test
  389. class ErrorIssueEventFrequencyPercentConditionTestCase(
  390. EventFrequencyPercentConditionTestCase, RuleTestCase, ErrorEventMixin
  391. ):
  392. pass
  393. @freeze_time((now() - timedelta(days=2)).replace(hour=12, minute=40, second=0, microsecond=0))
  394. @region_silo_test
  395. class PerfIssueEventFrequencyPercentConditionTestCase(
  396. EventFrequencyPercentConditionTestCase, RuleTestCase, PerfEventMixin
  397. ):
  398. pass