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:
2026-03-24 20:49:22 +01:00
parent c34e62d729
commit ad9101df8e
11 changed files with 1996 additions and 0 deletions

View File

@@ -0,0 +1,348 @@
<?php
namespace MintyPHP\Http;
use MintyPHP\Debugger;
final class ErrorDataCollector
{
private const CODE_CONTEXT_LINES = 10;
private const MAX_TRACE_FRAMES = 50;
private const REDACTED_HEADER_PATTERNS = [
'authorization',
'cookie',
'x-api-key',
];
private const REDACTED_KEY_PATTERNS = [
'password',
'secret',
'token',
'key',
'csrf',
];
private const SAFE_ENV_KEYS = [
'APP_DEBUG',
'APP_ENV',
'APP_NAME',
'APP_TIMEZONE',
'APP_LOCALE',
'APP_URL',
'TENANT_SCOPE_STRICT',
];
/**
* @return array{exception: array<string, mixed>, request: array<string, mixed>, environment: array<string, mixed>, queries: array<string, mixed>}
*/
public static function collect(\Throwable $exception): array
{
return [
'exception' => self::collectException($exception),
'request' => self::collectRequest(),
'environment' => self::collectEnvironment(),
'queries' => self::collectQueries(),
];
}
/**
* @return array<string, mixed>
*/
private static function collectException(\Throwable $exception): array
{
$data = [
'class' => get_class($exception),
'message' => $exception->getMessage(),
'code' => $exception->getCode(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => self::collectStackFrames($exception),
];
$previous = $exception->getPrevious();
if ($previous !== null) {
$data['previous'] = self::collectException($previous);
}
return $data;
}
/**
* @return list<array<string, mixed>>
*/
private static function collectStackFrames(\Throwable $exception): array
{
$frames = [];
// Frame 0 is the exception origin itself.
$frames[] = [
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'function' => null,
'class' => null,
'code_snippet' => self::extractCodeSnippet($exception->getFile(), $exception->getLine()),
];
foreach (array_slice($exception->getTrace(), 0, self::MAX_TRACE_FRAMES) as $frame) {
$file = $frame['file'] ?? null;
$line = $frame['line'] ?? null;
$function = $frame['function'];
$class = $frame['class'] ?? null;
$frames[] = [
'file' => $file,
'line' => $line,
'function' => $function,
'class' => $class,
'args_summary' => self::summarizeArgs($frame['args'] ?? []),
'code_snippet' => ($file !== null && $line !== null)
? self::extractCodeSnippet($file, $line)
: [],
];
}
return $frames;
}
/**
* @return array<int, string> Line number => code line
*/
private static function extractCodeSnippet(string $file, int $line): array
{
try {
if (!is_file($file) || !is_readable($file)) {
return [];
}
$lines = @file($file, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
return [];
}
$start = max(0, $line - self::CODE_CONTEXT_LINES - 1);
$end = min(count($lines), $line + self::CODE_CONTEXT_LINES);
$snippet = [];
for ($i = $start; $i < $end; $i++) {
$snippet[$i + 1] = $lines[$i];
}
return $snippet;
} catch (\Throwable) {
return [];
}
}
/**
* @param list<mixed> $args
* @return list<string>
*/
private static function summarizeArgs(array $args): array
{
$summary = [];
foreach ($args as $arg) {
$summary[] = match (true) {
is_null($arg) => 'null',
is_bool($arg) => $arg ? 'true' : 'false',
is_int($arg) => 'int',
is_float($arg) => 'float',
is_string($arg) => 'string(' . strlen($arg) . ')',
is_array($arg) => 'array(' . count($arg) . ')',
is_object($arg) => get_class($arg),
is_resource($arg) => 'resource',
default => 'unknown',
};
}
return $summary;
}
/**
* @return array<string, mixed>
*/
private static function collectRequest(): array
{
$data = [
'request_id' => null,
'channel' => 'web',
'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
'url' => ($_SERVER['REQUEST_URI'] ?? ''),
'ip' => $_SERVER['REMOTE_ADDR'] ?? null,
'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
'headers' => self::collectHeaders(),
'get' => $_GET,
'post' => self::redactParams($_POST),
'session_keys' => [],
'user_id' => null,
'tenant_id' => null,
];
// Safe request context access.
try {
$data['request_id'] = RequestContext::currentId() ?? RequestContext::id();
} catch (\Throwable) {
// RequestContext may not be initialized.
}
try {
$ctx = RequestContext::context();
$data['channel'] = $ctx['channel'] ?? 'web';
} catch (\Throwable) {
}
// Session data — keys and types only, never values.
if (session_status() === PHP_SESSION_ACTIVE) {
$data['session_keys'] = self::describeSessionKeys($_SESSION ?? []);
$data['user_id'] = $_SESSION['user']['id'] ?? $_SESSION['user_id'] ?? null;
$data['tenant_id'] = $_SESSION['current_tenant']['id'] ?? $_SESSION['tenant_id'] ?? null;
}
return $data;
}
/**
* @return array<string, string>
*/
private static function collectHeaders(): array
{
$headers = [];
foreach ($_SERVER as $key => $value) {
if (!str_starts_with($key, 'HTTP_')) {
continue;
}
$name = strtolower(str_replace('_', '-', substr($key, 5)));
$headers[$name] = self::shouldRedactHeader($name) ? '[REDACTED]' : (string) $value;
}
return $headers;
}
private static function shouldRedactHeader(string $name): bool
{
$lower = strtolower($name);
foreach (self::REDACTED_HEADER_PATTERNS as $pattern) {
if (str_contains($lower, $pattern)) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
private static function redactParams(array $params): array
{
$result = [];
foreach ($params as $key => $value) {
$result[$key] = self::shouldRedactParam((string) $key) ? '[REDACTED]' : $value;
}
return $result;
}
private static function shouldRedactParam(string $key): bool
{
$lower = strtolower($key);
foreach (self::REDACTED_KEY_PATTERNS as $pattern) {
if (str_contains($lower, $pattern)) {
return true;
}
}
return false;
}
/**
* @return array<string, string>
*/
private static function describeSessionKeys(array $session): array
{
$result = [];
foreach ($session as $key => $value) {
$result[(string) $key] = get_debug_type($value);
}
return $result;
}
/**
* @return array<string, mixed>
*/
private static function collectEnvironment(): array
{
$data = [
'php_version' => PHP_VERSION,
'php_sapi' => PHP_SAPI,
'extensions' => get_loaded_extensions(),
'memory_current' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
'env' => self::collectSafeEnv(),
'modules' => [],
];
// Active modules — may fail if container is unavailable.
try {
if (function_exists('app')) {
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
$modules = $registry->getModules();
$data['modules'] = array_map(
fn (object $m): string => $m->id ?? (string) $m,
$modules,
);
}
} catch (\Throwable) {
}
return $data;
}
/**
* @return array<string, string>
*/
private static function collectSafeEnv(): array
{
$result = [];
foreach (self::SAFE_ENV_KEYS as $key) {
$value = $_ENV[$key] ?? $_SERVER[$key] ?? null;
if ($value !== null) {
$result[$key] = (string) $value;
}
}
return $result;
}
/**
* @return array<string, mixed>
*/
private static function collectQueries(): array
{
try {
if (!class_exists(Debugger::class, false) || !Debugger::$enabled) {
return ['available' => false, 'queries' => [], 'start' => null];
}
$queries = Debugger::get('queries');
$start = Debugger::get('start');
if (!is_array($queries)) {
$queries = [];
}
return [
'available' => true,
'queries' => $queries,
'start' => is_float($start) ? $start : null,
'count' => count($queries),
];
} catch (\Throwable) {
return ['available' => false, 'queries' => [], 'start' => null];
}
}
}

190
lib/Http/ErrorHandler.php Normal file
View 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";
}
}
}

