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:
132
lib/Http/ErrorLogger.php
Normal file
132
lib/Http/ErrorLogger.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user