feature #36364 [HttpKernel][WebProfilerBundle] Add session profiling (mtarld)

This PR was merged into the 5.2-dev branch.

Discussion
----------

[HttpKernel][WebProfilerBundle] Add session profiling

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| License       | MIT
| Doc PR        |

This PR proposes to add session profiling.
It provides stateless checking status and session usage backtraces.

Under are screesnhots of provided profiling:
![Screenshot from 2020-04-06 13-42-41](https://user-images.githubusercontent.com/4955509/78581189-d6c32580-7833-11ea-9de5-d1e4f8e60c27.png)
![Screenshot from 2020-04-06 13-43-04](https://user-images.githubusercontent.com/4955509/78581193-d88ce900-7833-11ea-90a4-85d07c64d47e.png)
![Screenshot from 2020-04-06 17-43-17](https://user-images.githubusercontent.com/4955509/78581159-cca12700-7833-11ea-98d2-38306ec9ea37.png)
![Screenshot from 2020-04-06 17-43-35](https://user-images.githubusercontent.com/4955509/78581238-e8a4c880-7833-11ea-89e2-ff4fdea8dce5.png)

Commits
-------

5dbaef8883 Add session profiling
This commit is contained in:
Fabien Potencier 2020-07-01 16:42:25 +02:00
commit 8cc90b9845
10 changed files with 198 additions and 6 deletions

View File

@ -29,9 +29,16 @@ return static function (ContainerConfigurator $container) {
->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])

View File

@ -97,6 +97,7 @@ return static function (ContainerConfigurator $container) {
'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'),
])

View File

@ -504,7 +504,7 @@ abstract class FrameworkExtensionTest extends TestCase
$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 @@ abstract class FrameworkExtensionTest extends TestCase
{
$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()));
}

View File

@ -1,6 +1,11 @@
CHANGELOG
=========
5.2.0
-----
* added session usage
5.0.0
-----

View File

@ -59,6 +59,11 @@
<b>Has session</b>
<span>{% if collector.sessionmetadata|length %}yes{% else %}no{% endif %}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Stateless Check</b>
<span>{% if collector.statelesscheck %}yes{% else %}no{% endif %}</span>
</div>
</div>
{% if redirect_handler is defined -%}
@ -228,7 +233,7 @@
</div>
<div class="tab {{ collector.sessionmetadata is empty ? 'disabled' }}">
<h3 class="tab-title">Session</h3>
<h3 class="tab-title">Session{% if collector.sessionusages is not empty %} <span class="badge">{{ collector.sessionusages|length }}</span>{% endif %}</h3>
<div class="tab-content">
<h3>Session Metadata</h3>
@ -250,6 +255,54 @@
{% else %}
{{ include('@WebProfiler/Profiler/table.html.twig', { data: collector.sessionattributes, labels: ['Attribute', 'Value'] }, with_context = false) }}
{% endif %}
<h3>Session Usage</h3>
<div class="metrics">
<div class="metric">
<span class="value">{{ collector.sessionusages|length }}</span>
<span class="label">Usages</span>
</div>
<div class="metric">
<span class="value">{{ include('@WebProfiler/Icon/' ~ (collector.statelesscheck ? 'yes' : 'no') ~ '.svg') }}</span>
<span class="label">Stateless check enabled</span>
</div>
</div>
{% if collector.sessionusages is empty %}
<div class="empty">
<p>Session not used.</p>
</div>
{% else %}
<table class="session_usages">
<thead>
<tr>
<th class="full-width">Usage</th>
</tr>
</thead>
<tbody>
{% for key, usage in collector.sessionusages %}
<tr>
<td class="font-normal">
{%- set link = usage.file|file_link(usage.line) %}
{%- if link %}<a href="{{ link }}" title="{{ usage.name }}">{% else %}<span title="{{ usage.name }}">{% endif %}
{{ usage.name }}
{%- if link %}</a>{% else %}</span>{% endif %}
<div class="text-small font-normal">
{% set usage_id = 'session-usage-trace-' ~ key %}
<a class="btn btn-link text-small sf-toggle" data-toggle-selector="#{{ usage_id }}" data-toggle-alt-content="Hide trace">Show trace</a>
</div>
<div id="{{ usage_id }}" class="context sf-toggle-content sf-toggle-hidden">
{{ profiler_dump(usage.trace, maxDepth=2) }}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>

View File

@ -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

View File

@ -15,7 +15,10 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
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 @@ use Symfony\Component\HttpKernel\KernelEvents;
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 @@ class RequestDataCollector extends DataCollector implements EventSubscriberInter
'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 @@ class RequestDataCollector extends DataCollector implements EventSubscriberInter
{
$this->data = [];
$this->controllers = new \SplObjectStorage();
$this->sessionUsages = [];
}
public function getMethod()
@ -242,6 +251,16 @@ class RequestDataCollector extends DataCollector implements EventSubscriberInter
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 @@ class RequestDataCollector extends DataCollector implements EventSubscriberInter
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.
*

View File

@ -152,6 +152,10 @@ abstract class AbstractSessionListener implements EventSubscriberInterface
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;
}

View File

@ -17,8 +17,11 @@ use Symfony\Component\HttpFoundation\Cookie;
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 @@ class RequestDataCollectorTest extends TestCase
$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');

View File

@ -20,6 +20,7 @@ use Symfony\Component\HttpFoundation\RequestStack;
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 @@ class SessionListenerTest extends TestCase
$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 @@ class SessionListenerTest extends TestCase
$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();
}