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:
95
tests/Http/ErrorLoggerTest.php
Normal file
95
tests/Http/ErrorLoggerTest.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Http;
|
||||
|
||||
use MintyPHP\Http\ErrorLogger;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ErrorLoggerTest extends TestCase
|
||||
{
|
||||
private string $logFile;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->logFile = 'storage/logs/errors/error-' . date('Y-m-d') . '.log';
|
||||
// Remove the file so each test starts clean.
|
||||
if (is_file($this->logFile)) {
|
||||
@unlink($this->logFile);
|
||||
}
|
||||
}
|
||||
|
||||
public function testLogWritesJsonLineEntry(): void
|
||||
{
|
||||
$exception = new \RuntimeException('Test error', 42);
|
||||
$data = [
|
||||
'exception' => [
|
||||
'class' => 'RuntimeException',
|
||||
'message' => 'Test error',
|
||||
'code' => 42,
|
||||
'file' => '/app/web/index.php',
|
||||
'line' => 10,
|
||||
'trace' => [
|
||||
['file' => '/app/web/index.php', 'line' => 10],
|
||||
],
|
||||
],
|
||||
'request' => [
|
||||
'request_id' => 'abc-123',
|
||||
'channel' => 'web',
|
||||
'method' => 'GET',
|
||||
'url' => '/admin/dashboard',
|
||||
'ip' => '127.0.0.1',
|
||||
'user_id' => 5,
|
||||
'tenant_id' => 1,
|
||||
],
|
||||
'environment' => [
|
||||
'php_version' => '8.5.0',
|
||||
'memory_peak' => 12 * 1024 * 1024,
|
||||
],
|
||||
'queries' => [],
|
||||
];
|
||||
|
||||
ErrorLogger::log($exception, $data);
|
||||
|
||||
$this->assertFileExists($this->logFile);
|
||||
|
||||
$lines = array_filter(explode("\n", (string) file_get_contents($this->logFile)));
|
||||
$this->assertCount(1, $lines);
|
||||
|
||||
$entry = json_decode($lines[0], true);
|
||||
$this->assertIsArray($entry);
|
||||
$this->assertSame('error', $entry['level']);
|
||||
$this->assertSame('abc-123', $entry['request_id']);
|
||||
$this->assertSame('RuntimeException', $entry['exception']['class']);
|
||||
$this->assertSame('Test error', $entry['exception']['message']);
|
||||
$this->assertArrayHasKey('ip_hash', $entry['request']);
|
||||
$this->assertStringStartsWith('sha256:', $entry['request']['ip_hash']);
|
||||
// Raw IP should NOT be in the log.
|
||||
$this->assertStringNotContainsString('127.0.0.1', json_encode($entry));
|
||||
}
|
||||
|
||||
public function testLogRedactsIpAddress(): void
|
||||
{
|
||||
$exception = new \RuntimeException('IP test');
|
||||
$data = [
|
||||
'request' => ['ip' => '192.168.1.42', 'request_id' => 'test-id'],
|
||||
'exception' => [],
|
||||
'environment' => [],
|
||||
];
|
||||
|
||||
ErrorLogger::log($exception, $data);
|
||||
|
||||
$this->assertFileExists($this->logFile);
|
||||
$contents = (string) file_get_contents($this->logFile);
|
||||
$this->assertStringNotContainsString('192.168.1.42', $contents);
|
||||
$this->assertStringContainsString('sha256:', $contents);
|
||||
}
|
||||
|
||||
public function testLogNeverThrowsOnFailure(): void
|
||||
{
|
||||
// Even with broken data, log() must not throw.
|
||||
$exception = new \RuntimeException('safe test');
|
||||
ErrorLogger::log($exception, []);
|
||||
|
||||
$this->assertTrue(true); // If we get here, no exception was thrown.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user