Browse Source

feat(api): Support sending an incident alert via email (SEN-960)

This implements `EmailActionHandler` so that we'll fire off emails to users/teams. Also refactors to
pass in the project to `ActionHandler` classes, since it's helpful to know the specific project that
this was triggered on, rather than calculating from the incident, which might be incorrect in the
future.
Dan Fuller 5 years ago
parent
commit
ba906f6034

+ 97 - 4
src/sentry/incidents/action_handlers.py

@@ -3,15 +3,20 @@ from __future__ import absolute_import
 import abc
 
 import six
+from django.core.urlresolvers import reverse
 
-from sentry.incidents.models import AlertRuleTriggerAction
+from sentry.incidents.models import AlertRuleTriggerAction, QueryAggregations, TriggerStatus
+from sentry.utils.email import MessageBuilder
+from sentry.utils.http import absolute_uri
+from sentry.utils.linksign import generate_signed_link
 
 
 @six.add_metaclass(abc.ABCMeta)
 class ActionHandler(object):
-    def __init__(self, action, incident):
+    def __init__(self, action, incident, project):
         self.action = action
         self.incident = incident
+        self.project = project
 
     @abc.abstractmethod
     def fire(self):
@@ -24,8 +29,96 @@ class ActionHandler(object):
 
 @AlertRuleTriggerAction.register_type_handler(AlertRuleTriggerAction.Type.EMAIL)
 class EmailActionHandler(ActionHandler):
+    query_aggregations_display = {
+        QueryAggregations.TOTAL: "Total Events",
+        QueryAggregations.UNIQUE_USERS: "Total Unique Users",
+    }
+    status_display = {TriggerStatus.ACTIVE: "Fired", TriggerStatus.RESOLVED: "Resolved"}
+
+    def get_targets(self):
+        target = self.action.target
+        if not target:
+            return []
+        targets = []
+        if self.action.target_type in (
+            AlertRuleTriggerAction.TargetType.USER.value,
+            AlertRuleTriggerAction.TargetType.TEAM.value,
+        ):
+            alert_settings = self.project.get_member_alert_settings("mail:alert")
+            disabled_users = set(
+                user_id for user_id, setting in alert_settings.items() if setting == 0
+            )
+            if self.action.target_type == AlertRuleTriggerAction.TargetType.USER.value:
+                if target.id not in disabled_users:
+                    targets = [(target.id, target.email)]
+            elif self.action.target_type == AlertRuleTriggerAction.TargetType.TEAM.value:
+                targets = target.member_set.values_list("user_id", "user__email")
+                targets = [
+                    (user_id, email) for user_id, email in targets if user_id not in disabled_users
+                ]
+        # TODO: We need some sort of verification system to make sure we're not being
+        # used as an email relay.
+        # elif self.action.target_type == AlertRuleTriggerAction.TargetType.SPECIFIC.value:
+        #     emails = [target]
+        return targets
+
     def fire(self):
-        pass
+        self.email_users(TriggerStatus.ACTIVE)
 
     def resolve(self):
