Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/Monolog/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sarlandini@alice.it>
*/
Expand Down
116 changes: 116 additions & 0 deletions src/Monolog/LogToSentryIssueHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

declare(strict_types=1);

namespace Sentry\Monolog;

use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Level;
use Monolog\Logger;
use Monolog\LogRecord;
use Psr\Log\LogLevel;
use Sentry\Event;
use Sentry\EventHint;
use Sentry\State\HubInterface;
use Sentry\State\Scope;

/**
* This Monolog handler captures log messages as Sentry issues.
*/
class LogToSentryIssueHandler extends AbstractProcessingHandler
{
use CompatibilityProcessingHandlerTrait;

private const CONTEXT_EXCEPTION_KEY = 'exception';

/**
* @var HubInterface
*/
private $hub;

/**
* @var bool
*/
private $fillExtraContext;

/**
* @phpstan-param value-of<Level::VALUES>|value-of<Level::NAMES>|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<string, mixed>|LogRecord $record
*/
public function handle($record): bool
{
/** @phpstan-ignore-next-line */
if (!$this->isHandling($record) || $this->hasExceptionContext($record)) {
Comment thread
Litarnus marked this conversation as resolved.
Outdated
return false;
}

/** @phpstan-ignore-next-line */
return parent::handle($record);
}

/**
* @param array<string, mixed>|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<string, mixed>|LogRecord $record
*/
private function hasExceptionContext($record): bool
{
return \array_key_exists(self::CONTEXT_EXCEPTION_KEY, $this->getArrayFieldFromRecord($record, 'context'));
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* @param array<string, mixed>|LogRecord $record
*
* @return array<string, mixed>
*/
private function getArrayFieldFromRecord($record, string $field): array
{
if (isset($record[$field]) && \is_array($record[$field])) {
return $record[$field];
}

return [];
}
}
2 changes: 1 addition & 1 deletion src/Monolog/LogsHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
255 changes: 255 additions & 0 deletions tests/Monolog/LogToSentryIssueHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
<?php

declare(strict_types=1);

namespace Sentry\Tests\Monolog;

use Monolog\Logger;
use Monolog\LogRecord;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sentry\ClientBuilder;
use Sentry\ClientInterface;
use Sentry\Event;
use Sentry\EventHint;
use Sentry\Monolog\ExceptionToSentryIssueHandler;
use Sentry\Monolog\LogToSentryIssueHandler;
use Sentry\Severity;
use Sentry\State\Hub;
use Sentry\State\Scope;
use Sentry\Tests\StubTransport;

final class LogToSentryIssueHandlerTest extends TestCase
{
/**
* @dataProvider capturedRecordsDataProvider
*
* @param LogRecord|array<string, mixed> $record
* @param array<string, mixed> $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<array{bool, LogRecord|array<string, mixed>, Severity, array<string, mixed>}>
*/
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),
],
];
}
}
Loading