Initial commit: SES Monitor

Watchdog-демон для непрерывного мониторинга SES.
- Liveness-проверки API, лицензии, списка роботов
- Диалоговые сценарии из YAML с агрегацией в 6 фиксированных Zabbix-метрик
- Доставка: zabbix_sender (push) или UserParameter (poll)
- Hot-reload сценариев без перезапуска
This commit is contained in:
Maksim Totmin 2026-06-19 21:04:05 +07:00
commit 8dca7eb46b
25 changed files with 4267 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# --- Python ---
__pycache__/
*.py[cod]
*.egg-info/
*.egg
dist/
build/
# --- Virtual environment ---
.venv/
venv/
# --- IDE / editors ---
.vscode/
.idea/
*.swp
*.swo
*~
# --- OS ---
.DS_Store
Thumbs.db
# --- Project specific ---
config/config.yaml

330
README.md Normal file
View File

@ -0,0 +1,330 @@
# SES Monitor
Система непрерывного мониторинга диалоговой сценарной машины **SES** (Service Engine Server).
## Что делает
Watchdog-демон, который:
- **Проверяет доступность** SES API каждые 30 секунд
- **Имитирует диалоги пользователей** по сценариям, описанным в YAML-файлах
- **Отправляет метрики в Zabbix** через zabbix_sender или UserParameter
SES — универсальная сценарная машина, поэтому мониторинг не привязан к конкретному проекту. Один и тот же демон может обслуживать любой сценарий: голосового ассистента, чат-бота, IVR, диалогов поддержки и любых других.
## Архитектура
```
┌─────────────────────────────────────────────────────┐
│ ses-monitor (watchdog-демон) │
│ │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ LivenessCheck │ │ ScenarioRunner │ │
│ │ (30 сек) │ │ (по YAML) │ │
│ │ │ │ │ │
│ │ GET /hostname│ │ POST /ses/ask │ │
│ │ GET /license │ │ шаги+expect │ │
│ └──────┬───────┘ └───────┬─────────┘ │
│ │ │ │
│ ┌──────▼──────────────────▼──────┐ │
│ │ Zabbix (sender / UP) │ │
│ └──────────────┬─────────────────┘ │
└─────────────────┼────────────────────────────────────┘
│ TCP 10051 / JSON file
┌───────────▼───────────┐
│ Zabbix Server │
│ (триггеры, алерты) │
└───────────────────────┘
```
## Режимы доставки в Zabbix
Монитор поддерживает два режима отправки метрик:
### sender (по умолчанию) — zabbix_sender (активный push)
Монитор запускает утилиту `zabbix_sender` и передаёт метрики через stdin.
Данные отправляются на Zabbix сервер или прокси (порт 10051, trapper).
В Zabbix элементы данных имеют тип "Zabbix траппер".
Требуется: установленный zabbix_sender на сервере с монитором.
### userparameter — Zabbix агент (пассивный опрос)
Монитор пишет JSON со всеми метриками в файл (`/var/tmp/ses_monitor/metrics.json`).
Zabbix агент по запросу сервера запускает скрипт `scripts/zbx_get.py`,
который возвращает содержимое JSON-файла.
В Zabbix используется master-элемент `ses.metrics` (тип "Zabbix агент", текст)
и зависимые элементы с препроцессингом JSONPath.
Настройка:
1. Импортировать шаблон `zabbix/zbx_ses_template_5.0_up.xml` в Zabbix
2. Привязать шаблон к хосту (Configuration → Hosts → выберите хост → Templates)
3. В конфиге монитора `config/config.yaml` установить `mode: "userparameter"`
4. В `/etc/zabbix/zabbix_agentd.conf` добавить строку:
```
Include=/opt/ses-monitor/config/zbx_agentd_ses.conf
```
5. Перезапустить Zabbix агента: `sudo systemctl restart zabbix-agent`
## Структура проекта
```
/opt/ses-monitor/
├── src/
│ └── ses_monitor/ # Python-пакет (src-layout)
│ ├── __init__.py # Версия, метаданные
│ ├── __main__.py # Точка входа: python -m ses_monitor
│ ├── main.py # Главный asyncio watchdog-цикл
│ ├── config.py # Загрузка YAML-конфигурации
│ ├── checker.py # Liveness-проверки (API, лицензия, роботы)
│ ├── session_client.py # HTTP-клиент для SES API
│ ├── scenario_runner.py # Движок выполнения сценариев
│ ├── log_client.py # Чтение логов SES (задел на будущее)
│ ├── zabbix.py # Интеграция с Zabbix (sender / UserParameter)
│ └── utils.py # Утилиты (логирование, JSON-пути, проверки)
├── config/
│ ├── config.yaml.example # Шаблон конфигурации (скопировать в config.yaml)
│ └── scenarios/
│ ├── example_liveness.yaml # Пример: быстрая проверка
│ └── example_dialog.yaml # Пример: многошаговый диалог
├── zabbix/
│ ├── zbx_ses_template_5.0.xml # Шаблон для sender (trapper)
│ ├── zbx_ses_template_5.0_up.xml # Шаблон для UserParameter
│ └── zbx_ses_monitor.conf # UserParameter для Zabbix агента
├── scripts/
│ └── zbx_get.py # Скрипт чтения JSON для UserParameter
├── systemd/
│ └── ses-monitor.service # Systemd unit
├── tests/
│ ├── __init__.py
│ └── test_scenario_runner.py # 48 тестов
├── pyproject.toml # Зависимости и метаданные пакета
└── README.md
```
## Установка на сервере
### 1. Требования
- **Python 3.9+**
- **Git** — для клонирования репозитория
- **Zabbix Agent** с утилитой `zabbix_sender`
- Доступ к SES API (`http://<ses-host>:6190`)
- Доступ к Zabbix Server по порту 10051
- SSH-ключ, добавленный в Git-репозиторий (`git.totmin.ru`)
### 2. Установка пакета
```bash
# Клонируем репозиторий на сервер
git clone git@git.totmin.ru:en2zmax/ses-monitor.git /opt/ses-monitor
# Переходим в каталог проекта
cd /opt/ses-monitor
# Создаём виртуальное окружение и устанавливаем пакет
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
# Создаём каталог для логов
mkdir -p /var/log/ses-monitor
```
### 3. Настройка конфигурации
Скопируйте шаблон и отредактируйте:
```bash
cp config/config.yaml.example config/config.yaml
nano config/config.yaml
```
Основные поля:
```yaml
ses:
base_url: "http://YOUR_SES_HOST:6190" # URL вашего SES API
channel: "monitoring" # Канал для мониторинговых сессий
request_timeout: 30
zabbix:
enabled: true
mode: "userparameter" # или "sender"
host: "my-server-name" # Имя хоста в Zabbix
server: "127.0.0.1" # IP Zabbix сервера (только sender)
port: 10051
sender_path: "/usr/bin/zabbix_sender"
metrics_file: "/var/tmp/ses_monitor/metrics.json" # (только userparameter)
```
### 4. Создание сценария
Скопируйте пример и адаптируйте под ваш проект:
```bash
cp config/scenarios/example_liveness.yaml config/scenarios/my_project.yaml
```
### 5. Запуск
```bash
# Проверка вручную:
cd /opt/ses-monitor
.venv/bin/python -m ses_monitor
# Установка как systemd-сервиса:
cp systemd/ses-monitor.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now ses-monitor
# Просмотр логов:
journalctl -u ses-monitor -f
```
### 6. Импорт шаблона Zabbix
1. Zabbix Frontend → **Configuration → Templates → Import**
2. Выбрать файл `zabbix/zbx_ses_template_5.0.xml` (для sender) или `zabbix/zbx_ses_template_5.0_up.xml` (для UserParameter)
3. Нажать Import
4. Привязать шаблон "SES Monitor" к нужному хосту
## Формат сценариев
Сценарии описываются в YAML-файлах в каталоге `config/scenarios/`. Это гибкий DSL, который не зависит от конкретного проекта SES.
### Полный формат
```yaml
# Имя сценария (используется в логах)
name: "example: проверка диалога"
# Описание для документации
description: "Проверяет что бот корректно отвечает на вопросы"
# Включён ли сценарий (false — отключён)
enabled: true
# Интервал запуска в секундах
interval: 300
# Таймаут HTTP-запроса в секундах
timeout: 30
# ID робота (обязательно). Узнать: GET /ses/robot/listall
robot_id: "uuid-вашего-робота"
# Канал (если не указан — берётся из config.yaml)
channel: "monitoring"
# Не переоткрывать закрытые сессии (false — создаём новую сессию каждый раз)
restart_closed: false
# Шаги сценария
steps:
- send: "Привет"
expect:
# Каждое ожидание проверяет значение в JSON-ответе
- path: "error"
equals: 0
- path: "answer[0].messages[0]"
contains: "Здрав"
- send: "Как дела?"
expect:
- path: "error"
equals: 0
- path: "answer"
not_empty: true
```
### Условия проверки (expect)
| Условие | Описание | Пример |
|---|---|---|
| `equals` | Точное совпадение | `equals: 0` |
| `contains` | Подстрока содержится (без учёта регистра) | `contains: "здравствуйте"` |
| `not_contains` | Подстрока отсутствует | `not_contains: "ошибка"` |
| `not_empty` | Поле не пустое | `not_empty: true` |
| `regex` | Регулярное выражение | `regex: "\\d{10,}"` |
| `exists` | Поле существует и не None | `exists` (без значения) |
### Пути JSON (path)
Формат: `ключ.подключ[индекс].поле`
```yaml
# Корневое поле
path: "error"
# Массив → первый элемент → поле
path: "answer[0].messages[0]"
# Глубокая вложенность
path: "request.data[0].reply.confidence"
```
## Как адаптировать под ваш проект
1. **Узнайте robot_id** вашего сценария:
```bash
curl -s http://<ses-host>:6190/ses/robot/listall
```
2. **Пропишите robot_id** в файле сценария (поле `robot_id` в YAML)
3. **Создайте файл сценария**, описав шаги диалога:
```bash
cp config/scenarios/example_liveness.yaml config/scenarios/$(hostname).yaml
vim config/scenarios/$(hostname).yaml
```
4. **Протестируйте вручную** шаги через curl:
```bash
curl -X POST "http://<ses-host>:6190/ses/ask/<robot_id>?text=Привет&channel=monitoring"
```
5. **Запустите демон** — метрики агрегируются автоматически (шаблон Zabbix универсален)
## Расширение системы
### Добавление нового сценария
1. Создать `config/scenarios/my_scenario.yaml`
2. Скопировать структуру из примеров
3. Указать `robot_id` вашего робота (узнать: `GET /ses/robot/listall`)
4. Описать шаги и ожидания
5. Сценарий автоматически подхватится через hot-reload (каждые 60 секунд)
### Добавление новой метрики
1. В нужном модуле добавить поле в датакласс-результат
2. В `main.py` вызвать `self._zabbix.add(key, value)`
3. Добавить item в Zabbix шаблон
### Prometheus/Grafana
При необходимости можно добавить экспорт метрик в Prometheus:
```python
from prometheus_client import Gauge, start_http_server
api_liveness = Gauge("ses_api_liveness", "SES API availability")
```
## Мониторинг самого монитора
- **systemd**: `systemctl is-active ses-monitor`
- **Zabbix Active Agent**: `proc.num[python,root,ses_monitor]`
- **Логи**: `journalctl -u ses-monitor --since "10 min ago" -o cat | grep ERROR`
## Отказоустойчивость
- **Автоперезапуск**: systemd Restart=always, RestartSec=10
- **Graceful shutdown**: по SIGTERM отправляет последний батч метрик
- **Таймауты**: все HTTP-запросы с ограничением по времени (по умолчанию 30с)
- **Изоляция сценариев**: ошибка в одном сценарии не влияет на другие
- **Hot-reload**: сценарии перечитываются без перезапуска демона
## Лицензия
MIT

View File

@ -0,0 +1,89 @@
# =============================================================================
# Основная конфигурация системы мониторинга SES (шаблон)
# =============================================================================
# Скопируйте в config.yaml и заполните свои значения.
# cp config.yaml.example config.yaml
# Формат: YAML 1.2
# ---------------------------------------------------------------------------
# Настройки подключения к SES серверу
# ---------------------------------------------------------------------------
ses:
# Базовый URL SES API
base_url: "http://YOUR_SES_HOST:6190"
# Эндпоинт для получения списка роботов:
# /ses/robot/list — только опубликованные
# /ses/robot/listall — все (включая черновики)
robot_list_endpoint: "/ses/robot/listall"
# Канал для мониторинговых запросов (не влияет на реальных пользователей)
channel: "monitoring"
# Таймаут HTTP-запросов в секундах
request_timeout: 30
# ---------------------------------------------------------------------------
# Настройки интеграции с Zabbix
# ---------------------------------------------------------------------------
zabbix:
# Включить отправку метрик в Zabbix
enabled: true
# Режим доставки: "sender" (zabbix_sender) или "userparameter" (Zabbix агент)
# sender — активный push через zabbix_sender (trapper)
# userparameter — пассивный опрос Zabbix агентом (через JSON-файл)
mode: "userparameter"
# Имя хоста в Zabbix (техническое имя узла сети).
host: "YOUR_ZABBIX_HOST"
# ---- Настройки режима "sender" (zabbix_sender) ----
server: "YOUR_ZABBIX_SERVER_IP"
port: 10051
sender_path: "/usr/bin/zabbix_sender"
# ---- Настройки режима "userparameter" (Zabbix агент) ----
metrics_file: "/var/tmp/ses_monitor/metrics.json"
agentd_config_dir: "/opt/ses-monitor/config"
# ---------------------------------------------------------------------------
# Настройки мониторинга
# ---------------------------------------------------------------------------
monitoring:
# Интервал liveness-проверок (секунды)
liveness_interval: 30
# Интервал проверки архива диалогов (секунды)
archive_check_interval: 600
# Включить чтение логов через POST /ses/log (ресурсоёмко)
log_enabled: false
# Каталог с YAML-файлами сценариев
scenarios_dir: "/opt/ses-monitor/config/scenarios"
# Таймаут выполнения сценария по умолчанию (секунды)
scenario_default_timeout: 30
# Мониторинг времени обработки шага диалога из архива
step_time_monitoring: false
# ---------------------------------------------------------------------------
# Мониторинг внутренних моделей SES (SEE, SMC, TTS, QAS, SPR)
# ---------------------------------------------------------------------------
models_monitoring:
enabled: true
see_confidence_threshold: 0.5
smc_confidence_threshold: 0.5
tts_enabled: true
qas_enabled: true
spr_enabled: true
# ---------------------------------------------------------------------------
# Настройки логирования
# ---------------------------------------------------------------------------
logging:
level: "INFO"
file: "/var/log/ses-monitor/monitor.log"
format: "%(asctime)s [%(levelname)s] %(name)s: %(message)s"

