Change alerts
This commit is contained in:
parent
63351eb754
commit
02b262681e
8 changed files with 996 additions and 498 deletions
197
ARCHITECTURE.md
197
ARCHITECTURE.md
|
|
@ -10,32 +10,33 @@
|
|||
PostgreSQL
|
||||
sp_info + manual_info (UNION)
|
||||
apps_settings
|
||||
client_info
|
||||
client_info (waf_provider IS NOT NULL)
|
||||
│
|
||||
▼
|
||||
fetchClientsData()
|
||||
map[clientTitle]ClientData
|
||||
map[clientTitle]ClientData{WafProvider}
|
||||
│
|
||||
├──────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
generateSingleDashboard() generateAndSendAlerts() (OS)
|
||||
generateVLDashboard() generateAndSendAlertsVL() (VL)
|
||||
│ │
|
||||
▼ ▼
|
||||
dashboard_template.json alert_rules_template.json
|
||||
(Git-репозиторий) (Git-репозиторий)
|
||||
│ │
|
||||
▼ ▼
|
||||
POST /api/dashboards/db POST/PUT /api/v1/provisioning/alert-rules
|
||||
(Grafana API) (Grafana Provisioning API, параллельно x5)
|
||||
│ │
|
||||
▼ ▼
|
||||
state.json ──── clients_hash state.json ──── alerts_hash
|
||||
└─── alert_template_hash
|
||||
├──────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
spikClients (waf_provider=spik) spClients (waf_provider=sp)
|
||||
│ │
|
||||
├──────────────────────┐ ┌──────────────────────────┤
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
generateVLDashboard() generateAndSendAlertsVL() generateVLDashboard() generateAndSendAlertsVL()
|
||||
(SPIK дашборд) (группа: PTAF Grafana) (SP дашборд) (группа: SP PTAF Grafana)
|
||||
(receiver: tg+mail+...) (receiver: For_SP)
|
||||
│ │ │ │
|
||||
▼ └────────────────┘ │
|
||||
dashboard_template.json activeUIDs (spik+SP+static) │
|
||||
│ │ │
|
||||
▼ ▼ │
|
||||
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` |
|
||||
| `database.go` | Подключение к PostgreSQL, `fetchClientsData()` |
|
||||
| `database.go` | Подключение к PostgreSQL, `fetchClientsData()`, чтение `waf_provider` |
|
||||
| `dashboard.go` | Генерация JSON дашборда (OpenSearch) из шаблона и данных клиентов |
|
||||
| `dashboard_vl.go` | Генерация JSON дашборда для VictoriaLogs |
|
||||
| `panels.go` | Построение панелей (OpenSearch), реестр query-режимов, `applyRPSThreshold()` |
|
||||
| `panels_vl.go` | Построение панелей для VictoriaLogs, LogsQL query builders |
|
||||
| `alerts.go` | Генерация и отправка правил алертинга (OpenSearch), параллельный upsert |
|
||||
| `alerts_vl.go` | Генерация и отправка правил алертинга для VictoriaLogs |
|
||||
| `alerts.go` | Генерация и отправка правил алертинга (OpenSearch), `deleteObsoleteAlerts()` |
|
||||
| `alerts_vl.go` | Генерация и отправка правил алертинга для VictoriaLogs, возвращает список активных UID |
|
||||
| `template_validator.go` | Валидация и автоисправление критичных настроек шаблона |
|
||||
| `grafana.go` | HTTP-клиент для Grafana API (дашборды, папки) |
|
||||
| `template.go` | Загрузка `dashboard_template.json` и `alert_rules_template.json` |
|
||||
|
|
@ -67,7 +68,7 @@ PostgreSQL
|
|||
|
||||
### Источник данных
|
||||
|
||||
Данные всегда берутся из **обеих** таблиц одновременно через `UNION`:
|
||||
Данные берутся из **обеих** таблиц одновременно через `UNION`. Читаются только клиенты у которых `waf_provider IS NOT NULL`:
|
||||
|
||||
```sql
|
||||
SELECT ... FROM (
|
||||
|
|
@ -77,6 +78,7 @@ SELECT ... FROM (
|
|||
) s
|
||||
LEFT JOIN apps_settings a ON s.sid = a.l7resourceid
|
||||
LEFT JOIN client_info ci ON a.client_title = ci.client_title
|
||||
WHERE ci.waf_provider IS NOT NULL
|
||||
```
|
||||
|
||||
Дубликаты по 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)
|
||||
- `four_hundred` — порог 4xx ошибок в % (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
|
||||
-- 1. Ресурс появится автоматически из sp_info
|
||||
-- 1. Ресурс появится автоматически из sp_info (или добавить в manual_info)
|
||||
|
||||
-- 2. Привязать к клиенту
|
||||
INSERT INTO apps_settings (l7resourceid, client_title)
|
||||
VALUES ('SID12345', 'Название клиента');
|
||||
|
||||
-- 3. Задать лимиты для алертов (опционально)
|
||||
INSERT INTO client_info (client_title, rps_limit, rps_commercial_limit, four_hundred, five_hundred)
|
||||
VALUES ('Название клиента', 1800, 1200, 20, 10);
|
||||
-- 3. Задать провайдера и лимиты
|
||||
INSERT INTO client_info (client_title, waf_provider, rps_limit, rps_commercial_limit, four_hundred, five_hundred)
|
||||
VALUES ('Название клиента', 'spik', 1800, 1200, 20, 10);
|
||||
-- или для SP клиента: waf_provider = 'sp'
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -135,7 +153,7 @@ VALUES ('Название клиента', 1800, 1200, 20, 10);
|
|||
"status_codes": { "query_mode": "multi_target", "vl_interval": "1m", ... },
|
||||
"response_time": { "query_mode": "range_percent", "vl_interval": "1m", ... },
|
||||
"traffic_combined": { "query_mode": "multi_sum_expression", "vl_interval": "1m", ... },
|
||||
"overview_status_codes": { "query_mode": "bucket_logs", ... },
|
||||
"overview_status_codes": { "query_mode": "bucket_logs", ... },
|
||||
"overview_tenant_per_nodes": { "query_mode": "bucket_logs", ... },
|
||||
"overview_tenant_per_node": { "query_mode": "bucket_logs", ... },
|
||||
"overview_uri_top15": { "query_mode": "bucket_logs", ... },
|
||||
|
|
@ -231,11 +249,18 @@ VALUES ('Название клиента', 1800, 1200, 20, 10);
|
|||
|
||||
### 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 дашборда:**
|
||||
|
||||
|
|
@ -244,8 +269,6 @@ VALUES ('Название клиента', 1800, 1200, 20, 10);
|
|||
- `interval: "1m"` из поля `vl_interval` шаблона панели
|
||||
- `timeShift: "1m"` для обрезки неполной последней минуты
|
||||
- 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 (свёрнутая строка) │
|
||||
│ Response_status_code SID │
|
||||
│ Request Count by TENANT per nodes │
|
||||
│ Request Count by TENANT per node │
|
||||
│ Request Count by TENANT per nodes/node │
|
||||
│ URI top 15 │
|
||||
│ Response status code by Container │
|
||||
│ Nginx vs Angie Status Codes │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ ▶ Angie (свёрнутая строка) │
|
||||
│ Errors by Apps │
|
||||
│ Errors by Upstream Path (app: ${error_server})│
|
||||
│ Errors by Upstream Path │
|
||||
│ Errors by Node │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ ▼ Клиент A (развёрнутая строка) │
|
||||
│ ▼ Клиент A │
|
||||
│ Status Codes | RPS | Total 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`), а не создаётся заново.
|
||||
|
||||
**Разделение по 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 |
|
||||
|---|---|---|
|
||||
| Use in / | диск `/` > 75% | 3m |
|
||||
| Use in /var/log | диск `/var/log` > 75% | 5m |
|
||||
| CPU Busy | CPU > 75% | 3m |
|
||||
| RAM Busy | RAM > 75% | 3m |
|
||||
| Состояние контейнеров | `docker_container_status == 0` | 2m |
|
||||
| Упала Angie | `angie.service` не active | 1m |
|
||||
| `[SYS] Use in / 75%` | диск `/` > 75% | 3m |
|
||||
| `[SYS] Use in / 90%` | диск `/` > 90% | 1m |
|
||||
| `[SYS] Use in /var/log 75%` | диск `/var/log` > 75% | 5m |
|
||||
| `[SYS] Use in /var/log 90%` | диск `/var/log` > 90% | 1m |
|
||||
| `[SYS] CPU Busy 75%` | CPU > 75% | 3m |
|
||||
| `[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:
|
||||
|
||||
```
|
||||
[RPS client1] [RPS client2] [4xx client1] [5xx client2] [static: CPU] ← 5 горутин
|
||||
↓ освободился слот
|
||||
[static: RAM] ...
|
||||
```
|
||||
Все алерты отправляются параллельно через горутины с ограничением **5 одновременных запросов** к Grafana API.
|
||||
|
||||
### Подавление DatasourceNoData алертов
|
||||
|
||||
VL алерты при отсутствии данных переходят в состояние `NoData` (не `Alerting`). Для подавления уведомлений об этом состоянии в Grafana создан Silence с матчером `alertname = DatasourceNoData`.
|
||||
VL алерты при отсутствии данных переходят в состояние `NoData`. Для подавления уведомлений в Grafana создан Silence с матчером `alertname = DatasourceNoData`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -348,7 +370,7 @@ VL алерты при отсутствии данных переходят в
|
|||
`template_validator.go` запускается автоматически после загрузки шаблона и проверяет/исправляет:
|
||||
|
||||
- `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` с `node_name:in(${node_name:csv})` для `overview_tenant_per_node`
|
||||
- Layout tenant_panels в три столбца по 8 (status_codes + rps/rps_multi + traffic_combined_total)
|
||||
|
|
@ -360,8 +382,6 @@ VL алерты при отсутствии данных переходят в
|
|||
|
||||
Путь по умолчанию: `/var/lib/grafana_gen/state.json`
|
||||
|
||||
Хранит хэши для обнаружения изменений между запусками:
|
||||
|
||||
```json
|
||||
{
|
||||
"last_run": "2026-02-25T10:00:00Z",
|
||||
|
|
@ -387,7 +407,7 @@ VL алерты при отсутствии данных переходят в
|
|||
ничего не изменилось → выход без действий (если не указан -force)
|
||||
```
|
||||
|
||||
Хэш алертов сохраняется после успешного выполнения независимо от того какие системы алертов включены (OS/VL).
|
||||
`alerts_hash` считается только от `spikClients` и сохраняется после успешной отправки.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -404,7 +424,7 @@ VL алерты при отсутствии данных переходят в
|
|||
|
||||
### Пути к .env файлу (перебираются по порядку)
|
||||
|
||||
1. `$GRAFANA_GEN_ENV_FILE` (переменная окружения)
|
||||
1. `$GRAFANA_GEN_ENV_FILE`
|
||||
2. `/etc/grafana_gen/grafana_gen.env`
|
||||
3. `./grafana_gen.env`
|
||||
4. `./.env`
|
||||
|
|
@ -420,10 +440,10 @@ DB_PASSWORD=secret
|
|||
GRAFANA_URL=https://grafana.example.com
|
||||
GRAFANA_API_KEY=glsa-xxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Git (Gitea)
|
||||
# Git
|
||||
GIT_TOKEN=your-gitea-token
|
||||
|
||||
# Алерты (OpenSearch)
|
||||
# Алерты OpenSearch
|
||||
ALERTS_DATASOURCE_UID=af84zsvlp9blsa
|
||||
|
||||
# VictoriaLogs
|
||||
|
|
@ -431,6 +451,12 @@ VL_DATASOURCE_UID=efewdonokxybkf
|
|||
VL_DASHBOARD_TITLE=PT AF Requests (VictoriaLogs)
|
||||
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_VL_ENABLED=true
|
||||
|
|
@ -451,16 +477,21 @@ ALERTS_VL_ENABLED=true
|
|||
| `-db-name` | — | `waf_info` | PostgreSQL база данных |
|
||||
| `-grafana-url` | `GRAFANA_URL` | — | URL Grafana |
|
||||
| `-grafana-api-key` | `GRAFANA_API_KEY` | — | Grafana API ключ |
|
||||
| `-grafana-folder` | `GRAFANA_FOLDER` | `WAF - Auto Generated` | Папка для дашбордов |
|
||||
| `-dashboard-title` | `DASHBOARD_TITLE` | `PT AF Nodes` | Название дашборда |
|
||||
| `-grafana-folder` | `GRAFANA_FOLDER` | `WAF - Auto Generated` | Папка для OS дашборда |
|
||||
| `-dashboard-title` | `DASHBOARD_TITLE` | `PT AF Requests` | Название OS дашборда |
|
||||
| `-alerts-folder` | `ALERTS_FOLDER` | `WAF - PTAF` | Папка для алертов |
|
||||
| `-alerts-receiver` | `ALERTS_RECEIVER` | `tg+mail+mattermost PTAF Grafana` | Получатель уведомлений |
|
||||
| `-alerts-group` | `ALERTS_GROUP` | `PTAF Grafana` | Группа алертов |
|
||||
| `-alerts-receiver` | `ALERTS_RECEIVER` | `tg+mail+mattermost PTAF Grafana` | Contact point для SPIK алертов |
|
||||
| `-alerts-group` | `ALERTS_GROUP` | `PTAF Grafana` | Группа алертов для SPIK |
|
||||
| `-alerts-datasource-uid` | `ALERTS_DATASOURCE_UID` | `af84zsvlp9blsa` | UID datasource OpenSearch для алертов |
|
||||
| `-vl-datasource-uid` | `VL_DATASOURCE_UID` | — | UID VictoriaLogs datasource (пустая = не генерировать VL) |
|
||||
| `-vl-dashboard-title` | `VL_DASHBOARD_TITLE` | `PT AF Requests (VictoriaLogs)` | Название VL дашборда |
|
||||
| `-vl-grafana-folder` | `VL_GRAFANA_FOLDER` | — | Папка для VL дашборда (пустая = как у OS) |
|
||||
| `-vl-dashboard-uid` | `VL_DASHBOARD_UID` | `pt-af-requests-auto-vl` | UID VL дашборда в Grafana |
|
||||
| `-vl-datasource-uid` | `VL_DATASOURCE_UID` | — | UID VictoriaLogs datasource |
|
||||
| `-vl-dashboard-title` | `VL_DASHBOARD_TITLE` | `PT AF Requests (VictoriaLogs)` | Название SPIK VL дашборда |
|
||||
| `-vl-grafana-folder` | `VL_GRAFANA_FOLDER` | — | Папка для SPIK VL дашборда |
|
||||
| `-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 токен |
|
||||
| `-templates-repo` | — | `svc-git.cirex.ru/...` | URL Git-репозитория шаблонов |
|
||||
| `-templates-branch` | — | `master` | Ветка шаблонов |
|
||||
|
|
@ -489,10 +520,10 @@ ALERTS_VL_ENABLED=true
|
|||
# Без обновления шаблонов из Git
|
||||
./grafana_gen -skip-git-pull
|
||||
|
||||
# Только VL дашборд и VL алерты
|
||||
# Только VL дашборды и VL алерты
|
||||
DASHBOARD_OS_ENABLED=false ALERTS_OS_ENABLED=false ./grafana_gen
|
||||
|
||||
# Явное указание всех параметров
|
||||
# Явное указание параметров
|
||||
./grafana_gen \
|
||||
-db-host=10.100.10.8 \
|
||||
-db-user=reader \
|
||||
|
|
|
|||
642
alerts.go
642
alerts.go
|
|
@ -74,7 +74,7 @@ func buildRPSAlertRule(client ClientData, tmpl AlertRulesTemplate, config Config
|
|||
// Получаем настройки из шаблона
|
||||
defaults := tmpl.Defaults
|
||||
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)
|
||||
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),
|
||||
})
|
||||
}
|
||||
if t.client.Limit4xx > 0 {
|
||||
rule := buildErrorRateAlertRule(t.client, 4, t.client.Limit4xx, tmpl, config)
|
||||
rule["folderUID"] = folderUID
|
||||
tasks = append(tasks, alertTask{
|
||||
rule: rule,
|
||||
label: fmt.Sprintf("4xx %s (limit: %d%%)", t.client.ClientTitle, t.client.Limit4xx),
|
||||
})
|
||||
// WAF block алерт — по одному на каждый SID (пропускаем если ptaf_fallback_code = "pass" или пустой)
|
||||
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
|
||||
tasks = append(tasks, alertTask{
|
||||
rule: rule,
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Статические алерты — отправляются первыми (появляются в начале списка)
|
||||
if err := sendStaticAlerts(tmpl, config, dryRun); err != nil {
|
||||
log.Printf("Warning: static alerts error: %v", err)
|
||||
// Error rate алерты — пороги берутся из каждого домена (apps_settings)
|
||||
// Генерируем для response_status_code и upstream_status_code
|
||||
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))
|
||||
sent, failed := parallelUpsert(tasks, config, dryRun, 5)
|
||||
sent, failed := parallelUpsert(tasks, config, dryRun, 20)
|
||||
log.Printf("Alerts: sent=%d, failed=%d", sent, failed)
|
||||
if failed > 0 {
|
||||
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
|
||||
if err := upsertContactPoint(tmpl, config, dryRun); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// buildErrorRateAlertRule строит алерт на процент 4xx или 5xx ответов для клиента.
|
||||
// Структура: два count-запроса (total + errors) → reduce → math (%) → threshold
|
||||
func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl AlertRulesTemplate, config Config) map[string]interface{} {
|
||||
// buildErrorRateAlertRules строит алерт на процент 4xx или 5xx ответов для конкретного домена.
|
||||
// statusField: "response_status_code" или "upstream_status_code"
|
||||
func buildErrorRateAlertRules(client ClientData, domain DomainInfo, errCode, limitPct int, statusField string, tmpl AlertRulesTemplate, config Config) []map[string]interface{} {
|
||||
datasourceUID := config.AlertsDatasourceUID
|
||||
defaults := tmpl.Defaults
|
||||
receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
|
||||
group := orDefault(config.AlertsGroup, defaults.Group)
|
||||
|
||||
// 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 ")
|
||||
group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle)
|
||||
|
||||
codeRange := "[400 TO 499]"
|
||||
codeName := "4xx"
|
||||
|
|
@ -410,151 +414,180 @@ func buildErrorRateAlertRule(client ClientData, errCode, limitPct int, tmpl Aler
|
|||
codeName = "5xx"
|
||||
}
|
||||
|
||||
uid := generateAlertUID(client.ClientTitle, codeName)
|
||||
totalRefID := "Total"
|
||||
errRefID := "Errors"
|
||||
totalReduceID := "_TotalReduce"
|
||||
errReduceID := "_ErrorsReduce"
|
||||
percentRefID := "Значение"
|
||||
thresholdRefID := "Превышение"
|
||||
|
||||
summary := fmt.Sprintf("🚨 %s — %s ошибок >%d%% трафика", client.ClientTitle, codeName, limitPct)
|
||||
|
||||
relFrom := int64(900)
|
||||
|
||||
forDuration := "1m"
|
||||
if errCode == 4 {
|
||||
forDuration = "3m"
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"uid": uid,
|
||||
"title": fmt.Sprintf("%s %s", codeName, client.ClientTitle),
|
||||
"condition": thresholdRefID,
|
||||
"noDataState": defaults.NoDataState,
|
||||
"execErrState": defaults.ExecErrState,
|
||||
"isPaused": false,
|
||||
"folderUID": "",
|
||||
"ruleGroup": group,
|
||||
"for": forDuration,
|
||||
"annotations": map[string]string{"summary": summary},
|
||||
"notification_settings": map[string]string{"receiver": receiver},
|
||||
"data": []interface{}{
|
||||
// Total запрос
|
||||
map[string]interface{}{
|
||||
"refId": totalRefID,
|
||||
"queryType": "lucene",
|
||||
"relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0},
|
||||
"datasourceUid": datasourceUID,
|
||||
"model": map[string]interface{}{
|
||||
"alias": "Total",
|
||||
"bucketAggs": []interface{}{map[string]interface{}{"field": "@timestamp", "id": "2", "settings": map[string]interface{}{"interval": "1m"}, "type": "date_histogram"}},
|
||||
"datasource": map[string]interface{}{"type": "grafana-opensearch-datasource", "uid": datasourceUID},
|
||||
"format": "table",
|
||||
"instant": false,
|
||||
"intervalMs": 1000,
|
||||
"luceneQueryType": "Metric",
|
||||
"maxDataPoints": 43200,
|
||||
"metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}},
|
||||
"query": fmt.Sprintf("response_status_code:[0 TO 599] AND (%s)", sidQuery),
|
||||
"queryType": "lucene",
|
||||
"range": true,
|
||||
"refId": totalRefID,
|
||||
"timeField": "@timestamp",
|
||||
},
|
||||
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"
|
||||
errRefID := "Errors"
|
||||
totalReduceID := "_TotalReduce"
|
||||
errReduceID := "_ErrorsReduce"
|
||||
percentRefID := "Значение"
|
||||
thresholdRefID := "Превышение"
|
||||
|
||||
rule := map[string]interface{}{
|
||||
"uid": uid,
|
||||
"title": title,
|
||||
"condition": thresholdRefID,
|
||||
"noDataState": defaults.NoDataState,
|
||||
"execErrState": defaults.ExecErrState,
|
||||
"isPaused": false,
|
||||
"folderUID": "",
|
||||
"ruleGroup": group,
|
||||
"for": forDuration,
|
||||
"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,
|
||||
},
|
||||
// Error запрос
|
||||
map[string]interface{}{
|
||||
"refId": errRefID,
|
||||
"queryType": "lucene",
|
||||
"relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0},
|
||||
"datasourceUid": datasourceUID,
|
||||
"model": map[string]interface{}{
|
||||
"alias": codeName,
|
||||
"bucketAggs": []interface{}{map[string]interface{}{"field": "@timestamp", "id": "2", "settings": map[string]interface{}{"interval": "1m"}, "type": "date_histogram"}},
|
||||
"datasource": map[string]interface{}{"type": "grafana-opensearch-datasource", "uid": datasourceUID},
|
||||
"format": "table",
|
||||
"instant": false,
|
||||
"intervalMs": 1000,
|
||||
"luceneQueryType": "Metric",
|
||||
"maxDataPoints": 43200,
|
||||
"metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}},
|
||||
"query": fmt.Sprintf("response_status_code:%s AND (%s)", codeRange, sidQuery),
|
||||
"queryType": "lucene",
|
||||
"range": true,
|
||||
"refId": errRefID,
|
||||
"timeField": "@timestamp",
|
||||
},
|
||||
},
|
||||
// Reduce total
|
||||
map[string]interface{}{
|
||||
"refId": totalReduceID,
|
||||
"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": totalRefID,
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"reducer": "last",
|
||||
"refId": totalReduceID,
|
||||
"type": "reduce",
|
||||
},
|
||||
},
|
||||
// Reduce errors
|
||||
map[string]interface{}{
|
||||
"refId": errReduceID,
|
||||
"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": errRefID,
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"reducer": "last",
|
||||
"refId": errReduceID,
|
||||
"type": "reduce",
|
||||
},
|
||||
},
|
||||
// Math: процент ошибок
|
||||
map[string]interface{}{
|
||||
"refId": percentRefID,
|
||||
"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": fmt.Sprintf("($%s / $%s) * 100", errReduceID, totalReduceID),
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": percentRefID,
|
||||
"type": "math",
|
||||
},
|
||||
},
|
||||
// Threshold
|
||||
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{}{limitPct, 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",
|
||||
},
|
||||
"notification_settings": map[string]string{"receiver": receiver},
|
||||
"data": []interface{}{
|
||||
// Total запрос
|
||||
map[string]interface{}{
|
||||
"refId": totalRefID,
|
||||
"queryType": "lucene",
|
||||
"relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0},
|
||||
"datasourceUid": datasourceUID,
|
||||
"model": map[string]interface{}{
|
||||
"alias": "Total",
|
||||
"bucketAggs": []interface{}{map[string]interface{}{"field": "@timestamp", "id": "2", "settings": map[string]interface{}{"interval": "1m"}, "type": "date_histogram"}},
|
||||
"datasource": map[string]interface{}{"type": "grafana-opensearch-datasource", "uid": datasourceUID},
|
||||
"format": "table",
|
||||
"instant": false,
|
||||
"intervalMs": 1000,
|
||||
"luceneQueryType": "Metric",
|
||||
"maxDataPoints": 43200,
|
||||
"metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}},
|
||||
"query": fmt.Sprintf("response_status_code:[0 TO 599] AND %s", sidQuery),
|
||||
"queryType": "lucene",
|
||||
"range": true,
|
||||
"refId": totalRefID,
|
||||
"timeField": "@timestamp",
|
||||
},
|
||||
},
|
||||
// Error запрос
|
||||
map[string]interface{}{
|
||||
"refId": errRefID,
|
||||
"queryType": "lucene",
|
||||
"relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0},
|
||||
"datasourceUid": datasourceUID,
|
||||
"model": map[string]interface{}{
|
||||
"alias": codeName,
|
||||
"bucketAggs": []interface{}{map[string]interface{}{"field": "@timestamp", "id": "2", "settings": map[string]interface{}{"interval": "1m"}, "type": "date_histogram"}},
|
||||
"datasource": map[string]interface{}{"type": "grafana-opensearch-datasource", "uid": datasourceUID},
|
||||
"format": "table",
|
||||
"instant": false,
|
||||
"intervalMs": 1000,
|
||||
"luceneQueryType": "Metric",
|
||||
"maxDataPoints": 43200,
|
||||
"metrics": []interface{}{map[string]interface{}{"id": "1", "type": "count"}},
|
||||
"query": fmt.Sprintf("%s:%s AND %s", statusField, codeRange, sidQuery),
|
||||
"queryType": "lucene",
|
||||
"range": true,
|
||||
"refId": errRefID,
|
||||
"timeField": "@timestamp",
|
||||
},
|
||||
},
|
||||
// Reduce total
|
||||
map[string]interface{}{
|
||||
"refId": totalReduceID,
|
||||
"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": totalRefID,
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"reducer": "last",
|
||||
"refId": totalReduceID,
|
||||
"type": "reduce",
|
||||
},
|
||||
},
|
||||
// Reduce errors
|
||||
map[string]interface{}{
|
||||
"refId": errReduceID,
|
||||
"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": errRefID,
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"reducer": "last",
|
||||
"refId": errReduceID,
|
||||
"type": "reduce",
|
||||
},
|
||||
},
|
||||
// Math: процент ошибок
|
||||
map[string]interface{}{
|
||||
"refId": percentRefID,
|
||||
"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": fmt.Sprintf("($%s / $%s) * 100", errReduceID, totalReduceID),
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": percentRefID,
|
||||
"type": "math",
|
||||
},
|
||||
},
|
||||
// Threshold
|
||||
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{}{limitPct, 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": percentRefID,
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": thresholdRefID,
|
||||
"type": "threshold",
|
||||
},
|
||||
"datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"},
|
||||
"expression": percentRefID,
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": thresholdRefID,
|
||||
"type": "threshold",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
// sendStaticAlerts отправляет статические алерты из шаблона (не зависят от БД)
|
||||
|
|
@ -600,7 +633,7 @@ func sendStaticAlerts(tmpl AlertRulesTemplate, config Config, dryRun bool) error
|
|||
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)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -780,63 +813,110 @@ func upsertContactPoint(tmpl AlertRulesTemplate, config Config, dryRun bool) err
|
|||
return fmt.Errorf("failed to parse contact points: %w", err)
|
||||
}
|
||||
|
||||
// Ищем contact point с нужным именем
|
||||
var target map[string]interface{}
|
||||
// Ищем все telegram интеграции с нужным именем и обновляем message
|
||||
var updated int
|
||||
for _, cp := range contactPoints {
|
||||
if name, _ := cp["name"].(string); name == receiverName {
|
||||
target = cp
|
||||
break
|
||||
name, _ := cp["name"].(string)
|
||||
if name != receiverName {
|
||||
continue
|
||||
}
|
||||
// Обновляем все типы интеграций (telegram, email, slack и т.д.)
|
||||
uid, _ := cp["uid"].(string)
|
||||
cpType, _ := cp["type"].(string)
|
||||
settings, ok := cp["settings"].(map[string]interface{})
|
||||
if !ok {
|
||||
settings = make(map[string]interface{})
|
||||
cp["settings"] = settings
|
||||
}
|
||||
// Поле для шаблона зависит от типа интеграции
|
||||
switch cpType {
|
||||
case "slack":
|
||||
settings["text"] = tmpl.ContactPoint.MessageTemplate
|
||||
default: // telegram, email, oncall, mattermost и др.
|
||||
settings["message"] = tmpl.ContactPoint.MessageTemplate
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
data, _ := json.MarshalIndent(cp, "", " ")
|
||||
log.Printf("DRY RUN: Contact point update for '%s' (uid: %s):\n%s", receiverName, uid, string(data))
|
||||
updated++
|
||||
continue
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(cp)
|
||||
updReq, _ := http.NewRequest("PUT", baseURL+"/api/v1/provisioning/contact-points/"+uid, bytes.NewBuffer(payload))
|
||||
updReq.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey)
|
||||
updReq.Header.Set("Content-Type", "application/json")
|
||||
updReq.Header.Set("X-Disable-Provenance", "true")
|
||||
|
||||
updResp, err := http.DefaultClient.Do(updReq)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to update contact point '%s' (uid: %s): %v", receiverName, uid, err)
|
||||
continue
|
||||
}
|
||||
updResp.Body.Close()
|
||||
updated++
|
||||
log.Printf("Contact point '%s' (uid: %s) message template updated", receiverName, uid)
|
||||
}
|
||||
|
||||
if target == nil {
|
||||
log.Printf("Contact point '%s' not found, skipping message template update", receiverName)
|
||||
return nil
|
||||
if updated == 0 {
|
||||
log.Printf("No telegram integrations found for contact point '%s'", receiverName)
|
||||
}
|
||||
|
||||
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 {
|
||||
settings = make(map[string]interface{})
|
||||
target["settings"] = settings
|
||||
}
|
||||
settings["message"] = tmpl.ContactPoint.MessageTemplate
|
||||
|
||||
if dryRun {
|
||||
data, _ := json.MarshalIndent(target, "", " ")
|
||||
log.Printf("DRY RUN: Contact point update for '%s':\n%s", receiverName, string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// PUT /api/v1/provisioning/contact-points/{uid}
|
||||
payload, _ := json.Marshal(target)
|
||||
updReq, _ := http.NewRequest("PUT", baseURL+"/api/v1/provisioning/contact-points/"+uid, bytes.NewBuffer(payload))
|
||||
updReq.Header.Set("Authorization", "Bearer "+config.GrafanaAPIKey)
|
||||
updReq.Header.Set("Content-Type", "application/json")
|
||||
updReq.Header.Set("X-Disable-Provenance", "true")
|
||||
|
||||
updResp, err := http.DefaultClient.Do(updReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update contact point: %w", err)
|
||||
}
|
||||
defer updResp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(updResp.Body)
|
||||
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)
|
||||
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
|
||||
func orDefault(val, def string) string {
|
||||
if val != "" {
|
||||
|
|
@ -844,3 +924,113 @@ func orDefault(val, def string) string {
|
|||
}
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
440
alerts_vl.go
440
alerts_vl.go
|
|
@ -26,7 +26,7 @@ func buildRPSAlertRuleVL(client ClientData, tmpl AlertRulesTemplate, config Conf
|
|||
|
||||
defaults := tmpl.Defaults
|
||||
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)
|
||||
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.
|
||||
func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl AlertRulesTemplate, config Config) map[string]interface{} {
|
||||
// buildErrorRateAlertRulesVL строит алерт на процент ошибок для конкретного домена (VictoriaLogs).
|
||||
// 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
|
||||
datasourceType := DefaultVLDatasourceType
|
||||
|
||||
defaults := tmpl.Defaults
|
||||
receiver := orDefault(config.AlertsReceiver, defaults.Receiver)
|
||||
group := orDefault(config.AlertsGroup, defaults.Group)
|
||||
|
||||
sidQuery := buildTenantSIDQueryVL(client.Domains)
|
||||
group := fmt.Sprintf("%s %s", orDefault(config.AlertsGroup, defaults.Group), client.ClientTitle)
|
||||
|
||||
codeFrom, codeTo := 400, 499
|
||||
codeName := "4xx"
|
||||
|
|
@ -200,143 +199,173 @@ func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, tmpl Al
|
|||
codeName = "5xx"
|
||||
}
|
||||
|
||||
uid := generateAlertUID(client.ClientTitle+"_vl", codeName)
|
||||
totalRefID := "Total"
|
||||
errRefID := "Errors"
|
||||
totalReduceID := "_TotalReduce"
|
||||
errReduceID := "_ErrorsReduce"
|
||||
percentRefID := "Значение"
|
||||
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)
|
||||
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)
|
||||
relFrom := int64(900)
|
||||
|
||||
makeVLModel := func(refID, expr, legend string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": datasourceType,
|
||||
"uid": datasourceUID,
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": expr,
|
||||
"legendFormat": legend,
|
||||
"maxLines": 1000,
|
||||
"queryType": "statsRange",
|
||||
"refId": refID,
|
||||
"intervalMs": 60000,
|
||||
"maxDataPoints": 43200,
|
||||
var rules []map[string]interface{}
|
||||
|
||||
{
|
||||
sidQuery := fmt.Sprintf("SID:%s", domain.SID)
|
||||
// Короткое имя поля для использования в названии алерта
|
||||
fieldShort := "response"
|
||||
if statusField == "upstream_status_code" {
|
||||
fieldShort = "upstream"
|
||||
}
|
||||
}
|
||||
|
||||
makeReduce := func(refID, expression, reducer string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"},
|
||||
"expression": expression,
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"reducer": reducer,
|
||||
"refId": refID,
|
||||
"type": "reduce",
|
||||
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)
|
||||
|
||||
return map[string]interface{}{
|
||||
"uid": uid,
|
||||
"title": fmt.Sprintf("VL %s %s", codeName, client.ClientTitle),
|
||||
"condition": thresholdRefID,
|
||||
"noDataState": "NoData", // VL: нет данных = нет алерта
|
||||
"execErrState": "Error", // VL: ошибка datasource = Error, не Alerting
|
||||
"isPaused": false,
|
||||
"folderUID": "",
|
||||
"ruleGroup": group,
|
||||
"for": forDuration,
|
||||
"annotations": map[string]string{"summary": summary},
|
||||
"notification_settings": map[string]string{"receiver": receiver},
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": totalRefID,
|
||||
"queryType": "statsRange",
|
||||
"relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0},
|
||||
"datasourceUid": datasourceUID,
|
||||
"model": makeVLModel(totalRefID, totalExpr, "Total"),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": errRefID,
|
||||
"queryType": "statsRange",
|
||||
"relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0},
|
||||
"datasourceUid": datasourceUID,
|
||||
"model": makeVLModel(errRefID, errExpr, codeName),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": totalReduceID,
|
||||
"relativeTimeRange": map[string]interface{}{"from": 0, "to": 0},
|
||||
"datasourceUid": "__expr__",
|
||||
"model": makeReduce(totalReduceID, totalRefID, "sum"),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": errReduceID,
|
||||
"relativeTimeRange": map[string]interface{}{"from": 0, "to": 0},
|
||||
"datasourceUid": "__expr__",
|
||||
"model": makeReduce(errReduceID, errRefID, "sum"),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": percentRefID,
|
||||
"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": fmt.Sprintf("($%s / $%s) * 100", errReduceID, totalReduceID),
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": percentRefID,
|
||||
"type": "math",
|
||||
totalRefID := "Total"
|
||||
errRefID := "Errors"
|
||||
totalReduceID := "_TotalReduce"
|
||||
errReduceID := "_ErrorsReduce"
|
||||
percentRefID := "Значение"
|
||||
thresholdRefID := "Превышение"
|
||||
|
||||
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 %s:>=%d %s:<=%d | stats by (_time:1m) count() hits", sidQuery, statusField, codeFrom, statusField, codeTo)
|
||||
|
||||
makeVLModel := func(refID, expr, legend string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"datasource": map[string]interface{}{
|
||||
"type": datasourceType,
|
||||
"uid": datasourceUID,
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": expr,
|
||||
"legendFormat": legend,
|
||||
"maxLines": 1000,
|
||||
"queryType": "statsRange",
|
||||
"refId": refID,
|
||||
"intervalMs": 60000,
|
||||
"maxDataPoints": 43200,
|
||||
}
|
||||
}
|
||||
|
||||
makeReduce := func(refID, expression, reducer string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"},
|
||||
"expression": expression,
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"reducer": reducer,
|
||||
"refId": refID,
|
||||
"type": "reduce",
|
||||
}
|
||||
}
|
||||
|
||||
rule := map[string]interface{}{
|
||||
"uid": uid,
|
||||
"title": title,
|
||||
"condition": thresholdRefID,
|
||||
"noDataState": "NoData", // VL: нет данных = нет алерта
|
||||
"execErrState": "Error", // VL: ошибка datasource = Error, не Alerting
|
||||
"isPaused": false,
|
||||
"folderUID": "",
|
||||
"ruleGroup": group,
|
||||
"for": forDuration,
|
||||
"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,
|
||||
},
|
||||
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{}{limitPct, 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",
|
||||
},
|
||||
"notification_settings": map[string]string{"receiver": receiver},
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{
|
||||
"refId": totalRefID,
|
||||
"queryType": "statsRange",
|
||||
"relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0},
|
||||
"datasourceUid": datasourceUID,
|
||||
"model": makeVLModel(totalRefID, totalExpr, "Total"),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": errRefID,
|
||||
"queryType": "statsRange",
|
||||
"relativeTimeRange": map[string]interface{}{"from": relFrom, "to": 0},
|
||||
"datasourceUid": datasourceUID,
|
||||
"model": makeVLModel(errRefID, errExpr, codeName),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": totalReduceID,
|
||||
"relativeTimeRange": map[string]interface{}{"from": 0, "to": 0},
|
||||
"datasourceUid": "__expr__",
|
||||
"model": makeReduce(totalReduceID, totalRefID, "sum"),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": errReduceID,
|
||||
"relativeTimeRange": map[string]interface{}{"from": 0, "to": 0},
|
||||
"datasourceUid": "__expr__",
|
||||
"model": makeReduce(errReduceID, errRefID, "sum"),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"refId": percentRefID,
|
||||
"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": fmt.Sprintf("($%s / $%s) * 100", errReduceID, totalReduceID),
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": percentRefID,
|
||||
"type": "math",
|
||||
},
|
||||
},
|
||||
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{}{limitPct, 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": percentRefID,
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": thresholdRefID,
|
||||
"type": "threshold",
|
||||
},
|
||||
"datasource": map[string]interface{}{"name": "Expression", "type": "__expr__", "uid": "__expr__"},
|
||||
"expression": percentRefID,
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"refId": thresholdRefID,
|
||||
"type": "threshold",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
log.Printf("Skipping VL alerts: VLDatasourceUID not configured")
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
folderUID, err := ensureAlertFolder(config)
|
||||
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
|
||||
|
|
@ -356,34 +385,167 @@ func generateAndSendAlertsVL(clients map[string]ClientData, tmpl AlertRulesTempl
|
|||
})
|
||||
}
|
||||
|
||||
// Error rate алерты (4xx и 5xx)
|
||||
for _, errCode := range []int{4, 5} {
|
||||
limitPct := client.Limit4xx
|
||||
if errCode == 5 {
|
||||
limitPct = client.Limit5xx
|
||||
// WAF block алерт — по одному на каждый SID (пропускаем если ptaf_fallback_code = "pass" или пустой)
|
||||
if client.PtafFallbackCode != "" && strings.ToLower(client.PtafFallbackCode) != "pass" {
|
||||
for _, domain := range client.Domains {
|
||||
rule := buildWAFBlockAlertRuleVL(client, domain, tmpl, config)
|
||||
rule["folderUID"] = folderUID
|
||||
tasks = append(tasks, alertTask{
|
||||
rule: rule,
|
||||
label: fmt.Sprintf("VL %s WAF %s / %s SID:%s", client.PtafFallbackCode, client.ClientTitle, domain.DomainName, domain.SID),
|
||||
})
|
||||
}
|
||||
if limitPct <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
rule := buildErrorRateAlertRuleVL(client, errCode, limitPct, tmpl, config)
|
||||
rule["folderUID"] = folderUID
|
||||
tasks = append(tasks, alertTask{
|
||||
rule: rule,
|
||||
label: fmt.Sprintf("VL %dxx %s", errCode, client.ClientTitle),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sent, failed := parallelUpsert(tasks, config, dryRun, 5)
|
||||
// Обновляем шаблон 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)
|
||||
|
||||
// Статические алерты (CPU, RAM, диск, контейнеры, Angie)
|
||||
// Отправляем только если OS алерты отключены, иначе они уже отправлены там
|
||||
if !config.AlertsOSEnabled {
|
||||
if err := sendStaticAlerts(tmpl, config, dryRun); err != nil {
|
||||
log.Printf("Warning: VL static alerts error: %v", err)
|
||||
// Статические алерты отправляются отдельно из main.go чтобы избежать
|
||||
// дублирования при вызове функции для разных провайдеров (spik/sp)
|
||||
|
||||
// Собираем активные UID для возврата
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
config.go
24
config.go
|
|
@ -71,6 +71,11 @@ const (
|
|||
DefaultVLGrafanaFolder = "" // Пустая = та же папка что и основная
|
||||
DefaultVLDatasourceType = "victoriametrics-logs-datasource-ptaf-sw" // Тип VL datasource плагина
|
||||
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 - название единого дашборда со всеми клиентами
|
||||
// Пример результата: Home -> Dashboards -> WAF - PTAF -> PT AF Nodes
|
||||
|
|
@ -114,6 +119,11 @@ type Config struct {
|
|||
VLDashboardTitle string // Название второго дашборда (пустая строка = не генерировать)
|
||||
VLGrafanaFolder string // Папка для VL дашборда (пустая = та же что и основная)
|
||||
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 {
|
||||
|
|
@ -121,14 +131,18 @@ type ClientData struct {
|
|||
Domains []DomainInfo
|
||||
RPSLimit int // 0 = алерт не создавать (NULL или не задан в БД)
|
||||
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 {
|
||||
SID string
|
||||
DomainName string
|
||||
Aliases string
|
||||
SID string
|
||||
DomainName 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 {
|
||||
|
|
|
|||
36
database.go
36
database.go
|
|
@ -38,8 +38,11 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
|
|||
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_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,
|
||||
MAX(ci.five_hundred) OVER (PARTITION BY a.client_title) as limit_5xx
|
||||
COALESCE(a.four_hundred, 20) as limit_4xx,
|
||||
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 (
|
||||
SELECT sid, domain_name, aliases FROM sp_info WHERE sid IS NOT NULL
|
||||
UNION
|
||||
|
|
@ -47,6 +50,7 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
|
|||
) s
|
||||
LEFT JOIN apps_settings a ON s.sid = a.l7resourceid
|
||||
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
|
||||
`
|
||||
|
||||
|
|
@ -59,11 +63,10 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
|
|||
clients := make(map[string]ClientData)
|
||||
|
||||
for rows.Next() {
|
||||
var clientTitle, sid, domainName, aliasesJSON string
|
||||
var rpsLimit, rpsCommercialLimit int
|
||||
var limit4xx, limit5xx sql.NullInt64
|
||||
var clientTitle, sid, domainName, aliasesJSON, wafProvider, ptafFallbackCode string
|
||||
var rpsLimit, rpsCommercialLimit, limit4xx, limit5xx, ptafFallbackCodeAlertCount int
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -76,16 +79,19 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
|
|||
Domains: []DomainInfo{},
|
||||
RPSLimit: rpsLimit,
|
||||
RPSCommercialLimit: rpsCommercialLimit,
|
||||
Limit4xx: nullIntToInt(limit4xx),
|
||||
Limit5xx: nullIntToInt(limit5xx),
|
||||
WafProvider: wafProvider,
|
||||
PtafFallbackCode: ptafFallbackCode,
|
||||
}
|
||||
}
|
||||
|
||||
client := clients[clientTitle]
|
||||
client.Domains = append(client.Domains, DomainInfo{
|
||||
SID: sid,
|
||||
DomainName: domainName,
|
||||
Aliases: aliasesStr,
|
||||
SID: sid,
|
||||
DomainName: domainName,
|
||||
Aliases: aliasesStr,
|
||||
Limit4xx: limit4xx,
|
||||
Limit5xx: limit5xx,
|
||||
PtafFallbackCodeAlertCount: ptafFallbackCodeAlertCount,
|
||||
})
|
||||
clients[clientTitle] = client
|
||||
}
|
||||
|
|
@ -93,14 +99,6 @@ func fetchClientsData(db *sql.DB) (map[string]ClientData, error) {
|
|||
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 в строку
|
||||
// Input: ["domain1.com", "domain2.com"] или null
|
||||
// Output: "domain1.com, domain2.com" или ""
|
||||
|
|
|
|||
2
go.sum
Normal file
2
go.sum
Normal 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
130
main.go
|
|
@ -4,8 +4,26 @@ import (
|
|||
"flag"
|
||||
"log"
|
||||
"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() {
|
||||
// Load environment variables from .env file before parsing flags
|
||||
// This allows .env file to provide defaults that can be overridden by flags
|
||||
|
|
@ -95,9 +113,21 @@ func main() {
|
|||
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)
|
||||
alertsChanged := stateManager.AlertsNeedUpdate(validClients) || stateManager.AlertTemplateChanged(alertTemplateHash)
|
||||
alertsChanged := stateManager.AlertsNeedUpdate(spikClients) || stateManager.AlertTemplateChanged(alertTemplateHash)
|
||||
|
||||
// Check for changes
|
||||
hasChanges, changeReason := stateManager.CheckChanges(templateCommit, template.Version, clients)
|
||||
|
|
@ -124,11 +154,11 @@ func main() {
|
|||
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("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.DashboardOSEnabled {
|
||||
|
|
@ -137,19 +167,24 @@ func main() {
|
|||
if alertTmpl != nil {
|
||||
log.Printf("DRY RUN: Generating alert rules preview...")
|
||||
if config.AlertsOSEnabled {
|
||||
_ = generateAndSendAlerts(validClients, *alertTmpl, config, true)
|
||||
_ = generateAndSendAlerts(spikClients, *alertTmpl, config, true)
|
||||
}
|
||||
if config.AlertsVLEnabled {
|
||||
_ = generateAndSendAlertsVL(validClients, *alertTmpl, config, true)
|
||||
_, _ = generateAndSendAlertsVL(spikClients, *alertTmpl, config, true)
|
||||
}
|
||||
}
|
||||
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("DRY RUN: Dashboard generated but not sent to Grafana")
|
||||
if config.VLDatasourceUID != "" && config.DashboardVLEnabled {
|
||||
vlDashboard := generateVLDashboard(validClients, template, config)
|
||||
vlDashboard := generateVLDashboard(spikClients, template, config)
|
||||
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)
|
||||
}
|
||||
|
|
@ -159,7 +194,7 @@ func main() {
|
|||
if err := sendToGrafana(dashboard, config); err != nil {
|
||||
log.Printf("Error sending dashboard: %v", err)
|
||||
log.Printf("=== Summary ===")
|
||||
log.Printf("Clients: %d", len(validClients))
|
||||
log.Printf("Spik clients: %d", len(spikClients))
|
||||
log.Printf("Skipped: %d", skippedCount)
|
||||
log.Printf("Status: FAILED")
|
||||
os.Exit(1)
|
||||
|
|
@ -169,10 +204,10 @@ func main() {
|
|||
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 {
|
||||
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 {
|
||||
log.Printf("Warning: VictoriaLogs dashboard failed: %v", err)
|
||||
} else {
|
||||
|
|
@ -182,29 +217,74 @@ func main() {
|
|||
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
|
||||
if alertTmpl != nil {
|
||||
if alertsChanged || config.ForceRun {
|
||||
osOK := true
|
||||
osErr := false
|
||||
vlErr := false
|
||||
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)
|
||||
osOK = false
|
||||
osErr = true
|
||||
}
|
||||
} else {
|
||||
log.Printf("Alerts OS: disabled (ALERTS_OS_ENABLED=false)")
|
||||
}
|
||||
allActiveUIDs := map[string]bool{}
|
||||
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)
|
||||
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 {
|
||||
log.Printf("Alerts VL: disabled (ALERTS_VL_ENABLED=false)")
|
||||
}
|
||||
// Сохраняем хэш если хотя бы одна из систем алертов не вернула ошибку
|
||||
if osOK {
|
||||
stateManager.UpdateAlertsHash(validClients)
|
||||
// Сохраняем хэш если не было критических ошибок
|
||||
// Частичные ошибки отдельных алертов не блокируют сохранение хэша
|
||||
if !osErr && !vlErr {
|
||||
stateManager.UpdateAlertsHash(spikClients)
|
||||
stateManager.UpdateAlertTemplateHash(alertTemplateHash)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -215,7 +295,7 @@ func main() {
|
|||
// Summary
|
||||
log.Printf("=== Summary ===")
|
||||
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 {
|
||||
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.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
|
||||
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")
|
||||
|
|
|
|||
23
state.go
23
state.go
|
|
@ -193,22 +193,33 @@ func calculateClientsHash(clients map[string]ClientData) string {
|
|||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// calculateAlertsHash считает хэш только от данных нужных для алертов (client_title + rps_limit)
|
||||
// calculateAlertsHash считает хэш от данных нужных для алертов (rps_limit + пороги per-SID)
|
||||
func calculateAlertsHash(clients map[string]ClientData) string {
|
||||
type domainAlert struct {
|
||||
SID string
|
||||
Limit4xx int
|
||||
Limit5xx int
|
||||
}
|
||||
type alertData struct {
|
||||
ClientTitle string
|
||||
RPSLimit int
|
||||
Limit4xx int
|
||||
Limit5xx int
|
||||
Domains []domainAlert
|
||||
}
|
||||
var data []alertData
|
||||
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{
|
||||
ClientTitle: client.ClientTitle,
|
||||
RPSLimit: client.RPSLimit,
|
||||
Limit4xx: client.Limit4xx,
|
||||
Limit5xx: client.Limit5xx,
|
||||
Domains: domains,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue