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->failedJobs(), $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` minütlich per Cron?' : '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 ('.config('cache.default').').') : $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)); } } /** * Je nach QUEUE_DRIVER: `sync` braucht keinen Worker, `database` wird am * ältesten wartenden Job gemessen, `redis` an Horizon (läuft es, wie lange * warten die Jobs). * * @return array{key: string, label: string, status: string, detail: string} */ private function queue(): array { return match (config('queue.default')) { 'sync' => $this->check('queue', 'Queue', self::StatusOk, 'Treiber sync — Jobs laufen direkt im Request, kein Worker nötig.'), 'database' => $this->databaseQueue(), 'redis' => $this->horizonQueue(), default => $this->check('queue', 'Queue', self::StatusWarn, 'Treiber '.config('queue.default').' — hier nicht prüfbar.'), }; } /** * @return array{key: string, label: string, status: string, detail: string} */ private function databaseQueue(): array { 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 ? 'Treiber database — kein wartender Job.' : 'Treiber database — ältester wartender Job seit '.$waitingSeconds.' s.'.($waitingSeconds >= $criticalSeconds ? ' Worker gestoppt?' : ''), ); } /** * @return array{key: string, label: string, status: string, detail: string} */ private function horizonQueue(): array { try { Redis::connection()->ping(); $masters = app(MasterSupervisorRepository::class)->all(); } catch (Throwable $exception) { return $this->check('queue', 'Queue', self::StatusCritical, 'Redis nicht erreichbar: '.Str::limit($exception->getMessage(), 120)); } if ($masters === []) { return $this->check('queue', 'Queue', self::StatusCritical, 'Treiber redis — Horizon läuft nicht. Daemon in Ploi prüfen.'); } if (collect($masters)->every(fn (object $master): bool => $master->status === 'paused')) { return $this->check('queue', 'Queue', self::StatusWarn, 'Treiber redis — Horizon ist pausiert.'); } $longestWait = (int) collect(app(WaitTimeCalculator::class)->calculate())->max(); $criticalSeconds = (int) config('monitoring.health.queue_backlog_critical_seconds'); return $this->check( 'queue', 'Queue', $longestWait >= $criticalSeconds ? self::StatusCritical : self::StatusOk, 'Treiber redis — Horizon läuft, geschätzte Wartezeit höchstens '.$longestWait.' s.', ); } /** * @return array{key: string, label: string, status: string, detail: string} */ private function failedJobs(): array { $hours = (int) config('monitoring.health.failed_jobs_window_hours'); try { $count = DB::table('failed_jobs')->where('failed_at', '>=', now()->subHours($hours))->count(); } catch (Throwable $exception) { return $this->check('failed_jobs', 'Fehlgeschlagene Jobs', self::StatusWarn, 'Tabelle failed_jobs nicht lesbar: '.Str::limit($exception->getMessage(), 120)); } return $this->check( 'failed_jobs', 'Fehlgeschlagene Jobs', $count > 0 ? self::StatusWarn : self::StatusOk, $count > 0 ? $count.' in den letzten '.$hours.' Stunden.' : 'Keine in den letzten '.$hours.' Stunden.', ); } /** * @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]; } }