diff --git a/CHANGELOG.md b/CHANGELOG.md index f6a56a76..728a9a87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Fix compatibility with sentry/sentry 2.2+ (#244) - Add support for `class_serializers` option (#245) - Add support for `max_request_body_size` option (#249) + - Add option to disable the error listener completely (#247, thanks to @HypeMC) + - Add options to register the Monolog Handler (#247, thanks to @HypeMC) ## 3.1.0 - 2019-07-02 - Add support for Symfony 2.8 (#233, thanks to @nocive) diff --git a/README.md b/README.md index e969e1ed..bbb73be9 100644 --- a/README.md +++ b/README.md @@ -103,17 +103,20 @@ the [PHP specific](https://docs.sentry.io/platforms/php/#php-specific-options) o #### Optional: use monolog handler provided by `sentry/sentry` *Note: this step is optional* -If You're using `monolog` for logging e.g. in-app errors, You +If you're using `monolog` for logging e.g. in-app errors, you can use this handler in order for them to show up in Sentry. -First, define `Sentry\Monolog\Handler` as a service in `config/services.yaml` +First, enable & configure the `Sentry\Monolog\Handler`; you'll also need +to disable the `Sentry\SentryBundle\EventListener\ErrorListener` to +avoid having duplicate events in Sentry: ```yaml -services: - sentry.monolog.handler: - class: Sentry\Monolog\Handler - arguments: - $level: 'error' +sentry: + register_error_listener: false # Disables the ErrorListener + monolog: + error_handler: + enabled: true + level: error ``` Then enable it in `monolog` config: @@ -123,8 +126,7 @@ monolog: handlers: sentry: type: service - id: sentry.monolog.handler - level: error + id: Sentry\Monolog\Handler ``` ## Maintained versions diff --git a/composer.json b/composer.json index 435ce3e7..105153f0 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^2.8", "jangregor/phpstan-prophecy": "^0.3.0", + "monolog/monolog": "^1.11||^2.0", "php-http/mock-client": "^1.0", "phpstan/phpstan": "^0.11", "phpstan/phpstan-phpunit": "^0.11", @@ -39,13 +40,16 @@ "scrutinizer/ocular": "^1.4", "symfony/expression-language": "^2.8||^3.0||^4.0" }, + "suggest": { + "monolog/monolog": "Required to use the Monolog handler" + }, "autoload": { - "psr-4" : { + "psr-4": { "Sentry\\SentryBundle\\": "src/" } }, "autoload-dev": { - "psr-4" : { + "psr-4": { "Sentry\\SentryBundle\\Test\\": "test" } }, diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 8a25035f..e210bb16 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -37,6 +37,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->ifString() ->then($this->getTrimClosure()); + $rootNode->children() + ->booleanNode('register_error_listener') + ->defaultTrue(); + // Options array (to be passed to Sentry\Options constructor) -- please keep alphabetical order! $optionsNode = $rootNode->children() ->arrayNode('options') @@ -139,6 +143,24 @@ public function getConfigTreeBuilder(): TreeBuilder $listenerPriorities->scalarNode('console_error') ->defaultValue(128); + // Monolog handler configuration + $monologConfiguration = $rootNode->children() + ->arrayNode('monolog') + ->addDefaultsIfNotSet() + ->children(); + + $errorHandler = $monologConfiguration + ->arrayNode('error_handler') + ->addDefaultsIfNotSet() + ->children(); + $errorHandler->booleanNode('enabled') + ->defaultFalse(); + $errorHandler->scalarNode('level') + ->defaultValue('DEBUG') + ->cannotBeEmpty(); + $errorHandler->booleanNode('bubble') + ->defaultTrue(); + return $treeBuilder; } diff --git a/src/DependencyInjection/SentryExtension.php b/src/DependencyInjection/SentryExtension.php index 4d177b40..8a3ba649 100644 --- a/src/DependencyInjection/SentryExtension.php +++ b/src/DependencyInjection/SentryExtension.php @@ -2,7 +2,9 @@ namespace Sentry\SentryBundle\DependencyInjection; +use Monolog\Logger as MonologLogger; use Sentry\ClientBuilderInterface; +use Sentry\Monolog\Handler; use Sentry\Options; use Sentry\SentryBundle\Command\SentryTestCommand; use Sentry\SentryBundle\ErrorTypesParser; @@ -14,6 +16,7 @@ use Symfony\Component\Config\FileLocator; use Symfony\Component\Console\ConsoleEvents; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Loader; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\Extension; @@ -47,8 +50,9 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('sentry.listener_priorities.' . $key, $priority); } - $this->tagConsoleErrorListener($container); + $this->configureErrorListener($container, $processedConfiguration); $this->setLegacyVisibilities($container); + $this->configureMonologHandler($container, $processedConfiguration['monolog']); } private function passConfigurationToOptions(ContainerBuilder $container, array $processedConfiguration): void @@ -134,6 +138,17 @@ private function valueToCallable($value) return $value; } + private function configureErrorListener(ContainerBuilder $container, array $processedConfiguration): void + { + if (! $processedConfiguration['register_error_listener']) { + $container->removeDefinition(ErrorListener::class); + + return; + } + + $this->tagConsoleErrorListener($container); + } + /** * BC layer for Symfony < 3.3; see https://symfony.com/blog/new-in-symfony-3-3-better-handling-of-command-exceptions */ @@ -166,9 +181,40 @@ private function setLegacyVisibilities(ContainerBuilder $container): void if (Kernel::VERSION_ID < 30300) { $container->getDefinition(SentryTestCommand::class)->setPublic(true); $container->getDefinition(ConsoleListener::class)->setPublic(true); - $container->getDefinition(ErrorListener::class)->setPublic(true); $container->getDefinition(RequestListener::class)->setPublic(true); $container->getDefinition(SubRequestListener::class)->setPublic(true); + + if ($container->hasDefinition(ErrorListener::class)) { + $container->getDefinition(ErrorListener::class)->setPublic(true); + } } } + + private function configureMonologHandler(ContainerBuilder $container, array $monologConfiguration): void + { + $errorHandler = $monologConfiguration['error_handler']; + + if (! $errorHandler['enabled']) { + $container->removeDefinition(Handler::class); + + return; + } + + if (! class_exists(Handler::class)) { + throw new LogicException( + sprintf('Missing class "%s", try updating "sentry/sentry" to a newer version.', Handler::class) + ); + } + + if (! class_exists(MonologLogger::class)) { + throw new LogicException( + sprintf('You cannot use "%s" if Monolog is not available.', Handler::class) + ); + } + + $container + ->getDefinition(Handler::class) + ->replaceArgument('$level', MonologLogger::toMonologLevel($errorHandler['level'])) + ->replaceArgument('$bubble', $errorHandler['bubble']); + } } diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 28c60c8d..4c2e5f65 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -51,5 +51,11 @@ + + + + + + diff --git a/test/DependencyInjection/ConfigurationTest.php b/test/DependencyInjection/ConfigurationTest.php index 97874a57..2cda4a66 100644 --- a/test/DependencyInjection/ConfigurationTest.php +++ b/test/DependencyInjection/ConfigurationTest.php @@ -48,6 +48,7 @@ public function testConfigurationDefaults(): void $processed = $this->processConfiguration([]); $expectedDefaults = [ 'dsn' => null, + 'register_error_listener' => true, 'listener_priorities' => [ 'request' => 1, 'sub_request' => 1, @@ -67,6 +68,13 @@ public function testConfigurationDefaults(): void 'project_root' => '%kernel.root_dir%/..', 'tags' => [], ], + 'monolog' => [ + 'error_handler' => [ + 'enabled' => false, + 'level' => 'DEBUG', + 'bubble' => true, + ], + ], ]; if (method_exists(Kernel::class, 'getProjectDir')) { diff --git a/test/DependencyInjection/SentryExtensionTest.php b/test/DependencyInjection/SentryExtensionTest.php index e66f5d6a..f8ba499e 100644 --- a/test/DependencyInjection/SentryExtensionTest.php +++ b/test/DependencyInjection/SentryExtensionTest.php @@ -3,16 +3,23 @@ namespace Sentry\SentryBundle\Test\DependencyInjection; use Jean85\PrettyVersions; +use Monolog\Logger as MonologLogger; use Sentry\Breadcrumb; +use Sentry\ClientInterface; use Sentry\Event; use Sentry\Integration\IntegrationInterface; +use Sentry\Monolog\Handler; use Sentry\Options; use Sentry\SentryBundle\DependencyInjection\SentryExtension; +use Sentry\SentryBundle\EventListener\ErrorListener; use Sentry\SentryBundle\Test\BaseTestCase; +use Sentry\Severity; +use Sentry\State\Scope; use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\Container; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Kernel; @@ -20,6 +27,8 @@ class SentryExtensionTest extends BaseTestCase { private const OPTIONS_TEST_PUBLIC_ALIAS = 'sentry.options.public_alias'; + private const ERROR_LISTENER_TEST_PUBLIC_ALIAS = 'sentry.error_listener.public_alias'; + private const MONOLOG_HANDLER_TEST_PUBLIC_ALIAS = 'sentry.monolog_handler.public_alias'; public function testDataProviderIsMappingTheRightNumberOfOptions(): void { @@ -335,6 +344,76 @@ public function testIntegrations(): void $this->assertCount(1, $integrations); } + /** + * @dataProvider errorListenerConfigurationProvider + */ + public function testErrorListenerIsRegistered(bool $registerErrorListener): void + { + $container = $this->getContainer([ + 'register_error_listener' => $registerErrorListener, + ]); + + $this->assertSame($registerErrorListener, $container->has(self::ERROR_LISTENER_TEST_PUBLIC_ALIAS)); + } + + public function errorListenerConfigurationProvider(): array + { + return [ + [true], + [false], + ]; + } + + /** + * @dataProvider monologHandlerConfigurationProvider + */ + public function testMonologHandlerIsConfiguredProperly($level, bool $bubble, int $monologLevel): void + { + $this->expectExceptionIfMonologHandlerDoesNotExist(); + + $container = $this->getContainer([ + 'monolog' => [ + 'error_handler' => [ + 'enabled' => true, + 'level' => $level, + 'bubble' => $bubble, + ], + ], + ]); + + $this->assertTrue($container->has(self::MONOLOG_HANDLER_TEST_PUBLIC_ALIAS)); + + /** @var Handler $handler */ + $handler = $container->get(self::MONOLOG_HANDLER_TEST_PUBLIC_ALIAS); + $this->assertEquals($monologLevel, $handler->getLevel()); + $this->assertEquals($bubble, $handler->getBubble()); + } + + public function monologHandlerConfigurationProvider(): array + { + return [ + ['DEBUG', true, MonologLogger::DEBUG], + ['debug', false, MonologLogger::DEBUG], + ['ERROR', true, MonologLogger::ERROR], + ['error', false, MonologLogger::ERROR], + [MonologLogger::ALERT, true, MonologLogger::ALERT], + [MonologLogger::EMERGENCY, false, MonologLogger::EMERGENCY], + ]; + } + + public function testMonologHandlerIsNotRegistered(): void + { + $container = $this->getContainer([ + 'monolog' => [ + 'error_handler' => [ + 'enabled' => false, + ], + ], + ]); + + $this->assertFalse($container->has(self::MONOLOG_HANDLER_TEST_PUBLIC_ALIAS)); + } + private function getContainer(array $configuration = []): Container { $containerBuilder = new ContainerBuilder(); @@ -363,6 +442,17 @@ private function getContainer(array $configuration = []): Container $extension = new SentryExtension(); $extension->load(['sentry' => $configuration], $containerBuilder); + $client = new Definition(ClientMock::class); + $containerBuilder->setDefinition(ClientInterface::class, $client); + + if ($containerBuilder->hasDefinition(ErrorListener::class)) { + $containerBuilder->setAlias(self::ERROR_LISTENER_TEST_PUBLIC_ALIAS, new Alias(ErrorListener::class, true)); + } + + if ($containerBuilder->hasDefinition(Handler::class)) { + $containerBuilder->setAlias(self::MONOLOG_HANDLER_TEST_PUBLIC_ALIAS, new Alias(Handler::class, true)); + } + $containerBuilder->compile(); return $containerBuilder; @@ -377,6 +467,16 @@ private function getOptionsFrom(Container $container): Options return $options; } + + private function expectExceptionIfMonologHandlerDoesNotExist(): void + { + if (! class_exists(Handler::class)) { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + sprintf('Missing class "%s", try updating "sentry/sentry" to a newer version.', Handler::class) + ); + } + } } function mockBeforeSend(Event $event): ?Event @@ -413,3 +513,36 @@ public function setupOnce(): void { } } + +class ClientMock implements ClientInterface +{ + public function getOptions(): Options + { + return new Options(); + } + + public function captureMessage(string $message, ?Severity $level = null, ?Scope $scope = null): ?string + { + return null; + } + + public function captureException(\Throwable $exception, ?Scope $scope = null): ?string + { + return null; + } + + public function captureLastError(?Scope $scope = null): ?string + { + return null; + } + + public function captureEvent(array $payload, ?Scope $scope = null): ?string + { + return null; + } + + public function getIntegration(string $className): ?IntegrationInterface + { + return null; + } +}