diff --git a/CHANGELOG.md b/CHANGELOG.md index 11254063..60d47847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,17 @@ All notable changes to this project will be documented in this file, in reverse ### Added -- Nothing. +- [#6](https://github.com/zendframework/zend-log/pull/6) adds + [PSR-3](http://www.php-fig.org/psr/psr-3/) support to zend-log: + - `Zend\Log\PsrLoggerAdapter` allows you to decorate a + `Zend\Log\LoggerInterface` instance so it can be used wherever a PSR-3 + logger is expected. + - `Zend\Log\Writer\Psr` allows you to decorate a PSR-3 logger instance for use + as a log writer with `Zend\Log\Logger`. + - `Zend\Log\Processor\PsrPlaceholder` allows you to use PSR-3-compliant + message placeholders in your log messages; they will be substituted from + corresponding keys of values passed in the `$extra` array when logging the + message. ### Deprecated diff --git a/composer.json b/composer.json index 1ab39ab4..9b77ddf9 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "require": { "php": ">=5.5", "zendframework/zend-servicemanager": "~2.5", - "zendframework/zend-stdlib": "~2.5" + "zendframework/zend-stdlib": "~2.5", + "psr/log": "~1.0" }, "require-dev": { "zendframework/zend-console": "~2.5", diff --git a/doc/book/psr3.md b/doc/book/psr3.md new file mode 100644 index 00000000..4505b838 --- /dev/null +++ b/doc/book/psr3.md @@ -0,0 +1,61 @@ +# PSR-3 Logger Interface compatibility + +[PSR-3 Logger Interface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md) +is a standards recommendation defining a common interface for logging libraries. The `zend-log` +component predates it, and has minor incompatibilities, but starting with version 2.6 provides the +following compatibility features: + +- PSR logger adapter +- PSR logger writer +- PSR placeholder processor + +## PsrLoggerAdapter + +`Zend\Log\PsrLoggerAdapter` wraps `Zend\Log\LoggerInterface`, allowing it to be used +anywhere `Psr\Log\LoggerInterface` is expected. + +```php +$zendLogLogger = new Zend\Log\Logger; + +$psrLogger = new Zend\Log\PsrLoggerAdapter($zendLogLogger); +$psrLogger->log(Psr\Log\LogLevel::INFO, 'We have a PSR-compatible logger'); +``` + +## PSR-3 log writer + +`Zend\Log\Writer\Psr` allows log messages and extras to be forwared to any PSR-3 compatible logger. +As with any log writer, this has the added benefit that you filters can be used to limit forwarded +messages. + +The writer needs a `Psr\Logger\LoggerInterface` instance to be useful, and fallbacks to +`Psr\Log\NullLogger` if none is provided. There are three ways to provide the PSR logger instance to +the log writer: + +```php +// Via constructor parameter: +$writer = new Zend\Log\Writer\Psr($psrLogger); + +// Via option: +$writer = new Zend\Log\Writer\Psr(['logger' => $psrLogger]); + +// Via setter injection: +$writer = new Zend\Log\Writer\Psr(); +$writer->setLogger($psrLogger); +``` + +## PSR-3 placeholder processor + +`Zend\Log\Processor\PsrPlaceholder` adds support for [PSR-3 message placeholders](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md#12-message). +Placeholder names correspond to keys in the "extras" array passed when logging a message. + +Values can be of arbitrary type, including all scalars, and objects implementing `__toString`; +objects not capable of string serialization will result in the fully-qualified class name being +substituted. + +```php +$logger = new Zend\Log\Logger; +$logger->addProcessor(new Zend\Log\Processor\PsrPlaceholder); + +$logger->info('User with email {email} registered', ['email' => 'user@example.org']); +// logs message 'User with email user@example.org registered' +``` diff --git a/src/Processor/PsrPlaceholder.php b/src/Processor/PsrPlaceholder.php new file mode 100644 index 00000000..8bccbe57 --- /dev/null +++ b/src/Processor/PsrPlaceholder.php @@ -0,0 +1,50 @@ + $val) { + if (is_null($val) + || is_scalar($val) + || (is_object($val) && method_exists($val, "__toString")) + ) { + $replacements['{'.$key.'}'] = $val; + continue; + } + + if (is_object($val)) { + $replacements['{'.$key.'}'] = '[object '.get_class($val).']'; + continue; + } + + $replacements['{'.$key.'}'] = '['.gettype($val).']'; + } + + $event['message'] = strtr($event['message'], $replacements); + return $event; + } +} diff --git a/src/ProcessorPluginManager.php b/src/ProcessorPluginManager.php index 19120bb6..40e075a4 100644 --- a/src/ProcessorPluginManager.php +++ b/src/ProcessorPluginManager.php @@ -11,6 +11,9 @@ use Zend\ServiceManager\AbstractPluginManager; +/** + * Plugin manager for log processors. + */ class ProcessorPluginManager extends AbstractPluginManager { /** @@ -19,9 +22,10 @@ class ProcessorPluginManager extends AbstractPluginManager * @var array */ protected $invokableClasses = [ - 'backtrace' => 'Zend\Log\Processor\Backtrace', - 'referenceid' => 'Zend\Log\Processor\ReferenceId', - 'requestid' => 'Zend\Log\Processor\RequestId', + 'backtrace' => 'Zend\Log\Processor\Backtrace', + 'psrplaceholder' => 'Zend\Log\Processor\PsrPlaceholder', + 'referenceid' => 'Zend\Log\Processor\ReferenceId', + 'requestid' => 'Zend\Log\Processor\RequestId', ]; /** diff --git a/src/PsrLoggerAdapter.php b/src/PsrLoggerAdapter.php new file mode 100644 index 00000000..7fb3fc96 --- /dev/null +++ b/src/PsrLoggerAdapter.php @@ -0,0 +1,88 @@ + Logger::EMERG, + LogLevel::ALERT => Logger::ALERT, + LogLevel::CRITICAL => Logger::CRIT, + LogLevel::ERROR => Logger::ERR, + LogLevel::WARNING => Logger::WARN, + LogLevel::NOTICE => Logger::NOTICE, + LogLevel::INFO => Logger::INFO, + LogLevel::DEBUG => Logger::DEBUG, + ]; + + /** + * Constructor + * + * @param LoggerInterface $logger + */ + public function __construct(LoggerInterface $logger) + { + $this->logger = $logger; + } + + /** + * Returns composed LoggerInterface instance. + * + * @return LoggerInterface + */ + public function getLogger() + { + return $this->logger; + } + + /** + * Logs with an arbitrary level. + * + * @param mixed $level + * @param string $message + * @param array $context + * @return null + * @throws InvalidArgumentException if log level is not recognized + */ + public function log($level, $message, array $context = []) + { + if (! array_key_exists($level, $this->psrPriorityMap)) { + throw new InvalidArgumentException(sprintf( + '$level must be one of PSR-3 log levels; received %s', + var_export($level, 1) + )); + } + + $priority = $this->psrPriorityMap[$level]; + $this->logger->log($priority, $message, $context); + } +} diff --git a/src/Writer/Psr.php b/src/Writer/Psr.php new file mode 100644 index 00000000..02b06c35 --- /dev/null +++ b/src/Writer/Psr.php @@ -0,0 +1,99 @@ + LogLevel::EMERGENCY, + Logger::ALERT => LogLevel::ALERT, + Logger::CRIT => LogLevel::CRITICAL, + Logger::ERR => LogLevel::ERROR, + Logger::WARN => LogLevel::WARNING, + Logger::NOTICE => LogLevel::NOTICE, + Logger::INFO => LogLevel::INFO, + Logger::DEBUG => LogLevel::DEBUG, + ]; + + /** + * Default log level (warning) + * + * @var int + */ + protected $defaultLogLevel = LogLevel::WARNING; + + /** + * Constructor + * + * Set options for a writer. Accepted options are: + * + * - filters: array of filters to add to this filter + * - formatter: formatter for this writer + * - logger: PsrLoggerInterface implementation + * + * @param array|Traversable|LoggerInterface $options + * @throws Exception\InvalidArgumentException + */ + public function __construct($options = null) + { + if ($options instanceof PsrLoggerInterface) { + $this->setLogger($options); + } + + if ($options instanceof Traversable) { + $options = iterator_to_array($options); + } + + if (is_array($options) && isset($options['logger'])) { + $this->setLogger($options['logger']); + } + + parent::__construct($options); + + if (null === $this->logger) { + $this->setLogger(new NullLogger); + } + } + + /** + * Write a message to the PSR-3 compliant logger. + * + * @param array $event event data + * @return void + */ + protected function doWrite(array $event) + { + $priority = $event['priority']; + $message = $event['message']; + $context = $event['extra']; + + $level = isset($this->psrPriorityMap[$priority]) + ? $this->psrPriorityMap[$priority] + : $this->defaultLogLevel; + + $this->logger->log($level, $message, $context); + } +} diff --git a/src/WriterPluginManager.php b/src/WriterPluginManager.php index a842008a..a9c18339 100644 --- a/src/WriterPluginManager.php +++ b/src/WriterPluginManager.php @@ -11,6 +11,9 @@ use Zend\ServiceManager\AbstractPluginManager; +/** + * Plugin manager for log writers. + */ class WriterPluginManager extends AbstractPluginManager { protected $aliases = [ @@ -31,6 +34,7 @@ class WriterPluginManager extends AbstractPluginManager 'mail' => 'Zend\Log\Writer\Mail', 'mock' => 'Zend\Log\Writer\Mock', 'noop' => 'Zend\Log\Writer\Noop', + 'psr' => 'Zend\Log\Writer\Psr', 'stream' => 'Zend\Log\Writer\Stream', 'syslog' => 'Zend\Log\Writer\Syslog', 'zendmonitor' => 'Zend\Log\Writer\ZendMonitor', diff --git a/test/Processor/PsrPlaceholderTest.php b/test/Processor/PsrPlaceholderTest.php new file mode 100644 index 00000000..79e1e498 --- /dev/null +++ b/test/Processor/PsrPlaceholderTest.php @@ -0,0 +1,53 @@ +process([ + 'message' => '{foo}', + 'extra' => ['foo' => $val] + ]); + $this->assertEquals($expected, $event['message']); + } + + /** + * Data provider + * + * @return array + */ + public function pairsProvider() + { + return [ + 'string' => ['foo', 'foo'], + 'string-int' => ['3', '3'], + 'int' => [3, '3'], + 'null' => [null, ''], + 'true' => [true, '1'], + 'false' => [false, ''], + 'stdclass' => [new stdClass, '[object stdClass]'], + 'array' => [[], '[array]'], + ]; + } +} diff --git a/test/PsrLoggerAdapterTest.php b/test/PsrLoggerAdapterTest.php new file mode 100644 index 00000000..c9a523e7 --- /dev/null +++ b/test/PsrLoggerAdapterTest.php @@ -0,0 +1,127 @@ + + */ +class PsrLoggerAdapterTest extends LoggerInterfaceTest +{ + /** + * @var array + */ + protected $psrPriorityMap = [ + LogLevel::EMERGENCY => Logger::EMERG, + LogLevel::ALERT => Logger::ALERT, + LogLevel::CRITICAL => Logger::CRIT, + LogLevel::ERROR => Logger::ERR, + LogLevel::WARNING => Logger::WARN, + LogLevel::NOTICE => Logger::NOTICE, + LogLevel::INFO => Logger::INFO, + LogLevel::DEBUG => Logger::DEBUG, + ]; + + /** + * Provides logger for LoggerInterface compat tests + * + * @return PsrLoggerAdapter + */ + public function getLogger() + { + $this->mockWriter = new MockWriter; + $logger = new Logger; + $logger->addProcessor('psrplaceholder'); + $logger->addWriter($this->mockWriter); + return new PsrLoggerAdapter($logger); + } + + /** + * This must return the log messages in order. + * + * The simple formatting of the messages is: " ". + * + * Example ->error('Foo') would yield "error Foo". + * + * @return string[] + */ + public function getLogs() + { + $prefixMap = array_flip($this->psrPriorityMap); + return array_map(function ($event) use ($prefixMap) { + $prefix = $prefixMap[$event['priority']]; + $message = $prefix . ' ' . $event['message']; + return $message; + }, $this->mockWriter->events); + } + + public function tearDown() + { + unset($this->mockWriter); + } + + /** + * + * @covers ::__construct + * @covers ::getLogger + */ + public function testSetLogger() + { + $logger = new Logger; + + $adapter = new PsrLoggerAdapter($logger); + $this->assertAttributeEquals($logger, 'logger', $adapter); + + $this->assertSame($logger, $adapter->getLogger($logger)); + } + + /** + * @covers ::log + * @dataProvider logLevelsToPriorityProvider + */ + public function testPsrLogLevelsMapsToPriorities($logLevel, $priority) + { + $message = 'foo'; + $context = ['bar' => 'baz']; + + $logger = $this->getMock(Logger::class, ['log']); + $logger->expects($this->once()) + ->method('log') + ->with( + $this->equalTo($priority), + $this->equalTo($message), + $this->equalTo($context) + ); + + $adapter = new PsrLoggerAdapter($logger); + $adapter->log($logLevel, $message, $context); + } + + /** + * Data provider + * + * @return array + */ + public function logLevelsToPriorityProvider() + { + $return = []; + foreach ($this->psrPriorityMap as $level => $priority) { + $return[] = [$level, $priority]; + } + return $return; + } +} diff --git a/test/Writer/PsrTest.php b/test/Writer/PsrTest.php new file mode 100644 index 00000000..8f187255 --- /dev/null +++ b/test/Writer/PsrTest.php @@ -0,0 +1,110 @@ + + */ +class PsrTest extends \PHPUnit_Framework_TestCase +{ + /** + * @covers ::__construct + */ + public function testConstructWithPsrLogger() + { + $psrLogger = $this->getMock(LoggerInterface::class); + $writer = new PsrWriter($psrLogger); + $this->assertAttributeSame($psrLogger, 'logger', $writer); + } + + /** + * @covers ::__construct + */ + public function testConstructWithOptions() + { + $psrLogger = $this->getMock(LoggerInterface::class); + $formatter = new SimpleFormatter(); + $filter = new MockFilter(); + $writer = new PsrWriter([ + 'filters' => $filter, + 'formatter' => $formatter, + 'logger' => $psrLogger, + ]); + + $this->assertAttributeSame($psrLogger, 'logger', $writer); + $this->assertAttributeSame($formatter, 'formatter', $writer); + + $filters = self::readAttribute($writer, 'filters'); + $this->assertCount(1, $filters); + $this->assertEquals($filter, $filters[0]); + } + + /** + * @covers ::__construct + */ + public function testFallbackLoggerIsNullLogger() + { + $writer = new PsrWriter; + $this->assertAttributeInstanceOf(NullLogger::class, 'logger', $writer); + } + + /** + * @dataProvider priorityToLogLevelProvider + */ + public function testWriteLogMapsLevelsProperly($priority, $logLevel) + { + $message = 'foo'; + $extra = ['bar' => 'baz']; + + $psrLogger = $this->getMock(LoggerInterface::class); + $psrLogger->expects($this->once()) + ->method('log') + ->with( + $this->equalTo($logLevel), + $this->equalTo($message), + $this->equalTo($extra) + ); + + $writer = new PsrWriter($psrLogger); + $logger = new Logger(); + $logger->addWriter($writer); + + $logger->log($priority, $message, $extra); + } + + /** + * Data provider + * + * @return array + */ + public function priorityToLogLevelProvider() + { + return [ + 'emergency' => [Logger::EMERG, LogLevel::EMERGENCY], + 'alert' => [Logger::ALERT, LogLevel::ALERT], + 'critical' => [Logger::CRIT, LogLevel::CRITICAL], + 'error' => [Logger::ERR, LogLevel::ERROR], + 'warn' => [Logger::WARN, LogLevel::WARNING], + 'notice' => [Logger::NOTICE, LogLevel::NOTICE], + 'info' => [Logger::INFO, LogLevel::INFO], + 'debug' => [Logger::DEBUG, LogLevel::DEBUG], + ]; + } +}