forked from fa/breadcrumb-the-shire
feat: add developer error page with request ID tracking and structured logging
Custom-built error handler for the web channel that shows a tabbed debug dashboard (exception, request context, environment, SQL queries) in dev mode and a clean branded error page with copyable request ID in prod mode. All errors are logged as JSON lines to storage/logs/errors/ for lookup by request ID. Uses the project's design tokens for visual consistency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
190
lib/Http/ErrorHandler.php
Normal file
190
lib/Http/ErrorHandler.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Debugger;
|
||||
|
||||
final class ErrorHandler
|
||||
{
|
||||
/** @phpstan-ignore property.onlyWritten (Memory reserve freed on OOM to allow error rendering.) */
|
||||
private static ?string $reservedMemory = null;
|
||||
private static bool $registered = false;
|
||||
private static bool $handling = false;
|
||||
|
||||
public static function register(): void
|
||||
{
|
||||
if (self::$registered) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$registered = true;
|
||||
self::$reservedMemory = str_repeat('x', 64 * 1024); // 64 KB reserve for OOM recovery.
|
||||
|
||||
set_exception_handler([self::class, 'handleException']);
|
||||
set_error_handler([self::class, 'handleError']);
|
||||
register_shutdown_function([self::class, 'handleShutdown']);
|
||||
}
|
||||
|
||||
public static function handleException(\Throwable $exception): void
|
||||
{
|
||||
// Prevent recursive handling if the error page itself throws.
|
||||
if (self::$handling) {
|
||||
self::renderMinimalFallback($exception);
|
||||
return;
|
||||
}
|
||||
|
||||
self::$handling = true;
|
||||
|
||||
try {
|
||||
// Free reserved memory so we have room to work after OOM.
|
||||
self::$reservedMemory = null;
|
||||
|
||||
// Discard any partial output from the failed request.
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
// Set HTTP 500 if no error status has been set yet.
|
||||
$currentStatus = http_response_code();
|
||||
if ($currentStatus === false || $currentStatus < 400) {
|
||||
http_response_code(500);
|
||||
}
|
||||
|
||||
// Collect diagnostic data.
|
||||
$data = ErrorDataCollector::collect($exception);
|
||||
|
||||
// Log to file (always, in both dev and prod).
|
||||
ErrorLogger::log($exception, $data);
|
||||
|
||||
// Bail out if headers were already sent (cannot render a page).
|
||||
if (headers_sent()) {
|
||||
self::$handling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// API requests get a JSON response, not HTML.
|
||||
if (self::isApiRequest()) {
|
||||
self::sendApiError($data['request']['request_id'] ?? null);
|
||||
self::$handling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render HTML error page.
|
||||
if (self::shouldShowDebugPage()) {
|
||||
echo ErrorRenderer::renderDev($data);
|
||||
} else {
|
||||
$requestId = (string) ($data['request']['request_id'] ?? 'unknown');
|
||||
echo ErrorRenderer::renderProd($requestId);
|
||||
}
|
||||
} catch (\Throwable $inner) {
|
||||
self::renderMinimalFallback($exception);
|
||||
}
|
||||
|
||||
self::$handling = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts PHP errors into ErrorException.
|
||||
* Respects the error_reporting() bitmask (honors @ suppression operator).
|
||||
*/
|
||||
public static function handleError(int $severity, string $message, string $file, int $line): true
|
||||
{
|
||||
if (!(error_reporting() & $severity)) {
|
||||
// Error was suppressed with @.
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new \ErrorException($message, 0, $severity, $file, $line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches fatal errors that bypass set_error_handler (E_PARSE, E_COMPILE_ERROR, etc.).
|
||||
*/
|
||||
public static function handleShutdown(): void
|
||||
{
|
||||
self::$reservedMemory = null;
|
||||
|
||||
$error = error_get_last();
|
||||
if (!is_array($error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
|
||||
if (!in_array($error['type'], $fatalTypes, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only act if we haven't already handled this (e.g. via set_exception_handler).
|
||||
if (self::$handling) {
|
||||
return;
|
||||
}
|
||||
|
||||
$exception = new \ErrorException(
|
||||
$error['message'],
|
||||
0,
|
||||
$error['type'],
|
||||
$error['file'],
|
||||
$error['line'],
|
||||
);
|
||||
|
||||
self::handleException($exception);
|
||||
}
|
||||
|
||||
private static function isApiRequest(): bool
|
||||
{
|
||||
if (defined('MINTY_API_REQUEST')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$path = ltrim(parse_url($uri, PHP_URL_PATH) ?? '', '/');
|
||||
|
||||
return str_starts_with($path, 'api/');
|
||||
}
|
||||
|
||||
private static function shouldShowDebugPage(): bool
|
||||
{
|
||||
// Check the Debugger flag, which mirrors APP_DEBUG.
|
||||
if (class_exists(Debugger::class, false) && Debugger::$enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function sendApiError(?string $requestId): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$payload = [
|
||||
'ok' => false,
|
||||
'error_code' => 'internal_error',
|
||||
];
|
||||
if ($requestId !== null) {
|
||||
$payload['request_id'] = $requestId;
|
||||
}
|
||||
|
||||
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
private static function renderMinimalFallback(\Throwable $exception): void
|
||||
{
|
||||
// Last-resort plain-text response when everything else is broken.
|
||||
$requestId = 'unknown';
|
||||
try {
|
||||
$requestId = RequestContext::currentId() ?? RequestContext::id();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
if (!headers_sent()) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
}
|
||||
|
||||
echo "500 Internal Server Error\nRequest ID: {$requestId}\n";
|
||||
|
||||
// In debug mode, also show the exception for developers.
|
||||
if (class_exists(Debugger::class, false) && Debugger::$enabled) {
|
||||
echo "\n" . $exception->getMessage() . "\n" . $exception->getTraceAsString() . "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user