test_group.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. import time
  2. from datetime import timedelta
  3. import pytz
  4. from django.utils import timezone
  5. from sentry.api.serializers import serialize
  6. from sentry.api.serializers.models.group import (
  7. GroupSerializerSnuba,
  8. StreamGroupSerializerSnuba,
  9. snuba_tsdb,
  10. )
  11. from sentry.models import (
  12. Environment,
  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.testutils import APITestCase, SnubaTestCase
  25. from sentry.testutils.helpers.datetime import before_now, iso_format
  26. from sentry.types.integrations import ExternalProviders
  27. from sentry.utils.cache import cache
  28. from sentry.utils.compat import mock
  29. from sentry.utils.compat.mock import patch
  30. class GroupSerializerSnubaTest(APITestCase, SnubaTestCase):
  31. def setUp(self):
  32. super().setUp()
  33. self.min_ago = before_now(minutes=1)
  34. self.day_ago = before_now(days=1)
  35. self.week_ago = before_now(days=7)
  36. def test_permalink(self):
  37. group = self.create_group()
  38. result = serialize(group, self.user, serializer=GroupSerializerSnuba())
  39. assert "http://" in result["permalink"]
  40. assert f"{group.organization.slug}/issues/{group.id}" in result["permalink"]
  41. def test_permalink_outside_org(self):
  42. outside_user = self.create_user()
  43. group = self.create_group()
  44. result = serialize(group, outside_user, serializer=GroupSerializerSnuba())
  45. assert result["permalink"] is None
  46. def test_is_ignored_with_expired_snooze(self):
  47. now = timezone.now()
  48. user = self.create_user()
  49. group = self.create_group(status=GroupStatus.IGNORED)
  50. GroupSnooze.objects.create(group=group, until=now - timedelta(minutes=1))
  51. result = serialize(group, user, serializer=GroupSerializerSnuba())
  52. assert result["status"] == "unresolved"
  53. assert result["statusDetails"] == {}
  54. def test_is_ignored_with_valid_snooze(self):
  55. now = timezone.now()
  56. user = self.create_user()
  57. group = self.create_group(status=GroupStatus.IGNORED)
  58. snooze = GroupSnooze.objects.create(group=group, until=now + timedelta(minutes=1))
  59. result = serialize(group, user, serializer=GroupSerializerSnuba())
  60. assert result["status"] == "ignored"
  61. assert result["statusDetails"]["ignoreCount"] == snooze.count
  62. assert result["statusDetails"]["ignoreWindow"] == snooze.window
  63. assert result["statusDetails"]["ignoreUserCount"] == snooze.user_count
  64. assert result["statusDetails"]["ignoreUserWindow"] == snooze.user_window
  65. assert result["statusDetails"]["ignoreUntil"] == snooze.until
  66. assert result["statusDetails"]["actor"] is None
  67. def test_is_ignored_with_valid_snooze_and_actor(self):
  68. now = timezone.now()
  69. user = self.create_user()
  70. group = self.create_group(status=GroupStatus.IGNORED)
  71. GroupSnooze.objects.create(group=group, until=now + timedelta(minutes=1), actor_id=user.id)
  72. result = serialize(group, user, serializer=GroupSerializerSnuba())
  73. assert result["status"] == "ignored"
  74. assert result["statusDetails"]["actor"]["id"] == str(user.id)
  75. def test_resolved_in_next_release(self):
  76. release = self.create_release(project=self.project, version="a")
  77. user = self.create_user()
  78. group = self.create_group(status=GroupStatus.RESOLVED)
  79. GroupResolution.objects.create(
  80. group=group, release=release, type=GroupResolution.Type.in_next_release
  81. )
  82. result = serialize(group, user, serializer=GroupSerializerSnuba())
  83. assert result["status"] == "resolved"
  84. assert result["statusDetails"] == {"inNextRelease": True, "actor": None}
  85. def test_resolved_in_release(self):
  86. release = self.create_release(project=self.project, version="a")
  87. user = self.create_user()
  88. group = self.create_group(status=GroupStatus.RESOLVED)
  89. GroupResolution.objects.create(
  90. group=group, release=release, type=GroupResolution.Type.in_release
  91. )
  92. result = serialize(group, user, serializer=GroupSerializerSnuba())
  93. assert result["status"] == "resolved"
  94. assert result["statusDetails"] == {"inRelease": "a", "actor": None}
  95. def test_resolved_with_actor(self):
  96. release = self.create_release(project=self.project, version="a")
  97. user = self.create_user()
  98. group = self.create_group(status=GroupStatus.RESOLVED)
  99. GroupResolution.objects.create(
  100. group=group, release=release, type=GroupResolution.Type.in_release, actor_id=user.id
  101. )
  102. result = serialize(group, user, serializer=GroupSerializerSnuba())
  103. assert result["status"] == "resolved"
  104. assert result["statusDetails"]["actor"]["id"] == str(user.id)
  105. def test_resolved_in_commit(self):
  106. repo = self.create_repo(project=self.project)
  107. commit = self.create_commit(repo=repo)
  108. user = self.create_user()
  109. group = self.create_group(status=GroupStatus.RESOLVED)
  110. GroupLink.objects.create(
  111. group_id=group.id,
  112. project_id=group.project_id,
  113. linked_id=commit.id,
  114. linked_type=GroupLink.LinkedType.commit,
  115. relationship=GroupLink.Relationship.resolves,
  116. )
  117. result = serialize(group, user, serializer=GroupSerializerSnuba())
  118. assert result["status"] == "resolved"
  119. assert result["statusDetails"]["inCommit"]["id"] == commit.key
  120. @patch("sentry.models.Group.is_over_resolve_age")
  121. def test_auto_resolved(self, mock_is_over_resolve_age):
  122. mock_is_over_resolve_age.return_value = True
  123. user = self.create_user()
  124. group = self.create_group(status=GroupStatus.UNRESOLVED)
  125. result = serialize(group, user, serializer=GroupSerializerSnuba())
  126. assert result["status"] == "resolved"
  127. assert result["statusDetails"] == {"autoResolved": True}
  128. def test_subscribed(self):
  129. user = self.create_user()
  130. group = self.create_group()
  131. GroupSubscription.objects.create(
  132. user=user, group=group, project=group.project, is_active=True
  133. )
  134. result = serialize(group, user, serializer=GroupSerializerSnuba())
  135. assert result["isSubscribed"]
  136. assert result["subscriptionDetails"] == {"reason": "unknown"}
  137. def test_explicit_unsubscribed(self):
  138. user = self.create_user()
  139. group = self.create_group()
  140. GroupSubscription.objects.create(
  141. user=user, group=group, project=group.project, is_active=False
  142. )
  143. result = serialize(group, user, serializer=GroupSerializerSnuba())
  144. assert not result["isSubscribed"]
  145. assert not result["subscriptionDetails"]
  146. def test_implicit_subscribed(self):
  147. user = self.create_user()
  148. group = self.create_group()
  149. combinations = (
  150. # (default, project, subscribed, has_details)
  151. (
  152. NotificationSettingOptionValues.ALWAYS,
  153. NotificationSettingOptionValues.DEFAULT,
  154. True,
  155. False,
  156. ),
  157. (
  158. NotificationSettingOptionValues.ALWAYS,
  159. NotificationSettingOptionValues.ALWAYS,
  160. True,
  161. False,
  162. ),
  163. (
  164. NotificationSettingOptionValues.ALWAYS,
  165. NotificationSettingOptionValues.SUBSCRIBE_ONLY,
  166. False,
  167. False,
  168. ),
  169. (
  170. NotificationSettingOptionValues.ALWAYS,
  171. NotificationSettingOptionValues.NEVER,
  172. False,
  173. True,
  174. ),
  175. (
  176. NotificationSettingOptionValues.DEFAULT,
  177. NotificationSettingOptionValues.DEFAULT,
  178. False,
  179. False,
  180. ),
  181. (
  182. NotificationSettingOptionValues.SUBSCRIBE_ONLY,
  183. NotificationSettingOptionValues.DEFAULT,
  184. False,
  185. False,
  186. ),
  187. (
  188. NotificationSettingOptionValues.SUBSCRIBE_ONLY,
  189. NotificationSettingOptionValues.ALWAYS,
  190. True,
  191. False,
  192. ),
  193. (
  194. NotificationSettingOptionValues.SUBSCRIBE_ONLY,
  195. NotificationSettingOptionValues.SUBSCRIBE_ONLY,
  196. False,
  197. False,
  198. ),
  199. (
  200. NotificationSettingOptionValues.SUBSCRIBE_ONLY,
  201. NotificationSettingOptionValues.NEVER,
  202. False,
  203. True,
  204. ),
  205. (
  206. NotificationSettingOptionValues.NEVER,
  207. NotificationSettingOptionValues.DEFAULT,
  208. False,
  209. True,
  210. ),
  211. (
  212. NotificationSettingOptionValues.NEVER,
  213. NotificationSettingOptionValues.ALWAYS,
  214. True,
  215. False,
  216. ),
  217. (
  218. NotificationSettingOptionValues.NEVER,
  219. NotificationSettingOptionValues.SUBSCRIBE_ONLY,
  220. False,
  221. False,
  222. ),
  223. (
  224. NotificationSettingOptionValues.NEVER,
  225. NotificationSettingOptionValues.NEVER,
  226. False,
  227. True,
  228. ),
  229. )
  230. for default_value, project_value, is_subscribed, has_details in combinations:
  231. UserOption.objects.clear_local_cache()
  232. NotificationSetting.objects.update_settings(
  233. ExternalProviders.EMAIL,
  234. NotificationSettingTypes.WORKFLOW,
  235. default_value,
  236. user=user,
  237. )
  238. NotificationSetting.objects.update_settings(
  239. ExternalProviders.EMAIL,
  240. NotificationSettingTypes.WORKFLOW,
  241. project_value,
  242. user=user,
  243. project=group.project,
  244. )
  245. result = serialize(group, user, serializer=GroupSerializerSnuba())
  246. subscription_details = result.get("subscriptionDetails")
  247. assert result["isSubscribed"] is is_subscribed
  248. assert (
  249. subscription_details == {"disabled": True}
  250. if has_details
  251. else subscription_details is None
  252. )
  253. def test_global_no_conversations_overrides_group_subscription(self):
  254. user = self.create_user()
  255. group = self.create_group()
  256. GroupSubscription.objects.create(
  257. user=user, group=group, project=group.project, is_active=True
  258. )
  259. NotificationSetting.objects.update_settings(
  260. ExternalProviders.EMAIL,
  261. NotificationSettingTypes.WORKFLOW,
  262. NotificationSettingOptionValues.NEVER,
  263. user=user,
  264. )
  265. result = serialize(group, user, serializer=GroupSerializerSnuba())
  266. assert not result["isSubscribed"]
  267. assert result["subscriptionDetails"] == {"disabled": True}
  268. def test_project_no_conversations_overrides_group_subscription(self):
  269. user = self.create_user()
  270. group = self.create_group()
  271. GroupSubscription.objects.create(
  272. user=user, group=group, project=group.project, is_active=True
  273. )
  274. NotificationSetting.objects.update_settings(
  275. ExternalProviders.EMAIL,
  276. NotificationSettingTypes.WORKFLOW,
  277. NotificationSettingOptionValues.NEVER,
  278. user=user,
  279. project=group.project,
  280. )
  281. result = serialize(group, user, serializer=GroupSerializerSnuba())
  282. assert not result["isSubscribed"]
  283. assert result["subscriptionDetails"] == {"disabled": True}
  284. def test_no_user_unsubscribed(self):
  285. group = self.create_group()
  286. result = serialize(group, serializer=GroupSerializerSnuba())
  287. assert not result["isSubscribed"]
  288. def test_seen_stats(self):
  289. environment = self.create_environment(project=self.project)
  290. environment2 = self.create_environment(project=self.project)
  291. events = []
  292. for event_id, env, user_id, timestamp in [
  293. ("a" * 32, environment, 1, iso_format(self.min_ago)),
  294. ("b" * 32, environment, 2, iso_format(self.min_ago)),
  295. ("c" * 32, environment2, 3, iso_format(self.week_ago)),
  296. ]:
  297. events.append(
  298. self.store_event(
  299. data={
  300. "event_id": event_id,
  301. "fingerprint": ["put-me-in-group1"],
  302. "timestamp": timestamp,
  303. "environment": env.name,
  304. "user": {"id": user_id},
  305. },
  306. project_id=self.project.id,
  307. )
  308. )
  309. # Assert all events are in the same group
  310. (group_id,) = {e.group.id for e in events}
  311. group = Group.objects.get(id=group_id)
  312. group.times_seen = 3
  313. group.first_seen = self.week_ago - timedelta(days=5)
  314. group.last_seen = self.week_ago
  315. group.save()
  316. # should use group columns when no environments arg passed
  317. result = serialize(group, serializer=GroupSerializerSnuba(environment_ids=[]))
  318. assert result["count"] == "3"
  319. assert iso_format(result["lastSeen"]) == iso_format(self.min_ago)
  320. assert result["firstSeen"] == group.first_seen
  321. # update this to something different to make sure it's being used
  322. group_env = GroupEnvironment.objects.get(group_id=group_id, environment_id=environment.id)
  323. group_env.first_seen = self.day_ago - timedelta(days=3)
  324. group_env.save()
  325. group_env2 = GroupEnvironment.objects.get(group_id=group_id, environment_id=environment2.id)
  326. result = serialize(
  327. group,
  328. serializer=GroupSerializerSnuba(environment_ids=[environment.id, environment2.id]),
  329. )
  330. assert result["count"] == "3"
  331. # result is rounded down to nearest second
  332. assert iso_format(result["lastSeen"]) == iso_format(self.min_ago)
  333. assert iso_format(result["firstSeen"]) == iso_format(group_env.first_seen)
  334. assert iso_format(group_env2.first_seen) > iso_format(group_env.first_seen)
  335. assert result["userCount"] == 3
  336. result = serialize(
  337. group,
  338. serializer=GroupSerializerSnuba(
  339. environment_ids=[environment.id, environment2.id],
  340. start=self.week_ago - timedelta(hours=1),
  341. end=self.week_ago + timedelta(hours=1),
  342. ),
  343. )
  344. assert result["userCount"] == 1
  345. assert iso_format(result["lastSeen"]) == iso_format(self.week_ago)
  346. assert iso_format(result["firstSeen"]) == iso_format(self.week_ago)
  347. assert result["count"] == "1"
  348. def test_get_start_from_seen_stats(self):
  349. for days, expected in [(None, 30), (0, 14), (1000, 90)]:
  350. last_seen = None if days is None else before_now(days=days).replace(tzinfo=pytz.UTC)
  351. start = GroupSerializerSnuba._get_start_from_seen_stats({"": {"last_seen": last_seen}})
  352. assert iso_format(start) == iso_format(before_now(days=expected))
  353. class StreamGroupSerializerTestCase(APITestCase, SnubaTestCase):
  354. def test_environment(self):
  355. group = self.group
  356. environment = Environment.get_or_create(group.project, "production")
  357. with mock.patch(
  358. "sentry.api.serializers.models.group.snuba_tsdb.get_range",
  359. side_effect=snuba_tsdb.get_range,
  360. ) as get_range:
  361. serialize(
  362. [group],
  363. serializer=StreamGroupSerializerSnuba(
  364. environment_ids=[environment.id], stats_period="14d"
  365. ),
  366. )
  367. assert get_range.call_count == 1
  368. for args, kwargs in get_range.call_args_list:
  369. assert kwargs["environment_ids"] == [environment.id]
  370. with mock.patch(
  371. "sentry.api.serializers.models.group.snuba_tsdb.get_range",
  372. side_effect=snuba_tsdb.get_range,
  373. ) as get_range:
  374. serialize(
  375. [group],
  376. serializer=StreamGroupSerializerSnuba(environment_ids=None, stats_period="14d"),
  377. )
  378. assert get_range.call_count == 1
  379. for args, kwargs in get_range.call_args_list:
  380. assert kwargs["environment_ids"] is None
  381. def test_session_count(self):
  382. group = self.group
  383. environment = Environment.get_or_create(group.project, "prod")
  384. dev_environment = Environment.get_or_create(group.project, "dev")
  385. no_sessions_environment = Environment.get_or_create(group.project, "no_sessions")
  386. self.received = time.time()
  387. self.session_started = time.time() // 60 * 60
  388. self.session_release = "foo@1.0.0"
  389. self.session_crashed_release = "foo@2.0.0"
  390. self.store_session(
  391. {
  392. "session_id": "5d52fd05-fcc9-4bf3-9dc9-267783670341",
  393. "distinct_id": "39887d89-13b2-4c84-8c23-5d13d2102666",
  394. "status": "ok",
  395. "seq": 0,
  396. "release": self.session_release,
  397. "environment": "dev",
  398. "retention_days": 90,
  399. "org_id": self.project.organization_id,
  400. "project_id": self.project.id,
  401. "duration": 60.0,
  402. "errors": 0,
  403. "started": self.session_started,
  404. "received": self.received,
  405. }
  406. )
  407. self.store_session(
  408. {
  409. "session_id": "5e910c1a-6941-460e-9843-24103fb6a63c",
  410. "distinct_id": "39887d89-13b2-4c84-8c23-5d13d2102666",
  411. "status": "ok",
  412. "seq": 0,
  413. "release": self.session_release,
  414. "environment": "prod",
  415. "retention_days": 90,
  416. "org_id": self.project.organization_id,
  417. "project_id": self.project.id,
  418. "duration": None,
  419. "errors": 0,
  420. "started": self.session_started,
  421. "received": self.received,
  422. }
  423. )
  424. self.store_session(
  425. {
  426. "session_id": "5e910c1a-6941-460e-9843-24103fb6a63c",
  427. "distinct_id": "39887d89-13b2-4c84-8c23-5d13d2102666",
  428. "status": "exited",
  429. "seq": 1,
  430. "release": self.session_release,
  431. "environment": "prod",
  432. "retention_days": 90,
  433. "org_id": self.project.organization_id,
  434. "project_id": self.project.id,
  435. "duration": 30.0,
  436. "errors": 0,
  437. "started": self.session_started,
  438. "received": self.received,
  439. }
  440. )
  441. self.store_session(
  442. {
  443. "session_id": "a148c0c5-06a2-423b-8901-6b43b812cf82",
  444. "distinct_id": "39887d89-13b2-4c84-8c23-5d13d2102666",
  445. "status": "crashed",
  446. "seq": 0,
  447. "release": self.session_crashed_release,
  448. "environment": "prod",
  449. "retention_days": 90,
  450. "org_id": self.project.organization_id,
  451. "project_id": self.project.id,
  452. "duration": 60.0,
  453. "errors": 0,
  454. "started": self.session_started,
  455. "received": self.received,
  456. }
  457. )
  458. result = serialize(
  459. [group],
  460. serializer=StreamGroupSerializerSnuba(stats_period="14d"),
  461. )
  462. assert "sessionPercent" not in result[0]
  463. result = serialize(
  464. [group],
  465. serializer=StreamGroupSerializerSnuba(stats_period="14d", expand=["sessions"]),
  466. )
  467. assert result[0]["sessionPercent"] == 0.3333
  468. result = serialize(
  469. [group],
  470. serializer=StreamGroupSerializerSnuba(
  471. environment_ids=[environment.id], stats_period="14d", expand=["sessions"]
  472. ),
  473. )
  474. assert result[0]["sessionPercent"] == 0.5
  475. result = serialize(
  476. [group],
  477. serializer=StreamGroupSerializerSnuba(
  478. environment_ids=[no_sessions_environment.id],
  479. stats_period="14d",
  480. expand=["sessions"],
  481. ),
  482. )
  483. assert result[0]["sessionPercent"] is None
  484. result = serialize(
  485. [group],
  486. serializer=StreamGroupSerializerSnuba(
  487. environment_ids=[dev_environment.id], stats_period="14d", expand=["sessions"]
  488. ),
  489. )
  490. assert result[0]["sessionPercent"] == 1
  491. self.store_session(
  492. {
  493. "session_id": "a148c0c5-06a2-423b-8901-6b43b812cf83",
  494. "distinct_id": "39887d89-13b2-4c84-8c23-5d13d2102667",
  495. "status": "ok",
  496. "seq": 0,
  497. "release": self.session_release,
  498. "environment": "dev",
  499. "retention_days": 90,
  500. "org_id": self.project.organization_id,
  501. "project_id": self.project.id,
  502. "duration": 60.0,
  503. "errors": 0,
  504. "started": self.session_started - 1590061, # approximately 18 days
  505. "received": self.received - 1590061, # approximately 18 days
  506. }
  507. )
  508. result = serialize(
  509. [group],
  510. serializer=StreamGroupSerializerSnuba(
  511. environment_ids=[dev_environment.id],
  512. stats_period="14d",
  513. expand=["sessions"],
  514. start=timezone.now() - timedelta(days=30),
  515. end=timezone.now() - timedelta(days=15),
  516. ),
  517. )
  518. assert result[0]["sessionPercent"] == 0.0 # No events in that time period
  519. # Delete the cache from the query we did above, else this result comes back as 1 instead of 0.5
  520. cache.delete(f"w-s:{group.project.id}-{dev_environment.id}")
  521. project2 = self.create_project(
  522. organization=self.organization, teams=[self.team], name="Another project"
  523. )
  524. data = {
  525. "fingerprint": ["meow"],
  526. "timestamp": iso_format(timezone.now()),
  527. "type": "error",
  528. "exception": [{"type": "Foo"}],
  529. }
  530. event = self.store_event(data=data, project_id=project2.id)
  531. self.store_event(data=data, project_id=project2.id)
  532. self.store_event(data=data, project_id=project2.id)
  533. result = serialize(
  534. [group, event.group],
  535. serializer=StreamGroupSerializerSnuba(
  536. environment_ids=[dev_environment.id],
  537. stats_period="14d",
  538. expand=["sessions"],
  539. ),
  540. )
  541. assert result[0]["sessionPercent"] == 0.5
  542. # No sessions in project2
  543. assert result[1]["sessionPercent"] is None