Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions src/Model/TemplateEngine/Decorator/InspectorHints.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Magento\Framework\View\Element\AbstractBlock;
use Magento\Framework\View\Element\BlockInterface;
use Magento\Framework\View\TemplateEngineInterface;
use OpenForgeProject\MageForge\Service\Inspector\Cache\BlockCacheCollector;

/**
* Decorates block with inspector data attributes for frontend debugging
Expand All @@ -22,11 +23,13 @@ class InspectorHints implements TemplateEngineInterface
* @param TemplateEngineInterface $subject
* @param bool $showBlockHints
* @param Random $random
* @param BlockCacheCollector $cacheCollector
*/
public function __construct(
private readonly TemplateEngineInterface $subject,
private readonly bool $showBlockHints,
private readonly Random $random
private readonly Random $random,
private readonly BlockCacheCollector $cacheCollector
) {
// Get Magento root directory - try multiple strategies
// 1. Try from BP constant (most reliable)
Expand All @@ -49,7 +52,10 @@ public function __construct(
*/
public function render(BlockInterface $block, $templateFile, array $dictionary = []): string
{
// Measure render time
$startTime = hrtime(true);
$result = $this->subject->render($block, $templateFile, $dictionary);
$endTime = hrtime(true);

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

return $this->injectInspectorAttributes($result, $block, $templateFile);
// Calculate render time in milliseconds
$renderTimeNs = $endTime - $startTime;
$renderTimeMs = $renderTimeNs / 1_000_000;

$renderMetrics = [
'renderTimeMs' => round($renderTimeMs, 2),
'startTime' => $startTime,
'endTime' => $endTime,
];

return $this->injectInspectorAttributes($result, $block, $templateFile, $renderMetrics);
}

/**
Expand All @@ -69,10 +85,15 @@ public function render(BlockInterface $block, $templateFile, array $dictionary =
* @param string $html
* @param BlockInterface $block
* @param string $templateFile
* @param array{renderTimeMs: float, startTime: int, endTime: int} $renderMetrics
* @return string
*/
private function injectInspectorAttributes(string $html, BlockInterface $block, string $templateFile): string
{
private function injectInspectorAttributes(
string $html,
BlockInterface $block,
string $templateFile,
array $renderMetrics
): string {
$wrapperId = 'mageforge-' . $this->random->getRandomString(16);

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

// Collect performance and cache metrics
$cacheMetrics = $this->cacheCollector->getCacheInfo($block);
$formattedMetrics = $this->cacheCollector->formatMetricsForJson($renderMetrics, $cacheMetrics);

// Build metadata as JSON
$metadata = [
'id' => $wrapperId,
Expand All @@ -100,6 +125,8 @@ private function injectInspectorAttributes(string $html, BlockInterface $block,
'parent' => $parentBlock,
'alias' => $blockAlias,
'override' => $isOverride,
'performance' => $formattedMetrics['performance'],
'cache' => $formattedMetrics['cache'],
];

// JSON encode with proper escaping for HTML comments
Expand Down
231 changes: 231 additions & 0 deletions src/Service/Inspector/Cache/BlockCacheCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
<?php

declare(strict_types=1);

namespace OpenForgeProject\MageForge\Service\Inspector\Cache;

use Magento\Framework\View\Element\BlockInterface;
use Magento\Framework\View\LayoutInterface;

/**
* Collects performance metrics from Magento blocks for Inspector
*
* Measures render time and extracts cache configuration with strict type safety (PHPStan Level 8).
*
* @package OpenForgeProject\MageForge
*/
class BlockCacheCollector
{
/**
* @param LayoutInterface $layout
*/
public function __construct(
private readonly LayoutInterface $layout
) {
}
/**
* Get cache information from block
*
* Safely extracts cache lifetime, key, and tags with explicit type checking
* to satisfy PHPStan Level 8 requirements.
*
* @param BlockInterface $block
* @return array{cacheable: bool, lifetime: int|null, cacheKey: string, cacheTags: array<int, string>, pageCacheable: bool}
*/
public function getCacheInfo(BlockInterface $block): array
{
$lifetime = $this->resolveCacheLifetime($block);
$cacheable = $lifetime !== false;

if ($cacheable && $this->isBlockScopePrivate($block)) {
$cacheable = false;
$lifetime = null;
}

$cacheKey = $this->resolveCacheKey($block);
$cacheTags = $this->resolveCacheTags($block);

// Check if page itself is cacheable
$pageCacheable = $this->isPageCacheable();

return [
'cacheable' => $cacheable,
'lifetime' => $lifetime === false ? null : $lifetime,
'cacheKey' => $cacheKey,
'cacheTags' => $cacheTags,
'pageCacheable' => $pageCacheable,
];
}

/**
* Resolve cache lifetime from block
*
* @param BlockInterface $block
* @return int|null|false False if not cacheable, null for unlimited, int for specific lifetime
*/
private function resolveCacheLifetime(BlockInterface $block): int|null|false
{
if (!method_exists($block, 'getCacheLifetime')) {
return false;
}

$lifetimeRaw = $block->getCacheLifetime();

// In Magento:
// - false = not cacheable
// - null = unlimited cache (cacheable!)
// - int = specific cache lifetime in seconds (cacheable!)

if ($lifetimeRaw === false) {
return false;
}

if (is_int($lifetimeRaw)) {
return $lifetimeRaw;
}

if ($lifetimeRaw === null) {
return null; // Unlimited
}

if (is_numeric($lifetimeRaw) && (int)$lifetimeRaw === 0) {
return null; // Unlimited
}

return false; // Default fallback
}

/**
* Check if block is private (customer specific)
*
* @param BlockInterface $block
* @return bool
*/
private function isBlockScopePrivate(BlockInterface $block): bool
{
// Private blocks (like checkout, customer account) should not be cached
if (method_exists($block, 'isScopePrivate')) {
if ($block->isScopePrivate()) {
return true;
}
}

// Additional fallback: Check protected property via reflection if available
if (property_exists($block, '_isScopePrivate')) {
try {
$reflection = new \ReflectionProperty($block, '_isScopePrivate');
$reflection->setAccessible(true);
$isScopePrivate = $reflection->getValue($block);
if ($isScopePrivate === true) {
return true;
}
} catch (\ReflectionException $e) {
// If reflection fails, assume not private
}
}

return false;
}

/**
* Resolve cache key from block
*
* @param BlockInterface $block
* @return string
*/
private function resolveCacheKey(BlockInterface $block): string
{
if (method_exists($block, 'getCacheKey')) {
$keyRaw = $block->getCacheKey();
return is_string($keyRaw) && $keyRaw !== '' ? $keyRaw : '';
}
return '';
}

/**
* Resolve cache tags from block
*
* @param BlockInterface $block
* @return array<int, string>
*/
private function resolveCacheTags(BlockInterface $block): array
{
$cacheTags = [];
if (method_exists($block, 'getCacheTags')) {
$tagsRaw = $block->getCacheTags();
// Ensure string array (PHPStan strict)
if (is_array($tagsRaw)) {
foreach ($tagsRaw as $tag) {
if (is_string($tag)) {
$cacheTags[] = $tag;
}
}
}
}
return $cacheTags;
}

/**
* Check if current page is cacheable
*
* Checks layout configuration to determine if page has cacheable="false" attribute.
* If ANY block on the page is marked as non-cacheable in layout XML, the entire page is non-cacheable.
*
* @return bool True if page is cacheable, false otherwise
*/
private function isPageCacheable(): bool
{
try {
// Get all blocks from layout
$allBlocks = $this->layout->getAllBlocks();

foreach ($allBlocks as $block) {
// Check if block has isCacheable method (added by layout processor)
if (method_exists($block, 'isCacheable')) {
// @phpstan-ignore-next-line
if (!$block->isCacheable()) {
return false;
}
}

// Check data key 'cacheable' set by layout XML
if (method_exists($block, 'getData')) {
// @phpstan-ignore-next-line
$cacheableData = $block->getData('cacheable');
if ($cacheableData === false || $cacheableData === 'false') {
return false;
}
}
}

return true;
} catch (\Exception $e) {
// If we can't determine, assume cacheable to avoid false alarms
return true;
}
}

/**
* Format metrics for JSON export to frontend
*
* @param array{renderTimeMs: float, startTime: int, endTime: int} $renderMetrics
* @param array{cacheable: bool, lifetime: int|null, cacheKey: string, cacheTags: array<int, string>, pageCacheable: bool} $cacheMetrics
* @return array{performance: array{renderTime: string, timestamp: int}, cache: array{cacheable: bool, lifetime: int|null, key: string, tags: array<int, string>, pageCacheable: bool}}
*/
public function formatMetricsForJson(array $renderMetrics, array $cacheMetrics): array
{
return [
'performance' => [
'renderTime' => number_format($renderMetrics['renderTimeMs'], 2),
'timestamp' => (int)($renderMetrics['startTime'] / 1_000_000_000), // Convert ns to seconds
],
'cache' => [
'cacheable' => $cacheMetrics['cacheable'],
'lifetime' => $cacheMetrics['lifetime'],
'key' => $cacheMetrics['cacheKey'],
'tags' => $cacheMetrics['cacheTags'],
'pageCacheable' => $cacheMetrics['pageCacheable'],
],
];
}
}
12 changes: 12 additions & 0 deletions src/etc/frontend/di.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"
>
<!-- Performance Collector Service -->
<type name="OpenForgeProject\MageForge\Service\Inspector\Cache\BlockCacheCollector">
<!-- No dependencies needed -->
</type>

<!-- Inspector Hints Decorator with Performance Collector -->
<type name="OpenForgeProject\MageForge\Model\TemplateEngine\Decorator\InspectorHints">
<arguments>
<argument name="cacheCollector" xsi:type="object">OpenForgeProject\MageForge\Service\Inspector\Cache\BlockCacheCollector</argument>
</arguments>
</type>

<!-- Register Inspector Hints Plugin for Frontend Template Engine -->
<type name="Magento\Framework\View\TemplateEngineFactory">
<plugin name="mageforge_inspector_hints"
Expand Down
Loading
Loading