diff --git a/.env.example b/.env.example index 3a52f292..b615b693 100644 --- a/.env.example +++ b/.env.example @@ -22,10 +22,18 @@ PHP_CLI_SERVER_WORKERS=4 BCRYPT_ROUNDS=12 LOG_CHANNEL=stack +# Prod: auf `daily` stellen — der `single`-Channel wächst sonst unbegrenzt. +# Der daily-Channel rotiert mit LOG_DAILY_DAYS (Default 14, siehe Runbook). LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug +# Betriebs-Alerts (R5.3): fehlgeschlagene Scheduler-Tasks/Queue-Jobs werden als +# `critical` geloggt und – wenn aktiviert – zusätzlich an MONITORING_ALERT_EMAIL +# gemeldet (Fallback: LEGAL_CONTACT_EMAIL). In Prod echten Mailer voraussetzen. +MONITORING_ALERTS_ENABLED=true +MONITORING_ALERT_EMAIL= + # --------------------------------------------------------------------------- # Multi-Domain-Setup (config/domains.php) # Hub: pressekonto (Portal-Backend + Hub-Landing) — Spokes: presseecho, diff --git a/app/Mail/OperationalAlertMail.php b/app/Mail/OperationalAlertMail.php new file mode 100644 index 00000000..29cb5c07 --- /dev/null +++ b/app/Mail/OperationalAlertMail.php @@ -0,0 +1,46 @@ + $context + */ + public function __construct( + public readonly string $summary, + public readonly array $context = [], + ) {} + + public function envelope(): Envelope + { + return new Envelope( + subject: '[Monitoring] '.$this->summary, + ); + } + + public function content(): Content + { + return new Content( + text: 'emails.monitoring.alert', + with: [ + 'summary' => $this->summary, + 'context' => $this->context, + 'appName' => config('app.name'), + 'environment' => config('app.env'), + ], + ); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8589eb87..a6896449 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -23,6 +23,7 @@ use App\Observers\AdminPerformanceCacheObserver; use App\Services\Admin\AdminRequestPerformanceMetrics; use App\Services\Newsletter\NullNewsletterSyncClient; use App\Services\PressRelease\PressReleaseService; +use App\Support\OperationalAlert; use Illuminate\Auth\Events\Login; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Verified; @@ -31,9 +32,11 @@ use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Database\Events\QueryExecuted; use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Queue\Events\JobFailed; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Laravel\Cashier\Cashier; @@ -91,6 +94,17 @@ class AppServiceProvider extends ServiceProvider Gate::define('access-admin', fn (User $user): bool => $user->canAccessAdmin()); Gate::define('access-customer', fn (User $user): bool => $user->canAccessCustomer()); + // Betriebs-Alert bei fehlgeschlagenem Queue-Job (R5.3): die KI-Jobs + // laufen auf der `classification`-Queue — ein stiller Stau in + // `failed_jobs` würde jede Einreichung in der Prüfung hängen lassen. + Queue::failing(function (JobFailed $event): void { + OperationalAlert::send("Queue-Job fehlgeschlagen: {$event->job->resolveName()}", [ + 'connection' => $event->connectionName, + 'queue' => $event->job->getQueue(), + 'exception' => $event->exception->getMessage(), + ]); + }); + // Registrierung → Verifizierungsmail; bestätigter Klick → Aktivierung. Event::listen(Registered::class, SendEmailVerificationNotification::class); Event::listen(Verified::class, ActivateUserAfterVerification::class); diff --git a/app/Support/OperationalAlert.php b/app/Support/OperationalAlert.php new file mode 100644 index 00000000..3acc2361 --- /dev/null +++ b/app/Support/OperationalAlert.php @@ -0,0 +1,44 @@ + $context + */ + public static function send(string $summary, array $context = []): void + { + Log::critical('[monitoring] '.$summary, $context); + + if (! config('monitoring.alerts.enabled')) { + return; + } + + $recipient = config('monitoring.alerts.mail_to'); + + if (empty($recipient)) { + return; + } + + try { + Mail::to($recipient)->send(new OperationalAlertMail($summary, $context)); + } catch (Throwable $e) { + Log::error('[monitoring] Alert-Mail konnte nicht versendet werden.', [ + 'summary' => $summary, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/config/monitoring.php b/config/monitoring.php new file mode 100644 index 00000000..1cf6047d --- /dev/null +++ b/config/monitoring.php @@ -0,0 +1,26 @@ + [ + 'enabled' => (bool) env('MONITORING_ALERTS_ENABLED', true), + + // Ops-Postfach. Fällt auf die zentrale Rechts-/Betreiber-Adresse + // zurück, solange kein dediziertes Monitoring-Postfach gesetzt ist. + 'mail_to' => env('MONITORING_ALERT_EMAIL', env('LEGAL_CONTACT_EMAIL', 'info@pressekonto.com')), + ], + +]; diff --git a/resources/views/emails/monitoring/alert.blade.php b/resources/views/emails/monitoring/alert.blade.php new file mode 100644 index 00000000..94526d37 --- /dev/null +++ b/resources/views/emails/monitoring/alert.blade.php @@ -0,0 +1,13 @@ +[{{ $appName }} · {{ strtoupper($environment) }}] Betriebs-Alert + +{{ $summary }} + +@if (! empty($context)) +Kontext: +@foreach ($context as $key => $value) +- {{ $key }}: {{ is_scalar($value) || $value === null ? $value : json_encode($value) }} +@endforeach + +@endif +Diese Meldung stammt vom automatischen Monitoring (R5.3). Der zugehörige +Log-Eintrag steht als "critical" im Tageslog (Channel "daily"). diff --git a/routes/console.php b/routes/console.php index f53a5cc8..169d21eb 100644 --- a/routes/console.php +++ b/routes/console.php @@ -6,6 +6,8 @@ use App\Console\Commands\PurgeExpiredPressReleaseDrafts; use App\Console\Commands\PurgeMagicLinks; use App\Console\Commands\RefreshMarketData; use App\Console\Commands\ResetMonthlyPressReleaseQuota; +use App\Support\OperationalAlert; +use Illuminate\Console\Scheduling\Event; use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Schedule; @@ -14,6 +16,17 @@ Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); +/** + * Betriebs-Alert bei fehlgeschlagenem Scheduler-Task (R5.3): meldet den Task + * per Log + Best-Effort-Mail, damit ein stiller Ausfall (z. B. Rechnungslauf, + * Kontingent-Reset) nicht unbemerkt bleibt. + */ +$alertOnFailure = fn (string $task) => function (Event $event) use ($task): void { + OperationalAlert::send("Geplanter Task fehlgeschlagen: {$task}", [ + 'exit_code' => $event->exitCode, + ]); +}; + // ======================================== // Tägliche Bereinigungen // ======================================== @@ -22,7 +35,8 @@ Artisan::command('inspire', function () { Schedule::command(PurgeMagicLinks::class, ['--days=30']) ->dailyAt('03:00') ->withoutOverlapping() - ->runInBackground(); + ->runInBackground() + ->onFailure($alertOnFailure('magic-links:purge')); // PM-Entwürfe: Zombie-Drafts nach 180 Tagen Inaktivität archivieren (wöchentlich) Schedule::command(PurgeExpiredPressReleaseDrafts::class, ['--days=180']) @@ -30,7 +44,8 @@ Schedule::command(PurgeExpiredPressReleaseDrafts::class, ['--days=180']) ->sundays() ->at('04:00') ->withoutOverlapping() - ->runInBackground(); + ->runInBackground() + ->onFailure($alertOnFailure('press-releases:purge-drafts')); // ======================================== // Geplante PM-Veröffentlichung @@ -41,7 +56,8 @@ Schedule::command(PurgeExpiredPressReleaseDrafts::class, ['--days=180']) Schedule::command(PublishScheduledPressReleases::class) ->everyFiveMinutes() ->withoutOverlapping() - ->runInBackground(); + ->runInBackground() + ->onFailure($alertOnFailure('press-releases:publish-scheduled')); // ======================================== // Manueller Rechnungskreis (MAN-) — Legacy-Zahlungen @@ -53,7 +69,8 @@ Schedule::command(PublishScheduledPressReleases::class) Schedule::command(GenerateManualInvoices::class) ->dailyAt('04:30') ->withoutOverlapping() - ->runInBackground(); + ->runInBackground() + ->onFailure($alertOnFailure('billing:generate-manual-invoices')); // ======================================== // PM-Kontingent (Kalendermonats-Reset des Tarif-Modells) @@ -63,7 +80,8 @@ Schedule::command(GenerateManualInvoices::class) Schedule::command(ResetMonthlyPressReleaseQuota::class) ->monthlyOn(1, '00:05') ->withoutOverlapping() - ->runInBackground(); + ->runInBackground() + ->onFailure($alertOnFailure('press-releases:reset-monthly-quota')); // ======================================== // Börsendaten für den Web-Ticker @@ -74,4 +92,5 @@ Schedule::command(ResetMonthlyPressReleaseQuota::class) Schedule::command(RefreshMarketData::class) ->everyFifteenMinutes() ->withoutOverlapping() - ->runInBackground(); + ->runInBackground() + ->onFailure($alertOnFailure('market:refresh')); diff --git a/tests/Feature/OperationalAlertTest.php b/tests/Feature/OperationalAlertTest.php new file mode 100644 index 00000000..eb47b739 --- /dev/null +++ b/tests/Feature/OperationalAlertTest.php @@ -0,0 +1,74 @@ +set('monitoring.alerts.enabled', true); + config()->set('monitoring.alerts.mail_to', 'ops@example.com'); + Mail::fake(); + Log::spy(); + + OperationalAlert::send('Geplanter Task fehlgeschlagen: billing:generate-manual-invoices', ['exit_code' => 1]); + + Log::shouldHaveReceived('critical')->once(); + Mail::assertSent(OperationalAlertMail::class, function (OperationalAlertMail $mail) { + return $mail->hasTo('ops@example.com') + && str_contains($mail->summary, 'billing:generate-manual-invoices') + && $mail->context === ['exit_code' => 1]; + }); +}); + +test('no mail is sent when alerts are disabled', function () { + config()->set('monitoring.alerts.enabled', false); + config()->set('monitoring.alerts.mail_to', 'ops@example.com'); + Mail::fake(); + + OperationalAlert::send('Etwas ist kaputt'); + + Mail::assertNothingSent(); +}); + +test('no mail is sent when no recipient is configured', function () { + config()->set('monitoring.alerts.enabled', true); + config()->set('monitoring.alerts.mail_to', null); + Mail::fake(); + + OperationalAlert::send('Etwas ist kaputt'); + + Mail::assertNothingSent(); +}); + +test('a mail failure never bubbles up to the caller', function () { + config()->set('monitoring.alerts.enabled', true); + config()->set('monitoring.alerts.mail_to', 'ops@example.com'); + + // Mailserver down: der Versand wirft — der Alert muss das schlucken, + // damit der auslösende Scheduler-/Queue-Prozess weiterläuft. + Mail::shouldReceive('to')->andReturnSelf(); + Mail::shouldReceive('send')->andThrow(new RuntimeException('mail down')); + + OperationalAlert::send('Etwas ist kaputt'); + + expect(true)->toBeTrue(); +}); + +test('a failing queue job triggers an operational alert via the registered listener', function () { + config()->set('monitoring.alerts.enabled', true); + config()->set('monitoring.alerts.mail_to', 'ops@example.com'); + Mail::fake(); + + $job = Mockery::mock(Job::class); + $job->shouldReceive('resolveName')->andReturn('App\\Jobs\\ClassifyPressRelease'); + $job->shouldReceive('getQueue')->andReturn('classification'); + + event(new JobFailed('database', $job, new RuntimeException('job boom'))); + + Mail::assertSent(OperationalAlertMail::class, function (OperationalAlertMail $mail) { + return str_contains($mail->summary, 'ClassifyPressRelease'); + }); +});