diff --git a/src/Monolog/Handler.php b/src/Monolog/Handler.php index 11fb16ae5..2defe1cb6 100644 --- a/src/Monolog/Handler.php +++ b/src/Monolog/Handler.php @@ -17,7 +17,8 @@ * hub instance. * * @deprecated since version 4.24. To be removed in version 5.0. Use {@see LogsHandler} - * with the `enable_logs` SDK option instead. + * with the `enable_logs` SDK option instead for logging. {@see SentryExceptionHandler} + * to send monolog exceptions to Sentry. * * @author Stefano Arlandini */ diff --git a/src/Monolog/SentryExceptionHandler.php b/src/Monolog/SentryExceptionHandler.php new file mode 100644 index 000000000..2643c1418 --- /dev/null +++ b/src/Monolog/SentryExceptionHandler.php @@ -0,0 +1,129 @@ +|value-of|Level|LogLevel::* $level + */ + public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bubble = true) + { + $this->hub = $hub; + + parent::__construct($level, $bubble); + } + + /** + * @param array|LogRecord $record + */ + public function handle($record): bool + { + $exception = $this->getExceptionFromRecord($record); + + /** @phpstan-ignore-next-line */ + if ($exception === null || !$this->isHandling($record)) { + return false; + } + + $this->hub->withScope(function (Scope $scope) use ($record, $exception): void { + $scope->setExtra('monolog.channel', $record['channel']); + $scope->setExtra('monolog.level', $record['level_name']); + $scope->setExtra('monolog.message', $record['message']); + + $monologContextData = $this->getMonologContextData($this->getContextFromRecord($record)); + + if ($monologContextData !== []) { + $scope->setExtra('monolog.context', $monologContextData); + } + + $monologExtraData = $this->getExtraFromRecord($record); + + if ($monologExtraData !== []) { + $scope->setExtra('monolog.extra', $monologExtraData); + } + + $this->hub->captureException($exception); + }); + + return $this->bubble === false; + } + + /** + * @param array|LogRecord $record + */ + private function getExceptionFromRecord($record): ?\Throwable + { + $exception = $this->getContextFromRecord($record)['exception'] ?? null; + + if ($exception instanceof \Throwable) { + return $exception; + } + + return null; + } + + /** + * @param array|LogRecord $record + * + * @return array + */ + private function getContextFromRecord($record): array + { + return $this->getArrayFieldFromRecord($record, 'context'); + } + + /** + * @param array|LogRecord $record + * + * @return array + */ + private function getExtraFromRecord($record): array + { + return $this->getArrayFieldFromRecord($record, 'extra'); + } + + /** + * @param array|LogRecord $record + * + * @return array + */ + private function getArrayFieldFromRecord($record, string $field): array + { + if (isset($record[$field]) && \is_array($record[$field])) { + return $record[$field]; + } + + return []; + } + + /** + * @param array $context + * + * @return array + */ + private function getMonologContextData(array $context): array + { + unset($context['exception']); + + return $context; + } +} diff --git a/tests/Monolog/SentryExceptionHandlerTest.php b/tests/Monolog/SentryExceptionHandlerTest.php new file mode 100644 index 000000000..f3afbc785 --- /dev/null +++ b/tests/Monolog/SentryExceptionHandlerTest.php @@ -0,0 +1,230 @@ + $record + * @param array $expectedExtra + */ + public function testHandleCapturesExceptionAndAddsMetadata($record, \Throwable $exception, array $expectedExtra): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureException') + ->with( + $this->identicalTo($exception), + $this->callback(function (Scope $scopeArg) use ($expectedExtra): bool { + $event = $scopeArg->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame($expectedExtra, $event->getExtra()); + + return true; + }), + null + ); + + $handler = new SentryExceptionHandler(new Hub($client, new Scope())); + + $this->assertTrue($handler->isHandling($record)); + $handler->handle($record); + } + + public function testHandleReturnsFalseWhenBubblingEnabled(): void + { + $exception = new \RuntimeException('boom'); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureException') + ->with($this->identicalTo($exception), $this->isInstanceOf(Scope::class), null); + + $handler = new SentryExceptionHandler(new Hub($client, new Scope()), Logger::WARNING); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + ], + [] + ); + + $this->assertTrue($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testHandleReturnsTrueWhenBubblingDisabled(): void + { + $exception = new \RuntimeException('boom'); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureException') + ->with($this->identicalTo($exception), $this->isInstanceOf(Scope::class), null); + + $handler = new SentryExceptionHandler(new Hub($client, new Scope()), Logger::WARNING, false); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + ], + [] + ); + + $this->assertTrue($handler->isHandling($record)); + $this->assertTrue($handler->handle($record)); + } + + /** + * @dataProvider ignoredRecordsDataProvider + * + * @param LogRecord|array $record + */ + public function testHandleIgnoresRecordsWithoutThrowable($record): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->never()) + ->method('captureException'); + + $handler = new SentryExceptionHandler(new Hub($client, new Scope()), Logger::DEBUG, false); + + $this->assertTrue($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testHandleIgnoresRecordsBelowThreshold(): void + { + $exception = new \RuntimeException('boom'); + + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->never()) + ->method('captureException'); + + $handler = new SentryExceptionHandler(new Hub($client, new Scope()), Logger::ERROR, false); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + ], + [] + ); + + $this->assertFalse($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testLegacyIsHandlingUsesMinimalLevelRecord(): void + { + if (Logger::API >= 3) { + $this->markTestSkipped('Test only works for Monolog < 3'); + } + + $handler = new SentryExceptionHandler(new Hub($this->createMock(ClientInterface::class), new Scope()), Logger::WARNING); + + $this->assertTrue($handler->isHandling(['level' => Logger::WARNING])); + $this->assertFalse($handler->isHandling(['level' => Logger::INFO])); + } + + /** + * @return iterable}> + */ + public static function ignoredRecordsDataProvider(): iterable + { + yield [ + RecordFactory::create('foo bar', Logger::WARNING, 'channel.foo', [], []), + ]; + + yield [ + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => 'not an exception', + ], + [] + ), + ]; + } + + /** + * @return iterable, \Throwable, array}> + */ + public static function capturedRecordsDataProvider(): iterable + { + $exception = new \RuntimeException('exception message'); + + yield 'with exception only' => [ + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + ], + [] + ), + $exception, + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.message' => 'foo bar', + ], + ]; + + $exception = new \RuntimeException('exception message'); + + yield 'with context and extra' => [ + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => $exception, + 'foo' => 'bar', + ], + [ + 'bar' => 'baz', + ] + ), + $exception, + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.message' => 'foo bar', + 'monolog.context' => [ + 'foo' => 'bar', + ], + 'monolog.extra' => [ + 'bar' => 'baz', + ], + ], + ]; + } +}