Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/Monolog/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sarlandini@alice.it>
*/
Expand Down
141 changes: 141 additions & 0 deletions src/Monolog/SentryExceptionHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

declare(strict_types=1);

namespace Sentry\Monolog;

use Monolog\Handler\AbstractHandler;
use Monolog\Level;
use Monolog\Logger;
use Monolog\LogRecord;
use Psr\Log\LogLevel;
use Sentry\State\HubInterface;
use Sentry\State\Scope;

/**
* This Monolog handler will collect monolog events and send them to sentry.
*/
class SentryExceptionHandler extends AbstractHandler
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚲 : Not sure about the name, it's a bit redundant and generic so I'm up for suggestions

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

/**
* @phpstan-param value-of<Level::VALUES>|value-of<Level::NAMES>|Level|LogLevel::* $level
*/
public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bubble = true)
{
$this->hub = $hub;

parent::__construct($level, $bubble);
}

/**
* @param array<string, mixed>|LogRecord $record
*/
public function isHandling($record): bool
{
if ($this->getExceptionFromRecord($record) === null) {
return false;
}

/** @var LogRecord $record */
return parent::isHandling($record);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

/**
* @param array<string, mixed>|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<string, mixed>|LogRecord $record
*/
private function getExceptionFromRecord($record): ?\Throwable
{
$exception = $this->getContextFromRecord($record)['exception'] ?? null;

if ($exception instanceof \Throwable) {
return $exception;
}

return null;
}

/**
* @param array<string, mixed>|LogRecord $record
*
* @return array<string, mixed>
*/
private function getContextFromRecord($record): array
{
return $this->getArrayFieldFromRecord($record, 'context');
}

/**
* @param array<string, mixed>|LogRecord $record
*
* @return array<string, mixed>
*/
private function getExtraFromRecord($record): array
{
return $this->getArrayFieldFromRecord($record, 'extra');
}

/**
* @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 [];
}

/**
* @param array<string, mixed> $context
*
* @return array<string, mixed>
*/
private function getMonologContextData(array $context): array
{
unset($context['exception']);

return $context;
}
}
218 changes: 218 additions & 0 deletions tests/Monolog/SentryExceptionHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
<?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\ClientInterface;
use Sentry\Event;
use Sentry\Monolog\SentryExceptionHandler;
use Sentry\State\Hub;
use Sentry\State\Scope;

final class SentryExceptionHandlerTest extends TestCase
{
/**
* @dataProvider capturedRecordsDataProvider
*
* @param LogRecord|array<string, mixed> $record
* @param array<string, mixed> $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<string, mixed> $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<array{LogRecord|array<string, mixed>}>
*/
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<array{LogRecord|array<string, mixed>, \Throwable, array<string, mixed>}>
*/
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',
],
],
];
}
}
Loading