presseportale/routes/console.php
Kevin Adametz d884eb7482 monitoring: Betriebsüberwachung Phase 1 — GlitchTip (Server- und Browserfehler), Fehlersignale ohne Exception, /health mit Scheduler-Herzschlag, ntfy-Alarme, fehlgeschlagene Anmeldungen im Verlauf, Umami im Kundenbereich, Versionsnummer
- GlitchTip über die Sentry-SDKs; Datenschutzfilter (nur User-ID, keine
  Request-Bodys, Tokens aus URL/Query/Referer, E-Mails aus Meldungen).
  Browser-SDK erst beim ersten Fehler nachgeladen.
- ReportErrorSignals: tote interne Links (404 mit eigenem Referrer), 403/429
  für Angemeldete, 419 als gedrosselte Warnung.
- /health (Bearer-Token): DB, Cache, Queue-Rückstau, Scheduler-Herzschlag,
  Plattenplatz → 200/503; Scheduler und Plattenplatz auch im Systemstatus.
- OperationalAlert zusätzlich per ntfy (JSON-POST), unabhängig von der Mail.
- Verlauf: login_failed (Grund unbekannt/kein Passwort/falsch),
  login_lockout, two_factor_failed; IP nur als gekürzter HMAC.
- Umami: Kundenbereich mit eigenem Schalter und identify('user-<id>'),
  nie im Admin/bei Impersonation; Query-Filter auch auf den Webseiten
  (signierte Links gingen bisher an Umami).
- queue:prune-failed nach 30 Tagen.
- Versionsnummer: config('app.version') aus APP_VERSION oder Datei RELEASE
  (git describe --tags), in GlitchTip, /health, Systemstatus, artisan about.

Plan: dev/betrieb/monitoring-plan.md (Entscheidungen M1–M9).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:53:00 +00:00

