From ee2ae50f275f46ccd511aa466f75f140fcc9a060 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 3 Oct 2024 16:24:26 -0300 Subject: [PATCH] Include link information for objects referenced in alert group timeline (#5123) Reworked https://github.com/grafana/oncall/pull/5112 (post-revert) --- .../alerts/models/alert_group_log_record.py | 58 +++++++- .../tests/test_alert_group_log_record.py | 138 +++++++++++++++++- 2 files changed, 189 insertions(+), 7 deletions(-) diff --git a/engine/apps/alerts/models/alert_group_log_record.py b/engine/apps/alerts/models/alert_group_log_record.py index 3c4113a2..f4b796aa 100644 --- a/engine/apps/alerts/models/alert_group_log_record.py +++ b/engine/apps/alerts/models/alert_group_log_record.py @@ -227,14 +227,50 @@ class AlertGroupLogRecord(models.Model): STEP_SPECIFIC_INFO_KEYS = ["schedule_name", "custom_button_name", "usergroup_handle", "source_integration_name"] + def _make_log_line_link(self, url, title, html=False, for_slack=False, substitute_with_tag=False): + if html and url: + return f"{title}" + elif for_slack and url: + return f"<{url}|{title}>" + elif substitute_with_tag: + return f"{{{{{substitute_with_tag}}}}}" + else: + return title + def render_log_line_json(self): time = humanize.naturaldelta(self.alert_group.started_at - self.created_at) created_at = DateTimeField().to_representation(self.created_at) organization = self.alert_group.channel.organization author = self.author.short(organization) if self.author is not None else None + escalation_chain = self.alert_group.channel_filter.escalation_chain if self.alert_group.channel_filter else None + step_info = self.get_step_specific_info() + escalation_chain_data = ( + { + "pk": escalation_chain.public_primary_key, + "title": escalation_chain.name, + } + if escalation_chain + else None + ) + schedule = ( + { + "pk": self.escalation_policy.notify_schedule.public_primary_key, + "title": self.escalation_policy.notify_schedule.name, + } + if self.escalation_policy and self.escalation_policy.notify_schedule + else None + ) + webhook = ( + { + "pk": step_info["webhook_id"], + "title": step_info.get("webhook_name", "webhook"), + } + if step_info and "webhook_id" in step_info + else None + ) sf = SlackFormatter(organization) - action = sf.format(self.rendered_log_line_action(substitute_author_with_tag=True)) + action = sf.format(self.rendered_log_line_action(substitute_with_tag=True)) action = clean_markup(action) result = { @@ -244,6 +280,9 @@ class AlertGroupLogRecord(models.Model): "type": self.type, "created_at": created_at, "author": author, + "escalation_chain": escalation_chain_data, + "schedule": schedule, + "webhook": webhook, } return result @@ -258,7 +297,7 @@ class AlertGroupLogRecord(models.Model): result += self.rendered_log_line_action(for_slack=for_slack, html=html) return result - def rendered_log_line_action(self, for_slack=False, html=False, substitute_author_with_tag=False): + def rendered_log_line_action(self, for_slack=False, html=False, substitute_with_tag=False): from apps.alerts.models import EscalationPolicy result = "" @@ -276,7 +315,7 @@ class AlertGroupLogRecord(models.Model): elif self.action_source == ActionSource.BACKSYNC: author_name = "source integration " + step_specific_info.get("source_integration_name", "") elif self.author: - if substitute_author_with_tag: + if substitute_with_tag: author_name = "{{author}}" elif for_slack: author_name = self.author.get_username_with_slack_verbal() @@ -303,7 +342,9 @@ class AlertGroupLogRecord(models.Model): result += f'alert group assigned to route "{channel_filter.str_for_clients}"' if escalation_chain is not None: - result += f' with escalation chain "{escalation_chain.name}"' + tag = "escalation_chain" if substitute_with_tag else False + escalation_chain_text = self._make_log_line_link(None, escalation_chain.name, html, for_slack, tag) + result += f' with escalation chain "{escalation_chain_text}"' else: result += " with no escalation chain, skipping escalation" else: @@ -379,7 +420,9 @@ class AlertGroupLogRecord(models.Model): important_text = "" if escalation_policy_step == EscalationPolicy.STEP_NOTIFY_SCHEDULE_IMPORTANT: important_text = " (Important)" - result += f'triggered step "Notify on-call from Schedule {schedule_name}{important_text}"' + tag = "schedule" if substitute_with_tag else False + schedule_text = self._make_log_line_link(None, schedule_name, html, for_slack, tag) + result += f'triggered step "Notify on-call from Schedule {schedule_text}{important_text}"' elif escalation_policy_step == EscalationPolicy.STEP_REPEAT_ESCALATION_N_TIMES: result += "escalation started from the beginning" else: @@ -485,7 +528,10 @@ class AlertGroupLogRecord(models.Model): trigger = f"{author_name}" else: trigger = trigger or "escalation chain" - result += f"outgoing webhook `{webhook_name}` triggered by {trigger}" + tag = "webhook" if substitute_with_tag else False + webhook_text = self._make_log_line_link(None, webhook_name, html, for_slack, tag) + result += f"outgoing webhook `{webhook_text}` triggered by {trigger}" + elif self.type == AlertGroupLogRecord.TYPE_FAILED_ATTACHMENT: if self.alert_group.slack_message is not None: result += ( diff --git a/engine/apps/alerts/tests/test_alert_group_log_record.py b/engine/apps/alerts/tests/test_alert_group_log_record.py index dbc668dc..9dfaa84c 100644 --- a/engine/apps/alerts/tests/test_alert_group_log_record.py +++ b/engine/apps/alerts/tests/test_alert_group_log_record.py @@ -2,7 +2,8 @@ from unittest.mock import patch import pytest -from apps.alerts.models import AlertGroupLogRecord +from apps.alerts.models import AlertGroupLogRecord, EscalationPolicy +from apps.schedules.models import OnCallScheduleWeb @pytest.mark.django_db @@ -37,3 +38,138 @@ def test_trigger_update_signal( with patch("apps.alerts.tasks.send_update_log_report_signal") as mock_update_log_signal: alert_group.log_records.create(type=log_type) mock_update_log_signal.apply_async.assert_called_once() + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "for_slack, html, substitute_with_tag, expected", + [ + (True, False, False, 'with escalation chain "Escalation name"'), + (False, True, False, 'with escalation chain "Escalation name"'), + (False, False, True, 'with escalation chain "{{escalation_chain}}'), + ], +) +def test_log_record_escalation_chain_link( + make_organization_with_slack_team_identity, + make_alert_receive_channel, + make_escalation_chain, + make_channel_filter, + make_alert_group, + for_slack, + html, + substitute_with_tag, + expected, +): + organization, _ = make_organization_with_slack_team_identity() + alert_receive_channel = make_alert_receive_channel(organization) + escalation_chain = make_escalation_chain(organization, name="Escalation name") + channel_filter = make_channel_filter(alert_receive_channel, escalation_chain=escalation_chain) + alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + alert_group.raw_escalation_snapshot = alert_group.build_raw_escalation_snapshot() + + log = alert_group.log_records.create( + type=AlertGroupLogRecord.TYPE_ROUTE_ASSIGNED, + ) + + log_line = log.rendered_log_line_action(for_slack=for_slack, html=html, substitute_with_tag=substitute_with_tag) + assert expected in log_line + + log_data = log.render_log_line_json() + escalation_chain_data = log_data.get("escalation_chain") + assert escalation_chain_data == {"pk": escalation_chain.public_primary_key, "title": escalation_chain.name} + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "for_slack, html, substitute_with_tag, expected", + [ + (True, False, False, "Notify on-call from Schedule 'Schedule name'"), + (False, True, False, "Notify on-call from Schedule 'Schedule name'"), + (False, False, True, "Notify on-call from Schedule {{schedule}}"), + ], +) +def test_log_record_schedule_link( + make_organization_with_slack_team_identity, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_schedule, + make_escalation_chain, + make_escalation_policy, + for_slack, + html, + substitute_with_tag, + expected, +): + organization, _ = make_organization_with_slack_team_identity() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, name="Schedule name") + escalation_chain = make_escalation_chain(organization, name="Escalation name") + channel_filter = make_channel_filter(alert_receive_channel, escalation_chain=escalation_chain) + escalation_policy = make_escalation_policy( + escalation_chain=channel_filter.escalation_chain, + escalation_policy_step=EscalationPolicy.STEP_NOTIFY_SCHEDULE, + notify_schedule=schedule, + ) + + log = alert_group.log_records.create( + type=AlertGroupLogRecord.TYPE_ESCALATION_TRIGGERED, + step_specific_info={"schedule_name": schedule.name}, + escalation_policy=escalation_policy, + ) + + log_line = log.rendered_log_line_action(for_slack=for_slack, html=html, substitute_with_tag=substitute_with_tag) + assert expected in log_line + + log_data = log.render_log_line_json() + schedule_data = log_data.get("schedule") + assert schedule_data == {"pk": schedule.public_primary_key, "title": schedule.name} + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "for_slack, html, substitute_with_tag, expected", + [ + (True, False, False, "outgoing webhook `Webhook name`"), + (False, True, False, "outgoing webhook `Webhook name`"), + (False, False, True, "outgoing webhook `{{webhook}}`"), + ], +) +def test_log_record_webhook_link( + make_organization_with_slack_team_identity, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_custom_webhook, + make_escalation_chain, + make_escalation_policy, + for_slack, + html, + substitute_with_tag, + expected, +): + organization, _ = make_organization_with_slack_team_identity() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + webhook = make_custom_webhook(organization, name="Webhook name") + escalation_chain = make_escalation_chain(organization, name="Escalation name") + channel_filter = make_channel_filter(alert_receive_channel, escalation_chain=escalation_chain) + escalation_policy = make_escalation_policy( + escalation_chain=channel_filter.escalation_chain, + escalation_policy_step=EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK, + custom_webhook=webhook, + ) + + log = alert_group.log_records.create( + type=AlertGroupLogRecord.TYPE_CUSTOM_WEBHOOK_TRIGGERED, + step_specific_info={"webhook_id": webhook.public_primary_key, "webhook_name": webhook.name}, + escalation_policy=escalation_policy, + ) + + log_line = log.rendered_log_line_action(for_slack=for_slack, html=html, substitute_with_tag=substitute_with_tag) + assert expected in log_line + + log_data = log.render_log_line_json() + webhook_data = log_data.get("webhook") + assert webhook_data == {"pk": webhook.public_primary_key, "title": webhook.name}