-        pass
+        self.email_users(TriggerStatus.RESOLVED)
+
+    def email_users(self, status):
+        email_context = self.generate_email_context(status)
+        for user_id, email in self.get_targets():
+            email_context["unsubscribe_link"] = self.generate_unsubscribe_link(user_id)
+            self.build_message(email_context, status, user_id).send_async(to=[email])
+
+    def build_message(self, context, status, user_id):
+        context["unsubscribe_link"] = self.generate_unsubscribe_link(user_id)
+        display = self.status_display[status]
+        return MessageBuilder(
+            subject=u"Incident Alert Rule {} for Project {}".format(display, self.project.slug),
+            template=u"sentry/emails/incidents/trigger.txt",
+            html_template=u"sentry/emails/incidents/trigger.html",
+            type="incident.alert_rule_{}".format(display.lower()),
+            context=context,
+        )
+
+    def generate_unsubscribe_link(self, user_id):
+        return generate_signed_link(
+            user_id,
+            "sentry-account-email-unsubscribe-project",
+            kwargs={"project_id": self.project.id},
+        )
+
+    def generate_email_context(self, status):
+        trigger = self.action.alert_rule_trigger
+        alert_rule = trigger.alert_rule
+        return {
+            "link": absolute_uri(
+                reverse(
+                    "sentry-incident",
+                    kwargs={
+                        "organization_slug": self.incident.organization.slug,
+                        "incident_id": self.incident.identifier,
+                    },
+                )
+            ),
+            "rule_link": absolute_uri(
+                reverse(
+                    "sentry-alert-rule",
+                    kwargs={
+                        "organization_slug": self.incident.organization.slug,
+                        "alert_rule_id": self.action.alert_rule_trigger.alert_rule_id,
+                    },
+                )
+            ),
+            "incident_name": self.incident.title,
+            "aggregate": self.query_aggregations_display[QueryAggregations(alert_rule.aggregation)],
+            "query": alert_rule.query,
+            "threshold": trigger.alert_threshold
+            if status == TriggerStatus.ACTIVE
+            else trigger.resolve_threshold,
+            "status": self.status_display[status],
+        }

+ 6 - 6
src/sentry/incidents/models.py

@@ -425,20 +425,20 @@ class AlertRuleTriggerAction(Model):
             # ok to contact this email.
             return self.target_identifier
 
-    def build_handler(self, incident):
+    def build_handler(self, incident, project):
         type = AlertRuleTriggerAction.Type(self.type)
         if type in self.handlers:
-            return self.handlers[type](self, incident)
+            return self.handlers[type](self, incident, project)
         else:
             metrics.incr("alert_rule_trigger.unhandled_type.{}".format(self.type))
 
-    def fire(self, incident):
-        handler = self.build_handler(incident)
+    def fire(self, incident, project):
+        handler = self.build_handler(incident, project)
         if handler:
             return handler.fire()
 
-    def resolve(self, incident):
-        handler = self.build_handler(incident)
+    def resolve(self, incident, project):
+        handler = self.build_handler(incident, project)
         if handler:
             return handler.resolve()
 

+ 1 - 0
src/sentry/incidents/subscription_processor.py

@@ -246,6 +246,7 @@ class SubscriptionProcessor(object):
                 kwargs={
                     "action_id": action.id,
                     "incident_id": incident_trigger.incident_id,
+                    "project_id": self.subscription.project_id,
                     "method": method,
                 },
                 countdown=5,

+ 13 - 4
src/sentry/incidents/tasks.py

@@ -20,6 +20,7 @@ from sentry.incidents.models import (
     IncidentStatus,
     IncidentSuspectCommit,
 )
+from sentry.models import Project
 from sentry.snuba.query_subscription_consumer import register_subscriber
 from sentry.tasks.base import instrumented_task, retry
 from sentry.utils.email import MessageBuilder
@@ -181,16 +182,24 @@ def handle_snuba_query_update(subscription_update, subscription):
     default_retry_delay=60,
     max_retries=5,
 )
-def handle_trigger_action(action_id, incident_id, method):
+def handle_trigger_action(action_id, incident_id, project_id, method):
     try:
-        action = AlertRuleTriggerAction.objects.get(id=action_id)
+        action = AlertRuleTriggerAction.objects.select_related(
+            "alert_rule_trigger", "alert_rule_trigger__alert_rule"
+        ).get(id=action_id)
     except AlertRuleTriggerAction.DoesNotExist:
         metrics.incr("incidents.alert_rules.skipping_missing_action")
         return
     try:
-        incident = Incident.objects.get(id=incident_id)
+        incident = Incident.objects.select_related("organization").get(id=incident_id)
     except Incident.DoesNotExist:
         metrics.incr("incidents.alert_rules.skipping_missing_incident")
         return
 
-    getattr(action, method)(incident)
+    try:
+        project = Project.objects.get(id=project_id)
+    except Project.DoesNotExist:
+        metrics.incr("incidents.alert_rules.skipping_missing_project")
+        return
+
+    getattr(action, method)(incident, project)

