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:
parent
c2ec18be5f
commit
1ad2e544a9
8 changed files with 250 additions and 6 deletions
|
|
@ -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,
|
||||
|
|
|
|||
46
app/Mail/OperationalAlertMail.php
Normal file
46
app/Mail/OperationalAlertMail.php
Normal 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'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
44
app/Support/OperationalAlert.php
Normal file
44
app/Support/OperationalAlert.php
Normal 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
26
config/monitoring.php
Normal 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')),
|
||||
],
|
||||
|
||||
];
|
||||
13
resources/views/emails/monitoring/alert.blade.php
Normal file
13
resources/views/emails/monitoring/alert.blade.php
Normal 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").
|
||||
|
|
@ -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'));
|
||||
|
|
|
|||
74
tests/Feature/OperationalAlertTest.php
Normal file
74
tests/Feature/OperationalAlertTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue