Files
tinker_tickets/api/health.php
T
jaredandClaude Sonnet 5 1e972fe7dc Add memory_limit/max_execution_time sanity checks (#106)
config/requirements.php only checked PHP version and 6 extensions. A
deployment on a host with a low default memory_limit (e.g. shared-
hosting-style 128M) passed the startup requirements check cleanly and
only surfaced as a mysterious failure under real load — a large CSV
export, an oversized dashboard query on a big install.

Added min_memory_limit_mb (256) and min_max_execution_time (30s)
thresholds to config/requirements.php, checked as warnings (not hard
failures, since a low limit doesn't break every request) in both
scripts/check_requirements.php (CI) and api/health.php (production
monitoring). -1/0 (unlimited) always passes.

Verified the ini-size parsing and warning logic directly with
low/high/unlimited memory_limit and max_execution_time values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-08 11:58:20 -04:00

187 lines
5.7 KiB
PHP

<?php
/**
* Health Check Endpoint
*
* Returns system health status for monitoring tools.
* Does not require authentication - suitable for load balancer health checks.
*
* Returns:
* - 200 OK: System is healthy
* - 503 Service Unavailable: System has issues
*/
// Don't apply rate limiting to health checks - they should always respond
header('Content-Type: application/json');
header('Cache-Control: no-cache, no-store, must-revalidate');
$startTime = microtime(true);
$checks = [];
$healthy = true;
// Check 1: Database connectivity
try {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
$conn = Database::getConnection();
// Quick query to verify connection is actually working
$result = $conn->query('SELECT 1');
if ($result && $result->fetch_row()) {
$checks['database'] = [
'status' => 'ok',
'message' => 'Connected'
];
} else {
$checks['database'] = [
'status' => 'error',
'message' => 'Query failed'
];
$healthy = false;
}
} catch (Exception $e) {
$checks['database'] = [
'status' => 'error',
'message' => 'Connection failed'
];
$healthy = false;
}
// Check 2: File system (uploads directory writable)
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
if (is_dir($uploadDir) && is_writable($uploadDir)) {
$checks['filesystem'] = [
'status' => 'ok',
'message' => 'Writable'
];
} else {
$checks['filesystem'] = [
'status' => 'warning',
'message' => 'Upload directory not writable'
];
// Don't mark as unhealthy - this might be intentional
}
// Check 3: Session storage
$sessionPath = session_save_path() ?: sys_get_temp_dir();
if (is_dir($sessionPath) && is_writable($sessionPath)) {
$checks['sessions'] = [
'status' => 'ok',
'message' => 'Writable'
];
} else {
$checks['sessions'] = [
'status' => 'error',
'message' => 'Session storage not writable'
];
$healthy = false;
}
// Check 4: Rate limit storage
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
if (!is_dir($rateLimitDir)) {
@mkdir($rateLimitDir, 0755, true);
}
if (is_dir($rateLimitDir) && is_writable($rateLimitDir)) {
$checks['rate_limit'] = [
'status' => 'ok',
'message' => 'Writable'
];
} else {
$checks['rate_limit'] = [
'status' => 'warning',
'message' => 'Rate limit storage not writable'
];
}
// Check 5: Required PHP extensions (catches e.g. a PHP upgrade silently
// dropping php-ldap, which breaks avatars with no other visible error).
$requirements = require dirname(__DIR__) . '/config/requirements.php';
$missingExt = array_values(array_filter(
$requirements['required_extensions'],
fn($ext) => !extension_loaded($ext)
));
if (empty($missingExt)) {
$checks['php_extensions'] = [
'status' => 'ok',
'message' => 'All required extensions loaded'
];
} else {
$checks['php_extensions'] = [
'status' => 'error',
'message' => 'Missing extensions: ' . implode(', ', $missingExt)
];
$healthy = false;
}
// Check 6: PHP version meets the declared minimum
if (version_compare(PHP_VERSION, $requirements['min_php_version'], '>=')) {
$checks['php_version'] = [
'status' => 'ok',
'message' => PHP_VERSION
];
} else {
$checks['php_version'] = [
'status' => 'error',
'message' => sprintf('PHP %s < required %s', PHP_VERSION, $requirements['min_php_version'])
];
$healthy = false;
}
// Check 7: memory_limit / max_execution_time sanity (warnings, not fatal — a
// low default doesn't fail requests until something large actually runs, so
// surface it here rather than waiting for a mysterious failure under load).
$memLimitIni = ini_get('memory_limit');
$memLimitUnit = strtolower(substr(trim($memLimitIni), -1));
$memLimitBytes = $memLimitIni === '-1'
? -1
: (int)$memLimitIni * match ($memLimitUnit) {
'g' => 1024 * 1024 * 1024,
'm' => 1024 * 1024,
'k' => 1024,
default => 1,
};
$minMemBytes = $requirements['min_memory_limit_mb'] * 1024 * 1024;
if ($memLimitBytes === -1 || $memLimitBytes >= $minMemBytes) {
$checks['memory_limit'] = ['status' => 'ok', 'message' => $memLimitIni];
} else {
$checks['memory_limit'] = [
'status' => 'warning',
'message' => sprintf('%s is below the recommended minimum %dM', $memLimitIni, $requirements['min_memory_limit_mb'])
];
}
$maxExecTime = (int)ini_get('max_execution_time');
if ($maxExecTime === 0 || $maxExecTime >= $requirements['min_max_execution_time']) {
$checks['max_execution_time'] = ['status' => 'ok', 'message' => (string)$maxExecTime];
} else {
$checks['max_execution_time'] = [
'status' => 'warning',
'message' => sprintf('%ds is below the recommended minimum %ds', $maxExecTime, $requirements['min_max_execution_time'])
];
}
// Calculate response time
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
// Set status code
http_response_code($healthy ? 200 : 503);
// This endpoint is unauthenticated, so expose only a coarse per-component status
// and never the diagnostic messages (they leak PHP_VERSION, exact missing
// extension names, and filesystem paths to anonymous callers).
$publicChecks = [];
foreach ($checks as $name => $check) {
$publicChecks[$name] = ['status' => $check['status']];
}
// Return response
echo json_encode([
'status' => $healthy ? 'healthy' : 'unhealthy',
'timestamp' => date('c'),
'response_time_ms' => $responseTime,
'checks' => $publicChecks,
'version' => '1.0.0'
], JSON_PRETTY_PRINT);