Initial commit: ses-monitor watchdog for SES
Универсальный watchdog-мониторинг Service Engine Server (SES): - Liveness-проверки (API, лицензия, роботы) - Диалоговые сценарии из YAML с агрегацией в 6 фиксированных Zabbix-метрик - Доставка: zabbix_sender (active push) или UserParameter (пассивный опрос) - Поддержка Python 3.7+, Zabbix 5.0
This commit is contained in:
commit
7394b14b52
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
|
||||
363
README.md
Normal file
363
README.md
Normal file
@ -0,0 +1,363 @@
|
||||
# SES Monitor
|
||||
|
||||
Система непрерывного мониторинга диалоговой сценарной машины **SES** (Service Engine Server).
|
||||
|
||||
## Что делает
|
||||
|
||||
Watchdog-демон, который:
|
||||
- **Проверяет доступность** SES API каждые 30 секунд
|
||||
- **Имитирует диалоги пользователей** по сценариям, описанным в YAML-файлах
|
||||
- **Анализирует архив** диалогов на ошибки (unknown, not found, no data)
|
||||
- **Отправляет метрики в Zabbix** через `zabbix_sender`
|
||||
|
||||
SES — универсальная сценарная машина, поэтому мониторинг не привязан к конкретному проекту. Один и тот же демон может обслуживать любой сценарий: голосового ассистента, чат-бота, IVR, диалогов МФЦ, медорганизаций и любых других.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ses-monitor (watchdog-демон) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌────────────────┐ ┌──────────┐│
|
||||
│ │ LivenessCheck │ │ ScenarioRunner │ │ Archive ││
|
||||
│ │ (30 сек) │ │ (по YAML) │ │ Auditor ││
|
||||
│ │ │ │ │ │ (10 мин) ││
|
||||
│ │ GET /hostname│ │ POST /ses/ask │ │ /archive ││
|
||||
│ │ GET /license │ │ шаги+expect │ │ /list ││
|
||||
│ └──────┬───────┘ └───────┬─────────┘ └────┬─────┘│
|
||||
│ │ │ │ │
|
||||
│ ┌──────▼──────────────────▼──────────────────▼─────┐│
|
||||
│ │ ZabbixSender (zabbix_sender) ││
|
||||
│ └──────────────────────┬───────────────────────────┘│
|
||||
└─────────────────────────┼───────────────────────────┘
|
||||
│ TCP 10051
|
||||
┌───────────▼───────────┐
|
||||
│ 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/
|
||||
├── ses_monitor/ # Python-пакет
|
||||
│ ├── __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 # Движок выполнения сценариев
|
||||
│ ├── archive_auditor.py # Анализ архива диалогов
|
||||
│ ├── log_client.py # Чтение логов SES
|
||||
│ ├── zabbix.py # Интеграция с Zabbix (zabbix_sender)
|
||||
│ └── utils.py # Утилиты (логирование, JSON-пути, проверки)
|
||||
├── config/
|
||||
│ ├── config.yaml # Основная конфигурация
|
||||
│ └── scenarios/
|
||||
│ ├── example_liveness.yaml # Пример: быстрая проверка
|
||||
│ └── example_dialog.yaml # Пример: многошаговый диалог
|
||||
├── zabbix/
|
||||
│ ├── zbx_ses_template.xml # Шаблон Zabbix (импорт в frontend)
|
||||
│ └── zbx_ses_monitor.conf # UserParameter для Zabbix Agent (резерв)
|
||||
├── systemd/
|
||||
│ └── ses-monitor.service # Systemd unit
|
||||
├── tests/
|
||||
│ └── test_scenario_runner.py
|
||||
├── pyproject.toml # Зависимости и метаданные пакета
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Установка на сервере
|
||||
|
||||
### 1. Требования
|
||||
|
||||
- **Python 3.9+**
|
||||
- **Zabbix Agent** с утилитой `zabbix_sender`
|
||||
- Доступ к SES API (`http://<ses-host>:6190`)
|
||||
- Доступ к Zabbix Server по порту 10051
|
||||
|
||||
### 2. Установка пакета
|
||||
|
||||
```bash
|
||||
# Копируем проект на сервер
|
||||
rsync -avz --exclude='.git' --exclude='__pycache__' ./ root@<ses-host>:/opt/ses-monitor/
|
||||
|
||||
# На сервере:
|
||||
cd /opt/ses-monitor
|
||||
|
||||
# Создаём виртуальное окружение и устанавливаем зависимости
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
|
||||
# Устанавливаем zabbix_sender (если ещё не установлен)
|
||||
apt update && apt install -y zabbix-sender
|
||||
|
||||
# Создаём каталог для логов
|
||||
mkdir -p /var/log/ses-monitor
|
||||
```
|
||||
|
||||
### 3. Настройка конфигурации
|
||||
|
||||
Отредактируйте `config/config.yaml`:
|
||||
|
||||
```yaml
|
||||
ses:
|
||||
base_url: "http://10.101.60.31:6190" # URL вашего SES API
|
||||
robot_id: "" # ID робота из GET /ses/robot/list
|
||||
channel: "monitoring" # Имя канала в архиве
|
||||
request_timeout: 30
|
||||
|
||||
zabbix:
|
||||
enabled: true
|
||||
server: "127.0.0.1" # IP вашего Zabbix сервера
|
||||
host: "my-server-name" # Имя хоста в Zabbix
|
||||
port: 10051
|
||||
sender_path: "/usr/bin/zabbix_sender"
|
||||
```
|
||||
|
||||
### 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.xml`
|
||||
3. Нажать Import
|
||||
4. Привязать шаблон "SES Monitor" к нужному хосту
|
||||
|
||||
## Формат сценариев
|
||||
|
||||
Сценарии описываются в YAML-файлах в каталоге `config/scenarios/`. Это гибкий DSL, который не зависит от конкретного проекта SES.
|
||||
|
||||
### Полный формат
|
||||
|
||||
```yaml
|
||||
# Имя сценария (используется в логах и ключах Zabbix)
|
||||
name: "example: проверка диалога"
|
||||
|
||||
# Описание для документации
|
||||
description: "Проверяет что бот корректно отвечает на вопросы"
|
||||
|
||||
# Включён ли сценарий (false — отключён)
|
||||
enabled: true
|
||||
|
||||
# Интервал запуска в секундах
|
||||
interval: 300
|
||||
|
||||
# Таймаут HTTP-запроса в секундах
|
||||
timeout: 30
|
||||
|
||||
# ID робота (если не указан — берётся из config.yaml)
|
||||
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/list
|
||||
```
|
||||
|
||||
2. **Пропишите robot_id** в `config/config.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. **Добавьте item в Zabbix** для вашего сценария (см. комментарий в `zbx_ses_template.xml`)
|
||||
|
||||
6. **Запустите демон** и проверьте что метрики идут в Zabbix
|
||||
|
||||
## Расширение системы
|
||||
|
||||
### Добавление нового сценария
|
||||
|
||||
1. Создать `config/scenarios/my_scenario.yaml`
|
||||
2. Скопировать структуру из примеров
|
||||
3. Описать шаги и ожидания
|
||||
4. Сценарий автоматически подхватится через hot-reload (каждые 60 секунд)
|
||||
|
||||
### Добавление новой метрики
|
||||
|
||||
1. В нужном модуле добавить поле в датакласс-результат
|
||||
2. В методе `to_zabbix_metrics()` добавить соответствующий ключ
|
||||
3. В `main.py` вызвать `self._zabbix.add(key, value)`
|
||||
4. Добавить item в Zabbix шаблон
|
||||
|
||||
### Мониторинг SEE, SMC, TTS, QAS, SPR
|
||||
|
||||
Заложена возможность расширения для мониторинга внутренних сервисов SES.
|
||||
|
||||
**SEE (распознавание намерений/сущностей):**
|
||||
```python
|
||||
# В archive_auditor.py можно добавить анализ confidence-оценок:
|
||||
for entry in entries:
|
||||
for model in entry.models:
|
||||
if model.startswith("see:"):
|
||||
see_model = model.split(":", 1)[1]
|
||||
# Анализ точности распознавания
|
||||
```
|
||||
|
||||
**SMC (классификация):**
|
||||
```python
|
||||
if model.startswith("smc:"):
|
||||
# Отслеживание дрифта классификации
|
||||
```
|
||||
|
||||
**TTS (синтез речи):**
|
||||
```python
|
||||
# Проверка наличия голосовых ответов в реплаях
|
||||
if any(a.get("voice") for a in entry.raw.get("reply", {}).get("answers", [])):
|
||||
tts_ok_count += 1
|
||||
```
|
||||
|
||||
**QAS (вопросно-ответная система):**
|
||||
```python
|
||||
# Поле qas в archive entry — проверка наполненности
|
||||
if not entry.raw.get("current", {}).get("qas"):
|
||||
qas_empty_count += 1
|
||||
```
|
||||
|
||||
**SPR (распознавание речи):**
|
||||
```python
|
||||
# Косвенная проверка через тестовые сценарии,
|
||||
# которые отправляют аудио и проверяют текстовую расшифровку
|
||||
```
|
||||
|
||||
### 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
|
||||
94
config/config.yaml.example
Normal file
94
config/config.yaml.example
Normal file
@ -0,0 +1,94 @@
|
||||
# =============================================================================
|
||||
# Основная конфигурация системы мониторинга SES (шаблон)
|
||||
# =============================================================================
|
||||
# Скопируйте в config.yaml и заполните свои значения.
|
||||
# cp config.yaml.example config.yaml
|
||||
# Формат: YAML 1.2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Настройки подключения к SES серверу
|
||||
# ---------------------------------------------------------------------------
|
||||
ses:
|
||||
# Базовый URL SES API
|
||||
base_url: "http://YOUR_SES_HOST:6190"
|
||||
|
||||
# ID робота (сценария) для проверок.
|
||||
# Узнать ID: GET /ses/robot/list или /ses/robot/listall
|
||||
# Каждый сценарий может переопределить robot_id в своём YAML-файле
|
||||
robot_id: "YOUR_ROBOT_ID"
|
||||
|
||||
# Эндпоинт для получения списка роботов:
|
||||
# /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"
|
||||
42
config/scenarios/example_dialog.yaml
Normal file
42
config/scenarios/example_dialog.yaml
Normal file
@ -0,0 +1,42 @@
|
||||
# =============================================================================
|
||||
# Пример сценария: диалог с несколькими шагами
|
||||
# =============================================================================
|
||||
# Имитирует полноценный диалог пользователя с роботом SES.
|
||||
# Каждый шаг проверяет конкретные поля ответа.
|
||||
#
|
||||
# ИНСТРУКЦИЯ: скопируйте, переименуйте и опишите ваш диалог.
|
||||
# cp example_dialog.yaml my_project_dialog.yaml
|
||||
# # замените шаги и ожидания под логику вашего сценария
|
||||
|
||||
name: "example: диалог"
|
||||
description: >
|
||||
Пример многошагового диалога с проверкой промежуточных ответов.
|
||||
Адаптируйте под логику вашего робота.
|
||||
|
||||
enabled: false # Пример выключен по умолчанию
|
||||
interval: 600
|
||||
timeout: 45
|
||||
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
|
||||
27
config/scenarios/example_liveness.yaml
Normal file
27
config/scenarios/example_liveness.yaml
Normal file
@ -0,0 +1,27 @@
|
||||
# =============================================================================
|
||||
# Пример сценария: базовая проверка liveness робота
|
||||
# =============================================================================
|
||||
# Проверяет что робот отвечает на приветствие и не возвращает ошибок API.
|
||||
# Подходит для любого сценария SES — достаточно заменить robot_id в config.yaml.
|
||||
#
|
||||
# ИНСТРУКЦИЯ: скопируйте этот файл, переименуйте и адаптируйте под ваш проект.
|
||||
# cp example_liveness.yaml my_project_liveness.yaml
|
||||
# # отредактируйте: name, steps, expect
|
||||
|
||||
name: "example: liveness"
|
||||
description: >
|
||||
Базовая проверка: отправляем приветствие роботу
|
||||
и убеждаемся что API отвечает без ошибок.
|
||||
Не зависит от конкретного сценария SES.
|
||||
|
||||
enabled: false # Пример выключен по умолчанию
|
||||
interval: 60
|
||||
timeout: 15
|
||||
|
||||
steps:
|
||||
- send: "Здравствуйте"
|
||||
expect:
|
||||
- path: "error"
|
||||
equals: 0
|
||||
- path: "answer"
|
||||
not_empty: true
|
||||
23
config/scenarios/mfc_dialog.yaml
Normal file
23
config/scenarios/mfc_dialog.yaml
Normal file
@ -0,0 +1,23 @@
|
||||
# =============================================================================
|
||||
# Сценарий: диалог с роботом МФЦ (production)
|
||||
# =============================================================================
|
||||
# Проверяет что робот отвечает на приветствие и возвращает меню действий.
|
||||
# Выполняется каждые 60 секунд, создаёт сессию в SES.
|
||||
# Результат агрегируется в ses.scenario.ok/total/failed/avg_time/max_time/error.
|
||||
|
||||
name: "mfc_dialog"
|
||||
description: >
|
||||
Проверка диалога с МФЦ: отправка приветствия,
|
||||
ожидание меню действий (error=0, answer не пуст).
|
||||
|
||||
enabled: true
|
||||
interval: 60
|
||||
timeout: 15
|
||||
|
||||
steps:
|
||||
- send: "Здравствуйте"
|
||||
expect:
|
||||
- path: "error"
|
||||
equals: 0
|
||||
- path: "answer[0].messages[0]"
|
||||
not_empty: true
|
||||
13
config/zbx_agentd_ses.conf
Normal file
13
config/zbx_agentd_ses.conf
Normal 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
|
||||
50
pyproject.toml
Normal file
50
pyproject.toml
Normal file
@ -0,0 +1,50 @@
|
||||
[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]
|
||||
include = ["ses_monitor*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
39
scripts/zbx_get.py
Normal file
39
scripts/zbx_get.py
Normal 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())
|
||||
14
ses_monitor/__init__.py
Normal file
14
ses_monitor/__init__.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""
|
||||
Система мониторинга SES (Service Engine Server).
|
||||
|
||||
Модульный watchdog-демон для непрерывной проверки работоспособности
|
||||
диалоговой сценарной машины SES через её REST API.
|
||||
|
||||
Основные компоненты:
|
||||
- checker: быстрые liveness-проверки (доступность API, лицензия)
|
||||
- scenario_runner: выполнение сценариев диалогов из YAML-файлов
|
||||
- archive_auditor: анализ архива диалогов на ошибки
|
||||
- zabbix: отправка метрик в Zabbix через zabbix_sender
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
8
ses_monitor/__main__.py
Normal file
8
ses_monitor/__main__.py
Normal file
@ -0,0 +1,8 @@
|
||||
"""Точка входа: python -m ses_monitor."""
|
||||
|
||||
import sys
|
||||
|
||||
from ses_monitor.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
433
ses_monitor/archive_auditor.py
Normal file
433
ses_monitor/archive_auditor.py
Normal file
@ -0,0 +1,433 @@
|
||||
"""
|
||||
Анализ архива диалогов SES на наличие ошибок и качество работы моделей.
|
||||
|
||||
Проверяет записи в /ses/archive/list за последние N минут:
|
||||
- Количество диалогов с флагом unknown (не распознано).
|
||||
- Количество диалогов с событием "not found" или "no data".
|
||||
- Общее количество диалогов (чтобы заметить резкое падение).
|
||||
- SEE: точность распознавания намерений/сущностей (confidence, detections).
|
||||
- SMC: точность классификации (confidence, classifications).
|
||||
- TTS: наличие голосовых ответов.
|
||||
- QAS: наполненность вопросно-ответной системы.
|
||||
- SPR: распознавание речи (Speech Recognition) — наличие текстовых расшифровок.
|
||||
|
||||
Метрики агрегируются для отправки в Zabbix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ses_monitor.config import ModelsMonitoringConfig
|
||||
from ses_monitor.session_client import SESClient, ArchiveEntry
|
||||
|
||||
log = logging.getLogger("ses_monitor.archive")
|
||||
|
||||
# Формат даты в archive.list (пример: "2026-06-19 10:30:00")
|
||||
_ARCHIVE_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelMetric:
|
||||
"""Агрегированная метрика по одной модели (SEE или SMC)."""
|
||||
|
||||
name: str = "" # Имя модели (например, "fio", "phone2", "project_classifier")
|
||||
total: int = 0 # Всего детекций/классификаций
|
||||
low_confidence: int = 0 # Количество срабатываний ниже порога
|
||||
sum_confidence: float = 0.0 # Сумма confidence для расчёта среднего
|
||||
|
||||
@property
|
||||
def avg_confidence(self) -> float:
|
||||
"""Средняя уверенность модели за период."""
|
||||
if self.total == 0:
|
||||
return 0.0
|
||||
return self.sum_confidence / self.total
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchiveAuditResult:
|
||||
"""Результат анализа архива за период."""
|
||||
|
||||
period_start: str = ""
|
||||
period_end: str = ""
|
||||
|
||||
# === Базовые метрики ===
|
||||
|
||||
total_dialogs: int = 0
|
||||
unknown_count: int = 0
|
||||
not_found_count: int = 0
|
||||
no_data_count: int = 0
|
||||
closed_count: int = 0
|
||||
answered_count: int = 0
|
||||
|
||||
# === SEE (распознавание намерений/сущностей) ===
|
||||
|
||||
see_total: int = 0 # Всего детекций SEE
|
||||
see_low_confidence: int = 0 # Детекции ниже порога
|
||||
see_avg_confidence: float = 0.0 # Средняя уверенность по всем SEE
|
||||
# Детализация по конкретным SEE-моделям
|
||||
see_by_model: Dict[str, ModelMetric] = field(default_factory=dict)
|
||||
|
||||
# === SMC (классификация) ===
|
||||
|
||||
smc_total: int = 0 # Всего классификаций SMC
|
||||
smc_low_confidence: int = 0 # Классификации ниже порога
|
||||
smc_avg_confidence: float = 0.0 # Средняя уверенность по всем SMC
|
||||
# Детализация по конкретным SMC-моделям
|
||||
smc_by_model: Dict[str, ModelMetric] = field(default_factory=dict)
|
||||
|
||||
# === TTS (синтез речи) ===
|
||||
|
||||
tts_voice_present: int = 0 # Ответы с голосом
|
||||
tts_voice_missing: int = 0 # Ответы без голоса
|
||||
|
||||
# === QAS (вопросно-ответная система) ===
|
||||
|
||||
qas_total: int = 0 # Диалоги с полем qas
|
||||
qas_empty: int = 0 # Пустые qas
|
||||
qas_filled: int = 0 # Заполненные qas
|
||||
|
||||
# === SPR (распознавание речи: аудио→текст) ===
|
||||
|
||||
spr_total: int = 0 # Диалоги с полем reports
|
||||
spr_filled: int = 0 # Заполненные reports
|
||||
spr_empty: int = 0 # Пустые reports
|
||||
|
||||
# === Время шага диалога ===
|
||||
# Парсится из raw["request"]["datetime"] / raw["reply"]["datetime"]
|
||||
|
||||
avg_step_time_ms: float = 0.0 # Среднее время обработки шага (мс)
|
||||
max_step_time_ms: float = 0.0 # Максимальное время (мс)
|
||||
# Внутренние аккумуляторы для расчёта среднего
|
||||
_total_step_time_ms: float = 0.0
|
||||
_step_time_count: int = 0
|
||||
|
||||
# === Ошибки ===
|
||||
|
||||
fetch_error: str = ""
|
||||
|
||||
def to_zabbix_metrics(self, host: str) -> List[Tuple[str, str]]:
|
||||
"""Форматирует метрики для Zabbix.
|
||||
|
||||
Returns:
|
||||
Список кортежей (ключ_метрики, значение).
|
||||
"""
|
||||
metrics: List[Tuple[str, str]] = [
|
||||
# Базовые
|
||||
("ses.archive.total_5m", str(self.total_dialogs)),
|
||||
("ses.archive.unknown_5m", str(self.unknown_count)),
|
||||
("ses.archive.not_found_5m", str(self.not_found_count)),
|
||||
("ses.archive.no_data_5m", str(self.no_data_count)),
|
||||
("ses.archive.closed_5m", str(self.closed_count)),
|
||||
("ses.archive.answered_5m", str(self.answered_count)),
|
||||
# SEE
|
||||
("ses.models.see.total", str(self.see_total)),
|
||||
("ses.models.see.low_confidence", str(self.see_low_confidence)),
|
||||
("ses.models.see.avg_confidence", str(round(self.see_avg_confidence, 3))),
|
||||
# SMC
|
||||
("ses.models.smc.total", str(self.smc_total)),
|
||||
("ses.models.smc.low_confidence", str(self.smc_low_confidence)),
|
||||
("ses.models.smc.avg_confidence", str(round(self.smc_avg_confidence, 3))),
|
||||
# TTS
|
||||
("ses.models.tts.voice_present", str(self.tts_voice_present)),
|
||||
("ses.models.tts.voice_missing", str(self.tts_voice_missing)),
|
||||
# QAS
|
||||
("ses.models.qas.total", str(self.qas_total)),
|
||||
("ses.models.qas.empty", str(self.qas_empty)),
|
||||
# SPR
|
||||
("ses.models.spr.total", str(self.spr_total)),
|
||||
("ses.models.spr.filled", str(self.spr_filled)),
|
||||
# Время шага
|
||||
("ses.archive.avg_step_time_5m", str(round(self.avg_step_time_ms))),
|
||||
("ses.archive.max_step_time_5m", str(round(self.max_step_time_ms))),
|
||||
]
|
||||
return metrics
|
||||
|
||||
|
||||
class ArchiveAuditor:
|
||||
"""Анализирует архив диалогов SES.
|
||||
|
||||
Запускается с заданным интервалом (например, каждые 10 минут) и проверяет
|
||||
записи за последний период (например, 5 минут).
|
||||
|
||||
Отслеживает:
|
||||
- Базовые ошибки (unknown, not_found, no_data).
|
||||
- Качество SEE (entity/intent recognition): confidence, detections.
|
||||
- Качество SMC (classification): confidence, classifications.
|
||||
- Доступность TTS (text-to-speech): наличие голосовых ответов.
|
||||
- Наполненность QAS (question-answer): пустые qas.
|
||||
- Наполненность SPR (распознавание речи): непустые reports.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lookback_minutes: int = 5,
|
||||
models_config: Optional[ModelsMonitoringConfig] = None,
|
||||
step_time_enabled: bool = True,
|
||||
):
|
||||
self._lookback = lookback_minutes
|
||||
self._models_config = models_config or ModelsMonitoringConfig()
|
||||
self._step_time_enabled = step_time_enabled
|
||||
|
||||
async def audit(self, client: SESClient) -> ArchiveAuditResult:
|
||||
"""Выполняет анализ архива за последние N минут.
|
||||
|
||||
Args:
|
||||
client: HTTP-клиент для SES API.
|
||||
|
||||
Returns:
|
||||
Результат анализа со всеми метриками.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
period_start = now - timedelta(minutes=self._lookback)
|
||||
|
||||
start_str = period_start.strftime("%Y-%m-%d %H:%M")
|
||||
end_str = now.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
result = ArchiveAuditResult(
|
||||
period_start=start_str,
|
||||
period_end=end_str,
|
||||
)
|
||||
|
||||
try:
|
||||
entries = await client.get_archive(
|
||||
start_date=start_str,
|
||||
end_date=end_str,
|
||||
)
|
||||
|
||||
result.total_dialogs = len(entries)
|
||||
|
||||
for entry in entries:
|
||||
# Базовые проверки
|
||||
self._audit_base(result, entry)
|
||||
|
||||
# Мониторинг моделей
|
||||
if self._models_config.enabled:
|
||||
self._audit_models(result, entry)
|
||||
|
||||
# Время шага диалога
|
||||
if self._step_time_enabled:
|
||||
self._calc_step_time(result, entry)
|
||||
|
||||
# Расчёт средних значений
|
||||
if result.see_total > 0:
|
||||
total_conf = sum(m.sum_confidence for m in result.see_by_model.values())
|
||||
result.see_avg_confidence = total_conf / result.see_total
|
||||
|
||||
if result.smc_total > 0:
|
||||
total_conf = sum(m.sum_confidence for m in result.smc_by_model.values())
|
||||
result.smc_avg_confidence = total_conf / result.smc_total
|
||||
|
||||
if result._step_time_count > 0:
|
||||
result.avg_step_time_ms = result._total_step_time_ms / result._step_time_count
|
||||
|
||||
log.info(
|
||||
"Archive audit: %d диалогов за %d мин "
|
||||
"(unknown=%d, nf=%d, nd=%d, closed=%d, answered=%d) | "
|
||||
"SEE: %d det (low=%d, avg=%.2f) | "
|
||||
"SMC: %d cls (low=%d, avg=%.2f) | "
|
||||
"TTS: voice=%d no=%d | QAS: empty=%d/%d | SPR: filled=%d/%d | "
|
||||
"step_time: avg=%dms max=%dms",
|
||||
result.total_dialogs, self._lookback,
|
||||
result.unknown_count, result.not_found_count,
|
||||
result.no_data_count, result.closed_count,
|
||||
result.answered_count,
|
||||
result.see_total, result.see_low_confidence,
|
||||
result.see_avg_confidence,
|
||||
result.smc_total, result.smc_low_confidence,
|
||||
result.smc_avg_confidence,
|
||||
result.tts_voice_present, result.tts_voice_missing,
|
||||
result.qas_empty, result.qas_total,
|
||||
result.spr_filled, result.spr_total,
|
||||
round(result.avg_step_time_ms), round(result.max_step_time_ms),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
result.fetch_error = str(e)
|
||||
log.error("Archive audit: ошибка получения данных: %s", e)
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Базовые проверки (без изменений)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _audit_base(self, result: ArchiveAuditResult, entry: ArchiveEntry) -> None:
|
||||
"""Базовые проверки: unknown, not_found, no_data."""
|
||||
if entry.unknown:
|
||||
result.unknown_count += 1
|
||||
|
||||
for event in entry.events:
|
||||
if event == "not found":
|
||||
result.not_found_count += 1
|
||||
elif event == "no data":
|
||||
result.no_data_count += 1
|
||||
|
||||
if entry.closed and entry.closed is not False:
|
||||
result.closed_count += 1
|
||||
|
||||
if entry.answered:
|
||||
result.answered_count += 1
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Мониторинг внутренних моделей SES
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _audit_models(self, result: ArchiveAuditResult, entry: ArchiveEntry) -> None:
|
||||
"""Анализирует модели SEE, SMC, TTS, QAS, SPR в одной записи архива."""
|
||||
raw = entry.raw
|
||||
|
||||
# --- SEE и SMC: разбор request.data ---
|
||||
request_data = raw.get("request", {}).get("data", [])
|
||||
if isinstance(request_data, list):
|
||||
for item in request_data:
|
||||
item_type = item.get("type", "")
|
||||
|
||||
if item_type == "see":
|
||||
result.see_total += 1
|
||||
self._track_see(item, result)
|
||||
|
||||
elif item_type == "smc":
|
||||
result.smc_total += 1
|
||||
self._track_smc(item, result)
|
||||
|
||||
# --- TTS: проверка голосовых ответов ---
|
||||
if self._models_config.tts_enabled:
|
||||
answers = raw.get("reply", {}).get("answers", [])
|
||||
if isinstance(answers, list):
|
||||
for ans in answers:
|
||||
voice = ans.get("voice", "")
|
||||
if voice:
|
||||
result.tts_voice_present += 1
|
||||
else:
|
||||
result.tts_voice_missing += 1
|
||||
|
||||
# --- QAS: проверка наполненности ---
|
||||
if self._models_config.qas_enabled:
|
||||
qas = raw.get("current", {}).get("qas", "")
|
||||
result.qas_total += 1
|
||||
if not qas or qas == "":
|
||||
result.qas_empty += 1
|
||||
else:
|
||||
result.qas_filled += 1
|
||||
|
||||
# --- SPR: проверка reports ---
|
||||
if self._models_config.spr_enabled:
|
||||
reports = raw.get("reports", {})
|
||||
result.spr_total += 1
|
||||
if isinstance(reports, dict) and len(reports) > 0:
|
||||
result.spr_filled += 1
|
||||
else:
|
||||
result.spr_empty += 1
|
||||
|
||||
def _track_see(self, item: Dict[str, Any], result: ArchiveAuditResult) -> None:
|
||||
"""Учитывает одну SEE-детекцию в агрегированных метриках.
|
||||
|
||||
SEE (Semantic Entity Extraction) — извлечение сущностей и намерений.
|
||||
Каждая детекция имеет: model (имя модели), reply.confidence (уверенность).
|
||||
"""
|
||||
model_name = item.get("model", "unknown")
|
||||
confidence = self._extract_confidence(item)
|
||||
|
||||
# Глобальная агрегация
|
||||
if confidence is not None and confidence < self._models_config.see_confidence_threshold:
|
||||
result.see_low_confidence += 1
|
||||
|
||||
# Поимённая агрегация
|
||||
metric = result.see_by_model.get(model_name)
|
||||
if metric is None:
|
||||
metric = ModelMetric(name=model_name)
|
||||
result.see_by_model[model_name] = metric
|
||||
|
||||
metric.total += 1
|
||||
if confidence is not None:
|
||||
metric.sum_confidence += confidence
|
||||
if confidence < self._models_config.see_confidence_threshold:
|
||||
metric.low_confidence += 1
|
||||
|
||||
def _track_smc(self, item: Dict[str, Any], result: ArchiveAuditResult) -> None:
|
||||
"""Учитывает одну SMC-классификацию в агрегированных метриках.
|
||||
|
||||
SMC (Semantic Meaning Classification) — классификация запроса.
|
||||
Каждая классификация имеет: model (имя классификатора), reply.confidence.
|
||||
"""
|
||||
model_name = item.get("model", "unknown")
|
||||
confidence = self._extract_confidence(item)
|
||||
|
||||
if confidence is not None and confidence < self._models_config.smc_confidence_threshold:
|
||||
result.smc_low_confidence += 1
|
||||
|
||||
metric = result.smc_by_model.get(model_name)
|
||||
if metric is None:
|
||||
metric = ModelMetric(name=model_name)
|
||||
result.smc_by_model[model_name] = metric
|
||||
|
||||
metric.total += 1
|
||||
if confidence is not None:
|
||||
metric.sum_confidence += confidence
|
||||
if confidence < self._models_config.smc_confidence_threshold:
|
||||
metric.low_confidence += 1
|
||||
|
||||
@staticmethod
|
||||
def _extract_confidence(item: Dict[str, Any]) -> Optional[float]:
|
||||
"""Извлекает confidence из reply-блока элемента запроса.
|
||||
|
||||
Возможные структуры:
|
||||
reply.confidence (число)
|
||||
reply.position / reply.length (для NER, confidence обычно есть)
|
||||
"""
|
||||
reply = item.get("reply", {})
|
||||
if isinstance(reply, dict):
|
||||
conf = reply.get("confidence")
|
||||
if conf is not None:
|
||||
try:
|
||||
return float(conf)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
def _calc_step_time(self, result: ArchiveAuditResult, entry: ArchiveEntry) -> None:
|
||||
"""Парсит request.datetime / reply.datetime из записи архива и считает разницу.
|
||||
|
||||
Обновляет:
|
||||
- result._total_step_time_ms — суммарное время
|
||||
- result._step_time_count — количество учтённых записей
|
||||
- result.max_step_time_ms — максимальное время
|
||||
"""
|
||||
raw = entry.raw
|
||||
req_dt_str = raw.get("request", {}).get("datetime")
|
||||
rep_dt_str = raw.get("reply", {}).get("datetime")
|
||||
|
||||
if not req_dt_str or not rep_dt_str:
|
||||
return
|
||||
|
||||
try:
|
||||
req_dt = datetime.strptime(str(req_dt_str), _ARCHIVE_DATETIME_FORMAT)
|
||||
rep_dt = datetime.strptime(str(rep_dt_str), _ARCHIVE_DATETIME_FORMAT)
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
|
||||
# Пропускаем если время ответа меньше времени запроса (рассинхрон часов)
|
||||
if rep_dt < req_dt:
|
||||
return
|
||||
|
||||
ms = (rep_dt - req_dt).total_seconds() * 1000
|
||||
|
||||
result._total_step_time_ms += ms
|
||||
result._step_time_count += 1
|
||||
if ms > result.max_step_time_ms:
|
||||
result.max_step_time_ms = ms
|
||||
|
||||
# Лог предупреждения для диалогов дольше 5 секунд
|
||||
if ms > 5000:
|
||||
robot_label = entry.robot[-24:] if len(entry.robot) > 24 else entry.robot
|
||||
log.warning(
|
||||
"Медленный диалог: session=%s time=%dms request=\"%s\" robot=%s",
|
||||
entry.session,
|
||||
round(ms),
|
||||
entry.request_message[:80],
|
||||
robot_label,
|
||||
)
|
||||
183
ses_monitor/checker.py
Normal file
183
ses_monitor/checker.py
Normal 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)
|
||||
339
ses_monitor/config.py
Normal file
339
ses_monitor/config.py
Normal file
@ -0,0 +1,339 @@
|
||||
"""
|
||||
Загрузка и валидация 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"
|
||||
robot_id: str = "" # ID робота задаётся в config.yaml под конкретный проект
|
||||
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),
|
||||
robot_id=ses_data.get("robot_id", SESConfig.robot_id),
|
||||
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
|
||||
96
ses_monitor/log_client.py
Normal file
96
ses_monitor/log_client.py
Normal 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
ses_monitor/main.py
Normal file
328
ses_monitor/main.py
Normal 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
|
||||
240
ses_monitor/scenario_runner.py
Normal file
240
ses_monitor/scenario_runner.py
Normal 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
|
||||
302
ses_monitor/session_client.py
Normal file
302
ses_monitor/session_client.py
Normal 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._robot_id = config.robot_id
|
||||
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,
|
||||
*,
|
||||
session_id: Optional[str] = None,
|
||||
robot_id: Optional[str] = None,
|
||||
channel: Optional[str] = None,
|
||||
restart_closed: bool = False,
|
||||
) -> AskResponse:
|
||||
"""POST /ses/ask/{robot_id} — отправка сообщения роботу.
|
||||
|
||||
Args:
|
||||
text: Текст сообщения.
|
||||
session_id: UUID сессии. Если None — генерируется новый.
|
||||
robot_id: ID робота. Если None — из конфигурации.
|
||||
channel: Канал. Если None — из конфигурации.
|
||||
restart_closed: Переоткрывать закрытую сессию.
|
||||
|
||||
Returns:
|
||||
AskResponse с ответом робота.
|
||||
"""
|
||||
rid = robot_id or self._robot_id
|
||||
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/{rid}", 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
ses_monitor/utils.py
Normal file
233
ses_monitor/utils.py
Normal 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
ses_monitor/zabbix.py
Normal file
225
ses_monitor/zabbix.py
Normal 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"
|
||||
)
|
||||
41
systemd/ses-monitor.service
Normal file
41
systemd/ses-monitor.service
Normal 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
0
tests/__init__.py
Normal file
1109
tests/test_scenario_runner.py
Normal file
1109
tests/test_scenario_runner.py
Normal file
File diff suppressed because it is too large
Load Diff
10
zabbix/zbx_ses_monitor.conf
Normal file
10
zabbix/zbx_ses_monitor.conf
Normal 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
465
zabbix/zbx_ses_template.xml
Normal 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()}>{$SES_API_TIMEOUT_MS}</expression>
|
||||
<name>SES: медленный ответ API (>{$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()}>{$SES_UNKNOWN_THRESHOLD}</expression>
|
||||
<name>SES: много unknown запросов (>{$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()}>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()}>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()}>0 and {ses.models.qas.total.last()}>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()}>{$SES_STEP_TIME_WARNING_MS}</expression>
|
||||
<name>SES: медленная обработка шага диалога (>{$SES_STEP_TIME_WARNING_MS} мс) на {HOST.NAME}</name>
|
||||
<priority>WARNING</priority>
|
||||
<manual_close>YES</manual_close>
|
||||
</trigger>
|
||||
<trigger>
|
||||
<uuid>tr0000000000000000000000000000091</uuid>
|
||||
<expression>{last()}>{$SES_STEP_TIME_ERROR_MS}</expression>
|
||||
<name>SES: критическая задержка обработки шага (>{$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>
|
||||
212
zabbix/zbx_ses_template_5.0.xml
Normal file
212
zabbix/zbx_ses_template_5.0.xml
Normal 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()}>{$SES_API_TIMEOUT_MS}</expression>
|
||||
<name>SES: медленный ответ API (>{$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()}>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>
|
||||
261
zabbix/zbx_ses_template_5.0_up.xml
Normal file
261
zabbix/zbx_ses_template_5.0_up.xml
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user