Vorlagen in einem frischen Laravel 12 eingebaut (30 Tests grün), deploy.sh gegen ein simuliertes Repository durchgespielt (Deploy, Abbruch bei Migrationen, Drift, Rollback, fehlender Worker). GlitchTip-Aufbewahrung heißt seit Version 6 GLITCHTIP_RETENTION_DAYS — in der Projekt-Doku korrigiert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 KiB
03 — Betriebsüberwachung: /health, Herzschlag, Worker, Alarme, Uptime Kuma
GlitchTip (02) sieht nur Fehler, die PHP noch melden kann. Dieser Teil deckt den Rest ab: Scheduler steht, Worker steht, Platte voll, Server weg — und meldet es aufs Handy.
1. Bausteine in der App
Dateien aus vorlagen/laravel/ kopieren:
| Datei | Zweck |
|---|---|
config/monitoring.php |
Alarme (Mail, ntfy), Browser-DSN, Health-Token, Schwellen |
app/Support/OperationalAlert.php |
Alarm: immer Log::critical (Kanal monitoring), dazu Text-Mail und ntfy — unabhängig voneinander, Best-Effort |
app/Services/Monitoring/HealthProbe.php |
Prüfungen DB, Cache, Queue-Rückstau, Scheduler-Herzschlag, Plattenplatz |
app/Http/Controllers/HealthController.php |
GET /health mit Token → JSON, 200 oder 503 |
app/Console/Commands/MonitoringHeartbeat.php |
monitoring:heartbeat, minütlich vom Scheduler |
app/Console/Commands/MonitoringQueueProbe.php + app/Jobs/Monitoring/QueueProbe.php |
monitoring:queue-probe: Test-Job, wartet bis zum Abholen (Ende von deploy.sh) |
tests/Feature/Monitoring/HealthEndpointTest.php, QueueProbeTest.php |
Tests |
Log-Kanal monitoring in config/logging.php ergänzen (eigene Datei, rotierend):
'monitoring' => [
'driver' => 'daily',
'path' => storage_path('logs/monitoring.log'),
'level' => 'debug',
'days' => 14,
],
Verdrahtung
bootstrap/app.php — Route außerhalb der web-Gruppe (keine Session, kein CSRF):
use App\Http\Controllers\HealthController;
use Illuminate\Support\Facades\Route;
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
then: function (): void {
// Tiefer Health-Check für den externen Monitor
Route::get('/health', HealthController::class)
->middleware('throttle:30,1')
->name('health.deep');
},
)
Hat das Projekt Basic Auth (Staging) oder eine Host-Whitelist (trustHosts):
/health von Basic Auth ausnehmen (der Monitor schickt seinen eigenen
Authorization-Header) und IP/localhost als Host zulassen.
routes/console.php:
use App\Console\Commands\MonitoringHeartbeat;
use App\Support\OperationalAlert;
use Illuminate\Console\Scheduling\Event;
use Illuminate\Support\Facades\Schedule;
// Betriebs-Alert, wenn ein geplanter Task fehlschlägt — an JEDEN Task hängen
$alertOnFailure = fn (string $task) => function (Event $event) use ($task): void {
OperationalAlert::send("Geplanter Task fehlgeschlagen: {$task}", ['exit_code' => $event->exitCode]);
};
// Herzschlag: fällt der Scheduler 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
Schedule::command('queue:prune-failed', ['--hours=720'])
->dailyAt('03:55')
->withoutOverlapping(30)
->onFailure($alertOnFailure('queue:prune-failed'));
Bei allen eigenen Tasks: withoutOverlapping(<Minuten>) mit kurzer Sperrdauer
(Standard wäre 24 h — ein abgestürzter Lauf blockiert sonst einen Tag) und
->onFailure($alertOnFailure('<name>')).
app/Providers/AppServiceProvider.php → boot():
use App\Support\OperationalAlert;
use Illuminate\Foundation\Console\AboutCommand;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Queue;
// Betriebs-Alert bei endgültig fehlgeschlagenem Queue-Job
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(),
]);
});
// Ausgelieferte Version in `php artisan about` (Abschnitt-Name = ABOUT_SECTION in deploy.sh)
AboutCommand::add('App', fn (): array => ['Version' => config('app.version') ?? '—']);
Die Prüfungen von /health
| Prüfung | critical (→ 503) |
warn (200) |
|---|---|---|
database |
select 1 scheitert |
— |
cache |
Schreiben/Lesen scheitert | — |
queue |
ältester wartender Job ≥ MONITORING_QUEUE_BACKLOG_SECONDS (300) — nur Treiber database |
— |
scheduler |
kein Herzschlag oder ≥ 10 min alt | ≥ 3 min alt |
disk |
freier Platz < MONITORING_DISK_MIN_FREE_PERCENT (10 %) |
< 20 % |
Ohne konfiguriertes MONITORING_HEALTH_TOKEN gibt es den Endpunkt nicht (404),
falsches Token → 404. Token im Header Authorization: Bearer … oder ?token=.
Antwort enthält version.
Der Herzschlag liegt in storage/framework/scheduler-heartbeat (übersteht
cache:clear, keine Migration). Mehrere App-Server: stattdessen eine
DB-Zeile, sonst prüft jeder Server nur seine eigene Datei.
Andere Queue-Treiber (Redis, SQS): queue-Prüfung meldet „nicht prüfbar“;
dann eine eigene Prüfung ergänzen (z. B. Queue::size() plus Alter des
ältesten Jobs) oder Horizon-Status abfragen.
2. ntfy — Push aufs Handy
Server einmalig: vorlagen/server/ntfy-compose.yml + ntfy-server.yml
(auth-default-access: deny-all — ohne Anmeldung kein Lesen, kein Schreiben).
Je Projekt:
ntfy user add meineapp-alerts # Passwort wird nicht gebraucht
ntfy access meineapp-alerts meineapp-ops write-only # darf nur schreiben
ntfy access <dein-benutzer> meineapp-ops read-only # entfällt bei Admin-Rolle
ntfy token add meineapp-alerts # → tk_… in MONITORING_NTFY_TOKEN
Test: curl -H "Authorization: Bearer tk_…" -d "Test" https://push.example.com/meineapp-ops.
Handy: ntfy-App, Server hinzufügen (mit eigenem Benutzer), Topic abonnieren.
Warum eigener Benutzer mit Token: gerät der Token in falsche Hände, kann damit
niemand mitlesen; ntfy token remove sperrt ihn ohne Passwortwechsel.
OperationalAlert sendet JSON an die Basis-URL (topic, title, message,
priority, tags) — UTF-8-sicher, anders als Header-basierte Aufrufe.
3. Uptime Kuma — externer Monitor
Auf einem anderen Server als die App (vorlagen/server/uptime-kuma-compose.yml).
Admin-Konto anlegen, 2FA aktivieren.
| Monitor | Typ / Ziel | Intervall | Wiederholungen |
|---|---|---|---|
<app> /health |
HTTP(s), https://meineapp.de/health, Header {"Authorization": "Bearer <MONITORING_HEALTH_TOKEN>"}, Status 200-299 |
60 s | 2 |
<app> /up je Domain |
HTTP(s), https://<domain>/up |
5 min | 1 |
| Zertifikate | Settings ▸ Certificate Expiry Notification | 14 Tage vorher | — |
Benachrichtigung: ntfy (Server, Topic, Priorität 4, Access Token) und SMTP-Mail.
Ein stehender Worker fällt so nach etwa 5 + 2 Minuten auf (Queue-Schwelle plus Wiederholungen) — ein zusätzlicher Alarm aus dem Scheduler ist dann überflüssig.
Test: zweiter Monitor mit falschem Token → 404 → Alarm muss auf Handy und per Mail ankommen; danach löschen.
Zweite Ebene (empfohlen): kostenloser externer Dienst (z. B. UptimeRobot)
auf https://meineapp.de/up mit Mail — meldet auch, wenn der Monitoring-Server
selbst ausfällt.
4. Worker-Prüfung im Deploy
deploy.sh ruft am Ende php artisan monitoring:queue-probe --wait=60 auf:
Test-Job auf die Standard-Queue, Abholung wird über den Cache quittiert. Kein
Worker → Exit 3 mit Hinweis. Von Hand jederzeit:
php artisan monitoring:queue-probe
ps -eo pid,lstart,args | grep "queue:work" | grep -v grep
Voraussetzung: Der Worker bedient die Standard-Queue, und App und Worker teilen
denselben Cache-Store (nicht array).
5. Erweiterungen (Muster aus Pressekonto, nicht in den Vorlagen)
-
Admin-Systemstatus-Seite: Kacheln aus
HealthProbe::checks()plus projektspezifische Prüfungen (fehlende Permissions,APP_DEBUGan, ausstehende Datenmigrationen, letzte fehlgeschlagene Jobs). -
Fehlgeschlagene Logins: falsches Passwort / Konto ohne Passwort / Sperre in einen Aktivitäts-Verlauf, mit Einstieg (
livewire/fortify) und gekürztem HMAC der IP statt Klartext. Unbekannte Adressen nur zählen (Tag × Einstieg × IP-Hash), sonst verdecken Credential-Stuffing-Wellen (bei Pressekonto 250/Tag aus 50 Quellen) die relevanten Einträge. -
Webanalyse: Umami selbst gehostet, cookielos; Query-Parameter mit
data-before-sendfiltern (signierte Links!), im Admin und bei Impersonation nicht tracken. -
Nginx-Fehlerlog gelegentlich zusammenfassen:
sed -E 's/^[0-9\/]+ [0-9:]+ //; s/[0-9]+#[0-9]+: \*[0-9]+ //; s/client: [0-9a-f.:]+/client: X/; s/, (server|request|upstream|host|referrer): .*//' \ /var/log/nginx/<site>-error.log | cut -c1-180 | sort | uniq -c | sort -rn | head -25access forbidden by rule= Scanner suchen/.env(gut so);recv() failed (104…)nur zu Deploy-Zeiten = Reload, unkritisch.