From 8c0308b6a37079cfce6e15bd0909c7d8c964423e Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Thu, 24 Sep 2026 17:28:23 +0200 Subject: [PATCH] Show the cause of an exception in bref logs Without --full, an exception showed only its class and location, for example "RuntimeException at app/Billing.php:37". The actual cause, for example the HTTP error behind it, needed --full and the stack traces. Each previous exception now shows on a "Caused by Class: message" line. Bref Cloud sends the previous exceptions without --full from the next deployment. With an earlier Bref Cloud, the output does not change. --- src/Cli/LogRenderer.php | 26 +++++++++++++++----------- src/Commands/Logs.php | 2 +- tests/Cli/LogRendererTest.php | 19 +++++++++++++++++++ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/Cli/LogRenderer.php b/src/Cli/LogRenderer.php index e42ef72..27483ec 100644 --- a/src/Cli/LogRenderer.php +++ b/src/Cli/LogRenderer.php @@ -6,7 +6,7 @@ * Renders the log records returned by Bref Cloud, which already parsed and truncated them. * * One line per record: `2026-09-23 10:12:51.863 web 45f01a ERROR message {"context"}`, - * then the exception on an indented line, if any. + * then the exception and its causes on indented lines, if any. * * @phpstan-type LogException array{class: string, message: string, file: string, frames: int, trace?: list, previous?: array} * @phpstan-type LogRecord array{timestamp: string, function: string, instance: string, level: string|null, message: string, context?: array, extra?: array, exception?: LogException} @@ -71,19 +71,23 @@ private function renderException(array $exception, bool $isPrevious = false): st // Bref's runtime errors have no file $location = $exception['file'] !== '' ? " at {$exception['file']}" : ''; - if (! $this->full) { + if ($this->full) { + $lines = [$prefix . $this->red($exception['class']) . ': ' . $this->indent($exception['message'], 2)]; + if ($location !== '') { + $lines[] = self::INDENT . ' ' . $this->gray(ltrim($location)); + } + foreach ($exception['trace'] ?? [] as $i => $frame) { + $lines[] = self::INDENT . ' ' . $this->gray("#$i $frame"); + } + } elseif ($isPrevious) { + // Its message is usually the actual cause, e.g. the HTTP error behind a RuntimeException + $lines = [$prefix . $this->red($exception['class']) . ': ' . $this->indent($exception['message'], 2)]; + } else { + // Its message is usually the log message already $frames = $exception['frames'] > 0 ? " ({$exception['frames']} frames)" : ''; - - return $prefix . $this->red($exception['class']) . $this->gray($location . $frames); + $lines = [$prefix . $this->red($exception['class']) . $this->gray($location . $frames)]; } - $lines = [$prefix . $this->red($exception['class']) . ': ' . $this->indent($exception['message'], 2)]; - if ($location !== '') { - $lines[] = self::INDENT . ' ' . $this->gray(ltrim($location)); - } - foreach ($exception['trace'] ?? [] as $i => $frame) { - $lines[] = self::INDENT . ' ' . $this->gray("#$i $frame"); - } if (isset($exception['previous'])) { /** @var LogException $previous */ $previous = $exception['previous']; diff --git a/src/Commands/Logs.php b/src/Commands/Logs.php index 9c089de..9d5e740 100644 --- a/src/Commands/Logs.php +++ b/src/Commands/Logs.php @@ -35,7 +35,7 @@ protected function configure(): void ->setHelp(<<<'HELP' Shows the logs of all the functions of an environment, oldest first. Times are in UTC. - Laravel logs show their level and context, and exceptions show their class and location + Laravel logs show their level and context, and exceptions show their class, location and cause (their stack trace with --full). Other logs are shown as they are, one line per entry. The lines that Lambda and PHP-FPM write on every invocation are hidden, --all shows them. diff --git a/tests/Cli/LogRendererTest.php b/tests/Cli/LogRendererTest.php index bb36721..79c7574 100644 --- a/tests/Cli/LogRendererTest.php +++ b/tests/Cli/LogRendererTest.php @@ -63,6 +63,25 @@ public function test_no_level_column_when_no_line_has_a_level(): void $this->assertSame(['2026-09-23 10:14:00.000 web 0f9e8d START processing batch 12 of 40'], $lines); } + public function test_the_causes_of_an_exception_are_shown_with_their_message(): void + { + $record = self::ERROR; + $record['exception']['previous'] = [ + 'class' => 'GuzzleHttp\Exception\ServerException', + 'message' => '502 Bad Gateway', + 'file' => 'vendor/guzzlehttp/guzzle/src/Middleware.php:69', + 'frames' => 0, + ]; + + $lines = (new LogRenderer(colors: false, full: false))->render([$record]); + + $this->assertSame([implode("\n", [ + '2026-09-23 10:12:51.863 web 45f01a ERROR Payment gateway returned 502', + ' ↳ RuntimeException at app/Billing.php:37 (2 frames)', + ' ↳ Caused by GuzzleHttp\Exception\ServerException: 502 Bad Gateway', + ])], $lines); + } + public function test_full_records_show_the_stack_trace_and_the_previous_exceptions(): void { $record = self::ERROR;