test_group.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. from datetime import timedelta
  2. from unittest.mock import patch
  3. import pytz
  4. from django.utils import timezone
  5. from sentry.api.event_search import SearchFilter, SearchKey, SearchValue
  6. from sentry.api.serializers import serialize
  7. from sentry.api.serializers.models.group import GroupSerializerSnuba
  8. from sentry.issues.grouptype import (
  9. PerformanceRenderBlockingAssetSpanGroupType,
  10. ProfileFileIOGroupType,
  11. )
  12. from sentry.models import (
  13. Group,
  14. GroupEnvironment,
  15. GroupLink,
  16. GroupResolution,
  17. GroupSnooze,
  18. GroupStatus,
  19. GroupSubscription,
  20. NotificationSetting,
  21. UserOption,
  22. )
  23. from sentry.notifications.types import NotificationSettingOptionValues, NotificationSettingTypes
  24. from sentry.services.hybrid_cloud.actor import RpcActor
  25. from sentry.testutils import APITestCase, SnubaTestCase
  26. from sentry.testutils.helpers.datetime import before_now, iso_format
  27. from sentry.testutils.performance_issues.store_transaction import PerfIssueTransactionTestMixin
  28. from sentry.testutils.silo import exempt_from_silo_limits, region_silo_test
  29. from sentry.types.integrations import ExternalProviders
  30. from tests.sentry.issues.test_utils import SearchIssueTestMixin
  31. @region_silo_test(stable=True)
  32. class GroupSerializerSnubaTest(APITestCase, SnubaTestCase):
  33. def setUp(self):
  34. super().setUp()
  35. self.min_ago = before_now(minutes=1)
  36. self.day_ago = before_now(days=1)
  37. self.week_ago = before_now(days=7)
  38. def test_permalink(self):
  39. group = self.create_group()
  40. result = serialize(group, self.user, serializer=GroupSerializerSnuba())
  41. assert "http://" in result["permalink"]
  42. assert f"{group.organization.slug}/issues/{group.id}" in result["permalink"]
  43. def test_permalink_outside_org(self):
  44. outside_user = self.create_user()
  45. group = self.create_group()
  46. result = serialize(group, outside_user, serializer=GroupSerializerSnuba())
  47. assert result["permalink"] is None
  48. def test_is_ignored_with_expired_snooze(self):
  49. now = timezone.now()
  50. user = self.create_user()
  51. group = self.create_group(status=GroupStatus.IGNORED)
  52. GroupSnooze.objects.create(group=group, until=now - timedelta(minutes=1))
  53. result = serialize(group, user, serializer=GroupSerializerSnuba())
  54. assert result["status"] == "unresolved"
  55. assert result["statusDetails"] == {}
  56. def test_is_ignored_with_valid_snooze(self):
  57. now = timezone.now()
  58. user = self.create_user()
  59. group = self.create_group(status=GroupStatus.IGNORED)
  60. snooze = GroupSnooze.objects.create(group=group, until=now + timedelta(minutes=1))
  61. result = serialize(group, user, serializer=GroupSerializerSnuba())
  62. assert result["status"] == "ignored"
  63. assert result["statusDetails"]["ignoreCount"] == snooze.count
  64. assert result["statusDetails"]["ignoreWindow"] == snooze.window
  65. assert result["statusDetails"]["ignoreUserCount"] == snooze.user_count
  66. assert result["statusDetails"]["ignoreUserWindow"] == snooze.user_window
  67. assert result["statusDetails"]["ignoreUntil"] == snooze.until
  68. assert result["statusDetails"]["actor"] is None
  69. def test_is_ignored_with_valid_snooze_and_actor(self):
  70. now = timezone.now()
  71. user = self.create_user()
  72. group = self.create_group(status=GroupStatus.IGNORED)
  73. GroupSnooze.objects.create(group=group, until=now + timedelta(minutes=1), actor_id=user.id)
  74. result = serialize(group, user, serializer=GroupSerializerSnuba())
  75. assert result["status"] == "ignored"
  76. assert result["statusDetails"]["actor"]["id"] == str(user.id)
  77. def test_resolved_in_next_release(self):
  78. release = self.create_release(project=self.project, version="a")
  79. user = self.create_user()
  80. group = self.create_group(status=GroupStatus.RESOLVED)
  81. GroupResolution.objects.create(
  82. group=group, release=release, type=GroupResolution.Type.in_next_release
  83. )
  84. result = serialize(group, user, serializer=GroupSerializerSnuba())
  85. assert result["status"] == "resolved"
  86. assert result["statusDetails"] == {"inNextRelease": True, "actor": None}
  87. def test_resolved_in_release(self):
  88. release = self.create_release(project=self.project, version="a")
  89. user = self.create_user()
  90. group = self.create_group(status=GroupStatus.RESOLVED)
  91. GroupResolution.objects.create(
  92. group=group, release=release, type=GroupResolution.Type.in_release
  93. )
  94. result = serialize(group, user, serializer=GroupSerializerSnuba())
  95. assert result["status"] == "resolved"
  96. assert result["statusDetails"] == {"inRelease": "a", "actor": None}
  97. def test_resolved_with_actor(self):
  98. release = self.create_release(project=self.project, version="a")
  99. user = self.create_user()
  100. group = self.create_group(status=GroupStatus.RESOLVED)
  101. GroupResolution.objects.create(
  102. group=group, release=release, type=GroupResolution.Type.in_release, actor_id=user.id
  103. )
  104. result = serialize(group, user, serializer=GroupSerializerSnuba())
  105. assert result["status"] == "resolved"
  106. assert result["statusDetails"]["actor"]["id"] == str(user.id)
  107. def test_resolved_in_commit(self):
  108. repo = self.create_repo(project=self.project)
  109. commit = self.create_commit(repo=repo)
  110. user = self.create_user()
  111. group = self.create_group(status=GroupStatus.RESOLVED)
  112. GroupLink.objects.create(
  113. group_id=group.id,
  114. project_id=group.project_id,
  115. linked_id=commit.id,
  116. linked_type=GroupLink.LinkedType.commit,
  117. relationship=GroupLink.Relationship.resolves,
  118. )
  119. result = serialize(group, user, serializer=GroupSerializerSnuba())
  120. assert result["status"] == "resolved"
  121. assert result["statusDetails"]["inCommit"]["id"] == commit.key
  122. @patch("sentry.analytics.record")
  123. @patch("sentry.models.Group.is_over_resolve_age")
  124. def test_auto_resolved(self, mock_is_over_resolve_age, mock_record):
  125. mock_is_over_resolve_age.return_value = True
  126. user = self.create_user()
  127. group = self.create_group(status=GroupStatus.UNRESOLVED)
  128. result = serialize(group, user, serializer=GroupSerializerSnuba())
  129. assert result["status"] == "resolved"
  130. assert result["statusDetails"] == {"autoResolved": True}
  131. mock_record.assert_called_with(
  132. "issue.resolved",
  133. default_user_id=self.project.organization.get_default_owner().id,
  134. project_id=self.project.id,
  135. organization_id=self.project.organization_id,
  136. group_id=group.id,
  137. resolution_type="automatic",
  138. issue_type="error",
  139. issue_category="error",
  140. )
  141. def test_subscribed(self):
  142. user = self.create_user()
  143. group = self.create_group()
  144. GroupSubscription.objects.create(
  145. user_id=user.id, group=group, project=group.project, is_active=True
  146. )
  147. result = serialize(group, user, serializer=GroupSerializerSnuba())
  148. assert result["isSubscribed"]
  149. assert result["subscriptionDetails"] == {"reason": "unknown"}
  150. def test_explicit_unsubscribed(self):
  151. user = self.create_user()
  152. group = self.create_group()
  153. GroupSubscription.objects.create(
  154. user_id=user.id, group=group, project=group.project, is_active=False
  155. )
  156. result = serialize(group, user, serializer=GroupSerializerSnuba())
  157. assert not result["isSubscribed"]
  158. assert not result["subscriptionDetails"]
  159. def test_implicit_subscribed(self):
  160. user = self.create_user()
  161. group = self.create_group()
  162. combinations = (
  163. # (default, project, subscribed, has_details)
  164. (
  165. NotificationSettingOptionValues.ALWAYS,
  166. NotificationSettingOptionValues.DEFAULT,
  167. True,
  168. False,
  169. ),
  170. (
  171. NotificationSettingOptionValues.ALWAYS,
  172. NotificationSettingOptionValues.ALWAYS,
  173. True,
  174. False,
  175. ),
  176. (
  177. NotificationSettingOptionValues.ALWAYS,
  178. NotificationSettingOptionValues.SUBSCRIBE_ONLY,
  179. False,
  180. False,
  181. ),
  182. (
  183. NotificationSettingOptionValues.ALWAYS,
  184. NotificationSettingOptionValues.NEVER,
  185. False,
  186. True,
  187. ),
  188. (
  189. NotificationSettingOptionValues.DEFAULT,
  190. NotificationSettingOptionValues.DEFAULT,
  191. False,
  192. False,
  193. ),
  194. (
  195. NotificationSettingOptionValues.SUBSCRIBE_ONLY,
  196. NotificationSettingOptionValues.DEFAULT,
  197. False,
  198. False,
  199. ),
  200. (
  201. NotificationSettingOptionValues.SUBSCRIBE_ONLY,
  202. NotificationSettingOptionValues.ALWAYS,
  203. True,
  204. False,
  205. ),
  206. (
  207. NotificationSettingOptionValues.SUBSCRIBE_ONLY,
  208. NotificationSettingOptionValues.SUBSCRIBE_ONLY,
  209. False,
  210. False,
  211. ),
  212. (
  213. NotificationSettingOptionValues.SUBSCRIBE_ONLY,
  214. NotificationSettingOptionValues.NEVER,
  215. False,
  216. True,
  217. ),
  218. (
  219. NotificationSettingOptionValues.NEVER,
  220. NotificationSettingOptionValues.DEFAULT,
  221. False,
  222. True,
  223. ),
  224. (
  225. NotificationSettingOptionValues.NEVER,
  226. NotificationSettingOptionValues.ALWAYS,
  227. True,
  228. False,
  229. ),
  230. (
  231. NotificationSettingOptionValues.NEVER,
  232. NotificationSettingOptionValues.SUBSCRIBE_ONLY,
  233. False,
  234. False,
  235. ),
  236. (
  237. NotificationSettingOptionValues.NEVER,
  238. NotificationSettingOptionValues.NEVER,
  239. False,
  240. True,
  241. ),
  242. )
  243. for default_value, project_value, is_subscribed, has_details in combinations:
  244. with exempt_from_silo_limits():
  245. UserOption.objects.clear_local_cache()
  246. NotificationSetting.objects.update_settings(
  247. ExternalProviders.EMAIL,
  248. NotificationSettingTypes.WORKFLOW,
  249. default_value,
  250. actor=RpcActor.from_orm_user(user),
  251. )
  252. NotificationSetting.objects.update_settings(
  253. ExternalProviders.EMAIL,
  254. NotificationSettingTypes.WORKFLOW,
  255. project_value,
  256. actor=RpcActor.from_orm_user(user),
  257. project=group.project.id,
  258. )
  259. NotificationSetting.objects.update_settings(
  260. ExternalProviders.SLACK,
  261. NotificationSettingTypes.WORKFLOW,
  262. default_value,
  263. actor=RpcActor.from_orm_user(user),
  264. )
  265. NotificationSetting.objects.update_settings(
  266. ExternalProviders.SLACK,
  267. NotificationSettingTypes.WORKFLOW,
  268. project_value,
  269. actor=RpcActor.from_orm_user(user),
  270. project=group.project.id,
  271. )
  272. result = serialize(group, user, serializer=GroupSerializerSnuba())
  273. subscription_details = result.get("subscriptionDetails")
  274. assert result["isSubscribed"] is is_subscribed
  275. assert (
  276. subscription_details == {"disabled": True}
  277. if has_details
  278. else subscription_details is None
  279. )
  280. def test_global_no_conversations_overrides_group_subscription(self):
  281. user = self.create_user()
  282. group = self.create_group()
  283. GroupSubscription.objects.create(
  284. user_id=user.id, group=group, project=group.project, is_active=True
  285. )
  286. with exempt_from_silo_limits():
  287. for provider in [ExternalProviders.EMAIL, ExternalProviders.SLACK]:
  288. NotificationSetting.objects.update_settings(
  289. provider,
  290. NotificationSettingTypes.WORKFLOW,
  291. NotificationSettingOptionValues.NEVER,
  292. actor=RpcActor.from_orm_user(user),
  293. )
  294. result = serialize(group, user, serializer=GroupSerializerSnuba())
  295. assert not result["isSubscribed"]
  296. assert result["subscriptionDetails"] == {"disabled": True}
  297. def test_project_no_conversations_overrides_group_subscription(self):
  298. user = self.create_user()
  299. group = self.create_group()
  300. GroupSubscription.objects.create(
  301. user_id=user.id, group=group, project=group.project, is_active=True
  302. )
  303. for provider in [ExternalProviders.EMAIL, ExternalProviders.SLACK]:
  304. with exempt_from_silo_limits():
  305. NotificationSetting.objects.update_settings(
  306. provider,
  307. NotificationSettingTypes.WORKFLOW,
  308. NotificationSettingOptionValues.NEVER,
  309. actor=RpcActor.from_orm_user(user),
  310. project=group.project.id,
  311. )
  312. result = serialize(group, user, serializer=GroupSerializerSnuba())
  313. assert not result["isSubscribed"]
  314. assert result["subscriptionDetails"] == {"disabled": True}
  315. def test_no_user_unsubscribed(self):
  316. group = self.create_group()
  317. result = serialize(group, serializer=GroupSerializerSnuba())
  318. assert not result["isSubscribed"]
  319. def test_seen_stats(self):
  320. environment = self.create_environment(project=self.project)
  321. environment2 = self.create_environment(project=self.project)
  322. events = []
  323. for event_id, env, user_id, timestamp in [
  324. ("a" * 32, environment, 1, iso_format(self.min_ago)),
  325. ("b" * 32, environment, 2, iso_format(self.min_ago)),
  326. ("c" * 32, environment2, 3, iso_format(self.week_ago)),
  327. ]:
  328. events.append(
  329. self.store_event(
  330. data={
  331. "event_id": event_id,
  332. "fingerprint": ["put-me-in-group1"],
  333. "timestamp": timestamp,
  334. "environment": env.name,
  335. "user": {"id": user_id},
  336. },
  337. project_id=self.project.id,
  338. )
  339. )
  340. # Assert all events are in the same group
  341. (group_id,) = {e.group.id for e in events}
  342. group = Group.objects.get(id=group_id)
  343. group.times_seen = 3
  344. group.first_seen = self.week_ago - timedelta(days=5)
  345. group.last_seen = self.week_ago
  346. group.save()
  347. # should use group columns when no environments arg passed
  348. result = serialize(group, serializer=GroupSerializerSnuba(environment_ids=[]))
  349. assert result["count"] == "3"
  350. assert iso_format(result["lastSeen"]) == iso_format(self.min_ago)
  351. assert result["firstSeen"] == group.first_seen
  352. # update this to something different to make sure it's being used
  353. group_env = GroupEnvironment.objects.get(group_id=group_id, environment_id=environment.id)
  354. group_env.first_seen = self.day_ago - timedelta(days=3)
  355. group_env.save()
  356. group_env2 = GroupEnvironment.objects.get(group_id=group_id, environment_id=environment2.id)
  357. result = serialize(
  358. group,
  359. serializer=GroupSerializerSnuba(environment_ids=[environment.id, environment2.id]),
  360. )
  361. assert result["count"] == "3"
  362. # result is rounded down to nearest second
  363. assert iso_format(result["lastSeen"]) == iso_format(self.min_ago)
  364. assert iso_format(result["firstSeen"]) == iso_format(group_env.first_seen)
  365. assert iso_format(group_env2.first_seen) > iso_format(group_env.first_seen)
  366. assert result["userCount"] == 3
  367. result = serialize(
  368. group,
  369. serializer=GroupSerializerSnuba(
  370. environment_ids=[environment.id, environment2.id],
  371. start=self.week_ago - timedelta(hours=1),
  372. end=self.week_ago + timedelta(hours=1),
  373. ),
  374. )
  375. assert result["userCount"] == 1
  376. assert iso_format(result["lastSeen"]) == iso_format(self.week_ago)
  377. assert iso_format(result["firstSeen"]) == iso_format(self.week_ago)
  378. assert result["count"] == "1"
  379. def test_get_start_from_seen_stats(self):
  380. for days, expected in [(None, 30), (0, 14), (1000, 90)]:
  381. last_seen = None if days is None else before_now(days=days).replace(tzinfo=pytz.UTC)
  382. start = GroupSerializerSnuba._get_start_from_seen_stats({"": {"last_seen": last_seen}})
  383. assert iso_format(start) == iso_format(before_now(days=expected))
  384. def test_skipped_date_timestamp_filters(self):
  385. group = self.create_group()
  386. serializer = GroupSerializerSnuba(
  387. search_filters=[
  388. SearchFilter(
  389. SearchKey("timestamp"),
  390. ">",
  391. SearchValue(before_now(hours=1).replace(tzinfo=pytz.UTC)),
  392. ),
  393. SearchFilter(
  394. SearchKey("timestamp"),
  395. "<",
  396. SearchValue(before_now(seconds=1).replace(tzinfo=pytz.UTC)),
  397. ),
  398. SearchFilter(
  399. SearchKey("date"),
  400. ">",
  401. SearchValue(before_now(hours=1).replace(tzinfo=pytz.UTC)),
  402. ),
  403. SearchFilter(
  404. SearchKey("date"),
  405. "<",
  406. SearchValue(before_now(seconds=1).replace(tzinfo=pytz.UTC)),
  407. ),
  408. ]
  409. )
  410. assert not serializer.conditions
  411. result = serialize(group, self.user, serializer=serializer)
  412. assert result["id"] == str(group.id)
  413. @region_silo_test
  414. class PerformanceGroupSerializerSnubaTest(
  415. APITestCase,
  416. SnubaTestCase,
  417. PerfIssueTransactionTestMixin,
  418. ):
  419. def test_perf_seen_stats(self):
  420. proj = self.create_project()
  421. environment = self.create_environment(project=proj)
  422. first_group_fingerprint = f"{PerformanceRenderBlockingAssetSpanGroupType.type_id}-group1"
  423. timestamp = timezone.now() - timedelta(days=5)
  424. times = 5
  425. with self.options({"performance.issues.send_to_issues_platform": True}):
  426. for _ in range(0, times):
  427. self.store_transaction(
  428. proj.id,
  429. "user1",
  430. [first_group_fingerprint],
  431. environment.name,
  432. timestamp=timestamp + timedelta(minutes=1),
  433. )
  434. event = self.store_transaction(
  435. proj.id,
  436. "user2",
  437. [first_group_fingerprint],
  438. environment.name,
  439. timestamp=timestamp + timedelta(minutes=2),
  440. )
  441. first_group = event.groups[0]
  442. result = serialize(
  443. first_group,
  444. serializer=GroupSerializerSnuba(
  445. environment_ids=[environment.id],
  446. start=timestamp - timedelta(hours=1),
  447. end=timestamp + timedelta(hours=1),
  448. ),
  449. )
  450. assert result["userCount"] == 2
  451. assert iso_format(result["lastSeen"]) == iso_format(timestamp + timedelta(minutes=2))
  452. assert iso_format(result["firstSeen"]) == iso_format(timestamp + timedelta(minutes=1))
  453. assert result["count"] == str(times + 1)
  454. with self.feature("organizations:issue-platform-search-perf-issues"):
  455. result = serialize(
  456. first_group,
  457. serializer=GroupSerializerSnuba(
  458. environment_ids=[environment.id],
  459. start=timestamp - timedelta(hours=1),
  460. end=timestamp + timedelta(hours=1),
  461. ),
  462. )
  463. assert result["userCount"] == 2
  464. assert iso_format(result["lastSeen"]) == iso_format(timestamp + timedelta(minutes=2))
  465. assert iso_format(result["firstSeen"]) == iso_format(timestamp + timedelta(minutes=1))
  466. assert result["count"] == str(times + 1)
  467. @region_silo_test
  468. class ProfilingGroupSerializerSnubaTest(
  469. APITestCase,
  470. SnubaTestCase,
  471. SearchIssueTestMixin,
  472. ):
  473. def test_profiling_seen_stats(self):
  474. proj = self.create_project()
  475. environment = self.create_environment(project=proj)
  476. first_group_fingerprint = f"{ProfileFileIOGroupType.type_id}-group1"
  477. timestamp = (timezone.now() - timedelta(days=5)).replace(hour=0, minute=0, second=0)
  478. times = 5
  479. for incr in range(0, times):
  480. # for user_0 - user_4, first_group
  481. self.store_search_issue(
  482. proj.id,
  483. incr,
  484. [first_group_fingerprint],
  485. environment.name,
  486. timestamp + timedelta(minutes=incr),
  487. )
  488. # user_5, another_group
  489. event, issue_occurrence, group_info = self.store_search_issue(
  490. proj.id,
  491. 5,
  492. [first_group_fingerprint],
  493. environment.name,
  494. timestamp + timedelta(minutes=5),
  495. )
  496. first_group = group_info.group
  497. result = serialize(
  498. first_group,
  499. serializer=GroupSerializerSnuba(
  500. environment_ids=[environment.id],
  501. start=timestamp - timedelta(days=1),
  502. end=timestamp + timedelta(days=1),
  503. ),
  504. )
  505. assert result["userCount"] == 6
  506. assert iso_format(result["lastSeen"]) == iso_format(timestamp + timedelta(minutes=5))
  507. assert iso_format(result["firstSeen"]) == iso_format(timestamp)
  508. assert result["count"] == str(times + 1)