Skip to content

Commit

Permalink
Mattermost Alert Flow
Browse files Browse the repository at this point in the history
Add message type column
  • Loading branch information
ravishankar15 committed Oct 14, 2024
1 parent 1ca80ad commit 4b4b468
Show file tree
Hide file tree
Showing 21 changed files with 542 additions and 9 deletions.
5 changes: 5 additions & 0 deletions engine/apps/alerts/models/alert_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
)
from apps.base.models import UserNotificationPolicyLogRecord
from apps.labels.models import AlertGroupAssociatedLabel
from apps.mattermost.models import MattermostMessage
from apps.slack.models import SlackMessage

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -1989,6 +1990,10 @@ def slack_message(self) -> typing.Optional["SlackMessage"]:
except AttributeError:
return self.slack_messages.order_by("created_at").first()

@property
def mattermost_message(self) -> typing.Optional["MattermostMessage"]:
return self.mattermost_messages.order_by("created_at").first()

@cached_property
def last_stop_escalation_log(self):
from apps.alerts.models import AlertGroupLogRecord
Expand Down
6 changes: 6 additions & 0 deletions engine/apps/base/models/live_setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class LiveSetting(models.Model):
"MATTERMOST_HOST",
"MATTERMOST_BOT_TOKEN",
"MATTERMOST_LOGIN_RETURN_REDIRECT_HOST",
"MATTERMOST_SIGNING_SECRET",
)

DESCRIPTIONS = {
Expand Down Expand Up @@ -217,6 +218,11 @@ class LiveSetting(models.Model):
"https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup"
"' target='_blank'>instruction</a> for details how to set up Mattermost. "
),
"MATTERMOST_SIGNING_SECRET": (
"Check <a href='"
"https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup"
"' target='_blank'>instruction</a> for details how to set up Mattermost. "
),
}

SECRET_SETTING_NAMES = (
Expand Down
87 changes: 87 additions & 0 deletions engine/apps/mattermost/alert_group_representative.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import logging

from rest_framework import status

from apps.alerts.models import AlertGroup
from apps.alerts.representative import AlertGroupAbstractRepresentative
from apps.mattermost.alert_rendering import MattermostMessageRenderer
from apps.mattermost.client import MattermostClient
from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid
from apps.mattermost.tasks import on_alert_group_action_triggered_async, on_create_alert_async

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)


class AlertGroupMattermostRepresentative(AlertGroupAbstractRepresentative):
def __init__(self, log_record) -> None:
self.log_record = log_record

def is_applicable(self):
from apps.mattermost.models import MattermostChannel

organization = self.log_record.alert_group.channel.organization
handler_exists = self.log_record.type in self.get_handler_map().keys()

mattermost_channels = MattermostChannel.objects.filter(organization=organization)
return handler_exists and mattermost_channels.exists()

@staticmethod
def get_handler_map():
from apps.alerts.models import AlertGroupLogRecord

return {
AlertGroupLogRecord.TYPE_ACK: "alert_group_action",
AlertGroupLogRecord.TYPE_UN_ACK: "alert_group_action",
AlertGroupLogRecord.TYPE_AUTO_UN_ACK: "alert_group_action",
AlertGroupLogRecord.TYPE_RESOLVED: "alert_group_action",
AlertGroupLogRecord.TYPE_UN_RESOLVED: "alert_group_action",
AlertGroupLogRecord.TYPE_ACK_REMINDER_TRIGGERED: "alert_group_action",
AlertGroupLogRecord.TYPE_SILENCE: "alert_group_action",
AlertGroupLogRecord.TYPE_UN_SILENCE: "alert_group_action",
AlertGroupLogRecord.TYPE_ATTACHED: "alert_group_action",
AlertGroupLogRecord.TYPE_UNATTACHED: "alert_group_action",
}

def on_alert_group_action(self, alert_group: AlertGroup):
logger.info(f"Update mattermost message for alert_group {alert_group.pk}")
payload = MattermostMessageRenderer(alert_group).render_alert_group_message()
mattermost_message = alert_group.mattermost_message
try:
client = MattermostClient()
client.update_post(post_id=mattermost_message.post_id, data=payload)
except MattermostAPITokenInvalid:
logger.error(f"Mattermost API token is invalid could not create post for alert {alert_group.pk}")
except MattermostAPIException as ex:
logger.error(f"Mattermost API error {ex}")
if ex.status not in [status.HTTP_401_UNAUTHORIZED]:
raise ex