132
lib/Http/ErrorLogger.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
namespace MintyPHP\Http;
final class ErrorLogger
{
private const LOG_DIR = 'storage/logs/errors';
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
private const MAX_ROTATED_FILES = 5;
public static function log(\Throwable $exception, array $collectedData): void
{
try {
$logDir = self::ensureLogDirectory();
if ($logDir === null) {
return;
}
$logFile = $logDir . '/error-' . date('Y-m-d') . '.log';
self::rotateIfNeeded($logFile);
$entry = self::buildLogEntry($exception, $collectedData);
$json = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
$json = json_encode(['error' => 'Failed to encode log entry', 'request_id' => $entry['request_id'] ?? 'unknown']);
}
file_put_contents($logFile, $json . "\n", FILE_APPEND | LOCK_EX);
} catch (\Throwable) {
// Logging must never cause secondary failures.
}
}
private static function ensureLogDirectory(): ?string
{
$dir = self::LOG_DIR;
if (is_dir($dir)) {
return $dir;
}
if (!@mkdir($dir, 0o775, true) && !is_dir($dir)) {
return null;
}
return $dir;
}
private static function rotateIfNeeded(string $filePath): void
{
if (!is_file($filePath)) {
return;
}
$size = @filesize($filePath);
if ($size === false || $size < self::MAX_FILE_SIZE) {
return;
}
// Find the next available rotation slot.
for ($i = self::MAX_ROTATED_FILES; $i >= 2; $i--) {
$older = $filePath . '.' . $i;
$newer = $filePath . '.' . ($i - 1);
if (is_file($newer)) {
@rename($newer, $older);
}
}
@rename($filePath, $filePath . '.1');
}
/**
* @param array{exception?: array, request?: array, environment?: array, queries?: array} $collectedData
* @return array<string, mixed>
*/
private static function buildLogEntry(\Throwable $exception, array $collectedData): array
{
$exceptionData = $collectedData['exception'] ?? [];
$requestData = $collectedData['request'] ?? [];
$envData = $collectedData['environment'] ?? [];
$traceSummary = '';
$frames = $exceptionData['trace'] ?? [];
foreach (array_slice($frames, 0, 10) as $i => $frame) {
$file = $frame['file'] ?? '?';
$line = $frame['line'] ?? '?';
$traceSummary .= "#{$i} {$file}:{$line} ";
}
return [
'timestamp' => date('c'),
'level' => 'error',
'request_id' => $requestData['request_id'] ?? null,
'channel' => $requestData['channel'] ?? 'web',
'exception' => [
'class' => $exceptionData['class'] ?? get_class($exception),
'message' => $exceptionData['message'] ?? $exception->getMessage(),
'code' => $exceptionData['code'] ?? $exception->getCode(),
'file' => self::shortenPath($exceptionData['file'] ?? $exception->getFile()),
'line' => $exceptionData['line'] ?? $exception->getLine(),
],
'trace_summary' => trim($traceSummary),
'request' => [
'method' => $requestData['method'] ?? null,
'url' => $requestData['url'] ?? null,
'ip_hash' => self::hashIp($requestData['ip'] ?? null),
'user_id' => $requestData['user_id'] ?? null,
'tenant_id' => $requestData['tenant_id'] ?? null,
],
'php_version' => $envData['php_version'] ?? PHP_VERSION,
'memory_peak_mb' => round(($envData['memory_peak'] ?? memory_get_peak_usage(true)) / 1024 / 1024, 1),
];
}
private static function hashIp(?string $ip): ?string
{
if ($ip === null || $ip === '') {
return null;
}
return 'sha256:' . hash('sha256', $ip);
}
private static function shortenPath(string $path): string
{
$root = getcwd();
if ($root !== false && str_starts_with($path, $root . '/')) {
return substr($path, strlen($root) + 1);
}
return $path;
}
}

