From 54d8ff2f6e3266a71e8605e2cce5d0882efe61a0 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 28 Apr 2026 14:23:52 +0200 Subject: [PATCH 1/2] feat: add handler to convert logs to sentry issues --- src/Monolog/Handler.php | 5 +- src/Monolog/LogToSentryIssueHandler.php | 116 ++++++++ src/Monolog/LogsHandler.php | 2 +- tests/Monolog/LogToSentryIssueHandlerTest.php | 255 ++++++++++++++++++ 4 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 src/Monolog/LogToSentryIssueHandler.php create mode 100644 tests/Monolog/LogToSentryIssueHandlerTest.php diff --git a/src/Monolog/Handler.php b/src/Monolog/Handler.php index baa53c646..6cc69fef7 100644 --- a/src/Monolog/Handler.php +++ b/src/Monolog/Handler.php @@ -17,8 +17,9 @@ * hub instance. * * @deprecated since version 4.24. To be removed in version 5.0. Use {@see LogsHandler} - * with the `enable_logs` SDK option instead for logging. {@see ExceptionToSentryIssueHandler} - * to send monolog exceptions to Sentry. + * with the `enable_logs` SDK option for Sentry logs, {@see ExceptionToSentryIssueHandler} + * to send Monolog exceptions to Sentry issues, and {@see LogToSentryIssueHandler} + * to send Monolog log messages to Sentry issues. * * @author Stefano Arlandini */ diff --git a/src/Monolog/LogToSentryIssueHandler.php b/src/Monolog/LogToSentryIssueHandler.php new file mode 100644 index 000000000..04e338df6 --- /dev/null +++ b/src/Monolog/LogToSentryIssueHandler.php @@ -0,0 +1,116 @@ +|value-of|Level|LogLevel::* $level + */ + public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bubble = true, bool $fillExtraContext = false) + { + $this->hub = $hub; + $this->fillExtraContext = $fillExtraContext; + + parent::__construct($level, $bubble); + } + + /** + * @param array|LogRecord $record + */ + public function handle($record): bool + { + /** @phpstan-ignore-next-line */ + if (!$this->isHandling($record) || $this->hasExceptionContext($record)) { + return false; + } + + /** @phpstan-ignore-next-line */ + return parent::handle($record); + } + + /** + * @param array|LogRecord $record + */ + protected function doWrite($record): void + { + $event = Event::createEvent(); + $event->setLevel(self::getSeverityFromLevel($record['level'])); + $event->setMessage($record['message']); + $event->setLogger(\sprintf('monolog.%s', $record['channel'])); + + $hint = new EventHint(); + + $this->hub->withScope(function (Scope $scope) use ($record, $event, $hint): void { + $scope->setExtra('monolog.channel', $record['channel']); + $scope->setExtra('monolog.level', $record['level_name']); + + if ($this->fillExtraContext) { + $monologContextData = $this->getArrayFieldFromRecord($record, 'context'); + + if ($monologContextData !== []) { + $scope->setExtra('monolog.context', $monologContextData); + } + + $monologExtraData = $this->getArrayFieldFromRecord($record, 'extra'); + + if ($monologExtraData !== []) { + $scope->setExtra('monolog.extra', $monologExtraData); + } + } + + $this->hub->captureEvent($event, $hint); + }); + } + + /** + * @param array|LogRecord $record + */ + private function hasExceptionContext($record): bool + { + return \array_key_exists(self::CONTEXT_EXCEPTION_KEY, $this->getArrayFieldFromRecord($record, 'context')); + } + + /** + * @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 []; + } +} diff --git a/src/Monolog/LogsHandler.php b/src/Monolog/LogsHandler.php index 17c67d88d..94aa16ab2 100644 --- a/src/Monolog/LogsHandler.php +++ b/src/Monolog/LogsHandler.php @@ -63,7 +63,7 @@ public function handle($record): bool if (!$this->isHandling($record)) { return false; } - // Do not collect logs for exceptions, they should be handled seperately by the `Handler` or `captureException` + // Do not collect logs for exceptions, they should be handled separately by `ExceptionToSentryIssueHandler` or `captureException` if (isset($record['context']['exception']) && $record['context']['exception'] instanceof \Throwable) { return false; } diff --git a/tests/Monolog/LogToSentryIssueHandlerTest.php b/tests/Monolog/LogToSentryIssueHandlerTest.php new file mode 100644 index 000000000..f7768d611 --- /dev/null +++ b/tests/Monolog/LogToSentryIssueHandlerTest.php @@ -0,0 +1,255 @@ + $record + * @param array $expectedExtra + */ + public function testHandleCapturesLogMessageAsIssue(bool $fillExtraContext, $record, Severity $expectedSeverity, array $expectedExtra): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureEvent') + ->with( + $this->callback(function (Event $event) use ($expectedSeverity): bool { + $this->assertEquals($expectedSeverity, $event->getLevel()); + $this->assertSame('foo bar', $event->getMessage()); + $this->assertSame('monolog.channel.foo', $event->getLogger()); + + return true; + }), + $this->callback(function (EventHint $hint): bool { + $this->assertNull($hint->exception); + $this->assertNull($hint->mechanism); + $this->assertNull($hint->stacktrace); + $this->assertSame([], $hint->extra); + + return true; + }), + $this->callback(function (Scope $scopeArg) use ($expectedExtra): bool { + $event = $scopeArg->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame($expectedExtra, $event->getExtra()); + + return true; + }) + ); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::DEBUG, true, $fillExtraContext); + + $this->assertTrue($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testHandleReturnsTrueWhenBubblingDisabled(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureEvent') + ->with($this->isInstanceOf(Event::class), $this->isInstanceOf(EventHint::class), $this->isInstanceOf(Scope::class)); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::WARNING, false); + $record = RecordFactory::create('foo bar', Logger::WARNING, 'channel.foo', [], []); + + $this->assertTrue($handler->isHandling($record)); + $this->assertTrue($handler->handle($record)); + } + + public function testHandleIgnoresRecordsWithExceptionContext(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->never()) + ->method('captureEvent'); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::DEBUG, false); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => 'not an exception', + ], + [] + ); + + $this->assertTrue($handler->isHandling($record)); + $this->assertFalse($handler->handle($record)); + } + + public function testHandleIgnoresRecordsBelowThreshold(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->never()) + ->method('captureEvent'); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::ERROR, false); + $record = RecordFactory::create('foo bar', Logger::WARNING, 'channel.foo', [], []); + + $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 LogToSentryIssueHandler(new Hub($this->createMock(ClientInterface::class), new Scope()), Logger::WARNING); + + $this->assertTrue($handler->isHandling(['level' => Logger::WARNING])); + $this->assertFalse($handler->isHandling(['level' => Logger::INFO])); + } + + public function testLogAndExceptionIssueHandlersReplaceLegacyHandlerUseCases(): void + { + $client = ClientBuilder::create() + ->setTransport(StubTransport::getInstance()) + ->getClient(); + $hub = new Hub($client, new Scope()); + + $logger = new Logger('channel.foo', [ + new LogToSentryIssueHandler($hub, Logger::WARNING, true, true), + new ExceptionToSentryIssueHandler($hub, Logger::WARNING), + ]); + + $logger->warning('plain warning', [ + 'foo' => 'bar', + ]); + + $exception = new \RuntimeException('boom'); + $logger->error('exception error', [ + 'exception' => $exception, + 'foo' => 'bar', + ]); + + $this->assertCount(2, StubTransport::$events); + + $logEvent = StubTransport::$events[0]; + $this->assertSame('plain warning', $logEvent->getMessage()); + $this->assertEquals(Severity::warning(), $logEvent->getLevel()); + $this->assertSame('monolog.channel.foo', $logEvent->getLogger()); + $this->assertSame([], $logEvent->getExceptions()); + $this->assertSame([ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.context' => [ + 'foo' => 'bar', + ], + ], $logEvent->getExtra()); + + $exceptionEvent = StubTransport::$events[1]; + $this->assertNull($exceptionEvent->getMessage()); + $this->assertCount(1, $exceptionEvent->getExceptions()); + $this->assertSame(\RuntimeException::class, $exceptionEvent->getExceptions()[0]->getType()); + $this->assertSame('boom', $exceptionEvent->getExceptions()[0]->getValue()); + $this->assertSame([ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::ERROR), + 'monolog.message' => 'exception error', + 'monolog.context' => [ + 'foo' => 'bar', + ], + ], $exceptionEvent->getExtra()); + } + + /** + * @return iterable, Severity, array}> + */ + public static function capturedRecordsDataProvider(): iterable + { + foreach ([ + Logger::DEBUG => Severity::debug(), + Logger::INFO => Severity::info(), + Logger::NOTICE => Severity::info(), + Logger::WARNING => Severity::warning(), + Logger::ERROR => Severity::error(), + Logger::CRITICAL => Severity::fatal(), + Logger::ALERT => Severity::fatal(), + Logger::EMERGENCY => Severity::fatal(), + ] as $level => $severity) { + yield Logger::getLevelName($level) => [ + false, + RecordFactory::create('foo bar', $level, 'channel.foo', [], []), + $severity, + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName($level), + ], + ]; + } + + yield 'with context and extra' => [ + true, + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'foo' => 'bar', + ], + [ + 'bar' => 'baz', + ] + ), + Severity::warning(), + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.context' => [ + 'foo' => 'bar', + ], + 'monolog.extra' => [ + 'bar' => 'baz', + ], + ], + ]; + + yield 'without context and extra by default' => [ + false, + RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'foo' => 'bar', + ], + [ + 'bar' => 'baz', + ] + ), + Severity::warning(), + [ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + ], + ]; + } +} From 146ab15ffc0ccc227f10ca99dcb21aef1f1d44c8 Mon Sep 17 00:00:00 2001 From: Martin Linzmayer Date: Tue, 28 Apr 2026 16:58:44 +0200 Subject: [PATCH 2/2] handle throwables --- src/Monolog/LogToSentryIssueHandler.php | 8 ++-- tests/Monolog/LogToSentryIssueHandlerTest.php | 44 ++++++++++++++++++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/Monolog/LogToSentryIssueHandler.php b/src/Monolog/LogToSentryIssueHandler.php index 04e338df6..18dd6eab6 100644 --- a/src/Monolog/LogToSentryIssueHandler.php +++ b/src/Monolog/LogToSentryIssueHandler.php @@ -50,7 +50,7 @@ public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bub public function handle($record): bool { /** @phpstan-ignore-next-line */ - if (!$this->isHandling($record) || $this->hasExceptionContext($record)) { + if (!$this->isHandling($record) || $this->hasThrowable($record)) { return false; } @@ -95,9 +95,11 @@ protected function doWrite($record): void /** * @param array|LogRecord $record */ - private function hasExceptionContext($record): bool + private function hasThrowable($record): bool { - return \array_key_exists(self::CONTEXT_EXCEPTION_KEY, $this->getArrayFieldFromRecord($record, 'context')); + $exception = $this->getArrayFieldFromRecord($record, 'context')[self::CONTEXT_EXCEPTION_KEY] ?? null; + + return $exception instanceof \Throwable; } /** diff --git a/tests/Monolog/LogToSentryIssueHandlerTest.php b/tests/Monolog/LogToSentryIssueHandlerTest.php index f7768d611..7bbff023b 100644 --- a/tests/Monolog/LogToSentryIssueHandlerTest.php +++ b/tests/Monolog/LogToSentryIssueHandlerTest.php @@ -80,7 +80,7 @@ public function testHandleReturnsTrueWhenBubblingDisabled(): void $this->assertTrue($handler->handle($record)); } - public function testHandleIgnoresRecordsWithExceptionContext(): void + public function testHandleIgnoresRecordsWithThrowableExceptionContext(): void { /** @var ClientInterface&MockObject $client */ $client = $this->createMock(ClientInterface::class); @@ -93,7 +93,7 @@ public function testHandleIgnoresRecordsWithExceptionContext(): void Logger::WARNING, 'channel.foo', [ - 'exception' => 'not an exception', + 'exception' => new \RuntimeException('boom'), ], [] ); @@ -102,6 +102,46 @@ public function testHandleIgnoresRecordsWithExceptionContext(): void $this->assertFalse($handler->handle($record)); } + public function testHandleCapturesRecordsWithNonThrowableExceptionContext(): void + { + /** @var ClientInterface&MockObject $client */ + $client = $this->createMock(ClientInterface::class); + $client->expects($this->once()) + ->method('captureEvent') + ->with( + $this->isInstanceOf(Event::class), + $this->isInstanceOf(EventHint::class), + $this->callback(function (Scope $scopeArg): bool { + $event = $scopeArg->applyToEvent(Event::createEvent()); + + $this->assertNotNull($event); + $this->assertSame([ + 'monolog.channel' => 'channel.foo', + 'monolog.level' => Logger::getLevelName(Logger::WARNING), + 'monolog.context' => [ + 'exception' => 'not an exception', + ], + ], $event->getExtra()); + + return true; + }) + ); + + $handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::DEBUG, false, true); + $record = RecordFactory::create( + 'foo bar', + Logger::WARNING, + 'channel.foo', + [ + 'exception' => 'not an exception', + ], + [] + ); + + $this->assertTrue($handler->isHandling($record)); + $this->assertTrue($handler->handle($record)); + } + public function testHandleIgnoresRecordsBelowThreshold(): void { /** @var ClientInterface&MockObject $client */