From 734be914955f531bab8228b87657dc1f838e2d26 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 16:15:26 +0200 Subject: [PATCH 1/3] feat: Query Monitor parity (phase 2) Full backtrace, error and rows for each wpdb query through core filters (no wpdb subclass, so any db.php drop-in keeps working) and attribution of queries, HTTP calls, transients and checks to a component. New tabs: WP HTTP, WP Cache, WP Capabilities (aggregated, on by default), WP Blocks (with block bindings), WP Assets (with Vite builds), WP Languages and Doctor (pollora:doctor's web checks on demand). Redirects made with wp_redirect() are stacked for the next page. The Pollora tab adds the async actions queued, WordPress constants and drop-ins. --- README.md | 15 +- config/debugbar-pollora.php | 21 ++ resources/doctor-widget.js | 86 ++++++++ src/Collector.php | 17 +- src/CollectorRegistrar.php | 49 ++++- src/Collectors/DoctorCollector.php | 82 ++++++++ src/Collectors/PolloraCollector.php | 60 +++++- src/Collectors/WpAssetsCollector.php | 186 +++++++++++++++++ src/Collectors/WpBlocksCollector.php | 138 +++++++++++++ src/Collectors/WpCacheCollector.php | 102 ++++++++++ src/Collectors/WpCapabilitiesCollector.php | 88 +++++++++ src/Collectors/WpHttpCollector.php | 86 ++++++++ src/Collectors/WpLanguagesCollector.php | 80 ++++++++ src/Collectors/WpQueriesCollector.php | 39 +++- src/Collectors/WpRequestCollector.php | 9 +- src/DebugbarServiceProvider.php | 58 +++++- src/Http/DoctorController.php | 49 +++++ src/Http/WordPressExitResponder.php | 63 +++++- src/Recording/AsyncRecorder.php | 52 +++++ src/Recording/BlockRecorder.php | 101 ++++++++++ src/Recording/CacheRecorder.php | 62 ++++++ src/Recording/CapabilityRecorder.php | 103 ++++++++++ src/Recording/HttpRecorder.php | 115 +++++++++++ src/Recording/LanguageRecorder.php | 59 ++++++ src/Recording/QueryTracer.php | 126 ++++++++++++ src/Support/Components.php | 170 ++++++++++++++++ tests/Feature/CollectorRegistrarTest.php | 2 +- tests/Feature/DoctorTest.php | 75 +++++++ .../Collectors/WpQueriesCollectorTest.php | 31 +++ tests/Unit/Recording/QueryTracerTest.php | 74 +++++++ tests/Unit/Recording/RecordersTest.php | 187 ++++++++++++++++++ tests/Unit/Support/ComponentsTest.php | 55 ++++++ tests/e2e/fixtures/acme-debugbar.php | 12 ++ tests/e2e/specs/debugbar.spec.ts | 48 ++++- 34 files changed, 2463 insertions(+), 37 deletions(-) create mode 100644 resources/doctor-widget.js create mode 100644 src/Collectors/DoctorCollector.php create mode 100644 src/Collectors/WpAssetsCollector.php create mode 100644 src/Collectors/WpBlocksCollector.php create mode 100644 src/Collectors/WpCacheCollector.php create mode 100644 src/Collectors/WpCapabilitiesCollector.php create mode 100644 src/Collectors/WpHttpCollector.php create mode 100644 src/Collectors/WpLanguagesCollector.php create mode 100644 src/Http/DoctorController.php create mode 100644 src/Recording/AsyncRecorder.php create mode 100644 src/Recording/BlockRecorder.php create mode 100644 src/Recording/CacheRecorder.php create mode 100644 src/Recording/CapabilityRecorder.php create mode 100644 src/Recording/HttpRecorder.php create mode 100644 src/Recording/LanguageRecorder.php create mode 100644 src/Recording/QueryTracer.php create mode 100644 src/Support/Components.php create mode 100644 tests/Feature/DoctorTest.php create mode 100644 tests/Unit/Recording/QueryTracerTest.php create mode 100644 tests/Unit/Recording/RecordersTest.php create mode 100644 tests/Unit/Support/ComponentsTest.php diff --git a/README.md b/README.md index 0286e3f..037efbf 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,22 @@ It brings `fruitcake/laravel-debugbar` with it. Nothing runs unless Laravel Debu | Tab | Origin | Shows | | --- | --- | --- | -| Pollora | Pollora | What answered (`Route::wp()`, the template hierarchy and its view, a Laravel route, or WordPress alone), versions, discovery, modules, theme, async actions | +| Pollora | Pollora | What answered (`Route::wp()`, the template hierarchy and its view, a Laravel route, or WordPress alone), versions, discovery, modules, theme, async actions registered and queued, WordPress constants and drop-ins | +| Doctor | Pollora | A **Run doctor** button: `pollora:doctor`'s web checks on demand, errors first | | WP Request | WordPress | Rewrite rule, query vars, queried object, main query, true conditionals, template and hierarchy candidates | -| WP Queries | WordPress | `$wpdb` queries with time, caller, duplicates, slow ones and the main query (turns `SAVEQUERIES` on) | +| WP Queries | WordPress | `$wpdb` queries with time, full backtrace, rows, errors, duplicates, slow ones and the main query, grouped by component (core, plugin, theme…) | | WP Hooks | WordPress | Hooks that ran, their callbacks, and those Pollora registered | +| WP HTTP | WordPress | `wp_remote_*` calls: result, time, transport, who made them | +| WP Cache | WordPress | Object cache hits and misses, transients set, OPcache | +| WP Capabilities | WordPress | `current_user_can()` checks, each distinct check once with its count | +| WP Blocks | WordPress | Blocks rendered by type with their time, Pollora's Blade blocks, block bindings | +| WP Assets | WordPress | Scripts, styles and script modules, header or footer, missing dependencies, Vite builds | +| WP Languages | WordPress | Locale and the translation files looked for | | Timeline | WordPress | `muplugins_loaded` to `shutdown`, beside Debugbar's own measures | -REST and admin-ajax requests end with `exit`, which Laravel Debugbar never sees: this package stores them and sends the `phpdebugbar-id` header, so they appear in the bar's request list of the page that made the call. +REST and admin-ajax requests end with `exit`, which Laravel Debugbar never sees: this package stores them and sends the `phpdebugbar-id` header, so they appear in the bar's request list of the page that made the call. A `wp_redirect()` keeps its request for the page it leads to. + +WordPress queries are traced through core's `log_query_custom_data` and `query` filters, so this works with any `db.php` drop-in, Pollora's included. Configuration: `php artisan vendor:publish --tag=debugbar-pollora-config`. diff --git a/config/debugbar-pollora.php b/config/debugbar-pollora.php index 51b2371..3c88cba 100644 --- a/config/debugbar-pollora.php +++ b/config/debugbar-pollora.php @@ -27,6 +27,13 @@ | wp_queries $wpdb queries (turns SAVEQUERIES on) | wp_hooks Hooks that ran and the callbacks Pollora registered | wp_timeline WordPress phases on Debugbar's timeline + | wp_http HTTP calls made through wp_remote_* + | wp_cache Object cache, transients set, OPcache + | wp_capabilities current_user_can() checks, aggregated + | wp_blocks Blocks rendered and block bindings resolved + | wp_assets Scripts, styles, script modules and Vite containers + | wp_languages Locale and translation files + | doctor A button that runs pollora:doctor's web checks on demand | bridges pollora/debugbar/* and Query Monitor's qm/* actions | */ @@ -37,6 +44,13 @@ 'wp_queries' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_QUERIES', true), 'wp_hooks' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_HOOKS', true), 'wp_timeline' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_TIMELINE', true), + 'wp_http' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_HTTP', true), + 'wp_cache' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_CACHE', true), + 'wp_capabilities' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_CAPABILITIES', true), + 'wp_blocks' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_BLOCKS', true), + 'wp_assets' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_ASSETS', true), + 'wp_languages' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_LANGUAGES', true), + 'doctor' => env('DEBUGBAR_POLLORA_COLLECTORS_DOCTOR', true), 'bridges' => env('DEBUGBAR_POLLORA_COLLECTORS_BRIDGES', true), ], @@ -49,6 +63,13 @@ 'soft_limit' => (int) env('DEBUGBAR_POLLORA_WP_QUERIES_SOFT_LIMIT', 100), // Past this many queries, the rest are left out 'hard_limit' => (int) env('DEBUGBAR_POLLORA_WP_QUERIES_HARD_LIMIT', 500), + // Full backtrace, error, rows and component for each query + 'trace' => env('DEBUGBAR_POLLORA_WP_QUERIES_TRACE', true), + ], + + 'wp_capabilities' => [ + // Who asked: a backtrace per distinct check + 'backtrace' => env('DEBUGBAR_POLLORA_WP_CAPABILITIES_BACKTRACE', false), ], 'wp_hooks' => [ diff --git a/resources/doctor-widget.js b/resources/doctor-widget.js new file mode 100644 index 0000000..94aae99 --- /dev/null +++ b/resources/doctor-widget.js @@ -0,0 +1,86 @@ +/* + * The Doctor tab: a button that runs pollora:doctor's web checks on demand + * and lists what they found, errors first. + */ +(function () { + if (! window.PhpDebugBar || PhpDebugBar.Widgets.PolloraDoctorWidget) { + return; + } + + const csscls = PhpDebugBar.utils.makecsscls('phpdebugbar-widgets-'); + + class PolloraDoctorWidget extends PhpDebugBar.Widget { + get className() { + return csscls('pollora-doctor'); + } + + render() { + const toolbar = document.createElement('div'); + toolbar.style.padding = '8px'; + + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = 'Run doctor'; + + const status = document.createElement('span'); + status.style.marginLeft = '8px'; + status.textContent = 'Runs pollora:doctor\'s web checks for this site.'; + + const table = document.createElement('table'); + table.classList.add(csscls('tablevar')); + table.style.width = '100%'; + + toolbar.append(button, status); + this.el.append(toolbar, table); + + button.addEventListener('click', async () => { + const url = this.get('data')?.url; + + if (! url) { + return; + } + + button.disabled = true; + status.textContent = 'Running…'; + table.innerHTML = ''; + + try { + const response = await fetch(url, { headers: { Accept: 'application/json' }, credentials: 'same-origin' }); + const body = await response.json(); + + if (! response.ok) { + throw new Error(body.message || response.status); + } + + const counts = {}; + + for (const check of body.checks) { + counts[check.status] = (counts[check.status] || 0) + 1; + + const row = document.createElement('tr'); + row.classList.add(csscls('item')); + + const details = (check.details || []).join(' · '); + + for (const value of [check.status, check.label, [check.summary, details, check.fix ? 'Fix: ' + check.fix : ''].filter(Boolean).join(' — ')]) { + const cell = document.createElement('td'); + cell.classList.add(csscls('value')); + cell.textContent = value; + row.append(cell); + } + + table.append(row); + } + + status.textContent = Object.entries(counts).map(([name, count]) => count + ' ' + name).join(', ') || 'No web checks.'; + } catch (error) { + status.textContent = 'The doctor could not run: ' + error.message; + } finally { + button.disabled = false; + } + }); + } + } + + PhpDebugBar.Widgets.PolloraDoctorWidget = PolloraDoctorWidget; +})(); diff --git a/src/Collector.php b/src/Collector.php index e07d535..a59bf7a 100644 --- a/src/Collector.php +++ b/src/Collector.php @@ -134,7 +134,7 @@ public function getWidgets(): array } /** - * @return array + * @return array */ public function getAssets(): array { @@ -150,6 +150,21 @@ public function getAssets(): array public function reset(): void {} + /** + * A path relative to the project, so the bar names a file someone can open. + */ + protected function relativePath(string $path): string + { + try { + $base = function_exists('base_path') ? rtrim(base_path(), '/').'/' : ''; + } catch (\Throwable) { + // No application to ask: show the path as it is + return $path; + } + + return $base !== '' && str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; + } + /** * What the tab shows, in the shape its widget takes. * diff --git a/src/CollectorRegistrar.php b/src/CollectorRegistrar.php index e5fb241..ca3fe36 100644 --- a/src/CollectorRegistrar.php +++ b/src/CollectorRegistrar.php @@ -8,13 +8,28 @@ use DebugBar\DataCollector\TimeDataCollector; use Fruitcake\LaravelDebugbar\LaravelDebugbar; use Illuminate\Contracts\Container\Container; +use Pollora\Debugbar\Collectors\DoctorCollector; use Pollora\Debugbar\Collectors\PolloraCollector; +use Pollora\Debugbar\Collectors\WpAssetsCollector; +use Pollora\Debugbar\Collectors\WpBlocksCollector; +use Pollora\Debugbar\Collectors\WpCacheCollector; +use Pollora\Debugbar\Collectors\WpCapabilitiesCollector; use Pollora\Debugbar\Collectors\WpHooksCollector; +use Pollora\Debugbar\Collectors\WpHttpCollector; +use Pollora\Debugbar\Collectors\WpLanguagesCollector; use Pollora\Debugbar\Collectors\WpQueriesCollector; use Pollora\Debugbar\Collectors\WpRequestCollector; use Pollora\Debugbar\Collectors\WpTimelineCollector; use Pollora\Debugbar\Contracts\SectionProvider; +use Pollora\Debugbar\Recording\AsyncRecorder; +use Pollora\Debugbar\Recording\BlockRecorder; +use Pollora\Debugbar\Recording\CacheRecorder; +use Pollora\Debugbar\Recording\CapabilityRecorder; +use Pollora\Debugbar\Recording\HttpRecorder; +use Pollora\Debugbar\Recording\LanguageRecorder; +use Pollora\Debugbar\Recording\QueryTracer; use Pollora\Debugbar\Recording\RequestRecorder; +use Pollora\Debugbar\Support\Components; use Pollora\Hook\Domain\Contract\Action; use Pollora\Hook\Domain\Contract\Filter; @@ -98,7 +113,7 @@ private function builtIns(LaravelDebugbar $debugbar): array $collectors = []; if ($on('pollora')) { - $collectors[] = new PolloraCollector($this->container); + $collectors[] = new PolloraCollector($this->container, $this->container->make(AsyncRecorder::class)); } if ($on('wp_request')) { @@ -112,6 +127,8 @@ private function builtIns(LaravelDebugbar $debugbar): array is_numeric($threshold) ? (float) $threshold : null, (int) $config->get('debugbar-pollora.options.wp_queries.soft_limit', 100), (int) $config->get('debugbar-pollora.options.wp_queries.hard_limit', 500), + $this->container->make(Components::class), + $config->get('debugbar-pollora.options.wp_queries.trace', true) ? $this->container->make(QueryTracer::class) : null, ); } @@ -119,6 +136,36 @@ private function builtIns(LaravelDebugbar $debugbar): array $collectors[] = new WpHooksCollector($this->recorder, $this->polloraHookServices()); } + $components = $this->container->make(Components::class); + + if ($on('wp_http')) { + $collectors[] = new WpHttpCollector($this->container->make(HttpRecorder::class), $components); + } + + if ($on('wp_cache')) { + $collectors[] = new WpCacheCollector($this->container->make(CacheRecorder::class), $components); + } + + if ($on('wp_capabilities')) { + $collectors[] = new WpCapabilitiesCollector($this->container->make(CapabilityRecorder::class), $components); + } + + if ($on('wp_blocks')) { + $collectors[] = new WpBlocksCollector($this->container->make(BlockRecorder::class)); + } + + if ($on('wp_assets')) { + $collectors[] = new WpAssetsCollector($this->container); + } + + if ($on('wp_languages')) { + $collectors[] = new WpLanguagesCollector($this->container->make(LanguageRecorder::class)); + } + + if ($on('doctor')) { + $collectors[] = new DoctorCollector('/'.trim((string) $config->get('debugbar.route_prefix', '_debugbar'), '/').'/pollora/doctor'); + } + if ($on('wp_timeline') && $debugbar->hasCollector('time')) { $time = $debugbar->getCollector('time'); diff --git a/src/Collectors/DoctorCollector.php b/src/Collectors/DoctorCollector.php new file mode 100644 index 0000000..458afcc --- /dev/null +++ b/src/Collectors/DoctorCollector.php @@ -0,0 +1,82 @@ + $this->data(), 'count' => null]; + } + + /** + * @return array> + */ + public function getWidgets(): array + { + $widgets = parent::getWidgets(); + $widgets['pollora_doctor']['widget'] = 'PhpDebugBar.Widgets.PolloraDoctorWidget'; + + return $widgets; + } + + /** + * @return array{inline_js: array} + */ + public function getAssets(): array + { + return ['inline_js' => ['pollora-doctor-widget' => (string) file_get_contents(__DIR__.'/../../resources/doctor-widget.js')]]; + } + + /** + * @return array{url: string} + */ + protected function data(): array + { + return ['url' => $this->url]; + } +} diff --git a/src/Collectors/PolloraCollector.php b/src/Collectors/PolloraCollector.php index 604e957..8cce2d6 100644 --- a/src/Collectors/PolloraCollector.php +++ b/src/Collectors/PolloraCollector.php @@ -10,6 +10,7 @@ use Illuminate\Routing\Route; use Pollora\Debugbar\Collector; use Pollora\Debugbar\Origin; +use Pollora\Debugbar\Recording\AsyncRecorder; use Pollora\Discovery\Application\Services\DiscoveryManager; use Pollora\Hook\Infrastructure\Services\AsyncInspector; use Pollora\Modules\Application\Services\ModuleStates; @@ -29,6 +30,7 @@ final class PolloraCollector extends Collector { public function __construct( private readonly Container $container, + private readonly ?AsyncRecorder $async = null, ) {} public function getName(): string @@ -64,6 +66,8 @@ protected function data(): array 'Modules' => $this->safely($this->modules(...)), 'Theme' => $this->safely($this->theme(...)), 'Async actions' => $this->safely($this->asyncActions(...)), + 'Async dispatched' => $this->safely($this->asyncDispatched(...)), + 'WordPress' => $this->safely($this->wordpress(...)), ]; } @@ -110,7 +114,7 @@ private function template(): ?array } return [ - 'file' => $this->relative($resolution->template), + 'file' => $this->relativePath($resolution->template), 'view' => $resolution->view, 'condition' => $resolution->condition, 'index fallback' => $resolution->usedIndexFallback, @@ -157,7 +161,7 @@ private function discovery(): ?array 'time' => sprintf('%.1f ms', array_sum(array_column($scans, 'milliseconds'))), 'locations' => array_map(fn (array $scan): string => sprintf( '%s · %s · %d structures · %.1f ms', - $this->relative($scan['path']), + $this->relativePath($scan['path']), $scan['source'], $scan['structures'], $scan['milliseconds'], @@ -225,6 +229,51 @@ private function asyncActions(): ?array ) ?: null; } + /** + * What this request queued, with `pollora/hook` 1.5+. + * + * @return list|null + */ + private function asyncDispatched(): ?array + { + $dispatched = $this->async?->dispatched() ?? []; + + return array_map( + static fn (array $item): string => sprintf('%s → %s · %s%s', $item['hook'], $item['handler'], $item['driver'] !== '' ? $item['driver'] : 'default driver', $item['delay'] > 0 ? " · in {$item['delay']} s" : ''), + $dispatched, + ) ?: null; + } + + /** + * The constants and drop-ins that change how WordPress behaves. + * + * @return array + */ + private function wordpress(): array + { + $constants = []; + + foreach (['WP_DEBUG', 'WP_DEBUG_LOG', 'WP_DEBUG_DISPLAY', 'SCRIPT_DEBUG', 'SAVEQUERIES', 'WP_CACHE', 'DISABLE_WP_CRON', 'CONCATENATE_SCRIPTS'] as $constant) { + $constants[$constant] = defined($constant) ? constant($constant) : 'undefined'; + } + + $dropIns = []; + + if (defined('WP_CONTENT_DIR')) { + foreach (['db.php', 'object-cache.php', 'advanced-cache.php', 'maintenance.php', 'sunrise.php'] as $dropIn) { + if (is_file(WP_CONTENT_DIR.'/'.$dropIn)) { + $dropIns[] = $dropIn; + } + } + } + + return [ + 'environment' => function_exists('wp_get_environment_type') ? wp_get_environment_type() : null, + 'constants' => $constants, + 'drop-ins' => $dropIns === [] ? 'none' : implode(', ', $dropIns), + ]; + } + private function resolution(): ?TemplateResolution { return $this->container->bound(AnsweringTemplate::class) @@ -243,11 +292,4 @@ private function safely(\Closure $read): mixed return 'unavailable: '.$throwable->getMessage(); } } - - private function relative(string $path): string - { - $base = function_exists('base_path') ? rtrim(base_path(), '/').'/' : ''; - - return $base !== '' && str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; - } } diff --git a/src/Collectors/WpAssetsCollector.php b/src/Collectors/WpAssetsCollector.php new file mode 100644 index 0000000..3d24ad5 --- /dev/null +++ b/src/Collectors/WpAssetsCollector.php @@ -0,0 +1,186 @@ + + */ + public function columns(): array + { + return ['printed' => 'Printed', 'source' => 'Source', 'dependencies' => 'Dependencies', 'note' => 'Note']; + } + + /** + * @return array + */ + protected function data(): array + { + global $wp_scripts, $wp_styles; + + return [ + ...$this->vite(), + ...$this->dependencies('script', $wp_scripts), + ...$this->dependencies('style', $wp_styles), + ...$this->modules(), + ]; + } + + /** + * @return array + */ + private function dependencies(string $kind, mixed $dependencies): array + { + if (! $dependencies instanceof \WP_Dependencies) { + return []; + } + + $rows = []; + $handles = array_unique([...$dependencies->queue, ...$dependencies->done]); + + foreach ($handles as $handle) { + $asset = $dependencies->registered[$handle] ?? null; + $deps = $asset instanceof \_WP_Dependency ? $asset->deps : []; + $missing = array_filter($deps, static fn (string $dependency): bool => ! isset($dependencies->registered[$dependency])); + $printed = in_array($handle, $dependencies->done, true); + + $rows["{$kind}: {$handle}"] = [ + 'printed' => match (true) { + ! $printed => 'no', + $kind === 'script' && (int) $dependencies->get_data($handle, 'group') === 1 => 'footer', + default => 'header', + }, + 'source' => $asset instanceof \_WP_Dependency && is_string($asset->src) ? $this->shortUrl($asset->src) : '', + 'dependencies' => implode(', ', $deps), + 'note' => $missing !== [] ? 'missing: '.implode(', ', $missing) : '', + ]; + } + + return $rows; + } + + /** + * @return array + */ + private function modules(): array + { + if (! function_exists('wp_script_modules')) { + return []; + } + + $modules = wp_script_modules(); + + if (! method_exists($modules, 'get_queue')) { + return []; + } + + $rows = []; + + foreach ($modules->get_queue() as $id) { + $module = method_exists($modules, 'get_registered') ? $modules->get_registered($id) : null; + + $rows["module: {$id}"] = $module === null + ? ['printed' => '', 'source' => '', 'dependencies' => '', 'note' => 'not registered'] + : [ + 'printed' => $module['in_footer'] ? 'footer' : 'header', + 'source' => $this->shortUrl($module['src']), + 'dependencies' => implode(', ', array_column($module['dependencies'], 'id')), + 'note' => '', + ]; + } + + return $rows; + } + + /** + * Pollora's Vite containers: dev server or build. + * + * @return array + */ + private function vite(): array + { + if (! $this->container->bound(AssetManager::class)) { + return []; + } + + $manager = $this->container->make(AssetManager::class); + + if (! method_exists($manager, 'containers')) { + return []; + } + + $rows = []; + + foreach ($manager->containers() as $name => $container) { + $hot = is_file($container->getHotFile()); + $manifest = function_exists('public_path') ? public_path(trim($container->getBuildDirectory(), '/').'/'.$container->getManifestPath()) : ''; + + $rows["vite: {$name}"] = [ + 'printed' => '', + 'source' => $hot ? trim((string) file_get_contents($container->getHotFile())) : $this->shortUrl($container->getBuildDirectory()), + 'dependencies' => '', + 'note' => $hot ? 'dev server (hot file)' : ($manifest !== '' && is_file($manifest) ? 'build (manifest)' : 'no manifest: nothing built here'), + ]; + } + + return $rows; + } + + private function shortUrl(string $url): string + { + $home = function_exists('home_url') ? rtrim(home_url(), '/') : ''; + + return $home !== '' && str_starts_with($url, $home) ? substr($url, strlen($home)) : $url; + } +} diff --git a/src/Collectors/WpBlocksCollector.php b/src/Collectors/WpBlocksCollector.php new file mode 100644 index 0000000..d18ebba --- /dev/null +++ b/src/Collectors/WpBlocksCollector.php @@ -0,0 +1,138 @@ + + */ + public function columns(): array + { + return ['count' => 'Count', 'time' => 'Time', 'kind' => 'Kind', 'detail' => 'Detail']; + } + + /** + * @return array + */ + protected function data(): array + { + $formatter = $this->getDataFormatter(); + $rows = []; + + foreach ($this->recorder->blocks() as $name => $block) { + $rows[$name] = [ + 'count' => $block['count'], + 'time' => $formatter->formatDuration($block['milliseconds'] / 1000), + 'kind' => $this->kind($name), + 'detail' => $block['maxDepth'] > 0 ? 'nested up to '.$block['maxDepth'].' deep' : '', + ]; + } + + $bindings = []; + + foreach ($this->recorder->bindings() as $binding) { + $key = 'binding '.$binding->source.($binding->field !== '' ? '.'.$binding->field : '').' → '.$binding->attribute; + $row = $bindings[$key] ?? ['count' => 0, 'milliseconds' => 0.0, 'cached' => 0, 'empty' => 0, 'posts' => []]; + $row['count']++; + $row['milliseconds'] += $binding->milliseconds; + $row['cached'] += $binding->cached ? 1 : 0; + $row['empty'] += $binding->hasValue ? 0 : 1; + + if ($binding->postId !== null) { + $row['posts'][$binding->postId] = true; + } + + $bindings[$key] = $row; + } + + foreach ($bindings as $key => $row) { + $rows[$key] = [ + 'count' => $row['count'], + 'time' => $formatter->formatDuration($row['milliseconds'] / 1000), + 'kind' => 'binding', + 'detail' => implode(', ', array_filter([ + $row['posts'] !== [] ? 'post '.implode(', ', array_keys($row['posts'])) : null, + $row['cached'] > 0 ? $row['cached'].' from cache' : null, + $row['empty'] > 0 ? $row['empty'].' without value' : null, + ])), + ]; + } + + return $rows; + } + + /** + * Core, a Pollora Blade block, or another plugin's. + */ + private function kind(string $name): string + { + if (str_starts_with($name, 'core/')) { + return 'core'; + } + + if (! class_exists(\WP_Block_Type_Registry::class)) { + return 'block'; + } + + $type = \WP_Block_Type_Registry::get_instance()->get_registered($name); + $render = $type instanceof \WP_Block_Type ? $type->render_callback : null; + + if ($render instanceof \Closure) { + $scope = (new \ReflectionFunction($render))->getClosureScopeClass()?->getName() ?? ''; + + if (str_starts_with($scope, 'Pollora\\')) { + return 'Blade (Pollora)'; + } + } + + return $render !== null ? 'dynamic' : 'static'; + } +} diff --git a/src/Collectors/WpCacheCollector.php b/src/Collectors/WpCacheCollector.php new file mode 100644 index 0000000..b88a819 --- /dev/null +++ b/src/Collectors/WpCacheCollector.php @@ -0,0 +1,102 @@ + + */ + protected function data(): array + { + global $wp_object_cache; + + $hits = is_object($wp_object_cache) && isset($wp_object_cache->cache_hits) ? (int) $wp_object_cache->cache_hits : null; + $misses = is_object($wp_object_cache) && isset($wp_object_cache->cache_misses) ? (int) $wp_object_cache->cache_misses : null; + + $transients = []; + + foreach ($this->recorder->transients() as $transient) { + $transients[] = sprintf( + '%s%s · %s · %d bytes · %s', + $transient['network'] ? 'site: ' : '', + $transient['name'], + $transient['expiration'] > 0 ? "expires in {$transient['expiration']} s" : 'no expiration', + $transient['size'], + $this->components->ofTrace($transient['frames']), + ); + } + + return [ + 'Object cache' => match (true) { + ! is_object($wp_object_cache) => null, + function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache() => 'persistent ('.$wp_object_cache::class.')', + default => 'in memory for this request only ('.$wp_object_cache::class.')', + }, + 'Hits' => $hits, + 'Misses' => $misses, + 'Hit ratio' => $hits !== null && $misses !== null && $hits + $misses > 0 ? sprintf('%.1f %%', $hits / ($hits + $misses) * 100) : null, + 'Transients set' => $transients === [] ? 'none' : $transients, + 'OPcache' => $this->opcache(), + ]; + } + + private function opcache(): string + { + if (! function_exists('opcache_get_status')) { + return 'not installed'; + } + + $status = @opcache_get_status(false); + + if (! is_array($status) || ! ($status['opcache_enabled'] ?? false)) { + return 'off'; + } + + $statistics = $status['opcache_statistics'] ?? []; + + return sprintf('on, %.1f %% hits', (float) ($statistics['opcache_hit_rate'] ?? 0)); + } +} diff --git a/src/Collectors/WpCapabilitiesCollector.php b/src/Collectors/WpCapabilitiesCollector.php new file mode 100644 index 0000000..698b1b1 --- /dev/null +++ b/src/Collectors/WpCapabilitiesCollector.php @@ -0,0 +1,88 @@ + + */ + public function columns(): array + { + return ['result' => 'Result', 'user' => 'User', 'count' => 'Checks', 'component' => 'From']; + } + + /** + * @return array + */ + protected function data(): array + { + $rows = []; + + foreach ($this->recorder->checks() as $check) { + $label = $check['capability'].($check['arguments'] !== '' ? "({$check['arguments']})" : ''); + $key = $label; + + // The same capability can be granted to one user and refused to another + while (isset($rows[$key])) { + $key .= ' '; + } + + $rows[$key] = [ + 'result' => $check['granted'] ? 'granted' : 'refused', + 'user' => $check['user'] > 0 ? '#'.$check['user'] : 'visitor', + 'count' => $check['count'], + 'component' => $check['frames'] !== [] ? $this->components->ofTrace($check['frames']) : '—', + ]; + } + + return $rows; + } +} diff --git a/src/Collectors/WpHttpCollector.php b/src/Collectors/WpHttpCollector.php new file mode 100644 index 0000000..7cdaf4e --- /dev/null +++ b/src/Collectors/WpHttpCollector.php @@ -0,0 +1,86 @@ + + */ + public function columns(): array + { + return ['result' => 'Result', 'time' => 'Time', 'transport' => 'Transport', 'component' => 'From']; + } + + /** + * @return array + */ + protected function data(): array + { + $rows = []; + + foreach ($this->recorder->calls() as $index => $call) { + $result = match (true) { + $call['error'] !== null => 'error: '.$call['error'], + $call['end'] === null => 'no response recorded', + $call['shortCircuited'] => ($call['status'] ?? 'answered').' (answered by pre_http_request)', + default => (string) ($call['status'] ?? '—'), + }; + + $rows[sprintf('%d. %s %s', $index + 1, $call['method'], $call['url'])] = [ + 'result' => $result, + 'time' => $call['end'] !== null ? $this->getDataFormatter()->formatDuration($call['end'] - $call['start']) : '—', + 'transport' => $call['transport'] ?? '—', + 'component' => $this->components->ofTrace($call['frames']), + ]; + } + + return $rows; + } +} diff --git a/src/Collectors/WpLanguagesCollector.php b/src/Collectors/WpLanguagesCollector.php new file mode 100644 index 0000000..5c77e8b --- /dev/null +++ b/src/Collectors/WpLanguagesCollector.php @@ -0,0 +1,80 @@ + + */ + public function columns(): array + { + return ['file' => 'File', 'found' => 'Found', 'kind' => 'Kind']; + } + + /** + * @return array + */ + protected function data(): array + { + $rows = []; + + if (function_exists('determine_locale')) { + $rows['locale'] = ['file' => determine_locale().(function_exists('get_user_locale') ? ' (user: '.get_user_locale().')' : ''), 'found' => '', 'kind' => '']; + } + + foreach ($this->recorder->files() as $index => $file) { + $rows[sprintf('%d. %s', $index + 1, $file['domain'])] = [ + 'file' => $this->relativePath($file['file']), + 'found' => $file['found'] ? 'yes' : 'no', + 'kind' => $file['kind'], + ]; + } + + return $rows; + } +} diff --git a/src/Collectors/WpQueriesCollector.php b/src/Collectors/WpQueriesCollector.php index 60e5d26..f2ad55e 100644 --- a/src/Collectors/WpQueriesCollector.php +++ b/src/Collectors/WpQueriesCollector.php @@ -6,6 +6,8 @@ use Pollora\Debugbar\Collector; use Pollora\Debugbar\Origin; +use Pollora\Debugbar\Recording\QueryTracer; +use Pollora\Debugbar\Support\Components; use Pollora\Debugbar\Widget; /** @@ -14,7 +16,9 @@ * Laravel Debugbar only sees Laravel's PDO connection; WordPress talks to the * same database through its own mysqli one. With `SAVEQUERIES` on, `$wpdb` * keeps each query with its time and a caller string, read here once, at the - * end of the request. + * end of the request. With the query tracer on, each query also carries its + * full backtrace, error and row count, and is attributed to a component — + * shown as the SQL widget's connection, so its filter links group by it. */ final class WpQueriesCollector extends Collector { @@ -27,6 +31,8 @@ public function __construct( private readonly ?float $slowThreshold = null, private readonly int $softLimit = 100, private readonly int $hardLimit = 500, + private readonly ?Components $components = null, + private readonly ?QueryTracer $tracer = null, ) {} public function getName(): string @@ -71,9 +77,13 @@ protected function data(): array ? trim($wp_the_query->request) : null; + $this->tracer?->completePrevious(); + $queries = is_object($wpdb) && isset($wpdb->queries) && is_array($wpdb->queries) ? $wpdb->queries : $queries; + $formatter = $this->getDataFormatter(); $statements = []; $total = 0.0; + $failed = 0; foreach (array_values($queries) as $index => $query) { if ($index >= $this->hardLimit) { @@ -82,9 +92,22 @@ protected function data(): array $sql = trim((string) ($query[0] ?? '')); $duration = (float) ($query[1] ?? 0); - $frames = $index < $this->softLimit ? $this->frames((string) ($query[2] ?? '')) : []; + $traced = is_array($query[4] ?? null) && is_array($query[4][QueryTracer::KEY] ?? null) ? $query[4][QueryTracer::KEY] : null; $total += $duration; + if ($traced !== null && ($traced['frames'] ?? []) !== []) { + $frames = array_map(fn (array $frame): string => sprintf('%s — %s:%d', $frame['call'], $this->relativePath($frame['file']), $frame['line']), $traced['frames']); + $component = $this->components?->ofTrace($traced['frames']); + $source = $traced['frames'][0]['call']; + } else { + $frames = $index < $this->softLimit ? $this->frames((string) ($query[2] ?? '')) : []; + $component = null; + $source = $frames[0] ?? ''; + } + + $error = is_string($traced['error'] ?? null) ? $traced['error'] : null; + $failed += $error !== null ? 1 : 0; + $statements[] = [ 'sql' => $sql, 'type' => 'query', @@ -92,15 +115,15 @@ protected function data(): array 'duration' => $duration, 'duration_str' => $formatter->formatDuration($duration), 'memory_str' => '', - 'row_count' => null, - 'is_success' => true, + 'row_count' => is_int($traced['rows'] ?? null) ? $traced['rows'] : null, + 'is_success' => $error === null, 'error_code' => null, - 'error_message' => null, + 'error_message' => $error, 'backtrace' => $frames, - 'filename' => ($sql === $mainQuery ? 'main query · ' : '').($frames[0] ?? ''), + 'filename' => ($sql === $mainQuery ? 'main query · ' : '').$source, 'xdebug_link' => null, 'slow' => $this->slowThreshold !== null && $duration * 1000 >= $this->slowThreshold, - 'connection' => 'wpdb', + 'connection' => $component !== null ? "wpdb · {$component}" : 'wpdb', ]; } @@ -116,7 +139,7 @@ protected function data(): array return [ 'nb_statements' => count($statements), 'nb_excluded_statements' => max(0, count($queries) - count($statements)), - 'nb_failed_statements' => 0, + 'nb_failed_statements' => $failed, 'accumulated_duration' => $total, 'accumulated_duration_str' => $formatter->formatDuration($total), 'memory_usage_str' => '', diff --git a/src/Collectors/WpRequestCollector.php b/src/Collectors/WpRequestCollector.php index 034c093..347bfe0 100644 --- a/src/Collectors/WpRequestCollector.php +++ b/src/Collectors/WpRequestCollector.php @@ -93,7 +93,7 @@ protected function data(): array $data['Conditionals'] = implode(', ', $this->trueConditionals()); } - $data['Template'] = $this->recorder->template() !== null ? $this->relative($this->recorder->template()) : null; + $data['Template'] = $this->recorder->template() !== null ? $this->relativePath($this->recorder->template()) : null; $data['Template hierarchy'] = $this->recorder->hierarchies() !== [] ? $this->recorder->hierarchies() : null; return $data; @@ -131,11 +131,4 @@ private function queriedObject(): ?string default => null, }; } - - private function relative(string $path): string - { - $base = function_exists('base_path') ? rtrim(base_path(), '/').'/' : ''; - - return $base !== '' && str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; - } } diff --git a/src/DebugbarServiceProvider.php b/src/DebugbarServiceProvider.php index fef541a..682c49f 100644 --- a/src/DebugbarServiceProvider.php +++ b/src/DebugbarServiceProvider.php @@ -5,10 +5,21 @@ namespace Pollora\Debugbar; use Fruitcake\LaravelDebugbar\LaravelDebugbar; +use Fruitcake\LaravelDebugbar\Middleware\DebugbarEnabled; use Illuminate\Support\ServiceProvider; +use Pollora\BlockBinding\Domain\Events\BindingResolved; use Pollora\Debugbar\Bridges\MessageBridge; +use Pollora\Debugbar\Http\DoctorController; use Pollora\Debugbar\Http\WordPressExitResponder; +use Pollora\Debugbar\Recording\AsyncRecorder; +use Pollora\Debugbar\Recording\BlockRecorder; +use Pollora\Debugbar\Recording\CacheRecorder; +use Pollora\Debugbar\Recording\CapabilityRecorder; +use Pollora\Debugbar\Recording\HttpRecorder; +use Pollora\Debugbar\Recording\LanguageRecorder; +use Pollora\Debugbar\Recording\QueryTracer; use Pollora\Debugbar\Recording\RequestRecorder; +use Pollora\Debugbar\Support\Components; use Pollora\WordPress\Events\WordPressBooting; /** @@ -27,6 +38,14 @@ public function register(): void $this->app->singleton(RequestRecorder::class); $this->app->singleton(CollectorRegistrar::class); + $this->app->singleton(Components::class, fn (): Components => new Components); + $this->app->singleton(QueryTracer::class, fn ($app): QueryTracer => new QueryTracer((int) $app->make('config')->get('debugbar-pollora.options.wp_queries.soft_limit', 100))); + $this->app->singleton(HttpRecorder::class); + $this->app->singleton(CacheRecorder::class); + $this->app->singleton(LanguageRecorder::class); + $this->app->singleton(CapabilityRecorder::class, fn ($app): CapabilityRecorder => new CapabilityRecorder((bool) $app->make('config')->get('debugbar-pollora.options.wp_capabilities.backtrace', false))); + $this->app->singleton(BlockRecorder::class); + $this->app->singleton(AsyncRecorder::class); if (! Activation::shouldRun($this->app)) { return; @@ -38,11 +57,33 @@ public function register(): void define('SAVEQUERIES', true); } - $this->app->make('events')->listen(WordPressBooting::class, function () use ($config): void { + $on = static fn (string $collector): bool => (bool) $config->get("debugbar-pollora.collectors.{$collector}", true); + + if ($on('wp_blocks') && class_exists(BindingResolved::class)) { + $this->app->make('events')->listen(BindingResolved::class, function (BindingResolved $binding): void { + $this->app->make(BlockRecorder::class)->recordBinding($binding); + }); + } + + $this->app->make('events')->listen(WordPressBooting::class, function () use ($config, $on): void { $this->app->make(RequestRecorder::class)->install( countAllHooks: (bool) $config->get('debugbar-pollora.options.wp_hooks.count_filters', false), ); + $recorders = [ + QueryTracer::class => $on('wp_queries') && $config->get('debugbar-pollora.options.wp_queries.trace', true), + HttpRecorder::class => $on('wp_http'), + CacheRecorder::class => $on('wp_cache'), + LanguageRecorder::class => $on('wp_languages'), + CapabilityRecorder::class => $on('wp_capabilities'), + BlockRecorder::class => $on('wp_blocks'), + AsyncRecorder::class => $on('pollora'), + ]; + + foreach (array_keys(array_filter($recorders)) as $recorder) { + $this->app->make($recorder)->install(); + } + if ($config->get('debugbar-pollora.collectors.bridges', true)) { (new MessageBridge($this->collectingDebugbar(...)))->install( queryMonitor: (bool) $config->get('debugbar-pollora.options.bridges.query_monitor', true), @@ -51,7 +92,13 @@ public function register(): void (new WordPressExitResponder( $this->collectingDebugbar(...), - fn (LaravelDebugbar $debugbar) => $this->app->make(CollectorRegistrar::class)->register($debugbar), + function (LaravelDebugbar $debugbar): void { + // WordPress can exit before the booted callbacks that index + // route names have run, and Debugbar's collectors link to + // its own named routes + $this->app->make('router')->getRoutes()->refreshNameLookups(); + $this->app->make(CollectorRegistrar::class)->register($debugbar); + }, ))->install(); }); } @@ -66,6 +113,13 @@ public function boot(): void return; } + if ($this->app->make('config')->get('debugbar-pollora.collectors.doctor', true)) { + $this->app->make('router') + ->get(trim((string) $this->app->make('config')->get('debugbar.route_prefix', '_debugbar'), '/').'/pollora/doctor', DoctorController::class) + ->middleware([...(array) $this->app->make('config')->get('debugbar.route_middleware', []), DebugbarEnabled::class]) + ->name('debugbar.pollora.doctor'); + } + $this->app->booted(function (): void { $debugbar = $this->collectingDebugbar(); diff --git a/src/Http/DoctorController.php b/src/Http/DoctorController.php new file mode 100644 index 0000000..21413b5 --- /dev/null +++ b/src/Http/DoctorController.php @@ -0,0 +1,49 @@ +isStorageOpen($request)) { + return new JsonResponse(['message' => 'The doctor answers local and private addresses only, as Debugbar\'s stored requests do (debugbar.storage.open).'], 403); + } + + if (! class_exists(Doctor::class)) { + return new JsonResponse(['message' => 'pollora:doctor is not available in this version of the framework.'], 501); + } + + $order = ['error' => 0, 'warning' => 1, 'ok' => 2, 'skipped' => 3]; + + $checks = array_map(static fn (array $run): array => [ + 'id' => $run['check']->id(), + 'label' => $run['check']->label(), + 'status' => $run['result']->status->value, + 'summary' => $run['result']->summary, + 'details' => $run['result']->details, + 'fix' => $run['result']->fix, + ], $container->make(Doctor::class)->run(RunContext::Http)); + + usort($checks, static fn (array $a, array $b): int => $order[$a['status']] <=> $order[$b['status']]); + + return new JsonResponse(['checks' => $checks]); + } +} diff --git a/src/Http/WordPressExitResponder.php b/src/Http/WordPressExitResponder.php index 03529ff..0b4b6e5 100644 --- a/src/Http/WordPressExitResponder.php +++ b/src/Http/WordPressExitResponder.php @@ -5,6 +5,8 @@ namespace Pollora\Debugbar\Http; use Fruitcake\LaravelDebugbar\LaravelDebugbar; +use Fruitcake\LaravelDebugbar\LaravelHttpDriver; +use Symfony\Component\HttpFoundation\Response; /** * Keeps the requests WordPress answers and then exits from — REST and @@ -16,6 +18,11 @@ * about them. Here the request id goes out in the `phpdebugbar-id` header * before any output, and the data is collected and stored at `shutdown`. The * bar's fetch and XHR capture then lists the call like any other. + * + * `wp_redirect()` is followed by `exit` too. The request is stacked as + * Debugbar stacks a Laravel redirect, so the next page shows both: its HTTP + * driver can only set its cookie on a Laravel response, so it is given one, + * and the cookie is sent by hand. */ final class WordPressExitResponder { @@ -37,6 +44,52 @@ public function install(): void add_filter('rest_post_dispatch', $this->tagRestResponse(...), 10, 1); add_action('admin_init', $this->tagAjaxResponse(...), 0, 0); add_action('shutdown', $this->collect(...), 0, 0); + add_filter('wp_redirect', function (mixed $location, mixed $status): mixed { + if (is_string($location) && $location !== '') { + $this->stackRedirect($location, (int) $status); + } + + return $location; + }, PHP_INT_MAX, 2); + } + + /** + * Keep this request for the page the redirect leads to. + */ + public function stackRedirect(string $location, int $status): void + { + $debugbar = ($this->debugbar)(); + + if (! $debugbar instanceof LaravelDebugbar) { + return; + } + + $debugbar->addMessage(sprintf('wp_redirect() %d to %s', $status, $location), 'redirect'); + + $driver = $debugbar->getHttpDriver(); + + if (headers_sent() || ! $driver instanceof LaravelHttpDriver) { + return; + } + + $response = new Response; + + // A debugging tool must never break the redirect it watches + try { + ($this->prepare)($debugbar); + $driver->setResponse($response); + $debugbar->stackData(); + } catch (\Throwable $throwable) { + report($throwable); + + return; + } finally { + $driver->setResponse(null); + } + + foreach ($response->headers->getCookies() as $cookie) { + header('Set-Cookie: '.$cookie, false); + } } /** @@ -87,10 +140,14 @@ public function collect(): void return; } - ($this->prepare)($debugbar); + try { + ($this->prepare)($debugbar); - // getData() collects, and stores, only when nothing has been collected yet. - $debugbar->getData(); + // getData() collects, and stores, only when nothing has been collected yet. + $debugbar->getData(); + } catch (\Throwable $throwable) { + report($throwable); + } } private function storingDebugbar(): ?LaravelDebugbar diff --git a/src/Recording/AsyncRecorder.php b/src/Recording/AsyncRecorder.php new file mode 100644 index 0000000..c28d9b2 --- /dev/null +++ b/src/Recording/AsyncRecorder.php @@ -0,0 +1,52 @@ + + */ + private array $dispatched = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_action')) { + return; + } + + $this->installed = true; + + add_action('pollora/async/dispatched', function (mixed $payload, mixed $delay = 0): void { + if (is_object($payload)) { + $this->dispatched[] = [ + 'hook' => (string) ($payload->hook ?? ''), + 'handler' => is_string($payload->handler ?? null) ? $payload->handler : get_debug_type($payload->handler ?? null), + 'driver' => (string) ($payload->driver ?? ''), + 'delay' => (int) $delay, + ]; + } + }, 10, 2); + } + + /** + * @return list + */ + public function dispatched(): array + { + return $this->dispatched; + } + + public function reset(): void + { + $this->dispatched = []; + } +} diff --git a/src/Recording/BlockRecorder.php b/src/Recording/BlockRecorder.php new file mode 100644 index 0000000..4b02ede --- /dev/null +++ b/src/Recording/BlockRecorder.php @@ -0,0 +1,101 @@ + + */ + private array $open = []; + + /** + * @var array + */ + private array $blocks = []; + + /** + * @var list + */ + private array $bindings = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + add_filter('pre_render_block', function (mixed $pre, mixed $parsedBlock): mixed { + if ($pre === null && is_array($parsedBlock)) { + $this->open[] = ['name' => (string) ($parsedBlock['blockName'] ?? 'freeform'), 'start' => microtime(true)]; + } + + return $pre; + }, PHP_INT_MAX, 2); + + add_filter('render_block', function (mixed $content): mixed { + $this->close(); + + return $content; + }, PHP_INT_MIN, 1); + } + + public function recordBinding(BindingResolved $binding): void + { + $this->bindings[] = $binding; + } + + /** + * @return array + */ + public function blocks(): array + { + return $this->blocks; + } + + /** + * @return list + */ + public function bindings(): array + { + return $this->bindings; + } + + public function reset(): void + { + $this->open = []; + $this->blocks = []; + $this->bindings = []; + } + + private function close(): void + { + $render = array_pop($this->open); + + if ($render === null) { + return; + } + + $block = $this->blocks[$render['name']] ?? ['count' => 0, 'milliseconds' => 0.0, 'maxDepth' => 0]; + $block['count']++; + $block['milliseconds'] += (microtime(true) - $render['start']) * 1000; + $block['maxDepth'] = max($block['maxDepth'], count($this->open)); + + $this->blocks[$render['name']] = $block; + } +} diff --git a/src/Recording/CacheRecorder.php b/src/Recording/CacheRecorder.php new file mode 100644 index 0000000..fb71fc2 --- /dev/null +++ b/src/Recording/CacheRecorder.php @@ -0,0 +1,62 @@ +}> + */ + private array $transients = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_action')) { + return; + } + + $this->installed = true; + + // set_transient / set_site_transient replaced setted_* in WordPress 6.8 + add_action('set_transient', function (mixed $name, mixed $value, mixed $expiration): void { + $this->record((string) $name, $value, (int) $expiration, false); + }, PHP_INT_MAX, 3); + add_action('set_site_transient', function (mixed $name, mixed $value, mixed $expiration): void { + $this->record((string) $name, $value, (int) $expiration, true); + }, PHP_INT_MAX, 3); + } + + /** + * @return list}> + */ + public function transients(): array + { + return $this->transients; + } + + public function reset(): void + { + $this->transients = []; + } + + private function record(string $name, mixed $value, int $expiration, bool $network): void + { + $this->transients[] = [ + 'name' => $name, + 'network' => $network, + 'expiration' => $expiration, + 'size' => strlen(serialize($value)), + 'frames' => array_values(array_filter( + array_map(static fn (array $frame): array => ['file' => $frame['file'] ?? ''], debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 15)), + static fn (array $frame): bool => $frame['file'] !== '', + )), + ]; + } +} diff --git a/src/Recording/CapabilityRecorder.php b/src/Recording/CapabilityRecorder.php new file mode 100644 index 0000000..2d4838c --- /dev/null +++ b/src/Recording/CapabilityRecorder.php @@ -0,0 +1,103 @@ +}> + */ + private array $checks = []; + + public function __construct( + private readonly bool $backtrace = false, + ) {} + + public function install(): void + { + if ($this->installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + add_filter('user_has_cap', function (mixed $allcaps, mixed $caps, mixed $args): mixed { + if (is_array($allcaps) && is_array($caps) && is_array($args)) { + $this->record($allcaps, $caps, $args); + } + + return $allcaps; + }, PHP_INT_MAX, 3); + } + + /** + * @return list}> + */ + public function checks(): array + { + return array_values($this->checks); + } + + public function reset(): void + { + $this->checks = []; + } + + /** + * @param array $allcaps What the user has + * @param array $caps The primitive capabilities the check maps to + * @param array $args [requested capability, user id, ...extra arguments] + */ + private function record(array $allcaps, array $caps, array $args): void + { + $capability = (string) ($args[0] ?? ''); + $user = (int) ($args[1] ?? 0); + $arguments = implode(', ', array_map( + static fn (mixed $argument): string => is_scalar($argument) ? (string) $argument : get_debug_type($argument), + array_slice($args, 2), + )); + + // Granted when every primitive capability it maps to is held, as WP_User::has_cap() decides + $granted = $caps !== []; + + foreach ($caps as $cap) { + if (empty($allcaps[$cap])) { + $granted = false; + + break; + } + } + + $key = $user.'|'.$capability.'|'.$arguments.'|'.($granted ? 1 : 0); + + if (isset($this->checks[$key])) { + $this->checks[$key]['count']++; + + return; + } + + $this->checks[$key] = [ + 'user' => $user, + 'capability' => $capability, + 'arguments' => $arguments, + 'granted' => $granted, + 'count' => 1, + 'frames' => $this->backtrace ? array_values(array_filter( + array_map(static fn (array $frame): array => ['file' => $frame['file'] ?? ''], debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 15)), + static fn (array $frame): bool => $frame['file'] !== '', + )) : [], + ]; + } +} diff --git a/src/Recording/HttpRecorder.php b/src/Recording/HttpRecorder.php new file mode 100644 index 0000000..a7899f9 --- /dev/null +++ b/src/Recording/HttpRecorder.php @@ -0,0 +1,115 @@ +}> + */ + private array $calls = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + add_filter('http_request_args', fn (mixed $args, mixed $url): mixed => $this->starts($args, (string) $url), PHP_INT_MAX, 2); + add_filter('pre_http_request', fn (mixed $pre, mixed $args): mixed => $this->answeredInPlace($pre, $args), PHP_INT_MAX, 2); + add_action('http_api_debug', function (mixed $response, mixed $context, mixed $transport, mixed $args): void { + $this->ends($response, $args, is_string($transport) ? $transport : null); + }, PHP_INT_MAX, 4); + } + + /** + * @return list}> + */ + public function calls(): array + { + return array_values($this->calls); + } + + public function reset(): void + { + $this->calls = []; + } + + private function starts(mixed $args, string $url): mixed + { + if (! is_array($args)) { + return $args; + } + + $id = (string) count($this->calls); + $args[self::TAG] = $id; + + $this->calls[$id] = [ + 'method' => strtoupper(is_string($args['method'] ?? null) ? $args['method'] : 'GET'), + 'url' => $url, + 'start' => microtime(true), + 'end' => null, + 'status' => null, + 'error' => null, + 'transport' => null, + 'shortCircuited' => false, + 'frames' => array_values(array_filter( + array_map(static fn (array $frame): array => ['file' => $frame['file'] ?? ''], debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 20)), + static fn (array $frame): bool => $frame['file'] !== '', + )), + ]; + + return $args; + } + + private function answeredInPlace(mixed $pre, mixed $args): mixed + { + if ($pre !== false && is_array($args) && isset($this->calls[$args[self::TAG] ?? ''])) { + $id = $args[self::TAG]; + $this->calls[$id]['shortCircuited'] = true; + $this->finish($id, $pre, null); + } + + return $pre; + } + + private function ends(mixed $response, mixed $args, ?string $transport): void + { + if (is_array($args) && isset($this->calls[$args[self::TAG] ?? ''])) { + $this->finish($args[self::TAG], $response, $transport); + } + } + + private function finish(string $id, mixed $response, ?string $transport): void + { + $this->calls[$id]['end'] = microtime(true); + $this->calls[$id]['transport'] = $transport; + + if ($response instanceof \WP_Error) { + $this->calls[$id]['error'] = $response->get_error_message(); + + return; + } + + if (is_array($response)) { + $code = $response['response']['code'] ?? null; + $this->calls[$id]['status'] = is_int($code) || is_string($code) ? $code : null; + } + } +} diff --git a/src/Recording/LanguageRecorder.php b/src/Recording/LanguageRecorder.php new file mode 100644 index 0000000..7ae34c9 --- /dev/null +++ b/src/Recording/LanguageRecorder.php @@ -0,0 +1,59 @@ + + */ + private array $files = []; + + public function install(): void + { + if ($this->installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + // WordPress 6.5+ asks for .l10n.php first, then .mo, through load_translation_file + add_filter('load_translation_file', function (mixed $file, mixed $domain): mixed { + $this->record((string) $domain, (string) $file, 'php / mo'); + + return $file; + }, PHP_INT_MAX, 2); + add_filter('load_script_translation_file', function (mixed $file, mixed $handle, mixed $domain): mixed { + if (is_string($file)) { + $this->record((string) $domain, $file, 'script ('.$handle.')'); + } + + return $file; + }, PHP_INT_MAX, 3); + } + + /** + * @return list + */ + public function files(): array + { + return $this->files; + } + + public function reset(): void + { + $this->files = []; + } + + private function record(string $domain, string $file, string $kind): void + { + $this->files[] = ['domain' => $domain, 'file' => $file, 'found' => $file !== '' && is_readable($file), 'kind' => $kind]; + } +} diff --git a/src/Recording/QueryTracer.php b/src/Recording/QueryTracer.php new file mode 100644 index 0000000..65a8290 --- /dev/null +++ b/src/Recording/QueryTracer.php @@ -0,0 +1,126 @@ +installed || ! function_exists('add_filter')) { + return; + } + + $this->installed = true; + + add_filter('log_query_custom_data', fn (mixed $data): mixed => $this->trace($data), PHP_INT_MAX, 1); + add_filter('query', function (mixed $query): mixed { + $this->completePrevious(); + + return $query; + }, PHP_INT_MIN, 1); + } + + /** + * Copy the last query's error and rows, which no later query will do. + */ + public function completePrevious(): void + { + global $wpdb; + + if (! is_object($wpdb) || ! isset($wpdb->queries) || ! is_array($wpdb->queries) || $wpdb->queries === []) { + return; + } + + $index = array_key_last($wpdb->queries); + $data = $wpdb->queries[$index][4] ?? []; + + if (! is_array($data) || ! isset($data[self::KEY]) || array_key_exists('rows', $data[self::KEY])) { + return; + } + + $data[self::KEY]['error'] = is_string($wpdb->last_error ?? null) && $wpdb->last_error !== '' ? $wpdb->last_error : null; + $data[self::KEY]['rows'] = $this->rows($wpdb); + $wpdb->queries[$index][4] = $data; + } + + private function trace(mixed $data): mixed + { + global $wpdb; + + $data = is_array($data) ? $data : []; + $count = is_object($wpdb) && isset($wpdb->queries) && is_array($wpdb->queries) ? count($wpdb->queries) : 0; + + $data[self::KEY] = [ + 'frames' => $count < $this->softLimit ? $this->frames() : [], + ]; + + return $data; + } + + /** + * The calls that led to the query, innermost first, without `wpdb` and + * WordPress's hook plumbing. + * + * @return list + */ + private function frames(): array + { + $frames = []; + + foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, self::FRAMES + 8) as $frame) { + $class = $frame['class'] ?? ''; + + // The tracer itself, wpdb, and WordPress's hook plumbing say nothing about who asked + if ($class === self::class || is_a($class, 'wpdb', true) || $class === 'WP_Hook' || ! isset($frame['file']) + || str_ends_with($frame['file'], '/class-wpdb.php') || str_ends_with($frame['file'], '/wp-includes/plugin.php')) { + continue; + } + + $frames[] = [ + 'file' => $frame['file'], + 'line' => (int) ($frame['line'] ?? 0), + 'call' => ($class !== '' ? $class.($frame['type'] ?? '::') : '').$frame['function'], + ]; + + if (count($frames) >= self::FRAMES) { + break; + } + } + + return $frames; + } + + private function rows(object $wpdb): ?int + { + $rows = (int) ($wpdb->rows_affected ?? 0); + + return $rows > 0 ? $rows : (isset($wpdb->num_rows) ? (int) $wpdb->num_rows : null); + } +} diff --git a/src/Support/Components.php b/src/Support/Components.php new file mode 100644 index 0000000..7afe7b0 --- /dev/null +++ b/src/Support/Components.php @@ -0,0 +1,170 @@ +|null + */ + private ?array $directories = null; + + /** + * @var array + */ + private array $cache = []; + + /** + * @param array|null $directories Directory => owner, longest first; detected when null + */ + public function __construct(?array $directories = null) + { + if ($directories !== null) { + uksort($directories, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); + $this->directories = $directories; + } + } + + /** + * The owner of a file, as a label: `plugin: woocommerce`, `theme: default`, `core`… + */ + public function of(string $file): string + { + if ($file === '') { + return 'unknown'; + } + + return $this->cache[$file] ??= $this->label($this->owner($file)); + } + + /** + * The owner of a backtrace, innermost frame first. + * + * The project's own code wins (plugin, mu-plugin, theme, module, app), as + * in Query Monitor; otherwise the innermost frame decides. A query + * WordPress makes while Pollora boots it is core's, not Pollora's. + * + * @param list $frames + */ + public function ofTrace(array $frames): string + { + $best = null; + $bestRank = PHP_INT_MAX; + $innermost = null; + + foreach ($frames as $frame) { + if (! isset($frame['file']) || $frame['file'] === '' || str_contains($frame['file'], '/pollora/debugbar/')) { + continue; + } + + $owner = $this->owner($frame['file']); + $innermost ??= $owner; + $rank = array_search($owner['kind'], self::PRIORITY, true); + + if ($rank !== false && $rank < $bestRank) { + $best = $owner; + $bestRank = $rank; + } + } + + $owner = $best ?? $innermost; + + return $owner === null ? 'unknown' : $this->label($owner); + } + + /** + * @return array{kind: string, name: string|null} + */ + private function owner(string $file): array + { + $file = str_replace('\\', '/', $file); + + foreach ($this->directories() as $directory => $owner) { + if (! str_starts_with($file, $directory)) { + continue; + } + + if ($owner['name'] !== null) { + return $owner; + } + + // Plugins, themes, modules and packages are named by their first directory + $rest = substr($file, strlen($directory)); + $segments = explode('/', $rest); + $name = $owner['kind'] === 'vendor' ? implode('/', array_slice($segments, 0, 2)) : $segments[0]; + + return ['kind' => $owner['kind'], 'name' => $owner['kind'] === 'plugin' && ! str_contains($rest, '/') ? basename($name, '.php') : $name]; + } + + return ['kind' => 'unknown', 'name' => null]; + } + + /** + * @param array{kind: string, name: string|null} $owner + */ + private function label(array $owner): string + { + return $owner['name'] === null || $owner['name'] === '' ? $owner['kind'] : "{$owner['kind']}: {$owner['name']}"; + } + + /** + * @return array + */ + private function directories(): array + { + if ($this->directories !== null) { + return $this->directories; + } + + $directories = []; + $add = static function (?string $path, string $kind, ?string $name = null) use (&$directories): void { + if ($path !== null && $path !== '') { + $directories[rtrim(str_replace('\\', '/', $path), '/').'/'] = ['kind' => $kind, 'name' => $name]; + } + }; + + $add(defined('WP_PLUGIN_DIR') ? WP_PLUGIN_DIR : null, 'plugin'); + $add(defined('WPMU_PLUGIN_DIR') ? WPMU_PLUGIN_DIR : null, 'mu-plugin', ''); + + if (function_exists('get_theme_root')) { + $add(get_theme_root(), 'theme'); + } + + if (function_exists('base_path')) { + $add(base_path('themes'), 'theme'); + $add(base_path('Modules'), 'module'); + $add(base_path('vendor/pollora'), 'pollora', ''); + $add(base_path('vendor/laravel'), 'laravel', ''); + $add(base_path('vendor'), 'vendor'); + } + + if (function_exists('app_path')) { + $add(app_path(), 'app', ''); + } + + if (defined('ABSPATH')) { + $add(ABSPATH, 'core', ''); + } + + uksort($directories, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); + + return $this->directories = $directories; + } +} diff --git a/tests/Feature/CollectorRegistrarTest.php b/tests/Feature/CollectorRegistrarTest.php index fc20153..2129179 100644 --- a/tests/Feature/CollectorRegistrarTest.php +++ b/tests/Feature/CollectorRegistrarTest.php @@ -17,7 +17,7 @@ function registrar(): CollectorRegistrar registrar()->register($debugbar); - foreach (['pollora', 'wp_request', 'wp_queries', 'wp_hooks', 'wp_timeline'] as $name) { + foreach (['pollora', 'pollora_doctor', 'wp_request', 'wp_queries', 'wp_hooks', 'wp_timeline', 'wp_http', 'wp_cache', 'wp_capabilities', 'wp_blocks', 'wp_assets', 'wp_languages'] as $name) { expect($debugbar->hasCollector($name))->toBeTrue(); } }); diff --git a/tests/Feature/DoctorTest.php b/tests/Feature/DoctorTest.php new file mode 100644 index 0000000..eb3c612 --- /dev/null +++ b/tests/Feature/DoctorTest.php @@ -0,0 +1,75 @@ +name; + } + + public function label(): string + { + return ucfirst($this->name); + } + + public function runsIn(): array + { + return [RunContext::Http]; + } + + public function run(RunContext $context): CheckResult + { + return $this->result; + } + }; +} + +beforeEach(function (): void { + $this->app['router']->get('_debugbar/pollora/doctor', DoctorController::class); + $this->app->instance(Doctor::class, new Doctor([ + check('cache', CheckResult::ok('Fine.')), + check('patterns', CheckResult::error('Broken pattern.', ['patterns/hero.php'], 'Fix the markup.')), + check('theme', CheckResult::warning('No build.')), + ])); +}); + +it('refuses callers outside local and private addresses, as Debugbar does for stored requests', function (): void { + // Testbench runs as "testing", where Debugbar keeps its storage closed + $this->getJson('_debugbar/pollora/doctor') + ->assertForbidden() + ->assertJsonPath('message', fn (string $message): bool => str_contains($message, 'debugbar.storage.open')); +}); + +it('runs the web checks and lists errors first', function (): void { + config(['debugbar.force_allow_enable' => true, 'debugbar.enabled' => true, 'debugbar.storage.open' => true]); + $this->collectingDebugbar(); + + $this->getJson('_debugbar/pollora/doctor') + ->assertOk() + ->assertJsonPath('checks.0.id', 'patterns') + ->assertJsonPath('checks.0.fix', 'Fix the markup.') + ->assertJsonPath('checks.0.details', ['patterns/hero.php']) + ->assertJsonPath('checks.1.status', 'warning') + ->assertJsonPath('checks.2.status', 'ok'); +}); + +it('gives its tab the address to call and a widget of its own, inlined', function (): void { + $collector = new DoctorCollector('/_debugbar/pollora/doctor'); + + expect($collector->collect()['data'])->toBe(['url' => '/_debugbar/pollora/doctor']) + ->and($collector->getWidgets()['pollora_doctor']['widget'])->toBe('PhpDebugBar.Widgets.PolloraDoctorWidget') + ->and($collector->getAssets()['inline_js']['pollora-doctor-widget'])->toContain('PolloraDoctorWidget'); +}); diff --git a/tests/Unit/Collectors/WpQueriesCollectorTest.php b/tests/Unit/Collectors/WpQueriesCollectorTest.php index 155b7a6..51a401b 100644 --- a/tests/Unit/Collectors/WpQueriesCollectorTest.php +++ b/tests/Unit/Collectors/WpQueriesCollectorTest.php @@ -4,6 +4,8 @@ use DebugBar\DataFormatter\DataFormatter; use Pollora\Debugbar\Collectors\WpQueriesCollector; +use Pollora\Debugbar\Recording\QueryTracer; +use Pollora\Debugbar\Support\Components; /** * WordPress keeps each query as [sql, seconds, caller, start, data] once @@ -62,3 +64,32 @@ function queriesFrom(WpQueriesCollector $collector): array expect(queriesFrom(new WpQueriesCollector)['nb_statements'])->toBe(0); }); + +it('uses the tracer\'s backtrace, error and rows, and groups queries by component', function (): void { + $GLOBALS['wpdb']->queries = [[ + 'SELECT * FROM wp_nope', 0.001, 'ignored caller string', 1.0, + [QueryTracer::KEY => [ + 'frames' => [ + ['file' => '/site/public/cms/wp-includes/option.php', 'line' => 12, 'call' => 'get_option'], + ['file' => '/site/public/content/plugins/acme/acme.php', 'line' => 30, 'call' => 'acme_boot'], + ], + 'error' => "Table 'wp_nope' doesn't exist", + 'rows' => 0, + ]], + ]]; + + $components = new Components([ + '/site/public/content/plugins/' => ['kind' => 'plugin', 'name' => null], + '/site/public/cms/' => ['kind' => 'core', 'name' => ''], + ]); + + $data = queriesFrom(new WpQueriesCollector(components: $components)); + $statement = $data['statements'][0]; + + expect($statement['connection'])->toBe('wpdb · plugin: acme') + ->and($statement['is_success'])->toBeFalse() + ->and($statement['error_message'])->toBe("Table 'wp_nope' doesn't exist") + ->and($statement['row_count'])->toBe(0) + ->and($statement['backtrace'][1])->toBe('acme_boot — /site/public/content/plugins/acme/acme.php:30') + ->and($data['nb_failed_statements'])->toBe(1); +}); diff --git a/tests/Unit/Recording/QueryTracerTest.php b/tests/Unit/Recording/QueryTracerTest.php new file mode 100644 index 0000000..a78c7b7 --- /dev/null +++ b/tests/Unit/Recording/QueryTracerTest.php @@ -0,0 +1,74 @@ + + */ +function tracerHooks(QueryTracer $tracer): ArrayObject +{ + $hooks = new ArrayObject; + + foreach (['log_query_custom_data', 'query'] as $filter) { + Filters\expectAdded($filter)->zeroOrMoreTimes()->whenHappen(function (callable $callback) use ($hooks, $filter): void { + $hooks[$filter] = $callback; + }); + } + + $tracer->install(); + + return $hooks; +} + +beforeEach(function (): void { + $GLOBALS['wpdb'] = (object) ['queries' => [], 'last_error' => '', 'rows_affected' => 0, 'num_rows' => 0]; +}); + +afterEach(function (): void { + unset($GLOBALS['wpdb']); +}); + +it('adds the backtrace to the data WordPress saves with a query', function (): void { + $hooks = tracerHooks(new QueryTracer); + + $data = $hooks['log_query_custom_data']([]); + + expect($data[QueryTracer::KEY]['frames'])->not->toBeEmpty() + ->and($data[QueryTracer::KEY]['frames'][0])->toHaveKeys(['file', 'line', 'call']); +}); + +it('keeps no backtrace past the soft limit', function (): void { + $GLOBALS['wpdb']->queries = array_fill(0, 3, ['SELECT 1', 0.1, '', 0.0, []]); + $hooks = tracerHooks(new QueryTracer(softLimit: 2)); + + expect($hooks['log_query_custom_data']([])[QueryTracer::KEY]['frames'])->toBe([]); +}); + +it("copies the previous query's error and rows when the next one starts", function (): void { + $hooks = tracerHooks(new QueryTracer); + $GLOBALS['wpdb']->queries[] = ['SELECT nope', 0.1, '', 0.0, [QueryTracer::KEY => ['frames' => []]]]; + $GLOBALS['wpdb']->last_error = "Table 'nope' doesn't exist"; + $GLOBALS['wpdb']->num_rows = 0; + + $query = $hooks['query']('SELECT 2'); + + expect($query)->toBe('SELECT 2') + ->and($GLOBALS['wpdb']->queries[0][4][QueryTracer::KEY]['error'])->toBe("Table 'nope' doesn't exist") + ->and($GLOBALS['wpdb']->queries[0][4][QueryTracer::KEY]['rows'])->toBe(0); +}); + +it('completes a query once, so a later flush does not overwrite it', function (): void { + $tracer = new QueryTracer; + $GLOBALS['wpdb']->queries[] = ['UPDATE x', 0.1, '', 0.0, [QueryTracer::KEY => ['frames' => []]]]; + $GLOBALS['wpdb']->rows_affected = 3; + + $tracer->completePrevious(); + $GLOBALS['wpdb']->rows_affected = 0; + $GLOBALS['wpdb']->last_error = 'later'; + $tracer->completePrevious(); + + expect($GLOBALS['wpdb']->queries[0][4][QueryTracer::KEY])->toMatchArray(['rows' => 3, 'error' => null]); +}); diff --git a/tests/Unit/Recording/RecordersTest.php b/tests/Unit/Recording/RecordersTest.php new file mode 100644 index 0000000..9ea7e59 --- /dev/null +++ b/tests/Unit/Recording/RecordersTest.php @@ -0,0 +1,187 @@ + $hooks + */ +function captured(array $hooks): ArrayObject +{ + $callbacks = new ArrayObject; + + foreach ($hooks as $hook) { + Filters\expectAdded($hook)->zeroOrMoreTimes()->whenHappen(function (callable $callback) use ($callbacks, $hook): void { + $callbacks[$hook] = $callback; + }); + Actions\expectAdded($hook)->zeroOrMoreTimes()->whenHappen(function (callable $callback) use ($callbacks, $hook): void { + $callbacks[$hook] = $callback; + }); + } + + return $callbacks; +} + +function noComponents(): Components +{ + return new Components([]); +} + +describe('HTTP calls', function (): void { + it('pairs each response with its request, even when a plugin answers in its place', function (): void { + $hooks = captured(['http_request_args', 'pre_http_request', 'http_api_debug']); + $recorder = new HttpRecorder; + $recorder->install(); + + $first = $hooks['http_request_args'](['method' => 'post'], 'https://api.example.test/orders'); + $second = $hooks['http_request_args'](['method' => 'GET'], 'https://api.example.test/stock'); + $hooks['pre_http_request'](['response' => ['code' => 200]], $second); + $hooks['http_api_debug'](['response' => ['code' => 201]], 'response', 'WpOrg\\Requests\\Requests', $first); + + $rows = (new WpHttpCollector($recorder, noComponents()))->setDataFormatter(new DataFormatter)->collect()['data']['data']; + + expect(array_keys($rows))->toBe(['1. POST https://api.example.test/orders', '2. GET https://api.example.test/stock']) + ->and($rows['1. POST https://api.example.test/orders']['result'])->toBe('201') + ->and($rows['1. POST https://api.example.test/orders']['transport'])->toBe('WpOrg\\Requests\\Requests') + ->and($rows['2. GET https://api.example.test/stock']['result'])->toBe('200 (answered by pre_http_request)'); + }); + + it('leaves a pre_http_request that lets the call through untouched', function (): void { + $hooks = captured(['http_request_args', 'pre_http_request', 'http_api_debug']); + $recorder = new HttpRecorder; + $recorder->install(); + + $args = $hooks['http_request_args']([], 'https://example.test'); + + expect($hooks['pre_http_request'](false, $args))->toBeFalse() + ->and($recorder->calls()[0]['end'])->toBeNull(); + }); +}); + +describe('capability checks', function (): void { + it('counts identical checks once, and tells granted from refused', function (): void { + $hooks = captured(['user_has_cap']); + $recorder = new CapabilityRecorder; + $recorder->install(); + + $allcaps = ['edit_posts' => true]; + foreach ([1, 2, 3] as $ignored) { + $hooks['user_has_cap']($allcaps, ['edit_posts'], ['edit_posts', 5]); + } + $returned = $hooks['user_has_cap']($allcaps, ['edit_others_posts'], ['edit_post', 5, 42]); + + $rows = (new WpCapabilitiesCollector($recorder, noComponents()))->collect()['data']['data']; + + expect($returned)->toBe($allcaps) + ->and($rows['edit_posts'])->toMatchArray(['result' => 'granted', 'user' => '#5', 'count' => 3]) + ->and($rows['edit_post(42)'])->toMatchArray(['result' => 'refused', 'count' => 1]); + }); + + it('refuses a check that maps to no capability, as WordPress does', function (): void { + $hooks = captured(['user_has_cap']); + $recorder = new CapabilityRecorder; + $recorder->install(); + + $hooks['user_has_cap'](['read' => true], [], ['do_not_allow', 0]); + + expect($recorder->checks()[0]['granted'])->toBeFalse(); + }); +}); + +describe('blocks', function (): void { + it('times each block type, counting inner blocks inside their parent', function (): void { + $hooks = captured(['pre_render_block', 'render_block']); + $recorder = new BlockRecorder; + $recorder->install(); + + $hooks['pre_render_block'](null, ['blockName' => 'core/group']); + $hooks['pre_render_block'](null, ['blockName' => 'core/paragraph']); + $hooks['render_block']('

a

'); + $hooks['pre_render_block'](null, ['blockName' => 'core/paragraph']); + $hooks['render_block']('

b

'); + $hooks['render_block']('
'); + + expect($recorder->blocks()['core/paragraph'])->toMatchArray(['count' => 2, 'maxDepth' => 1]) + ->and($recorder->blocks()['core/group'])->toMatchArray(['count' => 1, 'maxDepth' => 0]); + }); + + it('opens nothing for a block a plugin answers in pre_render_block', function (): void { + $hooks = captured(['pre_render_block', 'render_block']); + $recorder = new BlockRecorder; + $recorder->install(); + + $hooks['pre_render_block']('

cached

', ['blockName' => 'acme/cached']); + + expect($recorder->blocks())->toBe([]); + }); + + it('groups bindings by source, field and attribute', function (): void { + $recorder = new BlockRecorder; + $binding = fn (bool $cached, bool $hasValue): BindingResolved => new BindingResolved('acme/event', 'seats', 'content', 7, $cached ? 0.0 : 1.5, $cached, $hasValue); + $recorder->recordBinding($binding(false, true)); + $recorder->recordBinding($binding(true, true)); + $recorder->recordBinding($binding(false, false)); + + $rows = (new WpBlocksCollector($recorder))->setDataFormatter(new DataFormatter)->collect()['data']['data']; + + expect($rows['binding acme/event.seats → content'])->toMatchArray([ + 'count' => 3, + 'kind' => 'binding', + 'detail' => 'post 7, 1 from cache, 1 without value', + ]); + }); +}); + +describe('transients, languages and async actions', function (): void { + it('keeps the transients set, with their size and expiration', function (): void { + $hooks = captured(['set_transient', 'set_site_transient']); + $recorder = new CacheRecorder; + $recorder->install(); + + $hooks['set_transient']('acme_rates', ['eur' => 1.0], 3600); + $hooks['set_site_transient']('acme_feed', 'x', 0); + + expect($recorder->transients())->toHaveCount(2) + ->and($recorder->transients()[0])->toMatchArray(['name' => 'acme_rates', 'network' => false, 'expiration' => 3600, 'size' => strlen(serialize(['eur' => 1.0]))]) + ->and($recorder->transients()[1]['network'])->toBeTrue(); + }); + + it('keeps the translation files asked for, and whether they exist', function (): void { + $hooks = captured(['load_translation_file', 'load_script_translation_file']); + $recorder = new LanguageRecorder; + $recorder->install(); + + $returned = $hooks['load_translation_file'](__FILE__, 'acme'); + $hooks['load_translation_file']('/nowhere/acme-fr_FR.mo', 'acme'); + + expect($returned)->toBe(__FILE__) + ->and(array_column($recorder->files(), 'found'))->toBe([true, false]); + }); + + it('keeps the async actions the request queued', function (): void { + $hooks = captured(['pollora/async/dispatched']); + $recorder = new AsyncRecorder; + $recorder->install(); + + $hooks['pollora/async/dispatched']((object) ['hook' => 'save_post', 'handler' => 'App\\Sync@handle', 'driver' => 'queue'], 30); + + expect($recorder->dispatched())->toBe([['hook' => 'save_post', 'handler' => 'App\\Sync@handle', 'driver' => 'queue', 'delay' => 30]]); + }); +}); diff --git a/tests/Unit/Support/ComponentsTest.php b/tests/Unit/Support/ComponentsTest.php new file mode 100644 index 0000000..181912e --- /dev/null +++ b/tests/Unit/Support/ComponentsTest.php @@ -0,0 +1,55 @@ + ['kind' => 'plugin', 'name' => null], + '/site/public/content/mu-plugins/' => ['kind' => 'mu-plugin', 'name' => ''], + '/site/themes/' => ['kind' => 'theme', 'name' => null], + '/site/vendor/pollora/' => ['kind' => 'pollora', 'name' => ''], + '/site/vendor/' => ['kind' => 'vendor', 'name' => null], + '/site/app/' => ['kind' => 'app', 'name' => ''], + '/site/public/cms/' => ['kind' => 'core', 'name' => ''], + ]); +} + +it('names the plugin, theme or package a file belongs to', function (): void { + expect(components()->of('/site/public/content/plugins/woocommerce/includes/class-wc.php'))->toBe('plugin: woocommerce') + ->and(components()->of('/site/public/content/plugins/hello.php'))->toBe('plugin: hello') + ->and(components()->of('/site/themes/default/functions.php'))->toBe('theme: default') + ->and(components()->of('/site/vendor/spatie/laravel-data/src/Data.php'))->toBe('vendor: spatie/laravel-data') + ->and(components()->of('/site/vendor/pollora/framework/src/Foo.php'))->toBe('pollora') + ->and(components()->of('/site/public/cms/wp-includes/option.php'))->toBe('core') + ->and(components()->of('/elsewhere/file.php'))->toBe('unknown'); +}); + +it("gives a backtrace to the project's own code it passes through", function (): void { + $trace = [ + ['file' => '/site/public/cms/wp-includes/option.php'], + ['file' => '/site/vendor/pollora/framework/src/Foo.php'], + ['file' => '/site/public/content/plugins/woocommerce/includes/class-wc.php'], + ['file' => '/site/public/cms/wp-settings.php'], + ]; + + expect(components()->ofTrace($trace))->toBe('plugin: woocommerce'); +}); + +it('gives a backtrace through no project code to its innermost frame', function (): void { + $trace = [ + ['file' => '/site/public/cms/wp-includes/option.php'], + ['file' => '/site/vendor/pollora/framework/src/WordPress/Bootstrap.php'], + ]; + + expect(components()->ofTrace($trace))->toBe('core'); +}); + +it('prefers a plugin to a theme when both are in the trace', function (): void { + expect(components()->ofTrace([ + ['file' => '/site/themes/default/functions.php'], + ['file' => '/site/public/content/plugins/acme/acme.php'], + ]))->toBe('plugin: acme'); +}); diff --git a/tests/e2e/fixtures/acme-debugbar.php b/tests/e2e/fixtures/acme-debugbar.php index 29ea479..c0e6d15 100644 --- a/tests/e2e/fixtures/acme-debugbar.php +++ b/tests/e2e/fixtures/acme-debugbar.php @@ -22,6 +22,18 @@ add_action('init', function (): void { do_action('pollora/debugbar/message', 'Acme cart {id} rebuilt', 'info', ['id' => 'c-42']); do_action('qm/warning', 'Written for Query Monitor'); + + // An HTTP call answered in place, so the test needs no network + add_filter('pre_http_request', function ($pre, $args, $url) { + return str_starts_with($url, 'https://api.acme.test/') + ? ['headers' => [], 'body' => '{}', 'response' => ['code' => 200, 'message' => 'OK'], 'cookies' => [], 'filename' => null] + : $pre; + }, 10, 3); + wp_remote_get('https://api.acme.test/stock'); + + // WordPress only announces a transient whose value changed + set_transient('acme_rates', ['eur' => 1.0, 'at' => microtime(true)], HOUR_IN_SECONDS); + current_user_can('edit_posts'); }); if (class_exists(Collector::class)) { diff --git a/tests/e2e/specs/debugbar.spec.ts b/tests/e2e/specs/debugbar.spec.ts index be0df36..5fc935a 100644 --- a/tests/e2e/specs/debugbar.spec.ts +++ b/tests/e2e/specs/debugbar.spec.ts @@ -53,7 +53,7 @@ test('shows the queries WordPress ran and the hooks that fired', async ({ page } const data = firstDataset(await debugbar(page)); expect(data.wp_queries.nb_statements).toBeGreaterThan(0); - expect(data.wp_queries.statements[0].connection).toBe('wpdb'); + expect(data.wp_queries.statements[0].connection).toMatch(/^wpdb/); expect(data.wp_hooks.data.data.init.calls).toBeGreaterThan(0); }); @@ -82,3 +82,49 @@ test('shows what a plugin and a package add, labelled as theirs', async ({ page expect.arrayContaining(['Acme cart c-42 rebuilt', 'Written for Query Monitor']), ); }); + +test('groups WordPress queries by component and keeps their backtrace', async ({ page }) => { + await page.goto(''); + const statements = firstDataset(await debugbar(page)).wp_queries.statements; + + expect(statements.map((statement: any) => statement.connection)).toContain('wpdb · core'); + expect(statements[0].backtrace.length).toBeGreaterThan(0); +}); + +test('lists the HTTP calls, transients and capability checks of the request', async ({ page }) => { + await page.goto(''); + const data = firstDataset(await debugbar(page)); + + expect(data.wp_http.data.data['1. GET https://api.acme.test/stock'].result).toBe('200 (answered by pre_http_request)'); + expect(JSON.stringify(data.wp_cache.data['Transients set'])).toContain('acme_rates'); + expect(data.wp_capabilities.data.data.edit_posts.result).toBe('refused'); +}); + +test('shows the blocks of a post and the theme’s Vite build', async ({ page }) => { + await page.goto('hello-world/'); + const data = firstDataset(await debugbar(page)); + + expect(data.wp_blocks.data.data['core/paragraph'].count).toBeGreaterThan(0); + expect(data.wp_assets.data.data['vite: theme'].note).toBe('build (manifest)'); +}); + +test('keeps the request WordPress redirected from', async ({ page }) => { + await page.goto('?p=1'); + await expect(page).toHaveURL(/hello-world/); + + await expect.poll(async () => page.evaluate( + () => Object.values((window as any).phpdebugbar.datasets).map((dataset: any) => dataset.__meta?.uri), + )).toContain('/?p=1'); +}); + +test('runs the doctor on demand from its tab', async ({ page }) => { + await page.goto(''); + await debugbar(page); + + await page.evaluate(() => (window as any).phpdebugbar.showTab('pollora_doctor')); + const panel = page.locator('.phpdebugbar-panel.phpdebugbar-active'); + await panel.getByRole('button', { name: 'Run doctor' }).click(); + + await expect(panel.locator('tr').first()).toBeVisible(); + await expect(panel).toContainText(/\d+ (ok|warning|error)/); +}); From 7153d82b43026f970366738b7cfde6d72354fc0a Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 16:18:00 +0200 Subject: [PATCH 2/3] ci: run on every pull request, stacked ones included --- .github/workflows/tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c4c4edc..9ed9ef3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,6 @@ on: push: branches: [ main, develop ] pull_request: - branches: [ main, develop ] workflow_dispatch: jobs: From 78b25b9bb25a1bfb495a4c37aa0ae69fe1608648 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 8 Oct 2026 17:18:59 +0200 Subject: [PATCH 3/3] fix: lighter query traces, attribution before WordPress defines its directories, readable doctor errors Each query keeps its 10 innermost frames to show, and is attributed from its whole trace when it runs: full traces made most of the bar's weight (309 KB of 411 on a WooCommerce shop page, now 154). Component directories are not cached until WordPress defines where plugins live, which had left early lookups without them. The Doctor tab says what came back when the answer is not JSON (a proxy's 404 while a server restarts) instead of a JSON syntax error. The Pollora tab adds the database server, memory limits and web server Query Monitor shows. --- resources/doctor-widget.js | 13 +++++++++-- src/Collectors/PolloraCollector.php | 9 ++++++++ src/Collectors/WpQueriesCollector.php | 2 +- src/DebugbarServiceProvider.php | 5 +++- src/Recording/QueryTracer.php | 23 +++++++++++++++++-- src/Support/Components.php | 29 +++++++++++++++++++----- tests/Unit/Recording/QueryTracerTest.php | 11 +++++++++ tests/Unit/Support/ComponentsTest.php | 8 +++++++ 8 files changed, 88 insertions(+), 12 deletions(-) diff --git a/resources/doctor-widget.js b/resources/doctor-widget.js index 94aae99..33c08cd 100644 --- a/resources/doctor-widget.js +++ b/resources/doctor-widget.js @@ -46,10 +46,19 @@ try { const response = await fetch(url, { headers: { Accept: 'application/json' }, credentials: 'same-origin' }); - const body = await response.json(); + const text = await response.text(); + const isJson = (response.headers.get('content-type') || '').includes('json'); + + // A proxy or a restarting server answers with plain text or + // HTML: say what came back rather than a JSON syntax error + if (! isJson) { + throw new Error('HTTP ' + response.status + ': ' + text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120)); + } + + const body = JSON.parse(text); if (! response.ok) { - throw new Error(body.message || response.status); + throw new Error(body.message || 'HTTP ' + response.status); } const counts = {}; diff --git a/src/Collectors/PolloraCollector.php b/src/Collectors/PolloraCollector.php index 8cce2d6..4127ff0 100644 --- a/src/Collectors/PolloraCollector.php +++ b/src/Collectors/PolloraCollector.php @@ -267,10 +267,19 @@ private function wordpress(): array } } + global $wpdb; + return [ 'environment' => function_exists('wp_get_environment_type') ? wp_get_environment_type() : null, 'constants' => $constants, 'drop-ins' => $dropIns === [] ? 'none' : implode(', ', $dropIns), + 'database server' => is_object($wpdb) && method_exists($wpdb, 'db_server_info') ? (string) $wpdb->db_server_info() : null, + 'memory limit' => sprintf( + 'PHP %s, WordPress %s', + (string) ini_get('memory_limit'), + defined('WP_MEMORY_LIMIT') ? (string) WP_MEMORY_LIMIT : '—', + ), + 'web server' => is_string($_SERVER['SERVER_SOFTWARE'] ?? null) ? $_SERVER['SERVER_SOFTWARE'] : null, ]; } diff --git a/src/Collectors/WpQueriesCollector.php b/src/Collectors/WpQueriesCollector.php index f2ad55e..ce4a006 100644 --- a/src/Collectors/WpQueriesCollector.php +++ b/src/Collectors/WpQueriesCollector.php @@ -97,7 +97,7 @@ protected function data(): array if ($traced !== null && ($traced['frames'] ?? []) !== []) { $frames = array_map(fn (array $frame): string => sprintf('%s — %s:%d', $frame['call'], $this->relativePath($frame['file']), $frame['line']), $traced['frames']); - $component = $this->components?->ofTrace($traced['frames']); + $component = is_string($traced['component'] ?? null) ? $traced['component'] : $this->components?->ofTrace($traced['frames']); $source = $traced['frames'][0]['call']; } else { $frames = $index < $this->softLimit ? $this->frames((string) ($query[2] ?? '')) : []; diff --git a/src/DebugbarServiceProvider.php b/src/DebugbarServiceProvider.php index 682c49f..2028be9 100644 --- a/src/DebugbarServiceProvider.php +++ b/src/DebugbarServiceProvider.php @@ -39,7 +39,10 @@ public function register(): void $this->app->singleton(RequestRecorder::class); $this->app->singleton(CollectorRegistrar::class); $this->app->singleton(Components::class, fn (): Components => new Components); - $this->app->singleton(QueryTracer::class, fn ($app): QueryTracer => new QueryTracer((int) $app->make('config')->get('debugbar-pollora.options.wp_queries.soft_limit', 100))); + $this->app->singleton(QueryTracer::class, fn ($app): QueryTracer => new QueryTracer( + (int) $app->make('config')->get('debugbar-pollora.options.wp_queries.soft_limit', 100), + $app->make(Components::class), + )); $this->app->singleton(HttpRecorder::class); $this->app->singleton(CacheRecorder::class); $this->app->singleton(LanguageRecorder::class); diff --git a/src/Recording/QueryTracer.php b/src/Recording/QueryTracer.php index 65a8290..707c580 100644 --- a/src/Recording/QueryTracer.php +++ b/src/Recording/QueryTracer.php @@ -4,6 +4,8 @@ namespace Pollora\Debugbar\Recording; +use Pollora\Debugbar\Support\Components; + /** * Adds what `SAVEQUERIES` leaves out to each query `$wpdb` keeps: the full * backtrace, the error and the number of rows. @@ -23,12 +25,17 @@ final class QueryTracer /** Key of the data this tracer adds to each query's custom data. */ public const string KEY = 'pollora_debugbar'; - private const int FRAMES = 25; + /** Frames read to find who asked for the query. */ + private const int FRAMES = 30; + + /** Frames kept to show it: the innermost, where the reader looks first. */ + private const int SHOWN_FRAMES = 10; private bool $installed = false; public function __construct( private readonly int $softLimit = 100, + private readonly ?Components $components = null, ) {} public function install(): void @@ -77,8 +84,20 @@ private function trace(mixed $data): mixed $data = is_array($data) ? $data : []; $count = is_object($wpdb) && isset($wpdb->queries) && is_array($wpdb->queries) ? count($wpdb->queries) : 0; + if ($count >= $this->softLimit) { + $data[self::KEY] = ['frames' => []]; + + return $data; + } + + // Attributed from the whole trace, shown from its innermost part: a + // page of WooCommerce runs a hundred queries, and full traces made + // most of the bar's weight + $frames = $this->frames(); + $data[self::KEY] = [ - 'frames' => $count < $this->softLimit ? $this->frames() : [], + 'frames' => array_slice($frames, 0, self::SHOWN_FRAMES), + 'component' => $this->components?->ofTrace($frames), ]; return $data; diff --git a/src/Support/Components.php b/src/Support/Components.php index 7afe7b0..7b037d1 100644 --- a/src/Support/Components.php +++ b/src/Support/Components.php @@ -51,7 +51,19 @@ public function of(string $file): string return 'unknown'; } - return $this->cache[$file] ??= $this->label($this->owner($file)); + if (isset($this->cache[$file])) { + return $this->cache[$file]; + } + + $label = $this->label($this->owner($file)); + + // Not kept while WordPress has not defined its directories yet: the + // same file belongs to a plugin once it has + if ($this->directories !== null) { + $this->cache[$file] = $label; + } + + return $label; } /** @@ -147,16 +159,15 @@ private function directories(): array $add(get_theme_root(), 'theme'); } - if (function_exists('base_path')) { + // Outside a Laravel application (a unit test), only WordPress's directories are known + try { $add(base_path('themes'), 'theme'); $add(base_path('Modules'), 'module'); $add(base_path('vendor/pollora'), 'pollora', ''); $add(base_path('vendor/laravel'), 'laravel', ''); $add(base_path('vendor'), 'vendor'); - } - - if (function_exists('app_path')) { $add(app_path(), 'app', ''); + } catch (\Throwable) { } if (defined('ABSPATH')) { @@ -165,6 +176,12 @@ private function directories(): array uksort($directories, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); - return $this->directories = $directories; + // The first queries run before WordPress defines where plugins and + // themes live; keep looking until it has + if (defined('WP_PLUGIN_DIR') && function_exists('get_theme_root')) { + $this->directories = $directories; + } + + return $directories; } } diff --git a/tests/Unit/Recording/QueryTracerTest.php b/tests/Unit/Recording/QueryTracerTest.php index a78c7b7..7d63e72 100644 --- a/tests/Unit/Recording/QueryTracerTest.php +++ b/tests/Unit/Recording/QueryTracerTest.php @@ -4,6 +4,7 @@ use Brain\Monkey\Filters; use Pollora\Debugbar\Recording\QueryTracer; +use Pollora\Debugbar\Support\Components; /** * @return array @@ -72,3 +73,13 @@ function tracerHooks(QueryTracer $tracer): ArrayObject expect($GLOBALS['wpdb']->queries[0][4][QueryTracer::KEY])->toMatchArray(['rows' => 3, 'error' => null]); }); + +it('attributes a query from its whole trace but keeps only the innermost frames to show', function (): void { + $components = new Components([dirname(__DIR__, 3).'/' => ['kind' => 'app', 'name' => '']]); + $hooks = tracerHooks(new QueryTracer(components: $components)); + + $data = $hooks['log_query_custom_data']([])[QueryTracer::KEY]; + + expect(count($data['frames']))->toBeLessThanOrEqual(10) + ->and($data['component'])->toBe('app'); +}); diff --git a/tests/Unit/Support/ComponentsTest.php b/tests/Unit/Support/ComponentsTest.php index 181912e..ed3fe6d 100644 --- a/tests/Unit/Support/ComponentsTest.php +++ b/tests/Unit/Support/ComponentsTest.php @@ -53,3 +53,11 @@ function components(): Components ['file' => '/site/public/content/plugins/acme/acme.php'], ]))->toBe('plugin: acme'); }); + +it('keeps looking for directories until WordPress has defined where plugins live', function (): void { + // No directories given and no WordPress here: nothing can be known, and nothing is kept + $components = new Components; + + expect($components->of('/anywhere/plugins/acme/acme.php'))->toBe('unknown') + ->and((new ReflectionProperty(Components::class, 'directories'))->getValue($components))->toBeNull(); +});