84620665a5
Добавлен БЛОК 5 в IncidentsWatchFailures::handle() — детекция шторма от одного supplier_lead_id. Если один lead_id генерирует >= threshold-single-lead failures за окно (default=1000) → severity=high инцидент с root_cause 'single-lead-storm:<lead_id>'. Дедуп по dedup-window как в остальных блоках. Новая опция: --threshold-single-lead=1000 (configurable). Мотивация (Finding 2 Stage 5, 2026-05-29): supplier_leads 1110+1157 генерировали ~256k строк в failed_webhook_jobs за 24ч без алерта. Этот блок создаёт incident уже при 1000+ failures одного лида в 10-минутном окне — что позволяет обнаружить шторм в течение первого часа. Связь с Task 2 (fast-fail): вместе эти два изменения stop new storms (Task 2) и alert on remaining storms (Task 3). Tests: 4 passing в SingleLeadStormTest.php - детекция шторма (>= threshold) - НЕ создаёт incident при распределённых failures - default threshold=1000 - dedup (второй запуск = 0 новых инцидентов) Task 3 plan 2026-05-29-supplier-webhook-fast-fail-and-stuck-cleanup.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
267 lines
11 KiB
PHP
267 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Mail\IncidentDetectedMail;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Mail;
|
|
|
|
/**
|
|
* Сканирует failed_webhook_jobs и failed_jobs за скользящее окно.
|
|
*
|
|
* failed_webhook_jobs: одно правило — spike ≥ threshold (200).
|
|
* failed_jobs: три правила:
|
|
* - spike: кол-во за окно одного job-класса ≥ threshold-spike (10) → high
|
|
* - daily-total: за 24ч одного job-класса ≥ threshold-daily (50) → medium
|
|
* - persistent: один exception повторяется > persistent-hours часов → medium
|
|
*
|
|
* Дедуп: если открытый инцидент с той же сигнатурой создан < dedup-window мин —
|
|
* пропускаем. Письмо на kdv1@bk.ru только для severity=high.
|
|
*/
|
|
class IncidentsWatchFailures extends Command
|
|
{
|
|
private const DB_CONNECTION = 'pgsql_supplier';
|
|
|
|
protected $signature = 'incidents:watch-failures
|
|
{--window=10 : Окно сканирования в минутах}
|
|
{--threshold=200 : Порог спайка для failed_webhook_jobs}
|
|
{--threshold-spike=10 : Порог спайка для failed_jobs (за окно)}
|
|
{--threshold-daily=50 : Порог суммы за 24ч для failed_jobs}
|
|
{--persistent-hours=3 : Порог возраста persistent-exception для failed_jobs}
|
|
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}
|
|
{--threshold-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}';
|
|
|
|
protected $description = 'Сканирует failed_webhook_jobs и failed_jobs, создаёт incidents_log на превышение порогов';
|
|
|
|
public function handle(): int
|
|
{
|
|
$windowMinutes = (int) $this->option('window');
|
|
$threshold = (int) $this->option('threshold');
|
|
$thresholdSpike = (int) $this->option('threshold-spike');
|
|
$thresholdDaily = (int) $this->option('threshold-daily');
|
|
$persistentHours = (int) $this->option('persistent-hours');
|
|
$dedupMinutes = (int) $this->option('dedup-window');
|
|
|
|
$thresholdSingleLead = (int) $this->option('threshold-single-lead');
|
|
|
|
$since = Carbon::now()->subMinutes($windowMinutes);
|
|
$since24h = Carbon::now()->subHours(24);
|
|
$dedupAt = Carbon::now()->subMinutes($dedupMinutes);
|
|
$now = Carbon::now();
|
|
|
|
// --- Проверяем наличие SaaS-администратора (FK NOT NULL) ---
|
|
$adminId = DB::connection(self::DB_CONNECTION)
|
|
->table('saas_admin_users')
|
|
->where('is_active', true)
|
|
->whereNull('deleted_at')
|
|
->value('id');
|
|
|
|
if ($adminId === null) {
|
|
$this->warn('No active saas_admin_users found — skipping incident creation (warn-only).');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$created = 0;
|
|
|
|
// ===== БЛОК 1: failed_webhook_jobs (исходная логика) =====
|
|
$webhookGroups = DB::connection(self::DB_CONNECTION)
|
|
->table('failed_webhook_jobs')
|
|
->selectRaw('LEFT(exception, 180) AS sig, COUNT(*) AS cnt')
|
|
->whereNull('resolved_at')
|
|
->where('failed_at', '>=', $since)
|
|
->groupByRaw('LEFT(exception, 180)')
|
|
->havingRaw('COUNT(*) >= ?', [$threshold])
|
|
->get();
|
|
|
|
foreach ($webhookGroups as $group) {
|
|
$sig = $group->sig;
|
|
$count = (int) $group->cnt;
|
|
$dedupKey = substr($sig, 0, 80);
|
|
|
|
if ($this->isDup($dedupKey, $dedupAt)) {
|
|
$this->line("Skipping webhook (dedup): {$dedupKey}");
|
|
|
|
continue;
|
|
}
|
|
|
|
$summary = "Автоматически: {$count} упавших webhook-джобов за {$windowMinutes} мин. Сигнатура: {$sig}";
|
|
|
|
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey);
|
|
$created++;
|
|
$this->info("Webhook incident [high]: {$count} failures");
|
|
}
|
|
|
|
// ===== БЛОК 2: failed_jobs — spike =====
|
|
$spikes = DB::connection(self::DB_CONNECTION)
|
|
->table('failed_jobs')
|
|
->selectRaw(
|
|
"payload::json->>'displayName' AS job_class, ".
|
|
'LEFT(exception, 80) AS exc_sig, '.
|
|
'COUNT(*) AS cnt'
|
|
)
|
|
->where('failed_at', '>=', $since)
|
|
->groupByRaw("payload::json->>'displayName', LEFT(exception, 80)")
|
|
->havingRaw('COUNT(*) >= ?', [$thresholdSpike])
|
|
->get();
|
|
|
|
foreach ($spikes as $row) {
|
|
$jobClass = (string) $row->job_class;
|
|
$excSig = (string) $row->exc_sig;
|
|
$cnt = (int) $row->cnt;
|
|
$dedupKey = "spike:{$jobClass}:{$excSig}";
|
|
|
|
if ($this->isDup($dedupKey, $dedupAt)) {
|
|
$this->line("Skipping spike (dedup): {$dedupKey}");
|
|
|
|
continue;
|
|
}
|
|
|
|
$summary = "Автоматически: spike {$cnt} failures job={$jobClass} за {$windowMinutes} мин. Exc: {$excSig}";
|
|
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey);
|
|
$created++;
|
|
$this->info("Job spike [high]: {$jobClass} — {$cnt}");
|
|
}
|
|
|
|
// ===== БЛОК 3: failed_jobs — daily-total =====
|
|
$daily = DB::connection(self::DB_CONNECTION)
|
|
->table('failed_jobs')
|
|
->selectRaw(
|
|
"payload::json->>'displayName' AS job_class, ".
|
|
'COUNT(*) AS cnt'
|
|
)
|
|
->where('failed_at', '>=', $since24h)
|
|
->groupByRaw("payload::json->>'displayName'")
|
|
->havingRaw('COUNT(*) >= ?', [$thresholdDaily])
|
|
->get();
|
|
|
|
foreach ($daily as $row) {
|
|
$jobClass = (string) $row->job_class;
|
|
$cnt = (int) $row->cnt;
|
|
$dedupKey = "daily:{$jobClass}";
|
|
|
|
if ($this->isDup($dedupKey, $dedupAt)) {
|
|
$this->line("Skipping daily (dedup): {$dedupKey}");
|
|
|
|
continue;
|
|
}
|
|
|
|
$summary = "Автоматически: daily-total {$cnt} failures job={$jobClass} за 24ч";
|
|
$this->createIncident($adminId, 'other', 'medium', $summary, $since24h, $now, $dedupKey);
|
|
$created++;
|
|
$this->info("Job daily [medium]: {$jobClass} — {$cnt}");
|
|
}
|
|
|
|
// ===== БЛОК 4: failed_jobs — persistent =====
|
|
$persistentSince = Carbon::now()->subHours($persistentHours);
|
|
|
|
$persistent = DB::connection(self::DB_CONNECTION)
|
|
->table('failed_jobs')
|
|
->selectRaw(
|
|
"payload::json->>'displayName' AS job_class, ".
|
|
'LEFT(exception, 80) AS exc_sig, '.
|
|
'MIN(failed_at) AS oldest_at, '.
|
|
'COUNT(*) AS cnt'
|
|
)
|
|
->where('failed_at', '<=', $persistentSince)
|
|
->groupByRaw("payload::json->>'displayName', LEFT(exception, 80)")
|
|
->get();
|
|
|
|
foreach ($persistent as $row) {
|
|
$jobClass = (string) $row->job_class;
|
|
$excSig = (string) $row->exc_sig;
|
|
$dedupKey = "persistent:{$jobClass}:{$excSig}";
|
|
|
|
if ($this->isDup($dedupKey, $dedupAt)) {
|
|
$this->line("Skipping persistent (dedup): {$dedupKey}");
|
|
|
|
continue;
|
|
}
|
|
|
|
$summary = "Автоматически: persistent exception job={$jobClass} повторяется >{$persistentHours}ч. Exc: {$excSig}";
|
|
$this->createIncident($adminId, 'other', 'medium', $summary, Carbon::parse($row->oldest_at), $now, $dedupKey);
|
|
$created++;
|
|
$this->info("Job persistent [medium]: {$jobClass}");
|
|
}
|
|
|
|
// ===== БЛОК 5: single-lead storm detection =====
|
|
// Detects случай когда один supplier_lead_id генерирует >= threshold
|
|
// failures за окно — классический шторм от застрявшего лида (Finding 2,
|
|
// 2026-05-29). Создаём severity=high инцидент per lead_id.
|
|
if ($thresholdSingleLead > 0) {
|
|
$stormLeads = DB::connection(self::DB_CONNECTION)
|
|
->table('failed_webhook_jobs')
|
|
->selectRaw("raw_payload->>'supplier_lead_id' AS lead_id, COUNT(*) AS cnt")
|
|
->whereNull('resolved_at')
|
|
->where('failed_at', '>=', $since)
|
|
->whereRaw("raw_payload ?? 'supplier_lead_id'")
|
|
->groupByRaw("raw_payload->>'supplier_lead_id'")
|
|
->havingRaw('COUNT(*) >= ?', [$thresholdSingleLead])
|
|
->get();
|
|
|
|
foreach ($stormLeads as $row) {
|
|
$leadId = $row->lead_id;
|
|
$cnt = (int) $row->cnt;
|
|
$dedupKey = "single-lead-storm:{$leadId}";
|
|
|
|
if ($this->isDup($dedupKey, $dedupAt)) {
|
|
$this->line("Skipping single-lead-storm (dedup): {$dedupKey}");
|
|
|
|
continue;
|
|
}
|
|
|
|
$summary = "Автоматически: single-lead-storm {$cnt} failures supplier_lead_id={$leadId} за {$windowMinutes} мин. Вероятная причина: terminal error без fast-fail guard.";
|
|
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey);
|
|
$created++;
|
|
$this->info("Single-lead storm [high]: lead_id={$leadId} — {$cnt}");
|
|
}
|
|
}
|
|
|
|
$this->info("Done. Created {$created} incident(s).");
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
private function isDup(string $dedupKey, Carbon $dedupAt): bool
|
|
{
|
|
// Сигнатура сохраняется в root_cause для надёжного дедупа
|
|
return DB::connection(self::DB_CONNECTION)
|
|
->table('incidents_log')
|
|
->where('root_cause', $dedupKey)
|
|
->whereNull('resolved_at')
|
|
->where('detected_at', '>=', $dedupAt)
|
|
->exists();
|
|
}
|
|
|
|
private function createIncident(
|
|
int $adminId,
|
|
string $type,
|
|
string $severity,
|
|
string $summary,
|
|
Carbon $startedAt,
|
|
Carbon $now,
|
|
string $dedupKey = '',
|
|
): void {
|
|
DB::connection(self::DB_CONNECTION)->table('incidents_log')->insert([
|
|
'type' => $type,
|
|
'severity' => $severity,
|
|
'summary' => $summary,
|
|
'root_cause' => $dedupKey !== '' ? $dedupKey : null,
|
|
'started_at' => $startedAt,
|
|
'detected_at' => $now,
|
|
'resolved_at' => null,
|
|
'created_by_admin_id' => $adminId,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
]);
|
|
|
|
if ($severity === 'high') {
|
|
Mail::to('kdv1@bk.ru')->send(new IncidentDetectedMail($summary, $severity));
|
|
}
|
|
}
|
|
}
|