Change alerts

This commit is contained in:
Magnus Root 2026-04-27 17:35:43 +03:00
parent 63351eb754
commit 02b262681e
8 changed files with 996 additions and 498 deletions

View file

@ -10,32 +10,33 @@
PostgreSQL PostgreSQL
sp_info + manual_info (UNION) sp_info + manual_info (UNION)
apps_settings apps_settings
client_info client_info (waf_provider IS NOT NULL)
fetchClientsData() fetchClientsData()
map[clientTitle]ClientData map[clientTitle]ClientData{WafProvider}
├──────────────────────────────────────────────────────┐ ├──────────────────────────────────────────────────────────────────
│ │ │ │
▼ ▼ spikClients (waf_provider=spik) spClients (waf_provider=sp)
generateSingleDashboard() generateAndSendAlerts() (OS)
generateVLDashboard() generateAndSendAlertsVL() (VL)
│ │ │ │
▼ ▼ ├──────────────────────┐ ┌──────────────────────────┤
dashboard_template.json alert_rules_template.json │ │ │ │
(Git-репозиторий) (Git-репозиторий) ▼ ▼ ▼ ▼
│ │ generateVLDashboard() generateAndSendAlertsVL() generateVLDashboard() generateAndSendAlertsVL()
▼ ▼ (SPIK дашборд) (группа: PTAF Grafana) (SP дашборд) (группа: SP PTAF Grafana)
POST /api/dashboards/db POST/PUT /api/v1/provisioning/alert-rules (receiver: tg+mail+...) (receiver: For_SP)
(Grafana API) (Grafana Provisioning API, параллельно x5) │ │ │ │
│ │ ▼ └────────────────┘ │
▼ ▼ dashboard_template.json activeUIDs (spik+SP+static) │
state.json ──── clients_hash state.json ──── alerts_hash │ │ │
└─── alert_template_hash ▼ ▼ │
POST /api/dashboards/db deleteObsoleteAlerts() POST /api/dashboards/db
state.json ── alerts_hash
└── alert_template_hash
``` ```
Генерация дашбордов и алертов для OS и VL управляется независимо через переменные окружения `DASHBOARD_OS_ENABLED`, `DASHBOARD_VL_ENABLED`, `ALERTS_OS_ENABLED`, `ALERTS_VL_ENABLED`. Генерация дашбордов и алертов управляется независимо через переменные окружения `DASHBOARD_OS_ENABLED`, `DASHBOARD_VL_ENABLED`, `ALERTS_OS_ENABLED`, `ALERTS_VL_ENABLED`.
--- ---
@ -43,15 +44,15 @@ PostgreSQL
| Файл | Назначение | | Файл | Назначение |
|---|---| |---|---|
| `main.go` | Точка входа, оркестрация всего потока | | `main.go` | Точка входа, оркестрация всего потока, разделение клиентов по `waf_provider` |
| `config.go` | Константы, структуры `Config`, `ClientData`, `DomainInfo`, `DashboardTemplate` | | `config.go` | Константы, структуры `Config`, `ClientData`, `DomainInfo`, `DashboardTemplate` |
| `database.go` | Подключение к PostgreSQL, `fetchClientsData()` | | `database.go` | Подключение к PostgreSQL, `fetchClientsData()`, чтение `waf_provider` |
| `dashboard.go` | Генерация JSON дашборда (OpenSearch) из шаблона и данных клиентов | | `dashboard.go` | Генерация JSON дашборда (OpenSearch) из шаблона и данных клиентов |
| `dashboard_vl.go` | Генерация JSON дашборда для VictoriaLogs | | `dashboard_vl.go` | Генерация JSON дашборда для VictoriaLogs |
| `panels.go` | Построение панелей (OpenSearch), реестр query-режимов, `applyRPSThreshold()` | | `panels.go` | Построение панелей (OpenSearch), реестр query-режимов, `applyRPSThreshold()` |
| `panels_vl.go` | Построение панелей для VictoriaLogs, LogsQL query builders | | `panels_vl.go` | Построение панелей для VictoriaLogs, LogsQL query builders |
| `alerts.go` | Генерация и отправка правил алертинга (OpenSearch), параллельный upsert | | `alerts.go` | Генерация и отправка правил алертинга (OpenSearch), `deleteObsoleteAlerts()` |
| `alerts_vl.go` | Генерация и отправка правил алертинга для VictoriaLogs | | `alerts_vl.go` | Генерация и отправка правил алертинга для VictoriaLogs, возвращает список активных UID |
| `template_validator.go` | Валидация и автоисправление критичных настроек шаблона | | `template_validator.go` | Валидация и автоисправление критичных настроек шаблона |
| `grafana.go` | HTTP-клиент для Grafana API (дашборды, папки) | | `grafana.go` | HTTP-клиент для Grafana API (дашборды, папки) |
| `template.go` | Загрузка `dashboard_template.json` и `alert_rules_template.json` | | `template.go` | Загрузка `dashboard_template.json` и `alert_rules_template.json` |
@ -67,7 +68,7 @@ PostgreSQL
### Источник данных ### Источник данных
Данные всегда берутся из **обеих** таблиц одновременно через `UNION`: Данные берутся из **обеих** таблиц одновременно через `UNION`. Читаются только клиенты у которых `waf_provider IS NOT NULL`:
```sql ```sql
SELECT ... FROM ( SELECT ... FROM (
@ -77,6 +78,7 @@ SELECT ... FROM (
) s ) s
LEFT JOIN apps_settings a ON s.sid = a.l7resourceid LEFT JOIN apps_settings a ON s.sid = a.l7resourceid
LEFT JOIN client_info ci ON a.client_title = ci.client_title LEFT JOIN client_info ci ON a.client_title = ci.client_title
WHERE ci.waf_provider IS NOT NULL
``` ```
Дубликаты по SID схлопываются автоматически (`UNION` без `ALL`). Дубликаты по SID схлопываются автоматически (`UNION` без `ALL`).
@ -98,19 +100,35 @@ LEFT JOIN client_info ci ON a.client_title = ci.client_title
- `rps_commercial_limit` — коммерческий лимит RPS для синей линии на графике (NULL = 100) - `rps_commercial_limit` — коммерческий лимит RPS для синей линии на графике (NULL = 100)
- `four_hundred` — порог 4xx ошибок в % (NULL = алерт не создавать) - `four_hundred` — порог 4xx ошибок в % (NULL = алерт не создавать)
- `five_hundred` — порог 5xx ошибок в % (NULL = алерт не создавать) - `five_hundred` — порог 5xx ошибок в % (NULL = алерт не создавать)
- `waf_provider` — тип провайдера WAF, определяет в какой дашборд и группу алертов попадает клиент:
- `spik` — основной VL дашборд + алерты в группе `PTAF Grafana`
- `sp` — отдельный SP дашборд + алерты в группе `SP PTAF Grafana` с contact point `For_SP`
- `NULL` — клиент игнорируется полностью
### Разделение клиентов по waf_provider
После загрузки данных `main.go` разделяет клиентов на две группы:
```go
spikClients := map[string]ClientData{} // waf_provider = "spik"
spClients := map[string]ClientData{} // waf_provider = "sp"
```
Хэш для обнаружения изменений алертов считается только от `spikClients`.
### Пример добавления нового клиента ### Пример добавления нового клиента
```sql ```sql
-- 1. Ресурс появится автоматически из sp_info -- 1. Ресурс появится автоматически из sp_info (или добавить в manual_info)
-- 2. Привязать к клиенту -- 2. Привязать к клиенту
INSERT INTO apps_settings (l7resourceid, client_title) INSERT INTO apps_settings (l7resourceid, client_title)
VALUES ('SID12345', 'Название клиента'); VALUES ('SID12345', 'Название клиента');
-- 3. Задать лимиты для алертов (опционально) -- 3. Задать провайдера и лимиты
INSERT INTO client_info (client_title, rps_limit, rps_commercial_limit, four_hundred, five_hundred) INSERT INTO client_info (client_title, waf_provider, rps_limit, rps_commercial_limit, four_hundred, five_hundred)
VALUES ('Название клиента', 1800, 1200, 20, 10); VALUES ('Название клиента', 'spik', 1800, 1200, 20, 10);
-- или для SP клиента: waf_provider = 'sp'
``` ```
--- ---
@ -231,11 +249,18 @@ VALUES ('Название клиента', 1800, 1200, 20, 10);
### OpenSearch дашборд ### OpenSearch дашборд
Создаётся один дашборд со всеми клиентами через `generateSingleDashboard()`. Создаётся через `generateSingleDashboard()` для `spikClients`.
### VictoriaLogs дашборд ### VictoriaLogs дашборды
Создаётся через `generateVLDashboard()` при наличии `VL_DATASOURCE_UID`. Использует те же панели и layout что и OS дашборд, но targets строятся через `queryModeRegistryVL` с LogsQL запросами. Генерируются через `generateVLDashboard()` при наличии `VL_DATASOURCE_UID`:
| Дашборд | Клиенты | UID | Папка |
|---|---|---|---|
| `PT AF Requests (VictoriaLogs)` | `spikClients` | `pt-af-requests-auto-vl` | `WAF - PTAF` |
| `SP PT AF Requests (VictoriaLogs)` | `spClients` | `pt-af-requests-auto-sp` | `WAF - PTAF` |
SP дашборд генерируется только если `SP_DASHBOARD_TITLE` задан и есть клиенты с `waf_provider=sp`.
**Особенности VL дашборда:** **Особенности VL дашборда:**
@ -244,8 +269,6 @@ VALUES ('Название клиента', 1800, 1200, 20, 10);
- `interval: "1m"` из поля `vl_interval` шаблона панели - `interval: "1m"` из поля `vl_interval` шаблона панели
- `timeShift: "1m"` для обрезки неполной последней минуты - `timeShift: "1m"` для обрезки неполной последней минуты
- Math expressions для RPS: `$A / $__interval_ms * 1000` - Math expressions для RPS: `$A / $__interval_ms * 1000`
- Math expressions для response time: `$ref / $total * 100`
- Transformation `renameByRegex` для очистки суффиксов в легенде
**Структура дашборда:** **Структура дашборда:**
@ -256,36 +279,24 @@ VALUES ('Название клиента', 1800, 1200, 20, 10);
├─────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────┤
│ ▶ PTAF-Nginx (свёрнутая строка) │ │ ▶ PTAF-Nginx (свёрнутая строка) │
│ Response_status_code SID │ │ Response_status_code SID │
│ Request Count by TENANT per nodes │ │ Request Count by TENANT per nodes/node │
│ Request Count by TENANT per node │
│ URI top 15 │ │ URI top 15 │
│ Response status code by Container │ │ Response status code by Container │
│ Nginx vs Angie Status Codes │ │ Nginx vs Angie Status Codes │
├─────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────┤
│ ▶ Angie (свёрнутая строка) │ │ ▶ Angie (свёрнутая строка) │
│ Errors by Apps │ │ Errors by Apps │
│ Errors by Upstream Path (app: ${error_server}) │ Errors by Upstream Path
│ Errors by Node │ │ Errors by Node │
├─────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────┤
│ ▼ Клиент A (развёрнутая строка) │ ▼ Клиент A
│ Status Codes | RPS | Total Traffic │ │ Status Codes | RPS | Total Traffic │
│ [domain1] Status Codes | Response Time | Traffic │ │ [domain1] Status Codes | Response Time | Traffic │
│ [domain2] ... │
├─────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────┤
│ ▶ Клиент B (свёрнутая строка) │ │ ▶ Клиент B ... │
│ ... │
└─────────────────────────────────────────────────┘ └─────────────────────────────────────────────────┘
``` ```
### Threshold на графике RPS
На панель RPS автоматически накладываются цветные линии из `client_info`:
| Линия | Значение | Источник |
|---|---|---|
| Синяя | `rps_commercial_limit` | `client_info.rps_commercial_limit` (NULL → 100) |
| Красная | `rps_limit` | `client_info.rps_limit` (0 → линия не рисуется) |
--- ---
## Алерты ## Алерты
@ -314,32 +325,43 @@ VL алерты имеют `noDataState: "NoData"` — при отсутстви
UID каждого алерта генерируется детерминированно: `md5(client_title + "_vl:" + type)[:8]`. При повторном запуске алерт обновляется (`PUT`), а не создаётся заново. UID каждого алерта генерируется детерминированно: `md5(client_title + "_vl:" + type)[:8]`. При повторном запуске алерт обновляется (`PUT`), а не создаётся заново.
**Разделение по waf_provider:**
| Клиенты | Группа алертов | Contact point | Папка |
|---|---|---|---|
| `spikClients` | `PTAF Grafana` | `tg+mail+mattermost PTAF Grafana` | `WAF - PTAF` |
| `spClients` | `SP PTAF Grafana` | `For_SP` | `WAF - PTAF` |
### Статические алерты (всегда присутствуют) ### Статические алерты (всегда присутствуют)
Описаны в `alert_rules_template.json` в секции `static_alerts`, не зависят от БД: Описаны в `alert_rules_template.json`, не зависят от БД. Отправляются в группе `[SYS] PTAF Grafana` первыми (до динамических):
| Алерт | Условие | for | | Алерт | Условие | for |
|---|---|---| |---|---|---|
| Use in / | диск `/` > 75% | 3m | | `[SYS] Use in / 75%` | диск `/` > 75% | 3m |
| Use in /var/log | диск `/var/log` > 75% | 5m | | `[SYS] Use in / 90%` | диск `/` > 90% | 1m |
| CPU Busy | CPU > 75% | 3m | | `[SYS] Use in /var/log 75%` | диск `/var/log` > 75% | 5m |
| RAM Busy | RAM > 75% | 3m | | `[SYS] Use in /var/log 90%` | диск `/var/log` > 90% | 1m |
| Состояние контейнеров | `docker_container_status == 0` | 2m | | `[SYS] CPU Busy 75%` | CPU > 75% | 3m |
| Упала Angie | `angie.service` не active | 1m | | `[SYS] CPU Busy 90%` | CPU > 90% | 1m |
| `[SYS] RAM Busy 75%` | RAM > 75% | 3m |
| `[SYS] RAM Busy 90%` | RAM > 90% | 1m |
| `[SYS] Состояние контейнеров` | контейнер упал | 2m |
| `[SYS] Упала Angie` | `angie.service` не active | 1m |
Если `ALERTS_OS_ENABLED=false` — статические алерты отправляются через VL путь.
### Удаление устаревших алертов
После каждой генерации `deleteObsoleteAlerts()` получает все алерты из папки `WAF - PTAF` и удаляет те, чьи UID не входят в актуальный набор (spik + SP + статические). Это автоматически удаляет алерты клиентов которые были удалены из БД или у которых изменился `waf_provider`.
### Параллельная отправка ### Параллельная отправка
Все алерты (динамические + статические) отправляются параллельно через горутины с ограничением **5 одновременных запросов** к Grafana API: Все алерты отправляются параллельно через горутины с ограничением **5 одновременных запросов** к Grafana API.
```
[RPS client1] [RPS client2] [4xx client1] [5xx client2] [static: CPU] ← 5 горутин
↓ освободился слот
[static: RAM] ...
```
### Подавление DatasourceNoData алертов ### Подавление DatasourceNoData алертов
VL алерты при отсутствии данных переходят в состояние `NoData` (не `Alerting`). Для подавления уведомлений об этом состоянии в Grafana создан Silence с матчером `alertname = DatasourceNoData`. VL алерты при отсутствии данных переходят в состояние `NoData`. Для подавления уведомлений в Grafana создан Silence с матчером `alertname = DatasourceNoData`.
--- ---
@ -348,7 +370,7 @@ VL алерты при отсутствии данных переходят в
`template_validator.go` запускается автоматически после загрузки шаблона и проверяет/исправляет: `template_validator.go` запускается автоматически после загрузки шаблона и проверяет/исправляет:
- `vl_interval: "1m"` для панелей: rps, rps_multi, status_codes, response_time, traffic_combined, traffic_combined_total - `vl_interval: "1m"` для панелей: rps, rps_multi, status_codes, response_time, traffic_combined, traffic_combined_total
- `spanNulls: true` для панели status_codes - `spanNulls: 180000` для панели status_codes
- `vl_base_query` с `TENANT:in(${tenant:csv})` для `overview_tenant_per_nodes` - `vl_base_query` с `TENANT:in(${tenant:csv})` для `overview_tenant_per_nodes`
- `vl_base_query` с `node_name:in(${node_name:csv})` для `overview_tenant_per_node` - `vl_base_query` с `node_name:in(${node_name:csv})` для `overview_tenant_per_node`
- Layout tenant_panels в три столбца по 8 (status_codes + rps/rps_multi + traffic_combined_total) - Layout tenant_panels в три столбца по 8 (status_codes + rps/rps_multi + traffic_combined_total)
@ -360,8 +382,6 @@ VL алерты при отсутствии данных переходят в
Путь по умолчанию: `/var/lib/grafana_gen/state.json` Путь по умолчанию: `/var/lib/grafana_gen/state.json`
Хранит хэши для обнаружения изменений между запусками:
```json ```json
{ {
"last_run": "2026-02-25T10:00:00Z", "last_run": "2026-02-25T10:00:00Z",
@ -387,7 +407,7 @@ VL алерты при отсутствии данных переходят в
ничего не изменилось → выход без действий (если не указан -force) ничего не изменилось → выход без действий (если не указан -force)
``` ```
Хэш алертов сохраняется после успешного выполнения независимо от того какие системы алертов включены (OS/VL). `alerts_hash` считается только от `spikClients` и сохраняется после успешной отправки.
--- ---
@ -404,7 +424,7 @@ VL алерты при отсутствии данных переходят в
### Пути к .env файлу (перебираются по порядку) ### Пути к .env файлу (перебираются по порядку)
1. `$GRAFANA_GEN_ENV_FILE` (переменная окружения) 1. `$GRAFANA_GEN_ENV_FILE`
2. `/etc/grafana_gen/grafana_gen.env` 2. `/etc/grafana_gen/grafana_gen.env`
3. `./grafana_gen.env` 3. `./grafana_gen.env`
4. `./.env` 4. `./.env`
@ -420,10 +440,10 @@ DB_PASSWORD=secret
GRAFANA_URL=https://grafana.example.com GRAFANA_URL=https://grafana.example.com
GRAFANA_API_KEY=glsa-xxxxxxxxxxxxxxxxxxxx GRAFANA_API_KEY=glsa-xxxxxxxxxxxxxxxxxxxx
# Git (Gitea) # Git
GIT_TOKEN=your-gitea-token GIT_TOKEN=your-gitea-token
# Алерты (OpenSearch) # Алерты OpenSearch
ALERTS_DATASOURCE_UID=af84zsvlp9blsa ALERTS_DATASOURCE_UID=af84zsvlp9blsa
# VictoriaLogs # VictoriaLogs
@ -431,6 +451,12 @@ VL_DATASOURCE_UID=efewdonokxybkf
VL_DASHBOARD_TITLE=PT AF Requests (VictoriaLogs) VL_DASHBOARD_TITLE=PT AF Requests (VictoriaLogs)
VL_GRAFANA_FOLDER=WAF - PTAF VL_GRAFANA_FOLDER=WAF - PTAF
# SP дашборд и алерты
SP_DASHBOARD_TITLE=SP PT AF Requests (VictoriaLogs)
SP_DASHBOARD_UID=pt-af-requests-auto-sp
SP_ALERTS_GROUP=SP PTAF Grafana
SP_ALERTS_RECEIVER=For_SP
# Управление генерацией # Управление генерацией
DASHBOARD_OS_ENABLED=false DASHBOARD_OS_ENABLED=false
DASHBOARD_VL_ENABLED=true DASHBOARD_VL_ENABLED=true
@ -451,16 +477,21 @@ ALERTS_VL_ENABLED=true
| `-db-name` | — | `waf_info` | PostgreSQL база данных | | `-db-name` | — | `waf_info` | PostgreSQL база данных |
| `-grafana-url` | `GRAFANA_URL` | — | URL Grafana | | `-grafana-url` | `GRAFANA_URL` | — | URL Grafana |
| `-grafana-api-key` | `GRAFANA_API_KEY` | — | Grafana API ключ | | `-grafana-api-key` | `GRAFANA_API_KEY` | — | Grafana API ключ |
| `-grafana-folder` | `GRAFANA_FOLDER` | `WAF - Auto Generated` | Папка для дашбордов | | `-grafana-folder` | `GRAFANA_FOLDER` | `WAF - Auto Generated` | Папка для OS дашборда |
| `-dashboard-title` | `DASHBOARD_TITLE` | `PT AF Nodes` | Название дашборда | | `-dashboard-title` | `DASHBOARD_TITLE` | `PT AF Requests` | Название OS дашборда |
| `-alerts-folder` | `ALERTS_FOLDER` | `WAF - PTAF` | Папка для алертов | | `-alerts-folder` | `ALERTS_FOLDER` | `WAF - PTAF` | Папка для алертов |
| `-alerts-receiver` | `ALERTS_RECEIVER` | `tg+mail+mattermost PTAF Grafana` | Получатель уведомлений | | `-alerts-receiver` | `ALERTS_RECEIVER` | `tg+mail+mattermost PTAF Grafana` | Contact point для SPIK алертов |
| `-alerts-group` | `ALERTS_GROUP` | `PTAF Grafana` | Группа алертов | | `-alerts-group` | `ALERTS_GROUP` | `PTAF Grafana` | Группа алертов для SPIK |
| `-alerts-datasource-uid` | `ALERTS_DATASOURCE_UID` | `af84zsvlp9blsa` | UID datasource OpenSearch для алертов | | `-alerts-datasource-uid` | `ALERTS_DATASOURCE_UID` | `af84zsvlp9blsa` | UID datasource OpenSearch для алертов |
| `-vl-datasource-uid` | `VL_DATASOURCE_UID` | — | UID VictoriaLogs datasource (пустая = не генерировать VL) | | `-vl-datasource-uid` | `VL_DATASOURCE_UID` | — | UID VictoriaLogs datasource |
| `-vl-dashboard-title` | `VL_DASHBOARD_TITLE` | `PT AF Requests (VictoriaLogs)` | Название VL дашборда | | `-vl-dashboard-title` | `VL_DASHBOARD_TITLE` | `PT AF Requests (VictoriaLogs)` | Название SPIK VL дашборда |
| `-vl-grafana-folder` | `VL_GRAFANA_FOLDER` | — | Папка для VL дашборда (пустая = как у OS) | | `-vl-grafana-folder` | `VL_GRAFANA_FOLDER` | — | Папка для SPIK VL дашборда |
| `-vl-dashboard-uid` | `VL_DASHBOARD_UID` | `pt-af-requests-auto-vl` | UID VL дашборда в Grafana | | `-vl-dashboard-uid` | `VL_DASHBOARD_UID` | `pt-af-requests-auto-vl` | UID SPIK VL дашборда |
| `-sp-dashboard-title` | `SP_DASHBOARD_TITLE` | `SP PT AF Requests (VictoriaLogs)` | Название SP дашборда |
| `-sp-dashboard-uid` | `SP_DASHBOARD_UID` | `pt-af-requests-auto-sp` | UID SP дашборда |
| `-sp-grafana-folder` | `SP_GRAFANA_FOLDER` | — | Папка для SP дашборда |
| — | `SP_ALERTS_GROUP` | `SP PTAF Grafana` | Группа алертов для SP клиентов |
| — | `SP_ALERTS_RECEIVER` | `For_SP` | Contact point для SP алертов |
| `-git-token` | `GIT_TOKEN` | — | Gitea токен | | `-git-token` | `GIT_TOKEN` | — | Gitea токен |
| `-templates-repo` | — | `svc-git.cirex.ru/...` | URL Git-репозитория шаблонов | | `-templates-repo` | — | `svc-git.cirex.ru/...` | URL Git-репозитория шаблонов |
| `-templates-branch` | — | `master` | Ветка шаблонов | | `-templates-branch` | — | `master` | Ветка шаблонов |
@ -489,10 +520,10 @@ ALERTS_VL_ENABLED=true
# Без обновления шаблонов из Git # Без обновления шаблонов из Git
./grafana_gen -skip-git-pull ./grafana_gen -skip-git-pull
# Только VL дашборд и VL алерты # Только VL дашборды и VL алерты
DASHBOARD_OS_ENABLED=false ALERTS_OS_ENABLED=false ./grafana_gen DASHBOARD_OS_ENABLED=false ALERTS_OS_ENABLED=false ./grafana_gen
# Явное указание всех параметров # Явное указание параметров
./grafana_gen \ ./grafana_gen \
-db-host=10.100.10.8 \ -db-host=10.100.10.8 \
-db-user=reader \ -db-user=reader \

362
alerts.go
View file

@ -74,7 +74,7 @@ func buildRPSAlertRule(client ClientData, tmpl AlertRulesTemplate, config Config
// Получаем настройки из шаблона // Получаем настройки из шаблона
defaults := tmpl.Defaults defaults := tmpl.Defaults
receiver := orDefault(config.AlertsReceiver, defaults.Receiver) receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
group := orDefault(config.AlertsGroup, defaults.Group) group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle)
timeRangeFrom := int64(1800) timeRangeFrom := int64(1800)
if v, ok := tmpl.RPSAlert["relative_time_range_from"].(float64); ok { if v, ok := tmpl.RPSAlert["relative_time_range_from"].(float64); ok {
@ -341,36 +341,56 @@ func generateAndSendAlerts(clients map[string]ClientData, tmpl AlertRulesTemplat
label: fmt.Sprintf("RPS %s (limit: %d)", t.client.ClientTitle, t.client.RPSLimit), label: fmt.Sprintf("RPS %s (limit: %d)", t.client.ClientTitle, t.client.RPSLimit),
}) })
} }
if t.client.Limit4xx > 0 { // WAF block алерт — по одному на каждый SID (пропускаем если ptaf_fallback_code = "pass" или пустой)
rule := buildErrorRateAlertRule(t.client, 4, t.client.Limit4xx, tmpl, config) if t.client.PtafFallbackCode != "" && strings.ToLower(t.client.PtafFallbackCode) != "pass" {
for _, domain := range t.client.Domains {
rule := buildWAFBlockAlertRule(t.client, domain, tmpl, config)
rule["folderUID"] = folderUID rule["folderUID"] = folderUID
tasks = append(tasks, alertTask{ tasks = append(tasks, alertTask{
rule: rule, rule: rule,
label: fmt.Sprintf("4xx %s (limit: %d%%)", t.client.ClientTitle, t.client.Limit4xx), label: fmt.Sprintf("%s WAF %s / %s SID:%s", t.client.PtafFallbackCode, t.client.ClientTitle, domain.DomainName, domain.SID),
})
}
if t.client.Limit5xx > 0 {
rule := buildErrorRateAlertRule(t.client, 5, t.client.Limit5xx, tmpl, config)
rule["folderUID"] = folderUID
tasks = append(tasks, alertTask{
rule: rule,
label: fmt.Sprintf("5xx %s (limit: %d%%)", t.client.ClientTitle, t.client.Limit5xx),
}) })
} }
} }
// Статические алерты — отправляются первыми (появляются в начале списка) // Error rate алерты — пороги берутся из каждого домена (apps_settings)
if err := sendStaticAlerts(tmpl, config, dryRun); err != nil { // Генерируем для response_status_code и upstream_status_code
log.Printf("Warning: static alerts error: %v", err) for _, domain := range t.client.Domains {
for _, statusField := range []string{"response_status_code", "upstream_status_code"} {
if domain.Limit4xx > 0 {
for _, rule := range buildErrorRateAlertRules(t.client, domain, 4, domain.Limit4xx, statusField, tmpl, config) {
rule["folderUID"] = folderUID
tasks = append(tasks, alertTask{
rule: rule,
label: fmt.Sprintf("4xx %s %s / %s SID:%s", statusField, t.client.ClientTitle, domain.DomainName, domain.SID),
})
}
}
if domain.Limit5xx > 0 {
for _, rule := range buildErrorRateAlertRules(t.client, domain, 5, domain.Limit5xx, statusField, tmpl, config) {
rule["folderUID"] = folderUID
tasks = append(tasks, alertTask{
rule: rule,
label: fmt.Sprintf("5xx %s %s / %s SID:%s", statusField, t.client.ClientTitle, domain.DomainName, domain.SID),
})
}
}
}
}
} }
log.Printf("Alerts: sending %d dynamic rules (concurrency: 5)", len(tasks)) log.Printf("Alerts: sending %d dynamic rules (concurrency: 5)", len(tasks))
sent, failed := parallelUpsert(tasks, config, dryRun, 5) sent, failed := parallelUpsert(tasks, config, dryRun, 20)
log.Printf("Alerts: sent=%d, failed=%d", sent, failed) log.Printf("Alerts: sent=%d, failed=%d", sent, failed)
if failed > 0 { if failed > 0 {
return fmt.Errorf("%d alert rules failed to upsert", failed) return fmt.Errorf("%d alert rules failed to upsert", failed)
} }
// Статические алерты — отправляются всегда вместе с динамическими
if err := sendStaticAlerts(tmpl, config, dryRun); err != nil {
log.Printf("Warning: static alerts error: %v", err)
}
// Обновляем шаблон сообщения Contact Point // Обновляем шаблон сообщения Contact Point
if err := upsertContactPoint(tmpl, config, dryRun); err != nil { if err := upsertContactPoint(tmpl, config, dryRun); err != nil {
log.Printf("Warning: failed to update contact point message template: %v", err) log.Printf("Warning: failed to update contact point message template: %v", err)
@ -379,29 +399,13 @@ func generateAndSendAlerts(clients map[string]ClientData, tmpl AlertRulesTemplat
return nil return nil
} }
// buildErrorRateAlertRule строит алерт на процент 4xx или 5xx ответов для клиента. // buildErrorRateAlertRules строит алерт на процент 4xx или 5xx ответов для конкретного домена.
// Структура: два count-запроса (total + errors) → reduce → math (%) → threshold // statusField: "response_status_code" или "upstream_status_code"
func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl AlertRulesTemplate, config Config) map[string]interface{} { func buildErrorRateAlertRules(client ClientData, domain DomainInfo, errCode, limitPct int, statusField string, tmpl AlertRulesTemplate, config Config) []map[string]interface{} {
datasourceUID := config.AlertsDatasourceUID datasourceUID := config.AlertsDatasourceUID
defaults := tmpl.Defaults defaults := tmpl.Defaults
receiver := orDefault(config.AlertsReceiver, defaults.Receiver) receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
group := orDefault(config.AlertsGroup, defaults.Group) group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle)
// SID-запрос
sids := make(map[string]bool)
for _, d := range client.Domains {
sids[d.SID] = true
}
unique := make([]string, 0, len(sids))
for s := range sids {
unique = append(unique, s)
}
sort.Strings(unique)
parts := make([]string, len(unique))
for i, s := range unique {
parts[i] = "SID:" + s
}
sidQuery := strings.Join(parts, " OR ")
codeRange := "[400 TO 499]" codeRange := "[400 TO 499]"
codeName := "4xx" codeName := "4xx"
@ -410,7 +414,34 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler
codeName = "5xx" codeName = "5xx"
} }
uid := generateAlertUID(client.ClientTitle, codeName) forDuration := "1m"
if errCode == 4 {
forDuration = "3m"
}
relFrom := int64(900)
var rules []map[string]interface{}
{
sidQuery := "SID:" + domain.SID
fieldShort := "response"
if statusField == "upstream_status_code" {
fieldShort = "upstream"
}
uid := generateAlertUID(client.ClientTitle+"_"+domain.SID+"_"+fieldShort, codeName)
title := fmt.Sprintf("%s %s %s / %s SID:%s", codeName, fieldShort, client.ClientTitle, domain.DomainName, domain.SID)
fieldDesc := "ответ от PTAF, не от origin"
if statusField == "upstream_status_code" {
fieldDesc = "ответ от origin"
}
summary := fmt.Sprintf(
"🔴 %s / %s (SID:%s) — %s %s ошибок трафика держится > %s.",
client.ClientTitle, domain.DomainName, domain.SID, codeName, fieldShort, forDuration,
)
thresholdAnnotation := fmt.Sprintf(">%d%% для %s (%s)", limitPct, statusField, fieldDesc)
fieldInfoAnnotation := fmt.Sprintf("для %s (%s)", statusField, fieldDesc)
totalRefID := "Total" totalRefID := "Total"
errRefID := "Errors" errRefID := "Errors"
totalReduceID := "_TotalReduce" totalReduceID := "_TotalReduce"
@ -418,18 +449,9 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler
percentRefID := "Значение" percentRefID := "Значение"
thresholdRefID := "Превышение" thresholdRefID := "Превышение"
summary := fmt.Sprintf("🚨 %s — %s ошибок >%d%% трафика", client.ClientTitle, codeName, limitPct) rule := map[string]interface{}{
relFrom := int64(900)
forDuration := "1m"
if errCode == 4 {
forDuration = "3m"
}
return map[string]interface{}{
"uid": uid, "uid": uid,
"title": fmt.Sprintf("%s %s", codeName, client.ClientTitle), "title": title,
"condition": thresholdRefID, "condition": thresholdRefID,
"noDataState": defaults.NoDataState, "noDataState": defaults.NoDataState,
"execErrState": defaults.ExecErrState, "execErrState": defaults.ExecErrState,
@ -437,7 +459,14 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler
"folderUID": "", "folderUID": "",
"ruleGroup": group, "ruleGroup": group,
"for": forDuration, "for": forDuration,
"annotations": map[string]string{"summary": summary}, "annotations": map[string]string{
"summary": summary,
"client_domain": fmt.Sprintf("%s / %s (SID:%s)", client.ClientTitle, domain.DomainName, domain.SID),
"code_field": fmt.Sprintf("%s %s", codeName, fieldShort),
"for_duration": forDuration,
"threshold": thresholdAnnotation,
"field_info": fieldInfoAnnotation,
},
"notification_settings": map[string]string{"receiver": receiver}, "notification_settings": map[string]string{"receiver": receiver},
"data": []interface{}{ "data": []interface{}{
// Total запрос // Total запрос
@ -456,7 +485,7 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler
"luceneQueryType": "Metric", "luceneQueryType": "Metric",
"maxDataPoints": 43200, "maxDataPoints": 43200,
"metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}}, "metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}},
"query": fmt.Sprintf("response_status_code:[0 TO 599] AND (%s)", sidQuery), "query": fmt.Sprintf("response_status_code:[0 TO 599] AND %s", sidQuery),
"queryType": "lucene", "queryType": "lucene",
"range": true, "range": true,
"refId": totalRefID, "refId": totalRefID,
@ -479,7 +508,7 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler
"luceneQueryType": "Metric", "luceneQueryType": "Metric",
"maxDataPoints": 43200, "maxDataPoints": 43200,
"metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}}, "metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}},
"query": fmt.Sprintf("response_status_code:%s AND (%s)", codeRange, sidQuery), "query": fmt.Sprintf("%s:%s AND %s", statusField, codeRange, sidQuery),
"queryType": "lucene", "queryType": "lucene",
"range": true, "range": true,
"refId": errRefID, "refId": errRefID,
@ -555,6 +584,10 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler
}, },
}, },
} }
rules = append(rules, rule)
}
return rules
} }
// sendStaticAlerts отправляет статические алерты из шаблона (не зависят от БД) // sendStaticAlerts отправляет статические алерты из шаблона (не зависят от БД)
@ -600,7 +633,7 @@ func sendStaticAlerts(tmpl AlertRulesTemplate, config Config, dryRun bool) error
tasks = append(tasks, alertTask{rule: rule, label: title}) tasks = append(tasks, alertTask{rule: rule, label: title})
} }
sent, failed := parallelUpsert(tasks, config, dryRun, 5) sent, failed := parallelUpsert(tasks, config, dryRun, 20)
log.Printf("Static alerts: sent=%d, failed=%d", sent, failed) log.Printf("Static alerts: sent=%d, failed=%d", sent, failed)
return nil return nil
} }
@ -780,43 +813,37 @@ func upsertContactPoint(tmpl AlertRulesTemplate, config Config, dryRun bool) err
return fmt.Errorf("failed to parse contact points: %w", err) return fmt.Errorf("failed to parse contact points: %w", err)
} }
// Ищем contact point с нужным именем // Ищем все telegram интеграции с нужным именем и обновляем message
var target map[string]interface{} var updated int
for _, cp := range contactPoints { for _, cp := range contactPoints {
if name, _ := cp["name"].(string); name == receiverName { name, _ := cp["name"].(string)
target = cp if name != receiverName {
break continue
} }
} // Обновляем все типы интеграций (telegram, email, slack и т.д.)
uid, _ := cp["uid"].(string)
if target == nil { cpType, _ := cp["type"].(string)
log.Printf("Contact point '%s' not found, skipping message template update", receiverName) settings, ok := cp["settings"].(map[string]interface{})
return nil
}
uid, _ := target["uid"].(string)
// Contact point — одиночный объект с полем type и settings
if cpType, _ := target["type"].(string); cpType != "telegram" {
log.Printf("Contact point '%s' is not Telegram (type: %s), skipping", receiverName, cpType)
return nil
}
settings, ok := target["settings"].(map[string]interface{})
if !ok { if !ok {
settings = make(map[string]interface{}) settings = make(map[string]interface{})
target["settings"] = settings cp["settings"] = settings
} }
// Поле для шаблона зависит от типа интеграции
switch cpType {
case "slack":
settings["text"] = tmpl.ContactPoint.MessageTemplate
default: // telegram, email, oncall, mattermost и др.
settings["message"] = tmpl.ContactPoint.MessageTemplate settings["message"] = tmpl.ContactPoint.MessageTemplate
}
if dryRun { if dryRun {
data, _ := json.MarshalIndent(target, "", " ") data, _ := json.MarshalIndent(cp, "", " ")
log.Printf("DRY RUN: Contact point update for '%s':\n%s", receiverName, string(data)) log.Printf("DRY RUN: Contact point update for '%s' (uid: %s):\n%s", receiverName, uid, string(data))
return nil updated++
continue
} }
// PUT /api/v1/provisioning/contact-points/{uid} payload, _ := json.Marshal(cp)
payload, _ := json.Marshal(target)
updReq, _ := http.NewRequest("PUT", baseURL+"/api/v1/provisioning/contact-points/"+uid, bytes.NewBuffer(payload)) updReq, _ := http.NewRequest("PUT", baseURL+"/api/v1/provisioning/contact-points/"+uid, bytes.NewBuffer(payload))
updReq.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey) updReq.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey)
updReq.Header.Set("Content-Type", "application/json") updReq.Header.Set("Content-Type", "application/json")
@ -824,19 +851,72 @@ func upsertContactPoint(tmpl AlertRulesTemplate, config Config, dryRun bool) err
updResp, err := http.DefaultClient.Do(updReq) updResp, err := http.DefaultClient.Do(updReq)
if err != nil { if err != nil {
return fmt.Errorf("failed to update contact point: %w", err) log.Printf("Warning: failed to update contact point '%s' (uid: %s): %v", receiverName, uid, err)
continue
} }
defer updResp.Body.Close() updResp.Body.Close()
updated++
respBody, _ := io.ReadAll(updResp.Body) log.Printf("Contact point '%s' (uid: %s) message template updated", receiverName, uid)
if updResp.StatusCode < 200 || updResp.StatusCode >= 300 {
return fmt.Errorf("contact point update failed: status %d: %s", updResp.StatusCode, string(respBody))
} }
log.Printf("Contact point '%s' message template updated", receiverName) if updated == 0 {
log.Printf("No telegram integrations found for contact point '%s'", receiverName)
}
return nil return nil
} }
// deleteObsoleteAlerts удаляет алерты из папки которые не входят в актуальный набор UID.
func deleteObsoleteAlerts(activeUIDs map[string]bool, folderUID string, config Config) {
baseURL := strings.TrimRight(config.GrafanaURL, "/")
req, err := http.NewRequest("GET", baseURL+"/api/v1/provisioning/alert-rules", nil)
if err != nil {
log.Printf("Warning: failed to list alert rules: %v", err)
return
}
req.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("Warning: failed to list alert rules: %v", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var rules []map[string]interface{}
if err := json.Unmarshal(body, &rules); err != nil {
log.Printf("Warning: failed to parse alert rules list: %v", err)
return
}
deleted := 0
for _, rule := range rules {
uid, _ := rule["uid"].(string)
ruleFolderUID, _ := rule["folderUID"].(string)
if ruleFolderUID != folderUID {
continue
}
if activeUIDs[uid] {
continue
}
delReq, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/api/v1/provisioning/alert-rules/%s", baseURL, uid), nil)
delReq.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey)
delResp, err := http.DefaultClient.Do(delReq)
if err != nil {
log.Printf("Warning: failed to delete alert %s: %v", uid, err)
continue
}
delResp.Body.Close()
title, _ := rule["title"].(string)
log.Printf("Deleted obsolete alert: %s (%s)", title, uid)
deleted++
}
if deleted > 0 {
log.Printf("Alerts: deleted %d obsolete rules", deleted)
}
}
// orDefault возвращает val если не пустой, иначе def // orDefault возвращает val если не пустой, иначе def
func orDefault(val, def string) string { func orDefault(val, def string) string {
if val != "" { if val != "" {
@ -844,3 +924,113 @@ func orDefault(val, def string) string {
} }
return def return def
} }
// buildWAFBlockAlertRule строит алерт на количество 418 ответов за минуту (OpenSearch).
func buildWAFBlockAlertRule(client ClientData, domain DomainInfo, tmpl AlertRulesTemplate, config Config) map[string]interface{} {
datasourceUID := config.AlertsDatasourceUID
defaults := tmpl.Defaults
receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle)
uid := generateAlertUID(client.ClientTitle+"_"+domain.SID, "waf_block")
title := fmt.Sprintf("%s WAF %s / %s SID:%s", client.PtafFallbackCode, client.ClientTitle, domain.DomainName, domain.SID)
clientDomain := fmt.Sprintf("%s / %s (SID:%s)", client.ClientTitle, domain.DomainName, domain.SID)
summary := fmt.Sprintf("🟡 %s — %s ошибка WAF - кол-во штук за 1 минуту.", clientDomain, client.PtafFallbackCode)
thresholdAnnotation := fmt.Sprintf(">%d штук(и) для response_status_code (ответ от PTAF, не от origin)", domain.PtafFallbackCodeAlertCount)
sidQuery := "SID:" + domain.SID
dataRefID := "A"
reduceRefID := "_Reduce"
thresholdRefID := "Превышение"
relFrom := int64(120)
return map[string]interface{}{
"uid": uid,
"title": title,
"condition": thresholdRefID,
"noDataState": defaults.NoDataState,
"execErrState": defaults.ExecErrState,
"isPaused": false,
"folderUID": "",
"ruleGroup": group,
"for": "1m",
"annotations": map[string]string{
"summary": summary,
"client_domain": clientDomain,
"code_field": fmt.Sprintf("%s WAF", client.PtafFallbackCode),
"for_duration": "1m",
"threshold": thresholdAnnotation,
"field_info": "для response_status_code (ответ от PTAF, не от origin)",
"runbook": "Проверить логи docker, процитировать ошибку и эскалировать на аналитика.",
"severity": "warning",
"value_unit": "шт.",
},
"notification_settings": map[string]string{"receiver": receiver},
"data": []interface{}{
map[string]interface{}{
"refId": dataRefID,
"queryType": "lucene",
"relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0},
"datasourceUid": datasourceUID,
"model": map[string]interface{}{
"datasource": map[string]interface{}{"type": "grafana-opensearch-datasource", "uid": datasourceUID},
"query": fmt.Sprintf("response_status_code:%s AND %s", client.PtafFallbackCode, sidQuery),
"luceneQueryType": "Metric",
"timeField": "@timestamp",
"metrics": []interface{}{
map[string]interface{}{"id": "1", "type": "count"},
},
"bucketAggs": []interface{}{
map[string]interface{}{
"id": "2",
"type": "date_histogram",
"field": "@timestamp",
"settings": map[string]interface{}{
"interval": "1m",
"min_doc_count": "1",
},
},
},
"refId": dataRefID,
},
},
map[string]interface{}{
"refId": reduceRefID,
"relativeTimeRange": map[string]interface{}{"from": 0, "to": 0},
"datasourceUid": "__expr__",
"model": map[string]interface{}{
"datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"},
"expression": dataRefID,
"intervalMs": 1000,
"maxDataPoints": 43200,
"reducer": "last",
"refId": reduceRefID,
"type": "reduce",
},
},
map[string]interface{}{
"refId": thresholdRefID,
"relativeTimeRange": map[string]interface{}{"from": 0, "to": 0},
"datasourceUid": "__expr__",
"model": map[string]interface{}{
"conditions": []interface{}{
map[string]interface{}{
"evaluator": map[string]interface{}{"params": []interface{}{domain.PtafFallbackCodeAlertCount, 0}, "type": "gt"},
"operator": map[string]interface{}{"type": "and"},
"query": map[string]interface{}{"params": []string{}},
"reducer": map[string]interface{}{"params": []string{}, "type": "avg"},
"type": "query",
},
},
"datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"},
"expression": reduceRefID,
"intervalMs": 1000,
"maxDataPoints": 43200,
"refId": thresholdRefID,
"type": "threshold",
},
},
},
}
}

