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
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
View file

@ -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",
},
},
},
}
}

View file

@ -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",
},
},
},
}
}

View file

@ -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 {

View file

@ -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
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"
"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")

View file

@ -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,
})
}
}