b2in/app/Services/Monitoring/HealthProbe.php
Kevin Adametz b5ed756d67 Git-Deploy per Tag, Betriebsüberwachung und Release-Seeder für den Livegang
- dev/betrieb/deploy.sh: Deploy nur per Tag, mit --seed (Seeder im Wartungsmodus)
  und --initial (Vorschau/Live einmalig an Ort und Stelle auf Git umstellen)
- dev/betrieb/vorschau-auffrischen.sh: Live-DB und Live-Medien in die Vorschau (Generalprobe)
- InteriorReleaseSeeder: alle Inhaltsschritte des Relaunchs in einem Lauf, idempotent;
  Displays, Playlists und Mediathek bleiben unberührt
- DisplayInteriorBrandingSeeder überschreibt keinen inzwischen gepflegten Claim mehr
- Monitoring: GlitchTip (Server + Browser), /health mit Token, Scheduler-Herzschlag,
  Alarme per Mail und ntfy, Version in about und /health
- public/build versioniert; .gitignore: macOS-Muster Icon traf resources/views/flux/icon/
- Runbook: dev/betrieb/README.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:56:23 +00:00

198 lines
6.9 KiB
PHP

<?php
namespace App\Services\Monitoring;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
/**
* Die Prüfungen, die ein externer Monitor jede Minute abfragt
*: Datenbank, Cache, Queue-Rückstau,
* Scheduler-Herzschlag, Plattenplatz. Bewusst billig — keine Zählungen über
* große Tabellen, damit `/health` selbst keine Last erzeugt.
*
* Ein Admin-Systemstatus kann `scheduler()` und `disk()` mitbenutzen.
*/
class HealthProbe
{
public const StatusOk = 'ok';
public const StatusWarn = 'warn';
public const StatusCritical = 'critical';
/**
* Datei statt Datenbank/Cache: übersteht `cache:clear`, braucht keine
* Migration. Bei mehreren App-Servern stattdessen eine DB-Zeile nehmen.
*/
public static function heartbeatPath(): string
{
return (string) (config('monitoring.scheduler.heartbeat_path') ?: storage_path('framework/scheduler-heartbeat'));
}
public function recordHeartbeat(): void
{
file_put_contents(self::heartbeatPath(), now()->toIso8601String(), LOCK_EX);
}
public function lastHeartbeat(): ?Carbon
{
$value = @file_get_contents(self::heartbeatPath());
return is_string($value) && trim($value) !== '' ? Carbon::parse(trim($value)) : null;
}
/**
* @return list<array{key: string, label: string, status: string, detail: string}>
*/
public function checks(): array
{
return [
$this->database(),
$this->cache(),
$this->queue(),
$this->scheduler(),
$this->disk(),
];
}
/**
* @param list<array{status: string}> $checks
*/
public static function worstStatus(array $checks): string
{
$statuses = array_column($checks, 'status');
return match (true) {
in_array(self::StatusCritical, $statuses, true) => self::StatusCritical,
in_array(self::StatusWarn, $statuses, true) => self::StatusWarn,
default => self::StatusOk,
};
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
public function scheduler(): array
{
$lastBeat = $this->lastHeartbeat();
$warnMinutes = (int) config('monitoring.scheduler.heartbeat_warn_minutes');
$criticalMinutes = (int) config('monitoring.scheduler.heartbeat_critical_minutes');
$minutesAgo = $lastBeat !== null ? (int) $lastBeat->diffInMinutes(now(), absolute: true) : null;
return $this->check(
'scheduler',
'Scheduler',
match (true) {
$minutesAgo === null, $minutesAgo >= $criticalMinutes => self::StatusCritical,
$minutesAgo >= $warnMinutes => self::StatusWarn,
default => self::StatusOk,
},
$lastBeat === null
? 'Noch kein Herzschlag — läuft `schedule:run` per Cron (bzw. `schedule:work`)?'
: 'Letzter Herzschlag '.$lastBeat->format('d.m.Y H:i:s').' (vor '.$minutesAgo.' Min.).',
);
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
public function disk(): array
{
$path = storage_path();
$free = @disk_free_space($path);
$total = @disk_total_space($path);
if ($free === false || $total === false || $total <= 0) {
return $this->check('disk', 'Plattenplatz', self::StatusWarn, 'Plattenplatz nicht ermittelbar.');
}
$freePercent = round($free / $total * 100, 1);
$minPercent = (float) config('monitoring.health.disk_min_free_percent');
return $this->check(
'disk',
'Plattenplatz',
match (true) {
$freePercent < $minPercent => self::StatusCritical,
$freePercent < $minPercent * 2 => self::StatusWarn,
default => self::StatusOk,
},
sprintf('%s %% frei (%s GB von %s GB).', $freePercent, round($free / 1024 ** 3, 1), round($total / 1024 ** 3, 1)),
);
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
private function database(): array
{
try {
DB::select('select 1');
return $this->check('database', 'Datenbank', self::StatusOk, 'Verbindung steht.');
} catch (Throwable $exception) {
return $this->check('database', 'Datenbank', self::StatusCritical, 'Keine Verbindung: '.Str::limit($exception->getMessage(), 120));
}
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
private function cache(): array
{
try {
$value = Str::random(8);
Cache::put('monitoring:health-probe', $value, 60);
return Cache::get('monitoring:health-probe') === $value
? $this->check('cache', 'Cache', self::StatusOk, 'Schreiben und Lesen funktionieren.')
: $this->check('cache', 'Cache', self::StatusCritical, 'Geschriebener Wert kommt nicht zurück.');
} catch (Throwable $exception) {
return $this->check('cache', 'Cache', self::StatusCritical, 'Nicht erreichbar: '.Str::limit($exception->getMessage(), 120));
}
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
private function queue(): array
{
if (config('queue.default') !== 'database') {
return $this->check('queue', 'Queue', self::StatusOk, 'Treiber '.config('queue.default').' — Rückstau hier nicht prüfbar.');
}
try {
$now = now()->getTimestamp();
$oldestAvailable = DB::table('jobs')
->whereNull('reserved_at')
->where('available_at', '<=', $now)
->min('available_at');
} catch (Throwable $exception) {
return $this->check('queue', 'Queue', self::StatusCritical, 'Jobs-Tabelle nicht lesbar: '.Str::limit($exception->getMessage(), 120));
}
$waitingSeconds = $oldestAvailable !== null ? max(0, $now - (int) $oldestAvailable) : 0;
$criticalSeconds = (int) config('monitoring.health.queue_backlog_critical_seconds');
return $this->check(
'queue',
'Queue',
$waitingSeconds >= $criticalSeconds ? self::StatusCritical : self::StatusOk,
$oldestAvailable === null
? 'Kein wartender Job.'
: 'Ältester wartender Job seit '.$waitingSeconds.' s.'.($waitingSeconds >= $criticalSeconds ? ' Worker gestoppt?' : ''),
);
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
private function check(string $key, string $label, string $status, string $detail): array
{
return ['key' => $key, 'label' => $label, 'status' => $status, 'detail' => $detail];
}
}