View File

@ -0,0 +1,43 @@
# =============================================================================
# Пример сценария: диалог с несколькими шагами
# =============================================================================
# Имитирует полноценный диалог пользователя с роботом SES.
# Каждый шаг проверяет конкретные поля ответа.
#
# ИНСТРУКЦИЯ: скопируйте, переименуйте и опишите ваш диалог.
# cp example_dialog.yaml my_project_dialog.yaml
# # замените шаги и ожидания под логику вашего сценария
name: "example: диалог"
description: >
Пример многошагового диалога с проверкой промежуточных ответов.
Адаптируйте под логику вашего робота.
enabled: false # Пример выключен по умолчанию
interval: 600
timeout: 45
# robot_id: "uuid-вашего-робота" # ОБЯЗАТЕЛЬНО: ID робота из /ses/robot/listall
restart_closed: false # Не переоткрывать закрытые сессии
steps:
# Шаг 1: инициируем диалог
- send: "Привет"
expect:
- path: "error"
equals: 0
- path: "answer[0].messages[0]"
not_empty: true
# Шаг 2: продолжаем диалог
- send: "Как дела?"
expect:
- path: "error"
equals: 0
- path: "answer[0].messages[0]"
not_empty: true
# Шаг 3: завершаем
- send: "Спасибо"
expect:
- path: "error"
equals: 0

View File

@ -0,0 +1,28 @@
# =============================================================================
# Пример сценария: базовая проверка liveness робота
# =============================================================================
# Проверяет что робот отвечает на приветствие и не возвращает ошибок API.
# robot_id указывается в этом же файле (не наследуется из глобального конфига).
#
# ИНСТРУКЦИЯ: скопируйте этот файл, переименуйте и адаптируйте под ваш проект.
# cp example_liveness.yaml my_project_liveness.yaml
# # отредактируйте: name, robot_id, steps, expect
name: "example: liveness"
description: >
Базовая проверка: отправляем приветствие роботу
и убеждаемся что API отвечает без ошибок.
Не зависит от конкретного сценария SES.
enabled: false # Пример выключен по умолчанию
interval: 60
timeout: 15
# robot_id: "uuid-вашего-робота" # ОБЯЗАТЕЛЬНО: ID робота из /ses/robot/listall
steps:
- send: "Здравствуйте"
expect:
- path: "error"
equals: 0
- path: "answer"
not_empty: true

View File

@ -0,0 +1,13 @@
# UserParameter для ses-monitor.
#
# Подключение: добавьте в /etc/zabbix/zabbix_agentd.conf строку:
# Include=/opt/ses-monitor/config/zbx_agentd_ses.conf
#
# После этого перезапустите агента:
# sudo systemctl restart zabbix-agent
#
# Шаблон для Zabbix 5.0: zabbix/zbx_ses_template_5.0_up.xml
# Master-элемент: ses.metrics (JSON)
# Dependent-элементы: ses.* с JSONPath препроцессингом
UserParameter=ses.metrics,/opt/ses-monitor/scripts/zbx_get.py

52
pyproject.toml Normal file
View File

