Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(notifications platform): Send Slack messages for Issue Alerts #25515

Merged
merged 9 commits into from Apr 23, 2021
125 changes: 78 additions & 47 deletions src/sentry/integrations/slack/message_builder/issues.py
Expand Up @@ -130,31 +130,61 @@ def build_action_text(group: Group, identity: Identity, action):
)


def build_rule_url(rule: Rule, group: Group, project: Project):
def build_rule_url(rule: Rule, group: Group, project: Project, issue_alert: bool):
org_slug = group.organization.slug
project_slug = project.slug
if issue_alert:
return absolute_uri(rule[1])
rule_url = f"/organizations/{org_slug}/alerts/rules/{project_slug}/{rule.id}/"
return absolute_uri(rule_url)


def build_group_attachment(
group: Group,
event=None,
tags: Mapping[str, str] = None,
identity: Identity = None,
actions=None,
rules: List[Rule] = None,
link_to_event: bool = False,
def build_footer(group: Group, issue_alert: bool, project: Project, rules=None):
footer = f"{group.qualified_short_id}"

if rules:
rule_url = build_rule_url(rules[0], group, project, issue_alert)
if issue_alert:
footer += f" via <{rule_url}|{rules[0][0]}>"
else:
footer += f" via <{rule_url}|{rules[0].label}>"

if len(rules) > 1:
footer += f" (+{len(rules) - 1} other)"

return footer


def build_tag_fields(event_for_tags, tags: Mapping[str, str] = None):
fields = []
if tags:
event_tags = event_for_tags.tags if event_for_tags else []
for key, value in event_tags:
std_key = tagstore.get_standardized_key(key)
if std_key not in tags:
continue

labeled_value = tagstore.get_tag_value_label(key, value)
fields.append(
{
"title": std_key.encode("utf-8"),
"value": labeled_value.encode("utf-8"),
"short": True,
}
)
return fields


def build_actions(
group: Group, project: Project, text: str, color: str, actions=None, identity: Identity = None
):
# XXX(dcramer): options are limited to 100 choices, even when nested
"""
Having actions means a button will be shown on the Slack message e.g. ignore, resolve, assign
"""
status = group.get_status()

members = get_member_assignees(group)
teams = get_team_assignees(group)

logo_url = absolute_uri(get_asset_url("sentry", "images/sentry-email-avatar.png"))
text = build_attachment_text(group, event) or ""

if actions is None:
actions = []

Expand All @@ -169,8 +199,6 @@ def build_group_attachment(

ignore_button = {"name": "status", "value": "ignored", "type": "button", "text": "Ignore"}

project = Project.objects.get_from_cache(id=group.project_id)

cache_key = "has_releases:2:%s" % (project.id)
has_releases = cache.get(cache_key)
if has_releases is None:
Expand Down Expand Up @@ -209,6 +237,31 @@ def build_group_attachment(
},
]

if actions:
action_texts = [_f for _f in [build_action_text(group, identity, a) for a in actions] if _f]
text += "\n" + "\n".join(action_texts)

color = ACTIONED_ISSUE_COLOR
payload_actions = []

return payload_actions, text, color


def build_group_attachment(
group: Group,
event=None,
tags: Mapping[str, str] = None,
identity: Identity = None,
actions=None,
rules: List[Rule] = None,
link_to_event: bool = False,
issue_alert: bool = False,
):
# XXX(dcramer): options are limited to 100 choices, even when nested
logo_url = absolute_uri(get_asset_url("sentry", "images/sentry-email-avatar.png"))
text = build_attachment_text(group, event) or ""
project = Project.objects.get_from_cache(id=group.project_id)

# If an event is unspecified, use the tags of the latest event (if one exists).
event_for_tags = event if event else group.get_latest_event()

Expand All @@ -219,51 +272,29 @@ def build_group_attachment(
else fallback_color
)

fields = []
if tags:
event_tags = event_for_tags.tags if event_for_tags else []
for key, value in event_tags:
std_key = tagstore.get_standardized_key(key)
if std_key not in tags:
continue

labeled_value = tagstore.get_tag_value_label(key, value)
fields.append(
{
"title": std_key.encode("utf-8"),
"value": labeled_value.encode("utf-8"),
"short": True,
}
)

if actions:
action_texts = [_f for _f in [build_action_text(group, identity, a) for a in actions] if _f]
text += "\n" + "\n".join(action_texts)

color = ACTIONED_ISSUE_COLOR
payload_actions = []
fields = build_tag_fields(event_for_tags, tags)

ts = group.last_seen

if event:
event_ts = event.datetime
ts = max(ts, event_ts)

footer = f"{group.qualified_short_id}"

if rules:
rule_url = build_rule_url(rules[0], group, project)
footer += f" via <{rule_url}|{rules[0].label}>"

if len(rules) > 1:
footer += f" (+{len(rules) - 1} other)"
footer = build_footer(group, issue_alert, project, rules)

obj = event if event is not None else group
if event and link_to_event:
title_link = group.get_absolute_url(params={"referrer": "slack"}, event_id=event.event_id)
elif issue_alert:
title_link = group.get_absolute_url(params={"referrer": "IssueAlertSlack"})
else:
title_link = group.get_absolute_url(params={"referrer": "slack"})

if not issue_alert:
payload_actions, text, color = build_actions(group, project, text, color, actions, identity)
else:
payload_actions = []

return {
"fallback": f"[{project.slug}] {obj.title}",
"title": build_attachment_title(obj),
Expand Down
14 changes: 13 additions & 1 deletion src/sentry/integrations/slack/message_builder/notifications.py
@@ -1,8 +1,10 @@
import re
from typing import Any, Mapping
from typing import Any, List, Mapping
from urllib.parse import urljoin

from sentry.integrations.slack.message_builder.issues import build_group_attachment
from sentry.integrations.slack.utils import LEVEL_TO_COLOR
from sentry.models import Group, Rule
from sentry.notifications.activity.base import ActivityNotification
from sentry.utils.http import absolute_uri

Expand Down Expand Up @@ -46,3 +48,13 @@ def build_notification_attachment(
"footer": footer,
"color": LEVEL_TO_COLOR["info"],
}


def build_issue_notification_attachment(
group: Group,
event=None,
tags: Mapping[str, str] = None,
rules: List[Rule] = None,
):

return build_group_attachment(group, event, tags, rules, issue_alert=True)
74 changes: 72 additions & 2 deletions src/sentry/integrations/slack/notifications.py
Expand Up @@ -2,7 +2,11 @@
from typing import AbstractSet, Any, Mapping, Tuple

from sentry.integrations.slack.client import SlackClient # NOQA
from sentry.integrations.slack.message_builder.notifications import build_notification_attachment
from sentry.integrations.slack.message_builder.notifications import (
build_issue_notification_attachment,
build_notification_attachment,
)
from sentry.mail.notify import register_issue_notification_provider
from sentry.models import ExternalActor, Organization, User
from sentry.notifications.activity.base import ActivityNotification
from sentry.notifications.notify import register_notification_provider
Expand Down Expand Up @@ -51,11 +55,15 @@ def get_channel_and_token(


@register_notification_provider(ExternalProviders.SLACK)
def send_notification_as_slack(
def send_activity_notification_as_slack(
notification: ActivityNotification,
users: Mapping[User, int],
shared_context: Mapping[str, Any],
) -> None:
"""
Send an "activity notification" to a Slack user which are workflow and deploy notification types
"""

external_actors_by_user = get_integrations_by_user_id(notification.organization, users.keys())

client = SlackClient()
Expand Down Expand Up @@ -100,3 +108,65 @@ def send_notification_as_slack(
instance="slack.activity.notification",
skip_internal=False,
)


@register_issue_notification_provider(ExternalProviders.SLACK)
def send_issue_notification_as_slack(
notification: Any,
user_ids: int,
context: Mapping[str, Any],
) -> None:
"""
Send an "issue notification" to a Slack user which are project level issue alerts
"""
users = User.objects.filter(id__in=list(user_ids))
external_actors_by_user = get_integrations_by_user_id(context["project"].organization, users)

client = SlackClient()
for user in users:
try:
channel, token = get_channel_and_token(external_actors_by_user, user)
except AttributeError as e:
logger.info(
"notification.fail.invalid_slack",
extra={
"error": str(e),
"notification": "issue_alert",
"user": user.id,
},
)
continue

attachment = [
build_issue_notification_attachment(
context["group"],
event=context["event"],
tags=context["tags"],
rules=context["rules"],
)
]
payload = {
"token": token,
"channel": channel,
"link_names": 1,
"attachments": json.dumps(attachment),
}
try:
client.post("/chat.postMessage", data=payload, timeout=5)
except ApiError as e:
logger.info(
"notification.fail.slack_post",
extra={
"error": str(e),
"notification": "issue_alert",
"user": user.id,
"channel_id": channel,
},
)
continue

metrics.incr(
"issue_alert.notifications.sent",
instance="slack.issue_alert.notification",
skip_internal=False,
)