Skip to content

Commit eea755d

Browse files
authored
feat: add cache and webvitals tabs to inspector and improve ux with dragable overlay (#127)
* feat: add cache and webvitals tabs to inspector and improve overlay ux * refactor: improve cache info retrieval and metrics rendering logic * feat: add resource type stats calculation and improve rendering logic
1 parent 22b8742 commit eea755d

5 files changed

Lines changed: 1809 additions & 323 deletions

File tree

src/Model/TemplateEngine/Decorator/InspectorHints.php

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Magento\Framework\View\Element\AbstractBlock;
99
use Magento\Framework\View\Element\BlockInterface;
1010
use Magento\Framework\View\TemplateEngineInterface;
11+
use OpenForgeProject\MageForge\Service\Inspector\Cache\BlockCacheCollector;
1112

1213
/**
1314
* Decorates block with inspector data attributes for frontend debugging
@@ -22,11 +23,13 @@ class InspectorHints implements TemplateEngineInterface
2223
* @param TemplateEngineInterface $subject
2324
* @param bool $showBlockHints
2425
* @param Random $random
26+
* @param BlockCacheCollector $cacheCollector
2527
*/
2628
public function __construct(
2729
private readonly TemplateEngineInterface $subject,
2830
private readonly bool $showBlockHints,
29-
private readonly Random $random
31+
private readonly Random $random,
32+
private readonly BlockCacheCollector $cacheCollector
3033
) {
3134
// Get Magento root directory - try multiple strategies
3235
// 1. Try from BP constant (most reliable)
@@ -49,7 +52,10 @@ public function __construct(
4952
*/
5053
public function render(BlockInterface $block, $templateFile, array $dictionary = []): string
5154
{
55+
// Measure render time
56+
$startTime = hrtime(true);
5257
$result = $this->subject->render($block, $templateFile, $dictionary);
58+
$endTime = hrtime(true);
5359

5460
if (!$this->showBlockHints) {
5561
return $result;
@@ -60,7 +66,17 @@ public function render(BlockInterface $block, $templateFile, array $dictionary =
6066
return $result;
6167
}
6268

63-
return $this->injectInspectorAttributes($result, $block, $templateFile);
69+
// Calculate render time in milliseconds
70+
$renderTimeNs = $endTime - $startTime;
71+
$renderTimeMs = $renderTimeNs / 1_000_000;
72+
73+
$renderMetrics = [
74+
'renderTimeMs' => round($renderTimeMs, 2),
75+
'startTime' => $startTime,
76+
'endTime' => $endTime,
77+
];
78+
79+
return $this->injectInspectorAttributes($result, $block, $templateFile, $renderMetrics);
6480
}
6581

6682
/**
@@ -69,10 +85,15 @@ public function render(BlockInterface $block, $templateFile, array $dictionary =
6985
* @param string $html
7086
* @param BlockInterface $block
7187
* @param string $templateFile
88+
* @param array{renderTimeMs: float, startTime: int, endTime: int} $renderMetrics
7289
* @return string
7390
*/
74-
private function injectInspectorAttributes(string $html, BlockInterface $block, string $templateFile): string
75-
{
91+
private function injectInspectorAttributes(
92+
string $html,
93+
BlockInterface $block,
94+
string $templateFile,
95+
array $renderMetrics
96+
): string {
7697
$wrapperId = 'mageforge-' . $this->random->getRandomString(16);
7798

7899
// Get block class name
@@ -90,6 +111,10 @@ private function injectInspectorAttributes(string $html, BlockInterface $block,
90111
$blockAlias = $this->getBlockAlias($block);
91112
$isOverride = $this->isTemplateOverride($templateFile, $moduleName) ? '1' : '0';
92113

114+
// Collect performance and cache metrics
115+
$cacheMetrics = $this->cacheCollector->getCacheInfo($block);
116+
$formattedMetrics = $this->cacheCollector->formatMetricsForJson($renderMetrics, $cacheMetrics);
117+
93118
// Build metadata as JSON
94119
$metadata = [
95120
'id' => $wrapperId,
@@ -100,6 +125,8 @@ private function injectInspectorAttributes(string $html, BlockInterface $block,
100125
'parent' => $parentBlock,
101126
'alias' => $blockAlias,
102127
'override' => $isOverride,
128+
'performance' => $formattedMetrics['performance'],
129+
'cache' => $formattedMetrics['cache'],
103130
];
104131

105132
// JSON encode with proper escaping for HTML comments
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenForgeProject\MageForge\Service\Inspector\Cache;
6+
7+
use Magento\Framework\View\Element\BlockInterface;
8+
use Magento\Framework\View\LayoutInterface;
9+
10+
/**
11+
* Collects performance metrics from Magento blocks for Inspector
12+
*
13+
* Measures render time and extracts cache configuration with strict type safety (PHPStan Level 8).
14+
*
15+
* @package OpenForgeProject\MageForge
16+
*/
17+
class BlockCacheCollector
18+
{
19+
/**
20+
* @param LayoutInterface $layout
21+
*/
22+
public function __construct(
23+
private readonly LayoutInterface $layout
24+
) {
25+
}
26+
/**
27+
* Get cache information from block
28+
*
29+
* Safely extracts cache lifetime, key, and tags with explicit type checking
30+
* to satisfy PHPStan Level 8 requirements.
31+
*
32+
* @param BlockInterface $block
33+
* @return array{cacheable: bool, lifetime: int|null, cacheKey: string, cacheTags: array<int, string>, pageCacheable: bool}
34+
*/
35+
public function getCacheInfo(BlockInterface $block): array
36+
{
37+
$lifetime = $this->resolveCacheLifetime($block);
38+
$cacheable = $lifetime !== false;
39+
40+
if ($cacheable && $this->isBlockScopePrivate($block)) {
41+
$cacheable = false;
42+
$lifetime = null;
43+
}
44+
45+
$cacheKey = $this->resolveCacheKey($block);
46+
$cacheTags = $this->resolveCacheTags($block);
47+
48+
// Check if page itself is cacheable
49+
$pageCacheable = $this->isPageCacheable();
50+
51+
return [
52+
'cacheable' => $cacheable,
53+
'lifetime' => $lifetime === false ? null : $lifetime,
54+
'cacheKey' => $cacheKey,
55+
'cacheTags' => $cacheTags,
56+
'pageCacheable' => $pageCacheable,
57+
];
58+
}
59+
60+
/**
61+
* Resolve cache lifetime from block
62+
*
63+
* @param BlockInterface $block
64+
* @return int|null|false False if not cacheable, null for unlimited, int for specific lifetime
65+
*/
66+
private function resolveCacheLifetime(BlockInterface $block): int|null|false
67+
{
68+
if (!method_exists($block, 'getCacheLifetime')) {
69+
return false;
70+
}
71+
72+
$lifetimeRaw = $block->getCacheLifetime();
73+
74+
// In Magento:
75+
// - false = not cacheable
76+
// - null = unlimited cache (cacheable!)
77+
// - int = specific cache lifetime in seconds (cacheable!)
78+
79+
if ($lifetimeRaw === false) {
80+
return false;
81+
}
82+
83+
if (is_int($lifetimeRaw)) {
84+
return $lifetimeRaw;
85+
}
86+
87+
if ($lifetimeRaw === null) {
88+
return null; // Unlimited
89+
}
90+
91+
if (is_numeric($lifetimeRaw) && (int)$lifetimeRaw === 0) {
92+
return null; // Unlimited
93+
}
94+
95+
return false; // Default fallback
96+
}
97+
98+
/**
99+
* Check if block is private (customer specific)
100+
*
101+
* @param BlockInterface $block
102+
* @return bool
103+
*/
104+
private function isBlockScopePrivate(BlockInterface $block): bool
105+
{
106+
// Private blocks (like checkout, customer account) should not be cached
107+
if (method_exists($block, 'isScopePrivate')) {
108+
if ($block->isScopePrivate()) {
109+
return true;
110+
}
111+
}
112+
113+
// Additional fallback: Check protected property via reflection if available
114+
if (property_exists($block, '_isScopePrivate')) {
115+
try {
116+
$reflection = new \ReflectionProperty($block, '_isScopePrivate');
117+
$reflection->setAccessible(true);
118+
$isScopePrivate = $reflection->getValue($block);
119+
if ($isScopePrivate === true) {
120+
return true;
121+
}
122+
} catch (\ReflectionException $e) {
123+
// If reflection fails, assume not private
124+
}
125+
}
126+
127+
return false;
128+
}
129+
130+
/**
131+
* Resolve cache key from block
132+
*
133+
* @param BlockInterface $block
134+
* @return string
135+
*/
136+
private function resolveCacheKey(BlockInterface $block): string
137+
{
138+
if (method_exists($block, 'getCacheKey')) {
139+
$keyRaw = $block->getCacheKey();
140+
return is_string($keyRaw) && $keyRaw !== '' ? $keyRaw : '';
141+
}
142+
return '';
143+
}
144+
145+
/**
146+
* Resolve cache tags from block
147+
*
148+
* @param BlockInterface $block
149+
* @return array<int, string>
150+
*/
151+
private function resolveCacheTags(BlockInterface $block): array
152+
{
153+
$cacheTags = [];
154+
if (method_exists($block, 'getCacheTags')) {
155+
$tagsRaw = $block->getCacheTags();
156+
// Ensure string array (PHPStan strict)
157+
if (is_array($tagsRaw)) {
158+
foreach ($tagsRaw as $tag) {
159+
if (is_string($tag)) {
160+
$cacheTags[] = $tag;
161+
}
162+
}
163+
}
164+
}
165+
return $cacheTags;
166+
}
167+
168+
/**
169+
* Check if current page is cacheable
170+
*
171+
* Checks layout configuration to determine if page has cacheable="false" attribute.
172+
* If ANY block on the page is marked as non-cacheable in layout XML, the entire page is non-cacheable.
173+
*
174+
* @return bool True if page is cacheable, false otherwise
175+
*/
176+
private function isPageCacheable(): bool
177+
{
178+
try {
179+
// Get all blocks from layout
180+
$allBlocks = $this->layout->getAllBlocks();
181+
182+
foreach ($allBlocks as $block) {
183+
// Check if block has isCacheable method (added by layout processor)
184+
if (method_exists($block, 'isCacheable')) {
185+
// @phpstan-ignore-next-line
186+
if (!$block->isCacheable()) {
187+
return false;
188+
}
189+
}
190+
191+
// Check data key 'cacheable' set by layout XML
192+
if (method_exists($block, 'getData')) {
193+
// @phpstan-ignore-next-line
194+
$cacheableData = $block->getData('cacheable');
195+
if ($cacheableData === false || $cacheableData === 'false') {
196+
return false;
197+
}
198+
}
199+
}
200+
201+
return true;
202+
} catch (\Exception $e) {
203+
// If we can't determine, assume cacheable to avoid false alarms
204+
return true;
205+
}
206+
}
207+
208+
/**
209+
* Format metrics for JSON export to frontend
210+
*
211+
* @param array{renderTimeMs: float, startTime: int, endTime: int} $renderMetrics
212+
* @param array{cacheable: bool, lifetime: int|null, cacheKey: string, cacheTags: array<int, string>, pageCacheable: bool} $cacheMetrics
213+
* @return array{performance: array{renderTime: string, timestamp: int}, cache: array{cacheable: bool, lifetime: int|null, key: string, tags: array<int, string>, pageCacheable: bool}}
214+
*/
215+
public function formatMetricsForJson(array $renderMetrics, array $cacheMetrics): array
216+
{
217+
return [
218+
'performance' => [
219+
'renderTime' => number_format($renderMetrics['renderTimeMs'], 2),
220+
'timestamp' => (int)($renderMetrics['startTime'] / 1_000_000_000), // Convert ns to seconds
221+
],
222+
'cache' => [
223+
'cacheable' => $cacheMetrics['cacheable'],
224+
'lifetime' => $cacheMetrics['lifetime'],
225+
'key' => $cacheMetrics['cacheKey'],
226+
'tags' => $cacheMetrics['cacheTags'],
227+
'pageCacheable' => $cacheMetrics['pageCacheable'],
228+
],
229+
];
230+
}
231+
}

src/etc/frontend/di.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@
33
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
44
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"
55
>
6+
<!-- Performance Collector Service -->
7+
<type name="OpenForgeProject\MageForge\Service\Inspector\Cache\BlockCacheCollector">
8+
<!-- No dependencies needed -->
9+
</type>
10+
11+
<!-- Inspector Hints Decorator with Performance Collector -->
12+
<type name="OpenForgeProject\MageForge\Model\TemplateEngine\Decorator\InspectorHints">
13+
<arguments>
14+
<argument name="cacheCollector" xsi:type="object">OpenForgeProject\MageForge\Service\Inspector\Cache\BlockCacheCollector</argument>
15+
</arguments>
16+
</type>
17+
618
<!-- Register Inspector Hints Plugin for Frontend Template Engine -->
719
<type name="Magento\Framework\View\TemplateEngineFactory">
820
<plugin name="mageforge_inspector_hints"

0 commit comments

Comments
 (0)