@ -0,0 +1,52 @@
[project]
name = "ses-monitor"
version = "1.0.0"
description = "Система мониторинга SES (Service Engine Server) — диалоговой сценарной машины"
readme = "README.md"
requires-python = ">=3.7"
license = { text = "MIT" }
authors = [
{ name = "SES Monitoring Team" }
]
keywords = ["monitoring", "ses", "zabbix", "dialog", "watchdog"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Topic :: System :: Monitoring",
]
dependencies = [
"httpx>=0.21,<0.24",
"pyyaml>=5.4",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-asyncio>=0.21",
"pytest-cov>=4.0",
"mypy>=1.0",
]
[project.scripts]
ses-monitor = "ses_monitor.main:main"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
include = ["ses_monitor*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
python_files = ["test_*.py"]
pythonpath = ["src"]

39
scripts/zbx_get.py Normal file
View File

@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""
Скрипт для Zabbix UserParameter.
Возвращает JSON со всеми метриками ses-monitor.
Используется как один UserParameter: ses.metrics
Настройка Zabbix агента:
UserParameter=ses.metrics,/opt/ses-monitor/scripts/zbx_get.py
В шаблоне Zabbix создаётся master-элемент ses.metrics (тип: Zabbix агент,
формат: Текст), а все остальные ses.* элементы зависимые (dependent) с
препроцессингом JSONPath.
"""
import json
import sys
METRICS_FILE = "/var/tmp/ses_monitor/metrics.json"
def main() -> int:
try:
with open(METRICS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
# Файла ещё нет — возвращаем пустой JSON
print("{}")
return 0
except json.JSONDecodeError:
print("{}")
return 0
print(json.dumps(data, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,13 @@
"""
Система мониторинга SES (Service Engine Server).
Модульный watchdog-демон для непрерывной проверки работоспособности
диалоговой сценарной машины SES через её REST API.
Основные компоненты:
- checker: быстрые liveness-проверки (доступность API, лицензия)
- scenario_runner: выполнение сценариев диалогов из YAML-файлов
- zabbix: отправка метрик в Zabbix через zabbix_sender
"""
__version__ = "1.0.0"

View File

@ -0,0 +1,8 @@
"""Точка входа: python -m ses_monitor."""
import sys
from ses_monitor.main import main
if __name__ == "__main__":
sys.exit(main())

183
src/ses_monitor/checker.py Normal file
View File

@ -0,0 +1,183 @@
"""
Быстрые liveness-проверки: доступность API, лицензия, количество роботов.
Запускаются с высокой частотой (каждые 30 секунд) и дают первый сигнал
о проблемах с SES сервером.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from ses_monitor.config import SESConfig
from ses_monitor.session_client import SESClient, HostnameResponse, LicenseResponse
log = logging.getLogger("ses_monitor.checker")
@dataclass
class LivenessResult:
"""Результат одного цикла liveness-проверок."""
# Доступность API
api_ok: bool = True
api_response_time_ms: float = 0.0
api_error: str = ""
# Лицензия
license_ok: bool = True
license_error: str = ""
license_days_remaining: int = -1
# Количество сценариев (роботов)
robots_count: int = 0
robots_error: str = ""
# Общая метка времени
timestamp: float = 0.0
@property
def all_ok(self) -> bool:
"""Все проверки прошли успешно."""
return self.api_ok and self.license_ok and not self.robots_error
class LivenessChecker:
"""Выполняет быстрые проверки работоспособности SES.
Проверяет:
1. GET /ses/hostname жив ли сервер.
2. GET /license/check не истекла ли лицензия.
3. GET /ses/robot/list не пропали ли сценарии.
"""
def __init__(self, ses_config: SESConfig, robot_names: Optional[List[str]] = None):
self._ses_config = ses_config
# Эталонный список роботов (для сравнения)
self._expected_robots = robot_names
async def check(self, client: SESClient) -> LivenessResult:
"""Выполняет полный цикл liveness-проверок.
Args:
client: HTTP-клиент для SES API.
Returns:
LivenessResult со статусом всех проверок.
"""
import time
result = LivenessResult(timestamp=time.time())
# 1. Проверка hostname (доступность API)
await self._check_hostname(client, result)
# 2. Проверка лицензии
await self._check_license(client, result)
# 3. Проверка количества роботов
await self._check_robots(client, result)
return result
async def _check_hostname(
self, client: SESClient, result: LivenessResult
) -> None:
"""Проверяет GET /ses/hostname и замеряет время ответа."""
import time
try:
t0 = time.monotonic()
resp = await client.get_hostname()
elapsed = (time.monotonic() - t0) * 1000
result.api_response_time_ms = round(elapsed, 1)
if resp.error == 0:
result.api_ok = True
log.debug(
"Liveness hostname OK: %s v%s, время=%dms",
resp.hostname, resp.version, round(elapsed),
)
else:
result.api_ok = False
result.api_error = f"API ошибка error={resp.error}: {resp.message}"
log.warning("Liveness hostname ошибка: %s", result.api_error)
except Exception as e:
result.api_ok = False
result.api_error = f"Недоступен: {e}"
log.error("Liveness hostname исключение: %s", e)
async def _check_license(
self, client: SESClient, result: LivenessResult
) -> None:
"""Проверяет GET /license/check."""
try:
resp = await client.get_license()
if resp.error == 0:
# "infinity" или число > 0 — лицензия в порядке
remaining = resp.remaining_licenses
if remaining == "infinity":
result.license_ok = True
result.license_days_remaining = -1
elif remaining.isdigit():
days = int(remaining)
result.license_days_remaining = days
if days > 0:
result.license_ok = True
else:
result.license_ok = False
result.license_error = (
f"Лицензия истекла: remaining={remaining}"
)
else:
result.license_ok = False
result.license_error = (
f"Лицензия истекла или исчерпана: remaining={remaining}"
)
log.debug("Liveness license OK: remaining=%s, days=%d", remaining, result.license_days_remaining)
else:
result.license_ok = False
result.license_error = f"Ошибка лицензии error={resp.error}"
log.warning("Liveness license ошибка: %s", result.license_error)
except Exception as e:
result.license_ok = False
result.license_error = f"Недоступна: {e}"
log.error("Liveness license исключение: %s", e)
async def _check_robots(
self, client: SESClient, result: LivenessResult
) -> None:
"""Проверяет GET /ses/robot/list — список сценариев."""
try:
robots = await client.get_robots()
result.robots_count = len(robots)
# Если задан эталонный список — проверяем имена
if self._expected_robots is not None:
actual_names = {r.get("name", "") for r in robots}
expected_set = set(self._expected_robots)
missing = expected_set - actual_names
extra = actual_names - expected_set
if missing:
result.robots_error = f"Отсутствуют роботы: {missing}"
log.warning("Liveness robots: не хватает %s", missing)
if extra:
msg = f"Новые роботы: {extra}"
if result.robots_error:
result.robots_error += "; " + msg
else:
result.robots_error = msg
log.info("Liveness robots: новые %s", extra)
log.debug("Liveness robots count: %d", result.robots_count)
except Exception as e:
result.robots_error = f"Недоступен: {e}"
log.error("Liveness robots исключение: %s", e)

337
src/ses_monitor/config.py Normal file
View File

@ -0,0 +1,337 @@
"""
Загрузка и валидация YAML-конфигурации монитора.
Основной конфигурационный файл: config/config.yaml
Сценарии загружаются из config/scenarios/*.yaml
"""
from __future__ import annotations
import enum
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
log = logging.getLogger("ses_monitor.config")
class ZabbixMode(enum.Enum):
"""Режим доставки метрик в Zabbix."""
SENDER = "sender" # zabbix_sender — активный push
USERPARAMETER = "userparameter" # UserParameter — пассивный опрос агентом
@classmethod
def from_str(cls, value: str) -> ZabbixMode:
"""Создаёт режим из строки (регистронезависимо)."""
for mode in cls:
if mode.value.lower() == value.strip().lower():
return mode
raise ValueError(f"Неизвестный режим Zabbix: '{value}'. Допустимые: {[m.value for m in cls]}")
@dataclass
class SESConfig:
"""Настройки подключения к SES серверу."""
base_url: str = "http://10.101.60.31:6190"
channel: str = "monitoring"
request_timeout: int = 30
robot_list_endpoint: str = "/ses/robot/list" # или "/ses/robot/listall"
@dataclass
class ZabbixConfig:
"""Настройки отправки метрик в Zabbix."""
enabled: bool = True
mode: ZabbixMode = ZabbixMode.SENDER # "sender" или "userparameter"
# ---- Общие поля для обоих режимов ----
host: str = "" # Задаётся в config.yaml: zabbix.host (техническое имя узла сети)
# ---- Поля режима sender ----
server: str = "127.0.0.1"
port: int = 10051
sender_path: str = "/usr/bin/zabbix_sender"
# ---- Поля режима userparameter ----
metrics_file: str = "/var/tmp/ses_monitor/metrics.json"
agentd_config_dir: str = "/opt/ses-monitor/config"
@dataclass
class MonitoringConfig:
"""Общие настройки мониторинга."""
liveness_interval: int = 30 # Интервал liveness-проверок, секунды
archive_check_interval: int = 600 # Интервал проверки архива, секунды
log_enabled: bool = False # Пока отключено — POST /ses/log тяжёлый
scenarios_dir: str = "config/scenarios"
scenario_default_timeout: int = 30
step_time_monitoring: bool = True # Мониторинг времени ответа из архива
@dataclass
class LoggingConfig:
"""Настройки логирования."""
level: str = "INFO"
file: Optional[str] = None
format: str = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
@dataclass
class ModelsMonitoringConfig:
"""Настройки мониторинга внутренних моделей SES (SEE, SMC, TTS, QAS, SPR)."""
enabled: bool = True
# Порог уверенности SEE ниже которого считаем проблемой (0..1)
see_confidence_threshold: float = 0.5
# Порог уверенности SMC ниже которого считаем проблемой (0..1)
smc_confidence_threshold: float = 0.5
# Отслеживать ли голосовые ответы TTS
tts_enabled: bool = True
# Отслеживать ли наполненность QAS
qas_enabled: bool = True
# Отслеживать ли наполненность SPR (распознавание речи: аудио→текст)
spr_enabled: bool = True
@dataclass
class AppConfig:
"""Корневая конфигурация приложения."""
ses: SESConfig = field(default_factory=SESConfig)
zabbix: ZabbixConfig = field(default_factory=ZabbixConfig)
monitoring: MonitoringConfig = field(default_factory=MonitoringConfig)
models: ModelsMonitoringConfig = field(default_factory=ModelsMonitoringConfig)
logging: LoggingConfig = field(default_factory=LoggingConfig)
@dataclass
class ScenarioStep:
"""Один шаг диалогового сценария."""
send: str # Текст сообщения, которое отправляем роботу
expect: List[Dict[str, Any]] = field(default_factory=list)
# Каждое ожидание — dict с ключами:
# path: str (путь в JSON-ответе, например "answer[0].messages[0]")
# contains/equals/not_contains/not_empty/regex: условие
@dataclass
class ArchiveCheckConfig:
"""Настройки проверки архива для сценария."""
enabled: bool = False
lookback_minutes: int = 10
expect: List[Dict[str, Any]] = field(default_factory=list)
@dataclass
class ScenarioConfig:
"""Конфигурация одного сценария мониторинга."""
name: str = ""
description: str = ""
enabled: bool = True
interval: int = 300 # Интервал запуска, секунды
timeout: int = 30
robot_id: Optional[str] = None # Если None — берётся из config.yaml
channel: Optional[str] = None
restart_closed: bool = False
steps: List[ScenarioStep] = field(default_factory=list)
archive_check: Optional[ArchiveCheckConfig] = None
def _dict_to_scenario_step(data: Dict[str, Any]) -> ScenarioStep:
"""Преобразует словарь из YAML в ScenarioStep."""
return ScenarioStep(
send=data["send"],
expect=data.get("expect", []),
)
def _dict_to_scenario_config(data: Dict[str, Any]) -> ScenarioConfig:
"""Преобразует словарь из YAML в ScenarioConfig."""
steps = [_dict_to_scenario_step(s) for s in data.get("steps", [])]
archive_data = data.get("archive_check")
archive_check = None
if archive_data:
archive_check = ArchiveCheckConfig(
enabled=archive_data.get("enabled", False),
lookback_minutes=archive_data.get("lookback_minutes", 10),
expect=archive_data.get("expect", []),
)
return ScenarioConfig(
name=data.get("name", ""),
description=data.get("description", ""),
enabled=data.get("enabled", True),
interval=data.get("interval", 300),
timeout=data.get("timeout", 30),
robot_id=data.get("robot_id"),
channel=data.get("channel"),
restart_closed=data.get("restart_closed", False),
steps=steps,
archive_check=archive_check,
)
def _build_config(data: Dict[str, Any]) -> AppConfig:
"""Собирает AppConfig из распарсенного YAML-словаря."""
ses_data = data.get("ses", {})
ses = SESConfig(
base_url=ses_data.get("base_url", SESConfig.base_url),
channel=ses_data.get("channel", SESConfig.channel),
request_timeout=ses_data.get("request_timeout", SESConfig.request_timeout),
robot_list_endpoint=ses_data.get("robot_list_endpoint", SESConfig.robot_list_endpoint),
)
zbx_data = data.get("zabbix", {})
mode_raw = zbx_data.get("mode", "sender")
try:
mode = ZabbixMode.from_str(mode_raw)
except ValueError:
mode = ZabbixMode.SENDER
log.warning("Неизвестный режим Zabbix '%s', используется 'sender'.", mode_raw)
zabbix = ZabbixConfig(
enabled=zbx_data.get("enabled", ZabbixConfig.enabled),
mode=mode,
host=zbx_data.get("host", ZabbixConfig.host),
server=zbx_data.get("server", ZabbixConfig.server),
port=zbx_data.get("port", ZabbixConfig.port),
sender_path=zbx_data.get("sender_path", ZabbixConfig.sender_path),
metrics_file=zbx_data.get("metrics_file", ZabbixConfig.metrics_file),
agentd_config_dir=zbx_data.get("agentd_config_dir", ZabbixConfig.agentd_config_dir),
)
mon_data = data.get("monitoring", {})
monitoring = MonitoringConfig(
liveness_interval=mon_data.get("liveness_interval", MonitoringConfig.liveness_interval),
archive_check_interval=mon_data.get("archive_check_interval", MonitoringConfig.archive_check_interval),
log_enabled=mon_data.get("log_enabled", MonitoringConfig.log_enabled),
scenarios_dir=mon_data.get("scenarios_dir", MonitoringConfig.scenarios_dir),
scenario_default_timeout=mon_data.get("scenario_default_timeout", MonitoringConfig.scenario_default_timeout),
step_time_monitoring=mon_data.get("step_time_monitoring", MonitoringConfig.step_time_monitoring),
)
models_data = data.get("models_monitoring", {})
models_cfg = ModelsMonitoringConfig(
enabled=models_data.get("enabled", ModelsMonitoringConfig.enabled),
see_confidence_threshold=models_data.get(
"see_confidence_threshold", ModelsMonitoringConfig.see_confidence_threshold
),
smc_confidence_threshold=models_data.get(
"smc_confidence_threshold", ModelsMonitoringConfig.smc_confidence_threshold
),
tts_enabled=models_data.get("tts_enabled", ModelsMonitoringConfig.tts_enabled),
qas_enabled=models_data.get("qas_enabled", ModelsMonitoringConfig.qas_enabled),
spr_enabled=models_data.get("spr_enabled", ModelsMonitoringConfig.spr_enabled),
)
log_data = data.get("logging", {})
logging_cfg = LoggingConfig(
level=log_data.get("level", LoggingConfig.level),
file=log_data.get("file"),
format=log_data.get("format", LoggingConfig.format),
)
return AppConfig(ses=ses, zabbix=zabbix, monitoring=monitoring, models=models_cfg, logging=logging_cfg)
def load_app_config(config_path: Optional[str] = None) -> AppConfig:
"""Загружает основную конфигурацию из YAML-файла.
Args:
config_path: Путь к config.yaml. Если None ищет
config/config.yaml относительно корня проекта.
Returns:
AppConfig с заполненными полями.
Raises:
FileNotFoundError: Файл конфигурации не найден.
yaml.YAMLError: Ошибка парсинга YAML.
"""
if config_path is None:
# Автоопределение пути: от места запуска ищем config/config.yaml
candidates = [
Path("config/config.yaml"),
Path("/opt/ses-monitor/config/config.yaml"),
]
# Добавляем относительный путь от этого файла
this_dir = Path(__file__).resolve().parent.parent
candidates.insert(0, this_dir / "config" / "config.yaml")
found = None
for c in candidates:
if c.exists():
found = c
break
if found is None:
raise FileNotFoundError(
"Файл конфигурации не найден. Проверьте config/config.yaml "
"или укажите путь явно."
)
config_path = str(found)
with open(config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if data is None:
data = {}
return _build_config(data)
def load_scenarios(scenarios_dir: str) -> List[ScenarioConfig]:
"""Загружает все YAML-файлы сценариев из указанного каталога.
Args:
scenarios_dir: Путь к каталогу со сценариями.
Returns:
Список конфигураций сценариев.
Notes:
Файлы сценариев читаются из `*.yaml` и `*.yml` в указанном каталоге.
Сценарии с `enabled: false` пропускаются.
"""
dir_path = Path(scenarios_dir)
if not dir_path.exists():
raise FileNotFoundError(f"Каталог сценариев не найден: {scenarios_dir}")
scenarios: List[ScenarioConfig] = []
for yaml_file in sorted(dir_path.glob("*.yaml")):
try:
with open(yaml_file, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if data is None:
continue
scenario = _dict_to_scenario_config(data)
if scenario.enabled:
scenarios.append(scenario)
except (yaml.YAMLError, KeyError) as e:
raise RuntimeError(f"Ошибка чтения сценария {yaml_file}: {e}") from e
for yml_file in sorted(dir_path.glob("*.yml")):
try:
with open(yml_file, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if data is None:
continue
scenario = _dict_to_scenario_config(data)
if scenario.enabled:
scenarios.append(scenario)
except (yaml.YAMLError, KeyError) as e:
raise RuntimeError(f"Ошибка чтения сценария {yml_file}: {e}") from e
return scenarios

View File

@ -0,0 +1,96 @@
"""
Чтение логов SES через POST /ses/log.
Позволяет получить логи за указанную дату для анализа ошибок.
Функциональность пока отключена по умолчанию (config.yaml: monitoring.log_enabled),
так как запрос логов может быть ресурсоёмким.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List, Optional
from ses_monitor.session_client import SESClient
log = logging.getLogger("ses_monitor.log_client")
@dataclass
class LogCheckResult:
"""Результат проверки логов."""
date: str = ""
fetch_error: str = ""
lines_count: int = 0
# Паттерны для поиска ошибок (расширяются по мере необходимости)
error_patterns: Dict[str, int] = None # type: ignore
def __post_init__(self):
if self.error_patterns is None:
self.error_patterns = {}
class LogChecker:
"""Проверяет логи SES на наличие ошибок.
Ищет паттерны:
- "ERROR" / "CRITICAL" критические ошибки.
- "HTTPError" / "RequestException" ошибки внешних API.
- "json.JSONDecodeError" ошибки парсинга.
- "connection refused" / "timeout" сетевые проблемы.
"""
ERROR_PATTERNS = [
("error_count", "ERROR"),
("critical_count", "CRITICAL"),
("http_error_count", "HTTPError"),
("request_exception_count", "RequestException"),
("json_error_count", "JSONDecodeError"),
("timeout_count", "timeout"),
("refused_count", "Connection refused"),
]
async def check(self, client: SESClient) -> LogCheckResult:
"""Запрашивает и анализирует логи за сегодняшний день.
Args:
client: HTTP-клиент для SES API.
Returns:
Результат анализа логов.
"""
result = LogCheckResult(date=datetime.now().strftime("%Y-%m-%d"))
try:
log_data = await client.get_logs(result.date)
except Exception as e:
result.fetch_error = str(e)
log.error("Log check: ошибка получения логов: %s", e)
return result
# Если ответ — строка (plain text логов)
if isinstance(log_data, str):
result.lines_count = log_data.count("\n") + 1
for key, pattern in self.ERROR_PATTERNS:
result.error_patterns[key] = log_data.count(pattern)
# Если ответ — словарь (структурированные данные)
elif isinstance(log_data, dict):
if log_data.get("error") == 0 and "data" in log_data:
data = log_data["data"]
if isinstance(data, str):
result.lines_count = data.count("\n") + 1
for key, pattern in self.ERROR_PATTERNS:
result.error_patterns[key] = data.count(pattern)
log.info(
"Log check: %d строк за %s, ошибок: %s",
result.lines_count,
result.date,
{k: v for k, v in result.error_patterns.items() if v > 0},
)
return result

328
src/ses_monitor/main.py Normal file
View File

@ -0,0 +1,328 @@
"""
Главный цикл watchdog-демона для мониторинга SES.
Архитектура:
- Бесконечный asyncio-цикл с тремя параллельными задачами:
1. liveness_task: быстрые проверки каждые 30 секунд.
2. scenarios_task: запуск сценариев по их индивидуальному расписанию.
3. archive_task: анализ архива каждые 10 минут.
- Hot-reload сценариев из каталога каждые 60 секунд.
- Все метрики отправляются в Zabbix единым батчем после проверок.
- Graceful shutdown по SIGTERM/SIGINT.
"""
from __future__ import annotations
import asyncio
import logging
import signal
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional, Set
from ses_monitor.config import (
AppConfig,
ScenarioConfig,
ZabbixMode,
load_app_config,
load_scenarios,
)
from ses_monitor.utils import setup_logging
from ses_monitor.session_client import SESClient
from ses_monitor.checker import LivenessChecker
from ses_monitor.scenario_runner import ScenarioRunner, ScenarioResult, aggregate_scenario_results
from ses_monitor.log_client import LogChecker
from ses_monitor.zabbix import ZabbixSender, UserParameterWriter
log = logging.getLogger("ses_monitor")
class SESMonitorApp:
"""Главный класс watchdog-приложения."""
def __init__(self, config: AppConfig):
self._config = config
# HTTP-клиент (один на всё приложение)
self._client = SESClient(config.ses)
# Компоненты — выбираем способ доставки в Zabbix по конфигу
self._zabbix = self._create_zabbix_sender(config)
self._liveness = LivenessChecker(config.ses)
self._log_checker = LogChecker()
# Сценарии загружаются динамически (hot-reload)
self._scenarios: List[ScenarioConfig] = []
self._scenario_runner: Optional[ScenarioRunner] = None
self._scenarios_dir = config.monitoring.scenarios_dir
# Отслеживание времени последнего запуска сценариев
self._last_scenario_run: Dict[str, float] = {}
# Флаг graceful shutdown — создаётся в start() внутри цикла (Python 3.7 фикс)
# Эталонный список роботов (заполняется при первом успешном запросе)
self._known_robots: Set[str] = set()
@staticmethod
def _create_zabbix_sender(config: AppConfig):
"""Фабрика: создаёт ZabbixSender или UserParameterWriter по конфигу."""
if config.zabbix.mode == ZabbixMode.USERPARAMETER:
log.info(
"Режим доставки: UserParameter (файл: %s)",
config.zabbix.metrics_file,
)
return UserParameterWriter(config.zabbix)
log.info(
"Режим доставки: zabbix_sender (сервер: %s:%d, хост: %s)",
config.zabbix.server,
config.zabbix.port,
config.zabbix.host,
)
return ZabbixSender(config.zabbix)
async def start(self) -> None:
"""Запускает watchdog-цикл."""
zbx_cfg = self._config.zabbix
if zbx_cfg.enabled:
if zbx_cfg.mode == ZabbixMode.USERPARAMETER:
log.info(
"SES Monitor v%s запускается (SES: %s, Zabbix UP: %s)",
__import__("ses_monitor").__version__,
self._config.ses.base_url,
zbx_cfg.metrics_file,
)
else:
log.info(
"SES Monitor v%s запускается (SES: %s, Zabbix: %s:%d, хост: %s)",
__import__("ses_monitor").__version__,
self._config.ses.base_url,
zbx_cfg.server, zbx_cfg.port, zbx_cfg.host,
)
else:
log.info("SES Monitor v%s запускается (SES: %s, Zabbix отключён)",
__import__("ses_monitor").__version__,
self._config.ses.base_url)
# Создаём Event УЖЕ внутри цикла (Python 3.7 фик: иначе self._loop
# привязывается к умолчательному циклу, а не к текущему)
self._shutdown_event = asyncio.Event()
# Привязываем child watcher к текущему циклу (Python 3.7-3.11)
loop = asyncio.get_running_loop()
if sys.platform != "win32" and sys.version_info < (3, 12):
watcher = asyncio.get_child_watcher()
if hasattr(watcher, "attach_loop"):
watcher.attach_loop(loop)
# Регистрируем обработчики сигналов
for sig in (signal.SIGTERM, signal.SIGINT):
try:
loop.add_signal_handler(sig, self._handle_shutdown)
except NotImplementedError:
# Сигналы не поддерживаются (например, Windows)
pass
try:
# Основной цикл
await self._main_loop()
except asyncio.CancelledError:
pass
finally:
await self._shutdown()
def _handle_shutdown(self) -> None:
"""Обработчик SIGTERM/SIGINT."""
log.info("Получен сигнал завершения, останавливаемся...")
self._shutdown_event.set()
async def _shutdown(self) -> None:
"""Graceful shutdown: закрываем клиент, сбрасываем метрики."""
log.info("Завершение работы...")
if self._zabbix.enabled:
# Отправляем последний батч метрик если есть
await self._zabbix.flush()
await self._client.close()
log.info("SES Monitor остановлен.")
async def _load_scenarios(self) -> None:
"""Загружает или перезагружает сценарии (hot-reload)."""
try:
scenarios = load_scenarios(self._scenarios_dir)
self._scenarios = scenarios
if self._scenario_runner is None:
self._scenario_runner = ScenarioRunner(
self._config.ses, scenarios
)
else:
self._scenario_runner.update_scenarios(scenarios)
log.debug("Загружено %d сценариев", len(scenarios))
except Exception as e:
log.error("Ошибка загрузки сценариев: %s", e)
async def _main_loop(self) -> None:
"""Основной цикл: liveness, сценарии, архив."""
# Первичная загрузка сценариев
await self._load_scenarios()
# Временные метки для планирования
last_hot_reload = time.monotonic()
while not self._shutdown_event.is_set():
now = time.monotonic()
# ---------- 1. Liveness-проверка ----------
await self._run_liveness()
# ---------- 2. Сценарии по расписанию ----------
await self._run_due_scenarios(now)
# ---------- 3. Hot-reload сценариев ----------
if now - last_hot_reload >= 60.0:
await self._load_scenarios()
last_hot_reload = now
# ---------- 4. Логи (если включено) ----------
if self._config.monitoring.log_enabled:
# Логи проверяем реже — раз в 30 минут
if int(now) % 1800 < self._config.monitoring.liveness_interval:
await self._run_log_check()
# ---------- 6. Отправка метрик в Zabbix ----------
await self._zabbix.flush()
# Ждём или до следующего цикла liveness, или до shutdown
sleep_time = self._config.monitoring.liveness_interval
try:
await asyncio.wait_for(
self._shutdown_event.wait(), timeout=sleep_time
)
except asyncio.TimeoutError:
pass # Нормально: просто прошёл интервал
async def _run_liveness(self) -> None:
"""Выполняет быстрые liveness-проверки."""
try:
result = await self._liveness.check(self._client)
if self._zabbix.enabled:
self._zabbix.add("ses.api.liveness", int(result.api_ok))
self._zabbix.add("ses.api.response_time", round(result.api_response_time_ms))
self._zabbix.add("ses.license.ok", int(result.license_ok))
self._zabbix.add("ses.license.days_remaining", result.license_days_remaining)
if result.robots_count > 0:
self._zabbix.add("ses.robots.count", result.robots_count)
if result.robots_count > 0 and not self._known_robots:
log.info(
"Liveness: %d сценариев, API=%s, license=%s, days_left=%d, время=%0.0fms",
result.robots_count,
"OK" if result.api_ok else "FAIL",
"OK" if result.license_ok else "FAIL",
result.license_days_remaining,
result.api_response_time_ms,
)
if not result.api_ok:
log.error("Liveness FAIL: %s", result.api_error)
if not result.license_ok:
log.error("License FAIL: %s", result.license_error)
except Exception as e:
log.exception("Liveness: неожиданная ошибка: %s", e)
async def _run_due_scenarios(self, now: float) -> None:
"""Запускает сценарии, у которых подошло время.
Все результаты собираются и агрегируются в фиксированный набор
Zabbix-метрик (ses.scenario.ok, .total, .failed, .avg_time, .max_time,
.error). Это делает шаблон Zabbix универсальным не нужно добавлять
отдельный item под каждый сценарий.
"""
if not self._scenario_runner:
return
due_results: List[ScenarioResult] = []
for scenario in self._scenarios:
name = scenario.name or "unknown"
last_run = self._last_scenario_run.get(name, 0)
if now - last_run >= scenario.interval:
try:
result = await self._scenario_runner.run_one(
self._client, scenario
)
due_results.append(result)
self._last_scenario_run[name] = now
if not result.passed:
log.warning(
"Сценарий '%s': ПРОВАЛЕН за %dms (ошибок: %d шагов)",
name,
round(result.total_time_ms),
len(result.failed_steps),
)
except Exception as e:
log.exception(
"Сценарий '%s': неожиданное исключение: %s", name, e
)
# Агрегируем результаты и отправляем фиксированные ключи
if due_results and self._zabbix.enabled:
agg = aggregate_scenario_results(due_results)
self._zabbix.add("ses.scenario.ok", agg["ok"])
self._zabbix.add("ses.scenario.total", agg["total"])
self._zabbix.add("ses.scenario.failed", agg["failed"])
self._zabbix.add("ses.scenario.avg_time", agg["avg_time"])
self._zabbix.add("ses.scenario.max_time", agg["max_time"])
if agg["error"]:
self._zabbix.add("ses.scenario.error", agg["error"])
async def _run_log_check(self) -> None:
"""Выполняет проверку логов (если включено)."""
try:
result = await self._log_checker.check(self._client)
if self._zabbix.enabled:
for key, count in result.error_patterns.items():
self._zabbix.add(f"ses.log.{key}", count)
except Exception as e:
log.exception("Log check: неожиданная ошибка: %s", e)
def main() -> int:
"""Точка входа в приложение.
Returns:
0 при успешном завершении, 1 при ошибке.
"""
try:
# Загружаем конфигурацию
config = load_app_config()
except FileNotFoundError as e:
print(f"ОШИБКА: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"ОШИБКА загрузки конфигурации: {e}", file=sys.stderr)
return 1
# Настраиваем логирование
logger = setup_logging(config.logging)
try:
# Python 3.7: инициализируем child watcher ДО создания цикла
if sys.platform != "win32":
asyncio.get_child_watcher()
app = SESMonitorApp(config)
asyncio.run(app.start())
return 0
except KeyboardInterrupt:
logger.info("Остановлено пользователем (Ctrl+C)")
return 0
except Exception as e:
logger.exception("Критическая ошибка: %s", e)
return 1

View File

@ -0,0 +1,240 @@
"""
Движок выполнения диалоговых сценариев из YAML-файлов.
Каждый сценарий это последовательность шагов:
1. Отправить сообщение роботу (POST /ses/ask)
2. Проверить ответ по условиям (expect)
3. Перейти к следующему шагу
Сценарии загружаются из config/scenarios/ во время старта и автоматически
перечитываются каждые 60 секунд (hot-reload).
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from ses_monitor.config import ScenarioConfig, ScenarioStep, SESConfig
from ses_monitor.session_client import SESClient, AskResponse
from ses_monitor.utils import check_all_expectations, timer
log = logging.getLogger("ses_monitor.scenario")
@dataclass
class StepResult:
"""Результат выполнения одного шага сценария."""
step_index: int
text_sent: str
response: Optional[AskResponse] = None
passed: bool = True
errors: List[str] = field(default_factory=list)
response_time_ms: float = 0.0
@dataclass
class ScenarioResult:
"""Результат выполнения полного сценария."""
scenario_name: str
passed: bool = True
total_time_ms: float = 0.0
step_results: List[StepResult] = field(default_factory=list)
error_message: str = ""
@property
def failed_steps(self) -> List[StepResult]:
"""Шаги, которые не прошли проверку."""
return [s for s in self.step_results if not s.passed]
def aggregate_scenario_results(
results: List[ScenarioResult],
) -> Dict[str, Any]:
"""Агрегирует результаты всех сценариев в фиксированный набор Zabbix-метрик.
Возвращает словарь с ключами:
ok (int): 1 если все прошли, 0 если есть упавшие.
total (int): общее количество выполненных сценариев.
failed (int): количество упавших сценариев.
avg_time (int): среднее время выполнения (мс), округлённое.
max_time (int): максимальное время выполнения (мс), округлённое.
error (str): текст ошибки ("N сценариев упали") или пустая строка.
"""
if not results:
return {
"ok": 1,
"total": 0,
"failed": 0,
"avg_time": 0,
"max_time": 0,
"error": "",
}
failed = sum(1 for r in results if not r.passed)
total = len(results)
times = [r.total_time_ms for r in results]
return {
"ok": 0 if failed else 1,
"total": total,
"failed": failed,
"avg_time": round(sum(times) / total),
"max_time": round(max(times)),
"error": f"{failed} сценариев упали" if failed else "",
}
class ScenarioRunner:
"""Выполняет диалоговые сценарии мониторинга.
Поддерживает:
- Переиспользование HTTP-клиента между проверками.
- Hot-reload сценариев из каталога.
- Автоматическое создание и отслеживание сессий.
"""
def __init__(self, ses_config: SESConfig, scenarios: List[ScenarioConfig]):
self._ses_config = ses_config
self._scenarios = scenarios
def update_scenarios(self, scenarios: List[ScenarioConfig]) -> None:
"""Обновляет список сценариев (hot-reload)."""
self._scenarios = scenarios
async def run_all(
self, client: SESClient
) -> List[ScenarioResult]:
"""Запускает все сценарии последовательно.
Args:
client: HTTP-клиент для SES API.
Returns:
Список результатов выполнения сценариев.
"""
results: List[ScenarioResult] = []
for scenario in self._scenarios:
result = await self.run_one(client, scenario)
results.append(result)
return results
async def run_one(
self, client: SESClient, scenario: ScenarioConfig
) -> ScenarioResult:
"""Выполняет один сценарий.
Args:
client: HTTP-клиент для SES API.
scenario: Конфигурация сценария.
Returns:
Результат выполнения.
"""
result = ScenarioResult(
scenario_name=scenario.name,
)
# Уникальный ID мониторинговой сессии
session_id = f"monitoring_{scenario.name.replace(' ', '_')}_{int(time.time())}"
log.info("Запуск сценария '%s' (сессия %s)", scenario.name, session_id)
with timer() as total_time:
try:
for i, step in enumerate(scenario.steps):
step_result = await self._execute_step(
client, scenario, session_id, i, step
)
result.step_results.append(step_result)
if not step_result.passed:
result.passed = False
log.warning(
"Сценарий '%s': шаг %d НЕ ПРОЙДЕН: %s",
scenario.name, i + 1, step_result.errors,
)
break # Прерываем сценарий при первой ошибке
if result.passed and result.step_results:
log.info(
"Сценарий '%s': УСПЕХ (%d шагов)",
scenario.name, len(result.step_results),
)
except Exception as e:
result.passed = False
result.error_message = f"Исключение: {e}"
log.exception("Сценарий '%s': исключение", scenario.name)
result.total_time_ms = total_time.get("elapsed", 0) * 1000
return result
async def _execute_step(
self,
client: SESClient,
scenario: ScenarioConfig,
session_id: str,
step_index: int,
step: ScenarioStep,
) -> StepResult:
"""Выполняет один шаг сценария: отправка → проверка ответа.
Args:
client: HTTP-клиент.
scenario: Конфигурация сценария.
session_id: ID текущей сессии.
step_index: Номер шага (0-based).
step: Определение шага.
Returns:
Результат шага.
"""
result = StepResult(
step_index=step_index,
text_sent=step.send,
)
try:
# Отправляем сообщение роботу
with timer() as step_time:
response = await client.ask(
text=step.send,
session_id=session_id,
robot_id=scenario.robot_id,
channel=scenario.channel,
restart_closed=scenario.restart_closed,
)
result.response = response
result.response_time_ms = step_time.get("elapsed", 0) * 1000
# Проверяем базовую ошибку API
if response.error != 0:
result.passed = False
result.errors.append(
f"SES API error={response.error} на сообщение '{step.send[:80]}'"
)
return result
# Проверяем ожидания
if step.expect:
passed, errors = check_all_expectations(response.raw, step.expect)
result.passed = passed
result.errors = errors
if not result.passed and not step.expect:
# Если ожиданий не было — считаем что ответ error=0 это успех
result.passed = True
except Exception as e:
result.passed = False
result.errors.append(f"HTTP ошибка: {e}")
log.exception(
"Сценарий '%s', шаг %d: исключение",
scenario.name, step_index + 1,
)
return result

View File

@ -0,0 +1,302 @@
"""
HTTP-клиент для взаимодействия с SES REST API.
Только чтение (GET). Для POST /ses/ask используется безопасный подход:
каждый сценарий открывает новую сессию, тестовые запросы идут с
префиксом 'monitoring_' в номере сессии.
"""
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from urllib.parse import urlencode, urljoin
import httpx
from ses_monitor.config import SESConfig
log = logging.getLogger("ses_monitor.client")
@dataclass
class AskResponse:
"""Ответ на POST /ses/ask."""
error: int
question: str
answer: List[Dict[str, Any]]
session: str
uuid: str
raw: Dict[str, Any]
@classmethod
def from_json(cls, data: Dict[str, Any]) -> "AskResponse":
"""Создаёт объект из JSON-ответа SES."""
return cls(
error=data.get("error", -1),
question=data.get("question", ""),
answer=data.get("answer", []),
session=data.get("session", ""),
uuid=data.get("uuid", ""),
raw=data,
)
@dataclass
class HostnameResponse:
"""Ответ на GET /ses/hostname."""
error: int
message: str
hostname: str
version: str
@classmethod
def from_json(cls, data: Dict[str, Any]) -> "HostnameResponse":
return cls(
error=data.get("error", -1),
message=data.get("message", ""),
hostname=data.get("hostname", ""),
version=data.get("version", ""),
)
@dataclass
class LicenseResponse:
"""Ответ на GET /license/check."""
error: int
uuid: str
remaining_licenses: str
threshold: int
@classmethod
def from_json(cls, data: Dict[str, Any]) -> "LicenseResponse":
return cls(
error=data.get("error", -1),
uuid=data.get("uuid", ""),
remaining_licenses=data.get("remaining_licenses", ""),
threshold=data.get("threshold", 0),
)
@dataclass
class ArchiveEntry:
"""Одна запись из архива диалогов."""
id: str
session: str
robot: str
hostname: str
channel: str
unknown: bool
closed: Any
endpoint: Optional[str]
answered: bool
conversion: bool
models: List[str]
events: List[str]
request_message: str
last_reply: str
raw: Dict[str, Any]
@classmethod
def from_json(cls, data: Dict[str, Any]) -> "ArchiveEntry":
robot_raw = data.get("robot", "")
if isinstance(robot_raw, list):
robot_raw = robot_raw[0] if robot_raw else ""
return cls(
id=data.get("id", ""),
session=data.get("session", ""),
robot=robot_raw,
hostname=data.get("hostname", ""),
channel=data.get("channel", ""),
unknown=data.get("unknown", False),
closed=data.get("closed", False),
endpoint=data.get("endpoint"),
answered=data.get("answered", False),
conversion=data.get("conversion", False),
models=data.get("models", []),
events=data.get("events", []),
request_message=data.get("request", {}).get("message", ""),
last_reply=data.get("current", {}).get("lastreply", ""),
raw=data,
)
class SESClient:
"""HTTP-клиент для SES REST API.
Выполняет только GET и POST /ses/ask запросы.
Все методы асинхронные для работы в asyncio-цикле.
"""
def __init__(self, config: SESConfig):
self._base_url = config.base_url.rstrip("/")
self._channel = config.channel
self._timeout = config.request_timeout
self._robot_list_endpoint = config.robot_list_endpoint
self._client: Optional[httpx.AsyncClient] = None
async def _ensure_client(self) -> httpx.AsyncClient:
"""Ленивое создание HTTP-клиента."""
if self._client is None:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self._timeout),
limits=httpx.Limits(max_keepalive_connections=5),
)
return self._client
async def close(self) -> None:
"""Закрывает HTTP-клиент."""
if self._client is not None:
await self._client.aclose()
self._client = None
async def _get(self, path: str) -> Dict[str, Any]:
"""Выполняет GET-запрос к SES API."""
url = urljoin(self._base_url, path)
client = await self._ensure_client()
try:
response = await client.get(url)
response.raise_for_status()
data = response.json()
return data
except httpx.HTTPError as e:
log.error("HTTP ошибка GET %s: %s", path, e)
raise
except json.JSONDecodeError as e:
log.error("Ошибка парсинга JSON от %s: %s", path, e)
raise
async def _post(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Выполняет POST-запрос к SES API."""
url = urljoin(self._base_url, path)
client = await self._ensure_client()
try:
response = await client.post(url, params=params)
response.raise_for_status()
data = response.json()
return data
except httpx.HTTPError as e:
log.error("HTTP ошибка POST %s: %s", path, e)
raise
except json.JSONDecodeError as e:
log.error("Ошибка парсинга JSON от %s: %s", path, e)
raise
# --------------- Liveness-проверки ---------------
async def get_hostname(self) -> HostnameResponse:
"""GET /ses/hostname — проверка доступности сервера."""
data = await self._get("/ses/hostname")
return HostnameResponse.from_json(data)
async def get_license(self) -> LicenseResponse:
"""GET /license/check — проверка лицензии."""
data = await self._get("/license/check")
return LicenseResponse.from_json(data)
async def get_robots(self) -> List[Dict[str, Any]]:
"""GET robot list — список сценариев (роботов).
Эндпоинт задаётся в config.yaml (robot_list_endpoint).
/ses/robot/list только опубликованные.
/ses/robot/listall все (включая черновики).
"""
data = await self._get(self._robot_list_endpoint)
return data if isinstance(data, list) else []
# --------------- Диалоговые сценарии ---------------
async def ask(
self,
text: str,
*,
robot_id: str,
session_id: Optional[str] = None,
channel: Optional[str] = None,
restart_closed: bool = False,
) -> AskResponse:
"""POST /ses/ask/{robot_id} — отправка сообщения роботу.
Args:
text: Текст сообщения.
robot_id: ID робота (обязательно, не наследуется из конфига).
session_id: UUID сессии. Если None генерируется новый.
channel: Канал. Если None из конфигурации.
restart_closed: Переоткрывать закрытую сессию.
Returns:
AskResponse с ответом робота.
"""
if not robot_id:
raise ValueError("robot_id обязателен для POST /ses/ask")
ch = channel or self._channel
if session_id is None:
session_id = f"monitoring_{int(time.time())}"
params = {
"text": text,
"channel": ch,
"session": session_id,
"restartClosed": int(restart_closed),
}
data = await self._post(f"/ses/ask/{robot_id}", params=params)
return AskResponse.from_json(data)
# --------------- Архив диалогов ---------------
async def get_archive(
self,
start_date: str,
end_date: str,
**filters: Any,
) -> List[ArchiveEntry]:
"""GET /ses/archive/list — получение истории диалогов.
Args:
start_date: Начало периода, формат "YYYY-MM-DD HH:MM".
end_date: Конец периода, формат "YYYY-MM-DD HH:MM".
**filters: Дополнительные параметры (channel, robot, text, etc.).
Returns:
Список записей архива.
"""
params: Dict[str, Any] = {
"startdate": start_date,
"enddate": end_date,
**filters,
}
data = await self._get(f"/ses/archive/list?{urlencode(params)}")
if isinstance(data, list):
return [ArchiveEntry.from_json(entry) for entry in data]
return []
async def get_dialog(self, dialog_uuid: str) -> Dict[str, Any]:
"""GET /ses/archive/dialog/{uuid} — получение диалога по UUID."""
return await self._get(f"/ses/archive/dialog/{dialog_uuid}")
# --------------- Логи ---------------
async def get_logs(self, date: str) -> Dict[str, Any]:
"""POST /ses/log — получение логов за дату."""
params = {"date": date}
return await self._post("/ses/log", params=params)
# --------------- TestAPI ---------------
async def call_test_api(self, name: str) -> Any:
"""GET /ses/testapi/{name} — вызов тестового API."""
return await self._get(f"/ses/testapi/{name}")

233
src/ses_monitor/utils.py Normal file
View File

@ -0,0 +1,233 @@
"""
Вспомогательные утилиты: логирование, работа с JSON-путями, тайминг.
"""
from __future__ import annotations
import logging
import re
import time
from contextlib import contextmanager
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from ses_monitor.config import LoggingConfig
def setup_logging(cfg: LoggingConfig) -> logging.Logger:
"""Настраивает и возвращает корневой логгер проекта.
Args:
cfg: Конфигурация логирования.
Returns:
Настроенный логгер 'ses_monitor'.
"""
logger = logging.getLogger("ses_monitor")
logger.setLevel(getattr(logging, cfg.level.upper(), logging.INFO))
# Убираем существующие обработчики, чтобы не дублировать
logger.handlers.clear()
formatter = logging.Formatter(cfg.format, datefmt="%Y-%m-%d %H:%M:%S")
# Всегда пишем в stdout для systemd
console = logging.StreamHandler()
console.setFormatter(formatter)
logger.addHandler(console)
# Опционально: файловый лог
if cfg.file:
from logging.handlers import RotatingFileHandler
file_handler = RotatingFileHandler(
cfg.file, maxBytes=10 * 1024 * 1024, backupCount=5,
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
def get_json_value(data: Union[Dict[str, Any], List[Any]], path: str) -> Any:
"""Извлекает значение из вложенной JSON-структуры по пути.
Args:
data: Словарь или список с данными.
path: Путь в формате "key.subkey[0].field" или "items[0].name".
Returns:
Значение по указанному пути, или None если путь не существует.
Examples:
>>> get_json_value({"a": {"b": [1, 2]}}, "a.b[0]")
1
>>> get_json_value({"answer": [{"text": "да"}]}, "answer[0].text")
'да'
"""
try:
# Разбираем путь: key, [0], .subkey
tokens = re.findall(r"(?:^|\.)(\w+)|\[(\d+)\]", path)
current: Any = data
for key, index in tokens:
if key:
if isinstance(current, dict):
current = current.get(key)
else:
return None
elif index is not None:
idx = int(index)
if isinstance(current, list) and idx < len(current):
current = current[idx]
else:
return None
return current
except (KeyError, IndexError, TypeError, AttributeError):
return None
def check_expectation(
data: Dict[str, Any],
path: str,
condition: str,
value: Any = None,
) -> Tuple[bool, str]:
"""Проверяет одно условие ожидания для JSON-ответа.
Args:
data: JSON-ответ от SES API.
path: Путь к полю в JSON (формат "answer[0].messages[0]").
condition: Тип проверки:
- "equals": поле == value
- "contains": поле содержит value
- "not_contains": поле не содержит value
- "not_empty": поле не пустое
- "regex": поле соответствует регулярному выражению в value
- "exists": поле существует и не None
value: Значение для сравнения (для equals/contains/not_contains/regex).
Returns:
(успех, сообщение_об_ошибке). При успехе сообщение пустое.
"""
actual = get_json_value(data, path)
if condition == "exists":
if actual is not None:
return True, ""
return False, f"Поле '{path}' не найдено в ответе"
if condition == "not_empty":
if actual is not None and actual != "" and actual != [] and actual != {}:
return True, ""
return False, f"Поле '{path}' пустое: {actual!r}"
if actual is None:
return False, f"Поле '{path}' не найдено в ответе"
if isinstance(actual, str) and value is not None:
actual_str = actual.lower()
if not isinstance(value, str):
value = str(value)
value_str = value.lower()
if condition == "contains":
if value_str in actual_str:
return True, ""
return False, (
f"Поле '{path}' = {actual_str[:100]!r} "
f"не содержит {value_str[:100]!r}"
)
if condition == "not_contains":
if value_str not in actual_str:
return True, ""
return False, (
f"Поле '{path}' = {actual_str[:100]!r} "
f"содержит запрещённое {value_str[:100]!r}"
)
if condition == "regex":
try:
if re.search(str(value), actual):
return True, ""
return False, (
f"Поле '{path}' = {actual[:100]!r} "
f"не соответствует regex {value!r}"
)
except re.error as e:
return False, f"Ошибка regex '{value}': {e}"
if condition == "equals":
# Приводим к строке для сравнения
actual_conv = actual
value_conv = value
if not isinstance(actual_conv, type(value_conv)):
# Пробуем сравнить через строковое представление
if str(actual_conv) == str(value_conv):
return True, ""
return False, (
f"Поле '{path}' = {actual_conv!r} "
f"не равно {value_conv!r}"
)
if actual_conv == value_conv:
return True, ""
return False, (
f"Поле '{path}' = {actual_conv!r} "
f"не равно {value_conv!r}"
)
return False, f"Неизвестное условие проверки: {condition}"
def check_all_expectations(
data: Dict[str, Any],
expectations: List[Dict[str, Any]],
) -> Tuple[bool, List[str]]:
"""Проверяет все ожидания для одного шага сценария.
Args:
data: JSON-ответ от SES API.
expectations: Список условий каждый dict с ключами
path, condition_type_1, condition_type_2, ...
Returns:
(все_прошли, список_сообщений_об_ошибках).
"""
errors: List[str] = []
for exp in expectations:
path = exp.get("path", "")
if not path:
errors.append("Ожидание без указания path")
continue
for condition, value in exp.items():
if condition == "path":
continue # path — не условие
ok, msg = check_expectation(data, path, condition, value)
if not ok:
errors.append(msg)
return len(errors) == 0, errors
@contextmanager
def timer() -> Generator[Any, None, None]:
"""Контекстный менеджер для измерения времени выполнения блока.
Yields:
Словарь, в который будет записано прошедшее время (в секундах)
по ключам 'start' и 'elapsed'.
Usage:
>>> with timer() as t:
... time.sleep(0.1)
>>> print(f"{t['elapsed']:.2f}s")
"""
result: Dict[str, float] = {"start": time.monotonic()}
try:
yield result
finally:
result["elapsed"] = time.monotonic() - result["start"]

225
src/ses_monitor/zabbix.py Normal file
View File

@ -0,0 +1,225 @@
"""
Отправка метрик в 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"
)

View File

@ -0,0 +1,41 @@
[Unit]
Description=SES Monitor — непрервыный мониторинг Service Engine Server
Documentation=https://github.com/your-org/ses-monitor
After=network.target network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
# Рабочий каталог проекта
WorkingDirectory=/opt/ses-monitor
# Запуск через venv
ExecStart=/opt/ses-monitor/venv/bin/python3 -m ses_monitor
# Перезапуск при падении
Restart=always
RestartSec=10
# Лимиты
LimitNOFILE=65536
LimitNPROC=4096
# Логирование — systemd journal
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ses-monitor
# Окружение
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONPATH=/opt/ses-monitor
# Защита (раскомментировать после тестирования)
# ProtectSystem=full
# ProtectHome=true
# NoNewPrivileges=true
[Install]
WantedBy=multi-user.target

0
tests/__init__.py Normal file
View File

View File

@ -0,0 +1,694 @@
"""
Тесты для основных компонентов системы мониторинга SES.
Проверяют:
- Загрузку конфигурации из YAML
- Извлечение JSON-значений по пути
- Проверку условий ожидания (expect)
- Парсинг ответов SES API
- Выполнение шагов сценария (с мок-клиентом)
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
import yaml
from ses_monitor.config import (
ScenarioConfig,
ScenarioStep,
load_scenarios,
)
from ses_monitor.utils import (
check_all_expectations,
check_expectation,
get_json_value,
setup_logging,
timer,
)
from ses_monitor.session_client import AskResponse, HostnameResponse, LicenseResponse
# =============================================================================
# Тесты: config.py
# =============================================================================
class TestConfigLoading:
"""Тесты загрузки конфигурации."""
def test_load_scenarios(self):
"""Проверяет загрузку YAML-сценариев из каталога."""
with tempfile.TemporaryDirectory() as tmpdir:
# Создаём тестовый сценарий
scenario_yaml = {
"name": "Тестовый сценарий",
"description": "Проверка загрузки",
"enabled": True,
"interval": 120,
"steps": [
{
"send": "привет",
"expect": [
{"path": "error", "equals": 0},
{"path": "answer[0].messages[0]", "contains": "при"},
],
}
],
}
with open(Path(tmpdir) / "test_scenario.yaml", "w") as f:
yaml.dump(scenario_yaml, f)
scenarios = load_scenarios(tmpdir)
assert len(scenarios) == 1
s = scenarios[0]
assert s.name == "Тестовый сценарий"
assert s.interval == 120
assert len(s.steps) == 1
assert s.steps[0].send == "привет"
assert len(s.steps[0].expect) == 2
def test_load_scenarios_disabled(self):
"""Проверяет что отключённые сценарии не загружаются."""
with tempfile.TemporaryDirectory() as tmpdir:
scenario_yaml = {
"name": "Отключённый",
"enabled": False,
"steps": [],
}
with open(Path(tmpdir) / "disabled.yaml", "w") as f:
yaml.dump(scenario_yaml, f)
scenarios = load_scenarios(tmpdir)
assert len(scenarios) == 0
def test_load_scenarios_empty_dir(self):
"""Проверяет загрузку из пустого каталога."""
with tempfile.TemporaryDirectory() as tmpdir:
scenarios = load_scenarios(tmpdir)
assert scenarios == []
# =============================================================================
# Тесты: utils.py — get_json_value
# =============================================================================
class TestGetJsonValue:
"""Тесты извлечения значений из JSON по пути."""
def test_simple_key(self):
data = {"error": 0, "message": "ok"}
assert get_json_value(data, "error") == 0
assert get_json_value(data, "message") == "ok"
def test_nested_key(self):
data = {"answer": [{"messages": ["здравствуйте"]}]}
assert get_json_value(data, "answer[0].messages[0]") == "здравствуйте"
def test_missing_key_returns_none(self):
data = {"error": 0}
assert get_json_value(data, "nonexistent") is None
assert get_json_value(data, "answer[0].text") is None
def test_array_index_out_of_bounds(self):
data = {"items": [1, 2]}
assert get_json_value(data, "items[5]") is None
def test_deeply_nested(self):
data = {
"request": {
"data": [
{
"reply": {
"confidence": 0.95,
}
}
]
}
}
assert get_json_value(data, "request.data[0].reply.confidence") == 0.95
def test_non_dict_data(self):
assert get_json_value([1, 2, 3], "items[0]") is None
# =============================================================================
# Тесты: utils.py — check_expectation
# =============================================================================
class TestCheckExpectation:
"""Тесты проверки условий ожидания."""
def test_equals_int(self):
ok, msg = check_expectation({"error": 0}, "error", "equals", 0)
assert ok
assert msg == ""
def test_equals_fail(self):
ok, msg = check_expectation({"error": 1}, "error", "equals", 0)
assert not ok
assert "не равно" in msg
def test_contains_str(self):
data = {"answer": [{"messages": ["введите фамилию"]}]}
ok, msg = check_expectation(
data, "answer[0].messages[0]", "contains", "фамилию"
)
assert ok
def test_contains_case_insensitive(self):
data = {"answer": [{"messages": ["Введите ФАМИЛИЮ"]}]}
ok, msg = check_expectation(
data, "answer[0].messages[0]", "contains", "фамилию"
)
assert ok
def test_not_contains(self):
data = {"answer": [{"messages": ["всё хорошо"]}]}
ok, msg = check_expectation(
data, "answer[0].messages[0]", "not_contains", "ошибка"
)
assert ok
def test_not_contains_fail(self):
data = {"answer": [{"messages": ["произошла ошибка"]}]}
ok, msg = check_expectation(
data, "answer[0].messages[0]", "not_contains", "ошибка"
)
assert not ok
def test_not_empty(self):
ok, msg = check_expectation(
{"text": "привет"}, "text", "not_empty", True
)
assert ok
def test_not_empty_fail(self):
ok, msg = check_expectation({}, "text", "not_empty", True)
assert not ok
def test_not_empty_empty_string(self):
ok, msg = check_expectation({"text": ""}, "text", "not_empty", True)
assert not ok
def test_regex_match(self):
data = {"phone": "+7-913-123-45-67"}
ok, msg = check_expectation(data, "phone", "regex", r"\+7[\d\-]+")
assert ok
def test_regex_no_match(self):
data = {"phone": "abc"}
ok, msg = check_expectation(data, "phone", "regex", r"^\+\d+$")
assert not ok
def test_exists(self):
ok, msg = check_expectation({"name": "test"}, "name", "exists")
assert ok
def test_exists_fail(self):
ok, msg = check_expectation({}, "name", "exists")
assert not ok
def test_missing_path(self):
ok, msg = check_expectation({}, "nonexistent[0].field", "equals", 0)
assert not ok
assert "не найдено" in msg or "не найд" in msg
# =============================================================================
# Тесты: utils.py — check_all_expectations
# =============================================================================
class TestCheckAllExpectations:
"""Тесты групповой проверки ожиданий."""
def test_all_pass(self):
data = {"error": 0, "answer": [{"messages": ["здравствуйте"]}]}
expectations = [
{"path": "error", "equals": 0},
{"path": "answer[0].messages[0]", "contains": "здрав"},
]
passed, errors = check_all_expectations(data, expectations)
assert passed
assert errors == []
def test_one_fails(self):
data = {"error": 1}
expectations = [
{"path": "error", "equals": 0},
{"path": "answer", "not_empty": True},
]
passed, errors = check_all_expectations(data, expectations)
assert not passed
assert len(errors) >= 1 # Первое или оба могут упасть
def test_no_expectations(self):
passed, errors = check_all_expectations({}, [])
assert passed
assert errors == []
def test_missing_path_in_expectation(self):
passed, errors = check_all_expectations({}, [{"contains": "test"}])
assert not passed
assert any("path" in e for e in errors)
# =============================================================================
# Тесты: utils.py — timer
# =============================================================================
class TestTimer:
"""Тесты контекстного менеджера timer."""
def test_measures_time(self):
import time
with timer() as t:
time.sleep(0.05)
assert "start" in t
assert "elapsed" in t
assert t["elapsed"] >= 0.04 # Допускаем небольшую погрешность
# =============================================================================
# Тесты: session_client.py — парсинг ответов
# =============================================================================
class TestAskResponse:
"""Тесты парсинга ответа POST /ses/ask."""
def test_from_valid_json(self):
data = {
"error": 0,
"question": "привет",
"answer": [{"messages": ["здравствуйте"], "voice": "Коля"}],
"session": "monitoring_test_123",
"uuid": "abc-def-123",
}
resp = AskResponse.from_json(data)
assert resp.error == 0
assert resp.question == "привет"
assert len(resp.answer) == 1
assert resp.session == "monitoring_test_123"
assert resp.uuid == "abc-def-123"
assert resp.raw == data
def test_from_error_json(self):
data = {"error": 1, "message": "robot not found"}
resp = AskResponse.from_json(data)
assert resp.error == 1
assert resp.question == ""
assert resp.answer == []
class TestHostnameResponse:
"""Тесты парсинга ответа GET /ses/hostname."""
def test_from_valid_json(self):
data = {
"error": 0,
"message": "Success",
"hostname": "ses-host.example.com",
"version": "5.046 release",
}
resp = HostnameResponse.from_json(data)
assert resp.error == 0
assert resp.hostname == "ses-host.example.com"
assert "5.046" in resp.version
class TestLicenseResponse:
"""Тесты парсинга ответа GET /license/check."""
def test_from_valid_json(self):
data = {
"error": 0,
"uuid": "398e8f18a83a6535d2bade81e9ee52a3",
"remaining_licenses": "infinity",
"threshold": 0,
}
resp = LicenseResponse.from_json(data)
assert resp.error == 0
assert resp.remaining_licenses == "infinity"
# =============================================================================
# Тесты: scenario_runner.py — с мок-клиентом
# =============================================================================
class TestScenarioRunner:
"""Тесты выполнения сценариев с мок-клиентом."""
@pytest.mark.asyncio
async def test_run_simple_scenario(self):
"""Проверяет выполнение простого сценария с успешным ответом."""
from ses_monitor.config import SESConfig
from ses_monitor.scenario_runner import ScenarioRunner
scenario = ScenarioConfig(
name="test",
interval=10,
steps=[
ScenarioStep(
send="привет",
expect=[{"path": "error", "equals": 0}],
),
],
)
runner = ScenarioRunner(SESConfig(), [scenario])
# Мок HTTP-клиента
mock_client = AsyncMock()
mock_client.ask.return_value = AskResponse.from_json({
"error": 0,
"question": "привет",
"answer": [{"messages": ["здравствуйте"], "voice": "Коля"}],
"session": "monitoring_test_1",
"uuid": "test-uuid",
})
results = await runner.run_all(mock_client)
assert len(results) == 1
assert results[0].passed
assert len(results[0].step_results) == 1
assert results[0].step_results[0].passed
@pytest.mark.asyncio
async def test_run_scenario_api_error(self):
"""Проверяет что сценарий фейлится при error != 0."""
from ses_monitor.config import SESConfig
from ses_monitor.scenario_runner import ScenarioRunner
scenario = ScenarioConfig(
name="test",
interval=10,
steps=[
ScenarioStep(
send="привет",
expect=[{"path": "error", "equals": 0}],
),
],
)
runner = ScenarioRunner(SESConfig(), [scenario])
mock_client = AsyncMock()
mock_client.ask.return_value = AskResponse.from_json({
"error": 1,
"question": "привет",
"answer": [],
"session": "",
"uuid": "",
})
results = await runner.run_all(mock_client)
assert len(results) == 1
assert not results[0].passed
@pytest.mark.asyncio
async def test_run_scenario_expectation_fail(self):
"""Проверяет что сценарий фейлится при несовпадении ожиданий."""
from ses_monitor.config import SESConfig
from ses_monitor.scenario_runner import ScenarioRunner
scenario = ScenarioConfig(
name="test",
interval=10,
steps=[
ScenarioStep(
send="привет",
expect=[{"path": "answer[0].messages[0]", "contains": "ОЖИДАЕМЫЙ_ТЕКСТ"}],
),
],
)
runner = ScenarioRunner(SESConfig(), [scenario])
mock_client = AsyncMock()
mock_client.ask.return_value = AskResponse.from_json({
"error": 0,
"question": "привет",
"answer": [{"messages": ["другой текст"], "voice": "Коля"}],
"session": "test_1",
"uuid": "test-uuid",
})
results = await runner.run_all(mock_client)
assert len(results) == 1
assert not results[0].passed
# =============================================================================
# Тесты: aggregate_scenario_results
# =============================================================================
class TestAggregateScenarioResults:
"""Тесты агрегации результатов сценариев."""
def _make_passed(self, name: str = "test", time_ms: float = 100.0):
"""Создаёт успешный результат."""
from ses_monitor.scenario_runner import ScenarioResult
return ScenarioResult(
scenario_name=name,
passed=True,
total_time_ms=time_ms,
)
def _make_failed(self, name: str = "test", time_ms: float = 100.0):
"""Создаёт упавший результат."""
from ses_monitor.scenario_runner import ScenarioResult
return ScenarioResult(
scenario_name=name,
passed=False,
total_time_ms=time_ms,
error_message="тестовая ошибка",
)
def test_empty_list_returns_zeroes(self):
"""Пустой список — ok=1, всё нули."""
from ses_monitor.scenario_runner import aggregate_scenario_results
agg = aggregate_scenario_results([])
assert agg["ok"] == 1
assert agg["total"] == 0
assert agg["failed"] == 0
assert agg["avg_time"] == 0
assert agg["max_time"] == 0
assert agg["error"] == ""
def test_all_pass(self):
"""Все сценарии прошли — ok=1, failed=0, error пуст."""
from ses_monitor.scenario_runner import aggregate_scenario_results
results = [
self._make_passed("a", 100.0),
self._make_passed("b", 200.0),
self._make_passed("c", 300.0),
]
agg = aggregate_scenario_results(results)
assert agg["ok"] == 1
assert agg["total"] == 3
assert agg["failed"] == 0
assert agg["error"] == ""
assert agg["avg_time"] == 200 # (100+200+300)/3
assert agg["max_time"] == 300
def test_some_fail(self):
"""Часть сценариев упала — ok=0, failed=N, error содержит счётчик."""
from ses_monitor.scenario_runner import aggregate_scenario_results
results = [
self._make_passed("a", 100.0),
self._make_failed("b", 200.0),
self._make_failed("c", 300.0),
]
agg = aggregate_scenario_results(results)
assert agg["ok"] == 0
assert agg["total"] == 3
assert agg["failed"] == 2
assert agg["error"] == "2 сценариев упали"
assert agg["avg_time"] == 200
assert agg["max_time"] == 300
def test_all_fail(self):
"""Все сценарии упали — ok=0, failed=total."""
from ses_monitor.scenario_runner import aggregate_scenario_results
results = [
self._make_failed("a", 50.0),
self._make_failed("b", 150.0),
]
agg = aggregate_scenario_results(results)
assert agg["ok"] == 0
assert agg["total"] == 2
assert agg["failed"] == 2
assert agg["error"] == "2 сценариев упали"
def test_avg_time_calculation(self):
"""Среднее время считается корректно (включая упавшие)."""
from ses_monitor.scenario_runner import aggregate_scenario_results
results = [
self._make_passed("fast", 50.0),
self._make_failed("slow", 450.0),
]
agg = aggregate_scenario_results(results)
assert agg["avg_time"] == 250 # (50+450)/2
assert agg["max_time"] == 450
def test_max_time_with_single_result(self):
"""Один сценарий — avg_time == max_time."""
from ses_monitor.scenario_runner import aggregate_scenario_results
results = [self._make_passed("x", 123.0)]
agg = aggregate_scenario_results(results)
assert agg["ok"] == 1
assert agg["total"] == 1
assert agg["failed"] == 0
assert agg["avg_time"] == 123
assert agg["max_time"] == 123
def test_rounding_behaviour(self):
"""Время округляется до целых миллисекунд."""
from ses_monitor.scenario_runner import aggregate_scenario_results
results = [
self._make_passed("a", 10.6),
self._make_passed("b", 10.3),
]
agg = aggregate_scenario_results(results)
assert agg["avg_time"] == 10 # round(20.9/2) = round(10.45) = 10
assert agg["max_time"] == 11 # round(10.6) = 11
@pytest.mark.asyncio
class TestUserParameterWriter:
"""Тесты для UserParameterWriter."""
async def test_write_json_file(self, tmp_path):
"""Проверяет что JSON-файл пишется атомарно и читается."""
from ses_monitor.zabbix import UserParameterWriter
from ses_monitor.config import ZabbixConfig, ZabbixMode
metrics_file = str(tmp_path / "metrics.json")
config = ZabbixConfig(
enabled=True,
mode=ZabbixMode.USERPARAMETER,
metrics_file=metrics_file,
)
writer = UserParameterWriter(config)
writer.add("ses.api.liveness", 1)
writer.add("ses.api.response_time", 42)
writer.add("ses.license.ok", True)
ok = await writer.flush()
assert ok, "flush должен вернуть True"
import json
with open(metrics_file, "r") as f:
data = json.load(f)
assert data["ses.api.liveness"] == "1"
assert data["ses.api.response_time"] == "42"
assert data["ses.license.ok"] == "1"
async def test_disabled_no_write(self, tmp_path):
"""Проверяет что при отключённом Zabbix файл не пишется."""
from ses_monitor.zabbix import UserParameterWriter
from ses_monitor.config import ZabbixConfig, ZabbixMode
metrics_file = str(tmp_path / "metrics.json")
config = ZabbixConfig(
enabled=False,
mode=ZabbixMode.USERPARAMETER,
metrics_file=metrics_file,
)
writer = UserParameterWriter(config)
writer.add("ses.test", 1)
ok = await writer.flush()
assert ok
import os
assert not os.path.exists(metrics_file), "файл не должен создаваться"
async def test_empty_no_write(self, tmp_path):
"""Проверяет что при пустом буфере файл не пишется."""
from ses_monitor.zabbix import UserParameterWriter
from ses_monitor.config import ZabbixConfig, ZabbixMode
metrics_file = str(tmp_path / "metrics.json")
config = ZabbixConfig(
enabled=True,
mode=ZabbixMode.USERPARAMETER,
metrics_file=metrics_file,
)
writer = UserParameterWriter(config)
ok = await writer.flush()
assert ok
import os
assert not os.path.exists(metrics_file), "файл не должен создаваться"
async def test_accumulates_in_single_cycle(self, tmp_path):
"""Проверяет что все метрики одного цикла попадают в файл."""
from ses_monitor.zabbix import UserParameterWriter
from ses_monitor.config import ZabbixConfig, ZabbixMode
metrics_file = str(tmp_path / "metrics.json")
config = ZabbixConfig(
enabled=True,
mode=ZabbixMode.USERPARAMETER,
metrics_file=metrics_file,
)
writer = UserParameterWriter(config)
writer.add("ses.metric.one", 1)
writer.add("ses.metric.two", 2)
await writer.flush()
import json
with open(metrics_file, "r") as f:
data = json.load(f)
assert data.get("ses.metric.one") == "1"
assert data.get("ses.metric.two") == "2"
async def test_generate_agentd_config(self):
"""Проверяет генерацию конфига Zabbix агента."""
from ses_monitor.zabbix import UserParameterWriter
from ses_monitor.config import ZabbixConfig, ZabbixMode
config = ZabbixConfig(mode=ZabbixMode.USERPARAMETER)
writer = UserParameterWriter(config)
cfg = writer.generate_agentd_config()
assert "UserParameter=ses.metrics," in cfg
assert "zbx_get.py" in cfg
async def test_atomic_write(self, tmp_path):
"""Проверяет что запись атомарна (файл не остаётся в повреждённом состоянии)."""
from ses_monitor.zabbix import UserParameterWriter
from ses_monitor.config import ZabbixConfig, ZabbixMode
metrics_file = str(tmp_path / "metrics.json")
config = ZabbixConfig(
enabled=True,
mode=ZabbixMode.USERPARAMETER,
metrics_file=metrics_file,
)
writer = UserParameterWriter(config)
for i in range(100):
writer.add(f"ses.test.{i}", i)
ok = await writer.flush()
assert ok
import json
with open(metrics_file, "r") as f:
data = json.load(f)
assert len(data) == 100
assert data["ses.test.99"] == "99"

View File

@ -0,0 +1,10 @@
# =============================================================================
# Резервный вариант: UserParameter для Zabbix Agent (пассивный режим).
# =============================================================================
# Используется если zabbix_sender недоступен.
# Поместить в /etc/zabbix/zabbix_agentd.d/ses_monitor.conf
# и перезапустить zabbix-agent: systemctl restart zabbix-agent
#
# Скрипт ses_monitor_zabbix_agent.py должен быть установлен в
# /opt/ses-monitor/scripts/ses_monitor_zabbix_agent.py
# =============================================================================

465
zabbix/zbx_ses_template.xml Normal file
View File

@ -0,0 +1,465 @@
<?xml version="1.0" encoding="UTF-8"?>
<zabbix_export>
<version>6.0</version>
<date>2026-06-19T12:00:00Z</date>
<groups>
<group>
<name>Templates</name>
</group>
</groups>
<templates>
<template>
<uuid>a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6</uuid>
<template>SES Monitor</template>
<name>SES Monitor — Service Engine Server</name>
<description>Шаблон для мониторинга диалоговой сценарной машины SES.
Метрики отправляются watchdog-демоном ses-monitor через zabbix_sender (trapper).
Проверяет: доступность API, лицензию, выполнение сценариев, архив диалогов.
Сценарии задаются в YAML-файлах (config/scenarios/). Ключи метрик
формируются из поля zabbix_key в файле сценария. Примеры метрик:
- ses.scenario.{имя_сценария}.ok
- ses.scenario.{имя_сценария}.time
- ses.scenario.{имя_сценария}.error
Для каждого сценария вручную добавьте соответствующий item в этот шаблон.</description>
<groups>
<group>
<name>Templates</name>
</group>
</groups>
<!-- ============================================================ -->
<!-- Макросы (переопределяются на уровне хоста) -->
<!-- ============================================================ -->
<macros>
<macro>
<macro>{$SES_API_TIMEOUT_MS}</macro>
<value>5000</value>
<description>Порог медленного ответа API (миллисекунды)</description>
</macro>
<macro>
<macro>{$SES_UNKNOWN_THRESHOLD}</macro>
<value>10</value>
<description>Порог unknown-запросов за 5 минут</description>
</macro>
<macro>
<macro>{$SES_STEP_TIME_WARNING_MS}</macro>
<value>5000</value>
<description>Порог предупреждения: среднее время шага диалога (мс)</description>
</macro>
<macro>
<macro>{$SES_STEP_TIME_ERROR_MS}</macro>
<value>15000</value>
<description>Порог ошибки: среднее время шага диалога (мс)</description>
</macro>
</macros>
<items>
<!-- ======================================================== -->
<!-- Liveness-проверки -->
<!-- ======================================================== -->
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000001</uuid>
<name>SES: API доступность</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.api.liveness</key>
<delay>0</delay>
<history>7d</history>
<trends>0</trends>
<value_type>UINT64</value_type>
<description>0 — API недоступен, 1 — работает. Проверяется GET /ses/hostname.</description>
<triggers>
<trigger>
<uuid>tr00000000000000000000000000000001</uuid>
<expression>{last()}=0</expression>
<name>SES API недоступен на {HOST.NAME}</name>
<priority>HIGH</priority>
<manual_close>YES</manual_close>
</trigger>
</triggers>
</item>
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000002</uuid>
<name>SES: время ответа API</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.api.response_time</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>FLOAT</value_type>
<units>ms</units>
<description>Время ответа GET /ses/hostname в миллисекундах.</description>
<triggers>
<trigger>
<uuid>tr00000000000000000000000000000002</uuid>
<expression>{last()}&gt;{$SES_API_TIMEOUT_MS}</expression>
<name>SES: медленный ответ API (&gt;{$SES_API_TIMEOUT_MS} мс)</name>
<priority>WARNING</priority>
<manual_close>YES</manual_close>
</trigger>
</triggers>
</item>
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000003</uuid>
<name>SES: статус лицензии</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.license.ok</key>
<delay>0</delay>
<history>7d</history>
<trends>0</trends>
<value_type>UINT64</value_type>
<description>0 — проблема с лицензией, 1 — в порядке.</description>
<triggers>
<trigger>
<uuid>tr00000000000000000000000000000003</uuid>
<expression>{last()}=0</expression>
<name>SES: проблема с лицензией на {HOST.NAME}</name>
<priority>HIGH</priority>
<manual_close>YES</manual_close>
</trigger>
</triggers>
</item>
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000004</uuid>
<name>SES: количество сценариев (роботов)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.robots.count</key>
<delay>0</delay>
<history>30d</history>
<trends>365d</trends>
<value_type>UINT64</value_type>
<description>Общее количество зарегистрированных сценариев (роботов).
Изменение значения может сигнализировать о проблемах конфигурации.</description>
</item>
<!-- ======================================================== -->
<!-- Сценарии (добавляйте items по мере создания YAML-файлов) -->
<!-- ======================================================== -->
<!--
Формат ключа: ses.scenario.{zabbix_key}.ok
Где {zabbix_key} — значение поля zabbix_key из YAML-файла сценария.
Пример для сценария с zabbix_key: "ses.scenario.my_bot":
<item>
<name>SES: сценарий my_bot</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.scenario.my_bot.ok</key>
<delay>0</delay>
<history>7d</history>
<value_type>UINT64</value_type>
<triggers>
<trigger>
<expression>{last()}=0</expression>
<name>SES: сценарий my_bot провален</name>
<priority>AVERAGE</priority>
<manual_close>YES</manual_close>
</trigger>
</triggers>
</item>
<item>
<name>SES: сценарий my_bot — время</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.scenario.my_bot.time</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>FLOAT</value_type>
<units>ms</units>
</item>
<item>
<name>SES: сценарий my_bot — ошибка</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.scenario.my_bot.error</key>
<delay>0</delay>
<history>7d</history>
<value_type>TEXT</value_type>
</item>
-->
<!-- ======================================================== -->
<!-- Архив диалогов -->
<!-- ======================================================== -->
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000030</uuid>
<name>SES: архив — всего диалогов (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.archive.total_5m</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>UINT64</value_type>
<description>Количество диалогов за последние 5 минут. 0 может означать отсутствие трафика или проблемы с коннекторами.</description>
</item>
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000031</uuid>
<name>SES: архив — unknown запросы (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.archive.unknown_5m</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>UINT64</value_type>
<description>Количество нераспознанных (unknown) запросов. Рост может указывать на проблемы с моделями SEE/SMC.</description>
<triggers>
<trigger>
<uuid>tr0000000000000000000000000000031</uuid>
<expression>{last()}&gt;{$SES_UNKNOWN_THRESHOLD}</expression>
<name>SES: много unknown запросов (&gt;{$SES_UNKNOWN_THRESHOLD} за 5 мин)</name>
<priority>WARNING</priority>
<manual_close>YES</manual_close>
</trigger>
</triggers>
</item>
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000032</uuid>
<name>SES: архив — not_found ошибки (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.archive.not_found_5m</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>UINT64</value_type>
</item>
<!-- ======================================================== -->
<!-- Мониторинг внутренних моделей SES -->
<!-- ======================================================== -->
<!-- SEE (распознавание намерений/сущностей) -->
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000040</uuid>
<name>SES: SEE — всего детекций (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.models.see.total</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>UINT64</value_type>
<description>Общее количество SEE-детекций (распознавание намерений и сущностей) за 5 минут.</description>
</item>
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000041</uuid>
<name>SES: SEE — низкая уверенность (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.models.see.low_confidence</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>UINT64</value_type>
<description>Количество SEE-детекций с уверенностью ниже порога (по умолчанию 0.5). Рост означает проблемы с распознаванием.</description>
<triggers>
<trigger>
<uuid>tr0000000000000000000000000000041</uuid>
<expression>{last()}&gt;0</expression>
<name>SES: низкая уверенность SEE-моделей</name>
<priority>WARNING</priority>
<manual_close>YES</manual_close>
</trigger>
</triggers>
</item>
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000042</uuid>
<name>SES: SEE — средняя уверенность (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.models.see.avg_confidence</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>FLOAT</value_type>
<description>Средняя уверенность (confidence) всех SEE-детекций за 5 минут (0.0 — 1.0).</description>
</item>
<!-- SMC (классификация запросов) -->
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000050</uuid>
<name>SES: SMC — всего классификаций (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.models.smc.total</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>UINT64</value_type>
<description>Общее количество SMC-классификаций за 5 минут.</description>
</item>
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000051</uuid>
<name>SES: SMC — низкая уверенность (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.models.smc.low_confidence</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>UINT64</value_type>
<description>Количество SMC-классификаций с уверенностью ниже порога. Рост означает дрифт классификации.</description>
<triggers>
<trigger>
<uuid>tr0000000000000000000000000000051</uuid>
<expression>{last()}&gt;0</expression>
<name>SES: низкая уверенность SMC-классификации</name>
<priority>WARNING</priority>
<manual_close>YES</manual_close>
</trigger>
</triggers>
</item>
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000052</uuid>
<name>SES: SMC — средняя уверенность (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.models.smc.avg_confidence</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>FLOAT</value_type>
<description>Средняя уверенность всех SMC-классификаций за 5 минут (0.0 — 1.0).</description>
</item>
<!-- TTS (синтез речи) -->
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000060</uuid>
<name>SES: TTS — голосовых ответов (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.models.tts.voice_present</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>UINT64</value_type>
<description>Количество ответов с синтезированным голосом за 5 минут.</description>
</item>
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000061</uuid>
<name>SES: TTS — ответов без голоса (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.models.tts.voice_missing</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>UINT64</value_type>
<description>Количество ответов без голоса за 5 минут. Рост при наличии трафика — проблема TTS.</description>
</item>
<!-- QAS (вопросно-ответная система) -->
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000070</uuid>
<name>SES: QAS — всего диалогов с QAS (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.models.qas.total</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>UINT64</value_type>
</item>
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000071</uuid>
<name>SES: QAS — пустых ответов (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.models.qas.empty</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>UINT64</value_type>
<description>Количество диалогов с пустым QAS за 5 минут. Высокое значение — QAS не отвечает.</description>
<triggers>
<trigger>
<uuid>tr0000000000000000000000000000071</uuid>
<expression>{last()}&gt;0 and {ses.models.qas.total.last()}&gt;0</expression>
<name>SES: QAS возвращает пустые ответы</name>
<priority>WARNING</priority>
<manual_close>YES</manual_close>
</trigger>
</triggers>
</item>
<!-- SPR (распознавание речи: аудио→текст) -->
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000080</uuid>
<name>SES: SPR — всего диалогов с reports (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.models.spr.total</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>UINT64</value_type>
</item>
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000081</uuid>
<name>SES: SPR — успешных расшифровок (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.models.spr.filled</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>UINT64</value_type>
<description>Количество диалогов с успешной расшифровкой речи (непустые reports) за 5 минут.</description>
</item>
<!-- ======================================================== -->
<!-- Время шага диалога (из request.datetime / reply.datetime) -->
<!-- ======================================================== -->
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000090</uuid>
<name>SES: среднее время шага диалога (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.archive.avg_step_time_5m</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>FLOAT</value_type>
<units>ms</units>
<description>Среднее время обработки шага диалога (request.datetime -> reply.datetime) за 5 минут. Рост указывает на тормоза в пайплайне (SEE/SMC/TTS/QAS или внешние API).</description>
<triggers>
<trigger>
<uuid>tr0000000000000000000000000000090</uuid>
<expression>{last()}&gt;{$SES_STEP_TIME_WARNING_MS}</expression>
<name>SES: медленная обработка шага диалога (&gt;{$SES_STEP_TIME_WARNING_MS} мс) на {HOST.NAME}</name>
<priority>WARNING</priority>
<manual_close>YES</manual_close>
</trigger>
<trigger>
<uuid>tr0000000000000000000000000000091</uuid>
<expression>{last()}&gt;{$SES_STEP_TIME_ERROR_MS}</expression>
<name>SES: критическая задержка обработки шага (&gt;{$SES_STEP_TIME_ERROR_MS} мс) на {HOST.NAME}</name>
<priority>AVERAGE</priority>
<manual_close>YES</manual_close>
</trigger>
</triggers>
</item>
<item>
<uuid>a1b2c3d4e5f6a7b8000000000000092</uuid>
<name>SES: макс. время шага диалога (5 мин)</name>
<type>ZABBIX_TRAPPER</type>
<key>ses.archive.max_step_time_5m</key>
<delay>0</delay>
<history>7d</history>
<trends>30d</trends>
<value_type>FLOAT</value_type>
<units>ms</units>
<description>Максимальное время обработки шага диалога за 5 минут. Показывает пиковые задержки.</description>
</item>
</items>
</template>
</templates>
</zabbix_export>

View File

@ -0,0 +1,212 @@
<?xml version='1.0' encoding='utf-8'?>
<zabbix_export>
<version>5.0</version>
<date>2026-06-19T12:00:00Z</date>
<groups>
<group>
<name>Templates</name>
</group>
</groups>
<templates>
<template>
<template>SES Monitor</template>
<name>SES Monitor — Service Engine Server</name>
<description>Шаблон для мониторинга диалоговой сценарной машины SES.
Метрики отправляются watchdog-демоном ses-monitor через zabbix_sender (trapper).
Проверяет: доступность API, лицензию, выполнение сценариев.
Сценарии задаются в YAML-файлах (config/scenarios/). Все сценарии
агрегируются в фиксированные метрики — шаблон универсален и не зависит
от количества или состава сценариев.</description>
<groups>
<group>
<name>Templates</name>
</group>
</groups>
<macros>
<macro>
<macro>{$SES_API_TIMEOUT_MS}</macro>
<value>5000</value>
<description>Порог медленного ответа API (миллисекунды)</description>
</macro>
</macros>
<items>
<!-- Liveness -->
<item>
<name>SES: API доступность</name>
<type>TRAP</type>
<key>ses.api.liveness</key>
<delay>0</delay>
<history>7d</history>
<trends>31536000</trends>
<value_type>UNSIGNED</value_type>
<description>0 — API недоступен, 1 — работает. Проверяется GET /ses/hostname.</description>
<triggers>
<trigger>
<expression>{SES Monitor:ses.api.liveness.last()}=0</expression>
<name>SES API недоступен на {HOST.NAME}</name>
<priority>HIGH</priority>
<manual_close>YES</manual_close>
</trigger>
</triggers>
</item>
<item>
<name>SES: время ответа API</name>
<type>TRAP</type>
<key>ses.api.response_time</key>
<delay>0</delay>
<history>7d</history>
<trends>31536000</trends>
<value_type>FLOAT</value_type>
<units>ms</units>
<description>Время ответа GET /ses/hostname в миллисекундах.</description>
<triggers>
<trigger>
<expression>{SES Monitor:ses.api.response_time.last()}&gt;{$SES_API_TIMEOUT_MS}</expression>
<name>SES: медленный ответ API (&gt;{$SES_API_TIMEOUT_MS} мс)</name>
<priority>WARNING</priority>
<manual_close>YES</manual_close>
</trigger>
</triggers>
</item>
<item>
<name>SES: дней до окончания лицензии</name>
<type>TRAP</type>
<key>ses.license.days_remaining</key>
<delay>0</delay>
<history>7d</history>
<trends>31536000</trends>
<value_type>FLOAT</value_type>
<units>d</units>
<description>Количество дней до окончания лицензии SES. -1 = бессрочная.</description>
</item>
<item>
<name>SES: статус лицензии</name>
<type>TRAP</type>
<key>ses.license.ok</key>
<delay>0</delay>
<history>7d</history>
<trends>31536000</trends>
<value_type>UNSIGNED</value_type>
<description>0 — проблема с лицензией, 1 — в порядке.</description>
<triggers>
<trigger>
<expression>{SES Monitor:ses.license.ok.last()}=0</expression>
<name>SES: проблема с лицензией на {HOST.NAME}</name>
<priority>HIGH</priority>
<manual_close>YES</manual_close>
</trigger>
</triggers>
</item>
<item>
<name>SES: количество сценариев (роботов)</name>
<type>TRAP</type>
<key>ses.robots.count</key>
<delay>0</delay>
<history>7d</history>
<trends>31536000</trends>
<value_type>UNSIGNED</value_type>
<description>Общее количество зарегистрированных сценариев (роботов).
Изменение значения может сигнализировать о проблемах конфигурации.</description>
</item>
<!-- Сценарии -->
<item>
<name>SES: сценарии — все успешны</name>
<type>TRAP</type>
<key>ses.scenario.ok</key>
<delay>0</delay>
<history>7d</history>
<trends>31536000</trends>
<value_type>UNSIGNED</value_type>
<description>1 — все сценарии прошли, 0 — есть упавшие.</description>
<triggers>
<trigger>
<expression>{SES Monitor:ses.scenario.ok.last()}=0</expression>
<name>SES: сценарии мониторинга падают на {HOST.NAME}</name>
<priority>AVERAGE</priority>
<manual_close>YES</manual_close>
</trigger>
</triggers>
</item>
<item>
<name>SES: сценарии — всего</name>
<type>TRAP</type>
<key>ses.scenario.total</key>
<delay>0</delay>
<history>7d</history>
<trends>31536000</trends>
<value_type>UNSIGNED</value_type>
<description>Количество активных сценариев мониторинга.</description>
</item>
<item>
<name>SES: сценарии — упало</name>
<type>TRAP</type>
<key>ses.scenario.failed</key>
<delay>0</delay>
<history>7d</history>
<trends>31536000</trends>
<value_type>UNSIGNED</value_type>
<description>Количество упавших сценариев за последний цикл.</description>
<triggers>
<trigger>
<expression>{SES Monitor:ses.scenario.failed.last()}&gt;0</expression>
<name>SES: сценарии мониторинга падают на {HOST.NAME}</name>
<priority>WARNING</priority>
<manual_close>YES</manual_close>
</trigger>
</triggers>
</item>
<item>
<name>SES: сценарии — среднее время</name>
<type>TRAP</type>
<key>ses.scenario.avg_time</key>
<delay>0</delay>
<history>7d</history>
<trends>31536000</trends>
<value_type>FLOAT</value_type>
<units>ms</units>
<description>Среднее время выполнения сценария (миллисекунды).</description>
</item>
<item>
<name>SES: сценарии — макс. время</name>
<type>TRAP</type>
<key>ses.scenario.max_time</key>
<delay>0</delay>
<history>7d</history>
<trends>31536000</trends>
<value_type>FLOAT</value_type>
<units>ms</units>
<description>Максимальное время выполнения сценария (миллисекунды).</description>
</item>
<item>
<name>SES: сценарии — ошибка</name>
<type>TRAP</type>
<key>ses.scenario.error</key>
<delay>0</delay>
<history>7d</history>
<trends>31536000</trends>
<value_type>TEXT</value_type>
<description>Текст ошибки: "N сценариев упали". Пустая строка если всё хорошо.</description>
</item>
</items>
</template>
</templates>
</zabbix_export>

View File

@ -0,0 +1,261 @@
<?xml version="1.0" encoding="UTF-8"?>
<zabbix_export>
<version>5.0</version>
<date>2026-06-19T12:00:00Z</date>
<groups>
<group>
<name>Templates</name>
</group>
</groups>
<templates>
<template>
<template>SES Monitor</template>
<name>SES Monitor — Service Engine Server</name>
<description>Шаблон для мониторинга диалоговой сценарной машины SES.
Метрики передаются watchdog-демоном ses-monitor через UserParameter (Zabbix агент).
JSON с метриками пишется в /var/tmp/ses_monitor/metrics.json и читается
скриптом scripts/zbx_get.py. Master-элемент: ses.metrics.
Проверяет: доступность API, лицензию, выполнение сценариев.
Сценарии задаются в YAML-файлах (config/scenarios/). Все сценарии
агрегируются в фиксированные метрики — шаблон универсален и не зависит
от количества или состава сценариев.</description>
<groups>
<group>
<name>Templates</name>
</group>
</groups>
<macros>
<macro>
<macro>{$SES_API_TIMEOUT_MS}</macro>
<value>5000</value>
<description>Порог медленного ответа API (миллисекунды)</description>
</macro>
</macros>
<items>
<item>
<name>SES: все метрики (JSON)</name>
<type>ZABBIX_PASSIVE</type>
<key>ses.metrics</key>
<delay>30s</delay>
<history>7d</history>
<trends>31536000</trends>
<value_type>TEXT</value_type>
<description>Полный JSON со всеми метриками ses-monitor. Используется как master item для dependent items с JSONPath.</description>
</item>
<!-- Liveness -->
<item>
<name>SES: API доступность</name>
<type>DEPENDENT</type>
<master_item>
<key>ses.metrics</key>
</master_item>
<preprocessing>
<step>
<type>JSONPATH</type>
<params>$["ses.api.liveness"]</params>
</step>
</preprocessing>
<history>7d</history>
<trends>31536000</trends>
<value_type>UNSIGNED</value_type>
<key>ses.api.liveness</key>
</item>
<item>
<name>SES: время ответа API</name>
<type>DEPENDENT</type>
<master_item>
<key>ses.metrics</key>
</master_item>
<preprocessing>
<step>
<type>JSONPATH</type>
<params>$["ses.api.response_time"]</params>
</step>
</preprocessing>
<history>7d</history>
<trends>31536000</trends>
<value_type>FLOAT</value_type>
<units>ms</units>
<key>ses.api.response_time</key>
</item>
<item>
<name>SES: статус лицензии</name>
<type>DEPENDENT</type>
<master_item>
<key>ses.metrics</key>
</master_item>
<preprocessing>
<step>
<type>JSONPATH</type>
<params>$["ses.license.ok"]</params>
</step>
</preprocessing>
<history>7d</history>
<trends>31536000</trends>
<value_type>UNSIGNED</value_type>
<key>ses.license.ok</key>
</item>
<item>
<name>SES: дней до окончания лицензии</name>
<type>DEPENDENT</type>
<master_item>
<key>ses.metrics</key>
</master_item>
<preprocessing>
<step>
<type>JSONPATH</type>
<params>$["ses.license.days_remaining"]</params>
</step>
</preprocessing>
<history>7d</history>
<trends>31536000</trends>
<value_type>FLOAT</value_type>
<units>d</units>
<key>ses.license.days_remaining</key>
</item>
<item>
<name>SES: количество сценариев (роботов)</name>
<type>DEPENDENT</type>
<master_item>
<key>ses.metrics</key>
</master_item>
<preprocessing>
<step>
<type>JSONPATH</type>
<params>$["ses.robots.count"]</params>
</step>
</preprocessing>
<history>7d</history>
<trends>31536000</trends>
<value_type>UNSIGNED</value_type>
<key>ses.robots.count</key>
</item>
<!-- Сценарии -->
<item>
<name>SES: сценарии — все успешны</name>
<type>DEPENDENT</type>
<master_item>
<key>ses.metrics</key>
</master_item>
<preprocessing>
<step>
<type>JSONPATH</type>
<params>$["ses.scenario.ok"]</params>
</step>
</preprocessing>
<history>7d</history>
<trends>31536000</trends>
<value_type>UNSIGNED</value_type>
<key>ses.scenario.ok</key>
</item>
<item>
<name>SES: сценарии — всего</name>
<type>DEPENDENT</type>
<master_item>
<key>ses.metrics</key>
</master_item>
<preprocessing>
<step>
<type>JSONPATH</type>
<params>$["ses.scenario.total"]</params>
</step>
</preprocessing>
<history>7d</history>
<trends>31536000</trends>
<value_type>UNSIGNED</value_type>
<key>ses.scenario.total</key>
</item>
<item>
<name>SES: сценарии — упало</name>
<type>DEPENDENT</type>
<master_item>
<key>ses.metrics</key>
</master_item>
<preprocessing>
<step>
<type>JSONPATH</type>
<params>$["ses.scenario.failed"]</params>
</step>
</preprocessing>
<history>7d</history>
<trends>31536000</trends>
<value_type>UNSIGNED</value_type>
<key>ses.scenario.failed</key>
</item>
<item>
<name>SES: сценарии — среднее время</name>
<type>DEPENDENT</type>
<master_item>
<key>ses.metrics</key>
</master_item>
<preprocessing>
<step>
<type>JSONPATH</type>
<params>$["ses.scenario.avg_time"]</params>
</step>
</preprocessing>
<history>7d</history>
<trends>31536000</trends>
<value_type>FLOAT</value_type>
<units>ms</units>
<key>ses.scenario.avg_time</key>
</item>
<item>
<name>SES: сценарии — макс. время</name>
<type>DEPENDENT</type>
<master_item>
<key>ses.metrics</key>
</master_item>
<preprocessing>
<step>
<type>JSONPATH</type>
<params>$["ses.scenario.max_time"]</params>
</step>
</preprocessing>
<history>7d</history>
<trends>31536000</trends>
<value_type>FLOAT</value_type>
<units>ms</units>
<key>ses.scenario.max_time</key>
</item>
<item>
<name>SES: сценарии — ошибка</name>
<type>DEPENDENT</type>
<master_item>
<key>ses.metrics</key>
</master_item>
<preprocessing>
<step>
<type>JSONPATH</type>
<params>$["ses.scenario.error"]</params>
</step>
</preprocessing>
<history>7d</history>
<trends>31536000</trends>
<value_type>TEXT</value_type>
<key>ses.scenario.error</key>
</item>
</items>
</template>
</templates>
</zabbix_export>