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 */ public function checks(): array { return [ $this->database(), $this->cache(), $this->queue(), $this->scheduler(), $this->disk(), ]; } /** * @param list $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]; } }