@staticmethod
def on_create_alert(**kwargs):
alert_pk = kwargs["alert"]
on_create_alert_async.apply_async((alert_pk,))

@staticmethod
def on_alert_group_action_triggered(**kwargs):
from apps.alerts.models import AlertGroupLogRecord

log_record = kwargs["log_record"]
if isinstance(log_record, AlertGroupLogRecord):
log_record_id = log_record.pk
else:
log_record_id = log_record
on_alert_group_action_triggered_async.apply_async((log_record_id,))

def get_handler(self):
handler_name = self.get_handler_name()
logger.info(f"Using '{handler_name}' handler to process alert action in mattermost")
if hasattr(self, handler_name):
handler = getattr(self, handler_name)
else:
handler = None

return handler

def get_handler_name(self):
return self.HANDLER_PREFIX + self.get_handler_map()[self.log_record.type]
128 changes: 128 additions & 0 deletions engine/apps/mattermost/alert_rendering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer
from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater
from apps.alerts.models import Alert, AlertGroup
from apps.mattermost.utils import MattermostEventAuthenticator
from common.api_helpers.utils import create_engine_url
from common.utils import is_string_with_visible_characters, str_or_backup


class MattermostMessageRenderer:
def __init__(self, alert_group: AlertGroup):
self.alert_group = alert_group

def render_alert_group_message(self):
attachments = AlertGroupMattermostRenderer(self.alert_group).render_alert_group_attachments()
return {"props": {"attachments": attachments}}


class AlertMattermostTemplater(AlertTemplater):
RENDER_FOR_MATTERMOST = "mattermost"

def _render_for(self) -> str:
return self.RENDER_FOR_MATTERMOST


class AlertMattermostRenderer(AlertBaseRenderer):
def __init__(self, alert: Alert):
super().__init__(alert)
self.channel = alert.group.channel

@property
def templater_class(self):
return AlertMattermostTemplater

def render_alert_attachments(self):
attachments = []
title = str_or_backup(self.templated_alert.title, "Alert")
message = ""
if is_string_with_visible_characters(self.templated_alert.message):
message = self.templated_alert.message
attachments.append(
{
"fallback": "{}: {}".format(self.channel.get_integration_display(), self.alert.title),
"title": title,
"title_link": self.templated_alert.source_link,
"text": message,
"image_url": self.templated_alert.image_url,
}
)
return attachments


class AlertGroupMattermostRenderer(AlertGroupBaseRenderer):
def __init__(self, alert_group: AlertGroup):
super().__init__(alert_group)

self.alert_renderer = self.alert_renderer_class(self.alert_group.alerts.last())

@property
def alert_renderer_class(self):
return AlertMattermostRenderer

def render_alert_group_attachments(self):
attachments = self.alert_renderer.render_alert_attachments()
alert_group = self.alert_group

if alert_group.resolved:
attachments.append(
{
"fallback": "Resolved...",
"text": alert_group.get_resolve_text(),
}
)
elif alert_group.acknowledged:
attachments.append(
{
"fallback": "Acknowledged...",
"text": alert_group.get_acknowledge_text(),
}
)

# append buttons to the initial attachment
attachments[0]["actions"] = self._get_buttons_attachments()

return self._set_attachments_color(attachments)

def _get_buttons_attachments(self):
actions = []

def _make_actions(id, name, token):
return {
"id": id,
"name": name,
"integration": {
"url": create_engine_url("api/internal/v1/mattermost/event/"),
"context": {
"action": id,
"token": token,
},
},
}

token = MattermostEventAuthenticator.create_token(organization=self.alert_group.channel.organization)
if not self.alert_group.resolved:
if self.alert_group.acknowledged:
actions.append(_make_actions("unacknowledge", "Unacknowledge", token))
else:
actions.append(_make_actions("acknowledge", "Acknonwledge", token))

if self.alert_group.resolved:
actions.append(_make_actions("unresolve", "Unresolve", token))
else:
actions.append(_make_actions("resolve", "Resolve", token))

return actions

def _set_attachments_color(self, attachments):
color = "#a30200" # danger
if self.alert_group.silenced:
color = "#dddddd" # slack-grey
if self.alert_group.acknowledged:
color = "#daa038" # warning
if self.alert_group.resolved:
color = "#2eb886" # good

