release/v1.4.3 #168
2 changed files with 201 additions and 11 deletions
|
|
@ -5,6 +5,8 @@
|
|||
use App\Events\ExceptionLogged;
|
||||
use App\Events\ExceptionOccurred;
|
||||
use App\Models\Log;
|
||||
use Illuminate\Support\Facades\Log as Logger;
|
||||
use Throwable;
|
||||
|
||||
class LogExceptionToDatabase
|
||||
{
|
||||
|
|
@ -19,20 +21,51 @@ public function handle(ExceptionOccurred $event): void
|
|||
$log = Log::create([
|
||||
'level' => $event->level,
|
||||
'message' => $message,
|
||||
'context' => [
|
||||
'context' => $this->buildContext($event),
|
||||
]);
|
||||
|
||||
ExceptionLogged::dispatch($log);
|
||||
} catch (Throwable $e) {
|
||||
$this->logToFallback($event, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The database is the thing that failed, so this must not touch it.
|
||||
*/
|
||||
private function logToFallback(ExceptionOccurred $event, Throwable $storageFailure): void
|
||||
{
|
||||
$context = [
|
||||
...$this->buildContext($event),
|
||||
'storage_failure' => $storageFailure->getMessage(),
|
||||
];
|
||||
|
||||
try {
|
||||
Logger::log($event->level->value, $event->message, $context);
|
||||
} catch (Throwable $loggerFailure) {
|
||||
error_log(sprintf(
|
||||
'%s: %s in %s:%d (database and log channel both unavailable: %s / %s)',
|
||||
get_class($event->exception),
|
||||
$event->message,
|
||||
$event->exception->getFile(),
|
||||
$event->exception->getLine(),
|
||||
$storageFailure->getMessage(),
|
||||
$loggerFailure->getMessage(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildContext(ExceptionOccurred $event): array
|
||||
{
|
||||
return [
|
||||
'exception_class' => get_class($event->exception),
|
||||
'file' => $event->exception->getFile(),
|
||||
'line' => $event->exception->getLine(),
|
||||
'trace' => $event->exception->getTraceAsString(),
|
||||
...$event->context,
|
||||
],
|
||||
]);
|
||||
|
||||
ExceptionLogged::dispatch($log);
|
||||
} catch (\Exception $e) {
|
||||
// Prevent infinite recursion by not logging this exception
|
||||
// Optionally log to file or other non-database destination
|
||||
error_log('Failed to log exception to database: '.$e->getMessage());
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
157
tests/Unit/Listeners/LogExceptionToDatabaseTest.php
Normal file
157
tests/Unit/Listeners/LogExceptionToDatabaseTest.php
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Listeners;
|
||||
|
||||
use App\Enums\LogLevelEnum;
|
||||
use App\Events\ExceptionOccurred;
|
||||
use App\Listeners\LogExceptionToDatabase;
|
||||
use App\Models\Log;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Log as Logger;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LogExceptionToDatabaseTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_persists_the_exception_to_the_database(): void
|
||||
{
|
||||
$listener = app(LogExceptionToDatabase::class);
|
||||
|
||||
$listener->handle(new ExceptionOccurred(
|
||||
new RuntimeException('boom'),
|
||||
LogLevelEnum::ERROR,
|
||||
'boom',
|
||||
));
|
||||
|
||||
$this->assertDatabaseHas('logs', [
|
||||
'level' => 'error',
|
||||
'message' => 'boom',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_logs_the_original_exception_to_the_fallback_when_the_database_fails(): void
|
||||
{
|
||||
$exception = new RuntimeException('the original failure');
|
||||
|
||||
Logger::shouldReceive('log')->once()->withArgs(
|
||||
function (string $level, string $message, array $context) use ($exception): bool {
|
||||
return $level === 'error'
|
||||
&& $message === 'the original failure'
|
||||
&& $context['exception_class'] === RuntimeException::class
|
||||
&& $context['line'] === $exception->getLine()
|
||||
&& str_contains($context['storage_failure'], 'no such table');
|
||||
}
|
||||
);
|
||||
|
||||
$this->failTheLogsTable();
|
||||
|
||||
app(LogExceptionToDatabase::class)->handle(new ExceptionOccurred(
|
||||
$exception,
|
||||
LogLevelEnum::ERROR,
|
||||
'the original failure',
|
||||
));
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function test_it_preserves_the_event_context_in_the_fallback(): void
|
||||
{
|
||||
Logger::shouldReceive('log')->once()->withArgs(
|
||||
fn (string $level, string $message, array $context): bool => $context['article_id'] === 42
|
||||
);
|
||||
|
||||
$this->failTheLogsTable();
|
||||
|
||||
app(LogExceptionToDatabase::class)->handle(new ExceptionOccurred(
|
||||
new RuntimeException('boom'),
|
||||
LogLevelEnum::ERROR,
|
||||
'boom',
|
||||
['article_id' => 42],
|
||||
));
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function test_it_does_not_truncate_the_message_in_the_fallback(): void
|
||||
{
|
||||
$message = str_repeat('a', 300);
|
||||
|
||||
Logger::shouldReceive('log')->once()->withArgs(
|
||||
fn (string $level, string $logged, array $context): bool => $logged === $message
|
||||
);
|
||||
|
||||
$this->failTheLogsTable();
|
||||
|
||||
app(LogExceptionToDatabase::class)->handle(new ExceptionOccurred(
|
||||
new RuntimeException($message),
|
||||
LogLevelEnum::ERROR,
|
||||
$message,
|
||||
));
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function test_it_swallows_a_failing_fallback_logger(): void
|
||||
{
|
||||
Logger::shouldReceive('log')->once()->andThrow(new RuntimeException('logger is down too'));
|
||||
|
||||
$this->failTheLogsTable();
|
||||
|
||||
app(LogExceptionToDatabase::class)->handle(new ExceptionOccurred(
|
||||
new RuntimeException('boom'),
|
||||
LogLevelEnum::ERROR,
|
||||
'boom',
|
||||
));
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{LogLevelEnum, string}>
|
||||
*/
|
||||
public static function levelProvider(): array
|
||||
{
|
||||
return [
|
||||
'debug' => [LogLevelEnum::DEBUG, 'debug'],
|
||||
'info' => [LogLevelEnum::INFO, 'info'],
|
||||
'warning' => [LogLevelEnum::WARNING, 'warning'],
|
||||
'error' => [LogLevelEnum::ERROR, 'error'],
|
||||
'critical' => [LogLevelEnum::CRITICAL, 'critical'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('levelProvider')]
|
||||
public function test_it_maps_every_level_to_a_psr_log_level(LogLevelEnum $level, string $expected): void
|
||||
{
|
||||
Logger::shouldReceive('log')->once()->withArgs(
|
||||
fn (string $logged, string $message, array $context): bool => $logged === $expected
|
||||
);
|
||||
|
||||
$this->failTheLogsTable();
|
||||
|
||||
app(LogExceptionToDatabase::class)->handle(new ExceptionOccurred(
|
||||
new RuntimeException('boom'),
|
||||
$level,
|
||||
'boom',
|
||||
));
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Log::flushEventListeners();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function failTheLogsTable(): void
|
||||
{
|
||||
Log::creating(function (): void {
|
||||
throw new RuntimeException('SQLSTATE[HY000]: no such table: logs');
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue