From f9c43c3e45ba03e10260d5e634553cbd5d88c9bd Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Tue, 12 Sep 2023 00:29:06 -0400 Subject: [PATCH] PHPLIB-1243: Adapter to pipe ext-mongodb logs to a PSR-3 logger Introduces an internal PsrLogAdapter class to connect PSR-3 loggers with PHPC. The internal class is exposed via addLogger and removeLogger functions. Bumps ext-mongodb dependency to 1.17-dev for PHPC-2180. Permit psr/log 1.x, 2.x, or 3.x for compatibility with PHP 7.4 and 8.0+. --- .evergreen/config.yml | 8 +- .github/workflows/benchmark.yml | 2 +- .github/workflows/coding-standards.yml | 2 +- .github/workflows/static-analysis.yml | 2 +- .github/workflows/tests.yml | 12 +- composer.json | 3 +- src/PsrLogAdapter.php | 161 +++++++++++++++++++++++++ src/functions.php | 23 ++++ tests/PsrLogAdapterTest.php | 141 ++++++++++++++++++++++ 9 files changed, 340 insertions(+), 14 deletions(-) create mode 100644 src/PsrLogAdapter.php create mode 100644 tests/PsrLogAdapterTest.php diff --git a/.evergreen/config.yml b/.evergreen/config.yml index fc7820f6e..9e6027b37 100644 --- a/.evergreen/config.yml +++ b/.evergreen/config.yml @@ -766,13 +766,13 @@ axes: display_name: Driver Version values: - id: "oldest-supported" - display_name: "PHPC 1.16.0" + display_name: "PHPC 1.17.0" variables: - EXTENSION_VERSION: "1.16.0" + EXTENSION_BRANCH: "master" - id: "latest-stable" - display_name: "PHPC (1.16.x)" + display_name: "PHPC (1.17.x)" variables: - EXTENSION_VERSION: "stable" + EXTENSION_BRANCH: "master" - id: "latest-dev" display_name: "PHPC (1.17-dev)" variables: diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 510941412..053d5776f 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -18,7 +18,7 @@ on: env: PHP_VERSION: "8.2" - DRIVER_VERSION: "stable" + DRIVER_VERSION: "mongodb/mongo-php-driver@master" jobs: psalm: diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index d39183073..76df7a698 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -18,7 +18,7 @@ on: env: PHP_VERSION: "8.2" - DRIVER_VERSION: "stable" + DRIVER_VERSION: "mongodb/mongo-php-driver@master" jobs: phpcs: diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index bb216b712..41e6fbb09 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -18,7 +18,7 @@ on: env: PHP_VERSION: "8.2" - DRIVER_VERSION: "stable" + DRIVER_VERSION: "mongodb/mongo-php-driver@master" jobs: psalm: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6d7964efd..8698e7459 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,34 +34,34 @@ jobs: mongodb-version: - "4.4" driver-version: - - "stable" + - "mongodb/mongo-php-driver@master" topology: - "server" include: - os: "ubuntu-20.04" php-version: "8.0" mongodb-version: "6.0" - driver-version: "stable" + driver-version: "mongodb/mongo-php-driver@master" topology: "replica_set" - os: "ubuntu-20.04" php-version: "8.0" mongodb-version: "6.0" - driver-version: "stable" + driver-version: "mongodb/mongo-php-driver@master" topology: "sharded_cluster" - os: "ubuntu-20.04" php-version: "8.0" mongodb-version: "5.0" - driver-version: "stable" + driver-version: "mongodb/mongo-php-driver@master" topology: "server" - os: "ubuntu-20.04" php-version: "8.0" mongodb-version: "4.4" - driver-version: "stable" + driver-version: "mongodb/mongo-php-driver@master" topology: "replica_set" - os: "ubuntu-20.04" php-version: "8.0" mongodb-version: "4.4" - driver-version: "stable" + driver-version: "mongodb/mongo-php-driver@master" topology: "sharded_cluster" steps: diff --git a/composer.json b/composer.json index b02e4eef6..a8a9a7e0b 100644 --- a/composer.json +++ b/composer.json @@ -13,8 +13,9 @@ "php": "^7.4 || ^8.0", "ext-hash": "*", "ext-json": "*", - "ext-mongodb": "^1.16.0", + "ext-mongodb": "^1.17.0", "jean85/pretty-package-versions": "^2.0.1", + "psr/log": "^1.1.4|^2|^3", "symfony/polyfill-php80": "^1.27", "symfony/polyfill-php81": "^1.27" }, diff --git a/src/PsrLogAdapter.php b/src/PsrLogAdapter.php new file mode 100644 index 000000000..bed61a350 --- /dev/null +++ b/src/PsrLogAdapter.php @@ -0,0 +1,161 @@ + */ + private SplObjectStorage $loggers; + + private const SPEC_TO_PSR = [ + self::EMERGENCY => LogLevel::EMERGENCY, + self::ALERT => LogLevel::ALERT, + self::CRITICAL => LogLevel::CRITICAL, + self::ERROR => LogLevel::ERROR, + self::WARN => LogLevel::WARNING, + self::NOTICE => LogLevel::NOTICE, + self::INFO => LogLevel::INFO, + self::DEBUG => LogLevel::DEBUG, + // PSR does not define a "trace" level, so map it to "debug" + self::TRACE => LogLevel::DEBUG, + ]; + + private const MONGOC_TO_PSR = [ + LogSubscriber::LEVEL_ERROR => LogLevel::ERROR, + /* libmongoc considers "critical" less severe than "error" so map it to + * "error" in the PSR logger. */ + LogSubscriber::LEVEL_CRITICAL => LogLevel::ERROR, + LogSubscriber::LEVEL_WARNING => LogLevel::WARNING, + LogSubscriber::LEVEL_MESSAGE => LogLevel::NOTICE, + LogSubscriber::LEVEL_INFO => LogLevel::INFO, + LogSubscriber::LEVEL_DEBUG => LogLevel::DEBUG, + ]; + + public static function addLogger(LoggerInterface $logger): void + { + $instance = self::getInstance(); + + $instance->loggers->attach($logger); + + addSubscriber($instance); + } + + /** + * Forwards a log message from libmongoc/PHPC to all registered PSR loggers. + * + * @see LogSubscriber::log() + */ + public function log(int $mongocLevel, string $domain, string $message): void + { + if (! isset(self::MONGOC_TO_PSR[$mongocLevel])) { + throw new UnexpectedValueException(sprintf( + 'Expected level to be >= %d and <= %d, %d given for domain "%s" and message: %s', + LogSubscriber::LEVEL_ERROR, + LogSubscriber::LEVEL_DEBUG, + $mongocLevel, + $domain, + $message, + )); + } + + $instance = self::getInstance(); + $psrLevel = self::MONGOC_TO_PSR[$mongocLevel]; + $context = ['domain' => $domain]; + + foreach ($instance->loggers as $logger) { + $logger->log($psrLevel, $message, $context); + } + } + + public static function removeLogger(LoggerInterface $logger): void + { + $instance = self::getInstance(); + $instance->loggers->detach($logger); + + if ($instance->loggers->count() === 0) { + removeSubscriber($instance); + } + } + + /** + * Writes a log message to all registered PSR loggers. + * + * This function is intended for internal use within the library. + */ + public static function writeLog(int $specLevel, string $domain, string $message): void + { + if (! isset(self::SPEC_TO_PSR[$specLevel])) { + throw new UnexpectedValueException(sprintf( + 'Expected level to be >= %d and <= %d, %d given for domain "%s" and message: %s', + self::EMERGENCY, + self::TRACE, + $specLevel, + $domain, + $message, + )); + } + + $instance = self::getInstance(); + $psrLevel = self::SPEC_TO_PSR[$specLevel]; + $context = ['domain' => $domain]; + + foreach ($instance->loggers as $logger) { + $logger->log($psrLevel, $message, $context); + } + } + + private function __construct() + { + $this->loggers = new SplObjectStorage(); + } + + private static function getInstance(): self + { + return self::$instance ??= new self(); + } +} diff --git a/src/functions.php b/src/functions.php index 4acec021c..fbe712270 100644 --- a/src/functions.php +++ b/src/functions.php @@ -31,6 +31,7 @@ use MongoDB\Exception\RuntimeException; use MongoDB\Operation\ListCollections; use MongoDB\Operation\WithTransaction; +use Psr\Log\LoggerInterface; use ReflectionClass; use ReflectionException; @@ -46,6 +47,28 @@ use function MongoDB\BSON\toPHP; use function substr; +/** + * Registers a PSR-3 logger to receive log messages from the driver/library. + * + * Calling this method again with a logger that has already been added will have + * no effect. + */ +function addLogger(LoggerInterface $logger): void +{ + PsrLogAdapter::addLogger($logger); +} + +/** + * Unregisters a PSR-3 logger. + * + * Calling this method with a logger that has not been added will have no + * effect. + */ +function removeLogger(LoggerInterface $logger): void +{ + PsrLogAdapter::removeLogger($logger); +} + /** * Check whether all servers support executing a write stage on a secondary. * diff --git a/tests/PsrLogAdapterTest.php b/tests/PsrLogAdapterTest.php new file mode 100644 index 000000000..cd3aa2c03 --- /dev/null +++ b/tests/PsrLogAdapterTest.php @@ -0,0 +1,141 @@ +logger = $this->createTestPsrLogger(); + + PsrLogAdapter::addLogger($this->logger); + } + + public function tearDown(): void + { + PsrLogAdapter::removeLogger($this->logger); + } + + public function testAddAndRemoveLoggerFunctions(): void + { + $logger = $this->createTestPsrLogger(); + + mongoc_log(LogSubscriber::LEVEL_INFO, 'domain1', 'info1'); + PsrLogAdapter::writeLog(PsrLogAdapter::INFO, 'domain2', 'info2'); + + addLogger($logger); + + mongoc_log(LogSubscriber::LEVEL_INFO, 'domain3', 'info3'); + PsrLogAdapter::writeLog(PsrLogAdapter::INFO, 'domain4', 'info4'); + + removeLogger($logger); + + mongoc_log(LogSubscriber::LEVEL_INFO, 'domain5', 'info5'); + PsrLogAdapter::writeLog(PsrLogAdapter::INFO, 'domain6', 'info6'); + + $expectedLogs = [ + [LogLevel::INFO, 'info3', ['domain' => 'domain3']], + [LogLevel::INFO, 'info4', ['domain' => 'domain4']], + ]; + + $this->assertSame($expectedLogs, $logger->logs); + } + + public function testLog(): void + { + /* This uses PHPC's internal mongoc_log() function to write messages + * directly to libmongoc. Those messages are then relayed to + * PsrLogAdapter and forwarded to each registered PSR logger. + * + * Note: it's not possible to test PsrLogAdapter::log() with an invalid + * level since mongoc_log() already validates its level parameter. */ + mongoc_log(LogSubscriber::LEVEL_ERROR, 'domain1', 'error'); + mongoc_log(LogSubscriber::LEVEL_CRITICAL, 'domain2', 'critical'); + mongoc_log(LogSubscriber::LEVEL_WARNING, 'domain3', 'warning'); + mongoc_log(LogSubscriber::LEVEL_MESSAGE, 'domain4', 'message'); + mongoc_log(LogSubscriber::LEVEL_INFO, 'domain5', 'info'); + mongoc_log(LogSubscriber::LEVEL_DEBUG, 'domain6', 'debug'); + + $expectedLogs = [ + [LogLevel::ERROR, 'error', ['domain' => 'domain1']], + [LogLevel::ERROR, 'critical', ['domain' => 'domain2']], + [LogLevel::WARNING, 'warning', ['domain' => 'domain3']], + [LogLevel::NOTICE, 'message', ['domain' => 'domain4']], + [LogLevel::INFO, 'info', ['domain' => 'domain5']], + [LogLevel::DEBUG, 'debug', ['domain' => 'domain6']], + ]; + + $this->assertSame($expectedLogs, $this->logger->logs); + } + + /** + * @testWith [-1] + * [9] + */ + public function testWriteLogWithInvalidLevel(int $level): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage(sprintf('Expected level to be >= 0 and <= 8, %d given for domain "domain" and message: message', $level)); + + PsrLogAdapter::writeLog($level, 'domain', 'message'); + } + + public function testWriteLog(): void + { + PsrLogAdapter::writeLog(PsrLogAdapter::EMERGENCY, 'domain1', 'emergency'); + PsrLogAdapter::writeLog(PsrLogAdapter::ALERT, 'domain2', 'alert'); + PsrLogAdapter::writeLog(PsrLogAdapter::CRITICAL, 'domain3', 'critical'); + PsrLogAdapter::writeLog(PsrLogAdapter::ERROR, 'domain4', 'error'); + PsrLogAdapter::writeLog(PsrLogAdapter::WARN, 'domain5', 'warn'); + PsrLogAdapter::writeLog(PsrLogAdapter::NOTICE, 'domain6', 'notice'); + PsrLogAdapter::writeLog(PsrLogAdapter::INFO, 'domain7', 'info'); + PsrLogAdapter::writeLog(PsrLogAdapter::DEBUG, 'domain8', 'debug'); + PsrLogAdapter::writeLog(PsrLogAdapter::TRACE, 'domain9', 'trace'); + + $expectedLogs = [ + [LogLevel::EMERGENCY, 'emergency', ['domain' => 'domain1']], + [LogLevel::ALERT, 'alert', ['domain' => 'domain2']], + [LogLevel::CRITICAL, 'critical', ['domain' => 'domain3']], + [LogLevel::ERROR, 'error', ['domain' => 'domain4']], + [LogLevel::WARNING, 'warn', ['domain' => 'domain5']], + [LogLevel::NOTICE, 'notice', ['domain' => 'domain6']], + [LogLevel::INFO, 'info', ['domain' => 'domain7']], + [LogLevel::DEBUG, 'debug', ['domain' => 'domain8']], + [LogLevel::DEBUG, 'trace', ['domain' => 'domain9']], + ]; + + $this->assertSame($expectedLogs, $this->logger->logs); + } + + private function createTestPsrLogger(): LoggerInterface + { + return new class extends AbstractLogger { + public $logs = []; + + /** + * Note: parameter type hints are omitted for compatibility with + * psr/log 1.1.4 and PHP 7.4. + */ + public function log($level, $message, array $context = []): void + { + $this->logs[] = func_get_args(); + } + }; + } +}