Forráskód Böngészése

Merge branch '2.x' into main

Jordi Boggiano 3 éve
szülő
commit
1181473f4b
31 módosított fájl, 693 hozzáadás és 178 törlés
  1. 176 22
      .github/workflows/continuous-integration.yml
  2. 1 1
      .github/workflows/lint.yml
  3. 5 0
      CHANGELOG.md
  4. 8 3
      composer.json
  5. 1 1
      phpstan-baseline.neon
  6. 3 0
      phpstan.neon.dist
  7. 3 3
      src/Monolog/Attribute/AsMonologProcessor.php
  8. 35 8
      src/Monolog/Handler/ElasticsearchHandler.php
  9. 110 0
      src/Monolog/Handler/SymfonyMailerHandler.php
  10. 9 0
      src/Monolog/Test/TestCase.php
  11. 7 0
      tests/Monolog/Formatter/ScalarFormatterTest.php
  12. 1 1
      tests/Monolog/Handler/AmqpHandlerTest.php
  13. 7 0
      tests/Monolog/Handler/DynamoDbHandlerTest.php
  14. 20 49
      tests/Monolog/Handler/ElasticaHandlerTest.php
  15. 80 65
      tests/Monolog/Handler/ElasticsearchHandlerTest.php
  16. 7 0
      tests/Monolog/Handler/FlowdockHandlerTest.php
  17. 7 0
      tests/Monolog/Handler/HandlerWrapperTest.php
  18. 7 0
      tests/Monolog/Handler/InsightOpsHandlerTest.php
  19. 7 0
      tests/Monolog/Handler/LogEntriesHandlerTest.php
  20. 7 0
      tests/Monolog/Handler/LogmaticHandlerTest.php
  21. 7 0
      tests/Monolog/Handler/PHPConsoleHandlerTest.php
  22. 7 0
      tests/Monolog/Handler/PushoverHandlerTest.php
  23. 7 0
      tests/Monolog/Handler/RollbarHandlerTest.php
  24. 12 8
      tests/Monolog/Handler/RotatingFileHandlerTest.php
  25. 7 0
      tests/Monolog/Handler/SlackHandlerTest.php
  26. 7 0
      tests/Monolog/Handler/SocketHandlerTest.php
  27. 19 16
      tests/Monolog/Handler/StreamHandlerTest.php
  28. 107 0
      tests/Monolog/Handler/SymfonyMailerHandlerTest.php
  29. 7 0
      tests/Monolog/Handler/ZendMonitorHandlerTest.php
  30. 7 0
      tests/Monolog/PsrLogCompatTest.php
  31. 5 1
      tests/Monolog/SignalHandlerTest.php

+ 176 - 22
.github/workflows/continuous-integration.yml

@@ -4,64 +4,218 @@ on:
   - push
   - pull_request
 
-env:
-  COMPOSER_FLAGS: "--ansi --no-interaction --no-progress --prefer-dist"
-
 jobs:
   tests:
     name: "CI"
 
-    runs-on: ubuntu-latest
+    runs-on: "${{ matrix.operating-system }}"
 
     strategy:
+      fail-fast: false
+
       matrix:
         php-version:
           - "8.1"
+
         dependencies: [highest]
+
+        operating-system:
+          - "ubuntu-latest"
+
         include:
           - php-version: "8.1"
             dependencies: lowest
+            operating-system: ubuntu-latest
 
     steps:
       - name: "Checkout"
         uses: "actions/checkout@v2"
 
+      - name: Run CouchDB
+        timeout-minutes: 1
+        continue-on-error: true
+        uses: "cobot/couchdb-action@master"
+        with:
+          couchdb version: '2.3.1'
+
+      - name: Run MongoDB
+        uses: supercharge/mongodb-github-action@1.7.0
+        with:
+          mongodb-version: 5.0
+
       - name: "Install PHP"
         uses: "shivammathur/setup-php@v2"
         with:
           coverage: "none"
           php-version: "${{ matrix.php-version }}"
           extensions: mongodb, redis, amqp
+          tools: "composer:v2"
+          ini-values: "memory_limit=-1"
+
+      - name: Add require for mongodb/mongodb to make tests runnable
+        run: 'composer require mongodb/mongodb --dev --no-update'
 
-      - name: Get composer cache directory
-        id: composercache
-        run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+      - name: "Change dependencies"
+        run: |
+          composer require --no-update --no-interaction --dev elasticsearch/elasticsearch:^7
+          composer config --no-plugins allow-plugins.ocramius/package-versions true
 
-      - name: Cache dependencies
-        uses: actions/cache@v2
+      - name: "Update dependencies with composer"
+        uses: "ramsey/composer-install@v1"
         with:
-          path: ${{ steps.composercache.outputs.dir }}
-          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}
-          restore-keys: ${{ runner.os }}-composer-
+          dependency-versions: "${{ matrix.dependencies }}"
 
-      - name: Add require for mongodb/mongodb to make tests runnable
-        run: 'composer require ${{ env.COMPOSER_FLAGS }} mongodb/mongodb --dev --no-update'
+      - name: "Run tests"
+        run: "composer exec phpunit -- --exclude-group Elasticsearch,Elastica --verbose"
+
+      - name: "Run tests with psr/log 3"
+        if: "contains(matrix.dependencies, 'highest') && matrix.php-version >= '8.0'"
+        run: |
+          composer remove --no-update --dev graylog2/gelf-php ruflin/elastica elasticsearch/elasticsearch rollbar/rollbar
+          composer require --no-update psr/log:^3
+          composer update -W
+          composer exec phpunit -- --exclude-group Elasticsearch,Elastica --verbose
+
+  tests-es-7:
+    name: "CI with ES ${{ matrix.es-version }} on PHP ${{ matrix.php-version }}"
+
+    needs: "tests"
 
