diff --git a/CHANGELOG.md b/CHANGELOG.md index 41c74b8..a4c104c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,32 +6,17 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas ### Added -- A memory collector reports current and peak PHP request usage. Time and Memory - are enabled by default and open from compact indicators on the right instead of - duplicate tabs; the indicators remain available when History is disabled. -- Optional, extensible collector summaries rendered as headline metrics above a panel. The database collector uses them to report total queries, duplicate runs (executions after the first), and accumulated SQL time, and marks repeated statements with their execution count. -- Optional browser-isolated request history with filesystem retention and an - internal `GET/DELETE /_debugbar/open` endpoint. A dedicated HttpOnly, SameSite=Lax - browser-session cookie keeps History independent from the application's PHP - session, is marked Secure on HTTPS, and is appended without replacing application - cookies; clients that do not return it create no storage. The endpoint resolves - the final base URI at request time, leaves application routes and shared responses - untouched, and is excluded from debug-bar collection and injection. Stored - payloads are versioned, bounded, expired automatically, and listed through - metadata sidecars. History requires an explicit absolute writable path and is - disabled by default. Storage creation is serialized and capped across browser - identities, with least-recently-updated eviction. -- `ResponseListener::handle()` runs the injection step with a `ResponseInterface` and no event. The MVC listener calls it, so MVC behavior does not change. [#39](https://github.com/phalcon/debugbar/issues/39) -- `Core` boots the bar with no `Phalcon\Mvc\Application` and no `Phalcon\Di\DiInterface`. It takes an optional events manager, request, config source and history parts. `Provider` is now a wrapper over `Core`, so MVC behavior does not change. [#40](https://github.com/phalcon/debugbar/issues/40) +- `memory` collector for current and peak PHP memory use. Time and Memory now open from indicators on the right, not from tabs. +- Optional collector summaries show headline metrics above a panel. The database panel shows total queries, duplicate runs and SQL time. +- Request history for each browser, at `GET/DELETE /_debugbar/open`. It is disabled by default and needs an absolute, writable `history.path`. +- `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) ### Fixed -- History now validates storage writability during boot, removes browser - directories emptied by reads, uses portable glob paths on Windows, and adds - `X-Content-Type-Options: nosniff` to its JSON responses. -- The browser asset exports its test helpers only outside a DOM environment and - discovers request method and URI through collector widget metadata instead of a - hard-coded collector name. +- History: check storage at boot, remove empty browser directories, fix glob paths on Windows, add `nosniff` to JSON responses. +- The browser asset exports test helpers only outside a DOM, and no longer hard-codes the request collector name. ## [0.4.0](https://github.com/phalcon/debugbar/releases/tag/v0.4.0) (2026-07-14) diff --git a/composer.json b/composer.json index 79b70b8..4b1f109 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "squizlabs/php_codesniffer": "^4.0" }, "suggest": { - "ext-phalcon": "Phalcon C extension (^5) - run the debug bar on Phalcon v5", + "ext-phalcon": "Phalcon C extension (^5) - run the debug bar on Phalcon v5. ADR support needs v5.18.0 or later", "phalcon/phalcon": "Phalcon PHP implementation (^6) - run the debug bar on Phalcon v6" }, "autoload": { diff --git a/docs/index.md b/docs/index.md index fccb968..d3efc45 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,7 @@ The bar runs only in development. It refuses to boot when the environment is und ## Requirements - PHP 8.1 or later. -- A Phalcon runtime: the Phalcon 5 C extension, or the Phalcon 6 PHP implementation (`phalcon/phalcon`). +- A Phalcon runtime: the Phalcon 5 C extension, or the Phalcon 6 PHP implementation (`phalcon/phalcon`). ADR support needs Phalcon `v5.18.0` or later, or Phalcon 6. - `matthiasmullie/minify`, pulled in automatically by Composer. It minifies the bar's CSS and JavaScript before they are inlined. ## Installation @@ -35,7 +35,7 @@ The remainder of this document covers the debug bar. ## Registering the Debug Bar -The bar is booted by `Phalcon\DebugBar\Provider`. It takes the MVC application and an optional configuration array. The application must have an events manager set before the bar boots. The provider does not add or modify application routes. +In an MVC application, the bar is booted by `Phalcon\DebugBar\Provider`. It takes the MVC application and an optional configuration array. The application must have an events manager set before the bar boots. The provider does not add or modify application routes. For other applications, see [ADR and Non-MVC Applications](#adr-and-non-mvc-applications). ```php handle($_SERVER['REQUEST_URI'])->getContent(); If the application has no events manager, `boot()` still registers the `Debug` facade but attaches no response listener, so nothing is injected. Set an events manager to enable the bar. +## ADR and Non-MVC Applications + +### ADR + +`Phalcon\DebugBar\ADR\Provider` boots the bar in a `Phalcon\ADR` application. It is a container service provider, and it takes an optional configuration array. It needs Phalcon `v5.18.0` or later, or Phalcon 6. + +```php +provide($container); + } +} +``` + +The provider reads the events manager (`Phalcon\Contracts\Events\Manager`) and the request (`Phalcon\Contracts\Http\AttributeRequest`) from the container. It attaches the response listener to `application:afterHandle`. The application fires this event for a successful response and for an error response, and it emits the response after this event. + +!!! warning "NOTE" + + Register the provider after all other providers. It resolves the events manager and the request during registration. After that, `extend()` on these services throws `CannotExtendResolved`, and `set()` has no effect. In a blocked environment, the provider resolves no service. + +These limits apply in an ADR application: + +- **One request for each process.** `Phalcon\ADR\Application::handle()` supports a reused request in a long-lived worker, but collectors have no reset method. In a worker, their data grows from request to request. `HistoryCookie::fromGlobals()` also reads only at boot. +- **Events manager.** The events manager must implement `Phalcon\Events\ManagerInterface`. The default manager does. A manager that implements only `Phalcon\Contracts\Events\Manager` gets no listener, so the bar is not injected. +- **Empty panels.** The `database`, `route` and `exceptions` panels have no ADR source yet. The `view` and `cache` panels get data only when the application gives the events manager to these services. The provider reads no `config` service, so the `config` panel is empty. +- **History.** The provider does not read the `history` keys. Request history works only in MVC. + +### A Host with No Events Manager + +`Phalcon\DebugBar\Core` boots the bar with no framework application. It takes the configuration array. All the arguments of `boot()` are optional: the events manager, the request and the config source. Call `handle()` when you have the response. The response must implement `Phalcon\Http\ResponseInterface`. + +```php + ['allow_ips' => ['127.0.0.1']]]); +$core->boot(null, $request); + +// The application creates the response here. + +$core->handle($response); +``` + +Give a request when you set `access.allow_ips`. With no request the client address is unknown, so the access gate stops the bar. + +If the host has an events manager, attach the listener that `boot()` returns to the response event of the host. `boot()` returns `null` when the bar cannot boot. In this example, `host:response` is a placeholder and Phalcon does not fire it. Replace it with the event that your host fires with the response as the event data. + +```php +boot($eventsManager, $request); +if (null !== $listener) { + // "host:response" is a placeholder. Replace it with the response event of your host. + $eventsManager->attach('host:response', $listener); +} +``` + ## Configuration -The second argument to `Provider` is a nested array. Every key is optional. +The second argument to `Provider` is a nested array. Every key is optional. `Phalcon\DebugBar\ADR\Provider` and `Phalcon\DebugBar\Core` take the same array as their only argument. They do not read the `history` keys. | Key | Type | Default | Purpose | |--------------------|---------------------------|--------------------------|------------------------------------------------------------------| diff --git a/src/DebugBar/ADR/Provider.php b/src/DebugBar/ADR/Provider.php new file mode 100644 index 0000000..3c8a480 --- /dev/null +++ b/src/DebugBar/ADR/Provider.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\DebugBar\ADR; + +use Phalcon\Contracts\Container\Service\Collection; +use Phalcon\Contracts\Container\Service\Provider as ProviderContract; +use Phalcon\Contracts\Events\Manager as ManagerContract; +use Phalcon\Contracts\Http\AttributeRequest; +use Phalcon\DebugBar\Core; +use Phalcon\DebugBar\DebugBarTypes; +use Phalcon\DebugBar\Exceptions\CannotUseInProduction; +use Phalcon\Events\ManagerInterface; +use Phalcon\Http\RequestInterface; + +/** + * Boots the debug bar in an ADR application. Register it after all other + * providers: it resolves the events manager and the request, and the container + * cannot change a service after that. The ADR application emits the response + * after `application:afterHandle`, so the bar is injected on this event. + * + * @phpstan-import-type provider_config from DebugBarTypes + */ +final class Provider implements ProviderContract +{ + private Core $core; + + /** + * @param provider_config $config + */ + public function __construct(array $config = []) + { + $this->core = new Core($config); + } + + /** + * @throws CannotUseInProduction When `env.strict` is set and the environment is blocked or undefined. + */ + public function provide(Collection $services): void + { + if (true !== $this->core->canBoot()) { + return; + } + + $eventsManager = $this->resolve($services, ManagerContract::class); + $eventsManager = $eventsManager instanceof ManagerInterface ? $eventsManager : null; + $request = $this->resolve($services, AttributeRequest::class); + $listener = $this->core->boot( + $eventsManager, + $request instanceof RequestInterface ? $request : null + ); + + if (null === $eventsManager || null === $listener) { + return; + } + + $eventsManager->attach('application:afterHandle', $listener); + } + + private function resolve(Collection $services, string $name): mixed + { + return $services->has($name) ? $services->get($name) : null; + } +} diff --git a/src/DebugBar/ResponseListener.php b/src/DebugBar/ResponseListener.php index 37de5f7..23f2d34 100644 --- a/src/DebugBar/ResponseListener.php +++ b/src/DebugBar/ResponseListener.php @@ -30,10 +30,11 @@ use function sprintf; /** - * The `application:beforeSendResponse` listener. On the event it runs the access - * gate, aggregates the bar, sets the diagnostic headers, and - for an injectable - * HTML response - renders and splices the bar in. `handle()` does the same work - * with no event. + * The response listener: MVC attaches it to `application:beforeSendResponse` and + * ADR to `application:afterHandle`. On the event it runs the access gate, + * aggregates the bar, sets the diagnostic headers, and - for an injectable HTML + * response - renders and splices the bar in. `handle()` does the same work with + * no event. * * @phpstan-import-type request_context from DebugBarTypes * @phpstan-import-type payload from DebugBarTypes diff --git a/tests/Unit/DebugBar/ADR/ProviderTest.php b/tests/Unit/DebugBar/ADR/ProviderTest.php new file mode 100644 index 0000000..a60ce47 --- /dev/null +++ b/tests/Unit/DebugBar/ADR/ProviderTest.php @@ -0,0 +1,226 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Unit\DebugBar\ADR; + +use Phalcon\ADR\Container\AdrProvider; +use Phalcon\Container\Container; +use Phalcon\Contracts\Events\Manager as ManagerContract; +use Phalcon\Contracts\Http\AttributeRequest; +use Phalcon\DebugBar\ADR\Provider; +use Phalcon\DebugBar\Debug; +use Phalcon\DebugBar\DebugBar; +use Phalcon\DebugBar\Exceptions\CannotUseInProduction; +use Phalcon\Events\ManagerInterface; +use Phalcon\Http\ResponseInterface; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use Phalcon\Tests\Support\DebugBar\ADR\Action\Data\GetData; +use Phalcon\Tests\Support\DebugBar\ADR\Action\Page\GetPage; +use Phalcon\Tests\Support\DebugBar\ADR\Front; +use Phalcon\Tests\Support\DebugBar\ADR\RecordingEmitter; +use PHPUnit\Framework\Attributes\BackupGlobals; + +use function is_array; + +#[BackupGlobals(true)] +final class ProviderTest extends AbstractUnitTestCase +{ + private const ENV_VAR = 'DEBUGBAR_TEST_ENV'; + + protected function setUp(): void + { + parent::setUp(); + + Debug::setBar(null); + } + + protected function tearDown(): void + { + Debug::setBar(null); + + parent::tearDown(); + } + + public function testBlockedEnvironmentBootsNothing(): void + { + $_ENV[self::ENV_VAR] = 'production'; + + $response = $this->dispatch('/page'); + + $this->assertSame(GetPage::CONTENT, $response->getContent()); + $this->assertFalse($response->getHeaders()->has('X-Debug-Bar')); + $this->assertNull(Debug::getBar()); + } + + public function testBlockedEnvironmentResolvesNoService(): void + { + $_ENV[self::ENV_VAR] = 'production'; + + $blocked = $this->container(); + + $this->assertFalse($blocked->hasInstance(ManagerContract::class)); + $this->assertFalse($blocked->hasInstance(AttributeRequest::class)); + + $_ENV[self::ENV_VAR] = 'dev'; + + $allowed = $this->container(); + + $this->assertTrue($allowed->hasInstance(ManagerContract::class)); + $this->assertTrue($allowed->hasInstance(AttributeRequest::class)); + } + + public function testBootsAndInjectsIntoAnHtmlResponse(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $response = $this->dispatch('/page'); + + $this->assertStringContainsString('phalcon-debugbar-data', $response->getContent()); + $this->assertStringContainsString('page', $response->getContent()); + $this->assertTrue($response->getHeaders()->has('X-Debug-Bar')); + $this->assertInstanceOf(DebugBar::class, Debug::getBar()); + } + + public function testCollectorsSubscribeToTheSharedEventsManager(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $manager = $this->container()->get(ManagerContract::class); + if (!$manager instanceof ManagerInterface) { + $this->fail('Expected the default events manager.'); + } + + $manager->fire('cache:afterGet', $this, 'cache-key-42'); + + $this->assertSame(1, $this->bootedBar()->collect()['data']['cache']['badge']); + } + + public function testContainerWithNoServicesAttachesNothing(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $this->provider()->provide(new Container()); + + $this->assertSame([], $this->bootedBar()->collect()['data']['request']['panel']); + } + + public function testDoesNotInjectIntoAJsonResponse(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $response = $this->dispatch('/data'); + + $this->assertSame(GetData::CONTENT, $response->getContent()); + $this->assertInstanceOf(DebugBar::class, Debug::getBar()); + } + + public function testErrorResponseReachesTheListener(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $response = $this->dispatch('/missing'); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertTrue($response->getHeaders()->has('X-Debug-Bar')); + $this->assertStringNotContainsString('phalcon-debugbar-data', $response->getContent()); + } + + public function testManagerOutsideTheEventsManagerInterfaceGetsNoListener(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $manager = $this->createMock(ManagerContract::class); + $manager->expects($this->never())->method('attach'); + + $container = new Container(); + $container->set( + ManagerContract::class, + function () use ($manager): ManagerContract { + return $manager; + } + ); + + $this->provider()->provide($container); + + $this->assertInstanceOf(DebugBar::class, Debug::getBar()); + } + + public function testRequestReachesTheCollector(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $this->dispatch('/page'); + + $panel = $this->bootedBar()->collect()['data']['request']['panel']; + if (!is_array($panel)) { + $this->fail('The request panel should be an array.'); + } + + $this->assertSame('GET', $panel['Method']); + $this->assertSame('/page', $panel['URI']); + } + + public function testStrictBlockedEnvironmentThrows(): void + { + $_ENV[self::ENV_VAR] = 'production'; + + $provider = new Provider(['env' => ['var' => self::ENV_VAR, 'strict' => true]]); + + $this->expectException(CannotUseInProduction::class); + + $provider->provide(new Container()); + } + + private function bootedBar(): DebugBar + { + $bar = Debug::getBar(); + if (!$bar instanceof DebugBar) { + $this->fail('Expected the debug bar to be booted.'); + } + + return $bar; + } + + private function container(): Container + { + $container = new Container(); + (new AdrProvider())->provide($container); + $this->provider()->provide($container); + + return $container; + } + + /** + * Runs a full `GET` request through a real ADR front controller. + */ + private function dispatch(string $uri): ResponseInterface + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = $uri; + + $emitter = new RecordingEmitter(); + $code = (new Front($emitter, ['env' => ['var' => self::ENV_VAR]]))->run(); + + $this->assertSame(0, $code); + if (null === $emitter->response) { + $this->fail('Expected the front controller to emit a response.'); + } + + return $emitter->response; + } + + private function provider(): Provider + { + return new Provider(['env' => ['var' => self::ENV_VAR]]); + } +} diff --git a/tests/support/DebugBar/ADR/Action/Data/GetData.php b/tests/support/DebugBar/ADR/Action/Data/GetData.php new file mode 100644 index 0000000..e074c1d --- /dev/null +++ b/tests/support/DebugBar/ADR/Action/Data/GetData.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\DebugBar\ADR\Action\Data; + +use Phalcon\Contracts\ADR\Action; +use Phalcon\Contracts\Http\AttributeRequest; +use Phalcon\Http\Response; +use Phalcon\Http\ResponseInterface; + +/** + * Returns a JSON response for `GET /data`. + */ +final class GetData implements Action +{ + public const CONTENT = '{"status":"ok"}'; + + public function __invoke(AttributeRequest $request): ResponseInterface + { + $response = new Response(); + $response->setContentType('application/json'); + $response->setContent(self::CONTENT); + + return $response; + } +} diff --git a/tests/support/DebugBar/ADR/Action/Page/GetPage.php b/tests/support/DebugBar/ADR/Action/Page/GetPage.php new file mode 100644 index 0000000..b78323e --- /dev/null +++ b/tests/support/DebugBar/ADR/Action/Page/GetPage.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\DebugBar\ADR\Action\Page; + +use Phalcon\Contracts\ADR\Action; +use Phalcon\Contracts\Http\AttributeRequest; +use Phalcon\Http\Response; +use Phalcon\Http\ResponseInterface; + +/** + * Returns an HTML response for `GET /page`. + */ +final class GetPage implements Action +{ + public const CONTENT = 'page'; + + public function __invoke(AttributeRequest $request): ResponseInterface + { + $response = new Response(); + $response->setContentType('text/html'); + $response->setContent(self::CONTENT); + + return $response; + } +} diff --git a/tests/support/DebugBar/ADR/Front.php b/tests/support/DebugBar/ADR/Front.php new file mode 100644 index 0000000..996d80e --- /dev/null +++ b/tests/support/DebugBar/ADR/Front.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\DebugBar\ADR; + +use Phalcon\ADR\Application; +use Phalcon\ADR\Front\AbstractHttpFront; +use Phalcon\Container\Container; +use Phalcon\Contracts\ADR\Application as ApplicationInterface; +use Phalcon\Contracts\ADR\Emitter\Emitter; +use Phalcon\DebugBar\ADR\Provider; +use Phalcon\DebugBar\DebugBarTypes; + +/** + * An ADR front controller that registers the debug bar provider last, as an + * application does. It routes to the Actions in this folder. + * + * @phpstan-import-type provider_config from DebugBarTypes + */ +final class Front extends AbstractHttpFront +{ + /** + * @param provider_config $config + */ + public function __construct( + private readonly Emitter $emitter, + private readonly array $config = [] + ) { + parent::__construct(__DIR__); + } + + protected function getApplication(Container $container): ApplicationInterface + { + return (new Application($container)) + ->setBaseNamespace(__NAMESPACE__ . '\\Action') + ->setActionDirectory($this->projectRoot . '/Action'); + } + + protected function registerProviders(Container $container): void + { + parent::registerProviders($container); + + $emitter = $this->emitter; + $container->set( + Emitter::class, + function () use ($emitter): Emitter { + return $emitter; + } + ); + + (new Provider($this->config))->provide($container); + } +} diff --git a/tests/support/DebugBar/ADR/RecordingEmitter.php b/tests/support/DebugBar/ADR/RecordingEmitter.php new file mode 100644 index 0000000..00441e1 --- /dev/null +++ b/tests/support/DebugBar/ADR/RecordingEmitter.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\DebugBar\ADR; + +use Phalcon\Contracts\ADR\Emitter\Emitter; +use Phalcon\Http\ResponseInterface; + +/** + * Keeps the response and sends no output. + */ +final class RecordingEmitter implements Emitter +{ + public ?ResponseInterface $response = null; + + public function emit(ResponseInterface $response): void + { + $this->response = $response; + } +}