Procházet zdrojové kódy

Merge remote-tracking branch 'akalongman/feature/elasticsearch'

Jordi Boggiano před 7 roky
rodič
revize
b978700c05

+ 2 - 0
CHANGELOG.md

@@ -6,6 +6,7 @@
   * BC Break: Removed non-PSR-3 methods to add records, all the `add*` (e.g. `addWarning`) methods as well as `emerg`, `crit`, `err` and `warn`
   * BC Break: The record timezone is now set per Logger instance and not statically anymore
   * BC Break: There is no more default handler configured on empty Logger instances
+  * BC Break: ElasticSearchHandler renamed to ElasticaHandler
   * BC Break: Various handler-specific breaks, see [UPGRADE.md] for details
   * Added scalar type hints and return hints in all the places it was possible. Switched strict_types on for more reliability.
   * Added DateTimeImmutable support, all record datetime are now immutable, and will toString/json serialize with the correct date format, including microseconds (unless disabled)
@@ -13,6 +14,7 @@
   * Added SendGridHandler to use the SendGrid API to send emails
   * Added LogmaticHandler to use the Logmatic.io API to store log records
   * Added SqsHandler to send log records to an AWS SQS queue
+  * Added ElasticsearchHandler to send records via the official ES library. Elastica users should now use ElasticaHandler instead of ElasticSearchHandler
   * Added NoopHandler which is similar to the NullHandle but does not prevent the bubbling of log records to handlers further down the configuration, useful for temporarily disabling a handler in configuration files
   * Added ProcessHandler to write log output to the STDIN of a given process
   * Added HostnameProcessor that adds the machine's hostname to log records

+ 5 - 0
UPGRADE.md

@@ -57,3 +57,8 @@
 #### HipChatHandler
 
 - Removed HipChat API v1 support
+
+#### ElasticSearchHandler
+
+- As support for the official Elasticsearch library was added, the former ElasticSearchHandler has been
+  renamed to ElasticaHandler and the new one added as ElasticsearchHandler.

+ 2 - 0
composer.json

@@ -29,6 +29,7 @@
         "jakub-onderka/php-parallel-lint": "^0.9",
         "predis/predis": "^1.1",
         "phpspec/prophecy": "^1.6.1",
