From 67dd80951c05e5653a9367f508b0f15ca7dde87c Mon Sep 17 00:00:00 2001 From: Magnus Root Date: Wed, 15 Apr 2026 17:27:47 +0300 Subject: [PATCH] Added VL alerts --- alerts_vl.go | 380 +++++++++++++++++++++++++++++++++++++++++++++++++++ config.go | 6 +- main.go | 64 ++++++--- panels_vl.go | 63 +++++++-- 4 files changed, 483 insertions(+), 30 deletions(-) create mode 100644 alerts_vl.go diff --git a/alerts_vl.go b/alerts_vl.go new file mode 100644 index 0000000..4eae0af --- /dev/null +++ b/alerts_vl.go @@ -0,0 +1,380 @@ +package main + +import ( + "fmt" + "log" + "sort" + "strings" +) + +// buildRPSAlertRuleVL строит правило алерта на RPS для VictoriaLogs datasource. +func buildRPSAlertRuleVL(client ClientData, tmpl AlertRulesTemplate, config Config) map[string]interface{} { + datasourceUID := config.VLDatasourceUID + datasourceType := DefaultVLDatasourceType + + // SID-запрос в формате LogsQL + sidMap := make(map[string]bool) + for _, d := range client.Domains { + sidMap[d.SID] = true + } + uniqueSIDs := make([]string, 0, len(sidMap)) + for s := range sidMap { + uniqueSIDs = append(uniqueSIDs, s) + } + sort.Strings(uniqueSIDs) + sidQuery := buildTenantSIDQueryVL(client.Domains) + + defaults := tmpl.Defaults + receiver := orDefault(config.AlertsReceiver, defaults.Receiver) + group := orDefault(config.AlertsGroup, defaults.Group) + + timeRangeFrom := int64(1800) + if v, ok := tmpl.RPSAlert["relative_time_range_from"].(float64); ok { + timeRangeFrom = int64(v) + } + + summaryTemplate := "Превышен порог в {rps_limit} RPS в {client_title}" + if v, ok := tmpl.RPSAlert["annotation_summary"].(string); ok { + summaryTemplate = v + } + summary := strings.NewReplacer( + "{rps_limit}", fmt.Sprintf("%d", client.RPSLimit), + "{client_title}", client.ClientTitle, + ).Replace(summaryTemplate) + + _ = uniqueSIDs // используется через sidQuery + + uid := generateAlertUID(client.ClientTitle+"_vl", "rps") + dataRefID := "A" + rpsRefID := "RPS" + reduceRefID := "_Reduce" + thresholdRefID := "Превышение" + + // LogsQL запрос: count за минуту + expr := fmt.Sprintf("%s log_type:nginx-access-PTAF | stats by (_time:1m) count() rps", sidQuery) + + return map[string]interface{}{ + "uid": uid, + "title": fmt.Sprintf("VL RPS %s", client.ClientTitle), + "condition": thresholdRefID, + "noDataState": defaults.NoDataState, + "execErrState": defaults.ExecErrState, + "isPaused": false, + "folderUID": "", + "ruleGroup": group, + "annotations": map[string]string{"summary": summary}, + "notification_settings": map[string]string{ + "receiver": receiver, + }, + "data": []interface{}{ + // Шаг 1: VL statsRange запрос + map[string]interface{}{ + "refId": dataRefID, + "queryType": "statsRange", + "relativeTimeRange": map[string]interface{}{ + "from": timeRangeFrom, + "to": 0, + }, + "datasourceUid": datasourceUID, + "model": map[string]interface{}{ + "datasource": map[string]interface{}{ + "type": datasourceType, + "uid": datasourceUID, + }, + "editorMode": "code", + "expr": expr, + "legendFormat": fmt.Sprintf("RPS %s", client.ClientTitle), + "maxLines": 1000, + "queryType": "statsRange", + "refId": dataRefID, + "intervalMs": 60000, + "maxDataPoints": 43200, + }, + }, + // Шаг 2: math $A / 60 → RPS (интервал 1m = 60 секунд) + map[string]interface{}{ + "refId": rpsRefID, + "relativeTimeRange": map[string]interface{}{ + "from": timeRangeFrom, + "to": 0, + }, + "datasourceUid": "__expr__", + "model": map[string]interface{}{ + "datasource": map[string]interface{}{ + "name": "Expression", + "type": "__expr__", + "uid": "__expr__", + }, + "expression": fmt.Sprintf("$%s / 60", dataRefID), + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": rpsRefID, + "type": "math", + "window": "", + }, + }, + // Шаг 3: reduce median + map[string]interface{}{ + "refId": reduceRefID, + "relativeTimeRange": map[string]interface{}{ + "from": 0, + "to": 0, + }, + "datasourceUid": "__expr__", + "model": map[string]interface{}{ + "conditions": []interface{}{ + map[string]interface{}{ + "evaluator": map[string]interface{}{"params": []int{0, 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": rpsRefID, + "intervalMs": 1000, + "maxDataPoints": 43200, + "reducer": "median", + "refId": reduceRefID, + "settings": map[string]interface{}{"mode": ""}, + "type": "reduce", + }, + }, + // Шаг 4: threshold > rps_limit + 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{}{client.RPSLimit, 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", + }, + }, + }, + } +} + +// buildErrorRateAlertRuleVL строит правило алерта на процент ошибок для VictoriaLogs. +func buildErrorRateAlertRuleVL(client ClientData, errCode, limitPct int, 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) + + codeFrom, codeTo := 400, 499 + codeName := "4xx" + if errCode == 5 { + codeFrom, codeTo = 500, 599 + 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) + + 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", + } + } + + return map[string]interface{}{ + "uid": uid, + "title": fmt.Sprintf("VL %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{}{ + 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", + }, + }, + }, + } +} + +// generateAndSendAlertsVL генерирует и отправляет алерты для VictoriaLogs. +func generateAndSendAlertsVL(clients map[string]ClientData, tmpl AlertRulesTemplate, config Config, dryRun bool) error { + if config.VLDatasourceUID == "" { + log.Printf("Skipping VL alerts: VLDatasourceUID not configured") + return nil + } + + folderUID, err := ensureAlertFolder(config) + if err != nil { + return fmt.Errorf("failed to ensure alert folder: %w", err) + } + + var tasks []alertTask + + for _, client := range clients { + if len(client.Domains) == 0 { + continue + } + + // RPS алерт + if client.RPSLimit > 0 { + rule := buildRPSAlertRuleVL(client, tmpl, config) + rule["folderUID"] = folderUID + tasks = append(tasks, alertTask{ + rule: rule, + label: fmt.Sprintf("VL RPS %s", client.ClientTitle), + }) + } + + // Error rate алерты (4xx и 5xx) + for _, errCode := range []int{4, 5} { + limitPct := client.Limit4xx + if errCode == 5 { + limitPct = client.Limit5xx + } + if limitPct <= 0 { + continue + } + 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) + log.Printf("VL Alerts: sent=%d failed=%d", sent, failed) + return nil +} diff --git a/config.go b/config.go index 57e6b1f..80cf2d2 100644 --- a/config.go +++ b/config.go @@ -61,7 +61,7 @@ const ( // Alerts settings DefaultAlertsFolder = "WAF - PTAF" - DefaultAlertsReceiver = "Telegram PTAF Grafana" + DefaultAlertsReceiver = "tg+mail+mattermost PTAF Grafana" DefaultAlertsGroup = "PTAF Grafana" DefaultAlertsDatasource = "af84zsvlp9blsa" // UID datasource OpenSearch @@ -104,6 +104,10 @@ type Config struct { AlertsReceiver string // Receiver (канал уведомлений) для алертов AlertsGroup string // Имя группы алертов в Grafana AlertsDatasourceUID string // UID datasource для алертов OpenSearch + AlertsOSEnabled bool // Генерировать алерты для OpenSearch (default: true) + AlertsVLEnabled bool // Генерировать алерты для VictoriaLogs (default: true) + DashboardOSEnabled bool // Генерировать дашборд для OpenSearch (default: true) + DashboardVLEnabled bool // Генерировать дашборд для VictoriaLogs (default: true) // VictoriaLogs второй дашборд VLDatasourceUID string // UID VictoriaLogs datasource diff --git a/main.go b/main.go index 218df2b..8538998 100644 --- a/main.go +++ b/main.go @@ -131,36 +131,46 @@ func main() { dashboard := generateSingleDashboard(validClients, template, config) if config.DryRun { - logDryRun(config.DashboardTitle, dashboard) + if config.DashboardOSEnabled { + logDryRun(config.DashboardTitle, dashboard) + } if alertTmpl != nil { log.Printf("DRY RUN: Generating alert rules preview...") - _ = generateAndSendAlerts(validClients, *alertTmpl, config, true) + if config.AlertsOSEnabled { + _ = generateAndSendAlerts(validClients, *alertTmpl, config, true) + } + if config.AlertsVLEnabled { + _ = generateAndSendAlertsVL(validClients, *alertTmpl, config, true) + } } log.Printf("=== Summary ===") log.Printf("Clients: %d", len(validClients)) log.Printf("Skipped: %d", skippedCount) log.Printf("DRY RUN: Dashboard generated but not sent to Grafana") - if config.VLDatasourceUID != "" { + if config.VLDatasourceUID != "" && config.DashboardVLEnabled { vlDashboard := generateVLDashboard(validClients, template, config) logDryRun(config.VLDashboardTitle, vlDashboard) } os.Exit(0) } - // Send to Grafana - 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("Skipped: %d", skippedCount) - log.Printf("Status: FAILED") - os.Exit(1) + // Send OS dashboard to Grafana + if config.DashboardOSEnabled { + 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("Skipped: %d", skippedCount) + log.Printf("Status: FAILED") + os.Exit(1) + } + log.Printf("Successfully created/updated dashboard: %s", config.DashboardTitle) + } else { + log.Printf("Dashboard OS: disabled (DASHBOARD_OS_ENABLED=false)") } - log.Printf("Successfully created/updated dashboard: %s", config.DashboardTitle) - // Generate VictoriaLogs dashboard if configured - if config.VLDatasourceUID != "" { + if config.VLDatasourceUID != "" && config.DashboardVLEnabled { log.Printf("Generating VictoriaLogs dashboard: %s", config.VLDashboardTitle) vlDashboard := generateVLDashboard(validClients, template, config) if err := sendVLDashboardToGrafana(vlDashboard, config); err != nil { @@ -168,14 +178,32 @@ func main() { } else { log.Printf("Successfully created/updated VL dashboard: %s", config.VLDashboardTitle) } + } else if config.VLDatasourceUID != "" { + log.Printf("Dashboard VL: disabled (DASHBOARD_VL_ENABLED=false)") } // Generate and send alert rules if alertTmpl != nil { if alertsChanged || config.ForceRun { - if err := generateAndSendAlerts(validClients, *alertTmpl, config, false); err != nil { - log.Printf("Warning: alerts generation failed: %v", err) + osOK := true + if config.AlertsOSEnabled { + if err := generateAndSendAlerts(validClients, *alertTmpl, config, false); err != nil { + log.Printf("Warning: alerts generation failed: %v", err) + osOK = false + } } else { + log.Printf("Alerts OS: disabled (ALERTS_OS_ENABLED=false)") + } + if config.AlertsVLEnabled { + if err := generateAndSendAlertsVL(validClients, *alertTmpl, config, false); err != nil { + log.Printf("Warning: VL alerts generation failed: %v", err) + osOK = false + } + } else { + log.Printf("Alerts VL: disabled (ALERTS_VL_ENABLED=false)") + } + // Сохраняем хэш если хотя бы одна из систем алертов не вернула ошибку + if osOK { stateManager.UpdateAlertsHash(validClients) stateManager.UpdateAlertTemplateHash(alertTemplateHash) } @@ -267,6 +295,10 @@ func parseFlags() Config { flag.BoolVar(&config.DryRun, "dry-run", false, "Generate JSON but don't send to Grafana") flag.StringVar(&config.StateFile, "state-file", DefaultStateFile, "Path to state file") flag.BoolVar(&config.ForceRun, "force", false, "Force regeneration even if no changes detected") + config.AlertsOSEnabled = getEnvOrDefault("ALERTS_OS_ENABLED", "true") != "false" + config.AlertsVLEnabled = getEnvOrDefault("ALERTS_VL_ENABLED", "true") != "false" + config.DashboardOSEnabled = getEnvOrDefault("DASHBOARD_OS_ENABLED", "true") != "false" + config.DashboardVLEnabled = getEnvOrDefault("DASHBOARD_VL_ENABLED", "true") != "false" flag.Parse() diff --git a/panels_vl.go b/panels_vl.go index b0bb79c..c05d15e 100644 --- a/panels_vl.go +++ b/panels_vl.go @@ -194,7 +194,7 @@ func buildVLRPSTargets(cfg map[string]interface{}, sidQuery string, _ string, da if alias == "" { alias = client.ClientTitle } - expr := fmt.Sprintf("%s log_type:access | stats by (_time:$__interval) count() rps", vlSID) + expr := fmt.Sprintf("%s log_type:nginx-access-PTAF | stats by (_time:$__interval) count() rps", vlSID) return []interface{}{ makeVLHiddenStatsTarget("A", expr, datasourceUID), @@ -208,7 +208,7 @@ func buildVLRPSVarTargets(cfg map[string]interface{}, _ string, _ string, dataso if alias == "" { alias = "rps" } - expr := "SID:${rps_sid} log_type:access | stats by (_time:$__interval) count() rps" + expr := "SID:${rps_sid} log_type:nginx-access-PTAF | stats by (_time:$__interval) count() rps" return []interface{}{ makeVLHiddenStatsTarget("A", expr, datasourceUID), @@ -221,14 +221,14 @@ func buildVLRPSMultiTargets(cfg map[string]interface{}, _ string, _ string, data // Суммарная линия allSID := buildTenantSIDQueryVL(client.Domains) - totalExpr := fmt.Sprintf("%s log_type:access | stats by (_time:$__interval) count() rps", allSID) + totalExpr := fmt.Sprintf("%s log_type:nginx-access-PTAF | stats by (_time:$__interval) count() rps", allSID) targets = append(targets, makeVLHiddenStatsTarget("A", totalExpr, datasourceUID)) targets = append(targets, makeMathTargetWithAlias("$A / $__interval_ms * 1000", "Total", "Total")) // Линия на каждый домен for i, domain := range client.Domains { rawRefID := fmt.Sprintf("D%d", i) - domainExpr := fmt.Sprintf("SID:%s log_type:access | stats by (_time:$__interval) count() rps", domain.SID) + domainExpr := fmt.Sprintf("SID:%s log_type:nginx-access-PTAF | stats by (_time:$__interval) count() rps", domain.SID) legend := domain.SID + "_" + domain.DomainName if domain.DomainName == "" { legend = domain.SID @@ -258,16 +258,28 @@ func buildVLMultiTargets(cfg map[string]interface{}, sidQuery string, _ string, } refID := getString(tMap, "ref_id") + "_" + uidSuffix alias := getString(tMap, "alias") - suffix := getString(tMap, "query_suffix") - - // Конвертируем suffix из Lucene в LogsQL и добавляем к sidQuery - vlSuffix := luceneToLogsQL(strings.TrimPrefix(suffix, " ")) + // Если есть vl_base — используем его как базовый запрос вместо sidQuery + vlBase := getString(tMap, "vl_base") + vlSuffixRaw := getString(tMap, "vl_suffix") var expr string - if vlSuffix != "" { - expr = fmt.Sprintf("%s %s | stats by (_time:$__interval) count() hits", sidQuery, vlSuffix) + if vlBase != "" { + // Панель с отдельным base query на каждый target (например nginx vs angie) + if vlSuffixRaw != "" { + expr = fmt.Sprintf("%s %s | stats by (_time:$__interval) count() hits", vlBase, vlSuffixRaw) + } else { + expr = fmt.Sprintf("%s | stats by (_time:$__interval) count() hits", vlBase) + } } else { - expr = fmt.Sprintf("%s | stats by (_time:$__interval) count() hits", sidQuery) + suffix := getString(tMap, "query_suffix") + vlSuffix := luceneToLogsQL(strings.TrimPrefix(suffix, " ")) + // Добавляем log_type:nginx-access-PTAF чтобы не смешивать nginx и angie логи + baseWithType := sidQuery + " log_type:nginx-access-PTAF" + if vlSuffix != "" { + expr = fmt.Sprintf("%s %s | stats by (_time:$__interval) count() hits", baseWithType, vlSuffix) + } else { + expr = fmt.Sprintf("%s | stats by (_time:$__interval) count() hits", baseWithType) + } } legend := alias @@ -406,7 +418,8 @@ func buildVLBucketLogsTargets(cfg map[string]interface{}, _ string, _ string, da // Иначе конвертируем base_query из Lucene var baseQuery string if vlRaw := getString(cfg, "vl_base_query"); vlRaw != "" { - baseQuery = vlRaw + // Убираем фильтры вида field:* — они ломают группировку в VL + baseQuery = removeWildcardFilters(vlRaw) } else { raw := getString(cfg, "base_query") raw = strings.ReplaceAll(raw, "SID:(${SID:lucene})", "SID:in(${SID:csv})") @@ -452,9 +465,18 @@ func buildVLBucketLogsTargets(cfg map[string]interface{}, _ string, _ string, da // legendFormat с именем поля — плагин подставит значение поля группировки legendFormat := fmt.Sprintf("{{%s}}", termsField) - return []interface{}{ + targets := []interface{}{ makeVLStatsTarget("A", expr, datasourceUID, legendFormat), } + + // Если задан unknown_label — добавляем отдельный target для записей с пустым значением поля + unknownLabel := getString(cfg, "unknown_label") + if unknownLabel != "" { + unknownQuery := fmt.Sprintf("%s %s:=\"\" | stats count() hits", baseQuery, termsField) + targets = append(targets, makeVLStatsTarget("B", unknownQuery, datasourceUID, unknownLabel)) + } + + return targets } // unescapeLucene убирает Lucene-экранирование символов вида \X → X @@ -473,3 +495,18 @@ func unescapeLucene(q string) string { } return result.String() } + +// removeWildcardFilters убирает из LogsQL запроса фильтры вида field:* и field:"*" +// которые ломают группировку — VL возвращает один фрейм вместо разбивки по полю. +func removeWildcardFilters(q string) string { + words := strings.Fields(q) + result := []string{} + for _, w := range words { + // Пропускаем field:* и field:"*" + if strings.HasSuffix(w, ":*") || strings.HasSuffix(w, `:"*"`) { + continue + } + result = append(result, w) + } + return strings.Join(result, " ") +}