Repository: getsentry/sentry-php (php)

Issue:
Monolog handler is not compatible with monolog v3
[`Sentry\Monolog\Handler`](https://github.com/getsentry/sentry-php/blob/master/src/Monolog/Handler.php) extends [`Monolog\Handler\AbstractProcessingHandler`](https://github.com/Seldaek/monolog/blob/main/src/Monolog/Handler/AbstractProcessingHandler.php). 

The signature of the constructor has changed in [monolog 3](https://github.com/Seldaek/monolog/blob/3.0.0/UPGRADE.md#300) which leads to a fatal error:

```
Fatal error: Declaration of Sentry\Monolog\Handler::write(array $record): void must be compatible with Monolog\Handler\AbstractProcessingHandler::write(Monolog\LogRecord $record): void in /var/www/html/vendor/sentry/sentry/src/Monolog/Handler.php on line 51
```

Pull request description:
Add compatibility with monolog v3
Fixes #1318 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0664ae9..9f1a9ab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 ## Unreleased
 
+- Add support for `monolog/monolog:^3.0` (#1321)
+
 ## 3.5.0 (2022-05-19)
 
 - Bump minimum version of `guzzlehttp/psr7` package to avoid [`CVE-2022-24775`](https://github.com/guzzle/psr7/security/advisories/GHSA-q7rv-6hp3-vh96) (#1305)
diff --git a/composer.json b/composer.json
index 3f96b27..3bab45e 100644
--- a/composer.json
+++ b/composer.json
@@ -41,7 +41,7 @@
     "require-dev": {
         "friendsofphp/php-cs-fixer": "^2.19|3.4.*",
         "http-interop/http-factory-guzzle": "^1.0",
-        "monolog/monolog": "^1.3|^2.0",
+        "monolog/monolog": "^1.6|^2.0|^3.0",
         "nikic/php-parser": "^4.10.3",
         "php-http/mock-client": "^1.3",
         "phpbench/phpbench": "^1.0",
diff --git a/phpstan.neon b/phpstan.neon
index 43c4ca6..9a3b357 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -10,3 +10,5 @@ parameters:
     excludePaths:
         - tests/resources
         - tests/Fixtures
+    dynamicConstantNames:
+        - Monolog\Logger::API
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 3df2be9..814952f 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<files psalm-version="4.18.1@dda05fa913f4dc6eb3386f2f7ce5a45d37a71bcb">
+<files psalm-version="4.23.0@f1fe6ff483bf325c803df9f510d09a03fd796f88">
   <file src="src/Dsn.php">
     <PossiblyUndefinedArrayOffset occurrences="4">
       <code>$parsedDsn['host']</code>
@@ -22,6 +22,28 @@
       <code>$userIntegrations</code>
     </PossiblyInvalidArgument>
   </file>
+  <file src="src/Monolog/CompatibilityProcessingHandlerTrait.php">
+    <DuplicateClass occurrences="1">
+      <code>CompatibilityProcessingHandlerTrait</code>
+    </DuplicateClass>
+    <UndefinedClass occurrences="1">
+      <code>Level</code>
+    </UndefinedClass>
+  </file>
+  <file src="src/Monolog/Handler.php">
+    <PossiblyInvalidArgument occurrences="3">
+      <code>$record['channel']</code>
+      <code>$record['level']</code>
+      <code>$record['message']</code>
+    </PossiblyInvalidArgument>
+    <PossiblyInvalidArrayOffset occurrences="1">
+      <code>$record['context']['exception']</code>
+    </PossiblyInvalidArrayOffset>
+    <PossiblyUndefinedMethod occurrences="2">
+      <code>$record['context']</code>
+      <code>$record['context']</code>
+    </PossiblyUndefinedMethod>
+  </file>
   <file src="src/Serializer/RepresentationSerializer.php">
     <InvalidReturnStatement occurrences="1">
       <code>$value</code>
@@ -58,4 +80,32 @@
       <code>startTransaction</code>
     </TooManyArguments>
   </file>
+  <file src="vendor/monolog/monolog/src/Monolog/Level.php">
+    <ParseError occurrences="12">
+      <code>$name</code>
+      <code>,</code>
+      <code>,</code>
+      <code>,</code>
+      <code>,</code>
+      <code>,</code>
+      <code>,</code>
+      <code>,</code>
+      <code>,</code>
+      <code>,</code>
+      <code>Level</code>
+      <code>case</code>
+    </ParseError>
+  </file>
+  <file src="vendor/monolog/monolog/src/Monolog/LogRecord.php">
+    <ParseError occurrences="1">
+      <code>\DateTimeImmutable</code>
+    </ParseError>
+  </file>
+  <file src="vendor/monolog/monolog/src/Monolog/Utils.php">
+    <ParseError occurrences="3">
+      <code>:</code>
+      <code>=&gt;</code>
+      <code>}</code>
+    </ParseError>
+  </file>
 </files>
diff --git a/src/Monolog/CompatibilityProcessingHandlerTrait.php b/src/Monolog/CompatibilityProcessingHandlerTrait.php
new file mode 100644
index 0000000..586619c
--- /dev/null
+++ b/src/Monolog/CompatibilityProcessingHandlerTrait.php
@@ -0,0 +1,104 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Sentry\Monolog;
+
+use Monolog\Level;
+use Monolog\Logger;
+use Monolog\LogRecord;
+use Sentry\Severity;
+
+if (Logger::API >= 3) {
+    /**
+     * Logic which is used if monolog >= 3 is installed.
+     *
+     * @internal
+     */
+    trait CompatibilityProcessingHandlerTrait
+    {
+        /**
+         * @param array<string, mixed>|LogRecord $record
+         */
+        abstract protected function doWrite($record): void;
+
+        /**
+         * {@inheritdoc}
+         */
+        protected function write(LogRecord $record): void
+        {
+            $this->doWrite($record);
+        }
+
+        /**
+         * Translates the Monolog level into the Sentry severity.
+         */
+        private static function getSeverityFromLevel(int $level): Severity
+        {
+            $level = Level::from($level);
+
+            switch ($level) {
+                case Level::Debug:
+                    return Severity::debug();
+                case Level::Warning:
+                    return Severity::warning();
+                case Level::Error:
+                    return Severity::error();
+                case Level::Critical:
+                case Level::Alert:
+                case Level::Emergency:
+                    return Severity::fatal();
+                case Level::Info:
+                case Level::Notice:
+                default:
+                    return Severity::info();
+            }
+        }
+    }
+} else {
+    /**
+     * Logic which is used if monolog < 3 is installed.
+     *
+     * @internal
+     */
+    trait CompatibilityProcessingHandlerTrait
+    {
+        /**
+         * @param array<string, mixed>|LogRecord $record
+         */
+        abstract protected function doWrite($record): void;
+
+        /**
+         * {@inheritdoc}
+         */
+        protected function write(array $record): void
+        {
+            $this->doWrite($record);
+        }
+
+        /**
+         * Translates the Monolog level into the Sentry severity.
+         *
+         * @param Logger::DEBUG|Logger::INFO|Logger::NOTICE|Logger::WARNING|Logger::ERROR|Logger::CRITICAL|Logger::ALERT|Logger::EMERGENCY $level The Monolog log level
+         */
+        private static function getSeverityFromLevel(int $level): Severity
+        {
+            switch ($level) {
+                case Logger::DEBUG:
+                    return Severity::debug();
+                case Logger::WARNING:
+                    return Severity::warning();
+                case Logger::ERROR:
+                    return Severity::error();
+                case Logger::CRITICAL:
+                case Logger::ALERT:
+                case Logger::EMERGENCY:
+                    return Severity::fatal();
+                case Logger::INFO:
+                case Logger::NOTICE:
+                default:
+                    return Severity::info();
+            }
+        }
+    }
+}
diff --git a/src/Monolog/Handler.php b/src/Monolog/Handler.php
index e2b97c0..e0e7bb6 100644
--- a/src/Monolog/Handler.php
+++ b/src/Monolog/Handler.php
@@ -6,9 +6,9 @@
 
 use Monolog\Handler\AbstractProcessingHandler;
 use Monolog\Logger;
+use Monolog\LogRecord;
 use Sentry\Event;
 use Sentry\EventHint;
-use Sentry\Severity;
 use Sentry\State\HubInterface;
 use Sentry\State\Scope;
 
@@ -20,6 +20,8 @@
  */
 final class Handler extends AbstractProcessingHandler
 {
+    use CompatibilityProcessingHandlerTrait;
+
     private const CONTEXT_EXCEPTION_KEY = 'exception';
 
     /**
@@ -46,9 +48,9 @@ public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bub
     }
 
     /**
-     * {@inheritdoc}
+     * @param array<string, mixed>|LogRecord $record
      */
-    protected function write(array $record): void
+    protected function doWrite($record): void
     {
         $event = Event::createEvent();
         $event->setLevel(self::getSeverityFromLevel($record['level']));
@@ -75,31 +77,6 @@ protected function write(array $record): void
         });
     }
 
-    /**
-     * Translates the Monolog level into the Sentry severity.
-     *
-     * @param int $level The Monolog log level
-     */
-    private static function getSeverityFromLevel(int $level): Severity
-    {
-        switch ($level) {
-            case Logger::DEBUG:
-                return Severity::debug();
-            case Logger::WARNING:
-                return Severity::warning();
-            case Logger::ERROR:
-                return Severity::error();
-            case Logger::CRITICAL:
-            case Logger::ALERT:
-            case Logger::EMERGENCY:
-                return Severity::fatal();
-            case Logger::INFO:
-            case Logger::NOTICE:
-            default:
-                return Severity::info();
-        }
-    }
-
     /**
      * @param mixed[] $context
      *


Test patch:
diff --git a/tests/Monolog/HandlerTest.php b/tests/Monolog/HandlerTest.php
index 7be7a09..d3c06df 100644
--- a/tests/Monolog/HandlerTest.php
+++ b/tests/Monolog/HandlerTest.php
@@ -20,7 +20,7 @@ final class HandlerTest extends TestCase
     /**
      * @dataProvider handleDataProvider
      */
-    public function testHandle(bool $fillExtraContext, array $record, Event $expectedEvent, EventHint $expectedHint, array $expectedExtra): void
+    public function testHandle(bool $fillExtraContext, $record, Event $expectedEvent, EventHint $expectedHint, array $expectedExtra): void
     {
         /** @var ClientInterface&MockObject $client */
         $client = $this->createMock(ClientInterface::class);
@@ -58,14 +58,13 @@ public function handleDataProvider(): iterable
 
         yield [
             false,
-            [
-                'message' => 'foo bar',
-                'level' => Logger::DEBUG,
-                'level_name' => Logger::getLevelName(Logger::DEBUG),
-                'channel' => 'channel.foo',
-                'context' => [],
-                'extra' => [],
-            ],
+            RecordFactory::create(
+                'foo bar',
+                Logger::DEBUG,
+                'channel.foo',
+                [],
+                []
+            ),
             $event,
             new EventHint(),
             [
@@ -81,14 +80,13 @@ public function handleDataProvider(): iterable
 
         yield [
             false,
-            [
-                'message' => 'foo bar',
-                'level' => Logger::INFO,
-                'level_name' => Logger::getLevelName(Logger::INFO),
-                'channel' => 'channel.foo',
-                'context' => [],
-                'extra' => [],
-            ],
+            RecordFactory::create(
+                'foo bar',
+                Logger::INFO,
+                'channel.foo',
+                [],
+                []
+            ),
             $event,
             new EventHint(),
             [
@@ -104,14 +102,13 @@ public function handleDataProvider(): iterable
 
         yield [
             false,
-            [
-                'message' => 'foo bar',
-                'level' => Logger::NOTICE,
-                'level_name' => Logger::getLevelName(Logger::NOTICE),
-                'channel' => 'channel.foo',
-                'context' => [],
-                'extra' => [],
-            ],
+            RecordFactory::create(
+                'foo bar',
+                Logger::NOTICE,
+                'channel.foo',
+                [],
+                []
+            ),
             $event,
             new EventHint(),
             [
@@ -127,14 +124,13 @@ public function handleDataProvider(): iterable
 
         yield [
             false,
-            [
-                'message' => 'foo bar',
-                'level' => Logger::WARNING,
-                'level_name' => Logger::getLevelName(Logger::WARNING),
-                'channel' => 'channel.foo',
-                'context' => [],
-                'extra' => [],
-            ],
+            RecordFactory::create(
+                'foo bar',
+                Logger::WARNING,
+                'channel.foo',
+                [],
+                []
+            ),
             $event,
             new EventHint(),
             [
@@ -150,14 +146,13 @@ public function handleDataProvider(): iterable
 
         yield [
             false,
-            [
-                'message' => 'foo bar',
-                'level' => Logger::ERROR,
-                'level_name' => Logger::getLevelName(Logger::ERROR),
-                'channel' => 'channel.foo',
-                'context' => [],
-                'extra' => [],
-            ],
+            RecordFactory::create(
+                'foo bar',
+                Logger::ERROR,
+                'channel.foo',
+                [],
+                []
+            ),
             $event,
             new EventHint(),
             [
@@ -173,14 +168,13 @@ public function handleDataProvider(): iterable
 
         yield [
             false,
-            [
-                'message' => 'foo bar',
-                'level' => Logger::CRITICAL,
-                'level_name' => Logger::getLevelName(Logger::CRITICAL),
-                'channel' => 'channel.foo',
-                'context' => [],
-                'extra' => [],
-            ],
+            RecordFactory::create(
+                'foo bar',
+                Logger::CRITICAL,
+                'channel.foo',
+                [],
+                []
+            ),
             $event,
             new EventHint(),
             [
@@ -196,14 +190,13 @@ public function handleDataProvider(): iterable
 
         yield [
             false,
-            [
-                'message' => 'foo bar',
-                'level' => Logger::ALERT,
-                'level_name' => Logger::getLevelName(Logger::ALERT),
-                'channel' => 'channel.foo',
-                'context' => [],
-                'extra' => [],
-            ],
+            RecordFactory::create(
+                'foo bar',
+                Logger::ALERT,
+                'channel.foo',
+                [],
+                []
+            ),
             $event,
             new EventHint(),
             [
@@ -219,14 +212,13 @@ public function handleDataProvider(): iterable
 
         yield [
             false,
-            [
-                'message' => 'foo bar',
-                'level' => Logger::EMERGENCY,
-                'level_name' => Logger::getLevelName(Logger::EMERGENCY),
-                'channel' => 'channel.foo',
-                'context' => [],
-                'extra' => [],
-            ],
+            RecordFactory::create(
+                'foo bar',
+                Logger::EMERGENCY,
+                'channel.foo',
+                [],
+                []
+            ),
             $event,
             new EventHint(),
             [
@@ -244,16 +236,15 @@ public function handleDataProvider(): iterable
 
         yield [
             false,
-            [
-                'message' => 'foo bar',
-                'level' => Logger::WARNING,
-                'level_name' => Logger::getLevelName(Logger::WARNING),
-                'context' => [
+            RecordFactory::create(
+                'foo bar',
+                Logger::WARNING,
+                'channel.foo',
+                [
                     'exception' => $exampleException,
                 ],
-                'channel' => 'channel.foo',
-                'extra' => [],
-            ],
+                []
+            ),
             $event,
             EventHint::fromArray([
                 'exception' => $exampleException,
@@ -271,17 +262,16 @@ public function handleDataProvider(): iterable
 
         yield 'Monolog\'s context is filled and the handler should fill the "extra" context' => [
             true,
-            [
-                'message' => 'foo bar',
-                'level' => Logger::WARNING,
-                'level_name' => Logger::getLevelName(Logger::WARNING),
-                'context' => [
+            RecordFactory::create(
+                'foo bar',
+                Logger::WARNING,
+                'channel.foo',
+                [
                     'foo' => 'bar',
                     'bar' => 'baz',
                 ],
-                'channel' => 'channel.foo',
-                'extra' => [],
-            ],
+                []
+            ),
             $event,
             new EventHint(),
             [
@@ -301,16 +291,15 @@ public function handleDataProvider(): iterable
 
         yield 'Monolog\'s context is filled with "exception" field and the handler should fill the "extra" context' => [
             true,
-            [
-                'message' => 'foo bar',
-                'level' => Logger::WARNING,
-                'level_name' => Logger::getLevelName(Logger::WARNING),
-                'context' => [
+            RecordFactory::create(
+                'foo bar',
+                Logger::WARNING,
+                'channel.foo',
+                [
                     'exception' => new \Exception('exception message'),
                 ],
-                'channel' => 'channel.foo',
-                'extra' => [],
-            ],
+                []
+            ),
             $event,
             EventHint::fromArray([
                 'exception' => $exampleException,
@@ -328,17 +317,16 @@ public function handleDataProvider(): iterable
 
         yield 'Monolog\'s context is filled but handler should not fill the "extra" context' => [
             false,
-            [
-                'message' => 'foo bar',
-                'level' => Logger::WARNING,
-                'level_name' => Logger::getLevelName(Logger::WARNING),
-                'context' => [
+            RecordFactory::create(
+                'foo bar',
+                Logger::WARNING,
+                'channel.foo',
+                [
                     'foo' => 'bar',
                     'bar' => 'baz',
                 ],
-                'channel' => 'channel.foo',
-                'extra' => [],
-            ],
+                []
+            ),
             $event,
             new EventHint(),
             [
diff --git a/tests/Monolog/RecordFactory.php b/tests/Monolog/RecordFactory.php
new file mode 100644
index 0000000..9f65473
--- /dev/null
+++ b/tests/Monolog/RecordFactory.php
@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Sentry\Tests\Monolog;
+
+use DateTimeImmutable;
+use Monolog\Logger;
+use Monolog\LogRecord;
+
+/**
+ * @internal
+ */
+final class RecordFactory
+{
+    /**
+     * @param Logger::DEBUG|Logger::INFO|Logger::NOTICE|Logger::WARNING|Logger::ERROR|Logger::CRITICAL|Logger::ALERT|Logger::EMERGENCY $level   The Monolog log level
+     * @param array<string, mixed>                                                                                                     $context
+     * @param array<string, mixed>                                                                                                     $extra
+     *
+     * @return array<string, mixed>|LogRecord
+     */
+    public static function create(string $message, int $level, string $channel, array $context, array $extra)
+    {
+        if (Logger::API >= 3) {
+            return new LogRecord(
+                new DateTimeImmutable(),
+                $channel,
+                Logger::toMonologLevel($level),
+                $message,
+                $context,
+                $extra
+            );
+        }
+
+        return [
+            'message' => $message,
+            'context' => $context,
+            'level' => $level,
+            'level_name' => Logger::getLevelName($level),
+            'channel' => $channel,
+            'extra' => $extra,
+        ];
+    }
+}