diff --git a/composer.json b/composer.json index 00039be77d..7b55e30efd 100644 --- a/composer.json +++ b/composer.json @@ -109,6 +109,7 @@ "provide": { "psr/cache-implementation": "1.0", "psr/container-implementation": "1.0", + "psr/log-implementation": "1.0", "psr/simple-cache-implementation": "1.0" }, "autoload": { diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 1e87a15660..34af26de69 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 3.4.0 ----- + * Always register a minimalist logger that writes in `stderr` * Deprecated `profiler.matcher` option * Added support for `EventSubscriberInterface` on `MicroKernelTrait` * Removed `doctrine/cache` from the list of required dependencies in `composer.json` diff --git a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php index 62df8c5ed9..c9778f95fe 100644 --- a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php +++ b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php @@ -28,6 +28,7 @@ use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\WorkflowGuardLis use Symfony\Component\Console\Application; use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass; use Symfony\Component\HttpKernel\DependencyInjection\ControllerArgumentValueResolverPass; +use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass; use Symfony\Component\HttpKernel\DependencyInjection\RegisterControllerArgumentLocatorsPass; use Symfony\Component\HttpKernel\DependencyInjection\RemoveEmptyControllerArgumentLocatorsPass; use Symfony\Component\HttpKernel\DependencyInjection\ResettableServicePass; @@ -82,6 +83,7 @@ class FrameworkBundle extends Bundle { parent::build($container); + $container->addCompilerPass(new LoggerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -32); $container->addCompilerPass(new RegisterControllerArgumentLocatorsPass()); $container->addCompilerPass(new RemoveEmptyControllerArgumentLocatorsPass(), PassConfig::TYPE_BEFORE_REMOVING); $container->addCompilerPass(new RoutingResolverPass()); diff --git a/src/Symfony/Component/Console/Logger/ConsoleLogger.php b/src/Symfony/Component/Console/Logger/ConsoleLogger.php index 27c9b6b278..d7bcdfad00 100644 --- a/src/Symfony/Component/Console/Logger/ConsoleLogger.php +++ b/src/Symfony/Component/Console/Logger/ConsoleLogger.php @@ -101,6 +101,8 @@ class ConsoleLogger extends AbstractLogger /** * Returns true when any messages have been logged at error levels. + * + * @return bool */ public function hasErrored() { diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index d646f7f6ae..1da376f7ad 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 3.4.0 ----- + * added a minimalist PSR-3 `Logger` class that writes in `stderr` * made kernels implementing `CompilerPassInterface` able to process the container * deprecated bundle inheritance * added `RebootableInterface` and implemented it in `Kernel` diff --git a/src/Symfony/Component/HttpKernel/DependencyInjection/LoggerPass.php b/src/Symfony/Component/HttpKernel/DependencyInjection/LoggerPass.php new file mode 100644 index 0000000000..2cffb99b32 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/DependencyInjection/LoggerPass.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\DependencyInjection; + +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; +use Symfony\Component\HttpKernel\Log\Logger; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Registers the default logger if necessary. + * + * @author Kévin Dunglas + */ +class LoggerPass implements CompilerPassInterface +{ + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container) + { + $alias = $container->setAlias(LoggerInterface::class, 'logger'); + $alias->setPublic(false); + + if ($container->has('logger')) { + return; + } + + $loggerDefinition = $container->register('logger', Logger::class); + $loggerDefinition->setPublic(false); + if ($container->getParameter('kernel.debug')) { + $loggerDefinition->addArgument(LogLevel::DEBUG); + } + } +} diff --git a/src/Symfony/Component/HttpKernel/Log/Logger.php b/src/Symfony/Component/HttpKernel/Log/Logger.php new file mode 100644 index 0000000000..cf20ca258a --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Log/Logger.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Log; + +use Psr\Log\AbstractLogger; +use Psr\Log\InvalidArgumentException; +use Psr\Log\LogLevel; + +/** + * Minimalist PSR-3 logger designed to write in stderr or any other stream. + * + * @author Kévin Dunglas + */ +class Logger extends AbstractLogger +{ + private static $levels = array( + LogLevel::DEBUG => 0, + LogLevel::INFO => 1, + LogLevel::NOTICE => 2, + LogLevel::WARNING => 3, + LogLevel::ERROR => 4, + LogLevel::CRITICAL => 5, + LogLevel::ALERT => 6, + LogLevel::EMERGENCY => 7, + ); + + private $minLevelIndex; + private $formatter; + private $handle; + + public function __construct($minLevel = LogLevel::WARNING, $output = 'php://stderr', callable $formatter = null) + { + if (!isset(self::$levels[$minLevel])) { + throw new InvalidArgumentException(sprintf('The log level "%s" does not exist.', $minLevel)); + } + + $this->minLevelIndex = self::$levels[$minLevel]; + $this->formatter = $formatter ?: array($this, 'format'); + if (false === $this->handle = @fopen($output, 'a')) { + throw new InvalidArgumentException(sprintf('Unable to open "%s".', $output)); + } + } + + /** + * {@inheritdoc} + */ + public function log($level, $message, array $context = array()) + { + if (!isset(self::$levels[$level])) { + throw new InvalidArgumentException(sprintf('The log level "%s" does not exist.', $level)); + } + + if (self::$levels[$level] < $this->minLevelIndex) { + return; + } + + $formatter = $this->formatter; + fwrite($this->handle, $formatter($level, $message, $context)); + } + + /** + * @param string $level + * @param string $message + * @param array $context + * + * @return string + */ + private function format($level, $message, array $context) + { + if (false !== strpos($message, '{')) { + $replacements = array(); + foreach ($context as $key => $val) { + if (null === $val || is_scalar($val) || (\is_object($val) && method_exists($val, '__toString'))) { + $replacements["{{$key}}"] = $val; + } elseif ($val instanceof \DateTimeInterface) { + $replacements["{{$key}}"] = $val->format(\DateTime::RFC3339); + } elseif (\is_object($val)) { + $replacements["{{$key}}"] = '[object '.\get_class($val).']'; + } else { + $replacements["{{$key}}"] = '['.\gettype($val).']'; + } + } + + $message = strtr($message, $replacements); + } + + return sprintf('%s [%s] %s', date(\DateTime::RFC3339), $level, $message).\PHP_EOL; + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/LoggerPassTest.php b/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/LoggerPassTest.php new file mode 100644 index 0000000000..d018929b2a --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/LoggerPassTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\DependencyInjection; + +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; +use Symfony\Component\HttpKernel\DependencyInjection\LoggerPass; +use Symfony\Component\HttpKernel\Log\Logger; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * @author Kévin Dunglas + */ +class LoggerPassTest extends TestCase +{ + public function testAlwaysSetAutowiringAlias() + { + $container = new ContainerBuilder(); + $container->register('logger', 'Foo'); + + (new LoggerPass())->process($container); + + $this->assertFalse($container->getAlias(LoggerInterface::class)->isPublic()); + } + + public function testDoNotOverrideExistingLogger() + { + $container = new ContainerBuilder(); + $container->register('logger', 'Foo'); + + (new LoggerPass())->process($container); + + $this->assertSame('Foo', $container->getDefinition('logger')->getClass()); + } + + public function testRegisterLogger() + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + + (new LoggerPass())->process($container); + + $definition = $container->getDefinition('logger'); + $this->assertSame(Logger::class, $definition->getClass()); + $this->assertFalse($definition->isPublic()); + } + + public function testSetMinLevelWhenDebugging() + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', true); + + (new LoggerPass())->process($container); + + $definition = $container->getDefinition('logger'); + $this->assertSame(LogLevel::DEBUG, $definition->getArgument(0)); + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Log/LoggerTest.php b/src/Symfony/Component/HttpKernel/Tests/Log/LoggerTest.php new file mode 100644 index 0000000000..ecf67af78c --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Log/LoggerTest.php @@ -0,0 +1,212 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\Log; + +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; +use Symfony\Component\HttpKernel\Log\Logger; + +/** + * @author Kévin Dunglas + * @author Jordi Boggiano + */ +class LoggerTest extends TestCase +{ + /** + * @var LoggerInterface + */ + private $logger; + + /** + * @var string + */ + private $tmpFile; + + protected function setUp() + { + $this->tmpFile = sys_get_temp_dir().DIRECTORY_SEPARATOR.'log'; + $this->logger = new Logger(LogLevel::DEBUG, $this->tmpFile); + } + + protected function tearDown() + { + if (!@unlink($this->tmpFile)) { + file_put_contents($this->tmpFile, ''); + } + } + + public static function assertLogsMatch(array $expected, array $given) + { + foreach ($given as $k => $line) { + self::assertThat(1 === preg_match('/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}[\+-][0-9]{2}:[0-9]{2} '.preg_quote($expected[$k]).'/', $line), self::isTrue(), "\"$line\" do not match expected pattern \"$expected[$k]\""); + } + } + + /** + * Return the log messages in order. + * + * @return string[] + */ + public function getLogs() + { + return file($this->tmpFile, FILE_IGNORE_NEW_LINES); + } + + public function testImplements() + { + $this->assertInstanceOf(LoggerInterface::class, $this->logger); + } + + /** + * @dataProvider provideLevelsAndMessages + */ + public function testLogsAtAllLevels($level, $message) + { + $this->logger->{$level}($message, array('user' => 'Bob')); + $this->logger->log($level, $message, array('user' => 'Bob')); + + $expected = array( + "[$level] message of level $level with context: Bob", + "[$level] message of level $level with context: Bob", + ); + $this->assertLogsMatch($expected, $this->getLogs()); + } + + public function provideLevelsAndMessages() + { + return array( + LogLevel::EMERGENCY => array(LogLevel::EMERGENCY, 'message of level emergency with context: {user}'), + LogLevel::ALERT => array(LogLevel::ALERT, 'message of level alert with context: {user}'), + LogLevel::CRITICAL => array(LogLevel::CRITICAL, 'message of level critical with context: {user}'), + LogLevel::ERROR => array(LogLevel::ERROR, 'message of level error with context: {user}'), + LogLevel::WARNING => array(LogLevel::WARNING, 'message of level warning with context: {user}'), + LogLevel::NOTICE => array(LogLevel::NOTICE, 'message of level notice with context: {user}'), + LogLevel::INFO => array(LogLevel::INFO, 'message of level info with context: {user}'), + LogLevel::DEBUG => array(LogLevel::DEBUG, 'message of level debug with context: {user}'), + ); + } + + public function testLogLevelDisabled() + { + $this->logger = new Logger(LogLevel::INFO, $this->tmpFile); + + $this->logger->debug('test', array('user' => 'Bob')); + $this->logger->log(LogLevel::DEBUG, 'test', array('user' => 'Bob')); + + // Will always be true, but asserts than an exception isn't thrown + $this->assertSame(array(), $this->getLogs()); + } + + /** + * @expectedException \Psr\Log\InvalidArgumentException + */ + public function testThrowsOnInvalidLevel() + { + $this->logger->log('invalid level', 'Foo'); + } + + /** + * @expectedException \Psr\Log\InvalidArgumentException + */ + public function testThrowsOnInvalidMinLevel() + { + new Logger('invalid'); + } + + /** + * @expectedException \Psr\Log\InvalidArgumentException + */ + public function testInvalidOutput() + { + new Logger(LogLevel::DEBUG, '/'); + } + + public function testContextReplacement() + { + $logger = $this->logger; + $logger->info('{Message {nothing} {user} {foo.bar} a}', array('user' => 'Bob', 'foo.bar' => 'Bar')); + + $expected = array('[info] {Message {nothing} Bob Bar a}'); + $this->assertLogsMatch($expected, $this->getLogs()); + } + + public function testObjectCastToString() + { + if (method_exists($this, 'createPartialMock')) { + $dummy = $this->createPartialMock(DummyTest::class, array('__toString')); + } else { + $dummy = $this->getMock(DummyTest::class, array('__toString')); + } + $dummy->expects($this->atLeastOnce()) + ->method('__toString') + ->will($this->returnValue('DUMMY')); + + $this->logger->warning($dummy); + + $expected = array('[warning] DUMMY'); + $this->assertLogsMatch($expected, $this->getLogs()); + } + + public function testContextCanContainAnything() + { + $context = array( + 'bool' => true, + 'null' => null, + 'string' => 'Foo', + 'int' => 0, + 'float' => 0.5, + 'nested' => array('with object' => new DummyTest()), + 'object' => new \DateTime(), + 'resource' => fopen('php://memory', 'r'), + ); + + $this->logger->warning('Crazy context data', $context); + + $expected = array('[warning] Crazy context data'); + $this->assertLogsMatch($expected, $this->getLogs()); + } + + public function testContextExceptionKeyCanBeExceptionOrOtherValues() + { + $logger = $this->logger; + $logger->warning('Random message', array('exception' => 'oops')); + $logger->critical('Uncaught Exception!', array('exception' => new \LogicException('Fail'))); + + $expected = array( + '[warning] Random message', + '[critical] Uncaught Exception!', + ); + $this->assertLogsMatch($expected, $this->getLogs()); + } + + public function testFormatter() + { + $this->logger = new Logger(LogLevel::DEBUG, $this->tmpFile, function ($level, $message, $context) { + return json_encode(array('level' => $level, 'message' => $message, 'context' => $context)).\PHP_EOL; + }); + + $this->logger->error('An error', array('foo' => 'bar')); + $this->logger->warning('A warning', array('baz' => 'bar')); + $this->assertSame(array( + '{"level":"error","message":"An error","context":{"foo":"bar"}}', + '{"level":"warning","message":"A warning","context":{"baz":"bar"}}', + ), $this->getLogs()); + } +} + +class DummyTest +{ + public function __toString() + { + } +} diff --git a/src/Symfony/Component/HttpKernel/composer.json b/src/Symfony/Component/HttpKernel/composer.json index 3c051bb5f7..a372edb588 100644 --- a/src/Symfony/Component/HttpKernel/composer.json +++ b/src/Symfony/Component/HttpKernel/composer.json @@ -40,6 +40,9 @@ "symfony/var-dumper": "~3.3|~4.0", "psr/cache": "~1.0" }, + "provide": { + "psr/log-implementation": "1.0" + }, "conflict": { "symfony/config": "<2.8", "symfony/dependency-injection": "<3.4",