421
lib/Http/ErrorRenderer.php Normal file
View File

@@ -0,0 +1,421 @@
<?php
namespace MintyPHP\Http;
final class ErrorRenderer
{
/**
* Renders the full developer debug page with all four panels.
*
* @param array{exception: array<string, mixed>, request: array<string, mixed>, environment: array<string, mixed>, queries: array<string, mixed>} $data
*/
public static function renderDev(array $data): string
{
$exc = $data['exception'];
$req = $data['request'];
$env = $data['environment'];
$qry = $data['queries'];
$requestId = self::esc((string) ($req['request_id'] ?? 'unknown'));
$statusCode = http_response_code() ?: 500;
$statusText = self::statusText($statusCode);
$queryCount = $qry['count'] ?? count($qry['queries'] ?? []);
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
$js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js');
$excClass = self::esc($exc['class'] ?? 'Exception');
$excMessage = self::esc($exc['message'] ?? 'An error occurred');
$excFile = self::esc(self::shortenPath($exc['file'] ?? ''));
$excLine = self::esc((string) ($exc['line'] ?? '?'));
$exceptionPanel = self::renderExceptionPanel($exc);
$requestPanel = self::renderRequestPanel($req);
$environmentPanel = self::renderEnvironmentPanel($env);
$queryPanel = self::renderQueryPanel($qry);
return <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$statusCode} {$statusText}</title>
<style>{$css}</style>
</head>
<body class="app-error-debug">
<header class="app-error-debug-header">
<div class="app-error-debug-container app-error-debug-header__inner">
<div class="app-error-debug-header__status">
<span class="app-error-debug-header__code">{$statusCode}</span>
<span class="app-error-debug-header__label">{$statusText}</span>
</div>
<div class="app-error-debug-request-id">
Request ID: {$requestId}
<button class="app-error-debug-copy-button" data-copy="{$requestId}" title="Copy request ID">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg>
</button>
</div>
</div>
</header>
<main class="app-error-debug-container">
<div class="app-error-debug-summary">
<div class="app-error-debug-summary__class">{$excClass}</div>
<div class="app-error-debug-summary__message">{$excMessage}</div>
<div class="app-error-debug-summary__location">in {$excFile} on line {$excLine}</div>
</div>
<nav class="app-error-debug-tabs">
<button class="app-error-debug-tab active" data-panel="panel-exception">Exception</button>
<button class="app-error-debug-tab" data-panel="panel-request">Request</button>
<button class="app-error-debug-tab" data-panel="panel-environment">Environment</button>
<button class="app-error-debug-tab" data-panel="panel-queries">Queries <span class="app-error-debug-tab__count">{$queryCount}</span></button>
</nav>
<div id="panel-exception" class="app-error-debug-panel active">{$exceptionPanel}</div>
<div id="panel-request" class="app-error-debug-panel">{$requestPanel}</div>
<div id="panel-environment" class="app-error-debug-panel">{$environmentPanel}</div>
<div id="panel-queries" class="app-error-debug-panel">{$queryPanel}</div>
</main>
<script>{$js}</script>
</body>
</html>
HTML;
}
/**
* Renders a clean production error page with request ID only.
* Uses the same design tokens as the dev page for visual consistency.
*/
public static function renderProd(string $requestId): string
{
$statusCode = http_response_code() ?: 500;
$requestId = self::esc($requestId);
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
return <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$statusCode} - Error</title>
<style>
{$css}
.app-error-prod {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 100dvh; padding: calc(var(--app-spacing) * 2); text-align: center;
font-family: var(--app-font-family); background: var(--app-background-color); color: var(--app-color);
}
.app-error-prod__code {
font-size: var(--text-5xl); font-weight: var(--font-bold);
line-height: var(--leading-none); color: var(--app-muted-color);
}
.app-error-prod__message {
margin-top: var(--app-spacing); font-size: var(--text-lg);
color: var(--app-color); max-width: 28rem;
}
.app-error-prod__back {
display: inline-block; margin-top: calc(var(--app-spacing) * 2);
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 1.25);
font-size: var(--text-sm); text-decoration: none;
border: 1px solid var(--app-border); border-radius: var(--app-border-radius);
color: var(--app-color); background: var(--app-card-background-color);
}
.app-error-prod__back:hover { opacity: 0.8; }
</style>
</head>
<body>
<div class="app-error-prod">
<div class="app-error-prod__code">{$statusCode}</div>
<div class="app-error-prod__message">An unexpected error occurred. If the problem persists, please contact support with the reference below.</div>
<div class="app-error-debug-request-id" style="margin-top: calc(var(--app-spacing) * 1.5);">
{$requestId}
<button class="app-error-debug-copy-button" onclick="navigator.clipboard?.writeText('{$requestId}')" title="Copy"><svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg></button>
</div>
<a class="app-error-prod__back" href="/">Back to start</a>
</div>
</body>
</html>
HTML;
}
private static function renderExceptionPanel(array $exceptionData): string
{
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">Stack Trace</div>';
$html .= '<ol class="app-error-debug-trace">';
$frames = $exceptionData['trace'] ?? [];
foreach ($frames as $i => $frame) {
$file = self::esc(self::shortenPath($frame['file'] ?? '[internal]'));
$line = self::esc((string) ($frame['line'] ?? '?'));
$function = '';
if (isset($frame['class'])) {
$function = self::esc($frame['class'] . '::' . ($frame['function'] ?? '?'));
} elseif (isset($frame['function'])) {
$function = self::esc($frame['function']);
}
$codeHtml = '';
$snippet = $frame['code_snippet'] ?? [];
if (!empty($snippet)) {
$highlightLine = (int) ($frame['line'] ?? 0);
$codeHtml = self::renderCodeBlock($snippet, $highlightLine);
}
$html .= <<<FRAME
<li class="app-error-debug-frame">
<div class="app-error-debug-frame__header">
<span class="app-error-debug-frame__index">#{$i}</span>
<span class="app-error-debug-frame__file">{$file}</span>:<span class="app-error-debug-frame__line">{$line}</span>
<span class="app-error-debug-frame__function">{$function}</span>
</div>
<div class="app-error-debug-frame__code">{$codeHtml}</div>
</li>
FRAME;
}
$html .= '</ol></div>';
// Chained exceptions.
if (isset($exceptionData['previous'])) {
$prev = $exceptionData['previous'];
$html .= '<div class="app-error-debug-chain">';
$html .= '<div class="app-error-debug-chain__label">Caused by</div>';
$html .= '<div class="app-error-debug-summary__class">' . self::esc($prev['class'] ?? 'Exception') . '</div>';
$html .= '<div class="app-error-debug-summary__message">' . self::esc($prev['message'] ?? '') . '</div>';
$html .= '<div class="app-error-debug-summary__location">in ' . self::esc(self::shortenPath($prev['file'] ?? '')) . ' on line ' . self::esc((string) ($prev['line'] ?? '?')) . '</div>';
$html .= '</div>';
}
return $html;
}
/**
* @param array<int, string> $lines
*/
private static function renderCodeBlock(array $lines, int $highlightLine): string
{
$html = '<table class="app-error-debug-code-table">';
foreach ($lines as $lineNo => $code) {
$isHighlight = ($lineNo === $highlightLine) ? ' highlight' : '';
$lineNoEsc = self::esc((string) $lineNo);
$codeEsc = self::esc($code);
$html .= "<tr class=\"{$isHighlight}\"><td class=\"app-error-debug-code-table__line-no\">{$lineNoEsc}</td><td class=\"app-error-debug-code-table__code\">{$codeEsc}</td></tr>";
}
$html .= '</table>';
return $html;
}
private static function renderRequestPanel(array $requestData): string
{
$html = '';
$html .= self::renderInfoSection('Request', [
'Method' => $requestData['method'] ?? 'GET',
'URL' => $requestData['url'] ?? '',
'IP' => $requestData['ip'] ?? '',
'User Agent' => $requestData['user_agent'] ?? '',
'Channel' => $requestData['channel'] ?? 'web',
]);
$headers = $requestData['headers'] ?? [];
if (!empty($headers)) {
$html .= self::renderInfoSection('Headers', $headers);
}
$get = $requestData['get'] ?? [];
if (!empty($get)) {
$html .= self::renderInfoSection('GET Parameters', array_map(
fn ($v): string => is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_SLASHES),
$get,
));
}
$post = $requestData['post'] ?? [];
if (!empty($post)) {
$html .= self::renderInfoSection('POST Parameters', array_map(
fn ($v): string => is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_SLASHES),
$post,
));
}
$session = $requestData['session_keys'] ?? [];
if (!empty($session)) {
$html .= self::renderInfoSection('Session (keys + types)', $session);
}
$html .= self::renderInfoSection('Context', array_filter([
'User ID' => $requestData['user_id'] ?? null,
'Tenant ID' => $requestData['tenant_id'] ?? null,
], fn ($v): bool => $v !== null));
return $html;
}
private static function renderEnvironmentPanel(array $envData): string
{
$html = '';
$html .= self::renderInfoSection('PHP', [
'Version' => $envData['php_version'] ?? PHP_VERSION,
'SAPI' => $envData['php_sapi'] ?? PHP_SAPI,
'Memory (current)' => self::formatBytes($envData['memory_current'] ?? 0),
'Memory (peak)' => self::formatBytes($envData['memory_peak'] ?? 0),
]);
$env = $envData['env'] ?? [];
if (!empty($env)) {
$html .= self::renderInfoSection('Application', $env);
}
$modules = $envData['modules'] ?? [];
if (!empty($modules)) {
$html .= self::renderInfoSection('Active Modules', array_combine(
array_map(fn ($m): string => (string) $m, $modules),
array_fill(0, count($modules), 'active'),
) ?: []);
}
$extensions = $envData['extensions'] ?? [];
if (!empty($extensions)) {
$html .= '<div class="app-error-debug-section"><div class="app-error-debug-section__title">Loaded Extensions (' . count($extensions) . ')</div>';
$html .= '<div style="font-family: var(--app-font-family-monospace); font-size: var(--text-xs); color: var(--app-muted-color); padding: calc(var(--app-spacing) * 0.5); background: var(--app-card-background-color); border-radius: var(--app-border-radius); border: 1px solid var(--app-border);">';
$html .= self::esc(implode(', ', $extensions));
$html .= '</div></div>';
}
return $html;
}
private static function renderQueryPanel(array $queryData): string
{
if (!($queryData['available'] ?? false)) {
return '<div class="app-error-debug-empty">Query tracking is not available (Debugger disabled or not initialized).</div>';
}
$queries = $queryData['queries'] ?? [];
if (empty($queries)) {
return '<div class="app-error-debug-empty">No queries were executed before the error occurred.</div>';
}
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">Queries (' . count($queries) . ')</div>';
foreach ($queries as $i => $query) {
$sql = is_string($query) ? $query : ($query[0] ?? $query['query'] ?? '?');
$duration = '';
if (is_array($query) && isset($query[1])) {
$durationMs = round((float) $query[1] * 1000, 2);
$duration = $durationMs . ' ms';
}
$html .= '<div class="app-error-debug-query">';
$html .= '<div class="app-error-debug-query__header">';
$html .= '<span>' . self::esc(self::truncate($sql, 120)) . '</span>';
if ($duration !== '') {
$html .= '<span class="app-error-debug-query__duration">' . self::esc($duration) . '</span>';
}
$html .= '</div>';
$html .= '<div class="app-error-debug-query__sql">' . self::esc($sql) . '</div>';
$html .= '</div>';
}
$html .= '</div>';
return $html;
}
/**
* @param array<string, mixed> $items
*/
private static function renderInfoSection(string $title, array $items): string
{
if (empty($items)) {
return '';
}
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">' . self::esc($title) . '</div>';
$html .= '<table class="app-error-debug-info-table">';
foreach ($items as $key => $value) {
$keyEsc = self::esc((string) $key);
$valueStr = (string) $value;
$isRedacted = ($valueStr === '[REDACTED]');
$valueEsc = $isRedacted
? '<span class="app-error-debug-redacted">[REDACTED]</span>'
: self::esc($valueStr);
$html .= "<tr><th>{$keyEsc}</th><td>{$valueEsc}</td></tr>";
}
$html .= '</table></div>';
return $html;
}
private static function loadAsset(string $path): string
{
$resolved = realpath($path);
if ($resolved === false || !is_file($resolved)) {
return '/* asset not found: ' . basename($path) . ' */';
}
return (string) file_get_contents($resolved);
}
public static function esc(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
private static function shortenPath(string $path): string
{
$root = getcwd();
if ($root !== false && str_starts_with($path, $root . '/')) {
return substr($path, strlen($root) + 1);
}
return $path;
}
private static function statusText(int $code): string
{
return match ($code) {
400 => 'Bad Request',
401 => 'Unauthorized',
403 => 'Forbidden',
404 => 'Not Found',
405 => 'Method Not Allowed',
408 => 'Request Timeout',
422 => 'Unprocessable Entity',
429 => 'Too Many Requests',
500 => 'Internal Server Error',
502 => 'Bad Gateway',
503 => 'Service Unavailable',
default => 'Error',
};
}
private static function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . ' B';
}
if ($bytes < 1024 * 1024) {
return round($bytes / 1024, 1) . ' KB';
}
return round($bytes / (1024 * 1024), 1) . ' MB';
}
private static function truncate(string $text, int $length): string
{
if (mb_strlen($text) <= $length) {
return $text;
}
return mb_substr($text, 0, $length) . '...';
}
}