From d5d9731ae419e63e57da23b61d04a7d33aedb08f Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Tue, 3 Nov 2026 09:55:31 -0600 Subject: [PATCH 1/2] collectors get data through public methods Assisted-by: Claude Code --- src/DebugBar/Collector/CacheCollector.php | 15 ++++-- src/DebugBar/Collector/DatabaseCollector.php | 33 ++++++++++--- src/DebugBar/Collector/RouteCollector.php | 46 +++++++++++------- src/DebugBar/Collector/ViewCollector.php | 43 +++++++++++------ .../DebugBar/Collector/CacheCollectorTest.php | 12 +++++ .../Collector/DatabaseCollectorTest.php | 48 +++++++++++++++++++ .../DebugBar/Collector/RouteCollectorTest.php | 48 +++++++++++++++++++ .../DebugBar/Collector/ViewCollectorTest.php | 37 ++++++++++++++ 8 files changed, 239 insertions(+), 43 deletions(-) diff --git a/src/DebugBar/Collector/CacheCollector.php b/src/DebugBar/Collector/CacheCollector.php index 8896773..88cb237 100644 --- a/src/DebugBar/Collector/CacheCollector.php +++ b/src/DebugBar/Collector/CacheCollector.php @@ -24,7 +24,7 @@ /** * Records cache operations by subscribing to the `cache:after*` events. The key * is the event data, so it is read straight off the event - the `cache` service - * is never resolved. + * is never resolved. `addOperation()` records an operation with no events manager. * * @phpstan-import-type list_envelope from DebugBarTypes * @phpstan-import-type list_panel from DebugBarTypes @@ -44,6 +44,14 @@ final class CacheCollector extends AbstractCollector implements Subscriber */ private array $operations = []; + public function addOperation(string $operation, string $key): void + { + $this->operations[] = [ + 'label' => $operation, + 'message' => $key, + ]; + } + /** * @return list_envelope */ @@ -68,10 +76,7 @@ public function subscribe(ManagerInterface $eventsManager): void $eventsManager->attach( $eventName, function (EventInterface $event, mixed $cache, mixed $key) use ($operation): void { - $this->operations[] = [ - 'label' => $operation, - 'message' => is_string($key) ? $key : '', - ]; + $this->addOperation($operation, is_string($key) ? $key : ''); } ); } diff --git a/src/DebugBar/Collector/DatabaseCollector.php b/src/DebugBar/Collector/DatabaseCollector.php index b4042e4..9027cb9 100644 --- a/src/DebugBar/Collector/DatabaseCollector.php +++ b/src/DebugBar/Collector/DatabaseCollector.php @@ -28,7 +28,8 @@ /** * Records SQL queries by subscribing to `db:beforeQuery`/`db:afterQuery`. The * connection is the event's source, so the statement, bindings, and timing are - * read straight off it - no `db` service is resolved. + * read straight off it - no `db` service is resolved. `startQuery()` and + * `finishQuery()` record a query with no events manager. * * @phpstan-import-type db_query from DebugBarTypes * @phpstan-import-type database_envelope from DebugBarTypes @@ -93,12 +94,31 @@ public function collect(): array ]; } + /** + * Records a query. Call `startQuery()` first: the time starts there. + * + * @param array $bindings + */ + public function finishQuery(string $sql, array $bindings = []): void + { + $this->queries[] = [ + 'sql' => $sql, + 'bindings' => $bindings, + 'time' => hrtime(true) - $this->started, + ]; + } + + public function startQuery(): void + { + $this->started = hrtime(true); + } + public function subscribe(ManagerInterface $eventsManager): void { $eventsManager->attach( 'db:beforeQuery', function (): void { - $this->started = hrtime(true); + $this->startQuery(); } ); @@ -106,11 +126,10 @@ function (): void { 'db:afterQuery', function (EventInterface $event, mixed $connection): void { if ($connection instanceof AbstractAdapter) { - $this->queries[] = [ - 'sql' => $connection->getSQLStatement(), - 'bindings' => $connection->getSQLVariables(), - 'time' => hrtime(true) - $this->started, - ]; + $this->finishQuery( + $connection->getSQLStatement(), + $connection->getSQLVariables() + ); } } ); diff --git a/src/DebugBar/Collector/RouteCollector.php b/src/DebugBar/Collector/RouteCollector.php index b84ceba..3902955 100644 --- a/src/DebugBar/Collector/RouteCollector.php +++ b/src/DebugBar/Collector/RouteCollector.php @@ -13,6 +13,7 @@ namespace Phalcon\DebugBar\Collector; +use Closure; use Phalcon\DebugBar\Contracts\Subscriber; use Phalcon\DebugBar\DebugBarTypes; use Phalcon\Events\EventInterface; @@ -23,9 +24,11 @@ * Reports the matched route. The router is the source of `router:matchedRoute`, * so it is captured there and read at `collect()` - by response time it has been * fully resolved (module/controller/action/params), which is not yet true when - * the event fires. Nothing is resolved from the container. + * the event fires. Nothing is resolved from the container. `setRoute()` gives the + * panel values with no events manager. * * @phpstan-import-type grid_envelope from DebugBarTypes + * @phpstan-import-type grid_panel from DebugBarTypes */ final class RouteCollector extends AbstractCollector implements Subscriber { @@ -39,39 +42,48 @@ final class RouteCollector extends AbstractCollector implements Subscriber protected string $panel = 'grid'; - private ?RouterInterface $router = null; + /** + * @var (Closure(): grid_panel)|grid_panel + */ + private array | Closure $route = []; /** * @return grid_envelope */ public function collect(): array { - if (null === $this->router) { - return [ - 'panel' => [], - 'badge' => null, - ]; - } - return [ - 'panel' => [ - 'Module' => $this->router->getModuleName(), - 'Namespace' => $this->router->getNamespaceName(), - 'Controller' => $this->router->getControllerName(), - 'Action' => $this->router->getActionName(), - 'Params' => $this->jsonOrEmpty($this->router->getParams()), - ], + 'panel' => $this->route instanceof Closure ? ($this->route)() : $this->route, 'badge' => null, ]; } + /** + * Takes the panel values, or a closure that returns them. The collector calls + * the closure at `collect()`. + * + * @param (Closure(): grid_panel)|grid_panel $route + */ + public function setRoute(array | Closure $route): void + { + $this->route = $route; + } + public function subscribe(ManagerInterface $eventsManager): void { $eventsManager->attach( 'router:matchedRoute', function (EventInterface $event, mixed $router): void { if ($router instanceof RouterInterface) { - $this->router = $router; + $this->setRoute( + fn (): array => [ + 'Module' => $router->getModuleName(), + 'Namespace' => $router->getNamespaceName(), + 'Controller' => $router->getControllerName(), + 'Action' => $router->getActionName(), + 'Params' => $this->jsonOrEmpty($router->getParams()), + ] + ); } } ); diff --git a/src/DebugBar/Collector/ViewCollector.php b/src/DebugBar/Collector/ViewCollector.php index 00e334e..16e044d 100644 --- a/src/DebugBar/Collector/ViewCollector.php +++ b/src/DebugBar/Collector/ViewCollector.php @@ -27,7 +27,8 @@ * Records rendered views by subscribing to `view:beforeRenderView`/ * `view:afterRenderView`. The path is the event data on the "before" event, so * nothing is resolved from the container. A stack pairs each "before" with its - * matching "after", so nested partials are timed correctly. + * matching "after", so nested partials are timed correctly. `startRender()` and + * `finishRender()` record a render with no events manager. * * @phpstan-import-type list_envelope from DebugBarTypes * @phpstan-import-type view_pending from DebugBarTypes @@ -74,30 +75,44 @@ public function collect(): array ]; } + /** + * Records the last render that `startRender()` started. It does nothing when + * no render is pending. + */ + public function finishRender(): void + { + $entry = array_pop($this->pending); + if (null === $entry) { + return; + } + + $this->rendered[] = [ + 'path' => $entry['path'], + 'time' => hrtime(true) - $entry['start'], + ]; + } + + public function startRender(string $path): void + { + $this->pending[] = [ + 'path' => $path, + 'start' => hrtime(true), + ]; + } + public function subscribe(ManagerInterface $eventsManager): void { $eventsManager->attach( 'view:beforeRenderView', function (EventInterface $event, mixed $view, mixed $path): void { - $this->pending[] = [ - 'path' => is_string($path) ? $path : '', - 'start' => hrtime(true), - ]; + $this->startRender(is_string($path) ? $path : ''); } ); $eventsManager->attach( 'view:afterRenderView', function (): void { - $entry = array_pop($this->pending); - if (null === $entry) { - return; - } - - $this->rendered[] = [ - 'path' => $entry['path'], - 'time' => hrtime(true) - $entry['start'], - ]; + $this->finishRender(); } ); } diff --git a/tests/Unit/DebugBar/Collector/CacheCollectorTest.php b/tests/Unit/DebugBar/Collector/CacheCollectorTest.php index cd85be7..5f46c96 100644 --- a/tests/Unit/DebugBar/Collector/CacheCollectorTest.php +++ b/tests/Unit/DebugBar/Collector/CacheCollectorTest.php @@ -22,6 +22,18 @@ final class CacheCollectorTest extends AbstractUnitTestCase { use PanelContractTrait; + public function testAddOperationRecordsWithNoEventsManager(): void + { + $collector = new CacheCollector(); + + $collector->addOperation('Get', 'user_42'); + + $envelope = $collector->collect(); + + $this->assertSame(1, $envelope['badge']); + $this->assertSame([['label' => 'Get', 'message' => 'user_42']], $envelope['panel']); + } + public function testCacheOperationsAreRecordedFromEvents(): void { $collector = new CacheCollector(); diff --git a/tests/Unit/DebugBar/Collector/DatabaseCollectorTest.php b/tests/Unit/DebugBar/Collector/DatabaseCollectorTest.php index e54acd3..4487d91 100644 --- a/tests/Unit/DebugBar/Collector/DatabaseCollectorTest.php +++ b/tests/Unit/DebugBar/Collector/DatabaseCollectorTest.php @@ -122,6 +122,38 @@ public function testEmptyCollectorHasZeroSummaryValues(): void ); } + public function testFinishQueryMeasuresTheTimeFromStartQuery(): void + { + $collector = new DatabaseCollector(); + + $collector->startQuery(); + $collector->finishQuery('SELECT 1'); + + $property = new ReflectionProperty(DatabaseCollector::class, 'queries'); + $property->setAccessible(true); + + /** @var array $queries */ + $queries = $property->getValue($collector); + $time = $queries[0]['time']; + + /** + * With no start time, `time` has the full monotonic clock value + * (billions of nanoseconds). + */ + $this->assertGreaterThanOrEqual(0, $time); + $this->assertLessThan(1000000000, $time); + } + + public function testFinishQueryWithNoBindingsShowsOnlyTheStatement(): void + { + $collector = new DatabaseCollector(); + + $collector->startQuery(); + $collector->finishQuery('SELECT 1'); + + $this->assertSame('SELECT 1', $collector->collect()['panel'][0]['message']); + } + public function testLabelReflectsQueryDurationInMilliseconds(): void { $collector = new DatabaseCollector(); @@ -183,6 +215,22 @@ public function testQueriesAreRecordedFromEvents(): void $this->assertSame('SELECT 1', $envelope['panel'][0]['message']); } + public function testStartAndFinishRecordAQueryWithNoEventsManager(): void + { + $collector = new DatabaseCollector(); + + $collector->startQuery(); + $collector->finishQuery('SELECT * FROM users WHERE id = :id', ['id' => 5]); + + $envelope = $collector->collect(); + + $this->assertSame(1, $envelope['badge']); + $this->assertSame( + 'SELECT * FROM users WHERE id = :id {"id":5}', + $envelope['panel'][0]['message'] + ); + } + public function testSummaryReportsQueryCountDuplicatesAndTotalDuration(): void { $collector = new DatabaseCollector(); diff --git a/tests/Unit/DebugBar/Collector/RouteCollectorTest.php b/tests/Unit/DebugBar/Collector/RouteCollectorTest.php index 67cdade..6b6b3b0 100644 --- a/tests/Unit/DebugBar/Collector/RouteCollectorTest.php +++ b/tests/Unit/DebugBar/Collector/RouteCollectorTest.php @@ -43,6 +43,21 @@ public function testNonRouterSourceIsIgnored(): void $this->assertSame([], $collector->collect()['panel']); } + public function testRouteClosureIsCalledAtCollectTime(): void + { + $collector = new RouteCollector(); + $action = 'before'; + + $collector->setRoute( + function () use (&$action): array { + return ['Action' => $action]; + } + ); + $action = 'after'; + + $this->assertSame(['Action' => 'after'], $collector->collect()['panel']); + } + public function testRouteIsSnapshotFromTheRouter(): void { $collector = new RouteCollector(); @@ -67,4 +82,37 @@ public function testRouteIsSnapshotFromTheRouter(): void $this->assertSame('login', $panel['Action']); $this->assertSame('{"id":5}', $panel['Params']); } + + public function testRouterIsReadAtCollectTime(): void + { + $collector = new RouteCollector(); + $eventsManager = new Manager(); + $collector->subscribe($eventsManager); + + $action = 'before'; + $router = $this->createMock(RouterInterface::class); + $router->method('getActionName')->willReturnCallback( + function () use (&$action): string { + return $action; + } + ); + + $eventsManager->fire('router:matchedRoute', $router, null); + $action = 'after'; + + $this->assertSame('after', $collector->collect()['panel']['Action']); + } + + public function testRouteValuesAreShownInThePanel(): void + { + $collector = new RouteCollector(); + $values = ['Action' => 'App\Action\GetPage', 'Attributes' => '{"id":5}']; + + $collector->setRoute($values); + + $envelope = $collector->collect(); + + $this->assertSame($values, $envelope['panel']); + $this->assertNull($envelope['badge']); + } } diff --git a/tests/Unit/DebugBar/Collector/ViewCollectorTest.php b/tests/Unit/DebugBar/Collector/ViewCollectorTest.php index 38fb226..94bddf3 100644 --- a/tests/Unit/DebugBar/Collector/ViewCollectorTest.php +++ b/tests/Unit/DebugBar/Collector/ViewCollectorTest.php @@ -45,6 +45,15 @@ public function testCollectLabelFormatsMilliseconds(): void $this->assertSame('/app/views/index.phtml', $row['message']); } + public function testFinishRenderWithNoStartIsIgnored(): void + { + $collector = new ViewCollector(); + + $collector->finishRender(); + + $this->assertSame(0, $collector->collect()['badge']); + } + public function testNameAndPanelContract(): void { $collector = new ViewCollector(); @@ -53,6 +62,21 @@ public function testNameAndPanelContract(): void $this->assertPanelContract($collector); } + public function testNestedRendersPairWithTheirOwnStart(): void + { + $collector = new ViewCollector(); + + $collector->startRender('/app/views/layout.phtml'); + $collector->startRender('/app/views/partial.phtml'); + $collector->finishRender(); + $collector->finishRender(); + + $this->assertSame( + ['/app/views/partial.phtml', '/app/views/layout.phtml'], + array_column($collector->collect()['panel'], 'message') + ); + } + public function testRenderedViewsAreRecordedFromEvents(): void { $collector = new ViewCollector(); @@ -68,6 +92,19 @@ public function testRenderedViewsAreRecordedFromEvents(): void $this->assertSame('/app/views/index.phtml', $envelope['panel'][0]['message']); } + public function testStartAndFinishRecordARenderWithNoEventsManager(): void + { + $collector = new ViewCollector(); + + $collector->startRender('/app/views/index.phtml'); + $collector->finishRender(); + + $envelope = $collector->collect(); + + $this->assertSame(1, $envelope['badge']); + $this->assertSame('/app/views/index.phtml', $envelope['panel'][0]['message']); + } + public function testTimeIsMeasuredBetweenBeforeAndAfterRender(): void { $collector = new ViewCollector(); From 95a7e9149e6fbe40ecdff089329a71c4a5830001 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Tue, 3 Nov 2026 09:55:38 -0600 Subject: [PATCH 2/2] updating changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4c104c..7456532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas - `ResponseListener::handle()` injects the bar with no event. [#39](https://github.com/phalcon/debugbar/issues/39) - `Core` boots the bar with no MVC application and no DI container. [#40](https://github.com/phalcon/debugbar/issues/40) - `ADR\Provider` boots the bar in `Phalcon\ADR` applications (Phalcon `v5.18.0` or later, or Phalcon 6). [#14](https://github.com/phalcon/debugbar/issues/14) +- The database, view, cache and route collectors take data through public methods, with no events manager. [#44](https://github.com/phalcon/debugbar/issues/44) ### Fixed