View file

@ -26,7 +26,7 @@ func buildRPSAlertRuleVL(client ClientData, tmpl AlertRulesTemplate, config Conf
defaults := tmpl.Defaults defaults := tmpl.Defaults
receiver := orDefault(config.AlertsReceiver, defaults.Receiver) receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
group := orDefault(config.AlertsGroup, defaults.Group) group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle)
timeRangeFrom := int64(1800) timeRangeFrom := int64(1800)
if v, ok := tmpl.RPSAlert["relative_time_range_from"].(float64); ok { if v, ok := tmpl.RPSAlert["relative_time_range_from"].(float64); ok {
@ -182,16 +182,15 @@ func buildRPSAlertRuleVL(client ClientData, tmpl AlertRulesTemplate, config Conf
} }
} }
// buildErrorRateAlertRuleVL строит правило алерта на процент ошибок для VictoriaLogs. // buildErrorRateAlertRulesVL строит алерт на процент ошибок для конкретного домена (VictoriaLogs).
func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl AlertRulesTemplate, config Config) map[string]interface{} { // statusField: "response_status_code" или "upstream_status_code"
func buildErrorRateAlertRulesVL(client ClientData, domain DomainInfo, errCode, limitPct int, statusField string, tmpl AlertRulesTemplate, config Config) []map[string]interface{} {
datasourceUID := config.VLDatasourceUID datasourceUID := config.VLDatasourceUID
datasourceType := DefaultVLDatasourceType datasourceType := DefaultVLDatasourceType
defaults := tmpl.Defaults defaults := tmpl.Defaults
receiver := orDefault(config.AlertsReceiver, defaults.Receiver) receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
group := orDefault(config.AlertsGroup, defaults.Group) group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle)
sidQuery := buildTenantSIDQueryVL(client.Domains)
codeFrom, codeTo := 400, 499 codeFrom, codeTo := 400, 499
codeName := "4xx" codeName := "4xx"
@ -200,7 +199,35 @@ func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl Al
codeName = "5xx" codeName = "5xx"
} }
uid := generateAlertUID(client.ClientTitle+"_vl", codeName) forDuration := "1m"
if errCode == 4 {
forDuration = "3m"
}
relFrom := int64(900)
var rules []map[string]interface{}
{
sidQuery := fmt.Sprintf("SID:%s", domain.SID)
// Короткое имя поля для использования в названии алерта
fieldShort := "response"
if statusField == "upstream_status_code" {
fieldShort = "upstream"
}
uid := generateAlertUID(client.ClientTitle+"_vl_"+domain.SID+"_"+fieldShort, codeName)
title := fmt.Sprintf("VL %s %s %s / %s SID:%s", codeName, fieldShort, client.ClientTitle, domain.DomainName, domain.SID)
fieldDesc := "ответ от PTAF, не от origin"
if statusField == "upstream_status_code" {
fieldDesc = "ответ от origin"
}
summary := fmt.Sprintf(
"🔴 %s / %s (SID:%s) — %s %s ошибок трафика держится > %s.",
client.ClientTitle, domain.DomainName, domain.SID, codeName, fieldShort, forDuration,
)
thresholdAnnotation := fmt.Sprintf(">%d%% для %s (%s)", limitPct, statusField, fieldDesc)
fieldInfoAnnotation := fmt.Sprintf("для %s (%s)", statusField, fieldDesc)
totalRefID := "Total" totalRefID := "Total"
errRefID := "Errors" errRefID := "Errors"
totalReduceID := "_TotalReduce" totalReduceID := "_TotalReduce"
@ -208,17 +235,8 @@ func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl Al
percentRefID := "Значение" percentRefID := "Значение"
thresholdRefID := "Превышение" thresholdRefID := "Превышение"
summary := fmt.Sprintf("🚨 %s — %s ошибок >%d%% трафика", client.ClientTitle, codeName, limitPct)
relFrom := int64(900)
forDuration := "1m"
if errCode == 4 {
forDuration = "3m"
}
totalExpr := fmt.Sprintf("%s log_type:nginx-access-PTAF | stats by (_time:1m) count() hits", sidQuery) totalExpr := fmt.Sprintf("%s log_type:nginx-access-PTAF | stats by (_time:1m) count() hits", sidQuery)
errExpr := fmt.Sprintf("%s log_type:nginx-access-PTAF response_status_code:>=%d response_status_code:<=%d | stats by (_time:1m) count() hits", sidQuery, codeFrom, codeTo) errExpr := fmt.Sprintf("%s log_type:nginx-access-PTAF %s:>=%d %s:<=%d | stats by (_time:1m) count() hits", sidQuery, statusField, codeFrom, statusField, codeTo)
makeVLModel := func(refID, expr, legend string) map[string]interface{} { makeVLModel := func(refID, expr, legend string) map[string]interface{} {
return map[string]interface{}{ return map[string]interface{}{
@ -249,9 +267,9 @@ func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl Al
} }
} }
return map[string]interface{}{ rule := map[string]interface{}{
"uid": uid, "uid": uid,
"title": fmt.Sprintf("VL %s %s", codeName, client.ClientTitle), "title": title,
"condition": thresholdRefID, "condition": thresholdRefID,
"noDataState": "NoData", // VL: нет данных = нет алерта "noDataState": "NoData", // VL: нет данных = нет алерта
"execErrState": "Error", // VL: ошибка datasource = Error, не Alerting "execErrState": "Error", // VL: ошибка datasource = Error, не Alerting
@ -259,7 +277,14 @@ func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl Al
"folderUID": "", "folderUID": "",
"ruleGroup": group, "ruleGroup": group,
"for": forDuration, "for": forDuration,
"annotations": map[string]string{"summary": summary}, "annotations": map[string]string{
"summary": summary,
"client_domain": fmt.Sprintf("%s / %s (SID:%s)", client.ClientTitle, domain.DomainName, domain.SID),
"code_field": fmt.Sprintf("%s %s", codeName, fieldShort),
"for_duration": forDuration,
"threshold": thresholdAnnotation,
"field_info": fieldInfoAnnotation,
},
"notification_settings": map[string]string{"receiver": receiver}, "notification_settings": map[string]string{"receiver": receiver},
"data": []interface{}{ "data": []interface{}{
map[string]interface{}{ map[string]interface{}{
@ -325,18 +350,22 @@ func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl Al
}, },
}, },
} }
rules = append(rules, rule)
}
return rules
} }
// generateAndSendAlertsVL генерирует и отправляет алерты для VictoriaLogs. // generateAndSendAlertsVL генерирует и отправляет алерты для VictoriaLogs.
func generateAndSendAlertsVL(clients map[string]ClientData, tmpl AlertRulesTemplate, config Config, dryRun bool) error { func generateAndSendAlertsVL(clients map[string]ClientData, tmpl AlertRulesTemplate, config Config, dryRun bool) ([]string, error) {
if config.VLDatasourceUID == "" { if config.VLDatasourceUID == "" {
log.Printf("Skipping VL alerts: VLDatasourceUID not configured") log.Printf("Skipping VL alerts: VLDatasourceUID not configured")
return nil return nil, nil
} }
folderUID, err := ensureAlertFolder(config) folderUID, err := ensureAlertFolder(config)
if err != nil { if err != nil {
return fmt.Errorf("failed to ensure alert folder: %w", err) return nil, fmt.Errorf("failed to ensure alert folder: %w", err)
} }
var tasks []alertTask var tasks []alertTask
@ -356,34 +385,167 @@ func generateAndSendAlertsVL(clients map[string]ClientData, tmpl AlertRulesTempl
}) })
} }
// Error rate алерты (4xx и 5xx) // WAF block алерт — по одному на каждый SID (пропускаем если ptaf_fallback_code = "pass" или пустой)
for _, errCode := range []int{4, 5} { if client.PtafFallbackCode != "" && strings.ToLower(client.PtafFallbackCode) != "pass" {
limitPct := client.Limit4xx for _, domain := range client.Domains {
if errCode == 5 { rule := buildWAFBlockAlertRuleVL(client, domain, tmpl, config)
limitPct = client.Limit5xx
}
if limitPct <= 0 {
continue
}
rule := buildErrorRateAlertRuleVL(client, errCode, limitPct, tmpl, config)
rule["folderUID"] = folderUID rule["folderUID"] = folderUID
tasks = append(tasks, alertTask{ tasks = append(tasks, alertTask{
rule: rule, rule: rule,
label: fmt.Sprintf("VL %dxx %s", errCode, client.ClientTitle), label: fmt.Sprintf("VL %s WAF %s / %s SID:%s", client.PtafFallbackCode, client.ClientTitle, domain.DomainName, domain.SID),
}) })
} }
} }
sent, failed := parallelUpsert(tasks, config, dryRun, 5) // Error rate алерты — пороги берутся из каждого домена (apps_settings)
// Генерируем для response_status_code и upstream_status_code
for _, domain := range client.Domains {
for _, statusField := range []string{"response_status_code", "upstream_status_code"} {
if domain.Limit4xx > 0 {
for _, rule := range buildErrorRateAlertRulesVL(client, domain, 4, domain.Limit4xx, statusField, tmpl, config) {
rule["folderUID"] = folderUID
tasks = append(tasks, alertTask{
rule: rule,
label: fmt.Sprintf("VL 4xx %s %s / %s SID:%s", statusField, client.ClientTitle, domain.DomainName, domain.SID),
})
}
}
if domain.Limit5xx > 0 {
for _, rule := range buildErrorRateAlertRulesVL(client, domain, 5, domain.Limit5xx, statusField, tmpl, config) {
rule["folderUID"] = folderUID
tasks = append(tasks, alertTask{
rule: rule,
label: fmt.Sprintf("VL 5xx %s %s / %s SID:%s", statusField, client.ClientTitle, domain.DomainName, domain.SID),
})
}
}
}
}
}
// Обновляем шаблон contact point (если OS алерты отключены — делаем здесь)
if !config.AlertsOSEnabled {
if err := upsertContactPoint(tmpl, config, dryRun); err != nil {
log.Printf("Warning: failed to update contact point: %v", err)
}
}
sent, failed := parallelUpsert(tasks, config, dryRun, 20)
log.Printf("VL Alerts: sent=%d failed=%d", sent, failed) log.Printf("VL Alerts: sent=%d failed=%d", sent, failed)
// Статические алерты (CPU, RAM, диск, контейнеры, Angie) // Статические алерты отправляются отдельно из main.go чтобы избежать
// Отправляем только если OS алерты отключены, иначе они уже отправлены там // дублирования при вызове функции для разных провайдеров (spik/sp)
if !config.AlertsOSEnabled {
if err := sendStaticAlerts(tmpl, config, dryRun); err != nil { // Собираем активные UID для возврата
log.Printf("Warning: VL static alerts error: %v", err) activeUIDs := []string{}
for _, task := range tasks {
if uid, ok := task.rule["uid"].(string); ok {
activeUIDs = append(activeUIDs, uid)
} }
} }
return nil return activeUIDs, nil
}
// buildWAFBlockAlertRuleVL строит алерт на количество 418 ответов за минуту (VictoriaLogs).
func buildWAFBlockAlertRuleVL(client ClientData, domain DomainInfo, tmpl AlertRulesTemplate, config Config) map[string]interface{} {
datasourceUID := config.VLDatasourceUID
datasourceType := DefaultVLDatasourceType
defaults := tmpl.Defaults
receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle)
uid := generateAlertUID(client.ClientTitle+"_vl_"+domain.SID, "waf_block")
title := fmt.Sprintf("VL %s WAF %s / %s SID:%s", client.PtafFallbackCode, client.ClientTitle, domain.DomainName, domain.SID)
clientDomain := fmt.Sprintf("%s / %s (SID:%s)", client.ClientTitle, domain.DomainName, domain.SID)
summary := fmt.Sprintf("🟡 %s — %s ошибка WAF - кол-во штук за 1 минуту.", clientDomain, client.PtafFallbackCode)
thresholdAnnotation := fmt.Sprintf(">%d штук(и) для response_status_code (ответ от PTAF, не от origin)", domain.PtafFallbackCodeAlertCount)
expr := fmt.Sprintf("SID:%s log_type:nginx-access-PTAF response_status_code:=%s | stats by (_time:1m) count() hits", domain.SID, client.PtafFallbackCode)
dataRefID := "A"
reduceRefID := "_Reduce"
thresholdRefID := "Превышение"
relFrom := int64(120)
return map[string]interface{}{
"uid": uid,
"title": title,
"condition": thresholdRefID,
"noDataState": "NoData",
"execErrState": "Error",
"isPaused": false,
"folderUID": "",
"ruleGroup": group,
"for": "1m",
"annotations": map[string]string{
"summary": summary,
"client_domain": clientDomain,
"code_field": fmt.Sprintf("%s WAF", client.PtafFallbackCode),
"for_duration": "1m",
"threshold": thresholdAnnotation,
"field_info": "для response_status_code (ответ от PTAF, не от origin)",
"runbook": "Проверить логи docker, процитировать ошибку и эскалировать на аналитика.",
"severity": "warning",
"value_unit": "шт.",
},
"notification_settings": map[string]string{"receiver": receiver},
"data": []interface{}{
map[string]interface{}{
"refId": dataRefID,
"queryType": "statsRange",
"relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0},
"datasourceUid": datasourceUID,
"model": map[string]interface{}{
"datasource": map[string]interface{}{"type": datasourceType, "uid": datasourceUID},
"editorMode": "code",
"expr": expr,
"legendFormat": fmt.Sprintf("418 %s", domain.DomainName),
"maxLines": 1000,
"queryType": "statsRange",
"refId": dataRefID,
"intervalMs": 60000,
"maxDataPoints": 43200,
},
},
map[string]interface{}{
"refId": reduceRefID,
"relativeTimeRange": map[string]interface{}{"from": 0, "to": 0},
"datasourceUid": "__expr__",
"model": map[string]interface{}{
"datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"},
"expression": dataRefID,
"intervalMs": 1000,
"maxDataPoints": 43200,
"reducer": "last",
"refId": reduceRefID,
"type": "reduce",
},
},
map[string]interface{}{
"refId": thresholdRefID,
"relativeTimeRange": map[string]interface{}{"from": 0, "to": 0},
"datasourceUid": "__expr__",
"model": map[string]interface{}{
"conditions": []interface{}{
map[string]interface{}{
"evaluator": map[string]interface{}{"params": []interface{}{domain.PtafFallbackCodeAlertCount, 0}, "type": "gt"},
"operator": map[string]interface{}{"type": "and"},
"query": map[string]interface{}{"params": []string{}},
"reducer": map[string]interface{}{"params": []string{}, "type": "avg"},
"type": "query",
},
},
"datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"},
"expression": reduceRefID,
"intervalMs": 1000,
"maxDataPoints": 43200,
"refId": thresholdRefID,
"type": "threshold",
},
},
},
}
} }