-      - name: "Handle lowest dependencies update"
-        if: "contains(matrix.dependencies, 'lowest')"
-        run: "echo \"COMPOSER_FLAGS=$COMPOSER_FLAGS --prefer-lowest\" >> $GITHUB_ENV"
+    runs-on: "${{ matrix.operating-system }}"
 
-      - name: "Install latest dependencies"
+    strategy:
+      fail-fast: false
+
+      matrix:
+        operating-system:
+          - "ubuntu-latest"
+
+        php-version:
+          - "8.1"
+
+        dependencies:
+          - "highest"
+          - "lowest"
+
+        es-version:
+          - "7.0.0"
+          - "7.17.0"
+
+    steps:
+      - name: "Checkout"
+        uses: "actions/checkout@v2"
+
+      # required for elasticsearch
+      - name: Configure sysctl limits
         run: |
-          composer update ${{ env.COMPOSER_FLAGS }}
+          sudo swapoff -a
+          sudo sysctl -w vm.swappiness=1
+          sudo sysctl -w fs.file-max=262144
+          sudo sysctl -w vm.max_map_count=262144
+
+      - name: Run Elasticsearch
+        timeout-minutes: 1
+        uses: elastic/elastic-github-actions/elasticsearch@master
+        with:
+          stack-version: "${{ matrix.es-version }}"
+
+      - name: "Install PHP"
+        uses: "shivammathur/setup-php@v2"
+        with:
+          coverage: "none"
+          php-version: "${{ matrix.php-version }}"
+          extensions: mongodb, redis, amqp
+          tools: "composer:v2"
+          ini-values: "memory_limit=-1"
+
+      - name: "Change dependencies"
+        run: "composer require --no-update --no-interaction --dev elasticsearch/elasticsearch:^${{ matrix.es-version }}"
+
+      - name: "Update dependencies with composer"
+        uses: "ramsey/composer-install@v1"
+        with:
+          dependency-versions: "${{ matrix.dependencies }}"
 
       - name: "Run tests"
-        run: "composer exec phpunit -- --verbose"
+        run: "composer exec phpunit -- --group Elasticsearch,Elastica --verbose"
 
       - name: "Run tests with psr/log 3"
         if: "contains(matrix.dependencies, 'highest') && matrix.php-version >= '8.0'"
         run: |
           composer remove --no-update --dev graylog2/gelf-php ruflin/elastica elasticsearch/elasticsearch rollbar/rollbar
+          composer require --no-update --no-interaction --dev ruflin/elastica elasticsearch/elasticsearch:^7
+          composer require --no-update psr/log:^3
+          composer update -W
+          composer exec phpunit -- --group Elasticsearch,Elastica --verbose
+
+  tests-es-8:
+    name: "CI with ES ${{ matrix.es-version }} on PHP ${{ matrix.php-version }}"
+
+    needs: "tests"
+
+    runs-on: "${{ matrix.operating-system }}"
+
+    strategy:
+      fail-fast: false
+
+      matrix:
+        operating-system:
+          - "ubuntu-latest"
+
+        php-version:
+          - "8.1"
+
+        dependencies:
+          - "highest"
+          - "lowest"
+
+        es-version:
+          - "8.0.0"
+          - "8.2.0"
+
+    steps:
+      - name: "Checkout"
+        uses: "actions/checkout@v2"
+
+      # required for elasticsearch
+      - name: Configure sysctl limits
+        run: |
+          sudo swapoff -a
+          sudo sysctl -w vm.swappiness=1
+          sudo sysctl -w fs.file-max=262144
+          sudo sysctl -w vm.max_map_count=262144
+
+      - name: Run Elasticsearch
+        timeout-minutes: 1
+        uses: elastic/elastic-github-actions/elasticsearch@master
+        with:
+          stack-version: "${{ matrix.es-version }}"
+
+      - name: "Install PHP"
+        uses: "shivammathur/setup-php@v2"
+        with:
+          coverage: "none"
+          php-version: "${{ matrix.php-version }}"
+          extensions: mongodb, redis, amqp
+          tools: "composer:v2"
+          ini-values: "memory_limit=-1"
+
+      - name: "Change dependencies"
+        run: |
+          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: "Update dependencies with composer"
+        uses: "ramsey/composer-install@v1"
+        with:
+          dependency-versions: "${{ matrix.dependencies }}"
+
+      - name: "Run tests"
+        run: "composer exec phpunit -- --group Elasticsearch,Elastica --verbose"
+
+      - name: "Run tests with psr/log 3"
+        if: "contains(matrix.dependencies, 'highest') && matrix.php-version >= '8.0'"
+        run: |
           composer require --no-update psr/log:^3
-          composer update -W ${{ env.COMPOSER_FLAGS }}
-          composer exec phpunit -- --verbose
+          composer update -W
+          composer exec phpunit -- --group Elasticsearch,Elastica --verbose

+ 1 - 1
.github/workflows/lint.yml

@@ -28,4 +28,4 @@ jobs:
           php-version: "${{ matrix.php-version }}"
 
       - name: "Lint PHP files"
-        run: "find src/ -type f -name '*.php' -not -name AsMonologProcessor.php -print0 | xargs -0 -L1 -P4 -- php -l -f"
+        run: "find src/ -type f -name '*.php' -print0 | xargs -0 -L1 -P4 -- php -l -f"

+ 5 - 0
CHANGELOG.md

