From 9247db3ed19724e3683df39e991c109d375d04ad Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 26 Sep 2026 12:35:53 +0200 Subject: [PATCH] 164 - Log exceptions to a non-database fallback --- app/Listeners/LogExceptionToDatabase.php | 55 ++++-- .../Listeners/LogExceptionToDatabaseTest.php | 157 ++++++++++++++++++ 2 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 tests/Unit/Listeners/LogExceptionToDatabaseTest.php diff --git a/app/Listeners/LogExceptionToDatabase.php b/app/Listeners/LogExceptionToDatabase.php index 599e251f..d471424f 100644 --- a/app/Listeners/LogExceptionToDatabase.php +++ b/app/Listeners/LogExceptionToDatabase.php @@ -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' => [ - 'exception_class' => get_class($event->exception), - 'file' => $event->exception->getFile(), - 'line' => $event->exception->getLine(), - 'trace' => $event->exception->getTraceAsString(), - ...$event->context, - ], + 'context' => $this->buildContext($event), ]); 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()); + } 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 + */ + 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, + ]; + } } diff --git a/tests/Unit/Listeners/LogExceptionToDatabaseTest.php b/tests/Unit/Listeners/LogExceptionToDatabaseTest.php new file mode 100644 index 00000000..db17cfb9 --- /dev/null +++ b/tests/Unit/Listeners/LogExceptionToDatabaseTest.php @@ -0,0 +1,157 @@ +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 + */ + 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'); + }); + } +}