Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[9.x] Improve exception logging #35723

Closed
wants to merge 3 commits into from

Conversation

levacic
Copy link
Contributor

@levacic levacic commented Dec 26, 2020

Preface

As the explanation seems a bit long, I'd just like to highlight the important fact that Laravel already does this exact thing when logging exceptions in Illuminate\Foundation\Exceptions\Handler::report() which was introduced via #19698 - this just makes logging exceptions via e.g. Log::error($exception) be consistent, which results in other benefits described further below.


Supersedes #35722

Here's the commit message from the second commit (the first is just an aesthetic change to make the second commit more readable - feel free to squash if needed):

Add special handling when logging exceptions

Previously, logged exceptions would be passed down to Illuminate\Monolog\Logger which forcibly casts them to strings, resulting in the __toString() method being called on them - which is inherited from Exception, which prints a stack trace. This stack trace ends up being used as the log message itself.

This change makes it so that when an exception is logged, the log message becomes a shorter and usually more readable string, which is the return value of the getMessage() method on the exception object, and passes the exception object itself as the exception key of the log message context, but only if that key wasn't already manually set by the user when logging the exception.

Monolog's LineFormatter will already process exceptions found in the context to include a nice stack trace, if the includeStacktraces option is turned on (which it is by default in Laravel).

This means that no information should be lost, unless someone is explicitly relying on the current behavior and/or making uncommon logging configuration changes.

Furthermore, this enables userland code to attach additional processors to the wrapped Monolog instance and include additional custom processing on the logged exception objects if desired.

Ref laravel/ideas#2449 (comment)

P.S. I'll attach a before and after in a comment below.

