Skip to content

Commit 54d8ff2

Browse files
committed
feat: add handler to convert logs to sentry issues
1 parent 6f19d11 commit 54d8ff2

4 files changed

Lines changed: 375 additions & 3 deletions

File tree

src/Monolog/Handler.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
* hub instance.
1818
*
1919
* @deprecated since version 4.24. To be removed in version 5.0. Use {@see LogsHandler}
20-
* with the `enable_logs` SDK option instead for logging. {@see ExceptionToSentryIssueHandler}
21-
* to send monolog exceptions to Sentry.
20+
* with the `enable_logs` SDK option for Sentry logs, {@see ExceptionToSentryIssueHandler}
21+
* to send Monolog exceptions to Sentry issues, and {@see LogToSentryIssueHandler}
22+
* to send Monolog log messages to Sentry issues.
2223
*
2324
* @author Stefano Arlandini <sarlandini@alice.it>
2425
*/
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sentry\Monolog;
6+
7+
use Monolog\Handler\AbstractProcessingHandler;
8+
use Monolog\Level;
9+
use Monolog\Logger;
10+
use Monolog\LogRecord;
11+
use Psr\Log\LogLevel;
12+
use Sentry\Event;
13+
use Sentry\EventHint;
14+
use Sentry\State\HubInterface;
15+
use Sentry\State\Scope;
16+
17+
/**
18+
* This Monolog handler captures log messages as Sentry issues.
19+
*/
20+
class LogToSentryIssueHandler extends AbstractProcessingHandler
21+
{
22+
use CompatibilityProcessingHandlerTrait;
23+
24+
private const CONTEXT_EXCEPTION_KEY = 'exception';
25+
26+
/**
27+
* @var HubInterface
28+
*/
29+
private $hub;
30+
31+
/**
32+
* @var bool
33+
*/
34+
private $fillExtraContext;
35+
36+
/**
37+
* @phpstan-param value-of<Level::VALUES>|value-of<Level::NAMES>|Level|LogLevel::* $level
38+
*/
39+
public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bubble = true, bool $fillExtraContext = false)
40+
{
41+
$this->hub = $hub;
42+
$this->fillExtraContext = $fillExtraContext;
43+
44+
parent::__construct($level, $bubble);
45+
}
46+
47+
/**
48+
* @param array<string, mixed>|LogRecord $record
49+
*/
50+
public function handle($record): bool
51+
{
52+
/** @phpstan-ignore-next-line */
53+
if (!$this->isHandling($record) || $this->hasExceptionContext($record)) {
54+
return false;
55+
}
56+
57+
/** @phpstan-ignore-next-line */
58+
return parent::handle($record);
59+
}
60+
61+
/**
62+
* @param array<string, mixed>|LogRecord $record
63+
*/
64+
protected function doWrite($record): void
65+
{
66+
$event = Event::createEvent();
67+
$event->setLevel(self::getSeverityFromLevel($record['level']));
68+
$event->setMessage($record['message']);
69+
$event->setLogger(\sprintf('monolog.%s', $record['channel']));
70+
71+
$hint = new EventHint();
72+
73+
$this->hub->withScope(function (Scope $scope) use ($record, $event, $hint): void {
74+
$scope->setExtra('monolog.channel', $record['channel']);
75+
$scope->setExtra('monolog.level', $record['level_name']);
76+
77+
if ($this->fillExtraContext) {
78+
$monologContextData = $this->getArrayFieldFromRecord($record, 'context');
79+
80+
if ($monologContextData !== []) {
81+
$scope->setExtra('monolog.context', $monologContextData);
82+
}
83+
84+
$monologExtraData = $this->getArrayFieldFromRecord($record, 'extra');
85+
86+
if ($monologExtraData !== []) {
87+
$scope->setExtra('monolog.extra', $monologExtraData);
88+
}
89+
}
90+
91+
$this->hub->captureEvent($event, $hint);
92+
});
93+
}
94+
95+
/**
96+
* @param array<string, mixed>|LogRecord $record
97+
*/
98+
private function hasExceptionContext($record): bool
99+
{
100+
return \array_key_exists(self::CONTEXT_EXCEPTION_KEY, $this->getArrayFieldFromRecord($record, 'context'));
101+
}
102+
103+
/**
104+
* @param array<string, mixed>|LogRecord $record
105+
*
106+
* @return array<string, mixed>
107+
*/
108+
private function getArrayFieldFromRecord($record, string $field): array
109+
{
110+
if (isset($record[$field]) && \is_array($record[$field])) {
111+
return $record[$field];
112+
}
113+
114+
return [];
115+
}
116+
}

