feature #24300 [HttpKernel][FrameworkBundle] Add a minimalist default PSR-3 logger (dunglas)
This PR was squashed before being merged into the 3.4 branch (closes #24300).
Discussion
----------
[HttpKernel][FrameworkBundle] Add a minimalist default PSR-3 logger
| Q | A
| ------------- | ---
| Branch? | 3.4
| Bug fix? | no
| New feature? | yes <!-- don't forget updating src/**/CHANGELOG.md files -->
| BC breaks? | no
| Deprecations? | no <!-- don't forget updating UPGRADE-*.md files -->
| Tests pass? | yes
| Fixed tickets | n/a
| License | MIT
| Doc PR | n/a
This PR provides a minimalist PSR-3 logger that is always available when FrameworkBundle is installed.
By default, it writes errors on `stderr`, regular logs on `stdout` and discards debug data (this is configurable).
This approach has several benefits:
- It's what expect from an app logging systems of major containerization and orchestration tools including [Docker](https://docs.docker.com/engine/admin/logging/view_container_logs/) and [Kubernetes](https://kubernetes.io/docs/concepts/cluster-administration/logging/), as well as most cloud providers such as [Heroku](https://devcenter.heroku.com/articles/logging#writing-to-your-log) and [Google Container Engine](https://kubernetes.io/docs/tasks/debug-application-cluster/logging-stackdriver/). If the app follows this standard (and it's not currently the case with Symfony by default) logs will be automatically collected, aggregated and stored.
- It's in sync with the "back to Unix roots" philosophy of Flex
- Logs are directly displayed in the console when running the integrated PHP web server (`bin/console server:start` or Flex's `make serve`), Create React App also do that for instance.
- It fixes a common problem when installing Flex recipes: many bundles expect a logger service but currently there is none available by default, and you usually get a `"logger" service not found error` (because packages depend of the PSR, but the PSR doesn't provide a logger service).
Commits
-------
9a06513ec7
[HttpKernel][FrameworkBundle] Add a minimalist default PSR-3 logger
This commit is contained in:
commit
09afa64909
@ -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": {
|
||||
|
@ -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`
|
||||
|
@ -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());
|
||||
|
@ -101,6 +101,8 @@ class ConsoleLogger extends AbstractLogger
|
||||
|
||||
/**
|
||||
* Returns true when any messages have been logged at error levels.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasErrored()
|
||||
{
|
||||
|
@ -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`
|
||||
|
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* 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 <dunglas@gmail.com>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
98
src/Symfony/Component/HttpKernel/Log/Logger.php
Normal file
98
src/Symfony/Component/HttpKernel/Log/Logger.php
Normal file
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* 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 <dunglas@gmail.com>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* 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 <dunglas@gmail.com>
|
||||
*/
|
||||
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));
|
||||
}
|
||||
}
|
212
src/Symfony/Component/HttpKernel/Tests/Log/LoggerTest.php
Normal file
212
src/Symfony/Component/HttpKernel/Tests/Log/LoggerTest.php
Normal file
@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* 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 <dunglas@gmail.com>
|
||||
* @author Jordi Boggiano <j.boggiano@seld.be>
|
||||
*/
|
||||
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()
|
||||
{
|
||||
}
|
||||
}
|
@ -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",
|
||||
|
Reference in New Issue
Block a user