From a6a2a91624e6c288dad46ab9cb3991c84ea0af98 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 9 Apr 2026 14:14:59 +0200 Subject: [PATCH 1/2] feat(monolog): add handler to only capture exceptions --- src/Monolog/Handler.php | 3 +- src/Monolog/SentryExceptionHandler.php | 141 ++++++++++++ tests/Monolog/SentryExceptionHandlerTest.php | 218 +++++++++++++++++++ 3 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 src/Monolog/SentryExceptionHandler.php create mode 100644 tests/Monolog/SentryExceptionHandlerTest.php 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..7d3604a2f --- /dev/null +++ b/src/Monolog/SentryExceptionHandler.php @@ -0,0 +1,141 @@ +|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 isHandling($record): bool + { + if ($this->getExceptionFromRecord($record) === null) { + return false; + } + + /** @var LogRecord $record */ + return parent::isHandling($record); + } + + /** + * @param array|LogRecord $record + */ + public function handle($record): bool + { + $exception = $this->getExceptionFromRecord($record); + + 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..b0218f6ec --- /dev/null +++ b/tests/Monolog/SentryExceptionHandlerTest.php @@ -0,0 +1,218 @@ + $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->assertFalse($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)); + } + + /** + * @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', + ], + ], + ]; + } +} From 645fa6c14884e9b1a82874e96a86345541874014 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Thu, 9 Apr 2026 14:55:43 +0200 Subject: [PATCH 2/2] fix for legacy monolog --- src/Monolog/SentryExceptionHandler.php | 14 +------------- tests/Monolog/SentryExceptionHandlerTest.php | 14 +++++++++++++- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Monolog/SentryExceptionHandler.php b/src/Monolog/SentryExceptionHandler.php index 7d3604a2f..2643c1418 100644 --- a/src/Monolog/SentryExceptionHandler.php +++ b/src/Monolog/SentryExceptionHandler.php @@ -32,19 +32,6 @@ public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bub parent::__construct($level, $bubble); } - /** - * @param array|LogRecord $record - */ - public function isHandling($record): bool - { - if ($this->getExceptionFromRecord($record) === null) { - return false; - } - - /** @var LogRecord $record */ - return parent::isHandling($record); - } - /** * @param array|LogRecord $record */ @@ -52,6 +39,7 @@ public function handle($record): bool { $exception = $this->getExceptionFromRecord($record); + /** @phpstan-ignore-next-line */ if ($exception === null || !$this->isHandling($record)) { return false; } diff --git a/tests/Monolog/SentryExceptionHandlerTest.php b/tests/Monolog/SentryExceptionHandlerTest.php index b0218f6ec..f3afbc785 100644 --- a/tests/Monolog/SentryExceptionHandlerTest.php +++ b/tests/Monolog/SentryExceptionHandlerTest.php @@ -111,7 +111,7 @@ public function testHandleIgnoresRecordsWithoutThrowable($record): void $handler = new SentryExceptionHandler(new Hub($client, new Scope()), Logger::DEBUG, false); - $this->assertFalse($handler->isHandling($record)); + $this->assertTrue($handler->isHandling($record)); $this->assertFalse($handler->handle($record)); } @@ -139,6 +139,18 @@ public function testHandleIgnoresRecordsBelowThreshold(): void $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}> */