@@ -1,3 +1,8 @@
+### 2.5.0 (2022-04-08)
+
+* Added `callType` to IntrospectionProcessor (#1612)
+* Fixed AsMonologProcessor syntax to be compatible with PHP 7.2 (#1651)
+
 ### 2.4.0 (2022-03-14)
 
   * Added [`Monolog\LogRecord`](src/Monolog/LogRecord.php) interface that can be used to type-hint records like `array|\Monolog\LogRecord $record` to be forward compatible with the upcoming Monolog 3 changes

+ 8 - 3
composer.json

@@ -17,20 +17,25 @@
         "psr/log": "^2.0 || ^3.0"
     },
     "require-dev": {
+        "ext-json": "*",
         "aws/aws-sdk-php": "^3.0",
         "doctrine/couchdb": "~1.0@dev",
-        "elasticsearch/elasticsearch": "^7",
+        "elasticsearch/elasticsearch": "^7 || ^8",
         "graylog2/gelf-php": "^1.4.2",
+        "guzzlehttp/guzzle": "^7.4",
+        "guzzlehttp/psr7": "^2.2",
         "mongodb/mongodb": "^1.8",
         "php-amqplib/php-amqplib": "~2.4 || ^3",
         "php-console/php-console": "^3.1.3",
-        "phpspec/prophecy": "^1.6.1",
+        "phpspec/prophecy": "^1.15",
         "phpstan/phpstan": "^1.4",
         "phpstan/phpstan-deprecation-rules": "^1.0",
         "phpstan/phpstan-strict-rules": "^1.1",
         "phpunit/phpunit": "^9.5.16",
         "predis/predis": "^1.1",
-        "ruflin/elastica": ">=0.90@dev"
+        "ruflin/elastica": "^7",
+        "symfony/mailer": "^5.4 || ^6",
+        "symfony/mime": "^5.4 || ^6"
     },
     "suggest": {
         "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",

+ 1 - 1
phpstan-baseline.neon

@@ -6,7 +6,7 @@ parameters:
 			path: src/Monolog/ErrorHandler.php
 
 		-
-			message: "#^Cannot access offset 'table' on array\\<array\\|bool\\|float\\|int\\|string\\|null\\>\\|bool\\|float\\|int\\|object\\|string\\|null\\.$#"
+			message: "#^Cannot access offset 'table' on array\\<array\\|bool\\|float\\|int\\|string\\|null\\>\\|bool\\|float\\|int\\|object\\|string\\.$#"
 			count: 1
 			path: src/Monolog/Formatter/WildfireFormatter.php
 

+ 3 - 0
phpstan.neon.dist

@@ -22,6 +22,9 @@ parameters:
         # can be removed when rollbar/rollbar can be added as dev require again (needs to allow monolog 3.x)
         - '#Rollbar\\RollbarLogger#'
 
+        # legacy elasticsearch namespace failures
+        - '# Elastic\\Elasticsearch\\#'
+
 includes:
     - phpstan-baseline.neon
     - vendor/phpstan/phpstan-strict-rules/rules.neon

+ 3 - 3
src/Monolog/Attribute/AsMonologProcessor.php

@@ -28,9 +28,9 @@ class AsMonologProcessor
      * @param string|null $method  The method that processes the records (if the attribute is used at the class level).
      */
     public function __construct(
-        public ?string $channel = null,
-        public ?string $handler = null,
-        public ?string $method = null,
+        public readonly ?string $channel = null,
+        public readonly ?string $handler = null,
+        public readonly ?string $method = null
     ) {
     }
 }

+ 35 - 8
src/Monolog/Handler/ElasticsearchHandler.php

@@ -12,6 +12,7 @@
 namespace Monolog\Handler;
 
 use Monolog\LevelName;
+use Elastic\Elasticsearch\Response\Elasticsearch;
 use Throwable;
 use RuntimeException;
 use Monolog\Level;
@@ -21,6 +22,8 @@ use InvalidArgumentException;
 use Elasticsearch\Common\Exceptions\RuntimeException as ElasticsearchRuntimeException;
 use Elasticsearch\Client;
 use Monolog\LogRecord;
+use Elastic\Elasticsearch\Exception\InvalidArgumentException as ElasticInvalidArgumentException;
+use Elastic\Elasticsearch\Client as Client8;
 
 /**
  * Elasticsearch handler
@@ -55,7 +58,7 @@ use Monolog\LogRecord;
  */
 class ElasticsearchHandler extends AbstractProcessingHandler
 {
-    protected Client $client;
+    protected Client|Client8 $client;
 
     /**
      * @var mixed[] Handler config options
@@ -64,12 +67,17 @@ class ElasticsearchHandler extends AbstractProcessingHandler
     protected array $options;
 
     /**
-     * @param Client  $client  Elasticsearch Client object
-     * @param mixed[] $options Handler configuration
+     * @var bool
+     */
+    private $needsType;
+
+    /**
+     * @param Client|Client8 $client  Elasticsearch Client object
+     * @param mixed[]        $options Handler configuration
      *
      * @phpstan-param InputOptions $options
      */
-    public function __construct(Client $client, array $options = [], int|string|Level|LevelName $level = Level::Debug, bool $bubble = true)
+    public function __construct(Client|Client8 $client, array $options = [], int|string|Level|LevelName $level = Level::Debug, bool $bubble = true)
     {
         parent::__construct($level, $bubble);
         $this->client = $client;
@@ -81,6 +89,14 @@ class ElasticsearchHandler extends AbstractProcessingHandler
             ],
             $options
         );
+
+        if ($client instanceof Client8 || $client::VERSION[0] === '7') {
+            $this->needsType = false;
+            // force the type to _doc for ES8/ES7
+            $this->options['type'] = '_doc';
+        } else {
+            $this->needsType = true;
+        }
     }
 
     /**
@@ -147,9 +163,11 @@ class ElasticsearchHandler extends AbstractProcessingHandler
 
             foreach ($records as $record) {
                 $params['body'][] = [
-                    'index' => [
+                    'index' => $this->needsType ? [
                         '_index' => $record['_index'],
                         '_type'  => $record['_type'],
+                    ] : [
+                        '_index' => $record['_index'],
                     ],
                 ];
                 unset($record['_index'], $record['_type']);
@@ -157,6 +175,7 @@ class ElasticsearchHandler extends AbstractProcessingHandler
                 $params['body'][] = $record;
             }
 
+            /** @var Elasticsearch */
             $responses = $this->client->bulk($params);
 
             if ($responses['errors'] === true) {
@@ -174,9 +193,9 @@ class ElasticsearchHandler extends AbstractProcessingHandler
      *
      * Only the first error is converted into an exception.
      *
-     * @param mixed[] $responses returned by $this->client->bulk()
+     * @param mixed[]|Elasticsearch $responses returned by $this->client->bulk()
      */
-    protected function createExceptionFromResponses(array $responses): ElasticsearchRuntimeException
+    protected function createExceptionFromResponses($responses): Throwable
     {
         foreach ($responses['items'] ?? [] as $item) {
             if (isset($item['index']['error'])) {
@@ -184,6 +203,10 @@ class ElasticsearchHandler extends AbstractProcessingHandler
             }
         }
 
+        if (class_exists(ElasticInvalidArgumentException::class)) {
+            return new ElasticInvalidArgumentException('Elasticsearch failed to index one or more records.');
+        }
+
         return new ElasticsearchRuntimeException('Elasticsearch failed to index one or more records.');
     }
 
@@ -192,10 +215,14 @@ class ElasticsearchHandler extends AbstractProcessingHandler
      *
      * @param mixed[] $error
      */
-    protected function createExceptionFromError(array $error): ElasticsearchRuntimeException
+    protected function createExceptionFromError(array $error): Throwable
     {
         $previous = isset($error['caused_by']) ? $this->createExceptionFromError($error['caused_by']) : null;
 
+        if (class_exists(ElasticInvalidArgumentException::class)) {
+            return new ElasticInvalidArgumentException($error['type'] . ': ' . $error['reason'], 0, $previous);
+        }
+
         return new ElasticsearchRuntimeException($error['type'] . ': ' . $error['reason'], 0, $previous);
     }
 }

