diff --git a/README.md b/README.md index c5e7ff90..819690c3 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,6 @@ sentry: dsn: "https://public:secret@sentry.example.com/1" ``` - ## Configuration The following can be configured via ``app/config/config.yml``: @@ -131,6 +130,138 @@ sentry: error_types: E_ALL & ~E_DEPRECATED & ~E_NOTICE ``` + +## Customization + +It is possible to customize the configuration of the user context, as well +as modify the client immediately before an exception is captured by wiring +up an event subscriber to the events that are emitted by the default +configured `ExceptionListener` (alternatively, you can also just defined +your own custom exception listener). + +### Create a Custom ExceptionListener + +You can always replace the default `ExceptionListener` with your own custom +listener. To do this, assign a different class to the `exception_listener` +property in your Sentry configuration, e.g.: + +```yaml +sentry: + exception_listener: AppBundle\EventListener\MySentryExceptionListener +``` + +... and then define the custom `ExceptionListener`, e.g.: + +```php +// src/AppBundle/EventSubscriber/MySentryEventListener.php +namespace AppBundle\EventSubscriber; + +use Sentry\SentryBundle\EventListener\SentryExceptionListenerInterface; +use Symfony\Component\Console\Event\ConsoleExceptionEvent; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpKernel\Event\GetResponseEvent; +use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; + +class MySentryExceptionListener implements SentryExceptionListenerInterface +{ + // ... + + public function __construct(TokenStorageInterface $tokenStorage = null, AuthorizationCheckerInterface $authorizationChecker = null, \Raven_Client $client = null, array $skipCapture, EventDispatcherInterface $dispatcher = null) + { + // ... + } + + public function onKernelRequest(GetResponseEvent $event) + { + // ... + } + + public function onKernelException(GetResponseForExceptionEvent $event) + { + // ... + } + + public function onConsoleException(ConsoleExceptionEvent $event) + { + // ... + } +} +``` + +As a side note, while the above demonstrates a custom exception listener that +does not extend anything you could choose to extend the default +`ExceptionListener` and only override the functionality that you want to. + +### Add an EventSubscriber for Sentry Events + +Create a new class, e.g. `MySentryEventSubscriber`: + +```php +// src/AppBundle/EventSubscriber/MySentryEventListener.php +namespace AppBundle\EventSubscriber; + +use Sentry\SentryBundle\Event\SentryUserContextEvent; +use Sentry\SentryBundle\SentrySymfonyEvents; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +class MySentryEventSubscriber implements EventSubscriberInterface +{ + /** @var \Raven_Client */ + protected $client; + + public function __construct(\Raven_Client $client) + { + $this->client = $client; + } + + public static function getSubscribedEvents() + { + // return the subscribed events, their methods and priorities + return array( + SentrySymfonyEvents::PRE_CAPTURE => 'onPreCapture', + SentrySymfonyEvents::SET_USER_CONTEXT => 'onSetUserContext' + ); + } + + public function onSetUserContext(SentryUserContextEvent $event) + { + // ... + } + + public function onPreCapture(Event $event) + { + if ($event instanceof GetResponseForExceptionEvent) { + // ... + } + elseif ($event instanceof ConsoleExceptionEvent) { + // ... + } + } +} +``` + +In the example above, if you subscribe to the `PRE_CAPTURE` event you may +get an event object that caters more toward a response to a web request (e.g. +`GetResponseForExceptionEvent`) or one for actions taken at the command line +(e.g. `ConsoleExceptionEvent`). Depending on what and how the code was +invoked, and whether or not you need to distinguish between these events +during pre-capture, it might be best to test for the type of the event (as is +demonstrated above) before you do any relevant processing of the object. + +To configure the above add the following configuration to your services +definitions: + +```yaml +app.my_sentry_event_subscriber: + class: AppBundle\EventSubscriber\MySentryEventSubscriber + arguments: + - '@sentry.client' + tags: + - { name: kernel.event_subscriber } +``` + [Last stable image]: https://poser.pugx.org/sentry/sentry-symfony/version.svg [Last unstable image]: https://poser.pugx.org/sentry/sentry-symfony/v/unstable.svg [Master build image]: https://travis-ci.org/getsentry/sentry-symfony.svg?branch=master diff --git a/src/Sentry/SentryBundle/DependencyInjection/Configuration.php b/src/Sentry/SentryBundle/DependencyInjection/Configuration.php index 8ce1764e..d12176d1 100644 --- a/src/Sentry/SentryBundle/DependencyInjection/Configuration.php +++ b/src/Sentry/SentryBundle/DependencyInjection/Configuration.php @@ -44,6 +44,10 @@ public function getConfigTreeBuilder() ->end() ->scalarNode('exception_listener') ->defaultValue('Sentry\SentryBundle\EventListener\ExceptionListener') + ->validate() + ->ifTrue($this->getExceptionListenerInvalidationClosure()) + ->thenInvalid('The "sentry.exception_listener" parameter should be a FQCN of a class implementing the SentryExceptionListenerInterface interface') + ->end() ->end() ->arrayNode('skip_capture') ->treatNullLike(array()) @@ -72,4 +76,18 @@ public function getConfigTreeBuilder() return $treeBuilder; } + + /** + * @return \Closure + */ + private function getExceptionListenerInvalidationClosure() + { + return function ($value) { + $implements = class_implements($value); + if ($implements === false) { + return true; + } + return !in_array('Sentry\SentryBundle\EventListener\SentryExceptionListenerInterface', $implements, true); + }; + } } diff --git a/src/Sentry/SentryBundle/Event/SentryUserContextEvent.php b/src/Sentry/SentryBundle/Event/SentryUserContextEvent.php new file mode 100644 index 00000000..ff9fd255 --- /dev/null +++ b/src/Sentry/SentryBundle/Event/SentryUserContextEvent.php @@ -0,0 +1,21 @@ +authenticationToken = $authenticationToken; + } + + public function getAuthenticationToken() + { + return $this->authenticationToken; + } +} diff --git a/src/Sentry/SentryBundle/EventListener/ExceptionListener.php b/src/Sentry/SentryBundle/EventListener/ExceptionListener.php index 03a9bde1..5533b187 100644 --- a/src/Sentry/SentryBundle/EventListener/ExceptionListener.php +++ b/src/Sentry/SentryBundle/EventListener/ExceptionListener.php @@ -3,7 +3,11 @@ namespace Sentry\SentryBundle\EventListener; use Sentry\SentryBundle; +use Sentry\SentryBundle\Event\SentryUserContextEvent; use Sentry\SentryBundle\SentrySymfonyClient; +use Sentry\SentryBundle\SentrySymfonyEvents; +use Symfony\Component\Console\Event\ConsoleExceptionEvent; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpKernel\Event\GetResponseEvent; use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -11,13 +15,12 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; use Symfony\Component\Security\Core\User\UserInterface; -use Symfony\Component\Console\Event\ConsoleExceptionEvent; /** * Class ExceptionListener * @package Sentry\SentryBundle\EventListener */ -class ExceptionListener +class ExceptionListener implements SentryExceptionListenerInterface { /** @var TokenStorageInterface */ private $tokenStorage; @@ -28,6 +31,9 @@ class ExceptionListener /** @var \Raven_Client */ protected $client; + /** @var EventDispatcherInterface */ + protected $eventDispatcher; + /** @var string[] */ protected $skipCapture; @@ -37,12 +43,14 @@ class ExceptionListener * @param AuthorizationCheckerInterface $authorizationChecker * @param \Raven_Client $client * @param array $skipCapture + * @param EventDispatcherInterface $dispatcher */ public function __construct( TokenStorageInterface $tokenStorage = null, AuthorizationCheckerInterface $authorizationChecker = null, \Raven_Client $client = null, - array $skipCapture + array $skipCapture, + EventDispatcherInterface $dispatcher ) { if (!$client) { $client = new SentrySymfonyClient(); @@ -50,6 +58,7 @@ public function __construct( $this->tokenStorage = $tokenStorage; $this->authorizationChecker = $authorizationChecker; + $this->dispatcher = $dispatcher; $this->client = $client; $this->skipCapture = $skipCapture; } @@ -81,6 +90,9 @@ public function onKernelRequest(GetResponseEvent $event) if (null !== $token && $this->authorizationChecker->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_REMEMBERED)) { $this->setUserValue($token->getUser()); + + $contextEvent = new SentryUserContextEvent($token); + $this->dispatcher->dispatch(SentrySymfonyEvents::SET_USER_CONTEXT, $contextEvent); } } @@ -90,11 +102,12 @@ public function onKernelRequest(GetResponseEvent $event) public function onKernelException(GetResponseForExceptionEvent $event) { $exception = $event->getException(); - + if ($this->shouldExceptionCaptureBeSkipped($exception)) { return; } + $this->dispatcher->dispatch(SentrySymfonyEvents::PRE_CAPTURE, $event); $this->client->captureException($exception); } @@ -105,7 +118,7 @@ public function onConsoleException(ConsoleExceptionEvent $event) { $command = $event->getCommand(); $exception = $event->getException(); - + if ($this->shouldExceptionCaptureBeSkipped($exception)) { return; } @@ -117,9 +130,10 @@ public function onConsoleException(ConsoleExceptionEvent $event) ), ); + $this->dispatcher->dispatch(SentrySymfonyEvents::PRE_CAPTURE, $event); $this->client->captureException($exception, $data); } - + private function shouldExceptionCaptureBeSkipped(\Exception $exception) { foreach ($this->skipCapture as $className) { diff --git a/src/Sentry/SentryBundle/EventListener/SentryExceptionListenerInterface.php b/src/Sentry/SentryBundle/EventListener/SentryExceptionListenerInterface.php new file mode 100644 index 00000000..4035c405 --- /dev/null +++ b/src/Sentry/SentryBundle/EventListener/SentryExceptionListenerInterface.php @@ -0,0 +1,38 @@ +getContainer(array( + static::CONFIG_ROOT => array( + 'exception_listener' => $class, + ), + )); + } + public function test_that_it_uses_defined_class_as_exception_listener_class_by_default() { $container = $this->getContainer(); @@ -145,14 +158,15 @@ public function test_that_it_uses_defined_class_as_exception_listener_class_by_d public function test_that_it_uses_exception_listener_value() { + $class = 'Sentry\SentryBundle\Test\Fixtures\CustomExceptionListener'; $container = $this->getContainer(array( static::CONFIG_ROOT => array( - 'exception_listener' => 'exceptionListenerClass', + 'exception_listener' => $class, ), )); $this->assertSame( - 'exceptionListenerClass', + $class, $container->getParameter('sentry.exception_listener') ); } @@ -274,6 +288,12 @@ private function getContainer(array $options = array()) $containerBuilder->setParameter('kernel.root_dir', 'kernel/root'); $containerBuilder->setParameter('kernel.environment', 'test'); + $mockEventDispatcher = $this + ->getMock('Symfony\Component\EventDispatcher\EventDispatcherInterface') + ; + + $containerBuilder->set('event_dispatcher', $mockEventDispatcher); + $extension = new SentryExtension(); $extension->load($options, $containerBuilder); diff --git a/test/Sentry/SentryBundle/Test/EventListener/ExceptionListenerTest.php b/test/Sentry/SentryBundle/Test/EventListener/ExceptionListenerTest.php index b3659670..6be07701 100644 --- a/test/Sentry/SentryBundle/Test/EventListener/ExceptionListenerTest.php +++ b/test/Sentry/SentryBundle/Test/EventListener/ExceptionListenerTest.php @@ -2,6 +2,7 @@ namespace Sentry\SentryBundle\Test\EventListener; +use Sentry\SentryBundle\SentrySymfonyEvents; use Sentry\SentryBundle\DependencyInjection\SentryExtension; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -47,6 +48,10 @@ public function setUp() ->getMock() ; + $this->mockEventDispatcher = $this + ->getMock('Symfony\Component\EventDispatcher\EventDispatcherInterface') + ; + $containerBuilder = new ContainerBuilder(); $containerBuilder->setParameter('kernel.root_dir', 'kernel/root'); $containerBuilder->setParameter('kernel.environment', 'test'); @@ -54,6 +59,7 @@ public function setUp() $containerBuilder->set('security.token_storage', $this->mockTokenStorage); $containerBuilder->set('security.authorization_checker', $this->mockAuthorizationChecker); $containerBuilder->set('sentry.client', $this->mockSentryClient); + $containerBuilder->set('event_dispatcher', $this->mockEventDispatcher); $extension = new SentryExtension(); $extension->load(array(), $containerBuilder); @@ -90,6 +96,13 @@ public function test_that_user_data_is_not_set_on_subrequest() ->withAnyParameters() ; + $this + ->mockEventDispatcher + ->expects($this->never()) + ->method('dispatch') + ->withAnyParameters() + ; + $this->containerBuilder->compile(); $listener = $this->containerBuilder->get('sentry.exception_listener'); $listener->onKernelRequest($mockEvent); @@ -118,6 +131,13 @@ public function test_that_user_data_is_not_set_if_token_storage_not_present() ->withAnyParameters() ; + $this + ->mockEventDispatcher + ->expects($this->never()) + ->method('dispatch') + ->withAnyParameters() + ; + $this->assertFalse($this->containerBuilder->has('security.token_storage')); $this->containerBuilder->compile(); @@ -149,6 +169,13 @@ public function test_that_user_data_is_not_set_if_authorization_checker_not_pres ->withAnyParameters() ; + $this + ->mockEventDispatcher + ->expects($this->never()) + ->method('dispatch') + ->withAnyParameters() + ; + $this->containerBuilder->compile(); $this->assertFalse($this->containerBuilder->has('security.authorization_checker')); @@ -193,6 +220,13 @@ public function test_that_user_data_is_not_set_if_token_not_present() ->withAnyParameters() ; + $this + ->mockEventDispatcher + ->expects($this->never()) + ->method('dispatch') + ->withAnyParameters() + ; + $this->containerBuilder->compile(); $listener = $this->containerBuilder->get('sentry.exception_listener'); $listener->onKernelRequest($mockEvent); @@ -241,6 +275,13 @@ public function test_that_user_data_is_not_set_if_not_authorized() ->withAnyParameters() ; + $this + ->mockEventDispatcher + ->expects($this->never()) + ->method('dispatch') + ->withAnyParameters() + ; + $this->containerBuilder->compile(); $listener = $this->containerBuilder->get('sentry.exception_listener'); $listener->onKernelRequest($mockEvent); @@ -289,6 +330,15 @@ public function test_that_username_is_set_from_user_interface_if_token_present_a ->with($this->identicalTo('username')) ; + $this + ->mockEventDispatcher + ->expects($this->once()) + ->method('dispatch') + ->with( + $this->identicalTo(SentrySymfonyEvents::SET_USER_CONTEXT), + $this->isInstanceOf('Sentry\SentryBundle\Event\SentryUserContextEvent')) + ; + $this->containerBuilder->compile(); $listener = $this->containerBuilder->get('sentry.exception_listener'); $listener->onKernelRequest($mockEvent); @@ -334,6 +384,15 @@ public function test_that_username_is_set_from_user_interface_if_token_present_a ->with($this->identicalTo('some_user')) ; + $this + ->mockEventDispatcher + ->expects($this->once()) + ->method('dispatch') + ->with( + $this->identicalTo(SentrySymfonyEvents::SET_USER_CONTEXT), + $this->isInstanceOf('Sentry\SentryBundle\Event\SentryUserContextEvent')) + ; + $this->containerBuilder->compile(); $listener = $this->containerBuilder->get('sentry.exception_listener'); $listener->onKernelRequest($mockEvent); @@ -390,6 +449,15 @@ public function test_that_username_is_set_from_user_interface_if_token_present_a ->with($this->identicalTo('std_user')) ; + $this + ->mockEventDispatcher + ->expects($this->once()) + ->method('dispatch') + ->with( + $this->identicalTo(SentrySymfonyEvents::SET_USER_CONTEXT), + $this->isInstanceOf('Sentry\SentryBundle\Event\SentryUserContextEvent')) + ; + $this->containerBuilder->compile(); $listener = $this->containerBuilder->get('sentry.exception_listener'); $listener->onKernelRequest($mockEvent); @@ -411,6 +479,13 @@ public function test_that_it_does_not_report_http_exception_if_included_in_captu ->willReturn($mockException) ; + $this + ->mockEventDispatcher + ->expects($this->never()) + ->method('dispatch') + ->withAnyParameters() + ; + $this ->mockSentryClient ->expects($this->never()) @@ -439,6 +514,13 @@ public function test_that_it_captures_exception() ->willReturn($reportableException) ; + $this + ->mockEventDispatcher + ->expects($this->once()) + ->method('dispatch') + ->with($this->identicalTo(SentrySymfonyEvents::PRE_CAPTURE), $this->identicalTo($mockEvent)) + ; + $this ->mockSentryClient ->expects($this->once()) @@ -491,6 +573,13 @@ public function test_that_it_captures_console_exception() ->willReturn($mockCommand) ; + $this + ->mockEventDispatcher + ->expects($this->once()) + ->method('dispatch') + ->with($this->identicalTo(SentrySymfonyEvents::PRE_CAPTURE), $this->identicalTo($mockEvent)) + ; + $this ->mockSentryClient ->expects($this->once()) @@ -533,6 +622,13 @@ public function test_that_it_can_replace_client() ->willReturn($reportableException) ; + $this + ->mockEventDispatcher + ->expects($this->once()) + ->method('dispatch') + ->with($this->identicalTo(SentrySymfonyEvents::PRE_CAPTURE), $this->identicalTo($mockEvent)) + ; + $this ->mockSentryClient ->expects($this->never()) diff --git a/test/Sentry/SentryBundle/Test/Fixtures/CustomExceptionListener.php b/test/Sentry/SentryBundle/Test/Fixtures/CustomExceptionListener.php new file mode 100644 index 00000000..31f285e3 --- /dev/null +++ b/test/Sentry/SentryBundle/Test/Fixtures/CustomExceptionListener.php @@ -0,0 +1,12 @@ +ExceptionListener for testing. + * + * This exception listener does not implement, or extend a class that + * implements, the appropriate interface that the dependency injection + * container requires. + * + * @package Sentry\SentryBundle\Tests\Fixtures + */ +class InvalidExceptionListener +{ +}