fix(error): harden logging, i18n, a11y and handler tests

This commit is contained in:
2026-03-24 22:00:14 +01:00
parent 44a357dfc4
commit a0d816caaa
10 changed files with 515 additions and 100 deletions

View File

@@ -2,6 +2,8 @@
namespace MintyPHP\Http;
use MintyPHP\I18n;
final class ErrorRenderer
{
/**
@@ -24,23 +26,37 @@ final class ErrorRenderer
$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'] ?? '?'));
$excClass = self::esc($exc['class'] ?? self::tr('Exception'));
$excMessage = self::esc((string) ($exc['message'] ?? self::tr('An error occurred')));
$excLocation = self::esc(self::tr(
'in %s on line %s',
self::shortenPath((string) ($exc['file'] ?? '')),
(string) ($exc['line'] ?? '?')
));
$exceptionPanel = self::renderExceptionPanel($exc);
$requestPanel = self::renderRequestPanel($req);
$environmentPanel = self::renderEnvironmentPanel($env);
$queryPanel = self::renderQueryPanel($qry);
$lang = self::esc(self::htmlLang());
$statusTextEsc = self::esc($statusText);
$requestIdLabel = self::esc(self::tr('Request ID'));
$copyRequestIdTitle = self::esc(self::tr('Copy request ID'));
$tabsLabel = self::esc(self::tr('Error details'));
$tabException = self::esc(self::tr('Exception'));
$tabRequest = self::esc(self::tr('Request'));
$tabEnvironment = self::esc(self::tr('Environment'));
$tabQueries = self::esc(self::tr('Queries'));
return <<<HTML
<!DOCTYPE html>
<html lang="en">
<html lang="{$lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$statusCode} {$statusText}</title>
<title>{$statusCode} {$statusTextEsc}</title>
<style>{$css}</style>
</head>
<body class="app-error-debug">
@@ -48,11 +64,11 @@ final class ErrorRenderer
<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>
<span class="app-error-debug-header__label">{$statusTextEsc}</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">
{$requestIdLabel}: {$requestId}
<button type="button" class="app-error-debug-copy-button" data-copy="{$requestId}" title="{$copyRequestIdTitle}" aria-label="{$copyRequestIdTitle}">
<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>
@@ -63,20 +79,20 @@ final class ErrorRenderer
<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 class="app-error-debug-summary__location">{$excLocation}</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 class="app-error-debug-tabs" role="tablist" aria-label="{$tabsLabel}">
<button type="button" id="tab-exception" class="app-error-debug-tab active" role="tab" aria-selected="true" aria-controls="panel-exception" data-panel="panel-exception" tabindex="0">{$tabException}</button>
<button type="button" id="tab-request" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-request" data-panel="panel-request" tabindex="-1">{$tabRequest}</button>
<button type="button" id="tab-environment" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-environment" data-panel="panel-environment" tabindex="-1">{$tabEnvironment}</button>
<button type="button" id="tab-queries" class="app-error-debug-tab" role="tab" aria-selected="false" aria-controls="panel-queries" data-panel="panel-queries" tabindex="-1">{$tabQueries} <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>
<div id="panel-exception" class="app-error-debug-panel active" role="tabpanel" aria-labelledby="tab-exception">{$exceptionPanel}</div>
<div id="panel-request" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-request">{$requestPanel}</div>
<div id="panel-environment" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-environment">{$environmentPanel}</div>
<div id="panel-queries" class="app-error-debug-panel" role="tabpanel" aria-labelledby="tab-queries">{$queryPanel}</div>
</main>
<script>{$js}</script>
@@ -94,14 +110,22 @@ final class ErrorRenderer
$statusCode = http_response_code() ?: 500;
$requestId = self::esc($requestId);
$css = self::loadAsset(__DIR__ . '/../../web/css/pages/app-error-debug.css');
$js = self::loadAsset(__DIR__ . '/../../web/js/components/app-error-debug.js');
$lang = self::esc(self::htmlLang());
$errorText = self::esc(self::tr('Error'));
$requestIdLabel = self::esc(self::tr('Request ID'));
$copyRequestIdTitle = self::esc(self::tr('Copy request ID'));
$message = self::esc(self::tr('An unexpected error occurred. If the problem persists, please contact support with the reference below.'));
$backLabel = self::esc(self::tr('Back to start'));
return <<<HTML
<!DOCTYPE html>
<html lang="en">
<html lang="{$lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{$statusCode} - Error</title>
<title>{$statusCode} - {$errorText}</title>
<style>
{$css}
.app-error-prod {
@@ -130,13 +154,14 @@ final class ErrorRenderer
<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-prod__message">{$message}</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>
{$requestIdLabel}: {$requestId}
<button type="button" class="app-error-debug-copy-button" data-copy="{$requestId}" title="{$copyRequestIdTitle}" aria-label="{$copyRequestIdTitle}"><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>
<a class="app-error-prod__back" href="/">{$backLabel}</a>
</div>
<script>{$js}</script>
</body>
</html>
HTML;
@@ -144,18 +169,18 @@ final class ErrorRenderer
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 = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">' . self::esc(self::tr('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]'));
$file = self::esc(self::shortenPath((string) ($frame['file'] ?? '[internal]')));
$line = self::esc((string) ($frame['line'] ?? '?'));
$function = '';
if (isset($frame['class'])) {
$function = self::esc($frame['class'] . '::' . ($frame['function'] ?? '?'));
$function = self::esc((string) $frame['class'] . '::' . (string) ($frame['function'] ?? '?'));
} elseif (isset($frame['function'])) {
$function = self::esc($frame['function']);
$function = self::esc((string) $frame['function']);
}
$codeHtml = '';
@@ -165,14 +190,20 @@ final class ErrorRenderer
$codeHtml = self::renderCodeBlock($snippet, $highlightLine);
}
$codeId = 'frame-code-' . $i;
$toggleId = 'frame-toggle-' . $i;
$isOpen = ($i === 0);
$expanded = $isOpen ? 'true' : 'false';
$openClass = $isOpen ? ' open' : '';
$html .= <<<FRAME
<li class="app-error-debug-frame">
<div class="app-error-debug-frame__header">
<li class="app-error-debug-frame{$openClass}">
<button type="button" id="{$toggleId}" class="app-error-debug-frame__header app-error-debug-frame__toggle" aria-expanded="{$expanded}" aria-controls="{$codeId}">
<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>
</button>
<div id="{$codeId}" class="app-error-debug-frame__code" role="region" aria-labelledby="{$toggleId}">{$codeHtml}</div>
</li>
FRAME;
}
@@ -183,10 +214,14 @@ final class ErrorRenderer
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 class="app-error-debug-chain__label">' . self::esc(self::tr('Caused by')) . '</div>';
$html .= '<div class="app-error-debug-summary__class">' . self::esc((string) ($prev['class'] ?? self::tr('Exception'))) . '</div>';
$html .= '<div class="app-error-debug-summary__message">' . self::esc((string) ($prev['message'] ?? '')) . '</div>';
$html .= '<div class="app-error-debug-summary__location">' . self::esc(self::tr(
'in %s on line %s',
self::shortenPath((string) ($prev['file'] ?? '')),
(string) ($prev['line'] ?? '?')
)) . '</div>';
$html .= '</div>';
}
@@ -214,43 +249,43 @@ final class ErrorRenderer
{
$html = '';
$html .= self::renderInfoSection('Request', [
'Method' => $requestData['method'] ?? 'GET',
'URL' => $requestData['url'] ?? '',
'IP' => $requestData['ip'] ?? '',
'User Agent' => $requestData['user_agent'] ?? '',
'Channel' => $requestData['channel'] ?? 'web',
$html .= self::renderInfoSection(self::tr('Request'), [
self::tr('Method') => $requestData['method'] ?? 'GET',
self::tr('URL') => $requestData['url'] ?? '',
self::tr('IP') => $requestData['ip'] ?? '',
self::tr('User Agent') => $requestData['user_agent'] ?? '',
self::tr('Channel') => $requestData['channel'] ?? 'web',
]);
$headers = $requestData['headers'] ?? [];
if (!empty($headers)) {
$html .= self::renderInfoSection('Headers', $headers);
$html .= self::renderInfoSection(self::tr('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),
$html .= self::renderInfoSection(self::tr('GET Parameters'), array_map(
fn ($v): string => is_string($v) ? $v : (string) 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),
$html .= self::renderInfoSection(self::tr('POST Parameters'), array_map(
fn ($v): string => is_string($v) ? $v : (string) json_encode($v, JSON_UNESCAPED_SLASHES),
$post,
));
}
$session = $requestData['session_keys'] ?? [];
if (!empty($session)) {
$html .= self::renderInfoSection('Session (keys + types)', $session);
$html .= self::renderInfoSection(self::tr('Session (keys + types)'), $session);
}
$html .= self::renderInfoSection('Context', array_filter([
'User ID' => $requestData['user_id'] ?? null,
'Tenant ID' => $requestData['tenant_id'] ?? null,
$html .= self::renderInfoSection(self::tr('Context'), array_filter([
self::tr('User ID') => $requestData['user_id'] ?? null,
self::tr('Tenant ID') => $requestData['tenant_id'] ?? null,
], fn ($v): bool => $v !== null));
return $html;
@@ -260,29 +295,31 @@ final class ErrorRenderer
{
$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),
$html .= self::renderInfoSection(self::tr('PHP'), [
self::tr('Version') => $envData['php_version'] ?? PHP_VERSION,
self::tr('SAPI') => $envData['php_sapi'] ?? PHP_SAPI,
self::tr('Memory (current)') => self::formatBytes((int) ($envData['memory_current'] ?? 0)),
self::tr('Memory (peak)') => self::formatBytes((int) ($envData['memory_peak'] ?? 0)),
]);
$env = $envData['env'] ?? [];
if (!empty($env)) {
$html .= self::renderInfoSection('Application', $env);
$html .= self::renderInfoSection(self::tr('Application'), $env);
}
$modules = $envData['modules'] ?? [];
if (!empty($modules)) {
$html .= self::renderInfoSection('Active Modules', array_combine(
$html .= self::renderInfoSection(self::tr('Active Modules'), array_combine(
array_map(fn ($m): string => (string) $m, $modules),
array_fill(0, count($modules), 'active'),
array_fill(0, count($modules), self::tr('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 class="app-error-debug-section"><div class="app-error-debug-section__title">'
. self::esc(self::tr('Loaded Extensions (%d)', 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>';
@@ -294,15 +331,17 @@ final class ErrorRenderer
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>';
return '<div class="app-error-debug-empty">' . self::esc(self::tr('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>';
return '<div class="app-error-debug-empty">' . self::esc(self::tr('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>';
$html = '<div class="app-error-debug-section"><div class="app-error-debug-section__title">'
. self::esc(self::tr('Queries (%d)', count($queries)))
. '</div>';
foreach ($queries as $i => $query) {
$sql = is_string($query) ? $query : ($query[0] ?? $query['query'] ?? '?');
@@ -312,14 +351,17 @@ final class ErrorRenderer
$duration = $durationMs . ' ms';
}
$querySqlId = 'query-sql-' . $i;
$queryToggleId = 'query-toggle-' . $i;
$html .= '<div class="app-error-debug-query">';
$html .= '<div class="app-error-debug-query__header">';
$html .= '<button type="button" id="' . self::esc($queryToggleId) . '" class="app-error-debug-query__header app-error-debug-query__toggle" aria-expanded="false" aria-controls="' . self::esc($querySqlId) . '">';
$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 .= '</button>';
$html .= '<div id="' . self::esc($querySqlId) . '" class="app-error-debug-query__sql" role="region" aria-labelledby="' . self::esc($queryToggleId) . '">' . self::esc($sql) . '</div>';
$html .= '</div>';
}
@@ -383,18 +425,18 @@ final class ErrorRenderer
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',
400 => self::tr('Bad Request'),
401 => self::tr('Unauthorized'),
403 => self::tr('Forbidden'),
404 => self::tr('Not Found'),
405 => self::tr('Method Not Allowed'),
408 => self::tr('Request Timeout'),
422 => self::tr('Unprocessable Entity'),
429 => self::tr('Too Many Requests'),
500 => self::tr('Internal Server Error'),
502 => self::tr('Bad Gateway'),
503 => self::tr('Service Unavailable'),
default => self::tr('Error'),
};
}
@@ -418,4 +460,42 @@ final class ErrorRenderer
return mb_substr($text, 0, $length) . '...';
}
private static function tr(string $text, mixed ...$args): string
{
if (function_exists('t')) {
try {
/** @var string $translated */
$translated = t($text, ...$args);
return $translated;
} catch (\Throwable) {
// Rendering must stay resilient, even if translation bootstrap failed.
}
}
if ($args === []) {
return $text;
}
return (string) @vsprintf($text, $args);
}
private static function htmlLang(): string
{
$locale = trim((string) (I18n::$locale ?? I18n::$defaultLocale ?? 'en'));
if ($locale === '') {
return 'en';
}
$locale = strtolower(str_replace('_', '-', $locale));
if (preg_match('/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/', $locale) === 1) {
return $locale;
}
if (preg_match('/^[a-z]{2,3}/', $locale, $matches) === 1) {
return $matches[0];
}
return 'en';
}
}