View file

@ -71,6 +71,11 @@ const (
DefaultVLGrafanaFolder = "" // Пустая = та же папка что и основная DefaultVLGrafanaFolder = "" // Пустая = та же папка что и основная
DefaultVLDatasourceType = "victoriametrics-logs-datasource-ptaf-sw" // Тип VL datasource плагина DefaultVLDatasourceType = "victoriametrics-logs-datasource-ptaf-sw" // Тип VL datasource плагина
DefaultVLDashboardUID = "pt-af-requests-auto-vl" // UID второго дашборда DefaultVLDashboardUID = "pt-af-requests-auto-vl" // UID второго дашборда
DefaultSPDashboardTitle = "SP PT AF Requests (VictoriaLogs)" // Название SP дашборда
DefaultSPDashboardUID = "pt-af-requests-auto-sp" // UID SP дашборда
DefaultSPGrafanaFolder = "" // Пустая = та же папка что и основная
DefaultSPAlertsGroup = "SP PTAF Grafana" // Группа алертов для SP
DefaultSPAlertsReceiver = "For_SP" // Contact point для SP алертов
// DefaultDashboardTitle - название единого дашборда со всеми клиентами // DefaultDashboardTitle - название единого дашборда со всеми клиентами
// Пример результата: Home -> Dashboards -> WAF - PTAF -> PT AF Nodes // Пример результата: Home -> Dashboards -> WAF - PTAF -> PT AF Nodes
@ -114,6 +119,11 @@ type Config struct {
VLDashboardTitle string // Название второго дашборда (пустая строка = не генерировать) VLDashboardTitle string // Название второго дашборда (пустая строка = не генерировать)
VLGrafanaFolder string // Папка для VL дашборда (пустая = та же что и основная) VLGrafanaFolder string // Папка для VL дашборда (пустая = та же что и основная)
VLDashboardUID string // UID второго дашборда в Grafana VLDashboardUID string // UID второго дашборда в Grafana
SPDashboardTitle string // Название SP дашборда (пустая строка = не генерировать)
SPDashboardUID string // UID SP дашборда в Grafana
SPGrafanaFolder string // Папка для SP дашборда (пустая = та же что и основная)
SPAlertsGroup string // Группа алертов для SP клиентов
SPAlertsReceiver string // Contact point для SP алертов
} }
type ClientData struct { type ClientData struct {
@ -121,14 +131,18 @@ type ClientData struct {
Domains []DomainInfo Domains []DomainInfo
RPSLimit int // 0 = алерт не создавать (NULL или не задан в БД) RPSLimit int // 0 = алерт не создавать (NULL или не задан в БД)
RPSCommercialLimit int // коммерческий лимит, при NULL берётся 100 RPSCommercialLimit int // коммерческий лимит, при NULL берётся 100
Limit4xx int // порог 4xx ответов в % (0 = алерт не создавать)
Limit5xx int // порог 5xx ответов в % (0 = алерт не создавать) WafProvider string // waf_provider из client_info (spik, sp, ...)
PtafFallbackCode string // HTTP код блокировки PTAF из client_info (напр. "418"), "pass" = алерт не нужен
} }
type DomainInfo struct { type DomainInfo struct {
SID string SID string
DomainName string DomainName string
Aliases string Aliases string
Limit4xx int // порог 4xx ошибок в % (из apps_settings, default 20)
Limit5xx int // порог 5xx ошибок в % (из apps_settings, default 10)
PtafFallbackCodeAlertCount int // порог 418 ошибок в штуках (из apps_settings, default 1)
} }
type DashboardTemplate struct { type DashboardTemplate struct {

View file

@ -38,8 +38,11 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
COALESCE(s.aliases::text, 'null') as aliases, COALESCE(s.aliases::text, 'null') as aliases,
COALESCE(MAX(ci.rps_limit) OVER (PARTITION BY a.client_title), 0) as rps_limit, COALESCE(MAX(ci.rps_limit) OVER (PARTITION BY a.client_title), 0) as rps_limit,
COALESCE(MAX(ci.rps_commercial_limit) OVER (PARTITION BY a.client_title), 100) as rps_commercial_limit, COALESCE(MAX(ci.rps_commercial_limit) OVER (PARTITION BY a.client_title), 100) as rps_commercial_limit,
MAX(ci.four_hundred) OVER (PARTITION BY a.client_title) as limit_4xx, COALESCE(a.four_hundred, 20) as limit_4xx,
MAX(ci.five_hundred) OVER (PARTITION BY a.client_title) as limit_5xx COALESCE(a.five_hundred, 10) as limit_5xx,
COALESCE(a.ptaf_fallback_code_alert_count, 1) as ptaf_fallback_code_alert_count,
COALESCE(MAX(ci.waf_provider) OVER (PARTITION BY a.client_title), '') as waf_provider,
COALESCE(MAX(ci.ptaf_fallback_code) OVER (PARTITION BY a.client_title), '') as ptaf_fallback_code
FROM ( FROM (
SELECT sid, domain_name, aliases FROM sp_info WHERE sid IS NOT NULL SELECT sid, domain_name, aliases FROM sp_info WHERE sid IS NOT NULL
UNION UNION
@ -47,6 +50,7 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
) s ) s
LEFT JOIN apps_settings a ON s.sid = a.l7resourceid LEFT JOIN apps_settings a ON s.sid = a.l7resourceid
LEFT JOIN client_info ci ON a.client_title = ci.client_title LEFT JOIN client_info ci ON a.client_title = ci.client_title
WHERE ci.waf_provider IS NOT NULL
ORDER BY COALESCE(a.client_title, 'Unknown'), s.sid ORDER BY COALESCE(a.client_title, 'Unknown'), s.sid
` `
@ -59,11 +63,10 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
clients := make(map[string]ClientData) clients := make(map[string]ClientData)
for rows.Next() { for rows.Next() {
var clientTitle, sid, domainName, aliasesJSON string var clientTitle, sid, domainName, aliasesJSON, wafProvider, ptafFallbackCode string
var rpsLimit, rpsCommercialLimit int var rpsLimit, rpsCommercialLimit, limit4xx, limit5xx, ptafFallbackCodeAlertCount int
var limit4xx, limit5xx sql.NullInt64
if err := rows.Scan(&clientTitle, &sid, &domainName, &aliasesJSON, &rpsLimit, &rpsCommercialLimit, &limit4xx, &limit5xx); err != nil { if err := rows.Scan(&clientTitle, &sid, &domainName, &aliasesJSON, &rpsLimit, &rpsCommercialLimit, &limit4xx, &limit5xx, &ptafFallbackCodeAlertCount, &wafProvider, &ptafFallbackCode); err != nil {
return nil, fmt.Errorf("scan failed: %w", err) return nil, fmt.Errorf("scan failed: %w", err)
} }
@ -76,8 +79,8 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
Domains: []DomainInfo{}, Domains: []DomainInfo{},
RPSLimit: rpsLimit, RPSLimit: rpsLimit,
RPSCommercialLimit: rpsCommercialLimit, RPSCommercialLimit: rpsCommercialLimit,
Limit4xx: nullIntToInt(limit4xx), WafProvider: wafProvider,
Limit5xx: nullIntToInt(limit5xx), PtafFallbackCode: ptafFallbackCode,
} }
} }
@ -86,6 +89,9 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
SID: sid, SID: sid,
DomainName: domainName, DomainName: domainName,
Aliases: aliasesStr, Aliases: aliasesStr,
Limit4xx: limit4xx,
Limit5xx: limit5xx,
PtafFallbackCodeAlertCount: ptafFallbackCodeAlertCount,
}) })
clients[clientTitle] = client clients[clientTitle] = client
} }
@ -93,14 +99,6 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
return clients, rows.Err() return clients, rows.Err()
} }
// nullIntToInt конвертирует sql.NullInt64 в int (0 если NULL)
func nullIntToInt(n sql.NullInt64) int {
if n.Valid {
return int(n.Int64)
}
return 0
}
// parseAliasesJSON конвертирует JSON массив aliases в строку // parseAliasesJSON конвертирует JSON массив aliases в строку
// Input: ["domain1.com", "domain2.com"] или null // Input: ["domain1.com", "domain2.com"] или null
// Output: "domain1.com, domain2.com" или "" // Output: "domain1.com, domain2.com" или ""

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=