No semantic changes in this commit, it's just a style change to make the next
commit's diff easier to read, and make the final code more consistent.
-
[`Illuminate\Foundation\Exceptions\Handler::report()`](https://github.com/laravel/framework/blob/08303c7cbaa0b3271060ce315160d0722b2a78f0/src/Illuminate/Foundation/Exceptions/Handler.php#L233-L240)
already does this when logging caught exceptions
- This is well-defined behavior as per
[PSR-3](https://www.php-fig.org/psr/psr-3/#13-context)

Previously, logged exceptions would be passed down to
`Illuminate\Monolog\Logger` which forcibly casts them to strings, resulting in
the `__toString()` method being called on them - which is inherited from
[Exception](https://www.php.net/manual/en/exception.tostring.php), which prints
a stack trace. This stack trace ends up being used as the log message itself.

This change makes it so that when an exception is logged, the log message
becomes a shorter and usually more readable string, which is the return value of
the `getMessage()` method on the exception object, and passes the exception
object itself as the `exception` key of the log message context, but only if
that key wasn't already manually set by the user when logging the exception.

Monolog's
[`LineFormatter`](https://github.com/Seldaek/monolog/blob/78bd7bd33313c3a7ad1f2b0fc0c11a203d4e3826/src/Monolog/Formatter/LineFormatter.php#L168-L195)
will already process exceptions found in the context to include a nice stack
trace, if the `includeStacktraces` option is turned on (which it is by default
in Laravel).

This means that no information should be lost, unless someone is explicitly
relying on the current behavior and/or making uncommon logging configuration
changes.

Furthermore, this enables userland code to attach additional processors to the
wrapped Monolog instance and include additional custom processing on the logged
exception objects if desired.

Ref laravel/ideas#2449 (comment)
@levacic
Copy link
Contributor Author

levacic commented Dec 26, 2020

Before and after examples from what gets logged into laravel.log in a completely clean laravel/laravel install, by executing the following commands in php artisan tinker:

$e1 = new InvalidArgumentException('A bad argument was supplied.');
$e2 = new RuntimeException('An error has occurred.', 0, $e1);
Log::error($e2);

Before

[2020-12-26 16:25:24] local.ERROR: InvalidArgumentException: A bad argument was supplied. in /home/levacic/development/laravel/vendor/psy/psysh/src/ExecutionLoopClosure.php(55) : eval()'d code:1
Stack trace:
#0 /home/levacic/development/laravel/vendor/psy/psysh/src/ExecutionLoopClosure.php(55): eval()
#1 /home/levacic/development/laravel/vendor/psy/psysh/src/ExecutionClosure.php(96): Psy\{closure}()
#2 /home/levacic/development/laravel/vendor/psy/psysh/src/Shell.php(370): Psy\ExecutionClosure->execute()
#3 /home/levacic/development/laravel/vendor/psy/psysh/src/Shell.php(341): Psy\Shell->doInteractiveRun()
#4 /home/levacic/development/laravel/vendor/symfony/console/Application.php(166): Psy\Shell->doRun()
#5 /home/levacic/development/laravel/vendor/psy/psysh/src/Shell.php(316): Symfony\Component\Console\Application->run()
#6 /home/levacic/development/laravel/vendor/laravel/tinker/src/Console/TinkerCommand.php(81): Psy\Shell->run()
#7 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(36): Laravel\Tinker\Console\TinkerCommand->handle()
#8 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/Util.php(40): Illuminate\Container\BoundMethod::Illuminate\Container\{closure}()
#9 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(93): Illuminate\Container\Util::unwrapIfClosure()
#10 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(37): Illuminate\Container\BoundMethod::callBoundMethod()
#11 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/Container.php(610): Illuminate\Container\BoundMethod::call()
#12 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Console/Command.php(136): Illuminate\Container\Container->call()
#13 /home/levacic/development/laravel/vendor/symfony/console/Command/Command.php(255): Illuminate\Console\Command->execute()
#14 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Console/Command.php(121): Symfony\Component\Console\Command\Command->run()
#15 /home/levacic/development/laravel/vendor/symfony/console/Application.php(971): Illuminate\Console\Command->run()
#16 /home/levacic/development/laravel/vendor/symfony/console/Application.php(290): Symfony\Component\Console\Application->doRunCommand()
#17 /home/levacic/development/laravel/vendor/symfony/console/Application.php(166): Symfony\Component\Console\Application->doRun()
#18 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Console/Application.php(93): Symfony\Component\Console\Application->run()
#19 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Console/Kernel.php(129): Illuminate\Console\Application->run()
#20 /home/levacic/development/laravel/artisan(37): Illuminate\Foundation\Console\Kernel->handle()
#21 {main}

Next RuntimeException: An error has occurred. in /home/levacic/development/laravel/vendor/psy/psysh/src/ExecutionLoopClosure.php(55) : eval()'d code:1
Stack trace:
#0 /home/levacic/development/laravel/vendor/psy/psysh/src/ExecutionLoopClosure.php(55): eval()
#1 /home/levacic/development/laravel/vendor/psy/psysh/src/ExecutionClosure.php(96): Psy\{closure}()
#2 /home/levacic/development/laravel/vendor/psy/psysh/src/Shell.php(370): Psy\ExecutionClosure->execute()
#3 /home/levacic/development/laravel/vendor/psy/psysh/src/Shell.php(341): Psy\Shell->doInteractiveRun()
#4 /home/levacic/development/laravel/vendor/symfony/console/Application.php(166): Psy\Shell->doRun()
#5 /home/levacic/development/laravel/vendor/psy/psysh/src/Shell.php(316): Symfony\Component\Console\Application->run()
#6 /home/levacic/development/laravel/vendor/laravel/tinker/src/Console/TinkerCommand.php(81): Psy\Shell->run()
#7 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(36): Laravel\Tinker\Console\TinkerCommand->handle()
#8 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/Util.php(40): Illuminate\Container\BoundMethod::Illuminate\Container\{closure}()
#9 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(93): Illuminate\Container\Util::unwrapIfClosure()
#10 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(37): Illuminate\Container\BoundMethod::callBoundMethod()
#11 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/Container.php(610): Illuminate\Container\BoundMethod::call()
#12 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Console/Command.php(136): Illuminate\Container\Container->call()
#13 /home/levacic/development/laravel/vendor/symfony/console/Command/Command.php(255): Illuminate\Console\Command->execute()
#14 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Console/Command.php(121): Symfony\Component\Console\Command\Command->run()
#15 /home/levacic/development/laravel/vendor/symfony/console/Application.php(971): Illuminate\Console\Command->run()
#16 /home/levacic/development/laravel/vendor/symfony/console/Application.php(290): Symfony\Component\Console\Application->doRunCommand()
#17 /home/levacic/development/laravel/vendor/symfony/console/Application.php(166): Symfony\Component\Console\Application->doRun()
#18 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Console/Application.php(93): Symfony\Component\Console\Application->run()
#19 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Console/Kernel.php(129): Illuminate\Console\Application->run()
#20 /home/levacic/development/laravel/artisan(37): Illuminate\Foundation\Console\Kernel->handle()
#21 {main}

After

[2020-12-26 16:24:50] local.ERROR: An error has occurred. {"exception":"[object] (RuntimeException(code: 0): An error has occurred. at /home/levacic/development/laravel/vendor/psy/psysh/src/ExecutionLoopClosure.php(55) : eval()'d code:1)
[stacktrace]
#0 /home/levacic/development/laravel/vendor/psy/psysh/src/ExecutionLoopClosure.php(55): eval()
#1 /home/levacic/development/laravel/vendor/psy/psysh/src/ExecutionClosure.php(96): Psy\\{closure}()
#2 /home/levacic/development/laravel/vendor/psy/psysh/src/Shell.php(370): Psy\\ExecutionClosure->execute()
#3 /home/levacic/development/laravel/vendor/psy/psysh/src/Shell.php(341): Psy\\Shell->doInteractiveRun()
#4 /home/levacic/development/laravel/vendor/symfony/console/Application.php(166): Psy\\Shell->doRun()
#5 /home/levacic/development/laravel/vendor/psy/psysh/src/Shell.php(316): Symfony\\Component\\Console\\Application->run()
#6 /home/levacic/development/laravel/vendor/laravel/tinker/src/Console/TinkerCommand.php(81): Psy\\Shell->run()
#7 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(36): Laravel\\Tinker\\Console\\TinkerCommand->handle()
#8 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/Util.php(40): Illuminate\\Container\\BoundMethod::Illuminate\\Container\\{closure}()
#9 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(93): Illuminate\\Container\\Util::unwrapIfClosure()
#10 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(37): Illuminate\\Container\\BoundMethod::callBoundMethod()
#11 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/Container.php(610): Illuminate\\Container\\BoundMethod::call()
#12 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Console/Command.php(136): Illuminate\\Container\\Container->call()
#13 /home/levacic/development/laravel/vendor/symfony/console/Command/Command.php(255): Illuminate\\Console\\Command->execute()
#14 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Console/Command.php(121): Symfony\\Component\\Console\\Command\\Command->run()
#15 /home/levacic/development/laravel/vendor/symfony/console/Application.php(971): Illuminate\\Console\\Command->run()
#16 /home/levacic/development/laravel/vendor/symfony/console/Application.php(290): Symfony\\Component\\Console\\Application->doRunCommand()
#17 /home/levacic/development/laravel/vendor/symfony/console/Application.php(166): Symfony\\Component\\Console\\Application->doRun()
#18 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Console/Application.php(93): Symfony\\Component\\Console\\Application->run()
#19 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Console/Kernel.php(129): Illuminate\\Console\\Application->run()
#20 /home/levacic/development/laravel/artisan(37): Illuminate\\Foundation\\Console\\Kernel->handle()
#21 {main}

[previous exception] [object] (InvalidArgumentException(code: 0): A bad argument was supplied. at /home/levacic/development/laravel/vendor/psy/psysh/src/ExecutionLoopClosure.php(55) : eval()'d code:1)
[stacktrace]
#0 /home/levacic/development/laravel/vendor/psy/psysh/src/ExecutionLoopClosure.php(55): eval()
#1 /home/levacic/development/laravel/vendor/psy/psysh/src/ExecutionClosure.php(96): Psy\\{closure}()
#2 /home/levacic/development/laravel/vendor/psy/psysh/src/Shell.php(370): Psy\\ExecutionClosure->execute()
#3 /home/levacic/development/laravel/vendor/psy/psysh/src/Shell.php(341): Psy\\Shell->doInteractiveRun()
#4 /home/levacic/development/laravel/vendor/symfony/console/Application.php(166): Psy\\Shell->doRun()
#5 /home/levacic/development/laravel/vendor/psy/psysh/src/Shell.php(316): Symfony\\Component\\Console\\Application->run()
#6 /home/levacic/development/laravel/vendor/laravel/tinker/src/Console/TinkerCommand.php(81): Psy\\Shell->run()
#7 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(36): Laravel\\Tinker\\Console\\TinkerCommand->handle()
#8 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/Util.php(40): Illuminate\\Container\\BoundMethod::Illuminate\\Container\\{closure}()
#9 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(93): Illuminate\\Container\\Util::unwrapIfClosure()
#10 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(37): Illuminate\\Container\\BoundMethod::callBoundMethod()
#11 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Container/Container.php(610): Illuminate\\Container\\BoundMethod::call()
#12 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Console/Command.php(136): Illuminate\\Container\\Container->call()
#13 /home/levacic/development/laravel/vendor/symfony/console/Command/Command.php(255): Illuminate\\Console\\Command->execute()
#14 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Console/Command.php(121): Symfony\\Component\\Console\\Command\\Command->run()
#15 /home/levacic/development/laravel/vendor/symfony/console/Application.php(971): Illuminate\\Console\\Command->run()
#16 /home/levacic/development/laravel/vendor/symfony/console/Application.php(290): Symfony\\Component\\Console\\Application->doRunCommand()
#17 /home/levacic/development/laravel/vendor/symfony/console/Application.php(166): Symfony\\Component\\Console\\Application->doRun()
#18 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Console/Application.php(93): Symfony\\Component\\Console\\Application->run()
#19 /home/levacic/development/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Console/Kernel.php(129): Illuminate\\Console\\Application->run()
#20 /home/levacic/development/laravel/artisan(37): Illuminate\\Foundation\\Console\\Kernel->handle()
#21 {main}
"}

Comment

As you can see, the full stack trace is still available in the log, it's just part of the context now, instead of the main log message - because by default, the context is also part of the formatting applied in LineFormatter.

This makes no significant difference to the readability of the messages in laravel.log, however it can be extremely helpful when logging to external systems (e.g. Sentry and similar) which can provide nicer formatting of the passed context.

@levacic
Copy link
Contributor Author

levacic commented Dec 26, 2020

P.S. One difference in how chained exceptions are logged by PHP's default __toString() method on exceptions, and how Monolog parses exception objects found in the log message context, is that they do it in reverse order:

  • PHP starts from the end of the chain and goes up
  • Monolog starts from the top-most/wrapper exception and goes down the chain

Monolog's approach is actually far more useful, as the first thing you see in its output is the actual exception which was logged, and is probably the place you want to look into first if you're debugging an issue.

@mfn
Copy link
Contributor

mfn commented Dec 27, 2020

I'm worried that the very first line is not an improvement at all, making it actually harder to quickly see what the culprit is.

What is see:
image

image

In fact, the "After" contains pretty much 0 usable information at the most important part 🤷‍♀️

@levacic
Copy link
Contributor Author

levacic commented Dec 27, 2020

I mean it does look useless based on my contrived example, but in practice, it's as useful as the log message itself is. In case it might not be obvious from my before/after examples, the An error has occurred. is just the message I instantiated the exception with - in practice this is going to be whichever message the actual exception has.

Examples randomly found in Laravel by searching for throw new (which could ostensibly be caught and logged like this by users):

  • Auth guard [{$name}] is not defined.
  • User must implement CanResetPassword interface.
  • Password resetter [{$name}] is not defined.

These are just the first few, and the usability of this format definitely depends on the exception message itself.

However my opinion is that an automatically generated stack trace is just not a good log message, which is meant to be short and to the point. Furthermore, from personal (ie. subjective) experience, stack traces as log messages make it harder to use external logging systems (e.g. Sentry), whereas it makes more sense for them to be part of the context.

Finally, as mentioned at the very start, this exact logging logic is what happens by default in Illuminate\Foundation\Exceptions\Handler::report() - so Laravel already does this exact thing for exceptions caught automatically by the top-level exception handler, and this seems to have been merged here: #19698

My PR just makes it so that the same logic is automatically applied when logging an exception object.

@taylorotwell
Copy link
Member

I don't see a big reason to change this to be honest.

@levacic
Copy link
Contributor Author

levacic commented Jan 3, 2021

@taylorotwell The goal was to make logging exceptions explicitly (ie. Log::error($exception)) consistent with how Laravel will already log exceptions caught by the exception handler, as currently the two will result in different output in the log file.

Without this, users need to manually always do Log::error($exception->getMessage(), ['exception' => $exception]) which is not as expressive, and complicates reading the developer's intent (ie. "I want to log this exception") with implementation details.

@GrahamCampbell
Copy link
Member

GrahamCampbell commented Jan 3, 2021

I think the old way is better. The way you are suggesting violates the logging contract. Implementations are not supposed to do anything with an object other than call its __toString method.

@levacic
Copy link
Contributor Author

levacic commented Jan 3, 2021

@GrahamCampbell That's not quite true, and I've linked to the relevant PSR-3 passage:

Every method accepts a string as the message, or an object with a __toString() method. Implementors MAY have special handling for the passed objects. If that is not the case, implementors MUST cast it to a string.

(Emphasis mine).

However, I feel I may have just overdocumented this PR in the comments, to the point where it isn't obviously clear why this would be the more useful behavior (while still being okay with PSR-3).

@GrahamCampbell
Copy link
Member

Hmm, well their phpdoc contradicts that comment. Saying only strings should be passed.

@levacic
Copy link
Contributor Author

levacic commented Jan 3, 2021

@GrahamCampbell I agree that's confusing, but I assume the phpdoc would've been string|Stringable had the PSR been written nowadays, because the explicit wording I pasted above makes it clear that stringable objects are valid arguments, and that implementors are allowed to do stuff other than just __toString() if an object is passed as a message.

Anyway, I appreciate your thoughts, and understand now that others simply don't see this as a useful improvement.

I just felt that the PR was misunderstood, because the comment by @mfn mentioned that the new output looks confusing, even though Laravel already logs exceptions precisely like this, when caught by the exception handler - this PR just adds to the consistency of the logging infrastructure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants