Files
portal/app/app/Console/Commands/IncidentsWatchFailures.php
T
Дмитрий 5b7bbbda50 fix(billing): письма о заморозке баланса не доходили — воркер не видел клиента под RLS
Прод-инцидент 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>
2026-07-14 08:55:37 +03:00

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));
}
}
}