src/Monolog/LogsHandler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public function handle($record): bool
6363
if (!$this->isHandling($record)) {
6464
return false;
6565
}
66-
// Do not collect logs for exceptions, they should be handled seperately by the `Handler` or `captureException`
66+
// Do not collect logs for exceptions, they should be handled separately by `ExceptionToSentryIssueHandler` or `captureException`
6767
if (isset($record['context']['exception']) && $record['context']['exception'] instanceof \Throwable) {
6868
return false;
6969
}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sentry\Tests\Monolog;
6+
7+
use Monolog\Logger;
8+
use Monolog\LogRecord;
9+
use PHPUnit\Framework\MockObject\MockObject;
10+
use PHPUnit\Framework\TestCase;
11+
use Sentry\ClientBuilder;
12+
use Sentry\ClientInterface;
13+
use Sentry\Event;
14+
use Sentry\EventHint;
15+
use Sentry\Monolog\ExceptionToSentryIssueHandler;
16+
use Sentry\Monolog\LogToSentryIssueHandler;
17+
use Sentry\Severity;
18+
use Sentry\State\Hub;
19+
use Sentry\State\Scope;
20+
use Sentry\Tests\StubTransport;
21+
22+
final class LogToSentryIssueHandlerTest extends TestCase
23+
{
24+
/**
25+
* @dataProvider capturedRecordsDataProvider
26+
*
27+
* @param LogRecord|array<string, mixed> $record
28+
* @param array<string, mixed> $expectedExtra
29+
*/
30+
public function testHandleCapturesLogMessageAsIssue(bool $fillExtraContext, $record, Severity $expectedSeverity, array $expectedExtra): void
31+
{
32+
/** @var ClientInterface&MockObject $client */
33+
$client = $this->createMock(ClientInterface::class);
34+
$client->expects($this->once())
35+
->method('captureEvent')
36+
->with(
37+
$this->callback(function (Event $event) use ($expectedSeverity): bool {
38+
$this->assertEquals($expectedSeverity, $event->getLevel());
39+
$this->assertSame('foo bar', $event->getMessage());
40+
$this->assertSame('monolog.channel.foo', $event->getLogger());
41+
42+
return true;
43+
}),
44+
$this->callback(function (EventHint $hint): bool {
45+
$this->assertNull($hint->exception);
46+
$this->assertNull($hint->mechanism);
47+
$this->assertNull($hint->stacktrace);
48+
$this->assertSame([], $hint->extra);
49+
50+
return true;
51+
}),
52+
$this->callback(function (Scope $scopeArg) use ($expectedExtra): bool {
53+
$event = $scopeArg->applyToEvent(Event::createEvent());
54+
55+
$this->assertNotNull($event);
56+
$this->assertSame($expectedExtra, $event->getExtra());
57+
58+
return true;
59+
})
60+
);
61+
62+
$handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::DEBUG, true, $fillExtraContext);
63+
64+
$this->assertTrue($handler->isHandling($record));
65+
$this->assertFalse($handler->handle($record));
66+
}
67+
68+
public function testHandleReturnsTrueWhenBubblingDisabled(): void
69+
{
70+
/** @var ClientInterface&MockObject $client */
71+
$client = $this->createMock(ClientInterface::class);
72+
$client->expects($this->once())
73+
->method('captureEvent')
74+
->with($this->isInstanceOf(Event::class), $this->isInstanceOf(EventHint::class), $this->isInstanceOf(Scope::class));
75+
76+
$handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::WARNING, false);
77+
$record = RecordFactory::create('foo bar', Logger::WARNING, 'channel.foo', [], []);
78+
79+
$this->assertTrue($handler->isHandling($record));
80+
$this->assertTrue($handler->handle($record));
81+
}
82+
83+
public function testHandleIgnoresRecordsWithExceptionContext(): void
84+
{
85+
/** @var ClientInterface&MockObject $client */
86+
$client = $this->createMock(ClientInterface::class);
87+
$client->expects($this->never())
88+
->method('captureEvent');
89+
90+
$handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::DEBUG, false);
91+
$record = RecordFactory::create(
92+
'foo bar',
93+
Logger::WARNING,
94+
'channel.foo',
95+
[
96+
'exception' => 'not an exception',
97+
],
98+
[]
99+
);
100+
101+
$this->assertTrue($handler->isHandling($record));
102+
$this->assertFalse($handler->handle($record));
103+
}
104+
105+
public function testHandleIgnoresRecordsBelowThreshold(): void
106+
{
107+
/** @var ClientInterface&MockObject $client */
108+
$client = $this->createMock(ClientInterface::class);
109+
$client->expects($this->never())
110+
->method('captureEvent');
111+
112+
$handler = new LogToSentryIssueHandler(new Hub($client, new Scope()), Logger::ERROR, false);
113+
$record = RecordFactory::create('foo bar', Logger::WARNING, 'channel.foo', [], []);
114+
115+
$this->assertFalse($handler->isHandling($record));
116+
$this->assertFalse($handler->handle($record));
117+
}
118+
119+
public function testLegacyIsHandlingUsesMinimalLevelRecord(): void
120+
{
121+
if (Logger::API >= 3) {
122+
$this->markTestSkipped('Test only works for Monolog < 3');
123+
}
124+
125+
$handler = new LogToSentryIssueHandler(new Hub($this->createMock(ClientInterface::class), new Scope()), Logger::WARNING);
126+
127+
$this->assertTrue($handler->isHandling(['level' => Logger::WARNING]));
128+
$this->assertFalse($handler->isHandling(['level' => Logger::INFO]));
129+
}
130+
131+
public function testLogAndExceptionIssueHandlersReplaceLegacyHandlerUseCases(): void
132+
{
133+
$client = ClientBuilder::create()
134+
->setTransport(StubTransport::getInstance())
135+
->getClient();
136+
$hub = new Hub($client, new Scope());
137+
138+
$logger = new Logger('channel.foo', [
139+
new LogToSentryIssueHandler($hub, Logger::WARNING, true, true),
140+
new ExceptionToSentryIssueHandler($hub, Logger::WARNING),
141+
]);
142+
143+
$logger->warning('plain warning', [
144+
'foo' => 'bar',
145+
]);
146+
147+
$exception = new \RuntimeException('boom');
148+
$logger->error('exception error', [
149+
'exception' => $exception,
150+
'foo' => 'bar',
151+
]);
152+
153+
$this->assertCount(2, StubTransport::$events);
154+
155+
$logEvent = StubTransport::$events[0];
156+
$this->assertSame('plain warning', $logEvent->getMessage());
157+
$this->assertEquals(Severity::warning(), $logEvent->getLevel());
158+
$this->assertSame('monolog.channel.foo', $logEvent->getLogger());
159+
$this->assertSame([], $logEvent->getExceptions());
160+
$this->assertSame([
161+
'monolog.channel' => 'channel.foo',
162+
'monolog.level' => Logger::getLevelName(Logger::WARNING),
163+
'monolog.context' => [
164+
'foo' => 'bar',
165+
],
166+
], $logEvent->getExtra());
167+
168+
$exceptionEvent = StubTransport::$events[1];
169+
$this->assertNull($exceptionEvent->getMessage());
170+
$this->assertCount(1, $exceptionEvent->getExceptions());
171+
$this->assertSame(\RuntimeException::class, $exceptionEvent->getExceptions()[0]->getType());
172+
$this->assertSame('boom', $exceptionEvent->getExceptions()[0]->getValue());
173+
$this->assertSame([
174+
'monolog.channel' => 'channel.foo',
175+
'monolog.level' => Logger::getLevelName(Logger::ERROR),
176+
'monolog.message' => 'exception error',
177+
'monolog.context' => [
178+
'foo' => 'bar',
179+
],
180+
], $exceptionEvent->getExtra());
181+
}
182+
183+
/**
184+
* @return iterable<array{bool, LogRecord|array<string, mixed>, Severity, array<string, mixed>}>
185+
*/
186+
public static function capturedRecordsDataProvider(): iterable
187+
{
188+
foreach ([
189+
Logger::DEBUG => Severity::debug(),
190+
Logger::INFO => Severity::info(),
191+
Logger::NOTICE => Severity::info(),
192+
Logger::WARNING => Severity::warning(),
193+
Logger::ERROR => Severity::error(),
194+
Logger::CRITICAL => Severity::fatal(),
195+
Logger::ALERT => Severity::fatal(),
196+
Logger::EMERGENCY => Severity::fatal(),
197+
] as $level => $severity) {
198+
yield Logger::getLevelName($level) => [
199+
false,
200+
RecordFactory::create('foo bar', $level, 'channel.foo', [], []),
201+
$severity,
202+
[
203+
'monolog.channel' => 'channel.foo',
204+
'monolog.level' => Logger::getLevelName($level),
205+
],
206+
];
207+
}
208+
209+
yield 'with context and extra' => [
210+
true,
211+
RecordFactory::create(
212+
'foo bar',
213+
Logger::WARNING,
214+
'channel.foo',
215+
[
216+
'foo' => 'bar',
217+
],
218+
[
219+
'bar' => 'baz',
220+
]
221+
),
222+
Severity::warning(),
223+
[
224+
'monolog.channel' => 'channel.foo',
225+
'monolog.level' => Logger::getLevelName(Logger::WARNING),
226+
'monolog.context' => [
227+
'foo' => 'bar',
228+
],
229+
'monolog.extra' => [
230+
'bar' => 'baz',
231+
],
232+
],
233+
];
234+
235+
yield 'without context and extra by default' => [
236+
false,
237+
RecordFactory::create(
238+
'foo bar',
239+
Logger::WARNING,
240+
'channel.foo',
241+
[
242+
'foo' => 'bar',
243+
],
244+
[
245+
'bar' => 'baz',
246+
]
247+
),
248+
Severity::warning(),
249+
[
250+
'monolog.channel' => 'channel.foo',
251+
'monolog.level' => Logger::getLevelName(Logger::WARNING),
252+
],
253+
];
254+
}
255+
}

0 commit comments

Comments
 (0)