+ 36 - 0
src/sentry/templates/sentry/emails/incidents/trigger.html

@@ -0,0 +1,36 @@
+{% extends "sentry/emails/activity/generic.html" %}
+
+{% load sentry_avatars %}
+{% load sentry_helpers %}
+{% load sentry_assets %}
+
+{% block activity %}
+    <h3>
+        <a href="{{ link }}">Alert Rule Trigger {{ status }} on Incident {{ incident_name }}.</a>
+    </h3>
+
+
+  {% if enhanced_privacy %}
+    <div class="notice">
+      Details about this incident alert are not shown in this email since enhanced privacy
+      controls are enabled. For more details about this incident, <a href="{{ link }}">view on Sentry.</a>
+    </div>
+
+  {% else %}
+      <table >
+        <tr>
+            <td>Rule: <a href="{{ rule_link }}">{{ rule_link }}</a></td>
+        </tr>
+        <tr>
+          <td>Aggregate: {{ aggregate }} </td>
+        </tr>
+        <tr>
+          <td>Query: {{ query }}</td>
+        </tr>
+        <tr>
+          <td>Threshold: {{ threshold }}</td>
+        </tr>
+        </tr>
+      </table>
+  {% endif %}
+{% endblock %}

+ 22 - 0
src/sentry/templates/sentry/emails/incidents/trigger.txt

@@ -0,0 +1,22 @@
+{% spaceless %}
+{% autoescape off %}
+# Alert Rule Trigger {{ status }} on Incident {{ incident_name }}.
+
+{% if enhanced_privacy %}
+Details about this incident alert are not shown in this email since enhanced privacy
+controls are enabled. For more details about this incident alert, view on Sentry:
+{{ incident_link }}.
+{% else %}
+Incident: {{ link }}
+Rule: {{ rule_link }}
+
+Aggregate: {{ aggregate }}
+Query: {{ query }}
+Threshold: {{ threshold }}
+
+{% endif %}
+
+Unsubscribe: {{ unsubscribe_link }}
+
+{% endautoescape %}
+{% endspaceless %}

+ 35 - 1
src/sentry/testutils/factories.py

@@ -22,8 +22,14 @@ from uuid import uuid4
 
 from sentry.event_manager import EventManager
 from sentry.constants import SentryAppStatus
-from sentry.incidents.logic import create_alert_rule
+from sentry.incidents.logic import (
+    create_alert_rule,
+    create_alert_rule_trigger,
+    create_alert_rule_trigger_action,
+)
 from sentry.incidents.models import (
+    AlertRuleThresholdType,
+    AlertRuleTriggerAction,
     Incident,
     IncidentGroup,
     IncidentProject,
@@ -961,3 +967,31 @@ class Factories(object):
             include_all_projects=include_all_projects,
             excluded_projects=excluded_projects,
         )
+
+    @staticmethod
+    def create_alert_rule_trigger(
+        alert_rule,
+        label=None,
+        threshold_type=AlertRuleThresholdType.ABOVE,
+        alert_threshold=100,
+        resolve_threshold=10,
+    ):
+        if not label:
+            label = petname.Generate(2, " ", letters=10).title()
+
+        return create_alert_rule_trigger(
+            alert_rule, label, threshold_type, alert_threshold, resolve_threshold
+        )
+
+    @staticmethod
+    def create_alert_rule_trigger_action(
+        trigger,
+        type=AlertRuleTriggerAction.Type.EMAIL,
+        target_type=AlertRuleTriggerAction.TargetType.USER,
+        target_identifier=None,
+        target_display=None,
+        integration=None,
+    ):
+        return create_alert_rule_trigger_action(
+            trigger, type, target_type, target_identifier, target_display, integration
+        )

+ 20 - 0
src/sentry/testutils/fixtures.py

@@ -1,5 +1,7 @@
 from __future__ import absolute_import, print_function, unicode_literals
 
+import six
+
 from sentry.models import Activity, OrganizationMember, OrganizationMemberTeam
 from sentry.incidents.models import IncidentActivityType
 
