test_event_manager_grouping.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. from __future__ import annotations
  2. from time import time
  3. from typing import Any
  4. from unittest import mock
  5. from unittest.mock import ANY, MagicMock
  6. import pytest
  7. from sentry import audit_log
  8. from sentry.conf.server import SENTRY_GROUPING_UPDATE_MIGRATION_PHASE
  9. from sentry.event_manager import _get_updated_group_title
  10. from sentry.eventtypes.base import DefaultEvent
  11. from sentry.models.auditlogentry import AuditLogEntry
  12. from sentry.models.group import Group
  13. from sentry.models.project import Project
  14. from sentry.projectoptions.defaults import DEFAULT_GROUPING_CONFIG, LEGACY_GROUPING_CONFIG
  15. from sentry.testutils.cases import TestCase
  16. from sentry.testutils.helpers.eventprocessing import save_new_event
  17. from sentry.testutils.pytest.fixtures import django_db_all
  18. from sentry.testutils.silo import assume_test_silo_mode_of
  19. from sentry.testutils.skips import requires_snuba
  20. pytestmark = [requires_snuba]
  21. def get_relevant_metrics_calls(mock_fn: MagicMock, key: str) -> list[mock._Call]:
  22. """
  23. Given a mock metrics function, grab only the calls which record the metric with the given key.
  24. """
  25. return [call for call in mock_fn.call_args_list if call.args[0] == key]
  26. class EventManagerGroupingTest(TestCase):
  27. def test_puts_events_with_matching_fingerprints_in_same_group(self):
  28. event = save_new_event(
  29. {"message": "Dogs are great!", "fingerprint": ["maisey"]}, self.project
  30. )
  31. # Normally this should go into a different group, since the messages don't match, but the
  32. # fingerprint takes precedence.
  33. event2 = save_new_event(
  34. {"message": "Adopt don't shop", "fingerprint": ["maisey"]}, self.project
  35. )
  36. assert event.group_id == event2.group_id
  37. def test_puts_events_with_different_fingerprints_in_different_groups(self):
  38. event = save_new_event(
  39. {"message": "Dogs are great!", "fingerprint": ["maisey"]}, self.project
  40. )
  41. # Normally this should go into the same group, since the message matches, but the
  42. # fingerprint takes precedence.
  43. event2 = save_new_event(
  44. {"message": "Dogs are great!", "fingerprint": ["charlie"]}, self.project
  45. )
  46. assert event.group_id != event2.group_id
  47. def test_puts_events_with_only_partial_message_match_in_different_groups(self):
  48. # We had a regression which caused the default hash to just be 'event.message' instead of
  49. # '[event.message]' which caused it to generate a hash per letter
  50. event1 = save_new_event({"message": "Dogs are great!"}, self.project)
  51. event2 = save_new_event({"message": "Dogs are really great!"}, self.project)
  52. assert event1.group_id != event2.group_id
  53. def test_adds_default_fingerprint_if_none_in_event(self):
  54. event = save_new_event({"message": "Dogs are great!"}, self.project)
  55. assert event.data["fingerprint"] == ["{{ default }}"]
  56. def test_ignores_fingerprint_on_transaction_event(self):
  57. error_event = save_new_event(
  58. {"message": "Dogs are great!", "fingerprint": ["charlie"]}, self.project
  59. )
  60. transaction_event = save_new_event(
  61. {
  62. "transaction": "dogpark",
  63. "fingerprint": ["charlie"],
  64. "type": "transaction",
  65. "contexts": {
  66. "trace": {
  67. "parent_span_id": "1121201212312012",
  68. "op": "sniffing",
  69. "trace_id": "11212012123120120415201309082013",
  70. "span_id": "1231201211212012",
  71. }
  72. },
  73. "start_timestamp": time(),
  74. "timestamp": time(),
  75. },
  76. self.project,
  77. )
  78. # Events are assigned to different groups even though they had identical fingerprints
  79. assert error_event.group_id != transaction_event.group_id
  80. def test_none_exception(self):
  81. """Test that when the exception is None, the group is still formed."""
  82. event = save_new_event({"exception": None}, self.project)
  83. assert event.group
  84. def test_updates_group_metadata(self):
  85. event1 = save_new_event(
  86. {"message": "Dogs are great!", "fingerprint": ["maisey"]}, self.project
  87. )
  88. group = Group.objects.get(id=event1.group_id)
  89. assert group.times_seen == 1
  90. assert group.last_seen == event1.datetime
  91. assert group.message == event1.message
  92. assert group.data["metadata"]["title"] == event1.title
  93. # Normally this should go into a different group, since the messages don't match, but the
  94. # fingerprint takes precedence. (We need to make the messages different in order to show
  95. # that the group's message gets updated.)
  96. event2 = save_new_event(
  97. {"message": "Adopt don't shop", "fingerprint": ["maisey"]}, self.project
  98. )
  99. assert event1.group_id == event2.group_id
  100. group = Group.objects.get(id=event2.group_id)
  101. assert group.times_seen == 2
  102. assert group.last_seen == event2.datetime
  103. assert group.message == event2.message
  104. assert group.data["metadata"]["title"] == event2.title
  105. def test_auto_updates_grouping_config_even_if_config_is_gone(self):
  106. """This tests that setups with deprecated configs will auto-upgrade."""
  107. self.project.update_option("sentry:grouping_config", "non_existing_config")
  108. save_new_event({"message": "foo"}, self.project)
  109. assert self.project.get_option("sentry:grouping_config") == DEFAULT_GROUPING_CONFIG
  110. assert self.project.get_option("sentry:secondary_grouping_config") is None
  111. def test_auto_updates_grouping_config(self):
  112. self.project.update_option("sentry:grouping_config", LEGACY_GROUPING_CONFIG)
  113. save_new_event({"message": "Adopt don't shop"}, self.project)
  114. assert self.project.get_option("sentry:grouping_config") == DEFAULT_GROUPING_CONFIG
  115. with assume_test_silo_mode_of(AuditLogEntry):
  116. audit_log_entry = AuditLogEntry.objects.get()
  117. assert audit_log_entry.event == audit_log.get_event_id("PROJECT_EDIT")
  118. assert audit_log_entry.actor_label == "Sentry"
  119. assert audit_log_entry.data == {
  120. "sentry:grouping_config": DEFAULT_GROUPING_CONFIG,
  121. "sentry:secondary_grouping_config": LEGACY_GROUPING_CONFIG,
  122. "sentry:secondary_grouping_expiry": ANY, # tested separately below
  123. "id": self.project.id,
  124. "slug": self.project.slug,
  125. "name": self.project.name,
  126. "status": 0,
  127. "public": False,
  128. }
  129. # When the config upgrade is actually happening, the expiry value is set before the
  130. # audit log entry is created, which means the expiry is based on a timestamp
  131. # ever-so-slightly before the audit log entry's timestamp, making a one-second tolerance
  132. # necessary.
  133. actual_expiry = audit_log_entry.data["sentry:secondary_grouping_expiry"]
  134. expected_expiry = (
  135. int(audit_log_entry.datetime.timestamp()) + SENTRY_GROUPING_UPDATE_MIGRATION_PHASE
  136. )
  137. assert actual_expiry == expected_expiry or actual_expiry == expected_expiry - 1
  138. class PlaceholderTitleTest(TestCase):
  139. """
  140. Tests for a bug where error events were interpreted as default-type events and therefore all
  141. came out with a placeholder title.
  142. """
  143. def test_fixes_broken_title_data(self):
  144. # An event before the bug was introduced
  145. event1 = save_new_event(
  146. {
  147. "exception": {
  148. "values": [{"type": "DogsAreNeverAnError", "value": "Dogs are great!"}],
  149. },
  150. # Use a fingerprint to guarantee all events end up in the same group
  151. "fingerprint": ["adopt don't shop"],
  152. },
  153. self.project,
  154. )
  155. group = Group.objects.get(id=event1.group_id)
  156. assert group.title == event1.title == "DogsAreNeverAnError: Dogs are great!"
  157. assert group.data["title"] == event1.data["title"] == "DogsAreNeverAnError: Dogs are great!"
  158. assert group.data["metadata"].get("title") is event1.data["metadata"].get("title") is None
  159. assert group.message == "Dogs are great! DogsAreNeverAnError"
  160. # Simulate the bug
  161. with mock.patch(
  162. "sentry.event_manager.get_event_type",
  163. return_value=DefaultEvent(),
  164. ):
  165. # Neutralize the data fixes by making them unable to recognize a bad title and by
  166. # unconditionally using the incoming title
  167. with (
  168. mock.patch(
  169. "sentry.event_manager._is_placeholder_title",
  170. return_value=False,
  171. ),
  172. mock.patch(
  173. "sentry.event_manager._get_updated_group_title",
  174. new=lambda existing_container, incoming_container: incoming_container.get(
  175. "title"
  176. ),
  177. ),
  178. ):
  179. event2 = save_new_event(
  180. {
  181. "exception": {
  182. "values": [{"type": "DogsAreNeverAnError", "value": "Maisey is silly"}],
  183. },
  184. "fingerprint": ["adopt don't shop"],
  185. },
  186. self.project,
  187. )
  188. assert event2.group_id == event1.group_id
  189. # Pull the group again to get updated data
  190. group = Group.objects.get(id=event2.group_id)
  191. # As expected, without the fixes, the bug screws up both the event and group data. (Compare
  192. # this to the next test, where the fixes are left in place, and the group remains untouched.)
  193. assert group.title == event2.title == "<unlabeled event>"
  194. assert group.data["title"] == event2.data["title"] == "<unlabeled event>"
  195. assert (
  196. group.data["metadata"]["title"]
  197. == event2.data["metadata"]["title"]
  198. == "<unlabeled event>"
  199. )
  200. assert group.message == "<unlabeled event>"
  201. # Now that we have a group with bad data, return to the current world - where the bug has
  202. # been fixed and the data fix is also in place - and we can see that the group's data
  203. # returns to what it should be
  204. event3 = save_new_event(
  205. {
  206. "exception": {
  207. "values": [{"type": "DogsAreNeverAnError", "value": "Charlie is goofy"}],
  208. },
  209. "fingerprint": ["adopt don't shop"],
  210. },
  211. self.project,
  212. )
  213. assert event3.group_id == event2.group_id == event1.group_id
  214. # Pull the group again to get updated data
  215. group = Group.objects.get(id=event3.group_id)
  216. # Title data is updated with values from newest event, and is back to the structure it was
  217. # before the bug
  218. assert group.title == event3.title == "DogsAreNeverAnError: Charlie is goofy"
  219. assert (
  220. group.data["title"] == event3.data["title"] == "DogsAreNeverAnError: Charlie is goofy"
  221. )
  222. assert group.data["metadata"].get("title") is event3.data["metadata"].get("title") is None
  223. assert group.message == "Charlie is goofy DogsAreNeverAnError"
  224. # This is the same as the data-fixing test above, except that the fix is left in place when
  225. # the bug happens, and so the bad titles never get saved on the group
  226. def test_bug_regression_no_longer_breaks_titles(self):
  227. # An event before the bug was introduced
  228. event1 = save_new_event(
  229. {
  230. "exception": {
  231. "values": [{"type": "DogsAreNeverAnError", "value": "Dogs are great!"}],
  232. },
  233. # Use a fingerprint to guarantee all events end up in the same group
  234. "fingerprint": ["adopt don't shop"],
  235. },
  236. self.project,
  237. )
  238. group = Group.objects.get(id=event1.group_id)
  239. assert group.title == event1.title == "DogsAreNeverAnError: Dogs are great!"
  240. assert group.data["title"] == event1.data["title"] == "DogsAreNeverAnError: Dogs are great!"
  241. assert group.data["metadata"].get("title") is event1.data["metadata"].get("title") is None
  242. assert group.message == "Dogs are great! DogsAreNeverAnError"
  243. # Simulate the bug, but with the fix in place
  244. with mock.patch(
  245. "sentry.event_manager.get_event_type",
  246. return_value=DefaultEvent(),
  247. ):
  248. event2 = save_new_event(
  249. {
  250. "exception": {
  251. "values": [{"type": "DogsAreNeverAnError", "value": "Maisey is silly"}],
  252. },
  253. "fingerprint": ["adopt don't shop"],
  254. },
  255. self.project,
  256. )
  257. assert event2.group_id == event1.group_id
  258. # Pull the group again to get updated data
  259. group = Group.objects.get(id=event2.group_id)
  260. # The event may be messed up, but it didn't mess up the group
  261. assert event2.title == "<unlabeled event>"
  262. assert group.title == "DogsAreNeverAnError: Dogs are great!"
  263. assert event2.data["title"] == "<unlabeled event>"
  264. assert group.data["title"] == "DogsAreNeverAnError: Dogs are great!"
  265. assert group.data["metadata"].get("title") is None
  266. assert event2.data["metadata"]["title"] == "<unlabeled event>"
  267. assert group.message == "Dogs are great! DogsAreNeverAnError"
  268. # An event after the bug was fixed
  269. event3 = save_new_event(
  270. {
  271. "exception": {
  272. "values": [{"type": "DogsAreNeverAnError", "value": "Charlie is goofy"}],
  273. },
  274. "fingerprint": ["adopt don't shop"],
  275. },
  276. self.project,
  277. )
  278. assert event3.group_id == event2.group_id == event1.group_id
  279. # Pull the group again to get updated data
  280. group = Group.objects.get(id=event3.group_id)
  281. # Title data is updated with values from newest event
  282. assert group.title == event3.title == "DogsAreNeverAnError: Charlie is goofy"
  283. assert (
  284. group.data["title"] == event3.data["title"] == "DogsAreNeverAnError: Charlie is goofy"
  285. )
  286. assert group.data["metadata"].get("title") is event3.data["metadata"].get("title") is None
  287. assert group.message == "Charlie is goofy DogsAreNeverAnError"
  288. @django_db_all
  289. @pytest.mark.parametrize(
  290. ["existing_title", "incoming_title", "expected_title"],
  291. [
  292. ("Dogs are great!", "Adopt don't shop", "Adopt don't shop"),
  293. ("Dogs are great!", "<untitled>", "Dogs are great!"),
  294. ("Dogs are great!", None, "Dogs are great!"),
  295. ("<unlabeled event>", "Adopt don't shop", "Adopt don't shop"),
  296. ("<unlabeled event>", "<untitled>", "<untitled>"),
  297. ("<unlabeled event>", None, None),
  298. (None, "Adopt don't shop", "Adopt don't shop"),
  299. (None, "<untitled>", None),
  300. (None, None, None),
  301. ],
  302. )
  303. def test_get_updated_group_title(existing_title, incoming_title, expected_title):
  304. existing_data = {"title": existing_title} if existing_title is not None else {}
  305. incoming_data = {"title": incoming_title} if incoming_title is not None else {}
  306. assert _get_updated_group_title(existing_data, incoming_data) == expected_title
  307. class EventManagerGroupingMetricsTest(TestCase):
  308. @mock.patch("sentry.event_manager.metrics.incr")
  309. def test_records_avg_calculations_per_event_metrics(self, mock_metrics_incr: MagicMock):
  310. project = self.project
  311. cases: list[Any] = [
  312. ["Dogs are great!", LEGACY_GROUPING_CONFIG, None, None, 1],
  313. ["Adopt don't shop", DEFAULT_GROUPING_CONFIG, LEGACY_GROUPING_CONFIG, time() + 3600, 2],
  314. ]
  315. for (
  316. message,
  317. primary_config,
  318. secondary_config,
  319. transition_expiry,
  320. expected_total_calcs,
  321. ) in cases:
  322. mock_metrics_incr.reset_mock()
  323. project.update_option("sentry:grouping_config", primary_config)
  324. project.update_option("sentry:secondary_grouping_config", secondary_config)
  325. project.update_option("sentry:secondary_grouping_expiry", transition_expiry)
  326. save_new_event({"message": message}, self.project)
  327. total_calculations_calls = get_relevant_metrics_calls(
  328. mock_metrics_incr, "grouping.total_calculations"
  329. )
  330. assert len(total_calculations_calls) == 1
  331. assert total_calculations_calls[0].kwargs["amount"] == expected_total_calcs
  332. assert set(total_calculations_calls[0].kwargs["tags"].keys()) == {
  333. "in_transition",
  334. "result",
  335. }
  336. event_hashes_calculated_calls = get_relevant_metrics_calls(
  337. mock_metrics_incr, "grouping.event_hashes_calculated"
  338. )
  339. assert len(event_hashes_calculated_calls) == 1
  340. assert set(event_hashes_calculated_calls[0].kwargs["tags"].keys()) == {
  341. "in_transition",
  342. "result",
  343. }
  344. @mock.patch("sentry.event_manager.metrics.incr")
  345. def test_adds_correct_tags_to_avg_calculations_per_event_metrics(
  346. self, mock_metrics_incr: MagicMock
  347. ):
  348. project = self.project
  349. in_transition_cases: list[Any] = [
  350. [LEGACY_GROUPING_CONFIG, None, None, "False"], # Not in transition
  351. [
  352. DEFAULT_GROUPING_CONFIG,
  353. LEGACY_GROUPING_CONFIG,
  354. time() + 3600,
  355. "True",
  356. ], # In transition
  357. ]
  358. for (
  359. primary_config,
  360. secondary_config,
  361. transition_expiry,
  362. expected_in_transition,
  363. ) in in_transition_cases:
  364. mock_metrics_incr.reset_mock()
  365. project.update_option("sentry:grouping_config", primary_config)
  366. project.update_option("sentry:secondary_grouping_config", secondary_config)
  367. project.update_option("sentry:secondary_grouping_expiry", transition_expiry)
  368. save_new_event({"message": "Dogs are great!"}, self.project)
  369. # Both metrics get the same tags, so we can check either one
  370. total_calculations_calls = get_relevant_metrics_calls(
  371. mock_metrics_incr, "grouping.total_calculations"
  372. )
  373. metric_tags = total_calculations_calls[0].kwargs["tags"]
  374. assert len(total_calculations_calls) == 1
  375. # The `result` tag is tested in `test_assign_to_group.py`
  376. assert metric_tags["in_transition"] == expected_in_transition
  377. @django_db_all
  378. @pytest.mark.parametrize(
  379. ["primary_hashes", "secondary_hashes", "expected_tag"],
  380. [
  381. (["maisey"], ["maisey"], "no change"),
  382. (["maisey"], ["charlie"], "full change"),
  383. (["maisey", "charlie"], ["maisey", "charlie"], "no change"),
  384. (["maisey", "charlie"], ["cory", "charlie"], "partial change"),
  385. (["maisey", "charlie"], ["cory", "bodhi"], "full change"),
  386. ],
  387. )
  388. @mock.patch("sentry.event_manager.metrics.incr")
  389. def test_records_hash_comparison_metric(
  390. mock_metrics_incr: MagicMock,
  391. primary_hashes: list[str],
  392. secondary_hashes: list[str],
  393. expected_tag: str,
  394. default_project: Project,
  395. ):
  396. project = default_project
  397. project.update_option("sentry:grouping_config", DEFAULT_GROUPING_CONFIG)
  398. project.update_option("sentry:secondary_grouping_config", LEGACY_GROUPING_CONFIG)
  399. project.update_option("sentry:secondary_grouping_expiry", time() + 3600)
  400. with mock.patch(
  401. "sentry.grouping.ingest.hashing._calculate_primary_hashes_and_variants",
  402. return_value=(primary_hashes, {}),
  403. ):
  404. with mock.patch(
  405. "sentry.grouping.ingest.hashing._calculate_secondary_hashes",
  406. return_value=secondary_hashes,
  407. ):
  408. save_new_event({"message": "Dogs are great!"}, project)
  409. hash_comparison_calls = get_relevant_metrics_calls(
  410. mock_metrics_incr, "grouping.hash_comparison"
  411. )
  412. assert len(hash_comparison_calls) == 1
  413. assert hash_comparison_calls[0].kwargs["tags"]["result"] == expected_tag