Compare commits

...

2 Commits

Author SHA1 Message Date
Дмитрий 9799c0eb0d feat(supplier): аварийный spike-детектор ручной очереди в incidents:watch-failures
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Блок 6: если за окно (10 мин) в supplier_manual_sync_queue упала пачка
(>= threshold-manual-queue, дефолт 10) — вероятное падение кабинета поставщика.
Инцидент дедупится 60 мин, письмо шлётся каждый прогон на kdv1@bk.ru + ops@liderra.ru
(громко при реальной аварии, но одним сводным письмом). Флаг sendMail в createIncident.
phpstan-baseline: +4 TestCall::artisan() (5→9) от новых тестов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 74d4d9f21d)
2026-07-07 19:19:26 +03:00
Дмитрий 316bc2d852 fix(supplier): FailoverProjectChannel не шлёт per-project CRITICAL письма
Убраны письма manual_required и failover_to_form (по проекту, без троттла) —
источник потопа ~116 писем на ops@ 07.07. Строка в ручной очереди остаётся
(запись видна в админке). Зависимость Mailer удалена из канала и DI-привязки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 72818f5a80)
2026-07-07 19:19:09 +03:00
7 changed files with 106 additions and 37 deletions
@@ -33,7 +33,8 @@ class IncidentsWatchFailures extends Command
{--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-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}
{--threshold-manual-queue=10 : Порог spike ручной очереди поставщика (за окно) возможное падение кабинета}';
protected $description = 'Сканирует failed_webhook_jobs и failed_jobs, создаёт incidents_log на превышение порогов';
@@ -47,6 +48,7 @@ class IncidentsWatchFailures extends Command
$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);
@@ -221,6 +223,35 @@ class IncidentsWatchFailures extends Command
}
}
// ===== БЛОК 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;
@@ -245,6 +276,7 @@ class IncidentsWatchFailures extends Command
Carbon $startedAt,
Carbon $now,
string $dedupKey = '',
bool $sendMail = true,
): void {
DB::connection(self::DB_CONNECTION)->table('incidents_log')->insert([
'type' => $type,
@@ -259,7 +291,7 @@ class IncidentsWatchFailures extends Command
'updated_at' => $now,
]);
if ($severity === 'high') {
if ($sendMail && $severity === 'high') {
Mail::to('kdv1@bk.ru')->send(new IncidentDetectedMail($summary, $severity));
}
}
-2
View File
@@ -21,7 +21,6 @@ use App\Services\Supplier\ProcessFactory;
use App\Services\Supplier\SymfonyProcessFactory;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@@ -49,7 +48,6 @@ class AppServiceProvider extends ServiceProvider
fn ($app) => new FailoverProjectChannel(
$app->make(AjaxProjectChannel::class),
$app->make(FormProjectChannel::class),
$app->make(Mailer::class),
),
);
@@ -7,13 +7,11 @@ namespace App\Services\Supplier\Channel;
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Support\Facades\Log;
use Throwable;
@@ -31,7 +29,6 @@ final class FailoverProjectChannel implements SupplierProjectChannel
public function __construct(
private readonly SupplierProjectChannel $tier1,
private readonly SupplierProjectChannel $tier2,
private readonly Mailer $mailer,
) {}
public function createProject(SupplierProjectDto $dto): int
@@ -81,7 +78,6 @@ final class FailoverProjectChannel implements SupplierProjectChannel
} catch (SupplierClientException|SupplierAuthException $e) {
try {
$id = $this->tier2->createProject($dto);
$this->alertFailoverToForm($project, 'create', $e);
return $id;
} catch (Throwable $tier2Error) {
@@ -108,7 +104,6 @@ final class FailoverProjectChannel implements SupplierProjectChannel
} catch (SupplierClientException|SupplierAuthException $e) {
try {
$this->tier2->updateProject($externalId, $dto);
$this->alertFailoverToForm($project, 'update', $e);
return;
} catch (Throwable $tier2Error) {
@@ -147,24 +142,9 @@ final class FailoverProjectChannel implements SupplierProjectChannel
'created_at' => now(),
]);
$this->mailer->to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'manual_required',
details: "Project #{$project->id} ({$dto->platform}/{$dto->uniqueKey}) — {$operation} queued #{$row->id}, reason: {$reason}. Cause: ".mb_substr($cause->getMessage(), 0, 300),
));
throw new TierEscalatedException($row->id, $reason);
}
private function alertFailoverToForm(Project $project, string $operation, Throwable $cause): void
{
$this->mailer->to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'failover_to_form',
details: "Project #{$project->id} {$operation}: Tier 1 (AJAX) failed, Tier 2 (browser) succeeded. Cause: ".mb_substr($cause->getMessage(), 0, 300),
));
}
/**
* Портальная сверка: ищет уже существующий проект на портале по тройке
* (platform, signal_type, unique_key). Возвращает external_id найденного
+1 -1
View File
@@ -1317,7 +1317,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 5
count: 9
path: tests/Feature/Console/IncidentsWatchFailuresTest.php
-
@@ -2,9 +2,13 @@
declare(strict_types=1);
use App\Mail\IncidentDetectedMail;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
@@ -107,3 +111,63 @@ test('creates separate incidents for different exception signatures', function (
expect(DB::table('incidents_log')->count())->toBe(2);
});
// ===== Блок 6: supplier-manual-queue spike =====
// Helper: seed a project + N supplier_manual_sync_queue rows within the window
function seedManualQueue(int $n, ?DateTimeInterface $at = null): int
{
$project = Project::factory()->for(Tenant::factory())->create();
for ($i = 0; $i < $n; $i++) {
DB::table('supplier_manual_sync_queue')->insert([
'project_id' => $project->id,
'platform' => 'B1',
'operation' => 'create',
'external_id' => null,
'payload_snapshot' => '{}',
'failure_reason' => 'portal_unreachable',
'status' => 'pending',
'created_at' => $at ?? now(),
]);
}
return $project->id;
}
test('manual-queue spike below threshold: no incident, no mail', function () {
Mail::fake();
seedManualQueue(3, now()); // < default threshold 10
$this->artisan('incidents:watch-failures')->assertSuccessful();
expect(DB::table('incidents_log')->where('root_cause', 'supplier-manual-queue-spike')->count())->toBe(0);
Mail::assertNothingSent();
});
test('manual-queue spike at/above threshold: high incident + mail to both addresses', function () {
Mail::fake();
seedManualQueue(10, now()); // == default threshold 10
$this->artisan('incidents:watch-failures')->assertSuccessful();
$incident = DB::table('incidents_log')->where('root_cause', 'supplier-manual-queue-spike')->first();
expect($incident)->not->toBeNull();
expect($incident->severity)->toBe('high');
expect($incident->summary)->toContain('10');
Mail::assertSent(IncidentDetectedMail::class, function ($mail) {
return $mail->hasTo('kdv1@bk.ru') && $mail->hasTo('ops@liderra.ru');
});
});
test('manual-queue spike dedup: second run in window makes no new incident BUT re-sends mail', function () {
Mail::fake();
seedManualQueue(12, now());
$this->artisan('incidents:watch-failures')->assertSuccessful();
$this->artisan('incidents:watch-failures')->assertSuccessful();
// Инцидент один (дедуп 60 мин), а письмо ушло на каждом прогоне (громкость).
expect(DB::table('incidents_log')->where('root_cause', 'supplier-manual-queue-spike')->count())->toBe(1);
Mail::assertSent(IncidentDetectedMail::class, 2);
});
@@ -5,7 +5,6 @@ declare(strict_types=1);
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
@@ -14,7 +13,6 @@ use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
@@ -46,7 +44,6 @@ function makeFailover(SupplierProjectChannel $tier1, ?SupplierProjectChannel $ti
return [];
}
},
app(Mailer::class),
);
}
@@ -125,7 +122,7 @@ it('createProject — Tier 1 transient-exhausted: skips Tier 2, jumps to Tier 3
expect($tier2Called)->toBeFalse();
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('failure_reason', 'portal_unreachable')->count())->toBe(1);
Mail::assertQueued(SupplierCriticalAlertMail::class);
Mail::assertNothingQueued();
});
it('createProject — Tier 1 client-exc → Tier 2 success: no queue', function (): void {
@@ -165,7 +162,7 @@ it('createProject — Tier 1 client-exc → Tier 2 success: no queue', function
expect($id)->toBe(800001);
expect(SupplierManualSyncQueue::count())->toBe(0);
Mail::assertQueued(SupplierCriticalAlertMail::class); // failover_to_form alert
Mail::assertNothingQueued(); // failover через форму — успех, не тревога
});
it('createProject — Tier 1 client-exc + Tier 2 fail: Tier 3 queue, manual_required alert', function (): void {
@@ -205,7 +202,7 @@ it('createProject — Tier 1 client-exc + Tier 2 fail: Tier 3 queue, manual_requ
->toThrow(TierEscalatedException::class);
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('status', 'pending')->count())->toBe(1);
Mail::assertQueued(SupplierCriticalAlertMail::class);
Mail::assertNothingQueued();
});
it('createProject — Tier 1 auth-exc → Tier 2 success', function (): void {
@@ -4,7 +4,6 @@ declare(strict_types=1);
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
@@ -12,13 +11,12 @@ use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
uses(RefreshDatabase::class);
test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row + queues alert mail', function (): void {
test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row, no per-project alert mail', function (): void {
Mail::fake();
config(['services.supplier.alert_email' => 'ops@liderra.local']);
@@ -32,7 +30,7 @@ test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row +
$tier2 = mock(SupplierProjectChannel::class);
$tier2->shouldReceive('createProject')->andThrow(new RuntimeException('Tier-2 manage-project.js selector break'));
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
$channel = new FailoverProjectChannel($tier1, $tier2);
$dto = new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'failover-smoke.example',
@@ -44,7 +42,7 @@ test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row +
expect(SupplierManualSyncQueue::where('project_id', $project->id)->count())->toBe(1);
Mail::assertQueued(SupplierCriticalAlertMail::class, fn ($m) => $m->alertType === 'manual_required');
Mail::assertNothingQueued(); // per-project тревога убрана — запись в ручной очереди достаточна
});
test('Tier-1 transient fail (portal unreachable) bypasses Tier-2 and goes straight to Tier-3', function (): void {
@@ -61,7 +59,7 @@ test('Tier-1 transient fail (portal unreachable) bypasses Tier-2 and goes straig
$tier2 = mock(SupplierProjectChannel::class);
$tier2->shouldNotReceive('createProject'); // КЛЮЧЕВОЕ — transient НЕ должен попасть в tier-2
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
$channel = new FailoverProjectChannel($tier1, $tier2);
$dto = new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'transient-smoke.example',