Skip to content

Commit d244c0c

Browse files
committed
feature #1152 [TwigComponent][WebProfiler] Add profile + StopWatch + WDT (smnandre)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [TwigComponent][WebProfiler] Add profile + StopWatch + WDT Add a DataCollector to add data in the web debug toolpar and a profiler pane. Based on the events (`PreRender` / `PostRender`) triggerd during the request. | Overview | Details | | - | - | | ![overview](https://github.com/symfony/ux/assets/1359581/9237e924-ba3d-47b6-970a-8b8cabbb4e8f) | ![details](https://github.com/symfony/ux/assets/1359581/6f78431e-e4a3-41a0-a0b1-2084b17bce19) | | Anonymous components | Stopwatch + performance | | - | - | | ![anonymous-timing](https://github.com/symfony/ux/assets/1359581/e4899dfc-f716-4164-8377-4f617bf6bde6) | ![perforlmance](https://github.com/symfony/ux/assets/1359581/6356858e-c3fe-426a-ae95-c7475cd192fd) | Commits ------- 28baab1 [TwigComponent][WebProfiler] Add profile + StopWatch + WDT
2 parents 1fcb41f + 28baab1 commit d244c0c

File tree

14 files changed

+869
-3
lines changed

14 files changed

+869
-3
lines changed

src/TwigComponent/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG
22

3+
## 2.13.0
4+
5+
- Add profiler integration: `TwigComponentDataCollector` and debug toolbar templates
6+
37
## 2.12.0
48

59
- Added a `debug:twig-component` command.

src/TwigComponent/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"symfony/framework-bundle": "^5.4|^6.0|^7.0",
4141
"symfony/phpunit-bridge": "^6.0|^7.0",
4242
"symfony/stimulus-bundle": "^2.9.1",
43+
"symfony/stopwatch": "^5.4|^6.0|^7.0",
4344
"symfony/twig-bundle": "^5.4|^6.0|^7.0",
4445
"symfony/webpack-encore-bundle": "^1.15"
4546
},

src/TwigComponent/config/debug.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\DependencyInjection\Loader\Configurator;
13+
14+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
15+
use Symfony\UX\TwigComponent\DataCollector\TwigComponentDataCollector;
16+
use Symfony\UX\TwigComponent\EventListener\TwigComponentLoggerListener;
17+
18+
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
19+
20+
return static function (ContainerConfigurator $container) {
21+
$container->services()
22+
23+
->set('ux.twig_component.component_logger_listener', TwigComponentLoggerListener::class)
24+
->args([
25+
service('debug.stopwatch')->ignoreOnInvalid(),
26+
])
27+
->tag('kernel.event_subscriber')
28+
29+
->set('ux.twig_component.data_collector', TwigComponentDataCollector::class)
30+
->args([
31+
service('ux.twig_component.component_logger_listener'),
32+
service('twig'),
33+
])
34+
->tag('data_collector', [
35+
'template' => '@TwigComponent/Collector/twig_component.html.twig',
36+
'id' => 'twig_component',
37+
'priority' => 256,
38+
]);
39+
};

