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..18dd6eab6 --- /dev/null +++ b/src/Monolog/LogToSentryIssueHandler.php @@ -0,0 +1,118 @@ +|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->hasThrowable($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 hasThrowable($record): bool + { + $exception = $this->getArrayFieldFromRecord($record, 'context')[self::CONTEXT_EXCEPTION_KEY] ?? null; + + return $exception instanceof \Throwable; + } + + /** + * @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..7bbff023b --- /dev/null +++ b/tests/Monolog/LogToSentryIssueHandlerTest.php @@ -0,0 +1,295 @@ + $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 testHandleIgnoresRecordsWithThrowableExceptionContext(): 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' => new \RuntimeException('boom'), + ], + [] + ); + + $this->assertTrue($handler->isHandling($record)); + $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 */ + $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), + ], + ]; + } +}