+ 110 - 0
src/Monolog/Handler/SymfonyMailerHandler.php

@@ -0,0 +1,110 @@
+<?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\Handler;
+
+use Closure;
+use Monolog\Level;
+use Monolog\LevelName;
+use Monolog\Logger;
+use Monolog\LogRecord;
+use Monolog\Utils;
+use Monolog\Formatter\FormatterInterface;
+use Monolog\Formatter\LineFormatter;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Mailer\Transport\TransportInterface;
+use Symfony\Component\Mime\Email;
+
+/**
+ * SymfonyMailerHandler uses Symfony's Mailer component to send the emails
+ *
+ * @author Jordi Boggiano <j.boggiano@seld.be>
+ */
+class SymfonyMailerHandler extends MailHandler
+{
+    protected MailerInterface|TransportInterface $mailer;
+    /** @var Email|Closure(string, LogRecord[]): Email */
+    private Email|Closure $emailTemplate;
+
+    /**
+     * @phpstan-param Email|Closure(string, LogRecord[]): Email $email
+     *
+     * @param MailerInterface|TransportInterface $mailer The mailer to use
+     * @param Closure|Email                      $email  An email template, the subject/body will be replaced
+     */
+    public function __construct($mailer, Email|Closure $email, int|string|Level|LevelName $level = Level::Error, bool $bubble = true)
+    {
+        parent::__construct($level, $bubble);
+
+        $this->mailer = $mailer;
+        $this->emailTemplate = $email;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    protected function send(string $content, array $records): void
+    {
+        $this->mailer->send($this->buildMessage($content, $records));
+    }
+
+    /**
+     * Gets the formatter for the Swift_Message subject.
+     *
+     * @param string|null $format The format of the subject
+     */
+    protected function getSubjectFormatter(?string $format): FormatterInterface
+    {
+        return new LineFormatter($format);
+    }
+
+    /**
+     * Creates instance of Email to be sent
+     *
+     * @param  string      $content formatted email body to be sent
+     * @param  LogRecord[] $records Log records that formed the content
+     */
+    protected function buildMessage(string $content, array $records): Email
+    {
+        $message = null;
+        if ($this->emailTemplate instanceof Email) {
+            $message = clone $this->emailTemplate;
+        } elseif (is_callable($this->emailTemplate)) {
+            $message = ($this->emailTemplate)($content, $records);
+        }
+
+        if (!$message instanceof Email) {
+            $record = reset($records);
+            throw new \InvalidArgumentException('Could not resolve message as instance of Email or a callable returning it' . ($record instanceof LogRecord ? Utils::getRecordMessageForException($record) : ''));
+        }
+
+        if (\count($records) > 0) {
+            $subjectFormatter = $this->getSubjectFormatter($message->getSubject());
+            $message->subject($subjectFormatter->format($this->getHighestRecord($records)));
+        }
+
+        if ($this->isHtmlBody($content)) {
+            if (null !== ($charset = $message->getHtmlCharset())) {
+                $message->html($content, $charset);
+            } else {
+                $message->html($content);
+            }
+        } else {
+            if (null !== ($charset = $message->getTextCharset())) {
+                $message->text($content, $charset);
+            } else {
+                $message->text($content);
+            }
+        }
+
+        return $message->date(new \DateTimeImmutable());
+    }
+}

+ 9 - 0
src/Monolog/Test/TestCase.php

@@ -26,6 +26,15 @@ use Psr\Log\LogLevel;
  */
 class TestCase extends \PHPUnit\Framework\TestCase
 {
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        if (isset($this->handler)) {
+            unset($this->handler);
+        }
+    }
+
     /**
      * @param array<mixed> $context
      * @param array<mixed> $extra

+ 7 - 0
tests/Monolog/Formatter/ScalarFormatterTest.php

@@ -23,6 +23,13 @@ class ScalarFormatterTest extends TestCase
         $this->formatter = new ScalarFormatter();
     }
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->formatter);
+    }
+
     public function buildTrace(\Exception $e)
     {
         $data = [];

+ 1 - 1
tests/Monolog/Handler/AmqpHandlerTest.php

@@ -78,7 +78,7 @@ class AmqpHandlerTest extends TestCase
 
     public function testHandlePhpAmqpLib()
     {
-        if (!class_exists('PhpAmqpLib\Connection\AMQPConnection')) {
+        if (!class_exists('PhpAmqpLib\Channel\AMQPChannel')) {
             $this->markTestSkipped("php-amqplib not installed");
         }
 

+ 7 - 0
tests/Monolog/Handler/DynamoDbHandlerTest.php

@@ -47,6 +47,13 @@ class DynamoDbHandlerTest extends TestCase
         $this->client = $clientMockBuilder->getMock();
     }
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->client);
+    }
+
     public function testGetFormatter()
     {
         $handler = new DynamoDbHandler($this->client, 'foo');

+ 20 - 49
tests/Monolog/Handler/ElasticaHandlerTest.php

@@ -19,6 +19,9 @@ use Elastica\Client;
 use Elastica\Request;
 use Elastica\Response;
 
+/**
+ * @group Elastica
+ */
 class ElasticaHandlerTest extends TestCase
 {
     /**
@@ -48,6 +51,13 @@ class ElasticaHandlerTest extends TestCase
             ->getMock();
     }
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->client);
+    }
+
     /**
      * @covers Monolog\Handler\ElasticaHandler::write
      * @covers Monolog\Handler\ElasticaHandler::handleBatch
@@ -144,52 +154,6 @@ class ElasticaHandlerTest extends TestCase
         ];
     }
 
-    /**
-     * Integration test using localhost Elastic Search server version <7
-     *
-     * @covers Monolog\Handler\ElasticaHandler::__construct
-     * @covers Monolog\Handler\ElasticaHandler::handleBatch
-     * @covers Monolog\Handler\ElasticaHandler::bulkSend
-     * @covers Monolog\Handler\ElasticaHandler::getDefaultFormatter
-     */
-    public function testHandleIntegration()
-    {
-        $msg = $this->getRecord(Level::Error, 'log', context: ['foo' => 7, 'bar', 'class' => new \stdClass], datetime: new \DateTimeImmutable("@0"));
-
-        $expected = $msg->toArray();
-        $expected['datetime'] = $msg['datetime']->format(\DateTime::ISO8601);
-        $expected['context'] = [
-            'class' => '[object] (stdClass: {})',
-            'foo' => 7,
-            0 => 'bar',
-        ];
-
-        $client = new Client();
-        $handler = new ElasticaHandler($client, $this->options);
-
-        try {
-            $handler->handleBatch([$msg]);
-        } catch (\RuntimeException $e) {
-            $this->markTestSkipped("Cannot connect to Elastic Search server on localhost");
-        }
-
-        // check document id from ES server response
-        $documentId = $this->getCreatedDocId($client->getLastResponse());
-        $this->assertNotEmpty($documentId, 'No elastic document id received');
-
-        // retrieve document source from ES and validate
-        $document = $this->getDocSourceFromElastic(
-            $client,
-            $this->options['index'],
-            $this->options['type'],
-            $documentId
-        );
-        $this->assertEquals($expected, $document);
-
-        // remove test index from ES
-        $client->request("/{$this->options['index']}", Request::DELETE);
-    }
-
     /**
      * Integration test using localhost Elastic Search server version 7+
      *
@@ -210,7 +174,9 @@ class ElasticaHandlerTest extends TestCase
             0 => 'bar',
         ];
 
-        $client = new Client();
+        $clientOpts = ['url' => 'http://elastic:changeme@127.0.0.1:9200'];
+        $client = new Client($clientOpts);
+
         $handler = new ElasticaHandler($client, $this->options);
 
         try {
@@ -243,9 +209,14 @@ class ElasticaHandlerTest extends TestCase
     protected function getCreatedDocId(Response $response): ?string
     {
         $data = $response->getData();
-        if (!empty($data['items'][0]['create']['_id'])) {
-            return $data['items'][0]['create']['_id'];
+
+        if (!empty($data['items'][0]['index']['_id'])) {
+            return $data['items'][0]['index']['_id'];
         }
+
+        var_dump('Unexpected response: ', $data);
+
+        return null;
     }
 
     /**

+ 80 - 65
tests/Monolog/Handler/ElasticsearchHandlerTest.php

@@ -11,17 +11,22 @@
 
 namespace Monolog\Handler;
 
-use Elasticsearch\ClientBuilder;
 use Monolog\Formatter\ElasticsearchFormatter;
 use Monolog\Formatter\NormalizerFormatter;
 use Monolog\Test\TestCase;
 use Monolog\Level;
 use Elasticsearch\Client;
+use Elastic\Elasticsearch\Client as Client8;
+use Elasticsearch\ClientBuilder;
+use Elastic\Elasticsearch\ClientBuilder as ClientBuilder8;
 
+/**
+ * @group Elasticsearch
+ */
 class ElasticsearchHandlerTest extends TestCase
 {
     /**
-     * @var Client mock
+     * @var Client|Client8 mock
      */
     protected Client $client;
 
@@ -35,55 +40,23 @@ class ElasticsearchHandlerTest extends TestCase
 
     public function setUp(): void
     {
-        // Elasticsearch lib required
-        if (!class_exists('Elasticsearch\Client')) {
-            $this->markTestSkipped('elasticsearch/elasticsearch not installed');
-        }
+        $hosts = ['http://elastic:changeme@127.0.0.1:9200'];
+        $this->client = $this->getClientBuilder()
+            ->setHosts($hosts)
+            ->build();
 
-        // base mock Elasticsearch Client object
-        $this->client = $this->getMockBuilder('Elasticsearch\Client')
-            ->onlyMethods(['bulk'])
-            ->disableOriginalConstructor()
-            ->getMock();
+        try {
+            $this->client->info();
+        } catch (\Throwable $e) {
+            $this->markTestSkipped('Could not connect to Elasticsearch on 127.0.0.1:9200');
+        }
     }
 
-    /**
-     * @covers Monolog\Handler\ElasticsearchHandler::write
-     * @covers Monolog\Handler\ElasticsearchHandler::handleBatch
-     * @covers Monolog\Handler\ElasticsearchHandler::bulkSend
-     * @covers Monolog\Handler\ElasticsearchHandler::getDefaultFormatter
-     */
-    public function testHandle()
+    public function tearDown(): void
     {
-        // log message
-        $msg = $this->getRecord(Level::Error, 'log', context: ['foo' => 7, 'bar', 'class' => new \stdClass], datetime: new \DateTimeImmutable("@0"));
+        parent::tearDown();
 
-        // format expected result
-        $formatter = new ElasticsearchFormatter($this->options['index'], $this->options['type']);
-        $data = $formatter->format($msg);
-        unset($data['_index'], $data['_type']);
-
-        $expected = [
-            'body' => [
-                [
-                    'index' => [
-                        '_index' => $this->options['index'],
-                        '_type' => $this->options['type'],
-                    ],
-                ],
-                $data,
-            ],
-        ];
-
-        // setup ES client mock
-        $this->client->expects($this->any())
-            ->method('bulk')
-            ->with($expected);
-
-        // perform tests
-        $handler = new ElasticsearchHandler($this->client, $this->options);
-        $handler->handle($msg);
-        $handler->handleBatch([$msg]);
+        unset($this->client);
     }
 
     /**
@@ -100,7 +73,7 @@ class ElasticsearchHandlerTest extends TestCase
     }
 
     /**
-     * @covers                   Monolog\Handler\ElasticsearchHandler::setFormatter
+     * @covers Monolog\Handler\ElasticsearchHandler::setFormatter
      */
     public function testSetFormatterInvalid()
     {
@@ -124,6 +97,11 @@ class ElasticsearchHandlerTest extends TestCase
             'type' => $this->options['type'],
             'ignore_error' => false,
         ];
+
+        if ($this->client instanceof Client8 || $this->client::VERSION[0] === '7') {
+            $expected['type'] = '_doc';
+        }
+
         $handler = new ElasticsearchHandler($this->client, $this->options);
         $this->assertEquals($expected, $handler->getOptions());
     }
@@ -134,10 +112,10 @@ class ElasticsearchHandlerTest extends TestCase
      */
     public function testConnectionErrors($ignore, $expectedError)
     {
-        $hosts = [['host' => '127.0.0.1', 'port' => 1]];
-        $client = ClientBuilder::create()
-                    ->setHosts($hosts)
-                    ->build();
+        $hosts = ['http://127.0.0.1:1'];
+        $client = $this->getClientBuilder()
+            ->setHosts($hosts)
+            ->build();
 
         $handlerOpts = ['ignore_error' => $ignore];
         $handler = new ElasticsearchHandler($client, $handlerOpts);
@@ -167,7 +145,7 @@ class ElasticsearchHandlerTest extends TestCase
      * @covers Monolog\Handler\ElasticsearchHandler::bulkSend
      * @covers Monolog\Handler\ElasticsearchHandler::getDefaultFormatter
      */
-    public function testHandleIntegration()
+    public function testHandleBatchIntegration()
     {
         $msg = $this->getRecord(Level::Error, 'log', context: ['foo' => 7, 'bar', 'class' => new \stdClass], datetime: new \DateTimeImmutable("@0"));
 
@@ -179,21 +157,26 @@ class ElasticsearchHandlerTest extends TestCase
             0 => 'bar',
         ];
 
-        $hosts = [['host' => '127.0.0.1', 'port' => 9200]];
-        $client = ClientBuilder::create()
+        $hosts = ['http://elastic:changeme@127.0.0.1:9200'];
+        $client = $this->getClientBuilder()
             ->setHosts($hosts)
             ->build();
         $handler = new ElasticsearchHandler($client, $this->options);
-
-        try {
-            $handler->handleBatch([$msg]);
-        } catch (\RuntimeException $e) {
-            $this->markTestSkipped('Cannot connect to Elasticsearch server on localhost');
-        }
+        $handler->handleBatch([$msg]);
 
         // check document id from ES server response
-        $documentId = $this->getCreatedDocId($client->transport->getLastConnection()->getLastRequestInfo());
-        $this->assertNotEmpty($documentId, 'No elastic document id received');
+        if ($client instanceof Client8) {
+            $messageBody = $client->getTransport()->getLastResponse()->getBody();
+
+            $info = json_decode((string) $messageBody, true);
+            $this->assertNotNull($info, 'Decoding failed');
+
+            $documentId = $this->getCreatedDocIdV8($info);
+            $this->assertNotEmpty($documentId, 'No elastic document id received');
+        } else {
+            $documentId = $this->getCreatedDocId($client->transport->getLastConnection()->getLastRequestInfo());
+            $this->assertNotEmpty($documentId, 'No elastic document id received');
+        }
 
         // retrieve document source from ES and validate
         $document = $this->getDocSourceFromElastic(
@@ -221,21 +204,41 @@ class ElasticsearchHandlerTest extends TestCase
         if (!empty($data['items'][0]['index']['_id'])) {
             return $data['items'][0]['index']['_id'];
         }
+
+        return null;
+    }
+
+    /**
+     * Return last created document id from ES response
+     *
+     * @param  array       $data Elasticsearch last request info
+     * @return string|null
+     */
+    protected function getCreatedDocIdV8(array $data)
+    {
+        if (!empty($data['items'][0]['index']['_id'])) {
+            return $data['items'][0]['index']['_id'];
+        }
+
+        return null;
     }
 
     /**
      * Retrieve document by id from Elasticsearch
      *
-     * @param Client $client Elasticsearch client
+     * @return array<mixed>
      */
-    protected function getDocSourceFromElastic(Client $client, string $index, string $type, string $documentId): array
+    protected function getDocSourceFromElastic(Client|Client8 $client, string $index, string $type, string $documentId): array
     {
         $params = [
             'index' => $index,
-            'type' => $type,
             'id' => $documentId,
         ];
 
+        if (!$client instanceof Client8 && $client::VERSION[0] !== '7') {
+            $params['type'] = $type;
+        }
+
         $data = $client->get($params);
 
         if (!empty($data['_source'])) {
@@ -244,4 +247,16 @@ class ElasticsearchHandlerTest extends TestCase
 
         return [];
     }
+
+    /**
+     * @return ClientBuilder|ClientBuilder8
+     */
+    private function getClientBuilder()
+    {
+        if (class_exists(ClientBuilder8::class)) {
+            return ClientBuilder8::create();
+        }
+
+        return ClientBuilder::create();
+    }
 }

+ 7 - 0
tests/Monolog/Handler/FlowdockHandlerTest.php

@@ -35,6 +35,13 @@ class FlowdockHandlerTest extends TestCase
         }
     }
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->res);
+    }
+
     public function testWriteHeader()
     {
         $this->createHandler();

+ 7 - 0
tests/Monolog/Handler/HandlerWrapperTest.php

@@ -30,6 +30,13 @@ class HandlerWrapperTest extends TestCase
         $this->wrapper = new HandlerWrapper($this->handler);
     }
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->wrapper);
+    }
+
     public function trueFalseDataProvider(): array
     {
         return [

+ 7 - 0
tests/Monolog/Handler/InsightOpsHandlerTest.php

@@ -28,6 +28,13 @@ class InsightOpsHandlerTest extends TestCase
 
     private InsightOpsHandler&MockObject $handler;
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->resource);
+    }
+
     public function testWriteContent()
     {
         $this->createHandler();

+ 7 - 0
tests/Monolog/Handler/LogEntriesHandlerTest.php

@@ -27,6 +27,13 @@ class LogEntriesHandlerTest extends TestCase
 
     private LogEntriesHandler&MockObject $handler;
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->res);
+    }
+
     public function testWriteContent()
     {
         $this->createHandler();

+ 7 - 0
tests/Monolog/Handler/LogmaticHandlerTest.php

@@ -27,6 +27,13 @@ class LogmaticHandlerTest extends TestCase
 
     private LogmaticHandler&MockObject $handler;
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->res);
+    }
+
     public function testWriteContent()
     {
         $this->createHandler();

+ 7 - 0
tests/Monolog/Handler/PHPConsoleHandlerTest.php

@@ -54,6 +54,13 @@ class PHPConsoleHandlerTest extends TestCase
         $this->connector->setErrorsDispatcher($this->errorDispatcher);
     }
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->connector, $this->debugDispatcher, $this->errorDispatcher);
+    }
+
     protected function initDebugDispatcherMock(Connector $connector)
     {
         return $this->getMockBuilder('PhpConsole\Dispatcher\Debug')

+ 7 - 0
tests/Monolog/Handler/PushoverHandlerTest.php

@@ -27,6 +27,13 @@ class PushoverHandlerTest extends TestCase
     private $res;
     private PushoverHandler&MockObject $handler;
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->res);
+    }
+
     public function testWriteHeader()
     {
         $this->createHandler();

+ 7 - 0
tests/Monolog/Handler/RollbarHandlerTest.php

@@ -38,6 +38,13 @@ class RollbarHandlerTest extends TestCase
         $this->setupRollbarLoggerMock();
     }
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->rollbarLogger, $this->reportedExceptionArguments);
+    }
+
     /**
      * When reporting exceptions to Rollbar the
      * level has to be set in the payload data

+ 12 - 8
tests/Monolog/Handler/RotatingFileHandlerTest.php

@@ -39,6 +39,18 @@ class RotatingFileHandlerTest extends TestCase
         });
     }
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        foreach (glob(__DIR__.'/Fixtures/*.rot') as $file) {
+            unlink($file);
+        }
+        restore_error_handler();
+
+        unset($this->lastError);
+    }
+
     private function assertErrorWasTriggered($code, $message)
     {
         if (empty($this->lastError)) {
@@ -239,12 +251,4 @@ class RotatingFileHandlerTest extends TestCase
         $handler->handle($this->getRecord());
         $this->assertEquals('footest', file_get_contents($log));
     }
-
-    public function tearDown(): void
-    {
-        foreach (glob(__DIR__.'/Fixtures/*.rot') as $file) {
-            unlink($file);
-        }
-        restore_error_handler();
-    }
 }

+ 7 - 0
tests/Monolog/Handler/SlackHandlerTest.php

@@ -36,6 +36,13 @@ class SlackHandlerTest extends TestCase
         }
     }
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->res);
+    }
+
     public function testWriteHeader()
     {
         $this->createHandler();

+ 7 - 0
tests/Monolog/Handler/SocketHandlerTest.php

@@ -27,6 +27,13 @@ class SocketHandlerTest extends TestCase
      */
     private $res;
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->res);
+    }
+
     public function testInvalidHostname()
     {
         $this->expectException(\UnexpectedValueException::class);

+ 19 - 16
tests/Monolog/Handler/StreamHandlerTest.php

@@ -270,20 +270,20 @@ STRING;
             $this->markTestSkipped('We could not set a memory limit that would trigger the error.');
         }
 