254 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Console\Commands\AggregatePressReleaseViews;
use App\Console\Commands\ArchiveExportTombstones;
use App\Console\Commands\ArchivePurge;
use App\Console\Commands\ArchiveRetryPending;
use App\Console\Commands\ArchiveSweepOrphans;
use App\Console\Commands\CheckIndexStatus;
use App\Console\Commands\CheckMailDomains;
use App\Console\Commands\ExpireGrandfathering;
use App\Console\Commands\FetchMailBounces;
use App\Console\Commands\GenerateManualInvoices;
use App\Console\Commands\MonitoringHeartbeat;
use App\Console\Commands\PublishScheduledPressReleases;
use App\Console\Commands\PurgeConversionEvents;
use App\Console\Commands\PurgeExpiredPressReleaseDrafts;
use App\Console\Commands\PurgeMagicLinks;
use App\Console\Commands\PurgePressReleaseEvents;
use App\Console\Commands\PurgeRedactionSnapshots;
use App\Console\Commands\RefreshMarketData;
use App\Console\Commands\ResetMonthlyPressReleaseQuota;
use App\Console\Commands\SweepOrphanPressReleaseOriginals;
use App\Support\OperationalAlert;
use Illuminate\Console\Scheduling\Event;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
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,
]);
};
// Sperren von withoutOverlapping() (Audit K-09): Ohne Angabe hält Laravel
// sie 24 h. Stürzt ein Lauf ab (OOM, Deploy, Container-Neustart), würde der
// Task bis zu einen Tag still übersprungen. Jede Sperre endet deshalb kurz
// nach der erwarteten Höchstlaufzeit: häufige Tasks nach ihrem Intervall,
// nächtliche nach 30–180 Minuten je nach Datenmenge.
// ========================================
// Tägliche Bereinigungen
// ========================================
// Bounce-Postfach: Rückläufer lesen und unzustellbare Adressen sperren
// (Roadmap 1b.6). Alle zehn Minuten auf UNSEEN; ohne konfiguriertes Konto
// beendet sich der Befehl sofort.
Schedule::command(FetchMailBounces::class)
->everyTenMinutes()
->withoutOverlapping(10)
->runInBackground()
->onFailure($alertOnFailure('mail:fetch-bounces'));
// Kontakterreichbarkeit: abgelaufene DNS-Befunde der Mail-Domains erneuern.
// Nur Auffrischung — neue Adressen prüft der Observer beim Anlegen. Ein
// Befund darf nicht unbegrenzt als Domainsperre stehen bleiben.
Schedule::command(CheckMailDomains::class, ['--stale', '--limit=500'])
->dailyAt('04:20')
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('mail:check-domains'));
// Magic-Links: Abgelaufene / verbrauchte Tokens entfernen (täglich 03:00)
Schedule::command(PurgeMagicLinks::class, ['--days=30'])
->dailyAt('03:00')
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('magic-links:purge'));
// Meldeverfahren: Schwärzungs-Snapshots nach Löschfrist entfernen (täglich 03:10)
Schedule::command(PurgeRedactionSnapshots::class)
->dailyAt('03:10')
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('snapshots:purge'));
// Beweisarchiv (Bildkennzeichnung Block F.3): fällige Einträge löschen (03:20),
// Tombstone-Stand exportieren (03:30), damit er einen Restore überlebt.
Schedule::command(ArchivePurge::class)
->dailyAt('03:20')
->withoutOverlapping(120)
->runInBackground()
->onFailure($alertOnFailure('archive:purge'));
// Bewusst eigener Termin statt ->then() an archive:purge (OPT-6, K-09):
// Als Anhängsel liefe der Export nach einem gescheiterten Purge gar nicht
// (onSuccess) bzw. ohne eigenen Failure-Alarm (then). So bleibt der Export
// auch dann aktuell und meldet sich selbst. Die Sperren enden nach 2 h bzw.
// 1 h; ein Purge, der länger als 10 Minuten läuft, überschneidet sich mit dem
// Export — der Export liest nur und nimmt den nächsten Stand am Folgetag mit.
Schedule::command(ArchiveExportTombstones::class)
->dailyAt('03:30')
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('archive:export-tombstones'));
// Fehlgeschlagene Archiveinträge nachholen (03:35) und Payloads ohne Zeile
// nach 24 h Karenz entfernen (03:40) — Umsetzungsaudit 25.09.2026, N11.
Schedule::command(ArchiveRetryPending::class)
->dailyAt('03:35')
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('archive:retry-pending'));
Schedule::command(ArchiveSweepOrphans::class)
->dailyAt('03:40')
->withoutOverlapping(120)
->runInBackground()
->onFailure($alertOnFailure('archive:sweep-orphans'));
// PM-Entwürfe: Zombie-Drafts nach 180 Tagen Inaktivität archivieren (wöchentlich)
Schedule::command(PurgeExpiredPressReleaseDrafts::class, ['--days=180'])
->weekly()
->sundays()
->at('04:00')
->withoutOverlapping(180)
->runInBackground()
->onFailure($alertOnFailure('press-releases:purge-drafts'));
// Beweisoriginale ohne Bildzeile (Umsetzungsaudit 29.09.2026, B-11): Reste
// eines hart abgebrochenen Uploads. 48 h Karenz schützt laufende Uploads;
// während eines Restore-Abgleichs (Archiv-Sweep pausiert) löscht er nichts.
Schedule::command(SweepOrphanPressReleaseOriginals::class, ['--execute', '--hours=48'])
->weekly()
->sundays()
->at('04:45')
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('press-release-images:sweep-orphan-originals'));
// ========================================
// Geplante PM-Veröffentlichung
// ========================================
// PM mit scheduled_at <= now & Status review automatisch veröffentlichen.
// Läuft alle 5 Min — passt zum FormRule "scheduled_at min. 5 Min in Zukunft".
Schedule::command(PublishScheduledPressReleases::class)
->everyFiveMinutes()
->withoutOverlapping(10)
->runInBackground()
->onFailure($alertOnFailure('press-releases:publish-scheduled'));
// ========================================
// Aufruf-Zähler
// ========================================
// Gezählte Aufrufe aus dem Ereignis-Log in press_releases.hits verbuchen.
// Bewusst außerhalb des Requests: der Schreibzugriff auf die große Tabelle
// darf keine Web-Worker blockieren (siehe AggregatePressReleaseViews).
Schedule::command(AggregatePressReleaseViews::class)
->everyMinute()
->withoutOverlapping(15)
->runInBackground()
->onFailure($alertOnFailure('press-releases:aggregate-views'));
// Ereignis-Log auf den Aufbewahrungszeitraum begrenzen (täglich 03:50 —
// nicht mehr zeitgleich mit archive:purge um 03:20, Audit K-09).
// Die Tabelle nimmt jeden Seitenaufruf auf und wuchs als einzige Log-Tabelle
// des Systems unbegrenzt. Gelöscht wird nur unterhalb der Wasserstandsmarke —
// noch nicht in `hits` verbuchte Aufrufe bleiben unangetastet.
Schedule::command(PurgePressReleaseEvents::class)
->dailyAt('03:50')
->withoutOverlapping(120)
->runInBackground()
->onFailure($alertOnFailure('press-releases:purge-events'));
// Indexierungsstatus aus der Search Console (Roadmap 1c.4), täglich 05:10.
// Ohne GSC_ENABLED/Schlüsseldatei beendet sich der Befehl sofort.
Schedule::command(CheckIndexStatus::class)
->dailyAt('05:10')
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('press-releases:check-index-status'));
// Konversions-Ereignisse auf den Aufbewahrungszeitraum begrenzen (täglich 03:25).
// Frist zusammen mit der Tabelle festgelegt (395 Tage), siehe config/conversion.php.
Schedule::command(PurgeConversionEvents::class)
->dailyAt('03:25')
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('conversion:purge-events'));
// ========================================
// Manueller Rechnungskreis (MAN-) — Legacy-Zahlungen
// ========================================
// Bestandsschutz mit überschrittenem Legacy-Vertragsende beenden (Audit
// F06) — vor dem Rechnungslauf, damit kein beendeter Vertrag mehr
// abgerechnet wird.
Schedule::command(ExpireGrandfathering::class)
->dailyAt('04:15')
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('billing:expire-grandfathering'));
// Fällige Rechnungen für laufende Legacy-Zahlungsvereinbarungen ausstellen
// (Periodenende erreicht), wie im Altsystem. Neue Abschlüsse laufen über
// Stripe (STR-Kreis) und werden hier nicht angefasst.
Schedule::command(GenerateManualInvoices::class)
->dailyAt('04:30')
->withoutOverlapping(60)
->runInBackground()
->onFailure($alertOnFailure('billing:generate-manual-invoices'));
// ========================================
// PM-Kontingent (Kalendermonats-Reset des Tarif-Modells)
// ========================================
// Monatlicher Reset des verbrauchten PM-Kontingents (am 1. um 00:05).
Schedule::command(ResetMonthlyPressReleaseQuota::class)
->monthlyOn(1, '00:05')
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('press-releases:reset-monthly-quota'));
// ========================================
// Börsendaten für den Web-Ticker
// ========================================
// Kurse (DAX, EUR/USD, Brent) alle 15 Minuten aktualisieren; die Views lesen
// nur aus dem Cache — ein API-Ausfall leert die Anzeige nicht sofort.
Schedule::command(RefreshMarketData::class)
->everyFifteenMinutes()
->withoutOverlapping(15)
->runInBackground()
->onFailure($alertOnFailure('market:refresh'));
// ========================================
// Betriebsüberwachung (Monitoring-Plan Phase 1)
// ========================================
// Herzschlag: Fällt der Scheduler komplett aus (Cron weg, Server hängt),
// greift kein onFailure mehr — dann meldet /health den fehlenden Herzschlag.
Schedule::command(MonitoringHeartbeat::class)
->everyMinute()
->withoutOverlapping(2)
->onFailure($alertOnFailure('monitoring:heartbeat'));
// Fehlgeschlagene Jobs nach 30 Tagen entfernen (täglich 03:55). Bis dahin
// sind sie im Systemstatus sichtbar und per `queue:retry` wiederholbar.
Schedule::command('queue:prune-failed', ['--hours=720'])
->dailyAt('03:55')
->withoutOverlapping(30)
->runInBackground()
->onFailure($alertOnFailure('queue:prune-failed'));