@@ -232,6 +234,24 @@ class Fixtures(object):
             projects = [self.project]
         return Factories.create_alert_rule(organization, projects, *args, **kwargs)
 
+    def create_alert_rule_trigger(self, alert_rule=None, *args, **kwargs):
+        if not alert_rule:
+            alert_rule = self.create_alert_rule()
+        return Factories.create_alert_rule_trigger(alert_rule, *args, **kwargs)
+
+    def create_alert_rule_trigger_action(
+        self, alert_rule_trigger=None, target_identifier=None, *args, **kwargs
+    ):
+        if not alert_rule_trigger:
+            alert_rule_trigger = self.create_alert_rule_trigger()
+
+        if not target_identifier:
+            target_identifier = six.text_type(self.user.id)
+
+        return Factories.create_alert_rule_trigger_action(
+            alert_rule_trigger, target_identifier=target_identifier, **kwargs
+        )
+
     @pytest.fixture(autouse=True)
     def _init_insta_snapshot(self, insta_snapshot):
         self.insta_snapshot = insta_snapshot

+ 2 - 0
src/sentry/web/debug_urls.py

@@ -5,6 +5,7 @@ from django.views.generic import TemplateView
 
 import sentry.web.frontend.debug.mail
 
+from sentry.web.frontend.debug.debug_alert_rule_trigger_email import DebugAlertRuleTriggerEmailView
 from sentry.web.frontend.debug.debug_assigned_email import (
     DebugAssignedEmailView,
     DebugSelfAssignedEmailView,
@@ -107,6 +108,7 @@ urlpatterns = patterns(
     url(r"^debug/mail/sso-linked/$", DebugSsoLinkedEmailView.as_view()),
     url(r"^debug/mail/sso-unlinked/$", DebugSsoUnlinkedEmailView.as_view()),
     url(r"^debug/mail/sso-unlinked/no-password$", DebugSsoUnlinkedNoPasswordEmailView.as_view()),
+    url(r"^debug/mail/alert-rule-trigger$", DebugAlertRuleTriggerEmailView.as_view()),
     url(r"^debug/mail/incident-activity$", DebugIncidentActivityEmailView.as_view()),
     url(r"^debug/mail/setup-2fa/$", DebugSetup2faEmailView.as_view()),
     url(r"^debug/embed/error-page/$", DebugErrorPageEmbedView.as_view()),

+ 39 - 0
src/sentry/web/frontend/debug/debug_alert_rule_trigger_email.py

@@ -0,0 +1,39 @@
+from __future__ import absolute_import, print_function
+
+from django.views.generic import View
+
+from sentry.incidents.models import (
+    Incident,
+    AlertRule,
+    AlertRuleTrigger,
+    AlertRuleTriggerAction,
+    TriggerStatus,
+)
+from sentry.models.project import Project
+from sentry.models.organization import Organization
+from sentry.incidents.action_handlers import EmailActionHandler
+
+from .mail import MailPreview
+
+
+class DebugAlertRuleTriggerEmailView(View):
+    def get(self, request):
+        organization = Organization(slug="myorg")
+        project = Project(id=30, slug="myproj")
+
+        incident = Incident(identifier=123, organization=organization, title="Something broke")
+        alert_rule = AlertRule(
+            id=1, organization=organization, aggregation=1, query="is:unresolved"
+        )
+        alert_rule_trigger = AlertRuleTrigger(
+            id=5, alert_rule=alert_rule, alert_threshold=100, resolve_threshold=50
+        )
+        action = AlertRuleTriggerAction(id=10, alert_rule_trigger=alert_rule_trigger)
+
+        handler = EmailActionHandler(action, incident, project)
+        email = handler.build_message(
+            handler.generate_email_context(TriggerStatus.ACTIVE), TriggerStatus.ACTIVE, 1
+        )
+        return MailPreview(
+            html_template=email.html_template, text_template=email.template, context=email.context
+        ).render(request)

Some files were not shown because too many files changed in this diff