Repository: Seldaek/monolog (php)

Issue:
PHP 8.2 - `utf8_encode` deprecated
Monolog version 1|2

**PHP 8.2** 
https://php.watch/versions/8.2/utf8_encode-utf8_decode-deprecated

**Suggestion:**
```php
function_exists('mb_convert_encoding') ? mb_convert_encoding($json, 'UTF-8', mb_list_encodings()) : utf8_encode($json);
```

----

https://github.com/Seldaek/monolog/blob/d381140ca134f7ece43cd2c139d2207bb26dc329/src/Monolog/Utils.php#L203
https://github.com/Seldaek/monolog/blob/5579edf28aee1190a798bfa5be8bc16c563bd524/src/Monolog/Handler/ChromePHPHandler.php#L152
https://github.com/Seldaek/monolog/blob/5579edf28aee1190a798bfa5be8bc16c563bd524/src/Monolog/Handler/ChromePHPHandler.php#L167

Pull request description:
Fix utf8_encode deprecation
Closes #1718

Patch:
diff --git a/.gitattributes b/.gitattributes
index 52e9cb43..6da920e3 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,7 +1,7 @@
 /doc export-ignore
 /tests export-ignore
 /.* export-ignore
-/phpstan.neon.dist export-ignore
+/phpstan* export-ignore
 /phpunit.xml.dist export-ignore
 /_config.yml export-ignore
 /UPGRADE.md
diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
index ff2cc1a6..ace0a642 100644
--- a/.github/workflows/continuous-integration.yml
+++ b/.github/workflows/continuous-integration.yml
@@ -152,6 +152,10 @@ jobs:
       - name: "Change dependencies"
         run: "composer require --no-update --no-interaction --dev elasticsearch/elasticsearch:^${{ matrix.es-version }}"
 
+      - name: "Allow composer plugin to run"
+        if: "matrix.php-version == '7.4' && matrix.dependencies == 'lowest'"
+        run: "composer config allow-plugins.ocramius/package-versions true"
+
       - name: "Update dependencies with composer"
         uses: "ramsey/composer-install@v1"
         with:
@@ -229,6 +233,10 @@ jobs:
           composer remove --no-update --dev graylog2/gelf-php ruflin/elastica elasticsearch/elasticsearch rollbar/rollbar
           composer require --no-update --no-interaction --dev elasticsearch/elasticsearch:^8
 
+      - name: "Allow composer plugin to run"
+        if: "matrix.php-version == '7.4' && matrix.dependencies == 'lowest'"
+        run: "composer config allow-plugins.ocramius/package-versions true"
+
       - name: "Update dependencies with composer"
         uses: "ramsey/composer-install@v1"
         with:
diff --git a/composer.json b/composer.json
index 52cb35f6..d01b8e18 100644
--- a/composer.json
+++ b/composer.json
@@ -30,7 +30,7 @@
         "phpspec/prophecy": "^1.15",
         "phpstan/phpstan": "^0.12.91",
         "phpunit/phpunit": "^8.5.14",
-        "predis/predis": "^1.1",
+        "predis/predis": "^1.1 || ^2.0",
         "rollbar/rollbar": "^1.3 || ^2 || ^3",
         "ruflin/elastica": "^7",
         "swiftmailer/swiftmailer": "^5.3|^6.0",
diff --git a/src/Monolog/Formatter/GoogleCloudLoggingFormatter.php b/src/Monolog/Formatter/GoogleCloudLoggingFormatter.php
new file mode 100644
index 00000000..0cd287f5
--- /dev/null
+++ b/src/Monolog/Formatter/GoogleCloudLoggingFormatter.php
@@ -0,0 +1,39 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Monolog package.
+ *
+ * (c) Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Monolog\Formatter;
+
+use DateTimeInterface;
+use Monolog\LogRecord;
+
+/**
+ * Encodes message information into JSON in a format compatible with Cloud logging.
+ *
+ * @see https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry
+ *
+ * @author Luís Cobucci <lcobucci@gmail.com>
+ */
+final class GoogleCloudLoggingFormatter extends JsonFormatter
+{
+    /** {@inheritdoc} **/
+    public function format(array $record): string
+    {
+        // Re-key level for GCP logging
+        $record['severity'] = $record['level_name'];
+        $record['timestamp'] = $record['datetime']->format(DateTimeInterface::RFC3339_EXTENDED);
+
+        // Remove keys that are not used by GCP
+        unset($record['level'], $record['level_name'], $record['datetime']);
+
+        return parent::format($record);
+    }
+}
+
diff --git a/src/Monolog/Formatter/LineFormatter.php b/src/Monolog/Formatter/LineFormatter.php
index d0b4de94..69dfee5c 100644
--- a/src/Monolog/Formatter/LineFormatter.php
+++ b/src/Monolog/Formatter/LineFormatter.php
@@ -153,6 +153,12 @@ class LineFormatter extends NormalizerFormatter
 
         if ($previous = $e->getPrevious()) {
             do {
+                $depth++;
+                if ($depth > $this->maxNormalizeDepth) {
+                    $str .= '\n[previous exception] Over ' . $this->maxNormalizeDepth . ' levels deep, aborting normalization';
+                    break;
+                }
+
                 $str .= "\n[previous exception] " . $this->formatException($previous);
             } while ($previous = $previous->getPrevious());
         }
