""" Отправка метрик в Zabbix. Два режима: - sender: zabbix_sender (активный push). - userparameter: запись JSON-файла + конфиг для Zabbix агента (пассивный опрос). """ from __future__ import annotations import asyncio import concurrent.futures import json import logging import os import subprocess import tempfile from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Optional from ses_monitor.config import ZabbixConfig, ZabbixMode log = logging.getLogger("ses_monitor.zabbix") @dataclass class ZabbixMetric: """Одна метрика для Zabbix.""" host: str key: str value: str def to_line(self) -> str: """Форматирует метрику в строку для zabbix_sender.""" return f"{self.host} {self.key} {self.value}" class ZabbixSender: """Асинхронная обёртка над zabbix_sender. Отправляет метрики через stdin утилиты zabbix_sender. Использует run_in_executor + subprocess.run (синхронный), чтобы избежать проблем с asyncio child watcher на Python 3.7. """ def __init__(self, config: ZabbixConfig): self._config = config self._metrics: List[ZabbixMetric] = [] self._executor: Optional[concurrent.futures.ThreadPoolExecutor] = None @property def enabled(self) -> bool: """Включена ли отправка в Zabbix.""" return self._config.enabled def add(self, key: str, value: Any) -> None: """Добавляет метрику в буфер отправки. Args: key: Ключ метрики (например, "ses.api.liveness"). value: Значение. Числа и булевы конвертируются в строку. """ str_value = str(value) if not isinstance(value, bool) else str(int(value)) metric = ZabbixMetric( host=self._config.host, key=key, value=str_value, ) self._metrics.append(metric) def _run_sender(self, input_data: str) -> bool: """Синхронный запуск zabbix_sender (выполняется в thread pool).""" try: proc = subprocess.run( [ self._config.sender_path, "-z", self._config.server, "-p", str(self._config.port), "-s", self._config.host, "-i", "-", ], input=input_data.encode("utf-8"), capture_output=True, timeout=30, ) if proc.returncode != 0: log.error( "zabbix_sender завершился с кодом %d: %s", proc.returncode, proc.stderr.decode("utf-8", errors="replace").strip(), ) return False output = proc.stdout.decode("utf-8", errors="replace") log.debug( "Zabbix: отправлено метрик. Ответ: %s", output.strip(), ) return True except FileNotFoundError: log.error( "zabbix_sender не найден по пути %s. Установите пакет.", self._config.sender_path, ) return False except subprocess.TimeoutExpired: log.error("zabbix_sender: таймаут отправки (30с)") return False except Exception as e: log.error("zabbix_sender: ошибка отправки: %s", e) return False async def flush(self) -> bool: """Отправляет накопленные метрики в Zabbix. Returns: True если отправка успешна, False при ошибке. """ if not self._metrics: return True if not self._config.enabled: self._metrics.clear() return True lines = "\n".join(m.to_line() for m in self._metrics) + "\n" self._metrics.clear() loop = asyncio.get_running_loop() return await loop.run_in_executor(self._executor, self._run_sender, lines) class UserParameterWriter: """Запись метрик в JSON для Zabbix UserParameter. Пишет атомарный JSON во временный файл с последующей заменой. Zabbix агент читает файл через UserParameter-скрипт zbx_get.py. """ def __init__(self, config: ZabbixConfig): self._config = config self._metrics: Dict[str, str] = {} self._executor: Optional[concurrent.futures.ThreadPoolExecutor] = None @property def enabled(self) -> bool: """Включена ли запись метрик.""" return self._config.enabled def add(self, key: str, value: Any) -> None: """Добавляет метрику в буфер. Args: key: Ключ метрики (например, "ses.api.liveness"). value: Значение. Конвертируется в строку. """ str_value = str(value) if not isinstance(value, bool) else str(int(value)) self._metrics[key] = str_value async def flush(self) -> bool: """Сбрасывает буфер метрик в JSON-файл и обновляет конфиг агента. Returns: True если запись успешна, False при ошибке. """ if not self._metrics: return True if not self._config.enabled: self._metrics.clear() return True data = dict(self._metrics) self._metrics.clear() loop = asyncio.get_running_loop() ok = await loop.run_in_executor( self._executor, self._write_json, data ) return ok def _write_json(self, data: Dict[str, str]) -> bool: """Атомарная запись JSON-файла с метриками.""" metrics_path = Path(self._config.metrics_file) try: metrics_path.parent.mkdir(parents=True, exist_ok=True) tmp = tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", dir=str(metrics_path.parent), prefix=".metrics_", suffix=".tmp", delete=False, ) tmp_path = tmp.name try: json.dump(data, tmp, ensure_ascii=False, indent=2) tmp.write("\n") tmp.close() os.replace(tmp_path, str(metrics_path)) os.chmod(str(metrics_path), 0o644) except Exception: if os.path.exists(tmp_path): os.unlink(tmp_path) raise return True except Exception as e: log.error("Ошибка записи JSON-файла метрик: %s", e) return False def generate_agentd_config(self) -> str: """Генерирует содержимое конфигурационного файла UserParameter. Returns: Строка с конфигурацией для Zabbix агента. """ return ( "# Автоматически сгенерировано ses-monitor.\n" "# Требуется: Include=/opt/ses-monitor/config/zbx_agentd_ses.conf\n" f"UserParameter=ses.metrics,/opt/ses-monitor/scripts/zbx_get.py\n" )