diff --git a/CHANGELOG.md b/CHANGELOG.md index 18cfb28c..76447871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ Changelog * Out of memory errors will now be reported by increasing the memory limit by 5 MiB. Use the new `memoryLimitIncrease` configuration option to change the amount of memory, or set it to `null` to disable the increase entirely. [#621](https://github.com/bugsnag/bugsnag-php/pull/621) +* Add a "discard classes" configuration option that allows events to be discarded based on the exception class name or PHP error name + [#622](https://github.com/bugsnag/bugsnag-php/pull/622) + ## 3.25.0 (2020-11-25) ### Enhancements diff --git a/src/Client.php b/src/Client.php index b53f20cd..7800230e 100644 --- a/src/Client.php +++ b/src/Client.php @@ -12,6 +12,7 @@ use Bugsnag\Internal\GuzzleCompat; use Bugsnag\Middleware\BreadcrumbData; use Bugsnag\Middleware\CallbackBridge; +use Bugsnag\Middleware\DiscardClasses; use Bugsnag\Middleware\NotificationSkipper; use Bugsnag\Middleware\SessionData; use Bugsnag\Request\BasicResolver; @@ -137,6 +138,7 @@ public function __construct( $this->sessionTracker = new SessionTracker($config, $this->http); $this->registerMiddleware(new NotificationSkipper($config)); + $this->registerMiddleware(new DiscardClasses($config)); $this->registerMiddleware(new BreadcrumbData($this->recorder)); $this->registerMiddleware(new SessionData($this)); @@ -959,4 +961,30 @@ public function getMemoryLimitIncrease() { return $this->config->getMemoryLimitIncrease(); } + + /** + * Set the array of classes that should not be sent to Bugsnag. + * + * @param array $discardClasses + * + * @return $this + */ + public function setDiscardClasses(array $discardClasses) + { + $this->config->setDiscardClasses($discardClasses); + + return $this; + } + + /** + * Get the array of classes that should not be sent to Bugsnag. + * + * This can contain both fully qualified class names and regular expressions. + * + * @var array + */ + public function getDiscardClasses() + { + return $this->config->getDiscardClasses(); + } } diff --git a/src/Configuration.php b/src/Configuration.php index 01784897..8fdeccc3 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -161,6 +161,15 @@ class Configuration */ protected $memoryLimitIncrease = 5242880; + /** + * An array of classes that should not be sent to Bugsnag. + * + * This can contain both fully qualified class names and regular expressions. + * + * @var array + */ + protected $discardClasses = []; + /** * Create a new config instance. * @@ -801,4 +810,30 @@ public function getMemoryLimitIncrease() { return $this->memoryLimitIncrease; } + + /** + * Set the array of classes that should not be sent to Bugsnag. + * + * @param array $discardClasses + * + * @return $this + */ + public function setDiscardClasses(array $discardClasses) + { + $this->discardClasses = $discardClasses; + + return $this; + } + + /** + * Get the array of classes that should not be sent to Bugsnag. + * + * This can contain both fully qualified class names and regular expressions. + * + * @var array + */ + public function getDiscardClasses() + { + return $this->discardClasses; + } } diff --git a/src/Middleware/DiscardClasses.php b/src/Middleware/DiscardClasses.php new file mode 100644 index 00000000..50e0ab7e --- /dev/null +++ b/src/Middleware/DiscardClasses.php @@ -0,0 +1,50 @@ +config = $config; + } + + /** + * @param \Bugsnag\Report $report + * @param callable $next + * + * @return void + */ + public function __invoke(Report $report, callable $next) + { + $errors = $report->getErrors(); + + foreach ($this->config->getDiscardClasses() as $discardClass) { + foreach ($errors as $error) { + if ($error['errorClass'] === $discardClass + || @preg_match($discardClass, $error['errorClass']) === 1 + ) { + syslog(LOG_INFO, sprintf( + 'Discarding event because error class "%s" matched discardClasses configuration', + $error['errorClass'] + )); + + return; + } + } + } + + $next($report); + } +} diff --git a/src/Report.php b/src/Report.php index ec7d6511..f3d470cb 100644 --- a/src/Report.php +++ b/src/Report.php @@ -644,6 +644,39 @@ public function setSessionData(array $session) $this->session = $session; } + /** + * Get a list of all errors in a fixed format of: + * - 'errorClass' + * - 'errorMessage' + * - 'type' (always 'php'). + * + * @return array + */ + public function getErrors() + { + $errors = [$this->toError()]; + $previous = $this->previous; + + while ($previous) { + $errors[] = $previous->toError(); + $previous = $previous->previous; + } + + return $errors; + } + + /** + * @return array + */ + private function toError() + { + return [ + 'errorClass' => $this->name, + 'errorMessage' => $this->message, + 'type' => 'php', + ]; + } + /** * Get the array representation. * diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 526d623b..8205eaa9 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -10,6 +10,7 @@ use Exception; use GuzzleHttp\Client as Guzzle; use GuzzleHttp\Psr7\Uri; +use LogicException; use PHPUnit\Framework\MockObject\MockObject; /** @@ -461,6 +462,37 @@ function (Report $report) use (&$pipelineCompleted) { $this->assertTrue($pipelineCompleted); } + public function testItAddsDiscardClassesMiddlewareByDefault() + { + $syslog = $this->getFunctionMock('Bugsnag\Middleware', 'syslog'); + $syslog->expects($this->once())->with( + LOG_INFO, + 'Discarding event because error class "Exception" matched discardClasses configuration' + ); + + $client = Client::make('foo'); + $client->setDiscardClasses([Exception::class]); + + $report = Report::fromPHPThrowable( + $client->getConfig(), + new Exception('oh no') + ); + + $pipeline = $client->getPipeline(); + $pipelineCompleted = false; + + $pipeline->execute( + $report, + function () use (&$pipelineCompleted) { + $pipelineCompleted = true; + + throw new LogicException('This should never be reached!'); + } + ); + + $this->assertFalse($pipelineCompleted); + } + public function testBreadcrumbsWorks() { $this->client = new Client($this->config = new Configuration('example-api-key'), null, $this->guzzle); @@ -1146,6 +1178,25 @@ public function testMemoryLimitIncreaseCanBeSetToNull() $this->assertNull($this->client->getMemoryLimitIncrease()); } + public function testDiscardClassesDefault() + { + $this->assertSame([], $this->client->getDiscardClasses()); + } + + public function testDiscardClassesCanBeSet() + { + $discardClasses = [ + \RuntimeException::class, + \LogicException::class, + \TypeError::class, + '/^(Under|Over)flowException$/', + ]; + + $this->client->setDiscardClasses($discardClasses); + + $this->assertSame($discardClasses, $this->client->getDiscardClasses()); + } + private function getGuzzleOption($guzzle, $name) { if (GuzzleCompat::isUsingGuzzle5()) { diff --git a/tests/ConfigurationTest.php b/tests/ConfigurationTest.php index 27bad4b7..235ac860 100644 --- a/tests/ConfigurationTest.php +++ b/tests/ConfigurationTest.php @@ -325,4 +325,23 @@ public function testMemoryLimitIncreaseCanBeSetToNull() $this->assertNull($this->config->getMemoryLimitIncrease()); } + + public function testDiscardClassesDefault() + { + $this->assertSame([], $this->config->getDiscardClasses()); + } + + public function testDiscardClassesCanBeSet() + { + $discardClasses = [ + \RuntimeException::class, + \LogicException::class, + \TypeError::class, + '/^(Under|Over)flowException$/', + ]; + + $this->config->setDiscardClasses($discardClasses); + + $this->assertSame($discardClasses, $this->config->getDiscardClasses()); + } } diff --git a/tests/Fakes/SomeException.php b/tests/Fakes/SomeException.php new file mode 100644 index 00000000..d853d5d6 --- /dev/null +++ b/tests/Fakes/SomeException.php @@ -0,0 +1,9 @@ +getFunctionMock('Bugsnag\Middleware', 'syslog'); + $syslog->expects($this->never()); + + $config = new Configuration('API-KEY'); + $middleware = new DiscardClasses($config); + + $report = Report::fromPHPThrowable($config, new Exception()); + $this->assertReportIsNotDiscarded($middleware, $report); + + $report = Report::fromPHPThrowable($config, new LogicException()); + $this->assertReportIsNotDiscarded($middleware, $report); + } + + public function testShouldNotifyWhenExceptionIsNotInDiscardClasses() + { + $syslog = $this->getFunctionMock('Bugsnag\Middleware', 'syslog'); + $syslog->expects($this->never()); + + $config = new Configuration('API-KEY'); + $config->setDiscardClasses([LogicException::class]); + + $middleware = new DiscardClasses($config); + + $report = Report::fromPHPThrowable($config, new Exception()); + $this->assertReportIsNotDiscarded($middleware, $report); + + $report = Report::fromPHPThrowable($config, new UnderflowException()); + $this->assertReportIsNotDiscarded($middleware, $report); + } + + public function testShouldNotifyWhenExceptionDoesNotMatchRegex() + { + $syslog = $this->getFunctionMock('Bugsnag\Middleware', 'syslog'); + $syslog->expects($this->never()); + + $config = new Configuration('API-KEY'); + $config->setDiscardClasses(['/^\d+$/']); + + $middleware = new DiscardClasses($config); + + $report = Report::fromPHPThrowable($config, new Exception()); + $this->assertReportIsNotDiscarded($middleware, $report); + + $report = Report::fromPHPThrowable($config, new LogicException()); + $this->assertReportIsNotDiscarded($middleware, $report); + } + + public function testShouldDiscardExceptionsThatExactlyMatchADiscardedClass() + { + $syslog = $this->getFunctionMock('Bugsnag\Middleware', 'syslog'); + $syslog->expects($this->once())->with( + LOG_INFO, + 'Discarding event because error class "LogicException" matched discardClasses configuration' + ); + + $config = new Configuration('API-KEY'); + $config->setDiscardClasses([LogicException::class]); + + $middleware = new DiscardClasses($config); + + $report = Report::fromPHPThrowable($config, new LogicException()); + $this->assertReportIsDiscarded($middleware, $report); + + $report = Report::fromPHPThrowable($config, new Exception()); + $this->assertReportIsNotDiscarded($middleware, $report); + } + + public function testShouldDiscardPreviousExceptionsThatExactlyMatchADiscardedClass() + { + $syslog = $this->getFunctionMock('Bugsnag\Middleware', 'syslog'); + $syslog->expects($this->once())->with( + LOG_INFO, + 'Discarding event because error class "LogicException" matched discardClasses configuration' + ); + + $config = new Configuration('API-KEY'); + $config->setDiscardClasses([LogicException::class]); + + $middleware = new DiscardClasses($config); + + $report = Report::fromPHPThrowable($config, new Exception('', 0, new LogicException())); + $this->assertReportIsDiscarded($middleware, $report); + + $report = Report::fromPHPThrowable($config, new Exception()); + $this->assertReportIsNotDiscarded($middleware, $report); + + $report = Report::fromPHPThrowable($config, new Exception('', 0, new UnderflowException())); + $this->assertReportIsNotDiscarded($middleware, $report); + } + + public function testShouldDiscardExceptionsThatMatchADiscardClassRegex() + { + $syslog = $this->getFunctionMock('Bugsnag\Middleware', 'syslog'); + $syslog->expects($this->exactly(3))->withConsecutive( + [LOG_INFO, 'Discarding event because error class "UnderflowException" matched discardClasses configuration'], + [LOG_INFO, 'Discarding event because error class "OverflowException" matched discardClasses configuration'], + [LOG_INFO, 'Discarding event because error class "OverflowException" matched discardClasses configuration'] + ); + + $config = new Configuration('API-KEY'); + $config->setDiscardClasses(['/^(Under|Over)flowException$/']); + + $middleware = new DiscardClasses($config); + + $report = Report::fromPHPThrowable($config, new UnderflowException()); + $this->assertReportIsDiscarded($middleware, $report); + + $report = Report::fromPHPThrowable($config, new OverflowException()); + $this->assertReportIsDiscarded($middleware, $report); + + $report = Report::fromPHPThrowable($config, new LogicException()); + $this->assertReportIsNotDiscarded($middleware, $report); + + $report = Report::fromPHPThrowable($config, new LogicException('', 0, new OverflowException())); + $this->assertReportIsDiscarded($middleware, $report); + } + + public function testShouldDiscardErrorsThatExactlyMatchAGivenErrorName() + { + $syslog = $this->getFunctionMock('Bugsnag\Middleware', 'syslog'); + $syslog->expects($this->once())->with( + LOG_INFO, + 'Discarding event because error class "PHP Warning" matched discardClasses configuration' + ); + + $config = new Configuration('API-KEY'); + $config->setDiscardClasses([ErrorTypes::getName(E_WARNING)]); + + $middleware = new DiscardClasses($config); + + $report = Report::fromPHPError($config, E_WARNING, 'warning', '/a/b/c.php', 123); + $this->assertReportIsDiscarded($middleware, $report); + + $report = Report::fromPHPError($config, E_USER_WARNING, 'user warning', '/a/b/c.php', 123); + $this->assertReportIsNotDiscarded($middleware, $report); + } + + public function testShouldDiscardErrorsThatMatchARegex() + { + $syslog = $this->getFunctionMock('Bugsnag\Middleware', 'syslog'); + $syslog->expects($this->exactly(2))->withConsecutive( + [LOG_INFO, 'Discarding event because error class "PHP Notice" matched discardClasses configuration'], + [LOG_INFO, 'Discarding event because error class "User Notice" matched discardClasses configuration'] + ); + + $config = new Configuration('API-KEY'); + $config->setDiscardClasses(['/\bNotice\b/']); + + $middleware = new DiscardClasses($config); + + $report = Report::fromPHPError($config, E_NOTICE, 'notice', '/a/b/c.php', 123); + $this->assertReportIsDiscarded($middleware, $report); + + $report = Report::fromPHPError($config, E_USER_NOTICE, 'user notice', '/a/b/c.php', 123); + $this->assertReportIsDiscarded($middleware, $report); + + $report = Report::fromPHPError($config, E_WARNING, 'warning', '/a/b/c.php', 123); + $this->assertReportIsNotDiscarded($middleware, $report); + } + + /** + * Assert that DiscardClasses calls the next middleware for this Report. + * + * @param DiscardClasses $middleware + * @param Report $report + * + * @return void + */ + private function assertReportIsNotDiscarded(DiscardClasses $middleware, Report $report) + { + $wasCalled = $this->runMiddleware($middleware, $report); + + $this->assertTrue($wasCalled, 'Expected the DiscardClasses middleware to call $next'); + } + + /** + * Assert that DiscardClasses does not call the next middleware for this Report. + * + * @param DiscardClasses $middleware + * @param Report $report + * + * @return void + */ + private function assertReportIsDiscarded(DiscardClasses $middleware, Report $report) + { + $wasCalled = $this->runMiddleware($middleware, $report); + + $this->assertFalse($wasCalled, 'Expected the DiscardClasses middleware not to call $next'); + } + + /** + * Run the given middleware against the report and return if it called $next. + * + * @param callable $middleware + * @param Report $report + * + * @return bool + */ + private function runMiddleware(callable $middleware, Report $report) + { + $wasCalled = false; + + $middleware($report, function () use (&$wasCalled) { + $wasCalled = true; + }); + + return $wasCalled; + } +} diff --git a/tests/ReportTest.php b/tests/ReportTest.php index a3749a86..60cf4b9c 100644 --- a/tests/ReportTest.php +++ b/tests/ReportTest.php @@ -2,13 +2,17 @@ namespace Bugsnag\Tests; +use BadMethodCallException; use Bugsnag\Configuration; use Bugsnag\Report; use Bugsnag\Stacktrace; +use Bugsnag\Tests\Fakes\SomeException; use Bugsnag\Tests\Fakes\StringableObject; use Exception; use InvalidArgumentException; +use LogicException; use ParseError; +use RuntimeException; use stdClass; class ReportTest extends TestCase @@ -581,4 +585,52 @@ public function testDefaultSeverityTypeSet() $data = $report->toArray(); $this->assertSame($data['severityReason'], ['foo' => 'bar', 'type' => 'userSpecifiedSeverity']); } + + public function testGetErrorsWithNoPreviousErrors() + { + $exception = new Exception('abc xyz'); + + $report = Report::fromPHPThrowable($this->config, $exception); + $actual = $report->getErrors(); + + $expected = [ + ['errorClass' => 'Exception', 'errorMessage' => 'abc xyz', 'type' => 'php'], + ]; + + $this->assertSame($expected, $actual); + } + + public function testGetErrorsWithPreviousErrors() + { + $exception5 = new SomeException('exception5'); + $exception4 = new BadMethodCallException('exception4', 0, $exception5); + $exception3 = new LogicException('exception3', 0, $exception4); + $exception2 = new RuntimeException('exception2', 0, $exception3); + $exception1 = new Exception('exception1', 0, $exception2); + + $report = Report::fromPHPThrowable($this->config, $exception1); + $actual = $report->getErrors(); + + $expected = [ + ['errorClass' => 'Exception', 'errorMessage' => 'exception1', 'type' => 'php'], + ['errorClass' => 'RuntimeException', 'errorMessage' => 'exception2', 'type' => 'php'], + ['errorClass' => 'LogicException', 'errorMessage' => 'exception3', 'type' => 'php'], + ['errorClass' => 'BadMethodCallException', 'errorMessage' => 'exception4', 'type' => 'php'], + ['errorClass' => 'Bugsnag\Tests\Fakes\SomeException', 'errorMessage' => 'exception5', 'type' => 'php'], + ]; + + $this->assertSame($expected, $actual); + } + + public function testGetErrorsWithPhpError() + { + $report = Report::fromPHPError($this->config, E_WARNING, 'bad stuff!', '/usr/src/stuff.php', 1234); + $actual = $report->getErrors(); + + $expected = [ + ['errorClass' => 'PHP Warning', 'errorMessage' => 'bad stuff!', 'type' => 'php'], + ]; + + $this->assertSame($expected, $actual); + } }