test_email.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. from functools import cached_property
  2. from unittest.mock import patch
  3. import pytest
  4. import responses
  5. from django.conf import settings
  6. from django.core import mail
  7. from django.urls import reverse
  8. from django.utils import timezone
  9. from sentry.incidents.action_handlers import (
  10. EmailActionHandler,
  11. generate_incident_trigger_email_context,
  12. )
  13. from sentry.incidents.charts import fetch_metric_alert_events_timeseries
  14. from sentry.incidents.logic import CRITICAL_TRIGGER_LABEL, WARNING_TRIGGER_LABEL
  15. from sentry.incidents.models import (
  16. INCIDENT_STATUS,
  17. AlertRuleThresholdType,
  18. AlertRuleTriggerAction,
  19. IncidentStatus,
  20. TriggerStatus,
  21. )
  22. from sentry.models.notificationsettingoption import NotificationSettingOption
  23. from sentry.models.options.user_option import UserOption
  24. from sentry.models.useremail import UserEmail
  25. from sentry.sentry_metrics import indexer
  26. from sentry.sentry_metrics.use_case_id_registry import UseCaseID
  27. from sentry.snuba.dataset import Dataset
  28. from sentry.snuba.models import SnubaQuery
  29. from sentry.testutils.cases import TestCase
  30. from sentry.testutils.helpers.datetime import freeze_time
  31. from sentry.testutils.helpers.features import with_feature
  32. from sentry.testutils.silo import assume_test_silo_mode_of, region_silo_test
  33. from . import FireTest
  34. pytestmark = pytest.mark.sentry_metrics
  35. @freeze_time()
  36. class EmailActionHandlerTest(FireTest):
  37. @responses.activate
  38. def run_test(self, incident, method):
  39. action = self.create_alert_rule_trigger_action(
  40. target_identifier=str(self.user.id),
  41. triggered_for_incident=incident,
  42. )
  43. handler = EmailActionHandler(action, incident, self.project)
  44. with self.tasks():
  45. handler.fire(1000, IncidentStatus(incident.status))
  46. out = mail.outbox[0]
  47. assert out.to == [self.user.email]
  48. assert out.subject == "[{}] {} - {}".format(
  49. INCIDENT_STATUS[IncidentStatus(incident.status)], incident.title, self.project.slug
  50. )
  51. def test_fire_metric_alert(self):
  52. self.run_fire_test()
  53. def test_resolve_metric_alert(self):
  54. self.run_fire_test("resolve")
  55. @patch("sentry.analytics.record")
  56. def test_alert_sent_recorded(self, mock_record):
  57. self.run_fire_test()
  58. mock_record.assert_called_with(
  59. "alert.sent",
  60. organization_id=self.organization.id,
  61. project_id=self.project.id,
  62. provider="email",
  63. alert_id=self.alert_rule.id,
  64. alert_type="metric_alert",
  65. external_id=str(self.user.id),
  66. notification_uuid="",
  67. )
  68. @region_silo_test
  69. class EmailActionHandlerGetTargetsTest(TestCase):
  70. @cached_property
  71. def incident(self):
  72. return self.create_incident()
  73. def test_user(self):
  74. action = self.create_alert_rule_trigger_action(
  75. target_type=AlertRuleTriggerAction.TargetType.USER,
  76. target_identifier=str(self.user.id),
  77. )
  78. handler = EmailActionHandler(action, self.incident, self.project)
  79. assert handler.get_targets() == [(self.user.id, self.user.email)]
  80. def test_rule_snoozed_by_user(self):
  81. action = self.create_alert_rule_trigger_action(
  82. target_type=AlertRuleTriggerAction.TargetType.USER,
  83. target_identifier=str(self.user.id),
  84. )
  85. handler = EmailActionHandler(action, self.incident, self.project)
  86. self.snooze_rule(user_id=self.user.id, alert_rule=self.incident.alert_rule)
  87. assert handler.get_targets() == []
  88. def test_user_rule_snoozed(self):
  89. action = self.create_alert_rule_trigger_action(
  90. target_type=AlertRuleTriggerAction.TargetType.USER,
  91. target_identifier=str(self.user.id),
  92. )
  93. handler = EmailActionHandler(action, self.incident, self.project)
  94. self.snooze_rule(alert_rule=self.incident.alert_rule)
  95. assert handler.get_targets() == []
  96. def test_user_alerts_disabled(self):
  97. with assume_test_silo_mode_of(NotificationSettingOption):
  98. NotificationSettingOption.objects.create(
  99. user_id=self.user.id,
  100. scope_type="project",
  101. scope_identifier=self.project.id,
  102. type="alerts",
  103. value="never",
  104. )
  105. action = self.create_alert_rule_trigger_action(
  106. target_type=AlertRuleTriggerAction.TargetType.USER,
  107. target_identifier=str(self.user.id),
  108. )
  109. handler = EmailActionHandler(action, self.incident, self.project)
  110. assert handler.get_targets() == [(self.user.id, self.user.email)]
  111. def test_team(self):
  112. new_user = self.create_user()
  113. self.create_team_membership(team=self.team, user=new_user)
  114. action = self.create_alert_rule_trigger_action(
  115. target_type=AlertRuleTriggerAction.TargetType.TEAM,
  116. target_identifier=str(self.team.id),
  117. )
  118. handler = EmailActionHandler(action, self.incident, self.project)
  119. assert set(handler.get_targets()) == {
  120. (self.user.id, self.user.email),
  121. (new_user.id, new_user.email),
  122. }
  123. def test_rule_snoozed_by_one_user_in_team(self):
  124. new_user = self.create_user()
  125. self.create_team_membership(team=self.team, user=new_user)
  126. action = self.create_alert_rule_trigger_action(
  127. target_type=AlertRuleTriggerAction.TargetType.TEAM,
  128. target_identifier=str(self.team.id),
  129. )
  130. handler = EmailActionHandler(action, self.incident, self.project)
  131. self.snooze_rule(user_id=new_user.id, alert_rule=self.incident.alert_rule)
  132. assert set(handler.get_targets()) == {
  133. (self.user.id, self.user.email),
  134. }
  135. def test_team_rule_snoozed(self):
  136. new_user = self.create_user()
  137. self.create_team_membership(team=self.team, user=new_user)
  138. action = self.create_alert_rule_trigger_action(
  139. target_type=AlertRuleTriggerAction.TargetType.TEAM,
  140. target_identifier=str(self.team.id),
  141. )
  142. handler = EmailActionHandler(action, self.incident, self.project)
  143. self.snooze_rule(alert_rule=self.incident.alert_rule)
  144. assert handler.get_targets() == []
  145. def test_team_alert_disabled(self):
  146. with assume_test_silo_mode_of(NotificationSettingOption):
  147. NotificationSettingOption.objects.create(
  148. user_id=self.user.id,
  149. scope_type="project",
  150. scope_identifier=self.project.id,
  151. type="alerts",
  152. value="never",
  153. )
  154. disabled_user = self.create_user()
  155. NotificationSettingOption.objects.create(
  156. user_id=disabled_user.id,
  157. scope_type="user",
  158. scope_identifier=disabled_user.id,
  159. type="alerts",
  160. value="never",
  161. )
  162. new_user = self.create_user()
  163. self.create_team_membership(team=self.team, user=new_user)
  164. action = self.create_alert_rule_trigger_action(
  165. target_type=AlertRuleTriggerAction.TargetType.TEAM,
  166. target_identifier=str(self.team.id),
  167. )
  168. handler = EmailActionHandler(action, self.incident, self.project)
  169. assert set(handler.get_targets()) == {(new_user.id, new_user.email)}
  170. def test_user_email_routing(self):
  171. new_email = "marcos@sentry.io"
  172. with assume_test_silo_mode_of(UserOption):
  173. UserOption.objects.create(
  174. user=self.user, project_id=self.project.id, key="mail:email", value=new_email
  175. )
  176. useremail = UserEmail.objects.get(email=self.user.email)
  177. useremail.email = new_email
  178. useremail.save()
  179. action = self.create_alert_rule_trigger_action(
  180. target_type=AlertRuleTriggerAction.TargetType.USER,
  181. target_identifier=str(self.user.id),
  182. )
  183. handler = EmailActionHandler(action, self.incident, self.project)
  184. assert handler.get_targets() == [(self.user.id, new_email)]
  185. def test_team_email_routing(self):
  186. new_email = "marcos@sentry.io"
  187. new_user = self.create_user(new_email)
  188. with assume_test_silo_mode_of(UserEmail):
  189. useremail = UserEmail.objects.get(email=self.user.email)
  190. useremail.email = new_email
  191. useremail.save()
  192. UserOption.objects.create(
  193. user=self.user, project_id=self.project.id, key="mail:email", value=new_email
  194. )
  195. UserOption.objects.create(
  196. user=new_user, project_id=self.project.id, key="mail:email", value=new_email
  197. )
  198. self.create_team_membership(team=self.team, user=new_user)
  199. action = self.create_alert_rule_trigger_action(
  200. target_type=AlertRuleTriggerAction.TargetType.TEAM,
  201. target_identifier=str(self.team.id),
  202. )
  203. handler = EmailActionHandler(action, self.incident, self.project)
  204. assert set(handler.get_targets()) == {
  205. (self.user.id, new_email),
  206. (new_user.id, new_email),
  207. }
  208. @freeze_time()
  209. class EmailActionHandlerGenerateEmailContextTest(TestCase):
  210. def test_simple(self):
  211. trigger_status = TriggerStatus.ACTIVE
  212. incident = self.create_incident()
  213. action = self.create_alert_rule_trigger_action(triggered_for_incident=incident)
  214. aggregate = action.alert_rule_trigger.alert_rule.snuba_query.aggregate
  215. alert_link = self.organization.absolute_url(
  216. reverse(
  217. "sentry-metric-alert",
  218. kwargs={
  219. "organization_slug": incident.organization.slug,
  220. "incident_id": incident.identifier,
  221. },
  222. ),
  223. query="referrer=metric_alert_email",
  224. )
  225. expected = {
  226. "link": alert_link,
  227. "incident_name": incident.title,
  228. "aggregate": aggregate,
  229. "query": action.alert_rule_trigger.alert_rule.snuba_query.query,
  230. "threshold": action.alert_rule_trigger.alert_threshold,
  231. "status": INCIDENT_STATUS[IncidentStatus(incident.status)],
  232. "status_key": INCIDENT_STATUS[IncidentStatus(incident.status)].lower(),
  233. "environment": "All",
  234. "is_critical": False,
  235. "is_warning": False,
  236. "threshold_direction_string": ">",
  237. "time_window": "10 minutes",
  238. "triggered_at": timezone.now(),
  239. "project_slug": self.project.slug,
  240. "unsubscribe_link": None,
  241. "chart_url": None,
  242. "timezone": settings.SENTRY_DEFAULT_TIME_ZONE,
  243. "snooze_alert": True,
  244. "snooze_alert_url": alert_link + "&mute=1",
  245. }
  246. assert expected == generate_incident_trigger_email_context(
  247. self.project,
  248. incident,
  249. action.alert_rule_trigger,
  250. trigger_status,
  251. IncidentStatus(incident.status),
  252. )
  253. @with_feature("organizations:customer-domains")
  254. def test_links_customer_domains(self):
  255. trigger_status = TriggerStatus.ACTIVE
  256. incident = self.create_incident()
  257. action = self.create_alert_rule_trigger_action(triggered_for_incident=incident)
  258. result = generate_incident_trigger_email_context(
  259. self.project,
  260. incident,
  261. action.alert_rule_trigger,
  262. trigger_status,
  263. IncidentStatus(incident.status),
  264. )
  265. path = reverse(
  266. "sentry-metric-alert",
  267. kwargs={
  268. "organization_slug": self.organization.slug,
  269. "incident_id": incident.identifier,
  270. },
  271. )
  272. assert self.organization.absolute_url(path) in result["link"]
  273. def test_resolve(self):
  274. status = TriggerStatus.RESOLVED
  275. incident = self.create_incident()
  276. action = self.create_alert_rule_trigger_action(triggered_for_incident=incident)
  277. generated_email_context = generate_incident_trigger_email_context(
  278. self.project,
  279. incident,
  280. action.alert_rule_trigger,
  281. status,
  282. IncidentStatus.CLOSED,
  283. )
  284. assert generated_email_context["threshold"] == 100
  285. assert generated_email_context["threshold_direction_string"] == "<"
  286. def test_resolve_critical_trigger_with_warning(self):
  287. status = TriggerStatus.RESOLVED
  288. rule = self.create_alert_rule()
  289. incident = self.create_incident(alert_rule=rule)
  290. crit_trigger = self.create_alert_rule_trigger(rule, CRITICAL_TRIGGER_LABEL, 100)
  291. self.create_alert_rule_trigger_action(crit_trigger, triggered_for_incident=incident)
  292. self.create_alert_rule_trigger(rule, WARNING_TRIGGER_LABEL, 50)
  293. generated_email_context = generate_incident_trigger_email_context(
  294. self.project,
  295. incident,
  296. crit_trigger,
  297. status,
  298. IncidentStatus.WARNING,
  299. )
  300. assert generated_email_context["threshold"] == 100
  301. assert generated_email_context["threshold_direction_string"] == "<"
  302. assert not generated_email_context["is_critical"]
  303. assert generated_email_context["is_warning"]
  304. assert generated_email_context["status"] == "Warning"
  305. assert generated_email_context["status_key"] == "warning"
  306. def test_context_for_crash_rate_alert(self):
  307. """
  308. Test that ensures the metric name for Crash rate alerts excludes the alias
  309. """
  310. status = TriggerStatus.ACTIVE
  311. incident = self.create_incident()
  312. alert_rule = self.create_alert_rule(
  313. aggregate="percentage(sessions_crashed, sessions) AS _crash_rate_alert_aggregate"
  314. )
  315. alert_rule_trigger = self.create_alert_rule_trigger(alert_rule)
  316. action = self.create_alert_rule_trigger_action(
  317. alert_rule_trigger=alert_rule_trigger, triggered_for_incident=incident
  318. )
  319. assert (
  320. generate_incident_trigger_email_context(
  321. self.project, incident, action.alert_rule_trigger, status, IncidentStatus.CRITICAL
  322. )["aggregate"]
  323. == "percentage(sessions_crashed, sessions)"
  324. )
  325. def test_context_for_resolved_crash_rate_alert(self):
  326. """
  327. Test that ensures the resolved notification contains the correct threshold string
  328. """
  329. status = TriggerStatus.RESOLVED
  330. incident = self.create_incident()
  331. alert_rule = self.create_alert_rule(
  332. aggregate="percentage(sessions_crashed, sessions) AS _crash_rate_alert_aggregate",
  333. threshold_type=AlertRuleThresholdType.BELOW,
  334. query="",
  335. )
  336. alert_rule_trigger = self.create_alert_rule_trigger(alert_rule)
  337. action = self.create_alert_rule_trigger_action(
  338. alert_rule_trigger=alert_rule_trigger, triggered_for_incident=incident
  339. )
  340. generated_email_context = generate_incident_trigger_email_context(
  341. self.project, incident, action.alert_rule_trigger, status, IncidentStatus.CLOSED
  342. )
  343. assert generated_email_context["aggregate"] == "percentage(sessions_crashed, sessions)"
  344. assert generated_email_context["threshold"] == 100
  345. assert generated_email_context["threshold_direction_string"] == ">"
  346. def test_environment(self):
  347. status = TriggerStatus.ACTIVE
  348. environments = [
  349. self.create_environment(project=self.project, name="prod"),
  350. self.create_environment(project=self.project, name="dev"),
  351. ]
  352. alert_rule = self.create_alert_rule(environment=environments[0])
  353. alert_rule_trigger = self.create_alert_rule_trigger(alert_rule=alert_rule)
  354. incident = self.create_incident()
  355. action = self.create_alert_rule_trigger_action(
  356. alert_rule_trigger=alert_rule_trigger, triggered_for_incident=incident
  357. )
  358. assert "prod" == generate_incident_trigger_email_context(
  359. self.project, incident, action.alert_rule_trigger, status, IncidentStatus.CRITICAL
  360. ).get("environment")
  361. @patch(
  362. "sentry.incidents.charts.fetch_metric_alert_events_timeseries",
  363. side_effect=fetch_metric_alert_events_timeseries,
  364. )
  365. @patch("sentry.charts.backend.generate_chart", return_value="chart-url")
  366. def test_metric_chart(self, mock_generate_chart, mock_fetch_metric_alert_events_timeseries):
  367. trigger_status = TriggerStatus.ACTIVE
  368. incident = self.create_incident()
  369. action = self.create_alert_rule_trigger_action(triggered_for_incident=incident)
  370. with self.feature(
  371. [
  372. "organizations:incidents",
  373. "organizations:discover",
  374. "organizations:discover-basic",
  375. "organizations:metric-alert-chartcuterie",
  376. ]
  377. ):
  378. result = generate_incident_trigger_email_context(
  379. self.project,
  380. incident,
  381. action.alert_rule_trigger,
  382. trigger_status,
  383. IncidentStatus(incident.status),
  384. )
  385. assert result["chart_url"] == "chart-url"
  386. chart_data = mock_generate_chart.call_args[0][1]
  387. assert chart_data["rule"]["id"] == str(incident.alert_rule.id)
  388. assert chart_data["selectedIncident"]["identifier"] == str(incident.identifier)
  389. assert mock_fetch_metric_alert_events_timeseries.call_args[0][2]["dataset"] == "errors"
  390. series_data = chart_data["timeseriesData"][0]["data"]
  391. assert len(series_data) > 0
  392. assert mock_generate_chart.call_args[1]["size"] == {"width": 600, "height": 200}
  393. @patch(
  394. "sentry.incidents.charts.fetch_metric_alert_events_timeseries",
  395. side_effect=fetch_metric_alert_events_timeseries,
  396. )
  397. @patch("sentry.charts.backend.generate_chart", return_value="chart-url")
  398. def test_metric_chart_mep(self, mock_generate_chart, mock_fetch_metric_alert_events_timeseries):
  399. indexer.record(
  400. use_case_id=UseCaseID.TRANSACTIONS, org_id=self.organization.id, string="level"
  401. )
  402. trigger_status = TriggerStatus.ACTIVE
  403. alert_rule = self.create_alert_rule(
  404. query_type=SnubaQuery.Type.PERFORMANCE, dataset=Dataset.PerformanceMetrics
  405. )
  406. incident = self.create_incident(alert_rule=alert_rule)
  407. action = self.create_alert_rule_trigger_action(triggered_for_incident=incident)
  408. with self.feature(
  409. [
  410. "organizations:incidents",
  411. "organizations:discover",
  412. "organizations:discover-basic",
  413. "organizations:metric-alert-chartcuterie",
  414. ]
  415. ):
  416. result = generate_incident_trigger_email_context(
  417. self.project,
  418. incident,
  419. action.alert_rule_trigger,
  420. trigger_status,
  421. IncidentStatus(incident.status),
  422. )
  423. assert result["chart_url"] == "chart-url"
  424. chart_data = mock_generate_chart.call_args[0][1]
  425. assert chart_data["rule"]["id"] == str(incident.alert_rule.id)
  426. assert chart_data["selectedIncident"]["identifier"] == str(incident.identifier)
  427. assert mock_fetch_metric_alert_events_timeseries.call_args[0][2]["dataset"] == "metrics"
  428. series_data = chart_data["timeseriesData"][0]["data"]
  429. assert len(series_data) > 0
  430. assert mock_generate_chart.call_args[1]["size"] == {"width": 600, "height": 200}
  431. def test_timezones(self):
  432. trigger_status = TriggerStatus.ACTIVE
  433. alert_rule = self.create_alert_rule(
  434. query_type=SnubaQuery.Type.PERFORMANCE, dataset=Dataset.PerformanceMetrics
  435. )
  436. incident = self.create_incident(alert_rule=alert_rule)
  437. action = self.create_alert_rule_trigger_action(triggered_for_incident=incident)
  438. est = "America/New_York"
  439. pst = "US/Pacific"
  440. UserOption.objects.set_value(user=self.user, key="timezone", value=est)
  441. result = generate_incident_trigger_email_context(
  442. self.project,
  443. incident,
  444. action.alert_rule_trigger,
  445. trigger_status,
  446. IncidentStatus(incident.status),
  447. self.user,
  448. )
  449. assert result["timezone"] == est
  450. UserOption.objects.set_value(user=self.user, key="timezone", value=pst)
  451. result = generate_incident_trigger_email_context(
  452. self.project,
  453. incident,
  454. action.alert_rule_trigger,
  455. trigger_status,
  456. IncidentStatus(incident.status),
  457. self.user,
  458. )
  459. assert result["timezone"] == pst