From f3f5898b4dd2102a4cfa7e6cc445d8a5c88c90a1 Mon Sep 17 00:00:00 2001 From: Alexander Zubarev Date: Thu, 6 Aug 2026 18:36:17 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=C2=AB?= =?UTF-8?q?alert-processor/app/notifications=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alert-processor/app/notifications/__init__.py | 1 + .../app/notifications/dispatcher.py | 115 +++++++++++ .../app/notifications/formatter.py | 192 ++++++++++++++++++ 3 files changed, 308 insertions(+) create mode 100644 alert-processor/app/notifications/__init__.py create mode 100644 alert-processor/app/notifications/dispatcher.py create mode 100644 alert-processor/app/notifications/formatter.py diff --git a/alert-processor/app/notifications/__init__.py b/alert-processor/app/notifications/__init__.py new file mode 100644 index 0000000..bc63beb --- /dev/null +++ b/alert-processor/app/notifications/__init__.py @@ -0,0 +1 @@ +# empty \ No newline at end of file diff --git a/alert-processor/app/notifications/dispatcher.py b/alert-processor/app/notifications/dispatcher.py new file mode 100644 index 0000000..3b310b9 --- /dev/null +++ b/alert-processor/app/notifications/dispatcher.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from app.mail_notifier import MailNotifier +from app.matrix_notifier import MatrixNotifier +from app.models import NotificationDecision, ProcessorForwardEnvelope +from app.notifications.formatter import NotificationFormatter + + +@dataclass +class DispatchReport: + attempted: bool = False + + matrix_attempted: bool = False + matrix_sent: bool = False + matrix_event_id: str | None = None + matrix_error: str | None = None + + matrix_image_attempted: bool = False + matrix_image_sent: bool = False + matrix_image_event_id: str | None = None + matrix_image_mxc_uri: str | None = None + matrix_image_error: str | None = None + + mail_attempted: bool = False + mail_sent: bool = False + mail_error: str | None = None + + errors: list[str] = field(default_factory=list) + + +class NotificationDispatcher: + def __init__( + self, + matrix_notifier: MatrixNotifier | None = None, + mail_notifier: MailNotifier | None = None, + ) -> None: + self.matrix_notifier = matrix_notifier + self.mail_notifier = mail_notifier + + async def dispatch( + self, + envelope: ProcessorForwardEnvelope, + decision: NotificationDecision, + ) -> DispatchReport: + report = DispatchReport() + + if not decision.notify or decision.suppressed: + return report + + report.attempted = True + + if "matrix" in decision.channels and self.matrix_notifier is not None: + report.matrix_attempted = True + + matrix_body = NotificationFormatter.format_matrix_message( + envelope=envelope, + decision=decision, + ) + matrix_result = await self.matrix_notifier.send_message(matrix_body) + + report.matrix_sent = matrix_result.ok + report.matrix_event_id = matrix_result.event_id + report.matrix_error = matrix_result.error + if matrix_result.error: + report.errors.append(f"matrix: {matrix_result.error}") + + # Если есть отрисованный PNG, отправляем его вторым сообщением + graph_image_path = envelope.event.graph_image_path + if graph_image_path and Path(graph_image_path).exists(): + report.matrix_image_attempted = True + + image_body = f"{envelope.event.trigger_name or 'Graph'} graph" + image_result = await self.matrix_notifier.send_image( + file_path=graph_image_path, + body=image_body, + ) + + report.matrix_image_sent = image_result.ok + report.matrix_image_event_id = image_result.event_id + report.matrix_image_mxc_uri = image_result.content_uri + report.matrix_image_error = image_result.error + if image_result.error: + report.errors.append(f"matrix_image: {image_result.error}") + + if "mail" in decision.channels and self.mail_notifier is not None: + report.mail_attempted = True + + subject = NotificationFormatter.format_mail_subject( + envelope=envelope, + decision=decision, + ) + body = NotificationFormatter.format_mail_body( + envelope=envelope, + decision=decision, + ) + + attachments: list[str] = [] + if envelope.event.graph_image_path: + attachments.append(envelope.event.graph_image_path) + + mail_result = await self.mail_notifier.send_message( + subject=subject, + body=body, + attachments=attachments, + ) + + report.mail_sent = mail_result.ok + report.mail_error = mail_result.error + if mail_result.error: + report.errors.append(f"mail: {mail_result.error}") + + return report \ No newline at end of file diff --git a/alert-processor/app/notifications/formatter.py b/alert-processor/app/notifications/formatter.py new file mode 100644 index 0000000..cfc9c02 --- /dev/null +++ b/alert-processor/app/notifications/formatter.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from app.config import settings +from app.models import NotificationDecision, ProcessorForwardEnvelope + + +class NotificationFormatter: + @staticmethod + def format_matrix_message( + envelope: ProcessorForwardEnvelope, + decision: NotificationDecision, + ) -> str: + e = envelope.event + ctx = e.zabbix_context or {} + + lines = [ + f"[{decision.severity or 'UNKNOWN'}] {decision.event_phase.upper()}", + f"Host: {e.host or 'unknown'}", + f"Service: {e.service or 'unknown'}", + f"Trigger: {e.trigger_name or 'unknown'}", + f"Reason: {decision.reason}", + f"Fingerprint: {decision.fingerprint or 'unknown'}", + f"Repeat count: {decision.repeat_count}", + ] + + if decision.triage_applied: + lines.append( + f"Triage: {decision.triage_verdict or 'unknown'}" + f" ({decision.triage_classification or 'unknown'}, source={decision.triage_source or 'unknown'})" + ) + if decision.triage_reason: + lines.append(f"Triage reason: {decision.triage_reason}") + + if decision.correlation_applied: + lines.append( + f"Correlation: role={decision.correlation_role or 'unknown'}" + f", kind={decision.correlation_kind or 'unknown'}" + ) + if decision.root_cause_candidate: + lines.append( + f"RCA: root cause candidate (related alerts in window: {decision.correlated_event_count})" + ) + if decision.parent_event_id: + lines.append(f"Parent event: {decision.parent_event_id}") + if decision.correlation_reason: + lines.append(f"Correlation reason: {decision.correlation_reason}") + + if decision.recovered_from_severity: + lines.append(f"Recovered from severity: {decision.recovered_from_severity}") + + if decision.flap_detected and decision.flap_reason: + lines.append(f"Flap: {decision.flap_reason}") + + if decision.suppressed and decision.suppress_reason: + lines.append(f"Suppress: {decision.suppress_reason}") + + if ctx.get("last_value"): + lines.append(f"Last value: {ctx['last_value']}") + if ctx.get("item_key"): + lines.append(f"Item key: {ctx['item_key']}") + if ctx.get("resolved_opdata"): + lines.append(f"OpData: {ctx['resolved_opdata']}") + + if decision.remediation_summary: + lines.append(f"Remediation: {decision.remediation_summary}") + + if decision.remediation_steps: + lines.append("Steps:") + for idx, step in enumerate(decision.remediation_steps, start=1): + lines.append(f"{idx}. {step}") + + if decision.remediation_commands: + lines.append("Commands:") + for cmd in decision.remediation_commands: + lines.append(f"- {cmd}") + + if e.event_url: + lines.append(f"Zabbix Event: {e.event_url}") + elif e.zabbix_url: + lines.append(f"Zabbix Event: {e.zabbix_url}") + + if e.graph_url: + lines.append(f"Graph: {e.graph_url}") + + return "\n".join(lines) + + @staticmethod + def format_mail_subject( + envelope: ProcessorForwardEnvelope, + decision: NotificationDecision, + ) -> str: + e = envelope.event + phase = decision.event_phase.upper() + severity = decision.severity or e.severity or "UNKNOWN" + host = e.host or "unknown-host" + trigger = e.trigger_name or "unknown-trigger" + return f"{settings.mail_subject_prefix} [{severity}] {phase} {host} :: {trigger}" + + @staticmethod + def format_mail_body( + envelope: ProcessorForwardEnvelope, + decision: NotificationDecision, + ) -> str: + e = envelope.event + ctx = e.zabbix_context or {} + + lines = [ + f"Severity: {decision.severity or e.severity or 'UNKNOWN'}", + f"Phase: {decision.event_phase}", + f"Host: {e.host or 'unknown'}", + f"Service: {e.service or 'unknown'}", + f"Trigger: {e.trigger_name or 'unknown'}", + f"Reason: {decision.reason}", + f"Fingerprint: {decision.fingerprint or 'unknown'}", + f"Repeat count: {decision.repeat_count}", + f"Correlation ID: {e.correlation_id}", + f"Event ID: {e.event_id or 'unknown'}", + ] + + if decision.triage_applied: + lines.append( + f"Triage: {decision.triage_verdict or 'unknown'}" + f" ({decision.triage_classification or 'unknown'}, source={decision.triage_source or 'unknown'})" + ) + if decision.triage_reason: + lines.append(f"Triage reason: {decision.triage_reason}") + + if decision.correlation_applied: + lines.append( + f"Correlation: role={decision.correlation_role or 'unknown'}" + f", kind={decision.correlation_kind or 'unknown'}" + ) + if decision.root_cause_candidate: + lines.append( + f"RCA: root cause candidate (related alerts in window: {decision.correlated_event_count})" + ) + if decision.parent_event_id: + lines.append(f"Parent event: {decision.parent_event_id}") + if decision.correlation_reason: + lines.append(f"Correlation reason: {decision.correlation_reason}") + + if decision.recovered_from_severity: + lines.append(f"Recovered from severity: {decision.recovered_from_severity}") + + if decision.flap_detected and decision.flap_reason: + lines.append(f"Flap reason: {decision.flap_reason}") + + if decision.suppressed and decision.suppress_reason: + lines.append(f"Suppress reason: {decision.suppress_reason}") + + if ctx.get("last_value"): + lines.append(f"Last value: {ctx['last_value']}") + if ctx.get("item_name"): + lines.append(f"Item name: {ctx['item_name']}") + if ctx.get("item_key"): + lines.append(f"Item key: {ctx['item_key']}") + if ctx.get("resolved_opdata"): + lines.append(f"OpData: {ctx['resolved_opdata']}") + if ctx.get("trigger_comments"): + lines.append(f"Trigger comments: {ctx['trigger_comments']}") + + if decision.remediation_summary: + lines.append("") + lines.append(f"Remediation: {decision.remediation_summary}") + + if decision.remediation_steps: + lines.append("Steps:") + for idx, step in enumerate(decision.remediation_steps, start=1): + lines.append(f"{idx}. {step}") + + if decision.remediation_commands: + lines.append("Commands:") + for cmd in decision.remediation_commands: + lines.append(f"- {cmd}") + + if decision.correlation_applied: + lines.append( + f"Correlation: role={decision.correlation_role or 'unknown'}" + f", kind={decision.correlation_kind or 'unknown'}" + f", source={decision.correlation_source or 'unknown'}" + f", confidence={decision.correlation_confidence or 'unknown'}" + ) + + if e.event_url: + lines.append(f"Zabbix Event URL: {e.event_url}") + elif e.zabbix_url: + lines.append(f"Zabbix Event URL: {e.zabbix_url}") + + if e.graph_url: + lines.append(f"Graph URL: {e.graph_url}") + + return "\n".join(lines) \ No newline at end of file