From 7ddd05e52e6cf0dde38aae6f6c297b25ce9a9e46 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Wed, 2 Oct 2024 15:47:06 -0300 Subject: [PATCH] Include link information for objects referenced in alert group timeline (#5112) Related to https://github.com/grafana/oncall/issues/4537. Related frontend changes: https://github.com/grafana/oncall/pull/5111/files#diff-98c45c177708c814aa5a8aafad36b0a76e9cf49d7e25dada214ae1ce9ed10699 --- .../alerts/models/alert_group_log_record.py | 67 +++++++-- .../tests/test_alert_group_log_record.py | 138 +++++++++++++++++- 2 files changed, 190 insertions(+), 15 deletions(-) diff --git a/engine/apps/alerts/models/alert_group_log_record.py b/engine/apps/alerts/models/alert_group_log_record.py index 3d88f17401..cd1c312d1c 100644 --- a/engine/apps/alerts/models/alert_group_log_record.py +++ b/engine/apps/alerts/models/alert_group_log_record.py @@ -242,12 +242,48 @@ class AlertGroupLogRecord(models.Model): "incident_title", ] + 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 - related_incident = self.render_incident_data_from_step_info(organization, self.get_step_specific_info()) + escalation_chain = self.alert_group.channel_filter.escalation_chain if self.alert_group.channel_filter else None + step_info = self.get_step_specific_info() + related_incident = self.render_incident_data_from_step_info(organization, step_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_with_tag=True)) @@ -261,6 +297,9 @@ def render_log_line_json(self): "created_at": created_at, "author": author, "incident": related_incident, + "escalation_chain": escalation_chain_data, + "schedule": schedule, + "webhook": webhook, } return result @@ -320,7 +359,9 @@ def rendered_log_line_action(self, for_slack=False, html=False, substitute_with_ 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: @@ -396,7 +437,9 @@ def rendered_log_line_action(self, for_slack=False, html=False, substitute_with_ 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" elif escalation_policy_step == EscalationPolicy.STEP_DECLARE_INCIDENT: @@ -404,16 +447,9 @@ def rendered_log_line_action(self, for_slack=False, html=False, substitute_with_ incident_data = self.render_incident_data_from_step_info(organization, step_specific_info) incident_link = incident_data["incident_link"] incident_title = incident_data["incident_title"] - - result += self.reason - if html: - result += f": {incident_title}" - elif for_slack: - result += f": <{incident_link}|{incident_title}>" - elif substitute_with_tag: - result += ": {{related_incident}}" - else: - result += f": {incident_title}" + tag = "related_incident" if substitute_with_tag else False + incident_text = self._make_log_line_link(incident_link, incident_title, html, for_slack, tag) + result += self.reason + f": {incident_text}" else: result += f'triggered step "{EscalationPolicy.get_step_display_name(escalation_policy_step)}"' elif self.type == AlertGroupLogRecord.TYPE_SILENCE: @@ -517,7 +553,10 @@ def rendered_log_line_action(self, for_slack=False, html=False, substitute_with_ 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 dbc668dc97..9dfaa84c35 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 @@ 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}