diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/collectors.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/collectors.php index af7e4c1d819a7..010b5bf8fccc6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/collectors.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/collectors.php @@ -29,9 +29,16 @@ ->tag('data_collector', ['template' => '@WebProfiler/Collector/config.html.twig', 'id' => 'config', 'priority' => -255]) ->set('data_collector.request', RequestDataCollector::class) + ->args([ + service('request_stack')->ignoreOnInvalid(), + ]) ->tag('kernel.event_subscriber') ->tag('data_collector', ['template' => '@WebProfiler/Collector/request.html.twig', 'id' => 'request', 'priority' => 335]) + ->set('data_collector.request.session_collector', \Closure::class) + ->factory([\Closure::class, 'fromCallable']) + ->args([[service('data_collector.request'), 'collectSessionUsage']]) + ->set('data_collector.ajax', AjaxDataCollector::class) ->tag('data_collector', ['template' => '@WebProfiler/Collector/ajax.html.twig', 'id' => 'ajax', 'priority' => 315]) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php index 0f5e5de071009..812ee50e7ce81 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/session.php @@ -97,6 +97,7 @@ 'session' => service('session')->ignoreOnInvalid(), 'initialized_session' => service('session')->ignoreOnUninitialized(), 'logger' => service('logger')->ignoreOnInvalid(), + 'session_collector' => service('data_collector.request.session_collector')->ignoreOnInvalid(), ]), param('kernel.debug'), ]) diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index 13912658d286c..5480ec7ecf133 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -504,7 +504,7 @@ public function testNullSessionHandler() $this->assertNull($container->getDefinition('session.storage.native')->getArgument(1)); $this->assertNull($container->getDefinition('session.storage.php_bridge')->getArgument(0)); - $expected = ['session', 'initialized_session', 'logger']; + $expected = ['session', 'initialized_session', 'logger', 'session_collector']; $this->assertEquals($expected, array_keys($container->getDefinition('session_listener')->getArgument(0)->getValues())); } @@ -1312,7 +1312,7 @@ public function testSessionCookieSecureAuto() { $container = $this->createContainerFromFile('session_cookie_secure_auto'); - $expected = ['session', 'initialized_session', 'logger', 'session_storage', 'request_stack']; + $expected = ['session', 'initialized_session', 'logger', 'session_collector', 'session_storage', 'request_stack']; $this->assertEquals($expected, array_keys($container->getDefinition('session_listener')->getArgument(0)->getValues())); } diff --git a/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md b/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md index 5418767fc9e03..028537ead68cd 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.2.0 +----- + + * added session usage + 5.0.0 ----- diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/request.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/request.html.twig index eb5c5595c4cdf..18311c169fece 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/request.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/request.html.twig @@ -59,6 +59,11 @@ Has session {% if collector.sessionmetadata|length %}yes{% else %}no{% endif %} + +
+ Stateless Check + {% if collector.statelesscheck %}yes{% else %}no{% endif %} +
{% if redirect_handler is defined -%} @@ -228,7 +233,7 @@
-

Session

+

Session{% if collector.sessionusages is not empty %} {{ collector.sessionusages|length }}{% endif %}

Session Metadata

@@ -250,6 +255,54 @@ {% else %} {{ include('@WebProfiler/Profiler/table.html.twig', { data: collector.sessionattributes, labels: ['Attribute', 'Value'] }, with_context = false) }} {% endif %} + +

Session Usage

+ +
+
+ {{ collector.sessionusages|length }} + Usages +
+ +
+ {{ include('@WebProfiler/Icon/' ~ (collector.statelesscheck ? 'yes' : 'no') ~ '.svg') }} + Stateless check enabled +
+
+ + {% if collector.sessionusages is empty %} +
+

Session not used.