-        $stream = tmpfile();
-
-        if ($stream === false) {
-            $this->markTestSkipped('We could not create a temp file to be use as a stream.');
-        }
-
-        $exceptionRaised = false;
+        try {
+            $stream = tmpfile();
 
-        $handler = new StreamHandler($stream);
-        stream_get_contents($stream, 1024);
+            if ($stream === false) {
+                $this->markTestSkipped('We could not create a temp file to be use as a stream.');
+            }
 
-        ini_set('memory_limit', $previousValue);
+            $handler = new StreamHandler($stream);
+            stream_get_contents($stream, 1024);
 
-        $this->assertEquals($expectedChunkSize, $handler->getStreamChunkSize());
+            $this->assertEquals($expectedChunkSize, $handler->getStreamChunkSize());
+        } finally {
+            ini_set('memory_limit', $previousValue);
+        }
     }
 
     public function testSimpleOOMPrevention(): void
@@ -294,10 +294,13 @@ STRING;
             $this->markTestSkipped('We could not set a memory limit that would trigger the error.');
         }
 
-        $stream = tmpfile();
-        new StreamHandler($stream);
-        stream_get_contents($stream);
-        ini_set('memory_limit', $previousValue);
-        $this->assertTrue(true);
+        try {
+            $stream = tmpfile();
+            new StreamHandler($stream);
+            stream_get_contents($stream);
+            $this->assertTrue(true);
+        } finally {
+            ini_set('memory_limit', $previousValue);
+        }
     }
 }

