diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d547298..3b84eb3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,7 +17,7 @@ updates: - "mimmi20" labels: - "dependencies" - versioning-strategy: "increase-if-necessary" + versioning-strategy: "widen" commit-message: include: "scope" prefix: "composer" diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 6851195..4870ecb 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -780,4 +780,4 @@ jobs: verbose: false - name: "Run mutation tests with infection/infection" - run: "infection -s --min-covered-msi=88 --min-msi=88 --coverage=.build/coverage --only-covered --logger-github" + run: "infection -s --min-covered-msi=91 --min-msi=91 --coverage=.build/coverage --only-covered --logger-github" diff --git a/README.md b/README.md index 1070046..d7e2297 100644 --- a/README.md +++ b/README.md @@ -1539,6 +1539,8 @@ return [ 'options' => [ 'client' => 'my-service', // Elastica Client object. Must be a valid container service 'index' => 'log', // Optional: Elastic index name + 'dateFormat' => \Mimmi20\LoggerFactory\Handler\ElasticsearchHandlerFactory::INDEX_PER_DAY, // Optional: possible Values are \Mimmi20\LoggerFactory\Handler\ElasticsearchHandlerFactory::INDEX_PER_DAY, \Mimmi20\LoggerFactory\Handler\ElasticsearchHandlerFactory::INDEX_PER_MONTH and \Mimmi20\LoggerFactory\Handler\ElasticsearchHandlerFactory::INDEX_PER_YEAR + 'indexNameFormat' => '{indexname}', // Optional: a string which must contain the string '{indexname}' (which is a placeholder for the `index`) and may contain the string '{date}' (which is a placeholder for the actual date formatted by `dateFormat`) 'type' => 'record', // Optional: Elastic document type 'ignoreError' => false, // Optional: Suppress Elastica exceptions 'level' => \Psr\Log\LogLevel::DEBUG, // Optional: The minimum logging level at which this handler will be triggered diff --git a/composer.json b/composer.json index eb0c852..b5fe78b 100644 --- a/composer.json +++ b/composer.json @@ -19,39 +19,39 @@ "php": "^7.4.3 || ^8.0.0", "ext-mbstring": "*", "laminas/laminas-log": "^2.15.1", - "monolog/monolog": "^2.6.0" + "monolog/monolog": "^2.7.0" }, "require-dev": { "actived/microsoft-teams-notifier": "^1.2.0", - "aws/aws-sdk-php": "^3.224.2", + "aws/aws-sdk-php": "^3.227.1", "bartlett/monolog-callbackfilterhandler": "^2.0.0", "cmdisp/monolog-microsoft-teams": "^1.2.0", "doctrine/couchdb": "1.0.0-beta4", - "elasticsearch/elasticsearch": "^v7.17.0 || ^v8.2.2", + "elasticsearch/elasticsearch": "^v7.17.0 || ^v8.2.3", "graylog2/gelf-php": "^1.7.1", - "guzzlehttp/guzzle": "^7.4.3", - "guzzlehttp/psr7": "^2.2.1", + "guzzlehttp/guzzle": "^7.4.4", + "guzzlehttp/psr7": "^2.3.0", "jk/monolog-request-header-processor": "^1.0.0", "laminas/laminas-config": "^3.7.0", "laminas/laminas-dependency-plugin": "^2.2.0", "laminas/laminas-modulemanager": "^2.11.0", - "laminas/laminas-servicemanager": "^3.11.2", + "laminas/laminas-servicemanager": "^3.12.0", "mikey179/vfsstream": "^1.6.10", - "mimmi20/coding-standard": "^2.8.1", + "mimmi20/coding-standard": "^2.9.0", "mimmi20/monolog-streamformatter": "^1.0.0", "php-amqplib/php-amqplib": "^3.2.0", "php-console/php-console": "^3.1.8", "phpstan/extension-installer": "^1.1.0", - "phpstan/phpstan": "^1.7.8", + "phpstan/phpstan": "^1.7.15", "phpstan/phpstan-deprecation-rules": "^1.0.0", "phpstan/phpstan-phpunit": "^1.1.1", - "phpunit/phpunit": "^9.5.20", - "predis/predis": "^1.1.10", + "phpunit/phpunit": "^9.5.21", + "predis/predis": "^1.1.10 || ^2.0.0", "rollbar/rollbar": "^v2.1.0 || ^v3.1.3", "ruflin/elastica": "^7.1.5", "swiftmailer/swiftmailer": "^6.3.0", - "symfony/mailer": "^v5.4.8 || ^v6.1.0", - "symfony/mime": "^v5.4.9 || ^v6.1.0" + "symfony/mailer": "^v5.4.8 || ^v6.1.1", + "symfony/mime": "^v5.4.9 || ^v6.1.1" }, "suggest": { "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", diff --git a/src/Handler/ElasticsearchHandlerFactory.php b/src/Handler/ElasticsearchHandlerFactory.php index 20bfbaf..e46e470 100644 --- a/src/Handler/ElasticsearchHandlerFactory.php +++ b/src/Handler/ElasticsearchHandlerFactory.php @@ -31,12 +31,16 @@ use function array_key_exists; use function assert; use function class_exists; +use function date; use function get_class; use function gettype; +use function in_array; use function is_array; use function is_object; use function is_string; +use function mb_strpos; use function sprintf; +use function str_replace; /** * @phpstan-import-type Level from Logger @@ -47,6 +51,10 @@ final class ElasticsearchHandlerFactory implements FactoryInterface use AddFormatterTrait; use AddProcessorTrait; + public const INDEX_PER_DAY = 'Y-m-d'; + public const INDEX_PER_MONTH = 'Y-m'; + public const INDEX_PER_YEAR = 'Y'; + /** * @param string $requestedName * @param array|null $options @@ -132,16 +140,32 @@ public function __invoke(ContainerInterface $container, $requestedName, ?array $ } } - $index = 'monolog'; - $type = 'record'; - $ignoreError = false; - $level = LogLevel::DEBUG; - $bubble = true; + $index = 'monolog'; + $dateFormat = self::INDEX_PER_DAY; + $indexNameFormat = '{indexname}'; + $type = 'record'; + $ignoreError = false; + $level = LogLevel::DEBUG; + $bubble = true; if (array_key_exists('index', $options)) { $index = $options['index']; } + if ( + array_key_exists('dateFormat', $options) + && in_array($options['dateFormat'], [self::INDEX_PER_DAY, self::INDEX_PER_MONTH, self::INDEX_PER_YEAR], true) + ) { + $dateFormat = $options['dateFormat']; + } + + if ( + array_key_exists('indexNameFormat', $options) + && false !== mb_strpos($options['indexNameFormat'], '{indexname}') + ) { + $indexNameFormat = $options['indexNameFormat']; + } + if (array_key_exists('type', $options)) { $type = $options['type']; } @@ -161,7 +185,7 @@ public function __invoke(ContainerInterface $container, $requestedName, ?array $ $handler = new ElasticsearchHandler( $client, [ - 'index' => $index, + 'index' => str_replace(['{indexname}', '{date}'], [$index, date($dateFormat)], $indexNameFormat), 'type' => $type, 'ignore_error' => $ignoreError, ], diff --git a/tests/Handler/ElasticsearchHandlerFactoryTest.php b/tests/Handler/ElasticsearchHandlerFactoryTest.php index e116658..8088cd6 100644 --- a/tests/Handler/ElasticsearchHandlerFactoryTest.php +++ b/tests/Handler/ElasticsearchHandlerFactoryTest.php @@ -34,6 +34,7 @@ use SebastianBergmann\RecursionContext\InvalidArgumentException; use function class_exists; +use function date; use function sprintf; final class ElasticsearchHandlerFactoryTest extends TestCase @@ -440,6 +441,310 @@ public function testInvokeWithV7ClientAndConfigAndFormatter2(): void self::assertCount(0, $processors); } + /** + * @throws Exception + * @throws ReflectionException + * @throws InvalidArgumentException + */ + public function testInvokeWithV7ClientAndConfigAndFormatter3(): void + { + if (!class_exists(V7Client::class)) { + self::markTestSkipped('requires elasticsearch/elasticsearch V7'); + } + + $client = 'xyz'; + $clientClass = $this->getMockBuilder(V7Client::class) + ->disableOriginalConstructor() + ->getMock(); + $index = 'test-index'; + $type = 'test-type'; + $dateFormat = ElasticsearchHandlerFactory::INDEX_PER_MONTH; + $formatter = $this->getMockBuilder(ElasticsearchFormatter::class) + ->disableOriginalConstructor() + ->getMock(); + + $monologFormatterPluginManager = $this->getMockBuilder(AbstractPluginManager::class) + ->disableOriginalConstructor() + ->getMock(); + $monologFormatterPluginManager->expects(self::never()) + ->method('has'); + $monologFormatterPluginManager->expects(self::never()) + ->method('get'); + + $container = $this->getMockBuilder(ContainerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $container->expects(self::never()) + ->method('has'); + $container->expects(self::exactly(2)) + ->method('get') + ->withConsecutive([$client], [MonologFormatterPluginManager::class]) + ->willReturnOnConsecutiveCalls($clientClass, $monologFormatterPluginManager); + + $factory = new ElasticsearchHandlerFactory(); + + $handler = $factory($container, '', ['client' => $client, 'index' => $index, 'type' => $type, 'ignoreError' => true, 'level' => LogLevel::ALERT, 'bubble' => false, 'formatter' => $formatter, 'dateFormat' => $dateFormat, 'indexNameFormat' => 'abc']); + + self::assertInstanceOf(ElasticsearchHandler::class, $handler); + + self::assertSame(Logger::ALERT, $handler->getLevel()); + self::assertFalse($handler->getBubble()); + + $clientP = new ReflectionProperty($handler, 'client'); + $clientP->setAccessible(true); + + self::assertSame($clientClass, $clientP->getValue($handler)); + + $optionsP = new ReflectionProperty($handler, 'options'); + $optionsP->setAccessible(true); + + $optionsArray = $optionsP->getValue($handler); + + self::assertIsArray($optionsArray); + + self::assertSame($index, $optionsArray['index']); + self::assertSame('_doc', $optionsArray['type']); + self::assertTrue($optionsArray['ignore_error']); + + self::assertSame($formatter, $handler->getFormatter()); + + $proc = new ReflectionProperty($handler, 'processors'); + $proc->setAccessible(true); + + $processors = $proc->getValue($handler); + + self::assertIsArray($processors); + self::assertCount(0, $processors); + } + + /** + * @throws Exception + * @throws ReflectionException + * @throws InvalidArgumentException + */ + public function testInvokeWithV7ClientAndConfigAndFormatter4(): void + { + if (!class_exists(V7Client::class)) { + self::markTestSkipped('requires elasticsearch/elasticsearch V7'); + } + + $client = 'xyz'; + $clientClass = $this->getMockBuilder(V7Client::class) + ->disableOriginalConstructor() + ->getMock(); + $index = 'test-index'; + $type = 'test-type'; + $dateFormat = ElasticsearchHandlerFactory::INDEX_PER_MONTH; + $formatter = $this->getMockBuilder(ElasticsearchFormatter::class) + ->disableOriginalConstructor() + ->getMock(); + + $monologFormatterPluginManager = $this->getMockBuilder(AbstractPluginManager::class) + ->disableOriginalConstructor() + ->getMock(); + $monologFormatterPluginManager->expects(self::never()) + ->method('has'); + $monologFormatterPluginManager->expects(self::never()) + ->method('get'); + + $container = $this->getMockBuilder(ContainerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $container->expects(self::never()) + ->method('has'); + $container->expects(self::exactly(2)) + ->method('get') + ->withConsecutive([$client], [MonologFormatterPluginManager::class]) + ->willReturnOnConsecutiveCalls($clientClass, $monologFormatterPluginManager); + + $factory = new ElasticsearchHandlerFactory(); + + $handler = $factory($container, '', ['client' => $client, 'index' => $index, 'type' => $type, 'ignoreError' => true, 'level' => LogLevel::ALERT, 'bubble' => false, 'formatter' => $formatter, 'dateFormat' => $dateFormat, 'indexNameFormat' => '{indexname}-{date}']); + + self::assertInstanceOf(ElasticsearchHandler::class, $handler); + + self::assertSame(Logger::ALERT, $handler->getLevel()); + self::assertFalse($handler->getBubble()); + + $clientP = new ReflectionProperty($handler, 'client'); + $clientP->setAccessible(true); + + self::assertSame($clientClass, $clientP->getValue($handler)); + + $optionsP = new ReflectionProperty($handler, 'options'); + $optionsP->setAccessible(true); + + $optionsArray = $optionsP->getValue($handler); + + self::assertIsArray($optionsArray); + + self::assertSame($index . '-' . date($dateFormat), $optionsArray['index']); + self::assertSame('_doc', $optionsArray['type']); + self::assertTrue($optionsArray['ignore_error']); + + self::assertSame($formatter, $handler->getFormatter()); + + $proc = new ReflectionProperty($handler, 'processors'); + $proc->setAccessible(true); + + $processors = $proc->getValue($handler); + + self::assertIsArray($processors); + self::assertCount(0, $processors); + } + + /** + * @throws Exception + * @throws ReflectionException + * @throws InvalidArgumentException + */ + public function testInvokeWithV7ClientAndConfigAndFormatter5(): void + { + if (!class_exists(V7Client::class)) { + self::markTestSkipped('requires elasticsearch/elasticsearch V7'); + } + + $client = 'xyz'; + $clientClass = $this->getMockBuilder(V7Client::class) + ->disableOriginalConstructor() + ->getMock(); + $index = 'test-index'; + $type = 'test-type'; + $dateFormat = ElasticsearchHandlerFactory::INDEX_PER_YEAR; + $formatter = $this->getMockBuilder(ElasticsearchFormatter::class) + ->disableOriginalConstructor() + ->getMock(); + + $monologFormatterPluginManager = $this->getMockBuilder(AbstractPluginManager::class) + ->disableOriginalConstructor() + ->getMock(); + $monologFormatterPluginManager->expects(self::never()) + ->method('has'); + $monologFormatterPluginManager->expects(self::never()) + ->method('get'); + + $container = $this->getMockBuilder(ContainerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $container->expects(self::never()) + ->method('has'); + $container->expects(self::exactly(2)) + ->method('get') + ->withConsecutive([$client], [MonologFormatterPluginManager::class]) + ->willReturnOnConsecutiveCalls($clientClass, $monologFormatterPluginManager); + + $factory = new ElasticsearchHandlerFactory(); + + $handler = $factory($container, '', ['client' => $client, 'index' => $index, 'type' => $type, 'ignoreError' => true, 'level' => LogLevel::ALERT, 'bubble' => false, 'formatter' => $formatter, 'dateFormat' => $dateFormat, 'indexNameFormat' => '{indexname}-{date}']); + + self::assertInstanceOf(ElasticsearchHandler::class, $handler); + + self::assertSame(Logger::ALERT, $handler->getLevel()); + self::assertFalse($handler->getBubble()); + + $clientP = new ReflectionProperty($handler, 'client'); + $clientP->setAccessible(true); + + self::assertSame($clientClass, $clientP->getValue($handler)); + + $optionsP = new ReflectionProperty($handler, 'options'); + $optionsP->setAccessible(true); + + $optionsArray = $optionsP->getValue($handler); + + self::assertIsArray($optionsArray); + + self::assertSame($index . '-' . date($dateFormat), $optionsArray['index']); + self::assertSame('_doc', $optionsArray['type']); + self::assertTrue($optionsArray['ignore_error']); + + self::assertSame($formatter, $handler->getFormatter()); + + $proc = new ReflectionProperty($handler, 'processors'); + $proc->setAccessible(true); + + $processors = $proc->getValue($handler); + + self::assertIsArray($processors); + self::assertCount(0, $processors); + } + + /** + * @throws Exception + * @throws ReflectionException + * @throws InvalidArgumentException + */ + public function testInvokeWithV7ClientAndConfigAndFormatter6(): void + { + if (!class_exists(V7Client::class)) { + self::markTestSkipped('requires elasticsearch/elasticsearch V7'); + } + + $client = 'xyz'; + $clientClass = $this->getMockBuilder(V7Client::class) + ->disableOriginalConstructor() + ->getMock(); + $index = 'test-index'; + $type = 'test-type'; + $dateFormat = ElasticsearchHandlerFactory::INDEX_PER_DAY; + $formatter = $this->getMockBuilder(ElasticsearchFormatter::class) + ->disableOriginalConstructor() + ->getMock(); + + $monologFormatterPluginManager = $this->getMockBuilder(AbstractPluginManager::class) + ->disableOriginalConstructor() + ->getMock(); + $monologFormatterPluginManager->expects(self::never()) + ->method('has'); + $monologFormatterPluginManager->expects(self::never()) + ->method('get'); + + $container = $this->getMockBuilder(ContainerInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $container->expects(self::never()) + ->method('has'); + $container->expects(self::exactly(2)) + ->method('get') + ->withConsecutive([$client], [MonologFormatterPluginManager::class]) + ->willReturnOnConsecutiveCalls($clientClass, $monologFormatterPluginManager); + + $factory = new ElasticsearchHandlerFactory(); + + $handler = $factory($container, '', ['client' => $client, 'index' => $index, 'type' => $type, 'ignoreError' => true, 'level' => LogLevel::ALERT, 'bubble' => false, 'formatter' => $formatter, 'dateFormat' => $dateFormat, 'indexNameFormat' => '{indexname}-{date}']); + + self::assertInstanceOf(ElasticsearchHandler::class, $handler); + + self::assertSame(Logger::ALERT, $handler->getLevel()); + self::assertFalse($handler->getBubble()); + + $clientP = new ReflectionProperty($handler, 'client'); + $clientP->setAccessible(true); + + self::assertSame($clientClass, $clientP->getValue($handler)); + + $optionsP = new ReflectionProperty($handler, 'options'); + $optionsP->setAccessible(true); + + $optionsArray = $optionsP->getValue($handler); + + self::assertIsArray($optionsArray); + + self::assertSame($index . '-' . date($dateFormat), $optionsArray['index']); + self::assertSame('_doc', $optionsArray['type']); + self::assertTrue($optionsArray['ignore_error']); + + self::assertSame($formatter, $handler->getFormatter()); + + $proc = new ReflectionProperty($handler, 'processors'); + $proc->setAccessible(true); + + $processors = $proc->getValue($handler); + + self::assertIsArray($processors); + self::assertCount(0, $processors); + } + /** * @throws Exception */