Files
moko-platform/monitoring/uptime-probe.php
T
jmiller d788400bfd
Universal: Cascade Main → Dev / Cascade main → branches (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
feat: add uptime-probe.php
Authored-by: Moko Consulting
2026-05-19 20:47:10 +00:00

296 lines
6.7 KiB
PHP

#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Scripts.Monitoring
* INGROUP: MokoStandards
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /monitoring/uptime-probe.php
* VERSION: 01.00.00
* BRIEF: Check uptime and response time for a list of URLs
*/
declare(strict_types=1);
final class UptimeProbe
{
/** @var array<string, mixed> */
private array $args = [];
/** @var array<array{url: string, status: int, time: float, result: string, error: string}> */
private array $results = [];
public function run(): int
{
$this->args = $this->parseArgs();
$urls = $this->resolveUrls();
if (count($urls) === 0) {
$this->log('No URLs provided. Use --urls <file> or --url <single-url>.');
return 1;
}
$timeout = (int) ($this->args['timeout'] ?? 15);
foreach ($urls as $url) {
$this->log("Probing: {$url}");
$entry = $this->probe($url, $timeout);
$this->results[] = $entry;
}
$hasFailure = false;
foreach ($this->results as $r) {
if ($r['result'] === 'FAIL') {
$hasFailure = true;
break;
}
}
if (!empty($this->args['json'])) {
$this->outputJson();
} else {
$this->outputTable();
}
if ($hasFailure && !empty($this->args['notify'])) {
$this->sendNotification();
}
return $hasFailure ? 1 : 0;
}
/**
* @return array<string, mixed>
*/
private function parseArgs(): array
{
$args = [
'urls' => null,
'url' => null,
'timeout' => 15,
'notify' => null,
'json' => false,
];
$argv = $_SERVER['argv'] ?? [];
$argc = count($argv);
for ($i = 1; $i < $argc; $i++) {
switch ($argv[$i]) {
case '--urls':
$args['urls'] = $argv[++$i] ?? null;
break;
case '--url':
$args['url'] = $argv[++$i] ?? null;
break;
case '--timeout':
$args['timeout'] = (int) ($argv[++$i] ?? 15);
break;
case '--notify':
$args['notify'] = $argv[++$i] ?? null;
break;
case '--json':
$args['json'] = true;
break;
}
}
return $args;
}
/**
* @return list<string>
*/
private function resolveUrls(): array
{
$urls = [];
if (!empty($this->args['urls'])) {
$file = $this->args['urls'];
if (!is_file($file) || !is_readable($file)) {
$this->log("Cannot read URL file: {$file}");
return [];
}
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines !== false) {
foreach ($lines as $line) {
$line = trim($line);
if ($line !== '' && $line[0] !== '#') {
$urls[] = $line;
}
}
}
}
if (!empty($this->args['url'])) {
$urls[] = trim($this->args['url']);
}
return $urls;
}
/**
* @return array{url: string, status: int, time: float, result: string, error: string}
*/
private function probe(string $url, int $timeout): array
{
$entry = [
'url' => $url,
'status' => 0,
'time' => 0.0,
'result' => 'FAIL',
'error' => '',
];
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => $timeout,
'follow_location' => 1,
'max_redirects' => 5,
'ignore_errors' => true,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$start = microtime(true);
$body = @file_get_contents($url, false, $context);
$elapsed = round(microtime(true) - $start, 3);
$entry['time'] = $elapsed;
if ($body === false) {
$entry['error'] = 'Connection failed or timed out';
return $entry;
}
// Parse status code from $http_response_header
$statusCode = 0;
if (isset($http_response_header) && is_array($http_response_header)) {
foreach ($http_response_header as $header) {
if (preg_match('/^HTTP\/[\d.]+ (\d{3})/', $header, $m)) {
$statusCode = (int) $m[1];
}
}
}
$entry['status'] = $statusCode;
// Check for PHP fatal errors in the response body
$fatalPatterns = [
'Fatal error:',
'Parse error:',
'Uncaught Exception',
'Uncaught Error',
'Stack trace:',
];
foreach ($fatalPatterns as $pattern) {
if (stripos($body, $pattern) !== false) {
$entry['error'] = "PHP fatal error detected in response body";
return $entry;
}
}
if ($statusCode >= 200 && $statusCode < 400) {
$entry['result'] = 'PASS';
} else {
$entry['error'] = "HTTP {$statusCode}";
}
return $entry;
}
private function outputTable(): void
{
$colUrl = 4;
$colStatus = 6;
$colTime = 6;
$colResult = 6;
foreach ($this->results as $r) {
$colUrl = max($colUrl, strlen($r['url']));
}
$colUrl = min($colUrl, 60);
$fmt = "%-{$colUrl}s | %-{$colStatus}s | %-{$colTime}s | %-{$colResult}s";
$this->log('');
$this->log(sprintf($fmt, 'URL', 'Status', 'Time', 'Result'));
$this->log(str_repeat('-', $colUrl + $colStatus + $colTime + $colResult + 10));
foreach ($this->results as $r) {
$urlDisplay = strlen($r['url']) > 60 ? substr($r['url'], 0, 57) . '...' : $r['url'];
$timeStr = $r['time'] . 's';
$statusStr = $r['status'] > 0 ? (string) $r['status'] : 'ERR';
$this->log(sprintf($fmt, $urlDisplay, $statusStr, $timeStr, $r['result']));
}
$this->log('');
}
private function outputJson(): void
{
echo json_encode($this->results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
}
private function sendNotification(): void
{
$ntfyUrl = $this->args['notify'];
if (empty($ntfyUrl)) {
return;
}
$failures = [];
foreach ($this->results as $r) {
if ($r['result'] === 'FAIL') {
$failures[] = $r['url'] . ' (' . ($r['error'] ?: 'HTTP ' . $r['status']) . ')';
}
}
$message = "Uptime probe failures:\n" . implode("\n", $failures);
$ch = curl_init($ntfyUrl);
if ($ch === false) {
$this->log('Failed to initialise curl for notification.');
return;
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $message,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Title: Uptime Probe Alert',
'Priority: high',
],
]);
$result = curl_exec($ch);
if ($result === false) {
$this->log('Notification failed: ' . curl_error($ch));
} else {
$this->log('Notification sent.');
}
curl_close($ch);
}
private function log(string $message): void
{
fwrite(STDERR, $message . "\n");
}
}
$probe = new UptimeProbe();
exit($probe->run());