Skip to content

Commit b0a6076

Browse files
authored
Make an educated guess if the exception actually was handled (#617)
1 parent cb21aa7 commit b0a6076

File tree

6 files changed

+157
-3
lines changed

6 files changed

+157
-3
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Fix exceptions sent via the `report()` helper being marked as unhandled (#617)
6+
57
## 3.1.1
68

79
- Fix missing scope information on unhandled exceptions (#611)

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"require": {
2424
"php": "^7.2 | ^8.0",
2525
"illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0",
26-
"sentry/sentry": "^3.10",
26+
"sentry/sentry": "^3.11",
2727
"sentry/sdk": "^3.3",
2828
"symfony/psr-http-message-bridge": "^1.0 | ^2.0",
2929
"nyholm/psr7": "^1.0"

src/Sentry/Laravel/Integration.php

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
use Sentry\Tracing\TransactionSource;
1111
use Throwable;
1212
use function Sentry\addBreadcrumb;
13-
use function Sentry\captureEvent;
1413
use function Sentry\configureScope;
1514
use Sentry\Breadcrumb;
1615
use Sentry\Event;
@@ -177,10 +176,63 @@ public static function sentryBaggageMeta(): string
177176
*/
178177
public static function captureUnhandledException(Throwable $throwable): ?EventId
179178
{
179+
// We instruct users to call `captureUnhandledException` in their exception handler, however this does not mean
180+
// the exception was actually unhandled. Laravel has the `report` helper function that is used to report to a log
181+
// file or Sentry, but that means they are handled otherwise they wouldn't have been routed through `report`. So to
182+
// prevent marking those as "unhandled" we try and make an educated guess if the call to `captureUnhandledException`
183+
// came from the `report` helper and shouldn't be marked as "unhandled" even though the come to us here to be reported
184+
$handled = self::makeAnEducatedGuessIfTheExceptionMaybeWasHandled();
185+
180186
$hint = EventHint::fromArray([
181-
'mechanism' => new ExceptionMechanism(ExceptionMechanism::TYPE_GENERIC, false),
187+
'mechanism' => new ExceptionMechanism(ExceptionMechanism::TYPE_GENERIC, $handled),
182188
]);
183189

184190
return SentrySdk::getCurrentHub()->captureException($throwable, $hint);
185191
}
192+
193+
/**
194+
* Try to make an educated guess if the call came from the Laravel `report` helper.
195+
*
196+
* @see https://github.com/laravel/framework/blob/008a4dd49c3a13343137d2bc43297e62006c7f29/src/Illuminate/Foundation/helpers.php#L667-L682
197+
*
198+
* @return bool
199+
*/
200+
private static function makeAnEducatedGuessIfTheExceptionMaybeWasHandled(): bool
201+
{
202+
// We limit the amount of backtrace frames since it is very unlikely to be any deeper
203+
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10);
204+
205+
// We are looking for `$handler->report()` to be called from the `report()` function
206+
foreach ($trace as $frameIndex => $frame) {
207+
// We need a frame with a class and function defined, we can skip frames missing either
208+
if (!isset($frame['class'], $frame['function'])) {
209+
continue;
210+
}
211+
212+
// Check if the frame was indeed `$handler->report()`
213+
if ($frame['type'] !== '->' || $frame['function'] !== 'report') {
214+
continue;
215+
}
216+
217+
// Make sure we have a next frame, we could have reached the end of the trace
218+
if (!isset($trace[$frameIndex + 1])) {
219+
continue;
220+
}
221+
222+
// The next frame should contain the call to the `report()` helper function
223+
$nextFrame = $trace[$frameIndex + 1];
224+
225+
// If a class was set or the function name is not `report` we can skip this frame
226+
if (isset($nextFrame['class']) || !isset($nextFrame['function']) || $nextFrame['function'] !== 'report') {
227+
continue;
228+
}
229+
230+
// If we reached this point we can be pretty sure the `report` function was called
231+
// and we can can come to the educated conclusion the exception was indeed handled
232+
return true;
233+
}
234+
235+
// If we reached this point we can be pretty sure the `report` function was not called
236+
return false;
237+
}
186238
}

test/Sentry/IntegrationTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
use Illuminate\Routing\Events\RouteMatched;
77
use Illuminate\Routing\Route;
88
use Mockery;
9+
use RuntimeException;
910
use Sentry\Event;
11+
use Sentry\ExceptionMechanism;
1012
use Sentry\Laravel\Integration;
1113
use Sentry\State\Scope;
1214
use Sentry\Tracing\TransactionSource;
@@ -111,6 +113,36 @@ public function testExtractingNameForRouteWithIncompleteGroupName(): void
111113
$this->assetRouteNameAndSource($route, $url, TransactionSource::route());
112114
}
113115

116+
public function testExceptionReportedUsingReportHelperIsNotMarkedAsUnhandled(): void
117+
{
118+
$testException = new RuntimeException('This was handled');
119+
120+
report($testException);
121+
122+
$this->assertCount(1, $this->lastSentryEvents);
123+
124+
[, $hint] = $this->lastSentryEvents[0];
125+
126+
$this->assertEquals($testException, $hint->exception);
127+
$this->assertNotNull($hint->mechanism);
128+
$this->assertTrue($hint->mechanism->isHandled());
129+
}
130+
131+
public function testExceptionIsNotMarkedAsUnhandled(): void
132+
{
133+
$testException = new RuntimeException('This was not handled');
134+
135+
Integration::captureUnhandledException($testException);
136+
137+
$this->assertCount(1, $this->lastSentryEvents);
138+
139+
[, $hint] = $this->lastSentryEvents[0];
140+
141+
$this->assertEquals($testException, $hint->exception);
142+
$this->assertNotNull($hint->mechanism);
143+
$this->assertFalse($hint->mechanism->isHandled());
144+
}
145+
114146
private function assetRouteNameAndSource(Route $route, string $expectedName, TransactionSource $expectedSource): void
115147
{
116148
[$actualName, $actualSource] = Integration::extractNameAndSourceForRoute($route);

test/Sentry/TestCase.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,20 @@
22

33
namespace Sentry\Laravel\Tests;
44

5+
use Illuminate\Contracts\Debug\ExceptionHandler;
56
use ReflectionMethod;
67
use Sentry\Breadcrumb;
78
use Sentry\ClientInterface;
9+
use Sentry\Event;
10+
use Sentry\EventHint;
11+
use Sentry\Laravel\Integration;
812
use Sentry\State\Scope;
913
use ReflectionProperty;
1014
use Sentry\Laravel\Tracing;
1115
use Sentry\State\HubInterface;
1216
use Sentry\Laravel\ServiceProvider;
1317
use Orchestra\Testbench\TestCase as LaravelTestCase;
18+
use Throwable;
1419

1520
abstract class TestCase extends LaravelTestCase
1621
{
@@ -19,13 +24,28 @@ abstract class TestCase extends LaravelTestCase
1924
// or use the `$this->resetApplicationWithConfig([ /* config */ ]);` helper method
2025
];
2126

27+
/** @var array<int, array{0: Event, 1: EventHint}> */
28+
protected $lastSentryEvents = [];
29+
2230
protected function getEnvironmentSetUp($app): void
2331
{
32+
$this->lastSentryEvents = [];
33+
34+
$app['config']->set('sentry.before_send', function (Event $event, EventHint $hint) {
35+
$this->lastSentryEvents[] = [$event, $hint];
36+
37+
return null;
38+
});
39+
2440
$app['config']->set('sentry.dsn', 'http://publickey:[email protected]/123');
2541

2642
foreach ($this->setupConfig as $key => $value) {
2743
$app['config']->set($key, $value);
2844
}
45+
46+
$app->extend(ExceptionHandler::class, function (ExceptionHandler $handler) {
47+
return new TestCaseExceptionHandler($handler);
48+
});
2949
}
3050

3151
protected function getPackageProviders($app): array
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace Sentry\Laravel\Tests;
4+
5+
use Illuminate\Contracts\Debug\ExceptionHandler;
6+
use Sentry\Laravel\Integration;
7+
8+
/**
9+
* This is a proxy class so we can inject the Sentry bits while running tests and handle exceptions like "normal".
10+
*
11+
* All type hints are remove from this class to prevent issues when running lower PHP versions where Throwable is not yet a thing.
12+
*/
13+
class TestCaseExceptionHandler implements ExceptionHandler
14+
{
15+
private $handler;
16+
17+
public function __construct(ExceptionHandler $handler)
18+
{
19+
$this->handler = $handler;
20+
}
21+
22+
public function report($e)
23+
{
24+
Integration::captureUnhandledException($e);
25+
26+
$this->handler->report($e);
27+
}
28+
29+
public function shouldReport($e)
30+
{
31+
return $this->handler->shouldReport($e);
32+
}
33+
34+
public function render($request, $e)
35+
{
36+
return $this->handler->render($request, $e);
37+
}
38+
39+
public function renderForConsole($output, $e)
40+
{
41+
$this->handler->render($output, $e);
42+
}
43+
44+
public function __call($name, $arguments)
45+
{
46+
return call_user_func_array([$this->handler, $name], $arguments);
47+
}
48+
}

0 commit comments

Comments
 (0)