src/TwigComponent/src/ComponentRenderer.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public function render(MountedComponent $mounted): string
7676
$event->getTemplateIndex(),
7777
)->render($event->getVariables());
7878
} finally {
79-
$this->componentStack->pop();
79+
$mounted = $this->componentStack->pop();
8080

8181
$event = new PostRenderEvent($mounted);
8282
$this->dispatcher->dispatch($event);
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\DataCollector;
13+
14+
use Symfony\Bundle\FrameworkBundle\DataCollector\AbstractDataCollector;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpFoundation\Response;
17+
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
18+
use Symfony\Component\VarDumper\Caster\ClassStub;
19+
use Symfony\Component\VarDumper\Cloner\Data;
20+
use Symfony\UX\TwigComponent\Event\PostRenderEvent;
21+
use Symfony\UX\TwigComponent\Event\PreRenderEvent;
22+
use Symfony\UX\TwigComponent\EventListener\TwigComponentLoggerListener;
23+
use Twig\Environment;
24+
use Twig\Error\LoaderError;
25+
26+
/**
27+
* @author Simon André <[email protected]>
28+
*/
29+
class TwigComponentDataCollector extends AbstractDataCollector implements LateDataCollectorInterface
30+
{
31+
private bool $hasStub;
32+
33+
public function __construct(
34+
private readonly TwigComponentLoggerListener $logger,
35+
private readonly Environment $twig,
36+
) {
37+
$this->hasStub = class_exists(ClassStub::class);
38+
}
39+
40+
public function collect(Request $request, Response $response, \Throwable $exception = null): void
41+
{
42+
}
43+
44+
public function lateCollect(): void
45+
{
46+
$this->collectDataFromLogger();
47+
$this->data = $this->cloneVar($this->data);
48+
}
49+
50+
public function getData(): array|Data
51+
{
52+
return $this->data;
53+
}
54+
55+
public function getName(): string
56+
{
57+
return 'twig_component';
58+
}
59+
60+
public function reset(): void
61+
{
62+
$this->logger->reset();
63+
parent::reset();
64+
}
65+
66+
public function getComponents(): array|Data
67+
{
68+
return $this->data['components'] ?? [];
69+
}
70+
71+
public function getComponentCount(): int
72+
{
73+
return $this->data['component_count'] ?? 0;
74+
}
75+
76+
public function getPeakMemoryUsage(): int
77+
{
78+
return $this->data['peak_memory_usage'] ?? 0;
79+
}
80+
81+
public function getRenders(): array|Data
82+
{
83+
return $this->data['renders'] ?? [];
84+
}
85+
86+
public function getRenderCount(): int
87+
{
88+
return $this->data['render_count'] ?? 0;
89+
}
90+
91+
public function getRenderTime(): int
92+
{
93+
return $this->data['render_time'] ?? 0;
94+
}
95+
96+
private function collectDataFromLogger(): void
97+
{
98+
$components = [];
99+
$renders = [];
100+
$ongoingRenders = [];
101+
102+
foreach ($this->logger->getEvents() as [$event, $profile]) {
103+
if ($event instanceof PreRenderEvent) {
104+
$mountedComponent = $event->getMountedComponent();
105+
106+
$metadata = $event->getMetadata();
107+
$componentName = $metadata->getName();
108+
$componentClass = $mountedComponent->getComponent()::class;
109+
110+
$components[$componentName] ??= [
111+
'name' => $componentName,
112+
'class' => $componentClass,
113+
'class_stub' => $this->hasStub ? new ClassStub($componentClass) : $componentClass,
114+
'template' => $metadata->getTemplate(),
115+
'template_path' => $this->resolveTemplatePath($metadata->getTemplate()), // defer ? lazy ?
116+
'render_count' => 0,
117+
'render_time' => 0,
118+
];
119+
120+
$renderId = spl_object_id($mountedComponent);
121+
$renders[$renderId] = [
122+
'name' => $componentName,
123+
'class' => $componentClass,
124+
'is_embed' => $event->isEmbedded(),
125+
'input_props' => $mountedComponent->getInputProps(),
126+
'attributes' => $mountedComponent->getAttributes()->all(),
127+
'variables' => $event->getVariables(),
128+
'template_index' => $event->getTemplateIndex(),
129+
'component' => $mountedComponent->getComponent(),
130+
'depth' => \count($ongoingRenders),
131+
'children' => [],
132+
'render_start' => $profile[0],
133+
];
134+
135+
if ($parentId = end($ongoingRenders)) {
136+
$renders[$parentId]['children'][] = $renderId;
137+
}
138+
139+
$ongoingRenders[$renderId] = $renderId;
140+
continue;
141+
}
142+
143+
if ($event instanceof PostRenderEvent) {
144+
$mountedComponent = $event->getMountedComponent();
145+
$componentName = $mountedComponent->getName();
146+
$renderId = spl_object_id($mountedComponent);
147+
148+
$renderTime = ($profile[0] - $renders[$renderId]['render_start']) * 1000;
149+
$renders[$renderId] += [
150+
'render_end' => $profile[0],
151+
'render_time' => $renderTime,
152+
'render_memory' => $profile[1],
153+
];
154+
155+
++$components[$componentName]['render_count'];
156+
$components[$componentName]['render_time'] += $renderTime;
157+
158+
unset($ongoingRenders[$renderId]);
159+
}
160+
}
161+
162+
// Sort by render count DESC
163+
uasort($components, fn ($a, $b) => $b['render_count'] <=> $a['render_count']);
164+
165+
$this->data['components'] = $components;
166+
$this->data['component_count'] = \count($components);
167+
168+
$this->data['renders'] = $renders;
169+
$this->data['render_count'] = \count($renders);
170+
$rootRenders = array_filter($renders, fn (array $r) => 0 === $r['depth']);
171+
$this->data['render_time'] = array_sum(array_column($rootRenders, 'render_time'));
172+
173+
$this->data['peak_memory_usage'] = max([0, ...array_column($renders, 'render_memory')]);
174+
}
175+
176+
private function resolveTemplatePath(string $logicalName): ?string
177+
{
178+
try {
179+
$source = $this->twig->getLoader()->getSourceContext($logicalName);
180+
} catch (LoaderError) {
181+
return null;
182+
}
183+
184+
return $source->getPath();
185+
}
186+
}

src/TwigComponent/src/DependencyInjection/TwigComponentExtension.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@
1111

1212
namespace Symfony\UX\TwigComponent\DependencyInjection;
1313

14+
use Symfony\Component\Config\FileLocator;
1415
use Symfony\Component\DependencyInjection\Argument\AbstractArgument;
1516
use Symfony\Component\DependencyInjection\ChildDefinition;
1617
use Symfony\Component\DependencyInjection\ContainerBuilder;
1718
use Symfony\Component\DependencyInjection\Exception\LogicException;
1819
use Symfony\Component\DependencyInjection\Extension\Extension;
20+
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
1921
use Symfony\Component\DependencyInjection\Parameter;
2022
use Symfony\Component\DependencyInjection\Reference;
23+
use Symfony\Component\Stopwatch\Stopwatch;
2124
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
2225
use Symfony\UX\TwigComponent\Command\ComponentDebugCommand;
2326
use Symfony\UX\TwigComponent\ComponentFactory;
@@ -41,6 +44,8 @@ final class TwigComponentExtension extends Extension
4144
{
4245
public function load(array $configs, ContainerBuilder $container): void
4346
{
47+
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../../config'));
48+
4449
if (!isset($container->getParameter('kernel.bundles')['TwigBundle'])) {
4550
throw new LogicException('The TwigBundle is not registered in your application. Try running "composer require symfony/twig-bundle".');
4651
}
@@ -105,5 +110,9 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in %
105110
])
106111
->addTag('console.command')
107112
;
113+
114+
if ($container->getParameter('kernel.debug') && class_exists(Stopwatch::class)) {
115+
$loader->load('debug.php');
116+
}
108117
}
109118
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent\EventListener;
13+
14+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15+
use Symfony\Component\Stopwatch\Stopwatch;
16+
use Symfony\Contracts\Service\ResetInterface;
17+
use Symfony\UX\TwigComponent\Event\PostMountEvent;
18+
use Symfony\UX\TwigComponent\Event\PostRenderEvent;
19+
use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent;
20+
use Symfony\UX\TwigComponent\Event\PreMountEvent;
21+
use Symfony\UX\TwigComponent\Event\PreRenderEvent;
22+
23+
/**
24+
* @author Simon André <[email protected]>
25+
*/
26+
class TwigComponentLoggerListener implements EventSubscriberInterface, ResetInterface
27+
{
28+
private array $events = [];
29+
30+
public function __construct(private ?Stopwatch $stopwatch = null)
31+
{
32+
}
33+
34+
public static function getSubscribedEvents(): array
35+
{
36+
return [
37+
PreCreateForRenderEvent::class => [
38+
// High priority: start the stopwatch as soon as possible
39+
['onPreCreateForRender', 255],
40+
// Low priority: check `event::getRenderedString()` as late as possible
41+
['onPostCreateForRender', -255],
42+
],
43+
PreMountEvent::class => ['onPreMount', 255],
44+
PostMountEvent::class => ['onPostMount', -255],
45+
PreRenderEvent::class => ['onPreRender', 255],
46+
PostRenderEvent::class => ['onPostRender', -255],
47+
];
48+
}
49+
50+
public function getEvents(): array
51+
{
52+
return $this->events;
53+
}
54+
55+
public function onPreCreateForRender(PreCreateForRenderEvent $event): void
56+
{
57+
$this->stopwatch?->start($event->getName(), 'twig_component');
58+
$this->logEvent($event);
59+
}
60+
61+
private function logEvent(object $event): void
62+
{
63+
$this->events[] = [$event, [microtime(true), memory_get_usage(true)]];
64+
}
65+
66+
public function onPostCreateForRender(PreCreateForRenderEvent $event): void
67+
{
68+
if (\is_string($event->getRenderedString())) {
69+
$this->stopwatch?->stop($event->getName());
70+
$this->logEvent($event);
71+
}
72+
}
73+
74+
public function onPreMount(PreMountEvent $event): void
75+
{
76+
$this->logEvent($event);
77+
}
78+
79+
public function onPostMount(PostMountEvent $event): void
80+
{
81+
$this->logEvent($event);
82+
}
83+
84+
public function onPreRender(PreRenderEvent $event): void
85+
{
86+
$this->logEvent($event);
87+
}
88+
89+
public function onPostRender(PostRenderEvent $event): void
90+
{
91+
if ($this->stopwatch?->isStarted($name = $event->getMountedComponent()->getName())) {
92+
$this->stopwatch->stop($name);
93+
}
94+
$this->logEvent($event);
95+
}
96+
97+
public function reset(): void
98+
{
99+
$this->events = [];
100+
}
101+
}
Lines changed: 4 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)