+
+ {% else %} + + + + + + + + + {% for key, usage in collector.sessionusages %} + + + + {% endfor %} + +
Usage
+ {%- set link = usage.file|file_link(usage.line) %} + {%- if link %}{% else %}{% endif %} + {{ usage.name }} + {%- if link %}{% else %}{% endif %} +
+ {% set usage_id = 'session-usage-trace-' ~ key %} + Show trace +
+
+ {{ profiler_dump(usage.trace, maxDepth=2) }} +
+
+ {% endif %}
diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index 19f8d9f3bde3b..b5024cf0f0be9 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 5.2.0 ----- + * added session usage * made the public `http_cache` service handle requests when available * allowed enabling trusted hosts and proxies using new `kernel.trusted_hosts`, `kernel.trusted_proxies` and `kernel.trusted_headers` parameters diff --git a/src/Symfony/Component/HttpKernel/DataCollector/RequestDataCollector.php b/src/Symfony/Component/HttpKernel/DataCollector/RequestDataCollector.php index b92518d8062b0..3b4063b4a9d92 100644 --- a/src/Symfony/Component/HttpKernel/DataCollector/RequestDataCollector.php +++ b/src/Symfony/Component/HttpKernel/DataCollector/RequestDataCollector.php @@ -15,7 +15,10 @@ use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Session\SessionBagInterface; +use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpKernel\Event\ControllerEvent; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; @@ -28,10 +31,13 @@ class RequestDataCollector extends DataCollector implements EventSubscriberInterface, LateDataCollectorInterface { protected $controllers; + private $sessionUsages = []; + private $requestStack; - public function __construct() + public function __construct(?RequestStack $requestStack = null) { $this->controllers = new \SplObjectStorage(); + $this->requestStack = $requestStack; } /** @@ -105,6 +111,8 @@ public function collect(Request $request, Response $response, \Throwable $except 'response_cookies' => $responseCookies, 'session_metadata' => $sessionMetadata, 'session_attributes' => $sessionAttributes, + 'session_usages' => array_values($this->sessionUsages), + 'stateless_check' => $this->requestStack && $this->requestStack->getMasterRequest()->attributes->get('_stateless', false), 'flashes' => $flashes, 'path_info' => $request->getPathInfo(), 'controller' => 'n/a', @@ -175,6 +183,7 @@ public function reset() { $this->data = []; $this->controllers = new \SplObjectStorage(); + $this->sessionUsages = []; } public function getMethod() @@ -242,6 +251,16 @@ public function getSessionAttributes() return $this->data['session_attributes']->getValue(); } + public function getStatelessCheck() + { + return $this->data['stateless_check']; + } + + public function getSessionUsages() + { + return $this->data['session_usages']; + } + public function getFlashes() { return $this->data['flashes']->getValue(); @@ -382,6 +401,37 @@ public function getName() return 'request'; } + public function collectSessionUsage(): void + { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + + $traceEndIndex = \count($trace) - 1; + for ($i = $traceEndIndex; $i > 0; --$i) { + if (null !== ($class = $trace[$i]['class'] ?? null) && (is_subclass_of($class, SessionInterface::class) || is_subclass_of($class, SessionBagInterface::class))) { + $traceEndIndex = $i; + break; + } + } + + if ((\count($trace) - 1) === $traceEndIndex) { + return; + } + + // Remove part of the backtrace that belongs to session only + array_splice($trace, 0, $traceEndIndex); + + // Merge identical backtraces generated by internal call reports + $name = sprintf('%s:%s', $trace[1]['class'] ?? $trace[0]['file'], $trace[0]['line']); + if (!\array_key_exists($name, $this->sessionUsages)) { + $this->sessionUsages[$name] = [ + 'name' => $name, + 'file' => $trace[0]['file'], + 'line' => $trace[0]['line'], + 'trace' => $trace, + ]; + } + } + /** * Parse a controller. * diff --git a/src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php b/src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php index 1fe3264f7d305..0208e8dec5371 100644 --- a/src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php +++ b/src/Symfony/Component/HttpKernel/EventListener/AbstractSessionListener.php @@ -152,6 +152,10 @@ public function onSessionUsage(): void return; } + if ($this->container && $this->container->has('session_collector')) { + $this->container->get('session_collector')(); + } + if (!$requestStack = $this->container && $this->container->has('request_stack') ? $this->container->get('request_stack') : null) { return; } diff --git a/src/Symfony/Component/HttpKernel/Tests/DataCollector/RequestDataCollectorTest.php b/src/Symfony/Component/HttpKernel/Tests/DataCollector/RequestDataCollectorTest.php index 5753dc88da7bc..b62f765068dc8 100644 --- a/src/Symfony/Component/HttpKernel/Tests/DataCollector/RequestDataCollectorTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/DataCollector/RequestDataCollectorTest.php @@ -17,8 +17,11 @@ use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\SessionBagInterface; +use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface; use Symfony\Component\HttpKernel\DataCollector\RequestDataCollector; @@ -248,6 +251,65 @@ public function testItCollectsTheRedirectionAndClearTheCookie() $this->assertNull($cookie->getValue()); } + public function testItCollectsTheSessionTraceProperly() + { + $collector = new RequestDataCollector(); + $request = $this->createRequest(); + + // RequestDataCollectorTest doesn't implement SessionInterface or SessionBagInterface, therefore should do nothing. + $collector->collectSessionUsage(); + + $collector->collect($request, $this->createResponse()); + $this->assertSame([], $collector->getSessionUsages()); + + $collector->reset(); + + $session = $this->createMock(SessionInterface::class); + $session->method('getMetadataBag')->willReturnCallback(static function () use ($collector) { + $collector->collectSessionUsage(); + }); + $session->getMetadataBag(); + + $collector->collect($request, $this->createResponse()); + $collector->lateCollect(); + + $usages = $collector->getSessionUsages(); + + $this->assertCount(1, $usages); + $this->assertSame(__FILE__, $usages[0]['file']); + $this->assertSame(__LINE__ - 9, $line = $usages[0]['line']); + + $trace = $usages[0]['trace']; + $this->assertSame('getMetadataBag', $trace[0]['function']); + $this->assertSame(self::class, $class = $trace[1]['class']); + + $this->assertSame(sprintf('%s:%s', $class, $line), $usages[0]['name']); + } + + public function testStatelessCheck() + { + $requestStack = new RequestStack(); + $request = $this->createRequest(); + $requestStack->push($request); + + $collector = new RequestDataCollector($requestStack); + $collector->collect($request, $response = $this->createResponse()); + $collector->lateCollect(); + + $this->assertFalse($collector->getStatelessCheck()); + + $requestStack = new RequestStack(); + $request = $this->createRequest(); + $request->attributes->set('_stateless', true); + $requestStack->push($request); + + $collector = new RequestDataCollector($requestStack); + $collector->collect($request, $response = $this->createResponse()); + $collector->lateCollect(); + + $this->assertTrue($collector->getStatelessCheck()); + } + protected function createRequest($routeParams = ['name' => 'foo']) { $request = Request::create('http://test.com/foo?bar=baz'); diff --git a/src/Symfony/Component/HttpKernel/Tests/EventListener/SessionListenerTest.php b/src/Symfony/Component/HttpKernel/Tests/EventListener/SessionListenerTest.php index 8df2ce51698e9..36183d3c138be 100644 --- a/src/Symfony/Component/HttpKernel/Tests/EventListener/SessionListenerTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/EventListener/SessionListenerTest.php @@ -20,6 +20,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage; +use Symfony\Component\HttpKernel\DataCollector\RequestDataCollector; use Symfony\Component\HttpKernel\Event\FinishRequestEvent; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\ResponseEvent; @@ -260,9 +261,13 @@ public function testSessionUsageCallbackWhenDebugAndStateless() $requestStack->push($request); $requestStack->push(new Request()); + $collector = $this->createMock(RequestDataCollector::class); + $collector->expects($this->once())->method('collectSessionUsage'); + $container = new Container(); $container->set('initialized_session', $session); $container->set('request_stack', $requestStack); + $container->set('session_collector', \Closure::fromCallable([$collector, 'collectSessionUsage'])); $this->expectException(UnexpectedSessionUsageException::class); (new SessionListener($container, true))->onSessionUsage(); @@ -277,12 +282,16 @@ public function testSessionUsageCallbackWhenNoDebug() $request = new Request(); $request->attributes->set('_stateless', true); - $requestStack = $this->getMockBuilder(RequestStack::class)->getMock(); - $requestStack->expects($this->never())->method('getMasterRequest')->willReturn($request); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $collector = $this->createMock(RequestDataCollector::class); + $collector->expects($this->never())->method('collectSessionUsage'); $container = new Container(); $container->set('initialized_session', $session); $container->set('request_stack', $requestStack); + $container->set('session_collector', $collector); (new SessionListener($container))->onSessionUsage(); }