diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/profiling.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/profiling.xml index 166be86b2e203..cc509600e6614 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/profiling.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/profiling.xml @@ -24,6 +24,9 @@ null %profiler_listener.only_exceptions% %profiler_listener.only_master_requests% + + + diff --git a/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php b/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php index fbcdb6782672b..7cd8c12ac7a1b 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php +++ b/src/Symfony/Bundle/WebProfilerBundle/EventListener/WebDebugToolbarListener.php @@ -16,8 +16,11 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Flash\AutoExpireFlashBag; +use Symfony\Component\HttpKernel\DataCollector\DumpDataCollector; +use Symfony\Component\HttpKernel\DataCollector\ExceptionDataCollector; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\HttpKernel\Profiler\ProfileStack; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Twig\Environment; @@ -44,8 +47,9 @@ class WebDebugToolbarListener implements EventSubscriberInterface protected $mode; protected $excludedAjaxPaths; private $cspHandler; + private $profileStack; - public function __construct(Environment $twig, bool $interceptRedirects = false, int $mode = self::ENABLED, UrlGeneratorInterface $urlGenerator = null, string $excludedAjaxPaths = '^/bundles|^/_wdt', ContentSecurityPolicyHandler $cspHandler = null) + public function __construct(Environment $twig, bool $interceptRedirects = false, int $mode = self::ENABLED, UrlGeneratorInterface $urlGenerator = null, string $excludedAjaxPaths = '^/bundles|^/_wdt', ContentSecurityPolicyHandler $cspHandler = null, ProfileStack $profileStack = null) { $this->twig = $twig; $this->urlGenerator = $urlGenerator; @@ -53,6 +57,7 @@ public function __construct(Environment $twig, bool $interceptRedirects = false, $this->mode = $mode; $this->excludedAjaxPaths = $excludedAjaxPaths; $this->cspHandler = $cspHandler; + $this->profileStack = $profileStack; } public function isEnabled() @@ -65,11 +70,38 @@ public function onKernelResponse(FilterResponseEvent $event) $response = $event->getResponse(); $request = $event->getRequest(); - if ($response->headers->has('X-Debug-Token') && null !== $this->urlGenerator) { + $hasProfile = $this->profileStack instanceof ProfileStack ? $this->profileStack->has($request) : $response->headers->has('X-Debug-Token'); + + if ($hasProfile && null !== $this->urlGenerator) { + $panel = null; + + if ($this->profileStack instanceof ProfileStack) { + $profile = $this->profileStack->get($request); + + $token = $profile->getToken(); + + foreach ($profile->getCollectors() as $collector) { + if ($collector instanceof ExceptionDataCollector && $collector->hasException()) { + $panel = $collector->getName(); + + break; + } + + if ($collector instanceof DumpDataCollector && $collector->getDumpsCount() > 0) { + $panel = $collector->getName(); + } + } + } else { + $token = $response->headers->get('X-Debug-Token'); + } + try { $response->headers->set( 'X-Debug-Token-Link', - $this->urlGenerator->generate('_profiler', ['token' => $response->headers->get('X-Debug-Token')], UrlGeneratorInterface::ABSOLUTE_URL) + $this->urlGenerator->generate('_profiler', [ + 'token' => $token, + 'panel' => $panel, + ], UrlGeneratorInterface::ABSOLUTE_URL) ); } catch (\Exception $e) { $response->headers->set('X-Debug-Error', \get_class($e).': '.preg_replace('/\s+/', ' ', $e->getMessage())); @@ -87,7 +119,7 @@ public function onKernelResponse(FilterResponseEvent $event) return; } - if ($response->headers->has('X-Debug-Token') && $response->isRedirect() && $this->interceptRedirects && 'html' === $request->getRequestFormat()) { + if ($hasProfile && $response->isRedirect() && $this->interceptRedirects && 'html' === $request->getRequestFormat()) { if ($request->hasSession() && ($session = $request->getSession())->isStarted() && $session->getFlashBag() instanceof AutoExpireFlashBag) { // keep current flashes for one more request if using AutoExpireFlashBag $session->getFlashBag()->setAll($session->getFlashBag()->peekAll()); @@ -99,7 +131,7 @@ public function onKernelResponse(FilterResponseEvent $event) } if (self::DISABLED === $this->mode - || !$response->headers->has('X-Debug-Token') + || !$hasProfile || $response->isRedirection() || ($response->headers->has('Content-Type') && false === strpos($response->headers->get('Content-Type'), 'html')) || 'html' !== $request->getRequestFormat() diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.xml b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.xml index c38db2056c1a4..3307d2044a375 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.xml +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/config/toolbar.xml @@ -15,6 +15,7 @@ + diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/base_js.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/base_js.html.twig index ecb559caaea56..a53e431ddf313 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/base_js.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/base_js.html.twig @@ -267,7 +267,7 @@ if (request.profilerUrl) { profilerCell.textContent = ''; var profilerLink = document.createElement('a'); - profilerLink.setAttribute('href', request.statusCode < 400 ? request.profilerUrl : request.profilerUrl + '?panel=exception'); + profilerLink.setAttribute('href', request.profilerUrl); profilerLink.textContent = request.profile; profilerCell.appendChild(profilerLink); } diff --git a/src/Symfony/Bundle/WebProfilerBundle/Tests/DependencyInjection/WebProfilerExtensionTest.php b/src/Symfony/Bundle/WebProfilerBundle/Tests/DependencyInjection/WebProfilerExtensionTest.php index c2c953fb843ae..f134c63401877 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Tests/DependencyInjection/WebProfilerExtensionTest.php +++ b/src/Symfony/Bundle/WebProfilerBundle/Tests/DependencyInjection/WebProfilerExtensionTest.php @@ -20,6 +20,7 @@ use Symfony\Component\ErrorRenderer\ErrorRenderer\HtmlErrorRenderer; use Symfony\Component\EventDispatcher\DependencyInjection\RegisterListenersPass; use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\HttpKernel\Profiler\ProfileStack; class WebProfilerExtensionTest extends TestCase { @@ -73,6 +74,7 @@ protected function setUp(): void $this->container->setParameter('data_collector.templates', []); $this->container->set('kernel', $this->kernel); $this->container->addCompilerPass(new RegisterListenersPass()); + $this->container->register('profile_stack', ProfileStack::class); } protected function tearDown(): void diff --git a/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php b/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php index 416f63916f042..d87a6d9420cbd 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php +++ b/src/Symfony/Bundle/WebProfilerBundle/Tests/EventListener/WebDebugToolbarListenerTest.php @@ -16,8 +16,13 @@ use Symfony\Component\HttpFoundation\HeaderBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; +use Symfony\Component\HttpKernel\DataCollector\DumpDataCollector; +use Symfony\Component\HttpKernel\DataCollector\ExceptionDataCollector; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\Profiler\Profile; +use Symfony\Component\HttpKernel\Profiler\ProfileStack; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class WebDebugToolbarListenerTest extends TestCase @@ -243,7 +248,7 @@ public function testXDebugUrlHeader() $urlGenerator ->expects($this->once()) ->method('generate') - ->with('_profiler', ['token' => 'xxxxxxxx'], UrlGeneratorInterface::ABSOLUTE_URL) + ->with('_profiler', ['token' => 'xxxxxxxx', 'panel' => null], UrlGeneratorInterface::ABSOLUTE_URL) ->willReturn('http://mydomain.com/_profiler/xxxxxxxx') ; @@ -264,7 +269,7 @@ public function testThrowingUrlGenerator() $urlGenerator ->expects($this->once()) ->method('generate') - ->with('_profiler', ['token' => 'xxxxxxxx']) + ->with('_profiler', ['token' => 'xxxxxxxx', 'panel' => null]) ->willThrowException(new \Exception('foo')) ; @@ -285,7 +290,7 @@ public function testThrowingErrorCleanup() $urlGenerator ->expects($this->once()) ->method('generate') - ->with('_profiler', ['token' => 'xxxxxxxx']) + ->with('_profiler', ['token' => 'xxxxxxxx', 'panel' => null]) ->willThrowException(new \Exception("This\nmultiline\r\ntabbed text should\tcome out\r on\n \ta single plain\r\nline")) ; @@ -297,6 +302,97 @@ public function testThrowingErrorCleanup() $this->assertEquals('Exception: This multiline tabbed text should come out on a single plain line', $response->headers->get('X-Debug-Error')); } + public function testToolbarIsInjectedWithProfileStack() + { + $response = new Response(''); + + $event = new ResponseEvent($this->getKernelMock(), $request = $this->getRequestMock(), HttpKernelInterface::MASTER_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock(), false, WebDebugToolbarListener::ENABLED, null, '', null, $profileStack = new ProfileStack()); + + $profileStack->set($request, new Profile('foobar')); + + $listener->onKernelResponse($event); + + $this->assertEquals("\nWDT\n", $response->getContent()); + } + + /** + * @dataProvider linksToPanelsProvider + */ + public function testLinksToPanels(DataCollectorInterface $dataCollector, Request $request, Response $response) + { + $urlGenerator = $this->getUrlGeneratorMock(); + $urlGenerator + ->expects($this->once()) + ->method('generate') + ->with('_profiler', ['token' => $token = 'xxxxxx', 'panel' => $dataCollector->getName()], UrlGeneratorInterface::ABSOLUTE_URL) + ->willReturn($expectedLink = 'http://mydomain.com/_profiler/'.$dataCollector->getName()); + + $event = new ResponseEvent($this->getKernelMock(), $request, HttpKernelInterface::MASTER_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock(), false, WebDebugToolbarListener::ENABLED, $urlGenerator, '', null, $profileStack = new ProfileStack()); + + $profileStack->set($request, $profile = new Profile($token)); + $profile->addCollector($dataCollector); + $profile->addCollector($this->createMock(DataCollectorInterface::class)); + + $listener->onKernelResponse($event); + + $this->assertEquals($expectedLink, $response->headers->get('X-Debug-Token-Link')); + } + + public function linksToPanelsProvider() + { + $exceptionDataCollector = new ExceptionDataCollector(); + $exceptionDataCollector->collect($request = new Request(), $response = new Response(), new \DomainException()); + + yield [$exceptionDataCollector, $request, $response]; + + $dumpDataCollector = $this->createMock(DumpDataCollector::class); + $dumpDataCollector + ->expects($this->atLeastOnce()) + ->method('getName') + ->willReturn('dump'); + $dumpDataCollector + ->expects($this->atLeastOnce()) + ->method('getDumpsCount') + ->willReturn(1); + + yield [$dumpDataCollector, new Request(), new Response()]; + } + + public function testLinkToExceptionPanelPriority() + { + $exceptionDataCollector = new ExceptionDataCollector(); + $exceptionDataCollector->collect($request = new Request(), $response = new Response(), new \DomainException()); + + $urlGenerator = $this->getUrlGeneratorMock(); + $urlGenerator + ->expects($this->once()) + ->method('generate') + ->with('_profiler', ['token' => $token = 'xxxxxx', 'panel' => $exceptionDataCollector->getName()], UrlGeneratorInterface::ABSOLUTE_URL) + ->willReturn($expectedLink = 'http://mydomain.com/_profiler/'.$exceptionDataCollector->getName()); + + $event = new ResponseEvent($this->getKernelMock(), $request, HttpKernelInterface::MASTER_REQUEST, $response); + + $listener = new WebDebugToolbarListener($this->getTwigMock(), false, WebDebugToolbarListener::ENABLED, $urlGenerator, '', null, $profileStack = new ProfileStack()); + + $profileStack->set($request, $profile = new Profile($token)); + $profile->addCollector($exceptionDataCollector); + + $dumpDataCollector = $this->createMock(DumpDataCollector::class); + $dumpDataCollector + ->expects($this->never()) + ->method('getDumpsCount'); + + $profile->addCollector($dumpDataCollector); + + $listener->onKernelResponse($event); + + $this->assertEquals($expectedLink, $response->headers->get('X-Debug-Token-Link')); + } + protected function getRequestMock($isXmlHttpRequest = false, $requestFormat = 'html', $hasSession = true) { $request = $this->getMockBuilder('Symfony\Component\HttpFoundation\Request')->setMethods(['getSession', 'isXmlHttpRequest', 'getRequestFormat'])->disableOriginalConstructor()->getMock(); diff --git a/src/Symfony/Component/HttpKernel/EventListener/ProfilerListener.php b/src/Symfony/Component/HttpKernel/EventListener/ProfilerListener.php index f9db55211ea83..d38f7b00c0980 100644 --- a/src/Symfony/Component/HttpKernel/EventListener/ProfilerListener.php +++ b/src/Symfony/Component/HttpKernel/EventListener/ProfilerListener.php @@ -12,13 +12,14 @@ namespace Symfony\Component\HttpKernel\EventListener; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestMatcherInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; -use Symfony\Component\HttpKernel\Event\PostResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\HttpKernel\Profiler\Profiler; +use Symfony\Component\HttpKernel\Profiler\ProfileStack; /** * ProfilerListener collects data for the current request by listening to the kernel events. @@ -37,12 +38,13 @@ class ProfilerListener implements EventSubscriberInterface protected $profiles; protected $requestStack; protected $parents; + private $profileStack; /** * @param bool $onlyException True if the profiler only collects data when an exception occurs, false otherwise * @param bool $onlyMasterRequests True if the profiler only collects data when the request is a master request, false otherwise */ - public function __construct(Profiler $profiler, RequestStack $requestStack, RequestMatcherInterface $matcher = null, bool $onlyException = false, bool $onlyMasterRequests = false) + public function __construct(Profiler $profiler, RequestStack $requestStack, RequestMatcherInterface $matcher = null, bool $onlyException = false, bool $onlyMasterRequests = false, ProfileStack $profileStack = null) { $this->profiler = $profiler; $this->matcher = $matcher; @@ -51,6 +53,7 @@ public function __construct(Profiler $profiler, RequestStack $requestStack, Requ $this->profiles = new \SplObjectStorage(); $this->parents = new \SplObjectStorage(); $this->requestStack = $requestStack; + $this->profileStack = $profileStack; } /** @@ -94,9 +97,13 @@ public function onKernelResponse(FilterResponseEvent $event) $this->profiles[$request] = $profile; $this->parents[$request] = $this->requestStack->getParentRequest(); + + if ($this->profileStack instanceof ProfileStack) { + $this->profileStack->set($request, $profile); + } } - public function onKernelTerminate(PostResponseEvent $event) + public function onKernelTerminate() { // attach children to parents foreach ($this->profiles as $request) { @@ -114,6 +121,10 @@ public function onKernelTerminate(PostResponseEvent $event) $this->profiles = new \SplObjectStorage(); $this->parents = new \SplObjectStorage(); + + if ($this->profileStack instanceof ProfileStack) { + $this->profileStack->reset(); + } } public static function getSubscribedEvents() diff --git a/src/Symfony/Component/HttpKernel/Profiler/ProfileStack.php b/src/Symfony/Component/HttpKernel/Profiler/ProfileStack.php new file mode 100644 index 0000000000000..f2b2ac4ce7677 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Profiler/ProfileStack.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Profiler; + +use Symfony\Component\HttpFoundation\Request; + +/** + * @internal + */ +final class ProfileStack +{ + /** + * @var \SplObjectStorage + */ + private $profiles; + + public function __construct() + { + $this->reset(); + } + + public function has(Request $request): bool + { + return isset($this->profiles[$request]); + } + + public function get(Request $request): Profile + { + try { + return $this->profiles[$request]; + } catch (\UnexpectedValueException $e) { + throw new \InvalidArgumentException('There is no profile in the stack for the passed request.'); + } + } + + public function set(Request $request, Profile $profile): void + { + $this->profiles[$request] = $profile; + } + + public function reset(): void + { + $this->profiles = new \SplObjectStorage(); + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Profiler/ProfileStackTest.php b/src/Symfony/Component/HttpKernel/Tests/Profiler/ProfileStackTest.php new file mode 100644 index 0000000000000..0115a5d173b40 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Profiler/ProfileStackTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\Profiler; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Profiler\Profile; +use Symfony\Component\HttpKernel\Profiler\ProfileStack; + +class ProfileStackTest extends TestCase +{ + public function test() + { + $profileStack = new ProfileStack(); + + $this->assertFalse($profileStack->has($request = new Request())); + + $profileStack->set($request, $profile = new Profile('foo')); + + $this->assertTrue($profileStack->has($request)); + + $this->assertSame($profile, $profileStack->get($request)); + + $profileStack->reset(); + + $this->assertFalse($profileStack->has($request)); + } + + public function testGetWitUnknownRequest() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('There is no profile in the stack for the passed request.'); + + (new ProfileStack())->get(new Request()); + } +}