Monitoring (R5.3): Betriebs-Alerts fuer Scheduler-/Queue-Ausfaelle

OperationalAlert loggt critical + Best-Effort-Mail an MONITORING_ALERT_EMAIL
(Fallback LEGAL_CONTACT_EMAIL, feature-flagged). Alle 6 Scheduler-Tasks mit
onFailure, Queue::failing-Listener; .env.example: LOG_STACK=daily als Prod-Soll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-09 15:34:40 +00:00
parent ac870a4076
commit 6984846233
8 changed files with 250 additions and 6 deletions

View file

@ -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,

View file

@ -0,0 +1,46 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Betriebs-Alert an das Ops-Postfach (R5.3). Bewusst schlicht und ungebrandet:
* es geht um schnelle, verlässliche Zustellung, nicht um CI.
*/
class OperationalAlertMail extends Mailable
{
use Queueable, SerializesModels;
/**
* @param array<string, mixed> $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'),
],
);
}
}

View file

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

View file

@ -0,0 +1,44 @@
<?php
namespace App\Support;
use App\Mail\OperationalAlertMail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Zentrale Betriebs-Alerts (R5.3): meldet fehlgeschlagene Scheduler-Tasks und
* Queue-Jobs. Der Log-Eintrag (`critical`) ist die verlässliche Ebene und läuft
* immer; die Mail ist Best-Effort und darf den auslösenden Prozess nie mit in
* den Abgrund reißen (Mailserver down o. ä.).
*/
class OperationalAlert
{
/**
* @param array<string, mixed> $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(),
]);
}
}
}

26
config/monitoring.php Normal file
View file

@ -0,0 +1,26 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Betriebs-Alerts (R5.3)
|--------------------------------------------------------------------------
|
| Fehlgeschlagene Scheduler-Tasks und Queue-Jobs werden immer als
| `critical` ins Log geschrieben (rotierter `daily`-Channel) und – wenn
| aktiviert und eine Adresse gesetzt ist – zusätzlich per Best-Effort-Mail
| gemeldet. Die Mail ist bewusst nur die zweite Ebene: schlägt der Versand
| fehl, bleibt der Log-Eintrag als verlässliche Spur bestehen.
|
*/
'alerts' => [
'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')),
],
];

View file

@ -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").

View file

@ -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'));

View file

@ -0,0 +1,74 @@
<?php
use App\Mail\OperationalAlertMail;
use App\Support\OperationalAlert;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
test('an alert always logs critical and mails the configured recipient', function () {
config()->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');
});
});