+ 107 - 0
tests/Monolog/Handler/SymfonyMailerHandlerTest.php

@@ -0,0 +1,107 @@
+<?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\Handler;
+
+use Monolog\Logger;
+use Monolog\Test\TestCase;
+use PHPUnit\Framework\MockObject\MockObject;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Mime\Email;
+
+class SymfonyMailerHandlerTest extends TestCase
+{
+    /** @var MailerInterface&MockObject */
+    private $mailer;
+
+    public function setUp(): void
+    {
+        $this->mailer = $this
+            ->getMockBuilder(MailerInterface::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->mailer);
+    }
+
+    public function testMessageCreationIsLazyWhenUsingCallback()
+    {
+        $this->mailer->expects($this->never())
+            ->method('send');
+
+        $callback = function () {
+            throw new \RuntimeException('Email creation callback should not have been called in this test');
+        };
+        $handler = new SymfonyMailerHandler($this->mailer, $callback);
+
+        $records = [
+            $this->getRecord(Logger::DEBUG),
+            $this->getRecord(Logger::INFO),
+        ];
+        $handler->handleBatch($records);
+    }
+
+    public function testMessageCanBeCustomizedGivenLoggedData()
+    {
+        // Wire Mailer to expect a specific Email with a customized Subject
+        $expectedMessage = new Email();
+        $this->mailer->expects($this->once())
+            ->method('send')
+            ->with($this->callback(function ($value) use ($expectedMessage) {
+                return $value instanceof Email
+                    && $value->getSubject() === 'Emergency'
+                    && $value === $expectedMessage;
+            }));
+
+        // Callback dynamically changes subject based on number of logged records
+        $callback = function ($content, array $records) use ($expectedMessage) {
+            $subject = count($records) > 0 ? 'Emergency' : 'Normal';
+            return $expectedMessage->subject($subject);
+        };
+        $handler = new SymfonyMailerHandler($this->mailer, $callback);
+
+        // Logging 1 record makes this an Emergency
+        $records = [
+            $this->getRecord(Logger::EMERGENCY),
+        ];
+        $handler->handleBatch($records);
+    }
+
+    public function testMessageSubjectFormatting()
+    {
+        // Wire Mailer to expect a specific Email with a customized Subject
+        $messageTemplate = new Email();
+        $messageTemplate->subject('Alert: %level_name% %message%');
+        $receivedMessage = null;
+
+        $this->mailer->expects($this->once())
+            ->method('send')
+            ->with($this->callback(function ($value) use (&$receivedMessage) {
+                $receivedMessage = $value;
+
+                return true;
+            }));
+
+        $handler = new SymfonyMailerHandler($this->mailer, $messageTemplate);
+
+        $records = [
+            $this->getRecord(Logger::EMERGENCY),
+        ];
+        $handler->handleBatch($records);
+
+        $this->assertEquals('Alert: EMERGENCY test', $receivedMessage->getSubject());
+    }
+}