+        "elasticsearch/elasticsearch": "^6.0",
         "rollbar/rollbar": "^1.3"
     },
     "suggest": {
@@ -36,6 +37,7 @@
         "sentry/sentry": "Allow sending log messages to a Sentry server",
         "doctrine/couchdb": "Allow sending log messages to a CouchDB server",
         "ruflin/elastica": "Allow sending log messages to an Elastic Search server",
+        "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
         "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
         "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
         "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",

+ 1 - 1
doc/02-handlers-formatters-processors.md

@@ -81,7 +81,7 @@
   [Mongo](http://pecl.php.net/package/mongo) extension connection.
 - [_CouchDBHandler_](../src/Monolog/Handler/CouchDBHandler.php): Logs records to a CouchDB server.
 - [_DoctrineCouchDBHandler_](../src/Monolog/Handler/DoctrineCouchDBHandler.php): Logs records to a CouchDB server via the Doctrine CouchDB ODM.
-- [_ElasticSearchHandler_](../src/Monolog/Handler/ElasticSearchHandler.php): Logs records to an Elastic Search server.
+- [_ElasticsearchHandler_](../src/Monolog/Handler/ElasticsearchHandler.php): Logs records to an Elasticsearch server.
 - [_DynamoDbHandler_](../src/Monolog/Handler/DynamoDbHandler.php): Logs records to a DynamoDB table with the [AWS SDK](https://github.com/aws/aws-sdk-php).
 
 ### Wrappers / Special Handlers

+ 2 - 0
src/Monolog/Formatter/ElasticaFormatter.php

@@ -65,6 +65,8 @@ class ElasticaFormatter extends NormalizerFormatter
 
     /**
      * Convert a log message into an Elastica Document
+     * @param  array   $record
+     * @return Document
      */
     protected function getDocument(array $record): Document
     {

+ 89 - 0
src/Monolog/Formatter/ElasticsearchFormatter.php

@@ -0,0 +1,89 @@
+<?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 DateTime;
+
+/**
+ * Format a log message into an Elasticsearch record
+ *
+ * @author Avtandil Kikabidze <akalongman@gmail.com>
+ */
+class ElasticsearchFormatter extends NormalizerFormatter
+{
+    /**
+     * @var string Elasticsearch index name
+     */
+    protected $index;
+
+    /**
+     * @var string Elasticsearch record type
+     */
+    protected $type;
+
+    /**
+     * @param string $index Elasticsearch index name
+     * @param string $type Elasticsearch record type
+     */
+    public function __construct(string $index, string $type)
+    {
+        // Elasticsearch requires an ISO 8601 format date with optional millisecond precision.
+        parent::__construct(DateTime::ISO8601);
+
+        $this->index = $index;
+        $this->type = $type;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function format(array $record)
+    {
+        $record = parent::format($record);
+
+        return $this->getDocument($record);
+    }
+
+    /**
+     * Getter index
+     *
+     * @return string
+     */
+    public function getIndex(): string
+    {
+        return $this->index;
+    }
+
+    /**
+     * Getter type
+     *
+     * @return string
+     */
+    public function getType(): string
+    {
+        return $this->type;
+    }
+
+    /**
+     * Convert a log message into an Elasticsearch record
+     *
+     * @param  array $record Log message
+     * @return array
+     */
+    protected function getDocument(array $record): array
+    {
+        $record['_index'] = $this->index;
+        $record['_type'] = $this->type;
+
+        return $record;
+    }
+}

+ 3 - 4
src/Monolog/Handler/ElasticSearchHandler.php → src/Monolog/Handler/ElasticaHandler.php

@@ -27,13 +27,13 @@ use Elastica\Exception\ExceptionInterface;
  *        'index' => 'elastic_index_name',
  *        'type' => 'elastic_doc_type',
  *    );
- *    $handler = new ElasticSearchHandler($client, $options);
+ *    $handler = new ElasticaHandler($client, $options);
  *    $log = new Logger('application');
  *    $log->pushHandler($handler);
  *
  * @author Jelle Vink <jelle.vink@gmail.com>
  */
-class ElasticSearchHandler extends AbstractProcessingHandler
+class ElasticaHandler extends AbstractProcessingHandler
 {
     /**
      * @var Client
@@ -81,8 +81,7 @@ class ElasticSearchHandler extends AbstractProcessingHandler
         if ($formatter instanceof ElasticaFormatter) {
             return parent::setFormatter($formatter);
         }
-
-        throw new \InvalidArgumentException('ElasticSearchHandler is only compatible with ElasticaFormatter');
+        throw new \InvalidArgumentException('ElasticaHandler is only compatible with ElasticaFormatter');
     }
 
     public function getOptions(): array

+ 158 - 0
src/Monolog/Handler/ElasticsearchHandler.php

@@ -0,0 +1,158 @@
+<?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 Elasticsearch\Client;
+use Elasticsearch\Common\Exceptions\RuntimeException as ElasticsearchRuntimeException;
+use InvalidArgumentException;
+use Monolog\Formatter\ElasticsearchFormatter;
+use Monolog\Formatter\FormatterInterface;
+use Monolog\Logger;
+use RuntimeException;
+use Throwable;
+
+/**
+ * Elasticsearch handler
+ *
+ * @link https://www.elastic.co/guide/en/elasticsearch/client/php-api/current/index.html
+ *
+ * Simple usage example:
+ *
+ *    $client = \Elasticsearch\ClientBuilder::create()
+ *        ->setHosts($hosts)
+ *        ->build();
+ *
+ *    $options = array(
+ *        'index' => 'elastic_index_name',
+ *        'type'  => 'elastic_doc_type',
+ *    );
+ *    $handler = new ElasticsearchHandler($client, $options);
+ *    $log = new Logger('application');
+ *    $log->pushHandler($handler);
+ *
+ * @author Avtandil Kikabidze <akalongman@gmail.com>
+ */
+class ElasticsearchHandler extends AbstractProcessingHandler
+{
+    /**
+     * @var Client
+     */
+    protected $client;
+
+    /**
+     * @var array Handler config options
+     */
+    protected $options = [];
+
+    /**
+     * @param Client     $client  Elasticsearch Client object
+     * @param array      $options Handler configuration
+     * @param string|int $level   The minimum logging level at which this handler will be triggered
+     * @param bool       $bubble  Whether the messages that are handled can bubble up the stack or not
+     */
+    public function __construct(Client $client, array $options = [], $level = Logger::DEBUG, bool $bubble = true)
+    {
+        parent::__construct($level, $bubble);
+        $this->client = $client;
+        $this->options = array_merge(
+            [
+                'index'        => 'monolog', // Elastic index name
+                'type'         => '_doc',    // Elastic document type
+                'ignore_error' => false,     // Suppress Elasticsearch exceptions
+            ],
+            $options
+        );
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    protected function write(array $record): void
+    {
+        $this->bulkSend([$record['formatted']]);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function setFormatter(FormatterInterface $formatter): HandlerInterface
+    {
+        if ($formatter instanceof ElasticsearchFormatter) {
+            return parent::setFormatter($formatter);
+        }
+        throw new InvalidArgumentException('ElasticsearchHandler is only compatible with ElasticsearchFormatter');
+    }
+
+    /**
+     * Getter options
+     *
+     * @return array
+     */
+    public function getOptions(): array
+    {
+        return $this->options;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    protected function getDefaultFormatter(): FormatterInterface
+    {
+        return new ElasticsearchFormatter($this->options['index'], $this->options['type']);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function handleBatch(array $records): void
+    {
+        $documents = $this->getFormatter()->formatBatch($records);
+        $this->bulkSend($documents);
+    }
+
+    /**
+     * Use Elasticsearch bulk API to send list of documents
+     *
+     * @param  array $records
+     * @throws \RuntimeException
+     */
+    protected function bulkSend(array $records): void
+    {
+        try {
+            $params = [
+                'body' => [],
+            ];
+
+            foreach ($records as $record) {
+                $params['body'][] = [
+                    'index' => [
+                        '_index' => $record['_index'],
+                        '_type'  => $record['_type'],
+                    ],
+                ];
+                unset($record['_index'], $record['_type']);
+
+                $params['body'][] = $record;
+            }
+
+            $responses = $this->client->bulk($params);
+
+            if ($responses['errors'] === true) {
+                throw new ElasticsearchRuntimeException('Elasticsearch returned error for one of the records');
+            }
+        } catch (Throwable $e) {
+            if (! $this->options['ignore_error']) {
+                throw new RuntimeException('Error sending messages to Elasticsearch', 0, $e);
+            }
+        }
+    }
+}

+ 70 - 0
tests/Monolog/Formatter/ElasticsearchFormatterTest.php

@@ -0,0 +1,70 @@
+<?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 Monolog\Logger;
+
+class ElasticsearchFormatterTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * @covers Monolog\Formatter\ElasticsearchFormatter::__construct
+     * @covers Monolog\Formatter\ElasticsearchFormatter::format
+     * @covers Monolog\Formatter\ElasticsearchFormatter::getDocument
+     */
+    public function testFormat()
+    {
+        // Test log message
+        $msg = [
+            'level' => Logger::ERROR,
+            'level_name' => 'ERROR',
+            'channel' => 'meh',
+            'context' => ['foo' => 7, 'bar', 'class' => new \stdClass],
+            'datetime' => new \DateTimeImmutable("@0"),
+            'extra' => [],
+            'message' => 'log',
+        ];
+
+        // Expected values
+        $expected = $msg;
+        $expected['datetime'] = '1970-01-01T00:00:00+0000';
+        $expected['context'] = [
+            'class' => ['stdClass' => []],
+            'foo' => 7,
+            0 => 'bar',
+        ];
+
+        // Format log message
+        $formatter = new ElasticsearchFormatter('my_index', 'doc_type');
+        $doc = $formatter->format($msg);
+        $this->assertInternalType('array', $doc);
+
+        // Record parameters
+        $this->assertEquals('my_index', $doc['_index']);
+        $this->assertEquals('doc_type', $doc['_type']);
+
+        // Record data values
+        foreach (array_keys($expected) as $key) {
+            $this->assertEquals($expected[$key], $doc[$key]);
+        }
+    }
+
+    /**
+     * @covers Monolog\Formatter\ElasticsearchFormatter::getIndex
+     * @covers Monolog\Formatter\ElasticsearchFormatter::getType
+     */
+    public function testGetters()
+    {
+        $formatter = new ElasticsearchFormatter('my_index', 'doc_type');
+        $this->assertEquals('my_index', $formatter->getIndex());
+        $this->assertEquals('doc_type', $formatter->getType());
+    }
+}

+ 21 - 22
tests/Monolog/Handler/ElasticSearchHandlerTest.php → tests/Monolog/Handler/ElasticaHandlerTest.php

@@ -19,7 +19,7 @@ use Elastica\Client;
 use Elastica\Request;
 use Elastica\Response;
 
-class ElasticSearchHandlerTest extends TestCase
+class ElasticaHandlerTest extends TestCase
 {
     /**
      * @var Client mock
@@ -49,10 +49,10 @@ class ElasticSearchHandlerTest extends TestCase
     }
 
     /**
-     * @covers Monolog\Handler\ElasticSearchHandler::write
-     * @covers Monolog\Handler\ElasticSearchHandler::handleBatch
-     * @covers Monolog\Handler\ElasticSearchHandler::bulkSend
-     * @covers Monolog\Handler\ElasticSearchHandler::getDefaultFormatter
+     * @covers Monolog\Handler\ElasticaHandler::write
+     * @covers Monolog\Handler\ElasticaHandler::handleBatch
+     * @covers Monolog\Handler\ElasticaHandler::bulkSend
+     * @covers Monolog\Handler\ElasticaHandler::getDefaultFormatter
      */
     public function testHandle()
     {
@@ -77,17 +77,17 @@ class ElasticSearchHandlerTest extends TestCase
             ->with($expected);
 
         // perform tests
-        $handler = new ElasticSearchHandler($this->client, $this->options);
+        $handler = new ElasticaHandler($this->client, $this->options);
         $handler->handle($msg);
         $handler->handleBatch([$msg]);
     }
 
     /**
-     * @covers Monolog\Handler\ElasticSearchHandler::setFormatter
+     * @covers Monolog\Handler\ElasticaHandler::setFormatter
      */
     public function testSetFormatter()
     {
-        $handler = new ElasticSearchHandler($this->client);
+        $handler = new ElasticaHandler($this->client);
         $formatter = new ElasticaFormatter('index_new', 'type_new');
         $handler->setFormatter($formatter);
         $this->assertInstanceOf('Monolog\Formatter\ElasticaFormatter', $handler->getFormatter());
@@ -96,20 +96,20 @@ class ElasticSearchHandlerTest extends TestCase
     }
 
     /**
-     * @covers                   Monolog\Handler\ElasticSearchHandler::setFormatter
+     * @covers                   Monolog\Handler\ElasticaHandler::setFormatter
      * @expectedException        InvalidArgumentException
-     * @expectedExceptionMessage ElasticSearchHandler is only compatible with ElasticaFormatter
+     * @expectedExceptionMessage ElasticaHandler is only compatible with ElasticaFormatter
      */
     public function testSetFormatterInvalid()
     {
-        $handler = new ElasticSearchHandler($this->client);
+        $handler = new ElasticaHandler($this->client);
         $formatter = new NormalizerFormatter();
         $handler->setFormatter($formatter);
     }
 
     /**
-     * @covers Monolog\Handler\ElasticSearchHandler::__construct
-     * @covers Monolog\Handler\ElasticSearchHandler::getOptions
+     * @covers Monolog\Handler\ElasticaHandler::__construct
+     * @covers Monolog\Handler\ElasticaHandler::getOptions
      */
     public function testOptions()
     {
@@ -118,12 +118,12 @@ class ElasticSearchHandlerTest extends TestCase
             'type' => $this->options['type'],
             'ignore_error' => false,
         ];
-        $handler = new ElasticSearchHandler($this->client, $this->options);
+        $handler = new ElasticaHandler($this->client, $this->options);
         $this->assertEquals($expected, $handler->getOptions());
     }
 
     /**
-     * @covers       Monolog\Handler\ElasticSearchHandler::bulkSend
+     * @covers       Monolog\Handler\ElasticaHandler::bulkSend
      * @dataProvider providerTestConnectionErrors
      */
     public function testConnectionErrors($ignore, $expectedError)
@@ -131,7 +131,7 @@ class ElasticSearchHandlerTest extends TestCase
         $clientOpts = ['host' => '127.0.0.1', 'port' => 1];
         $client = new Client($clientOpts);
         $handlerOpts = ['ignore_error' => $ignore];
-        $handler = new ElasticSearchHandler($client, $handlerOpts);
+        $handler = new ElasticaHandler($client, $handlerOpts);
 
         if ($expectedError) {
             $this->expectException($expectedError[0]);
@@ -156,10 +156,10 @@ class ElasticSearchHandlerTest extends TestCase
     /**
      * Integration test using localhost Elastic Search server
      *
-     * @covers Monolog\Handler\ElasticSearchHandler::__construct
-     * @covers Monolog\Handler\ElasticSearchHandler::handleBatch
-     * @covers Monolog\Handler\ElasticSearchHandler::bulkSend
-     * @covers Monolog\Handler\ElasticSearchHandler::getDefaultFormatter
+     * @covers Monolog\Handler\ElasticaHandler::__construct
+     * @covers Monolog\Handler\ElasticaHandler::handleBatch
+     * @covers Monolog\Handler\ElasticaHandler::bulkSend
+     * @covers Monolog\Handler\ElasticaHandler::getDefaultFormatter
      */
     public function testHandleIntegration()
     {
@@ -182,8 +182,7 @@ class ElasticSearchHandlerTest extends TestCase
         ];
 
         $client = new Client();
-        $handler = new ElasticSearchHandler($client, $this->options);
-
+        $handler = new ElasticaHandler($client, $this->options);
         try {
             $handler->handleBatch([$msg]);
         } catch (\RuntimeException $e) {

+ 269 - 0
tests/Monolog/Handler/ElasticsearchHandlerTest.php

@@ -0,0 +1,269 @@
+<?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 Elasticsearch\ClientBuilder;
+use Monolog\Formatter\ElasticsearchFormatter;
+use Monolog\Formatter\NormalizerFormatter;
+use Monolog\Test\TestCase;
+use Monolog\Logger;
+use Elasticsearch\Client;
+
+class ElasticsearchHandlerTest extends TestCase
+{
+    /**
+     * @var Client mock
+     */
+    protected $client;
+
+    /**
+     * @var array Default handler options
+     */
+    protected $options = [
+        'index' => 'my_index',
+        'type'  => 'doc_type',
+    ];
+
+    public function setUp()
+    {
+        // Elasticsearch lib required
+        if (!class_exists('Elasticsearch\Client')) {
+            $this->markTestSkipped('elasticsearch/elasticsearch not installed');
+        }
+
+        // base mock Elasticsearch Client object
+        $this->client = $this->getMockBuilder('Elasticsearch\Client')
+            ->setMethods(['bulk'])
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    /**
+     * @covers Monolog\Handler\ElasticsearchHandler::write
+     * @covers Monolog\Handler\ElasticsearchHandler::handleBatch
+     * @covers Monolog\Handler\ElasticsearchHandler::bulkSend
+     * @covers Monolog\Handler\ElasticsearchHandler::getDefaultFormatter
+     */
+    public function testHandle()
+    {
+        // log message
+        $msg = [
+            'level' => Logger::ERROR,
+            'level_name' => 'ERROR',
+            'channel' => 'meh',
+            'context' => ['foo' => 7, 'bar', 'class' => new \stdClass],
+            'datetime' => new \DateTimeImmutable("@0"),
+            'extra' => [],
+            'message' => 'log',
+        ];
+
+        // 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]);
+    }
+
+    /**
+     * @covers Monolog\Handler\ElasticsearchHandler::setFormatter
+     */
+    public function testSetFormatter()
+    {
+        $handler = new ElasticsearchHandler($this->client);
+        $formatter = new ElasticsearchFormatter('index_new', 'type_new');
+        $handler->setFormatter($formatter);
+        $this->assertInstanceOf('Monolog\Formatter\ElasticsearchFormatter', $handler->getFormatter());
+        $this->assertEquals('index_new', $handler->getFormatter()->getIndex());
+        $this->assertEquals('type_new', $handler->getFormatter()->getType());
+    }
+
+    /**
+     * @covers                   Monolog\Handler\ElasticsearchHandler::setFormatter
+     * @expectedException        InvalidArgumentException
+     * @expectedExceptionMessage ElasticsearchHandler is only compatible with ElasticsearchFormatter
+     */
+    public function testSetFormatterInvalid()
+    {
+        $handler = new ElasticsearchHandler($this->client);
+        $formatter = new NormalizerFormatter();
+        $handler->setFormatter($formatter);
+    }
+
+    /**
+     * @covers Monolog\Handler\ElasticsearchHandler::__construct
+     * @covers Monolog\Handler\ElasticsearchHandler::getOptions
+     */
+    public function testOptions()
+    {
+        $expected = [
+            'index' => $this->options['index'],
+            'type' => $this->options['type'],
+            'ignore_error' => false,
+        ];
+        $handler = new ElasticsearchHandler($this->client, $this->options);
+        $this->assertEquals($expected, $handler->getOptions());
+    }
+
+    /**
+     * @covers       Monolog\Handler\ElasticsearchHandler::bulkSend
+     * @dataProvider providerTestConnectionErrors
+     */
+    public function testConnectionErrors($ignore, $expectedError)
+    {
+        $hosts = [['host' => '127.0.0.1', 'port' => 1]];
+        $client = ClientBuilder::create()
+                    ->setHosts($hosts)
+                    ->build();
+
+        $handlerOpts = ['ignore_error' => $ignore];
+        $handler = new ElasticsearchHandler($client, $handlerOpts);
+
+        if ($expectedError) {
+            $this->expectException($expectedError[0]);
+            $this->expectExceptionMessage($expectedError[1]);
+            $handler->handle($this->getRecord());
+        } else {
+            $this->assertFalse($handler->handle($this->getRecord()));
+        }
+    }
+
+    /**
+     * @return array
+     */
+    public function providerTestConnectionErrors()
+    {
+        return [
+            [false, ['RuntimeException', 'Error sending messages to Elasticsearch']],
+            [true, false],
+        ];
+    }
+
+    /**
+     * Integration test using localhost Elasticsearch server
+     *
+     * @covers Monolog\Handler\ElasticsearchHandler::__construct
+     * @covers Monolog\Handler\ElasticsearchHandler::handleBatch
+     * @covers Monolog\Handler\ElasticsearchHandler::bulkSend
+     * @covers Monolog\Handler\ElasticsearchHandler::getDefaultFormatter
+     */
+    public function testHandleIntegration()
+    {
+        $msg = [
+            'level' => Logger::ERROR,
+            'level_name' => 'ERROR',
+            'channel' => 'meh',
+            'context' => ['foo' => 7, 'bar', 'class' => new \stdClass],
+            'datetime' => new \DateTimeImmutable("@0"),
+            'extra' => [],
+            'message' => 'log',
+        ];
+
+        $expected = $msg;
+        $expected['datetime'] = $msg['datetime']->format(\DateTime::ISO8601);
+        $expected['context'] = [
+            'class' => ["stdClass" => []],
+            'foo' => 7,
+            0 => 'bar',
+        ];
+
+        $hosts = [['host' => '127.0.0.1', 'port' => 9200]];
+        $client = ClientBuilder::create()
+            ->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');
+        }
+
+        // check document id from ES server response
+        $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(
+            $client,
+            $this->options['index'],
+            $this->options['type'],
+            $documentId
+        );
+
+        $this->assertEquals($expected, $document);
+
+        // remove test index from ES
+        $client->indices()->delete(['index' => $this->options['index']]);
+    }
+
+    /**
+     * Return last created document id from ES response
+     *
+     * @param  array $info Elasticsearch last request info
+     * @return string|null
+     */
+    protected function getCreatedDocId(array $info)
+    {
+        $data = json_decode($info['response']['body'], true);
+
+        if (!empty($data['items'][0]['index']['_id'])) {
+            return $data['items'][0]['index']['_id'];
+        }
+    }
+
+    /**
+     * Retrieve document by id from Elasticsearch
+     *
+     * @param  Client $client     Elasticsearch client
+     * @param  string $index
+     * @param  string $type
+     * @param  string $documentId
+     * @return array
+     */
+    protected function getDocSourceFromElastic(Client $client, $index, $type, $documentId)
+    {
+        $params = [
+            'index' => $index,
+            'type' => $type,
+            'id' => $documentId
+        ];
+
+        $data = $client->get($params);
+
+        if (!empty($data['_source'])) {
+            return $data['_source'];
+        }
+
+        return [];
+    }
+}