test_group.py 23 KB

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