130
main.go
View file

@ -4,8 +4,26 @@ import (
"flag" "flag"
"log" "log"
"os" "os"
"strings"
) )
// makeSPAlertsConfig создаёт конфиг для SP алертов с отдельной группой и contact point.
func makeSPAlertsConfig(config Config) Config {
spConfig := config
spConfig.AlertsGroup = config.SPAlertsGroup
spConfig.AlertsReceiver = config.SPAlertsReceiver
return spConfig
}
// makeSPConfig создаёт конфиг для SP дашборда на основе основного конфига.
func makeSPConfig(config Config) Config {
spConfig := config
spConfig.VLDashboardTitle = config.SPDashboardTitle
spConfig.VLDashboardUID = config.SPDashboardUID
spConfig.VLGrafanaFolder = config.SPGrafanaFolder
return spConfig
}
func main() { func main() {
// Load environment variables from .env file before parsing flags // Load environment variables from .env file before parsing flags
// This allows .env file to provide defaults that can be overridden by flags // This allows .env file to provide defaults that can be overridden by flags
@ -95,9 +113,21 @@ func main() {
os.Exit(0) os.Exit(0)
} }
// Разделяем клиентов по waf_provider
spikClients := map[string]ClientData{}
spClients := map[string]ClientData{}
for k, v := range validClients {
switch strings.ToLower(v.WafProvider) {
case "spik":
spikClients[k] = v
case "sp":
spClients[k] = v
}
}
// Хэш шаблона алертов — считается независимо от дашборда // Хэш шаблона алертов — считается независимо от дашборда
alertTemplateHash := hashAlertTemplate(config.TemplatesPath) alertTemplateHash := hashAlertTemplate(config.TemplatesPath)
alertsChanged := stateManager.AlertsNeedUpdate(validClients) || stateManager.AlertTemplateChanged(alertTemplateHash) alertsChanged := stateManager.AlertsNeedUpdate(spikClients) || stateManager.AlertTemplateChanged(alertTemplateHash)
// Check for changes // Check for changes
hasChanges, changeReason := stateManager.CheckChanges(templateCommit, template.Version, clients) hasChanges, changeReason := stateManager.CheckChanges(templateCommit, template.Version, clients)
@ -124,11 +154,11 @@ func main() {
log.Printf("Force run enabled - regenerating dashboard") log.Printf("Force run enabled - regenerating dashboard")
} }
// Generate single dashboard with all clients // Generate single dashboard with spik clients
log.Printf("Generating single dashboard: %s", config.DashboardTitle) log.Printf("Generating single dashboard: %s", config.DashboardTitle)
log.Printf("Including %d clients with %d total domains", len(validClients), countTotalDomains(validClients)) log.Printf("Including %d spik clients with %d total domains", len(spikClients), countTotalDomains(spikClients))
dashboard := generateSingleDashboard(validClients, template, config) dashboard := generateSingleDashboard(spikClients, template, config)
if config.DryRun { if config.DryRun {
if config.DashboardOSEnabled { if config.DashboardOSEnabled {
@ -137,19 +167,24 @@ func main() {
if alertTmpl != nil { if alertTmpl != nil {
log.Printf("DRY RUN: Generating alert rules preview...") log.Printf("DRY RUN: Generating alert rules preview...")
if config.AlertsOSEnabled { if config.AlertsOSEnabled {
_ = generateAndSendAlerts(validClients, *alertTmpl, config, true) _ = generateAndSendAlerts(spikClients, *alertTmpl, config, true)
} }
if config.AlertsVLEnabled { if config.AlertsVLEnabled {
_ = generateAndSendAlertsVL(validClients, *alertTmpl, config, true) _, _ = generateAndSendAlertsVL(spikClients, *alertTmpl, config, true)
} }
} }
log.Printf("=== Summary ===") log.Printf("=== Summary ===")
log.Printf("Clients: %d", len(validClients)) log.Printf("Spik clients: %d, SP clients: %d", len(spikClients), len(spClients))
log.Printf("Skipped: %d", skippedCount) log.Printf("Skipped: %d", skippedCount)
log.Printf("DRY RUN: Dashboard generated but not sent to Grafana") log.Printf("DRY RUN: Dashboard generated but not sent to Grafana")
if config.VLDatasourceUID != "" && config.DashboardVLEnabled { if config.VLDatasourceUID != "" && config.DashboardVLEnabled {
vlDashboard := generateVLDashboard(validClients, template, config) vlDashboard := generateVLDashboard(spikClients, template, config)
logDryRun(config.VLDashboardTitle, vlDashboard) logDryRun(config.VLDashboardTitle, vlDashboard)
if len(spClients) > 0 && config.SPDashboardTitle != "" {
spConfig := makeSPConfig(config)
spDashboard := generateVLDashboard(spClients, template, spConfig)
logDryRun(config.SPDashboardTitle, spDashboard)
}
} }
os.Exit(0) os.Exit(0)
} }
@ -159,7 +194,7 @@ func main() {
if err := sendToGrafana(dashboard, config); err != nil { if err := sendToGrafana(dashboard, config); err != nil {
log.Printf("Error sending dashboard: %v", err) log.Printf("Error sending dashboard: %v", err)
log.Printf("=== Summary ===") log.Printf("=== Summary ===")
log.Printf("Clients: %d", len(validClients)) log.Printf("Spik clients: %d", len(spikClients))
log.Printf("Skipped: %d", skippedCount) log.Printf("Skipped: %d", skippedCount)
log.Printf("Status: FAILED") log.Printf("Status: FAILED")
os.Exit(1) os.Exit(1)
@ -169,10 +204,10 @@ func main() {
log.Printf("Dashboard OS: disabled (DASHBOARD_OS_ENABLED=false)") log.Printf("Dashboard OS: disabled (DASHBOARD_OS_ENABLED=false)")
} }
// Generate VictoriaLogs dashboard if configured // Generate VictoriaLogs dashboard for spik clients
if config.VLDatasourceUID != "" && config.DashboardVLEnabled { if config.VLDatasourceUID != "" && config.DashboardVLEnabled {
log.Printf("Generating VictoriaLogs dashboard: %s", config.VLDashboardTitle) log.Printf("Generating VictoriaLogs dashboard: %s", config.VLDashboardTitle)
vlDashboard := generateVLDashboard(validClients, template, config) vlDashboard := generateVLDashboard(spikClients, template, config)
if err := sendVLDashboardToGrafana(vlDashboard, config); err != nil { if err := sendVLDashboardToGrafana(vlDashboard, config); err != nil {
log.Printf("Warning: VictoriaLogs dashboard failed: %v", err) log.Printf("Warning: VictoriaLogs dashboard failed: %v", err)
} else { } else {
@ -182,29 +217,74 @@ func main() {
log.Printf("Dashboard VL: disabled (DASHBOARD_VL_ENABLED=false)") log.Printf("Dashboard VL: disabled (DASHBOARD_VL_ENABLED=false)")
} }
// Generate SP VictoriaLogs dashboard for sp clients
if config.VLDatasourceUID != "" && config.SPDashboardTitle != "" && len(spClients) > 0 {
log.Printf("Generating SP VictoriaLogs dashboard: %s (%d clients)", config.SPDashboardTitle, len(spClients))
spConfig := makeSPConfig(config)
spDashboard := generateVLDashboard(spClients, template, spConfig)
if err := sendVLDashboardToGrafana(spDashboard, spConfig); err != nil {
log.Printf("Warning: SP dashboard failed: %v", err)
} else {
log.Printf("Successfully created/updated SP dashboard: %s", config.SPDashboardTitle)
}
}
// Generate and send alert rules // Generate and send alert rules
if alertTmpl != nil { if alertTmpl != nil {
if alertsChanged || config.ForceRun { if alertsChanged || config.ForceRun {
osOK := true osErr := false
vlErr := false
if config.AlertsOSEnabled { if config.AlertsOSEnabled {
if err := generateAndSendAlerts(validClients, *alertTmpl, config, false); err != nil { if err := generateAndSendAlerts(spikClients, *alertTmpl, config, false); err != nil {
log.Printf("Warning: alerts generation failed: %v", err) log.Printf("Warning: alerts generation failed: %v", err)
osOK = false osErr = true
} }
} else { } else {
log.Printf("Alerts OS: disabled (ALERTS_OS_ENABLED=false)") log.Printf("Alerts OS: disabled (ALERTS_OS_ENABLED=false)")
} }
allActiveUIDs := map[string]bool{}
if config.AlertsVLEnabled { if config.AlertsVLEnabled {
if err := generateAndSendAlertsVL(validClients, *alertTmpl, config, false); err != nil { spikUIDs, err := generateAndSendAlertsVL(spikClients, *alertTmpl, config, false)
if err != nil {
log.Printf("Warning: VL alerts generation failed: %v", err) log.Printf("Warning: VL alerts generation failed: %v", err)
osOK = false vlErr = true
}
for _, uid := range spikUIDs {
allActiveUIDs[uid] = true
}
// SP алерты — для sp клиентов с отдельным contact point и группой
if len(spClients) > 0 {
spAlertsConfig := makeSPAlertsConfig(config)
spUIDs, err := generateAndSendAlertsVL(spClients, *alertTmpl, spAlertsConfig, false)
if err != nil {
log.Printf("Warning: SP VL alerts generation failed: %v", err)
}
for _, uid := range spUIDs {
allActiveUIDs[uid] = true
}
}
// Статические алерты — отправляем один раз с основным конфигом
// (не с SP конфигом, чтобы попали в правильную группу [SYS] PTAF Grafana)
if !config.AlertsOSEnabled {
if err := sendStaticAlerts(*alertTmpl, config, false); err != nil {
log.Printf("Warning: VL static alerts error: %v", err)
}
for _, a := range alertTmpl.StaticAlerts {
if uid, ok := a["uid"].(string); ok {
allActiveUIDs[uid] = true
}
}
}
if folderUID, err := ensureAlertFolder(config); err == nil {
deleteObsoleteAlerts(allActiveUIDs, folderUID, config)
} }
} else { } else {
log.Printf("Alerts VL: disabled (ALERTS_VL_ENABLED=false)") log.Printf("Alerts VL: disabled (ALERTS_VL_ENABLED=false)")
} }
// Сохраняем хэш если хотя бы одна из систем алертов не вернула ошибку // Сохраняем хэш если не было критических ошибок
if osOK { // Частичные ошибки отдельных алертов не блокируют сохранение хэша
stateManager.UpdateAlertsHash(validClients) if !osErr && !vlErr {
stateManager.UpdateAlertsHash(spikClients)
stateManager.UpdateAlertTemplateHash(alertTemplateHash) stateManager.UpdateAlertTemplateHash(alertTemplateHash)
} }
} else { } else {
@ -215,7 +295,7 @@ func main() {
// Summary // Summary
log.Printf("=== Summary ===") log.Printf("=== Summary ===")
log.Printf("Dashboard: %s", config.DashboardTitle) log.Printf("Dashboard: %s", config.DashboardTitle)
log.Printf("Clients: %d", len(validClients)) log.Printf("Spik clients: %d, SP clients: %d", len(spikClients), len(spClients))
if skippedCount > 0 { if skippedCount > 0 {
log.Printf("Skipped: %d", skippedCount) log.Printf("Skipped: %d", skippedCount)
} }
@ -291,6 +371,16 @@ func parseFlags() Config {
flag.StringVar(&config.VLGrafanaFolder, "vl-grafana-folder", vlGrafanaFolder, "Grafana folder for VictoriaLogs dashboard (empty = same as main)") flag.StringVar(&config.VLGrafanaFolder, "vl-grafana-folder", vlGrafanaFolder, "Grafana folder for VictoriaLogs dashboard (empty = same as main)")
flag.StringVar(&config.VLDashboardUID, "vl-dashboard-uid", vlDashboardUID, "VictoriaLogs dashboard UID in Grafana") flag.StringVar(&config.VLDashboardUID, "vl-dashboard-uid", vlDashboardUID, "VictoriaLogs dashboard UID in Grafana")
// SP dashboard flags
spDashboardTitle := getEnvOrDefault("SP_DASHBOARD_TITLE", DefaultSPDashboardTitle)
spDashboardUID := getEnvOrDefault("SP_DASHBOARD_UID", DefaultSPDashboardUID)
spGrafanaFolder := getEnvOrDefault("SP_GRAFANA_FOLDER", DefaultSPGrafanaFolder)
flag.StringVar(&config.SPDashboardTitle, "sp-dashboard-title", spDashboardTitle, "SP VictoriaLogs dashboard title")
flag.StringVar(&config.SPDashboardUID, "sp-dashboard-uid", spDashboardUID, "SP VictoriaLogs dashboard UID in Grafana")
flag.StringVar(&config.SPGrafanaFolder, "sp-grafana-folder", spGrafanaFolder, "Grafana folder for SP dashboard (empty = same as main)")
config.SPAlertsGroup = getEnvOrDefault("SP_ALERTS_GROUP", DefaultSPAlertsGroup)
config.SPAlertsReceiver = getEnvOrDefault("SP_ALERTS_RECEIVER", DefaultSPAlertsReceiver)
// Other flags // Other flags
flag.BoolVar(&config.DryRun, "dry-run", false, "Generate JSON but don't send to Grafana") flag.BoolVar(&config.DryRun, "dry-run", false, "Generate JSON but don't send to Grafana")
flag.StringVar(&config.StateFile, "state-file", DefaultStateFile, "Path to state file") flag.StringVar(&config.StateFile, "state-file", DefaultStateFile, "Path to state file")

View file

@ -193,22 +193,33 @@ func calculateClientsHash(clients map[string]ClientData) string {
return fmt.Sprintf("%x", hash) return fmt.Sprintf("%x", hash)
} }
// calculateAlertsHash считает хэш только от данных нужных для алертов (client_title + rps_limit) // calculateAlertsHash считает хэш от данных нужных для алертов (rps_limit + пороги per-SID)
func calculateAlertsHash(clients map[string]ClientData) string { func calculateAlertsHash(clients map[string]ClientData) string {
type alertData struct { type domainAlert struct {
ClientTitle string SID string
RPSLimit int
Limit4xx int Limit4xx int
Limit5xx int Limit5xx int
} }
type alertData struct {
ClientTitle string
RPSLimit int
Domains []domainAlert
}
var data []alertData var data []alertData
for _, client := range clients { for _, client := range clients {
if client.RPSLimit > 0 || client.Limit4xx > 0 || client.Limit5xx > 0 { var domains []domainAlert
for _, d := range client.Domains {
domains = append(domains, domainAlert{
SID: d.SID,
Limit4xx: d.Limit4xx,
Limit5xx: d.Limit5xx,
})
}
if client.RPSLimit > 0 || len(domains) > 0 {
data = append(data, alertData{ data = append(data, alertData{
ClientTitle: client.ClientTitle, ClientTitle: client.ClientTitle,
RPSLimit: client.RPSLimit, RPSLimit: client.RPSLimit,
Limit4xx: client.Limit4xx, Domains: domains,
Limit5xx: client.Limit5xx,
}) })
} }
} }