diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 5e29298a6a3f..189946dcd870 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -11,6 +11,7 @@ CHANGELOG * Deprecate the `cache.adapter.doctrine` service * Add support for resetting container services after each messenger message * Add `configureContainer()`, `configureRoutes()`, `getConfigDir()` and `getBundlesPath()` to `MicroKernelTrait` + * Add support for configuring log level, and status code by exception class 5.3 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 5750dd6de3b6..4d1d2d6e1ccb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -15,6 +15,7 @@ use Doctrine\Common\Annotations\PsrCachedReader; use Doctrine\Common\Cache\Cache; use Doctrine\DBAL\Connection; +use Psr\Log\LogLevel; use Symfony\Bundle\FullStack; use Symfony\Component\Asset\Package; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; @@ -139,6 +140,7 @@ public function getConfigTreeBuilder() $this->addPropertyInfoSection($rootNode, $enableIfStandalone); $this->addCacheSection($rootNode, $willBeAvailable); $this->addPhpErrorsSection($rootNode); + $this->addExceptionsSection($rootNode); $this->addWebLinkSection($rootNode, $enableIfStandalone); $this->addLockSection($rootNode, $enableIfStandalone); $this->addMessengerSection($rootNode, $enableIfStandalone); @@ -1163,6 +1165,64 @@ private function addPhpErrorsSection(ArrayNodeDefinition $rootNode) ; } + private function addExceptionsSection(ArrayNodeDefinition $rootNode) + { + $logLevels = (new \ReflectionClass(LogLevel::class))->getConstants(); + + $rootNode + ->children() + ->arrayNode('exceptions') + ->info('Exception handling configuration') + ->beforeNormalization() + ->ifArray() + ->then(function (array $v): array { + if (!\array_key_exists('exception', $v)) { + return $v; + } + + // Fix XML normalization + $data = isset($v['exception'][0]) ? $v['exception'] : [$v['exception']]; + $exceptions = []; + foreach ($data as $exception) { + $config = []; + if (\array_key_exists('log-level', $exception)) { + $config['log_level'] = $exception['log-level']; + } + if (\array_key_exists('status-code', $exception)) { + $config['status_code'] = $exception['status-code']; + } + $exceptions[$exception['name']] = $config; + } + + return $exceptions; + }) + ->end() + ->prototype('array') + ->fixXmlConfig('exception') + ->children() + ->scalarNode('log_level') + ->info('The level of log message. Null to let Symfony decide.') + ->validate() + ->ifTrue(function ($v) use ($logLevels) { return !\in_array($v, $logLevels); }) + ->thenInvalid(sprintf('The log level is not valid. Pick one among "%s".', implode('", "', $logLevels))) + ->end() + ->defaultNull() + ->end() + ->scalarNode('status_code') + ->info('The status code of the response. Null to let Symfony decide.') + ->validate() + ->ifTrue(function ($v) { return !\in_array($v, range(100, 499)); }) + ->thenInvalid('The log level is not valid. Pick one among between 100 et 599.') + ->end() + ->defaultNull() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } + private function addLockSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) { $rootNode diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index cd4d45ff4c01..84299bd0ffc0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -427,6 +427,8 @@ public function load(array $configs, ContainerBuilder $container) $this->registerPropertyAccessConfiguration($config['property_access'], $container, $loader); $this->registerSecretsConfiguration($config['secrets'], $container, $loader); + $container->getDefinition('exception_listener')->replaceArgument(3, $config['exceptions']); + if ($this->isConfigEnabled($container, $config['serializer'])) { if (!class_exists(\Symfony\Component\Serializer\Serializer::class)) { throw new LogicException('Serializer support cannot be enabled as the Serializer component is not installed. Try running "composer require symfony/serializer-pack".'); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index a91698bc8068..94ec4092664d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -29,6 +29,7 @@ + @@ -346,6 +347,18 @@ + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php index 8d1934e345ed..362ae408b57b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php @@ -102,6 +102,7 @@ param('kernel.error_controller'), service('logger')->nullOnInvalid(), param('kernel.debug'), + abstract_arg('an exceptions to log & status code mapping'), ]) ->tag('kernel.event_subscriber') ->tag('monolog.logger', ['channel' => 'request']) diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index d47ced3796e7..b2a2906f0652 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -579,6 +579,7 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'name_based_uuid_version' => 5, 'time_based_uuid_version' => 6, ], + 'exceptions' => [], ]; } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/exceptions.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/exceptions.php new file mode 100644 index 000000000000..5d0dde0e0ac6 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/exceptions.php @@ -0,0 +1,12 @@ +loadFromExtension('framework', [ + 'exceptions' => [ + BadRequestHttpException::class => [ + 'log_level' => 'info', + 'status_code' => 422, + ], + ], +]); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/exceptions.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/exceptions.xml new file mode 100644 index 000000000000..cc73b8de3ced --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/exceptions.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/exceptions.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/exceptions.yml new file mode 100644 index 000000000000..82fab4e04a9f --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/exceptions.yml @@ -0,0 +1,5 @@ +framework: + exceptions: + Symfony\Component\HttpKernel\Exception\BadRequestHttpException: + log_level: info + status_code: 422 diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index fee9e7f8f9f0..77860c53762c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -521,6 +521,18 @@ public function testPhpErrorsWithLogLevels() ], $definition->getArgument(2)); } + public function testExceptionsConfig() + { + $container = $this->createContainerFromFile('exceptions'); + + $this->assertSame([ + \Symfony\Component\HttpKernel\Exception\BadRequestHttpException::class => [ + 'log_level' => 'info', + 'status_code' => 422, + ], + ], $container->getDefinition('exception_listener')->getArgument(3)); + } + public function testRouter() { $container = $this->createContainerFromFile('full'); diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index fd2bdf12154c..7df4eb26f304 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Deprecate `AbstractTestSessionListener::getSession` inject a session in the request instead * Deprecate the `fileLinkFormat` parameter of `DebugHandlersListener` + * Add support for configuring log level, and status code by exception class 5.3 --- diff --git a/src/Symfony/Component/HttpKernel/EventListener/ErrorListener.php b/src/Symfony/Component/HttpKernel/EventListener/ErrorListener.php index f5cac76bd8d0..bc4ca2f7d861 100644 --- a/src/Symfony/Component/HttpKernel/EventListener/ErrorListener.php +++ b/src/Symfony/Component/HttpKernel/EventListener/ErrorListener.php @@ -32,19 +32,30 @@ class ErrorListener implements EventSubscriberInterface protected $controller; protected $logger; protected $debug; + protected $exceptionsMapping; - public function __construct($controller, LoggerInterface $logger = null, bool $debug = false) + public function __construct($controller, LoggerInterface $logger = null, bool $debug = false, array $exceptionsMapping = []) { $this->controller = $controller; $this->logger = $logger; $this->debug = $debug; + $this->exceptionsMapping = $exceptionsMapping; } public function logKernelException(ExceptionEvent $event) { - $e = FlattenException::createFromThrowable($event->getThrowable()); + $throwable = $event->getThrowable(); + $logLevel = null; + foreach ($this->exceptionsMapping as $class => $config) { + if ($throwable instanceof $class && $config['log_level']) { + $logLevel = $config['log_level']; + break; + } + } + + $e = FlattenException::createFromThrowable($throwable); - $this->logException($event->getThrowable(), sprintf('Uncaught PHP Exception %s: "%s" at %s line %s', $e->getClass(), $e->getMessage(), $e->getFile(), $e->getLine())); + $this->logException($throwable, sprintf('Uncaught PHP Exception %s: "%s" at %s line %s', $e->getClass(), $e->getMessage(), $e->getFile(), $e->getLine()), $logLevel); } public function onKernelException(ExceptionEvent $event) @@ -53,8 +64,8 @@ public function onKernelException(ExceptionEvent $event) return; } - $exception = $event->getThrowable(); - $request = $this->duplicateRequest($exception, $event->getRequest()); + $throwable = $event->getThrowable(); + $request = $this->duplicateRequest($throwable, $event->getRequest()); try { $response = $event->getKernel()->handle($request, HttpKernelInterface::SUB_REQUEST, false); @@ -65,18 +76,25 @@ public function onKernelException(ExceptionEvent $event) $prev = $e; do { - if ($exception === $wrapper = $prev) { + if ($throwable === $wrapper = $prev) { throw $e; } } while ($prev = $wrapper->getPrevious()); $prev = new \ReflectionProperty($wrapper instanceof \Exception ? \Exception::class : \Error::class, 'previous'); $prev->setAccessible(true); - $prev->setValue($wrapper, $exception); + $prev->setValue($wrapper, $throwable); throw $e; } + foreach ($this->exceptionsMapping as $exception => $config) { + if ($throwable instanceof $exception && $config['status_code']) { + $response->setStatusCode($config['status_code']); + break; + } + } + $event->setResponse($response); if ($this->debug) { @@ -124,10 +142,12 @@ public static function getSubscribedEvents(): array /** * Logs an exception. */ - protected function logException(\Throwable $exception, string $message): void + protected function logException(\Throwable $exception, string $message, string $logLevel = null): void { if (null !== $this->logger) { - if (!$exception instanceof HttpExceptionInterface || $exception->getStatusCode() >= 500) { + if (null !== $logLevel) { + $this->logger->log($logLevel, $message, ['exception' => $exception]); + } elseif (!$exception instanceof HttpExceptionInterface || $exception->getStatusCode() >= 500) { $this->logger->critical($message, ['exception' => $exception]); } else { $this->logger->error($message, ['exception' => $exception]); diff --git a/src/Symfony/Component/HttpKernel/Tests/EventListener/ErrorListenerTest.php b/src/Symfony/Component/HttpKernel/Tests/EventListener/ErrorListenerTest.php index bd4b1799352d..2c8d725466e2 100644 --- a/src/Symfony/Component/HttpKernel/Tests/EventListener/ErrorListenerTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/EventListener/ErrorListenerTest.php @@ -97,6 +97,27 @@ public function testHandleWithLogger($event, $event2) $this->assertCount(3, $logger->getLogs('critical')); } + public function testHandleWithLoggerAndCustomConfiguration() + { + $request = new Request(); + $event = new ExceptionEvent(new TestKernel(), $request, HttpKernelInterface::MAIN_REQUEST, new \RuntimeException('bar')); + $logger = new TestLogger(); + $l = new ErrorListener('not used', $logger, false, [ + \RuntimeException::class => [ + 'log_level' => 'warning', + 'status_code' => 401, + ], + ]); + $l->logKernelException($event); + $l->onKernelException($event); + + $this->assertEquals(new Response('foo', 401), $event->getResponse()); + + $this->assertEquals(0, $logger->countErrors()); + $this->assertCount(0, $logger->getLogs('critical')); + $this->assertCount(1, $logger->getLogs('warning')); + } + public function provider() { if (!class_exists(Request::class)) {