diff --git a/src/Monolog/Formatter/NormalizerFormatter.php b/src/Monolog/Formatter/NormalizerFormatter.php
index 0c622715..5441bc0a 100644
--- a/src/Monolog/Formatter/NormalizerFormatter.php
+++ b/src/Monolog/Formatter/NormalizerFormatter.php
@@ -198,6 +198,10 @@ class NormalizerFormatter implements FormatterInterface
      */
     protected function normalizeException(Throwable $e, int $depth = 0)
     {
+        if ($depth > $this->maxNormalizeDepth) {
+            return ['Over ' . $this->maxNormalizeDepth . ' levels deep, aborting normalization'];
+        }
+
         if ($e instanceof \JsonSerializable) {
             return (array) $e->jsonSerialize();
         }
diff --git a/src/Monolog/Handler/AmqpHandler.php b/src/Monolog/Handler/AmqpHandler.php
index e30d784d..c4997482 100644
--- a/src/Monolog/Handler/AmqpHandler.php
+++ b/src/Monolog/Handler/AmqpHandler.php
@@ -27,6 +27,31 @@ class AmqpHandler extends AbstractProcessingHandler
      * @var AMQPExchange|AMQPChannel $exchange
      */
     protected $exchange;
+    /** @var array<string, mixed> */
+    private $extraAttributes = [];
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function getExtraAttributes(): array
+    {
+        return $this->extraAttributes;
+    }
+
+    /**
+     * Configure extra attributes to pass to the AMQPExchange (if you are using the amqp extension)
+     *
+     * @param array<string, mixed> $extraAttributes  One of content_type, content_encoding,
+     *                                               message_id, user_id, app_id, delivery_mode,
+     *                                               priority, timestamp, expiration, type
+     *                                               or reply_to, headers.
+     * @return AmqpHandler
+     */
+    public function setExtraAttributes(array $extraAttributes): self
+    {
+        $this->extraAttributes = $extraAttributes;
+        return $this;
+    }
 
     /**
      * @var string
@@ -60,14 +85,18 @@ class AmqpHandler extends AbstractProcessingHandler
         $routingKey = $this->getRoutingKey($record);
 
         if ($this->exchange instanceof AMQPExchange) {
+            $attributes = [
+                'delivery_mode' => 2,
+                'content_type'  => 'application/json',
+            ];
+            if ($this->extraAttributes) {
+                $attributes = array_merge($attributes, $this->extraAttributes);
+            }
             $this->exchange->publish(
                 $data,
                 $routingKey,
                 0,
-                [
-                    'delivery_mode' => 2,
-                    'content_type' => 'application/json',
-                ]
+                $attributes
             );
         } else {
             $this->exchange->basic_publish(
diff --git a/src/Monolog/Handler/ChromePHPHandler.php b/src/Monolog/Handler/ChromePHPHandler.php
index d1a98b8f..234ecf61 100644
--- a/src/Monolog/Handler/ChromePHPHandler.php
+++ b/src/Monolog/Handler/ChromePHPHandler.php
@@ -149,7 +149,7 @@ class ChromePHPHandler extends AbstractProcessingHandler
         }
 
         $json = Utils::jsonEncode(self::$json, Utils::DEFAULT_JSON_FLAGS & ~JSON_UNESCAPED_UNICODE, true);
-        $data = base64_encode(utf8_encode($json));
+        $data = base64_encode($json);
         if (strlen($data) > 3 * 1024) {
             self::$overflowed = true;
 
@@ -163,8 +163,8 @@ class ChromePHPHandler extends AbstractProcessingHandler
                 'extra' => [],
             ];
             self::$json['rows'][count(self::$json['rows']) - 1] = $this->getFormatter()->format($record);
-            $json = Utils::jsonEncode(self::$json, null, true);
-            $data = base64_encode(utf8_encode($json));
+            $json = Utils::jsonEncode(self::$json, Utils::DEFAULT_JSON_FLAGS & ~JSON_UNESCAPED_UNICODE, true);
+            $data = base64_encode($json);
         }
 
         if (trim($data) !== '') {
diff --git a/src/Monolog/Handler/RedisHandler.php b/src/Monolog/Handler/RedisHandler.php
index 938eee6b..91d16eaf 100644
--- a/src/Monolog/Handler/RedisHandler.php
+++ b/src/Monolog/Handler/RedisHandler.php
@@ -30,7 +30,7 @@ use Monolog\Logger;
  */
 class RedisHandler extends AbstractProcessingHandler
 {
-    /** @var \Predis\Client|\Redis */
+    /** @var \Predis\Client<\Predis\Client>|\Redis */
     private $redisClient;
     /** @var string */
     private $redisKey;
@@ -38,7 +38,7 @@ class RedisHandler extends AbstractProcessingHandler
     protected $capSize;
 
     /**
-     * @param \Predis\Client|\Redis $redis   The redis instance
+     * @param \Predis\Client<\Predis\Client>|\Redis $redis   The redis instance
      * @param string                $key     The key name to push records to
      * @param int                   $capSize Number of entries to limit list size to, 0 = unlimited
      */
diff --git a/src/Monolog/Handler/RedisPubSubHandler.php b/src/Monolog/Handler/RedisPubSubHandler.php
index f9fede8e..7789309c 100644
--- a/src/Monolog/Handler/RedisPubSubHandler.php
+++ b/src/Monolog/Handler/RedisPubSubHandler.php
@@ -28,13 +28,13 @@ use Monolog\Logger;
  */
 class RedisPubSubHandler extends AbstractProcessingHandler
 {
-    /** @var \Predis\Client|\Redis */
+    /** @var \Predis\Client<\Predis\Client>|\Redis */
     private $redisClient;
     /** @var string */
     private $channelKey;
 
     /**
-     * @param \Predis\Client|\Redis $redis The redis instance
+     * @param \Predis\Client<\Predis\Client>|\Redis $redis The redis instance
      * @param string                $key   The channel key to publish records to
      */
     public function __construct($redis, string $key, $level = Logger::DEBUG, bool $bubble = true)
diff --git a/src/Monolog/Handler/StreamHandler.php b/src/Monolog/Handler/StreamHandler.php
index e6c79569..65183512 100644
--- a/src/Monolog/Handler/StreamHandler.php
+++ b/src/Monolog/Handler/StreamHandler.php
@@ -212,7 +212,7 @@ class StreamHandler extends AbstractProcessingHandler
             set_error_handler([$this, 'customErrorHandler']);
             $status = mkdir($dir, 0777, true);
             restore_error_handler();
-            if (false === $status && !is_dir($dir)) {
+            if (false === $status && !is_dir($dir) && strpos((string) $this->errorMessage, 'File exists') === false) {
                 throw new \UnexpectedValueException(sprintf('There is no existing directory at "%s" and it could not be created: '.$this->errorMessage, $dir));
             }
         }
diff --git a/src/Monolog/Utils.php b/src/Monolog/Utils.php
index 726c9819..360c4219 100644
--- a/src/Monolog/Utils.php
+++ b/src/Monolog/Utils.php
@@ -211,7 +211,7 @@ final class Utils
             $data = preg_replace_callback(
                 '/[\x80-\xFF]+/',
                 function ($m) {
-                    return utf8_encode($m[0]);
+                    return function_exists('mb_convert_encoding') ? mb_convert_encoding($m[0], 'UTF-8', 'ISO-8859-1') : utf8_encode($m[0]);
                 },
                 $data
             );


Test patch:
diff --git a/tests/Monolog/Formatter/GoogleCloudLoggingFormatterTest.php b/tests/Monolog/Formatter/GoogleCloudLoggingFormatterTest.php
new file mode 100644
index 00000000..27aea2ca
--- /dev/null
+++ b/tests/Monolog/Formatter/GoogleCloudLoggingFormatterTest.php
@@ -0,0 +1,54 @@
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Monolog package.
+ *
+ * (c) Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Monolog\Formatter;
+
+use DateTimeInterface;
+use Monolog\Test\TestCase;
+use function json_decode;
+
+class GoogleCloudLoggingFormatterTest extends TestCase
+{
+    /**
+     * @test
+     *
+     * @covers \Monolog\Formatter\JsonFormatter
+     * @covers \Monolog\Formatter\GoogleCloudLoggingFormatter::format
+     */
+    public function formatProvidesRfc3339Timestamps(): void
+    {
+        $formatter = new GoogleCloudLoggingFormatter();
+        $record = $this->getRecord();
+
+        $formatted_decoded = json_decode($formatter->format($record), true);
+        $this->assertArrayNotHasKey("datetime", $formatted_decoded);
+        $this->assertArrayHasKey("timestamp", $formatted_decoded);
+        $this->assertSame($record['datetime']->format(DateTimeInterface::RFC3339_EXTENDED), $formatted_decoded["timestamp"]);
+    }
+
+    /**
+     * @test
+     *
+     * @covers \Monolog\Formatter\JsonFormatter
+     * @covers \Monolog\Formatter\GoogleCloudLoggingFormatter::format
+     */
+    public function formatIntroducesLogSeverity(): void
+    {
+        $formatter = new GoogleCloudLoggingFormatter();
+        $record = $this->getRecord();
+
+        $formatted_decoded = json_decode($formatter->format($record), true);
+        $this->assertArrayNotHasKey("level", $formatted_decoded);
+        $this->assertArrayNotHasKey("level_name", $formatted_decoded);
+        $this->assertArrayHasKey("severity", $formatted_decoded);
+        $this->assertSame($record['level_name'], $formatted_decoded["severity"]);
+    }
+}
diff --git a/tests/Monolog/Handler/ChromePHPHandlerTest.php b/tests/Monolog/Handler/ChromePHPHandlerTest.php
index 1ee58195..cf13dd89 100644
--- a/tests/Monolog/Handler/ChromePHPHandlerTest.php
+++ b/tests/Monolog/Handler/ChromePHPHandlerTest.php
@@ -38,7 +38,7 @@ class ChromePHPHandlerTest extends TestCase
         $handler->handle($this->getRecord(Logger::WARNING));
 
         $expected = [
-            'X-ChromeLogger-Data'   => base64_encode(utf8_encode(json_encode([
+            'X-ChromeLogger-Data'   => base64_encode(json_encode([
                 'version' => '4.0',
                 'columns' => ['label', 'log', 'backtrace', 'type'],
                 'rows' => [
@@ -46,7 +46,7 @@ class ChromePHPHandlerTest extends TestCase
                     'test',
                 ],
                 'request_uri' => '',
-            ]))),
+            ])),
         ];
 
         $this->assertEquals($expected, $handler->getHeaders());
@@ -72,7 +72,7 @@ class ChromePHPHandlerTest extends TestCase
         $handler->handle($this->getRecord(Logger::WARNING, str_repeat('b', 2 * 1024)));
 
         $expected = [
-            'X-ChromeLogger-Data'   => base64_encode(utf8_encode(json_encode([
+            'X-ChromeLogger-Data'   => base64_encode(json_encode([
                 'version' => '4.0',
                 'columns' => ['label', 'log', 'backtrace', 'type'],
                 'rows' => [
@@ -96,7 +96,7 @@ class ChromePHPHandlerTest extends TestCase
                     ],
                 ],
                 'request_uri' => '',
-            ]))),
+            ])),
         ];
 
         $this->assertEquals($expected, $handler->getHeaders());
@@ -115,7 +115,7 @@ class ChromePHPHandlerTest extends TestCase
         $handler2->handle($this->getRecord(Logger::WARNING));
 
         $expected = [
-            'X-ChromeLogger-Data'   => base64_encode(utf8_encode(json_encode([
+            'X-ChromeLogger-Data'   => base64_encode(json_encode([
                 'version' => '4.0',
                 'columns' => ['label', 'log', 'backtrace', 'type'],
                 'rows' => [
@@ -125,7 +125,7 @@ class ChromePHPHandlerTest extends TestCase
                     'test',
                 ],
                 'request_uri' => '',
-            ]))),
+            ])),
         ];
 
         $this->assertEquals($expected, $handler2->getHeaders());