for attachment in attachments:
attachment["color"] = color

return attachments
3 changes: 3 additions & 0 deletions engine/apps/mattermost/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@

class MattermostConfig(AppConfig):
name = "apps.mattermost"

def ready(self) -> None:
import apps.mattermost.signals # noqa: F401
24 changes: 24 additions & 0 deletions engine/apps/mattermost/client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from dataclasses import dataclass
from typing import Optional

Expand Down Expand Up @@ -33,6 +34,13 @@ class MattermostChannel:
display_name: str


@dataclass
class MattermostPost:
post_id: str
channel_id: str
user_id: str


class MattermostClient:
def __init__(self, token: Optional[str] = None) -> None:
self.token = token or settings.MATTERMOST_BOT_TOKEN
Expand Down Expand Up @@ -82,3 +90,19 @@ def get_user(self, user_id: str = "me"):
self._check_response(response)
data = response.json()
return MattermostUser(user_id=data["id"], username=data["username"], nickname=data["nickname"])

def create_post(self, channel_id: str, data: dict):
url = f"{self.base_url}/posts"
data.update({"channel_id": channel_id})
response = requests.post(url=url, data=json.dumps(data), timeout=self.timeout, auth=TokenAuth(self.token))
self._check_response(response)
data = response.json()
return MattermostPost(post_id=data["id"], channel_id=data["channel_id"], user_id=data["user_id"])

def update_post(self, post_id: str, data: dict):
url = f"{self.base_url}/posts/{post_id}"
data.update({"id": post_id})
response = requests.put(url=url, data=json.dumps(data), timeout=self.timeout, auth=TokenAuth(self.token))
self._check_response(response)
data = response.json()
return MattermostPost(post_id=data["id"], channel_id=data["channel_id"], user_id=data["user_id"])
8 changes: 8 additions & 0 deletions engine/apps/mattermost/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ def __init__(self, status, url, msg="", method="GET"):

def __str__(self) -> str:
return f"MattermostAPIException: status={self.status} url={self.url} method={self.method} error={self.msg}"


class MattermostEventTokenInvalid(Exception):
def __init__(self, msg=""):
self.msg = msg

def __str__(self):
return f"MattermostEventTokenInvalid message={self.msg}"
14 changes: 13 additions & 1 deletion engine/apps/mattermost/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 4.2.15 on 2024-10-07 02:12
# Generated by Django 4.2.15 on 2024-10-14 08:16

import apps.mattermost.models.channel
import django.core.validators
Expand All @@ -11,6 +11,7 @@ class Migration(migrations.Migration):
initial = True

dependencies = [
('alerts', '0060_relatedincident'),
('user_management', '0022_alter_team_unique_together'),
]

Expand All @@ -26,6 +27,17 @@ class Migration(migrations.Migration):
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_connection', to='user_management.user')),
],
),
migrations.CreateModel(
name='MattermostMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('post_id', models.CharField(max_length=100)),
('channel_id', models.CharField(max_length=100)),
('message_type', models.IntegerField(choices=[(0, 'Alert group message'), (1, 'Log message')])),
('created_at', models.DateTimeField(auto_now_add=True)),
('alert_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_messages', to='alerts.alertgroup')),
],
),
migrations.CreateModel(
name='MattermostChannel',
fields=[
Expand Down
1 change: 1 addition & 0 deletions engine/apps/mattermost/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from .channel import MattermostChannel # noqa: F401
from .message import MattermostMessage # noqa F401
from .user import MattermostUser # noqa F401
11 changes: 11 additions & 0 deletions engine/apps/mattermost/models/channel.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import typing

from django.conf import settings
from django.core.validators import MinLengthValidator
from django.db import models, transaction

from apps.alerts.models import AlertGroup
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length

Expand Down Expand Up @@ -44,6 +47,14 @@ class MattermostChannel(models.Model):
class Meta:
unique_together = ("organization", "channel_id")

@classmethod
def get_channel_for_alert_group(cls, alert_group: AlertGroup) -> typing.Optional["MattermostChannel"]:
default_channel = cls.objects.filter(
organization=alert_group.channel.organization, is_default_channel=True
).first()

return default_channel

def make_channel_default(self, author):
try:
old_default_channel = MattermostChannel.objects.get(organization=self.organization, is_default_channel=True)
Expand Down
Loading

0 comments on commit 4b4b468

Please sign in to comment.