+ 7 - 0
tests/Monolog/Handler/ZendMonitorHandlerTest.php

@@ -22,6 +22,13 @@ class ZendMonitorHandlerTest extends TestCase
         }
     }
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->zendMonitorHandler);
+    }
+
     /**
      * @covers  Monolog\Handler\ZendMonitorHandler::write
      */

+ 7 - 0
tests/Monolog/PsrLogCompatTest.php

@@ -26,6 +26,13 @@ class PsrLogCompatTest extends TestCase
 {
     private TestHandler $handler;
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        unset($this->handler);
+    }
+
     public function getLogger(): LoggerInterface
     {
         $logger = new Logger('foo');

+ 5 - 1
tests/Monolog/SignalHandlerTest.php

@@ -39,8 +39,10 @@ class SignalHandlerTest extends TestCase
         }
     }
 
-    protected function tearDown(): void
+    public function tearDown(): void
     {
+        parent::tearDown();
+
         if ($this->asyncSignalHandling !== null) {
             pcntl_async_signals($this->asyncSignalHandling);
         }
@@ -53,6 +55,8 @@ class SignalHandlerTest extends TestCase
                 pcntl_signal($signo, $handler);
             }
         }
+
+        unset($this->signalHandlers, $this->blockedSignals, $this->asyncSignalHandling);
     }
 
     private function setSignalHandler($signo, $handler = SIG_DFL)