5b7bbbda50
Прод-инцидент 14.07.2026. Пять писем (заморозка, напоминание, финальное, разморозка, «проект остановлен — нет денег») уезжали в очередь с Eloquent-моделью Tenant. SerializesModels заменяет модель на id, а воркер грузит её заново — под ролью crm_app_user, где RLS-policy tenants_self_isolation без app.current_tenant_id отдаёт 0 строк → ModelNotFoundException. Клиент №7 заморожен с 12.07 и не получил ни одного письма; на проде это ломало письма о заморозке для ВСЕХ клиентов. Письма больше не ходят в БД при отправке: несут снимок данных (без SerializesModels). Заодно: сторож incidents:watch-failures плодил копию persistent-инцидента каждый час (строка в failed_jobs живёт вечно, а дедуп был окном в 60 мин) — 2 залипшие ошибки дали 31 запись за сутки и красную лампу «Очереди/джобы». Дедуп persistent теперь по факту незакрытого инцидента, а не по возрасту последней копии. Регрессия: BalanceMailsQueueRestoreTest (6 кейсов) + 2 теста сторожа. Прогон: 165/165 billing+incidents, phpstan 0, pint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
319 lines
15 KiB
PHP
319 lines
15 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 за окно}
|
|
{--threshold-manual-queue=10 : Порог spike ручной очереди поставщика (за окно) → возможное падение кабинета}';
|
|
|
|
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');
|
|
$thresholdManualQueue = (int) $this->option('threshold-manual-queue');
|
|
|
|
$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}";
|
|
|
|
// Дедуп persistent — БЕЗ окна по времени (прод-инцидент 14.07.2026).
|
|
// Строка в failed_jobs живёт вечно, поэтому правило срабатывает на каждом
|
|
// прогоне (раз в 10 мин), а окно дедупа в 60 мин истекает → раньше сторож
|
|
// плодил копию того же инцидента КАЖДЫЙ ЧАС (2 залипшие строки → 31 запись
|
|
// за сутки, лампа «Очереди/джобы» красная с растущей цифрой). Пока прошлый
|
|
// инцидент не разобран — новый не нужен; после resolved_at сработает снова.
|
|
if ($this->hasOpenIncident($dedupKey)) {
|
|
$this->line("Skipping persistent (open incident exists): {$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}");
|
|
}
|
|
}
|
|
|
|
// ===== БЛОК 6: supplier-manual-queue spike =====
|
|
// Пачка проектов, ушедших в ручную очередь за короткое окно = вероятное падение
|
|
// кабинета поставщика. Инцидент дедупим (60 мин, чтобы не засорять журнал), НО письмо
|
|
// шлём на КАЖДОМ прогоне пока спайк держится — «сигналит как ненормальный» при реальной
|
|
// аварии, одним сводным письмом на оба адреса (не N писем на проект).
|
|
if ($thresholdManualQueue > 0) {
|
|
$manualQueueCount = (int) DB::connection(self::DB_CONNECTION)
|
|
->table('supplier_manual_sync_queue')
|
|
->where('created_at', '>=', $since)
|
|
->count();
|
|
|
|
if ($manualQueueCount >= $thresholdManualQueue) {
|
|
$dedupKey = 'supplier-manual-queue-spike';
|
|
$summary = "Похоже, кабинет поставщика упал: {$manualQueueCount} проект(ов) ушло в ручную очередь за {$windowMinutes} мин.";
|
|
|
|
// Инцидент — только если открытого с той же сигнатурой нет (дедуп 60 мин).
|
|
if (! $this->isDup($dedupKey, $dedupAt)) {
|
|
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey, sendMail: false);
|
|
$created++;
|
|
}
|
|
|
|
// Письмо — каждый прогон при активном спайке, на оба адреса.
|
|
Mail::to(['kdv1@bk.ru', 'ops@liderra.ru'])
|
|
->send(new IncidentDetectedMail($summary, 'high'));
|
|
|
|
$this->info("Supplier manual-queue spike [high]: {$manualQueueCount} in {$windowMinutes}m");
|
|
}
|
|
}
|
|
|
|
$this->info("Done. Created {$created} incident(s).");
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* Есть ли НЕразобранный инцидент с этой сигнатурой — без ограничения по времени.
|
|
* Для persistent-правила: источник (строка в failed_jobs) не исчезает сам, поэтому
|
|
* ориентир — состояние инцидента, а не возраст его последней копии.
|
|
*/
|
|
private function hasOpenIncident(string $dedupKey): bool
|
|
{
|
|
return DB::connection(self::DB_CONNECTION)
|
|
->table('incidents_log')
|
|
->where('root_cause', $dedupKey)
|
|
->whereNull('resolved_at')
|
|
->exists();
|
|
}
|
|
|
|
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 = '',
|
|
bool $sendMail = true,
|
|
): 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 ($sendMail && $severity === 'high') {
|
|
Mail::to('kdv1@bk.ru')->send(new IncidentDetectedMail($summary, $severity));
|
|
}
|
|
}
|
|
}
|