info('Health monitor is disabled (health_monitor.enabled=false).'); return self::SUCCESS; } $checks = [ 'database' => fn () => $this->checkDatabase(), 'redis' => fn () => $this->checkRedis(), 'elasticsearch' => fn () => $this->checkElasticsearch(), 'website' => fn () => $this->checkWebsite(), 'disk' => fn () => $this->checkDisk(), ]; $failures = []; foreach ($checks as $name => $check) { try { $problem = $check(); } catch (\Throwable $e) { $problem = $e->getMessage(); } if ($problem === null) { $this->line("OK: {$name}"); } else { $failures[] = "{$name}: {$problem}"; $this->error("FAIL: {$name}: {$problem}"); } } foreach ($failures as $line) { Log::error('[health-monitor] ' . $line); } $this->report($failures); return empty($failures) ? self::SUCCESS : self::FAILURE; } /** null when healthy, otherwise what is wrong. */ private function checkDatabase(): ?string { DB::select('SELECT 1'); return null; } private function checkRedis(): ?string { $reply = Redis::connection()->ping(); // phpredis answers true (or '+PONG'), predis a Status object. return ($reply === true || stripos((string) $reply, 'PONG') !== false) ? null : 'unexpected reply to PING: ' . var_export((string) $reply, true); } private function checkElasticsearch(): ?string { $host = config('elasticsearch.host'); if (! $host) { // Not every installation runs search. return null; } $url = (str_contains($host, '://') ? $host : 'http://' . $host) . '/_cluster/health'; $request = Http::timeout(10); if (config('elasticsearch.user')) { $request = $request->withBasicAuth(config('elasticsearch.user'), (string) config('elasticsearch.password')); } $response = $request->get($url); if (! $response->successful()) { return 'HTTP ' . $response->status(); } // Yellow is normal on a single node (replicas cannot be placed); red // means data is unavailable. $status = $response->json('status'); return $status === 'red' ? 'cluster status red' : null; } private function checkWebsite(): ?string { $url = timebank_config('health_monitor.website_url') ?: config('app.url'); // Over the public URL on purpose: this goes through Apache, PHP, the // database and the TLS certificate, as a visitor's request does. $response = Http::timeout(20)->get($url); return $response->status() === 200 ? null : "{$url} answered HTTP {$response->status()}"; } private function checkDisk(): ?string { $path = base_path(); $total = @disk_total_space($path); $free = @disk_free_space($path); if (! $total || $free === false) { return "cannot read disk space for {$path}"; } $used = (int) round(100 * (1 - $free / $total)); $limit = (int) timebank_config('health_monitor.disk_max_percent', 90); // Above 90% Elasticsearch also stops allocating shards. return $used >= $limit ? "{$used}% used (limit {$limit}%)" : null; } /** Report to healthchecks.io: a ping when healthy, /fail with the reasons otherwise. */ private function report(array $failures): void { $pingUrl = timebank_config('health_monitor.healthcheck_url'); if (! $pingUrl) { $this->comment('No health_monitor.healthcheck_url set - nothing reported.'); return; } if ($this->option('dry-run')) { $this->comment('Dry run - not reported to healthchecks.io.'); return; } $url = rtrim($pingUrl, '/') . (empty($failures) ? '' : '/fail'); $body = empty($failures) ? 'All checks passed.' : implode("\n", $failures); try { // healthchecks.io shows the body in its alert, so the email says // what failed. Http::timeout(10)->withBody($body, 'text/plain')->post($url); } catch (\Throwable $e) { // Not reaching healthchecks.io ends as a missed ping, which it // alerts on itself after the grace period. Log::error('[health-monitor] Could not reach healthchecks.io: ' . $e->getMessage()); $this->error('Could not reach healthchecks.io: ' . $e->getMessage()); } } }