diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index fcf117af..59629be1 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -40,6 +40,11 @@ parameters: count: 1 path: src/DependencyInjection/SentryExtension.php + - + message: "#^Cannot access offset 'enabled' on mixed\\.$#" + count: 1 + path: src/DependencyInjection/SentryExtension.php + - message: "#^Cannot access offset 'excluded_commands' on mixed\\.$#" count: 1 @@ -85,6 +90,11 @@ parameters: count: 1 path: src/DependencyInjection/SentryExtension.php + - + message: "#^Parameter \\#1 \\$level of static method Monolog\\\\Logger\\:\\:toMonologLevel\\(\\) expects 100\\|200\\|250\\|300\\|400\\|500\\|550\\|600\\|'ALERT'\\|'alert'\\|'CRITICAL'\\|'critical'\\|'DEBUG'\\|'debug'\\|'EMERGENCY'\\|'emergency'\\|'ERROR'\\|'error'\\|'INFO'\\|'info'\\|'NOTICE'\\|'notice'\\|'WARNING'\\|'warning', mixed given\\.$#" + count: 1 + path: src/DependencyInjection/SentryExtension.php + - message: "#^Parameter \\#2 \\$array of function array_map expects array, mixed given\\.$#" count: 1 @@ -110,6 +120,11 @@ parameters: count: 1 path: src/DependencyInjection/SentryExtension.php + - + message: "#^Parameter \\#2 \\$config of method Sentry\\\\SentryBundle\\\\DependencyInjection\\\\SentryExtension\\:\\:registerMonologHandlerConfiguration\\(\\) expects array\\, mixed given\\.$#" + count: 1 + path: src/DependencyInjection/SentryExtension.php + - message: "#^Parameter \\#2 \\$config of method Sentry\\\\SentryBundle\\\\DependencyInjection\\\\SentryExtension\\:\\:registerTracingConfiguration\\(\\) expects array\\, mixed given\\.$#" count: 1 @@ -175,6 +190,26 @@ parameters: count: 1 path: src/EventListener/SubRequestListener.php + - + message: "#^Cannot call method getType\\(\\) on Sentry\\\\ExceptionDataBag\\|null\\.$#" + count: 1 + path: src/Integration/IgnoreFatalErrorExceptionsIntegration.php + + - + message: "#^Cannot call method getValue\\(\\) on Sentry\\\\ExceptionDataBag\\|null\\.$#" + count: 1 + path: src/Integration/IgnoreFatalErrorExceptionsIntegration.php + + - + message: "#^Instanceof between null and Symfony\\\\Component\\\\ErrorHandler\\\\Error\\\\FatalError will always evaluate to false\\.$#" + count: 1 + path: src/Monolog/SymfonyHandler.php + + - + message: "#^Offset 'exception' on array\\{message\\: string, context\\: array, level\\: 100\\|200\\|250\\|300\\|400\\|500\\|550\\|600, level_name\\: 'ALERT'\\|'CRITICAL'\\|'DEBUG'\\|'EMERGENCY'\\|'ERROR'\\|'INFO'\\|'NOTICE'\\|'WARNING', channel\\: string, datetime\\: DateTimeImmutable, extra\\: array\\} on left side of \\?\\? does not exist\\.$#" + count: 1 + path: src/Monolog/SymfonyHandler.php + - message: "#^Parameter \\#1 \\$driver of method Sentry\\\\SentryBundle\\\\Tracing\\\\Doctrine\\\\DBAL\\\\TracingDriverMiddleware\\:\\:wrap\\(\\) expects Doctrine\\\\DBAL\\\\Driver, mixed given\\.$#" count: 1 diff --git a/src/DependencyInjection/Compiler/RegisterMonologHandlerPass.php b/src/DependencyInjection/Compiler/RegisterMonologHandlerPass.php new file mode 100644 index 00000000..259e47e7 --- /dev/null +++ b/src/DependencyInjection/Compiler/RegisterMonologHandlerPass.php @@ -0,0 +1,35 @@ +hasDefinition(Handler::class)) { + return; + } + + $this->registerMainHandler($container); + } + + private function registerMainHandler(ContainerBuilder $container): void + { + foreach ($container->getServiceIds() as $serviceName) { + if (!str_starts_with($serviceName, 'monolog.logger.')) { + continue; + } + + $logger = $container->getDefinition($serviceName); + $logger->addMethodCall('pushHandler', [new Reference(SymfonyHandler::class)]); + } + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 6e5a5902..7efbc9b2 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -6,6 +6,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Jean85\PrettyVersions; +use Monolog\Logger; use Sentry\Options; use Sentry\SentryBundle\ErrorTypesParser; use Sentry\Transport\TransportFactoryInterface; @@ -143,6 +144,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->end(); $this->addMessengerSection($rootNode); + $this->addMonologSection($rootNode); $this->addDistributedTracingSection($rootNode); return $treeBuilder; @@ -161,6 +163,26 @@ private function addMessengerSection(ArrayNodeDefinition $rootNode): void ->end(); } + private function addMonologSection(ArrayNodeDefinition $rootNode): void + { + $rootNode + ->children() + ->arrayNode('monolog') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('error_handler') + ->{class_exists(Logger::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->end() + ->scalarNode('level') + ->defaultValue(Logger::DEBUG) + ->cannotBeEmpty() + ->end() + ->booleanNode('bubble')->defaultTrue()->end() + ->end() + ->end() + ->end(); + } + private function addDistributedTracingSection(ArrayNodeDefinition $rootNode): void { $rootNode diff --git a/src/DependencyInjection/SentryExtension.php b/src/DependencyInjection/SentryExtension.php index 1b2bc394..b288e119 100644 --- a/src/DependencyInjection/SentryExtension.php +++ b/src/DependencyInjection/SentryExtension.php @@ -6,7 +6,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Jean85\PrettyVersions; -use LogicException; +use Monolog\Logger as MonologLogger; use Psr\Log\NullLogger; use Sentry\Client; use Sentry\ClientBuilder; @@ -14,6 +14,7 @@ use Sentry\Integration\IntegrationInterface; use Sentry\Integration\RequestFetcherInterface; use Sentry\Integration\RequestIntegration; +use Sentry\Monolog\Handler as SentryHandler; use Sentry\Options; use Sentry\SentryBundle\EventListener\ConsoleListener; use Sentry\SentryBundle\EventListener\ErrorListener; @@ -21,12 +22,14 @@ use Sentry\SentryBundle\EventListener\TracingConsoleListener; use Sentry\SentryBundle\EventListener\TracingRequestListener; use Sentry\SentryBundle\EventListener\TracingSubRequestListener; +use Sentry\SentryBundle\Monolog\SymfonyHandler; use Sentry\SentryBundle\SentryBundle; use Sentry\SentryBundle\Tracing\Doctrine\DBAL\ConnectionConfigurator; use Sentry\SentryBundle\Tracing\Doctrine\DBAL\TracingDriverMiddleware; use Sentry\SentryBundle\Tracing\Twig\TwigTracingExtension; use Sentry\Serializer\RepresentationSerializer; use Sentry\Serializer\Serializer; +use Sentry\State\HubInterface; use Sentry\Transport\TransportFactoryInterface; use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\Cache\CacheItem; @@ -34,6 +37,7 @@ use Symfony\Component\Debug\Exception\FatalErrorException; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Loader; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\ErrorHandler\Error\FatalError; @@ -74,6 +78,7 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container $this->registerDbalTracingConfiguration($container, $mergedConfig['tracing']); $this->registerTwigTracingConfiguration($container, $mergedConfig['tracing']); $this->registerCacheTracingConfiguration($container, $mergedConfig['tracing']); + $this->registerMonologHandlerConfiguration($container, $mergedConfig['monolog']); } /** @@ -247,6 +252,32 @@ private function registerCacheTracingConfiguration(ContainerBuilder $container, $container->setParameter('sentry.tracing.cache.enabled', $isConfigEnabled); } + /** + * @param array $config + */ + private function registerMonologHandlerConfiguration(ContainerBuilder $container, array $config): void + { + $errorHandlerConfig = $config['error_handler']; + + if (!$errorHandlerConfig['enabled']) { + $container->removeDefinition(SymfonyHandler::class); + $container->removeDefinition(SentryHandler::class); + + return; + } + + if (!class_exists(MonologLogger::class)) { + throw new LogicException(sprintf('To use the "%s" class you need to require the "symfony/monolog-bundle" package.', SymfonyHandler::class)); + } + + $definition = $container->getDefinition(SymfonyHandler::class); + $definition->setArguments([ + new Reference(HubInterface::class), + MonologLogger::toMonologLevel($config['level']), + $config['bubble'], + ]); + } + /** * @param string[] $integrations * @param array $config diff --git a/src/Integration/IgnoreFatalErrorExceptionsIntegration.php b/src/Integration/IgnoreFatalErrorExceptionsIntegration.php new file mode 100644 index 00000000..a373b200 --- /dev/null +++ b/src/Integration/IgnoreFatalErrorExceptionsIntegration.php @@ -0,0 +1,41 @@ +getExceptions()[0] ?? null; + + if ( + $exceptionDataBag instanceof ExceptionDataBag + && $exceptionDataBag->getType() === self::$previousExceptionDataBag->getType() + && $exceptionDataBag->getValue() === self::$previousExceptionDataBag->getValue() + ) { + return null; + } + + return $event; + }); + } +} diff --git a/src/Monolog/SymfonyHandler.php b/src/Monolog/SymfonyHandler.php new file mode 100644 index 00000000..ce7c1bbf --- /dev/null +++ b/src/Monolog/SymfonyHandler.php @@ -0,0 +1,77 @@ +decoratedHandler = $decoratedHandler; + } + + public function setFormatter(FormatterInterface $formatter): HandlerInterface + { + return $this->decoratedHandler->setFormatter($formatter); + } + + public function getFormatter(): FormatterInterface + { + return $this->decoratedHandler->getFormatter(); + } + + public function isHandling(array $record): bool + { + return $this->decoratedHandler->isHandling($record); + } + + public function handleBatch(array $records): void + { + $this->decoratedHandler->handleBatch($records); + } + + public function close(): void + { + $this->decoratedHandler->close(); + } + + public function pushProcessor(callable $callback): HandlerInterface + { + return $this->decoratedHandler->pushProcessor($callback); + } + + public function popProcessor(): callable + { + return $this->decoratedHandler->popProcessor(); + } + + public function reset(): void + { + $this->decoratedHandler->reset(); + } + + public function handle(array $record): bool + { + $exception = $record['exception'] ?? null; + + if ($exception instanceof FatalError) { + return false; + } + + return $this->decoratedHandler->handle($record); + } +} diff --git a/src/Resources/config/schema/sentry-1.0.xsd b/src/Resources/config/schema/sentry-1.0.xsd index 79a6aac4..ddf94742 100644 --- a/src/Resources/config/schema/sentry-1.0.xsd +++ b/src/Resources/config/schema/sentry-1.0.xsd @@ -12,6 +12,7 @@ + @@ -117,4 +118,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 41fe3633..7a3ca0cf 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -97,6 +97,12 @@ + + + + + + diff --git a/src/SentryBundle.php b/src/SentryBundle.php index 4098f8b3..552979b0 100644 --- a/src/SentryBundle.php +++ b/src/SentryBundle.php @@ -6,6 +6,8 @@ use Sentry\SentryBundle\DependencyInjection\Compiler\CacheTracingPass; use Sentry\SentryBundle\DependencyInjection\Compiler\DbalTracingPass; +use Sentry\SentryBundle\DependencyInjection\Compiler\RegisterMonologHandlerPass; +use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -17,6 +19,7 @@ public function build(ContainerBuilder $container): void { parent::build($container); + $container->addCompilerPass(new RegisterMonologHandlerPass(), PassConfig::TYPE_BEFORE_REMOVING); $container->addCompilerPass(new DbalTracingPass()); $container->addCompilerPass(new CacheTracingPass()); } diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php index 5f73cfde..61a01f14 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -6,6 +6,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Jean85\PrettyVersions; +use Monolog\Logger; use PHPUnit\Framework\TestCase; use Sentry\SentryBundle\DependencyInjection\Configuration; use Symfony\Bundle\TwigBundle\TwigBundle; @@ -40,6 +41,13 @@ public function testProcessConfigurationWithDefaultConfiguration(): void 'enabled' => interface_exists(MessageBusInterface::class), 'capture_soft_fails' => true, ], + 'monolog' => [ + 'error_handler' => [ + 'enabled' => class_exists(Logger::class), + ], + 'level' => Logger::DEBUG, + 'bubble' => true, + ], 'tracing' => [ 'enabled' => true, 'dbal' => [ diff --git a/tests/DependencyInjection/Fixtures/php/monolog_handler.php b/tests/DependencyInjection/Fixtures/php/monolog_handler.php new file mode 100644 index 00000000..0ba7c1ca --- /dev/null +++ b/tests/DependencyInjection/Fixtures/php/monolog_handler.php @@ -0,0 +1,14 @@ +loadFromExtension('sentry', [ + 'monolog' => [ + 'level' => Logger::ERROR, + 'bubble' => false, + ], +]); diff --git a/tests/DependencyInjection/Fixtures/php/monolog_handler_disabled.php b/tests/DependencyInjection/Fixtures/php/monolog_handler_disabled.php new file mode 100644 index 00000000..0f2fa62f --- /dev/null +++ b/tests/DependencyInjection/Fixtures/php/monolog_handler_disabled.php @@ -0,0 +1,12 @@ +loadFromExtension('sentry', [ + 'monolog' => [ + 'error_handler' => false, + ], +]); diff --git a/tests/DependencyInjection/Fixtures/xml/monolog_handler.xml b/tests/DependencyInjection/Fixtures/xml/monolog_handler.xml new file mode 100644 index 00000000..fbf63d5e --- /dev/null +++ b/tests/DependencyInjection/Fixtures/xml/monolog_handler.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/tests/DependencyInjection/Fixtures/xml/monolog_handler_disabled.xml b/tests/DependencyInjection/Fixtures/xml/monolog_handler_disabled.xml new file mode 100644 index 00000000..8d20321d --- /dev/null +++ b/tests/DependencyInjection/Fixtures/xml/monolog_handler_disabled.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/tests/DependencyInjection/Fixtures/yml/monolog_handler.yml b/tests/DependencyInjection/Fixtures/yml/monolog_handler.yml new file mode 100644 index 00000000..9f9fbb90 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/yml/monolog_handler.yml @@ -0,0 +1,4 @@ +sentry: + monolog: + level: !php/const Monolog\Logger::ERROR + bubble: false diff --git a/tests/DependencyInjection/Fixtures/yml/monolog_handler_disabled.yml b/tests/DependencyInjection/Fixtures/yml/monolog_handler_disabled.yml new file mode 100644 index 00000000..d2cae38b --- /dev/null +++ b/tests/DependencyInjection/Fixtures/yml/monolog_handler_disabled.yml @@ -0,0 +1,3 @@ +sentry: + monolog: + error_handler: false diff --git a/tests/DependencyInjection/SentryExtensionTest.php b/tests/DependencyInjection/SentryExtensionTest.php index eaafea6d..a923d70d 100644 --- a/tests/DependencyInjection/SentryExtensionTest.php +++ b/tests/DependencyInjection/SentryExtensionTest.php @@ -10,6 +10,7 @@ use Psr\Log\NullLogger; use Sentry\ClientInterface; use Sentry\Integration\IgnoreErrorsIntegration; +use Sentry\Monolog\Handler; use Sentry\Options; use Sentry\SentryBundle\DependencyInjection\SentryExtension; use Sentry\SentryBundle\EventListener\ConsoleListener; @@ -180,6 +181,21 @@ public function testSubRequestListener(): void ], $definition->getTags()); } + public function testMonologHandler(): void + { + $container = $this->createContainerFromFixture('monolog_handler'); + $definition = $container->getDefinition(Handler::class); + + $this->assertCount(1, $definition->getArguments(), 'Arguments are not default ones'); + } + + public function testMonologHandlerIsRemovedWhenDisabled(): void + { + $container = $this->createContainerFromFixture('monolog_handler_disabled'); + + $this->assertFalse($container->hasDefinition(Handler::class)); + } + public function testClientIsCreatedFromOptions(): void { $container = $this->createContainerFromFixture('full'); diff --git a/tests/End2End/App/KernelHookedToMonolog.php b/tests/End2End/App/KernelHookedToMonolog.php new file mode 100644 index 00000000..fa80ab22 --- /dev/null +++ b/tests/End2End/App/KernelHookedToMonolog.php @@ -0,0 +1,17 @@ +load(__DIR__ . '/sentry_hooked_to_monolog.yml'); + } +} diff --git a/tests/End2End/App/sentry_hooked_to_monolog.yml b/tests/End2End/App/sentry_hooked_to_monolog.yml new file mode 100644 index 00000000..d2590b79 --- /dev/null +++ b/tests/End2End/App/sentry_hooked_to_monolog.yml @@ -0,0 +1,10 @@ +sentry: + register_error_listener: false + monolog: + error_handler: + enabled: true + level: error + options: + # TODO: add a simple option to switch off the error/fatal integrations + default_integrations: false + integrations: [] diff --git a/tests/End2End/End2EndHookedToMonologTest.php b/tests/End2End/End2EndHookedToMonologTest.php new file mode 100644 index 00000000..b2ed2446 --- /dev/null +++ b/tests/End2End/End2EndHookedToMonologTest.php @@ -0,0 +1,15 @@ +